diff --git a/docs/pr-reports/2026-10-12-conflict-demotion.md b/docs/pr-reports/2026-10-12-conflict-demotion.md new file mode 100644 index 0000000..4776b21 --- /dev/null +++ b/docs/pr-reports/2026-10-12-conflict-demotion.md @@ -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"。 + +## 部署 + +待管理员确认。属判定正确性修复(真论文误判大修),建议随下次正常发版带上。 diff --git a/docs/pr-reports/2026-10-12-presubmit-four-bugs.md b/docs/pr-reports/2026-10-12-presubmit-four-bugs.md new file mode 100644 index 0000000..8d3bb3f --- /dev/null +++ b/docs/pr-reports/2026-10-12-presubmit-four-bugs.md @@ -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 + (只会更严,不误放)。 diff --git a/docs/pr-reports/2026-10-12-rebuttal-phase1.md b/docs/pr-reports/2026-10-12-rebuttal-phase1.md new file mode 100644 index 0000000..5b9e4c6 --- /dev/null +++ b/docs/pr-reports/2026-10-12-rebuttal-phase1.md @@ -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 可接列表入口)。 diff --git a/docs/samples/rebuttal_sample_reviewer.txt b/docs/samples/rebuttal_sample_reviewer.txt new file mode 100644 index 0000000..d05ee0e --- /dev/null +++ b/docs/samples/rebuttal_sample_reviewer.txt @@ -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. diff --git a/tests/frontend/unit/edu/presubmit.rationale.test.js b/tests/frontend/unit/edu/presubmit.rationale.test.js index 7af9021..46ac3cc 100644 --- a/tests/frontend/unit/edu/presubmit.rationale.test.js +++ b/tests/frontend/unit/edu/presubmit.rationale.test.js @@ -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(); // 模拟切走菜单( 卸载面板) + + // 重建: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); + }); }); diff --git a/tests/frontend/unit/edu/rebuttal.panel.test.js b/tests/frontend/unit/edu/rebuttal.panel.test.js new file mode 100644 index 0000000..7db6ca6 --- /dev/null +++ b/tests/frontend/unit/edu/rebuttal.panel.test.js @@ -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'); + }); +}); diff --git a/tests/frontend/unit/edu/tracker.panel.test.js b/tests/frontend/unit/edu/tracker.panel.test.js new file mode 100644 index 0000000..28ad6b7 --- /dev/null +++ b/tests/frontend/unit/edu/tracker.panel.test.js @@ -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('新增投稿'); + }); +});