Merge pull request 'feat(review): AI 预审稿修复 + 审稿回复助手 Phase 1' (#3) from wjq/PlantAI-rag-new:feature/presubmit-explainable-engine into main
Reviewed-on: http://192.168.0.175:3000/admin/PlantAI-rag-new/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
@@ -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,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)----
|
||||
|
||||
@@ -25,6 +25,7 @@ from .routers import (
|
||||
journals,
|
||||
presubmit,
|
||||
reformat,
|
||||
rebuttal,
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -48,6 +49,12 @@ 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
|
||||
@@ -104,6 +111,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)
|
||||
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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,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('新增投稿');
|
||||
});
|
||||
});
|
||||
+69
-1
@@ -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';
|
||||
|
||||
@@ -133,3 +133,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>
|
||||
@@ -117,6 +117,17 @@ 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 || '—');
|
||||
// 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(() => {});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (edu.journalsStatus === 'idle') edu.fetchJournals(); // 精选期刊下拉数据源
|
||||
@@ -195,9 +206,15 @@ 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>
|
||||
<p v-if="meta?.rules_note" class="rf-note" role="note">{{ meta.rules_note }}</p>
|
||||
|
||||
@@ -399,9 +416,17 @@ function download() {
|
||||
font-size: 12.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.rf-meta-sub {
|
||||
.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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
+139
-5
@@ -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, presubmit, recommend, reformatTex, reviewErrorMessage, rebuttalConfirm, rebuttalCreateFromPdf, rebuttalCreateJob, rebuttalDeleteJob, rebuttalDraft, rebuttalLetter, rebuttalSaveResponse } 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,9 @@ export const useEduStore = defineStore('edu', {
|
||||
reportCreatedAt: null, // ④报告头生成时间(ISO;payload 无时间字段,提交成功时盖章,clearReport 重置)
|
||||
reportStatus: 'idle',
|
||||
reportError: null,
|
||||
// ③ presubmit 已选 PDF(File 对象,仅内存:面板卸载/切菜单不丢,error 态可直接重试;
|
||||
// 不可序列化 → 不进 localStorage;clearReport 一并清空)
|
||||
presubmitFile: null,
|
||||
recommend: null, // ② 推荐响应 {ok, tiers, meta}
|
||||
recommendStatus: 'idle',
|
||||
recommendError: null,
|
||||
@@ -104,6 +108,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 +138,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 +260,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';
|
||||
|
||||
Reference in New Issue
Block a user