diff --git a/.gitignore b/.gitignore index 8d3182d..3e2619e 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,9 @@ docs/DEPLOY_PAPER_176.md # 一次性诊断脚本(不入库) scripts/debug_compile.py + +# P5 e2e 临时产物(compile_jobs/ 与 prewarm.log 已在上方「review_service 运行时产物」段,此处不重复) + +# P5 ppt job 运行数据(大纲/pptx/真图/预览,不入库) +review_service/data/ppt_jobs/ +review_service/data/review.db diff --git a/docs/pr-reports/2026-10-14-p5-ppt-assistant.md b/docs/pr-reports/2026-10-14-p5-ppt-assistant.md new file mode 100644 index 0000000..6a4d605 --- /dev/null +++ b/docs/pr-reports/2026-10-14-p5-ppt-assistant.md @@ -0,0 +1,131 @@ +# P5 科研汇报助手 — PR 报告(2026-10-14,v4 更新 2026-10-15) + +## 概要 + +新增「科研汇报助手」(P5):论文 PDF/粘贴文本 → vstore 解析 → map-reduce LLM 大纲 +(5 场景 × 4 时长 × **14 版式**,逐条证据可溯)→ 逐页编辑 → python-pptx 本地生成 +16:9 PPTX 初稿(论文真图卡片化嵌入、分隔页主副标题、讲者备注携带证据引用)。 +全程只新增 `review_service/` 内代码 + BFF 既有 `REVIEW_ENABLED` 条件反代, +禁区(RAG 核心、既有服务、前端 router/db)零改动。 + +**v4 迭代(2026-10-15)**:① 副标题风格数改为动态读取 `GET /ppt/styles`(缺数据回落 +「多风格·图文混排」)② 章节分隔页加副标题(prompt 强制 `subtitle`、渲染 40pt 主标题 + +20pt muted 副标题 + 底部 accent 装饰线、编辑器可改)③ 论文白底原图与页面底色融合 +(surface_2 圆角卡片 + 图注 + 宽/高上限 + 单图页居中留白)。 + +## 修改内容 + +### 后端(`review_service/`) + +| 类别 | 文件 | 说明 | +| --- | --- | --- | +| 改 | `app/config.py` | +`ppt_jobs_dir` / `ppt_styles_dir` 及 abs 属性(+14 行) | +| 改 | `app/main.py` | 导入 ppt 路由、lifespan `ppt_ensure_table()`、挂载(+8 行) | +| 改 | `tooling/requirements.lock` | **+3 行**:`pillow==12.3.0` / `python-pptx==1.0.2` / `xlsxwriter==3.2.9`(三者均已实装于统一 venv) | +| 改 | `.gitignore` | +9 行:`ppt_jobs/` 运行数据、`prewarm.log`、`data/compile_jobs/`、`scripts/`、`data/review.db` | +| 新 | `app/routers/ppt.py`(317 行) | **9 端点**:`GET /styles`、`POST /jobs`、`POST /jobs/pdf`、`GET/PATCH /jobs/{id}`、`POST /jobs/{id}/build`、`GET /jobs/{id}/pptx`、`GET /jobs/{id}/preview/{page}`、`DELETE /jobs/{id}` | +| 新 | `app/services/ppt_outline.py`(917 行) | map-reduce 大纲核心(索引/配额/重验/裁剪/膨胀回修/分隔页 subtitle 规则) | +| 新 | `app/services/ppt_build.py`(1176 行) | python-pptx 生成器(14 版式 token、字体栈、真图卡片容器、分隔页主副标题、讲者备注) | +| 新 | `app/services/ppt_figure_index.py`(264 行) | 论文真图索引(para/图号 → 图片落盘与状态) | +| 新 | `app/services/ppt_preview.py`(230 行) | 逐页 PNG 预览(供面板缩略图,失败不阻断) | +| 新 | `app/services/ppt_store.py`(155 行) | `ppt_jobs` SQLite 持久化(约定 DDL 逐字实现) | +| 新 | `data/ppt_styles/*.json`(14 个) | 版式 token 源码资产(随 git 走,`GET /styles` 的唯一数据源) | +| 新 | `tests/test_ppt_*.py`(6 个 / 102 例) | outline / build / figure_index / preview / router / store | + +### 前端(`ui/` + `tests/`) + +| 类别 | 文件 | 说明 | +| --- | --- | --- | +| 改 | `ui/src/api/review.js`(+50 行) | PPT API 段 + `pptStyles()` | +| 改 | `ui/src/stores/edu.js`(+147 行) | `ppt` 状态块 + actions + `pptLoadStyles` + `SECTIONS` 增列 | +| 改 | `ui/src/components/edu/EduSideNav.vue` / `EduWorkspace.vue` | 菜单项 + PptPanel 注册(各 +2 行) | +| 新 | `ui/src/components/edu/panels/PptPanel.vue`(578 行) | 生成表单 + 编辑器(动态风格数、分隔页副标题输入、逐页预览) | +| 新 | `ui/src/components/edu/panels/ppt-styles.css`(40 行) | 面板样式 | +| 新 | `ui/src/assets/ppt-style-cards/*.png`(14 个,约 21KB/个) | 版式卡面截图 | +| 新 | `tests/frontend/unit/edu/ppt.panel.test.js`(12 例)/ `ppt.store.test.js`(7 例) | P5 前端单测 | +| 改 | `tests/frontend/unit/b2/components.test.js`、`b3/benchpanel.test.js`、`bioflow/benchpanel.bioflow.test.js`、`tests/e2e/bench.spec.js` | **预存失败清零**:① tab 文案「提取任务」→「上传文献」(对齐 `51532a3`)② 模式 pill 断言改为「已随 `43a7c6c` 移除」 | + +**依赖偏差声明**:`python-pptx==1.0.2`(PyPI 最高 1.0.2;需求中 1.1.2 实为 +python-docx 版本,系记混,已在实现时更正)。`xlsxwriter==3.2.9` 当前无代码直接引用, +属统一 venv `pip freeze` 的真实记录,一并同步锁文件——**合并前仍需人工确认这 3 行锁变更**。 + +**DDL**(与约定逐字一致): + +```sql +ppt_jobs(id, status /*outline_ready|building|done|error*/, source_kind, filename, + raw_text, scenario, minutes, style_id, pages_json, pptx_path, + warnings_json, created_at, updated_at) +``` + +**API 前缀** `/api/review/ppt`(9 端点;业务错误一律 200 信封 +`{ok:false, error:{code,message,degraded}}`;二进制下载缺失时回同款信封 JSON)。 + +### 关键渲染约定(v4) + +- **分隔页**:`section_bg` 底(缺则 `surface_2`→`cover_bg`→`bg`),40pt 居中粗体主标题 + + 20pt muted 副标题(`bullets[0]` → `subtitle` → 该章第一页 title 三级兜底,浅底自动换深字)+ + 底部 30% 宽 accent 短线。 +- **真图卡片**:`surface_2` 圆角矩形(8px)容器 + 5mm 内边距,图居中、图注 + `Fig N(论文 S?P?)` 10–12pt muted、与图区留 3mm;混排页图区 ≤55% 页宽且 ≤60% 页高, + 单独图页居中、上下留白 ≥5%。 + +## 测试结果(2026-10-15) + +``` +pytest(review_service 全库): 215 passed in 13.7s (P5 新增 102 → 113 + 102) +vitest edu(tests/frontend/unit/edu): 43 passed (P5 新增 19:panel 12 + store 7) +vitest 全量(tests/frontend/unit): 32 files / 421 passed / 0 failed + —— 修掉 b2/b3/bioflow 共 5 个预存失败(文案改名 + 模式按钮移除) +npm run build: ✓ built(chunk > 500kB 警告为预存) +版式目检: 3 风格 × 10 页预览 PNG 渲染核对(分隔页主副标题/对比度回退、 + 混排图卡 ≤55%、单独图页居中留白)均符合预期 +``` + +## 真实 e2e(v1 阶段记录:官方 MDPI 模板实例 PDF,16813 字符,真实 DeepSeek) + +``` +PDF 模式创建: 21.77s → 12 页(目标 [11,14])warnings=0 llm_calls=7 unverified_evidence=0 +PATCH 编辑: 改写要点 + 新增无证据行 → evidence 同长重验、结构清洗 ✓ +build: 0.13s → 12 slides / 64157 B / 16:9(13.33×7.50in)逐页讲者备注 ✓ +python-pptx 回读: 12 页,S3 首条 = 人工改写文本 ✓ +编辑后下载: ok=false NOT_FOUND(盘上旧文件存在但拒发,pptx_path 清空生效)✓ +text 模式: 15.0s → 9 页(目标 [8,10])group_meeting / 10min / botanic-forest ✓ +短文本: 200 信封 VALIDATION「正文过短(<300 字),无法生成汇报大纲」✓ +清理: 双 DELETE → ppt_jobs 0 行、产物目录空 ✓ +``` + +## 部署验证步骤 + +1. 依赖:同步 `tooling/requirements.lock`(+3 行)并安装到统一 venv。 +2. 重启服务:`cd review_service && bash restart.sh`(8100)。 +3. 健康检查:`curl http://127.0.0.1:8100/health` → `status=ok`、`vstore connected=true`。 +4. 路由:`curl .../openapi.json` 确认 9 个 `/api/review/ppt/*` 路径在列(含 `/styles`)。 +5. BFF:`sdk/.env` 确认 `REVIEW_ENABLED=1`,走 8090 反代冒烟 + `POST /api/review/ppt/jobs`(短文本应得 200 VALIDATION 信封,不打 LLM)。 +6. 真实链路:上传一篇论文 PDF → 出大纲 → 编辑分隔页副标题并保存 → build → 下载 PPTX → + 用 python-pptx 核对页数、16:9 尺寸与分隔页副标题文本。 +7. 回归:`cd review_service && /bin/python -m pytest -q`; + `cd ui && npx vitest run` + `npm run build`。 + +## 部署决策 + +**建议:只合并,下次批量部署。** 本次改动对存量服务零影响(纯新增模块 + +开关化反代),无数据迁移、无 schema 破坏性变更;单点重启 8100 即可生效, +不值得为它单独发版,随下一次批量部署一并上即可。 + +## 风险与回滚 + +| 风险 | 影响 | 缓解 / 回滚 | +| --- | --- | --- | +| `python-pptx==1.0.2` 版本与需求(1.1.2)不一致 | 依赖锁未按原文落库 | 已声明偏差,合并前人工确认 3 行锁变更;如需回退直接还原 lock | +| `xlsxwriter==3.2.9` 无代码引用 | 锁文件带入未用依赖 | venv 实况记录,可保留;若要求最小化可单独剔除该行 | +| LLM 大纲偶发超时/无效 JSON | 创建接口报 `LLM_ERROR`/`LLM_INVALID` | 已分片 reduce + 单次膨胀回修 + 警告降级;前端可重试,无脏数据落库 | +| quote 反查失败残留 evidence(2 条) | 个别要点 `unverified` | **Phase 2**:最长公共子串(≥20 字符)匹配补 `para_id`,失败保持 unverified(边际价值低,v4 不做) | +| 编辑后旧 PPTX 残留盘上 | 无(`pptx_path` 清空后拒发,仅占磁盘) | 删除 job 时 `rmtree` 一并清理 | +| 回滚 | — | 反向还原 12 个已跟踪文件改动 + 删除 44 个新增文件(后端 26 + 前端 18)即可;`ppt_jobs` 表与产物目录可留可删,不被其他模块引用 | + +## 与 `feature/p3b-compile-ui#4f9c077` 的关系 + +v1 版报告曾以同一路径提交于该分支(`docs: add P5 PR report`),内容停留在 3 版式 / +7 端点 / 208 pytest 阶段。本文件为 v4 权威版本;若两分支先后合入,同路径会产生一次 +文本冲突,取本文件(v4)内容即可。 diff --git a/review_service/app/config.py b/review_service/app/config.py index 8b57c5b..986beaa 100644 --- a/review_service/app/config.py +++ b/review_service/app/config.py @@ -79,6 +79,10 @@ class Settings(BaseSettings): keep_failed_projects: bool = False # 失败 project/ 永久保留(调试开关; # 亦读裸环境变量 KEEP_FAILED_PROJECTS=1) + # ---- 科研汇报 PPT(P5:map-reduce 大纲 + python-pptx 初稿)---- + ppt_jobs_dir: str = "data/ppt_jobs" # 产物(draft.pptx + meta,相对 review_service/) + ppt_styles_dir: str = "data/ppt_styles" # 风格 tokens JSON(源码资产,随 git 走) + @property def _service_root(self) -> "Path": # review_service/app/config.py → review_service/(相对本文件定位,不依赖 cwd) @@ -102,6 +106,16 @@ class Settings(BaseSettings): p = Path(self.reformat_dir) return p if p.is_absolute() else self._service_root / p + @property + def ppt_jobs_abs(self) -> "Path": + p = Path(self.ppt_jobs_dir) + return p if p.is_absolute() else self._service_root / p + + @property + def ppt_styles_abs(self) -> "Path": + p = Path(self.ppt_styles_dir) + return p if p.is_absolute() else self._service_root / p + @property def tectonic_abs(self) -> "Path": p = Path(self.tectonic_path) diff --git a/review_service/app/main.py b/review_service/app/main.py index c378384..b4426b2 100644 --- a/review_service/app/main.py +++ b/review_service/app/main.py @@ -23,6 +23,7 @@ from .routers import ( format_check, journal_directory, journals, + ppt, presubmit, reformat, rebuttal, @@ -55,6 +56,12 @@ def create_app() -> FastAPI: ensure_table() except Exception: # noqa: BLE001 logger.warning("rebuttal 建表失败(忽略)", exc_info=True) + # P5 科研汇报:ppt_jobs 独立表,启动幂等建表 + try: + from .services.ppt_store import ensure_table as ppt_ensure_table + ppt_ensure_table() + except Exception: # noqa: BLE001 + logger.warning("ppt 建表失败(忽略)", exc_info=True) # 编译产物过期清扫:启动跑一次(失败不阻塞启动) try: from .services.latex_compile import sweep_expired @@ -153,6 +160,7 @@ def create_app() -> FastAPI: app.include_router(journal_directory.router) app.include_router(reformat.router) app.include_router(compile.router) + app.include_router(ppt.router) return app diff --git a/review_service/app/routers/ppt.py b/review_service/app/routers/ppt.py new file mode 100644 index 0000000..a3f1a31 --- /dev/null +++ b/review_service/app/routers/ppt.py @@ -0,0 +1,317 @@ +"""routers/ppt.py —— P5 科研汇报助手(9 端点,SQLite job 模式)。 + +信封与其余 review 端点一致:业务错误 = HTTP 200 + {ok:false,error:{code,message,degraded}}。 +- GET /styles:风格清单(data/ppt_styles/*.json 实际数量 → 副标题/风格卡动态渲染) +- 创建(text 主入口 / pdf 次要入口)= 解析 + map-reduce 大纲生成(同步; + map 并发 2 + 单调用超时收紧,总时长压在 BFF 读超时 360s 内) +- PATCH /jobs/{id}:结构清洗(复用 coerce_outline)+ evidence 状态按 raw_text + 重验(编辑后 quote 真伪即时刷新);warnings 保留生成期记录(页数/拆页等 + 结构提示不因编辑丢失) +- POST /jobs/{id}/build:python-pptx 本地生成 → data/ppt_jobs/{id}/draft.pptx + + 逐页缩略图渲染 → preview/slide_{n}.png(失败仅 warning,不阻断下载) +- GET /jobs/{id}/pptx:FileResponse 附件下载 +- GET /jobs/{id}/preview/{page}:第 page 页 PNG 缩略图(旧 job 缺图懒渲染) +- DELETE 手动清行 + 产物目录(无 TTL,对齐 rebuttal 决策 5) +""" +from __future__ import annotations + +import logging +import shutil +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, File, Form, UploadFile +from fastapi.responses import FileResponse, JSONResponse +from pydantic import BaseModel, Field + +from ..config import get_settings +from ..services import paper_parse, ppt_build, ppt_outline, ppt_preview, ppt_store +from ..services.ppt_build import BuildError +from ..services.ppt_figure_index import extract_figures, scan_figure_index +from ..services.ppt_outline import ( + OutlineError, + build_index, + coerce_outline, + renumber, + repair_evidence_para_ids, + validate_evidence, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/review/ppt", tags=["review-ppt"]) + +_MAX_RAW_CHARS = 500_000 # 粘贴文本上限(论文正文通常 ≤150K,留编辑余量) + + +# ---------- 信封 ---------- +class PptResponse(BaseModel): + ok: bool + job: dict[str, Any] | None = None + error: dict[str, Any] | None = None + meta: dict[str, Any] | None = None + + +def _error(code: str, message: str) -> PptResponse: + return PptResponse(ok=False, error={"code": code, "message": message, "degraded": False}) + + +def _ok(job: dict[str, Any], **meta: Any) -> PptResponse: + return PptResponse(ok=True, job=job, meta=meta or None) + + +class StylesResponse(BaseModel): + """GET /styles 信封(清单在 items,不用 job 字段)。""" + + ok: bool + items: list[dict[str, Any]] = Field(default_factory=list) + meta: dict[str, Any] | None = None + + +# ---------- 请求体 ---------- +class JobCreateBody(BaseModel): + text: str = Field(min_length=1) + scenario: str = "group_meeting" + minutes: int = 15 + style_id: str = "academic-minimal" + + +class PagesBody(BaseModel): + pages: list[dict[str, Any]] = Field(min_length=1) + + +# ---------- 内部工具 ---------- +def _pptx_path(job_id: str) -> Path: + return get_settings().ppt_jobs_abs / job_id / "draft.pptx" + + +def _preview_dir(job_id: str) -> Path: + return get_settings().ppt_jobs_abs / job_id / "preview" + + +def _preview_path(job_id: str, page: int) -> Path: + return _preview_dir(job_id) / f"slide_{page:02d}.png" + + +async def _create_job( + text: str, kind: str, filename: str | None, + scenario: str, minutes: int, style_id: str, + *, job_id: str | None = None, fig_meta: dict[str, Any] | None = None, +) -> PptResponse: + if len(text) > _MAX_RAW_CHARS: + return _error("VALIDATION", f"正文超过 {_MAX_RAW_CHARS} 字符上限") + # v3 修复2:PDF 提取出的原图 label → 大纲生成只从这些图里选(build 必能嵌入) + available_figures = None + if fig_meta: + fig_meta = dict(fig_meta) + available_figures = fig_meta.pop("figure_labels", None) or None + try: + pages, warnings, meta = await ppt_outline.generate_outline( + text, scenario, minutes, style_id, + available_figures=available_figures, + ) + except OutlineError as exc: + return _error(exc.code, exc.message) + job = { + "id": job_id or ppt_store.new_id("ppt_"), + "status": "outline_ready", + "source": {"kind": kind, "filename": filename}, + "scenario": scenario, + "minutes": minutes, + "style_id": style_id, + "pages": pages, + "warnings": warnings, + "pptx_path": None, + } + ppt_store.insert_job(job, raw_text=text) + if fig_meta: + meta = {**meta, **fig_meta} + return _ok(ppt_store.get_job(job["id"]) or job, **meta) + + +def _extract_job_figures(pdf_bytes: bytes, job_id: str) -> dict[str, Any]: + """2.3:PDF 原图 → data/ppt_jobs/{id}/figs/(失败仅告警,不阻塞建 job)。""" + out_dir = get_settings().ppt_jobs_abs / job_id / "figs" + try: + figs = extract_figures(pdf_bytes, out_dir) + except Exception as exc: # noqa: BLE001 - 提取属增强项,绝不 500 + logger.warning("图表原图提取失败(该 job 回落文字占位):%s", exc) + return {"figures_extracted": 0, "figures_extract_error": str(exc)[:200]} + # v3 修复2:已提取 label 供大纲只选「磁盘上有图」的条目 + return { + "figures_extracted": len(figs), + "figure_labels": [str(r.get("label") or "") for r in figs if r.get("label")], + } + + +def _not_found(job_id: str) -> PptResponse: + return _error("NOT_FOUND", f"job {job_id} 不存在或已删除") + + +# ---------- 端点 ---------- +@router.get("/styles", response_model=StylesResponse) +async def get_styles() -> StylesResponse: + """风格清单(data/ppt_styles/*.json 实际数量 + 文案;副标题/风格卡按它渲染)。""" + items = ppt_build.list_styles() + return StylesResponse(ok=True, items=items, meta={"count": len(items)}) + + +@router.post("/jobs", response_model=PptResponse) +async def create_job(body: JobCreateBody) -> PptResponse: + """文本粘贴主入口(论文纯文本 → 大纲)。""" + return await _create_job( + body.text, kind="text", filename=None, + scenario=body.scenario, minutes=body.minutes, style_id=body.style_id, + ) + + +@router.post("/jobs/pdf", response_model=PptResponse) +async def create_job_pdf( + file: UploadFile = File(...), + scenario: str = Form("group_meeting"), + minutes: int = Form(15), + style_id: str = Form("academic-minimal"), +) -> PptResponse: + """PDF 次要入口(经 vstore 8094 解析 → 大纲)。""" + settings = get_settings() + filename = file.filename or "paper.pdf" + if not filename.lower().endswith(".pdf"): + return _error("VALIDATION", "仅支持 PDF 文件") + data = await file.read() + if not data: + return _error("VALIDATION", "上传文件为空") + if len(data) > settings.max_pdf_bytes: + return _error("VALIDATION", f"PDF 超过 {settings.max_pdf_bytes // (1024 * 1024)}MB 上限") + try: + text = await paper_parse.parse_pdf_bytes(data, filename=filename) + except Exception as exc: # noqa: BLE001 - ParseError/网络异常统一信封 + return _error("UPSTREAM_DOWN", f"PDF 解析失败:{exc}") + # 2.3:先取 job_id 并提取原图(build 侧按 out 同级 figs/ 自动发现真图) + job_id = ppt_store.new_id("ppt_") + fig_meta = _extract_job_figures(data, job_id) + return await _create_job( + text, kind="pdf", filename=filename, + scenario=scenario, minutes=minutes, style_id=style_id, + job_id=job_id, fig_meta=fig_meta, + ) + + +@router.get("/jobs/{job_id}", response_model=PptResponse) +async def get_job(job_id: str) -> PptResponse: + job = ppt_store.get_job(job_id) + if job is None: + return _not_found(job_id) + return _ok(job) + + +@router.patch("/jobs/{job_id}", response_model=PptResponse) +async def patch_pages(job_id: str, body: PagesBody) -> PptResponse: + """保存前端编辑后的完整 pages(结构清洗 + evidence 状态重验)。""" + job = ppt_store.get_job(job_id) + if job is None: + return _not_found(job_id) + try: + pages = coerce_outline({"pages": body.pages}) + except OutlineError as exc: + return _error("VALIDATION", exc.message) + renumber(pages) + raw_text = ppt_store.get_raw_text(job_id) + if raw_text: + _, index = build_index(raw_text) + repair_evidence_para_ids(pages, index) # 编辑保存时同样反查修正(兼容旧 job) + validate_evidence(pages, index, scan_figure_index(index)) + updated = ppt_store.update_job(job_id, pages=pages, pptx_path=None) # 编辑后旧 pptx 失效 + return _ok(updated or job) + + +@router.post("/jobs/{job_id}/build", response_model=PptResponse) +async def build_job(job_id: str) -> PptResponse: + """大纲 → draft.pptx(python-pptx 本地生成,覆盖重建)。""" + job = ppt_store.get_job(job_id) + if job is None: + return _not_found(job_id) + if not job.get("pages"): + return _error("VALIDATION", "大纲为空,无法生成 PPT") + ppt_store.update_job(job_id, status="building") + try: + stats = ppt_build.build_pptx( + job["pages"], job.get("style_id") or "academic-minimal", + _pptx_path(job_id), + context={"scenario": job.get("scenario"), "minutes": job.get("minutes")}, + ) + except BuildError as exc: + warnings = list(job.get("warnings") or []) + [f"PPT 生成失败:{exc.message}"] + ppt_store.update_job(job_id, status="error", warnings=warnings) + fresh = ppt_store.get_job(job_id) + return PptResponse( + ok=False, job=fresh, + error={"code": exc.code, "message": exc.message, "degraded": False}, + ) + # 浏览器内预览:build 成功即渲染缩略图;失败仅告警,不阻断下载 + previews = ppt_preview.render_previews(_pptx_path(job_id), _preview_dir(job_id)) + stats["previews"] = len(previews) + updated = ppt_store.update_job(job_id, status="done", pptx_path=str(_pptx_path(job_id))) + if not previews: + fresh_warnings = list((updated or job).get("warnings") or []) + ["预览图生成失败(可正常下载 PPTX)"] + updated = ppt_store.update_job(job_id, warnings=fresh_warnings) or updated + return _ok(updated or job, **stats) + + +@router.get("/jobs/{job_id}/pptx", response_model=None) +async def download_pptx(job_id: str) -> FileResponse | JSONResponse: + """下载 draft.pptx(二进制流;缺失时回同款信封 JSON,对齐 compile 下载端点)。""" + job = ppt_store.get_job(job_id) + # pptx_ready 同时看 DB 列与磁盘文件:编辑后列被清 → 盘上旧文件不再下发 + if job is None or not job.get("pptx_ready"): + resp = _not_found(job_id) + return JSONResponse(status_code=200, content=resp.model_dump()) + path = _pptx_path(job_id) + filename = path.name + src_name = (job.get("source") or {}).get("filename") + if src_name: + filename = Path(src_name).stem + ".pptx" + return FileResponse( + str(path), + media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation", + filename=filename, + ) + + +@router.get("/jobs/{job_id}/preview/{page}", response_model=None) +async def preview_slide(job_id: str, page: int) -> FileResponse | JSONResponse: + """第 page 页缩略图 PNG(1-based;对齐 download_pptx 的信封约定)。 + + 缺 preview 但 pptx 在盘(旧 job,端点上线前构建的)→ 懒渲染一次再回图。 + job 不存在 / pptx 未就绪 / 页码越界 → HTTP 200 + 信封 JSON(对齐 pptx 下载)。 + """ + job = ppt_store.get_job(job_id) + if job is None or not job.get("pptx_ready"): + return JSONResponse(status_code=200, content=_not_found(job_id).model_dump()) + png = _preview_path(job_id, page) + if not png.is_file(): + # 懒渲染:首次访问补齐(渲染后仍不在 → 页码越界) + written = ppt_preview.render_previews(_pptx_path(job_id), _preview_dir(job_id)) + png = _preview_path(job_id, page) + if not png.is_file(): + if page < 1: + return JSONResponse( + status_code=200, + content=_error("VALIDATION", "page 必须 ≥1").model_dump(), + ) + return JSONResponse( + status_code=200, + content=_error("NOT_FOUND", f"page {page} 不存在(共 {len(written)} 页)").model_dump(), + ) + return FileResponse(str(png), media_type="image/png") + + +@router.delete("/jobs/{job_id}", response_model=PptResponse) +async def delete_job(job_id: str) -> PptResponse: + job = ppt_store.get_job(job_id) + if job is None: + return _not_found(job_id) + ppt_store.delete_job(job_id) + job_dir = get_settings().ppt_jobs_abs / job_id + if job_dir.exists(): + shutil.rmtree(job_dir, ignore_errors=True) + return PptResponse(ok=True, meta={"deleted": job_id}) diff --git a/review_service/app/services/ppt_build.py b/review_service/app/services/ppt_build.py new file mode 100644 index 0000000..a871d3e --- /dev/null +++ b/review_service/app/services/ppt_build.py @@ -0,0 +1,1176 @@ +"""services/ppt_build.py —— P5 大纲 → .pptx 初稿(python-pptx 本地生成,8 风格版式)。 + +版式(16:9 横版): +- title 页 → layouts[0] Title Slide:标题 + 副标题;cover_style.title_align + 可居中、accent_bar 加色块条 +- section 页 → layouts[6] Blank:色块横幅 + 大标题居中(章节分隔页) +- 内容页 → layouts[1]:**加粗** 拆 run、「- 子项」降为 level 1 缩进 +- 图占位页 → 左 (figure_split_pct)% bullets + 右侧虚线框 + ("Fig 1(论文 S1-P12)|请插入论文中的图") +- 大数字页 → result 页带 highlight:60pt 数值 + 24pt 副标题 + 底部小 bullets +- reference 页 → layouts[1],字号 reference_pt +- speaker_notes → notes_slide(evidence 只进备注不进正文) + +P5 二次开发 2.1(archetype,借 paper-skills/paper-ppt-skill,MIT): +- outline 编号 chip 行、「关键词:说明」要点行、2-4 并列短句圆角卡片、 + '→ ' 流程卡+箭头、'> ' 结论强调框、conclusion ≥6 条 50/50 双栏、 + '| ' → coerce table 原生表格;触发全靠 bullets 语法/启发式,零 schema 改动 +- 2.3 Tier B:figs/Fig_N.png 真图存在 → frame+contain 插入;否则 Tier A 占位几何不变 + +风格:data/ppt_styles/{id}.json tokens 是唯一真源(CSS 是前端投影,同步维护)。 +fonts.* 是多值字体栈(CSS 语法),pptx 只写首字体 → a:latin + a:ea 双写。 +""" +from __future__ import annotations + +import json +import logging +import re +from pathlib import Path +from typing import Any + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.dml import MSO_LINE_DASH_STYLE +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import MSO_ANCHOR, PP_ALIGN +from pptx.oxml.ns import qn +from pptx.util import Inches, Pt + +from ..config import get_settings +from .ppt_figure_index import canon_label +from .ppt_outline import CONTENT_TYPES, SCENARIO_LABELS, STYLE_IDS + +logger = logging.getLogger(__name__) + +_SLIDE_W_IN = 13.333 # 16:9 +_SLIDE_H_IN = 7.5 + +# P5 增强 3:正文标记语法(LLM 输出 → 版面) +_BOLD_RE = re.compile(r"\*\*(.+?)\*\*") +_SUB_RE = re.compile(r"^\s*(?:[-–—]|·|•)\s+(.*)$") + + +class BuildError(Exception): + """PPT 生成失败(VALIDATION | STYLE_NOT_FOUND | BUILD_FAILED)。""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +# ---------- 风格 tokens ---------- + +def load_style(style_id: str) -> dict[str, Any]: + """读 data/ppt_styles/{id}.json 并做最小结构校验。""" + if style_id not in STYLE_IDS: + raise BuildError("VALIDATION", f"未知风格 style_id={style_id}") + path: Path = get_settings().ppt_styles_abs / f"{style_id}.json" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise BuildError("STYLE_NOT_FOUND", f"风格文件缺失:{path.name}") from exc + except (OSError, ValueError) as exc: + raise BuildError("STYLE_NOT_FOUND", f"风格文件不可读:{exc}") from exc + if not isinstance(data, dict): + raise BuildError("STYLE_NOT_FOUND", "风格文件必须是 JSON 对象") + for key in ("palette", "fonts", "sizes"): + if not isinstance(data.get(key), dict): + raise BuildError("STYLE_NOT_FOUND", f"风格文件缺少 {key} 段") + return data + + +# ---------- 字体/颜色工具 ---------- + +def _rgb(hex_color: str) -> RGBColor: + return RGBColor.from_string((hex_color or "#000000").strip().lstrip("#")) + + +def _first_font(stack: str) -> str: + """补充 2:多值字体栈 → pptx 只写首字体(缺字体时 Office 主题回退)。""" + return next((s.strip() for s in (stack or "").split(",") if s.strip()), "Calibri") + + +def _apply_font( + run: Any, stack: str, size_pt: float, color_hex: str, *, bold: bool = False, +) -> None: + name = _first_font(stack) + font = run.font + font.name = name # a:latin + font.size = Pt(size_pt) + font.bold = bold + font.color.rgb = _rgb(color_hex) + r_pr = font._rPr + ea = r_pr.find(qn("a:ea")) + if ea is None: + ea = r_pr.makeelement(qn("a:ea"), {}) # CJK 走同一字族 + r_pr.append(ea) + ea.set("typeface", name) + + +def _set_bg(slide: Any, hex_color: str) -> None: + fill = slide.background.fill + fill.solid() + fill.fore_color.rgb = _rgb(hex_color) + + +def _fill_paragraph( + p: Any, text: str, stack: str, size_pt: float, color_hex: str, *, bold: bool = False, +) -> None: + """把 **强调** 拆成多 run(强调部分 bold=True);无标记则单 run 写入。""" + pos = 0 + wrote = False + for m in _BOLD_RE.finditer(text): + if m.start() > pos: + run = p.add_run() + run.text = text[pos:m.start()] + _apply_font(run, stack, size_pt, color_hex, bold=bold) + run = p.add_run() + run.text = m.group(1) + _apply_font(run, stack, size_pt, color_hex, bold=True) + wrote = True + pos = m.end() + if pos < len(text) or not wrote: + run = p.add_run() + run.text = text[pos:] + _apply_font(run, stack, size_pt, color_hex, bold=bold) + + +def _set_single_text( + shape: Any, text: str, stack: str, size_pt: float, color_hex: str, *, + bold: bool = False, align: PP_ALIGN | None = None, +) -> None: + tf = shape.text_frame + tf.word_wrap = True + tf.clear() + p = tf.paragraphs[0] + if align is not None: + p.alignment = align + _fill_paragraph(p, text, stack, size_pt, color_hex, bold=bold) + + +# ---------- P5 二次开发 2.1:archetype 底层 primitives ---------- +# 几何与触发语法借鉴 paper-skills style-presets / paper-ppt-skill Step7(均 MIT), +# 以 bullets 标记与启发式触发,零 schema 改动;无真图时全部回落 Tier A 文字版式。 + +_CONTENT_X, _CONTENT_TOP, _CONTENT_BOTTOM, _CONTENT_W = 0.5, 1.7, 6.6, 12.33 +_CARD_LIGHT, _CARD_DARK = "#F2F5F9", "#151E2E" +_KV_RE = re.compile(r"^\s*([^::]{1,10})[::]\s*(\S.*)$") +_FLOW_PREFIXES = ("→", "->", "➔", "➜") + + +def _lum(hex_color: str) -> float: + h = (hex_color or "#ffffff").lstrip("#") + try: + r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4)) + except ValueError: + return 1.0 + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 + + +def _strip_md(text: str) -> str: + return _BOLD_RE.sub(r"\1", text) + + +def _card_bg(pal: dict[str, Any]) -> str: + return str(pal["card_bg"]) if pal.get("card_bg") else ( + _CARD_DARK if _lum(pal.get("bg", "#FFFFFF")) < 0.5 else _CARD_LIGHT + ) + + +def _mm(v: float) -> float: + """毫米 → 英寸(本文件几何统一英寸;图卡内边距/图注间距按 mm 规范给)。""" + return v / 25.4 + + +def _px(v: float) -> float: + """CSS px → 英寸(16:9 页 13.333in ↔ 1280px → 96dpi;圆角 8px 换算用)。""" + return v / 96.0 + + +def _mix(color: str, other: str, ratio: float) -> str: + """把 color 按 ratio 向 other 混合(surface 层次推导)。""" + a = (color or "#ffffff").lstrip("#") + b = (other or "#ffffff").lstrip("#") + try: + ca = [int(a[i:i + 2], 16) for i in (0, 2, 4)] + cb = [int(b[i:i + 2], 16) for i in (0, 2, 4)] + except ValueError: + return color + return "#" + "".join(f"{int(round(x + (y - x) * ratio)):02X}" for x, y in zip(ca, cb)) + + +def _surface2(pal: dict[str, Any]) -> str: + """图卡容器底色 = tokens.surface_2(比页面底浅一档)。 + + 风格 JSON 目前未写 surface_2 → 按 bg 推导:浅底压暗一档、深底提亮一档, + 白底论文原图贴上去不再与页面底色同色(改进 3 的「融合」前提)。 + """ + for key in ("surface_2", "surface-2"): + if pal.get(key): + return str(pal[key]) + bg = str(pal.get("bg") or "#FFFFFF") + return _mix(bg, "#000000", 0.06) if _lum(bg) >= 0.5 else _mix(bg, "#FFFFFF", 0.12) + + +def _legible(bg: str, *candidates: str | None) -> str: + """在 bg 上按顺序挑第一个对比足够的颜色(|Δ亮度| ≥0.30 ≈ 大字号 3:1)。 + + tokens.muted / section_text 在部分风格里与底色太接近(如深蓝分隔底 + 灰字、 + 浅蓝分隔底 + 白字)会直接看不清 → 逐个回退,最后按底色明暗给黑白。 + """ + bg_l = _lum(bg) + for c in candidates: + if c and abs(_lum(c) - bg_l) >= 0.30: + return str(c) + return "#FFFFFF" if bg_l < 0.5 else "#0F172A" + + +def _add_rect( + slide: Any, x: float, y: float, w: float, h: float, *, + fill: str | None = None, line: str | None = None, line_pt: float = 1.0, + dash: bool = False, rounded: bool = False, +) -> Any: + shape = slide.shapes.add_shape( + MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE, + Inches(x), Inches(y), Inches(w), Inches(h), + ) + if fill: + shape.fill.solid() + shape.fill.fore_color.rgb = _rgb(fill) + else: + shape.fill.background() + if line: + shape.line.color.rgb = _rgb(line) + shape.line.width = Pt(line_pt) + if dash: + try: + shape.line.dash_style = MSO_LINE_DASH_STYLE.DASH + except (ValueError, TypeError): # pragma: no cover - 枚举兼容兜底 + logger.debug("dash_style 不可用,退回实线边框") + else: + shape.line.fill.background() + tf = shape.text_frame + tf.word_wrap = True + tf.clear() + return shape + + +def _add_rule(slide: Any, x: float, y: float, w: float, color: str) -> None: + _add_rect(slide, x, y, w, 0.012, fill=color) + + +def _set_bullet_list( + shape: Any, bullets: list[str], style: dict[str, Any], *, + size_pt: float | None = None, spacious: bool = False, +) -> None: + """要点列表渲染:**强调** 拆 bold run;「- 子项」降级 level 1 缩进。 + v3 spacious:默认页加大字号 + 行间距(仅无特殊版式的普通 bullets 页)。""" + size = size_pt if size_pt is not None else float(style["sizes"].get("bullet_pt", 18)) + if spacious: + size = max(min(size + 2, 24), 20) + if len(bullets) > 6: + size = max(size - 2, 12) + tf = shape.text_frame + tf.word_wrap = True + tf.clear() + for i, raw in enumerate(bullets): + p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() + p.space_after = Pt(12 if spacious else 6) + m = _SUB_RE.match(raw) + if m: + p.level = 1 # 子 bullet:PowerPoint 原生二级缩进 + raw = m.group(1) + _fill_paragraph( + p, raw, style["fonts"].get("body", ""), size, + style["palette"].get("text", "#000000"), + ) + + +# —— 触发启发式(纯函数,供 _render_page 分支决策)—— + +def _takeaway_index(bullets: list[str]) -> int | None: + """首字符 '> ' 的 bullet → 强调引用框(该条不再进正文列表)。""" + for i, b in enumerate(bullets): + if b.strip().startswith("> "): + return i + return None + + +def _sans_takeaway(bullets: list[str]) -> list[str]: + """hl/fig/table 分支借道 bullets 时剥 '> ' 前缀(框留给 takeaway 分支, + 避免图/大数字页正文出现字面 '> ')。""" + return [b.strip().lstrip(">").strip() if b.strip().startswith("> ") else b + for b in bullets] + + +# —— v3:行内合并 bullet 拆分(LLM 常把 4 条挤成一行「A- B- C- D」)—— + +_SPLIT_BULLET_RE = re.compile(r"-\s+") + + +def _split_bullets(bullets: list[str]) -> list[str]: + """'- A - B - C' / 'A- B- C' → 独立条目;单段 '- 子项' 保持原样 + (交给 _SUB_RE 降级 level 1)。'3-5' 连字符无空白不拆。""" + out: list[str] = [] + for raw in bullets: + parts = [p.strip() for p in _SPLIT_BULLET_RE.split(str(raw).strip()) if p.strip()] + if len(parts) >= 2: + out.extend(parts) + else: + out.append(str(raw)) + return out + + +# —— v3:按内容形态的 archetype 触发(降低门槛,非等显式标记)—— + +_HL_NUM_RE = re.compile(r"(? tuple[str, str] | None: + """恰好 1 条含千分位/百分比大数字 → 自动大数字页(value, label)。""" + hits = [(i, b) for i, b in enumerate(bullets) if _HL_NUM_RE.search(b)] + if len(hits) != 1: + return None + _, b = hits[0] + m = _HL_NUM_RE.search(b) + value = m.group(1) + label = (b[:m.start(1)] + " " + b[m.end(1):]).strip(" ::,,。 ") + label = re.sub(r"\s+", " ", label).strip() + if len(label) < 2: + label = b.strip() if b.strip() != value else "关键数字" + return value, label + + +def _compare_texts(bullets: list[str]) -> list[str] | None: + """≥2 条含对比语义(vs/对比/上下调…)→ 对照双栏;≤2 条页 1 命中也触发。""" + if not (2 <= len(bullets) <= 6): + return None + hits = sum(1 for b in bullets if _CMP_RE.search(b)) + if hits >= 2 or (hits == 1 and len(bullets) <= 2): + return [_strip_md(b) for b in bullets] + return None + + +def _flow_texts(bullets: list[str]) -> list[str] | None: + """≥3 条且全部以 '→' 开头 → 流程卡片行;否则 None。""" + if not (3 <= len(bullets) <= 6): + return None + texts: list[str] = [] + for b in bullets: + t = b.strip() + for pfx in _FLOW_PREFIXES: + if t.startswith(pfx): + t = t[len(pfx):].strip() + break + else: + return None + if not t: + return None + texts.append(t) + return texts + + +def _kv_rows(bullets: list[str], ptype: str) -> list[tuple[str, str]] | None: + """背景/方法页 ≥3 条全部 '关键词:说明' → 要点行(label 加粗 + rule)。""" + if ptype not in ("background", "method") or not (3 <= len(bullets) <= 6): + return None + rows: list[tuple[str, str]] = [] + for b in bullets: + m = _KV_RE.match(_strip_md(b)) + if not m: + return None + rows.append((m.group(1).strip(), m.group(2).strip())) + return rows + + +def _card_texts(bullets: list[str], ptype: str) -> list[str] | None: + """2-4 条并列短句 → 卡片网格(v3 放宽:长度 ≤26 字、允许 ** 强调, + 页型仍限 method/result/conclusion——背景/参考页保持叙述列表)。""" + if ptype not in ("method", "result", "conclusion") or not (2 <= len(bullets) <= 4): + return None + out: list[str] = [] + for b in bullets: + if _SUB_RE.match(b): + return None + t = b.strip() + if len(t) > 26: + return None + out.append(t) + return out + + +def _figure_image(context: dict[str, Any] | None, label: str) -> Path | None: + """figs/Fig_1.png 真图查找(extract_figures 落盘约定;不存在 → None)。 + + v3 修复2 兜底:字符串精确匹配失败(如 "Fig. 6b" 无 Fig_6b.png)→ + 按 canon_label(kind+编号)在 figs 目录里找同号文件(Fig 6b → Fig_6.png)。 + """ + figs_dir = (context or {}).get("figs_dir") + if not figs_dir or not label.strip(): + return None + path = Path(figs_dir) / f"{label.replace(' ', '_')}.png" + if path.is_file(): + return path + key = canon_label(label) + if not key or not Path(figs_dir).is_dir(): + return None + for cand in sorted(Path(figs_dir).glob("*.png")): + if canon_label(cand.stem.replace("_", " ")) == key: + return cand + return None + + +def _first_real_figure(context: dict[str, Any] | None, page: dict[str, Any]) -> Path | None: + for f in page.get("suggested_figures") or []: + if isinstance(f, dict): + hit = _figure_image(context, str(f.get("label") or "")) + if hit: + return hit + return None + + +# —— archetype 渲染器 —— + +def _add_outline_chips(slide: Any, bullets: list[str], style: dict[str, Any]) -> None: + """outline 页:编号 chip + 结论式条目(借 paper-skills outline archetype)。""" + pal, fonts = style["palette"], style["fonts"] + chip_fg = str(pal.get("bg") or "#FFFFFF") + texts = [_strip_md(str(b)) for b in bullets][:8] + n = max(len(texts), 1) + step = min(0.85, (_CONTENT_BOTTOM - _CONTENT_TOP - 0.4) / n) + y = _CONTENT_TOP + 0.1 + for i, text in enumerate(texts): + chip = _add_rect(slide, 0.9, y, 0.5, 0.5, fill=pal.get("primary", "#000000"), rounded=True) + tf = chip.text_frame + tf.vertical_anchor = MSO_ANCHOR.MIDDLE + p = tf.paragraphs[0] + p.alignment = PP_ALIGN.CENTER + run = p.add_run() + run.text = str(i + 1) + _apply_font(run, fonts.get("title", ""), 16, chip_fg, bold=True) + tb = slide.shapes.add_textbox(Inches(1.7), Inches(y), Inches(10.6), Inches(0.5)) + _set_single_text(tb, text, fonts.get("body", ""), 20, pal.get("text", "#000000")) + y += step + + +def _add_kv_rows(slide: Any, rows: list[tuple[str, str]], style: dict[str, Any]) -> None: + pal, fonts = style["palette"], style["fonts"] + n = max(len(rows), 1) + step = min(1.0, (_CONTENT_BOTTOM - _CONTENT_TOP) / n) + y = _CONTENT_TOP + for label, value in rows: + lb = slide.shapes.add_textbox(Inches(0.9), Inches(y + 0.08), Inches(2.5), Inches(step - 0.12)) + _set_single_text(lb, label, fonts.get("body", ""), 18, pal.get("primary", "#000000"), bold=True) + vb = slide.shapes.add_textbox(Inches(3.6), Inches(y + 0.08), Inches(8.7), Inches(step - 0.12)) + _set_single_text(vb, value, fonts.get("body", ""), 17, pal.get("text", "#000000")) + if y + step < _CONTENT_BOTTOM - 0.05: + _add_rule(slide, 0.9, y + step - 0.02, 11.4, pal.get("box_line", "#999999")) + y += step + + +def _add_cards(slide: Any, texts: list[str], style: dict[str, Any]) -> None: + """2-4 张圆角卡片(1×2 / 1×3 / 2×2),几何借 paper-skills key-message 卡。""" + pal, fonts = style["palette"], style["fonts"] + n = len(texts) + cols = n if n <= 3 else 2 + rows = 1 if n <= 3 else 2 + gap = 0.3 + cw = (_CONTENT_W - gap * (cols - 1)) / cols + ch = (_CONTENT_BOTTOM - _CONTENT_TOP - gap * (rows - 1)) / rows + for idx, text in enumerate(texts): + r, c = divmod(idx, cols) + x = _CONTENT_X + c * (cw + gap) + y = _CONTENT_TOP + r * (ch + gap) + card = _add_rect( + slide, x, y, cw, ch, fill=_card_bg(pal), + line=pal.get("primary", "#999999"), line_pt=1.25, rounded=True, + ) + tf = card.text_frame + tf.vertical_anchor = MSO_ANCHOR.MIDDLE + p = tf.paragraphs[0] + p.alignment = PP_ALIGN.CENTER + _fill_paragraph(p, text, fonts.get("body", ""), 17, pal.get("text", "#000000")) + + +def _add_flow(slide: Any, texts: list[str], style: dict[str, Any]) -> None: + """流程步骤卡 + 箭头(借 paper-skills buildSteps archetype)。""" + pal, fonts = style["palette"], style["fonts"] + n = len(texts) + arrow_w = 0.55 + cw = (_CONTENT_W - arrow_w * (n - 1)) / n + ch, y = 2.6, _CONTENT_TOP + 1.1 + x = _CONTENT_X + for i, text in enumerate(texts): + card = _add_rect( + slide, x, y, cw, ch, fill=_card_bg(pal), + line=pal.get("primary", "#999999"), line_pt=1.25, rounded=True, + ) + tf = card.text_frame + tf.vertical_anchor = MSO_ANCHOR.MIDDLE + p = tf.paragraphs[0] + p.alignment = PP_ALIGN.CENTER + _fill_paragraph(p, text, fonts.get("body", ""), 16, pal.get("text", "#000000")) + x += cw + if i < n - 1: + ar = slide.shapes.add_shape( + MSO_SHAPE.RIGHT_ARROW, Inches(x), Inches(y + ch / 2 - 0.22), + Inches(arrow_w), Inches(0.44), + ) + ar.fill.solid() + ar.fill.fore_color.rgb = _rgb(pal.get("accent", "#666666")) + ar.line.fill.background() + x += arrow_w + + +def _add_takeaway_box(slide: Any, text: str, style: dict[str, Any], *, top: float, height: float) -> None: + """强调引用框('> ' 结论句):accent 边框 + 卡底。""" + pal, fonts = style["palette"], style["fonts"] + box = _add_rect( + slide, 0.9, top, 11.53, height, fill=_card_bg(pal), + line=pal.get("accent", "#666666"), line_pt=2.25, rounded=True, + ) + tf = box.text_frame + tf.vertical_anchor = MSO_ANCHOR.MIDDLE + p = tf.paragraphs[0] + p.alignment = PP_ALIGN.CENTER + _fill_paragraph(p, _strip_md(text), fonts.get("body", ""), 18, pal.get("text", "#000000"), bold=True) + + +def _add_two_col(slide: Any, left_bullets: list[str], right_bullets: list[str], + style: dict[str, Any], body: Any) -> None: + """conclusion ≥6 条 → 50/50 双栏(借 paper-ppt-skill Step7 左右均分)。""" + body.text_frame.clear() + tb = slide.shapes.add_textbox(Inches(_CONTENT_X), Inches(_CONTENT_TOP), Inches(5.9), Inches(4.9)) + _set_bullet_list(tb, left_bullets, style) + tb2 = slide.shapes.add_textbox(Inches(6.93), Inches(_CONTENT_TOP), Inches(5.9), Inches(4.9)) + _set_bullet_list(tb2, right_bullets, style) + + +def _add_compare(slide: Any, texts: list[str], style: dict[str, Any]) -> None: + """v3 对照布局:两列卡片 + 中缝 accent 竖线(2 条时中央 VS 圆)。""" + pal, fonts = style["palette"], style["fonts"] + n = len(texts) + mid = (n + 1) // 2 + cols = (texts[:mid], texts[mid:]) + gap = 0.7 + col_w = (_CONTENT_W - gap) / 2 + gap_v = 0.24 + rows = max(len(c) for c in cols) or 1 + ch = (_CONTENT_BOTTOM - _CONTENT_TOP - gap_v * (rows - 1)) / rows + for ci, col in enumerate(cols): + x = _CONTENT_X + ci * (col_w + gap) + for ri, text in enumerate(col): + y = _CONTENT_TOP + ri * (ch + gap_v) + card = _add_rect( + slide, x, y, col_w, ch, fill=_card_bg(pal), + line=pal.get("primary", "#999999"), line_pt=1.25, rounded=True, + ) + tf = card.text_frame + tf.vertical_anchor = MSO_ANCHOR.MIDDLE + tf.margin_left = Inches(0.18) + tf.margin_right = Inches(0.18) + p = tf.paragraphs[0] + _fill_paragraph(p, text, fonts.get("body", ""), 17, pal.get("text", "#000000")) + mid_x = _CONTENT_X + col_w + gap / 2 + _add_rect(slide, mid_x - 0.015, _CONTENT_TOP, 0.03, + _CONTENT_BOTTOM - _CONTENT_TOP, fill=pal.get("accent", "#2563EB")) + if n == 2: + d = 0.74 + circle = slide.shapes.add_shape( + MSO_SHAPE.OVAL, Inches(mid_x - d / 2), + Inches((_CONTENT_TOP + _CONTENT_BOTTOM) / 2 - d / 2), + Inches(d), Inches(d), + ) + circle.fill.solid() + circle.fill.fore_color.rgb = _rgb(pal.get("accent", "#2563EB")) + circle.line.color.rgb = _rgb(pal.get("bg", "#FFFFFF")) + circle.line.width = Pt(3.0) + ctf = circle.text_frame + ctf.vertical_anchor = MSO_ANCHOR.MIDDLE + cp = ctf.paragraphs[0] + cp.alignment = PP_ALIGN.CENTER + crun = cp.add_run() + crun.text = "VS" + _apply_font(crun, fonts.get("title", ""), 18, "#FFFFFF", bold=True) + + +def _valid_table(page: dict[str, Any]) -> bool: + t = page.get("table") + return isinstance(t, dict) and isinstance(t.get("rows"), list) and bool(t["rows"]) + + +def _add_data_table(slide: Any, table: dict[str, Any], style: dict[str, Any], + *, top: float, height: float) -> None: + """原生 pptx 表格:primary 表头 + 隔行卡底('| ' bullet → coerce 成 table)。""" + pal, fonts = style["palette"], style["fonts"] + headers = [str(h) for h in (table.get("headers") or [])] + rows = [[str(c) for c in r] for r in table["rows"]] + cols = max([len(headers)] + [len(r) for r in rows]) or 1 + headers += [""] * (cols - len(headers)) + rows = [r + [""] * (cols - len(r)) for r in rows] + shape = slide.shapes.add_table( + len(rows) + 1, cols, Inches(0.7), Inches(top), Inches(11.9), Inches(height), + ) + tbl = shape.table + for c in range(cols): + tbl.columns[c].width = Inches(11.9 / cols) + size = 14 if cols <= 5 else 12 + header_fg = "#FFFFFF" if _lum(pal.get("primary", "#000000")) < 0.45 else str(pal.get("bg") or "#FFFFFF") + bg = _card_bg(pal) + + def _fill_cell(cell: Any, text: str, *, bold: bool, fg: str, back: str, + size_pt: float, align: PP_ALIGN) -> None: + cell.fill.solid() + cell.fill.fore_color.rgb = _rgb(back) + tf = cell.text_frame + tf.word_wrap = True + tf.clear() + p = tf.paragraphs[0] + p.alignment = align + run = p.add_run() + run.text = text + _apply_font(run, fonts.get("body", ""), size_pt, fg, bold=bold) + + for c, htext in enumerate(headers): + _fill_cell(tbl.cell(0, c), htext, bold=True, fg=header_fg, + back=pal.get("primary", "#000000"), size_pt=size, align=PP_ALIGN.CENTER) + for r, row in enumerate(rows): + back = bg if r % 2 == 0 else str(pal.get("bg") or "#FFFFFF") + for c, text in enumerate(row): + _fill_cell(tbl.cell(r + 1, c), text, bold=False, fg=pal.get("text", "#000000"), + back=back, size_pt=size, align=PP_ALIGN.LEFT) + + +def _add_real_figure(slide: Any, img_path: Path, figs: list[dict], style: dict[str, Any], + *, left_in: float, top_in: float, width_in: float, height_in: float) -> None: + """Tier B:真图 contain 插入 —— 浅色圆角卡片容器 + 图 + 底部图注(改进 3)。 + + 白底论文原图不再直接贴页面底色:容器 tokens.surface_2 + 圆角 8px + 无边框, + 图四周 5mm 内边距并居中;图注 10-12pt tokens.muted、贴容器底部、与图间距 3mm, + 格式「Fig 3(论文 S2-P19)」。 + """ + pal, fonts = style["palette"], style["fonts"] + fig_pt = min(max(float(style["sizes"].get("figure_pt", 13)), 10.0), 12.0) + # 1) 圆角矩形容器(surface_2,无边框) + card = slide.shapes.add_shape( + MSO_SHAPE.ROUNDED_RECTANGLE, + Inches(left_in), Inches(top_in), Inches(width_in), Inches(height_in), + ) + card.fill.solid() + card.fill.fore_color.rgb = _rgb(_surface2(pal)) + card.line.fill.background() + try: # 圆角半径 8px(96dpi → 0.083in);默认 adj 会圆成胶囊,按短边换算 + card.adjustments[0] = min(0.5, _px(8) / min(width_in, height_in)) + except (IndexError, ValueError, TypeError): # pragma: no cover - 调整点兼容兜底 + logger.debug("圆角调整不可用,保留默认圆角") + tf = card.text_frame + tf.word_wrap = True + tf.clear() + + # 2) 图注(容器底部):逐图一行,与图区留 3mm + captions = [ + f"{f.get('label') or '图'}(论文 {f.get('para_id') or '—'})" for f in figs[:6] + ] + cap_h = (len(captions) * 0.2 + _mm(3)) if captions else 0.0 + pad = _mm(5) + ix, iy = left_in + pad, top_in + pad + iw = width_in - 2 * pad + ih = height_in - 2 * pad - cap_h + aspect: float | None = None + try: + from PIL import Image + with Image.open(img_path) as im: + if im.height: + aspect = im.width / im.height + except Exception: # noqa: BLE001 - 图片不可读则只留容器 + logger.debug("真图不可读:%s", img_path) + if aspect and ih > 0.2 and iw > 0.2: + w, h = (ih * aspect, ih) if iw / ih > aspect else (iw, iw / aspect) + try: + slide.shapes.add_picture( + str(img_path), Inches(ix + (iw - w) / 2), Inches(iy + (ih - h) / 2), + Inches(w), Inches(h), + ) + except OSError: # pragma: no cover + logger.debug("真图插入失败:%s", img_path) + if not captions: + return + cap_tb = slide.shapes.add_textbox( + Inches(left_in), Inches(top_in + height_in - cap_h), + Inches(width_in), Inches(cap_h), + ) + ctf = cap_tb.text_frame + ctf.word_wrap = True + ctf.margin_left = ctf.margin_right = Inches(0.06) + ctf.margin_top = ctf.margin_bottom = 0 + ctf.clear() + cap_color = _legible(_surface2(pal), pal.get("muted"), pal.get("text"), pal.get("section_text")) + for i, text in enumerate(captions): + p = ctf.paragraphs[0] if i == 0 else ctf.add_paragraph() + p.alignment = PP_ALIGN.CENTER + r = p.add_run() + r.text = text + _apply_font(r, fonts.get("body", ""), fig_pt, cap_color) + + +def _render_table_page(slide: Any, page: dict[str, Any], style: dict[str, Any], + body: Any, bullets: list[str]) -> None: + """table 页:≤2 条导读 bullets 置顶 + 原生表格吃剩余版面。""" + top_b = bullets[:2] + t_top = _CONTENT_TOP + if top_b: + body.left, body.top = Inches(0.7), Inches(_CONTENT_TOP) + body.width = Inches(11.9) + body.height = Inches(0.5 * len(top_b) + 0.12) + _set_bullet_list(body, _sans_takeaway(top_b), style, + size_pt=max(float(style["sizes"].get("bullet_pt", 18)) - 4, 12)) + t_top = _CONTENT_TOP + 0.5 * len(top_b) + 0.24 + else: + body.text_frame.clear() + if t_top > 5.0: + body.text_frame.clear() + t_top = _CONTENT_TOP + _add_data_table(slide, page["table"], style, top=t_top, + height=max(_CONTENT_BOTTOM - t_top, 1.0)) + + +# ---------- 页渲染 ---------- + +def _cover_fallback(context: dict[str, Any] | None) -> str: + if not context: + return "科研汇报" + label = SCENARIO_LABELS.get(str(context.get("scenario") or ""), "科研汇报") + minutes = context.get("minutes") + return f"{label} · {minutes} 分钟" if minutes else label + + +def _notes_text(page: dict[str, Any]) -> str: + lines: list[str] = [] + if page.get("speaker_notes"): + lines.append(str(page["speaker_notes"])) + for e in page.get("evidence") or []: + if isinstance(e, dict) and e.get("quote"): + lines.append( + f"来源 {e.get('para_id') or '—'}:{e['quote']}" + f"({e.get('status') or 'unverified'})" + ) + for f in page.get("suggested_figures") or []: + if isinstance(f, dict): + lines.append( + f"图 {f.get('label') or '—'} → {f.get('para_id') or '—'}" + f"({f.get('status') or 'unverified'})" + ) + return "\n".join(lines) + + +def _add_notes(slide: Any, page: dict[str, Any]) -> None: + text = _notes_text(page) + if text: + slide.notes_slide.notes_text_frame.text = text + + +def _set_bullets(shape: Any, page: dict[str, Any], style: dict[str, Any], + *, size_pt: float | None = None, spacious: bool = False) -> None: + bullets = _split_bullets([str(b) for b in (page.get("bullets") or [])]) + _set_bullet_list(shape, bullets, style, size_pt=size_pt, spacious=spacious) + + +def _add_figure_box(slide: Any, page: dict[str, Any], style: dict[str, Any], *, + left_in: float, width_in: float, + top_in: float = 1.7, height_in: float = 4.9) -> None: + """右侧虚线图占位框:'Fig 1(论文 S1-P12)'+ 插图提示(手工插图模式)。""" + pal = style["palette"] + fig_pt = float(style["sizes"].get("figure_pt", 13)) + box = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, Inches(left_in), Inches(top_in), Inches(width_in), Inches(height_in), + ) + box.fill.background() # 透明,只留边框 + box.line.color.rgb = _rgb(pal.get("box_line", "#999999")) + box.line.width = Pt(1.5) + try: + box.line.dash_style = MSO_LINE_DASH_STYLE.DASH + except (ValueError, TypeError): # pragma: no cover - 枚举兼容兜底 + logger.debug("dash_style 不可用,退回实线边框") + tf = box.text_frame + tf.word_wrap = True + tf.vertical_anchor = MSO_ANCHOR.MIDDLE + tf.clear() + figs = [f for f in (page.get("suggested_figures") or []) if isinstance(f, dict)][:6] + first = True + for fig in figs: + p = tf.paragraphs[0] if first else tf.add_paragraph() + first = False + p.alignment = PP_ALIGN.CENTER + run = p.add_run() + run.text = f"{fig.get('label') or '图'}(论文 {fig.get('para_id') or '—'})" + _apply_font(run, style["fonts"].get("body", ""), fig_pt, pal.get("primary", "#000000"), bold=True) + p2 = tf.add_paragraph() + p2.alignment = PP_ALIGN.CENTER + run2 = p2.add_run() + run2.text = "请插入论文中的图" + _apply_font(run2, style["fonts"].get("body", ""), max(fig_pt - 2, 9), pal.get("muted", "#666666")) + p2.space_after = Pt(8) + + +def _add_highlight(slide: Any, page: dict[str, Any], style: dict[str, Any]) -> None: + """大数字突出页(借鉴 ppt-agent-skill "big number"):60pt 数值 + 24pt 副标题。""" + hl = page.get("highlight") or {} + pal, fonts = style["palette"], style["fonts"] + cs = style.get("content_style") or {} + value_pt = float(cs.get("highlight_value_pt", 60)) + label_pt = float(cs.get("highlight_label_pt", 24)) + + value_box = slide.shapes.add_textbox(Inches(0.5), Inches(1.7), Inches(12.33), Inches(1.5)) + _set_single_text( + value_box, str(hl.get("value") or ""), fonts.get("title", ""), value_pt, + pal.get("accent", "#000000"), bold=True, align=PP_ALIGN.CENTER, + ) + label_box = slide.shapes.add_textbox(Inches(0.5), Inches(3.25), Inches(12.33), Inches(0.7)) + _set_single_text( + label_box, str(hl.get("label") or ""), fonts.get("body", ""), label_pt, + pal.get("text", "#000000"), align=PP_ALIGN.CENTER, + ) + + +def _section_subtitle(page: dict[str, Any]) -> str: + """分隔页副标题:subtitle 字段 → bullets[0](旧大纲)→ 空。""" + sub = str(page.get("subtitle") or "").strip() + if sub: + return _strip_md(sub) + return next( + (_strip_md(str(b).strip()) for b in (page.get("bullets") or []) if str(b).strip()), "", + ) + + +def _add_section_page(slide: Any, page: dict[str, Any], style: dict[str, Any]) -> None: + """章节分隔页(P5 改进 2,layouts[6] Blank): + section_bg 底色 + 40pt 主标题居中粗体 + 20pt 副标题(tokens.muted) + + 底部 30% 宽 accent 装饰线。 + """ + pal, fonts, sizes = style["palette"], style["fonts"], style["sizes"] + section_bg = str( + pal.get("section_bg") + or pal.get("surface_2") or pal.get("surface-2") + or pal.get("cover_bg") or pal.get("bg") or "#FFFFFF" + ) + _set_bg(slide, section_bg) + + title_color = _legible( + section_bg, pal.get("text"), pal.get("cover_text"), pal.get("section_text"), + ) + title_box = slide.shapes.add_textbox( + Inches(0.6), Inches(2.85), Inches(_SLIDE_W_IN - 1.2), Inches(1.1), + ) + ttf = title_box.text_frame + ttf.word_wrap = True + ttf.vertical_anchor = MSO_ANCHOR.MIDDLE + ttf.clear() + tp = ttf.paragraphs[0] + tp.alignment = PP_ALIGN.CENTER + trun = tp.add_run() + trun.text = str(page.get("title") or "章节") + _apply_font( + trun, fonts.get("title", ""), float(sizes.get("section_title_pt", 40)), + title_color, bold=True, + ) + + subtitle = _section_subtitle(page) + if subtitle: + sub_color = _legible( + section_bg, pal.get("muted"), pal.get("section_text"), pal.get("text"), + ) + sub_box = slide.shapes.add_textbox( + Inches(0.9), Inches(4.05), Inches(_SLIDE_W_IN - 1.8), Inches(0.7), + ) + stf = sub_box.text_frame + stf.word_wrap = True + stf.vertical_anchor = MSO_ANCHOR.TOP + stf.clear() + sp = stf.paragraphs[0] + sp.alignment = PP_ALIGN.CENTER + srun = sp.add_run() + srun.text = subtitle + _apply_font(srun, fonts.get("body", ""), 20.0, sub_color) + + # 底部装饰线:accent 色、宽 30% 居中 + line_w = _SLIDE_W_IN * 0.3 + _add_rect( + slide, (_SLIDE_W_IN - line_w) / 2, 6.62, line_w, 0.03, + fill=_legible(section_bg, pal.get("accent"), pal.get("primary")), + ) + + +def _add_cover_bar(slide: Any, style: dict[str, Any], *, centered: bool) -> None: + """cover_style.accent_bar:居中封面 → 底部通栏;左对齐封面 → 左侧竖条。""" + pal = style["palette"] + if centered: + bar = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, Inches(0), Inches(_SLIDE_H_IN - 0.15), Inches(_SLIDE_W_IN), Inches(0.15), + ) + else: + bar = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), Inches(0.18), Inches(_SLIDE_H_IN), + ) + bar.fill.solid() + bar.fill.fore_color.rgb = _rgb(pal.get("accent", "#2563EB")) + bar.line.fill.background() + + +def _render_page( + prs: Presentation, page: dict[str, Any], style: dict[str, Any], + context: dict[str, Any] | None, +) -> str: + """渲染单页,返回 archetype 标签(v3:供 stats.archetypes 覆盖率统计)。""" + pal, fonts, sizes = style["palette"], style["fonts"], style["sizes"] + ptype = page.get("type") + + if ptype == "title": + cover_style = style.get("cover_style") or {} + centered = str(cover_style.get("title_align") or "left") == "center" + align = PP_ALIGN.CENTER if centered else None + slide = prs.slides.add_slide(prs.slide_layouts[0]) + _set_bg(slide, pal.get("cover_bg", "#FFFFFF")) + _set_single_text( + slide.shapes.title, str(page.get("title") or "科研汇报"), + fonts.get("title", ""), float(sizes.get("cover_title_pt", 40)), + pal.get("cover_text", "#000000"), bold=True, align=align, + ) + subtitle = " | ".join(str(b) for b in (page.get("bullets") or [])) or _cover_fallback(context) + _set_single_text( + slide.placeholders[1], subtitle, + fonts.get("body", ""), float(sizes.get("bullet_pt", 18)), + pal.get("muted", "#666666"), align=align, + ) + if cover_style.get("accent_bar"): + _add_cover_bar(slide, style, centered=centered) + _add_notes(slide, page) + return "cover" + + if ptype == "section": + slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank + _add_section_page(slide, page, style) + _add_notes(slide, page) + return "section" + + has_fig = bool(page.get("suggested_figures")) + has_hl = bool(page.get("highlight")) + # v3:行内合并 bullet 拆分('A- B- C' → 独立条目) + bullets = _split_bullets([str(b) for b in (page.get("bullets") or [])]) + slide = prs.slides.add_slide(prs.slide_layouts[1]) + _set_bg(slide, pal.get("bg", "#FFFFFF")) + _set_single_text( + slide.shapes.title, str(page.get("title") or ptype), + fonts.get("title", ""), float(sizes.get("title_pt", 30)), + pal.get("primary", "#000000"), bold=True, + ) + body = slide.placeholders[1] + if ptype == "outline" and bullets: + # 2.1:outline archetype(编号 chip 行,paper-skills outline 版式) + body.text_frame.clear() + _add_outline_chips(slide, bullets, style) + _add_notes(slide, page) + return "chips" + if has_hl: + # 大数字页:数值/副标题居中置顶,bullets 缩到下部小字 + _add_highlight(slide, page, style) + body.left, body.top = Inches(0.9), Inches(4.35) + body.width, body.height = Inches(11.5), Inches(2.5) + if bullets: + _set_bullet_list(body, _sans_takeaway(bullets), style, + size_pt=max(float(sizes.get("bullet_pt", 18)) - 4, 12)) + else: + body.text_frame.clear() + _add_notes(slide, page) + return "highlight" + if _valid_table(page): + # 2.1:对比表格('| ' bullets → coerce 成 table);优先于图占位—— + # 同页既引用图又给表时,表是显式数据内容,图证据仍进 notes + _render_table_page(slide, page, style, body, bullets) + _add_notes(slide, page) + return "table" + if has_fig: + # 图文混排:左 figure_split_pct% bullets + 右侧图卡(改进 3:圆角卡片容器) + # Tier B:figs/ 下有真图 → 容器内 contain 插入;否则 Tier A 虚线占位 + real = _first_real_figure(context, page) + figs = [f for f in page.get("suggested_figures") or [] if isinstance(f, dict)][:6] + if bullets: + usable, gap = 12.33, 0.3 + cs = style.get("content_style") or {} + split = min(max(float(cs.get("figure_split_pct", 60)), 45), 75) / 100 + body_w = (usable - gap) * split + box_left = 0.5 + body_w + gap + # 图占位控制:右侧 ≤55% 页宽、≤60% 页高 + box_w = min(12.83 - box_left, _SLIDE_W_IN * 0.55) + box_top, box_h = 1.7, _SLIDE_H_IN * 0.6 + body.left, body.top = Inches(0.5), Inches(1.7) + body.width, body.height = Inches(body_w), Inches(4.9) + _set_bullet_list(body, _sans_takeaway(bullets), style, + size_pt=float(sizes.get("bullet_pt", 18)) - 2) + else: + # 单独图页:图居中,上下留白 ≥5% 页高 + body.text_frame.clear() + box_w = _SLIDE_W_IN * 0.65 + box_left = (_SLIDE_W_IN - box_w) / 2 + box_top, box_h = 1.9, _SLIDE_H_IN * 0.6 + if real is not None: + _add_real_figure(slide, real, figs, style, + left_in=box_left, top_in=box_top, + width_in=box_w, height_in=box_h) + else: + _add_figure_box(slide, page, style, left_in=box_left, width_in=box_w, + top_in=box_top, height_in=box_h) + _add_notes(slide, page) + return "figure" + if (ti := _takeaway_index(bullets)) is not None: + # 2.1:'> ' 结论句 → 底部强调引用框,其余 bullets 留正文 + rest = [b for i, b in enumerate(bullets) if i != ti] + box_h, gap_in = 1.0, 0.18 + box_top = _CONTENT_BOTTOM - box_h + _add_takeaway_box(slide, bullets[ti].lstrip(">").strip(), style, + top=box_top, height=box_h) + body.left, body.top = Inches(0.9), Inches(_CONTENT_TOP) + body.width, body.height = Inches(11.53), Inches(max(box_top - gap_in - _CONTENT_TOP, 0.4)) + if rest: + _set_bullet_list(body, rest, style, + size_pt=max(float(sizes.get("bullet_pt", 18)) - 2, 12)) + else: + body.text_frame.clear() + _add_notes(slide, page) + return "takeaway" + if (flow := _flow_texts(bullets)) is not None: + # 2.1:'→ ' 步骤 → 流程卡 + 箭头 + body.text_frame.clear() + _add_flow(slide, flow, style) + _add_notes(slide, page) + return "flow" + if (kv := _kv_rows(bullets, ptype)) is not None: + # 2.1:'关键词:说明' → label 加粗要点行 + body.text_frame.clear() + _add_kv_rows(slide, kv, style) + _add_notes(slide, page) + return "kv" + if (cmp_texts := _compare_texts(bullets)) is not None: + # v3:对比语义(vs/对比/上下调…)→ 对照双栏 + body.text_frame.clear() + _add_compare(slide, cmp_texts, style) + _add_notes(slide, page) + return "compare" + if (auto_hl := _auto_highlight(bullets)) is not None: + # v3:恰 1 条含千分位/百分比大数字 → 自动大数字页(LLM 未显式给 highlight) + _add_highlight(slide, dict(page, highlight={"value": auto_hl[0], "label": auto_hl[1]}), + style) + body.left, body.top = Inches(0.9), Inches(4.35) + body.width, body.height = Inches(11.5), Inches(2.5) + if bullets: + _set_bullet_list(body, _sans_takeaway(bullets), style, + size_pt=max(float(sizes.get("bullet_pt", 18)) - 4, 12)) + else: + body.text_frame.clear() + _add_notes(slide, page) + return "highlight_auto" + if (cards := _card_texts(bullets, ptype)) is not None: + # 2.1:并列短句 → 圆角卡片网格(v3 放宽页型/长度/允许 **) + body.text_frame.clear() + _add_cards(slide, cards, style) + _add_notes(slide, page) + return "cards" + if ptype == "conclusion" and len(bullets) >= 6: + # 2.1:结论 ≥6 条 → 50/50 双栏 + mid = (len(bullets) + 1) // 2 + _add_two_col(slide, bullets[:mid], bullets[mid:], style, body) + _add_notes(slide, page) + return "two_col" + size = float(sizes.get("reference_pt", 14)) if ptype == "reference" else None + # v3:普通 bullets 页加大字号 + 行间距(reference 保持小字) + _set_bullets(body, page, style, size_pt=size, spacious=ptype != "reference") + _add_notes(slide, page) + return "reference" if ptype == "reference" else "bullets" + + +# ---------- 主入口 ---------- + +def section_subtitles(pages: list[dict[str, Any]]) -> dict[int, str]: + """section 页副标题决议(改进 2):subtitle → bullets[0] → 该章第一页 title 兜底。 + + 「该章」= 该分隔页之后到下一张分隔页之前的第一张内容页(background/method/ + result/conclusion);返回 {页下标: 副标题}(空串表示该页无副标题)。 + """ + out: dict[int, str] = {} + for i, page in enumerate(pages): + if page.get("type") != "section": + continue + sub = _section_subtitle(page) + if not sub: + for nxt in pages[i + 1:]: + if nxt.get("type") == "section": + break + if nxt.get("type") in CONTENT_TYPES: + title = str(nxt.get("title") or "").strip() + if title: + sub = _strip_md(title) + break + out[i] = sub + return out + + +def list_styles() -> list[dict[str, Any]]: + """风格清单(data/ppt_styles/*.json 实际内容;改进 1:前端数量不再写死)。 + + 单个风格文件缺失/损坏 → 跳过该条(清单其余照常返回,端点不 500)。 + """ + items: list[dict[str, Any]] = [] + for sid in STYLE_IDS: + try: + style = load_style(sid) + except BuildError as exc: + logger.warning("风格清单跳过 %s:%s", sid, exc.message) + continue + pal = style.get("palette") or {} + items.append({ + "id": sid, + "label": str(style.get("label") or style.get("display_name") or sid), + "display_name": str(style.get("display_name") or sid), + "description": str(style.get("description") or ""), + "bg": str(pal.get("bg") or "#FFFFFF"), + "accent": str(pal.get("accent") or "#2563EB"), + }) + return items + + +def build_pptx( + pages: list[dict], style_id: str, out_path: Path, + *, context: dict[str, Any] | None = None, +) -> dict[str, Any]: + """大纲 → .pptx 落盘。返回 {"slides": n, "bytes": n};失败抛 BuildError。""" + if not pages: + raise BuildError("VALIDATION", "大纲为空,无法生成 PPT") + style = load_style(style_id) + prs = Presentation() + prs.slide_width = Inches(_SLIDE_W_IN) + prs.slide_height = Inches(_SLIDE_H_IN) + # 2.3:真图目录 = 输出同级 figs/(extract_figures 落盘约定;缺目录 → Tier A 占位) + ctx = dict(context or {}) + ctx.setdefault("figs_dir", out_path.parent / "figs") + subs = section_subtitles(pages) + archetypes: list[str] = [] + for i, page in enumerate(pages): + if page.get("type") == "section": + page = {**page, "subtitle": subs.get(i, "")} # 决议后的副标题(含兜底) + archetypes.append(_render_page(prs, page, style, ctx)) + try: + out_path.parent.mkdir(parents=True, exist_ok=True) + prs.save(str(out_path)) + except OSError as exc: + raise BuildError("BUILD_FAILED", f"PPTX 落盘失败:{exc}") from exc + stats = { + "slides": len(prs.slides._sldIdLst), + "bytes": out_path.stat().st_size, + # v3:逐页 archetype("bullets"/"reference" 之外 = 版式变化页) + "archetypes": archetypes, + } + logger.info("pptx 生成:%s slides=%s bytes=%s", out_path.name, stats["slides"], stats["bytes"]) + return stats diff --git a/review_service/app/services/ppt_figure_index.py b/review_service/app/services/ppt_figure_index.py new file mode 100644 index 0000000..4c1e85a --- /dev/null +++ b/review_service/app/services/ppt_figure_index.py @@ -0,0 +1,264 @@ +"""services/ppt_figure_index.py —— 论文真实图表清单扫描(P5 增强 1)。 + +问题根因:LLM 不知道论文里真实图编号与位置,suggested_figures 全靠编。 +修复:用正则从论文全文扫描 Figure/Fig./图 + Table/表 编号 → figure_index +([{label, para_id, caption, kind}]),注入 reduce prompt 供 LLM「只选不编」, +代码侧再按清单校验:不在清单里的图引用直接丢弃(而非标 unverified)。 + +para_id 与 ppt_outline.build_index 的分段完全一致(复用同一套分段逻辑), +因此 figure_index 的 para_id 可直接用于证据链与前端展示「Fig 1(论文 S1-P12)」。 +""" +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +FIG_RE = re.compile(r"(?:Figure|Fig\.?|图)\s*(\d+)") +TABLE_RE = re.compile(r"(?:Table|表)\s*(\d+)") +_CAPTION_MARK = re.compile(r"\s*[::.]") # 紧跟编号的冒号/句点 → caption 形态 +_SENT_SPLIT = re.compile(r"(?<=[.!?。!?;;])\s*") +_MAX_CAPTION = 120 + +# label → 规约键:("fig", n) / ("table", n)("Figure 2"/"Fig. 2"/"图 2"/"Fig 2" 同键) +_LABEL_KIND = re.compile(r"^\s*(figure|fig|图|table|表)", re.IGNORECASE) + +# 行首 caption(借 paper-skills pdf_snap 的分隔符全集:::. 与 |;pdf_snap MIT) +_CAPTION_LINE = re.compile( + r"^\s*(Figure|Fig\.?|Table|Tab\.?|图|表|Algorithm|算法)\s*" + r"([0-9]+[a-z]?|[A-Z]\.?[0-9]+)\s*[::.|]\s*(\S.*)$", re.IGNORECASE, +) + + +def canon_label(label: Any) -> tuple[str, int] | None: + """图/表 label 归一化 → (kind, number);无法解析返回 None。""" + s = str(label or "").strip() + m = _LABEL_KIND.match(s) + digits = re.search(r"(\d+)", s) + if not m or not digits: + return None + kind = "table" if m.group(1).lower() in ("table", "表") else "fig" + return (kind, int(digits.group(1))) + + +def figure_map(figure_index: list[dict[str, Any]] | None) -> dict[tuple[str, int], dict[str, Any]]: + """figure_index → {("fig",2): entry} 查找表(校验用)。""" + out: dict[tuple[str, int], dict[str, Any]] = {} + for entry in figure_index or []: + key = canon_label(entry.get("label")) + if key and key not in out: + out[key] = entry + return out + + +def _sentence(paragraph: str, span: tuple[int, int]) -> str: + """取包含命中的句子(截 120 字符)作为 caption。""" + start = 0 + best = paragraph + for part in _SENT_SPLIT.split(paragraph): + end = start + len(part) + if start <= span[0] < end: + best = part + break + start = end + return best.strip()[:_MAX_CAPTION] + + +def _scan(index: dict[str, str]) -> list[dict[str, Any]]: + """para_id→原文 索引 → 真实图表清单(按 (kind,编号) 去重)。""" + found: dict[tuple[str, int], tuple[int, dict[str, Any]]] = {} + for para_id, para_text in index.items(): + for regex, kind in ((FIG_RE, "figure"), (TABLE_RE, "table")): + for m in regex.finditer(para_text): + key = (kind, int(m.group(1))) + # caption 形态("Figure 2: ...")优先于顺带提及;同分先到先得 + score = 2 if _CAPTION_MARK.match(para_text[m.end(): m.end() + 4]) else 1 + hit = { + "label": f"Fig {m.group(1)}" if kind == "figure" else f"Table {m.group(1)}", + "para_id": para_id, + "caption": _sentence(para_text, m.span()), + "kind": kind, + } + if key not in found or score > found[key][0]: + found[key] = (score, hit) + return [entry for _, entry in found.values()] + + +def scan_figure_index(index: dict[str, str]) -> list[dict[str, Any]]: + """para_id→原文 索引 → 图表清单(纯函数,可单测)。""" + return _scan(index) + + +def build_figure_index(text: str) -> list[dict[str, Any]]: + """论文全文 → 图表清单(与证据链共用同一套段落分段,para_id 一致)。""" + from .ppt_outline import build_index # 局部导入避免与 ppt_outline 循环依赖 + _, index = build_index(text) + return _scan(index) + + +# ---------- PDF 原图提取(P5 增强 2.3;AGENTS 例外:vstore 只回文本,图片须本地解档) ---------- + +_BAND_ABOVE_PT = 300.0 # caption 上方搜索带(图底边距 caption 的最大距离,pt) +_BAND_PAD_X_PT = 40.0 # caption 左右扩展(横向重叠判定) +_MIN_PX = (60, 40) # 裁切结果最小像素(过滤图标/装饰线) +_DPI = 300 + + +def extract_figures(pdf_bytes: bytes, out_dir: Path) -> list[dict[str, Any]]: + """PDF → 与 caption 配对的原图 PNG(out_dir/Fig_1.png…)+ 清单。 + + 策略(自 paper-skills pdf_snap「页渲染+裁图」与 paper-ppt-skill Step5 裁剪规则 + 自动化而来):同页 caption 行上方 band 内「嵌入位图 ∪ 矢量绘图」联合包围盒 + → clip 渲染 300dpi。Table 跳过(表格不出栅格图,由占位/表格版式承接)。 + 配对失败/过小 → 跳过(build 侧回落文字占位),绝不抛错阻塞建 job。 + """ + out = Path(out_dir) + out.mkdir(parents=True, exist_ok=True) + results: dict[str, dict[str, Any]] = {} + try: + import pymupdf # lock 中已有(format_check 同款) + except ImportError: # pragma: no cover + logger.warning("pymupdf 不可用,跳过图表原图提取") + return [] + try: + doc = pymupdf.open(stream=pdf_bytes, filetype="pdf") + except Exception as exc: # noqa: BLE001 + logger.warning("PDF 打开失败,跳过图表原图提取:%s", exc) + return [] + try: + for pno in range(doc.page_count): + page = doc[pno] + caps = _caption_lines(page) + if not caps: + continue + img_rects = _image_rects(page) + for cap in caps: + if cap["kind"] != "fig": + continue + key = f"Fig {cap['num']}" + if key in results: + continue + box = _figure_box(page, cap, img_rects) + if box is None: + continue + try: + pix = page.get_pixmap(clip=box, dpi=_DPI) + except Exception: # noqa: BLE001 - clip 越界等 + continue + if pix.width < _MIN_PX[0] or pix.height < _MIN_PX[1]: + continue + fname = f"Fig_{cap['num']}.png" + try: + pix.save(str(out / fname)) + except OSError: + continue + results[key] = { + "label": key, "file": fname, "page": pno + 1, + "caption": cap["text"][:_MAX_CAPTION], + "width": pix.width, "height": pix.height, + } + finally: + doc.close() + ordered = sorted(results.values(), key=lambda r: int(r["label"].split()[1])) + logger.info("图表原图提取:%d 张 → %s", len(ordered), out) + return ordered + + +def _caption_lines(page: Any) -> list[dict[str, Any]]: + """页内行首 caption → [{kind, num, text, x0, x1, y0}](每编号取最先一行)。""" + caps: dict[str, dict[str, Any]] = {} + try: + data = page.get_text("dict") + except Exception: # noqa: BLE001 + return [] + for block in data.get("blocks", []): + if block.get("type") != 0: + continue + for line in block.get("lines", []): + text = "".join(sp.get("text", "") for sp in line.get("spans", [])) + m = _CAPTION_LINE.match(text) + if not m: + continue + raw_kind = m.group(1).rstrip(".").lower() # "Fig." → "fig"(v3:剥尾点) + if raw_kind in ("figure", "fig", "图"): + kind = "fig" + elif raw_kind in ("table", "tab", "表"): + kind = "table" + else: + continue # Algorithm 等:清单不含,原图也跳过 + num = m.group(2) + digits = re.match(r"(\d+)", num) + if not digits: + continue + key = f"{kind}{digits.group(1)}" + if key in caps: + continue + x0, y0, x1, y1 = line["bbox"] + caps[key] = { + "kind": kind, "num": int(digits.group(1)), + "text": m.group(3).strip(), + "x0": float(x0), "x1": float(x1), "y0": float(y0), + } + return list(caps.values()) + + +def _image_rects(page: Any) -> list[Any]: + """页内嵌入位图的 rect 列表(失败返回空)。""" + rects = [] + try: + for info in page.get_image_info(xrefs=True): + xref = info.get("xref") + if not xref: + continue + for r in page.get_image_rects(xref): + rects.append(r) + except Exception: # noqa: BLE001 + return [] + return rects + + +def _figure_box(page: Any, cap: dict[str, Any], img_rects: list[Any]) -> Any | None: + """caption 上方 band 内「位图 ∪ 矢量绘图」联合包围盒(含 6pt 内边距)。""" + import pymupdf # 局部导入(extract_figures 已保证可用) + + page_rect = page.rect + band_y0 = cap["y0"] - _BAND_ABOVE_PT + band_y1 = cap["y0"] - 1.0 + bx0 = cap["x0"] - _BAND_PAD_X_PT + bx1 = cap["x1"] + _BAND_PAD_X_PT + + def _in_band(r: Any) -> bool: + return band_y0 <= float(r.y1) <= band_y1 and not (float(r.x1) < bx0 or float(r.x0) > bx1) + + union = None + + def _merge(r: Any) -> None: + nonlocal union + r = r & page_rect + if r.is_empty: + return + union = r if union is None else (union | r) + + for r in img_rects: + if _in_band(r): + _merge(r) + try: + drawings = page.get_drawings() + except Exception: # noqa: BLE001 + drawings = [] + for d in drawings: + r = d.get("rect") + if r is None or r.is_empty: + continue + # 细长规则线(页眉横线/表格线)不进联合,避免整页宽条 + if float(r.height) < 1.5 or (float(r.width) < 1.5): + continue + if _in_band(r): + _merge(r) + if union is None: + return None + box = pymupdf.Rect(union.x0 - 6, union.y0 - 6, union.x1 + 6, union.y1 + 6) & page_rect + return None if box.is_empty else box diff --git a/review_service/app/services/ppt_outline.py b/review_service/app/services/ppt_outline.py new file mode 100644 index 0000000..5868357 --- /dev/null +++ b/review_service/app/services/ppt_outline.py @@ -0,0 +1,917 @@ +"""services/ppt_outline.py —— P5 科研汇报大纲生成(map-reduce + 证据程序化校验)。 + +管线(全 LLM 只负责引用与措辞,结构/页数/证据状态由代码裁决): + 纯文本 → 段落索引(para_id 形如 S4-P1,figure 引用 S4-P1-Fig2) + → 图表清单扫描(ppt_figure_index 正则扫 Figure/图/Table/表,非 LLM) + → map:分块(≤5 块)抽 claims/figures(quote 逐字 + para_id 标注,LLM; + figures 先过真实清单,不在清单的丢弃) + → reduce:按 page_plan 配额生成页大纲(1 或 2 次调用;>14 页分两片, + 第二片带上一片页标题维持连贯;deepseek-chat 输出上限 8K,必须分片; + 真实图表清单注入 prompt,suggested_figures 只选不编) + → 后校验(纯函数、可单测): + ① 页数:PAGE_TARGETS 决策 2(10→8-10/15→11-14/20→15-18/30→22-26), + 超上限从尾部可删页裁掉(section/reference 先删);低于下限不凑页 + warning + ② bullets(补充 3):内容页 <2 条 → 一次按页扩充重试;>8 条 → 拆成两页 + ③ evidence:quote 归一化后必须是某段落的子串 → 反查真实 para_id + (repair_evidence_para_ids,修复 LLM 编造段落ID)→ verified; + 找不到段落 → unverified;result/conclusion 页未核实占比 >50% → warning + ④ suggested_figures:label 必须在真实图表清单内(否则丢弃而非标 + unverified);para_id 以清单扫描出的真实位置为准 → verified + +错误:OutlineError(code=VALIDATION|LLM_ERROR|LLM_INVALID) → 路由映射信封。 +LLM 调用次数:map ≤5 + reduce 1~2 + expand ≤1 ≈ 4~8 次/篇。 +""" +from __future__ import annotations + +import asyncio +import json +import math +import re +import unicodedata +from typing import Any + +from ..config import get_settings +from . import llm +from .llm import LLMError +from .ppt_figure_index import canon_label, figure_map, scan_figure_index + +# ---------- 枚举与决策常量 ---------- + +SCENARIOS = ( + "group_meeting", + "proposal", + "conclusion_report", + "defense", + "conference_talk", +) +SCENARIO_LABELS = { + "group_meeting": "组会", + "proposal": "开题", + "conclusion_report": "结题", + "defense": "答辩", + "conference_talk": "学术会议", +} +# P5 增强 2:风格 3 → 8 → 14(与 data/ppt_styles/*.json 一一对应)。 +# 其中 domestic/international/systems-talk/theory-beamer/jp-gothic/editorial +# 移植自开源 paper-skills styles/tokens.json(MIT),色/字号映射进自有 schema。 +STYLE_IDS = ( + "academic-minimal", "botanic-forest", "classic-defense", + "academic-blue", "nature-green", "tech-dark", "monochrome", "warm-earth", + "domestic", "international", "systems-talk", "theory-beamer", + "jp-gothic", "editorial", +) + +# section = 章节分隔页(P5 增强 3:大标题居中 + 色块背景,由代码定骨架、LLM 只起标题) +PAGE_TYPES = ( + "title", "outline", "section", "background", "method", "result", "conclusion", "reference", +) +CONTENT_TYPES = ("background", "method", "result", "conclusion") + +# 决策 2:时长 → [下限, 上限](含封面/目录,defense 含参考文献页) +PAGE_TARGETS: dict[int, tuple[int, int]] = { + 10: (8, 10), + 15: (11, 14), + 20: (15, 18), + 30: (22, 26), +} +MINUTES_CHOICES = tuple(sorted(PAGE_TARGETS)) + +# 场景骨架:内容页权重(reference 只有答辩要);代码算配额,LLM 只填内容 +_SKELETONS: dict[str, dict[str, Any]] = { + "group_meeting": {"weights": {"background": 0.25, "method": 0.40, "result": 0.35}, "reference": False}, + "proposal": {"weights": {"background": 0.40, "method": 0.45, "result": 0.15}, "reference": False}, + "conclusion_report": {"weights": {"background": 0.20, "method": 0.30, "result": 0.50}, "reference": False}, + "defense": {"weights": {"background": 0.25, "method": 0.35, "result": 0.40}, "reference": True}, + "conference_talk": {"weights": {"background": 0.25, "method": 0.35, "result": 0.40}, "reference": False}, +} + +# 索引切块(正文 ≤150K 字符 → 块数 ≤5,控制 map 调用次数) +_MAX_SECTIONS = 5 +_PARAS_PER_SECTION = 40 +_PARA_CHAR_CAP = 1000 # 单段送 map 的截断(pymupdf 块通常 <1000 字符) +_SECTION_CHAR_CAP = 30000 # 单块送 map 的总预算 +_MAX_CLAIMS_TOTAL = 50 +_MAX_FIGURES_TOTAL = 15 +_REDUCE_PAGE_SLICE = 14 # 单次 reduce 页数上限(>14 页分两片,守住 8K 输出) + +_MAP_TIMEOUT = 75.0 +_REDUCE_TIMEOUT = 200.0 +_EXPAND_TIMEOUT = 75.0 +_MAP_CONCURRENCY = 2 # BFF 读超时 360s 硬顶:map 并发压总时长 + + +class OutlineError(Exception): + """大纲生成失败(VALIDATION | LLM_ERROR | LLM_INVALID)。""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +# ---------- 决策 2:页数配额(纯函数) ---------- + +def page_plan(scenario: str, minutes: int) -> dict[str, tuple[int, int]]: + """时长目标页数 + 场景骨架 → 每 type 配额 (min, max)。 + + 固定页:title=1、outline=1、conclusion=1(defense +reference=1); + 其余按场景权重最大余数法分配(每内容页保底 1)。LLM 只能在配额内填, + 总页数另外夹在 PAGE_TARGETS[minutes] 区间内。 + """ + if scenario not in _SKELETONS: + raise ValueError(f"未知场景: {scenario}") + if minutes not in PAGE_TARGETS: + raise ValueError(f"不支持的时长: {minutes}(可选 {list(MINUTES_CHOICES)})") + lo, hi = PAGE_TARGETS[minutes] + target = (lo + hi) // 2 + weights = _SKELETONS[scenario]["weights"] + n_ref = 1 if _SKELETONS[scenario]["reference"] else 0 + # 章节分隔页:≥15 分钟(target≥11)每个内容组一张;10 分钟小 deck 不放分隔页 + n_sec = len(weights) if target >= 11 else 0 + rest = max(target - (3 + n_ref + n_sec), len(weights)) # 3 = title+outline+conclusion + + raw = {t: w * rest for t, w in weights.items()} + alloc = {t: int(v) for t, v in raw.items()} + for t in sorted(raw, key=lambda k: raw[k] - alloc[k], reverse=True)[: rest - sum(alloc.values())]: + alloc[t] += 1 + for t in alloc: + alloc[t] = max(1, alloc[t]) # 内容页保底 + + plan: dict[str, tuple[int, int]] = { + "title": (1, 1), "outline": (1, 1), "conclusion": (1, 1), + } + if n_sec: + plan["section"] = (n_sec, n_sec) + if n_ref: + plan["reference"] = (1, 1) + for t, a in alloc.items(): + plan[t] = (a, a + 1) + return plan + + +# ---------- 段落索引(para_id:S{块}-P{段}) ---------- + +def build_index(text: str) -> tuple[list[dict[str, Any]], dict[str, str]]: + """纯文本 → (blocks, index)。blocks 含段落ID标注文本(map 输入),index 为 + para_id → 原文(evidence 校验用)。块按固定段数均分(不做标题猜测, + 确定性可测;S 编号 = 块序号,P 编号 = 块内段序)。""" + paras = [ln.strip() for ln in text.splitlines() if ln.strip()] + paras = [p[:_PARA_CHAR_CAP] for p in paras] + if not paras: + return [], {} + n_sections = max(1, min(_MAX_SECTIONS, math.ceil(len(paras) / _PARAS_PER_SECTION))) + size = math.ceil(len(paras) / n_sections) + blocks: list[dict[str, Any]] = [] + index: dict[str, str] = {} + for si in range(0, len(paras), size): + sec_no = si // size + 1 + lines = paras[si : si + size] + paras_out = [] + for pi, p in enumerate(lines, 1): + pid = f"S{sec_no}-P{pi}" + paras_out.append({"para_id": pid, "text": p}) + index[pid] = p + blocks.append({ + "section_id": f"S{sec_no}", + "paras": paras_out, + "text": "\n".join(f"[{v['para_id']}] {v['text']}" for v in paras_out), + }) + return blocks, index + + +# ---------- map:分块抽 claims / figures ---------- + +_MAP_SYSTEM = """你是植物学论文精读助手。输入是一个论文文本块,每段以 [段落ID] 标注。 +只输出单个 JSON 对象: +{"claims": [{"claim": "一句话事实陈述", "para_id": "S1-P2", "quote": "支撑该事实的原文逐字引用"}], + "figures": [{"label": "Fig 2", "para_id": "S1-P5-Fig2"}]} +硬性要求: +- claim 必须能被同条 quote 直接支撑;quote 必须逐字来自对应段落(≤60字、单句、不得跨段拼接、不得改写); +- para_id 必须照抄输入标注;figure 的 para_id = 其 caption 所在段落ID + "-FigN"(N 为图序号); +- 每块最多 8 条 claims、4 个 figures;没有实质内容就返回空数组;严禁编造引用、数据、图号; +- 不要输出 JSON 以外的任何文字。""" + + +async def _map_block(block: dict[str, Any]) -> tuple[list[dict], list[dict]]: + payload = await llm.complete_json( + _MAP_SYSTEM, _map_block_user(block), temperature=0.2, + max_tokens=4000, timeout=_MAP_TIMEOUT, + ) + claims = [c for c in (payload.get("claims") or []) if isinstance(c, dict)][:8] + figures = [f for f in (payload.get("figures") or []) if isinstance(f, dict)][:4] + return claims, figures + + +def _map_block_user(block: dict[str, Any]) -> str: + body = block["text"] + if len(body) > _SECTION_CHAR_CAP: + body = body[:_SECTION_CHAR_CAP] + return f"论文文本块 {block['section_id']}:\n{body}" + + +# ---------- reduce:页大纲(配额内填内容) ---------- + +_REDUCE_SYSTEM = """你是学术汇报策划。根据给出的论文摘要卡片(claims/figures 已带逐字引用)、 +论文真实图表清单与页数配额,生成 PPT 大纲。只输出单个 JSON 对象: +{"pages": [{"type": "...", "title": "...", "subtitle": "...", "bullets": ["..."], + "evidence": [{"quote": "..."}], + "speaker_notes": "...", "suggested_figures": [{"label": "Fig 2", "para_id": "S1-P12"}], + "highlight": {"value": "+0.82 cm", "label": "体长增长"}, + "table": {"headers": ["处理", "株高(cm)"], "rows": [["对照", "10.2"]]}}]} +硬性要求: +- type 只能取:title, outline, section, background, method, result, conclusion, reference; +- 结构顺序固定:title → outline →(section → 对应内容组)×3 → conclusion(→ reference,若配额里有); + type=section 表示章节分隔页:放在 background、method、result 各组第一张之前, + title 只用组名(如「研究背景」,2~6 字,末尾不得带「是/的/了」等虚词,不要写成句子), + subtitle 必给(分隔页副标题,渲染在主标题下方):照抄该章第一张内容页的 title + (如「黄瓜遗传改良亟需高精度泛基因组」,≤20 字、一句话、与该页标题逐字一致), + bullets 用空数组(副标题走 subtitle 字段,不要再塞进 bullets), + evidence/speaker_notes 可省略; +- 每个 type 的页数必须落在给定配额 [min,max] 内,不得自创结构; +- 标题即结论:内容页 title 写成一句话结论或核心问题(≤20 字,看标题就懂这页讲什么), + 「背景/方法/结果」这类栏目词只允许出现在 section 分隔页; +- 叙事:方法页讲机制与做法,结果页说明「证明了什么」而不是罗列做了什么; +- bullets 每页 3-5 条(title 页放作者/单位/来源,outline 页列各部分标题, + reference 页列 5-8 条文献);单条 ≤40 字、名词短语化、不写整句; + 关键词强调用 **双星号** 包裹(如 **盐胁迫**);子要点写成独立数组元素并以「- 」开头 + (如 "- 子要点",其 evidence 填 null); +- 版面标记(生成端只按此写法,渲染端自动识别,勿在同一页混用多种): + · 并列短要点:方法/结果/结论页若内容就是 2-4 个并列短语,每条 ≤16 字、不带任何标记, + 直接写成这样的短句条目(会渲染为卡片网格); + · 关键词行:背景/方法概述页 ≥3 条统一用「关键词:说明」句式(如「靶基因:SOS1」); + · 流程:步骤页 ≥3 条统一以「→ 」开头(如「→ 提取 RNA」),会渲染为流程卡; + · 强调框:全篇最多 1-2 条最核心结论,以「> 」开头单独成条放在该页最后, + 其 evidence 填 null(会渲染为底部强调引用框); + · 表格:结果对比页优先用 table 字段(headers ≤5 列、rows ≤4 行、单元格 ≤18 字) + 代替 bullets;该页 bullets 最多 2 条导读,表格里的数据引用写进 speaker_notes; +- evidence 与 bullets 等长对齐(一一对齐,没有来源的位置填 null); + 每条 evidence 只需要 "quote" 字段:从摘要卡片里逐字照抄 quote 原文; + **不需要写 para_id,后端会按 quote 自动反查真实段落ID**(你写错会干扰校验); + 卡片里找不到对应 quote 的一律填 null,严禁改写或编造; +- speaker_notes 是讲稿口吻(80-160 字),可自然转述 quote 但不得出现段落ID; +- suggested_figures 只能从用户消息给出的「论文真实图表清单」里照抄 label 与 para_id, + 清单为空则一律给空数组;你只决定"哪个图放哪页",绝不发明图号; +- result 组最后 1-2 页若呈现对比数据,给该页加 "highlight": {"value": "≤12字符的数字", + "label": "≤16字符的说明"}(如 {"value": "+0.82 cm", "label": "体长增长"}); + 没有可突出的数字对比就省略 highlight 字段; +- 严禁编造引用、数据、图表编号。不要输出 JSON 以外的任何文字。""" + + +def _plan_table(plan: dict[str, tuple[int, int]], types: tuple[str, ...] | list[str]) -> str: + return "\n".join(f"- {t}: {plan[t][0]}~{plan[t][1]} 页" for t in types) + + +def _narrow_figure_index( + figure_index: list[dict], available: list[str] | None, +) -> list[dict]: + """v3 修复2:把注入 prompt 的图表清单收窄到「已提取原图」的 label。 + + available 为空/None → 不收窄(text 模式保持全清单占位框); + 交集为空(提取 label 与扫描不一致等意外)→ 回退全清单,绝不把图清空。 + 匹配按 canon_label(kind+编号)进行,且把 entry.label 统一成磁盘文件名形态 + ("Fig. 6b" → "Fig 6",子图后缀无对应文件),同编号去重只留首个。 + """ + if not available or not figure_index: + return figure_index + by_key: dict[tuple[str, int], str] = {} + for lbl in available: + key = canon_label(lbl) + if key and key not in by_key: + by_key[key] = str(lbl).strip() + if not by_key: + return figure_index + narrowed: list[dict] = [] + seen: set[tuple[str, int]] = set() + for f in figure_index: + key = canon_label(f.get("label")) + if key in by_key and key not in seen: + entry = dict(f) + entry["label"] = by_key[key] # LLM 所见 = 磁盘可嵌形态 + narrowed.append(entry) + seen.add(key) + return narrowed or figure_index + + +def _claims_block(claims: list[dict], figures: list[dict]) -> str: + # para_id 不下发给 reduce/expand(LLM 会照抄编造;quote 由后端反查真实段落ID) + compact = [ + {"claim": str(c.get("claim") or "")[:200], + "quote": str(c.get("quote") or "")[:80]} + for c in claims + ] + figs = [ + {"label": str(f.get("label") or "")[:40], "para_id": str(f.get("para_id") or "")} + for f in figures + ] + return json.dumps({"claims": compact, "figures": figs}, ensure_ascii=False) + + +def _reduce_user( + scenario: str, minutes: int, plan: dict[str, tuple[int, int]], + claims: list[dict], figures: list[dict], + types: list[str], *, prior_titles: list[str] | None = None, total_hint: int | None = None, + figure_index: list[dict] | None = None, +) -> str: + parts = [ + f"场景:{SCENARIO_LABELS[scenario]}({scenario}),汇报时长 {minutes} 分钟," + f"目标总页数 {PAGE_TARGETS[minutes][0]}~{PAGE_TARGETS[minutes][1]} 页。", + f"本次生成的页类型与配额(按顺序):\n{_plan_table(plan, types)}", + ] + if total_hint is not None: + parts.append(f"本次需生成 {total_hint} 页。") + if prior_titles: + parts.append("前半部分已生成的页标题(保持连贯,不要重复):" + ";".join(prior_titles)) + parts.append(f"摘要卡片:\n{_claims_block(claims, figures)}") + # P5 增强 1:论文真实图表清单注入(LLM 只选不编) + if figure_index: + compact = [ + {"label": f.get("label"), "para_id": f.get("para_id"), + "caption": str(f.get("caption") or "")[:80]} + for f in figure_index + ] + parts.append( + "论文中真实存在的图表清单(suggested_figures 只能从这里选,label 与 para_id 照抄):\n" + + json.dumps(compact, ensure_ascii=False) + ) + else: + parts.append("论文中未扫描到任何图表编号:suggested_figures 一律给空数组。") + return "\n\n".join(parts) + + +# ---------- 结构裁决(纯函数) ---------- + +def coerce_outline(payload: Any) -> list[dict]: + """LLM 返回 → 规范化 pages(枚举/字段/bullets-evidence 对齐/slide_number)。""" + pages_raw = payload.get("pages") if isinstance(payload, dict) else None + if not isinstance(pages_raw, list): + raise OutlineError("LLM_INVALID", "LLM 输出缺少 pages 列表") + pages: list[dict] = [] + for raw in pages_raw: + if not isinstance(raw, dict): + continue + ptype = str(raw.get("type") or "") + if ptype not in PAGE_TYPES: + continue + title = str(raw.get("title") or "").strip() or ptype + if ptype == "section": + # v3 修复3:LLM 偶发把「是」等虚词缀进组名(如「研究方法是」) + title = re.sub(r"[是的了]$", "", title) or title + bullets = [ + str(b).strip() for b in (raw.get("bullets") or []) + if isinstance(b, (str, int, float)) and str(b).strip() + ] + ev_raw = raw.get("evidence") + evidence: list[dict | None] = [] + for i in range(len(bullets)): + item = ev_raw[i] if isinstance(ev_raw, list) and i < len(ev_raw) else None + if isinstance(item, dict) and str(item.get("quote") or "").strip(): + evidence.append({ + "para_id": str(item.get("para_id") or ""), + "quote": str(item.get("quote") or "").strip(), + "status": "unverified", + }) + else: + evidence.append(None) + figs_raw = raw.get("suggested_figures") + figs = [] + if isinstance(figs_raw, list): + for f in figs_raw[:6]: + if isinstance(f, dict) and str(f.get("para_id") or "").strip(): + figs.append({ + "label": str(f.get("label") or "").strip(), + "para_id": str(f.get("para_id") or "").strip(), + "status": "unverified", + }) + # P5 增强 3:大数字突出页标记(result 组对比数据页,可选) + hl_raw = raw.get("highlight") + highlight = None + if isinstance(hl_raw, dict) and str(hl_raw.get("value") or "").strip(): + highlight = { + "value": str(hl_raw.get("value") or "").strip()[:24], + "label": str(hl_raw.get("label") or "").strip()[:32], + } + # P5 二次开发 2.2:对比表格(可选;rows 超限截断,坏结构直接丢弃) + table = None + tbl_raw = raw.get("table") + if isinstance(tbl_raw, dict) and isinstance(tbl_raw.get("rows"), list): + headers = [ + str(h).strip()[:24] for h in (tbl_raw.get("headers") or []) + if isinstance(h, (str, int, float)) and str(h).strip() + ][:8] + rows: list[list[str]] = [] + for r in tbl_raw["rows"][:12]: + if not isinstance(r, (list, tuple)): + continue + cells = [ + str(c).strip()[:60] for c in r + if isinstance(c, (str, int, float)) + ][:8] + if any(cells): + rows.append(cells) + if rows: + table = {"headers": headers, "rows": rows} + page = { + "slide_number": 0, + "type": ptype, + "title": title, + "bullets": bullets, + "evidence": evidence, + "speaker_notes": str(raw.get("speaker_notes") or "").strip(), + "suggested_figures": figs, + } + if ptype == "section": + # P5 改进 2:分隔页副标题独立字段(LLM 给 subtitle;旧大纲仍在 bullets[0], + # 编辑器改 bullets[0] 也算数 → bullets 优先,避免编辑被旧 subtitle 覆盖) + page["subtitle"] = (bullets[0] if bullets else "") or str( + raw.get("subtitle") or "" + ).strip() + if highlight: + page["highlight"] = highlight + if table: + page["table"] = table + pages.append(page) + if not pages: + raise OutlineError("LLM_INVALID", "LLM 输出中没有可用页(type 全部非法)") + return pages + + +# 超页裁剪优先级:section/reference 可整页删(floor 0),内容页保底 1 页; +# title/outline/conclusion 永不删 +_DROP_PRIORITY = ("section", "reference", "background", "method", "result") +_FLOOR_ZERO = ("section", "reference") + + +def trim_to_max( + pages: list[dict], hi: int, warnings: list[str], + floors: dict[str, int] | None = None, +) -> list[dict]: + """总页数超上限:按 _DROP_PRIORITY 优先级删该类型的最后一页;不凑下限。 + + floors(v3 修复3):按配额给类型设保底(如 section=(3,3) → 传 {"section": 3})。 + 有保底时先删「超配额的内容页/文献页」,section 不再被一刀清空; + 全部 floor 都满足却仍超页 → 才回退到 section/reference 整页删(原语义兜底)。 + """ + floors = floors or {} + floor_of = lambda t: floors.get(t, 0 if t in _FLOOR_ZERO else 1) # noqa: E731 + while len(pages) > hi: + counts: dict[str, int] = {} + for p in pages: + counts[p["type"]] = counts.get(p["type"], 0) + 1 + drop_type = next( + (t for t in _DROP_PRIORITY if counts.get(t, 0) > floor_of(t)), + None, + ) + if drop_type is None: + # 配额 floor 全满足仍超页 → 兜底砍可整删类型(section/reference 优先) + drop_type = next( + (t for t in _DROP_PRIORITY + if t in _FLOOR_ZERO and counts.get(t, 0) > 0), + None, + ) + if drop_type is None: + break + idx = max(i for i, p in enumerate(pages) if p["type"] == drop_type) + removed = pages.pop(idx) + warnings.append( + f"页数超过 {hi} 页上限,已移除 {removed['type']} 页「{removed['title']}」" + ) + return pages + + +def split_oversized(pages: list[dict], warnings: list[str]) -> list[dict]: + """内容页 bullets >8:拆成两页(续页 title+"(续)"),递归直到全 ≤8。""" + out: list[dict] = [] + for page in pages: + todo = [page] + while todo: + cur = todo.pop(0) + bullets = cur.get("bullets") or [] + if cur["type"] in CONTENT_TYPES and len(bullets) > 8: + k = math.ceil(len(bullets) / 2) + ev_full = list(cur.get("evidence") or []) + if len(ev_full) < len(bullets): + ev_full += [None] * (len(bullets) - len(ev_full)) + nxt = json.loads(json.dumps(cur)) # 深拷贝 + nxt["bullets"] = bullets[k:] + nxt["evidence"] = ev_full[k:] + nxt["title"] = f"{cur['title']}(续)" + nxt["suggested_figures"] = [] # 图占位只放首页 + nxt["speaker_notes"] = "" + cur["bullets"] = bullets[:k] + cur["evidence"] = ev_full[:k] + warnings.append(f"「{cur['title'].removesuffix('(续)')}」要点超过 8 条,已拆成两页") + todo.insert(0, nxt) + todo.insert(0, cur) + continue + out.append(cur) + return out + + +def renumber(pages: list[dict]) -> list[dict]: + for i, p in enumerate(pages, 1): + p["slide_number"] = i + return pages + + +# ---------- 证据程序化校验(纯函数) ---------- + +_FIG_RE = re.compile(r"^(S\d+-P\d+)(?:-Fig(\d+))?$", re.IGNORECASE) + + +def _norm(s: str) -> str: + """归一化:NFKC + 小写 + 去掉全部空白与标点(只留字母数字/CJK)。""" + s = unicodedata.normalize("NFKC", s).lower() + return re.sub(r"[\W_]+", "", s, flags=re.UNICODE) + + +def _concat_segments(norm_index: dict[str, str]) -> tuple[str, dict[str, tuple[int, int]]]: + """按 document 顺序把各段归一化文本拼成一条,并给出每段在拼接串中的 [start, end)。 + + LLM 照抄的 quote 常横跨两个段落边界 → 任何单段都不含它,但整篇文档含。 + repair/validate 据此做跨段宽松反查(v3 修复4)。 + """ + offsets: dict[str, tuple[int, int]] = {} + chunks: list[str] = [] + pos = 0 + for pid, nt in norm_index.items(): + offsets[pid] = (pos, pos + len(nt)) + chunks.append(nt) + pos += len(nt) + return "".join(chunks), offsets + + +def _locate_span( + nq: str, concat: str, offsets: dict[str, tuple[int, int]], +) -> str | None: + """跨段定位:nq 起点落在哪个段(取起点所在段为归属)。≥10 归一化字符才启用。""" + if len(nq) < 10: + return None + i = concat.find(nq) + if i < 0: + return None + for pid, (start, end) in offsets.items(): + if start <= i < end: + return pid + return None + + +def repair_evidence_para_ids(pages: list[dict], index: dict[str, str]) -> int: + """按 quote 归一化子串反查真实 para_id(P5 修复:LLM 常编 para_id,quote 往往逐字正确)。 + + LLM reduce/expand 只需给 quote(prompt 已声明后端反查);这里按 document + 顺序找第一个包含该 quote 的段落填回 para_id,后续 validate_evidence 据此标 + verified。找不到 → 不动,留给 validate_evidence 标 unverified(真幻觉)。 + 返回修复条数。 + """ + norm_index = {pid: _norm(t) for pid, t in index.items()} + concat, offsets = _concat_segments(norm_index) + repaired = 0 + for page in pages: + for ev in page.get("evidence") or []: + if not isinstance(ev, dict): + continue + quote = str(ev.get("quote") or "") + nq = _norm(quote) + if not nq: + continue + pid = str(ev.get("para_id") or "") + if pid and nq in norm_index.get(pid, ""): + continue # 标注已正确,不动 + hit = next((p for p, nt in norm_index.items() if nq in nt), None) + if not hit: + # 跨段 quote:任何单段都不含,但在整篇拼接串里 → 归属起点所在段 + hit = _locate_span(nq, concat, offsets) + if hit: + ev["para_id"] = hit + repaired += 1 + return repaired + + +def validate_evidence( + pages: list[dict], index: dict[str, str], + figure_index: list[dict] | None = None, +) -> list[str]: + """逐条校验 evidence / suggested_figures → status + warnings。 + + figure_index(论文真实图表清单)非空时,图引用按清单裁决: + - label 不在清单 → 直接丢弃(不是标 unverified,结果里不会出现论文没有的图); + - label 在清单 → 以清单里的真实 para_id 为准,status=verified。 + 为 None 时保持旧路径(格式 + 原文 fig{N} 子串校验),兼容无清单的调用方。 + """ + warnings: list[str] = [] + norm_index = {pid: _norm(t) for pid, t in index.items()} + doc_all = "".join(norm_index.values()) + concat, offsets = _concat_segments(norm_index) + fmap = figure_map(figure_index) if figure_index is not None else None + for page in pages: + sn = page["slide_number"] + evs = page.get("evidence") or [] + ver = unver = 0 + for ev in evs: + if ev is None: + continue + quote = str(ev.get("quote") or "") + pid = str(ev.get("para_id") or "") + nq, np_ = _norm(quote), norm_index.get(pid) + if quote and np_ is not None and nq and nq in np_: + ev["status"] = "verified" + ver += 1 + elif quote and nq and pid and _locate_span(nq, concat, offsets) == pid: + # 跨段 quote:repair 已把 para_id 指到起点段 → 等同逐字命中 + ev["status"] = "verified" + ver += 1 + else: + ev["status"] = "unverified" + unver += 1 + if nq and nq in doc_all: + warnings.append( + f"第{sn}页:引用真实存在,但段落标注 {pid or '(空)'} 不符(已标 unverified)" + ) + total = ver + unver + if page["type"] in ("result", "conclusion") and total and unver / total > 0.5: + warnings.append( + f"第{sn}页({page['type']})证据未核实占比超过 50%({unver}/{total}),请人工复核" + ) + kept: list[dict] = [] + for fig in page.get("suggested_figures") or []: + if fmap is not None: + key = canon_label(fig.get("label")) + entry = fmap.get(key) if key else None + if entry is None: + warnings.append( + f"第{sn}页:图引用 {fig.get('label') or '(空)'} 不在论文真实图表清单,已丢弃" + ) + continue + fig["para_id"] = entry["para_id"] # 以扫描出的真实位置为准 + fig["status"] = "verified" + kept.append(fig) + continue + m = _FIG_RE.match(str(fig.get("para_id") or "")) + if not m: + fig["status"] = "unverified" + warnings.append(f"第{sn}页:图引用 {fig.get('para_id')} 格式不合法") + kept.append(fig) + continue + base, num = m.group(1).upper(), m.group(2) + base_norm = norm_index.get(base) + fig_ok = base_norm is not None and ( + num is None or f"fig{num}" in base_norm or f"figure{num}" in base_norm + ) + fig["status"] = "verified" if fig_ok else "unverified" + if not fig_ok: + warnings.append( + f"第{sn}页:图引用 {fig['para_id']} 未在原文对应段落找到(已标 unverified)" + ) + kept.append(fig) + page["suggested_figures"] = kept + return warnings + + +# ---------- 扩充重试(内容页 bullets <2 → 一次按页补齐) ---------- + +_EXPAND_SYSTEM = """你是学术汇报编辑。某几页幻灯片的要点不足(少于 2 条)。 +基于给出的页信息与摘要卡片(claims 已带逐字引用),为每页补齐到 3-6 条要点。 +只输出单个 JSON 对象: +{"pages": [{"index": 0, "bullets": ["..."], "evidence": [{"quote": "..."}]}]} +硬性要求: +- index 是给定页在列表中的下标;bullets 保留原有要点(可精炼改写)并补充同主题新要点; +- evidence 与 bullets 等长对齐;每条 evidence 只需要 "quote" 字段,从卡片里逐字照抄, + **不需要写 para_id,后端会按 quote 自动反查**;没有依据填 null; +- 单条 ≤60 字;严禁编造。不要输出 JSON 以外的任何文字。""" + + +async def _expand_thin( + pages: list[dict], thin_idx: list[int], + claims: list[dict], figures: list[dict], warnings: list[str], +) -> None: + """一次 LLM 调用补齐所有薄弱页;失败仅 warning,不阻断。""" + page_info = [ + { + "index": i, + "type": pages[i]["type"], + "title": pages[i]["title"], + "bullets": pages[i]["bullets"], + "speaker_notes": pages[i]["speaker_notes"], + } + for i in thin_idx + ] + user = ( + f"待补齐页(下标即列表位置):\n{json.dumps(page_info, ensure_ascii=False)}\n\n" + f"摘要卡片:\n{_claims_block(claims, figures)}" + ) + try: + payload = await llm.complete_json( + _EXPAND_SYSTEM, user, temperature=0.3, + max_tokens=4000, timeout=_EXPAND_TIMEOUT, + ) + except LLMError as exc: + warnings.append(f"薄弱页扩充重试失败(保留原状):{exc}") + return + for item in payload.get("pages") or []: + if not isinstance(item, dict): + continue + idx = item.get("index") + if not isinstance(idx, int) or not (0 <= idx < len(pages)) or idx not in thin_idx: + continue + page = pages[idx] + bullets = [ + str(b).strip() for b in (item.get("bullets") or []) + if isinstance(b, (str, int, float)) and str(b).strip() + ][:8] + if len(bullets) < 2: + continue + ev_raw = item.get("evidence") + evidence: list[dict | None] = [] + for i in range(len(bullets)): + e = ev_raw[i] if isinstance(ev_raw, list) and i < len(ev_raw) else None + if isinstance(e, dict) and str(e.get("quote") or "").strip(): + evidence.append({ + "para_id": str(e.get("para_id") or ""), + "quote": str(e.get("quote") or "").strip(), + "status": "unverified", + }) + else: + evidence.append(None) + page["bullets"] = bullets + page["evidence"] = evidence + + +# ---------- 主编排 ---------- + +async def generate_outline( + text: str, scenario: str, minutes: int, style_id: str, + *, + available_figures: list[str] | None = None, +) -> tuple[list[dict], list[str], dict[str, Any]]: + """生成完整大纲。返回 (pages, warnings, meta);失败抛 OutlineError。 + + available_figures(v3 修复2):PDF 入口已提取到磁盘的原图 label 列表。 + 非空时只把「已提取」的图注入 reduce/expand(LLM 选了 build 就能 add_picture + 嵌入,不再出虚线占位);None/空 → 保持全清单(text 模式占位框不变)。 + """ + if scenario not in SCENARIOS: + raise OutlineError("VALIDATION", f"未知场景 scenario={scenario}") + if minutes not in PAGE_TARGETS: + raise OutlineError("VALIDATION", f"不支持的时长 {minutes} 分钟(可选 {list(MINUTES_CHOICES)})") + if style_id not in STYLE_IDS: + raise OutlineError("VALIDATION", f"未知风格 style_id={style_id}") + + text = (text or "").strip() + if len(text) < 300: + raise OutlineError("VALIDATION", "正文过短(<300 字),无法生成汇报大纲") + settings = get_settings() + if len(text) > settings.max_text_chars: + text = text[: settings.max_text_chars] + + blocks, index = build_index(text) + if not blocks: + raise OutlineError("VALIDATION", "正文解析为空,无法生成汇报大纲") + + # P5 增强 1:论文真实图表清单(正则扫描,非 LLM)——供 reduce 只选不编 + figure_index = scan_figure_index(index) + # v3 修复2:有已提取原图 → 注入清单收窄到「磁盘上有图」的条目(交集空则回退全清单) + inject_index = _narrow_figure_index(figure_index, available_figures) + fmap_pick = figure_map(inject_index) + + plan = page_plan(scenario, minutes) + lo, hi = PAGE_TARGETS[minutes] + warnings: list[str] = [] + llm_calls = 0 + + # ---- map(并发 2,控总时长压在 BFF 360s 内) ---- + claims: list[dict] = [] + figures: list[dict] = [] + sem = asyncio.Semaphore(_MAP_CONCURRENCY) + + async def _map_one(block: dict[str, Any]) -> tuple[list[dict], list[dict]]: + async with sem: + return await _map_block(block) + + try: + results = await asyncio.gather(*(_map_one(b) for b in blocks)) + except LLMError as exc: + raise OutlineError("LLM_ERROR", f"章节摘要失败:{exc}") from exc + llm_calls += len(results) + for cs, fs in results: + for c in cs: + if len(claims) < _MAX_CLAIMS_TOTAL: + claims.append(c) + for f in fs: + if len(figures) < _MAX_FIGURES_TOTAL: + figures.append(f) + # P5 增强 1:map 抽到的图先过一遍注入清单(不在清单 → 丢弃;在 → 用真实 para_id) + if fmap_pick: + filtered: list[dict] = [] + for f in figures: + key = canon_label(f.get("label")) + entry = fmap_pick.get(key) if key else None + if entry: + f = dict(f, para_id=entry["para_id"]) + filtered.append(f) + if len(filtered) != len(figures): + warnings.append( + f"map 阶段 {len(figures) - len(filtered)} 个图引用不在论文真实图表清单,已丢弃" + ) + figures = filtered + else: + if figures: + warnings.append(f"论文未扫描到图表编号,丢弃 map 阶段 {len(figures)} 个图引用") + figures = [] + if not claims: + raise OutlineError("LLM_INVALID", "未能从正文抽取到任何可引用事实(claims 为空)") + + # ---- reduce(>14 页分两片,第二片带上一片标题连贯) ---- + full_types = [t for t in PAGE_TYPES if t in plan] + split_at = len(full_types) + acc = 0 + for i, t in enumerate(full_types): + acc += plan[t][1] + if acc >= _REDUCE_PAGE_SLICE: + split_at = i + 1 + break + first_types, second_types = full_types[:split_at], full_types[split_at:] + + def _types_quota(ts: list[str]) -> int: + return sum(plan[t][0] for t in ts) + + pages: list[dict] = [] + try: + payload = await llm.complete_json( + _REDUCE_SYSTEM, + _reduce_user(scenario, minutes, plan, claims, figures, first_types, + figure_index=inject_index), + temperature=0.4, max_tokens=8000, timeout=_REDUCE_TIMEOUT, + ) + llm_calls += 1 + pages = coerce_outline(payload) + if second_types: + prior_titles = [p["title"] for p in pages] + payload2 = await llm.complete_json( + _REDUCE_SYSTEM, + _reduce_user( + scenario, minutes, plan, claims, figures, second_types, + prior_titles=prior_titles, total_hint=_types_quota(second_types), + figure_index=inject_index, + ), + temperature=0.4, max_tokens=8000, timeout=_REDUCE_TIMEOUT, + ) + llm_calls += 1 + pages.extend(coerce_outline(payload2)) + except LLMError as exc: + msg = str(exc) + if "JSON" in msg or "json" in msg: + raise OutlineError("LLM_INVALID", f"LLM 大纲输出无法解析:{msg}") from exc + raise OutlineError("LLM_ERROR", f"大纲生成失败:{msg}") from exc + + # ---- 后校验:拆页 → 夹页数 → 扩充 → 证据 → 重编号 ---- + pages = split_oversized(pages, warnings) + renumber(pages) + # v3 修复3:按配额给全类型设保底(先删超配额的页;section/reference 不再被一刀清空) + floors = {t: mn for t, (mn, _mx) in plan.items() if mn} + pages = trim_to_max(pages, hi, warnings, floors=floors) + renumber(pages) + if len(pages) < lo: + warnings.append( + f"生成 {len(pages)} 页,低于 {minutes} 分钟目标下限 {lo} 页(不凑页,请人工补足)" + ) + # 类型顺序归一:保持 LLM 输出顺序即可,但至少保证 title 在首位 + pages.sort(key=lambda p: (0 if p["type"] == "title" else 1)) + renumber(pages) + + thin_idx = [ + i for i, p in enumerate(pages) + if p["type"] in CONTENT_TYPES and len(p["bullets"]) < 2 + ] + if thin_idx: + await _expand_thin(pages, thin_idx, claims, figures, warnings) + for i in thin_idx: + if len(pages[i]["bullets"]) < 2: + warnings.append(f"第{pages[i]['slide_number']}页要点不足 2 条(扩充后仍不足)") + + # P5 修复:LLM 只给 quote(或编 para_id)→ 按 quote 反查真实段落ID,再进校验 + repaired = repair_evidence_para_ids(pages, index) + if repaired: + warnings.append(f"{repaired} 条 evidence 的段落标注已按 quote 反查修正") + warnings.extend(validate_evidence(pages, index, inject_index)) + renumber(pages) + + unverified_total = sum( + 1 for p in pages for e in (p.get("evidence") or []) if e and e.get("status") == "unverified" + ) + meta = { + "scenario": scenario, + "minutes": minutes, + "style_id": style_id, + "paragraphs": len(index), + "sections": len(blocks), + "claims": len(claims), + "figures": len(figure_index), # 真实图表清单规模(扫描所得) + "figures_used": sum( # 大纲里实际引用且通过校验的图数 + 1 for p in pages for f in (p.get("suggested_figures") or []) + if f.get("status") == "verified" + ), + "llm_calls": llm_calls, + "target_pages": [lo, hi], + "unverified_evidence": unverified_total, + } + return pages, warnings, meta diff --git a/review_service/app/services/ppt_preview.py b/review_service/app/services/ppt_preview.py new file mode 100644 index 0000000..b12c046 --- /dev/null +++ b/review_service/app/services/ppt_preview.py @@ -0,0 +1,230 @@ +"""services/ppt_preview.py —— pptx → PNG 页预览(PIL 自渲染,读回 python-pptx 对象)。 + +移植自交付脚本 /tmp/opencode/render_pptx_preview.py(本项目自有验证脚本), +用途:build 后给前端缩略图条 / 大图预览(浏览器内直接目检版式)。 +非 PowerPoint 截图:几何/颜色/字体色忠实还原,字形以 Noto CJK 近似(预览够用)。 + +依赖仅 Pillow + python-pptx(均已在 tooling/requirements.lock),零新依赖。 +约定:输出 out_dir/slide_{i:02d}.png(i 从 1 起),1280×720 @96dpi; +损坏/空 pptx → 返回 [](调用方降级,不抛)。 +""" +from __future__ import annotations + +import logging +import tempfile +from io import BytesIO +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont +from pptx import Presentation +from pptx.enum.shapes import MSO_SHAPE_TYPE +from pptx.util import Emu + +logger = logging.getLogger(__name__) + +DPI = 96 +SLIDE_W_IN, SLIDE_H_IN = 13.333, 7.5 + +# 字体 fallback 链:Noto CJK(中英混排)→ DejaVu(兜底,缺 CJK 也不崩) +_FONT_CANDIDATES_REG = ( + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", +) +_FONT_CANDIDATES_BOLD = ( + "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", +) + + +def _first_existing(paths: tuple[str, ...]) -> str: + for p in paths: + if Path(p).is_file(): + return p + return paths[-1] + + +_FONT_REG = _first_existing(_FONT_CANDIDATES_REG) +_FONT_BOLD = _first_existing(_FONT_CANDIDATES_BOLD) + + +def px(emu: int) -> int: + return int(Emu(emu).inches * DPI) + + +def font_for(pt: float, bold: bool) -> ImageFont.FreeTypeFont: + path = _FONT_BOLD if bold else _FONT_REG + return ImageFont.truetype(path, max(int(pt * DPI / 72), 6)) + + +def run_style(run) -> tuple[float, bool, tuple[int, int, int]]: + f = run.font + pt = (f.size.pt if f.size else 18) + bold = bool(f.bold) + color = (0, 0, 0) + try: + if f.color and f.color.rgb is not None: + color = tuple(f.color.rgb) + except Exception: # noqa: BLE001 - 主题色等非直接 RGB,回退黑 + pass + return pt, bold, color + + +def wrap_text(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_w: float) -> list[str]: + lines: list[str] = [] + cur = "" + for ch in text: + if ch == "\n": + lines.append(cur) + cur = "" + continue + if draw.textlength(cur + ch, font=font) > max_w and cur: + lines.append(cur) + cur = ch + else: + cur += ch + if cur: + lines.append(cur) + return lines or [""] + + +def draw_text_frame(draw, tf, box, *, v_anchor: str = "top") -> None: + """box = (x, y, w, h);按段落逐 run 画(粗体分段 / level 缩进 / 居中)。""" + x, y, w, h = box + lines_all: list[tuple[str, ImageFont.ImageFont, tuple, int, str]] = [] + for p in tf.paragraphs: + runs = p.runs + if not runs: + lines_all.append(("", font_for(14, False), (0, 0, 0), 0, str(p.alignment or "left"))) + continue + segs = [] + for r in runs: + pt, bold, col = run_style(r) + segs.append((r.text, pt, bold, col)) + # 预览够用:整段拼接,取最大字号 + 任一粗体,色随首 run + joined = "".join(s[0] for s in segs) + max_pt = max(s[1] for s in segs) + any_bold = any(s[2] for s in segs) + col = segs[0][3] + indent = (p.level or 0) * 24 + align = str(p.alignment or "left") + fnt = font_for(max_pt, any_bold) + for line in wrap_text(draw, joined, fnt, max(w - indent - 8, 20)): + lines_all.append((line, fnt, col, indent, align)) + total_h = sum(int(f.size * 1.35) for _, f, _, _, _ in lines_all) + if v_anchor == "middle": + y = y + max((h - total_h) // 2, 0) + for line, fnt, col, indent, align in lines_all: + lw = draw.textlength(line, font=fnt) + if "CENTER" in align: + lx = x + (w - lw) / 2 + elif "RIGHT" in align: + lx = x + w - lw - 6 + else: + lx = x + indent + 4 + draw.text((lx, y), line, font=fnt, fill=col) + y += int(fnt.size * 1.35) + + +def slide_bg_color(slide) -> tuple[int, int, int]: + try: + fill = slide.background.fill + if fill.type is not None and fill.fore_color and fill.fore_color.rgb is not None: + return tuple(fill.fore_color.rgb) + except Exception: # noqa: BLE001 - 无背景填充 → 白底 + pass + return (255, 255, 255) + + +def render_slide(slide) -> Image.Image: + img = Image.new("RGB", (int(SLIDE_W_IN * DPI), int(SLIDE_H_IN * DPI)), slide_bg_color(slide)) + d = ImageDraw.Draw(img) + for sh in slide.shapes: + try: + x, y, w, h = px(sh.left), px(sh.top), px(sh.width), px(sh.height) + except Exception: # noqa: BLE001 - 无定位形状(组合件等)跳过 + continue + if sh.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE: + # 色块(分隔页横幅 / 封面强调条 / 引用框) + fc = None + try: + if sh.fill.type is not None and sh.fill.fore_color.rgb is not None: + fc = tuple(sh.fill.fore_color.rgb) + except Exception: # noqa: BLE001 + fc = None + if fc: + d.rectangle([x, y, x + w, y + h], fill=fc) + else: + d.rectangle([x, y, x + w, y + h], outline=(150, 150, 150), width=2) + if sh.has_text_frame and sh.text_frame.text.strip(): + draw_text_frame(d, sh.text_frame, (x, y, w, h), v_anchor="middle") + elif sh.shape_type == MSO_SHAPE_TYPE.PICTURE: + # 真图(Tier B contain 插入的原图) + try: + pic = Image.open(BytesIO(sh.image.blob)).convert("RGB") + img.paste(pic.resize((max(w, 1), max(h, 1))), (x, y)) + except Exception: # noqa: BLE001 - 损坏图 → 灰块占位 + d.rectangle([x, y, x + w, y + h], fill=(205, 205, 205)) + elif getattr(sh, "has_table", False): + # 原生表格 + tbl = sh.table + cw = [px(tbl.columns[c].width) for c in range(len(tbl.columns))] + rh = [px(tbl.rows[r].height) for r in range(len(tbl.rows))] + cy = y + for r, row_h in enumerate(rh): + cx = x + for c, col_w in enumerate(cw): + cell = tbl.cell(r, c) + try: + fc = tuple(cell.fill.fore_color.rgb) + except Exception: # noqa: BLE001 + fc = (245, 245, 245) + d.rectangle([cx, cy, cx + col_w, cy + row_h], fill=fc, outline=(170, 170, 170)) + draw_text_frame(d, cell.text_frame, (cx + 3, cy + 2, col_w - 6, row_h - 4)) + cx += col_w + cy += row_h + elif sh.has_text_frame: + # 占位符 / 文本框:虚线图框判定 + txt = sh.text_frame.text + if "请插入" in txt: + d.rectangle([x, y, x + w, y + h], outline=(150, 150, 150), width=3) + for i in range(x, x + w, 12): # 近似虚线 + d.line([i, y, min(i + 6, x + w), y], fill=(150, 150, 150), width=3) + d.line([i, y + h, min(i + 6, x + w), y + h], fill=(150, 150, 150), width=3) + d.line([x, y, x, y + h], fill=(150, 150, 150), width=3) + d.line([x + w, y, x + w, y + h], fill=(150, 150, 150), width=3) + anchor = "middle" if "请插入" in txt else "top" + draw_text_frame(d, sh.text_frame, (x, y, w, h), v_anchor=anchor) + return img + + +def render_previews(pptx_path: Path, out_dir: Path) -> list[Path]: + """draft.pptx → out_dir/slide_{i:02d}.png;失败/空/损坏 → [](绝不抛)。""" + if not pptx_path.is_file(): + return [] + try: + prs = Presentation(str(pptx_path)) + except Exception as exc: # noqa: BLE001 - 坏文件降级(调用方 warning) + logger.warning("预览渲染:pptx 打开失败 %s:%s", pptx_path, exc) + return [] + try: + out_dir.mkdir(parents=True, exist_ok=True) + # 清旧页:重建后页数变少时不留 slide_{n}.png 残影(懒渲染 GET 同样受益) + for stale in out_dir.glob("slide_*.png"): + stale.unlink(missing_ok=True) + written: list[Path] = [] + for i, slide in enumerate(prs.slides, 1): + img = render_slide(slide) + final = out_dir / f"slide_{i:02d}.png" + # 原子写:先临时文件再 replace,避免并发 GET 读到半张图 + with tempfile.NamedTemporaryFile(dir=out_dir, suffix=".png", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + img.save(tmp_path, format="PNG") + tmp_path.replace(final) + finally: + tmp_path.unlink(missing_ok=True) + written.append(final) + return written + except Exception as exc: # noqa: BLE001 - 渲染异常不阻断构建 + logger.warning("预览渲染失败(该 job 无缩略图):%s", exc) + return [] diff --git a/review_service/app/services/ppt_store.py b/review_service/app/services/ppt_store.py new file mode 100644 index 0000000..d468146 --- /dev/null +++ b/review_service/app/services/ppt_store.py @@ -0,0 +1,155 @@ +"""services/ppt_store.py —— P5 科研汇报 job 持久化(SQLite,自含连接)。 + +模式同 rebuttal_store:表 ppt_jobs 落 review_service/review.db(同库不同表), +自开短连接 + WAL;pages/warnings 全量 JSON 列整读整写(单进程串行足够)。 +raw_text 只落库不回 API(150K 正文进 job dict 会把 GET 撑爆;重生成场景 +才需要它,经 DB 直读)。DDL 按 2026-10-14 决策 3 定稿。 +""" +from __future__ import annotations + +import json +import sqlite3 +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from ..config import get_settings + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS ppt_jobs ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + source_kind TEXT, + filename TEXT, + raw_text TEXT, + scenario TEXT, + minutes INTEGER, + style_id TEXT, + pages_json TEXT, + pptx_path TEXT, + warnings_json TEXT, + created_at TEXT, + updated_at TEXT +); +""" + +_STATUS_VALUES = ("outline_ready", "building", "done", "error") +_JSON_COLS = {"pages": "pages_json", "warnings": "warnings_json"} +_SCALAR_COLS = ("status", "scenario", "minutes", "style_id", "pptx_path", + "source_kind", "filename") + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def new_id(prefix: str = "") -> str: + return prefix + uuid.uuid4().hex[:12] + + +def _connect() -> sqlite3.Connection: + db_path: Path = get_settings().db_path_abs + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path), timeout=5) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def ensure_table() -> None: + with _connect() as conn: + conn.executescript(_SCHEMA) + + +def _row_to_job(row: sqlite3.Row) -> dict[str, Any]: + status = row["status"] + pptx_path = row["pptx_path"] + return { + "id": row["id"], + "status": status, + "source": { + "kind": row["source_kind"] or "text", + "filename": row["filename"], + "text_chars": len(row["raw_text"] or ""), + }, + "scenario": row["scenario"], + "minutes": row["minutes"], + "style_id": row["style_id"], + "pages": json.loads(row["pages_json"]) if row["pages_json"] else [], + "warnings": json.loads(row["warnings_json"]) if row["warnings_json"] else [], + "pptx_ready": bool(pptx_path) and Path(pptx_path).exists(), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + +def insert_job(job: dict[str, Any], raw_text: str = "") -> None: + now = job.get("created_at") or _now() + status = job.get("status") or "outline_ready" + if status not in _STATUS_VALUES: + raise ValueError(f"非法 status: {status}") + with _connect() as conn: + conn.execute( + "INSERT INTO ppt_jobs (id, status, source_kind, filename, raw_text," + " scenario, minutes, style_id, pages_json, pptx_path, warnings_json," + " created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + job["id"], status, + (job.get("source") or {}).get("kind"), + (job.get("source") or {}).get("filename"), + raw_text, + job.get("scenario"), job.get("minutes"), job.get("style_id"), + json.dumps(job.get("pages") or [], ensure_ascii=False), + job.get("pptx_path"), + json.dumps(job.get("warnings") or [], ensure_ascii=False), + now, now, + ), + ) + + +def get_job(job_id: str) -> dict[str, Any] | None: + with _connect() as conn: + row = conn.execute("SELECT * FROM ppt_jobs WHERE id=?", (job_id,)).fetchone() + return _row_to_job(row) if row else None + + +def update_job(job_id: str, **fields: Any) -> dict[str, Any] | None: + """局部更新(省略的列不动;pages/warnings 传对象自动序列化)。""" + sets: list[str] = [] + vals: list[Any] = [] + for key, value in fields.items(): + if key in _JSON_COLS: + sets.append(f"{_JSON_COLS[key]}=?") + vals.append(json.dumps(value, ensure_ascii=False) if value is not None else None) + elif key in _SCALAR_COLS: + if key == "status" and value not in _STATUS_VALUES: + raise ValueError(f"非法 status: {value}") + sets.append(f"{key}=?") + vals.append(value) + else: + raise TypeError(f"update_job 不支持的字段: {key}") + if not sets: + return get_job(job_id) + sets.append("updated_at=?") + vals.extend([_now(), job_id]) + with _connect() as conn: + cur = conn.execute(f"UPDATE ppt_jobs SET {', '.join(sets)} WHERE id=?", vals) + if cur.rowcount == 0: + return None + return get_job(job_id) + + +def delete_job(job_id: str) -> bool: + with _connect() as conn: + cur = conn.execute("DELETE FROM ppt_jobs WHERE id=?", (job_id,)) + return cur.rowcount > 0 + + +def get_raw_text(job_id: str) -> str: + """raw_text 只在 evidence 重验时直读(不进 API job dict,避免 GET 撑爆)。""" + with _connect() as conn: + row = conn.execute( + "SELECT raw_text FROM ppt_jobs WHERE id=?", (job_id,) + ).fetchone() + return (row["raw_text"] or "") if row else "" diff --git a/review_service/data/ppt_styles/academic-blue.json b/review_service/data/ppt_styles/academic-blue.json new file mode 100644 index 0000000..d49ccb3 --- /dev/null +++ b/review_service/data/ppt_styles/academic-blue.json @@ -0,0 +1,31 @@ +{ + "id": "academic-blue", + "display_name": "学术蓝", + "label": "学术蓝", + "description": "经典蓝白 · 答辩常用", + "palette": { + "bg": "#FFFFFF", + "cover_bg": "#EFF6FF", + "section_bg": "#DBEAFE", + "section_text": "#FFFFFF", + "primary": "#1D4ED8", + "accent": "#3B82F6", + "text": "#0F172A", + "muted": "#64748B", + "cover_text": "#1E3A8A", + "box_line": "#93C5FD" + }, + "fonts": { + "title": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif", + "body": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif" + }, + "sizes": { + "cover_title_pt": 40, + "title_pt": 30, + "bullet_pt": 18, + "reference_pt": 14, + "figure_pt": 13 + }, + "cover_style": { "title_align": "left", "accent_bar": true }, + "content_style": { "figure_split_pct": 60 } +} diff --git a/review_service/data/ppt_styles/academic-minimal.json b/review_service/data/ppt_styles/academic-minimal.json new file mode 100644 index 0000000..f0b819b --- /dev/null +++ b/review_service/data/ppt_styles/academic-minimal.json @@ -0,0 +1,31 @@ +{ + "id": "academic-minimal", + "display_name": "学术极简", + "label": "学术极简", + "description": "白底蓝黑 · 投屏通用", + "palette": { + "bg": "#FFFFFF", + "cover_bg": "#FFFFFF", + "section_bg": "#F3F4F6", + "section_text": "#FFFFFF", + "primary": "#111827", + "accent": "#2563EB", + "text": "#1F2937", + "muted": "#6B7280", + "cover_text": "#111827", + "box_line": "#9CA3AF" + }, + "fonts": { + "title": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif", + "body": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif" + }, + "sizes": { + "cover_title_pt": 40, + "title_pt": 30, + "bullet_pt": 18, + "reference_pt": 14, + "figure_pt": 13 + }, + "cover_style": { "title_align": "left", "accent_bar": false }, + "content_style": { "figure_split_pct": 60 } +} diff --git a/review_service/data/ppt_styles/botanic-forest.json b/review_service/data/ppt_styles/botanic-forest.json new file mode 100644 index 0000000..7e770d1 --- /dev/null +++ b/review_service/data/ppt_styles/botanic-forest.json @@ -0,0 +1,31 @@ +{ + "id": "botanic-forest", + "display_name": "植研绿林", + "label": "植研绿林", + "description": "植物绿系 · 所内汇报", + "palette": { + "bg": "#F4FAF6", + "cover_bg": "#14532D", + "section_bg": "#DCFCE7", + "section_text": "#FFFFFF", + "primary": "#14532D", + "accent": "#16A34A", + "text": "#1A2E1A", + "muted": "#4D7C57", + "cover_text": "#FFFFFF", + "box_line": "#86C79A" + }, + "fonts": { + "title": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif", + "body": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif" + }, + "sizes": { + "cover_title_pt": 40, + "title_pt": 30, + "bullet_pt": 18, + "reference_pt": 14, + "figure_pt": 13 + }, + "cover_style": { "title_align": "left", "accent_bar": false }, + "content_style": { "figure_split_pct": 60 } +} diff --git a/review_service/data/ppt_styles/classic-defense.json b/review_service/data/ppt_styles/classic-defense.json new file mode 100644 index 0000000..560a19a --- /dev/null +++ b/review_service/data/ppt_styles/classic-defense.json @@ -0,0 +1,31 @@ +{ + "id": "classic-defense", + "display_name": "经典答辩", + "label": "经典答辩", + "description": "深蓝衬线 · 正式答辩", + "palette": { + "bg": "#FFFFFF", + "cover_bg": "#1A365D", + "section_bg": "#EEF2F7", + "section_text": "#FFFFFF", + "primary": "#1A365D", + "accent": "#B7791F", + "text": "#1F2937", + "muted": "#64748B", + "cover_text": "#FFFFFF", + "box_line": "#94A3B8" + }, + "fonts": { + "title": "Times New Roman, Georgia, Noto Serif CJK SC, SimSun, serif", + "body": "Times New Roman, Georgia, Noto Serif CJK SC, SimSun, serif" + }, + "sizes": { + "cover_title_pt": 40, + "title_pt": 30, + "bullet_pt": 18, + "reference_pt": 14, + "figure_pt": 13 + }, + "cover_style": { "title_align": "left", "accent_bar": false }, + "content_style": { "figure_split_pct": 60 } +} diff --git a/review_service/data/ppt_styles/domestic.json b/review_service/data/ppt_styles/domestic.json new file mode 100644 index 0000000..78ecc8d --- /dev/null +++ b/review_service/data/ppt_styles/domestic.json @@ -0,0 +1,36 @@ +{ + "id": "domestic", + "display_name": "国内组会", + "label": "国内组会", + "description": "深蓝红强调 · 国内组会 / 学术报告", + "palette": { + "bg": "#FFFFFF", + "cover_bg": "#FFFFFF", + "section_bg": "#1F4E79", + "section_text": "#FFFFFF", + "primary": "#1F4E79", + "accent": "#C00000", + "text": "#1A1A1A", + "muted": "#6B6B6B", + "cover_text": "#1F4E79", + "box_line": "#F2F2F2" + }, + "fonts": { + "title": "Microsoft YaHei, PingFang SC, Source Han Sans SC, Noto Sans CJK SC, Arial, sans-serif", + "body": "Microsoft YaHei, PingFang SC, Source Han Sans SC, Noto Sans CJK SC, Arial, sans-serif" + }, + "sizes": { + "cover_title_pt": 36, + "title_pt": 28, + "bullet_pt": 20, + "reference_pt": 16, + "figure_pt": 13 + }, + "cover_style": { + "title_align": "left", + "accent_bar": true + }, + "content_style": { + "figure_split_pct": 60 + } +} diff --git a/review_service/data/ppt_styles/editorial.json b/review_service/data/ppt_styles/editorial.json new file mode 100644 index 0000000..3406d3f --- /dev/null +++ b/review_service/data/ppt_styles/editorial.json @@ -0,0 +1,36 @@ +{ + "id": "editorial", + "display_name": "杂志插画", + "label": "杂志插画", + "description": "米底紫衬线 · 插画杂志风", + "palette": { + "bg": "#FAF7F2", + "cover_bg": "#FAF7F2", + "section_bg": "#2B2B2B", + "section_text": "#FFFFFF", + "primary": "#8E7DBE", + "accent": "#C8553D", + "text": "#2B2B2B", + "muted": "#7D776F", + "cover_text": "#2B2B2B", + "box_line": "#F1ECE4" + }, + "fonts": { + "title": "Palatino Linotype, Book Antiqua, Georgia, Source Han Serif SC, SimSun, Noto Serif SC, serif", + "body": "Palatino Linotype, Book Antiqua, Georgia, Source Han Serif SC, SimSun, Noto Serif SC, serif" + }, + "sizes": { + "cover_title_pt": 40, + "title_pt": 32, + "bullet_pt": 20, + "reference_pt": 16, + "figure_pt": 13 + }, + "cover_style": { + "title_align": "left", + "accent_bar": true + }, + "content_style": { + "figure_split_pct": 60 + } +} diff --git a/review_service/data/ppt_styles/international.json b/review_service/data/ppt_styles/international.json new file mode 100644 index 0000000..fa84386 --- /dev/null +++ b/review_service/data/ppt_styles/international.json @@ -0,0 +1,36 @@ +{ + "id": "international", + "display_name": "国际会议", + "label": "国际会议", + "description": "白底红点 · 海外组会 / 国际会议", + "palette": { + "bg": "#FFFFFF", + "cover_bg": "#FFFFFF", + "section_bg": "#111111", + "section_text": "#FFFFFF", + "primary": "#111111", + "accent": "#C0392B", + "text": "#111111", + "muted": "#6B6B6B", + "cover_text": "#111111", + "box_line": "#E5E5E5" + }, + "fonts": { + "title": "Arial, Helvetica, Microsoft YaHei, PingFang SC, Source Han Sans SC, sans-serif", + "body": "Arial, Helvetica, Microsoft YaHei, PingFang SC, Source Han Sans SC, sans-serif" + }, + "sizes": { + "cover_title_pt": 40, + "title_pt": 32, + "bullet_pt": 20, + "reference_pt": 16, + "figure_pt": 13 + }, + "cover_style": { + "title_align": "left", + "accent_bar": true + }, + "content_style": { + "figure_split_pct": 60 + } +} diff --git a/review_service/data/ppt_styles/jp-gothic.json b/review_service/data/ppt_styles/jp-gothic.json new file mode 100644 index 0000000..5bcd862 --- /dev/null +++ b/review_service/data/ppt_styles/jp-gothic.json @@ -0,0 +1,36 @@ +{ + "id": "jp-gothic", + "display_name": "日韩发表", + "label": "日韩发表", + "description": "白底蓝黄贴纸 · 日韩技术发表", + "palette": { + "bg": "#FFFFFF", + "cover_bg": "#FFFFFF", + "section_bg": "#1E5BD8", + "section_text": "#FFFFFF", + "primary": "#1E5BD8", + "accent": "#1E5BD8", + "text": "#111111", + "muted": "#6B6B6B", + "cover_text": "#1E5BD8", + "box_line": "#EFEFEF" + }, + "fonts": { + "title": "Noto Sans JP, Hiragino Sans, Yu Gothic, Microsoft YaHei, Noto Sans SC, sans-serif", + "body": "Noto Sans JP, Hiragino Sans, Yu Gothic, Microsoft YaHei, Noto Sans SC, sans-serif" + }, + "sizes": { + "cover_title_pt": 46, + "title_pt": 28, + "bullet_pt": 20, + "reference_pt": 16, + "figure_pt": 13 + }, + "cover_style": { + "title_align": "left", + "accent_bar": true + }, + "content_style": { + "figure_split_pct": 60 + } +} diff --git a/review_service/data/ppt_styles/monochrome.json b/review_service/data/ppt_styles/monochrome.json new file mode 100644 index 0000000..d5f70cc --- /dev/null +++ b/review_service/data/ppt_styles/monochrome.json @@ -0,0 +1,31 @@ +{ + "id": "monochrome", + "display_name": "极简黑白", + "label": "极简黑白", + "description": "黑白灰 · 正式严肃", + "palette": { + "bg": "#FFFFFF", + "cover_bg": "#FFFFFF", + "section_bg": "#F5F5F5", + "section_text": "#FFFFFF", + "primary": "#111111", + "accent": "#444444", + "text": "#1A1A1A", + "muted": "#666666", + "cover_text": "#111111", + "box_line": "#999999" + }, + "fonts": { + "title": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif", + "body": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif" + }, + "sizes": { + "cover_title_pt": 40, + "title_pt": 30, + "bullet_pt": 18, + "reference_pt": 14, + "figure_pt": 13 + }, + "cover_style": { "title_align": "center", "accent_bar": true }, + "content_style": { "figure_split_pct": 55 } +} diff --git a/review_service/data/ppt_styles/nature-green.json b/review_service/data/ppt_styles/nature-green.json new file mode 100644 index 0000000..2d650b2 --- /dev/null +++ b/review_service/data/ppt_styles/nature-green.json @@ -0,0 +1,31 @@ +{ + "id": "nature-green", + "display_name": "自然绿", + "label": "自然绿", + "description": "自然生态绿 · 植物/生态领域", + "palette": { + "bg": "#FFFFFF", + "cover_bg": "#F0FDF4", + "section_bg": "#DCFCE7", + "section_text": "#FFFFFF", + "primary": "#15803D", + "accent": "#22C55E", + "text": "#14532D", + "muted": "#4D7C57", + "cover_text": "#14532D", + "box_line": "#86EFAC" + }, + "fonts": { + "title": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif", + "body": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif" + }, + "sizes": { + "cover_title_pt": 40, + "title_pt": 30, + "bullet_pt": 18, + "reference_pt": 14, + "figure_pt": 13 + }, + "cover_style": { "title_align": "left", "accent_bar": true }, + "content_style": { "figure_split_pct": 60 } +} diff --git a/review_service/data/ppt_styles/systems-talk.json b/review_service/data/ppt_styles/systems-talk.json new file mode 100644 index 0000000..d6a7408 --- /dev/null +++ b/review_service/data/ppt_styles/systems-talk.json @@ -0,0 +1,36 @@ +{ + "id": "systems-talk", + "display_name": "系统顶会", + "label": "系统顶会", + "description": "深蓝橙标 · 系统顶会报告", + "palette": { + "bg": "#FFFFFF", + "cover_bg": "#FFFFFF", + "section_bg": "#1E2633", + "section_text": "#FFFFFF", + "primary": "#1F3A5F", + "accent": "#E4572E", + "text": "#1A1A1A", + "muted": "#6B6B6B", + "cover_text": "#1F3A5F", + "box_line": "#EEF1F4" + }, + "fonts": { + "title": "Arial, Helvetica, Microsoft YaHei, Source Han Sans SC, sans-serif", + "body": "Arial, Helvetica, Microsoft YaHei, Source Han Sans SC, sans-serif" + }, + "sizes": { + "cover_title_pt": 44, + "title_pt": 30, + "bullet_pt": 20, + "reference_pt": 16, + "figure_pt": 13 + }, + "cover_style": { + "title_align": "left", + "accent_bar": true + }, + "content_style": { + "figure_split_pct": 60 + } +} diff --git a/review_service/data/ppt_styles/tech-dark.json b/review_service/data/ppt_styles/tech-dark.json new file mode 100644 index 0000000..5ab868c --- /dev/null +++ b/review_service/data/ppt_styles/tech-dark.json @@ -0,0 +1,31 @@ +{ + "id": "tech-dark", + "display_name": "深色科技", + "label": "深色科技", + "description": "深色投影 · AI/工程领域", + "palette": { + "bg": "#0B1220", + "cover_bg": "#0B1220", + "section_bg": "#111827", + "section_text": "#0B1220", + "primary": "#38BDF8", + "accent": "#22D3EE", + "text": "#E2E8F0", + "muted": "#94A3B8", + "cover_text": "#F8FAFC", + "box_line": "#475569" + }, + "fonts": { + "title": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif", + "body": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif" + }, + "sizes": { + "cover_title_pt": 40, + "title_pt": 30, + "bullet_pt": 18, + "reference_pt": 14, + "figure_pt": 13 + }, + "cover_style": { "title_align": "center", "accent_bar": true }, + "content_style": { "figure_split_pct": 65 } +} diff --git a/review_service/data/ppt_styles/theory-beamer.json b/review_service/data/ppt_styles/theory-beamer.json new file mode 100644 index 0000000..0bebba5 --- /dev/null +++ b/review_service/data/ppt_styles/theory-beamer.json @@ -0,0 +1,36 @@ +{ + "id": "theory-beamer", + "display_name": "理论报告", + "label": "理论报告", + "description": "灰绿橙点 · Metropolis 理论风", + "palette": { + "bg": "#FAFAFA", + "cover_bg": "#FAFAFA", + "section_bg": "#23373B", + "section_text": "#FFFFFF", + "primary": "#23373B", + "accent": "#EB811B", + "text": "#23373B", + "muted": "#6B7378", + "cover_text": "#23373B", + "box_line": "#D3D6D8" + }, + "fonts": { + "title": "Fira Sans, Calibri, Microsoft YaHei, Source Han Sans SC, sans-serif", + "body": "Fira Sans, Calibri, Microsoft YaHei, Source Han Sans SC, sans-serif" + }, + "sizes": { + "cover_title_pt": 38, + "title_pt": 26, + "bullet_pt": 20, + "reference_pt": 16, + "figure_pt": 13 + }, + "cover_style": { + "title_align": "left", + "accent_bar": false + }, + "content_style": { + "figure_split_pct": 60 + } +} diff --git a/review_service/data/ppt_styles/warm-earth.json b/review_service/data/ppt_styles/warm-earth.json new file mode 100644 index 0000000..3607ac7 --- /dev/null +++ b/review_service/data/ppt_styles/warm-earth.json @@ -0,0 +1,31 @@ +{ + "id": "warm-earth", + "display_name": "暖色土系", + "label": "暖色土系", + "description": "暖土色系 · 农业领域", + "palette": { + "bg": "#FFFBF5", + "cover_bg": "#FFF7ED", + "section_bg": "#FFEDD5", + "section_text": "#FFFFFF", + "primary": "#9A3412", + "accent": "#EA580C", + "text": "#431407", + "muted": "#9A6A55", + "cover_text": "#7C2D12", + "box_line": "#FDBA74" + }, + "fonts": { + "title": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif", + "body": "Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, sans-serif" + }, + "sizes": { + "cover_title_pt": 40, + "title_pt": 30, + "bullet_pt": 18, + "reference_pt": 14, + "figure_pt": 13 + }, + "cover_style": { "title_align": "left", "accent_bar": true }, + "content_style": { "figure_split_pct": 60 } +} diff --git a/review_service/tests/test_ppt_build.py b/review_service/tests/test_ppt_build.py new file mode 100644 index 0000000..3b6bdc1 --- /dev/null +++ b/review_service/tests/test_ppt_build.py @@ -0,0 +1,677 @@ +"""tests/test_ppt_build.py —— P5 python-pptx 初稿生成(本地 CPU,无网络)。 + +覆盖:三风格可用、版式分布(封面/内容/图占位)、notes 承载 evidence、 +多值字体栈首字体 latin+ea 双写、风格文件缺失信封码。 +运行: cd review_service && /bin/python -m pytest tests/test_ppt_build.py -q +""" +from __future__ import annotations + +import pytest +from pptx import Presentation +from pptx.enum.shapes import MSO_SHAPE, MSO_SHAPE_TYPE +from pptx.enum.text import PP_ALIGN +from pptx.oxml.ns import qn +from pptx.util import Inches + +from app.services.ppt_build import BuildError, build_pptx, load_style + +PAGES = [ + {"slide_number": 1, "type": "title", "title": "水稻盐胁迫响应机制", + "bullets": ["张三 等", "某大学植物科学学院"], "evidence": [], + "speaker_notes": "各位老师好。", "suggested_figures": []}, + {"slide_number": 2, "type": "outline", "title": "汇报提纲", + "bullets": ["背景", "方法", "结果", "结论"], "evidence": [None] * 4, + "speaker_notes": "", "suggested_figures": []}, + {"slide_number": 3, "type": "background", "title": "研究背景", + "bullets": ["盐胁迫是主要非生物胁迫", "机制不清"], + "evidence": [{"para_id": "S1-P3", "quote": "salt stress tolerance", + "status": "verified"}, None], + "speaker_notes": "背景讲两点。", "suggested_figures": []}, + {"slide_number": 4, "type": "result", "title": "结果:表型", + "bullets": ["根长下降"], "evidence": [None], + "speaker_notes": "", + "suggested_figures": [{"label": "Fig 2", "para_id": "S4-P1-Fig2", + "status": "unverified"}]}, + {"slide_number": 5, "type": "conclusion", "title": "结论", + "bullets": ["发现关键因子"], "evidence": [None], + "speaker_notes": "总结。", "suggested_figures": []}, +] + + +def test_load_style_all_ids(): + for sid in ("academic-minimal", "botanic-forest", "classic-defense"): + style = load_style(sid) + assert style["id"] == sid + # 补充 2:字体是多值栈(CSS 语法,pptx 侧取首字体) + assert "," in style["fonts"]["body"] + assert style["sizes"]["bullet_pt"] > 0 + with pytest.raises(BuildError) as ei: + load_style("neon-punk") + assert ei.value.code == "VALIDATION" + + +def test_load_style_missing_file(tmp_path, monkeypatch): + from app.config import get_settings + monkeypatch.setattr(get_settings(), "ppt_styles_dir", str(tmp_path)) + with pytest.raises(BuildError) as ei: + load_style("academic-minimal") + assert ei.value.code == "STYLE_NOT_FOUND" + + +def test_build_all_styles(tmp_path): + for sid in ("academic-minimal", "botanic-forest", "classic-defense"): + out = tmp_path / f"{sid}.pptx" + stats = build_pptx(PAGES, sid, out, context={"scenario": "defense", "minutes": 15}) + assert stats["slides"] == 5 + assert stats["bytes"] > 10_000 + assert out.exists() + + +def test_built_deck_structure_and_notes(tmp_path): + out = tmp_path / "draft.pptx" + build_pptx(PAGES, "botanic-forest", out, context={"scenario": "defense", "minutes": 15}) + prs = Presentation(str(out)) + assert len(prs.slides._sldIdLst) == 5 + + # 封面:标题 + 副标题(bullets 拼接) + s1 = prs.slides[0] + assert "水稻盐胁迫" in s1.shapes.title.text_frame.text + assert "张三 等 | 某大学植物科学学院" in s1.placeholders[1].text_frame.text + assert "各位老师好" in s1.notes_slide.notes_text_frame.text + + # 内容页:正文有 bullets,evidence 不进正文只进 notes + s3 = prs.slides[2] + body = s3.placeholders[1].text_frame.text + assert "盐胁迫是主要非生物胁迫" in body + assert "salt stress tolerance" not in body + notes3 = s3.notes_slide.notes_text_frame.text + assert "S1-P3" in notes3 and "verified" in notes3 and "背景讲两点" in notes3 + + # 图占位页:右侧虚线框(请插入 + 来源 para_id) + s4 = prs.slides[3] + boxes = [sh for sh in s4.shapes if sh.has_text_frame and "请插入" in sh.text_frame.text] + assert boxes and "S4-P1-Fig2" in boxes[0].text_frame.text + assert "Fig 2" in s4.notes_slide.notes_text_frame.text + + # 多值字体栈 → 首字体 latin + a:ea 双写 + run = s3.shapes.title.text_frame.paragraphs[0].runs[0] + latin = run.font._rPr.find(qn("a:latin")).get("typeface") + ea = run.font._rPr.find(qn("a:ea")).get("typeface") + assert latin == ea == "Microsoft YaHei" + assert run.font.size.pt == 30 + + +def test_build_rejects_empty_and_unknown_style(tmp_path): + with pytest.raises(BuildError) as ei: + build_pptx([], "academic-minimal", tmp_path / "x.pptx") + assert ei.value.code == "VALIDATION" + with pytest.raises(BuildError): + build_pptx(PAGES, "style-404", tmp_path / "x.pptx") + + +def test_cover_fallback_without_bullets(tmp_path): + pages = [dict(PAGES[0], bullets=[])] + out = tmp_path / "cover.pptx" + build_pptx(pages, "classic-defense", out, context={"scenario": "defense", "minutes": 30}) + prs = Presentation(str(out)) + assert "答辩 · 30 分钟" in prs.slides[0].placeholders[1].text_frame.text + + +# ---------- P5 增强:8 风格 / 加粗与子项 / 分隔页 / 图 60-40 / 大数字 ---------- + +RICH_PAGES = PAGES + [ + {"slide_number": 6, "type": "section", "title": "主要结果", "bullets": [], + "evidence": [], "speaker_notes": "进入结果部分。", "suggested_figures": []}, + {"slide_number": 7, "type": "result", "title": "株高显著提升", + "bullets": ["处理组高于对照"], "evidence": [None], "speaker_notes": "", + "suggested_figures": [], "highlight": {"value": "+0.82 cm", "label": "株高增长"}}, + {"slide_number": 8, "type": "method", "title": "方法要点", + "bullets": ["测定 **盐离子** 含量", "- 电导法"], "evidence": [None, None], + "speaker_notes": "", "suggested_figures": []}, +] + + +def test_load_style_all_eight_ids(): + from app.services.ppt_outline import STYLE_IDS + assert len(STYLE_IDS) == 14 + for sid in STYLE_IDS: + style = load_style(sid) + assert style["id"] == sid + assert style["display_name"] and style["label"] and style["description"] + assert "figure_split_pct" in style["content_style"] + assert style["palette"].get("section_bg") and style["palette"].get("section_text") + assert style["cover_style"]["title_align"] in ("left", "center") + # 既有三风格视觉不变量(升级 schema 不改观感) + for sid in ("academic-minimal", "botanic-forest", "classic-defense"): + legacy = load_style(sid) + assert legacy["cover_style"]["accent_bar"] is False + assert legacy["cover_style"]["title_align"] == "left" + + +def test_build_all_eight_styles(tmp_path): + from app.services.ppt_outline import STYLE_IDS + for sid in STYLE_IDS: + out = tmp_path / f"{sid}.pptx" + stats = build_pptx(RICH_PAGES, sid, out, + context={"scenario": "defense", "minutes": 15}) + assert stats["slides"] == 8, sid + assert stats["bytes"] > 10_000, sid + + +def test_bold_runs_and_sub_bullet_level(tmp_path): + out = tmp_path / "rich.pptx" + build_pptx(RICH_PAGES, "academic-minimal", out, + context={"scenario": "defense", "minutes": 15}) + prs = Presentation(str(out)) + body = prs.slides[7].placeholders[1] # method 页 + ps = body.text_frame.paragraphs + # **盐离子** → 拆 3 run,中间 run bold,** 标记不落盘 + assert [r.text for r in ps[0].runs] == ["测定 ", "盐离子", " 含量"] + assert ps[0].runs[1].font.bold is True + assert ps[0].runs[0].font.bold is False + assert "**" not in ps[0].text + # 「- 电导法」→ 二级缩进,前缀剥掉 + assert ps[1].level == 1 + assert ps[1].text == "电导法" + + +def test_section_divider_layout(tmp_path): + """改进 2:section_bg 底 + 40pt 主标题 + 20pt 副标题 + 底部 30% 装饰线。""" + out = tmp_path / "rich.pptx" + build_pptx(RICH_PAGES, "botanic-forest", out) + prs = Presentation(str(out)) + s = prs.slides[5] + assert s.slide_layout.name == "Blank" + assert s.shapes.title is None # 分隔页无占位符 + # 背景 = tokens.section_bg(不再用 accent 横幅) + assert str(s.background.fill.fore_color.rgb) == "DCFCE7" + # 形状:主标题框 + 副标题框(bullets 空 → 该章第一页 title 兜底)+ 底部装饰线 + assert len(s.shapes) == 3 + title_run = s.shapes[0].text_frame.paragraphs[0].runs[0] + assert title_run.text == "主要结果" + assert title_run.font.bold is True and title_run.font.size.pt == 40 + assert s.shapes[0].text_frame.paragraphs[0].alignment == PP_ALIGN.CENTER + sub_run = s.shapes[1].text_frame.paragraphs[0].runs[0] + assert sub_run.text == "株高显著提升" # 该章(section 之后首张内容页)title + assert sub_run.font.size.pt == 20 + assert s.shapes[1].top > s.shapes[0].top # 副标题在主标题下方 + # 底部装饰线:accent 色、宽 30% 页宽、水平居中 + line = s.shapes[2] + assert abs(int(line.width) - int(Inches(13.333 * 0.3))) <= 2000 + assert abs(int(line.left) - int((Inches(13.333) - line.width) / 2)) <= 2000 + assert str(line.fill.fore_color.rgb) == "16A34A" + assert "进入结果部分" in s.notes_slide.notes_text_frame.text + + +def test_figure_page_split_geometry(tmp_path): + out = tmp_path / "rich.pptx" + build_pptx(RICH_PAGES, "academic-minimal", out) + prs = Presentation(str(out)) + s = prs.slides[3] # result + Fig 2 占位 + body = s.placeholders[1] + box = next(sh for sh in s.shapes + if sh.has_text_frame and "请插入" in sh.text_frame.text) + # 左侧正文不越界到右侧图框:60/40 分栏 + assert body.left + body.width <= box.left + assert "Fig 2(论文 S4-P1-Fig2)" in box.text_frame.text + # 分栏计算:body 宽 = (12.33-0.3)*0.6 + assert body.width == Inches((12.33 - 0.3) * 0.6) + + +def test_highlight_big_number_page(tmp_path): + out = tmp_path / "rich.pptx" + build_pptx(RICH_PAGES, "tech-dark", out) + prs = Presentation(str(out)) + s = prs.slides[6] # highlight result 页 + value_box = next(sh for sh in s.shapes + if sh.has_text_frame and sh.text_frame.text == "+0.82 cm") + vrun = value_box.text_frame.paragraphs[0].runs[0] + assert vrun.font.size.pt == 60 and vrun.font.bold is True + assert value_box.text_frame.paragraphs[0].alignment == PP_ALIGN.CENTER + label_box = next(sh for sh in s.shapes + if sh.has_text_frame and sh.text_frame.text == "株高增长") + assert label_box.text_frame.paragraphs[0].runs[0].font.size.pt == 24 + # bullets 让位下移 + assert s.placeholders[1].top == Inches(4.35) + + +def test_cover_accent_bar_variants(tmp_path): + # 左对齐 + accent_bar → 左侧竖条 + out1 = tmp_path / "left.pptx" + build_pptx([PAGES[0]], "academic-blue", out1) + s1 = Presentation(str(out1)).slides[0] + vbars = [sh for sh in s1.shapes if sh.width == Inches(0.18)] + assert len(vbars) == 1 and vbars[0].height == Inches(7.5) + # 居中 + accent_bar → 底部通栏 + out2 = tmp_path / "center.pptx" + build_pptx([PAGES[0]], "monochrome", out2) + s2 = Presentation(str(out2)).slides[0] + hbars = [sh for sh in s2.shapes + if sh.height == Inches(0.15) and sh.width == Inches(13.333)] + assert len(hbars) == 1 and hbars[0].top == Inches(7.35) + # 既有风格无强调条 + out3 = tmp_path / "plain.pptx" + build_pptx([PAGES[0]], "academic-minimal", out3) + s3 = Presentation(str(out3)).slides[0] + assert not [sh for sh in s3.shapes + if sh.width == Inches(0.18) + or (sh.height == Inches(0.15) and sh.width == Inches(13.333))] + + +# ---------- P5 二次开发 2.1/2.3:archetype 版式 + 真图 Tier B ---------- + +def _deck(tmp_path, pages, name="arch.pptx", style="academic-minimal"): + out = tmp_path / name + build_pptx(pages, style, out) + return Presentation(str(out)) + + +def _textbox_texts(slide): + return [sh.text_frame.text for sh in slide.shapes + if sh.has_text_frame and sh.text_frame.text.strip()] + + +def test_outline_chip_layout(tmp_path): + prs = _deck(tmp_path, [PAGES[0], PAGES[1]]) + s = prs.slides[1] + assert s.placeholders[1].text_frame.text == "" # body 让位 + chips = [sh for sh in s.shapes + if sh.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE + and sh.auto_shape_type == MSO_SHAPE.ROUNDED_RECTANGLE] + assert [c.text_frame.text for c in chips] == ["1", "2", "3", "4"] + texts = " ".join(_textbox_texts(s)) + for item in ("背景", "方法", "结果", "结论"): + assert item in texts + + +def test_takeaway_box_renders_and_leaves_body(tmp_path): + page = {"slide_number": 1, "type": "background", "title": "研究背景", + "bullets": ["靶基因:SOS1", "组织:根尖", "> **盐胁迫**下 SOS1 显著上调"], + "evidence": [None] * 3, "speaker_notes": "", "suggested_figures": []} + prs = _deck(tmp_path, [page]) + s = prs.slides[0] + body = s.placeholders[1].text_frame.text + assert "靶基因" in body and "组织" in body + assert "> " not in body and "**" not in body + box = next(t for t in _textbox_texts(s) if "显著上调" in t) + assert "盐胁迫" in box and "**" not in box + + +def test_kv_rows_label_bold(tmp_path): + page = {"slide_number": 1, "type": "background", "title": "材料与条件", + "bullets": ["靶基因:SOS1", "组织:根尖", "时间:24 h"], + "evidence": [None] * 3, "speaker_notes": "", "suggested_figures": []} + s = _deck(tmp_path, [page]).slides[0] + label_box = next(sh for sh in s.shapes + if sh.has_text_frame and sh.text_frame.text == "靶基因") + assert label_box.text_frame.paragraphs[0].runs[0].font.bold is True + assert any("根尖" in t for t in _textbox_texts(s)) + assert s.placeholders[1].text_frame.text == "" + + +def test_flow_cards_with_arrows(tmp_path): + page = {"slide_number": 1, "type": "method", "title": "分析流程", + "bullets": ["→ 提取 RNA", "→ 建库测序", "→ 差异分析"], + "evidence": [None] * 3, "speaker_notes": "", "suggested_figures": []} + s = _deck(tmp_path, [page]).slides[0] + arrows = [sh for sh in s.shapes + if sh.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE + and sh.auto_shape_type == MSO_SHAPE.RIGHT_ARROW] + assert len(arrows) == 2 + texts = " ".join(_textbox_texts(s)) + for step in ("提取 RNA", "建库测序", "差异分析"): + assert step in texts and "→" not in texts.split(step)[0][-3:] + + +def test_method_short_bullets_become_cards(tmp_path): + page = {"slide_number": 1, "type": "method", "title": "处理设计", + "bullets": ["处理组 A", "处理组 B", "空白对照"], + "evidence": [None] * 3, "speaker_notes": "", "suggested_figures": []} + s = _deck(tmp_path, [page]).slides[0] + cards = [sh for sh in s.shapes + if sh.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE + and sh.auto_shape_type == MSO_SHAPE.ROUNDED_RECTANGLE] + assert len(cards) == 3 + texts = [c.text_frame.text for c in cards] + assert texts == ["处理组 A", "处理组 B", "空白对照"] + assert s.placeholders[1].text_frame.text == "" + + +def test_table_page_native_table(tmp_path): + page = {"slide_number": 1, "type": "result", "title": "处理组对比", + "bullets": ["差异达显著水平"], "evidence": [None], + "speaker_notes": "数据来自 Fig 2 统计。", "suggested_figures": [], + "table": {"headers": ["处理", "株高(cm)"], + "rows": [["对照", "10.2"], ["盐胁迫", "7.4"]]}} + s = _deck(tmp_path, [page]).slides[0] + tbl_shape = next(sh for sh in s.shapes if sh.has_table) + tbl = tbl_shape.table + assert tbl.cell(0, 0).text == "处理" and tbl.cell(0, 1).text == "株高(cm)" + assert tbl.cell(1, 1).text == "10.2" and tbl.cell(2, 0).text == "盐胁迫" + # 表头白字加粗 + hrun = tbl.cell(0, 0).text_frame.paragraphs[0].runs[0] + assert hrun.font.bold is True + assert "差异达显著水平" in s.placeholders[1].text_frame.text + + +def test_conclusion_six_bullets_two_columns(tmp_path): + page = {"slide_number": 1, "type": "conclusion", "title": "结论", + "bullets": [f"要点 {i}" for i in range(6)], + "evidence": [None] * 6, "speaker_notes": "", "suggested_figures": []} + s = _deck(tmp_path, [page]).slides[0] + assert s.placeholders[1].text_frame.text == "" + joined = " ".join(_textbox_texts(s)) + for i in range(6): + assert f"要点 {i}" in joined + cols = [sh for sh in s.shapes if sh.shape_type == MSO_SHAPE_TYPE.TEXT_BOX] + assert len(cols) >= 2 + + +def test_real_figure_tier_b_contain(tmp_path): + from PIL import Image + figs = tmp_path / "figs" + figs.mkdir() + img = figs / "Fig_2.png" + Image.new("RGB", (400, 240), "#cc3333").save(img) + out = tmp_path / "real.pptx" + build_pptx(PAGES, "academic-minimal", out) # PAGES[3] 引用 Fig 2 + s = Presentation(str(out)).slides[3] + pics = [sh for sh in s.shapes if sh.shape_type == MSO_SHAPE_TYPE.PICTURE] + assert len(pics) == 1 + assert pics[0].width > 0 and pics[0].height > 0 + assert not any("请插入" in t for t in _textbox_texts(s)) # 不再是占位框 + assert any("Fig 2" in t and "S4-P1-Fig2" in t for t in _textbox_texts(s)) + + +def test_real_figure_missing_falls_back_placeholder(tmp_path): + # figs 目录存在但无 Fig_2.png → 回落虚线占位(Tier A 几何不变) + (tmp_path / "figs").mkdir() + out = tmp_path / "fb.pptx" + build_pptx(PAGES, "academic-minimal", out) + s = Presentation(str(out)).slides[3] + assert any("请插入" in t for t in _textbox_texts(s)) + + +# ---------- P5 改进 3:图卡容器(surface_2 + 圆角 + 5mm 内边距 + 图注) ---------- + +def _figure_shapes(slide): + """返回 (容器, 图片, 图注框)。容器 = 承载图片的圆角矩形。""" + card = next(sh for sh in slide.shapes + if sh.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE + and sh.auto_shape_type == MSO_SHAPE.ROUNDED_RECTANGLE) + pic = next(sh for sh in slide.shapes if sh.shape_type == MSO_SHAPE_TYPE.PICTURE) + cap = next(sh for sh in slide.shapes + if sh.shape_type == MSO_SHAPE_TYPE.TEXT_BOX and "论文" in sh.text_frame.text) + return card, pic, cap + + +def test_real_figure_card_container_geometry(tmp_path): + """改进 3:白底原图进浅色圆角卡片(surface_2、5mm 内边距、图在容器内居中)。""" + from PIL import Image + figs = tmp_path / "figs" + figs.mkdir() + Image.new("RGB", (400, 240), "#ffffff").save(figs / "Fig_2.png") + out = tmp_path / "card.pptx" + build_pptx(PAGES, "academic-minimal", out) # bg #FFFFFF → surface_2 推导 #F0F0F0 + s = Presentation(str(out)).slides[3] + card, pic, cap = _figure_shapes(s) + # 容器:圆角矩形、tokens.surface_2(无显式 token → 按 bg 压暗一档)、无边框 + from pptx.enum.dml import MSO_FILL + assert str(card.fill.fore_color.rgb) == "F0F0F0" + assert card.line.fill.type == MSO_FILL.BACKGROUND + assert card.adjustments[0] < 0.05 # 圆角 8px,不是默认胶囊 + # 图四周 ≥5mm 内边距且水平/垂直都落在容器内 + pad = 914400 * 5 / 25.4 + assert pic.left >= card.left + pad - 500 + assert pic.top >= card.top + pad - 500 + assert pic.left + pic.width <= card.left + card.width - pad + 500 + assert pic.top + pic.height <= card.top + card.height - pad + 500 + # 图注:容器底部、10-12pt、tokens.muted、与图间距 ≥3mm + assert cap.top >= pic.top + pic.height + 914400 * 3 / 25.4 - 500 + assert cap.top + cap.height <= card.top + card.height + 500 + run = cap.text_frame.paragraphs[0].runs[0] + assert cap.text_frame.text == "Fig 2(论文 S4-P1-Fig2)" + assert 10 <= run.font.size.pt <= 12 + assert str(run.font.color.rgb) == "6B7280" # tokens.muted(academic-minimal) + # 图占位控制:右侧 ≤55% 页宽、≤60% 页高 + assert card.width <= Inches(13.333 * 0.55) + 500 + assert card.height <= Inches(7.5 * 0.6) + 500 + assert card.left >= s.placeholders[1].left + s.placeholders[1].width # 图在右侧不压正文 + + +def test_real_figure_standalone_page_centered(tmp_path): + """单独图页(有图无要点):图卡水平居中,上下留白 ≥5% 页高。""" + from PIL import Image + figs = tmp_path / "figs" + figs.mkdir() + Image.new("RGB", (300, 300), "#ffffff").save(figs / "Fig_1.png") + page = {"slide_number": 1, "type": "result", "title": "图示基因结构", + "bullets": [], "evidence": [], "speaker_notes": "", + "suggested_figures": [{"label": "Fig 1", "para_id": "S1-P561", + "status": "verified"}]} + out = tmp_path / "solo.pptx" + build_pptx([page], "academic-minimal", out) + s = Presentation(str(out)).slides[0] + card, pic, _ = _figure_shapes(s) + assert abs(int(card.left) - int((Inches(13.333) - card.width) / 2)) <= 2000 + assert card.top >= Inches(7.5 * 0.05) + assert card.top + card.height <= Inches(7.5 * 0.95) + + +def test_split_figure_page_width_capped_at_55(tmp_path): + """图文混排页 figure_split_pct 最小值时图宽仍 ≤55% 页宽。""" + page = {"slide_number": 1, "type": "result", "title": "结果", + "bullets": ["要点一"], "evidence": [None], "speaker_notes": "", + "suggested_figures": [{"label": "Fig 1", "para_id": "S1-P561", + "status": "verified"}]} + out = tmp_path / "cap.pptx" + build_pptx([page], "academic-minimal", out) # 无 figs 目录 → Tier A 占位框同几何 + box = next(sh for sh in Presentation(str(out)).slides[0].shapes + if sh.has_text_frame and "请插入" in sh.text_frame.text) + assert box.width <= Inches(13.333 * 0.55) + 500 + assert box.height <= Inches(7.5 * 0.6) + 500 + + +# ---------- P5 改进 1/2:风格清单 + 分隔页副标题决议 ---------- + +def test_list_styles_returns_all_style_files(): + from app.services.ppt_outline import STYLE_IDS + from app.services.ppt_build import list_styles + items = list_styles() + assert [i["id"] for i in items] == list(STYLE_IDS) + assert len(items) == 14 + for it in items: + assert it["label"] and it["description"].strip() + assert it["bg"].startswith("#") and it["accent"].startswith("#") + + +def test_section_subtitles_resolution_order(): + """subtitle 字段 → bullets[0] → 该章第一页 title 兜底。""" + from app.services.ppt_build import section_subtitles + pages = [ + {"type": "section", "title": "研究背景", "bullets": [], "subtitle": "显式副标题"}, + {"type": "background", "title": "黄瓜遗传改良亟需高精度泛基因组", "bullets": []}, + {"type": "section", "title": "材料方法", "bullets": ["旧大纲 bullets 副标题"]}, + {"type": "method", "title": "泛基因组构建流程", "bullets": []}, + {"type": "section", "title": "结果分析", "bullets": []}, # 其后无内容页 + ] + subs = section_subtitles(pages) + assert subs[0] == "显式副标题" + assert subs[2] == "旧大纲 bullets 副标题" + assert subs[4] == "" # 无内容页可兜底 → 空(只渲染主标题+线) + + +# ---------- P5 v3:bullet 拆分 / 内容形态 archetype / 覆盖率 ---------- + +def test_split_merged_bullets(): + from app.services.ppt_build import _split_bullets + # 任务样例:'- A - B - C' → 3 个独立 bullet + assert _split_bullets(["- A - B - C"]) == ["A", "B", "C"] + # LLM 真实坏例:4 条挤一行('- ' 无前导空白) + merged = ["纳米孔测序:11个大豆品系- 基因组组装:高质量参考基因组" + "- 变异检测:千份重测数据- 功能验证:候选基因定位"] + assert _split_bullets(merged) == [ + "纳米孔测序:11个大豆品系", "基因组组装:高质量参考基因组", + "变异检测:千份重测数据", "功能验证:候选基因定位", + ] + # 单段 '- 子项' 不拆(留给 _SUB_RE 降级 level 1);无分隔不变;'3-5' 连字符不拆 + assert _split_bullets(["- 电导法"]) == ["- 电导法"] + assert _split_bullets(["普通条目"]) == ["普通条目"] + assert _split_bullets(["3-5 条范围"]) == ["3-5 条范围"] + + +def test_split_bullets_applied_in_render(tmp_path): + out = tmp_path / "split.pptx" + page = {"slide_number": 1, "type": "result", "title": "四个发现", + "bullets": ["这是一条足够长的常规发现描述确保它绝对不会落入卡片版式甲" + "- 这是一条足够长的常规发现描述确保它绝对不会落入卡片版式乙" + "- 这是一条足够长的常规发现描述确保它绝对不会落入卡片版式丙" + "- 这是一条足够长的常规发现描述确保它绝对不会落入卡片版式丁"], + "evidence": [None], "speaker_notes": "", "suggested_figures": []} + build_pptx([page], "academic-minimal", out) + body = Presentation(str(out)).slides[0].placeholders[1].text_frame + texts = [p.text for p in body.paragraphs] + assert len(texts) == 4 + assert [t[-1] for t in texts] == ["甲", "乙", "丙", "丁"] + assert all(p.space_after.pt >= 10 for p in body.paragraphs) # spacious 行间距 + + +def test_auto_highlight_detects_single_big_number(): + from app.services.ppt_build import _auto_highlight + v, label = _auto_highlight(["从头组装 1,518 个基因", "方法 A", "方法 B"]) + assert v == "1,518" and "1,518" not in label + v2, _ = _auto_highlight(["BUSCO 完整度 95.3%", "另一条"]) + assert v2 == "95.3%" + # 多条含数字 / 无数字 → 不触发(避免普通页误放大数字) + assert _auto_highlight(["30% 上调", "50% 下调"]) is None + assert _auto_highlight(["无数字条", "也无"]) is None + + +def test_compare_texts_triggers(): + from app.services.ppt_build import _compare_texts + assert _compare_texts(["处理组 vs 对照组", "表达量显著上调"]) is not None + assert _compare_texts(["Enrei 与 Williams 82 对比", "基因数更多"]) is not None + assert _compare_texts(["普通要点一", "普通要点二"]) is None + assert _compare_texts(["单条 vs 对照"]) is None # 1 条页不触发 + + +def test_compare_layout_renders_vs_circle(tmp_path): + page = {"slide_number": 1, "type": "result", "title": "组间对照", + "bullets": ["处理组 vs 对照组", "盐处理后表达上调"], + "evidence": [None] * 2, "speaker_notes": "", "suggested_figures": []} + s = _deck(tmp_path, [page]).slides[0] + ovals = [sh for sh in s.shapes + if sh.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE + and sh.auto_shape_type == MSO_SHAPE.OVAL] + assert len(ovals) == 1 and ovals[0].text_frame.text == "VS" + texts = " ".join(_textbox_texts(s)) + assert "处理组 vs 对照组" in texts and "表达上调" in texts + assert s.placeholders[1].text_frame.text == "" + + +def test_build_archetype_coverage_18pages(tmp_path): + """18 页混合大纲:正文特殊版式 ≥10(用户验收口径:非纯 bullets/reference)。""" + def page(n, ptype, title, bullets, **kw): + return {"slide_number": n, "type": ptype, "title": title, "bullets": bullets, + "evidence": [None] * len(bullets), "speaker_notes": "", "suggested_figures": [], + **kw} + pages = [ + page(1, "title", "日本大豆基因组研究", ["张三", "组会汇报"]), + page(2, "outline", "汇报提纲", ["背景", "方法", "结果", "结论"]), + page(3, "section", "研究背景", []), + page(4, "background", "研究意义", ["靶基因:SOS1", "组织:根尖", + "> **盐胁迫**下耐性差异显著"]), + page(5, "background", "材料与条件", ["材料:31 份大豆", "处理:200 mM NaCl", + "时间:24 h"]), + page(6, "method", "分析流程", ["→ 提取 RNA", "→ 建库测序", "→ 差异分析"]), + page(7, "method", "处理设计", ["处理组 A", "处理组 B", "空白对照"]), + page(8, "section", "主要结果", []), + page(9, "result", "图示基因结构", ["结构域高度保守", "内含子位置一致"], + suggested_figures=[{"label": "Fig 1", "para_id": "S1-P561", "status": "verified"}]), + page(10, "result", "株高显著提升", ["盐处理后地上部生物量变化"], + highlight={"value": "+0.82 cm", "label": "株高增长"}), + page(11, "result", "基因家族扩张", ["从头组装完整 1,518 个基因", + "其余两条常规陈述", "补充说明也较长一些"]), + page(12, "result", "处理组对照", ["处理组 vs 对照组", "盐处理后表达上调"]), + page(13, "result", "常规结果罗列", [f"这是一条很长的常规结论描述第 {i} 条字数偏多超出限制" + for i in range(1, 6)]), + page(14, "method", "四项分析", ["基因组组装", "变异检测", "共线分析", "功能注释"]), + page(15, "section", "结论", []), + page(16, "conclusion", "结论", [f"要点 {i}" for i in range(6)]), + page(17, "conclusion", "展望", [f"后续研究方向需要进一步深入探讨其分子机制层面复杂问题 {i}" + for i in range(3)]), + page(18, "reference", "参考文献", ["1. Chen et al. 2024", "2. Li et al. 2023"]), + ] + out = tmp_path / "cover.pptx" + stats = build_pptx(pages, "academic-minimal", out, + context={"scenario": "group_meeting", "minutes": 15}) + arch = stats["archetypes"] + assert len(arch) == 18 + # 逐页触发正确 + assert arch[0] == "cover" and arch[1] == "chips" and arch[2] == "section" + assert arch[3] == "takeaway" and arch[4] == "kv" and arch[5] == "flow" + assert arch[6] == "cards" and arch[8] == "figure" and arch[9] == "highlight" + assert arch[10] == "highlight_auto" and arch[11] == "compare" and arch[13] == "cards" + assert arch[15] == "two_col" + assert arch[12] == "bullets" and arch[16] == "bullets" and arch[17] == "reference" + # 用户验收口径:正文(非 cover/chips/section/bullets/reference)特殊版式 ≥10 + body_special = [a for a in arch + if a not in ("cover", "chips", "section", "bullets", "reference")] + assert len(body_special) >= 10, arch + # 全口径:18 页里非纯 bullets/reference ≥14 + assert len([a for a in arch if a not in ("bullets", "reference")]) >= 14 + + +def test_section_divider_with_subtitle(tmp_path): + """改进 2:section.subtitle 字段 → 主标题下方 20pt muted 副标题行。""" + import copy + pages = copy.deepcopy(RICH_PAGES) + pages[5]["subtitle"] = "处理组与对照的产量差异解析" + out = tmp_path / "sec_sub.pptx" + build_pptx(pages, "botanic-forest", out) + s = Presentation(str(out)).slides[5] + assert len(s.shapes) == 3 + sub_run = s.shapes[1].text_frame.paragraphs[0].runs[0] + assert sub_run.text == "处理组与对照的产量差异解析" + assert sub_run.font.size.pt == 20 + assert str(sub_run.font.color.rgb) == "4D7C57" # tokens.muted + assert s.shapes[1].top > s.shapes[0].top # 主标题下方 + # 主标题与装饰线不变 + assert s.shapes[0].text_frame.paragraphs[0].runs[0].text == "主要结果" + assert int(s.shapes[2].width) == int(Inches(13.333 * 0.3)) + + +def test_section_divider_legacy_bullet_subtitle(tmp_path): + """旧大纲把副标题写在 bullets[0](无 subtitle 字段)→ 仍渲染(兼容)。""" + import copy + pages = copy.deepcopy(RICH_PAGES) + pages[5]["bullets"] = ["盐处理下的产量差异解析"] + out = tmp_path / "sec_legacy.pptx" + build_pptx(pages, "botanic-forest", out) + s = Presentation(str(out)).slides[5] + texts = [sh.text_frame.text for sh in s.shapes if sh.has_text_frame] + assert any("盐处理下的产量差异解析" in t for t in texts) + + +def test_section_divider_without_resolvable_subtitle_adds_no_subtitle_box(tmp_path): + """既无 subtitle/bullets、其后也无内容页 → 只有主标题框 + 装饰线(无副标题框)。""" + page = {"slide_number": 1, "type": "section", "title": "结论", "bullets": [], + "evidence": [], "speaker_notes": "", "suggested_figures": []} + out = tmp_path / "sec_plain.pptx" + build_pptx([page], "botanic-forest", out) + s = Presentation(str(out)).slides[0] + assert len(s.shapes) == 2 # 主标题 + 装饰线 + assert s.shapes[0].text_frame.paragraphs[0].runs[0].text == "结论" + + +def test_figure_image_falls_back_to_canon_key(tmp_path): + """v3 修复2 兜底:label 精确文件名不存在(子图 Fig. 6b)→ 按编号找 Fig_6.png。""" + from app.services.ppt_build import _figure_image + figs = tmp_path / "figs" + figs.mkdir() + (figs / "Fig_6.png").write_bytes(b"\x89PNG") + ctx = {"figs_dir": figs} + assert _figure_image(ctx, "Fig 6") is not None + assert _figure_image(ctx, "Fig. 6b") is not None # canon("fig",6) 兜底命中 + assert _figure_image(ctx, "Fig 9") is None + assert _figure_image(None, "Fig 6") is None diff --git a/review_service/tests/test_ppt_figure_index.py b/review_service/tests/test_ppt_figure_index.py new file mode 100644 index 0000000..de7a989 --- /dev/null +++ b/review_service/tests/test_ppt_figure_index.py @@ -0,0 +1,138 @@ +"""tests/test_ppt_figure_index.py —— P5 增强 1:论文真实图表清单扫描。 + +覆盖:Figure/Fig./图/Table/表 正则抽取、caption 形态优先、按编号去重、 +label 归一化(canon_label/figure_map)、build_figure_index 与证据链分段一致性。 +运行: cd review_service && /bin/python -m pytest tests/test_ppt_figure_index.py -q +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.services.ppt_figure_index import ( + build_figure_index, + canon_label, + figure_map, + scan_figure_index, +) +from app.services.ppt_outline import build_index + + +def make_text() -> str: + return "\n".join([ + "Introduction paragraph without any figure.", + "Growth increased under salt (see Figure 1).", + "Earlier mention of Figure 2 appears here.", + "Table 3 lists the primers used in this study.", + "图 4 显示了根系形态的差异。", + "Figure 2: Root length under salt stress treatments.", # caption 形态(后出现) + "最后没有编号的段落。", + ]) + + +def test_scan_extracts_real_numbers_with_para_ids(): + _, index = build_index(make_text()) + figs = scan_figure_index(index) + by_label = {f["label"]: f for f in figs} + # 真实编号全中:Fig 1 / Fig 2 / Fig 4 / Table 3 + assert set(by_label) == {"Fig 1", "Fig 2", "Fig 4", "Table 3"} + # para_id 必须存在于证据链索引里 + for f in figs: + assert f["para_id"] in index + assert by_label["Fig 1"]["kind"] == "figure" + assert by_label["Table 3"]["kind"] == "table" + + +def test_scan_prefers_caption_style_and_dedups(): + _, index = build_index(make_text()) + figs = {f["label"]: f for f in scan_figure_index(index)} + # Fig 2 提及两次(顺带 + caption 形态)→ 去重且选中 caption 所在段 + assert "Figure 2: Root length" in figs["Fig 2"]["caption"] + assert index[figs["Fig 2"]["para_id"]].startswith("Figure 2:") + # caption 是包含命中的句子 + assert "salt (see Figure 1)" in figs["Fig 1"]["caption"] + + +def test_canon_label_variants(): + assert canon_label("Fig 2") == ("fig", 2) + assert canon_label("Figure 2") == ("fig", 2) + assert canon_label("Fig. 3") == ("fig", 3) + assert canon_label("图 4") == ("fig", 4) + assert canon_label("Table 3") == ("table", 3) + assert canon_label("表 3") == ("table", 3) + assert canon_label("no number") is None + assert canon_label("") is None + + +def test_figure_map_lookup_keyed_by_kind_and_number(): + _, index = build_index(make_text()) + fmap = figure_map(scan_figure_index(index)) + assert fmap[("fig", 1)]["label"] == "Fig 1" + assert fmap[("table", 3)]["label"] == "Table 3" + # Fig 1 与 Table 1 同号不同类不串(清单里无 Table 1 → 缺) + assert ("table", 1) not in fmap + + +def test_build_figure_index_from_full_text_consistent_with_index(): + text = make_text() + entries = build_figure_index(text) + _, index = build_index(text) + assert entries and all(e["para_id"] in index for e in entries) + assert {e["label"] for e in entries} == {"Fig 1", "Fig 2", "Fig 4", "Table 3"} + + +def test_scan_empty_when_no_figures(): + _, index = build_index("just some plain paragraphs without numbering at all\n" * 5) + assert scan_figure_index(index) == [] + + +# ---------- P5 二次开发 2.3:PDF 原图提取 ---------- + +def test_extract_figures_real_pdf(tmp_path): + from app.services.ppt_figure_index import extract_figures + pdf = Path(__file__).resolve().parents[1] / "data" / "compile_jobs" / "78c49ed72b95" / "output.pdf" + if not pdf.is_file(): + pytest.skip("示例 PDF 未就位") + out_dir = tmp_path / "figs" + res = extract_figures(pdf.read_bytes(), out_dir) + assert [r["label"] for r in res] == ["Fig 1", "Fig 2"] + assert res[0]["page"] == 3 and res[1]["page"] == 4 + assert (out_dir / "Fig_1.png").is_file() and (out_dir / "Fig_1.png").stat().st_size > 0 + assert (out_dir / "Fig_2.png").is_file() + assert all(r["width"] >= 60 and r["height"] >= 40 for r in res) + + +def test_extract_figures_garbage_bytes(tmp_path): + from app.services.ppt_figure_index import extract_figures + assert extract_figures(b"not a pdf at all", tmp_path / "figs") == [] + + +# ---------- P5 v3:caption 行「Fig. N |」尾点修复(用户 PDF 0 张根因) ---------- + +def test_caption_line_fig_dot_variant(): + import pymupdf + from app.services.ppt_figure_index import _caption_lines + doc = pymupdf.open() + page = doc.new_page(width=612, height=792) + page.insert_text((72, 120), "Fig. 1 | Synthetic caption for unit test", fontsize=11) + caps = [c for c in _caption_lines(page) if c["kind"] == "fig"] + assert caps and caps[0]["num"] == 1 + doc.close() + + +def test_extract_figures_dot_caption_end_to_end(tmp_path): + import pymupdf + from app.services.ppt_figure_index import extract_figures + doc = pymupdf.open() + page = doc.new_page(width=612, height=792) + pix = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 300, 200)) + pix.clear_with(0x66AA66) + page.insert_image(pymupdf.Rect(100, 100, 400, 300), pixmap=pix) + page.insert_text((100, 330), "Fig. 1 | End to end synthetic figure", fontsize=11) + pdf = doc.tobytes() + doc.close() + out_dir = tmp_path / "figs" + res = extract_figures(pdf, out_dir) + assert [r["label"] for r in res] == ["Fig 1"] + assert (out_dir / "Fig_1.png").is_file() and (out_dir / "Fig_1.png").stat().st_size > 0 diff --git a/review_service/tests/test_ppt_outline.py b/review_service/tests/test_ppt_outline.py new file mode 100644 index 0000000..379ad65 --- /dev/null +++ b/review_service/tests/test_ppt_outline.py @@ -0,0 +1,673 @@ +"""tests/test_ppt_outline.py —— P5 大纲服务离线单测(纯函数 + mock LLM 管线)。 + +覆盖:决策 2 页数表、段落索引、coerce 清洗、拆页/裁页、证据程序化校验 +(含 S4-P1-Fig2 图引用)、map-reduce 编排、薄弱页扩充重试、错误信封码。 +运行: cd review_service && /bin/python -m pytest tests/test_ppt_outline.py -q +""" +from __future__ import annotations + +import json +import re + +import pytest + +from app.services import llm, ppt_outline +from app.services.ppt_outline import ( + OutlineError, + build_index, + coerce_outline, + generate_outline, + page_plan, + split_oversized, + trim_to_max, + validate_evidence, +) + +N_LINES = 160 # 160 段 / 每块 40 → 恰好 4 块(S1..S4) +FIG_LINE = 120 # S4-P1(0-based) + + +def make_text() -> str: + lines = [] + for i in range(N_LINES): + line = f"salt stress response line {i} describes ion transport and growth" + if i == FIG_LINE: + line += " . Figure 2: growth under salt stress." + lines.append(line) + return "\n".join(lines) + + +LONG_TEXT = make_text() + + +# ---------- 决策 2 / 索引 / coerce(纯函数) ---------- + +def test_page_plan_covers_all_scenarios_and_minutes(): + for sc in ppt_outline.SCENARIOS: + for m in ppt_outline.MINUTES_CHOICES: + plan = page_plan(sc, m) + lo, hi = ppt_outline.PAGE_TARGETS[m] + assert plan["title"] == (1, 1) and plan["outline"] == (1, 1) + assert plan["conclusion"] == (1, 1) + assert (plan.get("reference") is not None) == (sc == "defense") + assert all(1 <= mn <= mx for mn, mx in plan.values()) + # 配额总量上限不超时长目标上限(超了靠 trim 兜底,这里断言设计值) + assert sum(mx for _, mx in plan.values()) <= hi + 2 + assert sum(mn for mn, _ in plan.values()) >= lo - 3 + + +def test_page_plan_rejects_unknown_inputs(): + with pytest.raises(ValueError): + page_plan("paper_writing", 15) + with pytest.raises(ValueError): + page_plan("defense", 45) + + +def test_build_index_ids_and_section_cap(): + blocks, index = build_index(LONG_TEXT) + assert list(index)[0] == "S1-P1" + assert len(blocks) == 4 + assert len(index) == N_LINES + assert "S4-P1" in index + assert "Figure 2" in index["S4-P1"] + + +def test_coerce_outline_alignment_and_type_filter(): + payload = { + "pages": [ + {"type": "title", "title": "T", "bullets": ["a", "b"], + "evidence": [{"para_id": "S1-P1", "quote": "q1"}]}, + {"type": "method", "title": "M", "bullets": ["x", "y", "z"], + "evidence": []}, + {"type": "nope", "title": "bad", "bullets": []}, + "not-a-dict", + ] + } + pages = coerce_outline(payload) + assert [p["type"] for p in pages] == ["title", "method"] + # evidence 与 bullets 等长:缺的补 None,多余的截掉 + assert len(pages[0]["evidence"]) == 2 and pages[0]["evidence"][1] is None + assert len(pages[1]["evidence"]) == 3 + assert pages[0]["evidence"][0]["status"] == "unverified" + + +def test_coerce_outline_rejects_garbage(): + with pytest.raises(OutlineError) as ei: + coerce_outline({"pages": "not a list"}) + assert ei.value.code == "LLM_INVALID" + with pytest.raises(OutlineError): + coerce_outline({"pages": [{"type": "alien"}]}) + + +# ---------- 拆页 / 裁页 ---------- + +def test_split_oversized_recursive_and_exempts_non_content(): + warnings = [] + big = {"slide_number": 1, "type": "method", "title": "M", + "bullets": [f"b{i}" for i in range(17)], "evidence": [None] * 17, + "speaker_notes": "", "suggested_figures": []} + out = split_oversized([dict(big)], warnings) + assert all(len(p["bullets"]) <= 8 for p in out) + assert sum(len(p["bullets"]) for p in out) == 17 + assert out[1]["title"].endswith("(续)") + # reference 不是内容页:9 条也不拆 + ref = dict(big, type="reference", title="R") + out2 = split_oversized([ref], []) + assert len(out2) == 1 and len(out2[0]["bullets"]) == 17 + + +def test_trim_priority_and_floors(): + warnings = [] + types = ["title", "outline", "background", "background", "method", + "method", "result", "result", "result", "conclusion", + "reference", "reference"] + pages = [{"slide_number": i + 1, "type": t, "title": f"{t}{i}", + "bullets": [], "evidence": [], "speaker_notes": "", + "suggested_figures": []} for i, t in enumerate(types)] + out = trim_to_max(pages, 9, warnings) + ts = [p["type"] for p in out] + assert len(out) == 9 + assert ts[0] == "title" and ts[1] == "outline" + assert "conclusion" in ts and ts.count("background") == 1 + assert ts.count("reference") == 0 # reference 全删(floor 0,优先级最高) + assert ts.count("method") == 2 and ts.count("result") == 3 # 内容页不再往下删 + assert warnings + + +# ---------- 证据程序化校验 ---------- + +def test_validate_evidence_verified_unverified_ratio(): + index = {"S1-P1": "The yield increased by 30 percent in treatment A."} + pages = [ + {"slide_number": 1, "type": "result", "title": "r", "bullets": [], + "speaker_notes": "", "suggested_figures": [], + "evidence": [ + {"para_id": "S1-P1", "quote": "yield increased by 30 percent", "status": ""}, + {"para_id": "S1-P1", "quote": "invented 99 percent claim", "status": ""}, + {"para_id": "S9-P9", "quote": "yield increased by 30 percent", "status": ""}, + ]}, + {"slide_number": 2, "type": "conclusion", "title": "c", "bullets": [], + "speaker_notes": "", "suggested_figures": [], + "evidence": [{"para_id": "S1-P1", "quote": "no such sentence", "status": ""}]}, + ] + warnings = validate_evidence(pages, index) + ev = pages[0]["evidence"] + assert ev[0]["status"] == "verified" + assert ev[1]["status"] == "unverified" # 编造,不在全文 + assert ev[2]["status"] == "unverified" # 引用真实但段落标注错 + assert any("段落标注" in w for w in warnings) + # conclusion 页 1/1 未核实 >50% → warning + assert any("conclusion" in w and "50%" in w for w in warnings) + + +def test_validate_evidence_figures(): + index = {"S4-P1": "Figure 2: growth under salt stress.", "S1-P1": "intro text"} + pages = [ + {"slide_number": 1, "type": "result", "title": "r", "bullets": [], + "speaker_notes": "", "evidence": [], + "suggested_figures": [ + {"label": "Fig 2", "para_id": "S4-P1-Fig2", "status": ""}, + {"label": "Fig 9", "para_id": "S1-P1-Fig9", "status": ""}, + {"label": "bad", "para_id": "nope", "status": ""}, + ]}, + ] + warnings = validate_evidence(pages, index) + figs = pages[0]["suggested_figures"] + assert figs[0]["status"] == "verified" + assert figs[1]["status"] == "unverified" # 段落存在但没有 Fig9 + assert figs[2]["status"] == "unverified" # 格式非法 + assert any("格式不合法" in w for w in warnings) + + +# ---------- map-reduce 编排(mock LLM) ---------- + +def _page(t: str, i: int) -> dict: + if t == "title": + bullets = ["作者 甲 等", "某大学植物科学学院"] + elif t == "outline": + bullets = ["研究背景", "材料与方法", "主要结果", "结论"] + elif t == "reference": + bullets = [f"Ref {i}-{j}" for j in range(6)] + else: + bullets = [f"{t} 要点 {i}-{j}" for j in range(4)] + ev = [{"para_id": "S1-P1", "quote": "salt stress response line 0"}] * len(bullets) + return { + "type": t, "title": f"{t} 第{i}节", "bullets": bullets, + "evidence": ev, "speaker_notes": f"讲稿 {t} {i}。", + "suggested_figures": [], + } + + +def _install_fake_llm(monkeypatch, *, thin_method: bool = False, + map_fail: bool = False, expand_ok: bool = True, + bogus_figures: bool = False): + state = {"n": 0, "expand": 0} + + async def fake(system: str, user: str, **kwargs): + state["n"] += 1 + if "精读助手" in system: + if map_fail: + raise llm.LLMError("boom: upstream 500") + figs = [{"label": "Fig 2", "para_id": "S4-P1-Fig2"}] + if bogus_figures: + figs.append({"label": "Fig 9", "para_id": "S1-P1-Fig9"}) + return { + "claims": [{"claim": "salt 增加离子积累", + "para_id": "S1-P1", + "quote": "salt stress response line 0"}], + "figures": figs, + } + if "学术汇报策划" in system: + types = re.findall(r"^- (\w+): \d+~\d+ 页", user, re.M) + assert types, user[:300] + pages = [] + for t in types: + n = 1 if t in ("title", "outline", "conclusion", "reference") else 3 + for i in range(n): + p = _page(t, i) + if thin_method and t == "method" and i == 0: + p["bullets"] = ["只有一个要点"] + p["evidence"] = [{"para_id": "S1-P1", + "quote": "salt stress response line 0"}] + if t == "result": + p["suggested_figures"] = [ + {"label": "Fig 2", "para_id": "S4-P1-Fig2"}] + if bogus_figures: + p["suggested_figures"].append( + {"label": "Fig 9", "para_id": "S1-P1-Fig9"}) + pages.append(p) + return {"pages": pages} + if "某几页幻灯片的要点不足" in system: + state["expand"] += 1 + head = user.split("\n\n摘要卡片")[0] + info = json.loads(head.split(":\n", 1)[1]) + if not expand_ok: + return {"pages": []} + return {"pages": [{ + "index": item["index"], + "bullets": [f"扩充要点 {k}" for k in range(4)], + "evidence": [{"para_id": "S1-P1", + "quote": "salt stress response line 0"}] * 4, + } for item in info]} + raise AssertionError(f"未预期的 LLM system: {system[:60]}") + + monkeypatch.setattr(llm, "complete_json", fake) + return state + + +@pytest.mark.asyncio +async def test_generate_outline_happy_path(monkeypatch): + state = _install_fake_llm(monkeypatch) + pages, warnings, meta = await generate_outline(LONG_TEXT, "defense", 15, "academic-minimal") + lo, hi = ppt_outline.PAGE_TARGETS[15] + assert lo <= len(pages) <= hi, len(pages) + assert pages[0]["type"] == "title" + assert [p["slide_number"] for p in pages] == list(range(1, len(pages) + 1)) + # evidence 程序化校验已执行 + all_ev = [e for p in pages for e in (p["evidence"] or []) if e] + assert all_ev and all(e["status"] in ("verified", "unverified") for e in all_ev) + # 图引用 S4-P1-Fig2 verified + figs = [f for p in pages for f in (p["suggested_figures"] or [])] + assert figs and figs[0]["status"] == "verified", figs + # llm_calls 只计 map+reduce;expand 单独计数(state.n 含 expand) + assert meta["llm_calls"] == state["n"] - state["expand"] and meta["llm_calls"] >= 3 + assert meta["target_pages"] == [lo, hi] + + +@pytest.mark.asyncio +async def test_generate_outline_expand_thin_page(monkeypatch): + state = _install_fake_llm(monkeypatch, thin_method=True) + pages, warnings, meta = await generate_outline(LONG_TEXT, "group_meeting", 20, "botanic-forest") + assert state["expand"] == 1 + # 原薄弱页("只有一个要点")已被扩充为 4 条 + expanded = [p for p in pages + if p["type"] == "method" and p["bullets"] + and p["bullets"][0].startswith("扩充要点")] + assert expanded and len(expanded[0]["bullets"]) == 4 + assert not any(p["bullets"] == ["只有一个要点"] for p in pages) + + +@pytest.mark.asyncio +async def test_generate_outline_expand_failure_keeps_page(monkeypatch): + _install_fake_llm(monkeypatch, thin_method=True, expand_ok=False) + pages, warnings, meta = await generate_outline(LONG_TEXT, "group_meeting", 20, "botanic-forest") + thin = [p for p in pages if p["bullets"] == ["只有一个要点"]] + assert thin and len(thin[0]["bullets"]) == 1 # 扩充失败保留原状 + assert any("扩充后仍不足" in w for w in warnings) + + +@pytest.mark.asyncio +async def test_generate_outline_map_failure(monkeypatch): + _install_fake_llm(monkeypatch, map_fail=True) + with pytest.raises(OutlineError) as ei: + await generate_outline(LONG_TEXT, "defense", 15, "academic-minimal") + assert ei.value.code == "LLM_ERROR" + + +@pytest.mark.asyncio +async def test_generate_outline_input_validation(monkeypatch): + _install_fake_llm(monkeypatch) + for scenario, minutes, style, text, code in [ + ("nope", 15, "academic-minimal", LONG_TEXT, "VALIDATION"), + ("defense", 15, "nope", LONG_TEXT, "VALIDATION"), + ("defense", 45, "academic-minimal", LONG_TEXT, "VALIDATION"), + ("defense", 15, "academic-minimal", "short", "VALIDATION"), + ]: + with pytest.raises(OutlineError) as ei: + await generate_outline(text, scenario, minutes, style) + assert ei.value.code == code, (scenario, minutes, style) + + +# ---------- P5 增强:section 分隔页 / highlight / 真实图表清单 ---------- + +def test_page_plan_section_quota_long_decks_only(): + # 15 分钟 target=12 ≥11 → 每内容组一张分隔页(3 组) + assert page_plan("defense", 15)["section"] == (3, 3) + assert page_plan("proposal", 20)["section"] == (3, 3) + # 10 分钟 target=9 <11 → 小 deck 无分隔页 + assert "section" not in page_plan("group_meeting", 10) + assert "section" not in page_plan("conference_talk", 10) + + +def test_coerce_outline_highlight_roundtrip(): + payload = {"pages": [ + {"type": "result", "title": "r", "bullets": ["a"], + "highlight": {"value": "+0.82 cm", "label": "体长增长"}}, + {"type": "result", "title": "r2", "bullets": ["b"], + "highlight": {"value": ""}}, + {"type": "result", "title": "r3", "bullets": ["c"]}, + ]} + pages = coerce_outline(payload) + assert pages[0]["highlight"] == {"value": "+0.82 cm", "label": "体长增长"} + assert "highlight" not in pages[1] # value 空 → 不带该字段 + assert "highlight" not in pages[2] # 缺字段 → 不带该字段 + # 超长截断 value≤24 / label≤32 + payload["pages"][0]["highlight"] = {"value": "x" * 40, "label": "y" * 50} + hl = coerce_outline(payload)[0]["highlight"] + assert len(hl["value"]) == 24 and len(hl["label"]) == 32 + + +def test_trim_drops_section_pages_first(): + warnings = [] + types = ["title", "section", "outline", "section", "background", + "background", "method", "method", "result", "result", + "conclusion", "section"] + pages = [{"slide_number": i + 1, "type": t, "title": f"{t}{i}", + "bullets": [], "evidence": [], "speaker_notes": "", + "suggested_figures": []} for i, t in enumerate(types)] + out = trim_to_max(pages, 9, warnings) + ts = [p["type"] for p in out] + assert len(out) == 9 + assert ts.count("section") == 0 # section 优先级最高且 floor 0 + assert ts.count("conclusion") == 1 and ts[0] == "title" + assert warnings + + +def test_validate_evidence_figure_index_drop_and_rewire(): + index = {"S4-P1": "Figure 2: growth under salt stress.", "S1-P1": "intro text"} + figure_index = [{"label": "Fig 2", "para_id": "S4-P1", "kind": "figure", + "num": 2, "caption": "Figure 2: growth under salt stress."}] + pages = [ + {"slide_number": 1, "type": "result", "title": "r", "bullets": [], + "speaker_notes": "", "evidence": [], + "suggested_figures": [ + {"label": "Fig 2", "para_id": "S4-P1-Fake", "status": ""}, + {"label": "Fig 9", "para_id": "S1-P1-Fig9", "status": ""}, + {"label": "Table 3", "para_id": "S1-P1-T3", "status": ""}, + ]}, + ] + warnings = validate_evidence(pages, index, figure_index) + kept = pages[0]["suggested_figures"] + # 清单里只有 Fig 2 → 其余两个直接丢弃(不是标 unverified) + assert [f["label"] for f in kept] == ["Fig 2"] + assert kept[0]["para_id"] == "S4-P1" # 以扫描出的真实位置为准 + assert kept[0]["status"] == "verified" + assert sum("已丢弃" in w for w in warnings) == 2 + + # 空清单 → 全丢(论文没图就不许瞎引) + pages2 = [{"slide_number": 1, "type": "result", "title": "r", "bullets": [], + "speaker_notes": "", "evidence": [], + "suggested_figures": [{"label": "Fig 2", "para_id": "S4-P1-Fig2", + "status": ""}]}] + validate_evidence(pages2, index, []) + assert pages2[0]["suggested_figures"] == [] + + +@pytest.mark.asyncio +async def test_generate_outline_drops_bogus_figure(monkeypatch): + _install_fake_llm(monkeypatch, bogus_figures=True) + pages, warnings, meta = await generate_outline( + LONG_TEXT, "defense", 15, "academic-minimal") + # map 阶段的虚构图先被清单裁掉(每块各丢 1 个 Fig 9) + assert any("map 阶段" in w and "真实图表清单,已丢弃" in w for w in warnings) + # reduce 返回里的 Fig 9 也被丢弃;保留的 Fig 2 para_id 被改写为真实位置 + figs = [f for p in pages for f in (p.get("suggested_figures") or [])] + assert figs and all(f["label"] == "Fig 2" for f in figs) + assert all(f["para_id"] == "S4-P1" and f["status"] == "verified" for f in figs) + assert any("图引用 Fig 9 不在论文真实图表清单" in w for w in warnings) + # meta 反映真实清单与实际采用数 + assert meta["figures"] == 1 + assert meta["figures_used"] == len(figs) >= 1 + # 结果页仍落在目标页数区间 + lo, hi = ppt_outline.PAGE_TARGETS[15] + assert lo <= len(pages) <= hi + + +@pytest.mark.asyncio +async def test_generate_outline_emits_section_pages_and_highlights(monkeypatch): + _install_fake_llm(monkeypatch) + pages, warnings, meta = await generate_outline( + LONG_TEXT, "defense", 15, "academic-minimal") + ts = [p["type"] for p in pages] + # 15 分钟 defense 出现章节分隔页(fake 每组 3 张 → trim 后仍有) + assert ts.count("section") >= 1 + assert ts[0] == "title" and ts[-1] == "reference" + # fake 的 result 页无 highlight 字段 → meta 与页结构不异常 + assert all(isinstance(p.get("highlight"), dict) or "highlight" not in p + for p in pages) + + +# ---------- P5 二次开发 2.2:table 字段裁决 + prompt 内容契约 ---------- + +def test_coerce_table_roundtrip_and_truncation(): + payload = { + "pages": [ + {"type": "result", "title": "对比", "bullets": ["导读"], + "table": {"headers": ["处理", "株高"], + "rows": [["对照", "10.2"], ["盐胁迫", 7.4], + ["x"] * 20, "not-a-row"]}}, + {"type": "result", "title": "坏表", "bullets": [], + "table": {"rows": "oops"}}, + ] + } + pages = coerce_outline(payload) + tbl = pages[0]["table"] + assert tbl["headers"] == ["处理", "株高"] + assert tbl["rows"][0] == ["对照", "10.2"] + assert tbl["rows"][1] == ["盐胁迫", "7.4"] # 非字符串单元格 str 化 + assert tbl["rows"][2] == ["x"] * 8 # 单行超宽截到 8 列 + assert len(tbl["rows"]) == 3 # 非 list 行("not-a-row")丢弃 + assert "table" not in pages[1] # 坏结构直接丢弃 + + +def test_reduce_prompt_layout_contract(): + from app.services.ppt_outline import _REDUCE_SYSTEM + assert "标题即结论" in _REDUCE_SYSTEM + assert "版面标记" in _REDUCE_SYSTEM + assert '"table"' in _REDUCE_SYSTEM + assert "3-5 条" in _REDUCE_SYSTEM + assert "> " in _REDUCE_SYSTEM and "→ " in _REDUCE_SYSTEM + + +# ---------- P5 修复:evidence para_id 反查(LLM 编造段落ID) ---------- + +def _index_and_page(quote: str, pid: str): + from app.services.ppt_outline import build_index + text = "\n".join([ + "Introduction paragraph without any figure.", + f"Salt stress response line describes ion transport: {quote}", + "Final paragraph about growth phenotypes under salt.", + ]) + _, index = build_index(text) + real_pid = next(k for k, v in index.items() if quote in v) + page = {"slide_number": 1, "type": "background", "title": "t", + "bullets": ["b1"], "evidence": [{"para_id": pid, "quote": quote, + "status": "unverified"}], + "speaker_notes": "", "suggested_figures": []} + return index, page, real_pid + + +def test_repair_evidence_para_ids_fabricated_pid(): + from app.services.ppt_outline import repair_evidence_para_ids, validate_evidence + quote = "Salt stress response line describes ion transport" + index, page, real_pid = _index_and_page(quote, "S9-P42") # 编造的 para_id + assert repair_evidence_para_ids([page], index) == 1 + assert page["evidence"][0]["para_id"] == real_pid # 已填真实 para_id + validate_evidence([page], index, figure_index=[]) + assert page["evidence"][0]["status"] == "verified" + + +def test_repair_evidence_para_ids_missing_quote_stays_unverified(): + from app.services.ppt_outline import repair_evidence_para_ids, validate_evidence + text_quote = "Salt stress response line describes ion transport" + index, page, _ = _index_and_page(text_quote, "S1-P2") + # LLM 返回不存在的 quote(真幻觉)→ para_id 不填,保持 unverified + page["evidence"][0]["quote"] = "This sentence does not exist anywhere in the paper" + assert repair_evidence_para_ids([page], index) == 0 + assert page["evidence"][0]["para_id"] == "S1-P2" # 未改动 + validate_evidence([page], index, figure_index=[]) + assert page["evidence"][0]["status"] == "unverified" + + +def test_repair_evidence_para_ids_quote_only_no_pid(): + # quote-only evidence(LLM 未给 para_id)→ 反查填入 + from app.services.ppt_outline import repair_evidence_para_ids + quote = "Salt stress response line describes ion transport" + index, page, real_pid = _index_and_page(quote, "") + page["evidence"][0]["para_id"] = "" + assert repair_evidence_para_ids([page], index) == 1 + assert page["evidence"][0]["para_id"] == real_pid + + +def test_reduce_prompt_requires_quote_not_para_id(): + from app.services.ppt_outline import _REDUCE_SYSTEM, _EXPAND_SYSTEM + for sys_prompt in (_REDUCE_SYSTEM, _EXPAND_SYSTEM): + assert "quote" in sys_prompt + assert "不需要写 para_id" in sys_prompt + assert '"para_id": "...", "quote"' not in sys_prompt # evidence schema 已去 para_id + + +@pytest.mark.asyncio +async def test_generate_outline_repairs_fabricated_para_ids(monkeypatch): + """端到端:reduce 返回正确 quote + 编造 para_id → 输出里全部 verified。""" + from app.services import llm, ppt_outline + quote = "salt stress response line 0 describes ion transport and growth" + + async def fake_complete_json(system, user, **kwargs): + if "精读助手" in system: # map + return {"claims": [{"claim": "c", "para_id": "S1-P1", "quote": quote}], + "figures": []} + if "策划" in system: # reduce + return {"pages": [ + {"type": "title", "title": "盐胁迫汇报", "bullets": ["张三 等"], + "evidence": []}, + {"type": "background", "title": "背景", "bullets": ["要点一", "要点二"], + "evidence": [{"para_id": "S9-P42", "quote": quote}, None]}, + ]} + return {"pages": []} # expand 不应触发 + + monkeypatch.setattr(llm, "complete_json", fake_complete_json) + pages, warnings, meta = await ppt_outline.generate_outline( + LONG_TEXT, "defense", 15, "academic-minimal", + ) + ev = next(e for p in pages for e in (p.get("evidence") or []) if e) + assert ev["para_id"] != "S9-P42" and ev["status"] == "verified" + assert any("反查修正" in w for w in warnings) + assert meta["unverified_evidence"] == 0 + + +# ---------- v3 修复 2/3/4:清单收窄 / section 副标题 / 跨段 quote ---------- + +def test_narrow_figure_index_to_extracted_only(): + from app.services.ppt_outline import _narrow_figure_index + figs = [{"label": "Fig 1", "para_id": "S1-P2-Fig1"}, + {"label": "Fig 2", "para_id": "S4-P1-Fig2"}, + {"label": "Table 1", "para_id": "S5-P3"}] + # 部分提取 → 只留磁盘上有的(canon 编号匹配;label 统一成磁盘形态) + out = _narrow_figure_index(figs, ["Fig 2", "Table 1"]) + assert [f["label"] for f in out] == ["Fig 2", "Table 1"] + # None / 空列表 → 不收窄(text 模式保持全清单占位框) + assert _narrow_figure_index(figs, None) == figs + assert _narrow_figure_index(figs, []) == figs + # 交集为空(提取 label 与扫描不一致等意外)→ 回退全清单,绝不把图清空 + assert _narrow_figure_index(figs, ["Fig 99"]) == figs + + +def test_narrow_figure_index_rewrites_subfigure_and_dedups(): + """子图条目(Fig. 6b 无对应文件)→ 归一到磁盘形态 Fig 6,同号去重。""" + from app.services.ppt_outline import _narrow_figure_index + figs = [{"label": "Fig. 6b", "para_id": "S4-P9-Fig6b"}, + {"label": "Fig 6", "para_id": "S4-P9-Fig6"}, + {"label": "Fig 5", "para_id": "S3-P7-Fig5"}] + out = _narrow_figure_index(figs, ["Fig 6"]) + assert [f["label"] for f in out] == ["Fig 6"] # 两条同号合一条、磁盘形态 + # 磁盘无 Fig 5 → 剔除 + out2 = _narrow_figure_index(figs, ["Fig 6", "Fig 5"]) + assert [f["label"] for f in out2] == ["Fig 6", "Fig 5"] + + +def test_coerce_section_title_strips_trailing_particle_keeps_subtitle(): + pages = coerce_outline({"pages": [ + {"type": "section", "title": "研究方法是", "bullets": ["实验设计与取样策略"]}, + {"type": "section", "title": "研究背景", "bullets": [], + "subtitle": "黄瓜遗传改良亟需高精度泛基因组"}, + {"type": "section", "title": "结果的", "bullets": []}, + ]}) + # 错字「研究方法是」→ 组名;正常组名不动 + assert [p["title"] for p in pages] == ["研究方法", "研究背景", "结果"] + # 改进 2:副标题进独立 subtitle 字段(旧大纲仍在 bullets[0],读取时兼容) + assert pages[0]["subtitle"] == "实验设计与取样策略" + assert pages[1]["subtitle"] == "黄瓜遗传改良亟需高精度泛基因组" + assert pages[2]["subtitle"] == "" + # bullets 原样保留(编辑器仍可改;非 section 页不新增字段) + assert pages[0]["bullets"] == ["实验设计与取样策略"] + assert "subtitle" not in coerce_outline( + {"pages": [{"type": "background", "title": "背景", "bullets": ["要点"]}]} + )[0] + + +def test_coerce_section_subtitle_prefers_edited_bullet(): + """编辑器改 bullets[0] 后保存 → 以 bullets 为准,不被旧 subtitle 覆盖。""" + page = coerce_outline({"pages": [ + {"type": "section", "title": "研究背景", + "bullets": ["改写后的本章说明"], "subtitle": "旧的副标题"}, + ]})[0] + assert page["subtitle"] == "改写后的本章说明" + + +def test_reduce_prompt_section_subtitle_rule(): + from app.services.ppt_outline import _REDUCE_SYSTEM + assert "副标题" in _REDUCE_SYSTEM # 要求给分隔页副标题 + assert "subtitle" in _REDUCE_SYSTEM # 走独立字段,不再塞 bullets + assert "该章第一张内容页" in _REDUCE_SYSTEM # 副标题内容 = 该章首页 title + assert "虚词" in _REDUCE_SYSTEM # 防「研究方法是」类错字 + + +def test_cross_segment_locate_min_length_and_segment(): + from app.services.ppt_outline import _concat_segments, _locate_span, _norm + index = {"A": "alpha beta", "B": "gamma delta"} + ni = {k: _norm(v) for k, v in index.items()} + concat, offsets = _concat_segments(ni) + assert _locate_span("abetagamma", concat, offsets) == "A" # 跨段,起点在 A + assert _locate_span("gamma", concat, offsets) is None # <10 归一化字符不启用 + + +def test_repair_and_validate_cross_segment_quote_verified(): + """quote 横跨两个段落边界(单段都不含、拼接后含)→ 归属起点段并 verified。""" + from app.services.ppt_outline import repair_evidence_para_ids, validate_evidence + index = { + "S1-P1": "The yield of transgenic lines increased by 23.5 percent", + "S1-P2": "compared with the wild type under salt stress conditions.", + } + quote = "increased by 23.5 percent compared with the wild type" + page = {"slide_number": 1, "type": "result", "title": "t", "bullets": ["b"], + "evidence": [{"para_id": "", "quote": quote}], + "speaker_notes": "", "suggested_figures": []} + assert repair_evidence_para_ids([page], index) == 1 + assert page["evidence"][0]["para_id"] == "S1-P1" + warnings = validate_evidence([page], index, figure_index=[]) + assert page["evidence"][0]["status"] == "verified" + assert not any("不符" in w for w in warnings) + + +def test_validate_cross_segment_wrong_pid_stays_unverified_with_warning(): + from app.services.ppt_outline import validate_evidence + index = { + "S1-P1": "The yield of transgenic lines increased by 23.5 percent", + "S1-P2": "compared with the wild type under salt stress conditions.", + } + quote = "increased by 23.5 percent compared with the wild type" + # para_id 指到尾巴段(起点在 S1-P1)→ 起点不匹配,仍 unverified + 不符 warning + page = {"slide_number": 1, "type": "result", "title": "t", "bullets": ["b"], + "evidence": [{"para_id": "S1-P2", "quote": quote}], + "speaker_notes": "", "suggested_figures": []} + warnings = validate_evidence([page], index, figure_index=[]) + assert page["evidence"][0]["status"] == "unverified" + assert any("不符" in w for w in warnings) + + +def test_trim_keeps_section_floor_when_floors_given(): + """v3 修复3:传 section 保底 → 超页先删超配额内容页,section 不清空。""" + warnings = [] + types = ["title", "section", "outline", "section", "background", + "background", "method", "method", "result", "result", + "conclusion", "section"] + pages = [{"slide_number": i + 1, "type": t, "title": f"{t}{i}", + "bullets": [], "evidence": [], "speaker_notes": "", + "suggested_figures": []} for i, t in enumerate(types)] + out = trim_to_max(pages, 9, warnings, floors={"section": 3}) + ts = [p["type"] for p in out] + assert len(out) == 9 + assert ts.count("section") == 3 # 配额保底,3 张全留 + assert ts.count("background") == 1 # 超配额内容页先删(4→1,floor 1) + assert warnings diff --git a/review_service/tests/test_ppt_preview.py b/review_service/tests/test_ppt_preview.py new file mode 100644 index 0000000..6361306 --- /dev/null +++ b/review_service/tests/test_ppt_preview.py @@ -0,0 +1,73 @@ +"""tests/test_ppt_preview.py —— P5 可感知化:pptx → PNG 页预览(PIL)。 + +覆盖:真 3 页 pptx → 3 张 1280×720 PNG、缺文件/坏文件降级 []、 +重建页数变少不留 slide_*.png 残影、单页渲染失败不中断(原子写不半图)。 +运行: cd review_service && /bin/python -m pytest tests/test_ppt_preview.py -q +""" +from __future__ import annotations + +from pathlib import Path + +from PIL import Image + +from app.services.ppt_build import build_pptx +from app.services.ppt_preview import DPI, SLIDE_H_IN, SLIDE_W_IN, render_previews + +PAGES = [ + {"slide_number": 1, "type": "title", "title": "水稻盐胁迫响应机制", + "bullets": ["张三 等"], "evidence": [], + "speaker_notes": "", "suggested_figures": []}, + {"slide_number": 2, "type": "background", "title": "研究背景", + "bullets": ["盐胁迫是主要非生物胁迫", "机制不清"], + "evidence": [None, None], "speaker_notes": "", "suggested_figures": []}, + {"slide_number": 3, "type": "conclusion", "title": "结论", + "bullets": ["发现关键因子"], "evidence": [None], + "speaker_notes": "", "suggested_figures": []}, +] + + +def _build(tmp_path: Path, pages=None) -> Path: + out = tmp_path / "draft.pptx" + build_pptx(pages or PAGES, "academic-minimal", out, context={}) + return out + + +def test_render_three_pages(tmp_path): + pptx = _build(tmp_path) + written = render_previews(pptx, tmp_path / "preview") + assert [p.name for p in written] == ["slide_01.png", "slide_02.png", "slide_03.png"] + for p in written: + assert p.is_file() and p.stat().st_size > 1_000 + with Image.open(p) as img: + assert img.size == (int(SLIDE_W_IN * DPI), int(SLIDE_H_IN * DPI)) # 1280×720 + + +def test_missing_and_corrupt_pptx_degrade(tmp_path): + assert render_previews(tmp_path / "nope.pptx", tmp_path / "preview") == [] + bad = tmp_path / "bad.pptx" + bad.write_bytes(b"not a zip at all") + assert render_previews(bad, tmp_path / "preview") == [] + # 空 zip(0 页)→ 不写图不抛 + import io + import zipfile + empty = io.BytesIO() + with zipfile.ZipFile(empty, "w"): + pass + z = tmp_path / "empty.pptx" + z.write_bytes(empty.getvalue()) + assert render_previews(z, tmp_path / "preview") == [] + + +def test_rerender_clears_stale_pages(tmp_path): + """重建后页数变少 → 旧的多余 slide_*.png 被清掉(懒渲染 GET 同样受益)。""" + pptx = _build(tmp_path) + preview = tmp_path / "preview" + first = render_previews(pptx, preview) + assert len(first) == 3 + # 模拟"上一次 5 页构建"的残留 + (preview / "slide_04.png").write_bytes(b"stale") + (preview / "slide_05.png").write_bytes(b"stale") + second = render_previews(pptx, preview) + assert len(second) == 3 + names = sorted(p.name for p in preview.glob("slide_*.png")) + assert names == ["slide_01.png", "slide_02.png", "slide_03.png"] diff --git a/review_service/tests/test_ppt_router.py b/review_service/tests/test_ppt_router.py new file mode 100644 index 0000000..0e50205 --- /dev/null +++ b/review_service/tests/test_ppt_router.py @@ -0,0 +1,290 @@ +"""tests/test_ppt_router.py —— P5 九端点信封与 job 生命周期(mock 大纲,真 pptx)。 + +generate_outline 打桩(router 层单测),ppt_build/ppt_store 走真实实现; +覆盖:创建(text/pdf)、信封错误码、PATCH evidence 重验、build→done→下载、 +DELETE 清行+清目录、下载缺失信封、preview 缩略图(build 渲染/懒渲染/信封)。 +运行: cd review_service && /bin/python -m pytest tests/test_ppt_router.py -q +""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +import app.db as db_mod +from app import main as app_main +from app.config import get_settings +from app.services import paper_parse, ppt_store +from app.services import ppt_outline + +BASE = "/api/review/ppt" + +RAW_TEXT = "\n".join( + f"salt stress response line {i} describes ion transport and growth" + for i in range(60) +) + + +def _pages(): + return [ + {"slide_number": 1, "type": "title", "title": "盐胁迫汇报", + "bullets": ["张三 等"], "evidence": [], + "speaker_notes": "", "suggested_figures": []}, + {"slide_number": 2, "type": "background", "title": "背景", + "bullets": ["要点一", "要点二"], + "evidence": [ + {"para_id": "S1-P1", "quote": "salt stress response line 0", + "status": "unverified"}, + None, + ], + "speaker_notes": "备注", "suggested_figures": []}, + {"slide_number": 3, "type": "conclusion", "title": "结论", + "bullets": ["结论一"], "evidence": [None], + "speaker_notes": "", "suggested_figures": []}, + ] + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + db_path = tmp_path / "ppt_router_test.db" + settings = get_settings() + monkeypatch.setattr(settings, "db_path", str(db_path)) + monkeypatch.setattr(settings, "ppt_jobs_dir", str(tmp_path / "ppt_jobs")) + db_mod.set_path(db_path) + + calls = {"outline": 0} + + async def fake_generate_outline(text, scenario, minutes, style_id, + *, available_figures=None): + # 校验语义与真实 generate_outline 一致(信封码用例依赖) + calls["available"] = available_figures # v3 修复2:图 label 透传 + if scenario not in ppt_outline.SCENARIOS: + raise ppt_outline.OutlineError("VALIDATION", f"未知场景 scenario={scenario}") + if minutes not in ppt_outline.PAGE_TARGETS: + raise ppt_outline.OutlineError("VALIDATION", f"不支持的时长 {minutes} 分钟") + if style_id not in ppt_outline.STYLE_IDS: + raise ppt_outline.OutlineError("VALIDATION", f"未知风格 style_id={style_id}") + if len(text) < 300: + raise ppt_outline.OutlineError("VALIDATION", "正文过短(<300 字),无法生成汇报大纲") + calls["outline"] += 1 + return _pages(), ["生成期警告"], {"llm_calls": 3, "target_pages": [11, 14]} + + async def fake_parse_pdf(file_bytes: bytes, filename: str = "paper.pdf") -> str: + return RAW_TEXT + + monkeypatch.setattr(ppt_outline, "generate_outline", fake_generate_outline) + monkeypatch.setattr(paper_parse, "parse_pdf_bytes", fake_parse_pdf) + with TestClient(app_main.app) as c: + c.state_outline_calls = calls # type: ignore[attr-defined] + yield c + + +def _create(client, **extra): + body = {"text": RAW_TEXT, "scenario": "defense", "minutes": 15, + "style_id": "academic-minimal"} + body.update(extra) + r = client.post(f"{BASE}/jobs", json=body) + assert r.status_code == 200, r.text + payload = r.json() + assert payload["ok"] is True, payload + return payload["job"] + + +def test_styles_endpoint_lists_style_files(client): + """改进 1:GET /styles 回风格实际数量(副标题/风格卡不再写死 8/14)。""" + r = client.get(f"{BASE}/styles") + assert r.status_code == 200 + payload = r.json() + assert payload["ok"] is True + assert payload["meta"]["count"] == len(payload["items"]) == 14 + ids = [i["id"] for i in payload["items"]] + assert ids == list(ppt_outline.STYLE_IDS) + assert payload["items"][0]["label"] and payload["items"][0]["description"] + + +def test_create_text_job(client): + job = _create(client) + assert job["status"] == "outline_ready" + assert job["scenario"] == "defense" and job["minutes"] == 15 + assert len(job["pages"]) == 3 + assert job["warnings"] == ["生成期警告"] + assert job["source"]["kind"] == "text" + assert job["pptx_ready"] is False + # GET 全量还原 + r = client.get(f"{BASE}/jobs/{job['id']}") + assert r.json()["job"]["id"] == job["id"] + # raw_text 不进 job dict + assert "text" not in r.json()["job"]["source"] + + +def test_create_pdf_job(client): + r = client.post( + f"{BASE}/jobs/pdf", + files={"file": ("paper.pdf", b"%PDF-1.4 fake", "application/pdf")}, + data={"scenario": "proposal", "minutes": "20", "style_id": "botanic-forest"}, + ) + assert r.status_code == 200 + payload = r.json() + assert payload["ok"] is True, payload + assert payload["job"]["source"]["kind"] == "pdf" + assert payload["job"]["source"]["filename"] == "paper.pdf" + assert payload["job"]["style_id"] == "botanic-forest" + # 2.3:假 PDF 提取失败只降级不 500,meta 有 figures_extracted + assert payload["meta"].get("figures_extracted") == 0 + + +def test_pdf_create_passes_extracted_figure_labels(client, monkeypatch): + """v3 修复2:提取到的原图 label 透传给 generate_outline(且不泄漏进响应 meta)。""" + import app.routers.ppt as ppt_router + + def fake_extract(pdf_bytes: bytes, job_id: str): + return {"figures_extracted": 2, "figure_labels": ["Fig 1", "Fig 3"]} + + monkeypatch.setattr(ppt_router, "_extract_job_figures", fake_extract) + r = client.post( + f"{BASE}/jobs/pdf", + files={"file": ("paper.pdf", b"%PDF-1.4 fake", "application/pdf")}, + data={"scenario": "proposal", "minutes": "20", "style_id": "botanic-forest"}, + ) + assert r.status_code == 200 and r.json()["ok"] is True + assert client.state_outline_calls.get("available") == ["Fig 1", "Fig 3"] + meta = r.json()["meta"] + assert meta.get("figures_extracted") == 2 + assert "figure_labels" not in meta # 不进响应 + + +def test_create_pdf_rejects_non_pdf(client): + r = client.post( + f"{BASE}/jobs/pdf", + files={"file": ("notes.txt", b"hello", "text/plain")}, + data={"scenario": "defense", "minutes": "15", "style_id": "academic-minimal"}, + ) + assert r.status_code == 200 # 业务错误 = 200 信封 + payload = r.json() + assert payload["ok"] is False and payload["error"]["code"] == "VALIDATION" + + +def test_create_validation_error_envelope(client): + r = client.post(f"{BASE}/jobs", json={"text": RAW_TEXT, "scenario": "nope"}) + assert r.status_code == 200 + payload = r.json() + assert payload["ok"] is False and payload["error"]["code"] == "VALIDATION" + + +def test_patch_pages_and_evidence_revalidation(client): + job = _create(client) + pages = job["pages"] + # 编辑:改背景页第 1 条 quote 为编造内容 → 重验为 unverified + pages[1]["bullets"][0] = "改成新的要点" + pages[1]["evidence"][0] = {"para_id": "S1-P1", "quote": "totally invented words"} + r = client.patch(f"{BASE}/jobs/{job['id']}", json={"pages": pages}) + assert r.status_code == 200 + payload = r.json() + assert payload["ok"] is True, payload + ev = payload["job"]["pages"][1]["evidence"][0] + assert ev["status"] == "unverified" + assert payload["job"]["pages"][1]["bullets"][0] == "改成新的要点" + # 编辑为真实 quote → verified + pages[1]["evidence"][0] = {"para_id": "S1-P1", "quote": "salt stress response line 0"} + r = client.patch(f"{BASE}/jobs/{job['id']}", json={"pages": pages}) + assert r.json()["job"]["pages"][1]["evidence"][0]["status"] == "verified" + # slide_number 重排 + assert [p["slide_number"] for p in r.json()["job"]["pages"]] == [1, 2, 3] + # build 后 pptx_ready → 再编辑则旧 pptx 失效(盘上文件存在也不再下发) + assert client.post(f"{BASE}/jobs/{job['id']}/build").json()["job"]["pptx_ready"] is True + r = client.patch(f"{BASE}/jobs/{job['id']}", json={"pages": pages}) + assert r.json()["job"]["pptx_ready"] is False + assert (get_settings().ppt_jobs_abs / job["id"] / "draft.pptx").is_file() # 文件仍在盘 + r = client.get(f"{BASE}/jobs/{job['id']}/pptx") + assert r.status_code == 200 and r.json()["ok"] is False + + +def test_patch_invalid_pages(client): + job = _create(client) + r = client.patch(f"{BASE}/jobs/{job['id']}", json={"pages": [{"type": "alien"}]}) + assert r.status_code == 200 + assert r.json()["ok"] is False and r.json()["error"]["code"] == "VALIDATION" + # 空 pages 被 pydantic min_length 拦(422,与 rebuttal 各请求体一致) + r = client.patch(f"{BASE}/jobs/{job['id']}", json={"pages": []}) + assert r.status_code == 422 + + +def test_build_download_delete_lifecycle(client): + job = _create(client) + # build 前下载 → 信封 NOT_FOUND + r = client.get(f"{BASE}/jobs/{job['id']}/pptx") + assert r.status_code == 200 and r.json()["ok"] is False + # build → done + pptx_ready + r = client.post(f"{BASE}/jobs/{job['id']}/build") + assert r.status_code == 200 + payload = r.json() + assert payload["ok"] is True, payload + assert payload["job"]["status"] == "done" + assert payload["job"]["pptx_ready"] is True + assert payload["meta"]["slides"] == 3 and payload["meta"]["bytes"] > 10_000 + # 3.x:build 即渲染缩略图(页数与 slides 对齐) + assert payload["meta"]["previews"] == 3 + # 下载真 pptx + r = client.get(f"{BASE}/jobs/{job['id']}/pptx") + assert r.status_code == 200 + assert r.headers["content-type"].startswith("application/vnd.openxmlformats") + assert r.content[:2] == b"PK" # OOXML = zip + # DELETE 清行 + 清产物目录(preview/ 一并消失) + r = client.delete(f"{BASE}/jobs/{job['id']}") + assert r.json()["ok"] is True and r.json()["meta"]["deleted"] == job["id"] + assert ppt_store.get_job(job["id"]) is None + r = client.get(f"{BASE}/jobs/{job['id']}") + assert r.json()["ok"] is False and r.json()["error"]["code"] == "NOT_FOUND" + assert not (get_settings().ppt_jobs_abs / job["id"]).exists() + + +def test_build_without_pages(client): + job = _create(client) + ppt_store.update_job(job["id"], pages=[]) + r = client.post(f"{BASE}/jobs/{job['id']}/build") + assert r.status_code == 200 + assert r.json()["ok"] is False and r.json()["error"]["code"] == "VALIDATION" + + +def test_preview_endpoint(client): + """preview/{page}:build 即有图;越界/未 build 回信封;旧 job 缺图懒渲染。""" + job = _create(client) + # 未 build → 信封(HTTP 200,对齐 pptx 下载) + r = client.get(f"{BASE}/jobs/{job['id']}/preview/1") + assert r.status_code == 200 and r.json()["ok"] is False + assert r.json()["error"]["code"] == "NOT_FOUND" + + # build → 逐页 PNG + assert client.post(f"{BASE}/jobs/{job['id']}/build").json()["ok"] is True + r = client.get(f"{BASE}/jobs/{job['id']}/preview/1") + assert r.status_code == 200 + assert r.headers["content-type"] == "image/png" + assert r.content[:8] == b"\x89PNG\r\n\x1a\n" + r = client.get(f"{BASE}/jobs/{job['id']}/preview/3") + assert r.headers["content-type"] == "image/png" + + # 越界 / 非法页码 → 信封 + r = client.get(f"{BASE}/jobs/{job['id']}/preview/99") + assert r.status_code == 200 and r.json()["ok"] is False + assert r.json()["error"]["code"] == "NOT_FOUND" + r = client.get(f"{BASE}/jobs/{job['id']}/preview/0") + assert r.status_code == 200 and r.json()["ok"] is False + assert r.json()["error"]["code"] == "VALIDATION" + + # 懒渲染:删掉 preview 目录(模拟端点上线前构建的旧 job)→ 再 GET 自动补齐 + preview_dir = get_settings().ppt_jobs_abs / job["id"] / "preview" + assert preview_dir.is_dir() + import shutil + shutil.rmtree(preview_dir) + r = client.get(f"{BASE}/jobs/{job['id']}/preview/2") + assert r.status_code == 200 and r.headers["content-type"] == "image/png" + assert (preview_dir / "slide_02.png").is_file() + + # 编辑后 pptx 失效 → preview 同步封(与 pptx 下载一致) + pages = client.get(f"{BASE}/jobs/{job['id']}").json()["job"]["pages"] + client.patch(f"{BASE}/jobs/{job['id']}", json={"pages": pages}) + r = client.get(f"{BASE}/jobs/{job['id']}/preview/1") + assert r.status_code == 200 and r.json()["ok"] is False + + # 不存在的 job → 信封 + r = client.get(f"{BASE}/jobs/ppt_ghost/preview/1") + assert r.status_code == 200 and r.json()["error"]["code"] == "NOT_FOUND" diff --git a/review_service/tests/test_ppt_store.py b/review_service/tests/test_ppt_store.py new file mode 100644 index 0000000..90121bb --- /dev/null +++ b/review_service/tests/test_ppt_store.py @@ -0,0 +1,86 @@ +"""tests/test_ppt_store.py —— P5 SQLite job 存取(隔离 tmp review.db)。 + +运行: cd review_service && /bin/python -m pytest tests/test_ppt_store.py -q +""" +from __future__ import annotations + +import pytest + +import app.db as db_mod +from app.config import get_settings +from app.services import ppt_store + + +@pytest.fixture() +def store_env(tmp_path, monkeypatch): + db_path = tmp_path / "ppt_store_test.db" + settings = get_settings() + monkeypatch.setattr(settings, "db_path", str(db_path)) + db_mod.set_path(db_path) + ppt_store.ensure_table() + return db_path + + +def _job(job_id: str) -> dict: + return { + "id": job_id, + "status": "outline_ready", + "source": {"kind": "text", "filename": None}, + "scenario": "defense", + "minutes": 15, + "style_id": "academic-minimal", + "pages": [{"slide_number": 1, "type": "title", "title": "T"}], + "warnings": ["w1"], + } + + +def test_insert_get_roundtrip(store_env): + ppt_store.insert_job(_job("ppt_abc123def456"), raw_text="hello body") + job = ppt_store.get_job("ppt_abc123def456") + assert job is not None + assert job["status"] == "outline_ready" + assert job["scenario"] == "defense" + assert job["minutes"] == 15 + assert job["pages"][0]["type"] == "title" + assert job["warnings"] == ["w1"] + assert job["source"]["kind"] == "text" + assert job["source"]["text_chars"] == len("hello body") # raw_text 不进 dict,只回长度 + assert "raw_text" not in job["source"] + assert job["pptx_ready"] is False + # raw_text 独立直读(evidence 重验用) + assert ppt_store.get_raw_text("ppt_abc123def456") == "hello body" + + +def test_update_partial_fields(store_env): + ppt_store.insert_job(_job("ppt_u1"), raw_text="t") + job = ppt_store.update_job("ppt_u1", status="building") + assert job["status"] == "building" + assert job["pages"][0]["type"] == "title" # 未提供的列不动 + job = ppt_store.update_job( + "ppt_u1", pages=[{"type": "outline", "title": "O"}], warnings=[] + ) + assert job["pages"][0]["type"] == "outline" and job["warnings"] == [] + assert job["status"] == "building" # 仍不动 + + +def test_update_rejects_bad_fields(store_env): + ppt_store.insert_job(_job("ppt_u2"), raw_text="t") + with pytest.raises(ValueError): + ppt_store.update_job("ppt_u2", status="exploded") # 非法 status + with pytest.raises(TypeError): + ppt_store.update_job("ppt_u2", unknown=1) + assert ppt_store.get_job("ppt_u2")["status"] == "outline_ready" + + +def test_insert_rejects_bad_status(store_env): + with pytest.raises(ValueError): + ppt_store.insert_job({**_job("ppt_bad"), "status": "cooking"}) + + +def test_delete_and_missing(store_env): + ppt_store.insert_job(_job("ppt_d1"), raw_text="t") + assert ppt_store.delete_job("ppt_d1") is True + assert ppt_store.get_job("ppt_d1") is None + assert ppt_store.delete_job("ppt_d1") is False + assert ppt_store.get_job("ppt_none") is None + assert ppt_store.update_job("ppt_none", status="done") is None diff --git a/tests/e2e/bench.spec.js b/tests/e2e/bench.spec.js index 21d8ca6..66ad804 100644 --- a/tests/e2e/bench.spec.js +++ b/tests/e2e/bench.spec.js @@ -93,7 +93,7 @@ test.describe('E2E-07 知识图谱', () => { }); }); -test.describe('E2E-08 提取任务', () => { +test.describe('E2E-08 上传文献', () => { test('任务列表 → 详情展开 → 运行日志 → 上传(mock 成功)', async ({ page }) => { test.setTimeout(120_000); await cleanupSessionsByTitle(page, 'R1抽取'); @@ -109,7 +109,7 @@ test.describe('E2E-08 提取任务', () => { await page.goto('/'); await expect(page.locator('.app-shell')).toBeVisible(); - await selectBenchTab(page, '提取任务'); + await selectBenchTab(page, '上传文献'); const sc = benchScope(page); const extPane = sc.locator('.extraction-pane'); diff --git a/tests/frontend/unit/b2/components.test.js b/tests/frontend/unit/b2/components.test.js index 1263eaf..a7c2319 100644 --- a/tests/frontend/unit/b2/components.test.js +++ b/tests/frontend/unit/b2/components.test.js @@ -61,13 +61,11 @@ describe('Composer(FR-CHAT-005/007/011)', () => { expect(w.find('.send-btn').attributes('disabled')).toBeDefined(); }); - it('模式 pill 三态切换(labels.MODES)', async () => { + it('模式 pill 已随 43a7c6c「去模式按钮」移除:Composer 不渲染三态切换', () => { const w = mountComposer({ mode: 'ask' }); - const pills = w.findAll('.mode-pill'); - expect(pills).toHaveLength(3); - expect(pills[0].classes()).toContain('on'); - await pills[1].trigger('click'); - expect(w.emitted('change-mode')[0][0]).toBe('plan'); + expect(w.findAll('.mode-pill')).toHaveLength(0); + expect(w.emitted('change-mode')).toBeUndefined(); + expect(w.find('textarea').exists()).toBe(true); // 主体输入仍在 }); it('输入法组合键 Enter 不触发发送', async () => { diff --git a/tests/frontend/unit/b3/benchpanel.test.js b/tests/frontend/unit/b3/benchpanel.test.js index def3553..75cf610 100644 --- a/tests/frontend/unit/b3/benchpanel.test.js +++ b/tests/frontend/unit/b3/benchpanel.test.js @@ -37,7 +37,7 @@ describe('BenchPanel(FR-RETRIEVAL/SOURCE/EXTRACT/KG + P3 文档入口)', () const wrapper = mount(BenchPanel, { global: { plugins: [pinia] } }); const tabs = wrapper.findAll('.bench-tab'); expect(tabs).toHaveLength(5); - expect(tabs.map((t) => t.text())).toEqual(['检索过程', '文献来源', '提取任务', '知识图谱', '文档']); + expect(tabs.map((t) => t.text())).toEqual(['检索过程', '文献来源', '上传文献', '知识图谱', '文档']); expect(wrapper.find('.bench-tab.on').text()).toBe('检索过程'); }); @@ -56,7 +56,7 @@ describe('BenchPanel(FR-RETRIEVAL/SOURCE/EXTRACT/KG + P3 文档入口)', () const wrapper = mount(BenchPanel, { global: { plugins: [pinia] } }); const tabs = wrapper.findAll('.bench-tab'); await tabs[2].trigger('click'); - expect(wrapper.find('.bench-tab.on').text()).toBe('提取任务'); + expect(wrapper.find('.bench-tab.on').text()).toBe('上传文献'); expect(wrapper.findComponent({ name: 'ExtractionPane' }).exists()).toBe(true); await tabs[3].trigger('click'); expect(wrapper.findComponent({ name: 'KgPane' }).exists()).toBe(true); diff --git a/tests/frontend/unit/bioflow/benchpanel.bioflow.test.js b/tests/frontend/unit/bioflow/benchpanel.bioflow.test.js index 7dc8eec..220ed01 100644 --- a/tests/frontend/unit/bioflow/benchpanel.bioflow.test.js +++ b/tests/frontend/unit/bioflow/benchpanel.bioflow.test.js @@ -81,7 +81,7 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => { const w = mount(BenchPanel, { global: { plugins: [pinia] } }); await flushPromises(); const labels = w.findAll('.bench-tab').map((t) => t.text()); - expect(labels).toEqual(['检索过程', '文献来源', '提取任务', '知识图谱', '文档', '生信任务', '生信档案']); + expect(labels).toEqual(['检索过程', '文献来源', '上传文献', '知识图谱', '文档', '生信任务', '生信档案']); }); it('探活失败(health 不可达):生信 TAB 不渲染(TC-BIO-JOB-012/PANE-013)', async () => { @@ -89,7 +89,7 @@ describe('BenchPanel 生信 TAB 探活显隐(FR-BIO-025)', () => { const w = mount(BenchPanel, { global: { plugins: [pinia] } }); await flushPromises(); const labels = w.findAll('.bench-tab').map((t) => t.text()); - expect(labels).toEqual(['检索过程', '文献来源', '提取任务', '知识图谱', '文档']); + expect(labels).toEqual(['检索过程', '文献来源', '上传文献', '知识图谱', '文档']); expect(labels).not.toContain('生信任务'); expect(labels).not.toContain('生信档案'); }); diff --git a/tests/frontend/unit/edu/ppt.panel.test.js b/tests/frontend/unit/edu/ppt.panel.test.js new file mode 100644 index 0000000..c3f2c75 --- /dev/null +++ b/tests/frontend/unit/edu/ppt.panel.test.js @@ -0,0 +1,357 @@ +// tests/frontend/unit/edu/ppt.panel.test.js —— PptPanel 交互约束(P5) +// 1) 生成表单:场景 5 项 / 时长 4 档 / 风格 14 卡;pdf↔text 切换 +// 2) 编辑器:页卡渲染 + evidence 三色徽标(verified/unverified/无) +// + 图占位 chips(真实清单 label + para_id)+ section 分隔页标签 +// 3) 编辑 → dirty → 保存携带 pages(后端重验回写,highlight 保真) +// 4) 脏状态点「生成 PPT」= 先保存后构建(顺序约束);干净状态直接构建 +// 5) pptx_ready → 下载直链;warnings 展示 +// 6) 3.x 可感知化:buildMeta.previews → 缩略图条/大图直链; +// createMeta.figures_extracted 与证据核实比例 chips 露出 +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; + +// 改进 1:/ppt/styles 清单(14 条,顺序 = data/ppt_styles/*.json)供副标题与风格卡同源 +const { STYLE_ITEMS } = vi.hoisted(() => { + const ids = [ + 'academic-minimal', 'botanic-forest', 'classic-defense', 'academic-blue', + 'nature-green', 'tech-dark', 'monochrome', 'warm-earth', 'domestic', + 'international', 'systems-talk', 'theory-beamer', 'jp-gothic', 'editorial', + ]; + return { + STYLE_ITEMS: ids.map((id, i) => ({ + id, label: `风格 ${i + 1}`, display_name: `风格 ${i + 1}`, + description: `${id} 描述`, bg: '#ffffff', accent: '#2563eb', + })), + }; +}); + +vi.mock('../../../../ui/src/api/review.js', () => ({ + getJournals: vi.fn(), + getDirectory: vi.fn(), + getConferences: vi.fn(), + presubmit: vi.fn(), + recommend: vi.fn(), + formatCheck: vi.fn(), + formatErrorMessage: vi.fn(), + reformatTex: vi.fn(), + compileTex: vi.fn(), + inspectZip: vi.fn(), + runZipCompile: vi.fn(), + reviewErrorMessage: vi.fn((e) => (e && e.message) || 'err'), + rebuttalCreateJob: vi.fn(), + rebuttalCreateFromPdf: vi.fn(), + rebuttalGetJob: vi.fn(), + rebuttalSaveResponse: vi.fn(), + rebuttalDraft: vi.fn(), + rebuttalStructure: vi.fn(), + rebuttalConfirm: vi.fn(), + rebuttalLetter: vi.fn(), + rebuttalDownloadLetterMd: vi.fn(), + rebuttalDeleteJob: vi.fn(), + pptCreateJob: vi.fn(), + pptCreateFromPdf: vi.fn(), + pptSavePages: vi.fn(), + pptBuildJob: vi.fn(), + pptDeleteJob: vi.fn(), + pptStyles: vi.fn().mockResolvedValue({ ok: true, items: STYLE_ITEMS, meta: { count: 14 } }), + pptDownloadUrl: vi.fn((id) => `/api/review/ppt/jobs/${id}/pptx`), + pptPreviewUrl: vi.fn((id, page) => `/api/review/ppt/jobs/${id}/preview/${page}`), +})); + +import { pptBuildJob, pptSavePages, pptStyles } from '../../../../ui/src/api/review.js'; +import PptPanel from '../../../../ui/src/components/edu/panels/PptPanel.vue'; +import { useEduStore } from '../../../../ui/src/stores/edu.js'; + +function makePptJob(over = {}) { + return { + id: 'ppt_abc123def456', + status: 'outline_ready', + source: { kind: 'pdf', filename: 'paper.pdf', text_chars: 5000 }, + scenario: 'defense', + minutes: 15, + style_id: 'academic-minimal', + pages: [ + { slide_number: 1, type: 'title', title: '水稻盐胁迫', bullets: ['张三 等'], evidence: [null], speaker_notes: '', suggested_figures: [] }, + { + slide_number: 2, type: 'result', title: '结果', bullets: ['根长下降', '离子积累', '无来源要点'], + evidence: [ + { para_id: 'S4-P1', quote: 'salt stress', status: 'verified' }, + { para_id: 'S4-P2', quote: 'made up', status: 'unverified' }, + null, + ], + speaker_notes: '讲这里', + suggested_figures: [{ label: 'Fig 2', para_id: 'S4-P1-Fig2', status: 'verified' }], + highlight: { value: '+0.82 cm', label: '株高增长' }, + }, + ], + warnings: ['生成 9 页,低于目标下限 11 页(不凑页,请人工补足)'], + pptx_ready: true, + created_at: '2026-10-14T00:00:00+00:00', + updated_at: '2026-10-14T00:00:00+00:00', + ...over, + }; +} + +function mountFresh() { + const pinia = createPinia(); + setActivePinia(pinia); + const edu = useEduStore(); + const wrapper = mount(PptPanel, { global: { plugins: [pinia] } }); + return { wrapper, edu }; +} + +beforeEach(() => { + vi.clearAllMocks(); // 只清调用记录,保留 reviewErrorMessage 等实现 +}); + +function mountWithJob(job) { + const { wrapper, edu } = mountFresh(); + edu.ppt.job = job; + edu.ppt.status = 'editing'; + return { wrapper, edu }; +} + +/** 挂载并 flush 渲染(job 在 mount 后注入 → 须等一 tick 才有编辑器 DOM) */ +async function mountJobReady(job) { + const ctx = mountWithJob(job); + await ctx.wrapper.vm.$nextTick(); + return ctx; +} + +describe('PptPanel 生成表单', () => { + it('场景 5 项 / 时长 4 档 / 风格 14 卡;pdf↔text 切换', async () => { + const { wrapper, edu } = mountFresh(); + expect(wrapper.find('[data-test="ppt-form"]').exists()).toBe(true); + // 改进 1:副标题风格数取自 GET /ppt/styles;清单未返回前先显示「多风格」兜底 + expect(wrapper.find('.ppt-sub').text()).toContain('多风格·图文混排'); + await vi.waitFor(() => expect(edu.pptStylesStatus).toBe('ready')); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.ppt-sub').text()).toContain('14 风格·图文混排'); + expect(edu.pptStyles).toHaveLength(14); + expect(pptStyles).toHaveBeenCalledTimes(1); + expect(wrapper.findAll('[data-test="scenario-select"] option')).toHaveLength(5); + for (const m of [10, 15, 20, 30]) { + expect(wrapper.find(`[data-test="minutes-${m}"]`).exists()).toBe(true); + } + expect(wrapper.findAll('[class*="ppt-style-card"]')).toHaveLength(14); + // 3.x:每张风格卡带真实版式封面图(assets/ppt-style-cards/{id}.png) + const covers = wrapper.findAll('[data-test^="cover-"]'); + expect(covers).toHaveLength(14); + expect(covers[0].attributes('src')).toContain('academic-minimal.png'); + expect(covers[7].attributes('src')).toContain('warm-earth.png'); + expect(covers[8].attributes('src')).toContain('domestic.png'); + expect(covers[13].attributes('src')).toContain('editorial.png'); + + // 风格切换(trigger 内部 await nextTick;DOM 断言前必须 await) + await wrapper.find('[data-test="style-botanic-forest"]').trigger('click'); + expect(edu.ppt.form.styleId).toBe('botanic-forest'); + // 新增 5 风格可选 + await wrapper.find('[data-test="style-academic-blue"]').trigger('click'); + expect(edu.ppt.form.styleId).toBe('academic-blue'); + // 时长切换 + await wrapper.find('[data-test="minutes-30"]').trigger('click'); + expect(edu.ppt.form.minutes).toBe(30); + // pdf → text + await wrapper.find('[data-test="mode-text"]').trigger('click'); + expect(wrapper.find('[data-test="text-input"]').exists()).toBe(true); + await wrapper.find('[data-test="mode-pdf"]').trigger('click'); + expect(wrapper.find('[data-test="file-input"]').exists()).toBe(true); + }); + + it('缺文件点生成 → 表单错误,不发后端(mock 未被调用)', async () => { + const { wrapper } = mountFresh(); + await wrapper.find('[data-test="generate"]').trigger('click'); + expect(wrapper.find('[data-test="form-error"]').text()).toContain('PDF'); + }); + + it('风格清单接口失败 → 副标题回落「多风格」,风格卡走本地常量', async () => { + pptStyles.mockRejectedValueOnce(new Error('offline')); + const { wrapper, edu } = mountFresh(); + await vi.waitFor(() => expect(edu.pptStylesStatus).toBe('error')); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.ppt-sub').text()).toContain('多风格·图文混排'); + expect(edu.pptStyles).toHaveLength(0); + expect(wrapper.findAll('[class*="ppt-style-card"]')).toHaveLength(14); + }); +}); + +describe('PptPanel 编辑器', () => { + it('页卡 + evidence 三色徽标 + 图占位 chips + warnings + 下载直链', async () => { + const { wrapper } = await mountJobReady(makePptJob()); + expect(wrapper.find('[data-test="ppt-editor"]').exists()).toBe(true); + expect(wrapper.findAll('[data-test="page-card"]')).toHaveLength(2); + // 第 2 页三枚徽标:verified / unverified / 无来源 + const badges = wrapper.findAll('[data-test="page-card"][class*="tp-result"] [data-test="ev-badge"]'); + expect(badges).toHaveLength(3); + expect(badges[0].classes()).toContain('ev-ok'); + expect(badges[1].classes()).toContain('ev-bad'); + expect(badges[2].classes()).toContain('ev-none'); + expect(badges[0].attributes('title')).toContain('S4-P1'); + // 图占位(真实清单:label + 论文 para_id) + expect(wrapper.find('[data-test="fig-1-0"]').text()) + .toBe('Fig 2(论文 S4-P1-Fig2)'); + // warnings + expect(wrapper.find('[data-test="warnings"]').text()).toContain('低于目标下限'); + // 下载直链(pptx_ready) + expect(wrapper.find('[data-test="download"]').attributes('href')) + .toBe('/api/review/ppt/jobs/ppt_abc123def456/pptx'); + // 页数 + expect(wrapper.find('[data-test="page-count"]').text()).toBe('2 页'); + }); + + it('编辑要点 → dirty → 保存携带 pages(顺序:先 save)', async () => { + const { wrapper, edu } = await mountJobReady(makePptJob()); + const saveBtn = wrapper.find('[data-test="save"]'); + expect(saveBtn.attributes('disabled')).toBeDefined(); // 无修改禁用 + + await wrapper.find('[data-test="bullet-1-0"]').setValue('改过的要点'); + expect(wrapper.find('[data-test="dirty-hint"]').exists()).toBe(true); + expect(saveBtn.attributes('disabled')).toBeUndefined(); + + const newJob = makePptJob({ status: 'outline_ready', pptx_ready: false }); + pptSavePages.mockResolvedValue({ ok: true, job: newJob }); + await saveBtn.trigger('click'); + await vi.waitFor(() => expect(pptSavePages).toHaveBeenCalledTimes(1)); + + const [id, pages] = pptSavePages.mock.calls[0]; + expect(id).toBe('ppt_abc123def456'); + expect(pages[1].bullets[0]).toBe('改过的要点'); + expect(pages[1].evidence[0].para_id).toBe('S4-P1'); // evidence 随行 + expect(pages[1].highlight).toEqual({ value: '+0.82 cm', label: '株高增长' }); // highlight 保真 + expect(edu.ppt.job.pptx_ready).toBe(false); // 旧 PPTX 失效 + await vi.waitFor(() => expect(wrapper.find('[data-test="dirty-hint"]').exists()).toBe(false)); + }); + + it('脏状态「生成 PPT」= 先保存后构建(顺序约束)', async () => { + const { wrapper } = await mountJobReady(makePptJob()); + await wrapper.find('[data-test="bullet-0-0"]').setValue('脏了'); + pptSavePages.mockResolvedValue({ ok: true, job: makePptJob() }); + pptBuildJob.mockResolvedValue({ + ok: true, job: makePptJob({ status: 'done', pptx_ready: true }), + meta: { slides: 2, bytes: 12000 }, + }); + + await wrapper.find('[data-test="build"]').trigger('click'); + await vi.waitFor(() => expect(pptBuildJob).toHaveBeenCalledTimes(1)); + expect(pptSavePages).toHaveBeenCalledTimes(1); + expect(pptSavePages.mock.invocationCallOrder[0]).toBeLessThan(pptBuildJob.mock.invocationCallOrder[0]); + // 构建后 done + 下载链接仍在 + expect(wrapper.find('[data-test="download"]').exists()).toBe(true); + }); + + it('干净状态「生成 PPT」不打保存', async () => { + const { wrapper } = await mountJobReady(makePptJob()); + pptBuildJob.mockResolvedValue({ + ok: true, job: makePptJob({ status: 'done' }), meta: { slides: 2, bytes: 12000 }, + }); + await wrapper.find('[data-test="build"]').trigger('click'); + await vi.waitFor(() => expect(pptBuildJob).toHaveBeenCalledTimes(1)); + expect(pptSavePages).not.toHaveBeenCalled(); + }); +}); + +describe('P5 增强:section 分隔页与大数字', () => { + it('section 页显示「分隔」标签,result highlight 页正常渲染', async () => { + const sectionPage = { + slide_number: 3, type: 'section', title: '主要结果', bullets: [], + evidence: [], speaker_notes: '进入结果部分', suggested_figures: [], + }; + const { wrapper } = await mountJobReady(makePptJob({ pages: [ + ...makePptJob().pages, + sectionPage, + ] })); + expect(wrapper.findAll('[data-test="page-card"]')).toHaveLength(3); + const types = wrapper.findAll('[data-test="page-type"]').map((n) => n.text()); + expect(types).toContain('分隔'); + // highlight 大数字页:result 第 2 页仍走常规渲染,无异常 + expect(wrapper.find('[data-test="page-count"]').text()).toBe('3 页'); + }); + + it('section 页唯一副标题输入:编辑同写 bullets[0]+subtitle;清空回落默认', async () => { + const mkSection = (over = {}) => ({ + slide_number: 3, type: 'section', title: '主要结果', bullets: [], evidence: [], + speaker_notes: '', suggested_figures: [], ...over, + }); + const { wrapper } = await mountJobReady(makePptJob({ pages: [...makePptJob().pages, mkSection()] })); + expect(wrapper.findAll('[data-test="page-subtitle"]')).toHaveLength(1); // 只在分隔页出现 + expect(wrapper.find('[data-test="bullet-0-0"]').exists()).toBe(true); // 正文页仍走要点编辑 + + await wrapper.find('[data-test="page-subtitle"]').setValue('结果章节'); + expect(wrapper.find('[data-test="dirty-hint"]').exists()).toBe(true); + pptSavePages.mockResolvedValue({ + ok: true, + job: makePptJob({ pages: [...makePptJob().pages, mkSection({ bullets: ['结果章节'], subtitle: '结果章节' })] }), + }); + await wrapper.find('[data-test="save"]').trigger('click'); + await vi.waitFor(() => expect(pptSavePages).toHaveBeenCalledTimes(1)); + const [, pages] = pptSavePages.mock.calls[0]; + expect(pages[2].bullets).toEqual(['结果章节']); // 后端 coerce bullets[0] 优先 + expect(pages[2].subtitle).toBe('结果章节'); // 两字段同写,避免互踩 + + // 清空 → bullets/subtitle 均空(渲染端回落该章第一页标题) + await wrapper.find('[data-test="page-subtitle"]').setValue(''); + expect(wrapper.find('[data-test="dirty-hint"]').exists()).toBe(true); + await wrapper.find('[data-test="save"]').trigger('click'); + await vi.waitFor(() => expect(pptSavePages).toHaveBeenCalledTimes(2)); + const pages2 = pptSavePages.mock.calls[1][1]; + expect(pages2[2].bullets).toEqual([]); + expect(pages2[2].subtitle).toBe(''); + }); +}); + +describe('3.x 可感知化:页面预览与 meta 露出', () => { + it('build 返回 previews → 缩略图条 + 大图直链;点击切换页', async () => { + const { wrapper } = await mountJobReady(makePptJob()); + // 构建前无预览区 + expect(wrapper.find('[data-test="preview-wrap"]').exists()).toBe(false); + + pptBuildJob.mockResolvedValue({ + ok: true, + job: makePptJob({ status: 'done', pptx_ready: true }), + meta: { slides: 2, bytes: 12000, previews: 2 }, + }); + await wrapper.find('[data-test="build"]').trigger('click'); + await vi.waitFor(() => expect(wrapper.find('[data-test="preview-strip"]').exists()).toBe(true)); + + // 缩略图数 = previews;大图直链正确 + expect(wrapper.findAll('[data-test^="thumb-"]')).toHaveLength(2); + expect(wrapper.find('[data-test="preview-main-1"]').attributes('src')) + .toBe('/api/review/ppt/jobs/ppt_abc123def456/preview/1'); + + // 点缩略图切大图 + await wrapper.find('[data-test="thumb-2"]').trigger('click'); + expect(wrapper.find('[data-test="preview-main-2"]').exists()).toBe(true); + expect(wrapper.find('[data-test="preview-main-1"]').exists()).toBe(false); + }); + + it('旧响应无 previews → 无预览区;pptx 失效(编辑后)→ 已有预览也隐藏', async () => { + const { wrapper, edu } = await mountJobReady(makePptJob()); + pptBuildJob.mockResolvedValue({ + ok: true, job: makePptJob({ status: 'done', pptx_ready: true }), + meta: { slides: 2, bytes: 12000 }, // 无 previews(旧版后端响应) + }); + await wrapper.find('[data-test="build"]').trigger('click'); + await vi.waitFor(() => expect(pptBuildJob).toHaveBeenCalledTimes(1)); + await wrapper.vm.$nextTick(); + expect(wrapper.find('[data-test="preview-wrap"]').exists()).toBe(false); + + // 有 previews 但 pptx 已失效(保存编辑后 pptx_ready=false + buildMeta 被 store 清空) + edu.ppt.buildMeta = { slides: 2, bytes: 12000, previews: 2 }; + edu.ppt.job = makePptJob({ pptx_ready: false }); + await wrapper.vm.$nextTick(); + expect(wrapper.find('[data-test="preview-wrap"]').exists()).toBe(false); + }); + + it('提取原图 chip 与证据核实比例 chip 露出', async () => { + const { wrapper, edu } = await mountJobReady(makePptJob()); + // makePptJob:1 verified + 1 unverified → 1/2;无 figures_extracted → 不显示 + expect(wrapper.find('[data-test="ev-stats"]').text()).toBe('证据 1/2 已核实'); + expect(wrapper.find('[data-test="figs-chip"]').exists()).toBe(false); + + edu.ppt.createMeta = { target_pages: [11, 14], figures_extracted: 2 }; + await wrapper.vm.$nextTick(); + expect(wrapper.find('[data-test="figs-chip"]').text()).toBe('提取原图 2 张'); + expect(wrapper.find('[data-test="figs-chip"]').text()).not.toContain('0 张'); + }); +}); diff --git a/tests/frontend/unit/edu/ppt.store.test.js b/tests/frontend/unit/edu/ppt.store.test.js new file mode 100644 index 0000000..e3ad0d8 --- /dev/null +++ b/tests/frontend/unit/edu/ppt.store.test.js @@ -0,0 +1,199 @@ +// tests/frontend/unit/edu/ppt.store.test.js —— P5 科研汇报 store actions(edu.ppt) +// 1) setSection('ppt') 合法(SECTIONS 收录) +// 2) pptGenerate:pdf 入口 FormData 组装 / 缺文件与短文本前置校验(不打后端) +// 3) pptSave:job 回写 + buildMeta 清空(旧 PPTX 失效)+ 失败保留编辑态 +// 4) pptBuild:done + meta 回写 / 失败 → error +// 5) pptDelete:清 job 但保留表单(便于立即重做) +// 6) pptLoadStyles:清单 items+ready / 失败清空+error / 就绪后不重复请求 +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../../../ui/src/api/review.js', () => ({ + getJournals: vi.fn(), + getDirectory: vi.fn(), + getConferences: vi.fn(), + presubmit: vi.fn(), + recommend: vi.fn(), + formatCheck: vi.fn(), + formatErrorMessage: vi.fn(), + reformatTex: vi.fn(), + compileTex: vi.fn(), + inspectZip: vi.fn(), + runZipCompile: vi.fn(), + reviewErrorMessage: vi.fn((e) => (e && e.message) || 'err'), + rebuttalCreateJob: vi.fn(), + rebuttalCreateFromPdf: vi.fn(), + rebuttalGetJob: vi.fn(), + rebuttalSaveResponse: vi.fn(), + rebuttalDraft: vi.fn(), + rebuttalStructure: vi.fn(), + rebuttalConfirm: vi.fn(), + rebuttalLetter: vi.fn(), + rebuttalDownloadLetterMd: vi.fn(), + rebuttalDeleteJob: vi.fn(), + pptCreateJob: vi.fn(), + pptCreateFromPdf: vi.fn(), + pptSavePages: vi.fn(), + pptBuildJob: vi.fn(), + pptDeleteJob: vi.fn(), + pptStyles: vi.fn(), + pptDownloadUrl: vi.fn((id) => `/api/review/ppt/jobs/${id}/pptx`), +})); + +import { + pptBuildJob, pptCreateFromPdf, pptCreateJob, pptDeleteJob, pptSavePages, pptStyles, +} from '../../../../ui/src/api/review.js'; +import { createPinia, setActivePinia } from 'pinia'; +import { useEduStore } from '../../../../ui/src/stores/edu.js'; + +function makePptJob(over = {}) { + return { + id: 'ppt_abc123def456', + status: 'outline_ready', + source: { kind: 'pdf', filename: 'paper.pdf', text_chars: 5000 }, + scenario: 'defense', + minutes: 15, + style_id: 'academic-minimal', + pages: [ + { slide_number: 1, type: 'title', title: '封面', bullets: ['张三'], evidence: [null], speaker_notes: '', suggested_figures: [] }, + { slide_number: 2, type: 'background', title: '背景', bullets: ['a', 'b'], evidence: [null, null], speaker_notes: 'n', suggested_figures: [] }, + ], + warnings: ['生成 9 页,低于目标下限 11 页'], + pptx_ready: false, + created_at: '2026-10-14T00:00:00+00:00', + updated_at: '2026-10-14T00:00:00+00:00', + ...over, + }; +} + +let edu; +beforeEach(() => { + setActivePinia(createPinia()); + edu = useEduStore(); + vi.clearAllMocks(); +}); + +describe('edu.ppt store', () => { + it("setSection('ppt') 合法", () => { + edu.setSection('ppt'); + expect(edu.section).toBe('ppt'); + edu.setSection('nope'); + expect(edu.section).toBe('ppt'); // 非法 section 不落 + }); + + it('pptGenerate pdf:FormData 组装 + 成功进 editing', async () => { + edu.ppt.form.mode = 'pdf'; + edu.ppt.form.file = new File(['%PDF'], 'paper.pdf', { type: 'application/pdf' }); + edu.ppt.form.scenario = 'defense'; + edu.ppt.form.minutes = 20; + edu.ppt.form.styleId = 'botanic-forest'; + const job = makePptJob(); + pptCreateFromPdf.mockResolvedValue({ ok: true, job, meta: { target_pages: [15, 18], llm_calls: 5 } }); + + const got = await edu.pptGenerate(); + expect(got.id).toBe(job.id); + expect(edu.ppt.status).toBe('editing'); + expect(edu.ppt.createMeta.target_pages).toEqual([15, 18]); + const fd = pptCreateFromPdf.mock.calls[0][0]; + expect(fd).toBeInstanceOf(FormData); + expect(fd.get('scenario')).toBe('defense'); + expect(fd.get('minutes')).toBe('20'); + expect(fd.get('style_id')).toBe('botanic-forest'); + expect(pptCreateJob).not.toHaveBeenCalled(); + }); + + it('pptGenerate 前置校验:缺文件/短文本不打后端', async () => { + edu.ppt.form.mode = 'pdf'; + edu.ppt.form.file = null; + expect(await edu.pptGenerate()).toBeNull(); + expect(edu.ppt.status).toBe('error'); + expect(pptCreateFromPdf).not.toHaveBeenCalled(); + + edu.ppt.form.mode = 'text'; + edu.ppt.form.text = '短文本'; + expect(await edu.pptGenerate()).toBeNull(); + expect(pptCreateJob).not.toHaveBeenCalled(); + expect(edu.ppt.error).toContain('300'); + }); + + it('pptSave:job 回写 + buildMeta 清空;失败保留编辑态', async () => { + edu.ppt.job = makePptJob({ status: 'done', pptx_ready: true }); + edu.ppt.buildMeta = { slides: 2, bytes: 1000 }; + edu.ppt.status = 'done'; + const newJob = makePptJob({ pages: [{ ...makePptJob().pages[0], title: '改过' }] }); + pptSavePages.mockResolvedValue({ ok: true, job: newJob }); + + const saved = await edu.pptSave(newJob.pages); + expect(saved.id).toBe(newJob.id); + expect(pptSavePages).toHaveBeenCalledWith('ppt_abc123def456', newJob.pages); + expect(edu.ppt.job.pages[0].title).toBe('改过'); + expect(edu.ppt.buildMeta).toBeNull(); // 旧 PPTX 失效 + expect(edu.ppt.saving).toBe(false); + + // 失败:error 记录但 status 保持(job 不动) + pptSavePages.mockRejectedValue(new Error('保存炸了')); + expect(await edu.pptSave([])).toBeNull(); + expect(edu.ppt.job.pages[0].title).toBe('改过'); + expect(edu.ppt.error).toBe('保存炸了'); + }); + + it('pptBuild:done + meta;失败 → error', async () => { + edu.ppt.job = makePptJob(); + pptBuildJob.mockResolvedValue({ + ok: true, + job: makePptJob({ status: 'done', pptx_ready: true }), + meta: { slides: 8, bytes: 24576 }, + }); + const job = await edu.pptBuild(); + expect(job.status).toBe('done'); + expect(edu.ppt.status).toBe('done'); + expect(edu.ppt.buildMeta).toEqual({ slides: 8, bytes: 24576 }); + + pptBuildJob.mockRejectedValue(new Error('磁盘满')); + expect(await edu.pptBuild()).toBeNull(); + expect(edu.ppt.status).toBe('error'); + expect(edu.ppt.error).toBe('磁盘满'); + }); + + it('pptDelete:清 job 保留表单;job 不存在不打后端', async () => { + edu.ppt.form.mode = 'text'; + edu.ppt.form.text = 'x'.repeat(400); + edu.ppt.job = makePptJob(); + edu.ppt.status = 'editing'; + pptDeleteJob.mockResolvedValue({ ok: true, meta: { deleted: 'ppt_abc123def456' } }); + + const res = await edu.pptDelete(); + expect(res.ok).toBe(true); + expect(pptDeleteJob).toHaveBeenCalledWith('ppt_abc123def456'); + expect(edu.ppt.job).toBeNull(); + expect(edu.ppt.status).toBe('idle'); + expect(edu.ppt.form.text).toHaveLength(400); // 表单保留 + + await edu.pptDelete(); + expect(pptDeleteJob).toHaveBeenCalledTimes(1); // 无 job 不重复打 + }); + + it('pptLoadStyles:成功落 items+ready、就绪不重复请求;失败清空+error 可重试', async () => { + pptStyles.mockResolvedValue({ + ok: true, + items: [{ id: 'academic-minimal', label: '学术极简', description: '白底蓝黑' }], + meta: { count: 1 }, + }); + const items = await edu.pptLoadStyles(); + expect(items).toHaveLength(1); + expect(edu.pptStyles[0].id).toBe('academic-minimal'); + expect(edu.pptStylesStatus).toBe('ready'); + await edu.pptLoadStyles(); + expect(pptStyles).toHaveBeenCalledTimes(1); // 已就绪 → 不重复打 + + // 失败:清空清单 + error(error 态允许下次重试) + edu.pptStyles = []; + edu.pptStylesStatus = 'idle'; + pptStyles.mockRejectedValueOnce(new Error('offline')); + expect(await edu.pptLoadStyles()).toEqual([]); + expect(edu.pptStylesStatus).toBe('error'); + pptStyles.mockResolvedValue({ ok: true, items: [], meta: { count: 0 } }); + await edu.pptLoadStyles(); + expect(pptStyles).toHaveBeenCalledTimes(3); // error → 允许重试 + expect(edu.pptStylesStatus).toBe('ready'); + }); +}); diff --git a/tooling/requirements.lock b/tooling/requirements.lock index 72e091b..2f1f4f2 100644 --- a/tooling/requirements.lock +++ b/tooling/requirements.lock @@ -77,6 +77,7 @@ orjson==3.11.9 ormsgpack==1.12.2 packaging==26.3 pandas==3.0.5 +pillow==12.3.0 pluggy==1.6.0 portalocker==3.2.0 propcache==0.5.2 @@ -95,6 +96,7 @@ python-dateutil==2.9.0.post0 python-docx==1.1.2 python-dotenv==1.2.3 python-multipart==0.0.32 +python-pptx==1.0.2 pytz==2026.3.post1 PyYAML==6.0.3 qdrant-client==1.18.0 @@ -137,6 +139,7 @@ watchfiles==1.2.0 websockets==16.0 wheel==0.47.0 xlrd==2.0.1 +xlsxwriter==3.2.9 xxhash==3.7.0 yarl==1.24.2 zstandard==0.25.0 diff --git a/ui/src/api/review.js b/ui/src/api/review.js index d0cdd5d..a37af5f 100644 --- a/ui/src/api/review.js +++ b/ui/src/api/review.js @@ -223,3 +223,53 @@ export async function rebuttalDownloadLetterMd(id) { export function rebuttalDeleteJob(id, opts = {}) { return unwrap(del(`${RB}/jobs/${id}`, opts)); } + +/* ==================== P5 科研汇报助手(/api/review/ppt,SQLite job 模式) ==================== */ +const PPT = `${BASE}/ppt`; + +/** GET /ppt/styles → {ok, items:[{id,label,display_name,description,bg,accent}], meta:{count}} + * 风格清单来自 review_service/data/ppt_styles/*.json(数量动态,面板副标题不再写死 8/14) */ +export function pptStyles(opts = {}) { + return unwrap(get(`${PPT}/styles`, {}, opts)); +} + +/** POST /ppt/jobs {text,scenario,minutes,style_id} → {ok, job, meta} + * 文本粘贴主入口(map-reduce 大纲生成为长调用:BFF 360s / http.js 默认 600s) */ +export function pptCreateJob(body, opts = {}) { + return unwrap(post(`${PPT}/jobs`, body, opts)); +} + +/** POST /ppt/jobs/pdf(multipart:file + scenario/minutes/style_id form 字段)→ 同上 */ +export function pptCreateFromPdf(formData, opts = {}) { + return unwrap(post(`${PPT}/jobs/pdf`, formData, opts)); +} + +/** GET /ppt/jobs/{id} → {ok, job} 全量还原(raw_text 不回传,source.text_chars 代之) */ +export function pptGetJob(id, opts = {}) { + return unwrap(get(`${PPT}/jobs/${id}`, {}, opts)); +} + +/** PATCH /ppt/jobs/{id} {pages} → {ok, job}(结构清洗 + evidence 状态按原文重验) */ +export function pptSavePages(id, pages, opts = {}) { + return unwrap(patch(`${PPT}/jobs/${id}`, { pages }, opts)); +} + +/** POST /ppt/jobs/{id}/build → {ok, job, meta:{slides,bytes,previews}}(python-pptx 本地生成 + 缩略图) */ +export function pptBuildJob(id, opts = {}) { + return unwrap(post(`${PPT}/jobs/${id}/build`, {}, opts)); +} + +/** DELETE /ppt/jobs/{id} → {ok, meta:{deleted}} 手动清行 + 产物目录(无 TTL) */ +export function pptDeleteJob(id, opts = {}) { + return unwrap(del(`${PPT}/jobs/${id}`, opts)); +} + +/** pptx 下载直链(;FileResponse 附件下载,非信封,缺失时回信封 JSON) */ +export function pptDownloadUrl(id) { + return `${PPT}/jobs/${id}/pptx`; +} + +/** 第 page 页缩略图直链(;PNG。缺失时回信封 JSON → img onerror 隐藏) */ +export function pptPreviewUrl(id, page) { + return `${PPT}/jobs/${id}/preview/${page}`; +} diff --git a/ui/src/assets/ppt-style-cards/academic-blue.png b/ui/src/assets/ppt-style-cards/academic-blue.png new file mode 100644 index 0000000..5e2f77e Binary files /dev/null and b/ui/src/assets/ppt-style-cards/academic-blue.png differ diff --git a/ui/src/assets/ppt-style-cards/academic-minimal.png b/ui/src/assets/ppt-style-cards/academic-minimal.png new file mode 100644 index 0000000..a5c70bb Binary files /dev/null and b/ui/src/assets/ppt-style-cards/academic-minimal.png differ diff --git a/ui/src/assets/ppt-style-cards/botanic-forest.png b/ui/src/assets/ppt-style-cards/botanic-forest.png new file mode 100644 index 0000000..4901365 Binary files /dev/null and b/ui/src/assets/ppt-style-cards/botanic-forest.png differ diff --git a/ui/src/assets/ppt-style-cards/classic-defense.png b/ui/src/assets/ppt-style-cards/classic-defense.png new file mode 100644 index 0000000..b95aa0b Binary files /dev/null and b/ui/src/assets/ppt-style-cards/classic-defense.png differ diff --git a/ui/src/assets/ppt-style-cards/domestic.png b/ui/src/assets/ppt-style-cards/domestic.png new file mode 100644 index 0000000..046cc20 Binary files /dev/null and b/ui/src/assets/ppt-style-cards/domestic.png differ diff --git a/ui/src/assets/ppt-style-cards/editorial.png b/ui/src/assets/ppt-style-cards/editorial.png new file mode 100644 index 0000000..1b9f267 Binary files /dev/null and b/ui/src/assets/ppt-style-cards/editorial.png differ diff --git a/ui/src/assets/ppt-style-cards/international.png b/ui/src/assets/ppt-style-cards/international.png new file mode 100644 index 0000000..0a6f93e Binary files /dev/null and b/ui/src/assets/ppt-style-cards/international.png differ diff --git a/ui/src/assets/ppt-style-cards/jp-gothic.png b/ui/src/assets/ppt-style-cards/jp-gothic.png new file mode 100644 index 0000000..d92a953 Binary files /dev/null and b/ui/src/assets/ppt-style-cards/jp-gothic.png differ diff --git a/ui/src/assets/ppt-style-cards/monochrome.png b/ui/src/assets/ppt-style-cards/monochrome.png new file mode 100644 index 0000000..25d3a1b Binary files /dev/null and b/ui/src/assets/ppt-style-cards/monochrome.png differ diff --git a/ui/src/assets/ppt-style-cards/nature-green.png b/ui/src/assets/ppt-style-cards/nature-green.png new file mode 100644 index 0000000..8682ef1 Binary files /dev/null and b/ui/src/assets/ppt-style-cards/nature-green.png differ diff --git a/ui/src/assets/ppt-style-cards/systems-talk.png b/ui/src/assets/ppt-style-cards/systems-talk.png new file mode 100644 index 0000000..87706f6 Binary files /dev/null and b/ui/src/assets/ppt-style-cards/systems-talk.png differ diff --git a/ui/src/assets/ppt-style-cards/tech-dark.png b/ui/src/assets/ppt-style-cards/tech-dark.png new file mode 100644 index 0000000..1b3712c Binary files /dev/null and b/ui/src/assets/ppt-style-cards/tech-dark.png differ diff --git a/ui/src/assets/ppt-style-cards/theory-beamer.png b/ui/src/assets/ppt-style-cards/theory-beamer.png new file mode 100644 index 0000000..991054f Binary files /dev/null and b/ui/src/assets/ppt-style-cards/theory-beamer.png differ diff --git a/ui/src/assets/ppt-style-cards/warm-earth.png b/ui/src/assets/ppt-style-cards/warm-earth.png new file mode 100644 index 0000000..934efcc Binary files /dev/null and b/ui/src/assets/ppt-style-cards/warm-earth.png differ diff --git a/ui/src/components/edu/EduSideNav.vue b/ui/src/components/edu/EduSideNav.vue index 1219aa0..3750ebd 100644 --- a/ui/src/components/edu/EduSideNav.vue +++ b/ui/src/components/edu/EduSideNav.vue @@ -19,6 +19,8 @@ const ITEMS = [ // SVG 内联(U+1F4AC/U+2709 在用户 Ubuntu 浏览器均无字形 → 方框;SVG 不依赖字体) { key: 'rebuttal', label: '审稿回复助手', icon: '' }, { key: 'calendar', label: '会议与投稿日历', icon: '📅' }, + // P5 科研汇报(幻灯片图标纯 SVG,同上不依赖字体) + { key: 'ppt', label: '科研汇报助手', icon: '' }, ]; diff --git a/ui/src/components/edu/EduWorkspace.vue b/ui/src/components/edu/EduWorkspace.vue index e7a2d2a..373efce 100644 --- a/ui/src/components/edu/EduWorkspace.vue +++ b/ui/src/components/edu/EduWorkspace.vue @@ -14,6 +14,7 @@ import FormatPanel from './panels/FormatPanel.vue'; import ReformatPanel from './panels/ReformatPanel.vue'; import CalendarPanel from './panels/CalendarPanel.vue'; import RebuttalPanel from './panels/RebuttalPanel.vue'; +import PptPanel from './panels/PptPanel.vue'; const edu = useEduStore(); @@ -28,6 +29,7 @@ const panelMap = { reformat: ReformatPanel, calendar: CalendarPanel, rebuttal: RebuttalPanel, + ppt: PptPanel, }; const currentPanel = computed(() => panelMap[edu.section] || JournalsPanel); diff --git a/ui/src/components/edu/panels/PptPanel.vue b/ui/src/components/edu/panels/PptPanel.vue new file mode 100644 index 0000000..f8b0f8b --- /dev/null +++ b/ui/src/components/edu/panels/PptPanel.vue @@ -0,0 +1,578 @@ + + + +