forked from admin/PlantAI-rag-new
Compare commits
11
Commits
4add9f176b
...
de0b9f92be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de0b9f92be | ||
|
|
2797145104 | ||
|
|
4e19350115 | ||
|
|
e036301e1a | ||
|
|
da84dff9bb | ||
|
|
3a72d3a861 | ||
|
|
ac07cb2008 | ||
|
|
d077a5b930 | ||
|
|
c4dc344a31 | ||
|
|
cf5ccb3a37 | ||
|
|
d99d5ef74a |
@@ -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 行
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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})
|
||||
@@ -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} 条 → 建议小修。")
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 示例
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user