Files
deep_research/scripts/build_report.py
kaiandUser <human> d3fde1cbb8 v0.6-wip: build_report \u7edf\u4e00\u5165\u53e3 + build_glossary \u672f\u8bed\u6838\u67e5
\u7ee7\u7eed\u89e3\u51b3\u7528\u6237\u53cd\u9988\u7684 PDF \u95ee\u9898\u3002

scripts/build_report.py\uff08\u65b0\u589e\uff09\uff1a
- \u5355\u4e00\u5165\u53e3\u540c\u65f6\u51fa PDF + DOCX
- \u6587\u4ef6\u540d\u81ea\u52a8\u4ece manifest.report_title \u751f\u6210\uff08\u89e3\u51b3 "final.pdf" \u6CDB\u540d\u95EE\u9898\uff09
- pandoc --from=markdown-tex_math_dollars \u4fee\u590d DOCX \u751f\u6210\u65f6\u7684 $ \u8bef\u89e3
- \u81ea\u52a8\u5bfb\u627e phase2/sources.jsonl \u4f5c\u4e3a\u53c2\u8003\u6587\u732e\u5f15\u6587\u6e90

scripts/lib/search_client.py\uff08\u65b0\u589e\uff09\uff1a
- Exa \u4e3b\u529b + Tavily fallback \u7684\u7edf\u4e00\u63a5\u53e3
- \u5173\u952e\u4fee\u590d\uff1atrust_env=False \u7ed5\u5f00\u7cfb\u7edf socks5 \u4ee3\u7406
  \uff08Clash on macOS \u5c0a httpx TLS \u63e1\u624b\u5728 CONNECT \u540e EOF\uff09

scripts/build_glossary.py\uff08\u65b0\u589e\uff09\uff1a
- \u7528\u7684\u4e92\u65b9\u5f0f\u89e3\u51b3\u4e86\u7528\u6237\u53cd\u9988 #6\uff1a\u672f\u8bed\u7ffb\u8bd1\u4e0d\u4e13\u4e1a / \u4e8b\u5b9e\u9519\u8bef
- ThreadPoolExecutor \u5e76\u53d1\uff08\u9ed8\u8ba4 6 worker\uff09\uff0c\u6bcf\u4e2a\u672f\u8bed\u72ec\u7acb\uff1a
  Search \u2192 Top-3 snippet \u2192 Haiku \u5224\u5b9a \u2192 \u8fd4\u56de {zh, en_full, confidence, issue}
- \u5b9e\u6d4b\u6210\u529f\u8bc6\u522b "Maywavee" \u4e3a "Mabwell" \u7684\u62fc\u5199\u9519\u8bef\u5e76\u6807\u51fa issue
- \u65ad\u70b9\u7eed\u4f20\uff08\u5df2\u6807 verified_at \u7684\u9ed8\u8ba4\u8df3\u8fc7\uff09
- Haiku \u6210\u672c\u6781\u4f4e\uff083 \u4e2a\u672f\u8bed\u8c03\u7528 \u2248 0.01 \u7f8e\u5206\uff09
- \u652f\u6301 --extra terms.txt \u8865\u5145\u7ffb\u8bd1\u9636\u6bb5\u672a\u6536\u5165\u7684\u672f\u8bed

scripts/prompts/glossary_system.txt\uff08\u65b0\u589e\uff09\uff1a
- Haiku \u6838\u67e5\u672f\u8bed\u7684 prompt\uff0c\u660e\u786e\u5224\u5b9a\u7ef4\u5ea6\u548c JSON \u8f93\u51fa\u683c\u5f0f

Co-authored-by: User <human>
2026-04-22 13:12:01 +08:00

239 lines
7.7 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
"""Phase 4 成稿阶段:统一入口。
从 final_zh_polished.md(或指定的 Markdown+ manifest.json 生成:
- <title>.pdf ReportLab 出中文 PDF
- <title>.docx Pandoc 出 DOCX
- <title>-en.pdf 如果存在 final_en.md 也一并出英文版(可选)
文件名来自 manifest.report_title(去掉非法字符),不再用 "final.pdf" 这种通用名。
用法:
uv run python scripts/build_report.py <project_slug>
# 只生成 PDF
uv run python scripts/build_report.py <project_slug> --no-docx
# 从自定义 md 生成:
uv run python scripts/build_report.py <project_slug> --input phase4/final_zh.md
环境依赖:
- reportlab, pypandoc, 思源字体(bash .opencode/templates/fonts/download-fonts.sh
- pandoc 可执行文件在 PATH
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scripts.lib.zenmux_client import load_secrets # noqa: F401 (为一致性)
REPO_ROOT = Path(__file__).resolve().parent.parent
PDF_TEMPLATE = REPO_ROOT / ".opencode" / "templates" / "report-template.py"
DEFAULT_FONTS_DIR = REPO_ROOT / ".opencode" / "templates" / "fonts"
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}")
# 文件系统对文件名的常见限制:Windows 更严格,按最小公倍数来
_FS_ILLEGAL_RE = re.compile(r'[\\/:*?"<>|\r\n\t]+')
_WHITESPACE_RE = re.compile(r"\s+")
def sanitize_filename(name: str, max_len: int = 120) -> str:
"""把报告标题变成可跨 OS 使用的文件名。"""
if not name:
return "report"
# 去掉非法字符
cleaned = _FS_ILLEGAL_RE.sub(" ", name)
# 合并空白
cleaned = _WHITESPACE_RE.sub(" ", cleaned).strip()
# 首尾 . 空格 . Windows 要求)
cleaned = cleaned.strip(". ").strip()
if len(cleaned) > max_len:
cleaned = cleaned[:max_len].rstrip()
return cleaned or "report"
def load_manifest(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def determine_input_md(project_root: Path, preferred: str | None) -> Path:
"""决定用哪个 Markdown 出稿。
优先级:--input 指定 > final_zh_polished.md > final_zh.md > final_en.md
"""
if preferred:
p = project_root / preferred
if not p.exists():
raise SystemExit(f"指定的 --input 不存在:{p}")
return p
for candidate in (
"phase4/final_zh_polished.md",
"phase4/final_zh.md",
"phase4/final_en.md",
):
p = project_root / candidate
if p.exists():
return p
raise SystemExit(
f"找不到任何 Markdown 源。项目根:{project_root}\n"
"先跑 translate.py / polish.py 或使用 --input 指定路径。"
)
def build_pdf(
md_path: Path,
manifest_path: Path,
output_pdf: Path,
fonts_dir: Path,
sources_path: Path | None,
) -> None:
"""调用 report-template.py 生成 PDF。"""
cmd = [
sys.executable,
str(PDF_TEMPLATE),
"--input", str(md_path),
"--manifest", str(manifest_path),
"--output", str(output_pdf),
"--fonts-dir", str(fonts_dir),
]
if sources_path and sources_path.exists():
cmd += ["--sources", str(sources_path)]
print(f"\n→ 生成 PDF{output_pdf.name}")
result = subprocess.run(cmd, check=False)
if result.returncode != 0:
raise SystemExit(f"PDF 生成失败,返回码 {result.returncode}")
def build_docx(md_path: Path, output_docx: Path, title: str) -> None:
"""用 pandoc 生成 DOCX。"""
if not shutil.which("pandoc"):
print(f" ⚠ pandoc 不在 PATH,跳过 DOCX 生成", file=sys.stderr)
return
print(f"\n→ 生成 DOCX{output_docx.name}")
# 关闭 tex_math_dollars/tex_math_single_backslash 防止文中 "$100" "$10^6" 被当数学公式
cmd = [
"pandoc",
str(md_path),
"-o", str(output_docx),
"--from=markdown-tex_math_dollars-tex_math_single_backslash-raw_tex",
"--to=docx",
"--standalone",
"-M", f"title={title}",
"--wrap=preserve",
]
# reference-doc 如果存在就用
ref_doc = REPO_ROOT / ".opencode" / "templates" / "reference.docx"
if ref_doc.exists():
cmd += ["--reference-doc", str(ref_doc)]
result = subprocess.run(cmd, check=False)
if result.returncode != 0:
print(f" ⚠ DOCX 生成失败(返回码 {result.returncode}),但不阻断流程", file=sys.stderr)
def main() -> int:
parser = argparse.ArgumentParser(description="Phase 4 成稿(PDF + DOCX")
parser.add_argument("project", help="项目 slug 或完整路径")
parser.add_argument(
"--input",
default=None,
help="Markdown 源(默认自动寻找 phase4/final_zh_polished.md",
)
parser.add_argument(
"--output-dir",
default="phase4",
help="输出目录(相对项目根,默认 phase4)",
)
parser.add_argument(
"--fonts-dir",
default=str(DEFAULT_FONTS_DIR),
help="字体目录",
)
parser.add_argument(
"--sources",
default=None,
help="sources.jsonl 路径(默认 phase2/sources.jsonl",
)
parser.add_argument("--no-docx", action="store_true", help="跳过 DOCX 生成")
parser.add_argument("--no-pdf", action="store_true", help="跳过 PDF 生成")
parser.add_argument(
"--basename",
default=None,
help="文件名 stem(不带扩展名),默认从 manifest.report_title 生成",
)
args = parser.parse_args()
project_root = resolve_project(args.project)
manifest_path = project_root / "manifest.json"
if not manifest_path.exists():
raise SystemExit(f"manifest.json not found: {manifest_path}")
manifest = load_manifest(manifest_path)
md_path = determine_input_md(project_root, args.input)
title = manifest.get("report_title") or manifest.get("topic") or "Deep Research Report"
basename = args.basename or sanitize_filename(title)
output_dir = project_root / args.output_dir
output_dir.mkdir(parents=True, exist_ok=True)
pdf_path = output_dir / f"{basename}.pdf"
docx_path = output_dir / f"{basename}.docx"
fonts_dir = Path(args.fonts_dir)
if not fonts_dir.is_absolute():
fonts_dir = REPO_ROOT / fonts_dir
sources_path: Path | None = None
if args.sources:
sources_path = Path(args.sources)
else:
default_src = project_root / "phase2" / "sources.jsonl"
if default_src.exists():
sources_path = default_src
print("========== Phase 4 成稿 ==========")
print(f"项目: {project_root.name}")
print(f"Markdown {md_path.relative_to(project_root)}")
print(f"标题: {title}")
print(f"输出名: {basename}")
print(f"字体目录: {fonts_dir}")
print(f"Sources {sources_path if sources_path else '(缺失)'}")
if not args.no_pdf:
build_pdf(md_path, manifest_path, pdf_path, fonts_dir, sources_path)
if not args.no_docx:
build_docx(md_path, docx_path, title)
print("\n========== 完成 ==========")
if pdf_path.exists():
print(f" PDF: {pdf_path.relative_to(project_root)} ({pdf_path.stat().st_size // 1024} KB)")
if docx_path.exists():
print(f" DOCX: {docx_path.relative_to(project_root)} ({docx_path.stat().st_size // 1024} KB)")
return 0
if __name__ == "__main__":
sys.exit(main())