"""
实验 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
")
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"!\[.*?\]\(.*?\)|
]*>", "", 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()