Author SHA1 Message Date
Deep Research System d1169646b8 v0.13: add Quarto/xelatex PDF engine and fix ReportLab wide-table rendering
build_report.py:
- add --engine quarto option: Quarto 1.9 + xelatex pipeline with
  CJK font setup (Source Han Serif/Sans CN via fontspec),
  automatic {.landscape} wrapping for wide tables (>=8 cols),
  TOC/references placeholder replacement, sources.jsonl backfill
- prepare_qmd(): converts Markdown to .qmd with proper YAML front matter,
  writes _preamble.tex for longtable/pdflscape/lscape packages
- _detect_wide_tables(), _build_references_block(): helper functions
- ReportLab path unchanged (remains default)

report-template.py:
- render_table(): force equal-width column distribution for tables
  with >=4 cols or any cell >30 chars, preventing negative availWidth
  crash on mixed CJK/English content
- render_table_blocks(): split long tables (>25 rows) into chunks to
  avoid NoneType comparison crash in ReportLab splitByRow logic

.gitignore:
- add rules for LaTeX temp files (*.aux, xetest.*, *.qmd, _preamble.tex)
- add projects/ to gitignore (research data, not source code)

README.md:
- update status to v0.13
- rewrite PDF section as dual-engine guide with install steps,
  comparison table, and landscape table chunking guidance
- add Quarto troubleshooting (font italic mapping, tlmgr path, param_size)
- add v0.13 to changelog
2026-05-05 11:50:32 +08:00
kai ddaa6730bc v0.12.1: select model profile at init and carry via manifest 2026-04-29 16:20:24 +08:00
kai 5342a26018 v0.12: stabilize search routing and profile-driven phase4 pipeline 2026-04-29 15:53:20 +08:00
39 changed files with 1913 additions and 232 deletions
+16
View File
@@ -51,3 +51,19 @@ Thumbs.db
# ============ 归档(不纳入版本控制)============ # ============ 归档(不纳入版本控制)============
archive/* archive/*
!archive/.gitkeep !archive/.gitkeep
# ============ 临时 LaTeX / TeX 测试文件 ============
xetest.*
*.aux
*.fls
*.fdb_latexmk
*.synctex.gz
# ============ Quarto 生成的中间文件 ============
*_files/
*.qmd
_preamble.tex
# ============ 研究项目(实际数据,不纳入版本控制)============
# 如需备份,请用独立的私有仓库
projects/
+11
View File
@@ -17,6 +17,8 @@ permission:
"*": deny "*": deny
"wc *": allow "wc *": allow
"python3 *": allow "python3 *": allow
"uv run python scripts/search.py *": allow
"uv run python scripts/ground.py *": allow
"mkdir *": allow "mkdir *": allow
"grep *": allow "grep *": allow
"cat *": allow "cat *": allow
@@ -66,6 +68,15 @@ Read `projects/<slug>/phase1/framework.md` to understand the chapter's positioni
- Round 3: Counter-evidence (search for limitations, failures, controversies) - Round 3: Counter-evidence (search for limitations, failures, controversies)
- Round 4: Tavily/Exa/Brave for gap-filling, trace back to Tier 1-2 originals - Round 4: Tavily/Exa/Brave for gap-filling, trace back to Tier 1-2 originals
Mandatory project search gateway:
- Literature / reviews: `uv run python scripts/search.py "<query>" --route scholar --num-results 10 --year-low 2023`
- Patents / FTO: `uv run python scripts/search.py "<query>" --route patents --num-results 10`
- News / transactions: `uv run python scripts/search.py "<query>" --route news --num-results 10 --time-range m`
- Generic gap-fill: `uv run python scripts/search.py "<query>" --route general --num-results 10`
- Fast grounded fact-check (native model web search): `uv run python scripts/ground.py "<query>" --json`
Record the routes used in the evidence file. Do not use Tavily / Exa / Brave MCP as the primary path for literature or patent searches.
Search in **both English and Chinese** for each direction (Chinese sources critical for China market / NMPA / CSRC disclosures). Search in **both English and Chinese** for each direction (Chinese sources critical for China market / NMPA / CSRC disclosures).
### Step 3: Source Scoring ### Step 3: Source Scoring
+12 -4
View File
@@ -7,10 +7,14 @@ temperature: 0.1
tools: tools:
read: true read: true
webfetch: true webfetch: true
bash: true
skill: true skill: true
permission: permission:
bash: bash:
"*": deny "*": deny
"uv run python scripts/search.py *": allow
"uv run python scripts/ground.py *": allow
"python3 scripts/search.py *": allow
edit: deny edit: deny
webfetch: allow webfetch: allow
task: task:
@@ -25,10 +29,13 @@ permission:
1. 加载 `skill:search-strategy` 了解信源优先级与检索规则 1. 加载 `skill:search-strategy` 了解信源优先级与检索规则
2. 加载 `skill:source-quality` 了解评分标准与黑名单 2. 加载 `skill:source-quality` 了解评分标准与黑名单
3. 按调用方给定的关键词方向,执行 **3 轮检索** 3. 按调用方给定的关键词方向,执行 **3 轮检索**,必须优先使用项目搜索网关
- 第 1 轮:英文关键词,优先 Tavily advanced 模式,锁定 Tier 1 域名 - 文献:`uv run python scripts/search.py "<query>" --route scholar --num-results 10 --year-low 2023`
- 第 2 轮:中文关键词,查中文专业来源 - 专利:`uv run python scripts/search.py "<query>" --route patents --num-results 10`
- 第 3 轮:反方/限制性关键词(如 `limitations`, `adverse`, `failed` - 新闻/行业动态:`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`
- 快速 grounding`uv run python scripts/ground.py "<query>" --json`
- Tavily / Brave / Exa MCP 只能作为 gap-fill 或脚本不可用时的兜底
4. 对每条候选信源按 source-quality 评分,过滤掉评分 < 5 及黑名单 4. 对每条候选信源按 source-quality 评分,过滤掉评分 < 5 及黑名单
5. 整理输出,直接返回给调用方(不写文件) 5. 整理输出,直接返回给调用方(不写文件)
@@ -43,6 +50,7 @@ permission:
- 英文:... - 英文:...
- 中文:... - 中文:...
- 反方:... - 反方:...
- Routes used: scholar / patents / news / general
### 信源列表(共 N 条,Tier 1-2) ### 信源列表(共 N 条,Tier 1-2)
+10
View File
@@ -10,12 +10,16 @@ tools:
edit: false edit: false
apply_patch: false apply_patch: false
webfetch: true webfetch: true
bash: true
skill: true skill: true
permission: permission:
edit: allow edit: allow
webfetch: allow webfetch: allow
bash: bash:
"*": deny "*": deny
"uv run python scripts/search.py *": allow
"uv run python scripts/ground.py *": allow
"python3 scripts/search.py *": allow
task: task:
"*": deny "*": deny
--- ---
@@ -74,6 +78,12 @@ For each core claim, search:
Run 3-5 webfetch queries per claim, prioritizing Tier 1-2 sources. Run 3-5 webfetch queries per claim, prioritizing Tier 1-2 sources.
Use the project search gateway before generic webfetch:
- `uv run python scripts/search.py "<claim keyword> limitations failed controversy" --route scholar --num-results 10 --year-low 2023`
- For patent/IP claims: `uv run python scripts/search.py "<claim keyword>" --route patents --num-results 10`
- For news or transaction claims: `uv run python scripts/search.py "<claim keyword>" --route news --num-results 10 --time-range y`
- For rapid independent spot checks: `uv run python scripts/ground.py "<claim keyword>" --json`
### Step 3: Data Sanity Check ### Step 3: Data Sanity Check
Verify all numbers in the chapter: Verify all numbers in the chapter:
+33
View File
@@ -0,0 +1,33 @@
---
description: 将模型预设应用到 agent 文件。用法:/dr-apply-models <profile>
agent: dr-pm
---
你是 dr-pm。把模型预设应用到 agent 配置文件。
## 执行步骤
1. 如果 `$ARGUMENTS` 为空,先列出可用 profile
```bash
uv run python scripts/dr.py models --list
```
并提示用户至少选择 `simple / medium / premium` 之一。
2. 如果 `$ARGUMENTS` 非空,执行 dry-run
```bash
uv run python scripts/dr.py apply-models --profile "$ARGUMENTS" --target both --dry-run
```
3. 将 dry-run 结果展示给用户确认影响范围后,再执行实际应用:
```bash
uv run python scripts/dr.py apply-models --profile "$ARGUMENTS" --target both
```
4. 最后输出:
- 采用的 profile
- 更新的文件数与路径
- 下一步建议(如需同步到本机 `.codex/**`,运行 `uv run python scripts/install_codex_adapter.py --force`
+22 -39
View File
@@ -1,11 +1,11 @@
--- ---
description: Phase 4 - 成稿(v0.6)。dr-editor-in-chief 写 ES/Abstract/Glossary,然后调 Python 脚本链路:translate → build_glossary → apply_glossary → polish → build_report。用法:/dr-finalize [slug] description: Phase 4 - 成稿(v0.12)。dr-editor-in-chief 写 ES/Abstract/Glossary,然后调统一 Python pipelinephase4_pipeline.py。用法:/dr-finalize [slug]
agent: dr-editor-in-chief agent: dr-editor-in-chief
--- ---
你是 dr-editor-in-chief。用户执行了 `/dr-finalize $ARGUMENTS`,进入 Phase 4 成稿链路(v0.6 架构)。 你是 dr-editor-in-chief。用户执行了 `/dr-finalize $ARGUMENTS`,进入 Phase 4 成稿链路(v0.12 架构)。
## 架构变更说明(v0.6 ## 架构变更说明(v0.12
**Phase 4 的翻译/润色/出稿已从 LLM agent 改为 Python 脚本**。原因: **Phase 4 的翻译/润色/出稿已从 LLM agent 改为 Python 脚本**。原因:
- LLM agent 一次性处理整篇报告(19k+ 词)会超 Sonnet output token 上限(~32k),不稳定 - LLM agent 一次性处理整篇报告(19k+ 词)会超 Sonnet output token 上限(~32k),不稳定
@@ -40,57 +40,40 @@ agent: dr-editor-in-chief
- 给每章强加 SCQA 或小节标题 - 给每章强加 SCQA 或小节标题
- 保留调度元数据(字数配额/研究员/quota 等) - 保留调度元数据(字数配额/研究员/quota 等)
## Step 3: 翻译Python 脚本) ## Step 3: 执行统一 Phase 4 pipelinePython 脚本)
```bash ```bash
uv run python scripts/translate.py <slug> uv run python scripts/phase4_pipeline.py <slug>
``` ```
完成条件:`phase4/final_zh.md` 生成且字数 ≥ 目标字数的 90%。如未达标,`--force` 强制重跑。 默认行为:
- 自动估算 translate / polish 并发
- glossary 仅核查低置信度术语(`--glossary-mode low-confidence`
- 统一串联 translate → glossary(optional) → apply_glossary → polish → build_report
## Step 4: 术语表核查(强烈推荐) 可选参数示例:
```bash ```bash
uv run python scripts/build_glossary.py <slug> --workers 4 uv run python scripts/phase4_pipeline.py <slug> --glossary-mode full
uv run python scripts/phase4_pipeline.py <slug> --glossary-mode off
``` ```
完成后查看 `phase4/glossary.json` 完成条件:`phase4/final_zh_polished.md`、PDF、DOCX 全部生成,且无致命报错。
- `confidence == "high"``issue` 非空的条目:说明发现了错误,需要回塑到正文
- 关注公司名 / 机构名 / 产品名类,它们最容易有拼写错误
## Step 5: 应用术语修正(Python 脚本) ## Step 4: (可选)分步重跑
```bash 当你只想重跑单环节时,仍可手动调用:
# 先预览 - `translate.py`
uv run python scripts/apply_glossary.py <slug> --input phase4/final_zh.md --dry-run - `build_glossary.py`
- `apply_glossary.py`
# 确认无误后应用 - `polish.py`
uv run python scripts/apply_glossary.py <slug> --input phase4/final_zh.md - `build_report.py`
```
这会把 glossary 中发现的拼写错误 / 错译直接替换进 `final_zh.md`
如润色后仍需二次复核,可手动对 `final_zh_polished.md` 再运行一次:
`uv run python scripts/apply_glossary.py <slug> --input phase4/final_zh_polished.md --dry-run`
## Step 6: 润色(Python 脚本)
```bash
uv run python scripts/polish.py <slug>
```
输出:`phase4/final_zh_polished.md`。查看 `phase4/polish_notes.jsonl` 了解模型标记的异常点。
## Step 7: 出稿(Python 脚本)
```bash
uv run python scripts/build_report.py <slug>
```
自动: 自动:
-`manifest.report_title` 命名输出(`<Title>.pdf` + `<Title>.docx` -`manifest.report_title` 命名输出(`<Title>.pdf` + `<Title>.docx`
- PDF 自动插 TOC + 从 `phase2/sources.jsonl` 生成参考文献 - PDF 自动插 TOC + 从 `phase2/sources.jsonl` 生成参考文献
## Step 8: 更新 manifest ## Step 5: 更新 manifest
```json ```json
{ {
@@ -115,7 +98,7 @@ uv run python scripts/build_report.py <slug>
} }
``` ```
## Step 9: 汇报 ## Step 6: 汇报
向用户展示: 向用户展示:
- 各阶段耗时和成本 - 各阶段耗时和成本
+6 -4
View File
@@ -18,6 +18,7 @@ subtask: false
- `target_words_zh``target_words_en` 必须都存在 - `target_words_zh``target_words_en` 必须都存在
- `core_questions` 必须非空 - `core_questions` 必须非空
- `report_title` 必须非空(v0.5 新增检查) - `report_title` 必须非空(v0.5 新增检查)
- `model_profile` 必须存在(v0.12 新增检查,确保全流程模型策略一致)
任一检查不通过 → 回报用户"访谈不完整",停止。 任一检查不通过 → 回报用户"访谈不完整",停止。
@@ -52,7 +53,7 @@ prompt: |
Required skills: search-strategy, source-quality Required skills: search-strategy, source-quality
Tasks: Tasks:
1. 3 rounds of search: Tavily + Brave + Exa 1. 3 rounds of search through `scripts/search.py`: scholar/patents/news/general as appropriate; Tavily/Brave/Exa MCP only as gap-fill
2. Both English and Chinese keywords 2. Both English and Chinese keywords
3. Return 10-20 Tier 1-2 sources (score ≥6), exclude Tier 4 and blacklist 3. Return 10-20 Tier 1-2 sources (score ≥6), exclude Tier 4 and blacklist
4. 1-2 sentence outline per source 4. 1-2 sentence outline per source
@@ -60,9 +61,10 @@ prompt: |
Output format (Markdown): Output format (Markdown):
## Keyword Group <A>: <category> ## Keyword Group <A>: <category>
### Keywords Used ### Keywords Used
- English: ... - English: ...
- Chinese: ... - Chinese: ...
- Routes used: scholar / patents / news / general
### Initial Sources (≥10, Tier 1-2) ### Initial Sources (≥10, Tier 1-2)
1. [src_xxx] <title> | <author/institution> | <year> | <Tier> | <score> 1. [src_xxx] <title> | <author/institution> | <year> | <Tier> | <score>
- <core finding one sentence> - <core finding one sentence>
+22 -2
View File
@@ -1,5 +1,5 @@
--- ---
description: 初始化一个新的 Deep Research 主题。创建 projects/<slug>/ 目录与 manifest.json,启动 Phase 1 访谈(8 步),访谈末尾自动提议 3 个报告标题让用户选。用法:/dr-init <研究主题> description: 初始化一个新的 Deep Research 主题。创建 projects/<slug>/ 目录与 manifest.json,启动 Phase 1 访谈(9,含模型策略选择),访谈末尾自动提议 3 个报告标题让用户选。用法:/dr-init <研究主题>
agent: dr-plan agent: dr-plan
subtask: false subtask: false
--- ---
@@ -26,7 +26,7 @@ subtask: false
mkdir -p projects/<slug>/{phase1,phase2/drafts,phase2/evidence,phase3/revisions,phase4/figures} mkdir -p projects/<slug>/{phase1,phase2/drafts,phase2/evidence,phase3/revisions,phase4/figures}
``` ```
### Step 3: 启动访谈(8 步) ### Step 3: 启动访谈(9 步)
**不要急着生成 framework**,向用户清晰编号地提出以下 8 个关键问题: **不要急着生成 framework**,向用户清晰编号地提出以下 8 个关键问题:
@@ -55,6 +55,13 @@ mkdir -p projects/<slug>/{phase1,phase2/drafts,phase2/evidence,phase3/revisions,
- `deep` — 深度(50,000-80,000 中文字,12-15 章;行业专著级) - `deep` — 深度(50,000-80,000 中文字,12-15 章;行业专著级)
- 说明:字数只是参考,以把问题讲清楚为第一优先。 - 说明:字数只是参考,以把问题讲清楚为第一优先。
9. **模型策略选择(新增,必须在 init 阶段确定)**
- `simple`:低成本探索
- `medium`:默认推荐(平衡质量/成本)
- `premium`:高质量正式交付
- `cn_heavy`:中文/中国市场侧重
- `codex_native`Codex 原生模式
**等待用户回答**。用户可能一次性回答也可能分多轮。 **等待用户回答**。用户可能一次性回答也可能分多轮。
### Step 4: 提议报告正式标题(关键新增步骤) ### Step 4: 提议报告正式标题(关键新增步骤)
@@ -105,6 +112,9 @@ mkdir -p projects/<slug>/{phase1,phase2/drafts,phase2/evidence,phase3/revisions,
"comparison_targets": [], "comparison_targets": [],
"exclusions": [], "exclusions": [],
"word_budget_mode": "<auto/concise/detailed/deep>", "word_budget_mode": "<auto/concise/detailed/deep>",
"model_profile": "<simple/medium/premium/cn_heavy/codex_native>",
"model_profile_selected_at": "<今天 YYYY-MM-DD>",
"model_profile_source": "dr-init interview",
"target_words_zh": < length-budget skill §1-2>, "target_words_zh": < length-budget skill §1-2>,
"target_words_en": <target_words_zh / 1.4>, "target_words_en": <target_words_zh / 1.4>,
"min_words_zh": <target_words_zh × 0.8>, "min_words_zh": <target_words_zh × 0.8>,
@@ -123,6 +133,16 @@ mkdir -p projects/<slug>/{phase1,phase2/drafts,phase2/evidence,phase3/revisions,
把整个访谈对话写入 `projects/<slug>/phase1/interview.md`(用户原话 + 你的提问 + 提议的候选标题 + 用户选择)。 把整个访谈对话写入 `projects/<slug>/phase1/interview.md`(用户原话 + 你的提问 + 提议的候选标题 + 用户选择)。
### Step 6.5: 立刻应用模型策略(必须执行)
在项目初始化完成后,立即把 `model_profile` 应用到 agent 文件(OpenCode + Codex 模板):
```bash
uv run python scripts/dr.py apply-models --profile <model_profile> --target both
```
这样可以确保从 Phase 1plan)到 Phase 4polisher/reporter)全流程使用同一套预设策略,而不是中途切换。
### Step 7: 回报 ### Step 7: 回报
``` ```
+31
View File
@@ -0,0 +1,31 @@
---
description: 查看或解析模型预设。用法:/dr-models [profile]
agent: dr-pm
---
你是 dr-pm。目标是把当前模型预设解析成清晰结果,并给出可执行命令。
## 执行步骤
1. 如果 `$ARGUMENTS` 为空:运行
```bash
uv run python scripts/dr.py models
```
2. 如果 `$ARGUMENTS` 非空:把它当作 profile,运行
```bash
uv run python scripts/dr.py models --profile "$ARGUMENTS"
```
3. 输出结果时必须包含:
- 当前 profile 名称
- 各角色模型映射(至少 dr_plan / dr_pm / dr_analyst / dr_verifier / translate / polish / glossary
- 一条可复制命令,用于 Phase 4 指定该 profile
```bash
uv run python scripts/dr.py finalize <slug> --model-profile <profile>
```
4. 如果 profile 不存在,提示可用 profile 并建议 `simple / medium / premium` 三档。
+3 -3
View File
@@ -113,7 +113,7 @@
"environment": { "environment": {
"TAVILY_API_KEY": "{env:TAVILY_API_KEY}" "TAVILY_API_KEY": "{env:TAVILY_API_KEY}"
}, },
"enabled": true "enabled": false
}, },
"brave-search": { "brave-search": {
"type": "local", "type": "local",
@@ -121,7 +121,7 @@
"environment": { "environment": {
"BRAVE_API_KEY": "{env:BRAVE_API_KEY}" "BRAVE_API_KEY": "{env:BRAVE_API_KEY}"
}, },
"enabled": true "enabled": false
}, },
"exa": { "exa": {
"type": "local", "type": "local",
@@ -129,7 +129,7 @@
"environment": { "environment": {
"EXA_API_KEY": "{env:EXA_API_KEY}" "EXA_API_KEY": "{env:EXA_API_KEY}"
}, },
"enabled": true "enabled": false
} }
}, },
"permission": { "permission": {
+58 -18
View File
@@ -82,7 +82,46 @@ description: 生物医药深度研究的统一检索策略。规定信源优先
--- ---
## 三、API 调用顺序(技术栈,v0.8 更新 ## 三、强制工具入口(v0.12
所有 agent 做联网检索时,**优先调用项目内 Python 网关**,不要直接把 Tavily / Brave / Exa MCP 当成主路径:
```bash
uv run python scripts/search.py "<query>" --route scholar --num-results 10 --year-low 2023
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
uv run python scripts/search.py "<query>" --profile china_market --num-results 10 --trace
uv run python scripts/ground.py "<query>" --model google/gemini-3.1-flash-lite-preview --json
```
也可以按研究场景跑 profile
```bash
uv run python scripts/search.py "<query>" --profile biomed_literature --num-results 10 --year-low 2023
uv run python scripts/search.py "<query>" --profile patent_heavy --num-results 10
```
**原因**
- Python 网关在 repo 内,可被 OpenCode / Codex / Gemini CLI / Claude Code 共同复用。
- `--route patents` 固定优先 Serper + Google Patents,避免专利检索被 Tavily 普通网页结果替代。
- `--route scholar` 固定优先 Serper Scholar,避免论文检索只停留在通用网页摘要。
- 专用 routescholar/patents/news)默认 `--strict-specialized`,Serper 异常时应显式失败,不允许静默降级。
- Tavily / Exa / Brave 只作为 gap-fill 或 MCP 兜底,不作为文献/专利主路径。
每个检索小结必须写明实际使用过的 route,例如:
```text
Routes used: scholar, patents, general
```
如果由于缺 key 或 API 错误无法调用 Serper,必须在输出中明确写(且建议重新执行,不直接进入正文证据):
```text
Serper unavailable: <原因>; fallback used: general site:patents.google.com
```
## 四、API 调用顺序(技术栈,v0.11 更新)
**按"查询类型"路由到最合适的 API**,而不是一律走通用搜索。 **按"查询类型"路由到最合适的 API**,而不是一律走通用搜索。
@@ -114,24 +153,23 @@ description: 生物医药深度研究的统一检索策略。规定信源优先
### Serpergoogle.serper.dev)使用模板 ### Serpergoogle.serper.dev)使用模板
**专利检索** **专利检索**
```python ```bash
from scripts.lib.search_client import SearchClient uv run python scripts/search.py "dual-target siRNA GalNAc" --route patents --num-results 10
with SearchClient() as c:
hits = c.patents("dual-target siRNA GalNAc", num_results=10)
``` ```
**学术论文** **学术论文**
```python ```bash
hits = c.scholar("dual-target RNAi 2024", num_results=10, year_low=2023) uv run python scripts/search.py "dual-target RNAi 2024" --route scholar --num-results 10 --year-low 2023
# hits[i].snippet 里包含引用数和期刊信息
``` ```
**新闻(时效性)** **新闻(时效性)**
```python ```bash
hits = c.news("Arrowhead ARO-DIMER-PA clinical trial", time_range="w") # 最近一周 uv run python scripts/search.py "Arrowhead ARO-DIMER-PA clinical trial" --route news --num-results 10 --time-range w
``` ```
### Tavily MCP 调用模板(通用网页 - Phase 1 初扫 ### Tavily MCP 调用模板(兜底,不作为主路径
仅当 `scripts/search.py` 不可用,或需要 MCP 特有能力时使用。通用网页结果必须回溯到 Tier 1-2 原始来源。
``` ```
工具名:tavily_search 工具名:tavily_search
参数: 参数:
@@ -171,7 +209,7 @@ curl -s "https://api.fda.gov/drug/event.json?search=patient.drug.medicinalproduc
--- ---
## 、关键词策略 ## 、关键词策略
### 中英双语必备 ### 中英双语必备
- 任何生物医药主题**必须同时用中英文检索** - 任何生物医药主题**必须同时用中英文检索**
@@ -195,7 +233,7 @@ curl -s "https://api.fda.gov/drug/event.json?search=patient.drug.medicinalproduc
--- ---
## 、每条信源的提取字段(标准化) ## 、每条信源的提取字段(标准化)
任何信源进 `sources.jsonl` 必须有以下字段: 任何信源进 `sources.jsonl` 必须有以下字段:
@@ -225,7 +263,7 @@ curl -s "https://api.fda.gov/drug/event.json?search=patient.drug.medicinalproduc
--- ---
## 、失败兜底 ## 、失败兜底
- 某个 API 限流/超时:**等 5s 重试 3 次**,仍失败则跳过并在日志标注 - 某个 API 限流/超时:**等 5s 重试 3 次**,仍失败则跳过并在日志标注
- 某个信源 404:在 sources.jsonl 标 `"dead_link": true`,不删除(审计用) - 某个信源 404:在 sources.jsonl 标 `"dead_link": true`,不删除(审计用)
@@ -233,7 +271,7 @@ curl -s "https://api.fda.gov/drug/event.json?search=patient.drug.medicinalproduc
--- ---
## 、硬规则总结 ## 、硬规则总结
1. ✅ 每 section 至少 4 轮检索 1. ✅ 每 section 至少 4 轮检索
2. ✅ 中英双语必查 2. ✅ 中英双语必查
@@ -241,6 +279,8 @@ curl -s "https://api.fda.gov/drug/event.json?search=patient.drug.medicinalproduc
4. ✅ 反方关键词必查 4. ✅ 反方关键词必查
5. ✅ Tier 4 结果只做发现,不做佐证 5. ✅ Tier 4 结果只做发现,不做佐证
6. ✅ 所有信源写入 sources.jsonl 并评分 6. ✅ 所有信源写入 sources.jsonl 并评分
7. ❌ 不得引用 Wikipedia 做结论 7. ✅ 文献检索必须优先 `scripts/search.py --route scholar`
8. ❌ 不得编造数据、URL、DOI 8. ✅ 专利检索必须优先 `scripts/search.py --route patents`
9. ❌ 不得使用黑名单信源 9. ❌ 不得引用 Wikipedia 做结论
10. ❌ 不得编造数据、URL、DOI
11. ❌ 不得使用黑名单信源
+72 -8
View File
@@ -1085,6 +1085,7 @@ def render_table(md_table: str, styles: StyleSheet1) -> Table:
- 所有 cell 垂直居中 - 所有 cell 垂直居中
- 长文字自动 CJK 换行 - 长文字自动 CJK 换行
- 长表自动按行分页 - 长表自动按行分页
- 宽表(>=7 列)强制按页宽等分列宽,避免 ReportLab 自动分配失败
""" """
rows: list[list] = [] rows: list[list] = []
raw_rows = [] raw_rows = []
@@ -1097,28 +1098,90 @@ def render_table(md_table: str, styles: StyleSheet1) -> Table:
return Table([[""]]) return Table([[""]])
header_cells = raw_rows[0] header_cells = raw_rows[0]
ncols = len(header_cells)
# 判断是否需要强制等宽列:
# - 列数 >= 4 时(避免自动分配使某列被挤为负宽)
# - 或任意单元格文本超过 30 字符(中英文混排时 auto-allocation 不稳定)
_max_cell_len = 0
for r in raw_rows[1:]:
for c in r:
if len(c) > _max_cell_len:
_max_cell_len = len(c)
is_wide = ncols >= 4 or _max_cell_len > 30
is_very_wide = ncols >= 7 or _max_cell_len > 80
pad_lr = 2 if is_very_wide else (3 if is_wide else 6)
pad_tb = 3 if is_wide else 5
rows.append([ rows.append([
Paragraph(md_inline_to_rl(c), styles["table-header"]) for c in header_cells Paragraph(md_inline_to_rl(c), styles["table-header"]) for c in header_cells
]) ])
for cells in raw_rows[1:]: for cells in raw_rows[1:]:
# 补齐列数(防御性) while len(cells) < ncols:
while len(cells) < len(header_cells):
cells.append("") cells.append("")
rows.append([_render_table_cell(c, styles) for c in cells]) rows.append([_render_table_cell(c, styles) for c in cells])
table = Table(rows, repeatRows=1, splitByRow=True) # 可用页宽(A4 - margins),给 Table 分配等宽列
# 参考 doctemplate 页面宽度:A4.width (595) - left (54) - right (54) ≈ 487 pt
# 为安全起见,给表格留一点边距
col_widths = None
if is_wide:
from reportlab.lib.pagesizes import A4
avail_width = A4[0] - 110 # A4 宽度 - 两侧边距
col_widths = [avail_width / ncols] * ncols
table = Table(rows, colWidths=col_widths, repeatRows=1, splitByRow=True)
table.setStyle(TableStyle([ table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e0e7ff")), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e0e7ff")),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#cbd5e1")), ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#cbd5e1")),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 6), ("LEFTPADDING", (0, 0), (-1, -1), pad_lr),
("RIGHTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), pad_lr),
("TOPPADDING", (0, 0), (-1, -1), 5), ("TOPPADDING", (0, 0), (-1, -1), pad_tb),
("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), pad_tb),
])) ]))
return table return table
def render_table_blocks(md_table: str, styles: StyleSheet1, max_rows_per_chunk: int = 25) -> list:
"""Render a markdown table as potentially multiple Table objects.
When the body has more than ``max_rows_per_chunk`` rows we slice it into
smaller chunks (each re-printing the header). This avoids ReportLab's
split-by-row bug on very long tables which manifests as
``TypeError: '>' not supported between instances of 'NoneType' and 'NoneType'``.
"""
raw_rows = []
for line in md_table.strip().split("\n"):
line = line.strip().strip("|")
cells = [c.strip() for c in line.split("|")]
raw_rows.append(cells)
if not raw_rows:
return [Table([[""]])]
header = raw_rows[0]
body = raw_rows[1:]
if len(body) <= max_rows_per_chunk:
return [render_table(md_table, styles)]
# Split into chunks
out = []
from reportlab.platypus import Spacer
for start in range(0, len(body), max_rows_per_chunk):
chunk = body[start:start + max_rows_per_chunk]
lines_md = [
"| " + " | ".join(header) + " |",
"|" + "|".join(["---"] * len(header)) + "|",
]
for row in chunk:
lines_md.append("| " + " | ".join(row) + " |")
out.append(render_table("\n".join(lines_md), styles))
out.append(Spacer(1, 4))
return out
def _render_generic_block(block: Block, story: list, base_dir: Path, styles: StyleSheet1, *, in_summary: bool) -> None: def _render_generic_block(block: Block, story: list, base_dir: Path, styles: StyleSheet1, *, in_summary: bool) -> None:
"""渲染一个非 H1/H2 的 blockp/quote/bullet/hr/image/table/h3)。 """渲染一个非 H1/H2 的 blockp/quote/bullet/hr/image/table/h3)。
@@ -1157,7 +1220,8 @@ def _render_generic_block(block: Block, story: list, base_dir: Path, styles: Sty
)) ))
elif block.kind == "table": elif block.kind == "table":
try: try:
story.append(render_table(block.content, styles)) for _tbl_block in render_table_blocks(block.content, styles):
story.append(_tbl_block)
except Exception as e: except Exception as e:
story.append(Paragraph(f"[表格渲染失败: {e}]", styles["caption"])) story.append(Paragraph(f"[表格渲染失败: {e}]", styles["caption"]))
+3 -2
View File
@@ -74,8 +74,9 @@
### Phase 4:成稿 ### Phase 4:成稿
- **驱动命令**`/dr-finalize` - **驱动命令**`/dr-finalize`
- **主导 agent**dr-chief-editor → dr-polisher → dr-reporter - **主导 agent**dr-editor-in-chief(创作)→ `scripts/phase4_pipeline.py`(执行链路)
- **产出**`final.md` + `final.pdf`ReportLab+ `final.docx`Pandoc - **执行链路**translate → glossary(optional) → apply_glossary → polish → build_report
- **产出**`phase4/final_en.md` + `phase4/final_zh.md` + `phase4/final_zh_polished.md` + `phase4/*.pdf` + `phase4/*.docx`
--- ---
+53
View File
@@ -546,3 +546,56 @@ OpenCode 的坑:如果只是在主会话里装样子地写"让 X agent 做"
- 安装后运行 `/debug-config` 确认 `.codex/config.toml` 被 Codex 加载。 - 安装后运行 `/debug-config` 确认 `.codex/config.toml` 被 Codex 加载。
- 自动化研究默认权限:`sandbox_mode = "workspace-write"`、`approval_policy = "never"`、`web_search = "live"`、`sandbox_workspace_write.network_access = true`。 - 自动化研究默认权限:`sandbox_mode = "workspace-write"`、`approval_policy = "never"`、`web_search = "live"`、`sandbox_workspace_write.network_access = true`。
- Tavily / Brave / Exa MCP server 在模板中默认 `enabled = true` 且 `required = false`;确认本机 key、npm 与网络可用可直接使用,某个服务异常时再单独关闭。 - Tavily / Brave / Exa MCP server 在模板中默认 `enabled = true` 且 `required = false`;确认本机 key、npm 与网络可用可直接使用,某个服务异常时再单独关闭。
- 2026-04-24 v0.11**项目内搜索网关与 search-strategy 强化**
**目标**:把搜索主路径从平台 MCP 收敛到项目内 Python CLI,避免 Codex/OpenCode/Gemini/Claude Code 各自配置差异导致策略漂移。
**变更**
- 新增 `scripts/search.py`:统一搜索入口,支持 `--route scholar|patents|news|general` 与 `--profile biomed_literature|patent_heavy|china_market|investment`。
- `scripts/lib/search_client.py` 调整为 Serper / Exa / Tavily 路由:文献走 Serper Scholar,专利走 Serper + Google Patents,新闻走 Serper News,通用搜索走 Exa → Tavily。
- `search-strategy` 明确 MCP 只做 gap-fill;文献必须优先 `scripts/search.py --route scholar`,专利必须优先 `scripts/search.py --route patents`。
- OpenCode `dr-searcher` / `dr-analyst` / `dr-verifier` 增加搜索网关调用要求与必要 bash 权限。
- Codex adapter 模板同步要求 `dr-run`、`dr-searcher`、`dr-analyst`、`dr-verifier` 使用搜索网关。
- 2026-04-29 v0.12**三轨并行改造(搜索稳定性 + 模型配置化 + Phase 4 替代式 pipeline**
**目标**:并行解决三项瓶颈:
1) 搜索工具遵循不稳定;
2) 模型选择被硬编码锁定;
3) Phase 4 串行链路耗时过长。
**Track A — 搜索路径可控化(Sprint 1)**:
- 新增 `scripts/ground.py`,统一封装 ZenMux native grounding`web_search_options`)并输出引用 URL。
- `scripts/lib/zenmux_client.py` 增加 `web_search` 参数透传与 `chat_complete_with_meta()`(返回 content/usage/citations/raw)。
- `scripts/lib/search_client.py` 对 `scholar/patents/news` 默认启用 strict 模式,Serper 异常时显式失败,禁止静默降级。
- `scripts/search.py` 增加 `--strict-specialized`、`--trace`、`china_market` 查询重写。
- `.opencode/opencode.json` 关闭 Tavily/Brave/Exa MCP 的默认启用,收敛到项目内搜索网关。
**Track B — 模型配置化(Sprint 2-3**
- 新增统一配置 `configs/models.yaml``simple/medium/premium/cn_heavy/codex_native`)。
- 新增 `scripts/lib/model_config.py`,支持 profile 解析、override`ROLE=MODEL`)与 profile 列表。
- `scripts/dr.py` 新增 `models`、`apply-models`,并让 `finalize` 支持 `--model-profile` 与 `--model-override`。
- 新增 `scripts/apply_model_profile.py`,可将 profile 批量回填到 `.opencode/agents/*.md` 与 `codex_adapter_templates/codex/agents/*.toml`。
- 新增 OpenCode 命令:`/dr-models`、`/dr-apply-models`。
**Track C — Phase 4 替代式重构(Sprint 4**
- 新增 `scripts/phase4_pipeline.py` 作为统一编排入口:
`translate -> glossary(optional) -> apply_glossary -> polish -> build_report`。
- glossary 核查支持 `off/low-confidence/full`,默认 `low-confidence`;低置信度条目过多时自动回退 `full`,避免超长命令参数。
- translate/polish workers 支持自动估算(`0 => auto`),降低人工调参成本。
- `scripts/dr.py finalize` 与 `.opencode/commands/dr-finalize.md` 切换到新 pipeline。
**Sprint 5 回归验证**
- 新增 `scripts/sprint5_regression.py`,覆盖模型预设解析、搜索网关 dry-run、Phase 4 finalize dry-run 三项关键回归检查。
- 文档同步:`README.md`、`docs/model-playbook.md`、`docs/search-playbook.md`、`docs/codex-usage.md`。
**Sprint 6 收尾验收**
- AGENTS.md 的 Phase 4 描述更新为 v0.12 真实链路(`dr-editor-in-chief + scripts/phase4_pipeline.py`)。
- README 增补一键回归命令:`uv run python scripts/sprint5_regression.py <slug>`。
- 验收口径固定:
1) `dr.py models --list` 可列出预设;
2) `dr.py apply-models` 可 dry-run 与落盘;
3) `scripts/search.py` 专用路由默认 strict
4) `dr.py finalize --model-profile <x>` 走统一 Phase 4 pipeline
5) `scripts/sprint5_regression.py` 全部 PASS。
+145 -78
View File
@@ -2,7 +2,7 @@
> 生物医药行业的 AI 驱动深度研究流水线。基于 OpenCode 多 agent 协作,以麦肯锡/德勤式方法论产出专业级研究报告(PDF + DOCX)。 > 生物医药行业的 AI 驱动深度研究流水线。基于 OpenCode 多 agent 协作,以麦肯锡/德勤式方法论产出专业级研究报告(PDF + DOCX)。
**当前状态**v0.10 迭代中。OpenCode 全流程可用(Phase 1-4),Phase 4 已切换为 Python 脚本化流水线;Codex native adapter 正在建设为独立于 OpenCode 的并列入口 **当前状态**v0.13 迭代完成。新增 Quarto/xelatex PDF 引擎(`--engine quarto`),解决 ReportLab 超宽表格渲染 bugReportLab 引擎保留为默认后备。Quarto 依赖独立安装,不影响现有环境
详见 `PLAN.md` 了解完整方案、版本记录与迭代路径。 详见 `PLAN.md` 了解完整方案、版本记录与迭代路径。
--- ---
@@ -87,80 +87,8 @@ opencode # 启动 TUI
**跑单个 Python 脚本**(不用先激活): **跑单个 Python 脚本**(不用先激活):
```bash ```bash
uv run python .opencode/templates/report-template.py --input ... --output ... uv run python scripts/build_report.py <slug> # 默认 ReportLab
``` uv run python scripts/build_report.py <slug> --engine quarto # Quarto/xelatex
**加新依赖**
```bash
uv add <package> # 自动更新 pyproject.toml 和 uv.lock
```
**同步到最新锁定版本**(新 clone 或切分支后):
```bash
uv sync
```
如果你用 [direnv](https://direnv.net/),可在项目根目录建 `.envrc`
```bash
source scripts/activate.sh
```
这样 `cd` 进项目目录会自动激活,`cd` 出去会自动卸载。
---
## 可用命令
| 命令 | 功能 | 状态 |
|---|---|---|
| `/dr-init <主题>` | 初始化新研究,启动访谈 | ✅ 可用 |
| `/dr-frame [slug]` | Phase 1:生成 8-15 章双语研究框架 | ✅ 可用 |
| `/dr-research [slug]` | Phase 2:并行深度研究 | ✅ 可用 |
| `/dr-review [slug]` | Phase 3:总编审校 | ✅ 可用 |
| `/dr-finalize [slug]` | Phase 4:英文合稿 → 中文翻译/术语核查/润色 → PDF+DOCX | ✅ 可用 |
| `/dr-glossary [slug]` | 术语表事实核查 | ✅ 可用 |
| `/dr-status [slug]` | 查看进度 | ✅ 可用 |
### 典型流程
```
1. /dr-init GLP-1 减重药物市场
→ dr-plan 向你提 8 个访谈问题(研究类型、受众、时间范围等)
→ 你回答后,生成 projects/glp1-obesity-market-2026/manifest.json
2. /dr-frame
→ dr-plan 调用 skill:search-strategy
→ 委派 3-4 个 dr-searcherHaiku,轻量)并行初扫
→ 生成 8-15 章框架到 phase1/framework.md
→ 暂停等你确认
3. 你审核框架,或提修改意见,或直接确认
→ 确认后,manifest.phase1.approved = true
4. /dr-research
→ dr-pm 分批并行调度 dr-analyst 深研
→ dr-verifier 做反方验证
→ 产出 phase2/drafts、evidence、sources.jsonl
5. /dr-review
→ dr-chief-editor 通读审校,产出 phase3/critique.md
6. /dr-finalize
→ dr-editor-in-chief 合并英文终稿
→ Python 脚本执行 translate → glossary → apply_glossary → polish → build_report
→ 产出 final_zh_polished.md、PDF、DOCX
```
### Phase 4 Python 流水线
Phase 4 已不再依赖单个 LLM agent 一次性翻译整篇报告,而是由 Python 控制切块、并发、重试与断点续传:
```bash
uv run python scripts/translate.py <slug> --workers 4
uv run python scripts/build_glossary.py <slug> --workers 4
uv run python scripts/apply_glossary.py <slug> --input phase4/final_zh.md --dry-run
uv run python scripts/apply_glossary.py <slug> --input phase4/final_zh.md
uv run python scripts/polish.py <slug> --workers 4
uv run python scripts/build_report.py <slug>
``` ```
网络不稳或 API 限流时,把 `--workers` 降到 `3``1` 即可断点续跑。 网络不稳或 API 限流时,把 `--workers` 降到 `3``1` 即可断点续跑。
@@ -196,6 +124,37 @@ codex exec "$(uv run python scripts/dr.py prompt dr-run <slug-or-topic>)"
- `docs/model-playbook.md` - `docs/model-playbook.md`
- `docs/search-playbook.md` - `docs/search-playbook.md`
模型预设配置文件:
- `configs/models.yaml`(统一预设,支持 `simple / medium / premium / cn_heavy / codex_native`
推荐时机:在 `/dr-init` 访谈阶段就确定 `model_profile`,并立即执行 `apply-models`,保证 plan→pm→analyst→verifier→editor→polisher 的全流程策略一致。
命令行查看解析后的模型映射:
```bash
uv run python scripts/dr.py models
uv run python scripts/dr.py models --list
uv run python scripts/dr.py models --profile premium
uv run python scripts/dr.py models --profile medium --model-override dr_verifier=zenmux/openai/gpt-5.4
# apply profile to agent files
uv run python scripts/dr.py apply-models --profile medium --target both --dry-run
uv run python scripts/dr.py apply-models --profile medium --target both
```
Sprint 5 回归检查(一键):
```bash
uv run python scripts/sprint5_regression.py <slug>
```
统一搜索入口:
```bash
uv run python scripts/search.py "dual-target RNAi 2024" --route scholar --year-low 2023
uv run python scripts/search.py "dual-target siRNA GalNAc" --route patents
```
--- ---
## 项目结构 ## 项目结构
@@ -273,12 +232,71 @@ OpenCode 的常见陷阱:AI 在主会话里装样子地"委派"子 agent,实
Phase 1 分配章节配额,Phase 2 自检,不足返工。见 `skills/length-budget/SKILL.md` Phase 1 分配章节配额,Phase 2 自检,不足返工。见 `skills/length-budget/SKILL.md`
### 4. 中文 PDF 无坑 ### 4. 中文 PDF 双引擎
`build_report.py` 现在支持两套 PDF 引擎,按需选择:
#### 引擎 AReportLab(默认,无额外依赖)
```bash
uv run python scripts/build_report.py <slug>
```
- 字体:思源宋 + 思源黑 + 霞鹜文楷(全 SIL OFL,可商用嵌入) - 字体:思源宋 + 思源黑 + 霞鹜文楷(全 SIL OFL,可商用嵌入)
- 样式:集中在 `build_styles()`,所有字号行距单点维护 - 样式:集中在 `build_styles()`,所有字号行距单点维护
- 引擎:ReportLab(纯 Python30,000 字 3-5 秒出稿 - 速度:30,000 字 3-5 秒出稿
- 图表:matplotlib 预渲染 300 DPI PNG 嵌入 - 局限:超宽表格(≥4 列且含长文本)需借助列宽 patch 或改为 bullet list 格式
#### 引擎 BQuarto / xelatex`--engine quarto`,推荐用于宽表报告)
```bash
uv run python scripts/build_report.py <slug> --engine quarto
```
- 排版引擎:xelatexTeX Live / TinyTeX),LaTeX 级排版质量
- 字体:同样使用思源宋 + 思源黑,通过 fontspec 加载
- 宽表支持:超宽表通过 `longtable` + `tbl-colwidths` 精确指定列宽比例,不溢出
- 横向页面:通过 `{.landscape}` div 包裹超宽表,自动插入 `pdflscape` 代码(注意:101 行以上的 landscape longtable 可能触发 TeX `param_size` 上限,建议拆成 ≤20 行的子表块)
- 图表:暂不嵌入 matplotlib 图表(使用文字描述代替)
**安装 Quarto 引擎**(一次性,系统级):
```bash
# 1. 安装 Quarto CLI
# 下载页:https://github.com/quarto-dev/quarto-cli/releases/latest
# Linux 选 .deb 安装包,macOS 选 .pkg
# 2. 安装 TinyTeXQuarto 内置命令)
quarto install tinytex
# 3. 安装中文 LaTeX 支持包
~/.TinyTeX/bin/x86_64-linux/tlmgr install ctex xecjk cjk xetex
# macOS 路径通常为:~/.TinyTeX/bin/universal-darwin/tlmgr
# 4. 注册思源字体到 fontconfig
# (先确认字体已下载:bash .opencode/templates/fonts/download-fonts.sh
mkdir -p ~/.fonts
cp .opencode/templates/fonts/*.otf ~/.fonts/
cp .opencode/templates/fonts/*.ttf ~/.fonts/
cp .opencode/templates/fonts/ttf/*.ttf ~/.fonts/
fc-cache -fv ~/.fonts
# 5. 验证
quarto --version # 应输出 1.x.x
fc-list | grep "Source Han" # 应看到思源字体条目
```
**两引擎对比**
| 指标 | ReportLab | Quarto/xelatex |
|------|-----------|----------------|
| 安装复杂度 | 无额外依赖 | 需安装 Quarto + TinyTeX |
| 渲染速度 | 3-5 秒 | 30-90 秒(LaTeX 编译) |
| 宽表格处理 | 需 workaround | longtable 原生支持 |
| 横向页面 | 不支持 | 支持(≤20 行/块) |
| 字体嵌入 | OTF 直接嵌入 | fontspec 系统字体 |
| 输出体积 | ~1.2 MB/100页 | ~0.9 MB/100页 |
| 目录生成 | 自定义实现 | LaTeX 自动 \tableofcontents |
### 5. zenmux 双 providerClaude cache 关键) ### 5. zenmux 双 providerClaude cache 关键)
@@ -349,6 +367,51 @@ ls .opencode/templates/fonts/*.otf | wc -l # 应为 6+
python .opencode/templates/report-template.py --help python .opencode/templates/report-template.py --help
``` ```
### Quarto PDF 生成失败
**字体找不到(`Could not resolve font "Source Han Serif CN/I"`**
CJK 字体没有斜体变体,fontspec 默认会找 `/I` 导致报错。`build_report.py --engine quarto` 已通过 `mainfontoptions: [ItalicFont=...]` 自动绕开,无需手动处理。若自行编写 `.qmd`,需在 YAML 里加:
```yaml
format:
pdf:
pdf-engine: xelatex
CJKmainfont: "Source Han Serif CN"
mainfontoptions:
- BoldFont=Source Han Serif CN
- ItalicFont=Source Han Serif CN
- BoldItalicFont=Source Han Serif CN
```
**`tlmgr` 找不到**
TinyTeX 不在系统 PATH,用完整路径:
```bash
~/.TinyTeX/bin/x86_64-linux/tlmgr install <package> # Linux
~/.TinyTeX/bin/universal-darwin/tlmgr install <package> # macOS
```
**`TeX capacity exceeded [parameter stack size]`**landscape 大表):
pdflscape 的 `\LS@makefcolumn` 在 101 行以上的 longtable 里递归过深,耗尽 TeX 的 `param_size`。解决方法:把超大表拆成每块 ≤20 行的子表,每块都包在 `{.landscape}` div 里:
```markdown
::: {.landscape}
| 列1 | 列2 | ... |
|---|---|---|
| 第1-20行 | ... |
:::
::: {.landscape}
| 列1 | 列2 | ... |
|---|---|---|
| 第21-40行 | ... |
:::
```
若使用 `build_report.py --engine quarto`,可通过传入预处理好的 `.md`(宽表已拆块)来避免此问题。
### uv 安装后找不到 ### uv 安装后找不到
uv 官方脚本把 uv 装到 `~/.local/bin/`。若终端里 `which uv` 找不到: uv 官方脚本把 uv 装到 `~/.local/bin/`。若终端里 `which uv` 找不到:
```bash ```bash
@@ -397,6 +460,9 @@ direnv allow
- Skill 配置:https://opencode.ai/docs/skills - Skill 配置:https://opencode.ai/docs/skills
- MCP Servershttps://opencode.ai/docs/mcp-servers - MCP Servershttps://opencode.ai/docs/mcp-servers
- ReportLab 文档:https://docs.reportlab.com - ReportLab 文档:https://docs.reportlab.com
- Quarto 文档:https://quarto.org/docs/output-formats/pdf-basics.html
- Quarto PDF 引擎:https://quarto.org/docs/output-formats/pdf-engine.html
- Quarto 表格文档:https://quarto.org/docs/authoring/tables.html
- 思源字体:https://github.com/adobe-fonts - 思源字体:https://github.com/adobe-fonts
- 霞鹜文楷:https://github.com/lxgw/LxgwWenKai - 霞鹜文楷:https://github.com/lxgw/LxgwWenKai
@@ -407,5 +473,6 @@ direnv allow
- **v0.1** (2026-04-20) — MVP 路径 2 完成:dr-plan + dr-pm 两主 agent、4 个核心 skill、2 个命令、ReportLab 模板基础版、字体下载脚本 - **v0.1** (2026-04-20) — MVP 路径 2 完成:dr-plan + dr-pm 两主 agent、4 个核心 skill、2 个命令、ReportLab 模板基础版、字体下载脚本
- **v0.2** (2026-04-20) — 双 provider 架构(zenmux-anthropic + zenmux),解决 Claude prompt cache 生效问题 - **v0.2** (2026-04-20) — 双 provider 架构(zenmux-anthropic + zenmux),解决 Claude prompt cache 生效问题
- **v0.3** (2026-04-20) — 修正 v0.2 模型名(回到 Opus 4.7 / Sonnet 4.6 / Gemini 3.1 Pro / GPT-5.4 Pro 等真实 slug);改 venv + requirements.txt 跨平台方案(macOS + Debian);新增 `scripts/setup.sh``scripts/activate.sh` - **v0.3** (2026-04-20) — 修正 v0.2 模型名(回到 Opus 4.7 / Sonnet 4.6 / Gemini 3.1 Pro / GPT-5.4 Pro 等真实 slug);改 venv + requirements.txt 跨平台方案(macOS + Debian);新增 `scripts/setup.sh``scripts/activate.sh`
- **v0.13** (2026-05-02) — `build_report.py` 新增 `--engine quarto` 选项:Quarto 1.9 + xelatex 引擎,解决 ReportLab 超宽表格渲染 bug`negative availWidth`/`NoneType` 问题);`report-template.py` 同步修复(`render_table_blocks` 分块 + 等宽列强制分配);README 补充双引擎安装指南与排错
`PLAN.md` §12 了解完整变更历史。 `PLAN.md` §12 了解完整变更历史。
@@ -1,12 +1,18 @@
name = "dr-analyst" name = "dr-analyst"
description = "Chapter deep-research agent that writes English chapter drafts and evidence matrices." description = "Chapter deep-research agent that writes English chapter drafts and evidence matrices."
model = "gpt-5.4" model = "zenmux-anthropic/claude-sonnet-4-6"
model_reasoning_effort = "high" model_reasoning_effort = "high"
sandbox_mode = "workspace-write" sandbox_mode = "workspace-write"
developer_instructions = """ developer_instructions = """
You are dr-analyst. You are dr-analyst.
Work in English. Own exactly one assigned chapter. Work in English. Own exactly one assigned chapter.
Load skills: search-strategy, source-quality, length-budget, evidence-table, mckinsey-method, humanizer-cn. Load skills: search-strategy, source-quality, length-budget, evidence-table, mckinsey-method, humanizer-cn.
Use the project search gateway before MCP or generic web search:
- literature/reviews: uv run python scripts/search.py "<query>" --route scholar --num-results 10 --year-low 2023
- patents/FTO: uv run python scripts/search.py "<query>" --route patents --num-results 10
- news/transactions: uv run python scripts/search.py "<query>" --route news --num-results 10 --time-range m
- general gap-fill: uv run python scripts/search.py "<query>" --route general --num-results 10
Record the routes used in the evidence file. Tavily / Exa / Brave MCP are gap-fill only for literature and patent topics.
Write: Write:
- projects/<slug>/phase2/drafts/chXX.md - projects/<slug>/phase2/drafts/chXX.md
- projects/<slug>/phase2/evidence/chXX-evidence.md - projects/<slug>/phase2/evidence/chXX-evidence.md
@@ -1,6 +1,6 @@
name = "dr-chief-editor" name = "dr-chief-editor"
description = "Phase 3 read-only editorial reviewer for whole-report logic, evidence, MECE, and quality." description = "Phase 3 read-only editorial reviewer for whole-report logic, evidence, MECE, and quality."
model = "gpt-5.4" model = "zenmux/google/gemini-3.1-pro-preview"
model_reasoning_effort = "xhigh" model_reasoning_effort = "xhigh"
sandbox_mode = "read-only" sandbox_mode = "read-only"
developer_instructions = """ developer_instructions = """
@@ -1,6 +1,6 @@
name = "dr-editor-in-chief" name = "dr-editor-in-chief"
description = "Phase 4 lead editor for English final assembly and deterministic script orchestration." description = "Phase 4 lead editor for English final assembly and deterministic script orchestration."
model = "gpt-5.4" model = "zenmux-anthropic/claude-opus-4-7"
model_reasoning_effort = "xhigh" model_reasoning_effort = "xhigh"
sandbox_mode = "workspace-write" sandbox_mode = "workspace-write"
developer_instructions = """ developer_instructions = """
@@ -1,6 +1,6 @@
name = "dr-plan" name = "dr-plan"
description = "Deep Research framework planner for Phase 1 interview, initial scan synthesis, and bilingual research framework." description = "Deep Research framework planner for Phase 1 interview, initial scan synthesis, and bilingual research framework."
model = "gpt-5.4" model = "zenmux-anthropic/claude-opus-4-7"
model_reasoning_effort = "high" model_reasoning_effort = "high"
sandbox_mode = "workspace-write" sandbox_mode = "workspace-write"
developer_instructions = """ developer_instructions = """
@@ -1,6 +1,6 @@
name = "dr-pm" name = "dr-pm"
description = "Deep Research project manager for Phase 2 batching, analyst/verifier orchestration, and project status." description = "Deep Research project manager for Phase 2 batching, analyst/verifier orchestration, and project status."
model = "gpt-5.4" model = "zenmux-anthropic/claude-sonnet-4-6"
model_reasoning_effort = "high" model_reasoning_effort = "high"
sandbox_mode = "workspace-write" sandbox_mode = "workspace-write"
developer_instructions = """ developer_instructions = """
@@ -1,6 +1,6 @@
name = "dr-reporter" name = "dr-reporter"
description = "Report production agent for PDF/DOCX rendering and final output checks." description = "Report production agent for PDF/DOCX rendering and final output checks."
model = "gpt-5.4-mini" model = "zenmux-anthropic/claude-sonnet-4-6"
model_reasoning_effort = "medium" model_reasoning_effort = "medium"
sandbox_mode = "workspace-write" sandbox_mode = "workspace-write"
developer_instructions = """ developer_instructions = """
@@ -1,13 +1,18 @@
name = "dr-searcher" name = "dr-searcher"
description = "Lightweight source discovery agent for initial scans and targeted source finding." description = "Lightweight source discovery agent for initial scans and targeted source finding."
model = "gpt-5.4-mini" model = "zenmux-anthropic/claude-haiku-4-5"
model_reasoning_effort = "medium" model_reasoning_effort = "medium"
sandbox_mode = "read-only" sandbox_mode = "read-only"
developer_instructions = """ developer_instructions = """
You are dr-searcher. You are dr-searcher.
Your job is source discovery only. Do not write project files unless explicitly instructed by the parent. Your job is source discovery only. Do not write project files unless explicitly instructed by the parent.
Load skills: search-strategy and source-quality. Load skills: search-strategy and source-quality.
Search English and Chinese keywords, prioritize Tier 1-2 sources, include counter-evidence search terms, and return concise Markdown with URLs/DOIs and source-quality scores. Use the project search gateway before MCP or generic web search:
- literature: uv run python scripts/search.py "<query>" --route scholar --num-results 10 --year-low 2023
- patents: uv run python scripts/search.py "<query>" --route patents --num-results 10
- news: uv run python scripts/search.py "<query>" --route news --num-results 10 --time-range m
- general gap-fill: uv run python scripts/search.py "<query>" --route general --num-results 10
Search English and Chinese keywords, prioritize Tier 1-2 sources, include counter-evidence search terms, report the routes used, and return concise Markdown with URLs/DOIs and source-quality scores.
Do not use Wikipedia as evidence. Do not use Wikipedia as evidence.
Do not fabricate URLs, DOIs, trial IDs, patents, or source ids. Do not fabricate URLs, DOIs, trial IDs, patents, or source ids.
""" """
@@ -1,12 +1,16 @@
name = "dr-verifier" name = "dr-verifier"
description = "Independent counter-evidence and fact-checking agent for completed chapters." description = "Independent counter-evidence and fact-checking agent for completed chapters."
model = "gpt-5.4" model = "zenmux/openai/gpt-5.4"
model_reasoning_effort = "high" model_reasoning_effort = "high"
sandbox_mode = "workspace-write" sandbox_mode = "workspace-write"
developer_instructions = """ developer_instructions = """
You are dr-verifier. You are dr-verifier.
Act as an independent devil's advocate. Do not protect the analyst's conclusion. Act as an independent devil's advocate. Do not protect the analyst's conclusion.
Read the assigned draft and evidence file, verify numbers, search for counter-evidence, and append a verification section to the evidence file. Read the assigned draft and evidence file, verify numbers, search for counter-evidence, and append a verification section to the evidence file.
Use the project search gateway before generic web search:
- literature counter-evidence: uv run python scripts/search.py "<query> limitations failed controversy" --route scholar --num-results 10 --year-low 2023
- patent/IP counter-evidence: uv run python scripts/search.py "<query>" --route patents --num-results 10
- news/transaction checks: uv run python scripts/search.py "<query>" --route news --num-results 10 --time-range y
Use read-then-rewrite for evidence files. Do not edit chapter drafts. Use read-then-rewrite for evidence files. Do not edit chapter drafts.
Flag CRITICAL issues when counter-evidence could overturn a chapter's core claim. Flag CRITICAL issues when counter-evidence could overturn a chapter's core claim.
Use Chinese and English searches for China-market claims. Use Chinese and English searches for China-market claims.
+84
View File
@@ -0,0 +1,84 @@
version: 1
defaults:
profile: medium
script_models:
translate: anthropic/claude-sonnet-4.6
glossary: anthropic/claude-haiku-4.5
polish: anthropic/claude-sonnet-4.6
profiles:
simple:
description: Lower cost exploration profile for quick scoping.
roles:
dr_plan: zenmux/qwen/qwen3.6-plus
dr_pm: zenmux/qwen/qwen3.6-plus
dr_searcher: zenmux-anthropic/claude-haiku-4-5
dr_analyst: zenmux/deepseek/deepseek-v3.2
dr_verifier: zenmux/minimax/minimax-m2.7
dr_chief_editor: zenmux/google/gemini-2.5-pro
dr_editor_in_chief: zenmux-anthropic/claude-sonnet-4-6
dr_reporter: zenmux-anthropic/claude-sonnet-4-6
translate: anthropic/claude-haiku-4.5
glossary: anthropic/claude-haiku-4.5
polish: anthropic/claude-haiku-4.5
medium:
description: Recommended default profile for most production runs.
roles:
dr_plan: zenmux-anthropic/claude-opus-4-7
dr_pm: zenmux-anthropic/claude-sonnet-4-6
dr_searcher: zenmux-anthropic/claude-haiku-4-5
dr_analyst: zenmux-anthropic/claude-sonnet-4-6
dr_verifier: zenmux/openai/gpt-5.4-mini
dr_chief_editor: zenmux/google/gemini-3.1-pro-preview
dr_editor_in_chief: zenmux-anthropic/claude-opus-4-7
dr_reporter: zenmux-anthropic/claude-sonnet-4-6
translate: anthropic/claude-sonnet-4.6
glossary: anthropic/claude-haiku-4.5
polish: anthropic/claude-sonnet-4.6
premium:
description: Highest quality profile for formal client-facing deliverables.
roles:
dr_plan: zenmux-anthropic/claude-opus-4-7
dr_pm: zenmux-anthropic/claude-sonnet-4-6
dr_searcher: zenmux-anthropic/claude-haiku-4-5
dr_analyst: zenmux-anthropic/claude-sonnet-4-6
dr_verifier: zenmux/openai/gpt-5.4
dr_chief_editor: zenmux/google/gemini-3.1-pro-preview
dr_editor_in_chief: zenmux-anthropic/claude-opus-4-7
dr_reporter: zenmux-anthropic/claude-sonnet-4-6
translate: anthropic/claude-sonnet-4.6
glossary: anthropic/claude-haiku-4.5
polish: anthropic/claude-sonnet-4.6
cn_heavy:
description: China-market-heavy profile with stronger CN-side verification.
roles:
dr_plan: zenmux-anthropic/claude-opus-4-7
dr_pm: zenmux-anthropic/claude-sonnet-4-6
dr_searcher: zenmux-anthropic/claude-haiku-4-5
dr_analyst: zenmux-anthropic/claude-sonnet-4-6
dr_verifier: zenmux/qwen/qwen3.6-plus
dr_chief_editor: zenmux/google/gemini-3.1-pro-preview
dr_editor_in_chief: zenmux-anthropic/claude-opus-4-7
dr_reporter: zenmux-anthropic/claude-sonnet-4-6
translate: anthropic/claude-sonnet-4.6
glossary: anthropic/claude-haiku-4.5
polish: anthropic/claude-sonnet-4.6
codex_native:
description: OpenAI-native profile for Codex adapter runs.
roles:
dr_plan: gpt-5.4
dr_pm: gpt-5.4
dr_searcher: gpt-5.4-mini
dr_analyst: gpt-5.4
dr_verifier: gpt-5.4
dr_chief_editor: gpt-5.4
dr_editor_in_chief: gpt-5.4
dr_reporter: gpt-5.4-mini
translate: anthropic/claude-sonnet-4.6
glossary: anthropic/claude-haiku-4.5
polish: anthropic/claude-sonnet-4.6
+17 -4
View File
@@ -85,20 +85,33 @@ Phase 4 推荐走确定性 CLI,而不是让单个 agent 翻译整篇:
```bash ```bash
uv run python scripts/dr.py finalize <slug> \ uv run python scripts/dr.py finalize <slug> \
--translate-workers 4 \ --model-profile medium
--glossary-workers 4 \
--polish-workers 4
``` ```
网络不稳时 等价底层入口(统一 pipeline
```bash
uv run python scripts/phase4_pipeline.py <slug>
```
网络不稳时可显式降并发:
```bash ```bash
uv run python scripts/dr.py finalize <slug> \ uv run python scripts/dr.py finalize <slug> \
--model-profile medium \
--translate-workers 1 \ --translate-workers 1 \
--glossary-workers 3 \ --glossary-workers 3 \
--polish-workers 1 --polish-workers 1
``` ```
术语核查策略可选:
```bash
uv run python scripts/dr.py finalize <slug> --model-profile medium --glossary-mode low-confidence
uv run python scripts/dr.py finalize <slug> --model-profile medium --glossary-mode full
uv run python scripts/dr.py finalize <slug> --model-profile medium --glossary-mode off
```
## Subagent Usage ## Subagent Usage
Codex 的平台限制是:subagents 不会仅因为 `.codex/agents/*.toml` 存在就自动启动,必须由当前主线程明确要求。`dr-run` 已把这个要求写进 PM promptPhase 1 会调度 `dr-plan` / `dr-searcher`Phase 2 会调度 `dr-analyst` / `dr-verifier`Phase 3 会调度 `dr-chief-editor` Codex 的平台限制是:subagents 不会仅因为 `.codex/agents/*.toml` 存在就自动启动,必须由当前主线程明确要求。`dr-run` 已把这个要求写进 PM promptPhase 1 会调度 `dr-plan` / `dr-searcher`Phase 2 会调度 `dr-analyst` / `dr-verifier`Phase 3 会调度 `dr-chief-editor`
+1 -1
View File
@@ -1,6 +1,6 @@
# Model Playbook # Model Playbook
> v0.9 起,本文件作为模型选择攻略本。`.opencode/opencode.json` 仍是 OpenCode 的模型白名单,`configs/model_profiles.yaml` 是跨平台策略参考。 > v0.12 起,本文件作为模型选择攻略本。`.opencode/opencode.json` 仍是 OpenCode 的模型白名单,`configs/models.yaml` 是跨平台策略参考。
## Profiles ## Profiles
+19 -2
View File
@@ -2,6 +2,22 @@
> v0.9 起,本文件作为搜索 API 选择攻略本。搜索返回本身多为发现入口,结论支撑仍以 AGENTS.md 的 Tier 1-2 信源为准。 > v0.9 起,本文件作为搜索 API 选择攻略本。搜索返回本身多为发现入口,结论支撑仍以 AGENTS.md 的 Tier 1-2 信源为准。
## Default Pattern
v0.12 起,默认搜索路径收敛到项目内 Python 网关:
```bash
uv run python scripts/search.py "<query>" --route scholar --num-results 10 --year-low 2023
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
uv run python scripts/ground.py "<query>" --json
```
其中 `scholar / patents / news` 默认走严格模式(Serper 失败不静默降级);需要容错时显式加 `--no-strict-specialized`
MCP server 只作为交互式补漏和特殊工具能力,不作为文献、专利、新闻检索主路径。这样 OpenCode、Codex、Gemini CLI、Claude Code 都能复用同一套路由,减少每个平台单独配置 Tavily/Exa/Brave MCP 的依赖。
## Search Sources ## Search Sources
### Tavily ### Tavily
@@ -27,6 +43,7 @@
- 优点:Google Search / Scholar / News 代理,免费额度较高。 - 优点:Google Search / Scholar / News 代理,免费额度较高。
- 用法:Google Scholar、Google Patents、新闻时效检索。 - 用法:Google Scholar、Google Patents、新闻时效检索。
- 风险:专利是 `site:patents.google.com` 技巧,不等同官方专利库。 - 风险:专利是 `site:patents.google.com` 技巧,不等同官方专利库。
- 项目内调用:`scripts/search.py --route scholar|patents|news`
### PubMed / NCBI ### PubMed / NCBI
@@ -56,11 +73,11 @@
### biomed_literature ### biomed_literature
PubMed / NCBI → ClinicalTrials → FDA/EMA/NMPA → Serper Scholar → Tavily/Exa 补漏。 PubMed / NCBI → ClinicalTrials → FDA/EMA/NMPA → `scripts/search.py --route scholar` → Tavily/Exa 补漏。
### patent_heavy ### patent_heavy
Google Patents/Serper → USPTO/EPO/CNIPA → 公司年报/招股书 → Tavily/Exa 补同族专利线索。 `scripts/search.py --route patents` → USPTO/EPO/CNIPA → 公司年报/招股书 → Tavily/Exa 补同族专利线索。
### china_market ### china_market
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "deep-research" name = "deep-research"
version = "0.3.0" version = "0.12.0"
description = "生物医药 Deep Research 系统 - OpenCode 多 agent 协作研究流水线" description = "生物医药 Deep Research 系统 - OpenCode 多 agent 协作研究流水线"
requires-python = ">=3.10" requires-python = ">=3.10"
readme = "README.md" readme = "README.md"
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Apply a model profile to agent definition files.
Supports:
- OpenCode YAML frontmatter agents in .opencode/agents/*.md
- Codex TOML agents in codex_adapter_templates/codex/agents/*.toml
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from scripts.lib.model_config import (
ModelConfigError,
parse_model_overrides,
resolve_model_profile,
)
OPENCODE_ROLE_TO_FILE = {
"dr_plan": ".opencode/agents/dr-plan.md",
"dr_pm": ".opencode/agents/dr-pm.md",
"dr_searcher": ".opencode/agents/dr-searcher.md",
"dr_analyst": ".opencode/agents/dr-analyst.md",
"dr_verifier": ".opencode/agents/dr-verifier.md",
"dr_chief_editor": ".opencode/agents/dr-chief-editor.md",
"dr_editor_in_chief": ".opencode/agents/dr-editor-in-chief.md",
"dr_reporter": ".opencode/agents/dr-reporter.md",
}
CODEX_ROLE_TO_FILE = {
"dr_plan": "codex_adapter_templates/codex/agents/dr-plan.toml",
"dr_pm": "codex_adapter_templates/codex/agents/dr-pm.toml",
"dr_searcher": "codex_adapter_templates/codex/agents/dr-searcher.toml",
"dr_analyst": "codex_adapter_templates/codex/agents/dr-analyst.toml",
"dr_verifier": "codex_adapter_templates/codex/agents/dr-verifier.toml",
"dr_chief_editor": "codex_adapter_templates/codex/agents/dr-chief-editor.toml",
"dr_editor_in_chief": "codex_adapter_templates/codex/agents/dr-editor-in-chief.toml",
"dr_reporter": "codex_adapter_templates/codex/agents/dr-reporter.toml",
}
def replace_opencode_model(path: Path, model: str) -> bool:
text = path.read_text(encoding="utf-8")
new_text, count = re.subn(r"(?m)^model:\s*.+$", f"model: {model}", text, count=1)
if count == 0:
raise SystemExit(f"failed to locate model field: {path}")
if new_text == text:
return False
path.write_text(new_text, encoding="utf-8")
return True
def replace_codex_model(path: Path, model: str) -> bool:
text = path.read_text(encoding="utf-8")
new_text, count = re.subn(r'(?m)^model\s*=\s*"[^"]+"$', f'model = "{model}"', text, count=1)
if count == 0:
raise SystemExit(f"failed to locate model field: {path}")
if new_text == text:
return False
path.write_text(new_text, encoding="utf-8")
return True
def main() -> int:
parser = argparse.ArgumentParser(description="Apply model profile to agent files")
parser.add_argument("--profile", required=True, help="Profile name from configs/models.yaml")
parser.add_argument("--target", choices=["opencode", "codex", "both"], default="both")
parser.add_argument(
"--model-override",
action="append",
default=[],
metavar="ROLE=MODEL",
help="Override one role model, repeatable",
)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
try:
resolved = resolve_model_profile(
profile=args.profile,
overrides=parse_model_overrides(args.model_override),
)
except ModelConfigError as exc:
raise SystemExit(f"model profile resolution failed: {exc}") from exc
roles = resolved["roles"]
changed: list[str] = []
def apply_map(mapping: dict[str, str], mode: str) -> None:
for role, rel_path in mapping.items():
model = roles.get(role)
if not model:
continue
file_path = REPO_ROOT / rel_path
if not file_path.exists():
continue
if args.dry_run:
changed.append(f"{mode}:{rel_path} -> {model}")
continue
did_change = replace_opencode_model(file_path, model) if mode == "opencode" else replace_codex_model(file_path, model)
if did_change:
changed.append(f"{mode}:{rel_path} -> {model}")
if args.target in ("opencode", "both"):
apply_map(OPENCODE_ROLE_TO_FILE, "opencode")
if args.target in ("codex", "both"):
apply_map(CODEX_ROLE_TO_FILE, "codex")
print(f"Profile applied: {resolved['profile']}")
print(f"Target: {args.target}")
if changed:
print("Updated:")
for item in changed:
print(f" - {item}")
else:
print("No files changed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+259 -2
View File
@@ -2,7 +2,7 @@
"""Phase 4 成稿阶段:统一入口。 """Phase 4 成稿阶段:统一入口。
final_zh_polished.md或指定的 Markdown+ manifest.json 生成 final_zh_polished.md或指定的 Markdown+ manifest.json 生成
- <title>.pdf ReportLab 出中文 PDF - <title>.pdf PDFReportLab Quarto/xelatex
- <title>.docx Pandoc DOCX - <title>.docx Pandoc DOCX
- <title>-en.pdf 如果存在 final_en.md 也一并出英文版可选 - <title>-en.pdf 如果存在 final_en.md 也一并出英文版可选
@@ -11,6 +11,9 @@
用法 用法
uv run python scripts/build_report.py <project_slug> uv run python scripts/build_report.py <project_slug>
# 使用 Quarto/xelatex 引擎(推荐,更好的中文+宽表支持):
uv run python scripts/build_report.py <project_slug> --engine quarto
# 只生成 PDF # 只生成 PDF
uv run python scripts/build_report.py <project_slug> --no-docx uv run python scripts/build_report.py <project_slug> --no-docx
@@ -20,6 +23,8 @@
环境依赖 环境依赖
- reportlab, pypandoc, 思源字体bash .opencode/templates/fonts/download-fonts.sh - reportlab, pypandoc, 思源字体bash .opencode/templates/fonts/download-fonts.sh
- pandoc 可执行文件在 PATH - pandoc 可执行文件在 PATH
- Quarto可选--engine quarto 时需要https://quarto.org/docs/get-started/
安装后运行quarto install tinytex
""" """
from __future__ import annotations from __future__ import annotations
@@ -30,6 +35,7 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import textwrap
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -125,6 +131,248 @@ def build_pdf(
raise SystemExit(f"PDF 生成失败,返回码 {result.returncode}") raise SystemExit(f"PDF 生成失败,返回码 {result.returncode}")
def _detect_wide_tables(md_text: str, min_cols: int = 8) -> list[tuple[int, int]]:
"""返回所有列数 >= min_cols 的 Markdown 表格的 (start_line, end_line) 区间(0-based)。"""
lines = md_text.split("\n")
ranges = []
i = 0
while i < len(lines):
line = lines[i]
if line.startswith("|") and line.count("|") - 1 >= min_cols:
# Possible table header — next line should be separator
if i + 1 < len(lines) and re.match(r"^\|[\s\-:|]+\|", lines[i + 1]):
start = i
j = i + 2
while j < len(lines) and lines[j].strip().startswith("|"):
j += 1
ranges.append((start, j))
i = j
continue
i += 1
return ranges
def prepare_qmd(
md_path: Path,
manifest: dict,
output_qmd: Path,
fonts_dir: Path,
sources_path: Path | None,
wide_table_cols: int = 8,
) -> None:
"""将普通 Markdown 转换为带 Quarto front matter 的 .qmd 文件。
主要处理
1. 插入 YAML front matter标题字体页面设置等
2. {.landscape} div 包裹列数 >= wide_table_cols 的宽表
3. [TOC will be generated...] 占位符替换为真实 TOC 指令
4. [REFERENCES will be filled...] 占位符替换为参考文献内容
"""
title = manifest.get("report_title", "报告")
subtitle = manifest.get("report_subtitle", "")
date = manifest.get("date", "")
# 决定字体名称:思源宋体 CN 作正文,思源黑体 CN 作标题
main_font = "Source Han Serif CN"
sans_font = "Source Han Sans CN"
# Write LaTeX header file for CJK font setup.
# Using a separate .tex file avoids YAML escape issues with backslashes.
mf = main_font # "Source Han Serif CN"
sf = sans_font # "Source Han Sans CN"
front_matter = textwrap.dedent(f"""\
---
title: "{title}"
subtitle: "{subtitle}"
date: "{date}"
lang: zh
format:
pdf:
pdf-engine: xelatex
CJKmainfont: "{mf}"
mainfont: "{mf}"
mainfontoptions:
- BoldFont={mf}
- ItalicFont={mf}
- BoldItalicFont={mf}
CJKoptions:
- BoldFont={mf}
- ItalicFont={mf}
- BoldItalicFont={mf}
sansfont: "{sf}"
sansfontoptions:
- BoldFont={sf}
- ItalicFont={sf}
- BoldItalicFont={sf}
monofont: "Liberation Mono"
papersize: a4
documentclass: scrartcl
classoption:
- DIV=11
- headinclude
toc: true
toc-depth: 2
toc-title: "目录"
number-sections: false
colorlinks: true
linkcolor: NavyBlue
urlcolor: NavyBlue
geometry:
- top=25mm
- bottom=25mm
- left=25mm
- right=20mm
pdf-engine-opts:
- "-stack-size=32768"
- "-extra-mem-top=2000000"
include-in-header:
- file: _preamble.tex
---
""")
md_text = md_path.read_text(encoding="utf-8")
# Remove existing YAML front matter if any (between first two ---)
if md_text.startswith("---"):
end = md_text.find("\n---", 3)
if end != -1:
md_text = md_text[end + 4:].lstrip("\n")
# Replace TOC placeholder
md_text = re.sub(
r"\[TOC will be generated.*?\]",
"", # Quarto handles TOC via front matter
md_text,
)
# Replace REFERENCES placeholder with actual references from sources.jsonl
ref_block = _build_references_block(sources_path, md_text)
md_text = re.sub(
r"\[REFERENCES will be filled.*?\]",
ref_block,
md_text,
)
# Wrap wide tables in {.landscape} divs
lines = md_text.split("\n")
wide_ranges = _detect_wide_tables(md_text, min_cols=wide_table_cols)
if wide_ranges:
# Insert landscape wrappers from bottom up (so line numbers stay valid)
for start, end in reversed(wide_ranges):
lines.insert(end, "\n:::")
lines.insert(start, "::: {.landscape}\n")
md_text = "\n".join(lines)
# Write LaTeX preamble file (table + landscape support)
preamble_tex = output_qmd.parent / "_preamble.tex"
preamble_tex.write_text(
"\\usepackage{longtable}\n"
"\\usepackage{booktabs}\n"
"\\usepackage{array}\n"
# Use lscape instead of pdflscape to avoid \LS@makefcolumn recursion
# which exhausts TeX param_size on large longtables.
# lscape rotates content without changing page media box (reader must rotate).
"\\usepackage{lscape}\n"
"\\setlength{\\LTpre}{6pt}\n"
"\\setlength{\\LTpost}{6pt}\n"
"\\setlength{\\tabcolsep}{3pt}\n",
encoding="utf-8",
)
output_qmd.write_text(front_matter + md_text, encoding="utf-8")
print(f" .qmd prepared: {output_qmd.name} ({len(wide_ranges)} landscape table(s))")
def _build_references_block(sources_path: Path | None, md_text: str) -> str:
"""从 sources.jsonl 生成参考文献列表,只包含在正文中实际引用的信源。"""
if not sources_path or not sources_path.exists():
return "(参考文献列表:sources.jsonl 未找到)"
# Find cited src_ids
cited = set(re.findall(r"\[src_([a-z0-9_]+)\]", md_text))
if not cited:
return ""
sources: dict[str, dict] = {}
with open(sources_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
sid = obj.get("id", "")
key = sid.replace("src_", "")
if key in cited:
sources[sid] = obj
except json.JSONDecodeError:
pass
if not sources:
return ""
lines = ["## 参考文献\n"]
for sid in sorted(sources.keys()):
s = sources[sid]
authors = ", ".join(s.get("authors", [])) if s.get("authors") else ""
year = s.get("year", "")
title = s.get("title", sid)
venue = s.get("venue", "")
url = s.get("url", "")
entry = f"- **[{sid}]** "
if authors:
entry += f"{authors}. "
if year:
entry += f"({year}). "
entry += f"*{title}*"
if venue:
entry += f". {venue}"
if url:
entry += f". <{url}>"
lines.append(entry)
return "\n".join(lines)
def build_pdf_quarto(
md_path: Path,
manifest: dict,
output_pdf: Path,
fonts_dir: Path,
sources_path: Path | None,
) -> None:
"""使用 Quarto + xelatex 生成 PDF。"""
if not shutil.which("quarto"):
raise SystemExit(
"quarto 命令未找到。请先安装 Quartohttps://quarto.org/docs/get-started/\n"
"安装后运行:quarto install tinytex"
)
# Prepare .qmd in the same dir as output_pdf
qmd_path = output_pdf.parent / (output_pdf.stem + ".qmd")
prepare_qmd(md_path, manifest, qmd_path, fonts_dir, sources_path)
print(f"\n→ 生成 PDFQuarto/xelatex):{output_pdf.name}")
cmd = [
"quarto", "render", str(qmd_path),
"--to", "pdf",
"--output", output_pdf.name,
]
result = subprocess.run(cmd, cwd=str(output_pdf.parent), check=False)
if result.returncode != 0:
raise SystemExit(f"Quarto PDF 生成失败,返回码 {result.returncode}")
# Clean up auxiliary files Quarto leaves behind
for ext in (".tex", ".log", ".aux", ".toc", ".out", "-files"):
candidate = output_pdf.parent / (output_pdf.stem + ext)
if candidate.exists():
candidate.unlink(missing_ok=True)
def build_docx(md_path: Path, output_docx: Path, title: str) -> None: def build_docx(md_path: Path, output_docx: Path, title: str) -> None:
"""用 pandoc 生成 DOCX。""" """用 pandoc 生成 DOCX。"""
if not shutil.which("pandoc"): if not shutil.which("pandoc"):
@@ -178,6 +426,12 @@ def main() -> int:
) )
parser.add_argument("--no-docx", action="store_true", help="跳过 DOCX 生成") parser.add_argument("--no-docx", action="store_true", help="跳过 DOCX 生成")
parser.add_argument("--no-pdf", action="store_true", help="跳过 PDF 生成") parser.add_argument("--no-pdf", action="store_true", help="跳过 PDF 生成")
parser.add_argument(
"--engine",
choices=["reportlab", "quarto"],
default="reportlab",
help="PDF 渲染引擎:reportlab(默认,Python 原生)或 quartoxelatex,更好的中文+宽表支持)",
)
parser.add_argument( parser.add_argument(
"--basename", "--basename",
default=None, default=None,
@@ -222,7 +476,10 @@ def main() -> int:
print(f"Sources {sources_path if sources_path else '(缺失)'}") print(f"Sources {sources_path if sources_path else '(缺失)'}")
if not args.no_pdf: if not args.no_pdf:
build_pdf(md_path, manifest_path, pdf_path, fonts_dir, sources_path) if args.engine == "quarto":
build_pdf_quarto(md_path, manifest, pdf_path, fonts_dir, sources_path)
else:
build_pdf(md_path, manifest_path, pdf_path, fonts_dir, sources_path)
if not args.no_docx: if not args.no_docx:
build_docx(md_path, docx_path, title) build_docx(md_path, docx_path, title)
+126 -40
View File
@@ -16,6 +16,17 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from scripts.lib.model_config import (
ModelConfigError,
list_model_profiles,
parse_model_overrides,
resolve_model_profile,
)
PROJECTS_DIR = REPO_ROOT / "projects" PROJECTS_DIR = REPO_ROOT / "projects"
CODEX_COMMANDS_DIR = REPO_ROOT / ".codex" / "commands" CODEX_COMMANDS_DIR = REPO_ROOT / ".codex" / "commands"
CODEX_COMMAND_TEMPLATES_DIR = REPO_ROOT / "codex_adapter_templates" / "codex" / "commands" CODEX_COMMAND_TEMPLATES_DIR = REPO_ROOT / "codex_adapter_templates" / "codex" / "commands"
@@ -152,48 +163,84 @@ def cmd_glossary(args: argparse.Namespace) -> int:
def cmd_finalize(args: argparse.Namespace) -> int: def cmd_finalize(args: argparse.Namespace) -> int:
project_root = resolve_project(args.project) project_root = resolve_project(args.project)
steps = [ manifest = load_manifest(project_root)
[ effective_profile = args.model_profile or manifest.get("model_profile")
sys.executable, try:
str(REPO_ROOT / "scripts" / "translate.py"), resolved = resolve_model_profile(
str(project_root), profile=effective_profile,
"--workers", overrides=parse_model_overrides(args.model_override),
str(args.translate_workers), )
], except ModelConfigError as exc:
[ raise SystemExit(f"model profile resolution failed: {exc}") from exc
sys.executable, roles = resolved["roles"]
str(REPO_ROOT / "scripts" / "build_glossary.py"),
str(project_root), cmd = [
"--workers", sys.executable,
str(args.glossary_workers), str(REPO_ROOT / "scripts" / "phase4_pipeline.py"),
], str(project_root),
[ "--translate-workers",
sys.executable, str(args.translate_workers),
str(REPO_ROOT / "scripts" / "apply_glossary.py"), "--glossary-workers",
str(project_root), str(args.glossary_workers),
"--input", "--polish-workers",
"phase4/final_zh.md", str(args.polish_workers),
], "--translate-model",
[ roles.get("translate", "anthropic/claude-sonnet-4.6"),
sys.executable, "--glossary-model",
str(REPO_ROOT / "scripts" / "polish.py"), roles.get("glossary", "anthropic/claude-haiku-4.5"),
str(project_root), "--polish-model",
"--workers", roles.get("polish", "anthropic/claude-sonnet-4.6"),
str(args.polish_workers), "--glossary-mode",
], args.glossary_mode,
[
sys.executable,
str(REPO_ROOT / "scripts" / "build_report.py"),
str(project_root),
],
] ]
for step in steps: if args.dry_run:
rc = run_cmd(step, dry_run=args.dry_run) cmd.append("--dry-run")
if rc != 0: return run_cmd(cmd, dry_run=False)
return rc
def cmd_models(args: argparse.Namespace) -> int:
if args.list:
for name in list_model_profiles():
print(name)
return 0
try:
resolved = resolve_model_profile(
profile=args.profile,
overrides=parse_model_overrides(args.model_override),
)
except ModelConfigError as exc:
raise SystemExit(f"model profile resolution failed: {exc}") from exc
if args.json:
print(json.dumps(resolved, ensure_ascii=False, indent=2))
return 0
print(f"Profile: {resolved['profile']}")
if resolved["description"]:
print(f"Description: {resolved['description']}")
print("Roles:")
for role in sorted(resolved["roles"]):
print(f" {role}: {resolved['roles'][role]}")
return 0 return 0
def cmd_apply_models(args: argparse.Namespace) -> int:
cmd = [
sys.executable,
str(REPO_ROOT / "scripts" / "apply_model_profile.py"),
"--profile",
args.profile,
"--target",
args.target,
]
for item in args.model_override:
cmd += ["--model-override", item]
if args.dry_run:
cmd.append("--dry-run")
return run_cmd(cmd, dry_run=False)
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Deep Research platform-neutral CLI") parser = argparse.ArgumentParser(description="Deep Research platform-neutral CLI")
sub = parser.add_subparsers(dest="cmd", required=True) sub = parser.add_subparsers(dest="cmd", required=True)
@@ -219,12 +266,51 @@ def build_parser() -> argparse.ArgumentParser:
finalize = sub.add_parser("finalize", help="Run Phase 4 deterministic pipeline") finalize = sub.add_parser("finalize", help="Run Phase 4 deterministic pipeline")
finalize.add_argument("project", help="Project slug or path") finalize.add_argument("project", help="Project slug or path")
finalize.add_argument("--translate-workers", type=int, default=4) finalize.add_argument("--translate-workers", type=int, default=0)
finalize.add_argument("--glossary-workers", type=int, default=4) finalize.add_argument("--glossary-workers", type=int, default=4)
finalize.add_argument("--polish-workers", type=int, default=4) finalize.add_argument("--polish-workers", type=int, default=0)
finalize.add_argument(
"--glossary-mode",
choices=["off", "low-confidence", "full"],
default="low-confidence",
)
finalize.add_argument("--model-profile", help="Model profile name from configs/models.yaml")
finalize.add_argument(
"--model-override",
action="append",
default=[],
metavar="ROLE=MODEL",
help="Override one role model, repeatable",
)
finalize.add_argument("--dry-run", action="store_true") finalize.add_argument("--dry-run", action="store_true")
finalize.set_defaults(func=cmd_finalize) finalize.set_defaults(func=cmd_finalize)
models = sub.add_parser("models", help="Resolve and print model profile")
models.add_argument("--profile", help="Profile name from configs/models.yaml")
models.add_argument("--list", action="store_true", help="List available profiles")
models.add_argument(
"--model-override",
action="append",
default=[],
metavar="ROLE=MODEL",
help="Override one role model, repeatable",
)
models.add_argument("--json", action="store_true", help="Emit JSON")
models.set_defaults(func=cmd_models)
apply_models = sub.add_parser("apply-models", help="Apply profile to agent files")
apply_models.add_argument("--profile", required=True, help="Profile name from configs/models.yaml")
apply_models.add_argument("--target", choices=["opencode", "codex", "both"], default="both")
apply_models.add_argument(
"--model-override",
action="append",
default=[],
metavar="ROLE=MODEL",
help="Override one role model, repeatable",
)
apply_models.add_argument("--dry-run", action="store_true")
apply_models.set_defaults(func=cmd_apply_models)
return parser return parser
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Native web grounding wrapper via ZenMux chat completions.
Use this when you need reproducible, model-native web search (grounding) and
machine-readable citations.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from scripts.lib.zenmux_client import ZenMuxClient, load_secrets
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Grounded web query via ZenMux")
parser.add_argument("query", help="Question or search prompt")
parser.add_argument("--model", default="google/gemini-3.1-flash-lite-preview")
parser.add_argument("--max-tokens", type=int, default=2400)
parser.add_argument("--temperature", type=float, default=0.2)
parser.add_argument("--json", action="store_true", help="Emit JSON envelope")
parser.add_argument("--log-file", help="Optional JSONL call log path")
parser.add_argument("--system", default=(
"You are a research assistant. Use web grounding when helpful. "
"Return concise facts with explicit source-backed statements."
))
return parser
def main() -> int:
args = build_parser().parse_args()
load_secrets()
log_file = Path(args.log_file) if args.log_file else None
with ZenMuxClient(log_file=log_file) as client:
result = client.chat_complete_with_meta(
model=args.model,
system=args.system,
user=args.query,
temperature=args.temperature,
max_tokens=args.max_tokens,
web_search=True,
web_search_options={},
tag="ground",
)
if args.json:
payload = {
"query": args.query,
"model": args.model,
"content": result["content"],
"citations": result["citations"],
"usage": result["usage"],
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
print(result["content"])
if result["citations"]:
print("\nCitations:")
for idx, url in enumerate(result["citations"], start=1):
print(f"{idx}. {url}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+81
View File
@@ -0,0 +1,81 @@
"""Model profile loading and resolution utilities."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_MODEL_CONFIG = REPO_ROOT / "configs" / "models.yaml"
LEGACY_MODEL_CONFIG = REPO_ROOT / "configs" / "model_profiles.yaml"
class ModelConfigError(RuntimeError):
pass
def load_model_config(path: Path | None = None) -> dict[str, Any]:
cfg_path = path or DEFAULT_MODEL_CONFIG
if not cfg_path.exists() and LEGACY_MODEL_CONFIG.exists():
cfg_path = LEGACY_MODEL_CONFIG
if not cfg_path.exists():
raise ModelConfigError(f"model config not found: {cfg_path}")
try:
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
except Exception as exc:
raise ModelConfigError(f"invalid YAML in {cfg_path}: {exc}") from exc
if not isinstance(data, dict):
raise ModelConfigError(f"invalid model config shape in {cfg_path}")
return data
def resolve_model_profile(
*,
profile: str | None = None,
overrides: dict[str, str] | None = None,
path: Path | None = None,
) -> dict[str, Any]:
cfg = load_model_config(path)
profiles = cfg.get("profiles") or {}
defaults = cfg.get("defaults") or {}
selected = profile or defaults.get("profile")
if not selected:
raise ModelConfigError("no model profile provided and no defaults.profile set")
if selected not in profiles:
raise ModelConfigError(f"unknown model profile: {selected}")
roles = dict((profiles[selected] or {}).get("roles") or {})
if defaults.get("script_models"):
for role, model in (defaults.get("script_models") or {}).items():
roles.setdefault(role, model)
for role, model in (overrides or {}).items():
roles[role] = model
return {
"profile": selected,
"description": (profiles[selected] or {}).get("description", ""),
"roles": roles,
}
def list_model_profiles(path: Path | None = None) -> list[str]:
cfg = load_model_config(path)
profiles = cfg.get("profiles") or {}
return sorted(profiles.keys())
def parse_model_overrides(items: list[str] | None) -> dict[str, str]:
out: dict[str, str] = {}
for item in items or []:
if "=" not in item:
raise ModelConfigError(f"invalid override '{item}', expected role=model")
role, model = item.split("=", 1)
role = role.strip()
model = model.strip()
if not role or not model:
raise ModelConfigError(f"invalid override '{item}', expected role=model")
out[role] = model
return out
+24 -14
View File
@@ -1,11 +1,12 @@
"""通用搜索客户端(Exa 优先,Tavily fallback)。 """通用搜索客户端(Serper / Exa / Tavily 路由)。
build_glossary.py 这类术语核查场景服务 build_glossary.py 这类术语核查场景服务
关键设计 关键设计
- `trust_env=False` 绕开系统 socks 代理Clash on macOS socks5 httpx TLS EOF - `trust_env=False` 绕开系统 socks 代理Clash on macOS socks5 httpx TLS EOF
- Exa 优先LinkedIn / 官网 / 百度百科返回质量最高 - 专利 / Scholar / News 优先 Serper保证 Google Patents / Google Scholar 路径被真正调用
- 遇到配额问题自动降级到 Tavily 或返回 empty - 通用网页 Exa 优先Tavily fallback
- 遇到配额问题自动降级或返回 empty
- 不做深度 crawl只要摘要 - 不做深度 crawl只要摘要
""" """
@@ -125,10 +126,11 @@ class SearchClient:
所有客户端都延迟导入 serper_client避免没装 SERPAPI_KEY import 所有客户端都延迟导入 serper_client避免没装 SERPAPI_KEY import
""" """
def __init__(self) -> None: def __init__(self, *, strict_specialized: bool = True) -> None:
self._exa: ExaClient | None = None self._exa: ExaClient | None = None
self._tavily: TavilyClient | None = None self._tavily: TavilyClient | None = None
self._serper = None # 惰性实例化 self._serper = None # 惰性实例化
self.strict_specialized = strict_specialized
try: try:
self._exa = ExaClient() self._exa = ExaClient()
except SearchError: except SearchError:
@@ -137,10 +139,9 @@ class SearchClient:
self._tavily = TavilyClient() self._tavily = TavilyClient()
except SearchError: except SearchError:
pass pass
if not (self._exa or self._tavily): self._has_serper_key = bool(os.environ.get("SERPER_API_KEY") or os.environ.get("SERPAPI_KEY"))
raise SearchError( if not (self._exa or self._tavily or self._has_serper_key):
"neither EXA_API_KEY nor TAVILY_API_KEY available" raise SearchError("no search API key available: set SERPER_API_KEY, SERPAPI_KEY, EXA_API_KEY, or TAVILY_API_KEY")
)
def _get_serper(self): def _get_serper(self):
"""惰性创建 SerperClient。没 key 时返回 None。""" """惰性创建 SerperClient。没 key 时返回 None。"""
@@ -190,9 +191,12 @@ class SearchClient:
try: try:
hits = serper.patents(query, num_results=num_results) hits = serper.patents(query, num_results=num_results)
return [SearchHit(h.title, h.url, h.snippet) for h in hits] return [SearchHit(h.title, h.url, h.snippet) for h in hits]
except Exception: except Exception as exc:
pass if self.strict_specialized:
raise SearchError(f"serper patents failed: {exc}") from exc
# 降级:通用搜索加 site 限定 # 降级:通用搜索加 site 限定
if self.strict_specialized:
raise SearchError("serper unavailable for patents route; refusing silent fallback")
return self.search(f"site:patents.google.com {query}", num_results=num_results) return self.search(f"site:patents.google.com {query}", num_results=num_results)
def scholar( def scholar(
@@ -215,8 +219,11 @@ class SearchClient:
) )
for h in hits for h in hits
] ]
except Exception: except Exception as exc:
pass if self.strict_specialized:
raise SearchError(f"serper scholar failed: {exc}") from exc
if self.strict_specialized:
raise SearchError("serper unavailable for scholar route; refusing silent fallback")
return self.search(query, num_results=num_results) return self.search(query, num_results=num_results)
def news( def news(
@@ -239,8 +246,11 @@ class SearchClient:
) )
for h in hits for h in hits
] ]
except Exception: except Exception as exc:
pass if self.strict_specialized:
raise SearchError(f"serper news failed: {exc}") from exc
if self.strict_specialized:
raise SearchError("serper unavailable for news route; refusing silent fallback")
return self.search(query, num_results=num_results) return self.search(query, num_results=num_results)
+114
View File
@@ -141,6 +141,8 @@ class ZenMuxClient:
temperature: float = 0.3, temperature: float = 0.3,
max_tokens: int = 16000, max_tokens: int = 16000,
extra_messages: list[dict[str, str]] | None = None, extra_messages: list[dict[str, str]] | None = None,
web_search: bool = False,
web_search_options: dict[str, Any] | None = None,
tag: str = "", tag: str = "",
) -> str: ) -> str:
"""一次非流式对话补全。 """一次非流式对话补全。
@@ -167,6 +169,8 @@ class ZenMuxClient:
"temperature": temperature, "temperature": temperature,
"max_tokens": max_tokens, "max_tokens": max_tokens,
} }
if web_search:
body["web_search_options"] = web_search_options or {}
headers = { headers = {
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -233,6 +237,116 @@ class ZenMuxClient:
self.usage.failed_calls += 1 self.usage.failed_calls += 1
raise ZenMuxError(f"max retries exhausted. last error: {last_error}") raise ZenMuxError(f"max retries exhausted. last error: {last_error}")
def chat_complete_with_meta(
self,
model: str,
system: str,
user: str,
*,
temperature: float = 0.3,
max_tokens: int = 16000,
extra_messages: list[dict[str, str]] | None = None,
web_search: bool = False,
web_search_options: dict[str, Any] | None = None,
tag: str = "",
) -> dict[str, Any]:
"""Return content plus metadata from one completion call."""
messages: list[dict[str, str]] = [{"role": "system", "content": system}]
if extra_messages:
messages.extend(extra_messages)
messages.append({"role": "user", "content": user})
body: dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
if web_search:
body["web_search_options"] = web_search_options or {}
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
url = f"{self.base_url}/chat/completions"
last_error = ""
for attempt in range(MAX_RETRIES):
t0 = time.time()
try:
resp = self._client.post(url, json=body, headers=headers)
elapsed = time.time() - t0
except httpx.RequestError as e:
last_error = f"network: {e}"
elapsed = time.time() - t0
self._log({"tag": tag, "attempt": attempt, "elapsed": elapsed, "error": last_error})
time.sleep(2 ** attempt)
continue
if resp.status_code != 200:
retryable = resp.status_code in RETRYABLE_STATUSES
last_error = f"HTTP {resp.status_code}: {resp.text[:500]}"
self._log({
"tag": tag,
"attempt": attempt,
"elapsed": round(elapsed, 2),
"status": resp.status_code,
"error": last_error,
"retryable": retryable,
})
if not retryable:
self.usage.failed_calls += 1
raise ZenMuxError(last_error)
sleep_for = min(60, (2 ** attempt) + (attempt * 0.5))
time.sleep(sleep_for)
continue
try:
data = resp.json()
except Exception as e:
raise ZenMuxError(f"invalid JSON from zenmux: {e}; body={resp.text[:500]}")
usage = data.get("usage", {}) or {}
with self._usage_lock:
self.usage.add(model, usage)
message = ((data.get("choices") or [{}])[0].get("message") or {})
content = message.get("content") or ""
annotations = message.get("annotations") or []
urls: list[str] = []
for ann in annotations:
if not isinstance(ann, dict):
continue
citation = ann.get("url_citation") or {}
url_item = citation.get("url")
if url_item:
urls.append(url_item)
self._log({
"tag": tag,
"model": model,
"attempt": attempt,
"elapsed": round(elapsed, 2),
"usage": usage,
"out_chars": len(content),
"status": 200,
"web_search": web_search,
"citations": len(urls),
})
if not content.strip():
last_error = "empty content"
time.sleep(2 ** attempt)
continue
return {
"content": content,
"usage": usage,
"citations": urls,
"raw": data,
}
self.usage.failed_calls += 1
raise ZenMuxError(f"max retries exhausted. last error: {last_error}")
def load_secrets(env_path: Path | None = None) -> None: def load_secrets(env_path: Path | None = None) -> None:
"""从 secrets.env 把 key 塞到 os.environ,便于脚本直接运行。 """从 secrets.env 把 key 塞到 os.environ,便于脚本直接运行。
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Phase 4 replacement pipeline orchestrator.
Default flow:
1) translate.py
2) optional glossary verification (low-confidence/full/off)
3) apply_glossary.py
4) polish.py
5) build_report.py
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from scripts.lib.markdown_chunker import split_by_headers
def resolve_project(arg: str) -> Path:
p = Path(arg)
if p.is_dir():
return p.resolve()
cand = REPO_ROOT / "projects" / arg
if cand.is_dir():
return cand.resolve()
raise SystemExit(f"project not found: {arg}")
def run_step(cmd: list[str], *, dry_run: bool) -> int:
print("$ " + " ".join(cmd))
if dry_run:
return 0
return subprocess.run(cmd, cwd=REPO_ROOT, check=False).returncode
def infer_workers(source_file: Path, fallback: int, cap: int = 8) -> int:
if not source_file.exists():
return fallback
text = source_file.read_text(encoding="utf-8")
blocks = split_by_headers(text, max_level=2)
if not blocks:
return fallback
cpu_cap = max(2, min(cap, (os.cpu_count() or 4)))
suggested = max(2, min(cpu_cap, (len(blocks) + 5) // 6))
return max(1, suggested if fallback <= 0 else min(max(fallback, 1), cpu_cap))
def low_confidence_terms(glossary_path: Path) -> list[str]:
if not glossary_path.exists():
return []
try:
glossary = json.loads(glossary_path.read_text(encoding="utf-8"))
except Exception:
return []
out: list[str] = []
for term, entry in glossary.items():
if not isinstance(entry, dict):
out.append(term)
continue
conf = str(entry.get("confidence", "")).lower()
verified = bool(entry.get("verified_at"))
if conf != "high" or not verified:
out.append(term)
return sorted(set(out))
def main() -> int:
parser = argparse.ArgumentParser(description="Phase 4 replacement pipeline")
parser.add_argument("project", help="Project slug or full path")
parser.add_argument("--translate-model", default="anthropic/claude-sonnet-4.6")
parser.add_argument("--glossary-model", default="anthropic/claude-haiku-4.5")
parser.add_argument("--polish-model", default="anthropic/claude-sonnet-4.6")
parser.add_argument("--translate-workers", type=int, default=0, help="0 means auto")
parser.add_argument("--glossary-workers", type=int, default=4)
parser.add_argument("--polish-workers", type=int, default=0, help="0 means auto")
parser.add_argument(
"--glossary-mode",
choices=["off", "low-confidence", "full"],
default="low-confidence",
help="off: skip, low-confidence: verify only low-confidence terms, full: verify all",
)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
project_root = resolve_project(args.project)
phase4 = project_root / "phase4"
src_en = phase4 / "final_en.md"
if not src_en.exists():
raise SystemExit(f"missing source: {src_en}")
tw = infer_workers(src_en, args.translate_workers)
zh = phase4 / "final_zh.md"
pw = infer_workers(zh if zh.exists() else src_en, args.polish_workers)
print(f"Project: {project_root.name}")
print(f"Translate workers: {tw} | Polish workers: {pw}")
print(f"Glossary mode: {args.glossary_mode}")
print()
t0 = time.time()
steps: list[list[str]] = [
[
sys.executable,
str(REPO_ROOT / "scripts" / "translate.py"),
str(project_root),
"--workers",
str(tw),
"--model",
args.translate_model,
]
]
if args.glossary_mode != "off":
gcmd = [
sys.executable,
str(REPO_ROOT / "scripts" / "build_glossary.py"),
str(project_root),
"--workers",
str(args.glossary_workers),
"--model",
args.glossary_model,
]
if args.glossary_mode == "low-confidence":
terms = low_confidence_terms(phase4 / "glossary.json")
if terms:
if len(terms) > 80:
print(f"[info] low-confidence terms={len(terms)} is large; fallback to full glossary verify")
else:
gcmd += ["--only", ",".join(terms)]
else:
print("[info] no low-confidence glossary terms found; skipping glossary step")
gcmd = []
if gcmd:
steps.append(gcmd)
steps.extend(
[
[
sys.executable,
str(REPO_ROOT / "scripts" / "apply_glossary.py"),
str(project_root),
"--input",
"phase4/final_zh.md",
],
[
sys.executable,
str(REPO_ROOT / "scripts" / "polish.py"),
str(project_root),
"--workers",
str(pw),
"--model",
args.polish_model,
],
[
sys.executable,
str(REPO_ROOT / "scripts" / "build_report.py"),
str(project_root),
],
]
)
for cmd in steps:
rc = run_step(cmd, dry_run=args.dry_run)
if rc != 0:
return rc
print(f"\nPhase 4 pipeline done in {time.time() - t0:.1f}s")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Unified search gateway for Deep Research agents.
This script is the stable project-owned entrypoint that agents should call
instead of vendor MCP tools. MCP search remains optional, while this gateway
keeps routing behavior reproducible across OpenCode, Codex, and future
adapters.
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import asdict
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from scripts.lib.search_client import SearchClient, SearchError, SearchHit
from scripts.lib.zenmux_client import load_secrets
ROUTE_HELP = {
"general": "Exa -> Tavily generic web discovery",
"scholar": "Serper Scholar -> generic fallback",
"patents": "Serper Google Patents -> site:patents.google.com fallback",
"news": "Serper News -> generic fallback",
}
PROFILE_ROUTES = {
"biomed_literature": ["scholar", "general"],
"patent_heavy": ["patents", "general"],
"china_market": ["news", "general"],
"investment": ["news", "general"],
}
PROFILE_QUERY_PREFIX = {
"china_market": "(China OR Chinese OR 中国 OR 国内)",
}
def search_route(client: SearchClient, route: str, query: str, args: argparse.Namespace) -> list[SearchHit]:
if route == "general":
return client.search(query, num_results=args.num_results)
if route == "scholar":
return client.scholar(query, num_results=args.num_results, year_low=args.year_low)
if route == "patents":
return client.patents(query, num_results=args.num_results)
if route == "news":
return client.news(query, num_results=args.num_results, time_range=args.time_range)
raise SystemExit(f"unknown route: {route}")
def emit_markdown(route_hits: list[tuple[str, list[SearchHit]]], query: str) -> None:
print(f"# Search Results: {query}")
for route, hits in route_hits:
print()
print(f"## Route: {route} ({ROUTE_HELP[route]})")
if not hits:
print("No results.")
continue
for i, hit in enumerate(hits, start=1):
print(f"{i}. {hit.title or '(untitled)'}")
print(f" - URL: {hit.url}")
if hit.snippet:
print(f" - Snippet: {hit.snippet}")
def emit_json(route_hits: list[tuple[str, list[SearchHit]]], query: str) -> None:
data = {
"query": query,
"routes": [
{
"route": route,
"route_help": ROUTE_HELP[route],
"results": [asdict(hit) for hit in hits],
}
for route, hits in route_hits
],
}
print(json.dumps(data, ensure_ascii=False, indent=2))
def emit_trace_markdown(route_trace: list[dict[str, str]]) -> None:
print()
print("## Route Trace")
for item in route_trace:
print(f"- {item['route']}: {item['status']} ({item['detail']})")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Deep Research search gateway")
parser.add_argument("query", help="Search query")
parser.add_argument(
"--route",
choices=sorted(ROUTE_HELP),
default="general",
help="Single search route to run",
)
parser.add_argument(
"--profile",
choices=sorted(PROFILE_ROUTES),
help="Run a strategy profile instead of a single route",
)
parser.add_argument("--num-results", type=int, default=10)
parser.add_argument("--year-low", type=int, help="Lower year bound for scholar searches")
parser.add_argument("--time-range", choices=["d", "w", "m", "y"], help="Serper news time range")
parser.add_argument("--json", action="store_true", help="Emit JSON instead of Markdown")
parser.add_argument("--dry-run", action="store_true", help="Show planned routes without calling APIs")
parser.add_argument(
"--strict-specialized",
action=argparse.BooleanOptionalAction,
default=True,
help="Fail fast if scholar/news/patents cannot use Serper",
)
parser.add_argument("--trace", action="store_true", help="Include route execution trace")
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
load_secrets()
routes = PROFILE_ROUTES[args.profile] if args.profile else [args.route]
query = args.query
if args.profile in PROFILE_QUERY_PREFIX:
query = f"{PROFILE_QUERY_PREFIX[args.profile]} {query}"
if args.dry_run:
for route in routes:
print(f"{route}: {ROUTE_HELP[route]}")
if query != args.query:
print(f"query_rewritten: {query}")
return 0
try:
with SearchClient(strict_specialized=args.strict_specialized) as client:
route_hits = []
route_trace: list[dict[str, str]] = []
for route in routes:
try:
hits = search_route(client, route, query, args)
route_hits.append((route, hits))
route_trace.append({"route": route, "status": "ok", "detail": f"hits={len(hits)}"})
except SearchError as exc:
route_hits.append((route, []))
route_trace.append({"route": route, "status": "failed", "detail": str(exc)})
if route != "general":
continue
raise
except SearchError as exc:
raise SystemExit(f"search failed: {exc}") from exc
if args.json:
data = {
"query": query,
"original_query": args.query,
"strict_specialized": args.strict_specialized,
"routes": [
{
"route": route,
"route_help": ROUTE_HELP[route],
"results": [asdict(hit) for hit in hits],
}
for route, hits in route_hits
],
"trace": route_trace if args.trace else [],
}
print(json.dumps(data, ensure_ascii=False, indent=2))
else:
emit_markdown(route_hits, query)
if args.trace:
emit_trace_markdown(route_trace)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Sprint 5 regression checks for v0.12 changes.
Checks are non-destructive and default to dry-run behavior.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def run(cmd: list[str]) -> tuple[int, str]:
proc = subprocess.run(
cmd,
cwd=REPO_ROOT,
check=False,
capture_output=True,
text=True,
)
out = (proc.stdout or "") + (proc.stderr or "")
return proc.returncode, out
def check(name: str, cmd: list[str], must_contain: list[str] | None = None) -> bool:
print(f"[check] {name}")
print(" $ " + " ".join(cmd))
rc, out = run(cmd)
if rc != 0:
print(f" FAIL: exit={rc}")
if out.strip():
print(" output:")
print(" " + out.strip().replace("\n", "\n "))
return False
for token in must_contain or []:
if token not in out:
print(f" FAIL: missing token '{token}'")
return False
print(" PASS")
return True
def main() -> int:
parser = argparse.ArgumentParser(description="Run Sprint 5 regression checks")
parser.add_argument("project", help="Project slug or path for finalize dry-run")
args = parser.parse_args()
checks = [
(
"model profiles list",
["uv", "run", "python", "scripts/dr.py", "models", "--list"],
["medium", "premium", "simple"],
),
(
"search gateway dry-run",
[
"uv",
"run",
"python",
"scripts/search.py",
"GLP-1 obesity",
"--profile",
"china_market",
"--dry-run",
],
["news:", "general:", "query_rewritten:"],
),
(
"phase4 finalize dry-run",
[
"uv",
"run",
"python",
"scripts/dr.py",
"finalize",
args.project,
"--model-profile",
"medium",
"--dry-run",
],
["Phase 4 pipeline done"],
),
]
ok = True
for name, cmd, tokens in checks:
ok = check(name, cmd, tokens) and ok
if not ok:
print("\nSprint 5 regression: FAILED")
return 1
print("\nSprint 5 regression: PASSED")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Generated
+1 -1
View File
@@ -361,7 +361,7 @@ wheels = [
[[package]] [[package]]
name = "deep-research" name = "deep-research"
version = "0.3.0" version = "0.12.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "biopython" }, { name = "biopython" },