跳转至

paper-to-ppt

第5章 · Coding Agent 与代码生成 · 配套项目 chapter5/paper-to-ppt

项目说明

实验 5-4:基于论文的 PPT 自动生成(提议者-审核者机制)

配套《深入理解 AI Agent》第 5 章。把"做 PPT"重构为代码生成问题:用 Slidev(Markdown + HTML 定义幻灯片)框架,让 Agent 从一篇论文 自动生成演示文稿,并用提议者-审核者(Proposer-Reviewer)机制做视觉质量控制。

一句话结论

Proposer 只写 Slidev 代码、Reviewer 真正把每页渲染成 PNG 再用 Vision LLM 看图 挑毛病(文字溢出 / 内容拥挤 / 图片尺寸),Proposer 据结构化反馈迭代修订。相比"单 Agent 自审"(把历次渲染图片都堆在同一上下文里),双 Agent 分工的上下文峰值显著更小—— 因为 Proposer 全程不看图片、Reviewer 每轮只看最新一版截图。

为什么需要"渲染出来再看"

Agent 写完 Slidev 代码时并不知道实际渲染效果:内容会不会太挤、文字会不会溢出、 图片尺寸是否合适——这些只有真正渲染成像素才看得出来。所以 Reviewer 接触到的是 Proposer 看不到的新信息(渲染结果),这正是本机制的价值所在。

提议者-审核者分工

角色 职责 上下文里有什么
Proposergpt-5.6-luna,纯文本) 读论文 → 规划页面 → 生成/修订 slides.md 论文正文 + 累积的结构化文字反馈(永不含图片)
Reviewergpt-5.6-luna,Vision) 看最新一版每页 PNG,输出结构化建议 JSON 每轮全新调用,只含最新一版截图

Reviewer 的建议是结构化、可执行的,而非模糊的"不好看",包含字段: page(页码)、issue_typetext_overflow/overcrowded/image_size/readability/layout)、 severity(high/medium/low)、suggestion(具体修改建议)、以及整份的 overall_scorepass

Proposer 收到反馈 → 理解意图 → 修订代码 → 再次提交 Reviewer,循环直到 pass 或达最大轮数。

对照实验:单 Agent 自审 vs 双 Agent 分工

demo.py 同时跑两种方案,并用同一位独立 Vision 评委给两者的最终 PPT 打分(保证质量可比):

  • 方案 A 双 Agent:如上。Proposer 上下文只增文本;Reviewer 每轮重置、只看最新截图。
  • 方案 B 单 Agent 自审:一个 Agent 在同一段对话里生成 → 看自己的渲染截图自审 → 修订。 历次渲染的图片会一直留在上下文里,随迭代快速膨胀(书中所述"上下文迅速超限")。

脚本打印每次调用的 prompt token 序列、总量、以及上下文峰值(单次 prompt token, 决定是否撑爆上下文窗口)。页数越多、迭代越多,方案 B 的峰值相对方案 A 越夸张。

运行

# 1) Python 依赖
pip install -r requirements.txt

# 2) Slidev + 渲染依赖(Node)。首次约 1-2 分钟:
npm install
#   - @slidev/cli:Slidev 命令行
#   - playwright-chromium:slidev export --format png 的底层浏览器
#   - typescript:Slidev 的 twoslash 代码高亮所需(否则 export 会 ERR_MODULE_NOT_FOUND)
#   若 npm install 没有自动装好 chromium 浏览器二进制,运行:
#     npx playwright install chromium

# 3) 配置 Key
cp env.example .env    # 填入 OPENAI_API_KEY(未配置时设 OPENROUTER_API_KEY 自动改走 OpenRouter)

# 4) 跑完整流程(生成 → 渲染 → Vision 审查 → 迭代 → 对比)
python demo.py

常用参数(python demo.py --help

一次完整运行会做数十次 gpt-5.6-luna Vision 调用,较慢较贵。下列参数提供更快的路径,并允许更换论文、输出目录与模型:

参数 作用
--paper PATH 输入论文的 Markdown 路径(默认 paper/sample_paper.md)。换成你自己的论文即可。
--out-dir DIR 产物输出目录(默认 output/):各轮 slides.md/review.json/comparison_summary.json。渲染 PNG 始终在 slidev_workspace/exports/
--text-model NAME Proposer / 单 Agent 文本模型,覆盖 TEXT_MODEL 环境变量(默认 gpt-5.6-luna)。
--vision-model NAME Reviewer / 独立评委看图模型(须支持图像),覆盖 VISION_MODEL 环境变量(默认 gpt-5.6-luna)。
--mode {both,dual,single} 只跑一种方案(dual=提议者-审核者,single=单 Agent 自审),省一半时间/费用;both(默认)才做跨方案对比。
--max-rounds N 每种方案的最大迭代轮数(默认 3)。--max-rounds 1 只出首版、不修订,是最快的真实 LLM 冒烟。
--dry-run 离线走通提议者-审核者循环:真实渲染两版脚本化 slides.md(拥挤初稿→拆页修订稿),用确定性启发式规则(按每页文字量判定,非 Vision LLM)扮演 Reviewer,完整展示“生成→渲染→审查→修订”闭环。不调用任何 LLM、无需 API Key
--smoke 验证 Slidev 渲染链路(渲染一个两页 deck),不调用任何 LLM、无需 API Key。最快的“没搞坏渲染”自检。
python demo.py --smoke                 # 不花钱,验证 Node/Slidev/chromium 可用
python demo.py --dry-run               # 不花钱,离线看清提议者-审核者闭环(真实渲染)
python demo.py --mode dual --max-rounds 1   # 一次真实 LLM 冒烟(需 API Key)
python demo.py --paper my_paper.md --out-dir run_my   # 换论文、换输出目录

--dry-run 里的两版 slides.md脚本化的(不是 LLM 生成),Reviewer 也只是按字符数判定拥挤的启发式规则、并非 Vision LLM——它只用来在没有 API Key 时把闭环结构跑通、产出真实渲染的 PNG。要看 gpt-5.6-luna 真的看像素审查,请用 python demo.py(需 OPENAI_API_KEY)。一次离线 dry-run 的真实结果:初稿 4 页(第 2/3/4 页被判 high 级 overcrowded、score=55、pass=False)→ 拆页修订稿 18 页(score=100、pass=True),渲染 PNG 见 slidev_workspace/exports/dryrun_round*/

文件说明

文件 作用
demo.py 主流程:跑两种方案、独立评委打分、打印 token 对比
agents.py Proposer / Reviewer / SelfReviewAgent 三个 Agent + TokenMeter 计量
renderer.py slidev export --format pngslides.md 渲染成逐页 PNG
make_figures.py 用 matplotlib 从论文数字复现 2 张图表,放进 Slidev public/
paper/sample_paper.md 精简论文(FlashAttention,含标题/章节/表格/结果)
package.json Slidev 与渲染依赖
output/ 运行产物:各轮 slides.mdreview.jsoncomparison_summary.json
slidev_workspace/exports/ 各轮渲染出的 PNG(dual_round1/single_round1/ …)

预期输出示例

一次完整运行后,output/slidev_workspace/exports/ 下的真实产物(节选):

output/
├── dual_round1_slides.md      # 双 Agent 第 1 版 slidev 源码(首版故意很挤)
├── dual_round1_review.json    # Reviewer 对第 1 版的结构化建议 JSON
├── dual_round2_slides.md      # 据反馈修订后的第 2 版
├── dual_round2_review.json
├── dual_round3_slides.md
├── single_round1_slides.md    # 单 Agent 自审各版
├── single_round2_slides.md
├── single_round3_slides.md
└── comparison_summary.json    # 两方案质量分 + token 消耗汇总

slidev_workspace/exports/
├── dual_round1/1.png … 5.png      # 首版渲染:段落太长、图表底部超出页面
├── dual_round2/1.png … 8.png      # 修订版:拆页后每页 8 张更干净
└── single_round1/1.png …          # 单 Agent 各版渲染

说明:Slidev 的 PNG 导出是逐页一张 PNG1.png2.png…),本实验不产出单一 PDF; 如需 PDF,可把 renderer.py 里的 --format png 改为 --format pdfcomparison_summary.json 里记录两方案的 iteration_scoresfinal_qualitypeak_context_prompt_tokens(上下文峰值),即书中的核心对比数据。

如何适配 / 扩展

  • 换模型 / 换供应商:通过环境变量(见 env.example)或命令行参数(优先级更高),代码无需改动。
  • OPENAI_API_KEY:密钥(必填其一;未配置时用 OPENROUTER_API_KEY 兜底,自动改走 OpenRouter)。
  • OPENAI_BASE_URL:指向任何兼容 OpenAI 协议的端点(自建网关 / 其它供应商)。
  • TEXT_MODEL / --text-model:Proposer / 单 Agent 文本部分用的模型(默认 gpt-5.6-luna)。
  • VISION_MODEL / --vision-model:Reviewer / 独立评委看图用的模型,必须支持图像输入(默认 gpt-5.6-luna)。
  • 换输入论文 / 输出目录:命令行 --paper PATH 指定论文、--out-dir DIR 指定产物目录(无需改代码); 也可直接替换 paper/sample_paper.md(保留 Markdown 章节结构即可)。若新论文有自己的 数据图表,改 make_figures.py 里的画图函数并更新 generate_all() 返回的 {文件名: 描述}, Proposer 会据描述引用这些图。
  • Slidev 渲染依赖(重要):渲染链路依赖 Node + 本目录内 node_modules/,其中包含 @slidev/cliplaywright-chromiumslidev export --format png 的底层浏览器)、typescript (twoslash 代码高亮所需)。若 node_modules/ 缺失或损坏,在本目录执行 npm install 重装; 若浏览器二进制没装好,补跑 npx playwright install chromium。装好后先 python demo.py --smoke 验证渲染链路,再跑完整流程。

关于"第一版故意写得很挤"

为了稳定复现"渲染 → 发现问题 → 修订"的闭环,agents.py 里让 Proposer/单 Agent 的 首版先把整篇论文塞进约 4 页、成段贴原文(一种常见的"先把内容倒进去"的初稿写法)。 这会产生真实的文字溢出与图表被裁切(见 slidev_workspace/exports/dual_round1/2.png: 段落太长、图表底部超出页面)。Reviewer 的问题都是视觉模型(默认 gpt-5.6-luna)看真实像素得出的,修订也是 真实的——不是预设脚本。若把首版指令改成"直接生成 8-12 页精简版",视觉模型往往一版就过关, 反而看不到迭代过程。一次真实运行的结果(会有随机波动):

双 Agent:round1 score=85 pass=False(4 个 medium:p2/p3/p4 overcrowded、p2 image_size)
          → Proposer 拆页精简 → round2 score=95 pass=True(+10 改善)
上下文峰值:双 Agent = 9308 tok,单 Agent 自审 = 14179 tok(单 Agent 图片累积:1640→8069→14179)

局限

  • 审美主观:Reviewer 的偏好未必等于目标用户的偏好,反馈循环可能收敛到 Reviewer 认可但用户嫌挤的局部最优(见书末思考题:如何让用户偏好也进入循环)。
  • 图表来源:本实验不解析真实 PDF,图表由 make_figures.py 从论文数字程序化复现, 作为"论文原始图表"的替身;接入真实 PDF 需另加图片抽取。
  • 成本/时长:每轮 Reviewer 要把约 10 张截图发给视觉模型(默认 gpt-5.6-luna),单次运行需数十次 API 调用;已把截图统一缩放到 1280px 宽以控制 token。
  • 确定性:LLM 与 Vision 判定有随机性,具体分数/建议每次略有不同;temperature 已调低,但迭代是否恰好"1 轮达标"取决于首版质量。
  • 渲染依赖slidev export 依赖 playwright-chromium;无网络/无法装 chromium 的 环境需先解决浏览器二进制问题(见"运行"第 2 步)。

源代码

agents.py

"""
提议者(Proposer)与审核者(Reviewer)两个 Agent,以及一个带 token 计量的 LLM 客户端。

设计要点(对应书中“提议者-审核者”机制):
  - Proposer 只处理**文本**:论文正文 + 累积的结构化文字反馈;从不接收渲染图片。
  - Reviewer 每一轮**只看最新一版的渲染截图**,且每轮都是一次全新的、无历史的调用。
  - 单 Agent 自审对照组则相反:同一段对话里不断累积历次渲染的图片,上下文迅速膨胀。

所有对 OpenAI 的调用都经过 TokenMeter 统计 prompt / completion token,
用于最后的“单 Agent vs 双 Agent 上下文消耗”对比。
"""
import base64
import io
import json
import os
import re

from openai import OpenAI
from PIL import Image

# 文本生成用的模型(Proposer / 单 Agent 的文本部分)
TEXT_MODEL = os.environ.get("TEXT_MODEL", "gpt-5.6-luna")
# 视觉审查用的模型(Reviewer / 单 Agent 的看图部分),必须支持图像
VISION_MODEL = os.environ.get("VISION_MODEL", "gpt-5.6-luna")

OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"


def map_model_to_openrouter(model: str) -> str:
    """把直连模型名映射为 OpenRouter 上的 id(非可映射 id 统一兜底到当前廉价旗舰)。"""
    if not model or "/" in model:
        return model or "openai/gpt-5.6-luna"
    m = model.lower()
    if m.startswith(("gpt-", "o1", "o3", "o4")):
        return "openai/" + model
    if m.startswith("claude"):
        if "haiku" in m:
            return "anthropic/claude-haiku-4.5"
        if "sonnet" in m:
            return "anthropic/claude-sonnet-4.6"
        return "anthropic/claude-opus-4.8"
    if m.startswith("gemini"):
        return "google/" + model
    return "openai/gpt-5.6-luna"

# 发送给 Vision 前把截图缩放到该宽度,兼顾“看得清文字溢出”与“控制 token 成本”
VISION_IMAGE_WIDTH = 1280


class TokenMeter:
    """累计一个“角色/模式”消耗的 token,并记录每次调用的 prompt token(用于看上下文峰值)。"""

    def __init__(self, name: str):
        self.name = name
        self.prompt_tokens = 0
        self.completion_tokens = 0
        self.calls = 0
        self.peak_prompt_tokens = 0  # 单次调用最大的 prompt token —— 决定是否“撑爆上下文”
        self.per_call_prompt = []

    def add(self, usage):
        self.calls += 1
        pt = usage.prompt_tokens
        self.prompt_tokens += pt
        self.completion_tokens += usage.completion_tokens
        self.peak_prompt_tokens = max(self.peak_prompt_tokens, pt)
        self.per_call_prompt.append(pt)

    @property
    def total_tokens(self):
        return self.prompt_tokens + self.completion_tokens


def _client() -> OpenAI:
    # 通用 OpenRouter 兜底:无直连 key,或默认 gpt-5.x(直连需组织实名认证)时改走 OpenRouter。
    global TEXT_MODEL, VISION_MODEL
    api_key = os.environ.get("OPENAI_API_KEY")
    base_url = os.environ.get("OPENAI_BASE_URL")
    orkey = os.environ.get("OPENROUTER_API_KEY")
    prefer_or = bool(orkey) and (
        (TEXT_MODEL or "").lower().startswith("gpt-5") or (VISION_MODEL or "").lower().startswith("gpt-5")
    )
    if prefer_or or (not api_key and orkey):
        api_key, base_url = orkey, OPENROUTER_BASE_URL
        # 走 OpenRouter 时把模型名映射为其 id(幂等:已带前缀的 id 原样返回)。
        TEXT_MODEL = map_model_to_openrouter(TEXT_MODEL)
        VISION_MODEL = map_model_to_openrouter(VISION_MODEL)
    if not api_key:
        raise SystemExit(
            "❌ 未检测到 OPENAI_API_KEY(或 OPENROUTER_API_KEY 兜底)。请先 `cp env.example .env` 并填入有效的 "
            "OpenAI API Key(或 `export OPENAI_API_KEY=sk-...` / `export OPENROUTER_API_KEY=...`)后再运行。"
        )
    # timeout + max_retries:单次网络抖动/SSL 中断会自动重试,而不是让整条流水线崩溃。
    return OpenAI(
        api_key=api_key,
        base_url=base_url,
        timeout=60.0,
        max_retries=4,
    )


def encode_image(path: str) -> str:
    """读取 PNG,缩放到统一宽度,编码为 data URL(base64)。"""
    img = Image.open(path).convert("RGB")
    if img.width > VISION_IMAGE_WIDTH:
        h = int(img.height * VISION_IMAGE_WIDTH / img.width)
        img = img.resize((VISION_IMAGE_WIDTH, h), Image.LANCZOS)
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    b64 = base64.b64encode(buf.getvalue()).decode()
    return f"data:image/png;base64,{b64}"


def _extract_json(text: str):
    """从模型回复里稳健地抽取 JSON(容忍 ```json 代码块或前后多余文字)。"""
    text = text.strip()
    m = re.search(r"```(?:json)?\s*(.*?)```", text, re.DOTALL)
    if m:
        text = m.group(1).strip()
    # 找到第一个 { 到最后一个 }
    start = text.find("{")
    end = text.rfind("}")
    if start != -1 and end != -1:
        text = text[start:end + 1]
    return json.loads(text)


def _extract_slides_md(text: str) -> str:
    """从模型回复里抽取 slides.md 内容(容忍 ```markdown 包裹)。"""
    m = re.search(r"```(?:markdown|md)?\s*(.*?)```", text, re.DOTALL)
    if m:
        return m.group(1).strip()
    return text.strip()


# --------------------------------------------------------------------------- #
# Reviewer 的审查评分标准(Proposer / 单 Agent / 独立评委共用同一套 rubric)
# --------------------------------------------------------------------------- #
REVIEW_RUBRIC = """你是一名严格的演示文稿质量审核员(Reviewer)。你会看到一份由 Slidev 渲染出的 PPT,
每张图对应一页幻灯片(按顺序编号,从第 1 页开始)。请逐页检查以下问题:

- text_overflow(文字溢出/被裁切超出页面边界)
- overcrowded(内容过多/过于拥挤/留白不足)
- image_size(图片过大顶出布局,或过小看不清)
- readability(字号过小、对比度差、代码块难读)
- layout(对齐混乱、标题与正文比例失衡、空页)

请以**目标用户是听众**的严格标准审查——一页幻灯片若要点超过约 5 条、或正文文字块偏长、
或图片挤压了文字空间,都应视为 overcrowded/image_size 问题。报告真实存在的问题,
但不要放过"塞得太满"。对每个问题给出:page(页码,整数)、
issue_type(上面之一)、severity(high/medium/low)、suggestion(具体、可执行的修改建议,中文)。

同时给出:
- overall_score:0-100 的整体质量分(越高越好)
- pass:布尔值,仅当整份 PPT **既无 high 也无 medium 级问题**、排版干净可读时才为 true

严格输出如下 JSON(不要输出任何多余文字):
{
  "overall_score": <int>,
  "pass": <bool>,
  "issues": [
    {"page": <int>, "issue_type": "<type>", "severity": "<high|medium|low>", "suggestion": "<中文建议>"}
  ]
}"""


class Reviewer:
    """审核者 Agent:看最新一版渲染截图,输出结构化 JSON 建议。每轮独立调用、无历史。"""

    def __init__(self, meter: TokenMeter):
        self.client = _client()
        self.meter = meter

    def review(self, png_paths: list[str]) -> dict:
        content = [{"type": "text",
                    "text": f"这份 PPT 共 {len(png_paths)} 页,下面按页码顺序给出每一页的渲染截图。请审查。"}]
        for i, p in enumerate(png_paths, 1):
            content.append({"type": "text", "text": f"第 {i} 页:"})
            content.append({"type": "image_url",
                            "image_url": {"url": encode_image(p), "detail": "high"}})
        resp = self.client.chat.completions.create(
            model=VISION_MODEL,
            messages=[
                {"role": "system", "content": REVIEW_RUBRIC},
                {"role": "user", "content": content},
            ],
            temperature=0.2,
        )
        self.meter.add(resp.usage)
        return _extract_json(resp.choices[0].message.content)


PROPOSER_SYSTEM = """你是一名擅长把学术论文转化为演示文稿的 Proposer Agent。
你用 Slidev 框架(Markdown + HTML)编写 PPT 源码 slides.md。

Slidev 语法要点:
- 文件开头是 YAML frontmatter(--- 包裹),设置 theme: default。
- 用单独一行的 `---`(前后空行)分隔每一页幻灯片。
- 首页通常放标题、作者、会议。
- 引用图片用 markdown:![说明](/图片文件名.png),可用 HTML 控制尺寸,
  例如 <img src="/speedup_bar.png" class="h-60 mx-auto" />。
- 可用 Windi/Uno CSS 工具类控制排版(如 text-sm、grid grid-cols-2 gap-4)。

要求:
- 生成约 8-12 页,覆盖论文的标题、背景/动机、方法、实验结果、结论。
- 至少在 3 页中使用提供的图表/表格,且图文匹配。
- 每页信息量适中、不要塞太多字,宁可拆页也不要溢出。
- 只输出 slides.md 的完整内容,用 ```markdown 代码块包裹,不要额外解释。"""


class Proposer:
    """提议者 Agent:只吃文本(论文 + 累积文字反馈),产出 slides.md。"""

    def __init__(self, meter: TokenMeter, paper_md: str, figures: dict):
        self.client = _client()
        self.meter = meter
        fig_desc = "\n".join(f"- {name}{desc}" for name, desc in figures.items())
        first_user = (
            f"以下是论文全文(Markdown):\n\n{paper_md}\n\n"
            f"可直接引用的图表文件(放在 Slidev public 目录,用 /文件名 引用):\n{fig_desc}\n\n"
            f"请先做一版**快速初稿**:为了尽快出稿,把整篇论文压缩到 **4 页以内**——"
            f"直接把每个章节对应的**完整段落原文**成段贴到幻灯片上(保留整段文字,先不要精简成要点),"
            f"并把两张图表也放进去。(后续会有审核者看真实渲染效果再帮你调整。)生成完整的 slides.md。"
        )
        # Proposer 的对话历史——只累积文本,永不加入图片
        self.messages = [
            {"role": "system", "content": PROPOSER_SYSTEM},
            {"role": "user", "content": first_user},
        ]

    def _generate(self) -> str:
        resp = self.client.chat.completions.create(
            model=TEXT_MODEL, messages=self.messages, temperature=0.3,
        )
        self.meter.add(resp.usage)
        reply = resp.choices[0].message.content
        self.messages.append({"role": "assistant", "content": reply})
        return _extract_slides_md(reply)

    def propose(self) -> str:
        """首轮生成。"""
        return self._generate()

    def revise(self, review: dict) -> str:
        """根据 Reviewer 的结构化文字反馈修订(只把 JSON 文本加入上下文)。"""
        feedback = json.dumps(review, ensure_ascii=False, indent=2)
        self.messages.append({
            "role": "user",
            "content": (
                "审核者(Reviewer)渲染了你上一版 slides.md 的每一页截图,"
                "给出如下结构化改进建议(JSON):\n\n"
                f"{feedback}\n\n"
                "请理解这些问题并修订 slides.md(可拆页、精简文字、调整图片尺寸等),"
                "重新输出完整的 slides.md。"
            ),
        })
        return self._generate()


# --------------------------------------------------------------------------- #
# 单 Agent 自审对照组:同一段对话里既生成、又看自己的渲染图、又修订。
# 关键区别:历次渲染的图片会**留在**同一上下文里,导致上下文随迭代快速膨胀。
# --------------------------------------------------------------------------- #
SELF_REVIEW_SYSTEM = PROPOSER_SYSTEM + """

此外,你还要**自我审查**:当收到自己 PPT 的渲染截图时,先在心里按下列标准找出问题
(文字溢出、内容拥挤、图片尺寸、可读性、布局),再据此输出修订后的完整 slides.md。"""


class SelfReviewAgent:
    """单 Agent 自审:一条不断增长的对话,图片累积在上下文中。"""

    def __init__(self, meter: TokenMeter, paper_md: str, figures: dict):
        self.client = _client()
        self.meter = meter
        fig_desc = "\n".join(f"- {name}{desc}" for name, desc in figures.items())
        first_user = (
            f"以下是论文全文(Markdown):\n\n{paper_md}\n\n"
            f"可直接引用的图表文件:\n{fig_desc}\n\n"
            f"请先做一版**快速初稿**:为了尽快出稿,把整篇论文压缩到 **4 页以内**——"
            f"直接把每个章节对应的**完整段落原文**成段贴到幻灯片上(保留整段文字,先不要精简成要点),"
            f"并把两张图表也放进去。(之后你会看到真实渲染截图再据此调整。)生成完整的 slides.md。"
        )
        self.messages = [
            {"role": "system", "content": SELF_REVIEW_SYSTEM},
            {"role": "user", "content": first_user},
        ]

    def propose(self) -> str:
        resp = self.client.chat.completions.create(
            model=VISION_MODEL, messages=self.messages, temperature=0.3,
        )
        self.meter.add(resp.usage)
        reply = resp.choices[0].message.content
        self.messages.append({"role": "assistant", "content": reply})
        return _extract_slides_md(reply)

    def self_review_and_revise(self, png_paths: list[str]) -> str:
        """把最新渲染截图加入**同一**上下文,让模型自审并修订。图片会一直留在历史里。"""
        content = [{"type": "text",
                    "text": (f"这是你上一版 slides.md 渲染出的 {len(png_paths)} 页截图。"
                             "请自我审查(文字溢出/拥挤/图片尺寸/可读性/布局),"
                             "然后输出修订后的完整 slides.md。")}]
        for i, p in enumerate(png_paths, 1):
            content.append({"type": "text", "text": f"第 {i} 页:"})
            content.append({"type": "image_url",
                            "image_url": {"url": encode_image(p), "detail": "high"}})
        self.messages.append({"role": "user", "content": content})
        resp = self.client.chat.completions.create(
            model=VISION_MODEL, messages=self.messages, temperature=0.3,
        )
        self.meter.add(resp.usage)
        reply = resp.choices[0].message.content
        self.messages.append({"role": "assistant", "content": reply})
        return _extract_slides_md(reply)


def independent_judge(png_paths: list[str], meter: TokenMeter) -> dict:
    """用同一套 rubric、独立地给某一版最终 PPT 打分,用于公平比较两种方案的质量。"""
    reviewer = Reviewer(meter)
    return reviewer.review(png_paths)

demo.py

"""
实验 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 json
import os
import re
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 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 _review_issues(review: dict) -> list:
    """JSON null issues must behave like omit ([])."""
    issues = review.get("issues")
    return issues if issues is not None else []


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 = render_slides(slides, 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 = render_slides(slides, 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![]({fig_names[gi]})" 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=DEFAULT_PAPER_PATH,
                   help="输入论文的 Markdown 路径(默认 paper/sample_paper.md)。"
                        "替换为你自己的论文即可;保留章节结构即可被 Proposer 解析。")
    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("--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):
    """单方案运行(--mode dual/single)时,落盘该方案自身的质量与 token 结果。"""
    summary = {"models": {"text": agents.TEXT_MODEL, "vision": agents.VISION_MODEL}}
    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),
        }
    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,
        }
    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)
    # 模型覆盖(--text-model / --vision-model 优先于环境变量)
    if args.text_model:
        agents.TEXT_MODEL = args.text_model
    if args.vision_model:
        agents.VISION_MODEL = args.vision_model

    if args.smoke:
        smoke_test()
        return
    if args.dry_run:
        dry_run(args.paper)
        return
    if args.max_rounds < 1:
        print("--max-rounds 至少为 1")
        sys.exit(1)
    if not os.path.exists(args.paper):
        print(f"找不到论文文件:{args.paper}(用 --paper 指定,或参考默认 paper/sample_paper.md)")
        sys.exit(1)
    if not (os.environ.get("OPENAI_API_KEY") or os.environ.get("OPENROUTER_API_KEY")):
        print("请先设置 OPENAI_API_KEY(或 OPENROUTER_API_KEY 兜底,可参考 env.example)")
        sys.exit(1)

    banner("准备:论文 + 程序化复现的图表")
    with open(args.paper, encoding="utf-8") as f:
        paper_md = f.read()
    figures = generate_all()
    print(f"论文:{args.paper}{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)
        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 因图片在同一上下文累积,峰值最高;页数越多、迭代越多,差距越大。")

    # 汇总落盘
    summary = {
        "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,
        },
    }
    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()

make_figures.py

"""
从论文中的数据生成两张真实的图表 PNG,放进 Slidev 的 public/ 目录,
供 Proposer 生成的幻灯片直接引用(满足“至少 3 处原图表”的要求,同时
让 Reviewer 的 Vision 检查能真正评估“图片尺寸是否合适”)。

这些图是用 matplotlib 从论文正文里的数字画出来的,属于“论文原始图表”的
程序化复现,而非凭空捏造。
"""
import os
import matplotlib

matplotlib.use("Agg")  # 无显示环境
import matplotlib.pyplot as plt

PUBLIC_DIR = os.path.join(os.path.dirname(__file__), "slidev_workspace", "public")


def make_speedup_bar(path):
    """图1:FlashAttention 相对标准实现的端到端加速比(论文第 4 节数字)。"""
    labels = ["BERT-large\n(seq 512)", "GPT-2\n(seq 1K)", "Long-Range\nArena"]
    speedups = [1.15, 3.0, 2.4]
    fig, ax = plt.subplots(figsize=(6, 3.4), dpi=150)
    bars = ax.bar(labels, speedups, color=["#4C72B0", "#DD8452", "#55A868"])
    ax.axhline(1.0, color="gray", linestyle="--", linewidth=1, label="baseline (1x)")
    ax.set_ylabel("Speedup vs. standard")
    ax.set_title("FlashAttention End-to-End Speedup")
    for b, v in zip(bars, speedups):
        ax.text(b.get_x() + b.get_width() / 2, v + 0.05, f"{v}x",
                ha="center", va="bottom", fontweight="bold")
    ax.set_ylim(0, 3.5)
    ax.legend(loc="upper left", fontsize=8)
    fig.tight_layout()
    fig.savefig(path, bbox_inches="tight")
    plt.close(fig)


def make_memory_hierarchy(path):
    """图2:GPU 内存层次的带宽对比(论文第 2 节表格,对数坐标)。"""
    levels = ["SRAM\n(on-chip)", "HBM\n(main GPU)", "CPU DRAM"]
    bandwidth = [19000, 1750, 12.8]  # GB/s
    fig, ax = plt.subplots(figsize=(6, 3.4), dpi=150)
    bars = ax.bar(levels, bandwidth, color=["#C44E52", "#8172B3", "#937860"])
    ax.set_yscale("log")
    ax.set_ylabel("Bandwidth (GB/s, log scale)")
    ax.set_title("GPU Memory Hierarchy (A100)")
    for b, v in zip(bars, bandwidth):
        ax.text(b.get_x() + b.get_width() / 2, v * 1.15,
                f"{v:g}", ha="center", va="bottom", fontweight="bold")
    fig.tight_layout()
    fig.savefig(path, bbox_inches="tight")
    plt.close(fig)


def generate_all():
    os.makedirs(PUBLIC_DIR, exist_ok=True)
    f1 = os.path.join(PUBLIC_DIR, "speedup_bar.png")
    f2 = os.path.join(PUBLIC_DIR, "memory_hierarchy.png")
    make_speedup_bar(f1)
    make_memory_hierarchy(f2)
    return {
        "/speedup_bar.png": "FlashAttention 端到端加速比柱状图(BERT 1.15x / GPT-2 3x / LRA 2.4x)",
        "/memory_hierarchy.png": "A100 GPU 内存层次带宽对比(SRAM 19TB/s / HBM ~1.75TB/s / DRAM 12.8GB/s,对数坐标)",
    }


if __name__ == "__main__":
    print(generate_all())

renderer.py

"""
Slidev 渲染器:把 Proposer 生成的 slides.md 真正渲染成“每页一张 PNG”。

这是本实验的关键——Reviewer 之所以能看到 Proposer 看不到的“新信息”,
正是因为我们真的把代码跑起来、渲染出了像素级的截图。

实现:调用本地安装的 Slidev CLI
    npx slidev export slides.md --format png --output <dir> --timeout 60000
Slidev 的 PNG 导出底层用 playwright-chromium 打开每一页并截图。
"""
import glob
import os
import shutil
import subprocess

WORKSPACE = os.path.join(os.path.dirname(__file__), "slidev_workspace")


class RenderError(RuntimeError):
    pass


def render_slides(slides_md: str, out_subdir: str) -> list[str]:
    """
    将 slides_md 文本写入工作区并导出为逐页 PNG。

    参数:
        slides_md:  完整的 Slidev markdown 源码
        out_subdir: 输出子目录名(如 'proposer_iter1')

    返回:
        按页码排序的 PNG 绝对路径列表。
    """
    os.makedirs(WORKSPACE, exist_ok=True)
    slides_path = os.path.join(WORKSPACE, "slides.md")
    with open(slides_path, "w", encoding="utf-8") as f:
        f.write(slides_md)

    out_dir = os.path.join(WORKSPACE, "exports", out_subdir)
    if os.path.exists(out_dir):
        shutil.rmtree(out_dir)
    os.makedirs(out_dir, exist_ok=True)

    # Slidev 的 PNG 导出会在 output 目录下生成 1.png, 2.png ...
    cmd = [
        "npx", "--no-install", "slidev", "export", "slides.md",
        "--format", "png",
        "--output", out_dir,
        "--timeout", "60000",
        "--dark", "false",
    ]
    env = dict(os.environ)
    # 让 playwright 使用项目内安装的 chromium(package.json 里的 playwright-chromium)
    proc = subprocess.run(
        cmd, cwd=WORKSPACE, env=env,
        capture_output=True, text=True, timeout=600,
    )
    if proc.returncode != 0:
        raise RenderError(
            "Slidev export 失败:\n"
            f"stdout:\n{proc.stdout[-2000:]}\n"
            f"stderr:\n{proc.stderr[-2000:]}"
        )

    pngs = sorted(
        glob.glob(os.path.join(out_dir, "*.png")),
        key=lambda p: _page_num(p),
    )
    if not pngs:
        raise RenderError(
            f"Slidev export 未产出 PNG。stdout:\n{proc.stdout[-2000:]}"
        )
    return pngs


def _page_num(path: str) -> int:
    base = os.path.splitext(os.path.basename(path))[0]
    digits = "".join(ch for ch in base if ch.isdigit())
    return int(digits) if digits else 0


if __name__ == "__main__":
    # 自检:渲染一个最小的两页 slidev
    demo = """---
theme: default
---

# Hello Slidev

第一页

---

# 第二页

- 渲染自检成功
"""
    paths = render_slides(demo, "selftest")
    print("渲染出的 PNG:")
    for p in paths:
        print("  ", p)

test_null_issues.py

"""Null review issues must summarize without TypeError."""
import sys
from unittest.mock import MagicMock

# demo.py imports heavy optional deps at module import time.
sys.modules.setdefault("dotenv", MagicMock())
sys.modules.setdefault("agents", MagicMock())
sys.modules.setdefault("make_figures", MagicMock())
sys.modules.setdefault("renderer", MagicMock())

from demo import summarize_review, _review_issues


def test_null_issues_like_empty():
    assert _review_issues({"issues": None}) == []
    text = summarize_review({"overall_score": 90, "pass": True, "issues": None})
    assert "issues=0" in text
    assert "high=0" in text


def test_issues_preserved():
    issues = [{"severity": "high"}]
    assert _review_issues({"issues": issues}) == issues
    text = summarize_review({"overall_score": 50, "pass": False, "issues": issues})
    assert "high=1" in text

package.json

{
  "name": "paper-to-ppt-slidev",
  "private": true,
  "version": "1.0.0",
  "description": "Slidev workspace for 实验 5-4 (proposer-reviewer PPT generation)",
  "dependencies": {
    "@slidev/cli": "^0.49.29",
    "@slidev/theme-default": "^0.25.0",
    "playwright-chromium": "^1.44.0",
    "typescript": "^5.9.3"
  }
}