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 "*": 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)
+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. 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 mode: subagent
hidden: true hidden: true
model: zenmux/openai/gpt-5.4 model: zenmux/openai/gpt-5.4-mini
temperature: 0.2 temperature: 0.2
tools: tools:
read: true read: true
@@ -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: 汇报
向用户展示: 向用户展示:
- 各阶段耗时和成本 - 各阶段耗时和成本
+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. ❌ 不得使用黑名单信源
+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。
+45 -2
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.12 迭代完成。OpenCode 全流程可用(Phase 1-4),搜索网关、模型预设与 Phase 4 统一 pipeline 已落地Codex native adapter OpenCode 保持并列入口。
详见 `PLAN.md` 了解完整方案、版本记录与迭代路径。 详见 `PLAN.md` 了解完整方案、版本记录与迭代路径。
--- ---
@@ -117,6 +117,8 @@ source scripts/activate.sh
| `/dr-research [slug]` | Phase 2:并行深度研究 | ✅ 可用 | | `/dr-research [slug]` | Phase 2:并行深度研究 | ✅ 可用 |
| `/dr-review [slug]` | Phase 3:总编审校 | ✅ 可用 | | `/dr-review [slug]` | Phase 3:总编审校 | ✅ 可用 |
| `/dr-finalize [slug]` | Phase 4:英文合稿 → 中文翻译/术语核查/润色 → PDF+DOCX | ✅ 可用 | | `/dr-finalize [slug]` | Phase 4:英文合稿 → 中文翻译/术语核查/润色 → PDF+DOCX | ✅ 可用 |
| `/dr-models [profile]` | 解析模型预设(simple / medium / premium 等) | ✅ 可用 |
| `/dr-apply-models <profile>` | 把模型预设写入 OpenCode/Codex agent 文件 | ✅ 可用 |
| `/dr-glossary [slug]` | 术语表事实核查 | ✅ 可用 | | `/dr-glossary [slug]` | 术语表事实核查 | ✅ 可用 |
| `/dr-status [slug]` | 查看进度 | ✅ 可用 | | `/dr-status [slug]` | 查看进度 | ✅ 可用 |
@@ -152,7 +154,19 @@ source scripts/activate.sh
### Phase 4 Python 流水线 ### 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 ```bash
uv run python scripts/translate.py <slug> --workers 4 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/model-playbook.md`
- `docs/search-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" 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-mini"
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())
+124 -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,82 @@ 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 = [ try:
[ resolved = resolve_model_profile(
sys.executable, profile=args.model_profile,
str(REPO_ROOT / "scripts" / "translate.py"), overrides=parse_model_overrides(args.model_override),
str(project_root), )
"--workers", except ModelConfigError as exc:
str(args.translate_workers), raise SystemExit(f"model profile resolution failed: {exc}") from exc
], roles = resolved["roles"]
[
sys.executable, cmd = [
str(REPO_ROOT / "scripts" / "build_glossary.py"), sys.executable,
str(project_root), str(REPO_ROOT / "scripts" / "phase4_pipeline.py"),
"--workers", str(project_root),
str(args.glossary_workers), "--translate-workers",
], str(args.translate_workers),
[ "--glossary-workers",
sys.executable, str(args.glossary_workers),
str(REPO_ROOT / "scripts" / "apply_glossary.py"), "--polish-workers",
str(project_root), str(args.polish_workers),
"--input", "--translate-model",
"phase4/final_zh.md", roles.get("translate", "anthropic/claude-sonnet-4.6"),
], "--glossary-model",
[ roles.get("glossary", "anthropic/claude-haiku-4.5"),
sys.executable, "--polish-model",
str(REPO_ROOT / "scripts" / "polish.py"), roles.get("polish", "anthropic/claude-sonnet-4.6"),
str(project_root), "--glossary-mode",
"--workers", args.glossary_mode,
str(args.polish_workers),
],
[
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 +264,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())