test(docs): rebuttal + tracker tests + PR reports

This commit is contained in:
potaser
2026-10-12 16:38:28 +08:00
parent c4dc344a31
commit d077a5b930
7 changed files with 719 additions and 0 deletions
@@ -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 可接列表入口)。
+13
View File
@@ -0,0 +1,13 @@
Reviewer #1:
1. The GWAS analysis lacks multiple-testing correction. Please re-run with FDR control and report q-values for all significant loci. In addition, the sample size justification is missing — please add a power analysis or at least discuss the detectable effect size.
2. Figure 3 is blurry and the axis labels are unreadable. As noted in Comment 3, the same issue applies to Figure S2. Please replace all figures with high-resolution versions.
3. Minor: please add the standard error estimates to Supplementary Table 2.
Reviewer #2:
1. The claim of novelty overstates the advance relative to Zhang et al. 2023. The introduction should more clearly delineate what is new.
2. How were the batch effects between the two sequencing centers corrected? This is essential to support the conclusions.
@@ -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('新增投稿');
});
});