Compare commits

...
11 Commits
Author SHA1 Message Date
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
admin ac07cb2008 Merge pull request 'feat(review): AI 预审稿修复 + 审稿回复助手 Phase 1' (#3) from wjq/PlantAI-rag-new:feature/presubmit-explainable-engine into main
Reviewed-on: http://192.168.0.175:3000/admin/PlantAI-rag-new/pulls/3
2026-10-13 09:34:41 +08:00
potaser d077a5b930 test(docs): rebuttal + tracker tests + PR reports 2026-10-12 16:38:28 +08:00
potaser c4dc344a31 feat(edu): presubmit UI + rebuttal assistant + tracker removal
- PresubmitPanel: rationale / conflict chips / file persistence
- RebuttalPanel: Phase 1 UI (3-column, 5-type form, stance pre-check)
- ReformatPanel / ReportPanel: hide internal fields (job id / version)
- EduSideNav: SVG icon, remove tracker menu item
- EduWorkspace: unmount tracker
- edu.js: rebuttal state + presubmitFile + SECTIONS (drop tracker)
2026-10-12 16:38:28 +08:00
potaser cf5ccb3a37 feat(review): presubmit 4-bug fix + rebuttal Phase 1 backend
- 4 bugs: truncation window / keyword scanner / tier heuristic / vocab
- Scanner conflict demotion now truly affects recommendation (major → minor)
- Rebuttal Phase 1: store / parse / draft / 10 endpoints
- max_text_chars 150K + token guard + 400/413 retry
- Journals tier override (13 journals) + jif heuristic (exclude reviews)
2026-10-12 16:38:28 +08:00
potaser d99d5ef74a fix(review): replace presubmit recommendation quota with explainable rule engine
- Remove '3-8 major' quota that made accept/minor_revision unreachable
- Add recommend_engine.py: rule-based R0-R5 + tier-parameterized judgment
- Journal tier (1/2/3) from journals.json review_tier field
- LLM no longer outputs recommendation; engine is single source of truth
- Rationale exposed in API and rendered in PresubmitPanel
- Weak draft still major_revision (R1); strong draft now minor_revision (R5)
2026-10-12 09:55:07 +08:00
43 changed files with 7783 additions and 202 deletions
+6
View File
@@ -54,6 +54,12 @@ review_service/data/reformat_jobs/
review_service/tools/tectonic
review_service/data/tectonic-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
# 一次性诊断脚本(不入库)
scripts/debug_compile.py
@@ -0,0 +1,56 @@
# fix(review): 冲突降级真影响 recommendation(major → minor 真降级)
- 分支:`feature/academic-review-assistant`
- 日期:2026-10-12
- 服务:`review_service`(:8100)/ 前端 `PresubmitPanel.vue`
- 状态:**未 commit**,待验收
## 背景
s41588(已发表 Nature Genetics 论文)真实链路里,关键词扫描器报告"3 处冲突已降级为非硬性问题",但 `recommendation` 仍为大修、`major_weaknesses` 仍 5 条。诊断确认:原降级只做了"剥 hard_rule 标签 + 记 conflicts",**条目仍在 major 列表内**,`major_count` / 引擎输入未变,故判定不变。本次把"降级"从标签维度升级为**列表维度的真降级**(major → minor)。
## 变更
**后端**
- `paper_scanner.reconcile_hard_rules`:命中冲突的 major 从列表**移出**,`hard_rule=None` + `scanner_downgraded=true`,落入 demoted 列表;`fatal=true` 条目例外(只记 conflict 不降级,须人工过目)。
- `routers/presubmit.py`:demoted 追加进 `report.minor_weaknesses`;`major_weaknesses` 为过滤后列表;explanation 文案改"已降级为小修级问题"。
- `self_review.WeaknessItem`:新增 `scanner_downgraded: bool = False`(coerce 不读、LLM 无法伪造)。
- 引擎 `recommend_engine.decide_recommendation` **零改动**——计数因列表变短而自动真实。
**前端**
- `PresubmitPanel.vue`:minor 卡片对 `scanner_downgraded` 显示黄徽标"冲突降级"(与 major 卡片"硬性"红徽标对仗);冲突明细块保留。
## 测试
- pytest **104 passed**(新增:`test_reconcile_fatal_not_demoted`、`test_http_partial_conflict_leaves_residual_soft`;改写 `test_reconcile_*` 与 `test_http_scanner_conflict_downgrades` 为真降级断言)。
- vitest **382 passed / 5 failed**(5F 为既有他人 `benchpanel`+`Composer .mode-pill` labels.js 引发,与本改动无关);`npm run build` ✓。
## 验收(真 LLM,:8100)
| 样本 | rec | rule | M/S/minor | conflicts | downgraded→minor |
|---|---|---|---|---|---|
| diag_strong | minor_revision | R5 | 0/0/4 | 0 | 无 |
| diag_weak | major_revision | R1 | 6/2/7 | 1(hic) | 有(真降级生效) |
弱稿仍大修:4 条硬性命中(busco/accession/voucher/abs)为真缺,扫描器无冲突、不降级——真缺陷不放行。
## 验收:s41588(真 LLM,:8100)
输入 `/tmp/opencode/s41588-025-02113-5.pdf`,target_journal=Genome Biology(tier 1 override)。
| 项 | 修复前 | 修复后 |
|---|---|---|
| recommendation | major_revision | **minor_revision**(R5) |
| major_count / soft / minor | 5 / — / — | **0 / 0 / 6** |
| hard_hits | 含 busco/accession… | **[]** |
| scanner signals | — | accession_found=True, busco=partial, hic_found=True |
| major_weaknesses 列表 | 5 条 | **0 条** |
| scanner_conflicts | — | **2 条保留**(busco_missing 格式不完整、accession_missing 部分未给号)→ 均真降级进 minor(`scanner_downgraded=true`) |
判定链:扫描器证伪 2 条硬性 → 真降级 → 剩余为 minor → R5(tier1 accept 门槛 minor≤1,6 条 → minor_revision)。conflicts 透明保留、explanation 明示"已降级为小修级问题,请人工核对"。
注:本次 LLM 未再把"树图注未标数值 / GWAS 校正描述"判成 major(负向清单 Prompt 生效),故 major 从 5 归 0,优于预期的"降到 2"。
## 部署
待管理员确认。属判定正确性修复(真论文误判大修),建议随下次正常发版带上。
@@ -0,0 +1,81 @@
# fix(review): presubmit 四 Bug 修复——截断保真 / 分档 / 词表 / 扫描器兜底
## 背景
真实案例:已发表 Nature Genetics 论文(s41588-026-02682-z,黄瓜泛基因组)被判
"大修",但论文实有 BUSCO 98.57%(P2)、94.18% sequences anchored(P2)、
PRJCA038097 等 accession(P17 Data Availability)。诊断出四个独立缺陷:
1. **PDF 截断**:`max_text_chars=60000` 从头切,30 页论文 P17 的 Data Availability 被截掉;
2. **期刊分档**:Nature Genetics 不在 5 本精选 → 默认 tier 2(应 tier 1);
3. **硬性规则词表过窄**:缺 PRJCA/CRA(中国 NGDC/GSA)、Hi-C 同义词只认 anchoring rate、
BUSCO 仅百分比也算缺失;
4. **无程序层兜底**:hard_rule 全靠 LLM 输出,无关键词证伪机制。
## 修改内容
| 文件 | 变更 |
|---|---|
| `app/routers/presubmit.py` | **Bug 1**:段落感知装箱 `pack_feed_text`(超预算按
Abstract→Data Availability 全文→Methods 前 60%→Results→Discussion 优先保留,
dropped_sections 入 meta);无节标题回退头+尾窗(捞页尾 availability 块)。
**双层保护**:估算 token(len/1.5)>48K → 自动降 100K 重装;LLM 400/413 → 降 80K
重试一次。**Bug 4 接线**:coerce 后扫描器证伪 → rationale.scanner_conflicts |
**Bug 2**:tier_source ∈ override/featured/jif/默认 |
| `app/services/paper_scanner.py`(新增) | 关键词扫描器(纯 stdlib):accession
词表 PRJNA/PRJEB/PRJCA/SRP/ERP/DRP/CRA/SRR/ERR/DRR/GCA_/GCF_ **只在 Data Availability
段扫**(正文引用他人数据不误判);BUSCO 三级 none/partial/full;Hi-C 同义词
(anchored to chromosome(s)/anchoring rate/pseudo-chromosome(s)/chromosome-level)。
`reconcile_hard_rules`:LLM 报缺 X + 扫描器见 X → 剥硬性标签降 soft + 记冲突
(只证伪不证实,不引入新误判) |
| `app/services/recommend_engine.py` | **Bug 2**:tier 查找优先级 override
(13 本顶刊硬编码,"The " 前缀归一化)→ featured(journals.json)→ jif 启发式
(journal_directory 只读 jif 列;jif≥15→tier1;综述刊名含 Review/Trends/Annual
Review 除外)→ 默认 tier 2 |
| `app/services/self_review.py` | **Bug 3**:Prompt 词表扩充(PRJCA/CRA/SRR…);
BUSCO 三级判定(仅百分比→minor 不打 hard_rule);Hi-C 同义词清单。
**2026-10-12 追加 soft major 收紧**(s41588 复盘):维度三系统发育树分诊重写
(完全未做→major / 已声明仅图未标数值→presentation / 附带树→minor);新增
【负向清单】节(图未标统计量、标准方法描述不全、阈值缺数值、引用排版 →
minor/presentation,严禁列 major;总纲"只有补实验/补数据/重跑分析/改核心论证
才是 major";与各维度冲突时以本节为准) |
| `app/config.py` | `max_text_chars` 60000 → **150000**(REVIEW_MAX_TEXT_CHARS 可覆盖) |
| `ui/.../PresubmitPanel.vue` | 冲突黄标块(scanner_conflicts + 降级提示);截断
dropped_sections 美化提示;截断文案改为"保摘要/数据可用性/方法/结果/讨论" |
| `tests/test_presubmit.py` | 新增 15 用例(装箱/回退/token 守卫/400 重试/扫描器
三类信号/DA 段限定/冲突降级/端到端/tier 优先级矩阵/Prompt 新词表) |
## 测试用例与结果
- **pytest:102 passed**(87 基线 + 15 新增)。
- **vitest:379 passed / 5 failed**(5F 为既有他人 i18n labels 改动引发,与本改动
无关;新增冲突黄标/截断提示 2 条全绿)。`npm run build` ✓。
- **真实链路验收**(真 LLM,target_journal=Genome Biology → tier 1 override):
| 样本 | 修复前 | 修复后 | 扫描器 |
|---|---|---|---|
| 强稿 diag_strong.pdf | minor_revision | **minor_revision**(R5,0/0/5) | accession/busco=full/hic 全命中,0 冲突 ✓ |
| 弱稿 diag_weak.pdf | major_revision | **major_revision**(R1,硬性 4 + soft 4 + minor 5) | accession/busco 未命中(真缺不降);Hi-C 冲突降级并记录 ✓ |
| **真实案例 s41588**(NG 论文) | major_revision | 见下"验收"节 | — |
## 部署验证
```bash
bash review_service/restart.sh
curl -X POST http://127.0.0.1:8100/api/review/presubmit \
-F "file=@paper.pdf" -F "target_journal=Nature Genetics"
# 检查:meta.tier=1/tier_source=override;meta.dropped_sections;
# report.recommendation_rationale.scanner_conflicts
```
## 是否需要立即部署
待管理员确认。四修复均为判定正确性问题(真实发表论文误判大修),建议随下次正常发版带上。
## 遗留
- tier jif 启发式仅覆盖 journal_directory 已录入的 1056 刊;未收录刊名走默认 tier 2。
- 扫描器 accession 仅扫 DA 段——若论文把 accession 放 Methods 数据声明处且无 DA 节,
扫描器不证伪(保守方向,LLM 硬性判定仍生效,不会误放行)。
- BUSCO full 判定要求 S: 与 D: 标记在 200 字符窗内相邻——极端排版可能判 partial
(只会更严,不误放)。
@@ -0,0 +1,61 @@
# fix(review): presubmit recommendation 判定过严修复(配额制 → 规则引擎)
## 背景
用户反馈 AI 预审稿多次测试均返回 major_revision。2026-10-12 诊断确认根因是
**配额制**而非"尺子紧":
- `self_review.py` 硬性规则要求 `major_weaknesses 3-8 条`(强制配额)→ accept/minor_revision 几乎不可达;
- LLM 直接输出 recommendation,无程序层规则;JSON 示例锚定 `major_revision`、coerce 缺省回落 `major_revision`;
- 对照实验:近 GB 完备强稿(BUSCO 96.8% / Hi-C 98.7% / PRJNA+GCA+figshare 齐 / UFBoot 1000)仍被凑出 6 条 major,与弱稿(7 条)几乎无区分度;
- `target_journal` 传入但 Prompt 零分档标准。
## 修改内容
| 文件 | 变更 |
|---|---|
| `app/services/recommend_engine.py`(新增) | 纯函数规则引擎:R0 fatal→reject / R1 硬性 major→大修(不可协商,与 tier 无关)/ R2' tier3 仅 1-2 条 soft 可降小修 / R2 soft≥1→大修 / R4 0 major 且 minor≤tier 阈值→接收 / R5→小修;rationale 模板拼装(rule/hard_hits/计数/tier/explanation,含 tier 降级提示句);`resolve_tier` 按 journals.json `review_tier` 匹配,失败默认 tier 2 |
| `app/services/self_review.py` | Prompt 重写:严重分级标准(major/minor/presentation),**三类均不设下限、可 0 条、严禁凑数**;硬性规则 5 项打标签(busco/hic/accession/voucher/abs_missing);删除 recommendation 输出字段与示例锚定;coerce 不再读 recommendation(防注入),hard_rule 白名单过滤 |
| `app/routers/presubmit.py` | coerce 后接引擎;meta 增加 tier/tier_label/tier_source |
| `data/journals/journals.json` | 精选 5 本加 `review_tier`(GB/MP=1;BMC PB/FPS/Rice=2)+ notes 一行 |
| `ui/.../PresubmitPanel.vue` | 判定依据块(explanation + 硬性 chips);判定档位徽标;reject 红标"建议人工复核";顶部 AI 免责行;major 卡片"硬性/fatal"徽标 |
| `tests/test_presubmit.py` | 用例 1-10(引擎分支全覆盖 + tier 对照 + 注入忽略 + rationale 字段) |
判定链与旧链路唯一对外契约变化:`report.recommendation` 语义不变(四档枚举),
新增 `report.recommendation_rationale` 与 `meta.tier*`。
## 测试用例与结果
- **pytest:87 passed**(78 基线 + 9 新增/重写),含用例 1-10:
强稿形态不判大修、弱稿形态判大修且 hard_hits 正确、0 major 0/多 minor 分支、
soft major 分支、fatal→reject、tier3 降级与硬性豁免、tier1 accept 门槛、
LLM recommendation 注入被忽略、hard_rule 白名单过滤、rationale 含 tier/explanation。
- **vitest:377 passed / 5 failed**(5F 为既有他人 i18n labels 改动引发的 benchpanel
失败,与本改动无关;新增 4 条 rationale/档位/fatal 展示用例全绿)。
- **真实链路验收**(诊断同款样本、同 target_journal=Genome Biology,真 LLM 调用):
| 样本 | 修复前 | 修复后 | 依据 |
|---|---|---|---|
| 弱稿 diag_weak.pdf | major_revision(7 major) | **major_revision**(R1;硬性 5 命中:BUSCO/Hi-C/Accession/voucher/ABS + soft 2 + minor 6) | 真缺陷不漏判 ✓ |
| 强稿 diag_strong.pdf | major_revision(凑出 6 major) | **minor_revision**(R5;0 major / 5 minor) | 区分度恢复 ✓ |
## 部署验证
```bash
bash review_service/restart.sh
# 上传强/弱样本对比(或任意 PDF):
curl -X POST http://127.0.0.1:8100/api/review/presubmit \
-F "file=@paper.pdf" -F "target_journal=Genome Biology"
# 检查 report.recommendation_rationale.rule / meta.tier;前端看"判定依据"块
```
## 是否需要立即部署
待管理员确认。属判定质量修复(误报大修影响用户体验与信任),建议随下次正常发版带上。
## 遗留(Phase 2 候选)
- 程序层兜底 `_sanity_check_missing_hard_rules`:正文无 PRJNA/GCA_/SRP 关键词但 LLM
未报 accession_missing → 追加 warning(不进 recommendation 计算);先观察 LLM 是否
"躺平"再决定。
- tier 数据仅覆盖精选 5 本;扩展到 journal_directory 1056 条需人工核验后另行入库。
@@ -0,0 +1,161 @@
# PR 报告:Rebuttal Phase 1(审稿回复助手)
- 日期:2026-10-12
- 分支:`feature/academic-review-assistant`(未 commit,按用户约定统一处理)
- 范围:`review_service/` 新增 5 文件 + main 注册;`ui/` 新增面板与 store/api 扩展;无 sdk/、agent/、vstore/ 改动;无新增依赖。
## 功能概述
粘贴审稿意见文本(主入口)/ 上传批注版 PDF(次要入口)→ LLM+正则拆条(Reviewer→Comment)
→ 逐条填回复要点(5 类下拉,无 auto)→ 生成草稿(反幻觉校验前置)
→ 逐条勾确认 → 拼装 response letter → 下载 `.md`(human_ack 门槛)。docx 属 Phase 2。
## 10 个 API 端点(review_service:8100;BFF 8095 通用透传已验证可用)
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/review/rebuttal/jobs | 文本粘贴主入口 |
| POST | /api/review/rebuttal/jobs/pdf | PDF 次要入口(vstore 解析;非 PDF/超限→VALIDATION) |
| GET | /api/review/rebuttal/jobs/{id} | 全量还原 |
| PATCH | .../comments/{cid}/response | 保存 parts(改后旧 draft/validation 作废) |
| POST | .../comments/{cid}/draft | 生成草稿(校验C前置;MISSING_RESPONSE 时不调 LLM) |
| PATCH | .../comments/{cid}/structure | 手工 merge(≥2)/ split(≥2 非空子项) |
| PATCH | .../comments/{cid}/confirm | 逐条勾;后端重算 human_ack(全勾才 true,空 job 不置位) |
| POST | .../letter | 拼装 letter_md |
| GET | .../letter.md | 下载(human_ack=false → 400 HUMAN_ACK_REQUIRED) |
| DELETE | .../jobs/{id} | 手动删(替代 TTL) |
信封错误复用约定:HTTP 200 + `{ok:false,error:{code,message,degraded}}`。
## 数据模型
- Job:`{id,status,source:{kind,filename,raw_text},template:'generic',reviewers:[...],letter_md,validation,human_ack,created_at,updated_at}`
- Comment:`{id,group_id,severity,title,body,sub_items[],cross_refs[],response:{parts[]},draft,validation,human_confirmed}`
- part.type 枚举:`new_data | reanalysis | location | clarification | rebuttal`(决策 2:无 auto)
- SQLite 表 `rebuttal_jobs` 落在既有 `review_service/review.db`(自含短连接,不动 app/db.py);
启动幂等建表(main lifespan)。
## 关键实现决策(方案未写清处,按"保守、可测、不猜测")
1. **LLM 注入形态**:parse/draft 用 `async (messages, temperature) → dict` 回调,路由内适配
`llm.complete_json(system, user)`;单测直接 monkeypatch `llm.complete_json`。
2. **拆条双轨**:正则先切骨架(Reviewer 段头 + 编号行,兼容行内 `. 2.` 编号);
LLM 逐段出 `{title,severity,sub_items[]}`。LLM 失败/结构非法 → 该段回退正则
(title=首 60 字,severity=major)。`sub_items ≥2 且 max confidence ≥0.7` 才拆
(决策 3),拆出条目共享 group_id;未达阈值多子问题并回一条且 **body 保留段原文**
(不采信 LLM 改写)。
3. **交叉引用**:`see comment N / 参见第 N 条 / 同上` → 解析目标 id;解析不到
`target_id=null` 保留 raw(不猜)。
4. **反幻觉校验(负向词表收窄版)**:
- location:`shows that/as shown` 且无 `added/新增` → `LOCATION_EVIDENCE_WEAK` FAIL;
- rebuttal:命中 `we agree/accept/concede/you are right/we were wrong` →
`REBUTTAL_NEGATIVE` FAIL;无立场词(maintain/disagree/respectfully…)→
`REBUTTAL_NO_STANCE` FAIL;
- 其余三类 Phase 1 仅查非空。校验在 draft 端点前置,FAIL 不调 LLM。
5. **human_ack**:confirm 端点改完后由 store 重算(所有 comment 全 true 才置位;
空 job 不置位防平凡真)。前端无法直接置位。
6. **letter.md 下载**:纯文本端点(非信封),门槛失败返回 400 + 明文
`HUMAN_ACK_REQUIRED:…`;前端 `rebuttalDownloadLetterMd` 适配成 ApiError。
7. **MISSING_RESPONSE**:draft 端点 parts 为空或全空内容 → 信封错误,LLM 调用次数=0
(单测断言)。
8. **PDF 入口**:复用 vstore `parse_pdf_bytes`;校验顺序 = 非 .pdf → 空文件 → 超
`max_pdf_bytes`(50MB)→ 解析。与任务清单"PDF 超限/非 PDF"信封一致。
9. **前端状态**:`store.rebuttal` 纯内存(切面板不丢、刷新丢——与任务"状态持久化"
不同诉求,Phase 1 未要求 localStorage);draft 失败信封里的 job/validation 一并镜像。
10. **不改禁区**:sdk/agent/vstore/未动;PresubmitPanel 等既有面板未动;无新增依赖。
## 测试
### 后端 pytest(9 例新增,全量 113 passed)
```
tests/test_rebuttal.py::test_single_reviewer_single_comment PASSED
tests/test_rebuttal.py::test_multi_reviewer_attribution PASSED
tests/test_rebuttal.py::test_subitem_split_shares_group_id PASSED
tests/test_rebuttal.py::test_location_validation_fail_and_pass PASSED
tests/test_rebuttal.py::test_rebuttal_validation PASSED
tests/test_rebuttal.py::test_missing_response_no_llm_call PASSED
tests/test_rebuttal.py::test_cross_ref_unresolved_keeps_raw PASSED
tests/test_rebuttal.py::test_envelope_errors PASSED
tests/test_rebuttal.py::test_human_ack_gate_on_letter_download PASSED
全量: 113 passed in 10.05s(104 基线 + 9 新增)
```
### 前端 vitest(5 例新增)
```
rebuttal.panel.test.js 5 passed(5 类下拉无 auto / location hint 条件显示 /
未填禁用生成 / 未全确认禁用下载+横幅 / merge-split 仅 active 表单)
全量: 387 passed | 5 failed(失败全部为已知 labels.js 引发的 benchpanel×4 +
Composer .mode-pill×1 预存失败,与本次改动无关;基线 382P/5F → 387P/5F)
```
### build
`npm run build` ✓ built in 3.04s
## 手工冒烟(curl,8100 真 LLM + 真 SQLite)
```
POST /jobs(真实双 Reviewer 意见)→ ok=True job=rbt_ed245cd63b3a reviewers=2 comments=4
titles: 缺少FDR校正与q值报告 / Figure 3 blurry… / Missing coordinates… / Overstated novelty…
POST draft(未填) → ok=False code=MISSING_RESPONSE(LLM 0 调用)
PATCH response(location 弱) + POST draft
→ ok=False VALIDATION_FAILED findings=[LOCATION_EVIDENCE_WEAK]
PATCH response(location 合法) + POST draft → ok=True(真 LLM 草稿)
PATCH response(rebuttal 让步词) + draft → VALIDATION_FAILED findings=[REBUTTAL_NEGATIVE]
3 条合理回复(new_data/clarification/rebuttal)→ draft 全 ok=True
GET letter.md(未全勾) → HTTP 400 HUMAN_ACK_REQUIRED
PATCH confirm ×3 → human_ack=False(c0001 漏勾,恰好验证逐条门槛)
PATCH confirm c0001 → human_ack=True
POST letter → ok=True letter_md len=2731
GET letter.md → HTTP 200(/tmp/opencode/letter.md 落盘,含
Response to Reviewers + 4 条原文/回复)
DELETE job → ok=True meta={deleted:…};GET → NOT_FOUND
BFF 8095 探测 /rebuttal/jobs/nonexistent → HTTP 200(透传信封,REVIEW_ENABLED=1)
```
## 交付物清单
| 文件 | 动作 |
|---|---|
| review_service/app/services/rebuttal_store.py | 新增(SQLite CRUD + human_ack 重算) |
| review_service/app/services/rebuttal_parse.py | 新增(正则骨架 + LLM 拆分 + 交叉引用) |
| review_service/app/services/rebuttal_draft.py | 新增(5 类 Prompt + 反幻觉校验 + letter) |
| review_service/app/routers/rebuttal.py | 新增(10 端点) |
| review_service/app/main.py | +import +include_router +lifespan 建表(约 8 行) |
| review_service/tests/test_rebuttal.py | 新增(9 例) |
| ui/src/api/review.js | +10 API 封装 + rebuttal 错误码未进 CODE_HINTS(后端原话直达) |
| ui/src/stores/edu.js | +SECTIONS 'rebuttal' + rebuttal 状态块 + 10 actions |
| ui/src/components/edu/panels/RebuttalPanel.vue | 新增(拆条树/逐条表单/校验红条/draft 预览/手工拆合/letter) |
| ui/src/components/edu/EduSideNav.vue | +1 行菜单(审稿回复助手 💬) |
| ui/src/components/edu/EduWorkspace.vue | +import +panelMap 挂载 |
| tests/frontend/unit/edu/rebuttal.panel.test.js | 新增(5 例) |
| docs/pr-reports/2026-10-12-rebuttal-phase1.md | 本报告 |
## git status --short(新增部分)
```
?? docs/pr-reports/2026-10-12-rebuttal-phase1.md
?? review_service/app/routers/rebuttal.py
?? review_service/app/services/rebuttal_draft.py
?? review_service/app/services/rebuttal_parse.py
?? review_service/app/services/rebuttal_store.py
?? review_service/tests/test_rebuttal.py
?? tests/frontend/unit/edu/rebuttal.panel.test.js
?? ui/src/components/edu/panels/RebuttalPanel.vue
M review_service/app/main.py
M ui/src/api/review.js
M ui/src/components/edu/EduSideNav.vue
M ui/src/components/edu/EduWorkspace.vue
M ui/src/stores/edu.js
(此前 AI 预审稿修复的未 commit 改动保持原样,未混入本报告范围)
```
## 已知边界(Phase 1 明确不做)
- docx 导出(Phase 2,python-docx 已在 lock 备用);
- job TTL 自动清理(决策 5:手动 DELETE 替代);
- 跨 Reviewer 合并(merge 限同 Reviewer 内);
- 刷新页面后 job 恢复(纯内存态;GET /jobs/{id} 已备,Phase 2 可接列表入口)。
@@ -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 行
```
+13
View File
@@ -0,0 +1,13 @@
Reviewer #1:
1. The GWAS analysis lacks multiple-testing correction. Please re-run with FDR control and report q-values for all significant loci. In addition, the sample size justification is missing — please add a power analysis or at least discuss the detectable effect size.
2. Figure 3 is blurry and the axis labels are unreadable. As noted in Comment 3, the same issue applies to Figure S2. Please replace all figures with high-resolution versions.
3. Minor: please add the standard error estimates to Supplementary Table 2.
Reviewer #2:
1. The claim of novelty overstates the advance relative to Zhang et al. 2023. The introduction should more clearly delineate what is new.
2. How were the batch effects between the two sequencing centers corrected? This is essential to support the conclusions.
+31 -2
View File
@@ -36,7 +36,8 @@ class Settings(BaseSettings):
# ---- 自审稿(presubmit)----
presubmit_read_timeout: float = 360.0 # LLM 长调用读超时(秒)
max_text_chars: int = 60000 # 送 LLM 的正文截断上限(约 3 万汉字/6 万字符)
max_text_chars: int = 150000 # 送 LLM 的正文截断上限(REVIEW_MAX_TEXT_CHARS 覆盖;
# token 超 48K 自动降 100K,LLM 400/413 再降 80K 重试)
max_pdf_bytes: int = 50 * 1024 * 1024 # 上传 PDF 上限 50MB(对齐 extraction FR-EXTRACT-004)
# ---- 期刊情报与投稿推荐(recommend)----
@@ -61,6 +62,23 @@ class Settings(BaseSettings):
compile_queue_limit: int = 5 # 等待槽位上限(>5 → QUEUE_FULL 429)
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)
@property
def _service_root(self) -> "Path":
# review_service/app/config.py → review_service/(相对本文件定位,不依赖 cwd)
@@ -90,10 +108,21 @@ class Settings(BaseSettings):
return p if p.is_absolute() else self._service_root / p
@property
def tectonic_cache_abs(self) -> "Path":
def tectonic_cache_abs(self) -> Path:
p = Path(self.tectonic_cache_dir)
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
def vstore_base(self) -> str:
return self.vstore_base_url.rstrip("/")
+43
View File
@@ -25,6 +25,7 @@ from .routers import (
journals,
presubmit,
reformat,
rebuttal,
)
logging.basicConfig(level=logging.INFO)
@@ -48,12 +49,45 @@ def create_app() -> FastAPI:
# journals 推荐依赖独立 SQLite:启动即建表(幂等;测试可先 set_path 注入
# tmp 路径,本函数沿用注入路径)
await init_db()
# rebuttal Phase 1:独立表,启动幂等建表(自含连接,不走 db.py)
try:
from .services.rebuttal_store import ensure_table
ensure_table()
except Exception: # noqa: BLE001
logger.warning("rebuttal 建表失败(忽略)", exc_info=True)
# 编译产物过期清扫:启动跑一次(失败不阻塞启动)
try:
from .services.latex_compile import sweep_expired
sweep_expired()
except Exception: # noqa: BLE001
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;
# 路径沿用 db.path 注入链——生产=settings.db_path_abs,测试=tmp)
if settings.seed_on_startup:
@@ -81,6 +115,14 @@ def create_app() -> FastAPI:
try:
yield
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()
app.router.lifespan_context = _lifespan
@@ -104,6 +146,7 @@ def create_app() -> FastAPI:
}
app.include_router(presubmit.router)
app.include_router(rebuttal.router)
app.include_router(journals.router)
app.include_router(format_check.router)
app.include_router(conferences.router)
+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:
if not _CID_RE.fullmatch(cid or ""):
return None
@@ -139,6 +240,63 @@ def _cid_dir(cid: str) -> Path | 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)
async def get_compile(compile_id: str) -> CompileResponse:
cdir = _cid_dir(compile_id)
+194 -12
View File
@@ -1,23 +1,147 @@
"""routers/presubmit.py —— 投稿前自审稿(POST /api/review/presubmit)。
流程:上传 PDF → paper_parse(vstore 8094 解析)→ LLM 结构化审稿
(DeepSeek json_object,System Prompt 为植物基因组学六维专属清单,
见 services/self_review.py)→ 归一化为 SelfReviewReport。
流程:上传 PDF → paper_parse(vstore 8094 解析)→ 段落感知装箱 + token 双层保护
→ LLM 结构化审稿(DeepSeek json_object,System Prompt 为植物基因组学六维专属
清单,见 services/self_review.py)→ coerce 归一化 → 关键词扫描器证伪硬性缺项
(services/paper_scanner.py)→ 规则引擎计算 recommendation。
"""
from __future__ import annotations
import logging
import re
from fastapi import APIRouter, File, Form, UploadFile
from pydantic import BaseModel
from ..config import get_settings
from ..services import llm, paper_parse, self_review
from ..services import llm, paper_parse, paper_scanner, self_review
from ..services.recommend_engine import decide_recommendation, resolve_tier
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/review", tags=["review-presubmit"])
# ---- token 双层保护(2026-10-12)----
# 第一层:估算 token 超阈值 → 自动降到 TOKEN_GUARD_REPACK_CHARS 字符重装箱
# 第二层:LLM 返回 HTTP 400/413 → 降到 LLM_REJECT_RETRY_CHARS 字符重试一次
_EST_TOKENS_DIVISOR = 1.5 # 粗略上界:英文 ~4 字符/token,1.5 偏保守
_TOKEN_GUARD_THRESHOLD = 48000
_TOKEN_GUARD_REPACK_CHARS = 100000
_LLM_REJECT_RETRY_CHARS = 80000
# 无节标题可辨时的尾窗(捞页尾 Data Availability 块)
_TAIL_FALLBACK_CHARS = 5000
# 节标题(行首、允许编号前缀、独占一行);键 → 装箱分组名
_SECTION_HEADER_RE = re.compile(
r"^[ \t]*(?:\d+(?:\.\d+)*\.?\s+)?"
r"(Abstract|Summary|Introduction|Results|Discussion|Methods"
r"|Materials\s+and\s+Methods"
r"|Data\s+(?:and\s+(?:code\s+)?)?[Aa]vailability"
r"|Availability\s+of\s+[Dd]ata"
r"|Data\s+and\s+[Mm]aterials\s+[Aa]vailability"
r"|References|Acknowledg(?:e)?ments?)\s*$",
re.IGNORECASE | re.MULTILINE,
)
_SECTION_GROUP = {
"abstract": "abstract", "summary": "abstract",
"introduction": "introduction",
"results": "results",
"discussion": "discussion",
"methods": "methods", "materials and methods": "methods",
"data availability": "data_availability",
"data and code availability": "data_availability",
"data availability statement": "data_availability",
"availability of data": "data_availability",
"data and materials availability": "data_availability",
"references": "references", "acknowledgements": "acknowledgements",
"acknowledgments": "acknowledgements",
}
# 装箱优先级(首条命中优先;methods 限 60% 预算——六维清单大量依赖 Methods)
_PACK_PRIORITY = ("abstract", "data_availability", "methods", "results", "discussion")
_METHODS_CAP_RATIO = 0.6
def estimate_tokens(text: str) -> int:
"""粗略 token 上界估计(len/1.5),无需 tokenizer 依赖。"""
return int(len(text) / _EST_TOKENS_DIVISOR)
def _split_segments(text: str) -> list[tuple[str, str]] | None:
"""按节标题切段 → [(group, content)...]。无可辨标题 → None。"""
matches = list(_SECTION_HEADER_RE.finditer(text))
if not matches:
return None
segments: list[tuple[str, str]] = []
if matches[0].start() > 0:
segments.append(("preamble", text[:matches[0].start()]))
for i, m in enumerate(matches):
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
group = _SECTION_GROUP.get(m.group(1).casefold(), m.group(1).casefold())
segments.append((group, text[m.start():end]))
return segments
def pack_feed_text(text: str, budget: int) -> tuple[str, list[str]]:
"""段落感知装箱 → (feed_text, dropped_sections)。
预算内:原文返回。超预算:按优先级保留
① Abstract ② Data Availability 全文 ③ Methods 前 60% ④ Results
⑤ Discussion,其余按原序填满;未收段记入 dropped。
无节标题可辨 → 头 + 尾窗拼接(捞页尾 availability 块),不优于现状亦不劣于。
"""
if len(text) <= budget:
return text, []
segments = _split_segments(text)
if not segments:
sep = "\n[...]\n"
head = max(0, budget - _TAIL_FALLBACK_CHARS - len(sep))
return (
text[:head] + sep + text[-_TAIL_FALLBACK_CHARS:],
["[无节标题回退:保留头+尾窗]"],
)
methods_cap = int(budget * _METHODS_CAP_RATIO)
chosen: dict[int, str] = {}
used = 0
for group in _PACK_PRIORITY:
for i, (g, content) in enumerate(segments):
if g != group or i in chosen:
continue
room = budget - used
if room <= 0:
break
piece = content[:methods_cap] if group == "methods" else content
if len(piece) > room:
piece = piece[:room]
if not piece:
break
chosen[i] = piece
used += len(piece)
break
for i, (_, content) in enumerate(segments):
if i in chosen or used >= budget:
continue
room = budget - used
chosen[i] = content if len(content) <= room else content[:room]
used += len(chosen[i])
feed = "".join(chosen[i] for i in sorted(chosen))
dropped: list[str] = []
for i, (g, _) in enumerate(segments):
if i not in chosen and g not in dropped:
dropped.append(g)
return feed, dropped
def _fit_feed_text(text: str, budget: int) -> tuple[str, list[str]]:
"""装箱 + 第一层 token 保护(估算超阈值自动降到 100K 字符重装)。"""
feed, dropped = pack_feed_text(text, budget)
if estimate_tokens(feed) > _TOKEN_GUARD_THRESHOLD and len(feed) > _TOKEN_GUARD_REPACK_CHARS:
logger.info(
"presubmit: token 估算 %d > %d,降到 %d 字符重装箱",
estimate_tokens(feed), _TOKEN_GUARD_THRESHOLD, _TOKEN_GUARD_REPACK_CHARS,
)
feed, dropped = pack_feed_text(text, _TOKEN_GUARD_REPACK_CHARS)
return feed, dropped
class PresubmitResponse(BaseModel):
ok: bool
@@ -58,22 +182,71 @@ async def presubmit_review(
return _error("PARSE_FAILED", str(exc), degraded=True)
truncated = len(text) > settings.max_text_chars
feed_text = text[: settings.max_text_chars]
feed_text, dropped_sections = _fit_feed_text(text, settings.max_text_chars)
# ---- LLM 结构化自审(植物基因组学六维清单)----
# 第二层保护:HTTP 400/413(疑似超长/形态拒绝)→ 降 80K 字符重试一次
user_prompt = self_review.build_user_prompt(feed_text, (target_journal or "").strip())
try:
payload = await llm.complete_json(
self_review.SYSTEM_PROMPT,
self_review.build_user_prompt(feed_text, (target_journal or "").strip()),
temperature=0.2,
max_tokens=8000,
timeout=settings.presubmit_read_timeout,
)
try:
payload = await llm.complete_json(
self_review.SYSTEM_PROMPT,
user_prompt,
temperature=0.2,
max_tokens=8000,
timeout=settings.presubmit_read_timeout,
)
except llm.LLMError as exc:
msg = str(exc)
if ("HTTP 400" in msg or "HTTP 413" in msg) and len(feed_text) > _LLM_REJECT_RETRY_CHARS:
logger.warning("presubmit: LLM 拒绝输入(%s),降到 %d 字符重试",
msg[:120], _LLM_REJECT_RETRY_CHARS)
feed_text, dropped_sections = pack_feed_text(text, _LLM_REJECT_RETRY_CHARS)
truncated = True
payload = await llm.complete_json(
self_review.SYSTEM_PROMPT,
self_review.build_user_prompt(feed_text, (target_journal or "").strip()),
temperature=0.2,
max_tokens=8000,
timeout=settings.presubmit_read_timeout,
)
else:
raise
except llm.LLMError as exc:
logger.error("presubmit: LLM 审稿失败:%s", exc)
return _error("UPSTREAM_DOWN", f"审稿模型调用失败:{exc}", degraded=True)
report = self_review.coerce_report(payload)
# ---- 关键词扫描器:证伪 LLM 的硬性缺项(有 X 却报缺 X → 真降级至 minor)----
# A2(2026-10-12):被冲突的 major 不再留在 major 列表里只剥标签,而是移出
# major、append 到 minor(scanner_downgraded=True)——major_count/引擎输入真实
# 减少;fatal 条目例外(只记冲突不降级,须人工过目)。
signals = paper_scanner.scan_paper_signals(feed_text)
kept_majors, demoted_minors, scanner_conflicts = paper_scanner.reconcile_hard_rules(
report.major_weaknesses, signals
)
report.major_weaknesses = kept_majors
if demoted_minors:
report.minor_weaknesses = list(report.minor_weaknesses) + list(demoted_minors)
# recommendation 由规则引擎计算(LLM 输出的同名字段已被 coerce 忽略)
tier, tier_source = resolve_tier((target_journal or "").strip())
decision = decide_recommendation(
majors=report.major_weaknesses,
minors=report.minor_weaknesses,
presentations=report.presentation_format,
tier=tier,
)
rationale = decision["rationale"]
rationale["scanner_conflicts"] = scanner_conflicts
if scanner_conflicts:
rationale["explanation"] += (
f" 关键词扫描器与审稿意见存在 {len(scanner_conflicts)} 处冲突,"
f"已降级为小修级问题,请人工核对。"
)
report.recommendation = decision["recommendation"]
report.recommendation_rationale = rationale
return PresubmitResponse(
ok=True,
report=report,
@@ -82,6 +255,15 @@ async def presubmit_review(
"target_journal": (target_journal or "").strip(),
"text_chars": len(feed_text),
"truncated": truncated,
"dropped_sections": dropped_sections,
"scanner": {
"accession_found": bool((signals.get("accession") or {}).get("found")),
"busco_level": (signals.get("busco") or {}).get("level"),
"hic_found": bool((signals.get("hic") or {}).get("found")),
},
"model": llm._llm_config()["model"],
"tier": tier,
"tier_label": decision["rationale"]["tier_label"],
"tier_source": tier_source,
},
)
+288
View File
@@ -0,0 +1,288 @@
"""routers/rebuttal.py —— Rebuttal Phase 1(审稿回复助手,10 端点)。
信封与其余 review 端点一致:业务错误 = HTTP 200 + {ok:false,error:{code,message,degraded}}。
- 文本粘贴主入口 / PDF 次要入口(PDF 经 vstore 8094 解析);
- 逐条 response → draft(校验C前置)→ confirm(后端重算 human_ack)→ letter 拼装;
- letter.md 下载门槛 = human_ack;DELETE 手动清(无 TTL,决策 5)。
"""
from __future__ import annotations
import logging
from typing import Any, Literal
from fastapi import APIRouter, File, UploadFile
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, Field
from ..config import get_settings
from ..services import llm, paper_parse
from ..services.rebuttal_draft import PART_TYPES, assemble_letter, generate_draft, validate_parts
from ..services.rebuttal_parse import parse_review_text
from ..services import rebuttal_store
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/review/rebuttal", tags=["review-rebuttal"])
_MAX_RAW_CHARS = 200_000 # 粘贴文本上限(审稿意见通常 <20K,保守给大)
# ---------- 信封 ----------
class RebuttalResponse(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) -> RebuttalResponse:
return RebuttalResponse(ok=False, error={"code": code, "message": message, "degraded": False})
def _ok(job: dict[str, Any], **meta: Any) -> RebuttalResponse:
return RebuttalResponse(ok=True, job=job, meta=meta or None)
# ---------- 请求体 ----------
class JobCreateBody(BaseModel):
text: str = Field(min_length=1)
class PartBody(BaseModel):
type: Literal["new_data", "reanalysis", "location", "clarification", "rebuttal"]
content: str = ""
location_hint: str | None = None
class ResponseBody(BaseModel):
parts: list[PartBody] = Field(default_factory=list)
class StructureBody(BaseModel):
action: Literal["merge", "split"]
# merge: comment_ids(含当前 cid,≥2 条);split: items [{title, body}]
comment_ids: list[str] | None = None
items: list[dict[str, str]] | None = None
class ConfirmBody(BaseModel):
confirmed: bool
# ---------- 内部工具 ----------
async def _messages_llm(messages: list[dict[str, str]], *, temperature: float = 0.3) -> dict[str, Any]:
"""适配 rebuttal_parse/draft 的 messages 形态 → llm.complete_json。"""
system = next((m["content"] for m in messages if m.get("role") == "system"), "")
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
return await llm.complete_json(system, user, temperature=temperature)
def _find_comment(job: dict[str, Any], cid: str) -> tuple[dict, dict] | None:
for r in job["reviewers"]:
for c in r["comments"]:
if c["id"] == cid:
return r, c
return None
async def _build_job(raw_text: str, *, kind: str, filename: str | None) -> RebuttalResponse:
raw_text = (raw_text or "").strip()
if not raw_text:
return _error("VALIDATION", "审稿意见文本为空")
if len(raw_text) > _MAX_RAW_CHARS:
return _error("VALIDATION", f"文本超过 {_MAX_RAW_CHARS} 字符上限")
reviewers = await parse_review_text(raw_text, _messages_llm)
if not reviewers:
return _error("PARSE_FAILED", "未能从文本中解析出任何审稿意见条目")
job = {
"id": rebuttal_store.new_id("rbt_"),
"status": "ready",
"source": {"kind": kind, "filename": filename, "raw_text": raw_text},
"template": "generic",
"reviewers": reviewers,
"letter_md": None,
"validation": None,
"human_ack": False,
}
rebuttal_store.insert_job(job)
n_comments = sum(len(r["comments"]) for r in reviewers)
return _ok(job, reviewers=len(reviewers), comments=n_comments)
# ---------- 端点 ----------
@router.post("/jobs", response_model=RebuttalResponse)
async def create_job(body: JobCreateBody) -> RebuttalResponse:
"""文本粘贴主入口。"""
return await _build_job(body.text, kind="text", filename=None)
@router.post("/jobs/pdf", response_model=RebuttalResponse)
async def create_job_pdf(file: UploadFile = File(...)) -> RebuttalResponse:
"""PDF 次要入口(批注版审稿 PDF;经 vstore 8094 解析)。"""
settings = get_settings()
filename = file.filename or "review.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
return _error("UPSTREAM_DOWN", f"PDF 解析失败:{exc}")
return await _build_job(text, kind="pdf", filename=filename)
@router.get("/jobs/{job_id}", response_model=RebuttalResponse)
async def get_job(job_id: str) -> RebuttalResponse:
job = rebuttal_store.get_job(job_id)
if job is None:
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
return _ok(job)
@router.patch("/jobs/{job_id}/comments/{cid}/response", response_model=RebuttalResponse)
async def patch_response(job_id: str, cid: str, body: ResponseBody) -> RebuttalResponse:
job = rebuttal_store.get_job(job_id)
if job is None:
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
found = _find_comment(job, cid)
if found is None:
return _error("NOT_FOUND", f"comment {cid} 不存在")
_, comment = found
for p in body.parts:
if p.type not in PART_TYPES:
return _error("VALIDATION", f"未知 part.type: {p.type}")
comment["response"] = {"parts": [p.model_dump() for p in body.parts]}
# 改回复 → 旧 draft/validation 作废(保守:不保留过期草稿)
comment["draft"] = None
comment["validation"] = None
job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job
return _ok(job)
@router.post("/jobs/{job_id}/comments/{cid}/draft", response_model=RebuttalResponse)
async def post_draft(job_id: str, cid: str) -> RebuttalResponse:
job = rebuttal_store.get_job(job_id)
if job is None:
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
found = _find_comment(job, cid)
if found is None:
return _error("NOT_FOUND", f"comment {cid} 不存在")
_, comment = found
parts = (comment.get("response") or {}).get("parts") or []
if not parts or not any(str(p.get("content") or "").strip() for p in parts):
# 决策 4:生成前必须填 user_response;LLM 调用次数=0
return _error("MISSING_RESPONSE", "请先填写回复要点(user_response)再生成草稿")
validation = validate_parts(parts)
comment["validation"] = validation
if not validation["passed"]:
job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job
return RebuttalResponse(
ok=False, job=job,
error={"code": "VALIDATION_FAILED", "message": "反幻觉校验未通过",
"degraded": False},
meta={"validation": validation},
)
comment["draft"] = await generate_draft(_messages_llm, comment)
job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job
return _ok(job, validation=validation)
@router.patch("/jobs/{job_id}/comments/{cid}/structure", response_model=RebuttalResponse)
async def patch_structure(job_id: str, cid: str, body: StructureBody) -> RebuttalResponse:
job = rebuttal_store.get_job(job_id)
if job is None:
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
found = _find_comment(job, cid)
if found is None:
return _error("NOT_FOUND", f"comment {cid} 不存在")
reviewer, comment = found
comments = reviewer["comments"]
idx = comments.index(comment)
if body.action == "merge":
ids = body.comment_ids or []
if cid not in ids:
ids = [cid] + ids
if len(ids) < 2:
return _error("VALIDATION", "merge 至少需要 2 条 comment")
targets = [c for c in comments if c["id"] in ids]
if len(targets) < 2:
return _error("VALIDATION", "merge 目标中有效 comment 不足 2 条")
merged_body = "\n\n".join(c["body"] for c in targets)
comment["body"] = merged_body
comment["title"] = f"{comment['title']} (+{len(targets) - 1} merged)"
reviewer["comments"] = [
c for c in comments
if c is comment or c["id"] not in ids
]
else: # split
items = body.items or []
items = [i for i in items if str(i.get("body") or "").strip()]
if len(items) < 2:
return _error("VALIDATION", "split 至少需要 2 个非空子项")
group_id = comment.get("group_id") or f"g_{cid}"
new_comments = []
for n, it in enumerate(items, start=1):
c = dict(comment)
c["id"] = rebuttal_store.new_id(f"{cid}s{n}")
c["group_id"] = group_id
c["title"] = str(it.get("title") or f"{comment['title']} ({n})")
c["body"] = str(it.get("body") or "").strip()
c["sub_items"] = []
c["response"] = {"parts": []}
c["draft"] = None
c["validation"] = None
c["human_confirmed"] = False
new_comments.append(c)
comments[idx:idx + 1] = new_comments
job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job
return _ok(job)
@router.patch("/jobs/{job_id}/comments/{cid}/confirm", response_model=RebuttalResponse)
async def patch_confirm(job_id: str, cid: str, body: ConfirmBody) -> RebuttalResponse:
job = rebuttal_store.get_job(job_id)
if job is None:
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
found = _find_comment(job, cid)
if found is None:
return _error("NOT_FOUND", f"comment {cid} 不存在")
_, comment = found
comment["human_confirmed"] = bool(body.confirmed)
# human_ack 由 store 重算(决策 4:全勾才置位,不信前端)
job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job
return _ok(job, human_ack=job["human_ack"])
@router.post("/jobs/{job_id}/letter", response_model=RebuttalResponse)
async def post_letter(job_id: str) -> RebuttalResponse:
job = rebuttal_store.get_job(job_id)
if job is None:
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
job = rebuttal_store.update_job(job_id, letter_md=assemble_letter(job)) or job
return _ok(job)
@router.get("/jobs/{job_id}/letter.md")
async def download_letter(job_id: str) -> PlainTextResponse:
job = rebuttal_store.get_job(job_id)
if job is None:
return PlainTextResponse(
"job not found", status_code=404, media_type="text/plain; charset=utf-8")
if not job.get("human_ack"):
# 信封口径与下载端点适配:GET 下载返回纯文本,业务拒绝用 400 + 明文
return PlainTextResponse(
"HUMAN_ACK_REQUIRED: 请逐条勾选确认后再下载 letter.md",
status_code=400, media_type="text/plain; charset=utf-8")
md = job.get("letter_md") or assemble_letter(job)
return PlainTextResponse(md, media_type="text/markdown; charset=utf-8")
@router.delete("/jobs/{job_id}", response_model=RebuttalResponse)
async def delete_job(job_id: str) -> RebuttalResponse:
if not rebuttal_store.delete_job(job_id):
return _error("NOT_FOUND", f"job {job_id} 不存在或已删除")
return RebuttalResponse(ok=True, job=None, 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)
@@ -9,6 +9,13 @@
6. 产物 PDF > max_compile_pdf_bytes → 删文件 + OutputTooLargeError
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(路由层回安装指引)。
"""
from __future__ import annotations
@@ -20,7 +27,10 @@ import os
import re
import shutil
import signal
import subprocess
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -40,6 +50,9 @@ _pending = 0
class CompileError(Exception):
"""编译失败(含 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:
super().__init__(message)
@@ -234,3 +247,623 @@ def sweep_expired() -> int:
logger.info("compile sweep: 清理 %d 个过期任务目录(>%d 天)",
removed, settings.compile_retention_days)
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
@@ -0,0 +1,226 @@
"""services/paper_scanner.py —— 关键词兜底扫描器(纯 stdlib,零 LLM)。
背景(2026-10-12):hard_rule 标签由 LLM 输出,存在"正文明明有 accession/BUSCO/挂载率
却报缺失"的漏检风险(真实案例 s41588 Nature Genetics:正文有 PRJCA038097 等 +
BUSCO 98.57% + 94.18% anchored,仍被判 busco/accession/hic_missing → 大修)。
本模块做**程序层证伪**:LLM 说缺某项、扫描器在文中找到该项 → 剥掉 hard_rule
降级为非硬性 major,并在 rationale 记录冲突(前端黄色提示,请人工核对)。
边界(刻意保守,不引入新误判):
- 只做"证伪硬性缺项",不做"证实"——扫不到不等于真缺(可能同义表述),不升级任何条目
- accession 只在 Data Availability 段内扫(用户指令):正文引用他人数据的
accession 不得误认为"自己有"
"""
from __future__ import annotations
import re
from typing import Any
# ---- accession(只在 DA 段扫)----
ACCESSION_RE = re.compile(
r"\b(PRJNA|PRJEB|PRJCA|SRP|ERP|DRP|CRA|SRR|ERR|DRR|GCA_|GCF_)\s?(\d[\d._]*)",
re.IGNORECASE,
)
# ---- DA 段定位:标题多变体(行首允许编号前缀)----
_DA_START_RE = re.compile(
r"^[ \t]*(?:\d+(?:\.\d+)*\.?\s+)?"
r"(?:Data\s+(?:and\s+(?:code\s+)?)?[Aa]vailability"
r"|Availability\s+of\s+(?:data|Data)"
r"|Data\s+and\s+[Mm]aterials\s+[Aa]vailability"
r"|Data\s+availability\s+statement)",
re.MULTILINE,
)
# DA 段结束 = 下一个一级节标题
_NEXT_HEADER_RE = re.compile(
r"^[ \t]*(?:\d+(?:\.\d+)*\.?\s+)?"
r"(?:Abstract|Summary|Introduction|Results|Discussion|Methods"
r"|Materials\s+and\s+Methods|References|Acknowledg(?:e)?ments?"
r"|Author\s+[Ii]nformation|Rights\s+and\s+permissions|Change\s+history"
r"|Data\s+(?:and\s+(?:code\s+)?)?[Aa]vailability"
r"|Availability\s+of\s+[Dd]ata|Data\s+and\s+[Mm]aterials\s+[Aa]vailability)"
r"\s*$",
re.MULTILINE,
)
# 回退窗:DA 无行首标题时,短语出现处 + 上下文
_DA_PHRASE_RE = re.compile(r"data\s+availability", re.IGNORECASE)
_DA_FALLBACK_WINDOW = 3000
# ---- BUSCO(全文)----
_BUSCO_MENTION_RE = re.compile(r"\bBUSCO\b", re.IGNORECASE)
_BUSCO_FULL_RE = re.compile(
r"(?:\bS\s*:\s*\d|\bSingle[- ]copy)"
r".{0,200}?"
r"(?:\bD\s*:\s*\d|\bDuplicat)",
re.IGNORECASE | re.DOTALL,
)
# ---- Hi-C 挂载率(全文)----
HIC_RE = re.compile(
r"(anchored\s+to\s+chromosomes?"
r"|anchoring\s+rate"
r"|pseudo[- ]?chromosomes?"
r"|chromosome[- ]level"
r"|%[^.\n]{0,40}anchored)",
re.IGNORECASE,
)
_MAX_HITS = 20
def _extract_da_section(text: str) -> str:
"""定位 Data Availability 段正文(标题后 → 下一个一级节标题)。
无行首标题时回退:短语出现处向后 3000 字符窗。均未命中 → ""。
"""
m = _DA_START_RE.search(text)
if m:
nxt = _NEXT_HEADER_RE.search(text, m.end())
end = nxt.start() if nxt else min(len(text), m.end() + _DA_FALLBACK_WINDOW)
return text[m.start():end]
m2 = _DA_PHRASE_RE.search(text)
if m2:
return text[m2.start():min(len(text), m2.start() + _DA_FALLBACK_WINDOW)]
return ""
def _scan_accession(da_text: str) -> dict[str, Any]:
if not da_text:
return {"found": False, "hits": [], "in_da_section": False}
hits: list[str] = []
seen: set[str] = set()
for m in ACCESSION_RE.finditer(da_text):
token = (m.group(1) + m.group(2)).upper()
if token not in seen:
seen.add(token)
hits.append(token)
if len(hits) >= _MAX_HITS:
break
return {"found": bool(hits), "hits": hits, "in_da_section": True}
def _scan_busco(text: str) -> dict[str, Any]:
m = _BUSCO_MENTION_RE.search(text)
if not m:
return {"level": "none", "excerpt": ""}
excerpt = text[m.start():m.start() + 200]
if _BUSCO_FULL_RE.search(text):
level = "full"
else:
level = "partial" # 只有百分比、无 S/D 分项 → prompt 已定为 minor
return {"level": level, "excerpt": excerpt}
def _scan_hic(text: str) -> dict[str, Any]:
m = HIC_RE.search(text)
if not m:
return {"found": False, "excerpt": ""}
start = max(0, m.start() - 40)
return {"found": True, "excerpt": text[start:m.end() + 80]}
def scan_paper_signals(text: str) -> dict[str, Any]:
"""全文 → 结构化信号。accession 只在 DA 段;busco/hic 全文。"""
if not text:
return {
"accession": {"found": False, "hits": [], "in_da_section": False},
"busco": {"level": "none", "excerpt": ""},
"hic": {"found": False, "excerpt": ""},
}
return {
"accession": _scan_accession(_extract_da_section(text)),
"busco": _scan_busco(text),
"hic": _scan_hic(text),
}
def _get_hr(item: Any) -> Any:
if isinstance(item, dict):
return item.get("hard_rule")
return getattr(item, "hard_rule", None)
def _item_fatal(item: Any) -> bool:
if isinstance(item, dict):
return bool(item.get("fatal"))
return bool(getattr(item, "fatal", False))
def _set_downgraded(item: Any) -> None:
if isinstance(item, dict):
item["scanner_downgraded"] = True
else:
try:
item.scanner_downgraded = True
except Exception: # pydantic 严格模式等极端场景:吞掉,冲突记录仍保留
pass
def _set_hr_none(item: Any) -> None:
if isinstance(item, dict):
item["hard_rule"] = None
else:
try:
item.hard_rule = None
except Exception: # pydantic 严格模式等极端场景:吞掉,冲突记录仍保留
pass
def _item_title(item: Any) -> str:
if isinstance(item, dict):
return str(item.get("title") or "")
return str(getattr(item, "title", "") or "")
# 冲突映射:hard_rule 标签 → (信号检查, 冲突描述)
def _conflict_for(tag: str, signals: dict[str, Any]) -> str | None:
acc = signals.get("accession") or {}
busco = signals.get("busco") or {}
hic = signals.get("hic") or {}
if tag == "accession_missing" and acc.get("found"):
hits = ", ".join(acc.get("hits") or [])[:120]
return f"Data Availability 段已检出 accession({hits})"
if tag == "busco_missing" and busco.get("level") in ("partial", "full"):
return f"正文已提及 BUSCO({busco.get('level')} 格式)"
if tag == "hic_missing" and hic.get("found"):
excerpt = (hic.get("excerpt") or "").strip()[:80]
return f"正文已检出挂载率描述({excerpt})"
return None
def reconcile_hard_rules(
majors: list[Any], signals: dict[str, Any]
) -> tuple[list[Any], list[Any], list[dict[str, Any]]]:
"""对带 hard_rule 的 major 做扫描器证伪:命中 → 剥标签 + **移出 major 降级至
minor**(A2 语义,2026-10-12)→ 记冲突。
返回 (kept_majors, demoted_minors, conflicts):
- kept_majors:未冲突/未处理的 major(原序)
- demoted_minors:被冲突降级的条目(hard_rule 置 None、scanner_downgraded=True)
- conflicts:{hard_rule, title, reason}——fatal 条目只记冲突不降级
(拒稿级问题须人工过目,扫描器证伪不了伦理判断)。
"""
kept: list[Any] = []
demoted: list[Any] = []
conflicts: list[dict[str, Any]] = []
for m in majors:
tag = _get_hr(m)
if not isinstance(tag, str):
kept.append(m)
continue
reason = _conflict_for(tag, signals)
if reason is None:
kept.append(m)
continue
conflicts.append({
"hard_rule": tag,
"title": _item_title(m),
"reason": reason,
})
if _item_fatal(m):
kept.append(m) # fatal 保留原位(含其 hard_rule 供引擎 R1/R0 使用)
continue
_set_hr_none(m)
_set_downgraded(m)
demoted.append(m)
return kept, demoted, conflicts
@@ -0,0 +1,153 @@
"""services/rebuttal_draft.py —— 5 类回复片段 Prompt + 反幻觉校验 + letter 拼装。
- part.type 枚举(决策 2,无 auto):new_data | reanalysis | location | clarification | rebuttal。
- 反幻觉校验(补充 2 收窄版,只做负向证伪,不做泛化打分):
* location:出现 "shows that / as shown" 但无 "added/新增" 迹证 → FAIL
("S3 shows that..." 红 FAIL;"we have added ... in S3" 过)。
* rebuttal:命中负向词表 we agree / we accept / we concede / you are right /
we were wrong → FAIL;无立场词(maintain/disagree/respectfully/坚持/不同意…)
→ FAIL;"respectfully maintain" 过。
* 其他三类:Phase 1 仅查非空。
- 校验前置(决策:校验C前置):draft 生成前先跑校验,FAIL 则不调 LLM。
- letter 拼装:按 reviewer → comment 顺序,原文 + 草稿;无草稿留 "[待补充]"。
"""
from __future__ import annotations
import re
from typing import Any, Callable
LLMCallable = Callable[..., Any]
PART_TYPES = ("new_data", "reanalysis", "location", "clarification", "rebuttal")
_TYPE_FRAGMENTS: dict[str, str] = {
"new_data": (
"你的回复属于【补充新数据】类:说明新增了哪些实验/数据(图/表编号、关键数值),"
"并明确回应原审稿关切。不得虚构未提供的数据——只用 user_response 给出的事实。"
),
"reanalysis": (
"你的回复属于【重新分析】类:说明重跑了哪些分析、方法/参数变化与新结论要点。"
"不得虚构未提供的结果——只用 user_response 给出的事实。"
),
"location": (
"你的回复属于【定位/指出位置】类:明确指出修改或证据在稿件中的位置"
"(如 Section 3 / Fig. S2 / Methods 段落),与 user_response 给出的位置一致。"
"不得虚构未提供的位置。"
),
"clarification": (
"你的回复属于【澄清说明】类:针对误解给出准确、简洁的解释,"
"只用 user_response 与原评论内容,不引入新主张。"
),
"rebuttal": (
"你的回复属于【礼貌反驳】类:礼貌但明确地表达不同立场(maintain/disagree),"
"给出理由(基于 user_response 与原文),不得虚构证据。"
),
}
_BASELINE = (
"通用底线:只基于原评论与 user_response 起草;不得编造数据、图表编号、"
"统计结果或已做修改;不确定处用占位([XX])而非猜测。"
"输出 JSON:{\"draft_md\": \"英文正式回复正文(markdown,≤200词)\"}。只输出 JSON。"
)
_STANCE = re.compile(
r"\b(maintain|disagree|respectfully|cannot\s+agree|dispute|stand\s+by|"
r"hold\s+to)\b|坚持|不同意|保留|异议",
re.IGNORECASE,
)
_NEGATIVE = re.compile(
r"\bwe\s+agree\b|\bwe\s+accept\b|\bwe\s+concede\b|\byou\s+are\s+right\b|"
r"\bwe\s+were\s+wrong\b",
re.IGNORECASE,
)
_LOC_SHOWS = re.compile(r"shows?\s+that|as\s+shown|表明", re.IGNORECASE)
_LOC_ADDED = re.compile(r"\badded\b|新增|have\s+added|we\s+have\s+added", re.IGNORECASE)
def validate_parts(parts: list[dict[str, Any]]) -> dict[str, Any]:
"""反幻觉校验 → {passed: bool, findings: [{part_index, code, message}]}。"""
findings: list[dict[str, Any]] = []
for i, p in enumerate(parts):
ptype = str(p.get("type") or "")
content = str(p.get("content") or "").strip()
if not content:
findings.append({"part_index": i, "code": "EMPTY_CONTENT",
"message": "回复内容不能为空"})
continue
if ptype == "location":
if _LOC_SHOWS.search(content) and not _LOC_ADDED.search(content):
findings.append({
"part_index": i, "code": "LOCATION_EVIDENCE_WEAK",
"message": "location 类回复出现“shows that/如所示”但未给出新增位置;"
"请写明“we have added … in S{n}”式定位",
})
elif ptype == "rebuttal":
if _NEGATIVE.search(content):
findings.append({
"part_index": i, "code": "REBUTTAL_NEGATIVE",
"message": "rebuttal 类回复出现让步词(we agree/accept/concede…);"
"该类应礼貌但明确地表达不同立场",
})
elif not _STANCE.search(content):
findings.append({
"part_index": i, "code": "REBUTTAL_NO_STANCE",
"message": "rebuttal 类回复缺少立场词(maintain/disagree/respectfully…)",
})
# new_data / reanalysis / clarification:Phase 1 仅非空
return {"passed": not findings, "findings": findings}
def _draft_system(parts: list[dict[str, Any]]) -> str:
frags = [_TYPE_FRAGMENTS.get(str(p.get("type") or ""), "") for p in parts]
frags = [f for f in frags if f]
return " ".join(frags) + " " + _BASELINE if frags else _BASELINE
async def generate_draft(llm: LLMCallable, comment: dict[str, Any]) -> str:
"""按 comment.response.parts 起草 draft_md(调用方须先过 validate_parts)。"""
parts = (comment.get("response") or {}).get("parts") or []
user_lines = []
for p in parts:
hint = str(p.get("location_hint") or "").strip()
line = f"- [{p.get('type')}] {p.get('content')}"
if hint:
line += f" (location_hint: {hint})"
user_lines.append(line)
user_msg = (
f"原审稿意见:\n{comment.get('body') or comment.get('title') or ''}\n\n"
f"作者拟回复要点(user_response):\n" + "\n".join(user_lines)
)
try:
data = await llm(
[{"role": "system", "content": _draft_system(parts)},
{"role": "user", "content": user_msg}],
temperature=0.2,
)
draft = str((data or {}).get("draft_md") or "").strip()
return draft or "[draft generation returned empty]"
except Exception: # noqa: BLE001
return "[draft generation failed — 请重试]"
def assemble_letter(job: dict[str, Any]) -> str:
"""拼装整封 response letter markdown(Phase 1 纯 .md,docx 放 Phase 2)。"""
lines: list[str] = ["# Response to Reviewers", ""]
lines.append(f"_Job: {job.get('id')} · template: {job.get('template') or 'generic'}_")
lines.append("")
for r in job.get("reviewers") or []:
lines.append(f"## {r.get('label') or 'Reviewer'}")
lines.append("")
for c in r.get("comments") or []:
title = c.get("title") or c.get("id") or "Comment"
lines.append(f"### {title}")
lines.append("")
lines.append("**Original comment:**")
lines.append("")
lines.append(str(c.get("body") or "").strip())
lines.append("")
lines.append("**Our response:**")
lines.append("")
draft = str(c.get("draft") or "").strip()
lines.append(draft if draft else "[待补充]")
lines.append("")
return "\n".join(lines)
@@ -0,0 +1,207 @@
"""services/rebuttal_parse.py —— 审稿意见文本 → 结构化 Comment 树。
流程(保守:正则先切骨架,LLM 只做分类/拆子条,行数交叉核对不匹配则回退正则):
1. 归一化换行;正则识别 Reviewer 段头(Reviewer 1 / 审稿人 2 / Review A 等),
无段头 → 单 Reviewer(label=Reviewer)。
2. 每个 Reviewer 段内按编号行切 comment 段("1." "2)" "(3)" 等);
切不出编号 → 整段作 1 条。
3. LLM complete_json 对每段做:{title, severity, sub_items:[{title,body,confidence}]}。
- LLM 返回段数 != 提交段数 → 全量回退正则(title=首 60 字,severity 默认 major)。
- sub_items ≥ 2 且最高 confidence ≥ 0.7 → 拆多条(共享 group_id);否则单条。
4. cross_refs:正文里 "see comment 3 / 参见第 2 条" → 解析目标;解析不到
target_id=null 保留 raw(决策:不猜)。
不做:auto 分类(已砍);severity 以外的任何 LLM 自由发挥字段。
"""
from __future__ import annotations
import re
from typing import Any, Callable
# LLM 依赖注入(测试打桩):async (messages, temperature=..) -> dict
LLMCallable = Callable[..., Any]
_REVIEWER_HEADER = re.compile(
r"^(?:reviewers?\s*#?\s*([0-9]+|[A-Z])|审稿人\s*#?\s*([0-9A-Z]+)|review\s+([A-Z]))\s*[::.))]?",
re.IGNORECASE | re.MULTILINE,
)
_COMMENT_NUM = re.compile(
r"(?:(?<=\n)|(?<=\.\s)|^)\s*(?:[#((]?\s*(\d{1,2})\s*[)).:、]|[((]\s*([ivx]{1,4}|[a-z])\s*[))])\s+",
re.IGNORECASE | re.MULTILINE,
)
_CROSS_REF = re.compile(
r"(?:see|noted\s+in|refer(?:ring)?\s+to|per)\s+comment\s*#?\s*(\d+)|"
r"(?:please\s+)?refer\s+to\s+comment\s*#?\s*(\d+)|"
r"参见\s*第?\s*(\d+)\s*条|见\s*comment\s*#?\s*(\d+)|同上(?:一条)?评论",
re.IGNORECASE,
)
_SPLIT_CONFIDENCE = 0.7 # 决策 3:confidence ≥ 0.7 才拆
def _norm(text: str) -> str:
return text.replace("\r\n", "\n").replace("\r", "\n").strip()
def split_reviewers(text: str) -> list[tuple[str, str]]:
"""→ [(label, block_text)];无段头 → [(Reviewer, 全文)]。"""
text = _norm(text)
matches = list(_REVIEWER_HEADER.finditer(text))
if not matches:
return [("Reviewer", text)]
out: list[tuple[str, str]] = []
for i, m in enumerate(matches):
raw = next(g for g in m.groups() if g is not None)
label = raw.upper() if raw.isalpha() else f"Reviewer {raw}"
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
block = text[start:end].strip()
if block:
out.append((label, block))
if not out: # 段头全空块 → 兜底
return [("Reviewer", text)]
# 段头前的引言(无段头内容)丢弃——保守:不猜归属
return out
def split_comments(block: str) -> list[str]:
"""段内按编号行切 comment 块;无编号 → [整段]。"""
block = _norm(block)
matches = list(_COMMENT_NUM.finditer(block))
if len(matches) < 2: # 单个编号或无编号 → 整段一条
return [block] if block else []
out: list[str] = []
for i, m in enumerate(matches):
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(block)
chunk = block[start:end].strip()
if chunk:
out.append(chunk)
return out or [block]
def _fallback_segment(seg: str) -> dict[str, Any]:
title = re.sub(r"\s+", " ", seg)[:60]
return {
"title": title,
"severity": "major",
"sub_items": [{"title": title, "body": seg, "confidence": 1.0}],
}
_SEG_SYSTEM = (
"你是学术审稿意见解析助手。输入是一段审稿意见原文。输出 JSON:"
'{"title": "≤12词标题", "severity": "major|minor|question", '
'"sub_items": [{"title": "...", "body": "该子问题完整原文", "confidence": 0.0-1.0}]}。'
"severity:major=要求补实验/补数据/重分析;minor=格式/表述小问题;"
"question=仅提问、无需修改稿件。"
"若该段含多个可独立回复的子问题则拆成多个 sub_items(confidence 给拆分把握度),"
"否则 1 个 sub_items(confidence=1.0)。只输出 JSON。"
)
async def _ask_llm_segment(llm: LLMCallable, seg: str) -> dict[str, Any] | None:
try:
data = await llm(
[{"role": "system", "content": _SEG_SYSTEM},
{"role": "user", "content": seg}],
temperature=0.0,
)
if not isinstance(data, dict) or not isinstance(data.get("sub_items"), list):
return None
sev = str(data.get("severity", "")).lower()
title = str(data.get("title") or "").strip() or seg[:60]
subs: list[dict[str, Any]] = []
for s in data["sub_items"][:8]:
if not isinstance(s, dict):
continue
body = str(s.get("body") or "").strip()
if not body:
continue
try:
conf = float(s.get("confidence", 1.0))
except (TypeError, ValueError):
conf = 0.0
subs.append({
"title": str(s.get("title") or "").strip() or title,
"body": body,
"confidence": max(0.0, min(1.0, conf)),
})
if not subs:
return None
return {"title": title,
"severity": sev if sev in ("major", "minor", "question") else "major",
"sub_items": subs}
except Exception: # noqa: BLE001 — LLM 失败即回退正则,不猜测
return None
def _resolve_cross_refs(body: str, ordinal_map: dict[int, str]) -> list[dict[str, Any]]:
refs: list[dict[str, Any]] = []
for m in _CROSS_REF.finditer(body):
raw = m.group(0).strip()
num = next((int(g) for g in m.groups() if g and g.isdigit()), None)
if num is None and "同上" in raw:
num = _prev_ordinal(ordinal_map, m.start()) # 同上 → 上一条,保守仅前一条
target = ordinal_map.get(num) if num is not None else None
refs.append({"raw": raw, "target_id": target})
return refs
def _prev_ordinal(ordinal_map: dict[int, str], _pos: int) -> int | None:
# 简化:同上 → 已知最大编号(即前一条)
return max(ordinal_map) if ordinal_map else None
async def parse_review_text(text: str, llm: LLMCallable) -> list[dict[str, Any]]:
"""→ reviewers=[{id, label, ordinal, comments:[Comment]}](不含 job 包装)。"""
reviewers_out: list[dict[str, Any]] = []
cid_seq = 0
for r_ord, (label, block) in enumerate(split_reviewers(text), start=1):
segs = split_comments(block)
if not segs:
continue
comments: list[dict[str, Any]] = []
ordinal_map: dict[int, str] = {}
parsed_seg: list[dict[str, Any]] = []
for seg in segs:
llm_res = await _ask_llm_segment(llm, seg)
# 交叉核对:LLM 无法给出结构 → 该段回退正则
parsed_seg.append(llm_res if llm_res else _fallback_segment(seg))
for seg_idx, (seg, p) in enumerate(zip(segs, parsed_seg), start=1):
subs = p["sub_items"]
split = len(subs) >= 2 and max(s["confidence"] for s in subs) >= _SPLIT_CONFIDENCE
group_id = f"g{r_ord}-{seg_idx}"
items = subs if split else [subs[0]]
if not split and len(subs) > 1:
# 未达阈值 → 多子问题并回一条(body 用整段原文)
items = [{"title": p["title"], "body": seg, "confidence": 1.0}]
for it in items:
cid_seq += 1
cid = f"c{cid_seq:04d}"
# 保守:不拆时 body 保留该段原文(不采信 LLM 改写);拆时用子项 body
body = it["body"] if split else seg
ordinal_map[seg_idx] = cid
comments.append({
"id": cid,
"group_id": group_id if split else None,
"severity": p["severity"],
"title": it["title"],
"body": body,
"sub_items": [s["title"] for s in subs] if split else [],
"cross_refs": _resolve_cross_refs(body, ordinal_map),
"response": {"parts": []},
"draft": None,
"validation": None,
"human_confirmed": False,
"scanner_downgraded": False,
})
# cross_refs 需全量 comment id 后再扫一遍(前面扫时 map 未齐)
for c in comments:
c["cross_refs"] = _resolve_cross_refs(c["body"], ordinal_map)
if comments:
reviewers_out.append({
"id": f"r{r_ord}", "label": label, "ordinal": r_ord,
"comments": comments,
})
return reviewers_out
@@ -0,0 +1,162 @@
"""services/rebuttal_store.py —— Rebuttal Phase 1 任务持久化(SQLite,自含连接)。
设计(2026-10-12 定稿):
- 表 rebuttal_jobs 落在 review_service/review.db(与 journals 同库不同表);
本模块自开短连接(低频 CRUD,WAL 下足够),不改既有 app/db.py。
- Job/Comment 全量以 JSON 存 reviewers_json 列(Phase 1 规模 ≤ 数十条,
整读整写最简单且可测;并发写靠 updated_at + 单进程串行)。
- human_ack 不信前端置位:confirm 端点改完 comment 后由本模块重算——
所有 comment.human_confirmed 全 true 才置 1(决策 4)。
- 不做 TTL 自动清理(决策 5);DELETE 端点手动删。
"""
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 rebuttal_jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
source_kind TEXT,
filename TEXT,
raw_text TEXT,
template TEXT DEFAULT 'generic',
reviewers_json TEXT NOT NULL,
letter_md TEXT,
validation_json TEXT,
human_ack INTEGER DEFAULT 0,
created_at TEXT,
updated_at TEXT
);
"""
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]:
return {
"id": row["id"],
"status": row["status"],
"source": {
"kind": row["source_kind"] or "text",
"filename": row["filename"],
"raw_text": row["raw_text"] or "",
},
"template": row["template"] or "generic",
"reviewers": json.loads(row["reviewers_json"] or "[]"),
"letter_md": row["letter_md"],
"validation": json.loads(row["validation_json"]) if row["validation_json"] else None,
"human_ack": bool(row["human_ack"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def insert_job(job: dict[str, Any]) -> None:
"""写入完整 job(reviewers 等结构由调用方构造好)。"""
now = job.get("created_at") or _now()
with _connect() as conn:
conn.execute(
"INSERT INTO rebuttal_jobs (id, status, source_kind, filename, raw_text,"
" template, reviewers_json, letter_md, validation_json, human_ack,"
" created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(
job["id"], job.get("status") or "ready",
(job.get("source") or {}).get("kind"),
(job.get("source") or {}).get("filename"),
(job.get("source") or {}).get("raw_text"),
job.get("template") or "generic",
json.dumps(job.get("reviewers") or [], ensure_ascii=False),
job.get("letter_md"),
json.dumps(job.get("validation"), ensure_ascii=False)
if job.get("validation") is not None else None,
1 if job.get("human_ack") else 0,
now, now,
),
)
def get_job(job_id: str) -> dict[str, Any] | None:
with _connect() as conn:
row = conn.execute(
"SELECT * FROM rebuttal_jobs WHERE id=?", (job_id,)
).fetchone()
return _row_to_job(row) if row else None
def update_job(job_id: str, *, reviewers: list[dict[str, Any]] | None = None,
letter_md: str | None = None, status: str | None = None) -> dict[str, Any] | None:
"""局部更新;reviewers 变更时重算 human_ack(全勾才 true)。"""
job = get_job(job_id)
if job is None:
return None
if reviewers is not None:
job["reviewers"] = reviewers
if letter_md is not None:
job["letter_md"] = letter_md
if status is not None:
job["status"] = status
job["human_ack"] = all_comments_confirmed(job["reviewers"])
job["updated_at"] = _now()
with _connect() as conn:
conn.execute(
"UPDATE rebuttal_jobs SET reviewers_json=?, letter_md=?, status=?,"
" human_ack=?, updated_at=? WHERE id=?",
(
json.dumps(job["reviewers"], ensure_ascii=False),
job.get("letter_md"),
job["status"],
1 if job["human_ack"] else 0,
job["updated_at"],
job_id,
),
)
return job
def delete_job(job_id: str) -> bool:
with _connect() as conn:
cur = conn.execute("DELETE FROM rebuttal_jobs WHERE id=?", (job_id,))
return cur.rowcount > 0
def all_comments(reviewers: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for r in reviewers or []:
out.extend(r.get("comments") or [])
return out
def all_comments_confirmed(reviewers: list[dict[str, Any]]) -> bool:
comments = all_comments(reviewers)
# 空 job(未解析出任何 comment)不置位 human_ack,避免"全勾"平凡真
if not comments:
return False
return all(bool(c.get("human_confirmed")) for c in comments)
@@ -0,0 +1,232 @@
"""services/recommend_engine.py —— presubmit recommendation 规则引擎(纯函数,零 LLM)。
背景(2026-10-12 诊断):旧链路由 LLM 直接输出 recommendation,且 SYSTEM_PROMPT 配额
强制 "major_weaknesses 3-8 条",导致 accept/minor_revision 几乎不可达(对照实验中
强稿——BUSCO 96.8%/Hi-C 98.7%/accession 齐/UFBoot 1000——仍被凑出 6 条 major)。
新链路:LLM 只如实列 weakness(major 带 hard_rule/fatal 标签),本引擎按可测规则
计算 recommendation + rationale(模板拼装,可复现,不经 LLM)。
判定链(自上而下首条命中):
R0 任一 major 带 fatal=true → reject(须人工复核)
R1 H = 硬性 major 数 ≥ 1 → major_revision(与 tier 无关,不可协商)
R2' tier==3 且 1≤S≤2 且 H==0 → minor_revision(入门刊仅 soft 可降)
R2 S = soft major 数 ≥ 1 → major_revision(S≥5 标 severe,仍非 reject)
R4 S == 0 且 minor ≤ accept_max_minor[tier] → accept
R5 S == 0 → minor_revision
其中 S = 总 major 数 − 硬性标签数(标签漏打时 S 变大,只会更严不会漏放)。
"""
from __future__ import annotations
import json
import re
import sqlite3
from functools import lru_cache
from pathlib import Path
from typing import Any, Iterable
from ..config import get_settings
# 硬性规则标签(SYSTEM_PROMPT 与 coerce 共用白名单;未知标签一律置 None)
HARD_RULE_TAGS = (
"busco_missing", # 组装类论文缺 BUSCO 完整格式
"hic_missing", # 宣称 chromosome-level/Hi-C 但缺挂载率
"accession_missing", # 缺数据 Accession("will be deposited" 视为缺)
"voucher_missing", # 野生材料缺凭证标本
"abs_missing", # 缺 ABS/名古屋合规声明
)
HARD_RULE_LABELS = {
"busco_missing": "BUSCO 完整度缺失",
"hic_missing": "Hi-C 挂载率缺失",
"accession_missing": "数据 Accession 缺失",
"voucher_missing": "凭证标本缺失",
"abs_missing": "ABS/名古屋合规声明缺失",
}
TIER_LABELS = {1: "顶刊", 2: "主流期刊", 3: "入门期刊"}
# tier 参数:accept 门槛(minor 上限);tier3 额外允许 ≤2 条 soft major 降为小修
TIER_ACCEPT_MAX_MINOR = {1: 1, 2: 2, 3: 3}
TIER3_SOFT_MAJOR_AS_MINOR_MAX = 2
# ---- tier 查找优先级(2026-10-12):override → featured → jif → 默认 ----
# ① 用户高频投稿顶刊硬编码(精确名匹配,不受 jif/综述刊启发式影响;
# "The " 前缀归一化,"Plant Cell"/"The Plant Cell" 互认)
TIER1_OVERRIDES = frozenset(
re.sub(r"^the\s+", "", name.casefold()) for name in (
"Nature", "Nature Genetics", "Nature Plants", "Nature Biotechnology",
"Nature Methods", "Cell", "Science", "Genome Biology", "Molecular Plant",
"The Plant Cell", "Nature Communications", "Science Advances", "Cell Research",
)
)
# 综述刊不因高 jif 升 tier1(Annual Review of… / Trends in… / …Reviews)
_REVIEW_JOURNAL_RE = re.compile(r"reviews?\b|\btrends\b|annual\s+review", re.IGNORECASE)
_JIF_TIER1_MIN = 15.0 # jif >= 15 → tier1;5 ≤ jif < 15 → tier2;<5/NULL → 默认 tier2
@lru_cache(maxsize=1)
def _tier_by_name() -> dict[str, int]:
"""journals.json 精选期刊 name(casefold) → review_tier(读文件一次,缓存)。"""
path = get_settings().journals_seed_abs
try:
payload = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
out: dict[str, int] = {}
for j in payload.get("journals") or []:
if not isinstance(j, dict):
continue
name = str(j.get("name") or "").strip().casefold()
tier = j.get("review_tier")
if name and isinstance(tier, int) and tier in (1, 2, 3):
out[name] = tier
return out
@lru_cache(maxsize=256)
def _jif_by_name(name_bare_cf: str) -> float | None:
"""journal_directory 按名查 jif(只读一列;表缺失/查不到 → None)。"""
db = get_settings().db_path_abs
if not Path(db).exists():
return None
try:
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=2)
try:
row = conn.execute(
"SELECT jif FROM journal_directory WHERE lang='en' "
"AND (lower(name)=? OR lower(name)=?) "
"ORDER BY jif DESC LIMIT 1",
(name_bare_cf, "the " + name_bare_cf),
).fetchone()
finally:
conn.close()
except sqlite3.Error:
return None
if not row or row[0] is None:
return None
try:
return float(row[0])
except (TypeError, ValueError):
return None
def resolve_tier(target_journal: str) -> tuple[int, str]:
"""目标期刊 → (tier, source)。source ∈ override/featured/jif/默认。
查找优先级:① 硬编码 13 本顶刊 ② journals.json review_tier
③ journal_directory jif 启发式(综述刊除外)④ 默认 tier 2。
"""
raw = (target_journal or "").strip()
if not raw:
return 2, "默认"
key = raw.casefold()
bare = re.sub(r"^the\s+", "", key)
if bare in TIER1_OVERRIDES:
return 1, "override"
featured = _tier_by_name()
tier = featured.get(key, featured.get(bare))
if isinstance(tier, int) and tier in (1, 2, 3):
return tier, "featured"
if not _REVIEW_JOURNAL_RE.search(bare):
jif = _jif_by_name(bare)
if jif is not None:
return (1 if jif >= _JIF_TIER1_MIN else 2), "jif"
return 2, "默认"
def _item_field(item: Any, key: str) -> Any:
if isinstance(item, dict):
return item.get(key)
return getattr(item, key, None)
def decide_recommendation(
majors: Iterable[Any],
minors: Iterable[Any] = (),
presentations: Iterable[Any] = (),
tier: int = 2,
) -> dict[str, Any]:
"""规则引擎入口。majors 项可为 dict 或带 hard_rule/fatal 属性的对象。
返回 {"recommendation": ..., "rationale": {...}};
rationale 含 rule/hard_hits/计数/tier/tier_label/explanation(模板拼装)。
"""
del presentations # 呈现类不参与判定(仅归档展示)
majors = list(majors)
minors = list(minors)
if tier not in (1, 2, 3):
tier = 2
hard_hits: list[str] = []
fatal = False
for m in majors:
if bool(_item_field(m, "fatal")):
fatal = True
hr = _item_field(m, "hard_rule")
if hr in HARD_RULE_LABELS:
hard_hits.append(hr)
m_count = len(majors)
h_count = len(hard_hits)
s_count = m_count - h_count # soft major(标签漏打 → 计入 S,只会更严)
minor_count = len(minors)
tier_label = TIER_LABELS[tier]
# tier3 降级通道提示(对作者可见:同稿投不同档结果不同)
tier_note = ""
if h_count == 0 and 1 <= s_count <= TIER3_SOFT_MAJOR_AS_MINOR_MAX and tier != 3:
tier_note = f"当前判定基于 tier {tier} 档;tier 3 入门刊此稿可能判 minor_revision。"
def _result(rec: str, rule: str, explanation: str) -> dict[str, Any]:
return {
"recommendation": rec,
"rationale": {
"rule": rule,
"hard_hits": hard_hits,
"major_count": m_count,
"hard_count": h_count,
"soft_count": s_count,
"minor_count": minor_count,
"tier": tier,
"tier_label": tier_label,
"explanation": explanation + tier_note,
},
}
# R0:fatal → reject(前端须配"建议人工复核")
if fatal:
return _result(
"reject", "R0",
"存在 fatal 级问题(数据可靠性/伦理,须人工复核)→ 建议拒稿。")
# R1:硬性 major 不可协商
if h_count >= 1:
labels = "、".join(HARD_RULE_LABELS.get(t, t) for t in dict.fromkeys(hard_hits))
return _result(
"major_revision", "R1",
f"命中硬性缺陷({labels})→ 大修(不可协商);"
f"另有大修级 {s_count} 条、小修级 {minor_count} 条。")
# R2':入门刊 1-2 条 soft major 可降为小修
if tier == 3 and 1 <= s_count <= TIER3_SOFT_MAJOR_AS_MINOR_MAX:
return _result(
"minor_revision", "R2'",
f"无硬性缺陷,大修级(soft){s_count} 条;"
f"入门刊(tier 3)可将 ≤{TIER3_SOFT_MAJOR_AS_MINOR_MAX} 条 soft 降为小修 → 建议小修。")
# R2/R3:soft major ≥ 1(S≥5 标 severe)
if s_count >= 1:
sev = ",severe" if s_count >= 5 else ""
return _result(
"major_revision", "R2",
f"大修级问题 {s_count} 条(均非硬性{sev})→ 建议大修。")
# R4/R5:无 major
accept_max = TIER_ACCEPT_MAX_MINOR[tier]
if minor_count <= accept_max:
return _result(
"accept", "R4",
f"无大修级问题,小修级 {minor_count} 条(≤{accept_max})→ 建议接收。")
return _result(
"minor_revision", "R5",
f"无大修级问题,小修级 {minor_count} 条 → 建议小修。")
+104 -36
View File
@@ -1,21 +1,25 @@
"""services/self_review.py —— 植物基因组学专属自审稿(System Prompt + 报告模型)。
六大领域维度检查清单硬编码在 SYSTEM_PROMPT(发给 DeepSeek):
1. 基因组组装质量(N50/L50/BUSCO 完整格式/Hi-C 挂载率,缺项即 Major)
1. 基因组组装质量(N50/L50/BUSCO 完整格式/Hi-C 挂载率;硬性缺项打 hard_rule 标签)
2. 基因注释可靠性(MAKER/AUGUSTUS 流程、RNA-seq 证据、基因数与已知范围一致性)
3. 比较基因组学与进化(Bootstrap/后验概率、OrthoFinder 等直系同源策略)
4. 数据可用性合规(SRA/BioProject + 组装/GFF3 Accession,缺失即 Major)
4. 数据可用性合规(SRA/BioProject + 组装/GFF3 Accession,缺失即硬性 major)
5. 统计与实验设计(GWAS/QTL 样本量·多重校正·群体结构;转录组生物学重复)
6. 伦理与合规·植物特异性(凭证标本 voucher、ABS 惠益分享声明)
6. 伦理与合规·植物特异性(凭证标本 voucher、ABS 惠益分享声明——均为硬性 major)
Major/Minor 每条必须给 Evidence(论文章节位置或具体指标数值),由 coerce
宽容归一化(缺失补空串,不丢条目)。
2026-10-12 判定链重构:LLM 不再输出 recommendation(旧配额制 "major 3-8 条" 导致
accept/minor 几乎不可达,见 recommend_engine.py 模块 docstring);只如实列 weakness
并打 hard_rule/fatal 标签,recommendation 由规则引擎计算。Major/Minor 每条必须给
Evidence(论文章节位置或具体指标数值),由 coerce 宽容归一化(缺失补空串,不丢条目)。
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
RECOMMENDATIONS = ("accept", "minor_revision", "major_revision", "reject")
from .recommend_engine import HARD_RULE_TAGS
SYSTEM_PROMPT = """你是一位植物基因组学领域资深审稿人,长期为 Plant Cell、Nature Plants、
Genome Biology、Molecular Plant、Plant Journal 等期刊审稿。请对用户提供的论文正文做
@@ -28,9 +32,18 @@ Genome Biology、Molecular Plant、Plant Journal 等期刊审稿。请对用户
"Complete 95.3% [S:93.1%, D:2.2%], Missing 4.7%, n=1614 (embryophyta_odb10)"
(含 Complete/Single-copy/Duplicated/Missing 四项与数据集版本、n 值)
- Hi-C 挂载率(anchoring rate,chromosome-level 比例)及挂载到染色体的 scaffold 比例
判定规则:若论文只报 N50 而未报 BUSCO 完整度或 Hi-C 挂载率(组装类论文),直接列为
Major Weakness。同时检查组装是否为 pseudo-chromosome 级别、污染评估(如 k-mer/coverage
检查)与 gap 比例。
BUSCO 完整度三级判定:
- 完全未提 BUSCO → Major Weakness,hard_rule="busco_missing"
- 提了 BUSCO 但只有总百分比(如 98.57%),缺 Single-copy/Duplicated/Missing
分项或数据集版本(如 n=1614, embryophyta_odb10)→ 列 Minor(不打 hard_rule)
- 完整格式(如 Complete 95.3% [S:93.1%, D:2.2%], Missing 4.7%, n=1614)→ 通过,不列
Hi-C/挂载率报告识别(命中任一且给出数值即视为已报告,不得判缺):
anchoring rate / anchored to chromosome(s) / % of sequences anchored /
pseudo-chromosome(s) / chromosome-level 挂载比例。
判定规则:若组装类论文只报 N50 而未报 BUSCO 且未报 Hi-C 挂载率,列为
Major Weakness 并打对应 hard_rule 标签(BUSCO 缺失→"busco_missing";宣称
chromosome-level/Hi-C 挂载但未报挂载率→"hic_missing")。同时检查组装是否为
pseudo-chromosome 级别、污染评估(如 k-mer/coverage 检查)与 gap 比例。
【维度二:基因注释可靠性】检查:
- 是否使用标准注释流程(MAKER、AUGUSTUS、BRAKER、GeMoMa、EVidenceModeler 等),参数与版本是否交代
@@ -40,20 +53,28 @@ Major Weakness。同时检查组装是否为 pseudo-chromosome 级别、污染
- 注释质量评估:BRAKER/AED 分值、BUSCO 在蛋白集上的复核
【维度三:比较基因组学与进化】检查:
- 系统发育树:每个节点必须标注 Bootstrap 支持率(建议 ≥1000 次重复)或贝叶斯后验概率;
未标注或仅"高/低支持"的描述 → Major 或 Minor(视严重程度)
- 系统发育树:每个节点建议标注 Bootstrap 支持率(建议 ≥1000 次重复)或贝叶斯后验概率。
分诊:
* 完全未做节点支持率分析(方法与正文均无任何声明)且支撑核心进化结论 → Major
* 方法/正文声明已做 bootstrap 或后验概率,仅图中未标注数值 → presentation_format
(图注补标即可,不需重跑分析)
* 附带性质的树无支持率 → Minor
- 直系同源基因鉴定策略是否说明清楚:OrthoFinder/OrthoMCL/RBX 等工具、版本、参数、
聚类阈值(inflation/coverage)、single-copy 标记基因的筛选方式
- 分子进化分析是否规范:dN/dS 检验方法与多重校正、共线性(synteny)分析参数、
分化时间估算是否校准(化石/二次校准点)
【维度四:数据可用性合规——极其重要】必查:
- 原始测序数据是否上传 NCBI SRA / BioProject(或 ENA/DDBJ),是否给出 Accession 号
(PRJNA/PRJEB/ SRP 开头)
- 基因组组装是否有 GenBank/ENA accession(GCA_/GCF_),GFF3 注释文件、蛋白/CDS 文件
是否有可公开获取的位置(仓库 accession 或补充数据链接)
【维度四:数据可用性合规——极其重要】必查(在 Data Availability 节或
Methods 数据声明处核对,不因正文引用他人数据的 accession 而误判):
- 原始测序数据是否上传公共数据库并给出 Accession 号。前缀清单:
PRJNA/PRJEB(NCBI/ENA BioProject)、PRJCA/CRA(中国 NGDC/GSA)、
SRP/ERP/DRP(SRA 项目)、SRR/ERR/DRR(run 级号)——命中任一即视为已有
原始数据 accession
- 基因组组装是否有 GenBank/ENA accession(GCA_/GCF_),GFF3 注释文件、
蛋白/CDS 文件是否有可公开获取的位置(仓库 accession 或补充数据链接)
- 转录组数据是否同样有 SRA accession
判定规则:组装/注释类论文若缺失上述任何一环,直接列为 Major Weakness。
判定规则:组装/注释类论文若缺失上述任何一环(含 "will be deposited" 等未给号的
承诺式表述),列为 Major Weakness 并打 hard_rule 标签 "accession_missing"。
【维度五:统计与实验设计】检查:
- GWAS/QTL 定位:样本量/群体大小是否充分;多重检验校正是否明确(Bonferroni/FDR/
@@ -65,34 +86,72 @@ Major Weakness。同时检查组装是否为 pseudo-chromosome 级别、污染
【维度六:伦理与合规(植物特异性)】检查:
- 野生植物材料:是否提供凭证标本(voucher specimen)信息——标本馆编号、存放机构、
采集地点(GPS)与采集许可;无凭证标本的野生资源取样 → Major 或 Minor(视用途)
采集地点(GPS)与采集许可;无凭证标本的野生资源取样 → Major Weakness 并打
hard_rule 标签 "voucher_missing"
- 遗传资源获取与惠益分享(ABS)合规性:是否声明符合《名古屋议定书》/本国遗传资源
获取条例(如中国《生物安全法》/《种子法》相关条款)、是否说明材料来源与授权
获取条例(如中国《生物安全法》/《种子法》相关条款)、是否说明材料来源与授权;
涉及遗传资源但缺 ABS/名古屋合规声明 → Major Weakness 并打 hard_rule 标签
"abs_missing"
- 农业转基因/基因编辑材料的合规声明(若涉及)
- fatal 标签:仅当你发现明确的数据造假或伦理违规证据时,将该条 "fatal" 置 true
(数据内部矛盾、明显编造迹象等;无明确证据绝不可打标——该标签会触发拒稿建议)。
输出要求(必须输出单个 JSON 对象,不要输出其他任何内容):
输出要求(必须输出单个 JSON 对象,不要输出其他任何内容;不要输出 recommendation
字段——推荐结论由程序规则引擎根据你列出的问题与标签计算):
{
"summary": "总体评价(3-5 句,须点明论文类型与所适用的维度)",
"major_weaknesses": [{"title": "...", "detail": "...", "evidence": "...", "suggestion": "..."}],
"major_weaknesses": [{"title": "...", "detail": "...", "evidence": "...", "suggestion": "...",
"hard_rule": "busco_missing|hic_missing|accession_missing|voucher_missing|abs_missing|null",
"fatal": false}],
"minor_weaknesses": [{"title": "...", "detail": "...", "evidence": "...", "suggestion": "..."}],
"presentation_format": [{"title": "...", "detail": "...", "evidence": "...", "suggestion": "..."}],
"questions_for_authors": ["..."],
"recommendation": "major_revision"
"questions_for_authors": ["..."]
}
硬性规则:
1. major_weaknesses 3-8 条、minor_weaknesses 3-8 条;上述维度中命中的硬性判定规则
(组装缺 BUSCO/挂载率、缺数据 Accession)必须出现。
【严重程度分级——据此分类,不设条数配额】
- major_weaknesses:问题的解决需要补充实验/补充数据/补充分析流程/大幅改写论证结构,
或影响论文核心结论的可信度与数据合规性。列在这里意味着"不解决就不能发表"。
- minor_weaknesses:在现有数据与实验范围内即可解决(补一段说明、补一个基于已有数据的
统计、澄清方法描述、局部重写)。列在这里意味着"改了更好,不改也能审"。
- presentation_format:仅呈现层面的问题(图表样式、图注格式、参考文献体例、语法)。
严禁把呈现问题升格为 major。
【负向清单——以下情况严禁列为 major_weaknesses,按对应类别归档】
- 图表未标注统计量(bootstrap 数值/误差棒/显著性标记/P 值),但方法或正文已声明
做过该分析 → presentation_format 或 minor_weaknesses
- 方法描述不够详细,但所用方法是领域标准做法且论证逻辑完整 → minor_weaknesses
- 阈值/参数未给出具体数值,但逻辑完整且结论不依赖精确数值 → minor_weaknesses
- 引用格式/排版/图注体例/语法 → presentation_format
- 标准流程的合理省略(如 IQ-TREE/DESeq2 等工具默认参数未逐一列出)→ 不列
总纲:只有"需要补做实验/补数据/重跑分析/改核心论证"的问题才允许列为 major;
其余一律 minor 或 presentation。与上文各维度冲突时以本节为准。
分级与条数规则:
1. major_weaknesses、minor_weaknesses、presentation_format 均【不设下限,可以为 0 条】。
论文确实完善时输出空数组是正确答案;严禁为凑数而降低分级标准或制造问题。
2. **每条 major_weaknesses 的 evidence 字段必填**:必须引用论文的具体章节位置
(如 "Methods 2.1"、"Table 2"、"Results 第 3 段"、"Data Availability 一句话未见")
或具体指标数值(如 "仅报 scaffold N50=42.6 Mb,未提供 BUSCO 与 Hi-C 挂载率")。
evidence 严禁编造:引用不到具体位置时,写明"正文未见相关报告"。
evidence 严禁编造:引用不到具体位置时,写明"正文未见相关报告"。只列有 evidence
的问题:列不出具体章节位置或指标数值的问题,不要列。
3. minor_weaknesses/presentation_format 的 evidence 尽量给出,无法定位时可留空字符串。
4. 论文不涉及的维度(如纯 RNA-seq 论文不谈组装)在 summary 中说明并跳过,不硬凑条目。
5. recommendation 只能是 accept / minor_revision / major_revision / reject 之一,
缺失关键合规项(维度四)时通常不高于 major_revision。
6. presentation_format 覆盖:图表规范(图注含单位/误差棒说明)、参考文献格式、
字数与结构、英文语法(若投英文刊)。
5. 宁可把拿不准严重程度的问题放 minor_weaknesses(由作者判断),不要放 major。
硬性规则(命中必须列 major 并打 hard_rule 标签,不论其他方面多完善;规则不可协商):
A. 组装类论文缺 BUSCO 完整格式报告 → hard_rule="busco_missing"
B. 宣称 chromosome-level/Hi-C 挂载但未报挂载率 → hard_rule="hic_missing"
C. 缺数据 Accession(SRA/GenBank/组装/GFF3;"will be deposited" 等承诺式表述视为缺)
→ hard_rule="accession_missing"
D. 野生植物材料缺凭证标本(voucher)→ hard_rule="voucher_missing"
E. 涉及遗传资源但缺 ABS/名古屋合规声明 → hard_rule="abs_missing"
(核心进化结论所依赖的系统发育树若**完全未做**节点支持率分析 → 列 major(非硬性,
不打标签);若方法/正文已声明分析但图中未标数值 → presentation/minor;附带性质
的树无支持率 → minor。GWAS 缺多重校正等统计问题按同样原则判级:阈值/方法完全
缺失且影响结论 → major;已声明校正逻辑仅缺细节 → minor。)
presentation_format 覆盖:图表规范(图注含单位/误差棒说明)、参考文献格式、
字数与结构、英文语法(若投英文刊)。
"""
@@ -101,6 +160,11 @@ class WeaknessItem(BaseModel):
detail: str = ""
evidence: str = ""
suggestion: str = ""
hard_rule: str | None = None # 仅 major 可带;白名单外置 None(见 coerce)
fatal: bool = False # 明确造假/伦理证据才可 true(触发 R0 拒稿)
# 扫描器冲突降级标记(程序层置位,LLM 输出不读/coerce 不透传 → 不可伪造):
# True 表示该条原为 major,因关键词扫描器证伪其 hard_rule 缺项声明被降级至 minor
scanner_downgraded: bool = False
class SelfReviewReport(BaseModel):
@@ -109,7 +173,9 @@ class SelfReviewReport(BaseModel):
minor_weaknesses: list[WeaknessItem] = Field(default_factory=list)
presentation_format: list[WeaknessItem] = Field(default_factory=list)
questions_for_authors: list[str] = Field(default_factory=list)
recommendation: str = "major_revision"
# recommendation 由 recommend_engine 计算后写入;LLM 输出的同名字段一律忽略
recommendation: str = ""
recommendation_rationale: dict[str, Any] = Field(default_factory=dict)
def build_user_prompt(text: str, target_journal: str) -> str:
@@ -129,27 +195,29 @@ def _coerce_items(raw: object) -> list[WeaknessItem]:
detail = str(entry.get("detail") or "").strip()
if not title and not detail:
continue
hard_rule = str(entry.get("hard_rule") or "").strip() or None
if hard_rule not in HARD_RULE_TAGS:
hard_rule = None # 白名单外的标签丢弃(防 LLM 编造标签操纵引擎)
items.append(WeaknessItem(
title=title or "(未命名)",
detail=detail,
evidence=str(entry.get("evidence") or "").strip(),
suggestion=str(entry.get("suggestion") or "").strip(),
hard_rule=hard_rule,
fatal=bool(entry.get("fatal")),
))
return items
def coerce_report(payload: dict) -> SelfReviewReport:
"""宽容归一化 LLM 输出。不读 recommendation 字段(引擎唯一来源,防注入操纵)。"""
questions = payload.get("questions_for_authors") or []
if not isinstance(questions, list):
questions = []
recommendation = str(payload.get("recommendation") or "").strip().lower()
if recommendation not in RECOMMENDATIONS:
recommendation = "major_revision"
return SelfReviewReport(
summary=str(payload.get("summary") or "").strip(),
major_weaknesses=_coerce_items(payload.get("major_weaknesses")),
minor_weaknesses=_coerce_items(payload.get("minor_weaknesses")),
presentation_format=_coerce_items(payload.get("presentation_format")),
questions_for_authors=[str(q).strip() for q in questions if str(q).strip()],
recommendation=recommendation,
)
+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
+6 -1
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"updated": "2026-10-10",
"notes": "植物基因组学领域种子期刊。acceptance_rate 无公开权威数据一律为 null(不编造);impact_factor / review_time_weeks 为公开 JCR/期刊官网常识近似值,以 last_updated 标注刷新时间,接入真实数据源前需人工核验。scope_desc 为英文范围描述,供 vstore 向量匹配使用。 format_rules_default 为通用默认规则(rules_source=default);精选 5 本 format_rules 已于 2026-10-10 按各刊官网投稿指南人工核验填入(rules_source=official,附 source_url / last_verified / notes),官网未规定的字段一律 null 不猜测。 新增标签字段(不进 SQLite,接口按 id 从本文件合并):scope_tags / application_tags / cross_domain 按 scope_desc 归类的标签;cas_partition / jcr_quartile 为占位(partition_source=placeholder),核实不确定的一律 null、不猜测,接入真实分区数据前需人工核验。 精选列表已更换为本环境可访问官网的 5 本(Genome Biology / BMC Plant Biology / Molecular Plant / Frontiers in Plant Science / Rice);原 OUP/Wiley/Springer 4 本(The Plant Cell / The Plant Journal / Theoretical and Applied Genetics / The Plant Genome)移出精选,仍保留在 journal_directory 1056 条全量目录中。",
"notes": "植物基因组学领域种子期刊。acceptance_rate 无公开权威数据一律为 null(不编造);impact_factor / review_time_weeks 为公开 JCR/期刊官网常识近似值,以 last_updated 标注刷新时间,接入真实数据源前需人工核验。scope_desc 为英文范围描述,供 vstore 向量匹配使用。 format_rules_default 为通用默认规则(rules_source=default);精选 5 本 format_rules 已于 2026-10-10 按各刊官网投稿指南人工核验填入(rules_source=official,附 source_url / last_verified / notes),官网未规定的字段一律 null 不猜测。 新增标签字段(不进 SQLite,接口按 id 从本文件合并):scope_tags / application_tags / cross_domain 按 scope_desc 归类的标签;cas_partition / jcr_quartile 为占位(partition_source=placeholder),核实不确定的一律 null、不猜测,接入真实分区数据前需人工核验。 精选列表已更换为本环境可访问官网的 5 本(Genome Biology / BMC Plant Biology / Molecular Plant / Frontiers in Plant Science / Rice);原 OUP/Wiley/Springer 4 本(The Plant Cell / The Plant Journal / Theoretical and Applied Genetics / The Plant Genome)移出精选,仍保留在 journal_directory 1056 条全量目录中。 [2026-10-12] 5 本精选期刊新增 review_tier(1=顶刊/2=主流,供 presubmit recommendation 分档判定;匹配失败默认 tier 2)。",
"format_rules_default": {
"rules_source": "default",
"max_pages": 10,
@@ -27,6 +27,7 @@
"application_tags": ["基础研究", "算法与方法"],
"cas_partition": "1区",
"jcr_quartile": "Q1",
"review_tier": 1,
"partition_source": "placeholder",
"cross_domain": ["进化生物学", "单细胞组学"],
"format_rules": {
@@ -58,6 +59,7 @@
"application_tags": ["基础研究"],
"cas_partition": null,
"jcr_quartile": null,
"review_tier": 2,
"partition_source": "placeholder",
"cross_domain": ["植物生物技术"],
"format_rules": {
@@ -89,6 +91,7 @@
"application_tags": ["基础研究", "分子育种"],
"cas_partition": null,
"jcr_quartile": null,
"review_tier": 1,
"partition_source": "placeholder",
"cross_domain": ["植物生物技术"],
"format_rules": {
@@ -120,6 +123,7 @@
"application_tags": ["基础研究"],
"cas_partition": null,
"jcr_quartile": null,
"review_tier": 2,
"partition_source": "placeholder",
"cross_domain": ["农业科学"],
"format_rules": {
@@ -151,6 +155,7 @@
"application_tags": ["基础研究", "分子育种", "种业"],
"cas_partition": null,
"jcr_quartile": null,
"review_tier": 2,
"partition_source": "placeholder",
"cross_domain": ["作物科学"],
"format_rules": {
+4 -1
View File
@@ -30,4 +30,7 @@ set +a
echo "[restart] 启动审稿服务 :$PORT($PYTHON)"
nohup "$PYTHON" -m uvicorn "$APP_MODULE" --host "$HOST" --port "$PORT" \
> "$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()
+444 -10
View File
@@ -1,6 +1,7 @@
"""tests/test_presubmit.py —— presubmit 离线单元测试(mock 解析与 LLM,不依赖上游)。
覆盖:非 PDF 校验 / 报告归一化 / LLM 失败降级 / 解析失败降级 / 非法 recommendation 回退。
覆盖:非 PDF 校验 / 报告归一化 / LLM 失败降级 / 解析失败降级 / recommendation 规则
引擎(R0-R5 + tier 分档 + rationale 可解释 + LLM recommendation 注入被忽略)。
运行: cd review_service && <venv>/bin/python -m pytest -q
"""
from __future__ import annotations
@@ -10,6 +11,7 @@ from fastapi.testclient import TestClient
from app.main import app
from app.services import llm, paper_parse
from app.services.recommend_engine import decide_recommendation
FAKE_PDF = b"%PDF-1.4 fake-bytes-for-test"
@@ -21,6 +23,8 @@ LLM_PAYLOAD = {
"detail": "未做多重检验校正",
"evidence": "Methods 2.1 仅报 p<0.05,未见 FDR/Bonferroni",
"suggestion": "补充 FDR 校正",
"hard_rule": None,
"fatal": False,
},
"纯字符串条目", # 宽容归一化:字符串 → WeaknessItem(title=...)
],
@@ -28,7 +32,7 @@ LLM_PAYLOAD = {
"minor_weaknesses": [{"detail": "缺少缩写表", "title": "缩写"}],
"presentation_format": [{"title": "图注不完整", "detail": "Fig.2 无单位", "suggestion": "补单位"}],
"questions_for_authors": ["群体结构如何校正?", ""],
"recommendation": "MAJOR_REVISION", # 应归一化为小写
"recommendation": "MAJOR_REVISION", # 引擎忽略此字段(防注入);归一化不再读取
}
@@ -88,8 +92,14 @@ def test_normal_report_normalized(client: TestClient):
body = _post(client, "p.pdf", FAKE_PDF, journal="Plant Cell").json()
assert body["ok"] is True
report = body["report"]
# recommendation 大写 → 小写
# 2 条 soft major(无硬性标签)→ 引擎 R2 → major_revision(与 LLM 是否输出无关)
assert report["recommendation"] == "major_revision"
# rationale:规则依据 + tier 字段 + explanation(用例 10)
rat = report["recommendation_rationale"]
assert rat["rule"] == "R2"
assert rat["major_count"] == 2 and rat["hard_count"] == 0 and rat["soft_count"] == 2
assert rat["tier"] == 1 and rat["tier_label"] == "顶刊"
assert rat["explanation"]
# 字符串条目宽容归一化
assert len(report["major_weaknesses"]) == 2
assert report["major_weaknesses"][1]["title"] == "纯字符串条目"
@@ -100,10 +110,12 @@ def test_normal_report_normalized(client: TestClient):
# 空问题剔除
assert report["questions_for_authors"] == ["群体结构如何校正?"]
assert report["minor_weaknesses"] and report["presentation_format"] and report["summary"]
# meta 透传
# meta 透传 + tier 信息(The Plant Cell 在 13 本 override 名单 → tier 1)
assert body["meta"]["target_journal"] == "Plant Cell"
assert body["meta"]["text_chars"] > 0
assert body["meta"]["truncated"] is False
assert body["meta"]["tier"] == 1
assert body["meta"]["tier_source"] == "override"
def test_llm_failure_degrades(client: TestClient, monkeypatch):
@@ -128,15 +140,162 @@ def test_parse_failure_degrades(client: TestClient, monkeypatch):
assert body["error"]["degraded"] is True
def test_invalid_recommendation_falls_back(client: TestClient, monkeypatch):
async def weird(*args, **kwargs):
return {"summary": "x", "recommendation": "strong_accept", "major_weaknesses": "not-a-list"}
def test_llm_recommendation_injection_ignored(client: TestClient, monkeypatch):
"""LLM 擅自输出 recommendation:"accept" → 引擎不读该字段,按条数判定(用例 8)。"""
async def evil(*args, **kwargs):
return {
"summary": "x",
"recommendation": "accept", # 注入企图
"major_weaknesses": [
{"title": "M1", "evidence": "Methods"},
{"title": "M2", "evidence": "Results"},
],
"minor_weaknesses": [],
}
monkeypatch.setattr(llm, "complete_json", weird)
monkeypatch.setattr(llm, "complete_json", evil)
body = _post(client, "p.pdf", FAKE_PDF).json()
assert body["ok"] is True
assert body["report"]["recommendation"] == "major_revision"
assert body["report"]["major_weaknesses"] == []
assert body["report"]["recommendation"] == "major_revision" # 2 soft → R2
def test_empty_llm_payload_engine_accept(client: TestClient, monkeypatch):
"""LLM 漏输出各 weakness 字段 → 不崩,引擎按 0/0/0 给 accept(用例 9)。"""
async def bare(*args, **kwargs):
return {"summary": "x"}
monkeypatch.setattr(llm, "complete_json", bare)
body = _post(client, "p.pdf", FAKE_PDF).json()
assert body["ok"] is True
assert body["report"]["recommendation"] == "accept"
assert body["report"]["recommendation_rationale"]["rule"] == "R4"
# ================================================================
# recommendation 规则引擎(离线:mock LLM payload / 直调引擎)
# ================================================================
def _post_payload(client: TestClient, monkeypatch, payload: dict, journal: str = ""):
async def fake(*args, **kwargs):
return dict(payload)
monkeypatch.setattr(llm, "complete_json", fake)
return _post(client, "p.pdf", FAKE_PDF, journal=journal).json()
def test_strong_payload_not_major(client: TestClient, monkeypatch):
"""用例 1:强稿形态 0 major / 4 minor → minor_revision(诊断回归:不得再判大修)。"""
body = _post_payload(client, monkeypatch, {
"summary": "完备稿",
"major_weaknesses": [],
"minor_weaknesses": [{"title": f"m{i}"} for i in range(4)],
})
assert body["ok"] is True
assert body["report"]["recommendation"] == "minor_revision"
assert body["report"]["recommendation_rationale"]["rule"] == "R5"
def test_weak_payload_major(client: TestClient, monkeypatch):
"""用例 2:弱稿形态 7 major(含 2 硬性标签)→ major_revision + hard_hits。"""
body = _post_payload(client, monkeypatch, {
"summary": "缺项稿",
"major_weaknesses": [
{"title": "缺 BUSCO", "evidence": "Results 仅 N50", "hard_rule": "busco_missing"},
{"title": "缺 Accession", "evidence": "Data Availability 未见", "hard_rule": "accession_missing"},
*[{"title": f"s{i}", "evidence": "Methods"} for i in range(5)],
],
"minor_weaknesses": [{"title": "m1"}],
})
report = body["report"]
assert report["recommendation"] == "major_revision"
rat = report["recommendation_rationale"]
assert rat["rule"] == "R1"
assert set(rat["hard_hits"]) == {"busco_missing", "accession_missing"}
assert rat["hard_count"] == 2 and rat["soft_count"] == 5
def test_hard_rule_whitelist_stripped(client: TestClient, monkeypatch):
"""LLM 编造白名单外 hard_rule 标签 → coerce 丢弃,不得操纵引擎。"""
body = _post_payload(client, monkeypatch, {
"summary": "x",
"major_weaknesses": [
{"title": "m1", "evidence": "Methods", "hard_rule": "made_up_tag"},
],
"minor_weaknesses": [],
})
report = body["report"]
assert report["major_weaknesses"][0]["hard_rule"] is None
assert report["recommendation"] == "major_revision" # 按 soft 计 → R2
assert report["recommendation_rationale"]["hard_hits"] == []
def test_fatal_reject(client: TestClient, monkeypatch):
"""用例 6:fatal=true → reject(R0),rationale 提示人工复核。"""
body = _post_payload(client, monkeypatch, {
"summary": "疑似造假",
"major_weaknesses": [{"title": "数据矛盾", "evidence": "Table 2 vs Fig.3", "fatal": True}],
})
assert body["report"]["recommendation"] == "reject"
assert body["report"]["recommendation_rationale"]["rule"] == "R0"
assert "人工复核" in body["report"]["recommendation_rationale"]["explanation"]
def test_tier_matching_journals_json(client: TestClient, monkeypatch):
"""Genome Biology 在 13 本 override 名单 → tier 1(override 优先于 featured)。"""
body = _post_payload(client, monkeypatch, {
"summary": "x",
"major_weaknesses": [],
"minor_weaknesses": [{"title": "a"}],
}, journal="Genome Biology")
assert body["meta"]["tier"] == 1
assert body["meta"]["tier_label"] == "顶刊"
assert body["meta"]["tier_source"] == "override"
# tier1 accept 门槛更紧:1 minor 仍 accept(≤1)
assert body["report"]["recommendation"] == "accept"
def test_tier_featured_fallback(client: TestClient, monkeypatch):
"""不在 override 名单的精选刊走 journals.json review_tier(featured)。"""
body = _post_payload(client, monkeypatch, {
"summary": "x", "major_weaknesses": [], "minor_weaknesses": [],
}, journal="BMC Plant Biology")
assert body["meta"]["tier"] == 2
assert body["meta"]["tier_source"] == "featured"
# ---- 引擎直调(tier 分档与 rationale 分支)----
def test_engine_tier3_soft_major_downgrade():
"""用例 7:2 条 soft major——tier3 降级 minor_revision;tier2 仍 major_revision。"""
majors = [{"title": "m", "hard_rule": None, "fatal": False}] * 2
d3 = decide_recommendation(majors, [], [], tier=3)
assert d3["recommendation"] == "minor_revision"
assert d3["rationale"]["rule"] == "R2'"
d2 = decide_recommendation(majors, [], [], tier=2)
assert d2["recommendation"] == "major_revision"
assert d2["rationale"]["rule"] == "R2"
def test_engine_tier_note_and_accept_thresholds():
"""用例 10:rationale 含 tier 说明句;tier1 accept 门槛 minor≤1。"""
one_soft = [{"title": "m"}]
d = decide_recommendation(one_soft, [], [], tier=2)
assert "tier 3 入门刊此稿可能判 minor_revision" in d["rationale"]["explanation"]
assert d["rationale"]["tier"] == 2
# tier1:1 minor → accept;2 minor → minor_revision
assert decide_recommendation([], [{"title": "a"}], [], tier=1)["recommendation"] == "accept"
assert decide_recommendation([], [{"title": "a"}, {"title": "b"}], [], tier=1)["recommendation"] == "minor_revision"
# tier3:3 minor 仍 accept(≤3)
assert decide_recommendation([], [{"title": "a"}, {"title": "b"}, {"title": "c"}], [], tier=3)["recommendation"] == "accept"
def test_engine_hard_never_downgraded_by_tier():
"""硬性 major 不因 tier3 降级(不可协商)。"""
d = decide_recommendation(
[{"title": "缺 accession", "hard_rule": "accession_missing", "fatal": False}],
[], [], tier=3)
assert d["recommendation"] == "major_revision"
assert d["rationale"]["rule"] == "R1"
def test_domain_system_prompt_wired(client: TestClient, monkeypatch):
@@ -159,5 +318,280 @@ def test_domain_system_prompt_wired(client: TestClient, monkeypatch):
"GWAS", "多重检验校正", "生物学重复", # 维度5 统计设计
"凭证标本", "ABS", "惠益分享", # 维度6 伦理合规
"evidence", "章节", # Evidence 硬性要求
"负向清单", "严禁列为 major", "以本节为准", # 2026-10-12 负向清单(soft major 收紧)
"需要补做实验/补数据/重跑分析/改核心论证", # major 总纲
"仅图中未标注数值", # 系统发育树图注级降级
):
assert marker in system, f"System Prompt 缺少领域标记: {marker}"
# ================================================================
# 2026-10-12 四 Bug 修复:截断装箱 / 扫描器 / tier 分档(离线单测)
# ================================================================
from app.routers.presubmit import ( # noqa: E402
_fit_feed_text, estimate_tokens, pack_feed_text,
)
from app.services import paper_scanner # noqa: E402
from app.services.recommend_engine import resolve_tier # noqa: E402
# ---- Bug 1:段落感知装箱 + token 估算 ----
_SECTIONED = (
"Abstract\n本研究组装了黄瓜泛基因组。\n"
"Introduction\n引言内容。\n"
"Results\n主要结果。\n"
"Methods\n" + ("方法细节。" * 4000) + "\n"
"Data Availability\nAll data are available under PRJCA038097.\n"
"Discussion\n讨论内容。\n"
"References\n[1] Someone 2020.\n"
)
def test_estimate_tokens():
assert estimate_tokens("a" * 3000) == 2000
def test_pack_under_budget_untouched():
feed, dropped = pack_feed_text("short text", 1000)
assert feed == "short text" and dropped == []
def test_pack_keeps_data_availability_when_over_budget():
"""超预算时 Data Availability 必须保留(bug:30 页论文 DA 在 Page 17 被截)。"""
budget = 8000
assert len(_SECTIONED) > budget
feed, dropped = pack_feed_text(_SECTIONED, budget)
assert "PRJCA038097" in feed
assert "Abstract" in feed
assert len(feed) <= budget
# Methods 超出 60% 预算上限被截;预算仍有余量时低优先级节(introduction)
# 照常保留,dropped 只记真正被丢弃的段
assert "引言内容" in feed # introduction 标题在文中原序保留
assert dropped == []
# 预算极紧时:低优先级段被丢弃并记入 dropped
feed2, dropped2 = pack_feed_text(_SECTIONED, 100)
assert len(feed2) <= 100
assert "references" in dropped2 and "discussion" in dropped2
def test_pack_fallback_without_sections():
text = "x" * 20000 + "y" * 4000 + "\nDATA AVAILABILITY PRJNA12345"
feed, dropped = pack_feed_text(text, 10000)
assert "PRJNA12345" in feed # 尾窗捞回
assert dropped and dropped[0].startswith("[无节标题回退")
def test_fit_feed_text_token_guard():
"""第一层保护:估算超 48K token → 自动降到 100K 字符重装箱。"""
huge = "A" * 300000 # 估算 200K token
feed, _ = _fit_feed_text(huge, 150000)
assert len(feed) <= 100000
# 150K 字符内(估算 100K token? 150000/1.5=100000 > 48000 → 也会降)
# 60K 字符(40K token)不触发
mid = "B" * 60000
feed2, _ = _fit_feed_text(mid, 150000)
assert len(feed2) == 60000
# ---- Bug 4:关键词扫描器 + 冲突降级 ----
_SCANNER_TEXT = (
"Abstract\nCucumber pan-genome.\n"
"Results\nBUSCO completeness was 98.57%. "
"94.18% of sequences anchored to chromosomes.\n"
"Data Availability\nThe raw data have been deposited under "
"PRJCA038097, PRJCA043228 and CRA002855 in GSA.\n"
"Discussion\nDone.\n"
)
def test_scanner_signals():
s = paper_scanner.scan_paper_signals(_SCANNER_TEXT)
assert s["accession"]["found"] is True
assert "PRJCA038097" in s["accession"]["hits"]
assert "CRA002855" in s["accession"]["hits"]
assert s["busco"]["level"] == "partial" # 只有百分比,无 S/D 分项
assert s["hic"]["found"] is True
def test_scanner_accession_only_in_da_section():
"""正文引用他人的 PRJNA 出现在非 DA 段 → 不得算"已有 accession"。"""
text = (
"Introduction\nWe reused PRJNA9999999 from a prior study.\n"
"Results\nAssembly OK.\n"
# 无 Data Availability 节
)
s = paper_scanner.scan_paper_signals(text)
assert s["accession"]["found"] is False
def test_scanner_busco_levels():
assert paper_scanner.scan_paper_signals("")["busco"]["level"] == "none"
partial = "BUSCO completeness was 98.57% compared with embryophyta."
assert paper_scanner.scan_paper_signals(partial)["busco"]["level"] == "partial"
full = "BUSCO analysis: Complete 95.3% [S:93.1%, D:2.2%], Missing 4.7%, n=1614."
assert paper_scanner.scan_paper_signals(full)["busco"]["level"] == "full"
def test_reconcile_downgrades_conflicting_hard_rules():
majors = [
{"title": "缺 BUSCO", "hard_rule": "busco_missing", "fatal": False},
{"title": "缺 accession", "hard_rule": "accession_missing", "fatal": False},
{"title": "缺 Hi-C", "hard_rule": "hic_missing", "fatal": False},
{"title": "其他硬性", "hard_rule": "voucher_missing", "fatal": False},
]
signals = paper_scanner.scan_paper_signals(_SCANNER_TEXT)
kept, demoted, conflicts = paper_scanner.reconcile_hard_rules(majors, signals)
# 三项被证伪的硬性 major 真降级移出 kept,进入 demoted(A2)
assert [m["title"] for m in kept] == ["其他硬性"]
assert kept[0]["hard_rule"] == "voucher_missing"
assert [m["title"] for m in demoted] == ["缺 BUSCO", "缺 accession", "缺 Hi-C"]
# 降级项:剥标签 + 打 scanner_downgraded + 保留 title/detail
assert all(m["hard_rule"] is None for m in demoted)
assert all(m["scanner_downgraded"] is True for m in demoted)
assert demoted[0]["title"] == "缺 BUSCO"
assert {c["hard_rule"] for c in conflicts} == {
"busco_missing", "accession_missing", "hic_missing"}
def test_reconcile_fatal_not_demoted():
"""fatal 条目即使冲突也只记冲突、不降级(须人工过目)。"""
majors = [{
"title": "疑似数据造假", "hard_rule": "accession_missing", "fatal": True,
}]
signals = paper_scanner.scan_paper_signals(_SCANNER_TEXT)
kept, demoted, conflicts = paper_scanner.reconcile_hard_rules(majors, signals)
assert len(kept) == 1 and kept[0]["hard_rule"] == "accession_missing"
assert demoted == []
assert len(conflicts) == 1 # 冲突仍记录(透明)
def test_reconcile_no_conflict_untouched():
majors = [{"title": "真缺", "hard_rule": "accession_missing", "fatal": False}]
signals = paper_scanner.scan_paper_signals("只有正文,没有 DA 段。")
kept, demoted, conflicts = paper_scanner.reconcile_hard_rules(majors, signals)
assert kept[0]["hard_rule"] == "accession_missing"
assert demoted == [] and conflicts == []
def test_http_scanner_conflict_downgrades(client: TestClient, monkeypatch):
"""端到端:LLM 报三项缺失 + 正文实有 → 冲突记录、hard_count=0。"""
async def fake_parse(file_bytes: bytes, filename: str = "paper.pdf") -> str:
return _SCANNER_TEXT
async def fake_llm(*args, **kwargs):
return {
"summary": "x",
"major_weaknesses": [
{"title": "缺 BUSCO 完整格式", "evidence": "Results",
"hard_rule": "busco_missing", "fatal": False},
{"title": "缺数据 accession", "evidence": "全文未见",
"hard_rule": "accession_missing", "fatal": False},
{"title": "缺 Hi-C 挂载率", "evidence": "Results",
"hard_rule": "hic_missing", "fatal": False},
],
"minor_weaknesses": [],
}
monkeypatch.setattr(paper_parse, "parse_pdf_bytes", fake_parse)
monkeypatch.setattr(llm, "complete_json", fake_llm)
body = _post(client, "p.pdf", FAKE_PDF, journal="Genome Biology").json()
assert body["ok"] is True
report = body["report"]
rat = report["recommendation_rationale"]
# A2:3 条冲突 major 全部真降级移出 major → 0 major(非"剥标签留原地")
assert rat["major_count"] == 0 and rat["hard_count"] == 0 and rat["soft_count"] == 0
assert report["major_weaknesses"] == []
# 降级项落到 minor,带 scanner_downgraded 标记(前端黄徽标)
downgraded = [m for m in report["minor_weaknesses"] if m.get("scanner_downgraded")]
assert len(downgraded) == 3
assert all(m["hard_rule"] is None for m in downgraded)
# 0 major + 3 minor(tier1 门槛≤1)→ R5 minor_revision(不是 R2 大修)
assert rat["rule"] == "R5"
assert report["recommendation"] == "minor_revision"
assert len(rat["scanner_conflicts"]) == 3
assert "冲突" in rat["explanation"] and "小修级" in rat["explanation"]
assert body["meta"]["scanner"]["accession_found"] is True
assert body["meta"]["scanner"]["hic_found"] is True
assert rat["hard_hits"] == []
def test_http_partial_conflict_leaves_residual_soft(client: TestClient, monkeypatch):
"""1 条冲突降级 + 1 条真 soft 残留 → major_count=1、仍 R2(引擎语义不变的对照)。"""
async def fake_parse(file_bytes: bytes, filename: str = "paper.pdf") -> str:
return _SCANNER_TEXT
async def fake_llm(*args, **kwargs):
return {
"summary": "x",
"major_weaknesses": [
{"title": "缺数据 accession", "evidence": "全文未见",
"hard_rule": "accession_missing", "fatal": False},
{"title": "GWAS 校正描述不完整", "evidence": "Methods",
"hard_rule": None, "fatal": False},
],
"minor_weaknesses": [],
}
monkeypatch.setattr(paper_parse, "parse_pdf_bytes", fake_parse)
monkeypatch.setattr(llm, "complete_json", fake_llm)
body = _post(client, "p.pdf", FAKE_PDF, journal="Genome Biology").json()
rat = body["report"]["recommendation_rationale"]
assert rat["major_count"] == 1 and rat["soft_count"] == 1
assert rat["rule"] == "R2"
assert body["report"]["recommendation"] == "major_revision"
assert len(rat["scanner_conflicts"]) == 1
def test_llm_400_retry_with_smaller_feed(client: TestClient, monkeypatch):
"""第二层保护:LLM 报 HTTP 400 → 降 80K 重试一次成功。"""
long_text = "Introduction\n" + ("word " * 40000) # 200K 字符
calls: list[int] = []
async def fake_parse(file_bytes: bytes, filename: str = "paper.pdf") -> str:
return long_text
async def flaky(system: str, user: str, **kwargs):
calls.append(len(user))
if len(calls) == 1:
raise llm.LLMError("LLM 调用失败(HTTP 400):Request Entity Too Large")
return {"summary": "x", "major_weaknesses": [], "minor_weaknesses": []}
monkeypatch.setattr(paper_parse, "parse_pdf_bytes", fake_parse)
monkeypatch.setattr(llm, "complete_json", flaky)
body = _post(client, "p.pdf", FAKE_PDF).json()
assert body["ok"] is True
assert len(calls) == 2 # 重试恰好一次
assert body["meta"]["truncated"] is True
# ---- Bug 2:tier 分档优先级 ----
def test_resolve_tier_priority():
# ① override:13 本顶刊 + "The " 前缀互认
assert resolve_tier("Nature Genetics") == (1, "override")
assert resolve_tier("NATURE GENETICS") == (1, "override")
assert resolve_tier("The Plant Cell") == (1, "override")
assert resolve_tier("Plant Cell") == (1, "override")
assert resolve_tier("Nature Communications") == (1, "override")
# ② featured:journals.json review_tier
assert resolve_tier("BMC Plant Biology") == (2, "featured")
assert resolve_tier("Rice") == (2, "featured")
# ③ jif 启发式(directory 实数据)
assert resolve_tier("Nucleic Acids Research") == (1, "jif") # jif=15.0
assert resolve_tier("Euphytica") == (2, "jif") # jif=1.8
# 综述刊即便 jif=26.4 也不升 tier1
assert resolve_tier("Annual Review of Plant Biology") == (2, "默认")
# ④ 未命中 → 默认
assert resolve_tier("Totally Fake Journal") == (2, "默认")
assert resolve_tier("") == (2, "默认")
def test_prompt_has_new_vocab():
"""Bug 3:SYSTEM_PROMPT 词表扩充(PRJCA/CRA + BUSCO 三级 + anchored 同义词)。"""
from app.services.self_review import SYSTEM_PROMPT
assert "PRJCA" in SYSTEM_PROMPT and "CRA" in SYSTEM_PROMPT
assert "anchored to chromosome" in SYSTEM_PROMPT
assert "98.57%" in SYSTEM_PROMPT # BUSCO 仅百分比 → minor 示例
+198
View File
@@ -0,0 +1,198 @@
"""tests/test_rebuttal.py —— Rebuttal Phase 1 离线单测(mock LLM 与 PDF 解析)。
覆盖(对齐任务清单 9 条):单/多 Reviewer 归属、子条拆分(0.7 阈值)、
location/rebuttal 反幻觉校验、MISSING_RESPONSE 防 LLM 空转、交叉引用、
信封错误、human_ack 下载门槛。
运行: cd review_service && <venv>/bin/python -m pytest tests/test_rebuttal.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 llm, paper_parse
BASE = "/api/review/rebuttal"
REVIEW_TEXT = """Reviewer 1:
1. The statistical methods are inappropriate; multiple-testing correction is missing.
2. Figure 3 is blurry and the legends are incomplete. See comment 1 for related concern.
Reviewer 2:
1. The novelty is limited. 2. The writing needs improvement. 3. Please clarify the sampling site.
"""
SEG_LLMA = {
"title": "Statistical methods inappropriate",
"severity": "major",
"sub_items": [{"title": "Missing correction", "body": "Multiple-testing correction is missing.", "confidence": 0.9}],
}
SEG_LLM_MULTI = {
"title": "Multiple issues",
"severity": "minor",
"sub_items": [
{"title": "Blurry figure", "body": "Figure 3 is blurry.", "confidence": 0.95},
{"title": "Incomplete legends", "body": "Legends are incomplete.", "confidence": 0.9},
{"title": "Related", "body": "See comment 1 for related concern.", "confidence": 0.5},
],
}
@pytest.fixture()
def client(tmp_path, monkeypatch):
db_path = tmp_path / "rebuttal_test.db"
settings = get_settings()
monkeypatch.setattr(settings, "db_path", str(db_path))
db_mod.set_path(db_path) # 进 lifespan 前注入
# LLM 打桩:按输入返回预置结构(计数供 MISSING_RESPONSE 用例断言)
calls = {"n": 0}
async def fake_complete_json(system: str, user: str, **kwargs):
calls["n"] += 1
if "Multiple issues" in user or "Figure 3" in user:
return dict(SEG_LLM_MULTI)
return dict(SEG_LLMA)
async def fake_parse_pdf(file_bytes: bytes, filename: str = "paper.pdf") -> str:
return REVIEW_TEXT
monkeypatch.setattr(llm, "complete_json", fake_complete_json)
monkeypatch.setattr(paper_parse, "parse_pdf_bytes", fake_parse_pdf)
monkeypatch.setattr(app_main, "_REBUTTAL_TEST_CALLS", calls, raising=False)
with TestClient(app_main.app) as c:
c.state_llm_calls = calls # type: ignore[attr-defined]
yield c
def _create(client, text=REVIEW_TEXT):
r = client.post(f"{BASE}/jobs", json={"text": text})
assert r.status_code == 200
payload = r.json()
assert payload["ok"] is True, payload
return payload
def _all_comments(job):
return [c for rev in job["reviewers"] for c in rev["comments"]]
def test_single_reviewer_single_comment(client):
payload = _create(client, "Reviewer 1:\n1. Please add FDR correction.")
job = payload["job"]
assert len(job["reviewers"]) == 1
assert len(job["reviewers"][0]["comments"]) == 1
assert job["reviewers"][0]["comments"][0]["severity"] == "major"
def test_multi_reviewer_attribution(client):
payload = _create(client)
job = payload["job"]
labels = [r["label"] for r in job["reviewers"]]
assert labels == ["Reviewer 1", "Reviewer 2"]
# 无编号段落不被误切:Reviewer 2 段内三个编号 → ≥2 条
assert len(job["reviewers"][1]["comments"]) >= 2
def test_subitem_split_shares_group_id(client):
payload = _create(client)
job = payload["job"]
split_group = [c for c in _all_comments(job) if c.get("group_id")]
assert len(split_group) >= 2 # LLM confidence ≥0.7 拆出多条
assert len({c["group_id"] for c in split_group}) == 1 # 同组
def test_location_validation_fail_and_pass(client):
payload = _create(client, "Reviewer 1:\n1. Where is the data?\n2. Where is the code?")
job = payload["job"]
c0, c1 = _all_comments(job)[:2]
# FAIL:shows that 无 added
r = client.patch(f"{BASE}/jobs/{job['id']}/comments/{c0['id']}/response",
json={"parts": [{"type": "location", "content": "S3 shows that the data supports our claim."}]})
assert r.json()["ok"] is True
r = client.post(f"{BASE}/jobs/{job['id']}/comments/{c0['id']}/draft")
body = r.json()
assert body["ok"] is False and body["error"]["code"] == "VALIDATION_FAILED"
assert any(f["code"] == "LOCATION_EVIDENCE_WEAK" for f in body["meta"]["validation"]["findings"])
# PASS:we have added ... in S3
client.patch(f"{BASE}/jobs/{job['id']}/comments/{c1['id']}/response",
json={"parts": [{"type": "location", "content": "We have added the table in S3."}]})
r = client.post(f"{BASE}/jobs/{job['id']}/comments/{c1['id']}/draft")
assert r.json()["ok"] is True
def test_rebuttal_validation(client):
payload = _create(client, "Reviewer 1:\n1. The claim is unsupported.\n2. The design is weak.\n3. The model is wrong.")
job = payload["job"]
cs = _all_comments(job)[:3]
# 负向词 FAIL
client.patch(f"{BASE}/jobs/{job['id']}/comments/{cs[0]['id']}/response",
json={"parts": [{"type": "rebuttal", "content": "We agree with the reviewer on this point."}]})
r = client.post(f"{BASE}/jobs/{job['id']}/comments/{cs[0]['id']}/draft")
assert r.json()["error"]["code"] == "VALIDATION_FAILED"
# 缺立场词 FAIL
client.patch(f"{BASE}/jobs/{job['id']}/comments/{cs[1]['id']}/response",
json={"parts": [{"type": "rebuttal", "content": "The method follows standard practice in the field."}]})
r = client.post(f"{BASE}/jobs/{job['id']}/comments/{cs[1]['id']}/draft")
assert r.json()["error"]["code"] == "VALIDATION_FAILED"
# respectfully maintain 过
client.patch(f"{BASE}/jobs/{job['id']}/comments/{cs[2]['id']}/response",
json={"parts": [{"type": "rebuttal", "content": "We respectfully maintain our conclusion based on the updated analysis."}]})
r = client.post(f"{BASE}/jobs/{job['id']}/comments/{cs[2]['id']}/draft")
assert r.json()["ok"] is True
def test_missing_response_no_llm_call(client):
payload = _create(client, "Reviewer 1:\n1. Please clarify.")
job = payload["job"]
c = _all_comments(job)[0]
before = client.state_llm_calls["n"]
r = client.post(f"{BASE}/jobs/{job['id']}/comments/{c['id']}/draft")
body = r.json()
assert body["ok"] is False and body["error"]["code"] == "MISSING_RESPONSE"
assert client.state_llm_calls["n"] == before # 未空转 LLM
def test_cross_ref_unresolved_keeps_raw(client):
payload = _create(client, "Reviewer 1:\n1. First issue here.\n2. Related concern, see comment 9.")
job = payload["job"]
cs = _all_comments(job)
refs = cs[1].get("cross_refs") or []
assert refs and refs[0]["raw"] # raw 保留
assert refs[0]["target_id"] is None # 解析不到 → null(不猜)
def test_envelope_errors(client):
# job 不存在
r = client.get(f"{BASE}/jobs/rbt_nope")
assert r.status_code == 200 and r.json()["error"]["code"] == "NOT_FOUND"
# 非 PDF
r = client.post(f"{BASE}/jobs/pdf", files={"file": ("a.txt", b"hello", "text/plain")})
assert r.json()["error"]["code"] == "VALIDATION"
# PDF 超限(> max_pdf_bytes)——用超限字节直接触发大小校验(mock 解析不会被调)
settings = get_settings()
big = b"%PDF" + b"0" * (settings.max_pdf_bytes + 1)
r = client.post(f"{BASE}/jobs/pdf", files={"file": ("a.pdf", big, "application/pdf")})
assert r.json()["error"]["code"] == "VALIDATION"
assert "上限" in r.json()["error"]["message"]
def test_human_ack_gate_on_letter_download(client):
payload = _create(client, "Reviewer 1:\n1. Issue A.\n2. Issue B.")
job = payload["job"]
cs = _all_comments(job)
for c in cs:
client.patch(f"{BASE}/jobs/{job['id']}/comments/{c['id']}/response",
json={"parts": [{"type": "clarification", "content": "We clarified in the revised Methods."}]})
client.post(f"{BASE}/jobs/{job['id']}/comments/{c['id']}/draft")
# 未全勾 → human_ack=false → 下载 400
r = client.get(f"{BASE}/jobs/{job['id']}/letter.md")
assert r.status_code == 400 and "HUMAN_ACK_REQUIRED" in r.text
# 全勾 → human_ack=true → 200
for c in cs:
client.patch(f"{BASE}/jobs/{job['id']}/comments/{c['id']}/confirm", json={"confirmed": True})
r = client.get(f"{BASE}/jobs/{job['id']}/letter.md")
assert r.status_code == 200
assert "Response to Reviewers" in r.text
+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()
@@ -0,0 +1,206 @@
// tests/frontend/unit/edu/presubmit.rationale.test.js —— PresubmitPanel 判定 rationale/档位/fatal 展示(Dev-E 判定链重构配套)
// 2026-10-12:recommendation 改由规则引擎计算后新增展示——
// 1) rationale.explanation + 硬性 hard_hits chips
// 2) 判定档位徽标(tier + 期刊名;同稿投不同 tier 结果不同须显式告知)
// 3) reject → "建议人工复核"红标 + 顶部 AI 免责行(fatal 展示克制)
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import PresubmitPanel from '../../../../ui/src/components/edu/panels/PresubmitPanel.vue';
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(),
reviewErrorMessage: vi.fn((e) => (e && e.message) || 'err'),
}));
import { useEduStore } from '../../../../ui/src/stores/edu.js';
function makeReportPayload(over = {}) {
return {
ok: true,
report: {
summary: '总体尚可。',
major_weaknesses: [
{
title: '缺 BUSCO 完整度',
detail: '仅报 N50',
evidence: 'Results',
suggestion: '补 BUSCO',
hard_rule: 'busco_missing',
fatal: false,
},
],
minor_weaknesses: [{ title: '缩写表', detail: '', evidence: '', suggestion: '' }],
presentation_format: [],
questions_for_authors: [],
recommendation: 'major_revision',
recommendation_rationale: {
rule: 'R1',
hard_hits: ['busco_missing'],
major_count: 1, hard_count: 1, soft_count: 0, minor_count: 1,
tier: 1, tier_label: '顶刊',
explanation: '命中硬性缺陷(BUSCO 完整度缺失)→ 大修(不可协商);另有大修级 0 条、小修级 1 条。',
},
...over.report,
},
meta: {
filename: 'paper.pdf', target_journal: 'Genome Biology',
text_chars: 100, truncated: false, model: 'deepseek',
tier: 1, tier_label: '顶刊', tier_source: 'genome biology',
...over.meta,
},
};
}
describe('PresubmitPanel 判定 rationale / 档位 / fatal 克制展示', () => {
let store;
let pinia;
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
store = useEduStore();
});
it('展示 rationale.explanation 与硬性 hard_hits chips', () => {
store.report = makeReportPayload();
const wrapper = mount(PresubmitPanel, { global: { plugins: [pinia] } });
expect(wrapper.find('.pp-rationale-text').text()).toContain('命中硬性缺陷');
expect(wrapper.find('.pp-rationale-text').text()).toContain('BUSCO 完整度缺失');
const chips = wrapper.findAll('.pp-hard-chip');
expect(chips.length).toBe(1);
expect(chips[0].text()).toBe('BUSCO 完整度缺失');
// major 卡片上的"硬性"徽标
expect(wrapper.find('.pp-hard-badge').exists()).toBe(true);
expect(wrapper.find('.pp-hard-badge').text()).toBe('硬性');
});
it('判定档位徽标:期刊名 + tier + 档位名', () => {
store.report = makeReportPayload();
const wrapper = mount(PresubmitPanel, { global: { plugins: [pinia] } });
expect(wrapper.find('.pp-tier').text()).toBe('判定档位:Genome Biology(tier 1 顶刊)');
});
it('reject:红标"建议人工复核" + 顶部 AI 免责行(fatal 克制展示)', () => {
store.report = makeReportPayload({
report: {
recommendation: 'reject',
recommendation_rationale: {
rule: 'R0', hard_hits: [], major_count: 1, hard_count: 0, soft_count: 1,
minor_count: 0, tier: 2, tier_label: '主流期刊',
explanation: '存在 fatal 级问题(数据可靠性/伦理,须人工复核)→ 建议拒稿。',
},
},
});
const wrapper = mount(PresubmitPanel, { global: { plugins: [pinia] } });
expect(wrapper.find('.pp-rec').text()).toBe('拒稿');
expect(wrapper.find('.pp-rec-warn').text()).toBe('建议人工复核');
expect(wrapper.find('.pp-ai-note').text()).toContain('fatal 级判定请务必人工核对');
});
it('无 reject 时不显示"建议人工复核"红标', () => {
store.report = makeReportPayload();
const wrapper = mount(PresubmitPanel, { global: { plugins: [pinia] } });
expect(wrapper.find('.pp-rec-warn').exists()).toBe(false);
// AI 免责行始终展示
expect(wrapper.find('.pp-ai-note').exists()).toBe(true);
});
it('扫描器冲突黄标:展示降级提示与原因', () => {
store.report = makeReportPayload({
report: {
recommendation_rationale: {
rule: 'R2', hard_hits: [], major_count: 2, hard_count: 0, soft_count: 2,
minor_count: 0, tier: 1, tier_label: '顶刊',
explanation: '大修级问题 2 条。关键词扫描器与审稿意见存在 2 处冲突,已降级为非硬性问题,请人工核对。',
scanner_conflicts: [
{ hard_rule: 'accession_missing', title: '缺 accession', reason: 'Data Availability 段已检出 accession(PRJCA038097)' },
{ hard_rule: 'busco_missing', title: '缺 BUSCO', reason: '正文已提及 BUSCO(partial 格式)' },
],
},
},
});
const wrapper = mount(PresubmitPanel, { global: { plugins: [pinia] } });
expect(wrapper.find('.pp-conflict-title').text()).toContain('人工核对');
const items = wrapper.findAll('.pp-conflict-item');
expect(items.length).toBe(2);
expect(items[0].text()).toContain('PRJCA038097');
expect(items[1].text()).toContain('BUSCO');
});
it('扫描器冲突降级项在 minor 卡片显示"冲突降级"黄徽标(A2 真降级)', () => {
store.report = makeReportPayload({
report: {
major_weaknesses: [],
minor_weaknesses: [
{
title: '缺数据 accession', detail: '全文未见', evidence: 'Data Availability',
suggestion: '补 accession', hard_rule: null, scanner_downgraded: true,
},
{ title: '缩写表', detail: '', evidence: '', suggestion: '' },
],
recommendation: 'minor_revision',
recommendation_rationale: {
rule: 'R5', hard_hits: [], major_count: 0, hard_count: 0, soft_count: 0,
minor_count: 2, tier: 1, tier_label: '顶刊',
explanation: '无大修级问题,小修级 2 条 → 建议小修。关键词扫描器与审稿意见存在 1 处冲突,已降级为小修级问题,请人工核对。',
},
},
});
const wrapper = mount(PresubmitPanel, { global: { plugins: [pinia] } });
// 只有被降级那条有徽标
const badges = wrapper.findAll('.pp-down-badge');
expect(badges.length).toBe(1);
expect(badges[0].text()).toBe('冲突降级');
expect(wrapper.findAll('.pp-card.minor').length).toBe(2);
});
it('截断提示:dropped_sections 美化展示', () => {
store.report = makeReportPayload({
meta: { dropped_sections: ['references', 'acknowledgements'] },
});
const wrapper = mount(PresubmitPanel, { global: { plugins: [pinia] } });
expect(wrapper.find('.pp-dropped').text()).toContain('参考文献');
expect(wrapper.find('.pp-dropped').text()).toContain('非论文缺失');
});
it('已选 PDF 进 store:面板卸载重建后不退回空上传区(切菜单保留)', async () => {
// 首次挂载:无报告 → 上传区;选一个 PDF
const w1 = mount(PresubmitPanel, { global: { plugins: [pinia] } });
const f = new File(['pdf-bytes'], 'paper.pdf', { type: 'application/pdf' });
const input = w1.find('input.pp-file-input');
Object.defineProperty(input.element, 'files', { value: [f], configurable: true });
await input.trigger('change');
expect(store.presubmitFile).toBe(f);
expect(w1.find('.pp-file-name').text()).toContain('paper.pdf');
w1.unmount(); // 模拟切走菜单(<component :is> 卸载面板)
// 重建:file 从 store 恢复,文件名仍显示、drop 区呈 filled 态
const w2 = mount(PresubmitPanel, { global: { plugins: [pinia] } });
expect(w2.find('.pp-file-name').text()).toContain('paper.pdf');
expect(w2.find('.pp-drop').classes()).toContain('filled');
// 有报告时优先报告区(regression:hasReport 仍最高优先)
store.report = makeReportPayload();
await w2.vm.$nextTick();
expect(w2.find('.pp-upload').exists()).toBe(false);
expect(w2.find('.pp-rationale').exists()).toBe(true);
});
it('重新上传清空文件:store.presubmitFile 一并清空', async () => {
store.presubmitFile = new File(['x'], 'a.pdf', { type: 'application/pdf' });
store.report = makeReportPayload();
const wrapper = mount(PresubmitPanel, { global: { plugins: [pinia] } });
await wrapper.find('.pp-again').trigger('click');
expect(store.presubmitFile).toBeNull();
expect(store.report).toBeNull();
expect(wrapper.find('.pp-upload').exists()).toBe(true);
});
});
@@ -0,0 +1,209 @@
// tests/frontend/unit/edu/rebuttal.panel.test.js —— RebuttalPanel 交互约束(Phase 1 决策锁定项)
// 1) 5 类下拉(无 auto)
// 2) location 类选中才显示 hint 输入框
// 3) 未填 user_response → 生成按钮禁用
// 4) 未全确认 → 下载禁用 + "X 条未确认"横幅;全勾后启用
// 5) merge/split 按钮只在 active comment 表单出现(树区无)
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import RebuttalPanel from '../../../../ui/src/components/edu/panels/RebuttalPanel.vue';
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(),
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(),
}));
import { useEduStore } from '../../../../ui/src/stores/edu.js';
function makeJob(over = {}) {
return {
id: 'rbt_test01',
status: 'ready',
source: { kind: 'text', filename: null, raw_text: 'x' },
template: 'generic',
reviewers: [
{
id: 'r1', label: 'Reviewer 1', ordinal: 1,
comments: [
{
id: 'c0001', group_id: null, severity: 'major', title: 'Missing data',
body: 'Please provide the raw data.', sub_items: [], cross_refs: [],
response: { parts: [] }, draft: null, validation: null, human_confirmed: false,
},
{
id: 'c0002', group_id: null, severity: 'minor', title: 'Minor typo',
body: 'Typo in abstract.', sub_items: [], cross_refs: [],
response: { parts: [] }, draft: null, validation: null, human_confirmed: false,
},
],
},
],
letter_md: null,
validation: null,
human_ack: false,
created_at: '2026-10-12T00:00:00+00:00',
updated_at: '2026-10-12T00:00:00+00:00',
...over,
};
}
function mountWith(job) {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
edu.rebuttal.job = job;
edu.rebuttal.status = 'ready';
edu.rebuttal.activeCid = job.reviewers[0].comments[0].id;
const wrapper = mount(RebuttalPanel, { global: { plugins: [pinia] } });
return { wrapper, edu };
}
describe('RebuttalPanel', () => {
let wrapper;
let edu;
beforeEach(() => {
({ wrapper, edu } = mountWith(makeJob()));
});
it('1) part 类型下拉只有 5 类、无 auto', () => {
const options = wrapper.findAll('select.rb-select option');
expect(options.length).toBe(5);
const values = options.map((o) => o.element.value);
expect(values).toEqual(['new_data', 'reanalysis', 'location', 'clarification', 'rebuttal']);
expect(values).not.toContain('auto');
});
it('2) location 选中才显示 hint 输入框;非 location 隐藏', async () => {
// 默认 clarification → 无 hint
expect(wrapper.find('[data-testid="rb-location-hint"]').exists()).toBe(false);
// 切 location → 出现
const form = edu.rebuttal.job.reviewers[0].comments[0];
void form;
await wrapper.find('select.rb-select').setValue('location');
expect(wrapper.find('[data-testid="rb-location-hint"]').exists()).toBe(true);
// 切回 → 消失
await wrapper.find('select.rb-select').setValue('clarification');
expect(wrapper.find('[data-testid="rb-location-hint"]').exists()).toBe(false);
});
it('3) 未填 user_response 时生成按钮禁用;填后启用', async () => {
const draftBtn = wrapper.find('[data-testid="rb-draft"]');
expect(draftBtn.attributes('disabled')).toBeDefined();
await wrapper.find('[data-testid="rb-content-0"]').setValue('We have added the table in S3.');
await wrapper.vm.$nextTick();
expect(draftBtn.attributes('disabled')).toBeUndefined();
});
it('3b) 礼貌反驳未含立场词 → 输入框下方红字提示(不阻止编辑/生成)', async () => {
await wrapper.find('select.rb-select').setValue('rebuttal');
const content = wrapper.find('[data-testid="rb-content-0"]');
// 填无立场词内容 → 红字提示出现
await content.setValue('The reviewer misunderstood our experimental design.');
await wrapper.vm.$nextTick();
const hint = wrapper.find('[data-testid="rb-stance-hint-0"]');
expect(hint.exists()).toBe(true);
expect(hint.text()).toContain('maintain');
expect(hint.text()).toContain('respectfully');
// 提示不阻止生成按钮(不 disabled)
expect(wrapper.find('[data-testid="rb-draft"]').attributes('disabled')).toBeUndefined();
// 补立场词 → 提示消失
await content.setValue('We respectfully maintain that the design was appropriate.');
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="rb-stance-hint-0"]').exists()).toBe(false);
// 其他类型不做立场词检查
await wrapper.find('select.rb-select').setValue('clarification');
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="rb-stance-hint-0"]').exists()).toBe(false);
});
it('4) 未全确认 → 下载禁用 + 横幅;全部确认后启用', async () => {
const gate = wrapper.find('[data-testid="rb-gate"]');
expect(gate.text()).toContain('2 条未确认');
const dl = wrapper.find('[data-testid="rb-download"]');
expect(dl.attributes('disabled')).toBeDefined();
// 逐条勾(直接改 store 模拟后端确认回包;human_ack 由后端重算置位)
for (const c of edu.rebuttal.job.reviewers[0].comments) {
c.human_confirmed = true;
}
edu.rebuttal.job.human_ack = true;
edu.rebuttal.job.letter_md = '# Response';
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="rb-gate"]').exists()).toBe(false);
expect(dl.attributes('disabled')).toBeUndefined();
});
it('5) Phase 1 已砍手工合并/拆分与独立保存按钮(B 方案)', () => {
// merge/split 图标与区域不存在
expect(wrapper.find('[data-testid="rb-merge"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="rb-split"]').exists()).toBe(false);
expect(wrapper.text()).not.toContain('选择要合并');
// 无独立"保存要点"按钮——生成 draft 前自动保存
expect(wrapper.find('[data-testid="rb-save"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="rb-draft"]').exists()).toBe(true);
});
/* —— UI v2(2026-10-12)—— */
it('6) 步骤条 4 步,有 job 时高亮第 3 步', () => {
const steps = wrapper.findAll('[data-testid="rb-steps"] li');
expect(steps.length).toBe(4);
expect(steps[2].classes()).toContain('on');
});
it('7) 无 job → 粘贴区;填充示例按钮填入 Reviewer 分节文本', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu2 = useEduStore();
const w = mount(RebuttalPanel, { global: { plugins: [pinia] } });
expect(w.find('#rb-text').exists()).toBe(true);
await w.find('[data-testid="rb-sample"]').trigger('click');
expect(w.find('#rb-text').element.value).toContain('Reviewer #1');
expect(w.find('#rb-text').element.value).toContain('Reviewer #2');
});
it('8) loading → 拆条动画;expired → 任务过期提示', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu3 = useEduStore();
edu3.rebuttal.status = 'loading';
const w1 = mount(RebuttalPanel, { global: { plugins: [pinia] } });
expect(w1.find('[data-testid="rb-parsing"]').exists()).toBe(true);
expect(w1.text()).toContain('正在拆条');
edu3.rebuttal.status = 'error';
edu3.rebuttal.expired = true;
await w1.vm.$nextTick();
expect(w1.text()).toContain('任务已过期');
});
it('9) Letter 预览栏:X/Y 已确认 + 只列已填 draft 条目', async () => {
const job = makeJob();
job.reviewers[0].comments[0].draft = 'We have added FDR in S3.';
const { wrapper: w } = mountWith(job);
const count = w.find('[data-testid="rb-letter-count"]');
expect(count.text()).toContain('0/2 条已确认');
expect(w.text()).toContain('We have added FDR in S3.');
// 无 draft 的第二条不出现在 letter 栏
const letter = w.find('.rb-letter');
expect(letter.text()).not.toContain('Typo in abstract');
});
});
@@ -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);
});
});
@@ -0,0 +1,109 @@
// tests/frontend/unit/edu/tracker.panel.test.js —— TrackerPanel v3(方案 A 极简台账)
// 1) 统计条 5 项计数
// 2) 表单含返修截止日/返修轮次字段
// 3) 卡片显示返修轮次+截止倒计时;无"已开启/未开始返修"联动徽章
// 4) "去写返修"仅 under_review/revision 显示;点击 → section 切 rebuttal + 期刊预填(无 linkSid 回写)
// 5) 空态文案
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import TrackerPanel from '../../../../ui/src/components/edu/panels/TrackerPanel.vue';
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(),
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(),
}));
import { useEduStore } from '../../../../ui/src/stores/edu.js';
function seed(edu) {
edu.submissions = [
{ id: 's1', journal: 'Plant Cell', status: 'revision', submittedAt: '2026-09-01', updatedAt: '2026-10-01', notes: '', revisionDue: '2026-11-01', revisionRound: 2, rebuttalJobId: null },
{ id: 's2', journal: 'Mol Plant', status: 'accepted', submittedAt: '2026-08-01', updatedAt: '2026-09-15', notes: '' },
{ id: 's3', journal: 'GB', status: 'under_review', submittedAt: '2026-09-10', updatedAt: '2026-09-20', notes: '', rebuttalJobId: 'rbt_x1' },
{ id: 's4', journal: 'New Phytol', status: 'rejected', submittedAt: '2026-07-01', updatedAt: '2026-08-01', notes: '' },
];
}
describe('TrackerPanel v3', () => {
let wrapper;
let edu;
beforeEach(() => {
const pinia = createPinia();
setActivePinia(pinia);
edu = useEduStore();
seed(edu);
wrapper = mount(TrackerPanel, { global: { plugins: [pinia] } });
});
it('1) 统计条计数正确', () => {
const stats = wrapper.find('[data-testid="tk-stats"]');
expect(stats.text()).toContain('总投稿');
const b = stats.findAll('b').map((x) => x.text());
expect(b).toEqual(['4', '1', '1', '1', '1']); // total/active/revision/accepted/rejected
});
it('2) 表单含返修截止日与返修轮次字段', async () => {
await wrapper.find('.tk-add').trigger('click');
expect(wrapper.find('[data-testid="tk-revision-due"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tk-revision-round"]').exists()).toBe(true);
});
it('3) 卡片显示返修轮次+截止信息;无联动徽章', () => {
const rows = wrapper.findAll('[data-testid="tk-row"]');
expect(rows.length).toBe(4);
const rev = wrapper.find('[data-testid="tk-rev-info"]');
expect(rev.text()).toContain('第 2 轮返修');
expect(rev.text()).toContain('due 2026-11-01');
// 倒计时文案(今天 2026-10-12,due 2026-11-01 → 还剩 N 天)
expect(rev.text()).toMatch(/还剩 \d+ 天|今天截止|已逾期/);
// 方案 A:不再显示"已开启/未开始返修"联动徽章
expect(wrapper.find('[data-testid="tk-rebuttal-status"]').exists()).toBe(false);
expect(wrapper.text()).not.toContain('已开启返修任务');
expect(wrapper.text()).not.toContain('未开始返修');
});
it('4) 「去写返修」仅审稿中/返修中显示;点击只跳转+预填期刊(无回写)', () => {
// 按 updatedAt 倒序:s1(revision) → s3(under_review) → s2(accepted) → s4(rejected)
const btns = wrapper.findAll('[data-testid="tk-start-rebuttal"]');
expect(btns.length).toBe(2); // 仅 s1/s3
const rows = wrapper.findAll('[data-testid="tk-row"]');
expect(rows[2].text()).not.toContain('去写返修'); // s2 accepted
expect(rows[3].text()).not.toContain('去写返修'); // s4 rejected
btns[0].trigger('click');
return Promise.resolve().then(() => {
expect(edu.section).toBe('rebuttal');
expect(edu.rebuttal.prefillJournal).toBe('Plant Cell');
expect(edu.rebuttal.linkSid == null).toBe(true); // 方案 A:无回写联动
});
});
it('5) 空态文案', () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu2 = useEduStore();
edu2.submissions = [];
const w = mount(TrackerPanel, { global: { plugins: [pinia] } });
expect(w.find('.tk-empty').text()).toContain('暂无投稿记录');
expect(w.find('.tk-empty').text()).toContain('新增投稿');
});
});
+92 -2
View File
@@ -6,7 +6,7 @@
// 而 http.js 只对非 2xx 抛 ApiError —— 200 信封会正常 resolve。
// unwrap() 把两条错误路径(200 信封 / 非 2xx http.js 抛出)归一成 ApiError,
// 调用方只需 catch + reviewErrorMessage(err) 一个口径。
import { get, post, ApiError } from './http.js';
import { get, post, patch, del, ApiError } from './http.js';
const BASE = '/api/review';
@@ -19,6 +19,9 @@ function unwrap(promise) {
degraded: !!payload.error.degraded,
});
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;
}
return payload;
@@ -104,6 +107,23 @@ export function compileTex(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 用后端原话
* (与 presubmit 的"扫描件"提示是不同语境,不能复用 CODE_HINTS),其余走统一口径。 */
export function formatErrorMessage(err) {
@@ -119,9 +139,11 @@ const CODE_HINTS = {
VALIDATION: null,
PARSE_FAILED: '论文解析失败:可能是扫描件/图片型 PDF,请换文本型 PDF。',
UPSTREAM_DOWN: '审稿模型暂时不可用,请稍后重试。',
SEED_UNAVAILABLE: '期刊库未就绪(种子数据缺失),请联系管理员。',
SEED_UNAVAILABLE: '期刊库就绪中(种子数据缺失),请稍后再试。',
TIMEOUT: '请求超时,请稍后重试。',
NETWORK: '网络异常,请检查连接后重试。',
// P3b 编译:固定口径(后端 message 带动态 pending 数,此处按规范统一为"最多 5 个")
QUEUE_FULL: '编译队列已满(最多 5 个任务),请稍后重试',
};
/** ApiError(或任意错误)→ 用户可读文案。degraded 由调用方读 err.degraded 走降级横幅样式。 */
@@ -133,3 +155,71 @@ export function reviewErrorMessage(err) {
}
return '服务异常,请稍后重试';
}
/* —— Rebuttal Phase 1(审稿回复助手,10 端点)—— */
const RB = `${BASE}/rebuttal`;
/** POST /rebuttal/jobs {text} → {ok, job, meta:{reviewers,comments}} 文本粘贴主入口 */
export function rebuttalCreateJob(text, opts = {}) {
return unwrap(post(`${RB}/jobs`, { text }, opts));
}
/** POST /rebuttal/jobs/pdf(multipart)→ 同上;PDF 次要入口(批注版审稿 PDF) */
export function rebuttalCreateFromPdf(formData, opts = {}) {
return unwrap(post(`${RB}/jobs/pdf`, formData, opts));
}
/** GET /rebuttal/jobs/{id} → {ok, job} 全量还原 */
export function rebuttalGetJob(id, opts = {}) {
return unwrap(get(`${RB}/jobs/${id}`, {}, opts));
}
/** PATCH /rebuttal/jobs/{id}/comments/{cid}/response {parts} → {ok, job} */
export function rebuttalSaveResponse(id, cid, parts, opts = {}) {
return unwrap(patch(`${RB}/jobs/${id}/comments/${cid}/response`, { parts }, opts));
}
/** POST /rebuttal/jobs/{id}/comments/{cid}/draft → {ok, job} 或校验失败
* {ok:false, error:{code:'VALIDATION_FAILED'}, job, meta:{validation}}(校验C前置)。
* 不走 unwrap:校验失败信封里 job/meta 也是有效载荷,需原样返回给面板。 */
export function rebuttalDraft(id, cid, opts = {}) {
return post(`${RB}/jobs/${id}/comments/${cid}/draft`, {}, opts).then((payload) => {
if (payload && payload.ok === false && payload.error) {
const err = new ApiError(payload.error.message || '草稿生成失败', {
code: payload.error.code || 'INTERNAL',
status: 200,
degraded: !!payload.error.degraded,
});
err.payload = payload; // 信封全量(含 job + meta.validation)
throw err;
}
return payload;
});
}
/** PATCH /rebuttal/jobs/{id}/comments/{cid}/confirm {confirmed} → {ok, job, meta.human_ack} */
export function rebuttalConfirm(id, cid, confirmed, opts = {}) {
return unwrap(patch(`${RB}/jobs/${id}/comments/${cid}/confirm`, { confirmed }, opts));
}
/** POST /rebuttal/jobs/{id}/letter → {ok, job}(job.letter_md 已拼装) */
export function rebuttalLetter(id, opts = {}) {
return unwrap(post(`${RB}/jobs/${id}/letter`, {}, opts));
}
/** GET /rebuttal/jobs/{id}/letter.md —— 纯文本下载(human_ack 门槛,非信封)。
* 返回 Promise<string>;400 时抛 ApiError(code 取自响应文本前缀)。 */
export async function rebuttalDownloadLetterMd(id) {
const resp = await fetch(`${RB}/jobs/${id}/letter.md`);
if (!resp.ok) {
const text = await resp.text().catch(() => '');
const code = text.startsWith('HUMAN_ACK_REQUIRED') ? 'HUMAN_ACK_REQUIRED' : 'DOWNLOAD_FAILED';
throw new ApiError(text || `下载失败(HTTP ${resp.status})`, { code, status: resp.status });
}
return resp.text();
}
/** DELETE /rebuttal/jobs/{id} → {ok:true, meta:{deleted}} 手动清(无 TTL) */
export function rebuttalDeleteJob(id, opts = {}) {
return unwrap(del(`${RB}/jobs/${id}`, opts));
}
+7 -2
View File
@@ -16,7 +16,8 @@ const ITEMS = [
{ key: 'report', label: '专业审稿意见报告', icon: '📝' },
{ key: 'format', label: '论文排版与规范', icon: '📐' },
{ key: 'reformat', label: '论文自动重排版', icon: '🧩' },
{ key: 'tracker', label: '投稿进度与返修', icon: '📮' },
// SVG 内联(U+1F4AC/U+2709 在用户 Ubuntu 浏览器均无字形 → 方框;SVG 不依赖字体)
{ key: 'rebuttal', label: '审稿回复助手', icon: '<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor"><path d="M2 2h12v9H6l-4 3z"/></svg>' },
{ key: 'calendar', label: '会议与投稿日历', icon: '📅' },
];
</script>
@@ -35,7 +36,9 @@ const ITEMS = [
:title="it.label"
@click="edu.setSection(it.key)"
>
<span class="edu-icon" aria-hidden="true">{{ it.icon }}</span>
<!-- icon 为静态白名单字符串(本文件 ITEMS 常量),SVG 项用 v-html,emoji 项纯文本 -->
<span v-if="it.icon.startsWith('<svg')" class="edu-icon" aria-hidden="true" v-html="it.icon" />
<span v-else class="edu-icon" aria-hidden="true">{{ it.icon }}</span>
<span class="edu-label">{{ it.label }}</span>
</button>
</li>
@@ -106,6 +109,8 @@ const ITEMS = [
flex: none;
font-size: 14px;
line-height: 1;
display: inline-flex;
align-items: center;
}
.edu-label {
min-width: 0;
+3 -3
View File
@@ -12,13 +12,13 @@ import PresubmitPanel from './panels/PresubmitPanel.vue';
import ReportPanel from './panels/ReportPanel.vue';
import FormatPanel from './panels/FormatPanel.vue';
import ReformatPanel from './panels/ReformatPanel.vue';
import TrackerPanel from './panels/TrackerPanel.vue';
import CalendarPanel from './panels/CalendarPanel.vue';
import RebuttalPanel from './panels/RebuttalPanel.vue';
const edu = useEduStore();
/** section → 面板组件映射(与 edu store SECTIONS 一致;
* training → calendar 换版,TrainingPanel.vue 保留在 panels/ 下未删除,仅解除映射) */
* training → calendar、tracker 已解除映射(面板文件保留在 panels/ 下未删除) */
const panelMap = {
journals: JournalsPanel,
recommend: RecommendPanel,
@@ -26,8 +26,8 @@ const panelMap = {
report: ReportPanel,
format: FormatPanel,
reformat: ReformatPanel,
tracker: TrackerPanel,
calendar: CalendarPanel,
rebuttal: RebuttalPanel,
};
const currentPanel = computed(() => panelMap[edu.section] || JournalsPanel);
+199 -5
View File
@@ -2,10 +2,14 @@
链路:本地 PDF(form-data)→ edu.submitPresubmit(fd, {onUploadProgress})
→ POST /api/review/presubmit(file + target_journal;≤50MB,vstore 解析 + 六维 LLM)
→ {ok, report{summary, major/minor_weaknesses[], presentation_format[],
questions_for_authors[], recommendation}, meta{filename,target_journal,text_chars,truncated,model}}
(真实响应结构写入前已 curl 8100 实测;self_review.py 六维 prompt 不动,本组件纯展示)。
questions_for_authors[], recommendation(规则引擎计算), recommendation_rationale},
meta{...,tier,tier_label,tier_source}}
判定链(2026-10-12):LLM 只列 weakness(major 带 hard_rule/fatal 标签),
recommendation 由 recommend_engine 规则引擎计算并附 rationale(可解释);
fatal → reject 红标 + "建议人工复核"(克制展示,须人工核对)。
优先级:edu.report 存在 → 结果区(不退回上传区);否则 loading > error > 上传区。
结果/targetJournal 草稿存 store 跨菜单保留;PDF 文件为面板本地 ref(切菜单需重选)。
结果/targetJournal 草稿/已选 PDF(edu.presubmitFile,仅内存)均存 store 跨菜单保留,
切菜单回来:有报告显示报告,无报告保留已选文件可直接提交。
http.js FormData+onUploadProgress → XHR 分支,上传阶段显示真实百分比。 -->
<script setup>
import { computed, ref } from 'vue';
@@ -20,9 +24,16 @@ const REC = {
major_revision: { label: '大修', cls: 'major' },
reject: { label: '拒稿', cls: 'reject' },
};
const HARD_RULE_LABELS = {
busco_missing: 'BUSCO 完整度缺失',
hic_missing: 'Hi-C 挂载率缺失',
accession_missing: '数据 Accession 缺失',
voucher_missing: '凭证标本缺失',
abs_missing: 'ABS/名古屋合规声明缺失',
};
const fileInput = ref(null);
const file = ref(null); // File 面板本地(不进 store)
const file = ref(edu.presubmitFile || null); // File 存 store(切菜单/面板卸载不丢);本地 ref 为渲染镜像
const fileError = ref('');
const dragging = ref(false);
const uploadPct = ref(0);
@@ -33,6 +44,32 @@ const errored = computed(() => edu.reportStatus === 'error');
const report = computed(() => edu.report?.report || null);
const meta = computed(() => edu.report?.meta || null);
const rec = computed(() => REC[report.value?.recommendation] || REC.major_revision);
const rationale = computed(() => report.value?.recommendation_rationale || null);
const isReject = computed(() => report.value?.recommendation === 'reject');
/** 判定档位提示:同一论文投不同 tier 结果可能不同,必须显式告知 */
const tierText = computed(() => {
const r = rationale.value;
const m = meta.value;
const tier = r?.tier ?? m?.tier;
if (!tier) return '';
const label = r?.tier_label || m?.tier_label || '';
const journal = (m?.target_journal || '').trim();
const name = journal || (m?.tier_source && m.tier_source !== '默认' ? m.tier_source : '未指定期刊');
return `判定档位:${name}(tier ${tier} ${label})`;
});
const hardHits = computed(() =>
(rationale.value?.hard_hits || []).map((t) => HARD_RULE_LABELS[t] || t)
);
const scannerConflicts = computed(() => rationale.value?.scanner_conflicts || []);
const droppedSections = computed(() => {
const d = meta.value?.dropped_sections || [];
const pretty = {
preamble: '文首', abstract: '摘要', introduction: '引言', results: '结果',
discussion: '讨论', methods: '方法', data_availability: '数据可用性',
references: '参考文献', acknowledgements: '致谢',
};
return d.filter((s) => !s.startsWith('[')).map((s) => pretty[s] || s);
});
/** 上传阶段(pct<1)显示真实百分比;上传完成后是解析 + LLM 长等待 */
const phaseText = computed(() =>
@@ -59,6 +96,7 @@ function acceptFile(f) {
return;
}
file.value = f;
edu.presubmitFile = f; // 进 store:切菜单/面板卸载不丢
}
function onPick(e) {
acceptFile(e.target.files?.[0]);
@@ -70,6 +108,7 @@ function onDrop(e) {
}
function removeFile() {
file.value = null;
edu.presubmitFile = null;
}
async function submit() {
@@ -107,14 +146,39 @@ function reset() {
📄 {{ meta.filename }}
<template v-if="meta.target_journal"> · 目标期刊 {{ meta.target_journal }}</template>
· {{ meta.text_chars }} 字符
<span v-if="meta.truncated" class="pp-truncated">· 正文超限已截断至 6 万字符</span>
<span v-if="meta.truncated" class="pp-truncated">· 正文超限已截断(保摘要/数据可用性/方法/结果/讨论)</span>
</div>
<p class="pp-ai-note">本判定由 AI 生成,仅供投稿前参考;fatal 级判定请务必人工核对后再决定。</p>
<div class="pp-topline">
<span class="pp-rec" :class="rec.cls">{{ rec.label }}</span>
<span v-if="isReject" class="pp-rec-warn">建议人工复核</span>
<span v-if="tierText" class="pp-tier">{{ tierText }}</span>
<button class="pp-again" type="button" @click="reset">重新上传</button>
</div>
<!-- 判定依据(规则引擎 rationale:可解释、可复现) -->
<div v-if="rationale" class="pp-rationale">
<div class="pp-rationale-head">判定依据</div>
<p class="pp-rationale-text">{{ rationale.explanation }}</p>
<div v-if="hardHits.length" class="pp-hard-chips">
<span v-for="(h, i) in hardHits" :key="'hh-' + i" class="pp-hard-chip">{{ h }}</span>
</div>
<!-- 扫描器冲突(黄标:证伪了硬性缺项,请人工核对) -->
<div v-if="scannerConflicts.length" class="pp-conflicts">
<div class="pp-conflict-title">关键词扫描器冲突(已降级为非硬性,请人工核对)</div>
<div v-for="(c, i) in scannerConflicts" :key="'sc-' + i" class="pp-conflict-item">
<span class="pp-conflict-tag">{{ HARD_RULE_LABELS[c.hard_rule] || c.hard_rule }}</span>
<span class="pp-conflict-reason">{{ c.reason }}</span>
</div>
</div>
<!-- 截断提示:被丢弃的低优先级节(判定价值低,不代表论文缺失) -->
<div v-if="droppedSections.length" class="pp-dropped">
未送审部分:{{ droppedSections.join('、') }}(截断所致,非论文缺失)
</div>
</div>
<p class="pp-summary">{{ report.summary }}</p>
<!-- 主要问题(重视觉) -->
@@ -126,6 +190,8 @@ function reset() {
>
<div class="pp-card-title">
<span class="pp-idx">{{ i + 1 }}</span>{{ it.title }}
<span v-if="it.hard_rule" class="pp-hard-badge" title="硬性规则命中,不可协商">硬性</span>
<span v-if="it.fatal" class="pp-fatal-badge" title="fatal 级:请人工核对证据">fatal</span>
</div>
<p class="pp-detail">{{ it.detail }}</p>
<div v-if="it.evidence" class="pp-evidence">
@@ -146,6 +212,11 @@ function reset() {
>
<div class="pp-card-title">
<span class="pp-idx light">{{ i + 1 }}</span>{{ it.title }}
<span
v-if="it.scanner_downgraded"
class="pp-down-badge"
title="扫描器在正文检出该缺项证据,已从大修降级为小修;请人工核对"
>冲突降级</span>
</div>
<p class="pp-detail light">{{ it.detail }}</p>
<div v-if="it.evidence" class="pp-evidence light">
@@ -301,6 +372,129 @@ function reset() {
.pp-rec.minor { color: var(--pa-info); }
.pp-rec.major { color: var(--pa-warning); }
.pp-rec.reject { color: var(--pa-error); }
.pp-ai-note {
margin: 0;
padding: 6px 10px;
border: 1px dashed var(--pa-hairline);
border-radius: var(--pa-r-sm);
font-size: 12px;
color: var(--pa-ink-subtle);
}
.pp-rec-warn {
flex: none;
padding: 2px 8px;
border-radius: var(--pa-r-sm);
background: var(--pa-error);
color: #fff;
font-size: 11px;
font-weight: 600;
}
.pp-tier {
flex: 1;
font-size: 12px;
color: var(--pa-ink-muted);
}
.pp-rationale {
padding: 10px 12px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
}
.pp-rationale-head {
font-size: 12px;
font-weight: 600;
color: var(--pa-ink-muted);
margin-bottom: 4px;
}
.pp-rationale-text {
margin: 0;
font-size: 13px;
line-height: 1.65;
color: var(--pa-ink);
}
.pp-hard-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.pp-hard-chip {
padding: 1px 8px;
border: 1px solid var(--pa-error);
border-radius: var(--pa-r-pill);
font-size: 11.5px;
color: var(--pa-error);
}
.pp-conflicts {
margin-top: 10px;
padding: 8px 10px;
border: 1px solid var(--pa-warn, #d4a017);
border-radius: var(--pa-r-sm);
background: color-mix(in srgb, var(--pa-warn, #d4a017) 8%, transparent);
}
.pp-conflict-title {
font-size: 11.5px;
font-weight: 600;
color: var(--pa-warn, #9a6b00);
margin-bottom: 4px;
}
.pp-conflict-item {
display: flex;
gap: 6px;
align-items: baseline;
font-size: 12px;
line-height: 1.6;
color: var(--pa-ink);
}
.pp-conflict-tag {
flex: none;
padding: 0 6px;
border: 1px solid var(--pa-warn, #d4a017);
border-radius: var(--pa-r-pill);
font-size: 10.5px;
color: var(--pa-warn, #9a6b00);
}
.pp-conflict-reason {
color: var(--pa-muted, #666);
}
.pp-dropped {
margin-top: 8px;
font-size: 11.5px;
color: var(--pa-muted, #777);
}
.pp-hard-badge {
flex: none;
margin-left: 6px;
padding: 0 6px;
border-radius: var(--pa-r-sm);
border: 1px solid var(--pa-error);
font-size: 10.5px;
font-weight: 600;
color: var(--pa-error);
line-height: 1.6;
}
.pp-down-badge {
flex: none;
margin-left: 6px;
padding: 0 6px;
border-radius: var(--pa-r-sm);
border: 1px solid var(--pa-warn, #d4a017);
font-size: 10.5px;
font-weight: 600;
color: var(--pa-warn, #9a6b00);
line-height: 1.6;
}
.pp-fatal-badge {
flex: none;
margin-left: 4px;
padding: 0 6px;
border-radius: var(--pa-r-sm);
background: var(--pa-error);
font-size: 10.5px;
font-weight: 700;
color: #fff;
line-height: 1.6;
}
.pp-again {
padding: 5px 14px;
border: 1px solid var(--pa-hairline);
@@ -0,0 +1,635 @@
<!-- RebuttalPanel.vue —— 审稿回复助手(Rebuttal Phase 1,UI v2)。
独立工作,不依赖 tracker(tracker 已解除映射,2026-10-12)。
步骤引导:① 粘贴审稿意见 → ② 智能拆条 → ③ 逐条回复 → ④ 导出 Response Letter。
布局(大屏三栏,<1100px 两栏降级,letter 折叠):
左 220px:Reviewer 折叠分组 + comment 列表(编号/severity 徽标/状态点)
中 flex-1:原话(灰底只读)+ 问题要点(LLM 归纳)+ 5 类表单
+ draft 预览 + 校验红条 + 逐条确认
右 40% 可折叠:Letter 预览(仅已填 draft 条目)+ X/Y 已确认 + 下载
状态机:无 job→粘贴区;loading→拆条动画;error→重试;NOT_FOUND→任务过期。
样例文本与 docs/samples/rebuttal_sample_reviewer.txt 同源(前端内置副本)。 -->
<script setup>
import { computed, ref, watch } from 'vue';
import { useEduStore } from '../../../stores/edu.js';
const edu = useEduStore();
const rb = computed(() => edu.rebuttal);
const job = computed(() => rb.value.job);
const SAMPLE_TEXT = `Reviewer #1:
1. The GWAS analysis lacks multiple-testing correction. Please re-run with FDR control and report q-values for all significant loci. In addition, the sample size justification is missing — please add a power analysis or at least discuss the detectable effect size.
2. Figure 3 is blurry and the axis labels are unreadable. As noted in Comment 3, the same issue applies to Figure S2. Please replace all figures with high-resolution versions.
3. Minor: please add the standard error estimates to Supplementary Table 2.
Reviewer #2:
1. The claim of novelty overstates the advance relative to Zhang et al. 2023. The introduction should more clearly delineate what is new.
2. How were the batch effects between the two sequencing centers corrected? This is essential to support the conclusions.`;
const textInput = ref('');
const showPdf = ref(false);
const partErr = ref('');
const PART_TYPES = [
{ key: 'new_data', label: '补充新数据', hint: '新增实验/数据(图/表编号、关键数值)' },
{ key: 'reanalysis', label: '重新分析', hint: '重跑的分析、方法/参数变化与新结论' },
{ key: 'location', label: '定位/指出位置', hint: '修改或证据在稿件中的位置(Section/Fig/Methods)' },
{ key: 'clarification', label: '澄清说明', hint: '针对误解的准确解释' },
{ key: 'rebuttal', label: '礼貌反驳', hint: '礼貌但明确的不同立场与理由' },
];
const collapsed = ref({}); // reviewerId → bool
const allComments = computed(() => {
const j = job.value;
if (!j) return [];
return (j.reviewers || []).flatMap((r) =>
(r.comments || []).map((c, i) => ({ ...c, reviewer: r.label, ord: i + 1 })));
});
const activeComment = computed(() => allComments.value.find((c) => c.id === rb.value.activeCid) || null);
const unconfirmedCount = computed(() => allComments.value.filter((c) => !c.human_confirmed).length);
const confirmedCount = computed(() => allComments.value.length - unconfirmedCount.value);
const allConfirmed = computed(() => allComments.value.length > 0 && unconfirmedCount.value === 0);
const letterComments = computed(() => allComments.value.filter((c) => String(c.draft || '').trim()));
const step = computed(() => {
if (rb.value.status === 'loading') return 2;
if (!job.value) return 1;
if (job.value.letter_md && allConfirmed.value) return 4;
return 3;
});
const isExpired = computed(() => rb.value.expired === true);
const forms = ref({});
function formOf(cid) {
if (!forms.value[cid]) {
const c = allComments.value.find((x) => x.id === cid);
const parts = (c?.response?.parts || []).map((p) => ({ ...p }));
forms.value[cid] = { parts: parts.length ? parts : [{ type: 'clarification', content: '', location_hint: '' }] };
}
return forms.value[cid];
}
const canDraft = computed(() => {
const f = activeComment.value && forms.value[activeComment.value.id];
if (!f) return false;
return f.parts.length > 0 && f.parts.every((p) => String(p.content || '').trim());
});
const validationOf = computed(() => activeComment.value?.validation || null);
const validationFailed = computed(() => validationOf.value && validationOf.value.passed === false);
// 前端立场词预检(与后端 _STANCE 同词表):只提示不阻止保存/编辑
const STANCE_RE = /\b(disagree|maintain|stand\s+by|reject|respectfully|not\s+persuaded|cannot\s+agree|dispute)\b/i;
const STANCE_HINT = '礼貌反驳需要包含 maintain/disagree/respectfully 等立场词,'
+ '例如:We respectfully maintain that... 或 We disagree with the reviewer\'s point that...';
function stanceMissing(p) {
const text = String(p.content || '').trim();
return p.type === 'rebuttal' && !!text && !STANCE_RE.test(text);
}
const stanceHintParts = computed(() => {
const f = activeComment.value && forms.value[activeComment.value.id];
if (!f) return [];
return f.parts.map((p, i) => (stanceMissing(p) ? i : -1)).filter((i) => i >= 0);
});
function statusDot(c) {
if (c.human_confirmed) return 'done';
const f = forms.value[c.id] || c.response;
const parts = f?.parts || [];
const filled = c.draft || parts.some((p) => String(p.content || '').trim());
return filled ? 'filled' : 'empty';
}
function fillSample() {
textInput.value = SAMPLE_TEXT;
}
async function submitText() {
partErr.value = '';
const t = textInput.value.trim();
if (!t) { partErr.value = '请粘贴审稿意见文本'; return; }
const jobNew = await edu.rebuttalCreate(t);
if (jobNew?.reviewers?.[0]?.comments?.[0]) {
edu.rebuttalSetActive(jobNew.reviewers[0].comments[0].id);
}
}
async function onPdf(ev) {
const f = ev.target.files && ev.target.files[0];
if (!f) return;
partErr.value = '';
if (!f.name.toLowerCase().endsWith('.pdf')) {
partErr.value = '仅支持 PDF(推荐直接粘贴文本)';
ev.target.value = '';
return;
}
const fd = new FormData();
fd.append('file', f);
const jobNew = await edu.rebuttalCreatePdf(fd);
ev.target.value = '';
if (jobNew?.reviewers?.[0]?.comments?.[0]) {
edu.rebuttalSetActive(jobNew.reviewers[0].comments[0].id);
}
}
function partsPayload(cid) {
return formOf(cid).parts.map((p) => ({
type: p.type, content: String(p.content || ''), location_hint: p.location_hint || null,
}));
}
function addPart() {
formOf(activeComment.value.id).parts.push({ type: 'clarification', content: '', location_hint: '' });
}
function removePart(i) {
const f = formOf(activeComment.value.id);
if (f.parts.length > 1) f.parts.splice(i, 1);
}
const draftMsg = ref('');
async function genDraft() {
const c = activeComment.value;
if (!c || !canDraft.value) return;
draftMsg.value = '';
// 无独立"保存"按钮:生成前自动保存当前要点(保存+生成一步完成)
const res = await edu.rebuttalSaveResponse(c.id, partsPayload(c.id));
if (!res.ok) { draftMsg.value = res.error; return; }
const d = await edu.rebuttalDraft(c.id);
if (!d.ok) {
// 校验失败:按钮已由 store finally 解锁;这里补"如何改"的具体建议
const noStance = d.validation?.findings?.some((f) => f.code === 'REBUTTAL_NO_STANCE');
draftMsg.value = noStance
? `${d.error || '草稿生成失败'}。${STANCE_HINT}`
: (d.error || '草稿生成失败');
}
}
async function toggleConfirm(c, ev) {
const res = await edu.rebuttalConfirm(c.id, ev.target.checked);
if (!res.ok) draftMsg.value = res.error;
}
const letterAssembling = ref(false);
const letterError = ref('');
const letterCollapsed = ref(false);
async function assembleLetter() {
letterAssembling.value = true; letterError.value = '';
const res = await edu.rebuttalAssembleLetter();
letterAssembling.value = false;
if (!res.ok) letterError.value = res.error;
}
async function downloadLetter() {
if (!job.value?.human_ack) return;
letterError.value = '';
try {
const { rebuttalDownloadLetterMd } = await import('../../../api/review.js');
const md = await rebuttalDownloadLetterMd(job.value.id);
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `response-letter-${job.value.id}.md`;
a.click();
URL.revokeObjectURL(a.href);
} catch (err) {
letterError.value = err?.message || '下载失败';
}
}
function retryParse() {
edu.clearRebuttal();
if (!textInput.value.trim() && job.value === null) return; // 保留已输入文本
}
watch(() => rb.value.activeCid, () => {
partErr.value = ''; draftMsg.value = '';
});
</script>
<template>
<div class="rb-root">
<header class="rb-head">
<h2 class="rb-title">审稿回复助手</h2>
<p class="rb-sub">Phase 1:粘贴审稿意见 → 智能拆条 → 逐条回复 → 导出 Response Letter(.md)</p>
</header>
<!-- 步骤引导 -->
<ol class="rb-steps" data-testid="rb-steps">
<li v-for="(s, i) in ['粘贴审稿意见', '智能拆条', '逐条回复', '导出 Response Letter']"
:key="i" class="rb-step" :class="{ on: step === i + 1, past: step > i + 1 }">
<span class="rb-step-num"><span class="rb-step-digit">{{ i + 1 }}</span></span>{{ s }}
</li>
</ol>
<!-- 状态:拆条中 -->
<section v-if="rb.status === 'loading'" class="rb-parsing" data-testid="rb-parsing">
<div class="rb-spinner" aria-hidden="true" />
<p>正在拆条…(约 5-15 秒)</p>
<p class="rb-hint">正在识别 Reviewer 分节与编号条目,并归纳问题要点</p>
</section>
<!-- 状态:任务过期 -->
<section v-else-if="isExpired" class="rb-expired">
<p>任务已过期,请重新创建。</p>
<button class="rb-btn rb-btn-primary" type="button" @click="edu.clearRebuttal()">重新开始</button>
</section>
<!-- 状态:解析失败 -->
<section v-else-if="rb.status === 'error'" class="rb-expired">
<p class="rb-err">{{ rb.error || '拆条失败' }}</p>
<button class="rb-btn rb-btn-primary" type="button" @click="retryParse">重试</button>
</section>
<!-- 状态:无 job → 粘贴区 -->
<section v-else-if="!job" class="rb-input">
<div class="rb-input-head">
<label class="rb-label" for="rb-text">审稿意见文本</label>
<button class="rb-link" type="button" data-testid="rb-sample" @click="fillSample">填充示例</button>
</div>
<textarea
id="rb-text" v-model="textInput" class="rb-textarea"
placeholder="直接粘贴审稿意见(支持 Reviewer 1/2 分节或编号条目)..."
/>
<div class="rb-input-actions">
<button class="rb-btn rb-btn-primary" type="button" data-testid="rb-parse"
:disabled="!textInput.trim()" @click="submitText">
解析并拆条
</button>
</div>
<div class="rb-pdf-card">
<button class="rb-btn" type="button" data-testid="rb-pdf-toggle" @click="showPdf = !showPdf">
或上传 PDF
</button>
<p class="rb-hint">如果是审稿人批注版 PDF,通常提取不到批注,建议用文本粘贴。</p>
<div v-if="showPdf" class="rb-pdf">
<input type="file" accept="application/pdf,.pdf" @change="onPdf" />
</div>
</div>
<p v-if="partErr" class="rb-err">{{ partErr }}</p>
</section>
<!-- 主区:三栏 -->
<section v-else class="rb-main">
<div class="rb-toolbar">
<span />
<button class="rb-link rb-danger" type="button" @click="edu.rebuttalDelete()">删除任务</button>
</div>
<div class="rb-banner" :class="allConfirmed ? 'rb-banner-ok' : 'rb-banner-warn'">
<template v-if="allConfirmed">
全部已确认{{ job.human_ack ? '(human_ack 已置位)' : '' }} —— 可下载 letter.md
</template>
<template v-else>{{ unconfirmedCount }} 条未确认 —— 全部勾选确认后才能下载 letter.md</template>
</div>
<div class="rb-cols" :class="{ 'rb-cols-2': letterCollapsed }">
<!-- 左:Reviewer → Comment 树 -->
<aside class="rb-tree">
<div v-for="r in job.reviewers" :key="r.id" class="rb-rev">
<button class="rb-rev-label" type="button" @click="collapsed[r.id] = !collapsed[r.id]">
<span class="rb-rev-caret" :class="{ open: !collapsed[r.id] }">▸</span>
{{ r.label }}({{ (r.comments || []).length }})
</button>
<template v-if="!collapsed[r.id]">
<button
v-for="(c, i) in r.comments" :key="c.id"
class="rb-c-item" :class="{ active: c.id === rb.activeCid }"
type="button" @click="edu.rebuttalSetActive(c.id)"
>
<span class="rb-c-ord">{{ i + 1 }}</span>
<span class="rb-c-title">{{ c.title || c.id }}</span>
<span class="rb-c-sev" :class="c.severity">{{ c.severity }}</span>
<span class="rb-c-dot" :class="statusDot(c)" :title="statusDot(c)" />
</button>
</template>
</div>
</aside>
<!-- 中:编辑区 -->
<div v-if="activeComment" class="rb-form">
<div class="rb-form-head">
<h3 class="rb-form-title">{{ activeComment.title }}</h3>
<label class="rb-confirm">
<input
type="checkbox" :checked="activeComment.human_confirmed"
data-testid="rb-confirm" @change="toggleConfirm(activeComment, $event)"
/>
已确认本条
</label>
</div>
<div class="rb-original">
<div class="rb-original-label">审稿人原话({{ activeComment.reviewer }} · #{{ activeComment.ord }})</div>
<p class="rb-original-body">{{ activeComment.body }}</p>
</div>
<!-- 问题要点(Phase 1 砍手工合并/拆分:多子条由 LLM 拆分,纠错留 Phase 2) -->
<div class="rb-point">
<div class="rb-point-body">
<div class="rb-original-label">问题要点</div>
<p class="rb-point-text">{{ activeComment.title }}</p>
</div>
</div>
<div v-if="activeComment.cross_refs?.length" class="rb-xref">
交叉引用:<span v-for="(x, i) in activeComment.cross_refs" :key="i">「{{ x.raw }}」{{ x.target_id ? `→ ${x.target_id}` : '(未解析,保留原文)' }} </span>
</div>
<div class="rb-parts">
<div class="rb-parts-label">回复要点(user_response)</div>
<div v-for="(p, i) in formOf(activeComment.id).parts" :key="i" class="rb-part">
<select v-model="p.type" class="rb-select" :data-testid="`rb-type-${i}`">
<option v-for="t in PART_TYPES" :key="t.key" :value="t.key">{{ t.label }}</option>
</select>
<textarea
v-model="p.content" class="rb-part-content" rows="3"
:placeholder="PART_TYPES.find((t) => t.key === p.type)?.hint"
:data-testid="`rb-content-${i}`"
/>
<!-- 立场词预检:只提示不阻止继续编辑/生成 -->
<p
v-if="stanceMissing(p)" class="rb-err rb-stance"
:data-testid="`rb-stance-hint-${i}`"
>{{ STANCE_HINT }}</p>
<input
v-if="p.type === 'location'"
v-model="p.location_hint" class="rb-part-hint" data-testid="rb-location-hint"
placeholder="位置提示:如 Section 3 / Fig. S2 / Methods 第 2 段"
/>
</div>
<div class="rb-part-actions">
<button class="rb-link" type="button" @click="addPart">+ 添加要点</button>
<button
v-if="formOf(activeComment.id).parts.length > 1"
class="rb-link" type="button"
@click="removePart(formOf(activeComment.id).parts.length - 1)"
>移除最后一条</button>
</div>
</div>
<div class="rb-actions">
<button
class="rb-btn rb-btn-primary" type="button" data-testid="rb-draft"
:disabled="!canDraft || rb.draftCid === activeComment.id"
@click="genDraft"
>
{{ rb.draftCid === activeComment.id ? '生成中…' : '生成 draft' }}
</button>
<span class="rb-hint">填写要点后点击生成;内容会自动保存</span>
</div>
<p v-if="draftMsg" class="rb-err">{{ draftMsg }}</p>
<div v-if="validationFailed" class="rb-banner rb-banner-err" data-testid="rb-validation">
反幻觉校验未通过({{ validationOf.findings?.length || 0 }} 项):
<ul>
<li v-for="(f, i) in validationOf.findings" :key="i">[{{ f.code }}] {{ f.message }}</li>
</ul>
</div>
<div v-if="activeComment.draft" class="rb-draft" data-testid="rb-draft-preview">
<div class="rb-original-label">draft 预览</div>
<pre class="rb-draft-md">{{ activeComment.draft }}</pre>
</div>
</div>
<div v-else class="rb-empty">← 选择一条审稿意见开始回复</div>
<!-- 右:Letter 预览(可折叠) -->
<aside v-if="!letterCollapsed" class="rb-letter">
<div class="rb-letter-head">
<span class="rb-letter-count" data-testid="rb-letter-count">
{{ confirmedCount }}/{{ allComments.length }} 条已确认
</span>
<button class="rb-iconbtn" type="button" title="折叠" @click="letterCollapsed = true">»</button>
</div>
<div v-if="!letterComments.length" class="rb-empty rb-letter-empty">
尚无 draft —— 填写回复要点并生成后将在此预览
</div>
<div v-for="c in letterComments" :key="c.id" class="rb-letter-item">
<div class="rb-letter-title">{{ c.reviewer }} · {{ c.title }}</div>
<pre class="rb-letter-md">{{ c.draft }}</pre>
</div>
<div class="rb-letter-actions">
<button class="rb-btn" type="button" :disabled="letterAssembling" @click="assembleLetter">
{{ letterAssembling ? '拼装中…' : '拼装 letter' }}
</button>
<button
class="rb-btn rb-btn-primary" type="button" data-testid="rb-download"
:disabled="!job.human_ack || !job.letter_md" @click="downloadLetter"
>
下载 .md
</button>
</div>
<span v-if="!allConfirmed" class="rb-letter-gate" data-testid="rb-gate">
{{ unconfirmedCount }} 条未确认,下载暂不可用
</span>
<p v-if="letterError" class="rb-err">{{ letterError }}</p>
</aside>
<button v-else class="rb-letter-expand" type="button" title="展开 Letter 预览" @click="letterCollapsed = false">
« Letter
</button>
</div>
</section>
</div>
</template>
<style scoped>
/* 2026-10-12 深浅双主题适配:全部走 tokens.css 真实变量
(--pa-hairline/--pa-surface-N/--pa-raised/--pa-ink-N/--pa-g-*);
步骤徽章纯 CSS 圆形(无 emoji 数字圈,深色下对比度可控)。 */
.rb-root { display: flex; flex-direction: column; gap: 14px; padding: 18px 22px; max-width: 1280px; color: var(--pa-ink); }
.rb-title { font-size: 20px; margin: 0; color: var(--pa-ink); }
.rb-sub { color: var(--pa-ink-muted); font-size: 13px; margin: 4px 0 0; }
/* 步骤条:纯 CSS 圆形徽章 */
.rb-steps { display: flex; gap: 8px; list-style: none; margin: 0; padding: 0; flex-wrap: wrap; }
.rb-step {
display: flex; align-items: center; gap: 8px;
font-size: 12px; color: var(--pa-ink-subtle);
padding: 6px 14px 6px 8px; border-radius: var(--pa-r-pill);
background: var(--pa-surface-2); border: 1px solid var(--pa-hairline);
}
.rb-step-num {
width: 20px; height: 20px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600; flex-shrink: 0;
border: 1px solid var(--pa-ink-subtle); color: var(--pa-ink-subtle);
background: transparent;
}
/* 当前步骤:品牌渐变填充 + 白字 */
.rb-step.on {
background: var(--pa-surface-1); color: var(--pa-ink);
border-color: transparent; font-weight: 600;
}
.rb-step.on .rb-step-num {
background: var(--pa-gradient); border-color: transparent; color: #fff;
}
/* 已完成步骤:绿勾 + 提亮文字 */
.rb-step.past { color: var(--pa-success); border-color: var(--pa-hairline); }
.rb-step.past .rb-step-num {
border-color: var(--pa-success); color: var(--pa-success); background: transparent;
}
.rb-step.past .rb-step-num::before { content: '✓'; font-size: 11px; }
.rb-step.past .rb-step-num > .rb-step-digit { display: none; }
/* 输入区:surface 底,不用纯白 */
.rb-label, .rb-parts-label, .rb-original-label { font-size: 13px; font-weight: 600; color: var(--pa-ink); }
.rb-input-head { display: flex; justify-content: space-between; align-items: baseline; }
.rb-textarea {
width: 100%; box-sizing: border-box; font: inherit; padding: 10px;
background: var(--pa-surface-2); color: var(--pa-ink);
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md);
min-height: 240px; max-height: 45vh; overflow-y: auto; resize: vertical;
}
.rb-textarea:focus { outline: none; border-color: var(--pa-g-from); }
.rb-textarea::placeholder { color: var(--pa-ink-subtle); }
.rb-input-actions { display: flex; gap: 12px; align-items: center; margin-top: 8px; }
/* PDF 次要卡片:虚线 = hairline 50% 透明;说明文字 = muted */
.rb-pdf-card {
margin-top: 12px; padding: 12px 14px;
border: 1px dashed color-mix(in srgb, var(--pa-hairline) 50%, transparent);
border-radius: var(--pa-r-lg);
display: flex; flex-direction: column; gap: 6px; align-items: flex-start;
background: var(--pa-surface-1);
}
.rb-pdf { margin-top: 4px; }
.rb-pdf input { font: inherit; color: var(--pa-ink); accent-color: var(--pa-g-from); }
.rb-btn {
padding: 6px 14px; border: 1px solid var(--pa-hairline-strong); border-radius: var(--pa-r-md);
background: var(--pa-raised); color: var(--pa-ink); cursor: pointer; font: inherit;
}
.rb-btn:hover:not(:disabled) { border-color: var(--pa-g-from); }
.rb-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.rb-btn-primary { background: var(--pa-gradient); color: #fff; border-color: transparent; }
.rb-link { background: none; border: none; color: var(--pa-info); cursor: pointer; font: inherit; text-decoration: underline; }
.rb-danger { color: var(--pa-error); }
.rb-err { color: var(--pa-error); font-size: 13px; }
.rb-stance { margin: 4px 0 0; font-size: 12px; }
.rb-hint { color: var(--pa-ink-muted); font-size: 12px; margin: 0; }
/* 解析中 / 过期 / 失败 */
.rb-parsing, .rb-expired {
display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 20px;
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-lg); background: var(--pa-surface-1);
color: var(--pa-ink);
}
.rb-spinner {
width: 28px; height: 28px; border-radius: 50%;
border: 3px solid var(--pa-hairline-strong); border-top-color: var(--pa-g-to);
animation: rb-spin 0.8s linear infinite;
}
@keyframes rb-spin { to { transform: rotate(360deg); } }
/* 主区三栏:左/右 surface-2,中 surface-1 区分层级 */
.rb-toolbar { display: flex; justify-content: space-between; align-items: center; }
.rb-banner { padding: 8px 12px; border-radius: var(--pa-r-md); font-size: 13px; }
.rb-banner-warn { background: color-mix(in srgb, var(--pa-warning) 14%, transparent); color: var(--pa-warning); }
.rb-banner-ok { background: color-mix(in srgb, var(--pa-success) 14%, transparent); color: var(--pa-success); }
.rb-banner-err { background: color-mix(in srgb, var(--pa-error) 14%, transparent); color: var(--pa-error); margin-top: 8px; }
.rb-banner-err ul { margin: 4px 0 0 16px; padding: 0; }
.rb-cols { display: grid; grid-template-columns: 220px minmax(0, 1fr) 40%; gap: 14px; align-items: start; }
.rb-cols-2 { grid-template-columns: 220px minmax(0, 1fr) auto; }
.rb-tree {
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-lg);
padding: 6px; max-height: 560px; overflow: auto;
background: var(--pa-surface-2);
}
.rb-rev-label {
display: flex; align-items: center; gap: 4px; width: 100%; text-align: left;
border: none; background: none; font-weight: 600; font-size: 13px; cursor: pointer;
padding: 6px 4px; color: var(--pa-ink);
}
.rb-rev-caret { display: inline-block; transition: transform 0.15s; font-size: 11px; color: var(--pa-ink-muted); }
.rb-rev-caret.open { transform: rotate(90deg); }
.rb-c-item {
display: flex; gap: 6px; align-items: center; width: 100%; text-align: left;
padding: 5px 6px; border: none; background: none; cursor: pointer;
border-radius: var(--pa-r-sm); font: inherit; color: var(--pa-ink);
}
.rb-c-item.active { background: var(--pa-surface-3); box-shadow: inset 2px 0 0 var(--pa-g-from); }
.rb-c-ord {
width: 16px; height: 16px; border-radius: var(--pa-r-xs); background: var(--pa-surface-3); color: var(--pa-ink-muted);
font-size: 10px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.rb-c-title { flex: 1; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pa-ink); }
.rb-c-sev { font-size: 10px; padding: 1px 5px; border-radius: var(--pa-r-pill); flex-shrink: 0; }
.rb-c-sev.major { background: color-mix(in srgb, var(--pa-error) 18%, transparent); color: var(--pa-error); }
.rb-c-sev.minor { background: color-mix(in srgb, var(--pa-warning) 18%, transparent); color: var(--pa-warning); }
.rb-c-sev.question { background: color-mix(in srgb, var(--pa-info) 18%, transparent); color: var(--pa-info); }
.rb-c-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.rb-c-dot.empty { background: var(--pa-ink-subtle); }
.rb-c-dot.filled { background: var(--pa-info); }
.rb-c-dot.done { background: var(--pa-success); }
.rb-form {
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-lg); padding: 14px;
display: flex; flex-direction: column; gap: 10px; background: var(--pa-surface-1);
}
.rb-form-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.rb-form-title { margin: 0; font-size: 15px; color: var(--pa-ink); }
.rb-confirm { font-size: 13px; display: flex; gap: 6px; align-items: center; white-space: nowrap; color: var(--pa-ink); }
.rb-original { background: var(--pa-surface-2); border-radius: var(--pa-r-md); padding: 8px 10px; }
.rb-original-body { margin: 4px 0 0; font-size: 13px; white-space: pre-wrap; color: var(--pa-ink); }
.rb-point {
display: flex; gap: 8px; align-items: flex-start;
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md); padding: 8px 10px;
background: var(--pa-surface-2);
}
.rb-point-body { flex: 1; }
.rb-point-text { margin: 2px 0 0; font-size: 13px; font-weight: 600; color: var(--pa-ink); }
.rb-iconbtn {
border: 1px solid var(--pa-hairline); background: var(--pa-raised); border-radius: var(--pa-r-sm);
cursor: pointer; font-size: 13px; padding: 2px 8px; color: var(--pa-ink-muted);
}
.rb-iconbtn:hover { color: var(--pa-ink); border-color: var(--pa-g-from); }
.rb-xref { font-size: 12px; color: var(--pa-ink-muted); }
.rb-part { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.rb-select, .rb-part-hint {
font: inherit; padding: 6px 8px; border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md);
max-width: 320px; background: var(--pa-surface-2); color: var(--pa-ink);
}
.rb-part-content {
font: inherit; padding: 8px; border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md);
background: var(--pa-surface-2); color: var(--pa-ink);
}
.rb-part-content::placeholder, .rb-part-hint::placeholder { color: var(--pa-ink-subtle); }
.rb-part-actions { display: flex; gap: 12px; margin-top: 6px; }
.rb-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.rb-draft { border-top: 1px solid var(--pa-hairline); padding-top: 8px; }
.rb-draft-md {
white-space: pre-wrap; font-size: 13px; background: var(--pa-code-bg);
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md); padding: 10px; margin: 6px 0 0;
color: var(--pa-ink);
}
.rb-empty { color: var(--pa-ink-subtle); padding: 30px; text-align: center; }
/* 右栏 letter:surface-2 与中栏区分 */
.rb-letter {
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-lg); padding: 10px;
max-height: 640px; overflow: auto; display: flex; flex-direction: column; gap: 8px;
background: var(--pa-surface-2);
}
.rb-letter-head { display: flex; justify-content: space-between; align-items: center; }
.rb-letter-count { font-size: 12px; font-weight: 600; color: var(--pa-success); }
.rb-letter-empty { padding: 16px; font-size: 12px; }
.rb-letter-item { border-top: 1px solid var(--pa-hairline); padding-top: 6px; }
.rb-letter-title { font-size: 12px; font-weight: 600; margin-bottom: 4px; color: var(--pa-ink); }
.rb-letter-md {
white-space: pre-wrap; font-size: 12px; background: var(--pa-code-bg);
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md); padding: 8px; margin: 0;
color: var(--pa-ink);
}
.rb-letter-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.rb-letter-gate { font-size: 12px; color: var(--pa-warning); }
.rb-letter-expand {
writing-mode: vertical-rl; border: 1px solid var(--pa-hairline); background: var(--pa-surface-2);
border-radius: var(--pa-r-lg); cursor: pointer; font: inherit; font-size: 12px; padding: 12px 4px;
color: var(--pa-ink-muted);
}
.rb-letter-expand:hover { color: var(--pa-ink); border-color: var(--pa-g-from); }
@media (max-width: 1100px) {
.rb-cols, .rb-cols-2 { grid-template-columns: 200px minmax(0, 1fr); }
.rb-letter { grid-column: 1 / -1; max-height: none; }
.rb-letter-expand { display: none; }
}
</style>
+596 -35
View File
@@ -9,10 +9,14 @@
选定后 target = 目录 id,后端经 journal_directory 回链)。
结果三 Tab:修改后源码(Blob 下载)/ Diff 报告(按 rule_key 着色、
按 action 计数:modified=已修改、warning=建议·橙色·不计入)/
编译 PDF(P3 决策暂缓:仅「开发中」占位,不触发编译;后端 tectonic
API(P3a)保留备用,前端不暴露编译入口,引导下载 .tex 去 Overleaf/
本地编译)。
结果跨菜单保留(store reformatResult);样式 rf- 前缀,零新增依赖。 -->
编译 PDF(P3b):POST /compile {job_id}(tectonic 单槽队列,约 10-30 秒,
首次拉 bundle 1-3 分钟——store.compileFirstHint 黄条提示可切面板后台跑);
成功→blob URL 内嵌 iframe 预览 + 下载 + 耗时/重编;失败→错误摘要 +
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>
import { computed, onMounted, ref, watch } from 'vue';
import { getDirectory, reviewErrorMessage } from '../../../api/review.js';
@@ -28,7 +32,7 @@ const SOURCE_LABEL = {
};
const fileInput = ref(null);
const file = ref(null);
const file = ref(edu.reformatFile || null); // File 存 store(换期刊重排不丢;× 移除才清空)
const fileError = ref('');
const dragging = ref(false);
const tab = ref('source'); // 'source' | 'diff' | 'pdf'
@@ -117,6 +121,173 @@ const structWarnings = computed(() => result.value?.warnings || []);
const modifiedCount = computed(() => changes.value.filter((c) => c.action === 'modified').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 || '—');
// 目标期刊规则摘要:只显示有值字段(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 注释)
const generatedShort = computed(() => {
const s = meta.value?.generated_at;
if (!s) return '';
const str = String(s);
return str.length >= 16 ? str.slice(5, 16).replace('T', ' ') : '';
});
function copyJobId() {
const id = result.value?.job_id;
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(() => {
if (edu.journalsStatus === 'idle') edu.fetchJournals(); // 精选期刊下拉数据源
@@ -143,6 +314,7 @@ function acceptFile(f) {
return;
}
file.value = f;
edu.reformatFile = f; // 进 store:换期刊/切菜单重排不丢
}
function onPick(e) {
acceptFile(e.target.files?.[0]);
@@ -154,6 +326,7 @@ function onDrop(e) {
}
function removeFile() {
file.value = null;
edu.reformatFile = null; // 唯一清空入口:"×" 移除
}
async function submit() {
@@ -166,7 +339,7 @@ async function submit() {
}
function reset() {
edu.clearReformat();
file.value = null;
// 不清 file——换期刊重排时保留已上传文件(仅"×"移除才清)
fileError.value = '';
tab.value = 'source';
}
@@ -195,10 +368,21 @@ function download() {
<!-- ========== ① 有结果 → 结果区 ========== -->
<template v-if="hasResult">
<div class="rf-meta">
📄 {{ meta?.filename || '—' }} · 目标:{{ meta?.journal_name || '—' }}({{ sourceLabel }})·
{{ meta?.engine }} · {{ meta?.generated_at || '' }}
<span class="rf-meta-sub"> · job {{ result.job_id }}</span>
📄 {{ meta?.filename || '—' }} · 目标:{{ meta?.journal_name || '—' }}({{ sourceLabel }})
<template v-if="generatedShort"> · {{ generatedShort }}</template>
<!-- 内部 job id 默认不展示;报错/反馈时可点复制 -->
<button
v-if="result.job_id" class="rf-copy-id" type="button"
title="复制任务 ID(报错反馈时使用)" @click="copyJobId"
>
复制任务 ID
</button>
</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>
<!-- 汇总:已修改 / 建议(橙,不计入修改)/ 结构提示 -->
@@ -252,21 +436,95 @@ function download() {
</article>
</div>
<!-- 编译 PDF(P3 暂缓):「开发中」占位,不触发编译;后端 P3a API 保留备用 -->
<!-- ========== 编译 PDF(P3b) ========== -->
<div v-else class="rf-compile">
<div class="rf-wip" role="note">
<p class="rf-wip-main">
PDF 编译功能开发中,暂时请下载修改后的 .tex 文件,在 Overleaf 或本地编译。
后续将支持多文件工程和图片上传。
</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>
<!-- 无 job_id(未完成重排版)→ 引导 -->
<div v-if="!result?.job_id" class="rf-wip" role="note" data-testid="rf-compile-guide">
<p class="rf-wip-main">请先完成重排版再编译。</p>
<p class="rf-wip-sub">编译基于重排版后的 .tex 源码,重排版成功后此页会出现「编译 PDF」按钮。</p>
</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>
</template>
@@ -287,6 +545,145 @@ function download() {
<!-- ========== ④ 上传区 ========== -->
<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
class="rf-drop"
:class="{ dragging, filled: !!file }"
@@ -370,6 +767,7 @@ function download() {
开始自动重排版
</button>
<p v-if="dirMode && !actualTarget" class="rf-dirhint">请从搜索结果中选择期刊后提交</p>
</template>
</div>
</section>
</template>
@@ -399,9 +797,26 @@ function download() {
font-size: 12.5px;
color: var(--pa-ink-muted);
}
.rf-meta-sub {
/* 规则摘要:次级文字(同 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 {
margin-left: 8px;
padding: 1px 8px;
font-size: 11px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-sm);
background: transparent;
color: var(--pa-ink-subtle);
cursor: pointer;
}
.rf-copy-id:hover { color: var(--pa-ink); border-color: var(--pa-hairline-strong); }
.rf-note {
margin: 0;
padding: 8px 12px;
@@ -600,12 +1015,6 @@ function download() {
color: var(--pa-warning);
}
/* 编译 PDF(P3 暂缓:开发中占位) */
.rf-compile {
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-wip {
display: flex;
flex-direction: column;
@@ -627,13 +1036,126 @@ function download() {
font-size: 12.5px;
color: var(--pa-ink-subtle);
}
.rf-wip-feedback {
font-size: 12.5px;
font-weight: 600;
color: var(--pa-g-from);
text-decoration: none;
/* 编译 PDF(P3b) */
.rf-compile {
display: flex;
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 {
@@ -730,6 +1252,45 @@ function download() {
font-size: 12.5px;
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 {
font-size: 12px;
font-weight: 500;
+14 -1
View File
@@ -28,7 +28,8 @@ const meta = computed(() => edu.report?.meta || null);
const rec = computed(() => REC[report.value?.recommendation] || REC.major_revision);
const createdAtText = computed(() => {
const s = edu.reportCreatedAt;
return s ? String(s).slice(0, 16).replace('T', ' ') : '—';
// ISO → "10-12 15:25" 短格式(不裸露完整时间戳)
return s ? String(s).slice(5, 16).replace('T', ' ') : '—';
});
/* 五节结构:1 总体评价(summary 单列)+ 2..5 列表节(questions 映射为 {title} 条目) */
@@ -405,6 +406,18 @@ onUnmounted(() => clearTimeout(toastTimer));
.rp-toolbar {
display: none !important;
}
/* 2026-10-12 修"导出只导一页":祖先链 height:100vh + overflow:auto 会把打印
内容裁在视口第一页——打印态整链展开为正常文档流,让浏览器自然分页。 */
:global(.edu-workspace) {
height: auto !important;
display: block !important;
}
:global(.edu-panel) {
height: auto !important;
overflow: visible !important;
display: block !important;
padding: 0 !important;
}
.rp-sheet {
max-width: none;
padding: 0;
+169 -83
View File
@@ -1,11 +1,14 @@
<!-- TrackerPanel.vue —— ⑥ 投稿进度与返修(纯前端 localStorage 台账,无后端)。
严格复用 edu store 现有 API:submissionsSorted(updatedAt 倒序)/ addSubmission /
updateSubmission / removeSubmission / SUBMISSION_STATUS;不新增字段、不改 store。
交互:新增(内联抽屉)/ 编辑(同一表单预填)/ 删除(行内二次确认)/ 空态引导。
日期约定:表单为 'YYYY-MM-DD';存量 submittedAt 可能是 ISO → 读取统一 slice(0,10) 兼容;
addSubmission 固定 submittedAt=now,故仅当用户改了日期时再 updateSubmission 补丁。 -->
<!-- TrackerPanel.vue —— ⑦ 投稿进度台账(纯前端 localStorage,无后端)。
v3 方案 A 极简台账(2026-10-12):
- 定位:只做"我投了哪些稿、现在什么状态",不与 Rebuttal 假联动
- 顶部统计条:总投稿 | 进行中 | 待返修 | 已接收 | 已拒绝
- 卡片:期刊/状态/日期/备注 + 返修轮次与截止倒计时(台账信息)
- "去写返修"仅在 status ∈ {under_review, revision} 显示,跳转 RebuttalPanel
自行操作;不回写 jobId、不显示"已开启/未开始"联动徽章
- 表单保留返修截止日/返修轮次字段(台账数据)
方案 B 真联动(回写 jobId + 返修时间线)留待 Phase 2。 -->
<script setup>
import { reactive, ref } from 'vue';
import { computed, reactive, ref } from 'vue';
import { useEduStore, SUBMISSION_STATUS } from '../../../stores/edu.js';
const edu = useEduStore();
@@ -24,12 +27,37 @@ const mode = ref('create'); // 'create' | 'edit'
const editingId = ref(null);
const formError = ref('');
const pendingDeleteId = ref(null); // 删除行内二次确认
const form = reactive({ journal: '', status: 'draft', date: '', notes: '' });
const form = reactive({
journal: '', status: 'draft', date: '', notes: '',
revisionDue: '', revisionRound: '',
});
const todayStr = () => new Date().toISOString().slice(0, 10);
const dateOf = (s) => String(s || '').slice(0, 10);
const dtOf = (s) => String(s || '').slice(0, 16).replace('T', ' ');
// 返修截止倒计时:"overdue 2d" / "due today" / "due in 3d"
function dueText(due) {
if (!due) return '';
const dueDay = new Date(`${String(due).slice(0, 10)}T23:59:59`);
const diff = Math.ceil((dueDay - Date.now()) / 86400000);
if (Number.isNaN(diff)) return `due ${dateOf(due)}`;
if (diff < 0) return `已逾期 ${-diff} 天`;
if (diff === 0) return '今天截止';
return `还剩 ${diff} 天`;
}
const stats = computed(() => {
const rows = edu.submissionsSorted;
return {
total: rows.length,
active: rows.filter((r) => r.status === 'submitted' || r.status === 'under_review').length,
revision: rows.filter((r) => r.status === 'revision').length,
accepted: rows.filter((r) => r.status === 'accepted').length,
rejected: rows.filter((r) => r.status === 'rejected').length,
};
});
function openCreate() {
mode.value = 'create';
editingId.value = null;
@@ -37,6 +65,8 @@ function openCreate() {
form.status = 'draft';
form.date = todayStr();
form.notes = '';
form.revisionDue = '';
form.revisionRound = '';
formError.value = '';
pendingDeleteId.value = null;
formOpen.value = true;
@@ -48,6 +78,8 @@ function openEdit(row) {
form.status = row.status;
form.date = dateOf(row.submittedAt);
form.notes = row.notes || '';
form.revisionDue = row.revisionDue || '';
form.revisionRound = row.revisionRound != null ? String(row.revisionRound) : '';
formError.value = '';
pendingDeleteId.value = null;
formOpen.value = true;
@@ -62,18 +94,21 @@ function save() {
formError.value = '请输入期刊名';
return;
}
const round = form.revisionRound === '' ? null : Math.max(1, parseInt(form.revisionRound, 10) || 1);
const extra = {
notes: form.notes,
revisionDue: form.revisionDue || '',
revisionRound: round,
};
if (mode.value === 'create') {
const rec = edu.addSubmission({ journal, status: form.status, notes: form.notes });
// addSubmission 固定 submittedAt=now(ISO);仅当用户改了投稿日期才补丁(复用 updateSubmission,不改 store)
const rec = edu.addSubmission({ journal, status: form.status, ...extra });
// addSubmission 默认 submittedAt=now(ISO);仅当用户改了投稿日期才补丁
if (form.date && form.date !== todayStr()) {
edu.updateSubmission(rec.id, { submittedAt: form.date });
}
} else {
edu.updateSubmission(editingId.value, {
journal,
status: form.status,
notes: form.notes,
submittedAt: form.date,
journal, status: form.status, submittedAt: form.date, ...extra,
});
}
closeForm();
@@ -82,16 +117,28 @@ function confirmDelete() {
if (pendingDeleteId.value) edu.removeSubmission(pendingDeleteId.value);
pendingDeleteId.value = null;
}
function startRebuttal(row) {
edu.rebuttalStartFromTracker(row);
}
</script>
<template>
<section class="tk">
<header class="tk-head">
<h1 class="tk-title">投稿进度与返修</h1>
<h1 class="tk-title">投稿进度台账</h1>
<span class="tk-sub">本地台账 · 仅存于当前浏览器</span>
<button class="tk-add" type="button" @click="openCreate">+ 新增投稿</button>
</header>
<!-- ========== 统计条 ========== -->
<div class="tk-stats" data-testid="tk-stats">
<div class="tk-stat"><b>{{ stats.total }}</b><span>总投稿</span></div>
<div class="tk-stat"><b>{{ stats.active }}</b><span>进行中</span></div>
<div class="tk-stat tk-stat-rev"><b>{{ stats.revision }}</b><span>待返修</span></div>
<div class="tk-stat tk-stat-ok"><b>{{ stats.accepted }}</b><span>已接收</span></div>
<div class="tk-stat tk-stat-no"><b>{{ stats.rejected }}</b><span>已拒绝</span></div>
</div>
<!-- ========== 新增/编辑共用抽屉 ========== -->
<div v-if="formOpen" class="tk-form">
<div class="tk-form-grid">
@@ -117,6 +164,14 @@ function confirmDelete() {
<span class="tk-label">投稿日期</span>
<input v-model="form.date" class="tk-input" type="date" />
</label>
<label class="tk-field">
<span class="tk-label">返修截止日(可选)</span>
<input v-model="form.revisionDue" class="tk-input" type="date" data-testid="tk-revision-due" />
</label>
<label class="tk-field">
<span class="tk-label">返修轮次(可选,默认 1)</span>
<input v-model="form.revisionRound" class="tk-input" type="number" min="1" placeholder="1" data-testid="tk-revision-round" />
</label>
<label class="tk-field tk-field-wide">
<span class="tk-label">备注(可空)</span>
<textarea
@@ -138,7 +193,7 @@ function confirmDelete() {
<!-- ========== 空态 ========== -->
<p v-if="!edu.submissionsSorted.length" class="tk-empty">
暂无投稿记录,点击「新增投稿」开始追踪
暂无投稿记录。点击「新增投稿」记录你投出的稿件。
</p>
<!-- ========== 列表(store 已按 updatedAt 倒序) ========== -->
@@ -147,12 +202,18 @@ function confirmDelete() {
:key="row.id"
class="tk-row"
:class="{ confirming: pendingDeleteId === row.id }"
data-testid="tk-row"
>
<div class="tk-main">
<span class="tk-journal" :title="row.journal">{{ row.journal }}</span>
<span class="tk-badge" :class="row.status">{{ STATUS_LABEL[row.status] || row.status }}</span>
<span class="tk-date">投稿 {{ dateOf(row.submittedAt) || '—' }}</span>
<span class="tk-updated">更新 {{ dtOf(row.updatedAt) || '—' }}</span>
<!-- 返修轮次 + 截止倒计时(台账信息,无联动暗示) -->
<span v-if="row.revisionRound || row.revisionDue" class="tk-rev" data-testid="tk-rev-info">
第 {{ row.revisionRound || 1 }} 轮返修
<template v-if="row.revisionDue"> · due {{ dateOf(row.revisionDue) }}({{ dueText(row.revisionDue) }})</template>
</span>
</div>
<p class="tk-notes" :class="{ empty: !row.notes }">{{ row.notes || '—' }}</p>
<div class="tk-actions">
@@ -166,6 +227,14 @@ function confirmDelete() {
<button class="tk-act tk-danger" type="button" @click="pendingDeleteId = row.id">
删除
</button>
<!-- 方案 A:仅审稿中/返修中可跳去写返修(纯跳转,无联动) -->
<button
v-if="row.status === 'under_review' || row.status === 'revision'"
class="tk-act tk-rebuttal-btn" type="button" data-testid="tk-start-rebuttal"
@click="startRebuttal(row)"
>
去写返修
</button>
</template>
</div>
</div>
@@ -208,6 +277,31 @@ function confirmDelete() {
filter: brightness(1.08);
}
/* ---------- 统计条 ---------- */
.tk-stats {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.tk-stat {
display: flex;
align-items: baseline;
gap: 6px;
padding: 8px 14px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
font-size: 12px;
color: var(--pa-ink-muted);
}
.tk-stat b {
font-size: 18px;
color: var(--pa-ink);
}
.tk-stat-rev b { color: #b8860b; }
.tk-stat-ok b { color: #2b8a3e; }
.tk-stat-no b { color: #c92a2a; }
/* ---------- 抽屉表单 ---------- */
.tk-form {
display: flex;
@@ -248,84 +342,62 @@ function confirmDelete() {
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 13px;
font-family: inherit;
}
.tk-input:focus {
outline: none;
border-color: var(--pa-g-from);
}
.tk-textarea {
resize: vertical;
min-height: 60px;
min-height: 56px;
}
.tk-form-error {
margin: 0;
font-size: 12.5px;
color: var(--pa-error);
font-size: 12px;
}
.tk-form-btns {
display: flex;
gap: 10px;
gap: 8px;
}
.tk-save {
padding: 6px 20px;
padding: 7px 18px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
background: var(--pa-accent);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.tk-cancel,
.tk-act {
padding: 6px 14px;
.tk-cancel {
padding: 7px 14px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: transparent;
color: var(--pa-ink-muted);
font-size: 12.5px;
font-size: 13px;
cursor: pointer;
}
.tk-cancel:hover,
.tk-act:hover {
color: var(--pa-ink);
background: var(--pa-surface-2);
}
.tk-danger {
color: var(--pa-error);
border-color: currentColor;
}
.tk-danger:hover {
color: var(--pa-error);
background: transparent;
filter: brightness(1.15);
}
/* ---------- 空态 ---------- */
.tk-empty {
margin: 0;
padding: 40px 16px;
padding: 28px 18px;
text-align: center;
font-size: 13px;
color: var(--pa-ink-subtle);
border: 1px dashed var(--pa-hairline);
font-size: 13px;
background: var(--pa-surface-1);
border: 1px dashed var(--pa-hairline-strong);
border-radius: var(--pa-r-lg);
line-height: 1.7;
}
/* ---------- 列表行 ---------- */
.tk-row {
display: flex;
flex-direction: column;
gap: 7px;
padding: 12px 16px;
gap: 6px;
padding: 12px 14px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
transition: border-color .15s var(--pa-ease);
}
.tk-row:hover {
border-color: var(--pa-hairline-strong);
border-radius: var(--pa-r-lg);
}
.tk-row.confirming {
border-color: var(--pa-error);
@@ -333,62 +405,76 @@ function confirmDelete() {
.tk-main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
flex-wrap: wrap;
}
.tk-journal {
font-size: 14px;
font-weight: 600;
font-size: 14px;
color: var(--pa-ink);
max-width: 44%;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 状态徽章 6 态配色:灰/蓝/青/橙/绿/红。
青(under_review)无专用 --pa-cyan 变量 → 用现成渐变终点色 --pa-g-to(#00D4FF),不硬编码 */
.tk-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border: 1px solid currentColor;
border-radius: var(--pa-r-pill);
font-size: 11.5px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 1.6;
}
.tk-badge.draft { color: var(--pa-ink-subtle); }
.tk-badge.submitted { color: var(--pa-info); }
.tk-badge.under_review { color: var(--pa-g-to); }
.tk-badge.revision { color: var(--pa-warning); }
.tk-badge.accepted { color: var(--pa-success); }
.tk-badge.rejected { color: var(--pa-error); }
.tk-date,
.tk-updated {
font-family: var(--pa-font-mono);
font-size: 11.5px;
.tk-badge.draft { background: #f1f3f5; color: #495057; }
.tk-badge.submitted { background: #e7f5ff; color: #1971c2; }
.tk-badge.under_review { background: #fff9db; color: #997404; }
.tk-badge.revision { background: #fff3bf; color: #b8860b; }
.tk-badge.accepted { background: #d3f9d8; color: #2b8a3e; }
.tk-badge.rejected { background: #ffe3e3; color: #c92a2a; }
.tk-date, .tk-updated {
font-size: 12px;
color: var(--pa-ink-subtle);
}
.tk-rev {
font-size: 12px;
font-weight: 600;
color: #b8860b;
}
.tk-notes {
margin: 0;
font-size: 12.5px;
line-height: 1.55;
font-size: 12px;
color: var(--pa-ink-muted);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.5;
}
.tk-notes.empty {
color: var(--pa-ink-subtle);
}
.tk-actions {
display: flex;
align-items: center;
gap: 8px;
align-items: center;
}
.tk-confirm-q {
font-size: 12.5px;
font-size: 12px;
color: var(--pa-error);
}
.tk-act {
padding: 4px 12px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: transparent;
color: var(--pa-ink);
font-size: 12px;
cursor: pointer;
}
.tk-act:hover {
background: var(--pa-surface-2);
}
.tk-danger {
color: var(--pa-error);
border-color: var(--pa-error);
}
.tk-rebuttal-btn {
color: var(--pa-accent);
border-color: var(--pa-accent);
font-weight: 600;
}
</style>
+227 -8
View File
@@ -4,10 +4,11 @@
// - 进行中的请求由 store action 持有 → 面板卸载不中断;
// - 表单草稿存 drafts → 切菜单不丢。
import { defineStore } from 'pinia';
import { compileTex, formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, presubmit, recommend, reformatTex, reviewErrorMessage } from '../api/review.js';
import { compileTex, formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, inspectZip, presubmit, recommend, reformatTex, reviewErrorMessage, rebuttalConfirm, rebuttalCreateFromPdf, rebuttalCreateJob, rebuttalDeleteJob, rebuttalDraft, rebuttalLetter, rebuttalSaveResponse, runZipCompile } from '../api/review.js';
const STORAGE_KEY = 'plantai.edu.submissions'; // D3 台账持久化
const SECTIONS = ['journals', 'recommend', 'presubmit', 'report', 'format', 'reformat', 'tracker', 'calendar'];
const STORAGE_KEY = 'plantai.edu.submissions'; // D3 台账持久化(tracker 面板已解除映射,state 保留)
// tracker 已砍(2026-10-12,仅解除 UI 映射;TrackerPanel.vue 保留在 panels/ 下可复用)
const SECTIONS = ['journals', 'recommend', 'presubmit', 'report', 'format', 'reformat', 'calendar', 'rebuttal'];
// ⑥ 台账状态枚举(labels 由 step-7 UI 层给;store 只存字符串)
export const SUBMISSION_STATUS = ['draft', 'submitted', 'under_review', 'revision', 'accepted', 'rejected'];
@@ -68,6 +69,12 @@ export const useEduStore = defineStore('edu', {
reportCreatedAt: null, // ④报告头生成时间(ISO;payload 无时间字段,提交成功时盖章,clearReport 重置)
reportStatus: 'idle',
reportError: null,
// ③ presubmit 已选 PDF(File 对象,仅内存:面板卸载/切菜单不丢,error 态可直接重试;
// 不可序列化 → 不进 localStorage;clearReport 一并清空)
presubmitFile: null,
// ⑤' reformat 已选 .tex(同 presubmitFile 模式:换期刊重排不丢文件;
// 面板内"×"移除才清空。File 不可序列化 → 不进 localStorage)
reformatFile: null,
recommend: null, // ② 推荐响应 {ok, tiers, meta}
recommendStatus: 'idle',
recommendError: null,
@@ -79,11 +86,19 @@ export const useEduStore = defineStore('edu', {
reformatResult: null,
reformatStatus: 'idle',
reformatError: null,
/* —— P3a 编译 PDF(tectonic;与 reformatResult 同生命周期,重新排版即失效)—— */
compileResult: null, // {ok, compile_id, pdf_url, log_url, meta}
/* —— P3a/P3b 编译 PDF(tectonic;与 reformatResult 同生命周期,重新排版即失效)—— */
compileResult: null, // {ok, compile_id, pdf_url, log_url, meta:{seconds,pdf_bytes,...}}
compileStatus: 'idle', // 'idle' | 'loading' | 'ready' | 'error'
compileError: null,
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、
presubmit 用 targetJournal、format 用 formatJournalId(期刊 id 下拉))—— */
@@ -104,6 +119,19 @@ export const useEduStore = defineStore('edu', {
// 结果区档位筛选(纯视图状态,不参与 payload;随 store 保留跨菜单)
tierFilter: 'all',
},
/* —— Rebuttal Phase 1(审稿回复助手;纯内存态,切换面板不丢,刷新丢失)—— */
rebuttal: {
job: null, // 后端全量 job(reviewers/letter_md/human_ack...)
status: 'idle', // idle | loading | ready | error
error: null,
expired: false, // NOT_FOUND(job 已删除/过期)→ 面板显示"任务已过期"
activeCid: null, // 当前编辑的 comment id
letterLoading: false,
draftCid: null, // 正在生成 draft 的 comment(按钮 loading)
// Tracker「去写返修」带来的期刊名预填(方案 A:仅跳转,无 jobId 回写)
prefillJournal: '',
},
}),
getters: {
@@ -121,9 +149,16 @@ export const useEduStore = defineStore('edu', {
loadFromStorage() {
this.submissions = readStorage();
},
addSubmission({ journal, status = 'draft', notes = '' }) {
addSubmission({ journal, status = 'draft', notes = '', submittedAt = null, revisionDue = '', revisionRound = null, rebuttalJobId = null }) {
const now = new Date().toISOString();
const rec = { id: safeId(), journal, status, submittedAt: now, updatedAt: now, notes };
const rec = {
id: safeId(), journal, status,
submittedAt: submittedAt || now,
updatedAt: now, notes,
revisionDue: revisionDue || '', // 返修截止日 'YYYY-MM-DD'(可空)
revisionRound: revisionRound || null, // 返修轮次(可空)
rebuttalJobId: rebuttalJobId || null, // 关联的 rebuttal job(开始返修回写)
};
this.submissions.unshift(rec);
this._persist();
return rec;
@@ -236,6 +271,116 @@ export const useEduStore = defineStore('edu', {
this.reportCreatedAt = null;
this.reportStatus = 'idle';
this.reportError = null;
this.presubmitFile = null;
},
/* —— Rebuttal Phase 1 actions(job 为后端权威,本地只镜像)—— */
_rebuttalBase() {
return {
job: null, status: 'idle', error: null, expired: false, activeCid: null,
letterLoading: false, draftCid: null, prefillJournal: '',
};
},
_rebuttalAfterCreate(res) {
this.rebuttal.job = res.job;
this.rebuttal.status = 'ready';
return res.job;
},
async rebuttalCreate(text) {
this.rebuttal = { ...this._rebuttalBase(), status: 'loading', prefillJournal: this.rebuttal.prefillJournal };
try {
const res = await rebuttalCreateJob(text);
return this._rebuttalAfterCreate(res);
} catch (err) {
this.rebuttal.status = 'error';
this.rebuttal.error = reviewErrorMessage(err);
return null;
}
},
async rebuttalCreatePdf(formData) {
this.rebuttal = { ...this._rebuttalBase(), status: 'loading', prefillJournal: this.rebuttal.prefillJournal };
try {
const res = await rebuttalCreateFromPdf(formData);
return this._rebuttalAfterCreate(res);
} catch (err) {
this.rebuttal.status = 'error';
this.rebuttal.error = reviewErrorMessage(err);
return null;
}
},
/** Tracker「去写返修」(方案 A):仅跳转 + 期刊名预填,不做 jobId 回写联动 */
rebuttalStartFromTracker(row) {
this.clearRebuttal();
this.setSection('rebuttal');
this.rebuttal.prefillJournal = row?.journal || '';
},
rebuttalSetActive(cid) {
this.rebuttal.activeCid = cid;
},
_applyRebuttalJob(job) {
if (job) this.rebuttal.job = job;
},
async rebuttalSaveResponse(cid, parts) {
try {
const res = await rebuttalSaveResponse(this.rebuttal.job.id, cid, parts);
this._applyRebuttalJob(res.job);
return { ok: true };
} catch (err) {
return { ok: false, error: reviewErrorMessage(err), code: err?.code };
}
},
async rebuttalDraft(cid) {
this.rebuttal.draftCid = cid;
try {
const res = await rebuttalDraft(this.rebuttal.job.id, cid);
this._applyRebuttalJob(res.job);
return { ok: true, validation: res.meta?.validation };
} catch (err) {
// VALIDATION_FAILED:信封里的 job 已带 validation,一并镜像
if (err?.payload?.job) this._applyRebuttalJob(err.payload.job);
return {
ok: false,
error: reviewErrorMessage(err),
code: err?.code,
validation: err?.payload?.meta?.validation || null,
};
} finally {
// 成功/失败/异常都要解锁按钮——否则校验报红后按钮永卡"生成中"
this.rebuttal.draftCid = null;
}
},
// Phase 1 砍手工合并/拆分(2026-10-12):PATCH /structure 后端端点保留,前端不调
async rebuttalConfirm(cid, confirmed) {
try {
const res = await rebuttalConfirm(this.rebuttal.job.id, cid, confirmed);
this._applyRebuttalJob(res.job);
return { ok: true, humanAck: res.meta?.human_ack ?? res.job?.human_ack };
} catch (err) {
return { ok: false, error: reviewErrorMessage(err), code: err?.code };
}
},
async rebuttalAssembleLetter() {
try {
const res = await rebuttalLetter(this.rebuttal.job.id);
this._applyRebuttalJob(res.job);
return { ok: true };
} catch (err) {
return { ok: false, error: reviewErrorMessage(err), code: err?.code };
}
},
async rebuttalDelete() {
const id = this.rebuttal.job?.id;
this.clearRebuttal();
if (!id) return { ok: true };
try {
await rebuttalDeleteJob(id);
return { ok: true };
} catch (err) {
return { ok: false, error: reviewErrorMessage(err) };
}
},
clearRebuttal() {
this.rebuttal = this._rebuttalBase();
},
async submitRecommend(payload) {
this.recommendStatus = 'loading';
@@ -292,18 +437,20 @@ export const useEduStore = defineStore('edu', {
this.reformatError = null;
this.clearCompile();
},
/* —— P3a 编译 PDF(tectonic 单槽队列;约 10-30 秒)—— */
/* —— P3b 编译 PDF(tectonic 单槽队列;约 10-30 秒,首次拉 bundle 1-3 分钟)—— */
async submitCompile(jobId) {
if (this.compileStatus === 'loading') return null;
this.compileStatus = 'loading';
this.compileError = null;
this.compileLogTail = '';
if (!this.compileResult) this.compileFirstHint = true; // 首次编译提示条
const fd = new FormData();
fd.append('job_id', jobId);
try {
const payload = await compileTex(fd);
this.compileResult = payload;
this.compileStatus = 'ready';
this._loadCompileBlob(payload.pdf_url); // 异步换 blob URL,失败回退直链
return payload;
} catch (err) {
this.compileStatus = 'error';
@@ -312,11 +459,83 @@ export const useEduStore = defineStore('edu', {
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() {
if (this.compilePdfUrl) {
try { URL.revokeObjectURL(this.compilePdfUrl); } catch { /* noop */ }
}
this.compileResult = null;
this.compileStatus = 'idle';
this.compileError = null;
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();
},
},
});