Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1169646b8 | ||
|
|
ddaa6730bc | ||
|
|
5342a26018 |
+16
@@ -51,3 +51,19 @@ Thumbs.db
|
||||
# ============ 归档(不纳入版本控制)============
|
||||
archive/*
|
||||
!archive/.gitkeep
|
||||
|
||||
# ============ 临时 LaTeX / TeX 测试文件 ============
|
||||
xetest.*
|
||||
*.aux
|
||||
*.fls
|
||||
*.fdb_latexmk
|
||||
*.synctex.gz
|
||||
|
||||
# ============ Quarto 生成的中间文件 ============
|
||||
*_files/
|
||||
*.qmd
|
||||
_preamble.tex
|
||||
|
||||
# ============ 研究项目(实际数据,不纳入版本控制)============
|
||||
# 如需备份,请用独立的私有仓库
|
||||
projects/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`)
|
||||
@@ -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 pipeline(phase4_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 pipeline(Python 脚本)
|
||||
|
||||
```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: 汇报
|
||||
|
||||
向用户展示:
|
||||
- 各阶段耗时和成本
|
||||
|
||||
@@ -18,6 +18,7 @@ subtask: false
|
||||
- `target_words_zh` 和 `target_words_en` 必须都存在
|
||||
- `core_questions` 必须非空
|
||||
- `report_title` 必须非空(v0.5 新增检查)
|
||||
- `model_profile` 必须存在(v0.12 新增检查,确保全流程模型策略一致)
|
||||
|
||||
任一检查不通过 → 回报用户"访谈不完整",停止。
|
||||
|
||||
@@ -52,7 +53,7 @@ prompt: |
|
||||
Required skills: search-strategy, source-quality
|
||||
|
||||
Tasks:
|
||||
1. 3 rounds of search: Tavily + Brave + Exa
|
||||
1. 3 rounds of search through `scripts/search.py`: scholar/patents/news/general as appropriate; Tavily/Brave/Exa MCP only as gap-fill
|
||||
2. Both English and Chinese keywords
|
||||
3. Return 10-20 Tier 1-2 sources (score ≥6), exclude Tier 4 and blacklist
|
||||
4. 1-2 sentence outline per source
|
||||
@@ -63,6 +64,7 @@ prompt: |
|
||||
### Keywords Used
|
||||
- English: ...
|
||||
- Chinese: ...
|
||||
- Routes used: scholar / patents / news / general
|
||||
### Initial Sources (≥10, Tier 1-2)
|
||||
1. [src_xxx] <title> | <author/institution> | <year> | <Tier> | <score>
|
||||
- <core finding one sentence>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: 初始化一个新的 Deep Research 主题。创建 projects/<slug>/ 目录与 manifest.json,启动 Phase 1 访谈(8 步),访谈末尾自动提议 3 个报告标题让用户选。用法:/dr-init <研究主题>
|
||||
description: 初始化一个新的 Deep Research 主题。创建 projects/<slug>/ 目录与 manifest.json,启动 Phase 1 访谈(9 步,含模型策略选择),访谈末尾自动提议 3 个报告标题让用户选。用法:/dr-init <研究主题>
|
||||
agent: dr-plan
|
||||
subtask: false
|
||||
---
|
||||
@@ -26,7 +26,7 @@ subtask: false
|
||||
mkdir -p projects/<slug>/{phase1,phase2/drafts,phase2/evidence,phase3/revisions,phase4/figures}
|
||||
```
|
||||
|
||||
### Step 3: 启动访谈(8 步)
|
||||
### Step 3: 启动访谈(9 步)
|
||||
|
||||
**不要急着生成 framework**,向用户清晰编号地提出以下 8 个关键问题:
|
||||
|
||||
@@ -55,6 +55,13 @@ mkdir -p projects/<slug>/{phase1,phase2/drafts,phase2/evidence,phase3/revisions,
|
||||
- `deep` — 深度(50,000-80,000 中文字,12-15 章;行业专著级)
|
||||
- 说明:字数只是参考,以把问题讲清楚为第一优先。
|
||||
|
||||
9. **模型策略选择(新增,必须在 init 阶段确定)**:
|
||||
- `simple`:低成本探索
|
||||
- `medium`:默认推荐(平衡质量/成本)
|
||||
- `premium`:高质量正式交付
|
||||
- `cn_heavy`:中文/中国市场侧重
|
||||
- `codex_native`:Codex 原生模式
|
||||
|
||||
**等待用户回答**。用户可能一次性回答也可能分多轮。
|
||||
|
||||
### Step 4: 提议报告正式标题(关键新增步骤)
|
||||
@@ -105,6 +112,9 @@ mkdir -p projects/<slug>/{phase1,phase2/drafts,phase2/evidence,phase3/revisions,
|
||||
"comparison_targets": [],
|
||||
"exclusions": [],
|
||||
"word_budget_mode": "<auto/concise/detailed/deep>",
|
||||
"model_profile": "<simple/medium/premium/cn_heavy/codex_native>",
|
||||
"model_profile_selected_at": "<今天 YYYY-MM-DD>",
|
||||
"model_profile_source": "dr-init interview",
|
||||
"target_words_zh": <按类型和模式计算,见 length-budget skill §1-2>,
|
||||
"target_words_en": <target_words_zh / 1.4>,
|
||||
"min_words_zh": <target_words_zh × 0.8>,
|
||||
@@ -123,6 +133,16 @@ mkdir -p projects/<slug>/{phase1,phase2/drafts,phase2/evidence,phase3/revisions,
|
||||
|
||||
把整个访谈对话写入 `projects/<slug>/phase1/interview.md`(用户原话 + 你的提问 + 提议的候选标题 + 用户选择)。
|
||||
|
||||
### Step 6.5: 立刻应用模型策略(必须执行)
|
||||
|
||||
在项目初始化完成后,立即把 `model_profile` 应用到 agent 文件(OpenCode + Codex 模板):
|
||||
|
||||
```bash
|
||||
uv run python scripts/dr.py apply-models --profile <model_profile> --target both
|
||||
```
|
||||
|
||||
这样可以确保从 Phase 1(plan)到 Phase 4(polisher/reporter)全流程使用同一套预设策略,而不是中途切换。
|
||||
|
||||
### Step 7: 回报
|
||||
|
||||
```
|
||||
|
||||
@@ -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` 三档。
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,避免论文检索只停留在通用网页摘要。
|
||||
- 专用 route(scholar/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: 生物医药深度研究的统一检索策略。规定信源优先
|
||||
### Serper(google.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. ❌ 不得使用黑名单信源
|
||||
|
||||
@@ -1085,6 +1085,7 @@ def render_table(md_table: str, styles: StyleSheet1) -> Table:
|
||||
- 所有 cell 垂直居中
|
||||
- 长文字自动 CJK 换行
|
||||
- 长表自动按行分页
|
||||
- 宽表(>=7 列)强制按页宽等分列宽,避免 ReportLab 自动分配失败
|
||||
"""
|
||||
rows: list[list] = []
|
||||
raw_rows = []
|
||||
@@ -1097,28 +1098,90 @@ def render_table(md_table: str, styles: StyleSheet1) -> Table:
|
||||
return Table([[""]])
|
||||
|
||||
header_cells = raw_rows[0]
|
||||
ncols = len(header_cells)
|
||||
|
||||
# 判断是否需要强制等宽列:
|
||||
# - 列数 >= 4 时(避免自动分配使某列被挤为负宽)
|
||||
# - 或任意单元格文本超过 30 字符(中英文混排时 auto-allocation 不稳定)
|
||||
_max_cell_len = 0
|
||||
for r in raw_rows[1:]:
|
||||
for c in r:
|
||||
if len(c) > _max_cell_len:
|
||||
_max_cell_len = len(c)
|
||||
|
||||
is_wide = ncols >= 4 or _max_cell_len > 30
|
||||
is_very_wide = ncols >= 7 or _max_cell_len > 80
|
||||
|
||||
pad_lr = 2 if is_very_wide else (3 if is_wide else 6)
|
||||
pad_tb = 3 if is_wide else 5
|
||||
|
||||
rows.append([
|
||||
Paragraph(md_inline_to_rl(c), styles["table-header"]) for c in header_cells
|
||||
])
|
||||
for cells in raw_rows[1:]:
|
||||
# 补齐列数(防御性)
|
||||
while len(cells) < len(header_cells):
|
||||
while len(cells) < ncols:
|
||||
cells.append("")
|
||||
rows.append([_render_table_cell(c, styles) for c in cells])
|
||||
|
||||
table = Table(rows, repeatRows=1, splitByRow=True)
|
||||
# 可用页宽(A4 - margins),给 Table 分配等宽列
|
||||
# 参考 doctemplate 页面宽度:A4.width (595) - left (54) - right (54) ≈ 487 pt
|
||||
# 为安全起见,给表格留一点边距
|
||||
col_widths = None
|
||||
if is_wide:
|
||||
from reportlab.lib.pagesizes import A4
|
||||
avail_width = A4[0] - 110 # A4 宽度 - 两侧边距
|
||||
col_widths = [avail_width / ncols] * ncols
|
||||
|
||||
table = Table(rows, colWidths=col_widths, repeatRows=1, splitByRow=True)
|
||||
table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e0e7ff")),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#cbd5e1")),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), pad_lr),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), pad_lr),
|
||||
("TOPPADDING", (0, 0), (-1, -1), pad_tb),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), pad_tb),
|
||||
]))
|
||||
return table
|
||||
|
||||
|
||||
def render_table_blocks(md_table: str, styles: StyleSheet1, max_rows_per_chunk: int = 25) -> list:
|
||||
"""Render a markdown table as potentially multiple Table objects.
|
||||
|
||||
When the body has more than ``max_rows_per_chunk`` rows we slice it into
|
||||
smaller chunks (each re-printing the header). This avoids ReportLab's
|
||||
split-by-row bug on very long tables which manifests as
|
||||
``TypeError: '>' not supported between instances of 'NoneType' and 'NoneType'``.
|
||||
"""
|
||||
raw_rows = []
|
||||
for line in md_table.strip().split("\n"):
|
||||
line = line.strip().strip("|")
|
||||
cells = [c.strip() for c in line.split("|")]
|
||||
raw_rows.append(cells)
|
||||
if not raw_rows:
|
||||
return [Table([[""]])]
|
||||
|
||||
header = raw_rows[0]
|
||||
body = raw_rows[1:]
|
||||
if len(body) <= max_rows_per_chunk:
|
||||
return [render_table(md_table, styles)]
|
||||
|
||||
# Split into chunks
|
||||
out = []
|
||||
from reportlab.platypus import Spacer
|
||||
for start in range(0, len(body), max_rows_per_chunk):
|
||||
chunk = body[start:start + max_rows_per_chunk]
|
||||
lines_md = [
|
||||
"| " + " | ".join(header) + " |",
|
||||
"|" + "|".join(["---"] * len(header)) + "|",
|
||||
]
|
||||
for row in chunk:
|
||||
lines_md.append("| " + " | ".join(row) + " |")
|
||||
out.append(render_table("\n".join(lines_md), styles))
|
||||
out.append(Spacer(1, 4))
|
||||
return out
|
||||
|
||||
|
||||
def _render_generic_block(block: Block, story: list, base_dir: Path, styles: StyleSheet1, *, in_summary: bool) -> None:
|
||||
"""渲染一个非 H1/H2 的 block(p/quote/bullet/hr/image/table/h3)。
|
||||
|
||||
@@ -1157,7 +1220,8 @@ def _render_generic_block(block: Block, story: list, base_dir: Path, styles: Sty
|
||||
))
|
||||
elif block.kind == "table":
|
||||
try:
|
||||
story.append(render_table(block.content, styles))
|
||||
for _tbl_block in render_table_blocks(block.content, styles):
|
||||
story.append(_tbl_block)
|
||||
except Exception as e:
|
||||
story.append(Paragraph(f"[表格渲染失败: {e}]", styles["caption"]))
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 生物医药行业的 AI 驱动深度研究流水线。基于 OpenCode 多 agent 协作,以麦肯锡/德勤式方法论产出专业级研究报告(PDF + DOCX)。
|
||||
|
||||
**当前状态**:v0.10 迭代中。OpenCode 全流程可用(Phase 1-4),Phase 4 已切换为 Python 脚本化流水线;Codex native adapter 正在建设为独立于 OpenCode 的并列入口。
|
||||
**当前状态**:v0.13 迭代完成。新增 Quarto/xelatex PDF 引擎(`--engine quarto`),解决 ReportLab 超宽表格渲染 bug;ReportLab 引擎保留为默认后备。Quarto 依赖独立安装,不影响现有环境。
|
||||
详见 `PLAN.md` 了解完整方案、版本记录与迭代路径。
|
||||
|
||||
---
|
||||
@@ -87,80 +87,8 @@ opencode # 启动 TUI
|
||||
|
||||
**跑单个 Python 脚本**(不用先激活):
|
||||
```bash
|
||||
uv run python .opencode/templates/report-template.py --input ... --output ...
|
||||
```
|
||||
|
||||
**加新依赖**:
|
||||
```bash
|
||||
uv add <package> # 自动更新 pyproject.toml 和 uv.lock
|
||||
```
|
||||
|
||||
**同步到最新锁定版本**(新 clone 或切分支后):
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
如果你用 [direnv](https://direnv.net/),可在项目根目录建 `.envrc`:
|
||||
```bash
|
||||
source scripts/activate.sh
|
||||
```
|
||||
这样 `cd` 进项目目录会自动激活,`cd` 出去会自动卸载。
|
||||
|
||||
---
|
||||
|
||||
## 可用命令
|
||||
|
||||
| 命令 | 功能 | 状态 |
|
||||
|---|---|---|
|
||||
| `/dr-init <主题>` | 初始化新研究,启动访谈 | ✅ 可用 |
|
||||
| `/dr-frame [slug]` | Phase 1:生成 8-15 章双语研究框架 | ✅ 可用 |
|
||||
| `/dr-research [slug]` | Phase 2:并行深度研究 | ✅ 可用 |
|
||||
| `/dr-review [slug]` | Phase 3:总编审校 | ✅ 可用 |
|
||||
| `/dr-finalize [slug]` | Phase 4:英文合稿 → 中文翻译/术语核查/润色 → PDF+DOCX | ✅ 可用 |
|
||||
| `/dr-glossary [slug]` | 术语表事实核查 | ✅ 可用 |
|
||||
| `/dr-status [slug]` | 查看进度 | ✅ 可用 |
|
||||
|
||||
### 典型流程
|
||||
|
||||
```
|
||||
1. /dr-init GLP-1 减重药物市场
|
||||
→ dr-plan 向你提 8 个访谈问题(研究类型、受众、时间范围等)
|
||||
→ 你回答后,生成 projects/glp1-obesity-market-2026/manifest.json
|
||||
|
||||
2. /dr-frame
|
||||
→ dr-plan 调用 skill:search-strategy
|
||||
→ 委派 3-4 个 dr-searcher(Haiku,轻量)并行初扫
|
||||
→ 生成 8-15 章框架到 phase1/framework.md
|
||||
→ 暂停等你确认
|
||||
|
||||
3. 你审核框架,或提修改意见,或直接确认
|
||||
→ 确认后,manifest.phase1.approved = true
|
||||
|
||||
4. /dr-research
|
||||
→ dr-pm 分批并行调度 dr-analyst 深研
|
||||
→ dr-verifier 做反方验证
|
||||
→ 产出 phase2/drafts、evidence、sources.jsonl
|
||||
|
||||
5. /dr-review
|
||||
→ dr-chief-editor 通读审校,产出 phase3/critique.md
|
||||
|
||||
6. /dr-finalize
|
||||
→ dr-editor-in-chief 合并英文终稿
|
||||
→ Python 脚本执行 translate → glossary → apply_glossary → polish → build_report
|
||||
→ 产出 final_zh_polished.md、PDF、DOCX
|
||||
```
|
||||
|
||||
### Phase 4 Python 流水线
|
||||
|
||||
Phase 4 已不再依赖单个 LLM agent 一次性翻译整篇报告,而是由 Python 控制切块、并发、重试与断点续传:
|
||||
|
||||
```bash
|
||||
uv run python scripts/translate.py <slug> --workers 4
|
||||
uv run python scripts/build_glossary.py <slug> --workers 4
|
||||
uv run python scripts/apply_glossary.py <slug> --input phase4/final_zh.md --dry-run
|
||||
uv run python scripts/apply_glossary.py <slug> --input phase4/final_zh.md
|
||||
uv run python scripts/polish.py <slug> --workers 4
|
||||
uv run python scripts/build_report.py <slug>
|
||||
uv run python scripts/build_report.py <slug> # 默认 ReportLab
|
||||
uv run python scripts/build_report.py <slug> --engine quarto # Quarto/xelatex
|
||||
```
|
||||
|
||||
网络不稳或 API 限流时,把 `--workers` 降到 `3` 或 `1` 即可断点续跑。
|
||||
@@ -196,6 +124,37 @@ codex exec "$(uv run python scripts/dr.py prompt dr-run <slug-or-topic>)"
|
||||
- `docs/model-playbook.md`
|
||||
- `docs/search-playbook.md`
|
||||
|
||||
模型预设配置文件:
|
||||
- `configs/models.yaml`(统一预设,支持 `simple / medium / premium / cn_heavy / codex_native`)
|
||||
|
||||
推荐时机:在 `/dr-init` 访谈阶段就确定 `model_profile`,并立即执行 `apply-models`,保证 plan→pm→analyst→verifier→editor→polisher 的全流程策略一致。
|
||||
|
||||
命令行查看解析后的模型映射:
|
||||
|
||||
```bash
|
||||
uv run python scripts/dr.py models
|
||||
uv run python scripts/dr.py models --list
|
||||
uv run python scripts/dr.py models --profile premium
|
||||
uv run python scripts/dr.py models --profile medium --model-override dr_verifier=zenmux/openai/gpt-5.4
|
||||
|
||||
# apply profile to agent files
|
||||
uv run python scripts/dr.py apply-models --profile medium --target both --dry-run
|
||||
uv run python scripts/dr.py apply-models --profile medium --target both
|
||||
```
|
||||
|
||||
Sprint 5 回归检查(一键):
|
||||
|
||||
```bash
|
||||
uv run python scripts/sprint5_regression.py <slug>
|
||||
```
|
||||
|
||||
统一搜索入口:
|
||||
|
||||
```bash
|
||||
uv run python scripts/search.py "dual-target RNAi 2024" --route scholar --year-low 2023
|
||||
uv run python scripts/search.py "dual-target siRNA GalNAc" --route patents
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
@@ -273,12 +232,71 @@ OpenCode 的常见陷阱:AI 在主会话里装样子地"委派"子 agent,实
|
||||
|
||||
Phase 1 分配章节配额,Phase 2 自检,不足返工。见 `skills/length-budget/SKILL.md`。
|
||||
|
||||
### 4. 中文 PDF 无坑
|
||||
### 4. 中文 PDF 双引擎
|
||||
|
||||
`build_report.py` 现在支持两套 PDF 引擎,按需选择:
|
||||
|
||||
#### 引擎 A:ReportLab(默认,无额外依赖)
|
||||
|
||||
```bash
|
||||
uv run python scripts/build_report.py <slug>
|
||||
```
|
||||
|
||||
- 字体:思源宋 + 思源黑 + 霞鹜文楷(全 SIL OFL,可商用嵌入)
|
||||
- 样式:集中在 `build_styles()`,所有字号行距单点维护
|
||||
- 引擎:ReportLab(纯 Python,30,000 字 3-5 秒出稿)
|
||||
- 图表:matplotlib 预渲染 300 DPI PNG 嵌入
|
||||
- 速度:30,000 字 3-5 秒出稿
|
||||
- 局限:超宽表格(≥4 列且含长文本)需借助列宽 patch 或改为 bullet list 格式
|
||||
|
||||
#### 引擎 B:Quarto / xelatex(`--engine quarto`,推荐用于宽表报告)
|
||||
|
||||
```bash
|
||||
uv run python scripts/build_report.py <slug> --engine quarto
|
||||
```
|
||||
|
||||
- 排版引擎:xelatex(TeX Live / TinyTeX),LaTeX 级排版质量
|
||||
- 字体:同样使用思源宋 + 思源黑,通过 fontspec 加载
|
||||
- 宽表支持:超宽表通过 `longtable` + `tbl-colwidths` 精确指定列宽比例,不溢出
|
||||
- 横向页面:通过 `{.landscape}` div 包裹超宽表,自动插入 `pdflscape` 代码(注意:101 行以上的 landscape longtable 可能触发 TeX `param_size` 上限,建议拆成 ≤20 行的子表块)
|
||||
- 图表:暂不嵌入 matplotlib 图表(使用文字描述代替)
|
||||
|
||||
**安装 Quarto 引擎**(一次性,系统级):
|
||||
|
||||
```bash
|
||||
# 1. 安装 Quarto CLI
|
||||
# 下载页:https://github.com/quarto-dev/quarto-cli/releases/latest
|
||||
# Linux 选 .deb 安装包,macOS 选 .pkg
|
||||
|
||||
# 2. 安装 TinyTeX(Quarto 内置命令)
|
||||
quarto install tinytex
|
||||
|
||||
# 3. 安装中文 LaTeX 支持包
|
||||
~/.TinyTeX/bin/x86_64-linux/tlmgr install ctex xecjk cjk xetex
|
||||
# macOS 路径通常为:~/.TinyTeX/bin/universal-darwin/tlmgr
|
||||
|
||||
# 4. 注册思源字体到 fontconfig
|
||||
# (先确认字体已下载:bash .opencode/templates/fonts/download-fonts.sh)
|
||||
mkdir -p ~/.fonts
|
||||
cp .opencode/templates/fonts/*.otf ~/.fonts/
|
||||
cp .opencode/templates/fonts/*.ttf ~/.fonts/
|
||||
cp .opencode/templates/fonts/ttf/*.ttf ~/.fonts/
|
||||
fc-cache -fv ~/.fonts
|
||||
|
||||
# 5. 验证
|
||||
quarto --version # 应输出 1.x.x
|
||||
fc-list | grep "Source Han" # 应看到思源字体条目
|
||||
```
|
||||
|
||||
**两引擎对比**:
|
||||
|
||||
| 指标 | ReportLab | Quarto/xelatex |
|
||||
|------|-----------|----------------|
|
||||
| 安装复杂度 | 无额外依赖 | 需安装 Quarto + TinyTeX |
|
||||
| 渲染速度 | 3-5 秒 | 30-90 秒(LaTeX 编译) |
|
||||
| 宽表格处理 | 需 workaround | longtable 原生支持 |
|
||||
| 横向页面 | 不支持 | 支持(≤20 行/块) |
|
||||
| 字体嵌入 | OTF 直接嵌入 | fontspec 系统字体 |
|
||||
| 输出体积 | ~1.2 MB/100页 | ~0.9 MB/100页 |
|
||||
| 目录生成 | 自定义实现 | LaTeX 自动 \tableofcontents |
|
||||
|
||||
### 5. zenmux 双 provider(Claude cache 关键)
|
||||
|
||||
@@ -349,6 +367,51 @@ ls .opencode/templates/fonts/*.otf | wc -l # 应为 6+
|
||||
python .opencode/templates/report-template.py --help
|
||||
```
|
||||
|
||||
### Quarto PDF 生成失败
|
||||
|
||||
**字体找不到(`Could not resolve font "Source Han Serif CN/I"`)**:
|
||||
|
||||
CJK 字体没有斜体变体,fontspec 默认会找 `/I` 导致报错。`build_report.py --engine quarto` 已通过 `mainfontoptions: [ItalicFont=...]` 自动绕开,无需手动处理。若自行编写 `.qmd`,需在 YAML 里加:
|
||||
|
||||
```yaml
|
||||
format:
|
||||
pdf:
|
||||
pdf-engine: xelatex
|
||||
CJKmainfont: "Source Han Serif CN"
|
||||
mainfontoptions:
|
||||
- BoldFont=Source Han Serif CN
|
||||
- ItalicFont=Source Han Serif CN
|
||||
- BoldItalicFont=Source Han Serif CN
|
||||
```
|
||||
|
||||
**`tlmgr` 找不到**:
|
||||
|
||||
TinyTeX 不在系统 PATH,用完整路径:
|
||||
```bash
|
||||
~/.TinyTeX/bin/x86_64-linux/tlmgr install <package> # Linux
|
||||
~/.TinyTeX/bin/universal-darwin/tlmgr install <package> # macOS
|
||||
```
|
||||
|
||||
**`TeX capacity exceeded [parameter stack size]`**(landscape 大表):
|
||||
|
||||
pdflscape 的 `\LS@makefcolumn` 在 101 行以上的 longtable 里递归过深,耗尽 TeX 的 `param_size`。解决方法:把超大表拆成每块 ≤20 行的子表,每块都包在 `{.landscape}` div 里:
|
||||
|
||||
```markdown
|
||||
::: {.landscape}
|
||||
| 列1 | 列2 | ... |
|
||||
|---|---|---|
|
||||
| 第1-20行 | ... |
|
||||
:::
|
||||
|
||||
::: {.landscape}
|
||||
| 列1 | 列2 | ... |
|
||||
|---|---|---|
|
||||
| 第21-40行 | ... |
|
||||
:::
|
||||
```
|
||||
|
||||
若使用 `build_report.py --engine quarto`,可通过传入预处理好的 `.md`(宽表已拆块)来避免此问题。
|
||||
|
||||
### uv 安装后找不到
|
||||
uv 官方脚本把 uv 装到 `~/.local/bin/`。若终端里 `which uv` 找不到:
|
||||
```bash
|
||||
@@ -397,6 +460,9 @@ direnv allow
|
||||
- Skill 配置:https://opencode.ai/docs/skills
|
||||
- MCP Servers:https://opencode.ai/docs/mcp-servers
|
||||
- ReportLab 文档:https://docs.reportlab.com
|
||||
- Quarto 文档:https://quarto.org/docs/output-formats/pdf-basics.html
|
||||
- Quarto PDF 引擎:https://quarto.org/docs/output-formats/pdf-engine.html
|
||||
- Quarto 表格文档:https://quarto.org/docs/authoring/tables.html
|
||||
- 思源字体:https://github.com/adobe-fonts
|
||||
- 霞鹜文楷:https://github.com/lxgw/LxgwWenKai
|
||||
|
||||
@@ -407,5 +473,6 @@ direnv allow
|
||||
- **v0.1** (2026-04-20) — MVP 路径 2 完成:dr-plan + dr-pm 两主 agent、4 个核心 skill、2 个命令、ReportLab 模板基础版、字体下载脚本
|
||||
- **v0.2** (2026-04-20) — 双 provider 架构(zenmux-anthropic + zenmux),解决 Claude prompt cache 生效问题
|
||||
- **v0.3** (2026-04-20) — 修正 v0.2 模型名(回到 Opus 4.7 / Sonnet 4.6 / Gemini 3.1 Pro / GPT-5.4 Pro 等真实 slug);改 venv + requirements.txt 跨平台方案(macOS + Debian);新增 `scripts/setup.sh`、`scripts/activate.sh`
|
||||
- **v0.13** (2026-05-02) — `build_report.py` 新增 `--engine quarto` 选项:Quarto 1.9 + xelatex 引擎,解决 ReportLab 超宽表格渲染 bug(`negative availWidth`/`NoneType` 问题);`report-template.py` 同步修复(`render_table_blocks` 分块 + 等宽列强制分配);README 补充双引擎安装指南与排错
|
||||
|
||||
见 `PLAN.md` §12 了解完整变更历史。
|
||||
|
||||
@@ -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"
|
||||
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.
|
||||
|
||||
@@ -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
@@ -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 prompt:Phase 1 会调度 `dr-plan` / `dr-searcher`,Phase 2 会调度 `dr-analyst` / `dr-verifier`,Phase 3 会调度 `dr-chief-editor`。
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
+258
-1
@@ -2,7 +2,7 @@
|
||||
"""Phase 4 成稿阶段:统一入口。
|
||||
|
||||
从 final_zh_polished.md(或指定的 Markdown)+ manifest.json 生成:
|
||||
- <title>.pdf ReportLab 出中文 PDF
|
||||
- <title>.pdf PDF(ReportLab 或 Quarto/xelatex)
|
||||
- <title>.docx Pandoc 出 DOCX
|
||||
- <title>-en.pdf 如果存在 final_en.md 也一并出英文版(可选)
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
用法:
|
||||
uv run python scripts/build_report.py <project_slug>
|
||||
|
||||
# 使用 Quarto/xelatex 引擎(推荐,更好的中文+宽表支持):
|
||||
uv run python scripts/build_report.py <project_slug> --engine quarto
|
||||
|
||||
# 只生成 PDF:
|
||||
uv run python scripts/build_report.py <project_slug> --no-docx
|
||||
|
||||
@@ -20,6 +23,8 @@
|
||||
环境依赖:
|
||||
- reportlab, pypandoc, 思源字体(bash .opencode/templates/fonts/download-fonts.sh)
|
||||
- pandoc 可执行文件在 PATH
|
||||
- Quarto(可选,--engine quarto 时需要):https://quarto.org/docs/get-started/
|
||||
安装后运行:quarto install tinytex
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -30,6 +35,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
@@ -125,6 +131,248 @@ def build_pdf(
|
||||
raise SystemExit(f"PDF 生成失败,返回码 {result.returncode}")
|
||||
|
||||
|
||||
def _detect_wide_tables(md_text: str, min_cols: int = 8) -> list[tuple[int, int]]:
|
||||
"""返回所有列数 >= min_cols 的 Markdown 表格的 (start_line, end_line) 区间(0-based)。"""
|
||||
lines = md_text.split("\n")
|
||||
ranges = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if line.startswith("|") and line.count("|") - 1 >= min_cols:
|
||||
# Possible table header — next line should be separator
|
||||
if i + 1 < len(lines) and re.match(r"^\|[\s\-:|]+\|", lines[i + 1]):
|
||||
start = i
|
||||
j = i + 2
|
||||
while j < len(lines) and lines[j].strip().startswith("|"):
|
||||
j += 1
|
||||
ranges.append((start, j))
|
||||
i = j
|
||||
continue
|
||||
i += 1
|
||||
return ranges
|
||||
|
||||
|
||||
def prepare_qmd(
|
||||
md_path: Path,
|
||||
manifest: dict,
|
||||
output_qmd: Path,
|
||||
fonts_dir: Path,
|
||||
sources_path: Path | None,
|
||||
wide_table_cols: int = 8,
|
||||
) -> None:
|
||||
"""将普通 Markdown 转换为带 Quarto front matter 的 .qmd 文件。
|
||||
|
||||
主要处理:
|
||||
1. 插入 YAML front matter(标题、字体、页面设置等)
|
||||
2. 用 {.landscape} div 包裹列数 >= wide_table_cols 的宽表
|
||||
3. 将 [TOC will be generated...] 占位符替换为真实 TOC 指令
|
||||
4. 将 [REFERENCES will be filled...] 占位符替换为参考文献内容
|
||||
"""
|
||||
title = manifest.get("report_title", "报告")
|
||||
subtitle = manifest.get("report_subtitle", "")
|
||||
date = manifest.get("date", "")
|
||||
|
||||
# 决定字体名称:思源宋体 CN 作正文,思源黑体 CN 作标题
|
||||
main_font = "Source Han Serif CN"
|
||||
sans_font = "Source Han Sans CN"
|
||||
|
||||
# Write LaTeX header file for CJK font setup.
|
||||
# Using a separate .tex file avoids YAML escape issues with backslashes.
|
||||
mf = main_font # "Source Han Serif CN"
|
||||
sf = sans_font # "Source Han Sans CN"
|
||||
|
||||
front_matter = textwrap.dedent(f"""\
|
||||
---
|
||||
title: "{title}"
|
||||
subtitle: "{subtitle}"
|
||||
date: "{date}"
|
||||
lang: zh
|
||||
format:
|
||||
pdf:
|
||||
pdf-engine: xelatex
|
||||
CJKmainfont: "{mf}"
|
||||
mainfont: "{mf}"
|
||||
mainfontoptions:
|
||||
- BoldFont={mf}
|
||||
- ItalicFont={mf}
|
||||
- BoldItalicFont={mf}
|
||||
CJKoptions:
|
||||
- BoldFont={mf}
|
||||
- ItalicFont={mf}
|
||||
- BoldItalicFont={mf}
|
||||
sansfont: "{sf}"
|
||||
sansfontoptions:
|
||||
- BoldFont={sf}
|
||||
- ItalicFont={sf}
|
||||
- BoldItalicFont={sf}
|
||||
monofont: "Liberation Mono"
|
||||
papersize: a4
|
||||
documentclass: scrartcl
|
||||
classoption:
|
||||
- DIV=11
|
||||
- headinclude
|
||||
toc: true
|
||||
toc-depth: 2
|
||||
toc-title: "目录"
|
||||
number-sections: false
|
||||
colorlinks: true
|
||||
linkcolor: NavyBlue
|
||||
urlcolor: NavyBlue
|
||||
geometry:
|
||||
- top=25mm
|
||||
- bottom=25mm
|
||||
- left=25mm
|
||||
- right=20mm
|
||||
pdf-engine-opts:
|
||||
- "-stack-size=32768"
|
||||
- "-extra-mem-top=2000000"
|
||||
include-in-header:
|
||||
- file: _preamble.tex
|
||||
---
|
||||
|
||||
""")
|
||||
|
||||
md_text = md_path.read_text(encoding="utf-8")
|
||||
|
||||
# Remove existing YAML front matter if any (between first two ---)
|
||||
if md_text.startswith("---"):
|
||||
end = md_text.find("\n---", 3)
|
||||
if end != -1:
|
||||
md_text = md_text[end + 4:].lstrip("\n")
|
||||
|
||||
# Replace TOC placeholder
|
||||
md_text = re.sub(
|
||||
r"\[TOC will be generated.*?\]",
|
||||
"", # Quarto handles TOC via front matter
|
||||
md_text,
|
||||
)
|
||||
|
||||
# Replace REFERENCES placeholder with actual references from sources.jsonl
|
||||
ref_block = _build_references_block(sources_path, md_text)
|
||||
md_text = re.sub(
|
||||
r"\[REFERENCES will be filled.*?\]",
|
||||
ref_block,
|
||||
md_text,
|
||||
)
|
||||
|
||||
# Wrap wide tables in {.landscape} divs
|
||||
lines = md_text.split("\n")
|
||||
wide_ranges = _detect_wide_tables(md_text, min_cols=wide_table_cols)
|
||||
|
||||
if wide_ranges:
|
||||
# Insert landscape wrappers from bottom up (so line numbers stay valid)
|
||||
for start, end in reversed(wide_ranges):
|
||||
lines.insert(end, "\n:::")
|
||||
lines.insert(start, "::: {.landscape}\n")
|
||||
|
||||
md_text = "\n".join(lines)
|
||||
|
||||
# Write LaTeX preamble file (table + landscape support)
|
||||
preamble_tex = output_qmd.parent / "_preamble.tex"
|
||||
preamble_tex.write_text(
|
||||
"\\usepackage{longtable}\n"
|
||||
"\\usepackage{booktabs}\n"
|
||||
"\\usepackage{array}\n"
|
||||
# Use lscape instead of pdflscape to avoid \LS@makefcolumn recursion
|
||||
# which exhausts TeX param_size on large longtables.
|
||||
# lscape rotates content without changing page media box (reader must rotate).
|
||||
"\\usepackage{lscape}\n"
|
||||
"\\setlength{\\LTpre}{6pt}\n"
|
||||
"\\setlength{\\LTpost}{6pt}\n"
|
||||
"\\setlength{\\tabcolsep}{3pt}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
output_qmd.write_text(front_matter + md_text, encoding="utf-8")
|
||||
print(f" .qmd prepared: {output_qmd.name} ({len(wide_ranges)} landscape table(s))")
|
||||
|
||||
|
||||
def _build_references_block(sources_path: Path | None, md_text: str) -> str:
|
||||
"""从 sources.jsonl 生成参考文献列表,只包含在正文中实际引用的信源。"""
|
||||
if not sources_path or not sources_path.exists():
|
||||
return "(参考文献列表:sources.jsonl 未找到)"
|
||||
|
||||
# Find cited src_ids
|
||||
cited = set(re.findall(r"\[src_([a-z0-9_]+)\]", md_text))
|
||||
if not cited:
|
||||
return ""
|
||||
|
||||
sources: dict[str, dict] = {}
|
||||
with open(sources_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
sid = obj.get("id", "")
|
||||
key = sid.replace("src_", "")
|
||||
if key in cited:
|
||||
sources[sid] = obj
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if not sources:
|
||||
return ""
|
||||
|
||||
lines = ["## 参考文献\n"]
|
||||
for sid in sorted(sources.keys()):
|
||||
s = sources[sid]
|
||||
authors = ", ".join(s.get("authors", [])) if s.get("authors") else ""
|
||||
year = s.get("year", "")
|
||||
title = s.get("title", sid)
|
||||
venue = s.get("venue", "")
|
||||
url = s.get("url", "")
|
||||
entry = f"- **[{sid}]** "
|
||||
if authors:
|
||||
entry += f"{authors}. "
|
||||
if year:
|
||||
entry += f"({year}). "
|
||||
entry += f"*{title}*"
|
||||
if venue:
|
||||
entry += f". {venue}"
|
||||
if url:
|
||||
entry += f". <{url}>"
|
||||
lines.append(entry)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_pdf_quarto(
|
||||
md_path: Path,
|
||||
manifest: dict,
|
||||
output_pdf: Path,
|
||||
fonts_dir: Path,
|
||||
sources_path: Path | None,
|
||||
) -> None:
|
||||
"""使用 Quarto + xelatex 生成 PDF。"""
|
||||
if not shutil.which("quarto"):
|
||||
raise SystemExit(
|
||||
"quarto 命令未找到。请先安装 Quarto:https://quarto.org/docs/get-started/\n"
|
||||
"安装后运行:quarto install tinytex"
|
||||
)
|
||||
|
||||
# Prepare .qmd in the same dir as output_pdf
|
||||
qmd_path = output_pdf.parent / (output_pdf.stem + ".qmd")
|
||||
prepare_qmd(md_path, manifest, qmd_path, fonts_dir, sources_path)
|
||||
|
||||
print(f"\n→ 生成 PDF(Quarto/xelatex):{output_pdf.name}")
|
||||
cmd = [
|
||||
"quarto", "render", str(qmd_path),
|
||||
"--to", "pdf",
|
||||
"--output", output_pdf.name,
|
||||
]
|
||||
result = subprocess.run(cmd, cwd=str(output_pdf.parent), check=False)
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(f"Quarto PDF 生成失败,返回码 {result.returncode}")
|
||||
|
||||
# Clean up auxiliary files Quarto leaves behind
|
||||
for ext in (".tex", ".log", ".aux", ".toc", ".out", "-files"):
|
||||
candidate = output_pdf.parent / (output_pdf.stem + ext)
|
||||
if candidate.exists():
|
||||
candidate.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def build_docx(md_path: Path, output_docx: Path, title: str) -> None:
|
||||
"""用 pandoc 生成 DOCX。"""
|
||||
if not shutil.which("pandoc"):
|
||||
@@ -178,6 +426,12 @@ def main() -> int:
|
||||
)
|
||||
parser.add_argument("--no-docx", action="store_true", help="跳过 DOCX 生成")
|
||||
parser.add_argument("--no-pdf", action="store_true", help="跳过 PDF 生成")
|
||||
parser.add_argument(
|
||||
"--engine",
|
||||
choices=["reportlab", "quarto"],
|
||||
default="reportlab",
|
||||
help="PDF 渲染引擎:reportlab(默认,Python 原生)或 quarto(xelatex,更好的中文+宽表支持)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--basename",
|
||||
default=None,
|
||||
@@ -222,6 +476,9 @@ def main() -> int:
|
||||
print(f"Sources: {sources_path if sources_path else '(缺失)'}")
|
||||
|
||||
if not args.no_pdf:
|
||||
if args.engine == "quarto":
|
||||
build_pdf_quarto(md_path, manifest, pdf_path, fonts_dir, sources_path)
|
||||
else:
|
||||
build_pdf(md_path, manifest_path, pdf_path, fonts_dir, sources_path)
|
||||
if not args.no_docx:
|
||||
build_docx(md_path, docx_path, title)
|
||||
|
||||
+121
-35
@@ -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,83 @@ def cmd_glossary(args: argparse.Namespace) -> int:
|
||||
|
||||
def cmd_finalize(args: argparse.Namespace) -> int:
|
||||
project_root = resolve_project(args.project)
|
||||
steps = [
|
||||
[
|
||||
manifest = load_manifest(project_root)
|
||||
effective_profile = args.model_profile or manifest.get("model_profile")
|
||||
try:
|
||||
resolved = resolve_model_profile(
|
||||
profile=effective_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 +266,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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,便于脚本直接运行。
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user