From 7ced30fa96f0de88f60aa95d069ea7edc980e35d Mon Sep 17 00:00:00 2001 From: potaser Date: Thu, 15 Oct 2026 18:05:29 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(review):=20P5=20=E7=A7=91=E7=A0=94?= =?UTF-8?q?=E6=B1=87=E6=8A=A5=E5=8A=A9=E6=89=8B=20+=2014=20=E9=A3=8E?= =?UTF-8?q?=E6=A0=BC=20+=20=E7=9C=9F=E5=9B=BE=E5=B5=8C=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + review_service/app/config.py | 14 + review_service/app/main.py | 8 + review_service/app/routers/ppt.py | 317 +++++ review_service/app/services/ppt_build.py | 1176 +++++++++++++++++ .../app/services/ppt_figure_index.py | 264 ++++ review_service/app/services/ppt_outline.py | 917 +++++++++++++ review_service/app/services/ppt_preview.py | 230 ++++ review_service/app/services/ppt_store.py | 155 +++ .../data/ppt_styles/academic-blue.json | 31 + .../data/ppt_styles/academic-minimal.json | 31 + .../data/ppt_styles/botanic-forest.json | 31 + .../data/ppt_styles/classic-defense.json | 31 + review_service/data/ppt_styles/domestic.json | 36 + review_service/data/ppt_styles/editorial.json | 36 + .../data/ppt_styles/international.json | 36 + review_service/data/ppt_styles/jp-gothic.json | 36 + .../data/ppt_styles/monochrome.json | 31 + .../data/ppt_styles/nature-green.json | 31 + .../data/ppt_styles/systems-talk.json | 36 + review_service/data/ppt_styles/tech-dark.json | 31 + .../data/ppt_styles/theory-beamer.json | 36 + .../data/ppt_styles/warm-earth.json | 31 + review_service/tests/test_ppt_build.py | 677 ++++++++++ review_service/tests/test_ppt_figure_index.py | 138 ++ review_service/tests/test_ppt_outline.py | 673 ++++++++++ review_service/tests/test_ppt_preview.py | 73 + review_service/tests/test_ppt_router.py | 290 ++++ review_service/tests/test_ppt_store.py | 86 ++ tests/e2e/bench.spec.js | 4 +- tests/frontend/unit/b2/components.test.js | 10 +- tests/frontend/unit/b3/benchpanel.test.js | 4 +- .../unit/bioflow/benchpanel.bioflow.test.js | 4 +- tests/frontend/unit/edu/ppt.panel.test.js | 357 +++++ tests/frontend/unit/edu/ppt.store.test.js | 199 +++ tooling/requirements.lock | 3 + ui/src/api/review.js | 50 + .../assets/ppt-style-cards/academic-blue.png | Bin 0 -> 22032 bytes .../ppt-style-cards/academic-minimal.png | Bin 0 -> 20392 bytes .../assets/ppt-style-cards/botanic-forest.png | Bin 0 -> 19705 bytes .../ppt-style-cards/classic-defense.png | Bin 0 -> 20225 bytes ui/src/assets/ppt-style-cards/domestic.png | Bin 0 -> 18975 bytes ui/src/assets/ppt-style-cards/editorial.png | Bin 0 -> 22791 bytes .../assets/ppt-style-cards/international.png | Bin 0 -> 18749 bytes ui/src/assets/ppt-style-cards/jp-gothic.png | Bin 0 -> 21486 bytes ui/src/assets/ppt-style-cards/monochrome.png | Bin 0 -> 18142 bytes .../assets/ppt-style-cards/nature-green.png | Bin 0 -> 22742 bytes .../assets/ppt-style-cards/systems-talk.png | Bin 0 -> 21910 bytes ui/src/assets/ppt-style-cards/tech-dark.png | Bin 0 -> 22256 bytes .../assets/ppt-style-cards/theory-beamer.png | Bin 0 -> 21069 bytes ui/src/assets/ppt-style-cards/warm-earth.png | Bin 0 -> 22307 bytes ui/src/components/edu/EduSideNav.vue | 2 + ui/src/components/edu/EduWorkspace.vue | 2 + ui/src/components/edu/panels/PptPanel.vue | 578 ++++++++ ui/src/components/edu/panels/ppt-styles.css | 40 + ui/src/stores/edu.js | 147 ++- 56 files changed, 6874 insertions(+), 14 deletions(-) create mode 100644 review_service/app/routers/ppt.py create mode 100644 review_service/app/services/ppt_build.py create mode 100644 review_service/app/services/ppt_figure_index.py create mode 100644 review_service/app/services/ppt_outline.py create mode 100644 review_service/app/services/ppt_preview.py create mode 100644 review_service/app/services/ppt_store.py create mode 100644 review_service/data/ppt_styles/academic-blue.json create mode 100644 review_service/data/ppt_styles/academic-minimal.json create mode 100644 review_service/data/ppt_styles/botanic-forest.json create mode 100644 review_service/data/ppt_styles/classic-defense.json create mode 100644 review_service/data/ppt_styles/domestic.json create mode 100644 review_service/data/ppt_styles/editorial.json create mode 100644 review_service/data/ppt_styles/international.json create mode 100644 review_service/data/ppt_styles/jp-gothic.json create mode 100644 review_service/data/ppt_styles/monochrome.json create mode 100644 review_service/data/ppt_styles/nature-green.json create mode 100644 review_service/data/ppt_styles/systems-talk.json create mode 100644 review_service/data/ppt_styles/tech-dark.json create mode 100644 review_service/data/ppt_styles/theory-beamer.json create mode 100644 review_service/data/ppt_styles/warm-earth.json create mode 100644 review_service/tests/test_ppt_build.py create mode 100644 review_service/tests/test_ppt_figure_index.py create mode 100644 review_service/tests/test_ppt_outline.py create mode 100644 review_service/tests/test_ppt_preview.py create mode 100644 review_service/tests/test_ppt_router.py create mode 100644 review_service/tests/test_ppt_store.py create mode 100644 tests/frontend/unit/edu/ppt.panel.test.js create mode 100644 tests/frontend/unit/edu/ppt.store.test.js create mode 100644 ui/src/assets/ppt-style-cards/academic-blue.png create mode 100644 ui/src/assets/ppt-style-cards/academic-minimal.png create mode 100644 ui/src/assets/ppt-style-cards/botanic-forest.png create mode 100644 ui/src/assets/ppt-style-cards/classic-defense.png create mode 100644 ui/src/assets/ppt-style-cards/domestic.png create mode 100644 ui/src/assets/ppt-style-cards/editorial.png create mode 100644 ui/src/assets/ppt-style-cards/international.png create mode 100644 ui/src/assets/ppt-style-cards/jp-gothic.png create mode 100644 ui/src/assets/ppt-style-cards/monochrome.png create mode 100644 ui/src/assets/ppt-style-cards/nature-green.png create mode 100644 ui/src/assets/ppt-style-cards/systems-talk.png create mode 100644 ui/src/assets/ppt-style-cards/tech-dark.png create mode 100644 ui/src/assets/ppt-style-cards/theory-beamer.png create mode 100644 ui/src/assets/ppt-style-cards/warm-earth.png create mode 100644 ui/src/components/edu/panels/PptPanel.vue create mode 100644 ui/src/components/edu/panels/ppt-styles.css 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/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 0000000000000000000000000000000000000000..5e2f77eb71f47599b1b4e0d29d196c3440b6e47d GIT binary patch literal 22032 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 a9!RX;6=d?Bls1AQesS5s#lGI|TKXR(Ui!EI literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4901365febe298aca0cebcc6e792b59a00346e38 GIT binary patch literal 19705 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 Z5N!_{7`zmLV%f;IT=YIY$ zyKTpqqTw5$wIweasH*6UVhZw1z`T$;P*CYn9m%CXd1CdRuXNGn*UjF(SL8@Q@t z##KY$WO$i-d&sFLcebrLw`^H2^WTjD7BU=Z-A5xdRF;SY{c`#u>VAytXJ3g`w2qOR z?ETlWl7<%VrDZ$WoU2}Gcy$E1oZp`~``VLd(_Ad}R918Zcy$1mySX$S1Xfoq6D;RW z&bjIKOD=`V}RQ-T%LMQDC+@U#XyNX)wdR*sqDe!$97D z@&>izWY16an%SnFUx#i?lhKKe-c1(v^IkM5iKo~vAGbzbT2uIt&p7dFY> z7Y=$QAM(D3tJdn;?H1<7O^>-$v!AX>m1s+x@=C2xrNu;5i0%1Ir-eJdKK?pOh_hMx zW)g6j%=P@m^Ct(qs+gBMYoCCVd+R*6*~Rf4oYI+Jtxb0Xu9%|!Zridc6N17o^%%<9 zTV-a+)@ILdlKZQ=`m$}1lScBk%wPM~b4<+IlRaskarW=m4GpQL#~$}R2ezKPR#sT} zXzE%5&qv`@WCXS(r^-Ih_2J(8*5z!p{4Uwr9k*-Vm9Qsws?`*Eb-YWkedx*$YRKO1 zesHT`;>XMP%l@t^tJC`@em!}`-d}z>##+yG?Tj8yeyx^ayQ$J$fBWRzW74jr6StR! zoOq?2|9JD_tU6tLR%ePF;1IwdlAVJToqSujA?Z9|Kocu)KtSp4W!E;0uS%db#1Hy2h`*r(g{hACO U*>97ZXMh67)78&qol`;+0QFTN_y7O^ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b95aa0b96ba2d381a92299f2749da23f3c297f4d GIT binary patch literal 20225 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..046cc2069be8aabf9ab890aba23b9e77edc4fe68 GIT binary patch literal 18975 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 b@BU|RUnr*icGov4P{4S)`njxgN@xNAX$tMH literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1b9f2672a5f6f57cd670636b9c103677b0027fc4 GIT binary patch literal 22791 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 KS7}DKBmV=lo7Z;$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0a6f93e656093816a4f6ddf080cdf0ab7801fdac GIT binary patch literal 18749 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 nfA=Tg%B;v>CF5rhO#I-&OtDiDNFgd{+y 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..25d3a1b3d72e9b21869e7dda7bff4832e41a3316 GIT binary patch literal 18142 zcmeHvcQn`U|F;$;MUrGhl9e(uLrIdItjt2P_b9WH%1)A%tn84i>?CAm?^Q`w_Q-ZW z^gZ|a-sg9Jf8(FueeQFg+xZ+HM{nM**Y&!d&*x)ZypWqP{7E+0iB}9bnkD^pveYRKDUt2pDRbzBE@yuJH2rk+d{MNl zca2f@gqUulxa;2fC=t7}^=1lm4_e0z@xC*?Ka_Uk|9fxZ({3(&Vz|G9fPm~HzPe9< zOS(X?3t#S}!lxZ1`1H>g{{7zH7ykX;-xvP<;lD5Z`@{eEjsNP0|KoT0w?F(JzstXW ziT~2ZJAL9k<; ztGmGN&gIKq(o7LNw|xQw11E;VHucohUcP+E99maWQbI&R)pW6ICby)-`--`A&-0XoyOPg#(dYVU9&A%-@&=M(DU}~+x` zfcZav=mjikw>I=%Uh2%yInB;aPVD7LMA~7@Cti?NTwFZWUz8tSzR)vWwzY|`%gf6z zUcBf@c!1nn(qbyRM9;nR%$V26*Qm7g^vU+L(6X{JIp(#3ZbcC`Hny6tUln9!_mh%F zi+gN(_FZb)Z)R$mXWp%!rLn%gZuIpP8>R17h$iP&Vt04(4@^A{e5g7mBq;+@Mw{M?)Y~$Cjj>1v=kqz$A-R60{lQjJ1F=m5ZSq2t=W_xo6 zVjGMmS`zjfaPaZv6c$EMpNUdPYBu)s^}WtYF*`dO5)$Hi+PK=BKeBbqbs{}~G}Q3N zXYX}^u|d@|wdO#=gS66CKl8L!-{<8mtxcB=u6kJXz5bXc~<+wGj8S(87$n& zNvBJTc|JZqd-hW%w~t$uZM+nxS|!cWOm{3Qu^KG3zZ)pFnvCny;805o;lKN=)#)-3 zukBCis<6yVD`jP69UUE_O5`aazLom?k^Ol&8XOr( zid(qfM90n1QR5Qb#C8^Wa2~HA!GNKI z$IoNw-m0BGVmlGsGG-rtC2*=O<$B<8QOkki?tweI3B>nD#BTEDXr?Qtyr?m3BOmSY zXAhF5GH7UMP-!0PFLH{EdaDxBUFNa%TGYjPar~S~3lk@2CN3^lQ+|=Ehe2GtEHAIa zG`H4}CtXc8+L_2ftkASoRf91|Dd9$nit_q(m-kAnFJuywQ&bp(l2zDCc|SO`r+@gc z{HJWIq`~O0Z)Kb1Z+l^R=GIcTH3^nX_-qND1sMuIoSor0Ow4$P7QU6yP?a2N(3;xL#0X^rjgvE)@{G1#Wb@gf7^XB>r z>2fb4xi@yEy>L`$=+TnUe#@iYk}L|Z!i4M+)zTcyMvm^?P3U=RYja~`buoS}R_`%3 z0oHe*$Z2|ccBoN&t5}00v6Nu7Go3{cE0&m8HD^OAa?u!&OyP({E%~8S*sW*5RPMV`%urkmtdLXD?qa)t=4_YcMkXlih7D zCMH%F%Bjvu;g{v-=Z8zTqYZuWf{B8d?c_@D>8h@f)9ScoDub(6ukwWZZL~2^cdjep zebZDK$evc}2TA{2J(;>oi1uJ)gVf;2){xJTpp0>Rb8_dzZJuzc`0s)N9{VbV{JDOd zeos-q^T9`lCvCMQt}An|UcH*=%&Z%pJF0uPys^=0dFDHz=l6xNR_Bt}<97L0gBi)m zF%_2P=6xkD0>h^!4eTz+_OZib(P;STqr3CiR*0T(doW|OZ~yL z)y=Pu5Lb#!7hj6KyyYVqhnZm6Ux*D6t7ObYMNLgTx)#5xPcklGAock&=-B;=s*-u|O)nhYdFPy8Hu6_27U&zasXSMR` zCuzMTVzK9H3^&&PBt3sa>cx4ukS*3_|LXH8|Ihl|{m+D=6gNE#ZN z%Z%4<-#(>t$?133A!_Q>cGLK{xaEk$)i!6&oKf+dou5BU?6p3sm)72sa-Bu&XKh`b z1%ISk+PQE!N?$LY_|j5c1~T;T_wRX#uVrZzn|Hq-t_z)4z1lhP9`$DZ?ZRNGTa?0x z%XEp;M8}yPDdynj+bH~rW5yZHMv+DSPg5s>KX8o)WM~YO~G3H%m zKbU>H#%nGq`ACv6B{dZ-zs*q8s6On|CkbJ<65UL-G~5Il#@fPIMG~9A+msX&Y!vbJ znO&y}FEGr^&ZcB!sIiBv2hF5+`*GL0l-|3)C9b!}Y2%>3%MU??q}FDENk^CK*Mqgs z=4+<6bzUfA$HW=Uv*;atY+fJ6y|AxPwG@v-@s;4keC8uZjyO&A1=S5xOzzpUhd)xD zfvn4%AMH1|=Cg-m^7KxI`&(Cv7dy06CS#Ng{n9maQws`Kf)&NC%E|`IBv9x82cUYs zHf|RhyN-s=nsI{>@19+%Z+_a|v$2_dcH+{4vuLh0t{`42es&$j)EHgn@+TuRGqc&4 zFI+Ci&bLw`D$zAri-S^z?!kqac2hPdHgaPcZ%K)Zs(N~gM+9Z|7HGRq_MCfC5v!a+ z$*JqQzC7EWrhethmHesaZ`c!cGm}OEgP4O&Dt(UFXEKD=u^EeRE*q2{r$2sN%x%>^ zHLgHSz=FRw`C2?BhlY}>>SEJGj+h1?O{4D$7l~e z(arS0*u%c=dn{~6%Mx6}LFsF@#$_OvpnM};4P$|iI{5kX(GJs3kM_yS%iGst!I^{c z3>K#cqs5-md#qneQE8bxNJrAIoMZVtCejmc-CyY7%RfIm8|$t=-n2M37w2=Xzkc?J zrOLe>WFNOgM&Fy|YOZvsn{ojK6MHe9I#v1I`f244eEj-l7FNrnxUeLfqyX1bD*hjk5O;5k<-+CMKdw@7%TP z0HLQsQoND%lZq1|GAo4je+E6ufNM0S`pbNd^<|vNx?5`cn%>j%^uI@iKW@6m+ z+52!xV_4aNIE7VAcOyf?raNtgxqasRjS=U}Mw?>O2h(C=7|x&XvtVydkPT&IWIT_0 zwmOLJiC!wnc&OUO{yp$uVWB1~#kjq&Z{?>!lG&>@M}sEZA4%Mjm8~0ZPeVUaNLpKH zRRoMPsp{fwJa0GJbfT&YGfG7N^WIw92Ny1sy$UnieV<@6dalPTv-#8_dO~VS${}w_ z-=my~7rAfWzMXB<5T~8fZ4R`>96Z^ZZ-wz@FCQwC06$R|06m&)=H^uQ+`8 zFiPCy8{aT^xqX8ljew;_x>~@{!-o&00*_C=A6-b-g=r?wl%faJoj; zr@Fd{$^1b}W8;`5Rrfc1W_Rvq^)e(D77E8Ve-ZF(^%$R+pf?=m$hT6?$-I%xeExj# zqU=F>vGcb+?e5HKvb@sX)%8XFLb_U-5^LYPTNQX@bMy0q_eP>BeUHu)b@%n12eR+! z>8U>M^1EQUVZL$qm+$=puXNx=ZH%($s7 zog&8rw1P9U)%5wjld-X}V`F0!Em#78@l0tZAAf&Q>u&_PrkKztuLP_cDz{AES79%4 zUSvP#FBisDZj^09tFERdbvxI7tOW%Vsacou^UqYjY}C%>$UPSbwgO`Ux?a3^0ouaJ z$(h}~-XhCMCv1-fa*eQwAp-lnpv1a5fR5Lw{@a9>O0B{5 z_5yrtj=P+1*{{q>fthD)YdeQJFlAQ~iY~gRigd_%=N~Ju#uB zuCA`5BY(moS5q-sjPCgHtjhE4w)$_W1X4Na?J6ihK?TmldpvA-e+fHONp(|T#8co z1KhZ8_rtEPt`8qR0HXU06oQg(H$}k!`U8=#U%!6-yrr*C8;|Dt_3PT&z@EU73JPE8 zx0bJ%?nyW#ubk2= zSFhH9T0=N;;O(7sq+Y-5d#Pr4yF@lO)~>rXH#PCY*uC}K`YRf%|aM3!9LBBCxJKo+v$$$U; zJ(N=qWa~>q!-0bb6Qf>snib>D?7~{9awziT)K^-AE{OZ;naz3t)#@ z+Qv);smWzy%}mG=H&FxWddJDgVmc>QRBrkmpyB@w0;qEPXS`QNtc{J$X|=cC1T$Rz zOzDAdG(-vr1w@Lvy+-~Gr-sCZ#7O}$|}oRyy4p&S|!(f zt94k5iM$}7AYu3=N@=;`=2=qtxZByRoF;p>d!3zKwnXgA;$lKYOjgsn;qc3slu4~O zbabL3BSl=7tz(r0(P?XIWrC!0JhnD}{rc5Gmyd3Rk2B|!)kcI_e~-lNP&&*kr;%ga zY?fK#_`8dHJjp7%sy=i7FKfZ70J^o+iA=5Z4$x{m*vnEkBvD z>Xp5weuM1cZd7RjSzDv*IS`KLPpgeD>_w z$B!30u%*XM0hv#8H}5@3eWp_J$*}tPw~5)D4({lukOb|7qjU=Gud!0J?ViN$spAD@ z^sOYPqqE4;n46nZzJC1)Dg9Z>ZR#U0{0EDODV_gaowoXze-%UtW)>Cz@y&x57=vif z-<^Rf5SMz-<&PAd`tmD&_m*l*R;tWYv>v*12OB z;ai;|H74@gckW#GU&o!|X{e^PZ*Felsalsf9ooAaEm|0x_ncEJhqb@dd}rC_IYvf( zv?|x^D?~|EkpK8LQif7W+MeBCx5inh*9StfD- z6B-&>MSbgB)F4Z=YfE}UkQ!|yiH5jQpyu4>9WzlL4%!g}3R#sLN zlI(hNOiXi~D;^)e~T~F>)H(B~k5W)HMMO+)9(yV&UNE{_G%cni zOMU%?zVk`ygnB9(ZvD>~Q;#IRgM{m%cDuSXLS$}dcS9I(z$jIS5}q6^i|+VIE$VEq zp%FGgViYiB+;^0L$3iSKrxzIBHMP$U(f#4 z7~L4S7m|V|WZ#wq*{u-%UEBNKN!7zk;+B+D)mUp1c(D#yy4r-_FoI6`Es`aXcIEi)UZ8bgg(Lc1msU42|=oAM)0i51*rbLNRSt*GzJUSQg3Zjgi`m`vRPp6vjzyw^g3BT5xM2 zSM2keVSyp2b=Dnhb};!u0_bdD>HJrboevmTShiUeRu-TrAt3>5j*nuhKAe}>>(foH zrZ8(_Tc7rpmKN;a8pHQrzI;hdO%-vPVq#|A=ESGfGu~oI)Yis5ImJ~4cz*onmqlk$ z`GB}0s4^Gbwt&z0h_0Ru1^2vpisK)(;J+;)C$*yYweV0IWeW)k-bGn*b8~xnd8t^i zv$5UV+W4b2eS(1@1{{s_X3w3gSNFAFXA6gv{^UtDNSFXH1H{nnS#&c2We-84WNoS_ zXRZwc@|Zz;d%Hb-OtW#<#>i>GsIS|w63|Qh&u+H0qh~aKQhy}$EH~#r9U=og*}%+S zDqe#FOW*{yqLxO7U5fP{TiF4lBhiPsooCqgp1wY#!Q0f-aX?*)kEBnnls9V}RgzlS zLS&NU!XcTNG*AEjiJv$$Ki*g!(`o91L8l)ecKw{&^wwzPyR)m`dS)2cS zSg~nGYlD(Y`p$K+mG;YpPSY96DWDp_?NkO>zkz`P+&bX8epZ;(u94`0r)7Fr@(P^| zQTx#*$m^1f%?5#@YWPr5d85LPqL7m#Y3DT2w6M2dK&$IN8>60~-JNAXG`L%S@i{=- z>guZV-Mey8!VWXv%G1=+3&CC`Vx{HeIL@4*6STf*U=ZJLNq^3?t@z$NF)=ZiEqjOz zIxK|pDk``%3G4y+`2uBHu}ad{O+?S1PZoKZ_^0VEZRz3As#CM1-(!g=;3j==kZRfG zQExQA{cNqjeZkP+1w7)f!990_{@U;abS!eYyS-LxQHOiAJ9MdRow8N-7szQ#+1TQHfyT<1YJR zp!BJI{5BZ1QzNsp_0uzzU1}*RaBc*w20(DvmL?-XM^6TxN6BMwM}~zhgT=Fk$iz1b zqLfXKey6AVU{DZyw73|4NEunh)UR$a! zq?z-D~gY z_!`DNRPtxQ3@rK@KWa6zc+RKyIPgW5Nh@>biE6*BvpR)9a3ajik6&`RqJ&nelh&4( zw-?f5Yi-ce)3a>($9_nUw!<=-wRfRoSdN~7Rn?PVje-6yu%az`tA?!aNavSRX~=nAP#PG`~=-xS>t{NDeED7x;8k9NyYs)~$77=ftX@BPLbNq1 z@qml&*w5WE^-ChGN~zGj=*ZpDzvd6!)#lR&{~bZ_WmK z2u3Mr7Ey4M+$V5ny|$PIAZk$MR{&ZVbI}uXdigZP0Rwb^)ssIwyeP#dsfe}NrKdc`@LACjpB$|c$i zWJ(F?&{@{QF;JgBe}22}Wyp&cO&=4$7TGyCI0OX+&z$j>x>B@wW9~H5;eer0p<4U9 zunY*vWfPP;_i*%6o=1n%)zyuc@{cRGVG`H?ZmYbUo7)Be%}?6`_&n!KDa_LHBO_W( zj5J-+c`^hTE#)WdLoZbZyuSH&ZtOGin?3BhVkRJ77^YSc^Wbx|+fw+%Z58&ADRA7C zx#5LxLY=V}CPhi^t6#gujXJ=%h-~0T3s={33rYQ&l9B>m%NX>fqQW!N`ufco)l(5+ zvfUR{1?cJNtEe_)>r9#nWfGq2erhz1JGm{an-R>5$E9tFZ>=;!O9>c~3ZSiNq$_ma z5Z=Sp{xSJhI5`BQjqp3K+0LASAAw!W6K;;_4n3z)^iLluS4*HG?;fck7Zo5B4HZ?g za>_PL#4N)CH8m-fDvw6U@im)4m{L4HySu%(d&5#Czga^f_8qkH|H0OcS^1^z0?%ZQrM@ysdpP*4a^P65xc?^lgfeI*-uwq*H-F9sBaZzUQPv=mrD zckm*q`0hLlnge$JetruO+nw8S!%!+rhHnK7p;f!i{nrn$oB&V}gtz(CZ6 z@A~E03Cr9&sZCr}+c}OGFBX8@;P+Kdj@S2Bd)ymuOM$X<{m#!qhY4UfcMwNl*cL-= za1b3GQOiETAZf|KJ+M62U_G9rKHyMo*^}cv?n>?dc;~Xez`)Hv(N2;(To*>KrSe@r zY>5Z=z2eEn#s)k$yQw}QnV1uo9)q0Ne|;r{2h-Y`4P2j)a2wBMq3DYov2rn5LGWq{@(0N{?pT!zdTUAvBfpe|VW77@}n3h$^<+>X*B#*K6ZW|gZ9TwIL z9oBkn^5Izjlpmc)2Xq}vr{ya$ABKIjZp{_I%o%Bj1ZlmYs+!PjJUSbcQ;yp-{^C^T zzJYF4S64?vO>OKy+)=ix?GFdpUeF2cE8=7J!V%PMg?qeS6ZiG?ts{xhG)@aAhO=y) zXYlC{YeCk{2BYlE%;{}h9BBQmTKdVON0QKrckgZ*kv(OQt103z&Ufq6Bd`MK4Q$sF z{b+@rTYfo6C!AwHri^Ao9jeg!A;pZ~@7&gCKCH_&VB9wijDNg1GQG64bWy-WoY3=u z9-%EXkzIr&8Xv5`!~6ixH+)jT4TBSqD2h^Gr1Mm=#gt!H6@3A=_O?U9h-Mh<=574}J z9#3(Y=txLPny?oh7a7q~TA4L#qmfyZNNVjaJQ>sQiI0k;ZRzVB%;uQuVzxi`Mt!K% zk7Z$Pt*u%3bS+-$o{I~fRF+2HuMcKR9!2Il^wX@Az9u`fv|QfnX2LKAkx@=rfKcJO zxiXTeUGN@~hn}8ZXJs~(0URJ1S=snJqTrgJ)6-kP#7zofFf3)CI@fZFBSd(YBPsXSNLUdtDaC7q|7; zU)qH_L0Wp5CK=XH1+?;2fTLZUogY7Xq@;e@fs~Y*?e8eOpy;z_WDhQI+5GriTU!gg0h7WGSP?3c zzgikZD>LZObO))+gApV!I5kUPd}BzNOR zH{cuAAuTNp?jlw3Okg`D^LocUf+tV{&u++GX`dPy{|CJ}mGWDedKKO)(=Lq9`xfQU_aQ^b;` zGQYfOo>eRGqJdwy9J6M^^T#%SEh}Qtx#{wgk4$+yUt^ZMdNdK}ixnb8-O!AieNLP9Jx|YYSp5JMNe{;7VD~K;0`=43f z|9g_D|0*;3@1|`3dxOB=1%BBJdkp2^+}KB>rh>Y3aU*>x_WgvrFUOZf>58 zZnIjyxa(tu#Qn4ikE`VScRvis>|7@-Yq(|q%7V6fc5tO;m&-E?_YRxQLKizh+O4ev zY}Eym#Y6e=E!O?JwrYq<;|MqWCQ>g3e|k;YHOiuh9P{4e+DT2{{5Pg(zlV3 z(CBCn&_q~87%~Avzyk9_f#RT_NJ)U>3!go!a5i-0=TBN>e@>iuaCQnX4noisSJ&ch z^G$db1_lO5bOER!Qc6xvPGtbb znV)!h}66Id?$>C@sXKZAj; z&~XuMe0BbwGGoxA$B#GP3nyG)c-L(%6@QH}XoeHf%7w_2l)gICq)(; z8C=>2hl!WkXEAvBBmE!hoek~m=!j_e1xk+uLn-L6aMaYy_X=<+C_naYNjDLsGBGxm zmY4Sr4E(dWD6gpK@iXYk()6IX^MbK&B~mlSk=Wg^=&&vD=DHuOZ+-jt0Gigexxbyz zWP&HT%{9Eeu|vN!$6v7;C{Dr5cisTfL6$)%0AUXIq;^v+qA;v6ulh2z1*ApHJ!q_Z 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 OS1&6_W=Pz8_&)%%J)8*u literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8682ef154525d49cd14a096e0897fa0fd5a317f3 GIT binary patch literal 22742 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..87706f615c63f50a5f10fb8370b55be3fddc8b4d GIT binary patch literal 21910 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1b3712cde25fd7b7e3d2cd63a5e03f6923e56853 GIT binary patch literal 22256 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 M`4uyYsmr~;1Jf6%ga7~l literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..991054f16ef4f7aa58f5a40525bbdb6ecbe3437a GIT binary patch literal 21069 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' }, { 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 @@ + + + +