Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
641 lines
30 KiB
Python
641 lines
30 KiB
Python
"""
|
||
实验 5-4:基于论文的 PPT 自动生成(提议者-审核者机制)
|
||
|
||
完整流程:
|
||
1. 从精简论文(paper/sample_paper.md)+ 程序化复现的图表出发;
|
||
2. 【双 Agent】Proposer 生成 slides.md → Slidev 渲染每页 PNG → Reviewer 用 Vision LLM
|
||
看图给出结构化建议 → Proposer 据反馈修订 → 迭代,直到 pass 或达最大轮数;
|
||
3. 【单 Agent 自审】同一个 Agent 生成 → 渲染 → 把自己的截图塞回**同一上下文**自审并修订 → 迭代;
|
||
4. 用同一位“独立评委”(Vision)给两种方案的最终 PPT 打分,公平比较**质量**;
|
||
5. 打印两种方案的**上下文 token 消耗**对比(总量、峰值单次 prompt token)。
|
||
|
||
运行:python demo.py # 完整对比(两种方案)
|
||
python demo.py --help # 查看全部参数
|
||
python demo.py --mode dual --max-rounds 1 # 快速:只跑双 Agent、只出首版
|
||
python demo.py --smoke # 仅验证 Slidev 渲染链路,不调用任何 LLM
|
||
python demo.py --dry-run # 离线走通提议者-审核者循环(真实渲染 + 脚本化改稿)
|
||
依赖:Node/Slidev(渲染)、OPENAI_API_KEY(gpt-5.6-luna 视觉 + 文本;未配置时可用 OPENROUTER_API_KEY 兜底)。
|
||
"""
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import sys
|
||
|
||
from dotenv import load_dotenv
|
||
|
||
load_dotenv()
|
||
|
||
import agents # noqa: E402 —— 用模块名引用 TEXT_MODEL/VISION_MODEL,便于 CLI 覆盖
|
||
from agents import ( # noqa: E402
|
||
Proposer, Reviewer, SelfReviewAgent, TokenMeter, independent_judge,
|
||
)
|
||
from make_figures import generate_all # noqa: E402
|
||
from paper_source import PAPER, prepare_real_paper # noqa: E402
|
||
from renderer import render_slides # noqa: E402
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
DEFAULT_PAPER_PATH = os.path.join(HERE, "paper", "sample_paper.md")
|
||
DEFAULT_OUT_DIR = os.path.join(HERE, "output")
|
||
OUT_DIR = DEFAULT_OUT_DIR # 可被 --out-dir 覆盖(main 内 global 赋值)
|
||
MAX_ROUNDS = 3 # 每种方案的最大迭代轮数(首轮 + 最多 2 轮修订)
|
||
|
||
|
||
def banner(title):
|
||
print("\n" + "=" * 74)
|
||
print(f" {title}")
|
||
print("=" * 74)
|
||
|
||
|
||
def save_text(name, text):
|
||
os.makedirs(OUT_DIR, exist_ok=True)
|
||
path = os.path.join(OUT_DIR, name)
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.write(text)
|
||
return path
|
||
|
||
|
||
def retain_rendered(pngs, subdir):
|
||
"""Copy rendered pixels into the immutable output tree used as evidence."""
|
||
destination = os.path.join(OUT_DIR, "rendered", subdir)
|
||
os.makedirs(destination, exist_ok=True)
|
||
retained = []
|
||
for source in pngs:
|
||
target = os.path.join(destination, os.path.basename(source))
|
||
shutil.copyfile(source, target)
|
||
retained.append(target)
|
||
return retained
|
||
|
||
|
||
def file_sha256(path):
|
||
digest = hashlib.sha256()
|
||
with open(path, "rb") as handle:
|
||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||
digest.update(chunk)
|
||
return digest.hexdigest()
|
||
|
||
|
||
def _review_issues(review: dict) -> list:
|
||
"""Return issue dicts; null/non-list → []; skip non-dict entries."""
|
||
issues = review.get("issues")
|
||
if issues is None:
|
||
return []
|
||
if not isinstance(issues, list):
|
||
return []
|
||
return [i for i in issues if isinstance(i, dict)]
|
||
|
||
|
||
def summarize_review(review: dict) -> str:
|
||
n_high = sum(1 for x in _review_issues(review) if x.get("severity") == "high")
|
||
n_med = sum(1 for x in _review_issues(review) if x.get("severity") == "medium")
|
||
n_low = sum(1 for x in _review_issues(review) if x.get("severity") == "low")
|
||
return (f"score={review.get('overall_score')} pass={review.get('pass')} "
|
||
f"issues={len(_review_issues(review))} (high={n_high}, med={n_med}, low={n_low})")
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 方案 A:提议者-审核者(双 Agent)
|
||
# --------------------------------------------------------------------------- #
|
||
def run_proposer_reviewer(paper_md, figures, max_rounds=MAX_ROUNDS):
|
||
banner("方案 A:提议者-审核者(双 Agent 分工)")
|
||
proposer_meter = TokenMeter("Proposer(纯文本)")
|
||
reviewer_meter = TokenMeter("Reviewer(每轮只看最新截图)")
|
||
|
||
proposer = Proposer(proposer_meter, paper_md, figures)
|
||
reviewer = Reviewer(reviewer_meter)
|
||
|
||
history = [] # 每轮的 (score, review)
|
||
slides = proposer.propose()
|
||
final_pngs = None
|
||
|
||
for rnd in range(1, max_rounds + 1):
|
||
print(f"\n[双 Agent] 第 {rnd} 轮:Proposer 产出 slides.md({slides.count(chr(10) + '---' + chr(10)) + 1} 段分隔)")
|
||
md_path = save_text(f"dual_round{rnd}_slides.md", slides)
|
||
pngs = retain_rendered(
|
||
render_slides(slides, f"dual_round{rnd}"), f"dual_round{rnd}"
|
||
)
|
||
final_pngs = pngs
|
||
print(f" 渲染出 {len(pngs)} 页 PNG,例如:{pngs[0]}")
|
||
|
||
review = reviewer.review(pngs)
|
||
print(f" Reviewer(Vision)审查:{summarize_review(review)}")
|
||
# 打印真实的建议 JSON(前几条)
|
||
print(" Reviewer 结构化建议 JSON:")
|
||
print(_indent(json.dumps(review, ensure_ascii=False, indent=2), 4))
|
||
save_text(f"dual_round{rnd}_review.json",
|
||
json.dumps(review, ensure_ascii=False, indent=2))
|
||
history.append((review.get("overall_score", 0), review))
|
||
|
||
blocking = [i for i in _review_issues(review)
|
||
if i.get("severity") in ("high", "medium")]
|
||
if review.get("pass") and not blocking:
|
||
print(" ✓ Reviewer 判定达标(无 high/medium 问题),提前结束迭代。")
|
||
break
|
||
if rnd == max_rounds:
|
||
break
|
||
|
||
print(" → Proposer 接收结构化文字反馈并修订(上下文只增文本,不含图片)")
|
||
slides = proposer.revise(review)
|
||
|
||
return {
|
||
"slides": slides,
|
||
"final_pngs": final_pngs,
|
||
"history": history,
|
||
"proposer_meter": proposer_meter,
|
||
"reviewer_meter": reviewer_meter,
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 方案 B:单 Agent 自审
|
||
# --------------------------------------------------------------------------- #
|
||
def run_single_agent(paper_md, figures, max_rounds=MAX_ROUNDS):
|
||
banner("方案 B:单 Agent 自我审查(图片累积在同一上下文)")
|
||
meter = TokenMeter("SingleAgent(自审, 图片累积)")
|
||
agent = SelfReviewAgent(meter, paper_md, figures)
|
||
|
||
slides = agent.propose()
|
||
final_pngs = None
|
||
|
||
for rnd in range(1, max_rounds + 1):
|
||
print(f"\n[单 Agent] 第 {rnd} 轮:生成/修订 slides.md")
|
||
save_text(f"single_round{rnd}_slides.md", slides)
|
||
pngs = retain_rendered(
|
||
render_slides(slides, f"single_round{rnd}"), f"single_round{rnd}"
|
||
)
|
||
final_pngs = pngs
|
||
print(f" 渲染出 {len(pngs)} 页 PNG")
|
||
print(f" 当前上下文峰值 prompt token = {meter.peak_prompt_tokens}")
|
||
|
||
if rnd == max_rounds:
|
||
break
|
||
print(" → 把 %d 张截图塞回同一上下文,Agent 自审并修订(历史图片不清除)" % len(pngs))
|
||
slides = agent.self_review_and_revise(pngs)
|
||
|
||
return {"slides": slides, "final_pngs": final_pngs, "meter": meter}
|
||
|
||
|
||
def _indent(text, n):
|
||
pad = " " * n
|
||
return "\n".join(pad + line for line in text.splitlines())
|
||
|
||
|
||
def smoke_test():
|
||
"""快速冒烟:只验证 Slidev 渲染链路是否可用,不调用任何 LLM,无需 API Key。"""
|
||
from renderer import render_slides
|
||
banner("Smoke test:仅验证 Slidev 渲染链路(不调用 LLM)")
|
||
demo_md = (
|
||
"---\ntheme: default\n---\n\n"
|
||
"# Smoke Test\n\n渲染链路自检\n\n---\n\n"
|
||
"# 第二页\n\n- Slidev + playwright-chromium 正常\n"
|
||
)
|
||
pngs = render_slides(demo_md, "smoke")
|
||
print(f"✓ 渲染成功,产出 {len(pngs)} 页 PNG:")
|
||
for p in pngs:
|
||
print(" ", p)
|
||
print("Slidev 渲染链路可用。")
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 离线 dry-run:不调用任何 LLM,走通提议者-审核者循环的**结构**。
|
||
# - Proposer 的两版稿件是脚本化的(拥挤初稿 → 拆页修订稿),而非 LLM 生成;
|
||
# - 渲染是**真实**的(真的调 Slidev 导出 PNG);
|
||
# - Reviewer 用**确定性启发式规则**(按每页文字量判定 overcrowded),
|
||
# 明确不是 Vision LLM——仅用于离线演示“生成→渲染→审查→修订”的闭环。
|
||
# 真实的 Vision 审查请用 `python demo.py`(需 OPENAI_API_KEY)。
|
||
# --------------------------------------------------------------------------- #
|
||
def _split_paragraphs(paper_md: str) -> list[str]:
|
||
"""按空行切出正文段落,剔除标题行与表格/图片,供脚本化排版使用。"""
|
||
paras = []
|
||
for block in re.split(r"\n\s*\n", paper_md):
|
||
block = block.strip()
|
||
if not block or block.startswith("#") or block.startswith("|"):
|
||
continue
|
||
paras.append(re.sub(r"\s+", " ", block))
|
||
return paras
|
||
|
||
|
||
def _paper_title(paper_md: str) -> str:
|
||
m = re.search(r"^#\s+(.+)$", paper_md, re.MULTILINE)
|
||
return m.group(1).strip() if m else "论文演示"
|
||
|
||
|
||
def _dry_first_draft(paper_md: str, figures: dict) -> str:
|
||
"""脚本化“拥挤初稿”:把整篇论文压进约 4 页,每页塞多段原文(必然溢出)。"""
|
||
title = _paper_title(paper_md)
|
||
paras = _split_paragraphs(paper_md) or ["(论文正文为空)"]
|
||
fig_names = list(figures.keys())
|
||
# 把段落尽量塞进 3 张内容页
|
||
groups, per = [], max(1, (len(paras) + 2) // 3)
|
||
for i in range(0, len(paras), per):
|
||
groups.append(paras[i:i + per])
|
||
pages = [f"---\ntheme: default\n---\n\n# {title}\n\n自动生成演示(离线 dry-run 初稿)"]
|
||
for gi, g in enumerate(groups[:3]):
|
||
body = "\n\n".join(g)
|
||
img = f"\n\n" if gi < len(fig_names) else ""
|
||
pages.append(f"# 第 {gi + 1} 部分\n\n{body}{img}")
|
||
return "\n\n---\n\n".join(pages) + "\n"
|
||
|
||
|
||
def _dry_revised(paper_md: str, figures: dict) -> str:
|
||
"""脚本化“修订稿”:一段一页、要点化,图表单独成页——明显更宽松,可通过启发式。"""
|
||
title = _paper_title(paper_md)
|
||
paras = _split_paragraphs(paper_md) or ["(论文正文为空)"]
|
||
fig_names = list(figures.keys())
|
||
pages = [f"---\ntheme: default\n---\n\n# {title}\n\n自动生成演示(离线 dry-run 修订稿)"]
|
||
for i, para in enumerate(paras):
|
||
# 每页只放一段,且截断到约 220 字,模拟“精简成要点”
|
||
text = para if len(para) <= 220 else para[:210].rstrip() + "……"
|
||
pages.append(f"# 要点 {i + 1}\n\n{text}")
|
||
for name in fig_names: # 图表各自单独成页,尺寸受控
|
||
pages.append(f"# 图表\n\n<img src=\"{name}\" class=\"h-80 mx-auto\" />")
|
||
return "\n\n---\n\n".join(pages) + "\n"
|
||
|
||
|
||
def _heuristic_review(slides_md: str) -> dict:
|
||
"""确定性启发式(非 Vision LLM):按每页正文字符数判定 overcrowded。"""
|
||
parts = re.split(r"(?m)^---\s*$", slides_md)
|
||
pages, page_no = [], 0
|
||
for part in parts:
|
||
s = part.strip()
|
||
if not s or s.startswith("theme:") or "theme:" in s.split("\n")[0]:
|
||
continue
|
||
pages.append(s)
|
||
issues = []
|
||
for idx, page in enumerate(pages, 1):
|
||
text = re.sub(r"!\[.*?\]\(.*?\)|<img[^>]*>", "", page) # 不计图片
|
||
n = len(re.sub(r"\s+", "", text))
|
||
if n > 500:
|
||
issues.append({"page": idx, "issue_type": "overcrowded", "severity": "high",
|
||
"suggestion": f"该页正文约 {n} 字,严重溢出,建议拆成多页并精简为要点。"})
|
||
elif n > 300:
|
||
issues.append({"page": idx, "issue_type": "overcrowded", "severity": "medium",
|
||
"suggestion": f"该页正文约 {n} 字,偏挤,建议拆页或删减。"})
|
||
blocking = [i for i in issues if i["severity"] in ("high", "medium")]
|
||
score = max(0, 100 - 15 * len(blocking) - 3 * (len(issues) - len(blocking)))
|
||
return {"overall_score": score, "pass": not blocking, "issues": issues,
|
||
"_reviewer": "heuristic (offline, NOT a Vision LLM)"}
|
||
|
||
|
||
def dry_run(paper_path: str):
|
||
"""离线走通提议者-审核者循环:真实渲染 + 脚本化改稿 + 启发式审查。"""
|
||
banner("Dry-run:离线演示提议者-审核者循环(真实渲染,脚本化改稿,启发式审查)")
|
||
if not os.path.exists(paper_path):
|
||
print(f"找不到论文文件:{paper_path}")
|
||
sys.exit(1)
|
||
with open(paper_path, encoding="utf-8") as f:
|
||
paper_md = f.read()
|
||
figures = generate_all()
|
||
print(f"论文:{paper_path}({len(paper_md)} 字符);已复现图表:{', '.join(figures)}")
|
||
print("注意:本模式不调用任何 LLM。Reviewer 由确定性启发式规则扮演(非 Vision LLM),")
|
||
print(" 仅用于离线展示“生成→渲染→审查→修订”的闭环;真实 Vision 审查请用 `python demo.py`。")
|
||
|
||
stages = [
|
||
("拥挤初稿", _dry_first_draft(paper_md, figures)),
|
||
("拆页修订稿", _dry_revised(paper_md, figures)),
|
||
]
|
||
last_review = None
|
||
for rnd, (label, slides) in enumerate(stages, 1):
|
||
n_pages = slides.count("\n---\n") # 页分隔符数量≈页数
|
||
print(f"\n[dry-run] 第 {rnd} 轮:Proposer 产出 slides.md({label},约 {n_pages} 页)")
|
||
save_text(f"dryrun_round{rnd}_slides.md", slides)
|
||
pngs = render_slides(slides, f"dryrun_round{rnd}")
|
||
print(f" 渲染出 {len(pngs)} 页 PNG,例如:{pngs[0]}")
|
||
review = _heuristic_review(slides)
|
||
print(f" Reviewer(启发式)审查:{summarize_review(review)}")
|
||
print(" Reviewer 结构化建议 JSON:")
|
||
print(_indent(json.dumps(review, ensure_ascii=False, indent=2), 4))
|
||
save_text(f"dryrun_round{rnd}_review.json",
|
||
json.dumps(review, ensure_ascii=False, indent=2))
|
||
last_review = review
|
||
if review["pass"]:
|
||
print(" ✓ Reviewer 判定达标(无 high/medium 问题),闭环结束。")
|
||
break
|
||
if rnd < len(stages):
|
||
print(" → Proposer 接收结构化文字反馈并修订(拆页、精简;此处为脚本化改稿)")
|
||
|
||
banner("Dry-run 小结")
|
||
print(f"闭环演示完成:初稿被判定拥挤 → 修订稿 pass={last_review['pass']}"
|
||
f"(启发式打分 {last_review['overall_score']})。")
|
||
print(f"真实渲染 PNG:slidev_workspace/exports/dryrun_round*/")
|
||
print(f"脚本化 slides.md 与审查 JSON:{OUT_DIR}/dryrun_round*")
|
||
print("真实的 Vision 审查循环(gpt-5.6-luna 看像素)请运行:python demo.py --mode dual --max-rounds 3")
|
||
|
||
|
||
def parse_args(argv=None):
|
||
p = argparse.ArgumentParser(
|
||
prog="demo.py",
|
||
description="实验 5-4:论文 → PPT 自动生成(提议者-审核者 vs 单 Agent 自审对照)",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog=(
|
||
"示例:\n"
|
||
" python demo.py # 完整对比:两种方案 + 独立评委 + token 对比\n"
|
||
" python demo.py --mode dual # 只跑双 Agent(省一半时间/费用)\n"
|
||
" python demo.py --max-rounds 1 # 每种方案只出首版(最快的真实 LLM 冒烟)\n"
|
||
" python demo.py --paper my.md --out-dir run1 # 换论文、换输出目录\n"
|
||
" python demo.py --vision-model gpt-5.6-luna # 覆盖视觉模型\n"
|
||
" python demo.py --dry-run # 离线走通提议者-审核者循环,不调用任何 LLM\n"
|
||
" python demo.py --smoke # 仅验证 Slidev 渲染,不调用任何 LLM\n\n"
|
||
"模型/供应商也可通过环境变量配置(见 env.example);命令行 --text-model /\n"
|
||
"--vision-model 优先级更高:OPENAI_API_KEY / OPENAI_BASE_URL / TEXT_MODEL / VISION_MODEL"
|
||
),
|
||
)
|
||
p.add_argument("--paper", metavar="PATH", default=None,
|
||
help="非正式兼容入口:使用本地 Markdown。省略时使用固定哈希的真实 arXiv PDF;"
|
||
"本地 Markdown 运行永远不会通过正式实验门禁。")
|
||
p.add_argument("--out-dir", metavar="DIR", default=DEFAULT_OUT_DIR,
|
||
help="产物输出目录:各轮 slides.md / review.json / comparison_summary.json "
|
||
"(默认 output/)。渲染 PNG 始终位于 slidev_workspace/exports/。")
|
||
p.add_argument("--text-model", metavar="NAME", default=None,
|
||
help="Proposer / 单 Agent 文本部分用的模型,覆盖 TEXT_MODEL 环境变量"
|
||
f"(默认 {agents.TEXT_MODEL})。")
|
||
p.add_argument("--vision-model", metavar="NAME", default=None,
|
||
help="Reviewer / 独立评委看图用的模型,必须支持图像输入,覆盖 VISION_MODEL "
|
||
f"环境变量(默认 {agents.VISION_MODEL})。")
|
||
p.add_argument(
|
||
"--provider", choices=["auto", "openai", "openrouter", "moonshot", "ark"],
|
||
default="auto", help="文本与 Vision 调用的真实 API 提供商",
|
||
)
|
||
p.add_argument("--mode", choices=["both", "dual", "single"], default="both",
|
||
help="运行哪种方案:both=两种都跑并对比(默认);dual=仅提议者-审核者;"
|
||
"single=仅单 Agent 自审。只跑一种可显著省时省钱。")
|
||
p.add_argument("--max-rounds", type=int, default=MAX_ROUNDS, metavar="N",
|
||
help=f"每种方案的最大迭代轮数(默认 {MAX_ROUNDS})。设为 1 即只出首版、"
|
||
"不修订,是最快的真实运行冒烟。")
|
||
p.add_argument("--dry-run", action="store_true",
|
||
help="离线演示提议者-审核者循环:真实渲染两版脚本化 slides.md(拥挤初稿→"
|
||
"拆页修订稿),用启发式规则(非 Vision LLM)扮演 Reviewer,展示"
|
||
"生成→渲染→审查→修订的闭环结构。不调用任何 LLM,无需 API Key。")
|
||
p.add_argument("--smoke", action="store_true",
|
||
help="仅验证 Slidev 渲染链路(渲染一个两页 deck),不调用任何 LLM,无需 API Key。")
|
||
return p.parse_args(argv)
|
||
|
||
|
||
def _save_partial_summary(dual, dual_final, single, single_final, source_info, args, judge_meter):
|
||
"""单方案运行(--mode dual/single)时,落盘该方案自身的质量与 token 结果。"""
|
||
summary = {
|
||
"experiment": "5-4",
|
||
"official_complete": False,
|
||
"completion": {"campaign_complete": False, "reason": "both comparison arms are required"},
|
||
"provider": args.provider,
|
||
"models": {"text": agents.TEXT_MODEL, "vision": agents.VISION_MODEL},
|
||
"source": source_info,
|
||
"independent_judge": judge_meter.__dict__,
|
||
}
|
||
if dual:
|
||
pm, rm = dual["proposer_meter"], dual["reviewer_meter"]
|
||
summary["dual_agent"] = {
|
||
"iteration_scores": [h[0] for h in dual["history"]],
|
||
"final_quality": dual_final,
|
||
"total_tokens": pm.total_tokens + rm.total_tokens,
|
||
"peak_context_prompt_tokens": max(pm.peak_prompt_tokens, rm.peak_prompt_tokens),
|
||
"proposer_receipts": pm.receipts,
|
||
"reviewer_receipts": rm.receipts,
|
||
}
|
||
if single:
|
||
sm = single["meter"]
|
||
summary["single_agent"] = {
|
||
"final_quality": single_final,
|
||
"total_tokens": sm.total_tokens,
|
||
"peak_context_prompt_tokens": sm.peak_prompt_tokens,
|
||
"receipts": sm.receipts,
|
||
}
|
||
p = save_text("comparison_summary.json", json.dumps(summary, ensure_ascii=False, indent=2))
|
||
print(f"\n结果已保存:{p}")
|
||
print(f"所有 slides.md / review.json / 渲染 PNG 位于:{OUT_DIR}/ 与 slidev_workspace/exports/")
|
||
|
||
|
||
def main(argv=None):
|
||
global OUT_DIR
|
||
args = parse_args(argv)
|
||
|
||
# 输出目录(--out-dir):所有 save_text 都写到这里
|
||
OUT_DIR = os.path.abspath(args.out_dir)
|
||
os.makedirs(OUT_DIR, exist_ok=True)
|
||
os.environ["PPT_RECEIPT_CHECKPOINT"] = os.path.join(
|
||
OUT_DIR, "receipts.checkpoint.json"
|
||
)
|
||
# 模型覆盖(--text-model / --vision-model 优先于环境变量)
|
||
if args.text_model:
|
||
agents.TEXT_MODEL = args.text_model
|
||
if args.vision_model:
|
||
agents.VISION_MODEL = args.vision_model
|
||
agents.configure_provider(args.provider)
|
||
|
||
if args.smoke:
|
||
smoke_test()
|
||
return
|
||
if args.dry_run:
|
||
dry_run(args.paper or DEFAULT_PAPER_PATH)
|
||
return
|
||
if args.max_rounds < 1:
|
||
print("--max-rounds 至少为 1")
|
||
sys.exit(1)
|
||
if args.paper and not os.path.exists(args.paper):
|
||
print(f"找不到论文文件:{args.paper}(用 --paper 指定,或参考默认 paper/sample_paper.md)")
|
||
sys.exit(1)
|
||
provider_keys = {
|
||
"ark": "ARK_API_KEY", "moonshot": "MOONSHOT_API_KEY",
|
||
"openrouter": "OPENROUTER_API_KEY", "openai": "OPENAI_API_KEY",
|
||
}
|
||
required_key = provider_keys.get(args.provider)
|
||
if required_key and not os.environ.get(required_key):
|
||
print(f"请先设置 {required_key}(可参考 env.example)")
|
||
sys.exit(1)
|
||
if args.provider == "auto" and not any(os.environ.get(key) for key in (
|
||
"OPENAI_API_KEY", "OPENROUTER_API_KEY"
|
||
)):
|
||
print("auto provider 需要 OPENAI_API_KEY 或 OPENROUTER_API_KEY")
|
||
sys.exit(1)
|
||
|
||
banner("准备:固定真实论文 PDF + 原论文图")
|
||
if args.paper:
|
||
with open(args.paper, encoding="utf-8") as f:
|
||
paper_md = f.read()
|
||
figures = generate_all()
|
||
source_info = {
|
||
"canonical": False,
|
||
"reason": "legacy local Markdown and programmatic figures",
|
||
"paper_path": os.path.abspath(args.paper),
|
||
}
|
||
else:
|
||
prepared = prepare_real_paper(
|
||
OUT_DIR, os.path.join(HERE, "slidev_workspace", "public")
|
||
)
|
||
paper_md = prepared["paper_text"]
|
||
figures = prepared["figures"]
|
||
source_info = {
|
||
"canonical": True,
|
||
"paper": prepared["manifest"]["paper"],
|
||
"paper_text": prepared["manifest"]["paper_text"],
|
||
"visuals": prepared["manifest"]["visuals"],
|
||
"manifest_path": prepared["manifest_path"],
|
||
"pdf_path": prepared["pdf_path"],
|
||
}
|
||
print(f"论文:{PAPER['title']}(提取文本 {len(paper_md)} 字符)")
|
||
print(f"输出目录:{OUT_DIR}")
|
||
print(f"文本模型:{agents.TEXT_MODEL} 视觉模型:{agents.VISION_MODEL}")
|
||
print(f"运行模式:{args.mode} 最大轮数:{args.max_rounds}")
|
||
print("已生成图表:")
|
||
for k, v in figures.items():
|
||
print(f" {k} -> {v}")
|
||
|
||
# 方案 A / 方案 B(--mode 控制跑哪一种;只有 both 才能做跨方案对比)
|
||
dual = run_proposer_reviewer(paper_md, figures, args.max_rounds) \
|
||
if args.mode in ("both", "dual") else None
|
||
single = run_single_agent(paper_md, figures, args.max_rounds) \
|
||
if args.mode in ("both", "single") else None
|
||
|
||
# ------- 用同一位独立评委给两种方案的最终 PPT 打分(质量对比,尽量公平) -------
|
||
banner("独立评委:对最终 PPT 打分(同一 Vision rubric)")
|
||
judge_meter = TokenMeter("独立评委(不计入两方案成本)")
|
||
dual_final = independent_judge(dual["final_pngs"], judge_meter) if dual else None
|
||
single_final = independent_judge(single["final_pngs"], judge_meter) if single else None
|
||
if dual_final:
|
||
print(f"方案 A(双 Agent)最终质量:{summarize_review(dual_final)}")
|
||
if single_final:
|
||
print(f"方案 B(单 Agent)最终质量:{summarize_review(single_final)}")
|
||
|
||
if not (dual and single):
|
||
# 单方案运行:跳过跨方案的 token 对比,仅落盘已有结果
|
||
_save_partial_summary(
|
||
dual, dual_final, single, single_final, source_info, args, judge_meter
|
||
)
|
||
return
|
||
|
||
# ------- 迭代改善情况(双 Agent) -------
|
||
banner("迭代质量改善(方案 A:提议者-审核者)")
|
||
scores = [h[0] for h in dual["history"]]
|
||
if len(scores) >= 2:
|
||
print(f"Reviewer 打分随迭代变化:{scores} "
|
||
f"({'↑ 改善' if scores[-1] >= scores[0] else '↓'} {scores[-1] - scores[0]:+d})")
|
||
else:
|
||
print(f"仅 1 轮即达标,Reviewer 打分:{scores}")
|
||
|
||
# ------- 上下文 token 消耗对比 -------
|
||
banner("上下文 Token 消耗对比:单 Agent 自审 vs 提议者-审核者")
|
||
pm, rm, sm = dual["proposer_meter"], dual["reviewer_meter"], single["meter"]
|
||
dual_total = pm.total_tokens + rm.total_tokens
|
||
dual_peak = max(pm.peak_prompt_tokens, rm.peak_prompt_tokens)
|
||
|
||
def row(label, calls, prompt, completion, total, peak):
|
||
print(f" {label:<34} calls={calls:<3} prompt={prompt:<8} "
|
||
f"completion={completion:<7} total={total:<8} peak_ctx={peak}")
|
||
|
||
print("双 Agent(方案 A)拆分:")
|
||
row(pm.name, pm.calls, pm.prompt_tokens, pm.completion_tokens, pm.total_tokens, pm.peak_prompt_tokens)
|
||
row(rm.name, rm.calls, rm.prompt_tokens, rm.completion_tokens, rm.total_tokens, rm.peak_prompt_tokens)
|
||
print("-" * 74)
|
||
row("【方案 A 合计】", pm.calls + rm.calls, pm.prompt_tokens + rm.prompt_tokens,
|
||
pm.completion_tokens + rm.completion_tokens, dual_total, dual_peak)
|
||
row("【方案 B 单Agent自审】", sm.calls, sm.prompt_tokens, sm.completion_tokens,
|
||
sm.total_tokens, sm.peak_prompt_tokens)
|
||
print("-" * 74)
|
||
print(f"每次调用的 prompt token 序列:")
|
||
print(f" 方案A Proposer : {pm.per_call_prompt}")
|
||
print(f" 方案A Reviewer : {rm.per_call_prompt} ← 每轮独立、只看最新截图,不随迭代累积")
|
||
print(f" 方案B 单Agent : {sm.per_call_prompt} ← 图片累积在同一上下文,峰值随迭代上升")
|
||
print()
|
||
print(f"关键结论:")
|
||
print(f" · 上下文峰值(单次 prompt token,决定是否撑爆上下文窗口):")
|
||
print(f" 方案 A = {dual_peak} 方案 B = {sm.peak_prompt_tokens} "
|
||
f"(B/A = {sm.peak_prompt_tokens / max(dual_peak,1):.2f}x)")
|
||
print(f" · Proposer 全程不看图片,其峰值仅 {pm.peak_prompt_tokens} token(纯文本反馈)。")
|
||
print(f" · 方案 B 因图片在同一上下文累积,峰值最高;页数越多、迭代越多,差距越大。")
|
||
|
||
# 汇总落盘
|
||
visual_names = [visual["filename"] for visual in source_info.get("visuals", [])]
|
||
receipts = pm.receipts + rm.receipts + sm.receipts + judge_meter.receipts
|
||
receipts_path = save_text(
|
||
"receipts.json", json.dumps(receipts, ensure_ascii=False, indent=2)
|
||
)
|
||
gates = {
|
||
"pinned_real_academic_pdf": bool(
|
||
source_info.get("canonical")
|
||
and source_info.get("paper", {}).get("observed_pdf_sha256") == PAPER["pdf_sha256"]
|
||
),
|
||
"three_original_pdf_visuals": bool(
|
||
len(visual_names) >= 3
|
||
and all(v["sha256"] == v["public_copy_sha256"] for v in source_info.get("visuals", []))
|
||
),
|
||
"both_final_decks_reference_every_source_visual": bool(
|
||
visual_names
|
||
and all(name in dual["slides"] and name in single["slides"] for name in visual_names)
|
||
),
|
||
"both_final_decks_have_10_to_20_rendered_pages": bool(
|
||
10 <= len(dual["final_pngs"]) <= 20
|
||
and 10 <= len(single["final_pngs"]) <= 20
|
||
),
|
||
"real_slidev_rendered_every_final_page": bool(
|
||
all(os.path.exists(path) and os.path.getsize(path) > 0
|
||
for path in dual["final_pngs"] + single["final_pngs"])
|
||
),
|
||
"proposer_reviewer_and_self_review_both_run": bool(
|
||
dual["history"] and sm.calls >= 2
|
||
),
|
||
"same_independent_vision_judge_scored_both": bool(
|
||
dual_final and single_final and judge_meter.calls == 2
|
||
),
|
||
# The manuscript acceptance criterion is about the rendered result,
|
||
# not merely about having exercised the review mechanism. A run with
|
||
# 10--20 pages and real Vision receipts is still incomplete when the
|
||
# independent pixel-level judge reports blocking layout/overflow
|
||
# defects in the proposer--reviewer deck.
|
||
"dual_final_deck_passes_visual_acceptance": bool(
|
||
dual_final and dual_final.get("pass") is True
|
||
),
|
||
"real_api_receipts_complete": bool(
|
||
receipts
|
||
and all(r.get("response", {}).get("id")
|
||
and r.get("usage", {}).get("total_tokens")
|
||
and r.get("latency_s") is not None
|
||
for r in receipts)
|
||
),
|
||
}
|
||
summary = {
|
||
"schema_version": "2.0",
|
||
"experiment": "5-4",
|
||
"provider": args.provider,
|
||
"source": source_info,
|
||
"models": {"text": agents.TEXT_MODEL, "vision": agents.VISION_MODEL},
|
||
"dual_agent": {
|
||
"iteration_scores": scores,
|
||
"final_quality": dual_final,
|
||
"proposer_tokens": pm.__dict__,
|
||
"reviewer_tokens": rm.__dict__,
|
||
"total_tokens": dual_total,
|
||
"peak_context_prompt_tokens": dual_peak,
|
||
},
|
||
"single_agent": {
|
||
"final_quality": single_final,
|
||
"tokens": sm.__dict__,
|
||
"total_tokens": sm.total_tokens,
|
||
"peak_context_prompt_tokens": sm.peak_prompt_tokens,
|
||
},
|
||
"independent_judge": judge_meter.__dict__,
|
||
"artifact_hashes": {
|
||
"dual_final_pngs": {os.path.basename(p): file_sha256(p) for p in dual["final_pngs"]},
|
||
"single_final_pngs": {os.path.basename(p): file_sha256(p) for p in single["final_pngs"]},
|
||
"raw_receipts": file_sha256(receipts_path),
|
||
},
|
||
"completion": {"gates": gates, "campaign_complete": all(gates.values())},
|
||
"official_complete": all(gates.values()),
|
||
"observed_hypothesis": {
|
||
"dual_quality_minus_single": (
|
||
dual_final.get("overall_score", 0) - single_final.get("overall_score", 0)
|
||
),
|
||
"dual_peak_context": dual_peak,
|
||
"single_peak_context": sm.peak_prompt_tokens,
|
||
"note": "Hypothesis outcome is reported separately from execution completion.",
|
||
},
|
||
}
|
||
p = save_text("comparison_summary.json", json.dumps(summary, ensure_ascii=False, indent=2))
|
||
print(f"\n完整对比已保存:{p}")
|
||
print(f"所有 slides.md / review.json / 渲染 PNG 位于:{OUT_DIR}/ 与 slidev_workspace/exports/")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|