release: v0.20 Codex-ready skill-driven core

This commit is contained in:
kai
2026-05-07 08:21:28 +08:00
parent 0644a68ecc
commit 68e45bcf41
45 changed files with 3005 additions and 157 deletions
+26 -1
View File
@@ -138,7 +138,32 @@ dr-reporter 从 sources.jsonl 生成参考文献列表时,按以下格式:
---
## 五、引用完整性检查(dr-chief-editor 用)
## 五、脚注使用边界
脚注不是行内引用的替代品,也不用于重复输入材料中已经被正文自然承载的事实。脚注只在以下场景使用:
- **法规原文或条款定位**:正文需要引用法规要求,但完整条款会打断叙事时,脚注写明法规名称、章节/条款和关键原文。
- **关键资料原文**:原文措辞本身会影响判断强度,且正文只保留管理结论时,脚注可放短摘录。
- **补充背景或术语解释**:正文读者可能需要额外背景,但展开会破坏行文节奏。
- **版权或使用限制说明**:图表、第三方材料、内部材料使用边界需要单独说明时。
禁止事项:
- 不要把“某份输入材料说过什么”机械搬到脚注;这类事实应通过正文和数字引用解决。
- 不要为每个本地材料引用都加脚注;脚注应少而精,优先服务关键判断。
- 不要用脚注堆砌证据,核心证据仍应进入正文或证据表。
推荐格式:
```markdown
正文关键判断<sup>[12]</sup>。[^1]
[^1]: ICH Q10《Pharmaceutical Quality System》第 4.1 节要求管理评审输入覆盖“results of regulatory inspections and findings, audits and commitments”,并纳入 CAPA、变更以及上次管理评审行动。
```
---
## 六、引用完整性检查(dr-chief-editor 用)
审校时检查:
1. 正文中所有 [src_xxx] 都在 sources.jsonl 里有对应记录
+62
View File
@@ -0,0 +1,62 @@
---
name: search-gateway
description: Use when Deep Research agents or subagents need web, scholar, patent, news, regulatory, or source-discovery search without using platform MCP tools or browser search directly.
---
# Search Gateway
## Rule
Use the project Python search gateway as the only default search interface. Do not call Tavily MCP, browser MCP, generic web tools, or platform-native search from a subagent unless the user explicitly asks for that escape hatch.
## Commands
Run searches from the repository root:
```bash
uv run python scripts/search.py "<query>" --route general --json --trace
uv run python scripts/search.py "<query>" --route evidence --json --trace
uv run python scripts/search.py "<query>" --route scholar --year-low 2020 --json --trace
uv run python scripts/search.py "<query>" --route news --time-range y --json --trace
uv run python scripts/search.py "<query>" --route patents --json --trace
uv run python scripts/search.py "<query>" --profile biomed_literature --json --trace
```
If `uv` cannot use the user cache in a sandbox, set a local cache:
```bash
UV_CACHE_DIR=/private/tmp/deep_research_uv_cache uv run python scripts/search.py "<query>" --route general --json --trace
```
## Routing
- `general`: Tavily first, Exa fallback, Brave fallback; use for broad discovery and gap filling.
- `evidence`: Exa highlights first, Tavily fallback, Brave fallback; use when a task card needs concise, source-level candidate evidence for an evidence packet.
- `scholar`: Serper Scholar first; use for papers, reviews, technical literature, and academic validation only.
- `news`: Serper News first; use for recent industry/current information.
- `patents`: Serper Google Patents first.
- `biomed_literature`: scholar plus general discovery.
Serper is not the default general web search source. Keep it mainly for Scholar, Google Patents, News, and targeted `site:` searches where Google coverage matters.
Tavily Research is a phase-level scan tool, not a packet-writing shortcut. Use it for Phase 1 initial landscape scans, Phase 2 gap-fill after a chapter is thin, or Phase 3回炉补证据;its output must be saved, source-scored, deduplicated, and converted into candidate evidence before citation.
Exa is the preferred controlled evidence discovery route for agents because it can return short highlights/text per URL. Treat Exa hits as candidate sources unless the URL itself is an original Tier 1-2 source.
API keys are loaded from `secrets.env` by `scripts/search.py`; do not ask the user to authorize MCP calls when the env keys are available.
## Subagent Protocol
For evidence packets:
1. Search through `scripts/search.py`, save or summarize the returned JSON in the packets `raw_quotes_or_notes`.
2. Use search hits only as candidate sources; whenever possible, cite the original regulator, guideline, paper, or official document.
3. Put every used source in `sources` with `id`, `title`, `url`, `tier`, and `score`.
4. Do not write a final chapter during search; produce structured evidence only.
5. For repeatedly used Tier 1-2 sources, run `uv run python scripts/dr.py sources cache <project>` so later phases can cite a local Markdown snapshot rather than only a URL.
For chapter assembly:
1. Do not search. Use only `phase2/chapter_briefs`, `phase2/packets`, `phase2/sources.jsonl`, `phase0/extracted`, and `phase1/framework.md`.
2. Do not create new `source_id`.
3. If evidence is thin, mark the chapter as needing Phase 2 enrichment instead of filling with generic prose.
+5 -2
View File
@@ -75,10 +75,12 @@ description: 生物医药深度研究的统一检索策略。规定信源优先
- 例:研究"GLP-1 成为减重首选"→ 反方要搜 "GLP-1 limitations" "semaglutide side effects" "discontinuation rate"
- 至少 3-5 条反方证据
### 第 4 轮:Tavily/Brave/Exa 补漏
### 第 4 轮:Exa/Tavily/Brave 补漏
- 仅用于发现前 3 轮遗漏的 URL
- 发现后**必须**回溯到原始 Tier 1-2 来源(论文 DOI、监管公告原文)
- 不得直接引用搜索返回的二次报道
- 章节级 evidence packet 优先用 `scripts/search.py --route evidence`,让 Exa highlights 进入 source-quality 和 evidence-table。
- Tavily Research 只用于 Phase 1 初扫、薄弱章节补证据和 Phase 3 回炉;输出必须存盘、评分、去重后再转成 candidate evidence。
---
@@ -88,6 +90,7 @@ description: 生物医药深度研究的统一检索策略。规定信源优先
```bash
uv run python scripts/search.py "<query>" --route scholar --num-results 10 --year-low 2023
uv run python scripts/search.py "<query>" --route evidence --num-results 10 --json --trace
uv run python scripts/search.py "<query>" --route patents --num-results 10
uv run python scripts/search.py "<query>" --route news --num-results 10 --time-range m
uv run python scripts/search.py "<query>" --route general --num-results 10
@@ -107,7 +110,7 @@ uv run python scripts/search.py "<query>" --profile patent_heavy --num-results 1
- `--route patents` 固定优先 Serper + Google Patents,避免专利检索被 Tavily 普通网页结果替代。
- `--route scholar` 固定优先 Serper Scholar,避免论文检索只停留在通用网页摘要。
- 专用 routescholar/patents/news)默认 `--strict-specialized`,Serper 异常时应显式失败,不允许静默降级。
- Tavily / Exa / Brave 只作为 gap-fill 或 MCP 兜底,不作为文献/专利主路径。
- Exa evidence route 是 packet 候选证据发现主路径;Tavily / Brave 只作为 gap-fill 或 MCP 兜底,不作为文献/专利主路径。
每个检索小结必须写明实际使用过的 route,例如:
+136 -14
View File
@@ -49,7 +49,7 @@ try:
Table,
TableStyle,
)
from reportlab.platypus.flowables import HRFlowable
from reportlab.platypus.flowables import Flowable, HRFlowable
except ImportError:
print("ERROR: missing reportlab. Run: uv sync", file=sys.stderr)
sys.exit(1)
@@ -279,6 +279,23 @@ def build_styles() -> StyleSheet1:
allowOrphans=0,
))
# Inline evidence footnotes placed by number_citations / finalization.
ss.add(ParagraphStyle(
name="evidence-footnote",
fontName="SrcSerif",
fontSize=7.4,
leading=10,
alignment=TA_JUSTIFY,
leftIndent=18,
firstLineIndent=-18,
spaceBefore=0,
spaceAfter=2,
textColor=colors.HexColor("#4b5563"),
wordWrap="CJK",
allowWidows=0,
allowOrphans=0,
))
# Table cell - no first-line indent, smaller font, CJK wrap for auto line break
ss.add(ParagraphStyle(
name="table-cell",
@@ -422,6 +439,23 @@ def build_styles() -> StyleSheet1:
bulletIndent=8,
))
# Ordered list. Keep references flush-left; do not prepend decorative bullets.
ss.add(ParagraphStyle(
name="ordered",
fontName="SrcSerif",
fontSize=9,
leading=13,
alignment=TA_JUSTIFY,
firstLineIndent=0,
leftIndent=0,
spaceBefore=0,
spaceAfter=4,
textColor=colors.HexColor("#374151"),
wordWrap="CJK",
allowWidows=0,
allowOrphans=0,
))
return ss
@@ -436,6 +470,28 @@ class Block:
meta: Optional[dict] = None
class FootnoteFlowable(Flowable):
"""Zero-height anchor that registers a footnote for the current PDF page."""
def __init__(self, label: str, content: str):
super().__init__()
self.label = label
self.content = content
self.width = 0
self.height = 0
def wrap(self, availWidth, availHeight):
return 0, 0
def draw(self):
page = self.canv.getPageNumber()
notes = getattr(self.canv, "_dr_footnotes", None)
if notes is None:
notes = {}
setattr(self.canv, "_dr_footnotes", notes)
notes.setdefault(page, []).append((self.label, self.content))
def parse_markdown(md_text: str) -> List[Block]:
blocks: List[Block] = []
lines = md_text.split("\n")
@@ -483,6 +539,17 @@ def parse_markdown(md_text: str) -> List[Block]:
blocks.append(Block(kind="quote", content="\n".join(quote_lines)))
continue
# Markdown footnote definition: [^1]: 原文摘录...
m = re.match(r"^\[\^([A-Za-z0-9_-]+)\]:\s*(.+)$", stripped)
if m:
blocks.append(Block(
kind="footnote",
content=m.group(2).strip(),
meta={"label": m.group(1)},
))
i += 1
continue
# Unordered list
if re.match(r"^[-*+]\s+", stripped):
while i < len(lines) and re.match(r"^[-*+]\s+", lines[i].strip()):
@@ -493,12 +560,12 @@ def parse_markdown(md_text: str) -> List[Block]:
# Ordered list
if re.match(r"^\d+\.\s+", stripped):
idx = 1
while i < len(lines) and re.match(r"^\d+\.\s+", lines[i].strip()):
item = re.sub(r"^\d+\.\s+", "", lines[i].strip())
blocks.append(Block(kind="bullet", content=f"{idx}. {item}"))
m = re.match(r"^(\d+)\.\s+(.+)$", lines[i].strip())
if not m:
break
blocks.append(Block(kind="ordered", content=f"{m.group(1)}. {m.group(2)}"))
i += 1
idx += 1
continue
# Table
@@ -518,6 +585,7 @@ def parse_markdown(md_text: str) -> List[Block]:
while i < len(lines) and lines[i].strip() and not (
lines[i].strip().startswith(("#", ">", "-", "*", "+", "!"))
or re.match(r"^\d+\.\s+", lines[i].strip())
or re.match(r"^\[\^[A-Za-z0-9_-]+\]:", lines[i].strip())
or "|" in lines[i]
):
para_lines.append(lines[i])
@@ -638,6 +706,17 @@ def md_inline_to_rl(text: str, *, add_cjk_space: bool = True) -> str:
+ ']</font></super>',
text,
)
text = re.sub(
r"<sup>(.*?)</sup>",
r"<super><font size=7>\1</font></super>",
text,
flags=re.IGNORECASE,
)
text = re.sub(
r"\[\^([A-Za-z0-9_-]+)\]",
lambda m: f"<super><font size=7>注{m.group(1)}</font></super>",
text,
)
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1", text)
return text
@@ -746,9 +825,27 @@ def make_page_decorator(manifest: Manifest):
canvas.setLineWidth(0.5)
canvas.line(2 * cm, A4[1] - 1.4 * cm, A4[0] - 2 * cm, A4[1] - 1.4 * cm)
# Page-bottom evidence footnotes.
notes = getattr(canvas, "_dr_footnotes", {}).get(doc.page, [])
if notes:
width = A4[0] - 4.4 * cm
x = 2.2 * cm
y = 3.45 * cm
canvas.setStrokeColor(colors.HexColor("#cbd5e1"))
canvas.setLineWidth(0.45)
canvas.line(x, y + 0.16 * cm, x + 6.8 * cm, y + 0.16 * cm)
footnote_style = build_styles()["evidence-footnote"]
for label, content in notes:
text = f"{label}{content}"
para = Paragraph(md_inline_to_rl(text), footnote_style)
_, h = para.wrap(width, 1.3 * cm)
y -= h
para.drawOn(canvas, x, y)
y -= 0.04 * cm
# Footer: page number centered
canvas.setFont("SrcSans-Light", 8)
canvas.drawCentredString(A4[0] / 2, 1.2 * cm, f"{doc.page}")
canvas.drawCentredString(A4[0] / 2, 1.0 * cm, f"{doc.page}")
canvas.restoreState()
@@ -830,6 +927,8 @@ def collect_toc_entries(blocks: List[Block]) -> List[tuple[int, str]]:
title = b.content.strip()
if any(s in title.lower() for s in skip_titles_substr):
continue
if b.kind == "h1" and not re.match(r"^第\s*\d+\s*章\b", title):
continue
level = 1 if b.kind == "h1" else 2
entries.append((level, title))
return entries
@@ -1196,8 +1295,13 @@ def _render_generic_block(block: Block, story: list, base_dir: Path, styles: Sty
story.append(Paragraph(md_inline_to_rl(block.content), style))
elif block.kind == "quote":
story.append(Paragraph(md_inline_to_rl(block.content), styles["quote"]))
elif block.kind == "footnote":
label = block.meta.get("label") if block.meta else ""
story.append(FootnoteFlowable(str(label), block.content))
elif block.kind == "bullet":
story.append(Paragraph("" + md_inline_to_rl(block.content), styles["bullet"]))
elif block.kind == "ordered":
story.append(Paragraph(md_inline_to_rl(block.content), styles["ordered"]))
elif block.kind == "hr":
story.append(Spacer(1, 0.3 * cm))
elif block.kind == "image":
@@ -1349,6 +1453,11 @@ def build_body(
front_sections[kind] = sec
k = next_k
# If the markdown does not contain a TOC marker, still insert a generated TOC.
# This keeps PDF output stable when Phase 4 emits a clean markdown body.
if "toc" not in front_sections:
front_sections["toc"] = [Block(kind="h2", content="目录")]
# 前置件输出顺序(固定)
front_order = [
"disclaimer", # 免责声明
@@ -1408,13 +1517,26 @@ def build_body(
i = _skip_until_next_section(i + 1)
continue
# 参考文献:自动生成
# 参考文献:如果正文仍使用 [src_xxx],则从 sources.jsonl 自动生成
# 如果正文已被 number_citations.py 转为数字编号,则保留 Markdown 内的编号清单。
if kind == "references":
story.append(PageBreak())
story.extend(build_references(blocks, sources_path, styles))
j = i + 1
while j < n and blocks[j].kind == "p" and _REF_PLACEHOLDER_RE.search(blocks[j].content):
j += 1
has_src_citations = bool(collect_cited_src_ids(blocks))
has_ref_placeholder = (
j < n and blocks[j].kind == "p" and _REF_PLACEHOLDER_RE.search(blocks[j].content)
)
if has_src_citations or has_ref_placeholder:
story.extend(build_references(blocks, sources_path, styles))
while j < n and blocks[j].kind not in ("h1", "h2"):
j += 1
else:
story.append(Paragraph(md_inline_to_rl(block.content), styles["h1"]))
while j < n and blocks[j].kind not in ("h1", "h2"):
_render_generic_block(blocks[j], story, base_dir, styles, in_summary=False)
j += 1
i = j
continue
i = j
continue
@@ -1497,7 +1619,7 @@ def main():
leftMargin=2.2 * cm,
rightMargin=2.2 * cm,
topMargin=2 * cm,
bottomMargin=2 * cm,
bottomMargin=4.0 * cm,
title=manifest.report_title,
author=manifest.author,
subject=manifest.type,
@@ -1510,14 +1632,14 @@ def main():
id="cover",
)
normal_frame = Frame(
2.2 * cm, 2 * cm,
A4[0] - 4.4 * cm, A4[1] - 4 * cm,
2.2 * cm, 4.0 * cm,
A4[0] - 4.4 * cm, A4[1] - 6.0 * cm,
id="normal",
)
decorator = make_page_decorator(manifest)
doc.addPageTemplates([
PageTemplate(id="cover", frames=[cover_frame]),
PageTemplate(id="normal", frames=[normal_frame], onPage=decorator),
PageTemplate(id="normal", frames=[normal_frame], onPageEnd=decorator),
])
# Assemble story