Author SHA1 Message Date
admin 9b746db452 Merge feature/admin-console: 管理后台 + 用户端鉴权(登录/授权/数据隔离/日志) 2026-10-16 16:01:13 +08:00
admin 8b5095f70f 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
2026-10-15 20:19:30 +08:00
potaser f5a951f47a docs: add P5 PR report 2026-10-15 19:35:51 +08:00
potaser 7ced30fa96 feat(review): P5 科研汇报助手 + 14 风格 + 真图嵌入 2026-10-15 18:51:59 +08:00
admin 69e3f35a43 Merge pull request 'feat(review): P3b+P4 论文重排版支持编译 PDF(含 zip 项目)' (#4) from wjq/PlantAI-rag-new:feature/p3b-compile-ui into main
Reviewed-on: http://8.163.92.8:3000/admin/PlantAI-rag-new/pulls/4
2026-10-15 18:30:14 +08:00
potaser de0b9f92be fix(review): EPS batch conversion in zip compile (v2)
- v1 fell through because it required pre-converted PDF in zip
- v2: convert all .eps files post-extract via gs -dSAFER
- 15s timeout per file; fail per-file, not blocking
- Size guards: 20MB per file warning, 100MB total abort
- Conversion log written to output.log
- Verified on real MDPI official zip → 7-page PDF

v1 lesson: test sample had pre-converted PDF but real zip does not
2026-10-14 09:47:49 +08:00
potaser 2797145104 feat(review): P4 two-phase zip compile + XDV→PDF diagnostics + EPS fallback
- inspect/upload zip then compile project (main-file detection, zip safety,
  single-slot queue, failed-envelope carries compile_id + log_url)
- keep failed project/ scene 24h (failed_project_ttl_s; KEEP_FAILED_PROJECTS=1
  forever), success deletes immediately; two-tier compile_jobs sweep
- add tectonic --print and persist full engine chatter to output.log so
  XDV→PDF (xdvipdfmx) errors are visible (MDPI: logo EPS inclusion failure)
- EPS fallback: swap X.eps refs for *-eps-converted-to.pdf when present and
  materialize X.pdf for extension-less graphicx lookup; friendly error when
  no converted PDF exists
- frontend: ReformatPanel zip upload/inspect/run flow; one-off
  scripts/debug_compile.py (gitignored)
- tests: backend 169 passed (22 zip_safety + 34 compile_zip + P3a regression),
  frontend edu vitest 43 passed
2026-10-13 18:42:04 +08:00
potaser 4e19350115 docs: add P3b PR report 2026-10-13 11:23:01 +08:00
potaser e036301e1a feat(review): show format rules summary per target journal
- New summary row: shows the journal's rules (even if no diff applies)
- Fixes user confusion when switching journals yields same diff
- When all rules are null: 'no explicit format requirements'
- Vitest: add case for summary rendering
2026-10-13 11:22:52 +08:00
potaser da84dff9bb fix(review): persist uploaded .tex file in reformat panel
- File now stored in edu.js store (reformatFile)
- Switching target journal no longer requires re-upload
- Explicit reset button clears file
- Vitest: 4 cases
2026-10-13 11:20:33 +08:00
potaser 3a72d3a861 feat(review): P3b enable compile PDF in reformat panel
- ReformatPanel: compile tab state machine (idle/loading/success/error)
- Blob URL for PDF preview; backend path hidden
- First-compile 1-3min hint banner (dismissible)
- QUEUE_FULL 429 with fixed message
- Store: compileStatus/compileResult/compileError + actions
- prewarm_tectonic.sh: minimal .tex compilation to pre-cache TeX bundles
- Vitest: 6 new cases (guide / button / loading / success / error / queue-full)
2026-10-13 10:24:55 +08:00
70 changed files with 10404 additions and 52 deletions
+12
View File
@@ -54,6 +54,18 @@ review_service/data/reformat_jobs/
review_service/tools/tectonic review_service/tools/tectonic
review_service/data/tectonic-cache/ review_service/data/tectonic-cache/
review_service/data/format_cache/ review_service/data/format_cache/
review_service/data/compile_jobs/
review_service/data/compile_inspect/
review_service/prewarm.log
# 部署方案文档(含主机凭据,不入库) # 部署方案文档(含主机凭据,不入库)
docs/DEPLOY_PAPER_176.md 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,111 @@
# PR 报告:P3b 编译 PDF Tab(论文重排版面板)
- 日期:2026-10-13
- 分支:`feature/p3b-compile-ui`(自最新 main 新开;未 commit,按用户约定统一处理)
- 范围:`ui/` 面板 + store/api 扩展;`review_service/` 仅 restart.sh 调起预热脚本 + 新增
scripts/prewarm_tectonic.sh。**后端 compile.py / latex_compile.py(P3a)零改动**;
无 sdk/、agent/、vstore/ 改动;无新增依赖。
## 概要
P3b:论文重排版面板启用"编译 PDF" Tab,实现"上传 .tex → 服务器编译 → 下载 PDF"的完整闭环。
## 修改内容
| 文件 | 变更 |
|---|---|
| ui/src/components/edu/panels/ReformatPanel.vue | 编译 Tab 状态机:无 job_id 引导 / 编译按钮 / loading 动画 + 首次 1-3 分钟黄条(可关闭)/ 成功 blob URL iframe 预览 + 下载 + 耗时 + 重新编译 / 失败错误摘要 + log 尾 200 行折叠 + 完整 log 下载;按钮不 await,状态全由 store 驱动(切 Tab/切面板编译继续) |
| ui/src/stores/edu.js | compileStatus/compileResult/compileError/compileLogTail + compilePdfUrl(blob URL,fetch 失败静默回退 pdf_url 直链)+ compileFirstHint;submitCompile(loading 重入保护)/ clearCompile(revoke blob);重新排版自动 clearCompile |
| ui/src/api/review.js | CODE_HINTS 增 QUEUE_FULL 固定文案"编译队列已满(最多 5 个任务),请稍后重试"(后端 message 带动态 pending 数,规范统一口径);compileTex() 为 P3a 已有封装 |
| review_service/restart.sh | 启动后后台调起 prewarm_tectonic.sh(nohup 非阻塞,日志 prewarm.log) |
| review_service/scripts/prewarm_tectonic.sh(新增) | 最小 hello.tex 实编译以缓存 TeX 环境。**注**:tectonic v0.17.0 无 `download` 子命令(实测 `no subcommand download`),改用 tools/README 的最小 .tex 预编译法;失败仅告警不影响服务启动 |
| tests/frontend/unit/edu/reformat.compile.test.js(新增) | 6 个用例(真实 reviewErrorMessage 经 importOriginal 保留,QUEUE_FULL 走 CODE_HINTS) |
## 关键实现决策
1. **无异步轮询**:后端编译同步阻塞返回(单槽队列,常规 10-30s,上限 `compile_queue_limit=5`),
无 stage 可显示 → loading 只做不确定时长旋转动画 + 文案说明。
2. **QUEUE_FULL**:HTTP 429 + 同款信封,http.js 抛 ApiError(code=QUEUE_FULL);
前端 CODE_HINTS 固定文案,报错后"重新编译"按钮立即可用(store finally 解锁)。
3. **PDF blob URL**:成功后异步 `fetch(pdf_url) → blob → createObjectURL`,
预览/下载不暴露后端路径;jsdom/网络失败回退 pdf_url 直链,不阻塞渲染。
4. **失败 log**:信封 detail = log 尾 200 行(折叠 `<pre>`);完整 log 下载兜底拼
`/api/review/compile/{job_id}/log`(失败信封不带 log_url,但 job_id 即 anchor_id)。
5. **首次编译提示条**:`compileFirstHint` 在首次 submitCompile 置位,loading 期间显示,
可手动关闭;`clearCompile`(重新排版)复位。
6. **预热命令修正**:任务原文 `./tools/tectonic -X download default` 在钉死版本
v0.17.0 不存在——实测后改为最小 .tex 实编译预热(同 tools/README.md 建议)。
## 测试结果
- 后端 pytest(review_service,后端未动):**113 passed**
- 前端 vitest 全量:**403 passed / 5 failed**(5F = 既有 labels.js 基线失败
benchpanel×4 + Composer pill×1,与本 PR 无关)
- vitest 新增 reformat.compile.test.js:**6/6 passed**
(无 job_id 引导 / 按钮可点 / 编译中+黄条+重入保护 / 成功 iframe+下载+耗时 /
失败 log 折叠 / QUEUE_FULL 文案+重试可用)
- npm run build:✓ 3.12s
## 手工冒烟(8100 真实链路)
- reformat /tmp/opencode/reformat_sample.tex → ok=True job_id=b3631727af68
- POST /compile {job_id=b3631727af68} → ok=True seconds=0.61 pdf_bytes=3596
- GET /compile/{cid}/pdf → 200,3596 bytes,application/pdf(file: PDF 1.5)
- GET /compile/{cid}/log → 200,3533 bytes
- job_id 不存在 / 格式非法 → VALIDATION 信封
- 坏 tex(\thisCommandDoesNotExist)→ COMPILE_FAILED + detail 尾 200 行
(含 "! Undefined control sequence")
- QUEUE_FULL → 429 + 信封(后端路径实测于 P3a;前端文案 vitest 6/6 覆盖)
- scripts/prewarm_tectonic.sh → exit=0 OK,宏包缓存就绪
## EPS 兼容性修复 v2(2026-10-14 追加)
背景:真实 MDPI 官方 zip(v6.5a)上传编译在 XDV→PDF 阶段失败:
`xdvipdfmx: image inclusion failed for "Definitions/logo-mdpi.eps"`。
v1 回退逻辑 gate 在"zip 自带 `*-eps-converted-to.pdf` 预转换版"——真实官方 zip
**不带**该文件(v1 本地 e2e 通过是因自造样本带了;测试样本与真实样本不一致,教训已记录)。
v2 方案:**不依赖扫描引用与 zip 自带预转换版**,解压后扫描项目内所有 `.eps`
批量现场转同名 `.pdf`(`latex_compile.process_eps_images`):
1. 工具优先级 **gs → epstopdf → ps2pdf**(`shutil.which` 逐个 fallback);gs 命令
约束1 强制 `-dSAFER -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -dPDFSETTINGS=/prepress`
(-dSAFER 防 EPS 内嵌任意 PostScript 执行);无新 Python 依赖(subprocess)
2. 约束2:单文件转换 **15s 超时** → warning 行 `[eps-convert] xxx.eps 转换超时`
并跳过该文件继续
3. 约束3:单文件转换后 >20MB → warning;累计转换产物 >100MB →
`InspectError("ZIP_TOO_LARGE")` 中止(信封原样透出该 code)
4. 约束4:`[eps-convert] {src} → {dst} ({in} → {out}, {elapsed}s, {tool})`
写入 `output.log`(正常/超时两条路径都落盘,引擎 log 之后、chatter 之前)
5. 转换成功 → 显式 `X.eps` 引用字节替换为 `X.pdf`(.tex/.cls/.sty/.cfg/.def 全扫,
不碰编码、不改正文);无扩展名引用 `{.../X}` 由 graphicx 命中新 `X.pdf`
(解析顺序 .pdf→…→.eps,规避 .eps)
6. 全工具失败 → log 行 `[eps-convert] 全部工具失败,图片 xxx.eps 未转换` →
编译自然失败 → 前端提示「项目包含 EPS 图片,服务器转换失败,请转成 PDF/PNG 后重试」
真实官方 zip 实测(`scripts/debug_compile.py` 重打包 fba37ae3c853 现场 + 8100 HTTP 链路):
| 指标 | 结果 |
|---|---|
| 转换 EPS 数 | 2(logo-mdpi.eps、logo-updates.eps,均 gs) |
| 每个耗时 | 563678→13057B 0.27s;287998→16588B 0.27s(合计 0.54s) |
| 显式引用改写 | 1 个文件(Definitions/mdpi.cls) |
| 编译 | 2.83s(debug 脚本)/ 2.99s(HTTP) |
| 产出 PDF | 70688 / 70687 bytes,7 页,`%PDF-` ✓ |
测试:pytest **175 passed**(原 169 全保留,移除 3 个 v1 语义已变的 EPS 用例,
新增 9 个 v2 用例:preset 免转换 / 真实 gs 转换 / 工具 fallback / 全失败 /
超时 warning / 20MB warning / 100MB 中止 / 端到端成功 / 友好报错)。
教训(关键):**外部下载的模板类场景,e2e 必须用真实官方文件**——v1 的自造样本
带了预转换版,真实官方 zip 不带,导致修复上线后失效。
## 部署验证(部署后执行)
```bash
bash review_service/restart.sh
# 观察日志:prewarm_tectonic.sh 后台跑(非阻塞),prewarm.log 出现 "[prewarm] OK"
# 浏览器 → 教育科研 → 论文自动重排版 → 上传 .tex → 选期刊 → 重排版
# → 切"编译 PDF" → 点编译
# 期望:首次 1-3 分钟(若预热已完成则秒级)、后续秒回;失败可见 log 尾 200 行
```
@@ -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)内容即可。
+43 -1
View File
@@ -62,6 +62,27 @@ class Settings(BaseSettings):
compile_queue_limit: int = 5 # 等待槽位上限(>5 → QUEUE_FULL 429) compile_queue_limit: int = 5 # 等待槽位上限(>5 → QUEUE_FULL 429)
compile_retention_days: int = 7 # 编译产物保留天数(后台清扫) compile_retention_days: int = 7 # 编译产物保留天数(后台清扫)
# ---- LaTeX 项目 zip 编译(P4:两段式 inspect + run)----
max_zip_bytes: int = 50 * 1024 * 1024 # 上传 zip 上限 50MB
max_extract_bytes: int = 200 * 1024 * 1024 # 单项目解压总大小硬顶 200MB(实写字节计数)
max_zip_entries: int = 2000 # 条目数上限(预扫第一件事)
max_zip_entry_bytes: int = 50 * 1024 * 1024 # 单条目解压上限 50MB
max_zip_ratio: float = 100.0 # 单条目压缩比 file_size/compress_size 上限
max_zip_path_depth: int = 16 # 路径深度上限(深层嵌套拒绝)
max_zip_path_len: int = 200 # 单条目路径长度上限
compile_project_timeout_s: float = 120.0 # 项目 zip 编译墙钟上限(单文件仍 compile_timeout_s)
inspect_dir: str = "data/compile_inspect" # zip 解压暂存(1 小时 TTL,相对 review_service/)
inspect_ttl_s: int = 3600 # inspect 暂存过期秒数
inspect_sweep_interval_s: float = 300.0 # inspect 后台清扫周期(lifespan 定时任务)
compile_jobs_dir: str = "data/compile_jobs" # 项目编译产物(pdf/log/meta,7 天 sweep)
failed_project_ttl_s: int = 24 * 3600 # 编译失败保留 project/ 现场秒数(诊断用)
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 @property
def _service_root(self) -> "Path": def _service_root(self) -> "Path":
# review_service/app/config.py → review_service/(相对本文件定位,不依赖 cwd) # review_service/app/config.py → review_service/(相对本文件定位,不依赖 cwd)
@@ -85,16 +106,37 @@ class Settings(BaseSettings):
p = Path(self.reformat_dir) p = Path(self.reformat_dir)
return p if p.is_absolute() else self._service_root / p 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 @property
def tectonic_abs(self) -> "Path": def tectonic_abs(self) -> "Path":
p = Path(self.tectonic_path) p = Path(self.tectonic_path)
return p if p.is_absolute() else self._service_root / p return p if p.is_absolute() else self._service_root / p
@property @property
def tectonic_cache_abs(self) -> "Path": def tectonic_cache_abs(self) -> Path:
p = Path(self.tectonic_cache_dir) p = Path(self.tectonic_cache_dir)
return p if p.is_absolute() else self._service_root / p return p if p.is_absolute() else self._service_root / p
@property
def inspect_dir_abs(self) -> Path:
p = Path(self.inspect_dir)
return p if p.is_absolute() else self._service_root / p
@property
def compile_jobs_dir_abs(self) -> Path:
p = Path(self.compile_jobs_dir)
return p if p.is_absolute() else self._service_root / p
@property @property
def vstore_base(self) -> str: def vstore_base(self) -> str:
return self.vstore_base_url.rstrip("/") return self.vstore_base_url.rstrip("/")
+43
View File
@@ -23,6 +23,7 @@ from .routers import (
format_check, format_check,
journal_directory, journal_directory,
journals, journals,
ppt,
presubmit, presubmit,
reformat, reformat,
rebuttal, rebuttal,
@@ -55,12 +56,45 @@ def create_app() -> FastAPI:
ensure_table() ensure_table()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.warning("rebuttal 建表失败(忽略)", exc_info=True) 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: try:
from .services.latex_compile import sweep_expired from .services.latex_compile import sweep_expired
sweep_expired() sweep_expired()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.warning("compile sweep 启动清扫失败(忽略)", exc_info=True) logger.warning("compile sweep 启动清扫失败(忽略)", exc_info=True)
# P4:inspect 暂存(1h TTL)+ 项目编译产物(7 天)启动清扫
try:
from .services.latex_compile import (
sweep_compile_jobs_expired,
sweep_inspect_expired,
)
sweep_inspect_expired()
sweep_compile_jobs_expired()
except Exception: # noqa: BLE001
logger.warning("P4 sweep 启动清扫失败(忽略)", exc_info=True)
# P4:inspect 暂存后台定时清扫(默认 300s;补充 1——上传不点编译不堆积)
sweep_task: asyncio.Task[None] | None = None
try:
from .services.latex_compile import sweep_inspect_expired as _sweep_insp
async def _inspect_sweep_loop() -> None:
interval = get_settings().inspect_sweep_interval_s
while True:
await asyncio.sleep(interval)
try:
await asyncio.to_thread(_sweep_insp)
except Exception: # noqa: BLE001
logger.warning("inspect 后台清扫失败(忽略)", exc_info=True)
sweep_task = asyncio.create_task(_inspect_sweep_loop())
except Exception: # noqa: BLE001
logger.warning("inspect 后台清扫任务启动失败(忽略)", exc_info=True)
# journal_directory 自动 seed:空表才导(xlsx 解析约 1-3s,不堵 event loop; # journal_directory 自动 seed:空表才导(xlsx 解析约 1-3s,不堵 event loop;
# 路径沿用 db.path 注入链——生产=settings.db_path_abs,测试=tmp) # 路径沿用 db.path 注入链——生产=settings.db_path_abs,测试=tmp)
if settings.seed_on_startup: if settings.seed_on_startup:
@@ -88,6 +122,14 @@ def create_app() -> FastAPI:
try: try:
yield yield
finally: finally:
if sweep_task is not None:
sweep_task.cancel()
try:
await sweep_task
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001
pass
await close_db() await close_db()
app.router.lifespan_context = _lifespan app.router.lifespan_context = _lifespan
@@ -118,6 +160,7 @@ def create_app() -> FastAPI:
app.include_router(journal_directory.router) app.include_router(journal_directory.router)
app.include_router(reformat.router) app.include_router(reformat.router)
app.include_router(compile.router) app.include_router(compile.router)
app.include_router(ppt.router)
return app return app
+158
View File
@@ -132,6 +132,107 @@ async def compile_tex(
) )
# ================================================================ P4:项目 zip(两段式)
def _p4_error(code: str, message: str, detail: str = "",
compile_id: str | None = None,
log_url: str | None = None) -> CompileResponse:
"""P4 错误信封(补充 3:失败也带 compile_id + log_url,前端可下载完整 log)。"""
err: dict[str, Any] = {"code": code, "message": message, "degraded": False}
if detail:
err["detail"] = detail
return CompileResponse(
ok=False, compile_id=compile_id, log_url=log_url, error=err)
def _project_pdf_url(cid: str) -> str:
return f"/api/review/compile/zip/{cid}/pdf"
def _project_log_url(cid: str) -> str:
return f"/api/review/compile/zip/{cid}/log"
@router.post("/compile/inspect", response_model=CompileResponse)
async def inspect_zip_project(
file: UploadFile = File(..., description="LaTeX 项目 zip(含 main.tex/.cls/图片/.bib)"),
) -> CompileResponse:
"""两段式之①:安全解压 + 主文件识别 → job_id(1 小时 TTL,run 前不占编译队列)。
暂用 request.body() 整体缓冲(≤50MB);Phase 2 改流式上传
"""
settings = get_settings()
filename = file.filename or "project.zip"
if Path(filename).name != filename or ".." in filename:
return _p4_error("VALIDATION", "非法文件名")
if not filename.lower().endswith(".zip"):
return _p4_error("VALIDATION", "仅支持 .zip 文件")
# 暂用 request.body() 整体缓冲(≤50MB);Phase 2 改流式上传
content = await file.read()
if not content:
return _p4_error("VALIDATION", "上传文件为空")
if len(content) > settings.max_zip_bytes:
return _p4_error(
"VALIDATION",
f"zip 超过 {settings.max_zip_bytes // (1024 * 1024)}MB 上限")
try:
meta = await lc.inspect_zip(content)
except lc.InspectError as exc:
return _p4_error(exc.code, exc.message)
return CompileResponse(ok=True, meta=meta)
class RunZipRequest(BaseModel):
job_id: str
@router.post("/compile/run", response_model=CompileResponse)
async def run_zip_compile(payload: RunZipRequest) -> CompileResponse:
"""两段式之②:原子消费 inspect(rename 防并发双跑)→ tectonic 编译 → pdf/log。
队列/工具失败发生在消费**之前** → inspect 保留可重试;
编译失败(COMPILE_FAILED/TIMEOUT)信封仍带 compile_id + log_url(补充 3)。
"""
jid = (payload.job_id or "").strip()
if not _CID_RE.fullmatch(jid):
return _p4_error(
"VALIDATION", "job_id 格式非法(应为 12 位 hex)")
try:
result = await lc.compile_project_job(jid)
except lc.InspectError as exc:
return _p4_error(exc.code, exc.message)
except lc.ToolUnavailableError as exc:
return _p4_error("TOOL_UNAVAILABLE", str(exc))
except lc.QueueFullError as exc:
# 队列满发生在 consume_inspect 之前 → job 未消费,前端可直接重试
return JSONResponse(
status_code=429,
content={
"ok": False,
"compile_id": None, "pdf_url": None, "log_url": None, "meta": None,
"error": {"code": "QUEUE_FULL", "message": str(exc),
"degraded": False},
},
)
except lc.CompileError as exc:
# 补充 3:失败也带 compile_id + log_url(cid 由 compile_project_job 附加)
cid = getattr(exc, "compile_id", None)
return _p4_error(
exc.code, str(exc), detail=exc.log_tail,
compile_id=cid,
log_url=_project_log_url(cid) if cid else None)
return CompileResponse(
ok=True,
compile_id=result["compile_id"],
pdf_url=_project_pdf_url(result["compile_id"]),
log_url=_project_log_url(result["compile_id"]),
meta=json.loads((lc.compile_job_dir(result["compile_id"]) / "meta.json")
.read_text("utf-8"))
if (lc.compile_job_dir(result["compile_id"]) / "meta.json").is_file()
else {"compile_id": result["compile_id"], "seconds": result["seconds"]},
)
def _cid_dir(cid: str) -> Path | None: def _cid_dir(cid: str) -> Path | None:
if not _CID_RE.fullmatch(cid or ""): if not _CID_RE.fullmatch(cid or ""):
return None return None
@@ -139,6 +240,63 @@ def _cid_dir(cid: str) -> Path | None:
return d if d.is_dir() else None return d if d.is_dir() else None
def _zip_cid_dir(cid: str) -> Path | None:
"""P4 项目产物目录 data/compile_jobs/{cid}/(独立于 P3a reformat_dir 树)。"""
if not _CID_RE.fullmatch(cid or ""):
return None
d = lc.compile_job_dir(cid)
return d if d.is_dir() else None
@router.get("/compile/zip/{compile_id}", response_model=CompileResponse)
async def get_zip_compile(compile_id: str) -> CompileResponse:
cdir = _zip_cid_dir(compile_id)
meta_path = cdir / "meta.json" if cdir else None
if meta_path is None or not meta_path.is_file():
return _p4_error("NOT_FOUND", "编译任务不存在或已过期,请重新编译")
try:
meta = json.loads(meta_path.read_text("utf-8"))
except (OSError, ValueError):
return _p4_error("NOT_FOUND", "编译产物损坏,请重新编译")
return CompileResponse(
ok=True,
compile_id=compile_id,
pdf_url=_project_pdf_url(compile_id),
log_url=_project_log_url(compile_id),
meta=meta,
)
@router.get("/compile/zip/{compile_id}/pdf", response_model=None)
async def download_zip_pdf(compile_id: str) -> FileResponse | JSONResponse:
cdir = _zip_cid_dir(compile_id)
pdf = cdir / "output.pdf" if cdir else None
if pdf is None or not pdf.is_file():
return JSONResponse(
status_code=200,
content={"ok": False, "error": {
"code": "NOT_FOUND", "message": "PDF 不存在或已过期",
"degraded": False}},
)
return FileResponse(pdf, media_type="application/pdf",
filename=f"{compile_id}.pdf")
@router.get("/compile/zip/{compile_id}/log", response_model=None)
async def download_zip_log(compile_id: str) -> PlainTextResponse | JSONResponse:
"""补充 3:成功/失败都可下载 output.log。"""
cdir = _zip_cid_dir(compile_id)
log = cdir / "output.log" if cdir else None
if log is None or not log.is_file():
return JSONResponse(
status_code=200,
content={"ok": False, "error": {
"code": "NOT_FOUND", "message": "日志不存在或已过期",
"degraded": False}},
)
return PlainTextResponse(log.read_text("utf-8", errors="replace"))
@router.get("/compile/{compile_id}", response_model=CompileResponse) @router.get("/compile/{compile_id}", response_model=CompileResponse)
async def get_compile(compile_id: str) -> CompileResponse: async def get_compile(compile_id: str) -> CompileResponse:
cdir = _cid_dir(compile_id) cdir = _cid_dir(compile_id)
+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})
+634 -1
View File
@@ -1,4 +1,4 @@
"""services/latex_compile.py —— tectonic 编译 .tex → PDF(P3a)。 """services/latex_compile.py —— tectonic 编译 .tex → PDF(P3a + P4 项目 zip)。
隔离与安全(对齐设计清单): 隔离与安全(对齐设计清单):
1. 一编译一目录:{reformat_dir}/{anchor_id}/compile/(input.tex/output.pdf/output.log/meta.json) 1. 一编译一目录:{reformat_dir}/{anchor_id}/compile/(input.tex/output.pdf/output.log/meta.json)
@@ -9,6 +9,13 @@
6. 产物 PDF > max_compile_pdf_bytes → 删文件 + OutputTooLargeError 6. 产物 PDF > max_compile_pdf_bytes → 删文件 + OutputTooLargeError
7. 启动/每次编译前机会性清扫 retention_days 天前的 job 目录(只删 12 位 hex 锚点目录) 7. 启动/每次编译前机会性清扫 retention_days 天前的 job 目录(只删 12 位 hex 锚点目录)
P4 项目 zip(两段式 inspect + run,与 P3a 共享单槽队列/清扫/错误类):
- inspect_zip:安全解压(zip_safety)→ 主文件识别 → compile_inspect/{job_id}/(1h TTL)
- compile_project_job:原子 rename 消费 inspect → compile_jobs/{cid}/ → tectonic →
finally 删 project 树(只留 output.pdf/output.log/meta.json)
- 失败也保证 output.log 落盘(补充 3);classify_compile_error 给出用户友好文案
- P3a 单文件路径(prepare_input/compile_tex/_run_once)零改动
tectonic 二进制缺失 → ToolUnavailableError(路由层回安装指引)。 tectonic 二进制缺失 → ToolUnavailableError(路由层回安装指引)。
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,7 +27,10 @@ import os
import re import re
import shutil import shutil
import signal import signal
import subprocess
import time import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -40,6 +50,9 @@ _pending = 0
class CompileError(Exception): class CompileError(Exception):
"""编译失败(含 log 尾)。code ∈ {COMPILE_FAILED, TIMEOUT, OUTPUT_TOO_LARGE}。""" """编译失败(含 log 尾)。code ∈ {COMPILE_FAILED, TIMEOUT, OUTPUT_TOO_LARGE}。"""
# compile_id:P4 项目编译失败时由 compile_project_job 附加(P3a 单文件为 None),
# 路由层据此在失败信封里回 compile_id + log_url(补充 3)。
compile_id: str | None = None
def __init__(self, code: str, message: str, log_tail: str = "") -> None: def __init__(self, code: str, message: str, log_tail: str = "") -> None:
super().__init__(message) super().__init__(message)
@@ -234,3 +247,623 @@ def sweep_expired() -> int:
logger.info("compile sweep: 清理 %d 个过期任务目录(>%d 天)", logger.info("compile sweep: 清理 %d 个过期任务目录(>%d 天)",
removed, settings.compile_retention_days) removed, settings.compile_retention_days)
return removed return removed
# ================================================================ P4:项目 zip(inspect + run)
#
# 与 P3a 共用:_lock/_pending 单槽队列、_ensure_tool、CompileError/QueueFullError/
# ToolUnavailableError、_kill_group、_log_tail、12-hex 目录与 mtime sweep 约定。
# P3a 函数(prepare_input/compile_tex/_run_once)零改动。
class InspectError(Exception):
"""inspect/消费阶段业务失败。
code ∈ {VALIDATION, ZIP_UNSAFE, ZIP_BOMB, ZIP_INVALID, ZIP_TOO_LARGE}。
"""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
_DOCCLASS_RE = re.compile(r"(?<!\\)\\documentclass\s*(\[|\{)")
_IMG_LIKE_EXT = {".png", ".jpg", ".jpeg", ".pdf", ".eps", ".svg"}
def inspect_root() -> Path:
"""data/compile_inspect/(1 小时 TTL 暂存根,存在性由调用方按需 mkdir)。"""
return get_settings().inspect_dir_abs
def compile_job_dir(cid: str) -> Path:
"""data/compile_jobs/{cid}/(产物根;不走 reformat_dir,P4 独立清扫)。"""
return get_settings().compile_jobs_dir_abs / cid
def _strip_tex_comments(text: str) -> str:
"""粗剥注释:行内 % 截断(\\% 误伤可接受,MVP 设计决策)。"""
return "\n".join(ln.split("%", 1)[0] for ln in text.splitlines())
def _read_tex(path: Path) -> str:
"""utf-8 → gb18030 → latin-1 降级读(中文模板常见 GBK)。"""
raw = path.read_bytes()
for enc in ("utf-8", "gb18030", "latin-1"):
try:
return raw.decode(enc)
except UnicodeDecodeError:
continue
return raw.decode("utf-8", errors="replace")
def find_main_tex(project_dir: Path) -> tuple[str | None, list[str], int]:
"""主文件识别(补充 2)→ (main_rel | None, 含 documentclass 的相对路径清单, .tex 总数)。
场景:唯一候选 → main;0 个 .tex / 0 候选 / ≥2 候选 → None + 清单(调用方报错)。
返回的相对路径用 posix 分隔符(与 zip 条目名一致)。
"""
tex_files = sorted(
p for p in project_dir.rglob("*")
if p.is_file() and p.suffix.lower() == ".tex"
)
if not tex_files:
return None, [], 0
candidates: list[str] = []
for p in tex_files:
if _DOCCLASS_RE.search(_strip_tex_comments(_read_tex(p))):
candidates.append(p.relative_to(project_dir).as_posix())
if len(candidates) == 1:
return candidates[0], candidates, len(tex_files)
return None, candidates, len(tex_files)
_MISSING_FILE_RE = re.compile(
r"LaTeX Error: File [`']([^`']+)' not found"
r"|I can't find file [`']([^`']+)'"
)
_IMG_INCLUSION_RE = re.compile(r"""image inclusion failed for ["']([^"']+)["']""")
_EPS_CONVERTED_SUFFIX = "-eps-converted-to.pdf"
_REF_TEXT_EXTS = {".tex", ".cls", ".sty", ".cfg", ".def"}
# EPS 现场转换 v2(约束 1-4):xdvipdfmx 无法内嵌 EPS → 解压后批量转同名 .pdf
_EPS_CONVERT_TIMEOUT_S = 15 # 约束2:单文件转换超时(秒)
_EPS_CONVERT_MAX_FILE = 20 * 1024 * 1024 # 约束3:单文件转换后上限(warning)
_EPS_CONVERT_MAX_TOTAL = 100 * 1024 * 1024 # 约束3:转换产物总上限(中止)
def _eps_tool_commands(eps: Path, pdf: Path) -> list[tuple[str, list[str]]]:
"""转换工具优先级:gs(约束1 必须 -dSAFER)→ epstopdf → ps2pdf;只列 PATH 里可用的。"""
tools: list[tuple[str, list[str]]] = []
gs = shutil.which("gs")
if gs:
tools.append(("gs", [
gs, "-dSAFER", "-dNOPAUSE", "-dBATCH", "-sDEVICE=pdfwrite",
"-dPDFSETTINGS=/prepress",
f"-sOutputFile={pdf}", str(eps),
]))
epstopdf = shutil.which("epstopdf")
if epstopdf:
# epstopdf 默认输出到同目录同名 .pdf(即 pdf 路径)
tools.append(("epstopdf", [epstopdf, str(eps)]))
ps2pdf = shutil.which("ps2pdf")
if ps2pdf:
tools.append(("ps2pdf", [ps2pdf, str(eps), str(pdf)]))
return tools
def _convert_eps_file(eps: Path, pdf: Path) -> tuple[str, str]:
"""逐工具尝试把 eps 转成同名 pdf → ("ok", tool) | ("timeout", tool) | ("fail", "")。
约束2:单工具 15s 超时 → 放弃该文件(继续下一个 eps,不再试后续工具)。
成功判定:退出后 pdf 存在且非空(防半成品)。
"""
tools = _eps_tool_commands(eps, pdf)
if not tools:
return "fail", ""
for tool, cmd in tools:
pdf.unlink(missing_ok=True) # 清上一个工具的半成品
try:
subprocess.run(
cmd, timeout=_EPS_CONVERT_TIMEOUT_S,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False,
)
except subprocess.TimeoutExpired:
pdf.unlink(missing_ok=True)
return "timeout", tool
except OSError:
continue
if pdf.is_file() and pdf.stat().st_size > 0:
return "ok", tool
pdf.unlink(missing_ok=True)
return "fail", ""
def process_eps_images(project: Path) -> list[str]:
"""EPS 兼容 v2:解压后**批量转换项目内所有 .eps**(不依赖 zip 自带预转换版)。
对每个 X.eps(原 .eps 保留):
1. 已有同名 X.pdf → 跳过转换(引用照常切换为 .pdf);
2. zip 自带 X-eps-converted-to.pdf 预转换版 → 复制为 X.pdf(免转换);
3. 否则现场转换:gs(-dSAFER) → epstopdf → ps2pdf 逐个 fallback(约束1),
单文件 15s 超时 → warning 后继续下一个(约束2)。
转换成功 → 显式 `X.eps` 引用字节替换为 `X.pdf`(.tex/.cls/.sty/.cfg/.def 全扫,
纯字节 ASCII/UTF-8 替换不碰编码);无扩展名引用 `{.../X}` 由 graphicx 直接
命中新生成的 X.pdf(解析顺序 .pdf→…→.eps)。
约束3:单文件转换后 >20MB → warning;转换产物累计 >100MB → 抛
InspectError("ZIP_TOO_LARGE") 中止;约束4:返回值为写入 output.log 的
"[eps-convert]" 日志行(由 _run_project 落盘)。
→ 日志行列表(无 .eps 项目 → 空)。
"""
eps_files = sorted(
p for p in project.rglob("*")
if p.is_file() and p.suffix.lower() == ".eps"
)
if not eps_files:
return []
lines: list[str] = []
handled: list[tuple[bytes, bytes]] = []
total_out = 0
def _rel(p: Path) -> str:
return p.relative_to(project).as_posix()
for eps in eps_files:
pdf = eps.with_suffix(".pdf")
src_rel, dst_rel = _rel(eps), _rel(pdf)
if pdf.is_file():
handled.append((eps.name.encode("utf-8"), pdf.name.encode("utf-8")))
continue
converted = eps.with_name(eps.stem + _EPS_CONVERTED_SUFFIX)
if converted.is_file():
t0 = time.monotonic()
shutil.copyfile(converted, pdf)
elapsed = time.monotonic() - t0
tool = "preset"
else:
t0 = time.monotonic()
status, tool = _convert_eps_file(eps, pdf)
elapsed = time.monotonic() - t0
if status == "timeout":
lines.append(f"[eps-convert] {src_rel} 转换超时") # 约束2
continue
if status != "ok":
lines.append(
f"[eps-convert] 全部工具失败,图片 {src_rel} 未转换")
continue
# 约束4:转换日志(成功路径)
lines.append(
f"[eps-convert] {src_rel} → {dst_rel} "
f"({eps.stat().st_size} → {pdf.stat().st_size}, "
f"{elapsed:.2f}s, {tool})")
size_out = pdf.stat().st_size
if size_out > _EPS_CONVERT_MAX_FILE: # 约束3
lines.append(
f"[eps-convert] 警告:{dst_rel} 转换后 {size_out} 字节"
f"超过 {_EPS_CONVERT_MAX_FILE // (1024 * 1024)}MB 上限")
total_out += size_out
if total_out > _EPS_CONVERT_MAX_TOTAL: # 约束3
raise InspectError(
"ZIP_TOO_LARGE",
f"EPS 转换后总大小超过 {_EPS_CONVERT_MAX_TOTAL // (1024 * 1024)}MB 上限"
f"(已达 {total_out // (1024 * 1024)}MB),请压缩图片后重试")
handled.append((eps.name.encode("utf-8"), pdf.name.encode("utf-8")))
# 显式 .eps 引用 → .pdf(仅对转换成功者;失败的保持 .eps → 编译兜底友好报错)
changed = 0
if handled:
for f in project.rglob("*"):
if not (f.is_file() and f.suffix.lower() in _REF_TEXT_EXTS):
continue
data = f.read_bytes()
new = data
for old_b, new_b in handled:
new = new.replace(old_b, new_b)
if new != data:
f.write_bytes(new)
changed += 1
if changed:
lines.append(f"[eps-convert] {changed} 个文件的显式 .eps 引用已改写为 .pdf")
if lines:
logger.info("eps v2: %s", " | ".join(lines))
return lines
def _append_eps_lines(log_path: Path, lines: list[str] | None) -> None:
"""约束4:把 [eps-convert] 转换日志写入 output.log(引擎 log 之后、chatter 之前)。"""
if not lines:
return
with log_path.open("a", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
def classify_compile_error(log_tail: str) -> str:
"""COMPILE_FAILED 的日志 → 用户友好文案(Q4/§4.4)。"""
m = _MISSING_FILE_RE.search(log_tail)
if m:
name = next(g for g in m.groups() if g)
ext = Path(name).suffix.lower()
if ext == ".cls":
return f"项目缺少 {name},请从期刊官网下载模板后重新上传"
if ext in (".sty", ".def", ".clo"):
return f"缺少 LaTeX 宏包 {name},可能是模板不完整"
if ext in _IMG_LIKE_EXT:
return f"图片 {name} 未在 zip 里,请检查引用路径与大小写"
if ext == ".bib":
return f"缺少文献库 {name},请确认 .bib 已包含在 zip 中"
return f"编译找不到文件 {name},请检查 zip 内文件路径"
m = _IMG_INCLUSION_RE.search(log_tail)
if m:
name = m.group(1)
if Path(name).suffix.lower() == ".eps":
# EPS v2 已现场转换;仍失败 = 服务器转换没成功(工具缺失/超时/格式怪异)
return "项目包含 EPS 图片,服务器转换失败,请转成 PDF/PNG 后重试"
return f"图片 {name} 无法内嵌,请检查图片格式与路径(建议使用 PDF/PNG)"
if re.search(r"Emergency stop|Undefined control sequence", log_tail):
return "LaTeX 源码有语法错误,请展开日志定位出错行"
return "编译失败,请查看日志定位 LaTeX 错误"
async def inspect_zip(zip_bytes: bytes) -> dict[str, Any]:
"""两段式之①:安全解压 + 主文件识别 + 落盘 inspect 暂存(1h TTL)。
→ {job_id, main, stats{files,tex,images,cls,bib,other,bytes}, skipped[], skipped_count}
失败抛 InspectError(路由层转信封);任何失败 → rmtree 暂存目录,磁盘零残留。
"""
from .zip_safety import ZipSafetyError, safe_extract
settings = get_settings()
sweep_inspect_expired() # 机会性清扫(同 P3a 模式)
job_id = uuid.uuid4().hex[:12]
jdir = inspect_root() / job_id
project = jdir / "project"
try:
try:
result = await asyncio.to_thread(safe_extract, zip_bytes, project, settings)
except ZipSafetyError as exc:
raise InspectError(exc.code, exc.message) from exc
main, candidates, tex_total = find_main_tex(project)
if tex_total == 0:
raise InspectError("VALIDATION", "zip 中没有 .tex 文件")
if not candidates:
raise InspectError(
"VALIDATION", "未找到含 \\documentclass 的 .tex 文件")
if main is None:
shown = ", ".join(Path(c).name for c in candidates)
raise InspectError(
"VALIDATION",
f"检测到多个主文件({shown}),请合并为单一入口后重新上传")
meta = {
"job_id": job_id,
"main": main,
"stats": {
"files": result.files, "tex": result.tex, "images": result.images,
"cls": result.cls, "bib": result.bib, "other": result.other,
"bytes": result.bytes,
},
"skipped": [{"name": s.name, "reason": s.reason} for s in result.skipped],
"skipped_count": result.skipped_count,
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
jdir.mkdir(parents=True, exist_ok=True)
(jdir / "inspect.json").write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
return meta
except BaseException:
shutil.rmtree(jdir, ignore_errors=True)
raise
def consume_inspect(job_id: str, cid: str) -> tuple[Path, dict[str, Any]]:
"""两段式之②前置:**原子 rename 消费** inspect 暂存 → compile_jobs/{cid}/project。
并发正确性(防同一 job 双跑):
os.rename(src, dst) 在同一文件系统内是原子的,且 src 不存在 → FileNotFoundError。
两个并发 run 拿同一 job_id 时,只有一个能成功 rename;后到者拿到
FileNotFoundError → 路由层转 VALIDATION"已过期/不存在",**绝不可能双编译**。
inspect 残壳(inspect.json)在 rename 成功后立即删除,也杜绝二跑读到半状态。
"""
settings = get_settings()
if not _ANCHOR_ID_RE.fullmatch(job_id or ""):
raise InspectError("VALIDATION", "job_id 格式非法(应为 12 位 hex)")
src_root = inspect_root() / job_id
meta_path = src_root / "inspect.json"
src_project = src_root / "project"
if not (meta_path.is_file() and src_project.is_dir()):
raise InspectError(
"VALIDATION", "上传任务不存在或已过期(1 小时),请重新上传 zip")
try:
meta = json.loads(meta_path.read_text("utf-8"))
except (OSError, ValueError) as exc:
raise InspectError(
"VALIDATION", "上传任务数据损坏,请重新上传 zip") from exc
dst_root = compile_job_dir(cid)
dst_project = dst_root / "project"
dst_root.mkdir(parents=True, exist_ok=True)
try:
os.rename(src_project, dst_project)
except FileNotFoundError as exc:
raise InspectError(
"VALIDATION", "上传任务不存在或已过期(1 小时),请重新上传 zip") from exc
except OSError as exc: # 跨设备等罕见失败 → 保持 inspect 原样可重试
raise InspectError(
"VALIDATION", "任务消费失败,请重试或重新上传") from exc
shutil.rmtree(src_root, ignore_errors=True) # 消费成功 → 删残壳
return dst_project, meta
async def compile_project_job(job_id: str) -> dict[str, Any]:
"""两段式之②:原子消费 inspect → tectonic 编译 → 产物留 pdf/log/meta。
队列/工具检查**先于消费**(inspect 保留 → 失败可直接重试);
EPS v2:解压后批量转换所有 .eps → 同名 .pdf(gs -dSAFER → epstopdf → ps2pdf,
单文件 15s 超时;产物累计 >100MB → ZIP_TOO_LARGE 中止),转换日志进 output.log;
成功 → 立即删 project/;失败(COMPILE_FAILED/TIMEOUT)→ **保留 project/ 现场 24h**
供诊断(sweep_compile_jobs_expired 按 failed_project_ttl_s 清理;
KEEP_FAILED_PROJECTS=1 永久保留);
失败必落完整 output.log(含 --print 的 XDV→PDF 阶段输出,补充 3)。
→ {compile_id, pdf_path, log_path, seconds, pdf_bytes}
"""
global _pending
settings = get_settings()
_ensure_tool() # 快速失败:不消费 inspect、不占队列
sweep_inspect_expired()
sweep_compile_jobs_expired()
if _pending >= settings.compile_queue_limit:
raise QueueFullError(
f"编译队列已满({_pending} 个任务排队),请稍后重试")
_pending += 1 # 占位先于消费:QUEUE_FULL 时 inspect 未动
cid = uuid.uuid4().hex[:12]
project: Path | None = None
meta: dict[str, Any] = {}
try:
project, meta = await asyncio.to_thread(consume_inspect, job_id, cid)
try:
# EPS v2:解压后批量转换所有 .eps → 同名 .pdf 并切换显式引用
# (不依赖 zip 自带预转换版;失败现场保留转换后的改写结果)
eps_lines = await asyncio.to_thread(process_eps_images, project)
async with _lock:
result = await _run_project(
cid, project, str(meta.get("main", "")), eps_lines)
# 成功 → 立即清理 project 树(失败路径在 finally 保留现场)
shutil.rmtree(project, ignore_errors=True)
project = None
return result
except CompileError as exc:
# 补充 3:失败信封也要能下载 log → 异常携带产物 cid(log 落盘于 compile_jobs/{cid}/)
exc.compile_id = cid
raise
finally:
_pending -= 1
if project is not None:
# 编译失败 → 保留 compile_jobs/{cid}/project/ 现场供诊断;
# touch 目录使 24h 保留窗从失败时刻起算
try:
os.utime(project, None)
except OSError:
pass
async def _run_project(
cid: str,
project: Path,
main_rel: str,
eps_log_lines: list[str] | None = None,
) -> dict[str, Any]:
"""单槽内执行项目编译:cwd=main 所在目录(相对路径解析),outdir=compile_jobs/{cid}。
eps_log_lines:EPS v2 的 [eps-convert] 转换日志(约束4),正常/超时两条路径
都会写进 output.log(引擎 log 之后、chatter/[timeout] 之前)。
"""
settings = get_settings()
outdir = compile_job_dir(cid)
outdir.mkdir(parents=True, exist_ok=True)
main_path = project / main_rel
if not main_rel or not main_path.is_file():
raise InspectError("VALIDATION", "主文件缺失,请重新上传 zip")
cwd = main_path.parent
stem = main_path.stem
env = dict(os.environ)
env["TECTONIC_CACHE_DIR"] = str(settings.tectonic_cache_abs)
# --print:打印引擎完整 chatter(含 XDV→PDF/xdvipdfmx 阶段输出);
# 不加时 xdvipdfmx 报错只进 tectonic stderr、不落 {stem}.log,导致"LaTeX 成功但
# 无 PDF"且 log 无错误行(MDPI 模板实测)。stdout/stderr 统一落盘 output.log。
cmd = [
str(_tectonic_bin()), "-X", "compile", main_path.name,
"--untrusted", "--keep-logs", "--print", "--outdir", str(outdir),
]
timeout = settings.compile_project_timeout_s
log_path = outdir / "output.log"
started = time.monotonic()
try:
proc = await asyncio.create_subprocess_exec(
*cmd, cwd=str(cwd), env=env,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
start_new_session=True,
)
try:
stdout_bytes, _ = await asyncio.wait_for(
proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
_kill_group(proc)
try:
await asyncio.wait_for(proc.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass
# 超时后 communicate 已取消,chatter 不可取;.log(--keep-logs)仍完整
_ensure_project_log(outdir, stem, cwd)
# 约束4:转换日志先落(转换发生在编译之前),再追加超时标记
_append_eps_lines(log_path, eps_log_lines)
# 追加而非覆盖:保留完整引擎输出(诊断 XDV→PDF 阶段必需)
with log_path.open("a", encoding="utf-8") as fh:
fh.write(f"\n[timeout] 编译超时({timeout:g}s),进程已终止\n")
raise CompileError(
"TIMEOUT",
f"编译超过 {timeout:g} 秒未完成,已终止"
"(项目首次编译需下载宏包,可能较慢,请重试)",
_log_tail(log_path),
) from None
except FileNotFoundError as exc:
raise ToolUnavailableError(f"tectonic 不可执行:{exc}") from exc
finally:
seconds = round(time.monotonic() - started, 2)
_ensure_project_log(outdir, stem, cwd)
# 产物归一:{stem}.pdf/.log → output.pdf/.log(补充 3:失败也必有 output.log)
produced_pdf = outdir / f"{stem}.pdf"
produced_log = outdir / f"{stem}.log"
if produced_log.is_file():
shutil.move(str(produced_log), str(log_path))
if not log_path.is_file():
log_path.write_text(
"[no log] tectonic 未产出日志(进程异常退出)", encoding="utf-8")
# 约束4:[eps-convert] 转换日志(引擎 log 之后、chatter 之前)
_append_eps_lines(log_path, eps_log_lines)
# --print 的进程输出(含 XDV→PDF 阶段)追加到 output.log 全文之后;
# 信封 detail 仍只取尾 200 行,磁盘保留完整 log 供"查看完整 log"下载
_append_chatter(log_path, stdout_bytes)
if not produced_pdf.is_file():
raise CompileError(
"COMPILE_FAILED",
classify_compile_error(_log_tail(log_path)),
_log_tail(log_path),
)
pdf_bytes = produced_pdf.stat().st_size
if pdf_bytes > settings.max_compile_pdf_bytes:
produced_pdf.unlink(missing_ok=True)
raise CompileError(
"OUTPUT_TOO_LARGE",
f"产出 PDF 超过 {settings.max_compile_pdf_bytes // (1024 * 1024)}MB 上限,已删除",
_log_tail(log_path),
)
produced_pdf.rename(outdir / "output.pdf")
meta = {
"compile_id": cid,
"project": True,
"main": main_rel,
"engine": _ENGINE,
"seconds": seconds,
"pdf_bytes": pdf_bytes,
"returncode": 0,
"compiled_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
(outdir / "meta.json").write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
logger.info("compile(project): %s main=%s → PDF %d bytes, %.2fs",
cid, main_rel, pdf_bytes, seconds)
return {
"compile_id": cid,
"pdf_path": outdir / "output.pdf",
"log_path": log_path,
"seconds": seconds,
"pdf_bytes": pdf_bytes,
}
def _ensure_project_log(outdir: Path, stem: str, cwd: Path) -> None:
"""tectonic 落 log 到 outdir 或 cwd → 统一挪到 outdir/output.log;都没有则占位。"""
log_path = outdir / "output.log"
if log_path.is_file():
return
for cand in (outdir / f"{stem}.log", cwd / f"{stem}.log"):
if cand.is_file():
shutil.move(str(cand), str(log_path))
return
def _append_chatter(log_path: Path, stdout_bytes: bytes | None) -> None:
"""把 tectonic --print 的进程 stdout(含 XDV→PDF/xdvipdfmx 阶段)追加到 output.log。
{stem}.log 只有 LaTeX 引擎内容(止于 "Output written on … .xdv");
XDV→PDF 转换错误只出现在进程 chatter 里——不落盘则"无 PDF 且 log 无错误行"。
"""
if not stdout_bytes:
return
chatter = stdout_bytes.decode("utf-8", errors="replace").strip()
if not chatter:
return
with log_path.open("a", encoding="utf-8") as fh:
fh.write("\n\n===== tectonic --print(进程输出,含 XDV→PDF 阶段)=====\n")
fh.write(chatter)
fh.write("\n")
def sweep_inspect_expired() -> int:
"""删 mtime 超 inspect_ttl_s(默认 1h)的 12-hex inspect 暂存目录。"""
settings = get_settings()
base = settings.inspect_dir_abs
if not base.is_dir():
return 0
cutoff = time.time() - settings.inspect_ttl_s
removed = 0
for child in base.iterdir():
if not child.is_dir() or not _ANCHOR_ID_RE.fullmatch(child.name):
continue
try:
if child.stat().st_mtime < cutoff:
shutil.rmtree(child, ignore_errors=True)
removed += 1
except OSError:
continue
if removed:
logger.info("inspect sweep: 清理 %d 个过期 inspect 暂存(>%d 秒)",
removed, settings.inspect_ttl_s)
return removed
def _keep_failed_projects() -> bool:
"""失败现场是否永久保留(调试):REVIEW_KEEP_FAILED_PROJECTS=1 或裸 KEEP_FAILED_PROJECTS=1。"""
if get_settings().keep_failed_projects:
return True
return os.environ.get("KEEP_FAILED_PROJECTS", "").strip() == "1"
def sweep_compile_jobs_expired() -> int:
"""编译产物清扫,两级:
① 失败现场 project/ 子目录:mtime 超 failed_project_ttl_s(默认 24h)→ 删子目录
(compile_jobs/{cid} 本体与 output.log/meta 保留,供"查看完整 log");
KEEP_FAILED_PROJECTS=1 跳过(永久保留,调试用)。
② 整个 compile_jobs/{cid}:mtime 超 compile_retention_days(7 天)→ 整目录删。
"""
settings = get_settings()
base = settings.compile_jobs_dir_abs
if not base.is_dir():
return 0
removed = 0
keep_failed = _keep_failed_projects()
# ① 失败现场 24h 清理(成功编译的 project/ 已在 compile_project_job 立即删)
if not keep_failed:
proj_cutoff = time.time() - settings.failed_project_ttl_s
for child in base.iterdir():
if not child.is_dir() or not _ANCHOR_ID_RE.fullmatch(child.name):
continue
proj = child / "project"
if not proj.is_dir():
continue
try:
if proj.stat().st_mtime < proj_cutoff:
shutil.rmtree(proj, ignore_errors=True)
removed += 1
except OSError:
continue
# ② 整目录 7 天清理
cutoff = time.time() - settings.compile_retention_days * 86400
for child in base.iterdir():
if not child.is_dir() or not _ANCHOR_ID_RE.fullmatch(child.name):
continue
try:
if child.stat().st_mtime < cutoff:
shutil.rmtree(child, ignore_errors=True)
removed += 1
except OSError:
continue
if removed:
logger.info("compile_jobs sweep: 清理 %d 个过期项目产物/失败现场", removed)
return removed
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 ""
+288
View File
@@ -0,0 +1,288 @@
r"""services/zip_safety.py —— LaTeX 项目 zip 安全校验与解压(P4,纯函数模块)。
安全清单(每条攻击 → 防御位置,对应设计 §2):
#1 路径穿越 ../../etc/passwd → sanitize_entry_name:拒绝 .. 段/绝对路径/盘符/空段
(\ 已先归一为 /,Windows 反斜杠变体同规则)
#2 反斜杠变体 ..\\..\\x → 同上(name.replace("\\\\", "/") 后再校验)
#3 符号链接条目 → _is_symlink:external_attr 的 S_IFMT == S_IFLNK → 整包拒绝
#4 可执行扩展名 .exe/.sh/.so → classify_entry:DENY_EXT → 整包拒绝(点名条目)
#5 Unix 可执行位 0o111 → _has_exec_bit:普通文件带 x 位 → 整包拒绝(目录 0o755 例外)
#6 无害非白名单 .docx/.log/.zip → classify_entry:skip(记 skipped[];.zip 跳过=天然防嵌套递归)
#7 zip bomb 压缩比 → 预扫 file_size/compress_size > max_zip_ratio → ZIP_BOMB
#8 zip bomb 总量 → 预扫声明累计 > max_extract_bytes → ZIP_BOMB;
流式写盘按**实际字节**再计数(不信头部声明)超限即中止
#9 zip bomb 单条目 → 预扫 file_size > max_entry_bytes → ZIP_BOMB;实写计数兜底
#10 zip bomb 条目数 → 预扫 len(infolist) > max_zip_entries → ZIP_BOMB(先于一切写盘)
#11 深嵌套/超长路径 → 路径深度 > max_path_depth 或总长 > max_path_len → ZIP_UNSAFE
#12 损坏/加密 zip → zipfile.BadZipFile / RuntimeError → ZIP_INVALID
#13 头部谎报小文件 → 实写计数兜底(#8);CRC 错 → ZIP_INVALID
#14 恶意 .tex shell escape → 不在本模块范围(tectonic --untrusted 防护,P3a 已实证)
#15 解压阻塞事件循环 → 调用方 asyncio.to_thread 包裹;本模块上限 200MB/2000 条封顶
设计要点:
- 两段式:①纯预扫(所有结构性检查,全部通过才开始写)→ ②流式写盘(8KB 块 + 实字节计数)。
任何异常 → 调用方 rmtree(dest),磁盘零残留。
- 绝不 extractall;绝不保留可执行位(写盘一律 0o644)。
- 目录条目只建不校验扩展名;SKIP_DIRS 整目录跳过(补充 4 提示对应)。
纯函数:输入 bytes/路径/Settings,输出 ExtractResult 或抛 ZipSafetyError,无 FastAPI/asyncio 依赖。
"""
from __future__ import annotations
import io
import re
import stat
import zipfile
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from typing import Literal
# 危险类型(deny → 整包拒绝)
DENY_EXT = {
".exe", ".com", ".bat", ".cmd", ".sh", ".bash", ".ps1", ".vbs",
".dll", ".so", ".dylib", ".msi", ".jar", ".py", ".rb", ".pl", ".php", ".js",
}
# LaTeX 项目白名单(allow → 解压)
ALLOW_EXT = {
".tex", ".cls", ".sty", ".bst", ".bib", ".bbl", ".blg", ".def", ".clo",
".fd", ".cfg", ".ist", ".dtx", ".ins",
".png", ".jpg", ".jpeg", ".pdf", ".eps", ".svg",
".txt", ".md", ".csv", ".dat", ".ttf", ".otf",
}
# 无扩展名白名单
ALLOW_BARE = {"README", "LICENSE", "Makefile", ".gitignore"}
# 整目录跳过(不进 skipped 计数之外的拒绝路径,直接 skip)
SKIP_DIRS = {".git", "node_modules", "__MACOSX"}
class ZipSafetyError(Exception):
"""zip 不安全/超限/损坏。code ∈ {ZIP_UNSAFE, ZIP_BOMB, ZIP_INVALID}。"""
def __init__(self, code: str, message: str, entry: str = "") -> None:
super().__init__(message)
self.code = code
self.message = message
self.entry = entry
@dataclass
class SkippedEntry:
name: str
reason: str
@dataclass
class ExtractResult:
files: int = 0
tex: int = 0
images: int = 0
cls: int = 0
bib: int = 0
other: int = 0
bytes: int = 0
skipped: list[SkippedEntry] = field(default_factory=list)
skipped_count: int = 0
_IMG_EXT = {".png", ".jpg", ".jpeg", ".eps", ".svg"}
_BARE_RE = re.compile(r"^[A-Za-z]:") # 盘符
_CTRL_RE = re.compile(r"[\x00-\x1f]") # 控制字符
def sanitize_entry_name(raw: str) -> str:
"""归一 + 校验条目名,返回 posix 相对路径;不安全 → ZIP_UNSAFE。
拒绝:绝对路径(/ 或 \\ 开头)、盘符(C:)、任一段 `..`、空段、NUL/控制字符。
"""
name = raw.replace("\\", "/")
if not name or name in ("/", "."):
raise ZipSafetyError("ZIP_UNSAFE", f"zip 含空条目名:{raw!r}", raw)
if name.startswith("/") or _BARE_RE.match(name):
raise ZipSafetyError("ZIP_UNSAFE", f"zip 含绝对路径条目:{raw}", raw)
if _CTRL_RE.search(name):
raise ZipSafetyError("ZIP_UNSAFE", f"zip 条目名含控制字符:{raw!r}", raw)
segs = name.split("/")
if any(s == ".." for s in segs):
raise ZipSafetyError("ZIP_UNSAFE", f"zip 含路径穿越条目:{raw}", raw)
# 空段(//)拒绝;尾段为 ""(目录 / 结尾)在除末位外的段上已由 split 覆盖
if any(s == "" for s in segs[:-1]) or (segs and segs[0] == ""):
raise ZipSafetyError("ZIP_UNSAFE", f"zip 含非法路径条目:{raw}", raw)
depth = len([s for s in segs if s])
if depth > 16:
raise ZipSafetyError("ZIP_UNSAFE", f"zip 条目路径过深({depth} 层):{raw}", raw)
if len(name) > 200:
raise ZipSafetyError("ZIP_UNSAFE", f"zip 条目路径过长({len(name)} 字符):{raw}", raw)
return "/".join(s for s in segs if s)
def classify_entry(name: str, is_dir: bool) -> Literal["allow", "skip", "deny"]:
"""条目分类:目录 → allow(只建);DENY_EXT → deny(整包拒绝);白名单 → allow;其余 → skip。"""
if is_dir:
return "allow"
segs = name.replace("\\", "/").split("/")
# 中间目录段命中 SKIP_DIRS → skip(.git/node_modules 整树跳过)
if any(seg in SKIP_DIRS for seg in segs[:-1]):
return "skip"
p = PurePosixPath(name)
ext = p.suffix.lower()
# 多重扩展如 .synctex.gz:取最后一段即可(gz 不在白名单 → skip)
if ext in DENY_EXT:
return "deny"
if ext in ALLOW_EXT:
return "allow"
if p.name.upper() in {b.upper() for b in ALLOW_BARE}:
return "allow"
return "skip"
def _is_symlink(info: zipfile.ZipInfo) -> bool:
"""external_attr 高 16 位为 Unix mode;S_IFMT == S_IFLNK(0xA000) → 符号链接。"""
mode = (info.external_attr >> 16) & 0xFFFF
return stat.S_IFMT(mode) == stat.S_IFLNK
def _has_exec_bit(info: zipfile.ZipInfo) -> bool:
mode = (info.external_attr >> 16) & 0xFFFF
# Windows 造的 zip(external_attr 无 Unix mode)mode 可能为 0 → 无 x 位
return bool(mode & 0o111)
def _prescan(zf: zipfile.ZipFile, settings) -> list[tuple[zipfile.ZipInfo, str]]:
"""预扫中央目录:条目数 → 路径 → 符号链接/可执行位 → 分类 → 声明大小/压缩比。
全部通过才允许写盘(两段式第一段)。
"""
infos = zf.infolist()
if len(infos) > settings.max_zip_entries:
raise ZipSafetyError(
"ZIP_BOMB", f"zip 条目数超限({len(infos)} > {settings.max_zip_entries})")
plan: list[tuple[zipfile.ZipInfo, str]] = []
declared_total = 0
for info in infos:
is_dir = info.is_dir()
name = sanitize_entry_name(info.filename)
if is_dir:
continue
# #3 符号链接 → 整包拒绝
if _is_symlink(info):
raise ZipSafetyError("ZIP_UNSAFE", f"zip 含符号链接条目:{info.filename}", info.filename)
# #4/#6 分类
kind = classify_entry(name, is_dir=False)
if kind == "deny":
raise ZipSafetyError("ZIP_UNSAFE", f"zip 含危险条目:{info.filename}", info.filename)
if kind == "skip":
continue
# #5 可执行位 → 整包拒绝(仅普通文件;目录已 continue)
if _has_exec_bit(info):
raise ZipSafetyError(
"ZIP_UNSAFE", f"zip 含可执行权限条目:{info.filename}", info.filename)
# #9 单条目声明大小
if info.file_size > settings.max_zip_entry_bytes:
raise ZipSafetyError(
"ZIP_BOMB",
f"zip 条目过大:{info.filename}({info.file_size} 字节)", info.filename)
# #7 压缩比(compress_size=0 且 file_size>0 视为异常)
if info.file_size > 0:
cs = info.compress_size
if cs == 0 or info.file_size / cs > settings.max_zip_ratio:
raise ZipSafetyError(
"ZIP_BOMB",
f"zip 条目压缩比异常(疑似 zip bomb):{info.filename}", info.filename)
declared_total += info.file_size
# #8 声明累计
if declared_total > settings.max_extract_bytes:
raise ZipSafetyError(
"ZIP_BOMB",
f"zip 解压总大小超限(声明 {declared_total} > "
f"{settings.max_extract_bytes} 字节)")
plan.append((info, name))
return plan
def safe_extract(zip_bytes: bytes, dest: Path, settings) -> ExtractResult:
"""安全解压 zip_bytes → dest(新建目录)。
两段式:①预扫(结构性检查全过)②流式写盘(8KB 块 + 实字节计数)。
任何 ZipSafetyError → 调用方负责 rmtree(dest) 清残留。
"""
try:
zf = zipfile.ZipFile(io.BytesIO(zip_bytes))
except zipfile.BadZipFile as exc:
raise ZipSafetyError("ZIP_INVALID", f"zip 文件损坏或不是有效 zip:{exc}") from exc
result = ExtractResult()
with zf:
# ---- 第一段:纯预扫 ----
plan = _prescan(zf, settings)
# ---- 第二段:流式写盘 ----
dest.mkdir(parents=True, exist_ok=True)
written_total = 0
for info, rel in plan:
target = dest / rel
# #1 双保险:resolve 后必须仍在 dest 内
resolved_parent = target.parent.resolve()
if not str(resolved_parent).startswith(str(dest.resolve())):
raise ZipSafetyError(
"ZIP_UNSAFE", f"zip 条目逃逸目标目录:{info.filename}", info.filename)
target.parent.mkdir(parents=True, exist_ok=True)
try:
src = zf.open(info)
except RuntimeError as exc: # 加密条目
raise ZipSafetyError("ZIP_INVALID", f"zip 条目受密码保护:{info.filename}") from exc
try:
with src, open(target, "wb") as out:
while True:
chunk = src.read(8192)
if not chunk:
break
# #8/#13 实写字节计数(不信头部声明)
written_total += len(chunk)
if written_total > settings.max_extract_bytes:
raise ZipSafetyError(
"ZIP_BOMB",
f"zip 实际解压超限(> {settings.max_extract_bytes} 字节),已中止")
out.write(chunk)
except zipfile.BadZipFile as exc: # CRC 校验失败(#12/#13)
raise ZipSafetyError(
"ZIP_INVALID", f"zip 条目损坏(CRC 校验失败):{info.filename}",
info.filename) from exc
# #5 绝不保留可执行位
target.chmod(0o644)
result.files += 1
_tally(result, rel)
result.bytes = written_total # 实写累计(#8 计数器终值)
# 跳过统计(限 20 条 + 总数)
skipped: list[SkippedEntry] = []
skipped_count = 0
for info in zf.infolist():
if info.is_dir():
continue
try:
name = sanitize_entry_name(info.filename)
except ZipSafetyError:
continue # 预扫已拒绝,不会到这里
if classify_entry(name, is_dir=False) == "skip":
skipped_count += 1
if len(skipped) < 20:
skipped.append(SkippedEntry(name=name, reason="unsupported"))
result.skipped = skipped
result.skipped_count = skipped_count
return result
def _tally(result: ExtractResult, rel: str) -> None:
ext = PurePosixPath(rel).suffix.lower()
if ext == ".tex":
result.tex += 1
elif ext in _IMG_EXT or ext == ".png":
result.images += 1
elif ext == ".cls":
result.cls += 1
elif ext == ".bib":
result.bib += 1
else:
result.other += 1
@@ -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 }
}
+4 -1
View File
@@ -30,4 +30,7 @@ set +a
echo "[restart] 启动审稿服务 :$PORT($PYTHON)" echo "[restart] 启动审稿服务 :$PORT($PYTHON)"
nohup "$PYTHON" -m uvicorn "$APP_MODULE" --host "$HOST" --port "$PORT" \ nohup "$PYTHON" -m uvicorn "$APP_MODULE" --host "$HOST" --port "$PORT" \
> "$SCRIPT_DIR/review.log" 2>&1 & > "$SCRIPT_DIR/review.log" 2>&1 &
echo "[restart] 完成;日志 review_service/review.log" # P3b:后台预热 tectonic TeX bundle(非阻塞;仅首次需 1-3 分钟,失败不影响服务)
nohup bash "$SCRIPT_DIR/scripts/prewarm_tectonic.sh" \
> "$SCRIPT_DIR/prewarm.log" 2>&1 &
echo "[restart] 完成;日志 review_service/review.log(预热 prewarm.log)"
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# prewarm_tectonic.sh —— P3b:预下载 tectonic TeX bundle(宏包缓存)
# 目的:服务启动时预热 data/tectonic-cache/,用户首次编译不必等 1-3 分钟下载。
# 用法: bash scripts/prewarm_tectonic.sh (后台跑,非阻塞;restart.sh 自动调起)
# 注:tectonic v0.17.0 无 `download` 子命令——预热 = 用最小 hello.tex 实编译一次,
# 让 bundle/宏包落进 TECTONIC_CACHE_DIR(同 tools/README.md 预热法)。
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$SCRIPT_DIR"
TECTONIC="$SCRIPT_DIR/tools/tectonic"
[[ -x "$TECTONIC" ]] || { echo "[prewarm] tectonic 不存在,跳过:$TECTONIC"; exit 0; }
export TECTONIC_CACHE_DIR="${REVIEW_TECTONIC_CACHE_DIR:-$SCRIPT_DIR/data/tectonic-cache}"
mkdir -p "$TECTONIC_CACHE_DIR"
TMPDIR_PREWARM="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_PREWARM"' EXIT
cat > "$TMPDIR_PREWARM/hello.tex" <<'EOF'
\documentclass{article}
\begin{document}
prewarm
\end{document}
EOF
echo "[prewarm] 预下载 TeX bundle 至 $TECTONIC_CACHE_DIR(首次 1-3 分钟,仅一次)"
if "$TECTONIC" -X compile "$TMPDIR_PREWARM/hello.tex" --untrusted \
--outdir "$TMPDIR_PREWARM" >/dev/null 2>&1; then
echo "[prewarm] OK:宏包缓存就绪,用户首编可秒级命中"
else
echo "[prewarm] 预热失败(网络/磁盘):不影响服务启动,首编时仍会现场拉取" >&2
exit 0
fi
+639
View File
@@ -0,0 +1,639 @@
"""tests/test_compile_zip.py —— P4 项目 zip 两段式编译集成测试。
真实调用 tools/tectonic(宏包已预热缓存);二进制缺失时集成用例 skipif 跳过,
校验/消费/清扫等纯逻辑用例照常运行。
运行: cd review_service && <venv>/bin/python -m pytest -q
"""
from __future__ import annotations
import io
import json
import os
import shutil
import time
import zipfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.config import get_settings
from app.main import app
from app.services import latex_compile as lc
MINIMAL = "\\documentclass[11pt]{article}\n\\begin{document}\nhello p4\n\\end{document}\n"
_TECTONIC_OK = get_settings().tectonic_abs.is_file()
needs_tectonic = pytest.mark.skipif(
not _TECTONIC_OK, reason="tools/tectonic 未安装(bash tools/install_tectonic.sh)"
)
def _zip_bytes(entries: dict[str, str | bytes]) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for name, data in entries.items():
zf.writestr(name, data)
return buf.getvalue()
def _upload(client: TestClient, payload: bytes, name: str = "project.zip"):
return client.post(
"/api/review/compile/inspect",
files={"file": (name, payload, "application/zip")},
)
def _tiny_pdf() -> bytes:
"""极小合法 PDF(1 页 20x20 蓝色方块),供图片内嵌集成用例。"""
stream = b"0 0 1 rg 2 2 10 10 re f\n"
objs = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20]"
b" /Contents 4 0 R /Resources << >> >>",
b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n"
+ stream + b"endstream",
]
out = bytearray(b"%PDF-1.4\n")
offsets = []
for i, body in enumerate(objs, 1):
offsets.append(len(out))
out += f"{i} 0 obj\n".encode() + body + b"\nendobj\n"
xref_pos = len(out)
out += f"xref\n0 {len(objs) + 1}\n".encode()
out += b"0000000000 65535 f \n"
for off in offsets:
out += f"{off:010d} 00000 n \n".encode()
out += (f"trailer\n<< /Size {len(objs) + 1} /Root 1 0 R >>\n"
f"startxref\n{xref_pos}\n%%EOF\n").encode()
return bytes(out)
@pytest.fixture()
def client(tmp_path, monkeypatch):
settings = get_settings()
monkeypatch.setattr(settings, "reformat_dir", str(tmp_path / "jobs"))
monkeypatch.setattr(settings, "inspect_dir", str(tmp_path / "inspect"))
monkeypatch.setattr(settings, "compile_jobs_dir", str(tmp_path / "compile_jobs"))
monkeypatch.setattr(settings, "compile_timeout_s", 60.0)
monkeypatch.setattr(settings, "compile_project_timeout_s", 60.0)
monkeypatch.setattr(lc, "_pending", 0)
with TestClient(app) as c:
yield c
# ================================================================ inspect 校验
def test_inspect_not_zip(client: TestClient):
r = client.post(
"/api/review/compile/inspect",
files={"file": ("x.zip", b"notazip", "application/zip")},
)
assert r.status_code == 200 and r.json()["error"]["code"] == "ZIP_INVALID"
def test_inspect_empty(client: TestClient):
r = _upload(client, b"")
assert r.json()["error"]["code"] == "VALIDATION"
def test_inspect_oversize(client: TestClient, monkeypatch):
monkeypatch.setattr(get_settings(), "max_zip_bytes", 10)
r = _upload(client, _zip_bytes({"main.tex": MINIMAL}))
assert r.json()["error"]["code"] == "VALIDATION"
assert "上限" in r.json()["error"]["message"]
def test_inspect_bad_filename(client: TestClient):
r = _upload(client, _zip_bytes({"main.tex": MINIMAL}), name="../evil.zip")
assert r.json()["error"]["code"] == "VALIDATION"
def test_inspect_path_traversal(client: TestClient):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("main.tex", MINIMAL)
info = zipfile.ZipInfo("../../evil.txt")
zf.writestr(info, "x")
r = _upload(client, buf.getvalue())
assert r.json()["error"]["code"] == "ZIP_UNSAFE"
def test_inspect_denied_ext(client: TestClient):
r = _upload(client, _zip_bytes({"main.tex": MINIMAL, "evil.sh": "#!/bin/sh\n"}))
assert r.json()["error"]["code"] == "ZIP_UNSAFE"
assert "evil.sh" in r.json()["error"]["message"]
def test_inspect_ratio_bomb(client: TestClient, monkeypatch):
monkeypatch.setattr(get_settings(), "max_zip_ratio", 1.5)
r = _upload(client, _zip_bytes({"main.tex": "a" * 100_000}))
assert r.json()["error"]["code"] == "ZIP_BOMB"
def test_inspect_no_tex(client: TestClient):
r = _upload(client, _zip_bytes({"readme.txt": "hi"}))
assert r.json()["error"]["code"] == "VALIDATION"
assert "没有 .tex" in r.json()["error"]["message"]
def test_inspect_no_documentclass(client: TestClient):
r = _upload(client, _zip_bytes({"chapters/a.tex": "just text\n"}))
assert r.json()["error"]["code"] == "VALIDATION"
assert "documentclass" in r.json()["error"]["message"]
def test_inspect_multiple_mains(client: TestClient):
r = _upload(client, _zip_bytes({
"main.tex": MINIMAL, "appendix.tex": MINIMAL}))
body = r.json()
assert body["error"]["code"] == "VALIDATION"
assert "多个主文件" in body["error"]["message"]
# ================================================================ inspect 正常 + 卫生
def test_inspect_happy_path(client: TestClient):
payload = _zip_bytes({
"main.tex": MINIMAL,
"figs/fig1.png": b"\x89PNG\r\n\x1a\nfake",
"refs.bib": "@article{a,\n title={t}\n}\n",
"journal.cls": "% fake cls\n",
"notes.docx": "skip me",
".DS_Store": "junk",
"__MACOSX/._main.tex": "resource fork junk",
})
r = _upload(client, payload)
body = r.json()
assert body["ok"] is True, body
meta = body["meta"]
assert meta["main"] == "main.tex"
stats = meta["stats"]
assert stats["tex"] == 1 and stats["images"] == 1
assert stats["cls"] == 1 and stats["bib"] == 1
assert meta["skipped_count"] >= 2
names = {s["name"] for s in meta["skipped"]}
assert any("notes.docx" in n for n in names)
job_id = meta["job_id"]
assert len(job_id) == 12
jdir = get_settings().inspect_dir_abs / job_id
assert (jdir / "inspect.json").is_file()
assert (jdir / "project" / "main.tex").is_file()
def test_inspect_fail_leaves_no_residue(client: TestClient):
r = _upload(client, _zip_bytes({"main.tex": MINIMAL, "evil.sh": "x"}))
assert r.json()["ok"] is False
base = get_settings().inspect_dir_abs
if base.is_dir():
assert not any(base.iterdir()), "inspect 失败后磁盘有残留"
# ================================================================ run:校验 / 原子消费
def test_run_bad_job_id(client: TestClient):
r = client.post("/api/review/compile/run", json={"job_id": "zz"})
assert r.json()["error"]["code"] == "VALIDATION"
def test_run_unknown_job(client: TestClient):
r = client.post("/api/review/compile/run", json={"job_id": "0123456789ab"})
assert r.json()["error"]["code"] == "VALIDATION"
def test_run_double_consume_rejected(client: TestClient):
"""补充:原子消费——同一 job 第二次 run 必须 VALIDATION(防并发双跑)。"""
job_id = _upload(client, _zip_bytes({"main.tex": MINIMAL})).json()["meta"]["job_id"]
first = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
first_err = first.get("error") or {}
if first_err.get("code") == "TOOL_UNAVAILABLE":
pytest.skip("tectonic 未安装,消费逻辑未触达")
# 首次无论编译成败(消费已发生),二次必失败
assert first["ok"] is True or first["error"]["code"] in (
"COMPILE_FAILED", "TIMEOUT")
second = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
assert second["ok"] is False
assert second["error"]["code"] == "VALIDATION"
# inspect 残壳也不应存在
assert not (get_settings().inspect_dir_abs / job_id / "project").exists()
def test_run_tool_unavailable_keeps_job(client: TestClient, monkeypatch):
"""队列/工具检查先于消费 → TOOL_UNAVAILABLE 时 inspect 保留可重试。"""
job_id = _upload(client, _zip_bytes({"main.tex": MINIMAL})).json()["meta"]["job_id"]
with monkeypatch.context() as m:
m.setattr(get_settings(), "tectonic_path", "tools/definitely-missing")
r = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
assert r["error"]["code"] == "TOOL_UNAVAILABLE"
assert (get_settings().inspect_dir_abs / job_id / "inspect.json").is_file()
def test_run_queue_full_keeps_job(client: TestClient, monkeypatch):
job_id = _upload(client, _zip_bytes({"main.tex": MINIMAL})).json()["meta"]["job_id"]
monkeypatch.setattr(lc, "_pending", 5)
monkeypatch.setattr(get_settings(), "compile_queue_limit", 5)
r = client.post("/api/review/compile/run", json={"job_id": job_id})
assert r.status_code == 429
assert r.json()["error"]["code"] == "QUEUE_FULL"
assert (get_settings().inspect_dir_abs / job_id / "inspect.json").is_file()
# ================================================================ run:真实 tectonic
@needs_tectonic
def test_run_project_happy_path(client: TestClient):
payload = _zip_bytes({"main.tex": MINIMAL})
job_id = _upload(client, payload).json()["meta"]["job_id"]
r = client.post("/api/review/compile/run", json={"job_id": job_id})
body = r.json()
assert body["ok"] is True, body
cid = body["compile_id"]
assert len(cid) == 12
pdf = client.get(body["pdf_url"])
assert pdf.status_code == 200
assert pdf.headers["content-type"].startswith("application/pdf")
assert pdf.content[:5] == b"%PDF-"
log = client.get(body["log_url"])
assert log.status_code == 200
assert "LaTeX2e" in log.text and "Output written" in log.text
meta = client.get(f"/api/review/compile/zip/{cid}")
m = meta.json()
assert m["ok"] is True
assert m["meta"]["project"] is True
assert m["meta"]["main"] == "main.tex"
assert m["meta"]["pdf_bytes"] > 0
# project 树已删,只留产物
cdir = get_settings().compile_jobs_dir_abs / cid
assert not (cdir / "project").exists()
assert (cdir / "output.pdf").is_file()
@needs_tectonic
def test_run_failed_envelope_has_log_url(client: TestClient):
"""补充 3:编译失败信封带 compile_id + log_url,且 log 可下载。"""
bad = "\\documentclass{article}\n\\begin{document}\n\\undefinedcommandxyz\n"
job_id = _upload(client, _zip_bytes({"main.tex": bad})).json()["meta"]["job_id"]
body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
assert body["ok"] is False
assert body["error"]["code"] == "COMPILE_FAILED"
cid = body.get("compile_id")
assert cid and len(cid) == 12
assert body["log_url"] == f"/api/review/compile/zip/{cid}/log"
log = client.get(body["log_url"])
assert log.status_code == 200 and log.text.strip()
@needs_tectonic
def test_run_failed_keeps_project_scene(client: TestClient):
"""失败保留现场(诊断):COMPILE_FAILED → compile_jobs/{cid}/project/ 不删。"""
bad = "\\documentclass{article}\n\\begin{document}\n\\undefinedcommandxyz\n"
job_id = _upload(client, _zip_bytes({"main.tex": bad})).json()["meta"]["job_id"]
body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
assert body["ok"] is False and body["error"]["code"] == "COMPILE_FAILED"
cid = body["compile_id"]
cdir = get_settings().compile_jobs_dir_abs / cid
assert (cdir / "project" / "main.tex").is_file() # 现场保留
assert (cdir / "output.log").is_file()
@needs_tectonic
def test_run_missing_cls_friendly_message(client: TestClient):
"""classify_compile_error:缺 .cls → 期刊模板提示(Q4/§4.4)。"""
tex = "\\documentclass{noexist-cls}\n\\begin{document}\nx\n"
job_id = _upload(client, _zip_bytes({"main.tex": tex})).json()["meta"]["job_id"]
body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
assert body["ok"] is False and body["error"]["code"] == "COMPILE_FAILED"
assert "noexist-cls" in body["error"]["message"]
@needs_tectonic
def test_run_project_malicious_write18(client: TestClient, tmp_path):
canary = tmp_path / "canary_p4"
evil = (
"\\documentclass{article}\n"
f"\\write18{{touch {canary}}}\n"
"\\begin{document}\nno shell please\n\\end{document}\n"
)
job_id = _upload(client, _zip_bytes({"main.tex": evil})).json()["meta"]["job_id"]
client.post("/api/review/compile/run", json={"job_id": job_id})
assert not canary.exists(), r"\write18 竟然执行了!"
@needs_tectonic
def test_run_project_timeout(client: TestClient, monkeypatch):
monkeypatch.setattr(get_settings(), "compile_project_timeout_s", 5.0)
loop_tex = (
"\\documentclass{article}\n\\begin{document}\n"
"\\count255=0\n\\loop\\iftrue\\advance\\count255 by 1\\repeat\n"
"\\end{document}\n"
)
job_id = _upload(client, _zip_bytes({"main.tex": loop_tex})).json()["meta"]["job_id"]
started = time.monotonic()
body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
elapsed = time.monotonic() - started
assert body["ok"] is False and body["error"]["code"] == "TIMEOUT"
assert elapsed < 15.0, f"超时回收过慢:{elapsed:.1f}s"
# 超时也必落 log(补充 3)
log = client.get(body["log_url"])
assert log.status_code == 200
assert "[timeout]" in log.text
# ================================================================ EPS 兼容 v2(批量现场转换)
_MIN_EPS = (
b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n"
b"0 0 1 setrgbcolor 0 0 10 10 rectfill\nshowpage\n%%EOF\n"
)
def _eps_project(tmp_path, tex_body: str = "") -> Path:
proj = tmp_path / "p"
proj.mkdir()
(proj / "fig.eps").write_bytes(_MIN_EPS)
(proj / "main.tex").write_text(
"\\documentclass{article}\n\\usepackage{graphicx}\n"
"\\begin{document}\n" + tex_body + "\n\\end{document}\n",
encoding="utf-8")
return proj
def test_eps_v2_preset_converted(tmp_path, monkeypatch):
"""zip 自带 *-eps-converted-to.pdf → 免转换复制为 X.pdf + 显式引用改写。"""
# other.eps 无转换版 → mock 工具为空使其失败,隔离 preset 逻辑(不真跑 gs)
monkeypatch.setattr(lc, "_eps_tool_commands", lambda eps, pdf: [])
proj = _eps_project(tmp_path)
defs = proj / "Definitions"
defs.mkdir()
(defs / "logo-mdpi.eps").write_bytes(_MIN_EPS)
(defs / "logo-mdpi-eps-converted-to.pdf").write_bytes(_tiny_pdf())
(defs / "other.eps").write_bytes(b"%!PS\n") # 无转换版 → 走现场转换(被 mock 挡下)
(defs / "mdpi.cls").write_text(
"\\includegraphics{Definitions/logo-mdpi.eps}\n"
"\\includegraphics{Definitions/other.eps}\n", encoding="utf-8")
lines = lc.process_eps_images(proj)
cls = (defs / "mdpi.cls").read_text(encoding="utf-8")
assert "Definitions/logo-mdpi.pdf" in cls # 显式引用已改写为 .pdf
assert "Definitions/other.eps" in cls # 转换失败的保持原样
assert (defs / "logo-mdpi.eps").is_file() # 原文件保留
assert (defs / "logo-mdpi.pdf").is_file() # 免转换复制
assert not (defs / "other.pdf").exists()
assert any(", preset)" in l for l in lines), lines
assert any("全部工具失败,图片 Definitions/other.eps 未转换" in l
for l in lines), lines
def test_eps_v2_real_gs_convert(tmp_path):
"""无预转换版 → 现场 gs 转换(真实工具链;测试点:显式 + 缺省扩展名引用都就位)。"""
if not shutil.which("gs"):
pytest.skip("gs 未安装")
proj = _eps_project(tmp_path, "\\includegraphics{fig.eps}\n\\includegraphics{fig}")
lines = lc.process_eps_images(proj)
assert any("[eps-convert] fig.eps → fig.pdf" in l and ", gs)" in l for l in lines)
pdf = proj / "fig.pdf"
assert pdf.is_file() and pdf.read_bytes()[:5] == b"%PDF-"
tex = (proj / "main.tex").read_text(encoding="utf-8")
assert "{fig.pdf}" in tex # 显式 .eps 引用已改写
assert "{fig}" in tex # 缺省扩展名引用由 X.pdf 命中
assert (proj / "fig.eps").is_file() # 原文件保留
def test_eps_v2_tool_fallback(tmp_path, monkeypatch):
"""工具优先级 gs → epstopdf → ps2pdf:gs 失败 → epstopdf 成功。"""
def fake_tools(eps: Path, pdf: Path):
return [
("gs", ["sh", "-c", "exit 1"]),
("epstopdf", ["sh", "-c", f"echo %PDF-fake > {pdf}"]),
]
monkeypatch.setattr(lc, "_eps_tool_commands", fake_tools)
proj = _eps_project(tmp_path, "\\includegraphics{fig.eps}")
lines = lc.process_eps_images(proj)
assert any(l.startswith("[eps-convert] fig.eps → fig.pdf") and ", epstopdf)" in l
for l in lines), lines
assert (proj / "fig.pdf").is_file()
assert "{fig.pdf}" in (proj / "main.tex").read_text(encoding="utf-8")
def test_eps_v2_all_tools_fail(tmp_path, monkeypatch):
"""全工具失败 → log 行「全部工具失败…未转换」,引用保持 .eps。"""
monkeypatch.setattr(
lc, "_eps_tool_commands",
lambda eps, pdf: [("gs", ["sh", "-c", "exit 1"])])
proj = _eps_project(tmp_path, "\\includegraphics{fig.eps}")
lines = lc.process_eps_images(proj)
assert "[eps-convert] 全部工具失败,图片 fig.eps 未转换" in lines
assert not (proj / "fig.pdf").exists()
assert "{fig.eps}" in (proj / "main.tex").read_text(encoding="utf-8")
def test_eps_v2_timeout_warns(tmp_path, monkeypatch):
"""约束2:单文件转换 15s 超时 → warning 行,放弃该文件继续。"""
monkeypatch.setattr(lc, "_EPS_CONVERT_TIMEOUT_S", 1)
monkeypatch.setattr(
lc, "_eps_tool_commands",
lambda eps, pdf: [("gs", ["sh", "-c", "sleep 5"])])
proj = _eps_project(tmp_path)
lines = lc.process_eps_images(proj)
assert "[eps-convert] fig.eps 转换超时" in lines
assert not (proj / "fig.pdf").exists()
def test_eps_v2_file_size_warning(tmp_path, monkeypatch):
"""约束3a:单文件转换后超上限 → warning 行(不中止)。"""
monkeypatch.setattr(lc, "_EPS_CONVERT_MAX_FILE", 10)
monkeypatch.setattr(
lc, "_eps_tool_commands",
lambda eps, pdf: [("gs", ["sh", "-c", f"head -c 1024 /dev/zero > {pdf}"])])
proj = _eps_project(tmp_path)
lines = lc.process_eps_images(proj)
assert any("警告" in l and "超过" in l for l in lines), lines
def test_eps_v2_total_size_aborts(tmp_path, monkeypatch):
"""约束3b:转换产物累计超上限 → InspectError(ZIP_TOO_LARGE) 中止。"""
monkeypatch.setattr(lc, "_EPS_CONVERT_MAX_TOTAL", 100)
monkeypatch.setattr(
lc, "_eps_tool_commands",
lambda eps, pdf: [("gs", ["sh", "-c", f"head -c 1024 /dev/zero > {pdf}"])])
proj = _eps_project(tmp_path)
with pytest.raises(lc.InspectError) as ei:
lc.process_eps_images(proj)
assert ei.value.code == "ZIP_TOO_LARGE"
assert "MB" in ei.value.message
@needs_tectonic
def test_run_eps_with_converted_pdf_succeeds(client: TestClient):
"""EPS + 预转换 PDF(preset 路径)→ 显式与缺省扩展名引用都编译成功。"""
tex = (
"\\documentclass{article}\n\\usepackage{graphicx}\n"
"\\begin{document}\n"
"\\includegraphics[width=1cm]{fig.eps}\n" # 显式 .eps → 文本替换
"\\includegraphics[width=1cm]{fig}\n" # 缺省扩展名 → X.pdf 命中
"\\end{document}\n"
)
payload = _zip_bytes({
"main.tex": tex,
"fig.eps": _MIN_EPS,
"fig-eps-converted-to.pdf": _tiny_pdf(),
})
job_id = _upload(client, payload).json()["meta"]["job_id"]
body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
assert body["ok"] is True, body
pdf = client.get(body["pdf_url"])
assert pdf.content[:5] == b"%PDF-"
@needs_tectonic
def test_run_eps_real_gs_conversion_succeeds(client: TestClient):
"""核心 v2 场景:真实 EPS 无预转换版 → 服务器现场转换 → 端到端编译成功。"""
if not shutil.which("gs"):
pytest.skip("gs 未安装")
tex = (
"\\documentclass{article}\n\\usepackage{graphicx}\n"
"\\begin{document}\n"
"\\includegraphics[width=1cm]{fig.eps}\n"
"\\includegraphics[width=1cm]{fig}\n"
"\\end{document}\n"
)
payload = _zip_bytes({"main.tex": tex, "fig.eps": _MIN_EPS})
job_id = _upload(client, payload).json()["meta"]["job_id"]
body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
assert body["ok"] is True, body
assert client.get(body["pdf_url"]).content[:5] == b"%PDF-"
# 约束4:转换日志落 output.log
log = client.get(body["log_url"]).text
assert "[eps-convert] fig.eps → fig.pdf" in log and ", gs)" in log
@needs_tectonic
def test_run_eps_all_tools_fail_friendly_message(
client: TestClient, monkeypatch):
"""测试点2+3:全工具失败 → log 记「全部工具失败」→ 编译失败 → 前端友好提示。"""
monkeypatch.setattr(lc, "_eps_tool_commands", lambda eps, pdf: [])
tex = (
"\\documentclass{article}\n\\usepackage{graphicx}\n"
"\\begin{document}\n\\includegraphics[width=1cm]{fig.eps}\n\\end{document}\n"
)
payload = _zip_bytes({"main.tex": tex, "fig.eps": _MIN_EPS})
job_id = _upload(client, payload).json()["meta"]["job_id"]
body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
assert body["ok"] is False and body["error"]["code"] == "COMPILE_FAILED"
assert body["error"]["message"] == \
"项目包含 EPS 图片,服务器转换失败,请转成 PDF/PNG 后重试"
log = client.get(body["log_url"]).text
assert "[eps-convert] 全部工具失败,图片 fig.eps 未转换" in log
# ================================================================ zip 只读端点 404
def test_zip_meta_404(client: TestClient):
r = client.get("/api/review/compile/zip/0123456789ab")
assert r.json()["error"]["code"] == "NOT_FOUND"
def test_zip_pdf_404(client: TestClient):
r = client.get("/api/review/compile/zip/0123456789ab/pdf")
assert r.json()["error"]["code"] == "NOT_FOUND"
def test_zip_log_404(client: TestClient):
r = client.get("/api/review/compile/zip/0123456789ab/log")
assert r.json()["error"]["code"] == "NOT_FOUND"
# ================================================================ 清扫
def test_sweep_inspect_expired(tmp_path, monkeypatch):
settings = get_settings()
monkeypatch.setattr(settings, "inspect_dir", str(tmp_path / "inspect"))
base = tmp_path / "inspect"
stale = base / "aabbccddeeff"
stale.mkdir(parents=True)
(stale / "inspect.json").write_text("{}", encoding="utf-8")
(stale / "project").mkdir()
old = time.time() - 2 * 3600
os.utime(stale, (old, old))
fresh = base / "112233445566"
fresh.mkdir()
noise = base / "not-a-job"
noise.mkdir()
removed = lc.sweep_inspect_expired()
assert removed == 1
assert not stale.exists()
assert fresh.exists()
assert noise.exists()
def test_sweep_compile_jobs_expired(tmp_path, monkeypatch):
settings = get_settings()
monkeypatch.setattr(settings, "compile_jobs_dir", str(tmp_path / "cj"))
base = tmp_path / "cj"
stale = base / "aabbccddeeff"
stale.mkdir(parents=True)
(stale / "output.pdf").write_bytes(b"%PDF-fake")
old = time.time() - 8 * 86400
os.utime(stale, (old, old))
fresh = base / "112233445566"
fresh.mkdir()
(fresh / "output.pdf").write_bytes(b"%PDF-fake")
noise = base / "not-a-job"
noise.mkdir()
removed = lc.sweep_compile_jobs_expired()
assert removed == 1
assert not stale.exists()
assert fresh.exists()
assert noise.exists()
def test_sweep_failed_project_ttl(tmp_path, monkeypatch):
"""两级清扫①:失败现场 project/ 超 failed_project_ttl_s → 只删子目录,log 保留。"""
settings = get_settings()
monkeypatch.setattr(settings, "compile_jobs_dir", str(tmp_path / "cj"))
monkeypatch.delenv("KEEP_FAILED_PROJECTS", raising=False)
base = tmp_path / "cj"
stale = base / "aabbccddeeff"
(stale / "project").mkdir(parents=True)
(stale / "output.log").write_text("log", encoding="utf-8")
old = time.time() - settings.failed_project_ttl_s - 3600
os.utime(stale / "project", (old, old))
os.utime(stale, (old, old)) # 整目录也旧,但未超 7 天 → ② 不触发
fresh = base / "112233445566"
(fresh / "project").mkdir(parents=True)
(fresh / "output.log").write_text("log", encoding="utf-8")
removed = lc.sweep_compile_jobs_expired()
assert removed == 1
assert not (stale / "project").exists() # 现场删
assert (stale / "output.log").is_file() # log 留(完整 log 下载)
assert (fresh / "project").exists()
def test_sweep_failed_project_kept_when_flag(tmp_path, monkeypatch):
"""KEEP_FAILED_PROJECTS=1:失败现场永久保留(跳过①)。"""
settings = get_settings()
monkeypatch.setattr(settings, "compile_jobs_dir", str(tmp_path / "cj"))
monkeypatch.setenv("KEEP_FAILED_PROJECTS", "1")
base = tmp_path / "cj"
stale = base / "aabbccddeeff"
(stale / "project").mkdir(parents=True)
old = time.time() - settings.failed_project_ttl_s - 3600
os.utime(stale / "project", (old, old))
removed = lc.sweep_compile_jobs_expired()
assert removed == 0
assert (stale / "project").exists()
+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
+195
View File
@@ -0,0 +1,195 @@
"""tests/test_zip_safety.py —— P4 zip 安全模块纯逻辑测试(无需 tectonic)。
覆盖设计 §2 安全清单 #1-#13(#14 tectonic --untrusted 归 test_compile.py,
#15 由调用方 to_thread 承担,模块本身只测上限封顶行为)。
运行: cd review_service && <venv>/bin/python -m pytest -q tests/test_zip_safety.py
"""
from __future__ import annotations
import io
import stat
import zipfile
from pathlib import Path
import pytest
from app.config import get_settings
from app.services.zip_safety import (
ExtractResult,
ZipSafetyError,
classify_entry,
safe_extract,
sanitize_entry_name,
)
def _settings(**over):
"""取真实 settings 并覆盖上限(dataclass 风格 monkeypatch)。"""
s = get_settings()
for k, v in over.items():
setattr(s, k, v)
return s
def _make_zip(entries: dict[str, bytes], exec_bits: dict[str, int] | None = None) -> bytes:
"""entries: name → content;exec_bits: name → unix mode(如 0o100755)。
必须显式 ZIP_DEFLATED:手动构造的 ZipInfo 默认 ZIP_STORED(ratio=1,测不出压缩比炸弹)。
"""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for name, data in entries.items():
info = zipfile.ZipInfo(name)
info.compress_type = zipfile.ZIP_DEFLATED
if exec_bits and name in exec_bits:
info.external_attr = exec_bits[name] << 16
zf.writestr(info, data)
return buf.getvalue()
def _symlink_zip(name: str = "link") -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
info = zipfile.ZipInfo(name)
info.external_attr = (stat.S_IFLNK | 0o777) << 16 # 0xA1FF
zf.writestr(info, "../../etc/passwd")
return buf.getvalue()
# ================================================================ sanitize(#1/#2/#11)
class TestSanitize:
def test_normal_path_ok(self):
assert sanitize_entry_name("main.tex") == "main.tex"
assert sanitize_entry_name("figs/a.png") == "figs/a.png"
assert sanitize_entry_name("a/b/c/") == "a/b/c" # 目录尾斜杠归一
def test_path_traversal_rejected(self): # #1
with pytest.raises(ZipSafetyError) as e:
sanitize_entry_name("../../etc/passwd")
assert e.value.code == "ZIP_UNSAFE"
def test_backslash_traversal_rejected(self): # #2 Windows 变体
with pytest.raises(ZipSafetyError) as e:
sanitize_entry_name("..\\..\\evil.sh")
assert e.value.code == "ZIP_UNSAFE"
def test_absolute_path_rejected(self):
with pytest.raises(ZipSafetyError):
sanitize_entry_name("/etc/passwd")
with pytest.raises(ZipSafetyError):
sanitize_entry_name("C:\\Windows\\evil")
def test_control_char_rejected(self):
with pytest.raises(ZipSafetyError):
sanitize_entry_name("ma\x00in.tex")
def test_depth_limit(self): # #11
deep = "/".join(f"d{i}" for i in range(20)) + "/x.tex"
with pytest.raises(ZipSafetyError) as e:
sanitize_entry_name(deep)
assert e.value.code == "ZIP_UNSAFE"
def test_length_limit(self):
with pytest.raises(ZipSafetyError):
sanitize_entry_name("a" * 250 + ".tex")
# ================================================================ classify(#4/#6)
class TestClassify:
def test_allow(self):
for n in ("main.tex", "journal.cls", "refs.bib", "figs/a.png", "x.sty"):
assert classify_entry(n, False) == "allow"
assert classify_entry("figs/", True) == "allow"
def test_deny_dangerous(self): # #4
for n in ("run.sh", "evil.exe", "x.so", "x.dll", "a.py", "b.js"):
assert classify_entry(n, False) == "deny"
def test_skip_harmless_non_whitelist(self): # #6
for n in ("notes.docx", "old.log", "archive.zip", "img.gif"):
assert classify_entry(n, False) == "skip"
def test_skip_dirs(self): # #6 附属
assert classify_entry("node_modules/x.js", False) == "skip"
assert classify_entry(".git/config", False) == "skip"
assert classify_entry("__MACOSX/._a.tex", False) == "skip"
# ================================================================ safe_extract(#3/#5/#7-#10/#12/#13)
class TestSafeExtract:
def test_happy_path(self, tmp_path: Path):
zb = _make_zip({"main.tex": b"hi", "figs/a.png": b"\x89PNG"})
r = safe_extract(zb, tmp_path / "p", _settings())
assert isinstance(r, ExtractResult)
assert r.files == 2 and r.tex == 1 and r.images == 1
assert (tmp_path / "p" / "main.tex").read_text() == "hi"
assert (tmp_path / "p" / "figs" / "a.png").exists()
def test_symlink_rejected(self, tmp_path: Path): # #3
with pytest.raises(ZipSafetyError) as e:
safe_extract(_symlink_zip(), tmp_path / "p", _settings())
assert e.value.code == "ZIP_UNSAFE"
assert not (tmp_path / "p").exists() # 预扫拒绝 → 未写盘
def test_deny_ext_rejects_whole_zip(self, tmp_path: Path): # #4
zb = _make_zip({"main.tex": b"x", "run.sh": b"#!/bin/sh"})
with pytest.raises(ZipSafetyError) as e:
safe_extract(zb, tmp_path / "p", _settings())
assert e.value.code == "ZIP_UNSAFE" and "run.sh" in e.value.message
assert not (tmp_path / "p").exists()
def test_exec_bit_rejects(self, tmp_path: Path): # #5
zb = _make_zip({"main.tex": b"x"}, exec_bits={"main.tex": 0o100755})
with pytest.raises(ZipSafetyError) as e:
safe_extract(zb, tmp_path / "p", _settings())
assert e.value.code == "ZIP_UNSAFE"
def test_harmless_skipped_not_rejected(self, tmp_path: Path): # #6 决策1
zb = _make_zip({"main.tex": b"x", "notes.docx": b"DOC", "old.zip": b"PK"})
r = safe_extract(zb, tmp_path / "p", _settings())
assert r.files == 1
assert r.skipped_count == 2
assert {s.name for s in r.skipped} == {"notes.docx", "old.zip"}
assert not (tmp_path / "p" / "notes.docx").exists()
def test_ratio_bomb_rejected(self, tmp_path: Path): # #7
zb = _make_zip({"big.txt": b"\x00" * (2 * 1024 * 1024)}) # 全零 → ratio ≫100
with pytest.raises(ZipSafetyError) as e:
safe_extract(zb, tmp_path / "p", _settings())
assert e.value.code == "ZIP_BOMB"
def test_total_bytes_rejected_runtime(self, tmp_path: Path): # #8 声明上限
zb = _make_zip({"a.txt": b"x" * 5000, "b.txt": b"y" * 5000})
with pytest.raises(ZipSafetyError) as e:
safe_extract(zb, tmp_path / "p", _settings(max_extract_bytes=6000))
assert e.value.code == "ZIP_BOMB"
def test_entry_bytes_rejected(self, tmp_path: Path): # #9
zb = _make_zip({"a.txt": b"x" * 10000})
with pytest.raises(ZipSafetyError) as e:
safe_extract(zb, tmp_path / "p", _settings(max_zip_entry_bytes=1000))
assert e.value.code == "ZIP_BOMB"
def test_entry_count_rejected(self, tmp_path: Path): # #10
zb = _make_zip({f"f{i}.txt": b"x" for i in range(30)})
with pytest.raises(ZipSafetyError) as e:
safe_extract(zb, tmp_path / "p", _settings(max_zip_entries=10))
assert e.value.code == "ZIP_BOMB"
assert not (tmp_path / "p").exists() # 预扫先于写盘
def test_corrupt_zip(self, tmp_path: Path):
with pytest.raises(ZipSafetyError) as e:
safe_extract(b"not a zip at all", tmp_path / "p", _settings())
assert e.value.code == "ZIP_INVALID"
def test_traversal_entry_rejected_end_to_end(self, tmp_path: Path): # #1 端到端
zb = _make_zip({"../evil.txt": b"pwned"})
with pytest.raises(ZipSafetyError) as e:
safe_extract(zb, tmp_path / "p", _settings())
assert e.value.code == "ZIP_UNSAFE"
assert not (tmp_path / "evil.txt").exists() # 磁盘无残留
assert not (tmp_path / "p").exists()
+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('任务列表 → 详情展开 → 运行日志 → 上传(mock 成功)', async ({ page }) => {
test.setTimeout(120_000); test.setTimeout(120_000);
await cleanupSessionsByTitle(page, 'R1抽取'); await cleanupSessionsByTitle(page, 'R1抽取');
@@ -109,7 +109,7 @@ test.describe('E2E-08 提取任务', () => {
await page.goto('/'); await page.goto('/');
await expect(page.locator('.app-shell')).toBeVisible(); await expect(page.locator('.app-shell')).toBeVisible();
await selectBenchTab(page, '提取任务'); await selectBenchTab(page, '上传文献');
const sc = benchScope(page); const sc = benchScope(page);
const extPane = sc.locator('.extraction-pane'); 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(); expect(w.find('.send-btn').attributes('disabled')).toBeDefined();
}); });
it('模式 pill 三态切换(labels.MODES)', async () => { it('模式 pill 已随 43a7c6c「去模式按钮」移除:Composer 不渲染三态切换', () => {
const w = mountComposer({ mode: 'ask' }); const w = mountComposer({ mode: 'ask' });
const pills = w.findAll('.mode-pill'); expect(w.findAll('.mode-pill')).toHaveLength(0);
expect(pills).toHaveLength(3); expect(w.emitted('change-mode')).toBeUndefined();
expect(pills[0].classes()).toContain('on'); expect(w.find('textarea').exists()).toBe(true); // 主体输入仍在
await pills[1].trigger('click');
expect(w.emitted('change-mode')[0][0]).toBe('plan');
}); });
it('输入法组合键 Enter 不触发发送', async () => { 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 wrapper = mount(BenchPanel, { global: { plugins: [pinia] } });
const tabs = wrapper.findAll('.bench-tab'); const tabs = wrapper.findAll('.bench-tab');
expect(tabs).toHaveLength(5); 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('检索过程'); 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 wrapper = mount(BenchPanel, { global: { plugins: [pinia] } });
const tabs = wrapper.findAll('.bench-tab'); const tabs = wrapper.findAll('.bench-tab');
await tabs[2].trigger('click'); 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); expect(wrapper.findComponent({ name: 'ExtractionPane' }).exists()).toBe(true);
await tabs[3].trigger('click'); await tabs[3].trigger('click');
expect(wrapper.findComponent({ name: 'KgPane' }).exists()).toBe(true); 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] } }); const w = mount(BenchPanel, { global: { plugins: [pinia] } });
await flushPromises(); await flushPromises();
const labels = w.findAll('.bench-tab').map((t) => t.text()); 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 () => { 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] } }); const w = mount(BenchPanel, { global: { plugins: [pinia] } });
await flushPromises(); await flushPromises();
const labels = w.findAll('.bench-tab').map((t) => t.text()); const labels = w.findAll('.bench-tab').map((t) => t.text());
expect(labels).toEqual(['检索过程', '文献来源', '提取任务', '知识图谱', '文档']); expect(labels).toEqual(['检索过程', '文献来源', '上传文献', '知识图谱', '文档']);
expect(labels).not.toContain('生信任务'); expect(labels).not.toContain('生信任务');
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');
});
});
@@ -0,0 +1,178 @@
// tests/frontend/unit/edu/reformat.compile.test.js —— ReformatPanel 编译 PDF Tab(P3b)
// 1) 无 job_id → "请先完成重排版再编译" 引导
// 2) 有 job_id → "编译 PDF" 按钮可点
// 3) 点击后按钮变 "编译中…"(loading 期间禁用;store 状态驱动,切面板不中断)
// 4) 成功状态:iframe 预览 + 下载按钮 + 耗时
// 5) 失败状态:错误摘要 + log 尾折叠 <pre> + 查看完整 log
// 6) QUEUE_FULL → 固定文案"编译队列已满(最多 5 个任务)"
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import ReformatPanel from '../../../../ui/src/components/edu/panels/ReformatPanel.vue';
const compileTexMock = vi.fn();
vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
const actual = await importOriginal(); // 保留真实 reviewErrorMessage(QUEUE_FULL 文案走 CODE_HINTS)
return {
...actual,
getJournals: vi.fn().mockResolvedValue({ ok: true, items: [] }),
getDirectory: vi.fn(),
reformatTex: vi.fn(),
compileTex: (...args) => compileTexMock(...args),
};
});
import { useEduStore } from '../../../../ui/src/stores/edu.js';
import { ApiError } from '../../../../ui/src/api/http.js';
function seedResult(edu, job_id = 'abc123def456') {
edu.reformatResult = {
ok: true,
job_id,
modified_tex: '\\documentclass{article}\\begin{document}Hi\\end{document}',
changes: [],
warnings: [],
meta: { filename: 'paper.tex', journal_name: 'Plant Cell', rules_source: 'official' },
};
edu.reformatStatus = 'ready';
}
async function mountPdfTab(edu) {
const pinia = createPinia();
setActivePinia(pinia);
edu = useEduStore();
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
// 切到"编译 PDF" Tab
const tabs = wrapper.findAll('.rf-tab');
const pdfTab = tabs.find((t) => t.text() === '编译 PDF');
await pdfTab.trigger('click');
return { wrapper, edu };
}
describe('ReformatPanel 编译 PDF Tab(P3b)', () => {
beforeEach(() => {
compileTexMock.mockReset();
// jsdom 无 URL.createObjectURL:store blob 加载走 catch 回退,不致挂
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
});
it('1) 无 job_id → "请先完成重排版再编译" 引导', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
edu.reformatResult = { ok: true, job_id: null, changes: [], meta: {} };
edu.reformatStatus = 'ready';
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
const guide = wrapper.find('[data-testid="rf-compile-guide"]');
expect(guide.exists()).toBe(true);
expect(guide.text()).toContain('请先完成重排版再编译');
expect(wrapper.find('[data-testid="rf-compile-btn"]').exists()).toBe(false);
});
it('2) 有 job_id → "编译 PDF" 按钮可点', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
seedResult(edu);
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
const btn = wrapper.find('[data-testid="rf-compile-btn"]');
expect(btn.exists()).toBe(true);
expect(btn.text()).toBe('编译 PDF');
expect(btn.attributes('disabled')).toBeUndefined();
});
it('3) 点击后按钮变"编译中…"并禁用;首次编译黄条出现', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
seedResult(edu);
compileTexMock.mockReturnValue(new Promise(() => {})); // 挂起,模拟编译中
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
await wrapper.vm.$nextTick();
const btn = wrapper.find('[data-testid="rf-compile-btn"]');
expect(btn.text()).toContain('编译中');
expect(btn.attributes('disabled')).toBeDefined();
expect(wrapper.find('[data-testid="rf-compile-loading"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="rf-first-hint"]').exists()).toBe(true);
expect(compileTexMock).toHaveBeenCalledTimes(1);
// loading 期间再点不重复提交
await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
expect(compileTexMock).toHaveBeenCalledTimes(1);
});
it('4) 成功状态:iframe 预览 + 下载按钮 + 耗时', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
seedResult(edu);
edu.compileResult = {
ok: true, compile_id: 'abc123def456',
pdf_url: '/api/review/compile/abc123def456/pdf',
log_url: '/api/review/compile/abc123def456/log',
meta: { seconds: 12.34, pdf_bytes: 1048576, engine: 'tectonic' },
};
edu.compileStatus = 'ready';
edu.compilePdfUrl = ''; // blob 失败回退直链
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
await wrapper.vm.$nextTick();
const frame = wrapper.find('[data-testid="rf-pdf-frame"]');
expect(frame.exists()).toBe(true);
expect(frame.attributes('src')).toContain('/pdf');
expect(wrapper.find('[data-testid="rf-download-pdf"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="rf-pdf-size"]').text()).toContain('MB');
expect(wrapper.find('[data-testid="rf-compile-secs"]').text()).toContain('12.3 秒');
expect(wrapper.find('[data-testid="rf-recompile"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="rf-compile-btn"]').exists()).toBe(false); // 操作行换成下载/重编
});
it('5) 失败状态:错误摘要 + log 尾折叠区 + 完整 log 按钮', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
seedResult(edu);
edu.compileStatus = 'error';
edu.compileError = 'LaTeX 编译失败:缺少宏包';
edu.compileLogTail = 'This is tectonic\n! LaTeX Error: File `foo.sty\' not found.\n68 lines omitted...';
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
await wrapper.vm.$nextTick();
const err = wrapper.find('[data-testid="rf-compile-error"]');
expect(err.exists()).toBe(true);
expect(err.text()).toContain('缺少宏包');
const details = wrapper.find('[data-testid="rf-log-details"]');
expect(details.exists()).toBe(true);
expect(wrapper.find('[data-testid="rf-log-body"]').text()).toContain('foo.sty');
expect(wrapper.find('[data-testid="rf-download-log"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="rf-recompile"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="rf-pdf-frame"]').exists()).toBe(false);
});
it('6) QUEUE_FULL → 固定文案"编译队列已满(最多 5 个任务)"', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
seedResult(edu);
compileTexMock.mockRejectedValue(new ApiError('编译队列已满(7 个任务排队),请稍后重试', {
code: 'QUEUE_FULL', status: 429,
}));
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const err = wrapper.find('[data-testid="rf-compile-error"]');
expect(err.exists()).toBe(true);
expect(err.text()).toContain('编译队列已满(最多 5 个任务),请稍后重试');
// 报错后按钮立即恢复可点(重新编译)
const retry = wrapper.find('[data-testid="rf-recompile"]');
expect(retry.exists()).toBe(true);
expect(retry.attributes('disabled')).toBeUndefined();
});
});
@@ -0,0 +1,99 @@
// tests/frontend/unit/edu/reformat.file.test.js —— ReformatPanel 文件持久化(P3b 修复)
// bug:上传 .tex → 重排版 → 点"重新排版"换期刊 → 本地 file 被清空 → 要求重传。
// 修复:File 进 store(reformatFile,同 presubmitFile 模式);reset() 不清 file;
// 仅"×" removeFile 清空。
// 1) store 有 reformatFile → 挂载即回显文件名
// 2) 选择文件 → 切换目标期刊 → 文件仍在(store + DOM)
// 3) 有结果时点"重新排版"(reset)→ 回上传区且文件保留
// 4) "×" 移除 → store + 本地都清空
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import ReformatPanel from '../../../../ui/src/components/edu/panels/ReformatPanel.vue';
vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
getJournals: vi.fn().mockResolvedValue({
ok: true,
items: [{ id: 'gb', name: 'Genome Biology' }, { id: 'mp', name: 'Molecular Plant' }],
}),
getDirectory: vi.fn(),
reformatTex: vi.fn(),
compileTex: vi.fn(),
};
});
import { useEduStore } from '../../../../ui/src/stores/edu.js';
const TEX = new File(['\\documentclass{article}\\begin{document}Hi\\end{document}'], 'paper.tex', {
type: 'application/x-tex',
});
/** 先建 pinia → seed store → 再挂载(保证 mount 时读到 store 初值) */
function setup(seedFn) {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
if (seedFn) seedFn(edu);
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
return { wrapper, edu };
}
describe('ReformatPanel 文件持久化(换期刊不丢文件)', () => {
beforeEach(() => {
global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
});
it('1) store 有 reformatFile → 挂载即回显文件名', async () => {
const { wrapper } = setup((edu) => { edu.reformatFile = TEX; });
await wrapper.vm.$nextTick();
expect(wrapper.find('.rf-file-name').text()).toContain('paper.tex');
});
it('2) 选择文件后切目标期刊 → 文件仍在(store + DOM)', async () => {
const { wrapper, edu } = setup();
const input = wrapper.find('input.rf-file-input');
Object.defineProperty(input.element, 'files', { value: [TEX], configurable: true });
await input.trigger('change');
await wrapper.vm.$nextTick();
expect(edu.reformatFile).toBe(TEX);
expect(wrapper.find('.rf-file-name').text()).toContain('paper.tex');
// 切换目标期刊(watch 只写 drafts,不清 file)
await wrapper.find('#rf-target').setValue('mp');
await wrapper.vm.$nextTick();
expect(edu.reformatFile).toBe(TEX);
expect(wrapper.find('.rf-file-name').text()).toContain('paper.tex');
expect(edu.drafts.reformatTarget).toBe('mp');
});
it('3) 有结果时点"重新排版" → 回上传区且文件保留', async () => {
const { wrapper, edu } = setup((e) => {
e.reformatFile = TEX;
e.reformatResult = {
ok: true, job_id: 'abc123def456', modified_tex: 'x', changes: [], warnings: [],
meta: { filename: 'paper.tex' },
};
e.reformatStatus = 'ready';
});
await wrapper.vm.$nextTick();
const again = wrapper.findAll('button').find((b) => b.text() === '重新排版');
expect(again).toBeTruthy();
await again.trigger('click');
await wrapper.vm.$nextTick();
expect(edu.reformatResult).toBeNull();
expect(edu.reformatFile).toBe(TEX); // store 保留
expect(wrapper.find('.rf-file-name').text()).toContain('paper.tex'); // DOM 回显
});
it('4) "×" 移除 → store + 本地都清空', async () => {
const { wrapper, edu } = setup((e) => { e.reformatFile = TEX; });
await wrapper.vm.$nextTick();
await wrapper.find('.rf-remove').trigger('click');
expect(edu.reformatFile).toBeNull();
expect(wrapper.find('.rf-file-name').exists()).toBe(false);
});
});
@@ -0,0 +1,123 @@
// tests/frontend/unit/edu/reformat.rules-summary.test.js
// 目标期刊规则摘要行(data-testid="rf-rules-summary"):
// 1) 有规则字段 + 有 diff → 摘要 + "已修改 N 处"
// 2) 规则全 null + diff=0 → "无强制约束" + "样本已满足该刊要求,无需修改"
// 背景:GB/BMC PB/Rice 规则相同、Frontiers 几乎无规则,用户切换期刊看不到 diff 差异,
// 即使 diff=0 也要展示"该刊要求什么",消除"没生效"错觉。
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import ReformatPanel from '../../../../ui/src/components/edu/panels/ReformatPanel.vue';
vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
getJournals: vi.fn().mockResolvedValue({ ok: true, items: [] }),
getDirectory: vi.fn(),
reformatTex: vi.fn(),
compileTex: vi.fn(),
};
});
import { useEduStore } from '../../../../ui/src/stores/edu.js';
function mountWithResult(eduSeed) {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
Object.assign(edu, eduSeed);
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
return { wrapper, edu };
}
function resultWith(rules, changes) {
return {
ok: true,
job_id: 'abc123def456',
modified_tex: 'x',
changes,
warnings: [],
meta: {
filename: 'paper.tex',
journal_name: 'Molecular Plant',
rules_source: 'official',
rules_note: 'journals.json 期刊专属规则',
rules,
},
};
}
describe('ReformatPanel 目标期刊规则摘要', () => {
beforeEach(() => {
global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
});
it('1) 有规则字段 + 有 diff → 摘要列出字段且标注"已修改 N 处"', async () => {
// Molecular Plant 真实规则形态:12pt / 1.5 / author_year(font/margin null 不显示)
const { wrapper } = mountWithResult({
reformatStatus: 'ready',
reformatResult: resultWith(
{
min_font_pt: 12, min_margin_mm: null, min_line_spacing: 1.5,
refs_style: 'author_year', max_pages: null, min_figures: null, max_fonts: null,
},
[
{ line: 1, original: 'a', modified: 'b', reason: 'r', rule_key: 'font_pt', action: 'modified' },
{ line: 3, original: 'a', modified: 'b', reason: 'r', rule_key: 'line_spacing', action: 'modified' },
{ line: 4, original: 'a', modified: 'b', reason: 'r', rule_key: 'bib_style', action: 'modified' },
],
),
});
await wrapper.vm.$nextTick();
const el = wrapper.find('[data-testid="rf-rules-summary"]');
expect(el.exists()).toBe(true);
const text = el.text();
expect(text).toContain('1.5 倍行距');
expect(text).toContain('作者-年份引用');
expect(text).toContain('字号 ≥12pt');
expect(text).not.toContain('null');
expect(text).not.toContain('边距'); // min_margin_mm=null → 不显示
expect(text).toContain('已修改 3 处');
});
it('2) 规则全 null + diff=0 → "无强制约束" + "样本已满足该刊要求,无需修改"', async () => {
const { wrapper } = mountWithResult({
reformatStatus: 'ready',
reformatResult: resultWith(
{
min_font_pt: null, min_margin_mm: null, min_line_spacing: null,
refs_style: null, max_pages: null, min_figures: null, max_fonts: null,
},
[],
),
});
await wrapper.vm.$nextTick();
const el = wrapper.find('[data-testid="rf-rules-summary"]');
expect(el.exists()).toBe(true);
const text = el.text();
expect(text).toContain('该刊官网未规定明确格式要求,无强制约束');
expect(text).toContain('样本已满足该刊要求,无需修改');
expect(text).not.toContain('已修改');
});
it('3) 双倍行距 + Vancouver(GB/BMC/Rice 形态)→ 正确映射中文标签', async () => {
const { wrapper } = mountWithResult({
reformatStatus: 'ready',
reformatResult: resultWith(
{
min_font_pt: null, min_margin_mm: null, min_line_spacing: 2.0,
refs_style: 'vancouver', max_pages: null, min_figures: null, max_fonts: null,
},
[{ line: 3, original: 'a', modified: 'b', reason: 'r', rule_key: 'line_spacing', action: 'modified' }],
),
});
await wrapper.vm.$nextTick();
const text = wrapper.find('[data-testid="rf-rules-summary"]').text();
expect(text).toContain('双倍行距');
expect(text).toContain('Vancouver 引用');
expect(text).toContain('已修改 1 处');
});
});
@@ -0,0 +1,188 @@
// tests/frontend/unit/edu/reformat.zip.test.js —— ReformatPanel 项目 zip 两段式(P4)
// 1) 默认 .tex 模式;切"项目 .zip"→ zip 上传区 + 打包建议文案 + inspect 按钮
// 2) 选 .zip → inspect 成功 → 摘要(main/统计/跳过)+ 编译按钮
// 3) inspect 失败(ZIP_UNSAFE 后端原话)→ 错误展示
// 4) 点编译 → loading → 成功:iframe 预览 + 耗时
// 5) 编译失败:错误摘要 + log 折叠区 + 查看完整 log(失败信封带 log_url)
// 6) 模式互斥:切回 .tex 清 zip 状态
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import ReformatPanel from '../../../../ui/src/components/edu/panels/ReformatPanel.vue';
const inspectZipMock = vi.fn();
const runZipCompileMock = vi.fn();
vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
getJournals: vi.fn().mockResolvedValue({ ok: true, items: [] }),
getDirectory: vi.fn(),
reformatTex: vi.fn(),
compileTex: vi.fn(),
inspectZip: (...args) => inspectZipMock(...args),
runZipCompile: (...args) => runZipCompileMock(...args),
};
});
import { useEduStore } from '../../../../ui/src/stores/edu.js';
import { ApiError } from '../../../../ui/src/api/http.js';
const INSPECT_OK = {
ok: true,
meta: {
job_id: 'abc123def456',
main: 'main.tex',
stats: { files: 5, tex: 1, images: 1, cls: 1, bib: 1, other: 1, bytes: 4096 },
skipped: [{ name: '__MACOSX/._main.tex', reason: 'skip_dir' }],
skipped_count: 1,
created_at: '2026-10-13T00:00:00+00:00',
},
};
function zipFile(name = 'project.zip') {
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
type: 'application/zip',
});
}
async function mountZipPanel(edu) {
const pinia = createPinia();
setActivePinia(pinia);
edu = useEduStore();
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
await wrapper.find('[data-testid="rf-mode-zip"]').trigger('click');
return { wrapper, edu };
}
async function pickZip(wrapper, edu, file = zipFile()) {
const input = wrapper.find('input[type="file"]');
Object.defineProperty(input.element, 'files', { value: [file], configurable: true });
await input.trigger('change');
await wrapper.vm.$nextTick();
}
describe('ReformatPanel 项目 zip 两段式(P4)', () => {
beforeEach(() => {
inspectZipMock.mockReset();
runZipCompileMock.mockReset();
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
});
it('1) 默认 .tex 模式;切 zip → zip 上传区 + 打包建议 + inspect 按钮', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
expect(wrapper.find('[data-testid="rf-mode-tex"]').classes()).toContain('active');
expect(wrapper.find('[data-testid="rf-zip-inspect-btn"]').exists()).toBe(false);
// tex 提交按钮仍在
expect(wrapper.findAll('button').some((b) => b.text() === '开始自动重排版')).toBe(true);
await wrapper.find('[data-testid="rf-mode-zip"]').trigger('click');
const zipBtn = wrapper.find('[data-testid="rf-zip-inspect-btn"]');
expect(zipBtn.exists()).toBe(true);
expect(zipBtn.attributes('disabled')).toBeDefined(); // 未选文件不可点
expect(wrapper.text()).toContain('打包建议:main.tex 放 zip 根目录');
// tex 提交按钮消失(互斥)
expect(wrapper.findAll('button').some((b) => b.text() === '开始自动重排版')).toBe(false);
});
it('2) 选 .zip → inspect 成功 → 摘要 + 编译按钮', async () => {
inspectZipMock.mockResolvedValue(INSPECT_OK);
const { wrapper, edu } = await mountZipPanel(null);
await pickZip(wrapper, edu);
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
await vi.waitFor(() => {
expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(true);
});
expect(inspectZipMock).toHaveBeenCalledTimes(1);
const meta = wrapper.find('[data-testid="rf-zip-meta"]');
expect(meta.text()).toContain('main.tex');
expect(meta.text()).toContain('文件 5 个');
expect(meta.text()).toContain('1 个文件已跳过');
expect(wrapper.find('[data-testid="rf-zip-skipped"]').text()).toContain('__MACOSX');
expect(wrapper.find('[data-testid="rf-zip-compile-btn"]').exists()).toBe(true);
});
it('3) inspect 失败(ZIP_UNSAFE)→ 错误展示', async () => {
inspectZipMock.mockRejectedValue(
new ApiError('zip 含危险条目:evil.sh', { code: 'ZIP_UNSAFE', status: 200 }),
);
const { wrapper, edu } = await mountZipPanel(null);
await pickZip(wrapper, edu);
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
await vi.waitFor(() => {
expect(wrapper.find('[data-testid="rf-zip-error"]').exists()).toBe(true);
});
expect(wrapper.find('[data-testid="rf-zip-error"]').text()).toContain('evil.sh');
expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(false);
});
it('4) 编译 → loading → 成功:iframe 预览 + 耗时', async () => {
inspectZipMock.mockResolvedValue(INSPECT_OK);
let resolveRun;
runZipCompileMock.mockReturnValue(new Promise((r) => { resolveRun = r; }));
const { wrapper, edu } = await mountZipPanel(null);
await pickZip(wrapper, edu);
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
await vi.waitFor(() => {
expect(wrapper.find('[data-testid="rf-zip-compile-btn"]').exists()).toBe(true);
});
await wrapper.find('[data-testid="rf-zip-compile-btn"]').trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="rf-compile-loading"]').exists()).toBe(true);
resolveRun({
ok: true,
compile_id: 'aabbccddeeff',
pdf_url: '/api/review/compile/zip/aabbccddeeff/pdf',
log_url: '/api/review/compile/zip/aabbccddeeff/log',
meta: { seconds: 3.2, pdf_bytes: 2048, project: true },
});
await vi.waitFor(() => {
expect(wrapper.find('[data-testid="rf-pdf-frame"]').exists()).toBe(true);
});
expect(wrapper.find('[data-testid="rf-compile-secs"]').text()).toContain('3.2 秒');
expect(wrapper.find('[data-testid="rf-download-pdf"]').exists()).toBe(true);
});
it('5) 编译失败:错误摘要 + log 折叠区 + 查看完整 log', async () => {
inspectZipMock.mockResolvedValue(INSPECT_OK);
const compileErr = new ApiError('LaTeX 源码有语法错误', {
code: 'COMPILE_FAILED', status: 200,
});
compileErr.detail = 'l.12 \\badcmd'; // unwrap 从 error.detail 挂载(log 尾)
runZipCompileMock.mockRejectedValue(compileErr);
const { wrapper, edu } = await mountZipPanel(null);
await pickZip(wrapper, edu);
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
await vi.waitFor(() => {
expect(wrapper.find('[data-testid="rf-zip-compile-btn"]').exists()).toBe(true);
});
await wrapper.find('[data-testid="rf-zip-compile-btn"]').trigger('click');
await vi.waitFor(() => {
expect(wrapper.find('[data-testid="rf-compile-error"]').exists()).toBe(true);
});
expect(wrapper.find('[data-testid="rf-compile-error"]').text()).toContain('语法错误');
expect(wrapper.find('[data-testid="rf-log-body"]').text()).toContain('l.12');
expect(wrapper.find('[data-testid="rf-download-log"]').exists()).toBe(true);
});
it('6) 模式互斥:切回 .tex 清 zip 状态(inspect 摘要消失)', async () => {
inspectZipMock.mockResolvedValue(INSPECT_OK);
const { wrapper, edu } = await mountZipPanel(null);
await pickZip(wrapper, edu);
await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
await vi.waitFor(() => {
expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(true);
});
await wrapper.find('[data-testid="rf-mode-tex"]').trigger('click');
expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(false);
expect(edu.zipInspect).toBeNull();
expect(edu.zipFile).toBeNull();
// tex 上传区回来了
expect(wrapper.findAll('button').some((b) => b.text() === '开始自动重排版')).toBe(true);
});
});
+3
View File
@@ -77,6 +77,7 @@ orjson==3.11.9
ormsgpack==1.12.2 ormsgpack==1.12.2
packaging==26.3 packaging==26.3
pandas==3.0.5 pandas==3.0.5
pillow==12.3.0
pluggy==1.6.0 pluggy==1.6.0
portalocker==3.2.0 portalocker==3.2.0
propcache==0.5.2 propcache==0.5.2
@@ -95,6 +96,7 @@ python-dateutil==2.9.0.post0
python-docx==1.1.2 python-docx==1.1.2
python-dotenv==1.2.3 python-dotenv==1.2.3
python-multipart==0.0.32 python-multipart==0.0.32
python-pptx==1.0.2
pytz==2026.3.post1 pytz==2026.3.post1
PyYAML==6.0.3 PyYAML==6.0.3
qdrant-client==1.18.0 qdrant-client==1.18.0
@@ -137,6 +139,7 @@ watchfiles==1.2.0
websockets==16.0 websockets==16.0
wheel==0.47.0 wheel==0.47.0
xlrd==2.0.1 xlrd==2.0.1
xlsxwriter==3.2.9
xxhash==3.7.0 xxhash==3.7.0
yarl==1.24.2 yarl==1.24.2
zstandard==0.25.0 zstandard==0.25.0
+73 -1
View File
@@ -19,6 +19,9 @@ function unwrap(promise) {
degraded: !!payload.error.degraded, degraded: !!payload.error.degraded,
}); });
if (payload.error.detail) err.detail = payload.error.detail; // 编译失败的 log 尾等 if (payload.error.detail) err.detail = payload.error.detail; // 编译失败的 log 尾等
// P4:项目编译失败信封带 compile_id + log_url(补充 3,前端"查看完整 log"用)
if (payload.compile_id) err.compileId = payload.compile_id;
if (payload.log_url) err.logUrl = payload.log_url;
throw err; throw err;
} }
return payload; return payload;
@@ -104,6 +107,23 @@ export function compileTex(formData, opts = {}) {
return unwrap(post(`${BASE}/compile`, formData, opts)); return unwrap(post(`${BASE}/compile`, formData, opts));
} }
/** P4 POST /api/review/compile/inspect(multipart .zip ≤50MB,两段式之①)
* → {ok, meta:{job_id, main, stats:{files,tex,images,cls,bib,other,bytes},
* skipped:[{name,reason}], skipped_count, created_at}}
* 错误信封:VALIDATION / ZIP_UNSAFE / ZIP_BOMB / ZIP_INVALID(HTTP 200)。
* job_id 1 小时 TTL;run 前不占编译队列。 */
export function inspectZip(formData, opts = {}) {
return unwrap(post(`${BASE}/compile/inspect`, formData, opts));
}
/** P4 POST /api/review/compile/run(JSON {job_id},两段式之②)
* → 与 compileTex 同构信封 {ok, compile_id, pdf_url, log_url, meta};
* pdf/log 路径带 /zip/ 前缀。失败(COMPILE_FAILED/TIMEOUT)也带 compile_id+log_url。
* QUEUE_FULL → HTTP 429(此时 job 未消费,可直接重试)。 */
export function runZipCompile(payload, opts = {}) {
return unwrap(post(`${BASE}/compile/run`, payload, opts));
}
/** ⑤ format-check 专用错误文案:解档/校验类 PARSE_FAILED/VALIDATION 用后端原话 /** ⑤ format-check 专用错误文案:解档/校验类 PARSE_FAILED/VALIDATION 用后端原话
* (与 presubmit 的"扫描件"提示是不同语境,不能复用 CODE_HINTS),其余走统一口径。 */ * (与 presubmit 的"扫描件"提示是不同语境,不能复用 CODE_HINTS),其余走统一口径。 */
export function formatErrorMessage(err) { export function formatErrorMessage(err) {
@@ -119,9 +139,11 @@ const CODE_HINTS = {
VALIDATION: null, VALIDATION: null,
PARSE_FAILED: '论文解析失败:可能是扫描件/图片型 PDF,请换文本型 PDF。', PARSE_FAILED: '论文解析失败:可能是扫描件/图片型 PDF,请换文本型 PDF。',
UPSTREAM_DOWN: '审稿模型暂时不可用,请稍后重试。', UPSTREAM_DOWN: '审稿模型暂时不可用,请稍后重试。',
SEED_UNAVAILABLE: '期刊库未就绪(种子数据缺失),请联系管理员。', SEED_UNAVAILABLE: '期刊库就绪中(种子数据缺失),请稍后再试。',
TIMEOUT: '请求超时,请稍后重试。', TIMEOUT: '请求超时,请稍后重试。',
NETWORK: '网络异常,请检查连接后重试。', NETWORK: '网络异常,请检查连接后重试。',
// P3b 编译:固定口径(后端 message 带动态 pending 数,此处按规范统一为"最多 5 个")
QUEUE_FULL: '编译队列已满(最多 5 个任务),请稍后重试',
}; };
/** ApiError(或任意错误)→ 用户可读文案。degraded 由调用方读 err.degraded 走降级横幅样式。 */ /** ApiError(或任意错误)→ 用户可读文案。degraded 由调用方读 err.degraded 走降级横幅样式。 */
@@ -201,3 +223,53 @@ export async function rebuttalDownloadLetterMd(id) {
export function rebuttalDeleteJob(id, opts = {}) { export function rebuttalDeleteJob(id, opts = {}) {
return unwrap(del(`${RB}/jobs/${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 不依赖字体) // 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: '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: '📅' }, { 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> </script>
+2
View File
@@ -14,6 +14,7 @@ import FormatPanel from './panels/FormatPanel.vue';
import ReformatPanel from './panels/ReformatPanel.vue'; import ReformatPanel from './panels/ReformatPanel.vue';
import CalendarPanel from './panels/CalendarPanel.vue'; import CalendarPanel from './panels/CalendarPanel.vue';
import RebuttalPanel from './panels/RebuttalPanel.vue'; import RebuttalPanel from './panels/RebuttalPanel.vue';
import PptPanel from './panels/PptPanel.vue';
const edu = useEduStore(); const edu = useEduStore();
@@ -28,6 +29,7 @@ const panelMap = {
reformat: ReformatPanel, reformat: ReformatPanel,
calendar: CalendarPanel, calendar: CalendarPanel,
rebuttal: RebuttalPanel, rebuttal: RebuttalPanel,
ppt: PptPanel,
}; };
const currentPanel = computed(() => panelMap[edu.section] || JournalsPanel); 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>
+567 -31
View File
@@ -9,10 +9,14 @@
选定后 target = 目录 id,后端经 journal_directory 回链)。 选定后 target = 目录 id,后端经 journal_directory 回链)。
结果三 Tab:修改后源码(Blob 下载)/ Diff 报告(按 rule_key 着色、 结果三 Tab:修改后源码(Blob 下载)/ Diff 报告(按 rule_key 着色、
按 action 计数:modified=已修改、warning=建议·橙色·不计入)/ 按 action 计数:modified=已修改、warning=建议·橙色·不计入)/
编译 PDF(P3 决策暂缓:仅「开发中」占位,不触发编译;后端 tectonic 编译 PDF(P3b):POST /compile {job_id}(tectonic 单槽队列,约 10-30 秒,
API(P3a)保留备用,前端不暴露编译入口,引导下载 .tex 去 Overleaf/ 首次拉 bundle 1-3 分钟——store.compileFirstHint 黄条提示可切面板后台跑);
本地编译)。 成功→blob URL 内嵌 iframe 预览 + 下载 + 耗时/重编;失败→错误摘要 +
结果跨菜单保留(store reformatResult);样式 rf- 前缀,零新增依赖。 --> log 尾 200 行折叠 + 完整 log 下载;QUEUE_FULL→固定文案。
P4 项目 zip(上传区模式切换,与单文件互斥):POST /compile/inspect(≤50MB,
1h TTL,run 前不占编译队列)→ 主文件/统计/跳过文件摘要 → POST /compile/run
(原子消费 inspect;信封与 P3b 同构复用 compile* 字段;失败也带 log_url)。
结果跨菜单保留(store reformatResult/compile*);样式 rf- 前缀,零新增依赖。 -->
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { getDirectory, reviewErrorMessage } from '../../../api/review.js'; import { getDirectory, reviewErrorMessage } from '../../../api/review.js';
@@ -28,7 +32,7 @@ const SOURCE_LABEL = {
}; };
const fileInput = ref(null); const fileInput = ref(null);
const file = ref(null); const file = ref(edu.reformatFile || null); // File 存 store(换期刊重排不丢;× 移除才清空)
const fileError = ref(''); const fileError = ref('');
const dragging = ref(false); const dragging = ref(false);
const tab = ref('source'); // 'source' | 'diff' | 'pdf' const tab = ref('source'); // 'source' | 'diff' | 'pdf'
@@ -117,6 +121,31 @@ const structWarnings = computed(() => result.value?.warnings || []);
const modifiedCount = computed(() => changes.value.filter((c) => c.action === 'modified').length); const modifiedCount = computed(() => changes.value.filter((c) => c.action === 'modified').length);
const suggestCount = computed(() => changes.value.filter((c) => c.action === 'warning').length); const suggestCount = computed(() => changes.value.filter((c) => c.action === 'warning').length);
const sourceLabel = computed(() => SOURCE_LABEL[meta.value?.rules_source] || meta.value?.rules_source || '—'); const sourceLabel = computed(() => SOURCE_LABEL[meta.value?.rules_source] || meta.value?.rules_source || '—');
// 目标期刊规则摘要:只显示有值字段(null 不显示);全 null → 明确说明无强制约束。
// 目的:diff=0 时(如 Frontiers / GB↔BMC 互切)也能看到"该刊要求什么",消除"没生效"错觉。
const _REFS_LABEL = { vancouver: 'Vancouver 引用', apa: 'APA 引用', author_year: '作者-年份引用' };
const rulesSummary = computed(() => {
const r = meta.value?.rules;
if (!r || typeof r !== 'object') return null;
const parts = [];
const ls = r.min_line_spacing;
if (typeof ls === 'number' && ls > 0) {
if (ls <= 1.05) parts.push('单倍行距');
else if (Math.abs(ls - 1.5) < 0.01) parts.push('1.5 倍行距');
else if (ls >= 1.95) parts.push('双倍行距');
else parts.push(`行距 ≥${ls} 倍`);
}
if (r.refs_style && _REFS_LABEL[r.refs_style]) parts.push(_REFS_LABEL[r.refs_style]);
if (typeof r.min_font_pt === 'number') parts.push(`字号 ≥${r.min_font_pt}pt`);
if (typeof r.min_margin_mm === 'number') parts.push(`边距 ≥${r.min_margin_mm}mm`);
if (!parts.length) return '该刊官网未规定明确格式要求,无强制约束';
return parts.join(' · ');
});
// 与 diff 的关系:有修改 → "已修改 N 处";0 修改 → "样本已满足该刊要求,无需修改"
const rulesStatusText = computed(() => {
if (modifiedCount.value > 0) return `已修改 ${modifiedCount.value} 处`;
return '样本已满足该刊要求,无需修改';
});
// ISO 时间戳 → "10-12 15:25"(内部字段不裸露;helper 规则见 UI 注释) // ISO 时间戳 → "10-12 15:25"(内部字段不裸露;helper 规则见 UI 注释)
const generatedShort = computed(() => { const generatedShort = computed(() => {
const s = meta.value?.generated_at; const s = meta.value?.generated_at;
@@ -129,6 +158,137 @@ function copyJobId() {
if (id && navigator.clipboard) navigator.clipboard.writeText(String(id)).catch(() => {}); if (id && navigator.clipboard) navigator.clipboard.writeText(String(id)).catch(() => {});
} }
/* —— P3b 编译 PDF(状态全在 store → 切菜单/切 Tab 编译继续跑)—— */
const compiling = computed(() => edu.compileStatus === 'loading');
const compileReady = computed(() => edu.compileStatus === 'ready' && !!edu.compileResult);
const compileErrored = computed(() => edu.compileStatus === 'error');
const compileMeta = computed(() => edu.compileResult?.meta || null);
const pdfSrc = computed(() => edu.compilePdfUrl || edu.compileResult?.pdf_url || '');
const pdfSizeText = computed(() => {
const b = compileMeta.value?.pdf_bytes;
if (b == null) return '';
return b >= 1048576 ? `${(b / 1048576).toFixed(1)} MB` : `${Math.ceil(b / 1024)} KB`;
});
const compileLog = computed(() => edu.compileLogTail || '');
const logCollapsed = ref(true); // log 折叠区(默认收起)
const firstHintDismissed = ref(false);
const showFirstHint = computed(() =>
edu.compileFirstHint && compiling.value && !firstHintDismissed.value,
);
function startCompile() {
const jid = result.value?.job_id;
if (!jid || compiling.value) return;
logCollapsed.value = true;
edu.submitCompile(jid); // 不 await:按钮/进度由 store 状态驱动,切面板不中断
}
function downloadPdf() {
const href = pdfSrc.value;
if (!href) return;
const a = document.createElement('a');
a.href = href;
a.download = `${result.value?.job_id || 'paper'}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
}
function downloadFullLog() {
// 成功:信封带 log_url;失败信封不带——job_id 即 anchor_id,log 同目录可拼路径
const jid = result.value?.job_id;
const href = edu.compileResult?.log_url || (jid ? `/api/review/compile/${jid}/log` : '');
if (!href) return;
const a = document.createElement('a');
a.href = href;
a.download = `${jid || 'compile'}.log`;
document.body.appendChild(a);
a.click();
a.remove();
}
/* —— P4 项目 zip(两段式;与单文件模式互斥,切换即清对侧状态)—— */
const uploadMode = ref('tex'); // 'tex' | 'zip'
const zipInput = ref(null);
const zipInspecting = computed(() => edu.zipInspectStatus === 'loading');
const zipInspected = computed(() => edu.zipInspectStatus === 'ready' && !!edu.zipInspect);
const zipError = computed(() => edu.zipInspectError || '');
const MAX_ZIP_SIZE = 50 * 1024 * 1024; // 与后端 REVIEW_MAX_ZIP_BYTES 一致
function switchMode(mode) {
if (uploadMode.value === mode) return;
uploadMode.value = mode;
fileError.value = '';
if (mode === 'zip') {
edu.clearReformat(); // 互斥:zip 编译结果与单文件编译共用 compile* 字段
} else {
edu.clearZip();
}
}
function acceptZip(f) {
if (!f) return;
if (!String(f.name || '').toLowerCase().endsWith('.zip')) {
edu.zipInspectStatus = 'error';
edu.zipInspectError = '仅支持 .zip 项目包';
return;
}
if (!f.size) {
edu.zipInspectStatus = 'error';
edu.zipInspectError = '上传文件为空';
return;
}
if (f.size > MAX_ZIP_SIZE) {
edu.zipInspectStatus = 'error';
edu.zipInspectError = 'zip 超过 50MB 上限';
return;
}
edu.zipFile = f;
edu.zipInspectStatus = 'idle'; // 换文件 → 旧 inspect 失效
edu.zipInspect = null;
edu.zipInspectError = null;
edu.clearCompile();
}
function onZipPick(e) {
acceptZip(e.target.files?.[0]);
e.target.value = '';
}
function onZipDrop(e) {
dragging.value = false;
acceptZip(e.dataTransfer?.files?.[0]);
}
function removeZip() {
edu.clearZip(); // 唯一清空入口:"×" 移除
}
async function startZipInspect() {
if (!edu.zipFile || zipInspecting.value) return;
await edu.submitZipInspect(); // 不 await 也可,但保持与其他面板一致写法
}
function startZipCompile() {
logCollapsed.value = true;
edu.submitZipCompile(); // 不 await:状态由 store 驱动,切面板不中断
}
function downloadZipPdf() {
const href = pdfSrc.value;
if (!href) return;
const cid = edu.compileResult?.compile_id || 'project';
const a = document.createElement('a');
a.href = href;
a.download = `${cid}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
}
function downloadZipLog() {
const cid = edu.compileResult?.compile_id;
const href = edu.compileResult?.log_url || (cid ? `/api/review/compile/zip/${cid}/log` : '');
if (!href) return;
const a = document.createElement('a');
a.href = href;
a.download = `${cid || 'compile'}.log`;
document.body.appendChild(a);
a.click();
a.remove();
}
onMounted(() => { onMounted(() => {
if (edu.journalsStatus === 'idle') edu.fetchJournals(); // 精选期刊下拉数据源 if (edu.journalsStatus === 'idle') edu.fetchJournals(); // 精选期刊下拉数据源
// 恢复的 target 是目录刊但无选中记录(历史草稿)→ 回落通用规则 // 恢复的 target 是目录刊但无选中记录(历史草稿)→ 回落通用规则
@@ -154,6 +314,7 @@ function acceptFile(f) {
return; return;
} }
file.value = f; file.value = f;
edu.reformatFile = f; // 进 store:换期刊/切菜单重排不丢
} }
function onPick(e) { function onPick(e) {
acceptFile(e.target.files?.[0]); acceptFile(e.target.files?.[0]);
@@ -165,6 +326,7 @@ function onDrop(e) {
} }
function removeFile() { function removeFile() {
file.value = null; file.value = null;
edu.reformatFile = null; // 唯一清空入口:"×" 移除
} }
async function submit() { async function submit() {
@@ -177,7 +339,7 @@ async function submit() {
} }
function reset() { function reset() {
edu.clearReformat(); edu.clearReformat();
file.value = null; // 不清 file——换期刊重排时保留已上传文件(仅"×"移除才清)
fileError.value = ''; fileError.value = '';
tab.value = 'source'; tab.value = 'source';
} }
@@ -216,6 +378,11 @@ function download() {
复制任务 ID 复制任务 ID
</button> </button>
</div> </div>
<!-- 目标期刊规则摘要:diff=0 时也能看到"该刊要求什么"(次级文字,不抢 diff 主视觉) -->
<p v-if="rulesSummary" class="rf-rules" data-testid="rf-rules-summary">
📋 该刊规则:{{ rulesSummary }}
<span class="rf-rules-status">({{ rulesStatusText }})</span>
</p>
<p v-if="meta?.rules_note" class="rf-note" role="note">{{ meta.rules_note }}</p> <p v-if="meta?.rules_note" class="rf-note" role="note">{{ meta.rules_note }}</p>
<!-- 汇总:已修改 / 建议(橙,不计入修改)/ 结构提示 --> <!-- 汇总:已修改 / 建议(橙,不计入修改)/ 结构提示 -->
@@ -269,21 +436,95 @@ function download() {
</article> </article>
</div> </div>
<!-- 编译 PDF(P3 暂缓):「开发中」占位,不触发编译;后端 P3a API 保留备用 --> <!-- ========== 编译 PDF(P3b) ========== -->
<div v-else class="rf-compile"> <div v-else class="rf-compile">
<div class="rf-wip" role="note"> <!-- 无 job_id(未完成重排版)→ 引导 -->
<p class="rf-wip-main"> <div v-if="!result?.job_id" class="rf-wip" role="note" data-testid="rf-compile-guide">
PDF 编译功能开发中,暂时请下载修改后的 .tex 文件,在 Overleaf 或本地编译。 <p class="rf-wip-main">请先完成重排版再编译。</p>
后续将支持多文件工程和图片上传。 <p class="rf-wip-sub">编译基于重排版后的 .tex 源码,重排版成功后此页会出现「编译 PDF」按钮。</p>
</p>
<p class="rf-wip-sub">已提供逐行修改对照(Diff 报告)与修改后源码下载。</p>
<a
class="rf-wip-feedback"
href="http://192.168.0.175:3000/admin/PlantAI-rag-new/issues"
target="_blank"
rel="noopener noreferrer"
>反馈需求 →</a>
</div> </div>
<template v-else>
<!-- 首次编译慢提示(loading 期间显示,可手动关闭) -->
<div v-if="showFirstHint" class="rf-first-hint" role="note" data-testid="rf-first-hint">
<span>
首次编译需要 1-3 分钟(下载 TeX 环境),后续编译秒级。
可以切到其他面板,编译会在后台继续。
</span>
<button class="rf-hint-x" type="button" aria-label="关闭提示"
@click="firstHintDismissed = true">×</button>
</div>
<!-- 操作行:编译按钮 / 下载 / 重新编译 -->
<div class="rf-compile-bar">
<button
v-if="!compileReady && !compileErrored"
class="rf-compile-btn" type="button"
data-testid="rf-compile-btn"
:disabled="compiling"
@click="startCompile"
>
{{ compiling ? '编译中…(首次约 1-3 分钟)' : '编译 PDF' }}
</button>
<template v-if="compileReady">
<button class="rf-compile-btn" type="button" data-testid="rf-download-pdf"
@click="downloadPdf">下载 PDF</button>
<button class="rf-compile-btn rf-compile-ghost" type="button"
data-testid="rf-recompile"
:disabled="compiling" @click="startCompile">
{{ compiling ? '编译中…' : '重新编译' }}
</button>
</template>
<template v-if="compileErrored">
<button class="rf-compile-btn" type="button" data-testid="rf-recompile"
:disabled="compiling" @click="startCompile">
{{ compiling ? '编译中…' : '重新编译' }}
</button>
<button class="rf-compile-btn rf-compile-ghost" type="button"
data-testid="rf-download-log" @click="downloadFullLog">查看完整 log</button>
</template>
<span v-if="compileReady && pdfSizeText" class="rf-compile-size"
data-testid="rf-pdf-size">{{ pdfSizeText }}</span>
</div>
<!-- loading:动画进度(后端无 stage,不确定时长) -->
<div v-if="compiling" class="rf-compile-loading" role="status"
data-testid="rf-compile-loading">
<span class="rf-compile-spin" aria-hidden="true" />
<p class="rf-state-msg">
编译中…(tectonic 单槽队列,常规 10-30 秒;首次 1-3 分钟)
可切换到其他面板,编译在后台继续。
</p>
</div>
<!-- 成功:内嵌 PDF 预览(blob URL) -->
<div v-else-if="compileReady" class="rf-compile-ready">
<iframe
class="rf-compile-frame" :src="pdfSrc" title="PDF 预览"
data-testid="rf-pdf-frame"
/>
<div class="rf-compile-foot">
<span class="rf-compile-secs" data-testid="rf-compile-secs">
编译耗时 {{ compileMeta?.seconds != null ? `${Number(compileMeta.seconds).toFixed(1)} 秒` : '—' }}
</span>
</div>
</div>
<!-- 失败:错误摘要 + log 尾折叠 + 完整 log 下载 -->
<div v-else-if="compileErrored" class="rf-compile-error" role="alert"
data-testid="rf-compile-error">
<p class="rf-compile-msg">{{ edu.compileError }}</p>
<details
v-if="compileLog" class="rf-log" :open="!logCollapsed"
data-testid="rf-log-details"
>
<summary class="rf-log-sum" @click="logCollapsed = !logCollapsed">
编译日志(尾 200 行)
</summary>
<pre class="rf-log-body" data-testid="rf-log-body">{{ compileLog }}</pre>
</details>
</div>
</template>
</div> </div>
</template> </template>
@@ -304,6 +545,145 @@ function download() {
<!-- ========== ④ 上传区 ========== --> <!-- ========== ④ 上传区 ========== -->
<div v-else class="rf-upload"> <div v-else class="rf-upload">
<!-- P4 模式切换:单文件 .tex(P3 重排版链路)| 项目 .zip(两段式编译,不经重排版) -->
<div class="rf-modes" role="tablist" aria-label="上传模式">
<button
class="rf-mode" :class="{ active: uploadMode === 'tex' }" type="button"
role="tab" :aria-selected="uploadMode === 'tex'" data-testid="rf-mode-tex"
@click="switchMode('tex')"
>单文件 .tex</button>
<button
class="rf-mode" :class="{ active: uploadMode === 'zip' }" type="button"
role="tab" :aria-selected="uploadMode === 'zip'" data-testid="rf-mode-zip"
@click="switchMode('zip')"
>项目 .zip</button>
</div>
<!-- ========== ④-zip 项目上传(P4 两段式) ========== -->
<template v-if="uploadMode === 'zip'">
<div
class="rf-drop"
:class="{ dragging, filled: !!edu.zipFile }"
role="button"
tabindex="0"
@click="zipInput.click()"
@keydown.enter="zipInput.click()"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onZipDrop"
>
<input
ref="zipInput"
class="rf-file-input"
type="file"
accept=".zip"
@click.stop
@change="onZipPick"
/>
<template v-if="edu.zipFile">
<span class="rf-file-name">🗜 {{ edu.zipFile.name }} · {{ (edu.zipFile.size / 1024 / 1024).toFixed(1) }} MB</span>
<button class="rf-remove" type="button" title="移除文件" @click.stop="removeZip">×</button>
</template>
<template v-else>
<span class="rf-drop-main">拖拽 LaTeX 项目(.zip)到此处,或点击选择</span>
<span class="rf-hint">打包建议:main.tex 放 zip 根目录,图片用相对路径引用,不要包含 .git / node_modules / 无关大文件。</span>
</template>
</div>
<p v-if="zipError" class="rf-file-error" data-testid="rf-zip-error">{{ zipError }}</p>
<button
class="rf-submit"
type="button"
:disabled="!edu.zipFile || zipInspecting"
data-testid="rf-zip-inspect-btn"
@click="startZipInspect"
>
{{ zipInspecting ? '检查中…' : '上传并检查' }}
</button>
<!-- inspect 通过:项目摘要 + 编译入口 + 编译结果(复用 P3b compile* 状态) -->
<template v-if="zipInspected">
<div class="rf-zip-meta" data-testid="rf-zip-meta">
📦 主文件:<code>{{ edu.zipInspect?.main }}</code>
· 文件 {{ edu.zipInspect?.stats?.files }} 个
· .tex {{ edu.zipInspect?.stats?.tex }}
<template v-if="edu.zipInspect?.stats?.images"> · 图片 {{ edu.zipInspect.stats.images }}</template>
<template v-if="edu.zipInspect?.stats?.cls"> · .cls {{ edu.zipInspect.stats.cls }}</template>
<template v-if="edu.zipInspect?.stats?.bib"> · .bib {{ edu.zipInspect.stats.bib }}</template>
<template v-if="edu.zipInspect?.skipped_count">
· <span class="rf-zip-skipped">{{ edu.zipInspect.skipped_count }} 个文件已跳过</span>
</template>
</div>
<p v-if="edu.zipInspect?.skipped_count" class="rf-zip-skipped-list" data-testid="rf-zip-skipped">
已跳过(不参与编译):{{ (edu.zipInspect?.skipped || []).map((s) => s.name).join('、') }}
</p>
<div class="rf-compile-bar">
<button
v-if="!compileReady && !compileErrored"
class="rf-compile-btn" type="button"
data-testid="rf-zip-compile-btn"
:disabled="compiling"
@click="startZipCompile"
>
{{ compiling ? '编译中…' : '编译 PDF' }}
</button>
<template v-if="compileReady">
<button class="rf-compile-btn" type="button" data-testid="rf-download-pdf"
@click="downloadZipPdf">下载 PDF</button>
<button class="rf-compile-btn rf-compile-ghost" type="button"
data-testid="rf-recompile" :disabled="compiling" @click="startZipCompile">
{{ compiling ? '编译中…' : '重新编译' }}
</button>
</template>
<template v-if="compileErrored">
<button class="rf-compile-btn" type="button" data-testid="rf-recompile"
:disabled="compiling" @click="startZipCompile">
{{ compiling ? '编译中…' : '重新编译' }}
</button>
<button class="rf-compile-btn rf-compile-ghost" type="button"
data-testid="rf-download-log" @click="downloadZipLog">查看完整 log</button>
</template>
<span v-if="compileReady && pdfSizeText" class="rf-compile-size"
data-testid="rf-pdf-size">{{ pdfSizeText }}</span>
</div>
<div v-if="compiling" class="rf-compile-loading" role="status"
data-testid="rf-compile-loading">
<span class="rf-compile-spin" aria-hidden="true" />
<p class="rf-state-msg">编译中…(tectonic 单槽队列,常规 10-30 秒;项目首次编译需下载宏包可能更慢)</p>
</div>
<div v-else-if="compileReady" class="rf-compile-ready">
<iframe
class="rf-compile-frame" :src="pdfSrc" title="PDF 预览"
data-testid="rf-pdf-frame"
/>
<div class="rf-compile-foot">
<span class="rf-compile-secs" data-testid="rf-compile-secs">
编译耗时 {{ compileMeta?.seconds != null ? `${Number(compileMeta.seconds).toFixed(1)} 秒` : '—' }}
</span>
</div>
</div>
<div v-else-if="compileErrored" class="rf-compile-error" role="alert"
data-testid="rf-compile-error">
<p class="rf-compile-msg">{{ edu.compileError }}</p>
<details
v-if="compileLog" class="rf-log" :open="!logCollapsed"
data-testid="rf-log-details"
>
<summary class="rf-log-sum" @click="logCollapsed = !logCollapsed">
编译日志(尾 200 行)
</summary>
<pre class="rf-log-body" data-testid="rf-log-body">{{ compileLog }}</pre>
</details>
</div>
</template>
</template>
<!-- ========== ④-tex 单文件上传(P3 链路,零改动) ========== -->
<template v-else>
<div <div
class="rf-drop" class="rf-drop"
:class="{ dragging, filled: !!file }" :class="{ dragging, filled: !!file }"
@@ -387,6 +767,7 @@ function download() {
开始自动重排版 开始自动重排版
</button> </button>
<p v-if="dirMode && !actualTarget" class="rf-dirhint">请从搜索结果中选择期刊后提交</p> <p v-if="dirMode && !actualTarget" class="rf-dirhint">请从搜索结果中选择期刊后提交</p>
</template>
</div> </div>
</section> </section>
</template> </template>
@@ -416,6 +797,15 @@ function download() {
font-size: 12.5px; font-size: 12.5px;
color: var(--pa-ink-muted); color: var(--pa-ink-muted);
} }
/* 规则摘要:次级文字(同 rf-meta 色阶),与汇总条的计数视觉区分 */
.rf-rules {
margin: 4px 0 0;
font-size: 12.5px;
color: var(--pa-ink-muted);
}
.rf-rules-status {
color: var(--pa-ink-subtle);
}
.rf-copy-id { .rf-copy-id {
margin-left: 8px; margin-left: 8px;
padding: 1px 8px; padding: 1px 8px;
@@ -625,12 +1015,6 @@ function download() {
color: var(--pa-warning); color: var(--pa-warning);
} }
/* 编译 PDF(P3 暂缓:开发中占位) */
.rf-compile {
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-wip { .rf-wip {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -652,13 +1036,126 @@ function download() {
font-size: 12.5px; font-size: 12.5px;
color: var(--pa-ink-subtle); color: var(--pa-ink-subtle);
} }
.rf-wip-feedback {
font-size: 12.5px; /* 编译 PDF(P3b) */
font-weight: 600; .rf-compile {
color: var(--pa-g-from); display: flex;
text-decoration: none; flex-direction: column;
gap: 10px;
}
.rf-first-hint {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
padding: 10px 14px;
border: 1px solid color-mix(in srgb, var(--pa-warning) 45%, transparent);
border-radius: var(--pa-r-md);
background: color-mix(in srgb, var(--pa-warning) 12%, transparent);
color: var(--pa-ink);
font-size: 12.5px;
line-height: 1.6;
}
.rf-hint-x {
border: none;
background: transparent;
color: var(--pa-ink-muted);
font-size: 15px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.rf-hint-x:hover { color: var(--pa-ink); }
.rf-compile-bar {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.rf-compile-btn {
padding: 7px 18px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.rf-compile-btn:disabled { opacity: .6; cursor: not-allowed; }
.rf-compile-btn:not(:disabled):hover { filter: brightness(1.08); }
.rf-compile-ghost {
background: transparent;
border: 1px solid var(--pa-hairline-strong);
color: var(--pa-ink);
}
.rf-compile-size {
font-size: 12px;
color: var(--pa-ink-subtle);
}
.rf-compile-loading {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 16px;
background: var(--pa-surface-2);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.rf-compile-spin {
flex: none;
width: 18px;
height: 18px;
border: 2px solid var(--pa-hairline-strong);
border-top-color: var(--pa-g-from);
border-radius: 50%;
animation: rf-spin .8s linear infinite;
}
@keyframes rf-spin { to { transform: rotate(360deg); } }
.rf-compile-ready { display: flex; flex-direction: column; gap: 8px; }
.rf-compile-frame {
width: 100%;
height: 560px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
background: var(--pa-surface-2);
}
.rf-compile-foot { display: flex; gap: 12px; align-items: center; }
.rf-compile-secs { font-size: 12px; color: var(--pa-ink-subtle); }
.rf-compile-error {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
background: color-mix(in srgb, var(--pa-error) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--pa-error) 40%, transparent);
border-radius: var(--pa-r-lg);
}
.rf-compile-msg {
margin: 0;
font-size: 13px;
color: var(--pa-error);
}
.rf-log { border-top: 1px dashed var(--pa-hairline-strong); padding-top: 8px; }
.rf-log-sum {
font-size: 12.5px;
color: var(--pa-ink-muted);
cursor: pointer;
user-select: none;
}
.rf-log-body {
margin: 8px 0 0;
max-height: 300px;
overflow: auto;
padding: 10px 12px;
background: var(--pa-code-bg);
border-radius: var(--pa-r-md);
font-size: 12px;
line-height: 1.55;
color: var(--pa-ink-muted);
white-space: pre-wrap;
word-break: break-word;
} }
.rf-wip-feedback:hover { text-decoration: underline; }
/* 状态区 */ /* 状态区 */
.rf-state { .rf-state {
@@ -755,6 +1252,45 @@ function download() {
font-size: 12.5px; font-size: 12.5px;
color: var(--pa-error); color: var(--pa-error);
} }
/* P4 模式切换(单文件 .tex | 项目 .zip) */
.rf-modes {
display: flex;
gap: 6px;
}
.rf-mode {
padding: 5px 14px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-pill);
background: transparent;
color: var(--pa-ink-muted);
font-size: 12px;
cursor: pointer;
}
.rf-mode.active {
border-color: var(--pa-g-from);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-weight: 600;
}
/* P4 zip 项目摘要(次级文字,与 rf-meta 同色阶) */
.rf-zip-meta {
margin: 4px 0 0;
font-size: 12.5px;
color: var(--pa-ink-muted);
}
.rf-zip-meta code {
padding: 1px 6px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-sm);
font-size: 12px;
}
.rf-zip-skipped { color: var(--pa-warning); }
.rf-zip-skipped-list {
margin: 0;
font-size: 12px;
color: var(--pa-ink-subtle);
word-break: break-all;
}
.rf-label { .rf-label {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
@@ -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; }
+233 -5
View File
@@ -4,11 +4,11 @@
// - 进行中的请求由 store action 持有 → 面板卸载不中断; // - 进行中的请求由 store action 持有 → 面板卸载不中断;
// - 表单草稿存 drafts → 切菜单不丢。 // - 表单草稿存 drafts → 切菜单不丢。
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { compileTex, formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, presubmit, recommend, reformatTex, reviewErrorMessage, rebuttalConfirm, rebuttalCreateFromPdf, rebuttalCreateJob, rebuttalDeleteJob, rebuttalDraft, rebuttalLetter, rebuttalSaveResponse } 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 保留) const STORAGE_KEY = 'plantai.edu.submissions'; // D3 台账持久化(tracker 面板已解除映射,state 保留)
// tracker 已砍(2026-10-12,仅解除 UI 映射;TrackerPanel.vue 保留在 panels/ 下可复用) // 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 只存字符串) // ⑥ 台账状态枚举(labels 由 step-7 UI 层给;store 只存字符串)
export const SUBMISSION_STATUS = ['draft', 'submitted', 'under_review', 'revision', 'accepted', 'rejected']; export const SUBMISSION_STATUS = ['draft', 'submitted', 'under_review', 'revision', 'accepted', 'rejected'];
@@ -72,6 +72,9 @@ export const useEduStore = defineStore('edu', {
// ③ presubmit 已选 PDF(File 对象,仅内存:面板卸载/切菜单不丢,error 态可直接重试; // ③ presubmit 已选 PDF(File 对象,仅内存:面板卸载/切菜单不丢,error 态可直接重试;
// 不可序列化 → 不进 localStorage;clearReport 一并清空) // 不可序列化 → 不进 localStorage;clearReport 一并清空)
presubmitFile: null, presubmitFile: null,
// ⑤' reformat 已选 .tex(同 presubmitFile 模式:换期刊重排不丢文件;
// 面板内"×"移除才清空。File 不可序列化 → 不进 localStorage)
reformatFile: null,
recommend: null, // ② 推荐响应 {ok, tiers, meta} recommend: null, // ② 推荐响应 {ok, tiers, meta}
recommendStatus: 'idle', recommendStatus: 'idle',
recommendError: null, recommendError: null,
@@ -83,11 +86,19 @@ export const useEduStore = defineStore('edu', {
reformatResult: null, reformatResult: null,
reformatStatus: 'idle', reformatStatus: 'idle',
reformatError: null, reformatError: null,
/* —— P3a 编译 PDF(tectonic;与 reformatResult 同生命周期,重新排版即失效)—— */ /* —— P3a/P3b 编译 PDF(tectonic;与 reformatResult 同生命周期,重新排版即失效)—— */
compileResult: null, // {ok, compile_id, pdf_url, log_url, meta} compileResult: null, // {ok, compile_id, pdf_url, log_url, meta:{seconds,pdf_bytes,...}}
compileStatus: 'idle', // 'idle' | 'loading' | 'ready' | 'error' compileStatus: 'idle', // 'idle' | 'loading' | 'ready' | 'error'
compileError: null, compileError: null,
compileLogTail: '', // COMPILE_FAILED/TIMEOUT 的 log 尾 200 行(err.detail) compileLogTail: '', // COMPILE_FAILED/TIMEOUT 的 log 尾 200 行(err.detail)
compilePdfUrl: null, // PDF blob URL(预览/下载,不暴露 API 路径;失败回退 pdf_url)
compileFirstHint: false, // 首次编译黄色提示条(编译开始到 clearCompile 前保留)
/* —— P4 项目 zip 两段式(inspect 暂存 1h + run 编译;run 信封与 P3b 同构,
复用 compile* 字段展示 PDF/log——zip 编译结果与单文件编译互斥,切换模式即清)—— */
zipFile: null, // File(内存态:切菜单不丢,× 移除才清空)
zipInspect: null, // inspect meta {job_id, main, stats{}, skipped[], skipped_count}
zipInspectStatus: 'idle', // 'idle' | 'loading' | 'ready' | 'error'
zipInspectError: null,
/* —— 表单草稿(切菜单不丢;四字段并存:recommend 用 abstract+prefs、 /* —— 表单草稿(切菜单不丢;四字段并存:recommend 用 abstract+prefs、
presubmit 用 targetJournal、format 用 formatJournalId(期刊 id 下拉))—— */ presubmit 用 targetJournal、format 用 formatJournalId(期刊 id 下拉))—— */
@@ -121,6 +132,29 @@ export const useEduStore = defineStore('edu', {
// Tracker「去写返修」带来的期刊名预填(方案 A:仅跳转,无 jobId 回写) // Tracker「去写返修」带来的期刊名预填(方案 A:仅跳转,无 jobId 回写)
prefillJournal: '', 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: { getters: {
@@ -371,6 +405,126 @@ export const useEduStore = defineStore('edu', {
clearRebuttal() { clearRebuttal() {
this.rebuttal = this._rebuttalBase(); 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) { async submitRecommend(payload) {
this.recommendStatus = 'loading'; this.recommendStatus = 'loading';
this.recommendError = null; this.recommendError = null;
@@ -426,18 +580,20 @@ export const useEduStore = defineStore('edu', {
this.reformatError = null; this.reformatError = null;
this.clearCompile(); this.clearCompile();
}, },
/* —— P3a 编译 PDF(tectonic 单槽队列;约 10-30 秒)—— */ /* —— P3b 编译 PDF(tectonic 单槽队列;约 10-30 秒,首次拉 bundle 1-3 分钟)—— */
async submitCompile(jobId) { async submitCompile(jobId) {
if (this.compileStatus === 'loading') return null; if (this.compileStatus === 'loading') return null;
this.compileStatus = 'loading'; this.compileStatus = 'loading';
this.compileError = null; this.compileError = null;
this.compileLogTail = ''; this.compileLogTail = '';
if (!this.compileResult) this.compileFirstHint = true; // 首次编译提示条
const fd = new FormData(); const fd = new FormData();
fd.append('job_id', jobId); fd.append('job_id', jobId);
try { try {
const payload = await compileTex(fd); const payload = await compileTex(fd);
this.compileResult = payload; this.compileResult = payload;
this.compileStatus = 'ready'; this.compileStatus = 'ready';
this._loadCompileBlob(payload.pdf_url); // 异步换 blob URL,失败回退直链
return payload; return payload;
} catch (err) { } catch (err) {
this.compileStatus = 'error'; this.compileStatus = 'error';
@@ -446,11 +602,83 @@ export const useEduStore = defineStore('edu', {
return null; return null;
} }
}, },
/** PDF → blob URL(预览/下载不暴露后端路径);失败静默回退 pdf_url 直链 */
async _loadCompileBlob(url) {
if (!url) return;
try {
const resp = await fetch(url);
if (!resp.ok) return;
const blob = await resp.blob();
if (this.compilePdfUrl) URL.revokeObjectURL(this.compilePdfUrl);
this.compilePdfUrl = URL.createObjectURL(blob);
} catch { /* 保持 pdf_url 直链 */ }
},
clearCompile() { clearCompile() {
if (this.compilePdfUrl) {
try { URL.revokeObjectURL(this.compilePdfUrl); } catch { /* noop */ }
}
this.compileResult = null; this.compileResult = null;
this.compileStatus = 'idle'; this.compileStatus = 'idle';
this.compileError = null; this.compileError = null;
this.compileLogTail = ''; this.compileLogTail = '';
this.compilePdfUrl = null;
this.compileFirstHint = false;
},
/* —— P4 项目 zip 两段式 —— */
/** 之① inspect:安全解压 + 主文件识别(1h TTL,run 前不占编译队列) */
async submitZipInspect() {
if (!this.zipFile || this.zipInspectStatus === 'loading') return null;
this.zipInspectStatus = 'loading';
this.zipInspectError = null;
this.zipInspect = null;
this.clearCompile(); // 新 zip 使旧 PDF 失效
const fd = new FormData();
fd.append('file', this.zipFile);
try {
const payload = await inspectZip(fd);
this.zipInspect = payload.meta;
this.zipInspectStatus = 'ready';
return payload;
} catch (err) {
this.zipInspectStatus = 'error';
this.zipInspectError = reviewErrorMessage(err); // ZIP_UNSAFE/BOMB/INVALID 走后端原话
return null;
}
},
/** 之② run:原子消费 inspect → 编译(信封与 P3b 同构 → 复用 compile* 字段) */
async submitZipCompile() {
const jid = this.zipInspect?.job_id;
if (!jid || this.compileStatus === 'loading') return null;
this.compileStatus = 'loading';
this.compileError = null;
this.compileLogTail = '';
if (!this.compileResult) this.compileFirstHint = true;
try {
const payload = await runZipCompile({ job_id: jid });
this.compileResult = payload;
this.compileStatus = 'ready';
this._loadCompileBlob(payload.pdf_url);
return payload;
} catch (err) {
this.compileStatus = 'error';
this.compileError = reviewErrorMessage(err);
// 失败信封带 compile_id + log_url(补充 3)→ log 尾仍走 err.detail
this.compileLogTail = err.detail || '';
// 失败也挂结果壳(仅 id/url):下载完整 log 用 /zip/{cid}/log(成功信封同构)
if (err.compileId) {
this.compileResult = {
ok: false, compile_id: err.compileId, log_url: err.logUrl || null,
};
}
return null;
}
},
clearZip() {
this.zipFile = null;
this.zipInspect = null;
this.zipInspectStatus = 'idle';
this.zipInspectError = null;
this.clearCompile();
}, },
}, },
}); });