Files
deep_research/scripts/build_glossary.py

333 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""双语术语表事实核查脚本。
输入:
- <project>/phase4/glossary.json(来自 translate.py 累积的初版术语表)
- 可选:--extra terms.txt(每行一个英文术语,补充进来一起核查)
流程(每个术语独立可并行):
1. 用 SearchClientExa > Tavily)搜一次(query = "<term> <domain hint>"
2. 把 top 3-5 snippet 喂给 Haiku,让模型返回 {zh, en_full, confidence, issue}
3. 合并回 glossary,字段扩展:
{
"Mabwell": {
"zh": "迈威生物",
"en_full": "Mabwell (Shanghai) Bioscience Co., Ltd.",
"confidence": "high",
"issue": "...",
"verified_at": "2026-04-22",
"sources": ["https://mabwell.com/", ...]
}
}
用法:
uv run python scripts/build_glossary.py <project_slug>
uv run python scripts/build_glossary.py <project_slug> --workers 4
uv run python scripts/build_glossary.py <project_slug> --only "Mabwell,Maywavee"
uv run python scripts/build_glossary.py <project_slug> --force
断点续传:已核查过的条目(有 verified_at 字段)默认跳过;--force 全部重跑。
"""
from __future__ import annotations
import argparse
import concurrent.futures
import datetime as dt
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scripts.lib.search_client import SearchClient, SearchError
from scripts.lib.zenmux_client import ZenMuxClient, ZenMuxError, load_secrets
DEFAULT_MODEL = "anthropic/claude-haiku-4.5"
PROMPT_FILE = Path(__file__).parent / "prompts" / "glossary_system.txt"
def resolve_project(arg: str) -> Path:
p = Path(arg)
if p.is_dir():
return p
cand = Path.cwd() / "projects" / arg
if cand.is_dir():
return cand
raise SystemExit(f"project not found: {arg}")
def load_glossary(path: Path) -> dict:
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
def save_glossary(path: Path, glossary: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(glossary, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def migrate_legacy_entry(value) -> dict:
"""旧版 glossary 里 value 是字符串;升级为对象格式。"""
if isinstance(value, str):
return {"zh": value}
if isinstance(value, dict):
return value
return {"zh": str(value)}
def build_query(term: str, domain: str) -> str:
if domain:
return f"{term} {domain} 中文名"
return term
def parse_json_line(text: str) -> dict:
"""模型返回的单行 JSON。容忍前后额外字符。"""
s = text.strip()
l = s.find("{")
r = s.rfind("}")
if l == -1 or r == -1:
raise ValueError(f"no JSON object: {text[:200]}")
obj = json.loads(s[l : r + 1])
if not isinstance(obj, dict):
raise ValueError("top-level JSON not object")
return obj
def build_user_prompt(term: str, domain: str, current_zh: str, hits: list) -> str:
hits_text = "\n\n".join(
f"[{i+1}] {h.title}\n URL: {h.url}\n {h.snippet[:500]}"
for i, h in enumerate(hits[:5])
)
if not hits_text:
hits_text = "(无搜索结果)"
return (
f"term: {term}\n"
f"domain: {domain or '(未指定)'}\n"
f"current_zh: {current_zh or '(空)'}\n\n"
f"search_hits:\n{hits_text}\n"
)
def verify_term(
term: str,
current_zh: str,
domain: str,
search_client: SearchClient,
llm_client: ZenMuxClient,
*,
model: str,
system_prompt: str,
) -> dict:
try:
hits = search_client.search(
build_query(term, domain),
num_results=4,
)
except SearchError as e:
return {
"zh": current_zh or "",
"en_full": term,
"confidence": "low",
"issue": f"搜索失败:{e}",
"sources": [],
}
user = build_user_prompt(term, domain, current_zh, hits)
try:
raw = llm_client.chat_complete(
model=model,
system=system_prompt,
user=user,
temperature=0.1,
max_tokens=800,
tag=f"glossary:{term[:30]}",
)
obj = parse_json_line(raw)
except (ZenMuxError, ValueError) as e:
return {
"zh": current_zh or "",
"en_full": term,
"confidence": "low",
"issue": f"LLM 核查失败:{e}",
"sources": [h.url for h in hits[:3]],
}
# 规范化输出
return {
"zh": str(obj.get("zh", current_zh or "")),
"en_full": str(obj.get("en_full", term)),
"confidence": str(obj.get("confidence", "low")),
"issue": str(obj.get("issue", "")),
"sources": [h.url for h in hits[:3]],
}
def main() -> int:
parser = argparse.ArgumentParser(description="双语术语表事实核查(Haiku + Exa")
parser.add_argument("project", help="项目 slug 或完整路径")
parser.add_argument(
"--input",
default="phase4/glossary.json",
help="初版术语表路径(相对项目根)",
)
parser.add_argument(
"--output",
default="phase4/glossary.json",
help="输出路径(默认原地覆盖)",
)
parser.add_argument("--model", default=DEFAULT_MODEL, help="LLM 模型")
parser.add_argument(
"--workers", type=int, default=4, help="并发度(默认 4;网络不稳时建议降到 3)"
)
parser.add_argument(
"--force", action="store_true",
help="忽略已核查状态,全部重跑",
)
parser.add_argument(
"--only",
default=None,
help="只核查指定术语(逗号分隔,大小写敏感)",
)
parser.add_argument(
"--extra",
default=None,
help="每行一个英文术语的文本文件,补充进术语表一起核查",
)
parser.add_argument(
"--domain",
default=None,
help="术语领域提示(默认自动读 manifest.topic",
)
args = parser.parse_args()
load_secrets()
project_root = resolve_project(args.project)
input_path = project_root / args.input
output_path = project_root / args.output
manifest_path = project_root / "manifest.json"
domain = args.domain
if not domain and manifest_path.exists():
m = json.loads(manifest_path.read_text(encoding="utf-8"))
domain = m.get("topic") or m.get("report_title") or ""
glossary = load_glossary(input_path)
# migration
for k, v in list(glossary.items()):
glossary[k] = migrate_legacy_entry(v)
if args.extra:
extra_path = Path(args.extra)
if not extra_path.exists():
raise SystemExit(f"--extra 文件不存在:{extra_path}")
for line in extra_path.read_text(encoding="utf-8").splitlines():
term = line.strip()
if term and term not in glossary:
glossary[term] = {"zh": ""}
only_terms: set[str] | None = None
if args.only:
only_terms = {t.strip() for t in args.only.split(",") if t.strip()}
system_prompt = PROMPT_FILE.read_text(encoding="utf-8")
# 筛选需要核查的
todo: list[str] = []
for term, entry in glossary.items():
if only_terms is not None and term not in only_terms:
continue
if not args.force and entry.get("verified_at"):
continue
todo.append(term)
print(f"Project: {project_root.name}")
print(f"Domain hint: {domain or '(none)'}")
print(f"Glossary size: {len(glossary)} | to verify: {len(todo)} | workers: {args.workers}")
print(f"Model: {args.model}")
if not todo:
print(" 没有需要核查的条目(使用 --force 强制重跑)")
save_glossary(output_path, glossary)
return 0
today = dt.date.today().isoformat()
logs_dir = project_root / "phase4" / "logs"
log_file = logs_dir / "glossary.jsonl"
start = time.time()
done_count = 0
failed: list[str] = []
# 并发执行:SearchClient/ZenMuxClient 都是 thread-safehttpx.Client 支持)
with SearchClient() as search_client, ZenMuxClient(log_file=log_file) as llm_client:
def worker(term: str) -> tuple[str, dict]:
current_zh = glossary.get(term, {}).get("zh", "")
result = verify_term(
term,
current_zh,
domain or "",
search_client,
llm_client,
model=args.model,
system_prompt=system_prompt,
)
return term, result
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = {pool.submit(worker, term): term for term in todo}
for fut in concurrent.futures.as_completed(futures):
term = futures[fut]
try:
_term, result = fut.result()
except Exception as e:
print(f" [FAIL] {term}: {e}")
failed.append(term)
continue
# 合并到 glossary
old = glossary.get(term, {})
old.update(result)
old["verified_at"] = today
glossary[term] = old
done_count += 1
conf = result.get("confidence", "?")
issue = result.get("issue", "")
zh = result.get("zh") or "(保留英文)"
marker = {"high": "✓", "medium": "~", "low": "?"}.get(conf, " ")
issue_str = f" ⚠ {issue[:80]}" if issue else ""
print(f" [{marker}] {term:<35}{zh}{issue_str}")
# 阶段性存盘,避免中途挂掉丢数据
if done_count % 10 == 0:
save_glossary(output_path, glossary)
save_glossary(output_path, glossary)
elapsed = time.time() - start
print(f"\n完成:{done_count}/{len(todo)}(失败 {len(failed)},耗时 {elapsed:.1f}s")
print(f"术语表:{output_path.relative_to(project_root)}")
print(llm_client.usage.summary())
# 高警示项汇总
issues = [
(k, v) for k, v in glossary.items()
if v.get("issue") and v.get("confidence") != "high"
]
if issues:
print(f"\n⚠ 低置信度或带问题的条目({len(issues)} 条):")
for k, v in issues[:15]:
print(f" - {k:<30} [{v.get('confidence','?')}] {v.get('issue','')[:100]}")
if len(issues) > 15:
print(f" … 还有 {len(issues) - 15} 条,见 {output_path.name}")
return 0
if __name__ == "__main__":
sys.exit(main())