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>
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user