v0.12: stabilize search routing and profile-driven phase4 pipeline

This commit is contained in:
kai
2026-04-29 15:53:20 +08:00
parent 450ecebcff
commit 5342a26018
33 changed files with 1436 additions and 140 deletions
+11
View File
@@ -17,6 +17,8 @@ permission:
"*": deny
"wc *": allow
"python3 *": allow
"uv run python scripts/search.py *": allow
"uv run python scripts/ground.py *": allow
"mkdir *": allow
"grep *": 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 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).
### Step 3: Source Scoring
+12 -4
View File
@@ -7,10 +7,14 @@ temperature: 0.1
tools:
read: true
webfetch: true
bash: true
skill: true
permission:
bash:
"*": deny
"uv run python scripts/search.py *": allow
"uv run python scripts/ground.py *": allow
"python3 scripts/search.py *": allow
edit: deny
webfetch: allow
task:
@@ -25,10 +29,13 @@ permission:
1. 加载 `skill:search-strategy` 了解信源优先级与检索规则
2. 加载 `skill:source-quality` 了解评分标准与黑名单
3. 按调用方给定的关键词方向,执行 **3 轮检索**
- 第 1 轮:英文关键词,优先 Tavily advanced 模式,锁定 Tier 1 域名
- 第 2 轮:中文关键词,查中文专业来源
- 第 3 轮:反方/限制性关键词(如 `limitations`, `adverse`, `failed`
3. 按调用方给定的关键词方向,执行 **3 轮检索**,必须优先使用项目搜索网关
- 文献:`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`
- 快速 grounding`uv run python scripts/ground.py "<query>" --json`
- Tavily / Brave / Exa MCP 只能作为 gap-fill 或脚本不可用时的兜底
4. 对每条候选信源按 source-quality 评分,过滤掉评分 < 5 及黑名单
5. 整理输出,直接返回给调用方(不写文件)
@@ -43,6 +50,7 @@ permission:
- 英文:...
- 中文:...
- 反方:...
- Routes used: scholar / patents / news / general
### 信源列表(共 N 条,Tier 1-2)
+11 -1
View File
@@ -2,7 +2,7 @@
description: Cross-model verification agent (English). Uses non-Claude model (GPT-5.4) to do counter-evidence searching and fact-check on completed chapters, avoiding same-source bias. Scheduled by dr-pm after dr-analyst finishes each chapter.
mode: subagent
hidden: true
model: zenmux/openai/gpt-5.4
model: zenmux/openai/gpt-5.4-mini
temperature: 0.2
tools:
read: true
@@ -10,12 +10,16 @@ tools:
edit: false
apply_patch: false
webfetch: true
bash: true
skill: true
permission:
edit: allow
webfetch: allow
bash:
"*": deny
"uv run python scripts/search.py *": allow
"uv run python scripts/ground.py *": allow
"python3 scripts/search.py *": allow
task:
"*": deny
---
@@ -74,6 +78,12 @@ For each core claim, search:
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
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
---
你是 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 脚本**。原因:
- LLM agent 一次性处理整篇报告(19k+ 词)会超 Sonnet output token 上限(~32k),不稳定
@@ -40,57 +40,40 @@ agent: dr-editor-in-chief
- 给每章强加 SCQA 或小节标题
- 保留调度元数据(字数配额/研究员/quota 等)
## Step 3: 翻译Python 脚本)
## Step 3: 执行统一 Phase 4 pipelinePython 脚本)
```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
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`
- `confidence == "high"``issue` 非空的条目:说明发现了错误,需要回塑到正文
- 关注公司名 / 机构名 / 产品名类,它们最容易有拼写错误
完成条件:`phase4/final_zh_polished.md`、PDF、DOCX 全部生成,且无致命报错。
## Step 5: 应用术语修正(Python 脚本)
## Step 4: (可选)分步重跑
```bash
# 先预览
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
```
这会把 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>
```
当你只想重跑单环节时,仍可手动调用:
- `translate.py`
- `build_glossary.py`
- `apply_glossary.py`
- `polish.py`
- `build_report.py`
自动:
-`manifest.report_title` 命名输出(`<Title>.pdf` + `<Title>.docx`
- PDF 自动插 TOC + 从 `phase2/sources.jsonl` 生成参考文献
## Step 8: 更新 manifest
## Step 5: 更新 manifest
```json
{
@@ -115,7 +98,7 @@ uv run python scripts/build_report.py <slug>
}
```
## Step 9: 汇报
## Step 6: 汇报
向用户展示:
- 各阶段耗时和成本
+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": {
"TAVILY_API_KEY": "{env:TAVILY_API_KEY}"
},
"enabled": true
"enabled": false
},
"brave-search": {
"type": "local",
@@ -121,7 +121,7 @@
"environment": {
"BRAVE_API_KEY": "{env:BRAVE_API_KEY}"
},
"enabled": true
"enabled": false
},
"exa": {
"type": "local",
@@ -129,7 +129,7 @@
"environment": {
"EXA_API_KEY": "{env:EXA_API_KEY}"
},
"enabled": true
"enabled": false
}
},
"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**,而不是一律走通用搜索。
@@ -114,24 +153,23 @@ description: 生物医药深度研究的统一检索策略。规定信源优先
### Serpergoogle.serper.dev)使用模板
**专利检索**
```python
from scripts.lib.search_client import SearchClient
with SearchClient() as c:
hits = c.patents("dual-target siRNA GalNAc", num_results=10)
```bash
uv run python scripts/search.py "dual-target siRNA GalNAc" --route patents --num-results 10
```
**学术论文**
```python
hits = c.scholar("dual-target RNAi 2024", num_results=10, year_low=2023)
# hits[i].snippet 里包含引用数和期刊信息
```bash
uv run python scripts/search.py "dual-target RNAi 2024" --route scholar --num-results 10 --year-low 2023
```
**新闻(时效性)**
```python
hits = c.news("Arrowhead ARO-DIMER-PA clinical trial", time_range="w") # 最近一周
```bash
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
参数:
@@ -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` 必须有以下字段:
@@ -225,7 +263,7 @@ curl -s "https://api.fda.gov/drug/event.json?search=patient.drug.medicinalproduc
---
## 、失败兜底
## 、失败兜底
- 某个 API 限流/超时:**等 5s 重试 3 次**,仍失败则跳过并在日志标注
- 某个信源 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 轮检索
2. ✅ 中英双语必查
@@ -241,6 +279,8 @@ curl -s "https://api.fda.gov/drug/event.json?search=patient.drug.medicinalproduc
4. ✅ 反方关键词必查
5. ✅ Tier 4 结果只做发现,不做佐证
6. ✅ 所有信源写入 sources.jsonl 并评分
7. ❌ 不得引用 Wikipedia 做结论
8. ❌ 不得编造数据、URL、DOI
9. ❌ 不得使用黑名单信源
7. ✅ 文献检索必须优先 `scripts/search.py --route scholar`
8. ✅ 专利检索必须优先 `scripts/search.py --route patents`
9. ❌ 不得引用 Wikipedia 做结论
10. ❌ 不得编造数据、URL、DOI
11. ❌ 不得使用黑名单信源
+3 -2
View File
@@ -74,8 +74,9 @@
### Phase 4:成稿
- **驱动命令**`/dr-finalize`
- **主导 agent**dr-chief-editor → dr-polisher → dr-reporter
- **产出**`final.md` + `final.pdf`ReportLab+ `final.docx`Pandoc
- **主导 agent**dr-editor-in-chief(创作)→ `scripts/phase4_pipeline.py`(执行链路)
- **执行链路**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 加载。
- 自动化研究默认权限:`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 与网络可用可直接使用,某个服务异常时再单独关闭。
- 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。
+45 -2
View File
@@ -2,7 +2,7 @@
> 生物医药行业的 AI 驱动深度研究流水线。基于 OpenCode 多 agent 协作,以麦肯锡/德勤式方法论产出专业级研究报告(PDF + DOCX)。
**当前状态**v0.10 迭代。OpenCode 全流程可用(Phase 1-4),Phase 4 已切换为 Python 脚本化流水线Codex native adapter 正在建设为独立于 OpenCode 并列入口。
**当前状态**v0.12 迭代完成。OpenCode 全流程可用(Phase 1-4),搜索网关、模型预设与 Phase 4 统一 pipeline 已落地Codex native adapter OpenCode 保持并列入口。
详见 `PLAN.md` 了解完整方案、版本记录与迭代路径。
---
@@ -117,6 +117,8 @@ source scripts/activate.sh
| `/dr-research [slug]` | Phase 2:并行深度研究 | ✅ 可用 |
| `/dr-review [slug]` | Phase 3:总编审校 | ✅ 可用 |
| `/dr-finalize [slug]` | Phase 4:英文合稿 → 中文翻译/术语核查/润色 → PDF+DOCX | ✅ 可用 |
| `/dr-models [profile]` | 解析模型预设(simple / medium / premium 等) | ✅ 可用 |
| `/dr-apply-models <profile>` | 把模型预设写入 OpenCode/Codex agent 文件 | ✅ 可用 |
| `/dr-glossary [slug]` | 术语表事实核查 | ✅ 可用 |
| `/dr-status [slug]` | 查看进度 | ✅ 可用 |
@@ -152,7 +154,19 @@ source scripts/activate.sh
### Phase 4 Python 流水线
Phase 4 已不再依赖单个 LLM agent 一次性翻译整篇报告,而是由 Python 控制切块、并发、重试与断点续传:
Phase 4 已切到统一 pipeline(替代式):由 Python 控制切块、并发、重试与断点续传:
```bash
uv run python scripts/phase4_pipeline.py <slug>
# 等价入口(支持模型预设)
uv run python scripts/dr.py finalize <slug> --model-profile medium
```
默认行为:
- 自动估算 translate/polish 并发(`--translate-workers 0` / `--polish-workers 0`
- glossary 仅核查低置信度术语(`--glossary-mode low-confidence`
你也可以手动分步执行:
```bash
uv run python scripts/translate.py <slug> --workers 4
@@ -196,6 +210,35 @@ codex exec "$(uv run python scripts/dr.py prompt dr-run <slug-or-topic>)"
- `docs/model-playbook.md`
- `docs/search-playbook.md`
模型预设配置文件:
- `configs/models.yaml`(统一预设,支持 `simple / medium / premium / cn_heavy / codex_native`
命令行查看解析后的模型映射:
```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
```
---
## 项目结构
@@ -1,12 +1,18 @@
name = "dr-analyst"
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"
sandbox_mode = "workspace-write"
developer_instructions = """
You are dr-analyst.
Work in English. Own exactly one assigned chapter.
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:
- projects/<slug>/phase2/drafts/chXX.md
- projects/<slug>/phase2/evidence/chXX-evidence.md
@@ -1,6 +1,6 @@
name = "dr-chief-editor"
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"
sandbox_mode = "read-only"
developer_instructions = """
@@ -1,6 +1,6 @@
name = "dr-editor-in-chief"
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"
sandbox_mode = "workspace-write"
developer_instructions = """
@@ -1,6 +1,6 @@
name = "dr-plan"
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"
sandbox_mode = "workspace-write"
developer_instructions = """
@@ -1,6 +1,6 @@
name = "dr-pm"
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"
sandbox_mode = "workspace-write"
developer_instructions = """
@@ -1,6 +1,6 @@
name = "dr-reporter"
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"
sandbox_mode = "workspace-write"
developer_instructions = """
@@ -1,13 +1,18 @@
name = "dr-searcher"
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"
sandbox_mode = "read-only"
developer_instructions = """
You are dr-searcher.
Your job is source discovery only. Do not write project files unless explicitly instructed by the parent.
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 fabricate URLs, DOIs, trial IDs, patents, or source ids.
"""
@@ -1,12 +1,16 @@
name = "dr-verifier"
description = "Independent counter-evidence and fact-checking agent for completed chapters."
model = "gpt-5.4"
model = "zenmux/openai/gpt-5.4-mini"
model_reasoning_effort = "high"
sandbox_mode = "workspace-write"
developer_instructions = """
You are dr-verifier.
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.
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.
Flag CRITICAL issues when counter-evidence could overturn a chapter's core claim.
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
uv run python scripts/dr.py finalize <slug> \
--translate-workers 4 \
--glossary-workers 4 \
--polish-workers 4
--model-profile medium
```
网络不稳时
等价底层入口(统一 pipeline
```bash
uv run python scripts/phase4_pipeline.py <slug>
```
网络不稳时可显式降并发:
```bash
uv run python scripts/dr.py finalize <slug> \
--model-profile medium \
--translate-workers 1 \
--glossary-workers 3 \
--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
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
> v0.9 起,本文件作为模型选择攻略本。`.opencode/opencode.json` 仍是 OpenCode 的模型白名单,`configs/model_profiles.yaml` 是跨平台策略参考。
> v0.12 起,本文件作为模型选择攻略本。`.opencode/opencode.json` 仍是 OpenCode 的模型白名单,`configs/models.yaml` 是跨平台策略参考。
## Profiles
+19 -2
View File
@@ -2,6 +2,22 @@
> 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
### Tavily
@@ -27,6 +43,7 @@
- 优点:Google Search / Scholar / News 代理,免费额度较高。
- 用法:Google Scholar、Google Patents、新闻时效检索。
- 风险:专利是 `site:patents.google.com` 技巧,不等同官方专利库。
- 项目内调用:`scripts/search.py --route scholar|patents|news`
### PubMed / NCBI
@@ -56,11 +73,11 @@
### 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
Google Patents/Serper → USPTO/EPO/CNIPA → 公司年报/招股书 → Tavily/Exa 补同族专利线索。
`scripts/search.py --route patents` → USPTO/EPO/CNIPA → 公司年报/招股书 → Tavily/Exa 补同族专利线索。
### china_market
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "deep-research"
version = "0.3.0"
version = "0.12.0"
description = "生物医药 Deep Research 系统 - OpenCode 多 agent 协作研究流水线"
requires-python = ">=3.10"
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())
+119 -35
View File
@@ -16,6 +16,17 @@ 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,
list_model_profiles,
parse_model_overrides,
resolve_model_profile,
)
PROJECTS_DIR = REPO_ROOT / "projects"
CODEX_COMMANDS_DIR = REPO_ROOT / ".codex" / "commands"
CODEX_COMMAND_TEMPLATES_DIR = REPO_ROOT / "codex_adapter_templates" / "codex" / "commands"
@@ -152,47 +163,81 @@ def cmd_glossary(args: argparse.Namespace) -> int:
def cmd_finalize(args: argparse.Namespace) -> int:
project_root = resolve_project(args.project)
steps = [
[
try:
resolved = resolve_model_profile(
profile=args.model_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"]
cmd = [
sys.executable,
str(REPO_ROOT / "scripts" / "translate.py"),
str(REPO_ROOT / "scripts" / "phase4_pipeline.py"),
str(project_root),
"--workers",
"--translate-workers",
str(args.translate_workers),
],
[
sys.executable,
str(REPO_ROOT / "scripts" / "build_glossary.py"),
str(project_root),
"--workers",
"--glossary-workers",
str(args.glossary_workers),
],
[
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",
"--polish-workers",
str(args.polish_workers),
],
[
sys.executable,
str(REPO_ROOT / "scripts" / "build_report.py"),
str(project_root),
],
"--translate-model",
roles.get("translate", "anthropic/claude-sonnet-4.6"),
"--glossary-model",
roles.get("glossary", "anthropic/claude-haiku-4.5"),
"--polish-model",
roles.get("polish", "anthropic/claude-sonnet-4.6"),
"--glossary-mode",
args.glossary_mode,
]
for step in steps:
rc = run_cmd(step, dry_run=args.dry_run)
if rc != 0:
return rc
if args.dry_run:
cmd.append("--dry-run")
return run_cmd(cmd, dry_run=False)
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
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:
parser = argparse.ArgumentParser(description="Deep Research platform-neutral CLI")
@@ -219,12 +264,51 @@ def build_parser() -> argparse.ArgumentParser:
finalize = sub.add_parser("finalize", help="Run Phase 4 deterministic pipeline")
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("--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.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
+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 这类术语核查场景服务
关键设计
- `trust_env=False` 绕开系统 socks 代理Clash on macOS socks5 httpx TLS EOF
- Exa 优先LinkedIn / 官网 / 百度百科返回质量最高
- 遇到配额问题自动降级到 Tavily 或返回 empty
- 专利 / Scholar / News 优先 Serper保证 Google Patents / Google Scholar 路径被真正调用
- 通用网页 Exa 优先Tavily fallback
- 遇到配额问题自动降级或返回 empty
- 不做深度 crawl只要摘要
"""
@@ -125,10 +126,11 @@ class SearchClient:
所有客户端都延迟导入 serper_client避免没装 SERPAPI_KEY import
"""
def __init__(self) -> None:
def __init__(self, *, strict_specialized: bool = True) -> None:
self._exa: ExaClient | None = None
self._tavily: TavilyClient | None = None
self._serper = None # 惰性实例化
self.strict_specialized = strict_specialized
try:
self._exa = ExaClient()
except SearchError:
@@ -137,10 +139,9 @@ class SearchClient:
self._tavily = TavilyClient()
except SearchError:
pass
if not (self._exa or self._tavily):
raise SearchError(
"neither EXA_API_KEY nor TAVILY_API_KEY available"
)
self._has_serper_key = bool(os.environ.get("SERPER_API_KEY") or os.environ.get("SERPAPI_KEY"))
if not (self._exa or self._tavily or self._has_serper_key):
raise SearchError("no search API key available: set SERPER_API_KEY, SERPAPI_KEY, EXA_API_KEY, or TAVILY_API_KEY")
def _get_serper(self):
"""惰性创建 SerperClient。没 key 时返回 None。"""
@@ -190,9 +191,12 @@ class SearchClient:
try:
hits = serper.patents(query, num_results=num_results)
return [SearchHit(h.title, h.url, h.snippet) for h in hits]
except Exception:
pass
except Exception as exc:
if self.strict_specialized:
raise SearchError(f"serper patents failed: {exc}") from exc
# 降级:通用搜索加 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)
def scholar(
@@ -215,8 +219,11 @@ class SearchClient:
)
for h in hits
]
except Exception:
pass
except Exception as exc:
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)
def news(
@@ -239,8 +246,11 @@ class SearchClient:
)
for h in hits
]
except Exception:
pass
except Exception as exc:
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)
+114
View File
@@ -141,6 +141,8 @@ class ZenMuxClient:
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 = "",
) -> str:
"""一次非流式对话补全。
@@ -167,6 +169,8 @@ class ZenMuxClient:
"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",
@@ -233,6 +237,116 @@ class ZenMuxClient:
self.usage.failed_calls += 1
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:
"""从 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())