Merge pull request 'feat(review): P5 科研汇报助手(论文 → PPT 大纲 → PPTX)' (#5) from wjq/PlantAI-rag-new:feature/p5-ppt-assistant into main

Reviewed-on: http://8.163.92.8:3000/admin/PlantAI-rag-new/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
2026-10-15 20:19:30 +08:00
57 changed files with 7005 additions and 14 deletions
+6
View File
@@ -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
@@ -0,0 +1,131 @@
# P5 科研汇报助手 — PR 报告(2026-10-14,v4 更新 2026-10-15)
## 概要
新增「科研汇报助手」(P5):论文 PDF/粘贴文本 → vstore 解析 → map-reduce LLM 大纲
(5 场景 × 4 时长 × **14 版式**,逐条证据可溯)→ 逐页编辑 → python-pptx 本地生成
16:9 PPTX 初稿(论文真图卡片化嵌入、分隔页主副标题、讲者备注携带证据引用)。
全程只新增 `review_service/` 内代码 + BFF 既有 `REVIEW_ENABLED` 条件反代,
禁区(RAG 核心、既有服务、前端 router/db)零改动。
**v4 迭代(2026-10-15)**:① 副标题风格数改为动态读取 `GET /ppt/styles`(缺数据回落
「多风格·图文混排」)② 章节分隔页加副标题(prompt 强制 `subtitle`、渲染 40pt 主标题 +
20pt muted 副标题 + 底部 accent 装饰线、编辑器可改)③ 论文白底原图与页面底色融合
(surface_2 圆角卡片 + 图注 + 宽/高上限 + 单图页居中留白)。
## 修改内容
### 后端(`review_service/`)
| 类别 | 文件 | 说明 |
| --- | --- | --- |
| 改 | `app/config.py` | +`ppt_jobs_dir` / `ppt_styles_dir` 及 abs 属性(+14 行) |
| 改 | `app/main.py` | 导入 ppt 路由、lifespan `ppt_ensure_table()`、挂载(+8 行) |
| 改 | `tooling/requirements.lock` | **+3 行**:`pillow==12.3.0` / `python-pptx==1.0.2` / `xlsxwriter==3.2.9`(三者均已实装于统一 venv) |
| 改 | `.gitignore` | +9 行:`ppt_jobs/` 运行数据、`prewarm.log`、`data/compile_jobs/`、`scripts/`、`data/review.db` |
| 新 | `app/routers/ppt.py`(317 行) | **9 端点**:`GET /styles`、`POST /jobs`、`POST /jobs/pdf`、`GET/PATCH /jobs/{id}`、`POST /jobs/{id}/build`、`GET /jobs/{id}/pptx`、`GET /jobs/{id}/preview/{page}`、`DELETE /jobs/{id}` |
| 新 | `app/services/ppt_outline.py`(917 行) | map-reduce 大纲核心(索引/配额/重验/裁剪/膨胀回修/分隔页 subtitle 规则) |
| 新 | `app/services/ppt_build.py`(1176 行) | python-pptx 生成器(14 版式 token、字体栈、真图卡片容器、分隔页主副标题、讲者备注) |
| 新 | `app/services/ppt_figure_index.py`(264 行) | 论文真图索引(para/图号 → 图片落盘与状态) |
| 新 | `app/services/ppt_preview.py`(230 行) | 逐页 PNG 预览(供面板缩略图,失败不阻断) |
| 新 | `app/services/ppt_store.py`(155 行) | `ppt_jobs` SQLite 持久化(约定 DDL 逐字实现) |
| 新 | `data/ppt_styles/*.json`(14 个) | 版式 token 源码资产(随 git 走,`GET /styles` 的唯一数据源) |
| 新 | `tests/test_ppt_*.py`(6 个 / 102 例) | outline / build / figure_index / preview / router / store |
### 前端(`ui/` + `tests/`)
| 类别 | 文件 | 说明 |
| --- | --- | --- |
| 改 | `ui/src/api/review.js`(+50 行) | PPT API 段 + `pptStyles()` |
| 改 | `ui/src/stores/edu.js`(+147 行) | `ppt` 状态块 + actions + `pptLoadStyles` + `SECTIONS` 增列 |
| 改 | `ui/src/components/edu/EduSideNav.vue` / `EduWorkspace.vue` | 菜单项 + PptPanel 注册(各 +2 行) |
| 新 | `ui/src/components/edu/panels/PptPanel.vue`(578 行) | 生成表单 + 编辑器(动态风格数、分隔页副标题输入、逐页预览) |
| 新 | `ui/src/components/edu/panels/ppt-styles.css`(40 行) | 面板样式 |
| 新 | `ui/src/assets/ppt-style-cards/*.png`(14 个,约 21KB/个) | 版式卡面截图 |
| 新 | `tests/frontend/unit/edu/ppt.panel.test.js`(12 例)/ `ppt.store.test.js`(7 例) | P5 前端单测 |
| 改 | `tests/frontend/unit/b2/components.test.js`、`b3/benchpanel.test.js`、`bioflow/benchpanel.bioflow.test.js`、`tests/e2e/bench.spec.js` | **预存失败清零**:① tab 文案「提取任务」→「上传文献」(对齐 `51532a3`)② 模式 pill 断言改为「已随 `43a7c6c` 移除」 |
**依赖偏差声明**:`python-pptx==1.0.2`(PyPI 最高 1.0.2;需求中 1.1.2 实为
python-docx 版本,系记混,已在实现时更正)。`xlsxwriter==3.2.9` 当前无代码直接引用,
属统一 venv `pip freeze` 的真实记录,一并同步锁文件——**合并前仍需人工确认这 3 行锁变更**。
**DDL**(与约定逐字一致):
```sql
ppt_jobs(id, status /*outline_ready|building|done|error*/, source_kind, filename,
raw_text, scenario, minutes, style_id, pages_json, pptx_path,
warnings_json, created_at, updated_at)
```
**API 前缀** `/api/review/ppt`(9 端点;业务错误一律 200 信封
`{ok:false, error:{code,message,degraded}}`;二进制下载缺失时回同款信封 JSON)。
### 关键渲染约定(v4)
- **分隔页**:`section_bg` 底(缺则 `surface_2`→`cover_bg`→`bg`),40pt 居中粗体主标题 +
20pt muted 副标题(`bullets[0]` → `subtitle` → 该章第一页 title 三级兜底,浅底自动换深字)+
底部 30% 宽 accent 短线。
- **真图卡片**:`surface_2` 圆角矩形(8px)容器 + 5mm 内边距,图居中、图注
`Fig N(论文 S?P?)` 10–12pt muted、与图区留 3mm;混排页图区 ≤55% 页宽且 ≤60% 页高,
单独图页居中、上下留白 ≥5%。
## 测试结果(2026-10-15)
```
pytest(review_service 全库): 215 passed in 13.7s (P5 新增 102 → 113 + 102)
vitest edu(tests/frontend/unit/edu): 43 passed (P5 新增 19:panel 12 + store 7)
vitest 全量(tests/frontend/unit): 32 files / 421 passed / 0 failed
—— 修掉 b2/b3/bioflow 共 5 个预存失败(文案改名 + 模式按钮移除)
npm run build: ✓ built(chunk > 500kB 警告为预存)
版式目检: 3 风格 × 10 页预览 PNG 渲染核对(分隔页主副标题/对比度回退、
混排图卡 ≤55%、单独图页居中留白)均符合预期
```
## 真实 e2e(v1 阶段记录:官方 MDPI 模板实例 PDF,16813 字符,真实 DeepSeek)
```
PDF 模式创建: 21.77s → 12 页(目标 [11,14])warnings=0 llm_calls=7 unverified_evidence=0
PATCH 编辑: 改写要点 + 新增无证据行 → evidence 同长重验、结构清洗 ✓
build: 0.13s → 12 slides / 64157 B / 16:9(13.33×7.50in)逐页讲者备注 ✓
python-pptx 回读: 12 页,S3 首条 = 人工改写文本 ✓
编辑后下载: ok=false NOT_FOUND(盘上旧文件存在但拒发,pptx_path 清空生效)✓
text 模式: 15.0s → 9 页(目标 [8,10])group_meeting / 10min / botanic-forest ✓
短文本: 200 信封 VALIDATION「正文过短(<300 字),无法生成汇报大纲」✓
清理: 双 DELETE → ppt_jobs 0 行、产物目录空 ✓
```
## 部署验证步骤
1. 依赖:同步 `tooling/requirements.lock`(+3 行)并安装到统一 venv。
2. 重启服务:`cd review_service && bash restart.sh`(8100)。
3. 健康检查:`curl http://127.0.0.1:8100/health` → `status=ok`、`vstore connected=true`。
4. 路由:`curl .../openapi.json` 确认 9 个 `/api/review/ppt/*` 路径在列(含 `/styles`)。
5. BFF:`sdk/.env` 确认 `REVIEW_ENABLED=1`,走 8090 反代冒烟
`POST /api/review/ppt/jobs`(短文本应得 200 VALIDATION 信封,不打 LLM)。
6. 真实链路:上传一篇论文 PDF → 出大纲 → 编辑分隔页副标题并保存 → build → 下载 PPTX →
用 python-pptx 核对页数、16:9 尺寸与分隔页副标题文本。
7. 回归:`cd review_service && <venv>/bin/python -m pytest -q`;
`cd ui && npx vitest run` + `npm run build`。
## 部署决策
**建议:只合并,下次批量部署。** 本次改动对存量服务零影响(纯新增模块 +
开关化反代),无数据迁移、无 schema 破坏性变更;单点重启 8100 即可生效,
不值得为它单独发版,随下一次批量部署一并上即可。
## 风险与回滚
| 风险 | 影响 | 缓解 / 回滚 |
| --- | --- | --- |
| `python-pptx==1.0.2` 版本与需求(1.1.2)不一致 | 依赖锁未按原文落库 | 已声明偏差,合并前人工确认 3 行锁变更;如需回退直接还原 lock |
| `xlsxwriter==3.2.9` 无代码引用 | 锁文件带入未用依赖 | venv 实况记录,可保留;若要求最小化可单独剔除该行 |
| LLM 大纲偶发超时/无效 JSON | 创建接口报 `LLM_ERROR`/`LLM_INVALID` | 已分片 reduce + 单次膨胀回修 + 警告降级;前端可重试,无脏数据落库 |
| quote 反查失败残留 evidence(2 条) | 个别要点 `unverified` | **Phase 2**:最长公共子串(≥20 字符)匹配补 `para_id`,失败保持 unverified(边际价值低,v4 不做) |
| 编辑后旧 PPTX 残留盘上 | 无(`pptx_path` 清空后拒发,仅占磁盘) | 删除 job 时 `rmtree` 一并清理 |
| 回滚 | — | 反向还原 12 个已跟踪文件改动 + 删除 44 个新增文件(后端 26 + 前端 18)即可;`ppt_jobs` 表与产物目录可留可删,不被其他模块引用 |
## 与 `feature/p3b-compile-ui#4f9c077` 的关系
v1 版报告曾以同一路径提交于该分支(`docs: add P5 PR report`),内容停留在 3 版式 /
7 端点 / 208 pytest 阶段。本文件为 v4 权威版本;若两分支先后合入,同路径会产生一次
文本冲突,取本文件(v4)内容即可。
+14
View File
@@ -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)
+8
View File
@@ -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
+317
View File
@@ -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})
File diff suppressed because it is too large Load Diff
@@ -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
+917
View File
@@ -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
+230
View File
@@ -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 []
+155
View File
@@ -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 ""
@@ -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 }
}
@@ -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 }
}
@@ -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 }
}
@@ -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 }
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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 }
}
@@ -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 }
}
@@ -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
}
}
@@ -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 }
}
@@ -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
}
}
@@ -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 }
}
+677
View File
@@ -0,0 +1,677 @@
"""tests/test_ppt_build.py —— P5 python-pptx 初稿生成(本地 CPU,无网络)。
覆盖:三风格可用、版式分布(封面/内容/图占位)、notes 承载 evidence、
多值字体栈首字体 latin+ea 双写、风格文件缺失信封码。
运行: cd review_service && <venv>/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
@@ -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 && <venv>/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
+673
View File
@@ -0,0 +1,673 @@
"""tests/test_ppt_outline.py —— P5 大纲服务离线单测(纯函数 + mock LLM 管线)。
覆盖:决策 2 页数表、段落索引、coerce 清洗、拆页/裁页、证据程序化校验
(含 S4-P1-Fig2 图引用)、map-reduce 编排、薄弱页扩充重试、错误信封码。
运行: cd review_service && <venv>/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
+73
View File
@@ -0,0 +1,73 @@
"""tests/test_ppt_preview.py —— P5 可感知化:pptx → PNG 页预览(PIL)。
覆盖:真 3 页 pptx → 3 张 1280×720 PNG、缺文件/坏文件降级 []、
重建页数变少不留 slide_*.png 残影、单页渲染失败不中断(原子写不半图)。
运行: cd review_service && <venv>/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"]
+290
View File
@@ -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 && <venv>/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"
+86
View File
@@ -0,0 +1,86 @@
"""tests/test_ppt_store.py —— P5 SQLite job 存取(隔离 tmp review.db)。
运行: cd review_service && <venv>/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
+2 -2
View File
@@ -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');
+4 -6
View File
@@ -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 () => {
+2 -2
View File
@@ -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);
@@ -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('生信档案');
});
+357
View File
@@ -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 张');
});
});
+199
View File
@@ -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');
});
});
+3
View File
@@ -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
+50
View File
@@ -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 下载直链(<a href>;FileResponse 附件下载,非信封,缺失时回信封 JSON) */
export function pptDownloadUrl(id) {
return `${PPT}/jobs/${id}/pptx`;
}
/** 第 page 页缩略图直链(<img src>;PNG。缺失时回信封 JSON → img onerror 隐藏) */
export function pptPreviewUrl(id, page) {
return `${PPT}/jobs/${id}/preview/${page}`;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+2
View File
@@ -19,6 +19,8 @@ const ITEMS = [
// SVG 内联(U+1F4AC/U+2709 在用户 Ubuntu 浏览器均无字形 → 方框;SVG 不依赖字体)
{ key: 'rebuttal', label: '审稿回复助手', icon: '<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor"><path d="M2 2h12v9H6l-4 3z"/></svg>' },
{ key: 'calendar', label: '会议与投稿日历', icon: '📅' },
// P5 科研汇报(幻灯片图标纯 SVG,同上不依赖字体)
{ key: 'ppt', label: '科研汇报助手', icon: '<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor"><path d="M2 2h12v8H2V2zm1 2v4h10V4H3zm2 6h4l1 3H4l1-3z"/></svg>' },
];
</script>
+2
View File
@@ -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);
+578
View File
@@ -0,0 +1,578 @@
<!-- PptPanel.vue —— 科研汇报助手(P5,/api/review/ppt job 模式)。
流程:① 选 PDF/粘贴文本 + 场景/时长/风格 → 生成大纲(同步长调用)
→ ② 逐页编辑(bullets/讲稿;evidence 徽标只读展示)→ 保存(重验)
→ ③ 生成 PPTX(python-pptx 本地生成,多风格版式 + 逐页缩略图预览)
→ 下载;可删除/重新生成。
状态机:edu.ppt.status = idle|loading|editing|building|done|error;
本地 localPages 副本 + dirty 标记,保存后以后端返回为权威(evidence 重验)。
预览:build 返回 meta.previews → 缩略图条 + 大图(preview/{page}.png 直链);
旧响应无 previews / 编辑后 pptx 失效 → 区块隐藏。 -->
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useEduStore } from '../../../stores/edu.js';
import { pptDownloadUrl, pptPreviewUrl } from '../../../api/review.js';
import './ppt-styles.css';
const edu = useEduStore();
const ppt = computed(() => edu.ppt);
const job = computed(() => edu.ppt.job);
const SCENARIOS = [
{ key: 'group_meeting', label: '组会汇报' },
{ key: 'proposal', label: '开题报告' },
{ key: 'conclusion_report', label: '结题汇报' },
{ key: 'defense', label: '学位答辩' },
{ key: 'conference_talk', label: '学术会议' },
];
const MINUTES = [
{ v: 10, pages: '8-10 页' },
{ v: 15, pages: '11-14 页' },
{ v: 20, pages: '15-18 页' },
{ v: 30, pages: '22-26 页' },
];
const STYLES = [
{ id: 'academic-minimal', label: '学术极简', desc: '白底蓝黑 · 投屏通用' },
{ id: 'botanic-forest', label: '植研绿林', desc: '植物绿系 · 所内汇报' },
{ id: 'classic-defense', label: '经典答辩', desc: '深蓝衬线 · 正式答辩' },
{ id: 'academic-blue', label: '学术蓝', desc: '经典蓝白 · 答辩常用' },
{ id: 'nature-green', label: '自然绿', desc: '自然生态绿 · 植物/生态领域' },
{ id: 'tech-dark', label: '深色科技', desc: '深色投影 · AI/工程领域' },
{ id: 'monochrome', label: '极简黑白', desc: '黑白灰 · 正式严肃' },
{ id: 'warm-earth', label: '暖色土系', desc: '暖土色系 · 农业领域' },
{ id: 'domestic', label: '国内组会', desc: '深蓝红强调 · 国内组会 / 学术报告' },
{ id: 'international', label: '国际会议', desc: '白底红点 · 海外组会 / 国际会议' },
{ id: 'systems-talk', label: '系统顶会', desc: '深蓝橙标 · 系统顶会报告' },
{ id: 'theory-beamer', label: '理论报告', desc: '灰绿橙点 · Metropolis 理论风' },
{ id: 'jp-gothic', label: '日韩发表', desc: '白底蓝黄贴纸 · 日韩技术发表' },
{ id: 'editorial', label: '杂志插画', desc: '米底紫衬线 · 插画杂志风' },
];
const TYPE_LABELS = {
title: '封面', outline: '目录', section: '分隔', background: '背景', method: '方法',
result: '结果', conclusion: '结论', reference: '参考文献',
};
/** 风格卡封面(assets/ppt-style-cards/*.png:真实 build+PIL 渲染的各风格封面) */
function styleCover(id) {
return new URL(`../../../assets/ppt-style-cards/${id}.png`, import.meta.url).href;
}
/* —— 改进 1:副标题风格数动态(后端 data/ppt_styles 实际数量,不再写死 8/14) —— */
onMounted(() => {
edu.pptLoadStyles();
});
/** 副标题括号文案:有清单 →「{n} 风格·图文混排」;接口未取到 →「多风格·图文混排」 */
const styleTag = computed(() => {
const items = edu.pptStyles;
const n = Array.isArray(items) ? items.length : 0;
return n > 0 ? `${n} 风格·图文混排` : '多风格·图文混排';
});
/** 风格卡数据:优先后端清单(与副标题数量同源),未取到回落本地 STYLES 常量 */
const styleCards = computed(() => {
const items = Array.isArray(edu.pptStyles) ? edu.pptStyles : [];
if (!items.length) return STYLES;
return items.map((s) => ({
id: s.id,
label: s.label || s.display_name || s.id,
desc: s.description || '',
}));
});
/* —— 本地编辑副本(后端 job 为权威;生成/保存/构建后重新同步) —— */
const localPages = ref([]);
const dirty = ref(false);
watch(
job,
(j) => {
const pages = j ? JSON.parse(JSON.stringify(j.pages || [])) : [];
pages.forEach((p) => {
p.bullets = p.bullets || [];
p.evidence = p.evidence || [];
while (p.evidence.length < p.bullets.length) p.evidence.push(null);
p.evidence.length = p.bullets.length;
p.suggested_figures = p.suggested_figures || [];
});
localPages.value = pages;
dirty.value = false;
},
{ immediate: true },
);
function touch() {
dirty.value = true;
}
function evOf(page, i) {
return (page.evidence && page.evidence[i]) || null;
}
function badgeClass(page, i) {
const ev = evOf(page, i);
if (!ev) return 'ev-none';
return ev.status === 'verified' ? 'ev-ok' : 'ev-bad';
}
function badgeTitle(page, i) {
const ev = evOf(page, i);
if (!ev) return '无来源';
const q = String(ev.quote || '').slice(0, 80);
return `${ev.status === 'verified' ? '已核实' : '未核实'} ${ev.para_id || ''}:${q}`;
}
function removeBullet(pi, bi) {
const p = localPages.value[pi];
p.bullets.splice(bi, 1);
if (p.evidence) p.evidence.splice(bi, 1);
touch();
}
function addBullet(pi) {
const p = localPages.value[pi];
p.bullets.push('');
p.evidence = p.evidence || [];
p.evidence.push(null);
touch();
}
// 分隔页副标题:后端 coerce 以 bullets[0] 优先、subtitle 兜底,
// 编辑器同写两字段保持一致;清空 → 回落该章第一页标题
function onSectionSubtitle(pi, e) {
const p = localPages.value[pi];
const v = e.target.value;
p.bullets = v ? [v] : [];
p.subtitle = v;
touch();
}
/* —— 动作 —— */
async function onGenerate() {
await edu.pptGenerate();
}
async function onSave() {
const saved = await edu.pptSave(JSON.parse(JSON.stringify(localPages.value)));
if (saved) dirty.value = false;
return !!saved;
}
async function onBuild() {
if (dirty.value) {
const saved = await onSave();
if (!saved) return;
}
await edu.pptBuild();
}
function onFileChange(e) {
edu.ppt.form.file = (e.target.files && e.target.files[0]) || null;
}
function onReset() {
if (dirty.value && !window.confirm('有未保存的修改,放弃并重新生成?')) return;
edu.clearPpt();
}
async function onDelete() {
if (!window.confirm('删除该任务(大纲与 PPTX 一并清除)?')) return;
await edu.pptDelete();
}
const isBusy = computed(() => ['loading', 'building'].includes(ppt.value.status));
const statusLabel = computed(() => ({
idle: '待生成', loading: '生成大纲中…', editing: '编辑中',
building: '生成 PPT 中…', done: '已生成', error: '出错',
}[ppt.value.status] || ppt.value.status));
const pageTarget = computed(() => {
const m = MINUTES.find((x) => x.v === ppt.value.form.minutes);
return m ? m.pages : '';
});
/* —— 可感知化(3.x):meta 露出 + 逐页缩略图预览 —— */
/** createMeta.figures_extracted(PDF 原图提取数;text 模式无此键 → 0) */
const figsExtracted = computed(() => {
const n = ppt.value.createMeta && ppt.value.createMeta.figures_extracted;
return typeof n === 'number' ? n : 0;
});
/** evidence 核实比例(本地副本实时算,保存/重验后自动刷新) */
const evStats = computed(() => {
let ok = 0;
let total = 0;
for (const p of localPages.value) {
for (const ev of p.evidence || []) {
if (!ev) continue;
total += 1;
if (ev.status === 'verified') ok += 1;
}
}
return { ok, total };
});
/** 缩略图条:仅最近一次 build 返回了 previews 且 pptx 就绪才渲染 */
const previewCount = computed(() => {
const m = ppt.value.buildMeta;
return job.value && job.value.pptx_ready && m && m.previews > 0 ? m.previews : 0;
});
const activePreview = ref(1);
const failedPreviews = ref(new Set());
const previewPages = computed(() => {
const out = [];
for (let i = 1; i <= previewCount.value; i += 1) {
if (!failedPreviews.value.has(i)) out.push(i);
}
return out;
});
function onPreviewError(page) {
failedPreviews.value = new Set(failedPreviews.value).add(page);
if (activePreview.value === page) {
activePreview.value = previewPages.value[0] || 1;
}
}
function previewSrc(page) {
return pptPreviewUrl(job.value.id, page);
}
// job/重建变化 → 回到第 1 页、清失败缓存(新 build 会覆盖同名 PNG)
watch(
() => [job.value && job.value.id, ppt.value.buildMeta],
() => {
activePreview.value = 1;
failedPreviews.value = new Set();
},
{ deep: false },
);
</script>
<template>
<section class="ppt-root">
<header class="ppt-head">
<div>
<h2 class="ppt-title">科研汇报助手</h2>
<p class="ppt-sub">论文 PDF → 证据可溯的大纲 → 逐页编辑 → PPTX 初稿 + 逐页页面预览({{ styleTag }})</p>
</div>
<span v-if="job" class="ppt-status" :class="`st-${ppt.status}`" data-test="status-chip">
{{ statusLabel }}
</span>
</header>
<!-- ============ ① 生成表单(无 job) ============ -->
<div v-if="!job" class="ppt-form" data-test="ppt-form">
<div class="ppt-field">
<label class="ppt-label">输入方式</label>
<div class="ppt-seg">
<button type="button" :class="{ on: ppt.form.mode === 'pdf' }"
data-test="mode-pdf" @click="ppt.form.mode = 'pdf'">上传 PDF</button>
<button type="button" :class="{ on: ppt.form.mode === 'text' }"
data-test="mode-text" @click="ppt.form.mode = 'text'">粘贴文本</button>
</div>
</div>
<div v-if="ppt.form.mode === 'pdf'" class="ppt-field">
<label class="ppt-label">论文 PDF(vstore 解析,≤50MB)</label>
<input type="file" accept=".pdf,application/pdf" data-test="file-input" @change="onFileChange" />
<span v-if="ppt.form.file" class="ppt-file">{{ ppt.form.file.name }}</span>
</div>
<div v-else class="ppt-field">
<label class="ppt-label">论文正文(≥300 字)</label>
<textarea v-model="ppt.form.text" rows="6" data-test="text-input"
placeholder="粘贴论文纯文本(标题/摘要/正文/参考文献)" />
</div>
<div class="ppt-field">
<label class="ppt-label">汇报场景</label>
<select v-model="ppt.form.scenario" data-test="scenario-select">
<option v-for="s in SCENARIOS" :key="s.key" :value="s.key">{{ s.label }}</option>
</select>
</div>
<div class="ppt-field">
<label class="ppt-label">汇报时长</label>
<div class="ppt-seg">
<button v-for="m in MINUTES" :key="m.v" type="button"
:class="{ on: ppt.form.minutes === m.v }"
:data-test="`minutes-${m.v}`"
@click="ppt.form.minutes = m.v">
{{ m.v }} 分钟<span class="min-pages">{{ m.pages }}</span>
</button>
</div>
</div>
<div class="ppt-field">
<label class="ppt-label">风格模板(卡面为该风格真实版式封面)</label>
<div class="ppt-styles">
<button v-for="s in styleCards" :key="s.id" type="button"
class="ppt-style-card" :class="[s.id, { on: ppt.form.styleId === s.id }]"
:data-test="`style-${s.id}`" @click="ppt.form.styleId = s.id">
<img class="ppt-style-cover" :src="styleCover(s.id)" :alt="`${s.label} 版式封面`"
:data-test="`cover-${s.id}`" />
<span class="ppt-style-meta">
<span class="ppt-style-name">{{ s.label }}</span>
<span class="ppt-style-desc">{{ s.desc }}</span>
</span>
</button>
</div>
</div>
<p v-if="ppt.status === 'error' && ppt.error" class="ppt-error" data-test="form-error">
{{ ppt.error }}
</p>
<div class="ppt-actions">
<button type="button" class="ppt-primary" data-test="generate"
:disabled="isBusy" @click="onGenerate">
{{ ppt.status === 'loading' ? '大纲生成中(约 1-3 分钟)…' : '生成大纲' }}
</button>
<span class="ppt-hint">目标 {{ pageTarget }}(超上限自动裁剪,不足不凑页)</span>
</div>
</div>
<!-- ============ ② 编辑器(有 job) ============ -->
<div v-else class="ppt-editor" data-test="ppt-editor">
<div class="ppt-toolbar">
<span class="ppt-chip">{{ job.scenario }} · {{ job.minutes }} 分钟</span>
<span class="ppt-chip">{{ job.style_id }}</span>
<span class="ppt-chip" data-test="page-count">{{ localPages.length }} 页</span>
<span v-if="ppt.createMeta && ppt.createMeta.target_pages" class="ppt-chip">
目标 {{ ppt.createMeta.target_pages[0] }}-{{ ppt.createMeta.target_pages[1] }} 页
</span>
<span v-if="figsExtracted" class="ppt-chip" data-test="figs-chip">
提取原图 {{ figsExtracted }} 张
</span>
<span v-if="evStats.total" class="ppt-chip" data-test="ev-stats">
证据 {{ evStats.ok }}/{{ evStats.total }} 已核实
</span>
<span class="ppt-spacer" />
<button type="button" data-test="save" :disabled="!dirty || ppt.saving"
:title="dirty ? '保存编辑' : '无修改'" @click="onSave">
{{ ppt.saving ? '保存中…' : dirty ? '保存' : '已保存' }}
</button>
<button type="button" class="ppt-primary" data-test="build"
:disabled="isBusy" @click="onBuild">
{{ ppt.status === 'building' ? '生成中…' : '生成 PPT' }}
</button>
<a v-if="job.pptx_ready" class="ppt-dl" data-test="download"
:href="pptDownloadUrl(job.id)" download>下载 PPTX</a>
<button type="button" data-test="regenerate" :disabled="isBusy" @click="onReset">重新生成</button>
<button type="button" class="ppt-danger" data-test="delete" :disabled="isBusy" @click="onDelete">删除</button>
</div>
<p v-if="ppt.error && (ppt.status === 'error' || ppt.saving)" class="ppt-error" data-test="editor-error">
{{ ppt.error }}
</p>
<p v-if="dirty" class="ppt-dirty" data-test="dirty-hint">有未保存修改(生成 PPT 前会自动保存)</p>
<details v-if="job.warnings && job.warnings.length" class="ppt-warnings" data-test="warnings" open>
<summary>生成提示({{ job.warnings.length }})</summary>
<ul>
<li v-for="(w, i) in job.warnings" :key="i">{{ w }}</li>
</ul>
</details>
<!-- 3.x 页面预览:build 后逐页缩略图(preview/{page}.png 直链,失败单张隐藏) -->
<div v-if="previewCount" class="ppt-previews" data-test="preview-wrap">
<div class="ppt-preview-main-wrap">
<img class="ppt-preview-main" :src="previewSrc(activePreview)"
:data-test="`preview-main-${activePreview}`" :alt="`第 ${activePreview} 页预览`"
@error="onPreviewError(activePreview)" />
</div>
<div class="ppt-preview-thumbs" data-test="preview-strip">
<button v-for="p in previewPages" :key="p" type="button"
class="ppt-preview-thumb" :class="{ on: p === activePreview }"
:data-test="`thumb-${p}`" :title="`第 ${p} 页`"
@click="activePreview = p">
<img :src="previewSrc(p)" :alt="`第 ${p} 页`" loading="lazy" @error.stop="onPreviewError(p)" />
</button>
</div>
</div>
<ol class="ppt-pages">
<li v-for="(page, pi) in localPages" :key="pi" class="ppt-page" :class="`tp-${page.type}`"
data-test="page-card">
<div class="ppt-page-head">
<span class="ppt-page-no">{{ page.slide_number }}</span>
<span class="ppt-type" data-test="page-type">{{ TYPE_LABELS[page.type] || page.type }}</span>
<input v-model="page.title" class="ppt-page-title" data-test="page-title"
placeholder="页标题" @input="touch" />
</div>
<!-- 分隔页只暴露副标题(渲染端 40pt 主标题 + 20pt 副标题,见 ppt_build._add_section_page) -->
<div v-if="page.type === 'section'" class="ppt-sub-row">
<input :value="page.bullets[0] || page.subtitle || ''" class="ppt-page-subtitle"
data-test="page-subtitle" placeholder="副标题(默认取该章第一页标题)"
@input="onSectionSubtitle(pi, $event)" />
</div>
<template v-else>
<ul class="ppt-bullets">
<li v-for="(b, bi) in page.bullets" :key="bi" class="ppt-bullet-row">
<span class="ppt-ev" :class="badgeClass(page, bi)" data-test="ev-badge"
:title="badgeTitle(page, bi)" />
<input v-model="page.bullets[bi]" class="ppt-bullet-input" :data-test="`bullet-${pi}-${bi}`"
placeholder="要点" @input="touch" />
<button type="button" class="ppt-x" :data-test="`rm-${pi}-${bi}`"
title="删除要点" @click="removeBullet(pi, bi)">×</button>
</li>
</ul>
<button type="button" class="ppt-add" :data-test="`add-${pi}`" @click="addBullet(pi)">+ 要点</button>
</template>
<div v-if="page.suggested_figures && page.suggested_figures.length" class="ppt-figs">
<span v-for="(f, fi) in page.suggested_figures" :key="fi" class="ppt-fig"
:class="f.status === 'verified' ? 'ev-ok' : 'ev-bad'"
:data-test="`fig-${pi}-${fi}`"
:title="`论文真实图引用 ${f.para_id}(${f.status === 'verified' ? '已核实' : '未核实'})`">
{{ f.label || '图' }}(论文 {{ f.para_id }})
</span>
</div>
<textarea v-model="page.speaker_notes" class="ppt-notes" rows="2"
:data-test="`notes-${pi}`" placeholder="讲稿备注(不进正文;证据来源自动生成在备注尾部)"
@input="touch" />
</li>
</ol>
<p class="ppt-foot">
e 徽标:<span class="ppt-ev ev-ok" /> 已核实 · <span class="ppt-ev ev-bad" /> 未核实
(保存后按原文重新校验) · <span class="ppt-ev ev-none" /> 无来源
<template v-if="ppt.buildMeta"> · 最近生成 {{ ppt.buildMeta.slides }} 页 / {{ Math.round(ppt.buildMeta.bytes / 1024) }}KB</template>
</p>
</div>
</section>
</template>
<style scoped>
/* tokens 全走 tokens.css(--pa-ink/--pa-surface-N/--pa-hairline/--pa-*);
风格选择卡的三色板见 ppt-styles.css(与 data/ppt_styles/*.json 同步)。 */
.ppt-root { display: flex; flex-direction: column; gap: 14px; padding: 18px 22px; max-width: 1280px; color: var(--pa-ink); }
.ppt-title { font-size: 20px; margin: 0; color: var(--pa-ink); }
.ppt-sub { color: var(--pa-ink-muted); font-size: 13px; margin: 4px 0 0; }
.ppt-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.ppt-status {
font-size: 12px; padding: 4px 12px; border-radius: var(--pa-r-pill);
border: 1px solid var(--pa-hairline); background: var(--pa-surface-2); color: var(--pa-ink-muted);
}
.ppt-status.st-error { color: var(--pa-error, #c2410c); border-color: currentColor; }
.ppt-status.st-done { color: var(--pa-success, #16a34a); border-color: currentColor; }
.ppt-form, .ppt-editor {
background: var(--pa-surface-1); border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md, 12px); padding: 16px 18px;
display: flex; flex-direction: column; gap: 14px;
}
.ppt-field { display: flex; flex-direction: column; gap: 6px; }
.ppt-label { font-size: 13px; font-weight: 600; color: var(--pa-ink); }
.ppt-seg { display: flex; gap: 8px; flex-wrap: wrap; }
.ppt-seg button {
padding: 6px 14px; border-radius: var(--pa-r-pill, 999px); font-size: 13px;
border: 1px solid var(--pa-hairline); background: var(--pa-surface-2); color: var(--pa-ink); cursor: pointer;
}
.ppt-seg button.on { background: var(--pa-gradient, linear-gradient(135deg, #2563eb, #7c3aed)); color: #fff; border-color: transparent; font-weight: 600; }
.min-pages { font-size: 11px; opacity: 0.85; margin-left: 6px; }
.ppt-styles { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px; }
.ppt-file { font-size: 12px; color: var(--pa-ink-muted); }
.ppt-hint { font-size: 12px; color: var(--pa-ink-subtle); align-self: center; }
.ppt-actions { display: flex; gap: 10px; align-items: center; }
.ppt-primary {
padding: 8px 18px; border-radius: var(--pa-r-pill, 999px); border: none; cursor: pointer;
background: var(--pa-gradient, linear-gradient(135deg, #2563eb, #7c3aed)); color: #fff; font-weight: 600; font-size: 14px;
}
.ppt-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.ppt-danger { color: var(--pa-error, #c2410c); }
.ppt-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.ppt-toolbar button, .ppt-dl {
font-size: 13px; padding: 6px 12px; border-radius: var(--pa-r-pill, 999px);
border: 1px solid var(--pa-hairline); background: var(--pa-surface-2); color: var(--pa-ink);
cursor: pointer; text-decoration: none;
}
.ppt-toolbar button:disabled { opacity: 0.5; cursor: not-allowed; }
.ppt-spacer { flex: 1; }
.ppt-chip {
font-size: 12px; padding: 3px 10px; border-radius: var(--pa-r-pill, 999px);
background: var(--pa-surface-2); border: 1px solid var(--pa-hairline); color: var(--pa-ink-muted);
}
.ppt-error { color: var(--pa-error, #c2410c); font-size: 13px; margin: 0; }
.ppt-dirty { color: #b45309; font-size: 12px; margin: 0; }
.ppt-warnings { font-size: 13px; color: #92400e; background: #fef3c722; border: 1px solid #f59e0b66; border-radius: 8px; padding: 6px 12px; }
.ppt-warnings summary { cursor: pointer; font-weight: 600; }
.ppt-warnings ul { margin: 6px 0 2px; padding-left: 18px; }
.ppt-warnings li { margin: 2px 0; }
/* 页面预览(3.x):大图 + 缩略图条 */
.ppt-previews {
display: flex; flex-direction: column; gap: 8px;
border: 1px solid var(--pa-hairline); border-radius: 10px;
background: var(--pa-surface-2); padding: 10px 12px;
}
.ppt-preview-main-wrap {
display: flex; justify-content: center;
background: #0000000d; border-radius: 8px; padding: 8px;
}
.ppt-preview-main {
max-width: 100%; width: 720px; max-height: 420px; object-fit: contain;
border: 1px solid var(--pa-hairline); border-radius: 4px; background: #fff;
}
.ppt-preview-thumbs { display: flex; gap: 6px; flex-wrap: wrap; }
.ppt-preview-thumb {
padding: 0; border: 2px solid var(--pa-hairline); border-radius: 6px;
background: var(--pa-surface-1); cursor: pointer; overflow: hidden;
width: 96px; height: 54px; line-height: 0;
}
.ppt-preview-thumb.on { border-color: var(--pa-accent, #2563eb); box-shadow: 0 0 0 2px #2563eb33; }
.ppt-preview-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ppt-pages { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.ppt-page {
border: 1px solid var(--pa-hairline); border-left: 4px solid var(--pa-accent, #2563eb);
border-radius: 10px; background: var(--pa-surface-2); padding: 10px 12px;
display: flex; flex-direction: column; gap: 8px;
}
.ppt-page.tp-title { border-left-color: #7c3aed; }
.ppt-page.tp-outline { border-left-color: #0891b2; }
.ppt-page.tp-method { border-left-color: #2563eb; }
.ppt-page.tp-result { border-left-color: #16a34a; }
.ppt-page.tp-conclusion { border-left-color: #b45309; }
.ppt-page.tp-reference { border-left-color: #6b7280; }
.ppt-page-head { display: flex; align-items: center; gap: 8px; }
.ppt-page-no {
width: 22px; height: 22px; border-radius: 50%; font-size: 11px; font-weight: 700;
display: inline-flex; align-items: center; justify-content: center;
background: var(--pa-surface-1); border: 1px solid var(--pa-hairline); color: var(--pa-ink-muted); flex-shrink: 0;
}
.ppt-type { font-size: 12px; color: var(--pa-ink-muted); min-width: 42px; }
.ppt-page-title { flex: 1; font-size: 14px; font-weight: 600; padding: 4px 8px; border-radius: 6px;
border: 1px solid transparent; background: transparent; color: var(--pa-ink); }
.ppt-page-title:hover, .ppt-page-title:focus { border-color: var(--pa-hairline); background: var(--pa-surface-1); }
.ppt-sub-row { display: flex; }
.ppt-page-subtitle {
flex: 1; font-size: 13px; padding: 4px 8px; border-radius: 6px;
border: 1px dashed var(--pa-hairline); background: transparent; color: var(--pa-ink-muted);
}
.ppt-page-subtitle:hover, .ppt-page-subtitle:focus {
border-style: solid; border-color: var(--pa-hairline); background: var(--pa-surface-1); color: var(--pa-ink);
}
.ppt-bullets { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.ppt-bullet-row { display: flex; align-items: center; gap: 6px; }
.ppt-ev {
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; display: inline-block;
border: 1px solid var(--pa-hairline);
}
.ppt-ev.ev-ok { background: var(--pa-success, #16a34a); border-color: transparent; }
.ppt-ev.ev-bad { background: #f59e0b; border-color: transparent; }
.ppt-ev.ev-none { background: transparent; border-color: var(--pa-ink-subtle, #9ca3af); }
.ppt-bullet-input {
flex: 1; font-size: 13px; padding: 4px 8px; border-radius: 6px;
border: 1px solid transparent; background: transparent; color: var(--pa-ink);
}
.ppt-bullet-input:hover, .ppt-bullet-input:focus { border-color: var(--pa-hairline); background: var(--pa-surface-1); }
.ppt-x { border: none; background: transparent; color: var(--pa-ink-subtle); cursor: pointer; font-size: 14px; padding: 0 4px; }
.ppt-x:hover { color: var(--pa-error, #c2410c); }
.ppt-add {
align-self: flex-start; font-size: 12px; color: var(--pa-ink-muted); cursor: pointer;
border: 1px dashed var(--pa-hairline); background: transparent; border-radius: 6px; padding: 2px 10px;
}
.ppt-figs { display: flex; gap: 6px; flex-wrap: wrap; }
.ppt-fig {
font-size: 11px; padding: 2px 8px; border-radius: var(--pa-r-pill, 999px);
background: var(--pa-surface-1); border: 1px solid var(--pa-hairline); color: var(--pa-ink-muted);
}
.ppt-notes {
font-size: 12px; padding: 6px 8px; border-radius: 6px; resize: vertical;
border: 1px solid var(--pa-hairline); background: var(--pa-surface-1); color: var(--pa-ink-muted);
}
.ppt-foot { font-size: 12px; color: var(--pa-ink-subtle); margin: 0; }
input, textarea, select { font-family: inherit; color-scheme: light dark; }
select { padding: 5px 8px; border-radius: 6px; border: 1px solid var(--pa-hairline); background: var(--pa-surface-2); color: var(--pa-ink); max-width: 320px; }
</style>
@@ -0,0 +1,40 @@
/* ppt-styles.css —— P5 科研汇报风格选择卡(前端 CSS 投影)。
真源:review_service/data/ppt_styles/*.json(tokens JSON 优先,决定生成的
PPT 配色/字体);卡面封面图 = ui/src/assets/ppt-style-cards/*.png
(build_pptx + ppt_preview 真实渲染的各风格封面,风格变更时需重新生成)。 */
.ppt-style-card {
display: flex; flex-direction: column; align-items: stretch; gap: 7px;
padding: 8px 10px 9px; border-radius: var(--pa-r-md, 10px);
border: 1px solid var(--pa-hairline); background: var(--pa-surface-1);
cursor: pointer; text-align: left; width: 100%;
}
.ppt-style-card.on {
border-color: var(--pa-accent, #2563eb);
box-shadow: 0 0 0 1px var(--pa-accent, #2563eb);
}
.ppt-style-cover {
width: 100%; aspect-ratio: 16 / 9; object-fit: cover; display: block;
border-radius: 6px; border: 1px solid var(--pa-hairline);
background: var(--st-bg, #fff);
}
.ppt-style-meta { display: flex; flex-direction: column; gap: 1px; }
.ppt-style-name { font-size: 13px; font-weight: 600; color: var(--pa-ink); }
.ppt-style-desc { font-size: 12px; color: var(--pa-ink-muted); margin-top: 1px; }
/* 十四风格 tokens(与 data/ppt_styles/*.json palette 同步;
domestic 起 6 个移植自开源 paper-skills styles) */
.ppt-style-card.academic-minimal { --st-bg: #ffffff; --st-primary: #111827; --st-accent: #2563eb; }
.ppt-style-card.botanic-forest { --st-bg: #f4faf6; --st-primary: #14532d; --st-accent: #16a34a; }
.ppt-style-card.classic-defense { --st-bg: #ffffff; --st-primary: #1a365d; --st-accent: #b7791f; }
.ppt-style-card.academic-blue { --st-bg: #eff6ff; --st-primary: #1d4ed8; --st-accent: #3b82f6; }
.ppt-style-card.nature-green { --st-bg: #f0fdf4; --st-primary: #15803d; --st-accent: #22c55e; }
.ppt-style-card.tech-dark { --st-bg: #0b1220; --st-primary: #38bdf8; --st-accent: #22d3ee; }
.ppt-style-card.monochrome { --st-bg: #ffffff; --st-primary: #111111; --st-accent: #444444; }
.ppt-style-card.warm-earth { --st-bg: #fff7ed; --st-primary: #9a3412; --st-accent: #ea580c; }
.ppt-style-card.domestic { --st-bg: #ffffff; --st-primary: #1f4e79; --st-accent: #c00000; }
.ppt-style-card.international { --st-bg: #ffffff; --st-primary: #111111; --st-accent: #c0392b; }
.ppt-style-card.systems-talk { --st-bg: #ffffff; --st-primary: #1f3a5f; --st-accent: #e4572e; }
.ppt-style-card.theory-beamer { --st-bg: #fafafa; --st-primary: #23373b; --st-accent: #eb811b; }
.ppt-style-card.jp-gothic { --st-bg: #ffffff; --st-primary: #1e5bd8; --st-accent: #ffe14d; }
.ppt-style-card.editorial { --st-bg: #faf7f2; --st-primary: #8e7dbe; --st-accent: #c8553d; }
+145 -2
View File
@@ -4,11 +4,11 @@
// - 进行中的请求由 store action 持有 → 面板卸载不中断;
// - 表单草稿存 drafts → 切菜单不丢。
import { defineStore } from 'pinia';
import { compileTex, formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, inspectZip, presubmit, recommend, reformatTex, reviewErrorMessage, rebuttalConfirm, rebuttalCreateFromPdf, rebuttalCreateJob, rebuttalDeleteJob, rebuttalDraft, rebuttalLetter, rebuttalSaveResponse, runZipCompile } from '../api/review.js';
import { compileTex, formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, inspectZip, pptBuildJob, pptCreateFromPdf, pptCreateJob, pptDeleteJob, pptSavePages, pptStyles, presubmit, recommend, reformatTex, reviewErrorMessage, rebuttalConfirm, rebuttalCreateFromPdf, rebuttalCreateJob, rebuttalDeleteJob, rebuttalDraft, rebuttalLetter, rebuttalSaveResponse, runZipCompile } from '../api/review.js';
const STORAGE_KEY = 'plantai.edu.submissions'; // D3 台账持久化(tracker 面板已解除映射,state 保留)
// tracker 已砍(2026-10-12,仅解除 UI 映射;TrackerPanel.vue 保留在 panels/ 下可复用)
const SECTIONS = ['journals', 'recommend', 'presubmit', 'report', 'format', 'reformat', 'calendar', 'rebuttal'];
const SECTIONS = ['journals', 'recommend', 'presubmit', 'report', 'format', 'reformat', 'calendar', 'rebuttal', 'ppt'];
// ⑥ 台账状态枚举(labels 由 step-7 UI 层给;store 只存字符串)
export const SUBMISSION_STATUS = ['draft', 'submitted', 'under_review', 'revision', 'accepted', 'rejected'];
@@ -132,6 +132,29 @@ export const useEduStore = defineStore('edu', {
// Tracker「去写返修」带来的期刊名预填(方案 A:仅跳转,无 jobId 回写)
prefillJournal: '',
},
/* —— P5 风格清单(GET /ppt/styles;与 job 生命周期无关,clearPpt/删除都不清)—— */
pptStyles: [], // [{id,label,display_name,description,bg,accent}]
pptStylesStatus: 'idle', // idle | loading | ready | error(error 允许下次面板挂载重试)
/* —— P5 科研汇报助手(PPT 大纲 job;纯内存态,切换面板不丢,刷新丢失)—— */
ppt: {
job: null, // 后端全量 job(pages/warnings/status...;raw_text 不回传)
status: 'idle', // idle | loading | editing | building | done | error
error: null,
saving: false, // PATCH 进行中(编辑器保存按钮 loading)
buildMeta: null, // 最近一次 {slides, bytes}(编辑后清空,提示重新生成)
createMeta: null, // 生成期 meta(llm_calls/target_pages... 供页头展示)
// 生成表单(跨菜单保留;style/scenario/minutes 在创建时固化进 job)
form: {
mode: 'pdf', // 'pdf' | 'text'
scenario: 'group_meeting',
minutes: 15,
styleId: 'academic-minimal',
file: null, // File(pdf 模式;每次生成后保留,便于重试)
text: '', // 文本模式草稿
},
},
}),
getters: {
@@ -382,6 +405,126 @@ export const useEduStore = defineStore('edu', {
clearRebuttal() {
this.rebuttal = this._rebuttalBase();
},
/* —— P5 科研汇报助手 actions(job 为后端权威,本地只镜像)—— */
/** 风格清单(面板挂载时取一次;失败留空 → 副标题回落「多风格」文案) */
async pptLoadStyles() {
if (this.pptStylesStatus === 'loading' || this.pptStylesStatus === 'ready') {
return this.pptStyles;
}
this.pptStylesStatus = 'loading';
try {
const res = await pptStyles();
const items = Array.isArray(res && res.items) ? res.items : [];
this.pptStyles = items;
this.pptStylesStatus = 'ready';
} catch {
this.pptStyles = [];
this.pptStylesStatus = 'error';
}
return this.pptStyles;
},
_pptBase() {
return {
job: null,
status: 'idle',
error: null,
saving: false,
buildMeta: null,
createMeta: null,
form: { ...this.ppt.form },
};
},
/** 生成大纲(pdf 上传 / 文本粘贴,按 form.mode 分派;同步长调用) */
async pptGenerate() {
const f = this.ppt.form;
if (f.mode === 'pdf' && !f.file) {
this.ppt.status = 'error';
this.ppt.error = '请先选择 PDF 文件';
return null;
}
if (f.mode === 'text' && (!f.text || f.text.trim().length < 300)) {
this.ppt.status = 'error';
this.ppt.error = '正文过短(<300 字),无法生成汇报大纲';
return null;
}
this.ppt.status = 'loading';
this.ppt.error = null;
try {
let res;
if (f.mode === 'pdf') {
const fd = new FormData();
fd.append('file', f.file);
fd.append('scenario', f.scenario);
fd.append('minutes', String(f.minutes));
fd.append('style_id', f.styleId);
res = await pptCreateFromPdf(fd);
} else {
res = await pptCreateJob({
text: f.text, scenario: f.scenario, minutes: f.minutes, style_id: f.styleId,
});
}
this.ppt.job = res.job;
this.ppt.createMeta = res.meta || null;
this.ppt.buildMeta = null;
this.ppt.status = 'editing';
return res.job;
} catch (err) {
this.ppt.status = 'error';
this.ppt.error = reviewErrorMessage(err);
return null;
}
},
/** 保存编辑后的完整 pages(后端结构清洗 + evidence 重验;失败保留编辑态) */
async pptSave(pages) {
if (!this.ppt.job) return null;
this.ppt.saving = true;
try {
const res = await pptSavePages(this.ppt.job.id, pages);
this.ppt.job = res.job;
this.ppt.buildMeta = null; // 旧 pptx 失效(后端亦清 pptx_path)
return res.job;
} catch (err) {
this.ppt.error = reviewErrorMessage(err);
return null;
} finally {
this.ppt.saving = false;
}
},
/** 大纲 → draft.pptx(python-pptx 本地生成) */
async pptBuild() {
if (!this.ppt.job) return null;
this.ppt.status = 'building';
this.ppt.error = null;
try {
const res = await pptBuildJob(this.ppt.job.id);
this.ppt.job = res.job;
this.ppt.buildMeta = res.meta || null;
this.ppt.status = 'done';
return res.job;
} catch (err) {
this.ppt.status = 'error';
this.ppt.error = reviewErrorMessage(err);
return null;
}
},
/** 删除 job(行 + 产物目录);保留表单便于立即重做 */
async pptDelete() {
const id = this.ppt.job?.id;
this.ppt = this._pptBase();
if (!id) return { ok: true };
try {
await pptDeleteJob(id);
return { ok: true };
} catch (err) {
return { ok: false, error: reviewErrorMessage(err) };
}
},
/** 回到生成表单(保留表单值,清 job/产物状态) */
clearPpt() {
this.ppt = this._pptBase();
},
async submitRecommend(payload) {
this.recommendStatus = 'loading';
this.recommendError = null;