test(docs): rebuttal + tracker tests + PR reports
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,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.
|
||||
@@ -113,4 +113,94 @@ describe('PresubmitPanel 判定 rationale / 档位 / fatal 克制展示', () =>
|
||||
// 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('新增投稿');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user