From d99d5ef74aecca7522fc7317ae2f795126a12f15 Mon Sep 17 00:00:00 2001 From: potaser Date: Mon, 12 Oct 2026 09:55:07 +0800 Subject: [PATCH 1/4] fix(review): replace presubmit recommendation quota with explainable rule engine - Remove '3-8 major' quota that made accept/minor_revision unreachable - Add recommend_engine.py: rule-based R0-R5 + tier-parameterized judgment - Journal tier (1/2/3) from journals.json review_tier field - LLM no longer outputs recommendation; engine is single source of truth - Rationale exposed in API and rendered in PresubmitPanel - Weak draft still major_revision (R1); strong draft now minor_revision (R5) --- .../2026-10-12-presubmit-recommend-engine.md | 61 ++++++ review_service/app/routers/presubmit.py | 14 ++ .../app/services/recommend_engine.py | 174 ++++++++++++++++++ review_service/app/services/self_review.py | 96 +++++++--- review_service/data/journals/journals.json | 7 +- review_service/tests/test_presubmit.py | 170 ++++++++++++++++- .../unit/edu/presubmit.rationale.test.js | 116 ++++++++++++ .../components/edu/panels/PresubmitPanel.vue | 120 +++++++++++- 8 files changed, 716 insertions(+), 42 deletions(-) create mode 100644 docs/pr-reports/2026-10-12-presubmit-recommend-engine.md create mode 100644 review_service/app/services/recommend_engine.py create mode 100644 tests/frontend/unit/edu/presubmit.rationale.test.js diff --git a/docs/pr-reports/2026-10-12-presubmit-recommend-engine.md b/docs/pr-reports/2026-10-12-presubmit-recommend-engine.md new file mode 100644 index 0000000..0a9cb72 --- /dev/null +++ b/docs/pr-reports/2026-10-12-presubmit-recommend-engine.md @@ -0,0 +1,61 @@ +# fix(review): presubmit recommendation 判定过严修复(配额制 → 规则引擎) + +## 背景 + +用户反馈 AI 预审稿多次测试均返回 major_revision。2026-10-12 诊断确认根因是 +**配额制**而非"尺子紧": + +- `self_review.py` 硬性规则要求 `major_weaknesses 3-8 条`(强制配额)→ accept/minor_revision 几乎不可达; +- LLM 直接输出 recommendation,无程序层规则;JSON 示例锚定 `major_revision`、coerce 缺省回落 `major_revision`; +- 对照实验:近 GB 完备强稿(BUSCO 96.8% / Hi-C 98.7% / PRJNA+GCA+figshare 齐 / UFBoot 1000)仍被凑出 6 条 major,与弱稿(7 条)几乎无区分度; +- `target_journal` 传入但 Prompt 零分档标准。 + +## 修改内容 + +| 文件 | 变更 | +|---|---| +| `app/services/recommend_engine.py`(新增) | 纯函数规则引擎:R0 fatal→reject / R1 硬性 major→大修(不可协商,与 tier 无关)/ R2' tier3 仅 1-2 条 soft 可降小修 / R2 soft≥1→大修 / R4 0 major 且 minor≤tier 阈值→接收 / R5→小修;rationale 模板拼装(rule/hard_hits/计数/tier/explanation,含 tier 降级提示句);`resolve_tier` 按 journals.json `review_tier` 匹配,失败默认 tier 2 | +| `app/services/self_review.py` | Prompt 重写:严重分级标准(major/minor/presentation),**三类均不设下限、可 0 条、严禁凑数**;硬性规则 5 项打标签(busco/hic/accession/voucher/abs_missing);删除 recommendation 输出字段与示例锚定;coerce 不再读 recommendation(防注入),hard_rule 白名单过滤 | +| `app/routers/presubmit.py` | coerce 后接引擎;meta 增加 tier/tier_label/tier_source | +| `data/journals/journals.json` | 精选 5 本加 `review_tier`(GB/MP=1;BMC PB/FPS/Rice=2)+ notes 一行 | +| `ui/.../PresubmitPanel.vue` | 判定依据块(explanation + 硬性 chips);判定档位徽标;reject 红标"建议人工复核";顶部 AI 免责行;major 卡片"硬性/fatal"徽标 | +| `tests/test_presubmit.py` | 用例 1-10(引擎分支全覆盖 + tier 对照 + 注入忽略 + rationale 字段) | + +判定链与旧链路唯一对外契约变化:`report.recommendation` 语义不变(四档枚举), +新增 `report.recommendation_rationale` 与 `meta.tier*`。 + +## 测试用例与结果 + +- **pytest:87 passed**(78 基线 + 9 新增/重写),含用例 1-10: + 强稿形态不判大修、弱稿形态判大修且 hard_hits 正确、0 major 0/多 minor 分支、 + soft major 分支、fatal→reject、tier3 降级与硬性豁免、tier1 accept 门槛、 + LLM recommendation 注入被忽略、hard_rule 白名单过滤、rationale 含 tier/explanation。 +- **vitest:377 passed / 5 failed**(5F 为既有他人 i18n labels 改动引发的 benchpanel + 失败,与本改动无关;新增 4 条 rationale/档位/fatal 展示用例全绿)。 +- **真实链路验收**(诊断同款样本、同 target_journal=Genome Biology,真 LLM 调用): + +| 样本 | 修复前 | 修复后 | 依据 | +|---|---|---|---| +| 弱稿 diag_weak.pdf | major_revision(7 major) | **major_revision**(R1;硬性 5 命中:BUSCO/Hi-C/Accession/voucher/ABS + soft 2 + minor 6) | 真缺陷不漏判 ✓ | +| 强稿 diag_strong.pdf | major_revision(凑出 6 major) | **minor_revision**(R5;0 major / 5 minor) | 区分度恢复 ✓ | + +## 部署验证 + +```bash +bash review_service/restart.sh +# 上传强/弱样本对比(或任意 PDF): +curl -X POST http://127.0.0.1:8100/api/review/presubmit \ + -F "file=@paper.pdf" -F "target_journal=Genome Biology" +# 检查 report.recommendation_rationale.rule / meta.tier;前端看"判定依据"块 +``` + +## 是否需要立即部署 + +待管理员确认。属判定质量修复(误报大修影响用户体验与信任),建议随下次正常发版带上。 + +## 遗留(Phase 2 候选) + +- 程序层兜底 `_sanity_check_missing_hard_rules`:正文无 PRJNA/GCA_/SRP 关键词但 LLM + 未报 accession_missing → 追加 warning(不进 recommendation 计算);先观察 LLM 是否 + "躺平"再决定。 +- tier 数据仅覆盖精选 5 本;扩展到 journal_directory 1056 条需人工核验后另行入库。 diff --git a/review_service/app/routers/presubmit.py b/review_service/app/routers/presubmit.py index f15b69e..2285aff 100644 --- a/review_service/app/routers/presubmit.py +++ b/review_service/app/routers/presubmit.py @@ -13,6 +13,7 @@ from pydantic import BaseModel from ..config import get_settings from ..services import llm, paper_parse, self_review +from ..services.recommend_engine import decide_recommendation, resolve_tier logger = logging.getLogger(__name__) @@ -74,6 +75,16 @@ async def presubmit_review( return _error("UPSTREAM_DOWN", f"审稿模型调用失败:{exc}", degraded=True) report = self_review.coerce_report(payload) + # recommendation 由规则引擎计算(LLM 输出的同名字段已被 coerce 忽略) + tier, tier_matched = resolve_tier((target_journal or "").strip()) + decision = decide_recommendation( + majors=report.major_weaknesses, + minors=report.minor_weaknesses, + presentations=report.presentation_format, + tier=tier, + ) + report.recommendation = decision["recommendation"] + report.recommendation_rationale = decision["rationale"] return PresubmitResponse( ok=True, report=report, @@ -83,5 +94,8 @@ async def presubmit_review( "text_chars": len(feed_text), "truncated": truncated, "model": llm._llm_config()["model"], + "tier": tier, + "tier_label": decision["rationale"]["tier_label"], + "tier_source": tier_matched or "默认", }, ) diff --git a/review_service/app/services/recommend_engine.py b/review_service/app/services/recommend_engine.py new file mode 100644 index 0000000..397635f --- /dev/null +++ b/review_service/app/services/recommend_engine.py @@ -0,0 +1,174 @@ +"""services/recommend_engine.py —— presubmit recommendation 规则引擎(纯函数,零 LLM)。 + +背景(2026-10-12 诊断):旧链路由 LLM 直接输出 recommendation,且 SYSTEM_PROMPT 配额 +强制 "major_weaknesses 3-8 条",导致 accept/minor_revision 几乎不可达(对照实验中 +强稿——BUSCO 96.8%/Hi-C 98.7%/accession 齐/UFBoot 1000——仍被凑出 6 条 major)。 +新链路:LLM 只如实列 weakness(major 带 hard_rule/fatal 标签),本引擎按可测规则 +计算 recommendation + rationale(模板拼装,可复现,不经 LLM)。 + +判定链(自上而下首条命中): + R0 任一 major 带 fatal=true → reject(须人工复核) + R1 H = 硬性 major 数 ≥ 1 → major_revision(与 tier 无关,不可协商) + R2' tier==3 且 1≤S≤2 且 H==0 → minor_revision(入门刊仅 soft 可降) + R2 S = soft major 数 ≥ 1 → major_revision(S≥5 标 severe,仍非 reject) + R4 S == 0 且 minor ≤ accept_max_minor[tier] → accept + R5 S == 0 → minor_revision +其中 S = 总 major 数 − 硬性标签数(标签漏打时 S 变大,只会更严不会漏放)。 +""" +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any, Iterable + +from ..config import get_settings + +# 硬性规则标签(SYSTEM_PROMPT 与 coerce 共用白名单;未知标签一律置 None) +HARD_RULE_TAGS = ( + "busco_missing", # 组装类论文缺 BUSCO 完整格式 + "hic_missing", # 宣称 chromosome-level/Hi-C 但缺挂载率 + "accession_missing", # 缺数据 Accession("will be deposited" 视为缺) + "voucher_missing", # 野生材料缺凭证标本 + "abs_missing", # 缺 ABS/名古屋合规声明 +) + +HARD_RULE_LABELS = { + "busco_missing": "BUSCO 完整度缺失", + "hic_missing": "Hi-C 挂载率缺失", + "accession_missing": "数据 Accession 缺失", + "voucher_missing": "凭证标本缺失", + "abs_missing": "ABS/名古屋合规声明缺失", +} + +TIER_LABELS = {1: "顶刊", 2: "主流期刊", 3: "入门期刊"} + +# tier 参数:accept 门槛(minor 上限);tier3 额外允许 ≤2 条 soft major 降为小修 +TIER_ACCEPT_MAX_MINOR = {1: 1, 2: 2, 3: 3} +TIER3_SOFT_MAJOR_AS_MINOR_MAX = 2 + + +@lru_cache(maxsize=1) +def _tier_by_name() -> dict[str, int]: + """journals.json 精选期刊 name(casefold) → review_tier(读文件一次,缓存)。""" + path = get_settings().journals_seed_abs + try: + payload = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + out: dict[str, int] = {} + for j in payload.get("journals") or []: + if not isinstance(j, dict): + continue + name = str(j.get("name") or "").strip().casefold() + tier = j.get("review_tier") + if name and isinstance(tier, int) and tier in (1, 2, 3): + out[name] = tier + return out + + +def resolve_tier(target_journal: str) -> tuple[int, str]: + """目标期刊 → (tier, matched_name)。空/未命中 → (2, "") 默认主流档。""" + key = (target_journal or "").strip().casefold() + if not key: + return 2, "" + tier = _tier_by_name().get(key) + return (tier, key) if isinstance(tier, int) else (2, "") + + +def _item_field(item: Any, key: str) -> Any: + if isinstance(item, dict): + return item.get(key) + return getattr(item, key, None) + + +def decide_recommendation( + majors: Iterable[Any], + minors: Iterable[Any] = (), + presentations: Iterable[Any] = (), + tier: int = 2, +) -> dict[str, Any]: + """规则引擎入口。majors 项可为 dict 或带 hard_rule/fatal 属性的对象。 + + 返回 {"recommendation": ..., "rationale": {...}}; + rationale 含 rule/hard_hits/计数/tier/tier_label/explanation(模板拼装)。 + """ + del presentations # 呈现类不参与判定(仅归档展示) + majors = list(majors) + minors = list(minors) + if tier not in (1, 2, 3): + tier = 2 + + hard_hits: list[str] = [] + fatal = False + for m in majors: + if bool(_item_field(m, "fatal")): + fatal = True + hr = _item_field(m, "hard_rule") + if hr in HARD_RULE_LABELS: + hard_hits.append(hr) + + m_count = len(majors) + h_count = len(hard_hits) + s_count = m_count - h_count # soft major(标签漏打 → 计入 S,只会更严) + minor_count = len(minors) + tier_label = TIER_LABELS[tier] + + # tier3 降级通道提示(对作者可见:同稿投不同档结果不同) + tier_note = "" + if h_count == 0 and 1 <= s_count <= TIER3_SOFT_MAJOR_AS_MINOR_MAX and tier != 3: + tier_note = f"当前判定基于 tier {tier} 档;tier 3 入门刊此稿可能判 minor_revision。" + + def _result(rec: str, rule: str, explanation: str) -> dict[str, Any]: + return { + "recommendation": rec, + "rationale": { + "rule": rule, + "hard_hits": hard_hits, + "major_count": m_count, + "hard_count": h_count, + "soft_count": s_count, + "minor_count": minor_count, + "tier": tier, + "tier_label": tier_label, + "explanation": explanation + tier_note, + }, + } + + # R0:fatal → reject(前端须配"建议人工复核") + if fatal: + return _result( + "reject", "R0", + "存在 fatal 级问题(数据可靠性/伦理,须人工复核)→ 建议拒稿。") + + # R1:硬性 major 不可协商 + if h_count >= 1: + labels = "、".join(HARD_RULE_LABELS.get(t, t) for t in dict.fromkeys(hard_hits)) + return _result( + "major_revision", "R1", + f"命中硬性缺陷({labels})→ 大修(不可协商);" + f"另有大修级 {s_count} 条、小修级 {minor_count} 条。") + + # R2':入门刊 1-2 条 soft major 可降为小修 + if tier == 3 and 1 <= s_count <= TIER3_SOFT_MAJOR_AS_MINOR_MAX: + return _result( + "minor_revision", "R2'", + f"无硬性缺陷,大修级(soft){s_count} 条;" + f"入门刊(tier 3)可将 ≤{TIER3_SOFT_MAJOR_AS_MINOR_MAX} 条 soft 降为小修 → 建议小修。") + + # R2/R3:soft major ≥ 1(S≥5 标 severe) + if s_count >= 1: + sev = ",severe" if s_count >= 5 else "" + return _result( + "major_revision", "R2", + f"大修级问题 {s_count} 条(均非硬性{sev})→ 建议大修。") + + # R4/R5:无 major + accept_max = TIER_ACCEPT_MAX_MINOR[tier] + if minor_count <= accept_max: + return _result( + "accept", "R4", + f"无大修级问题,小修级 {minor_count} 条(≤{accept_max})→ 建议接收。") + return _result( + "minor_revision", "R5", + f"无大修级问题,小修级 {minor_count} 条 → 建议小修。") diff --git a/review_service/app/services/self_review.py b/review_service/app/services/self_review.py index 0def3bd..07e653b 100644 --- a/review_service/app/services/self_review.py +++ b/review_service/app/services/self_review.py @@ -1,21 +1,25 @@ """services/self_review.py —— 植物基因组学专属自审稿(System Prompt + 报告模型)。 六大领域维度检查清单硬编码在 SYSTEM_PROMPT(发给 DeepSeek): -1. 基因组组装质量(N50/L50/BUSCO 完整格式/Hi-C 挂载率,缺项即 Major) +1. 基因组组装质量(N50/L50/BUSCO 完整格式/Hi-C 挂载率;硬性缺项打 hard_rule 标签) 2. 基因注释可靠性(MAKER/AUGUSTUS 流程、RNA-seq 证据、基因数与已知范围一致性) 3. 比较基因组学与进化(Bootstrap/后验概率、OrthoFinder 等直系同源策略) -4. 数据可用性合规(SRA/BioProject + 组装/GFF3 Accession,缺失即 Major) +4. 数据可用性合规(SRA/BioProject + 组装/GFF3 Accession,缺失即硬性 major) 5. 统计与实验设计(GWAS/QTL 样本量·多重校正·群体结构;转录组生物学重复) -6. 伦理与合规·植物特异性(凭证标本 voucher、ABS 惠益分享声明) +6. 伦理与合规·植物特异性(凭证标本 voucher、ABS 惠益分享声明——均为硬性 major) -Major/Minor 每条必须给 Evidence(论文章节位置或具体指标数值),由 coerce -宽容归一化(缺失补空串,不丢条目)。 +2026-10-12 判定链重构:LLM 不再输出 recommendation(旧配额制 "major 3-8 条" 导致 +accept/minor 几乎不可达,见 recommend_engine.py 模块 docstring);只如实列 weakness +并打 hard_rule/fatal 标签,recommendation 由规则引擎计算。Major/Minor 每条必须给 +Evidence(论文章节位置或具体指标数值),由 coerce 宽容归一化(缺失补空串,不丢条目)。 """ from __future__ import annotations +from typing import Any + from pydantic import BaseModel, Field -RECOMMENDATIONS = ("accept", "minor_revision", "major_revision", "reject") +from .recommend_engine import HARD_RULE_TAGS SYSTEM_PROMPT = """你是一位植物基因组学领域资深审稿人,长期为 Plant Cell、Nature Plants、 Genome Biology、Molecular Plant、Plant Journal 等期刊审稿。请对用户提供的论文正文做 @@ -28,9 +32,10 @@ Genome Biology、Molecular Plant、Plant Journal 等期刊审稿。请对用户 "Complete 95.3% [S:93.1%, D:2.2%], Missing 4.7%, n=1614 (embryophyta_odb10)" (含 Complete/Single-copy/Duplicated/Missing 四项与数据集版本、n 值) - Hi-C 挂载率(anchoring rate,chromosome-level 比例)及挂载到染色体的 scaffold 比例 -判定规则:若论文只报 N50 而未报 BUSCO 完整度或 Hi-C 挂载率(组装类论文),直接列为 -Major Weakness。同时检查组装是否为 pseudo-chromosome 级别、污染评估(如 k-mer/coverage -检查)与 gap 比例。 +判定规则:若论文只报 N50 而未报 BUSCO 完整度或 Hi-C 挂载率(组装类论文),列为 +Major Weakness 并打对应 hard_rule 标签(BUSCO 缺失→"busco_missing";宣称 +chromosome-level/Hi-C 挂载但未报挂载率→"hic_missing")。同时检查组装是否为 +pseudo-chromosome 级别、污染评估(如 k-mer/coverage 检查)与 gap 比例。 【维度二:基因注释可靠性】检查: - 是否使用标准注释流程(MAKER、AUGUSTUS、BRAKER、GeMoMa、EVidenceModeler 等),参数与版本是否交代 @@ -53,7 +58,8 @@ Major Weakness。同时检查组装是否为 pseudo-chromosome 级别、污染 - 基因组组装是否有 GenBank/ENA accession(GCA_/GCF_),GFF3 注释文件、蛋白/CDS 文件 是否有可公开获取的位置(仓库 accession 或补充数据链接) - 转录组数据是否同样有 SRA accession -判定规则:组装/注释类论文若缺失上述任何一环,直接列为 Major Weakness。 +判定规则:组装/注释类论文若缺失上述任何一环(含 "will be deposited" 等未给号的 +承诺式表述),列为 Major Weakness 并打 hard_rule 标签 "accession_missing"。 【维度五:统计与实验设计】检查: - GWAS/QTL 定位:样本量/群体大小是否充分;多重检验校正是否明确(Bonferroni/FDR/ @@ -65,34 +71,60 @@ Major Weakness。同时检查组装是否为 pseudo-chromosome 级别、污染 【维度六:伦理与合规(植物特异性)】检查: - 野生植物材料:是否提供凭证标本(voucher specimen)信息——标本馆编号、存放机构、 - 采集地点(GPS)与采集许可;无凭证标本的野生资源取样 → Major 或 Minor(视用途) + 采集地点(GPS)与采集许可;无凭证标本的野生资源取样 → Major Weakness 并打 + hard_rule 标签 "voucher_missing" - 遗传资源获取与惠益分享(ABS)合规性:是否声明符合《名古屋议定书》/本国遗传资源 - 获取条例(如中国《生物安全法》/《种子法》相关条款)、是否说明材料来源与授权 + 获取条例(如中国《生物安全法》/《种子法》相关条款)、是否说明材料来源与授权; + 涉及遗传资源但缺 ABS/名古屋合规声明 → Major Weakness 并打 hard_rule 标签 + "abs_missing" - 农业转基因/基因编辑材料的合规声明(若涉及) +- fatal 标签:仅当你发现明确的数据造假或伦理违规证据时,将该条 "fatal" 置 true + (数据内部矛盾、明显编造迹象等;无明确证据绝不可打标——该标签会触发拒稿建议)。 -输出要求(必须输出单个 JSON 对象,不要输出其他任何内容): +输出要求(必须输出单个 JSON 对象,不要输出其他任何内容;不要输出 recommendation +字段——推荐结论由程序规则引擎根据你列出的问题与标签计算): { "summary": "总体评价(3-5 句,须点明论文类型与所适用的维度)", - "major_weaknesses": [{"title": "...", "detail": "...", "evidence": "...", "suggestion": "..."}], + "major_weaknesses": [{"title": "...", "detail": "...", "evidence": "...", "suggestion": "...", + "hard_rule": "busco_missing|hic_missing|accession_missing|voucher_missing|abs_missing|null", + "fatal": false}], "minor_weaknesses": [{"title": "...", "detail": "...", "evidence": "...", "suggestion": "..."}], "presentation_format": [{"title": "...", "detail": "...", "evidence": "...", "suggestion": "..."}], - "questions_for_authors": ["..."], - "recommendation": "major_revision" + "questions_for_authors": ["..."] } -硬性规则: -1. major_weaknesses 3-8 条、minor_weaknesses 3-8 条;上述维度中命中的硬性判定规则 - (组装缺 BUSCO/挂载率、缺数据 Accession)必须出现。 +【严重程度分级——据此分类,不设条数配额】 +- major_weaknesses:问题的解决需要补充实验/补充数据/补充分析流程/大幅改写论证结构, + 或影响论文核心结论的可信度与数据合规性。列在这里意味着"不解决就不能发表"。 +- minor_weaknesses:在现有数据与实验范围内即可解决(补一段说明、补一个基于已有数据的 + 统计、澄清方法描述、局部重写)。列在这里意味着"改了更好,不改也能审"。 +- presentation_format:仅呈现层面的问题(图表样式、图注格式、参考文献体例、语法)。 + 严禁把呈现问题升格为 major。 + +分级与条数规则: +1. major_weaknesses、minor_weaknesses、presentation_format 均【不设下限,可以为 0 条】。 + 论文确实完善时输出空数组是正确答案;严禁为凑数而降低分级标准或制造问题。 2. **每条 major_weaknesses 的 evidence 字段必填**:必须引用论文的具体章节位置 (如 "Methods 2.1"、"Table 2"、"Results 第 3 段"、"Data Availability 一句话未见") 或具体指标数值(如 "仅报 scaffold N50=42.6 Mb,未提供 BUSCO 与 Hi-C 挂载率")。 - evidence 严禁编造:引用不到具体位置时,写明"正文未见相关报告"。 + evidence 严禁编造:引用不到具体位置时,写明"正文未见相关报告"。只列有 evidence + 的问题:列不出具体章节位置或指标数值的问题,不要列。 3. minor_weaknesses/presentation_format 的 evidence 尽量给出,无法定位时可留空字符串。 4. 论文不涉及的维度(如纯 RNA-seq 论文不谈组装)在 summary 中说明并跳过,不硬凑条目。 -5. recommendation 只能是 accept / minor_revision / major_revision / reject 之一, - 缺失关键合规项(维度四)时通常不高于 major_revision。 -6. presentation_format 覆盖:图表规范(图注含单位/误差棒说明)、参考文献格式、 - 字数与结构、英文语法(若投英文刊)。 +5. 宁可把拿不准严重程度的问题放 minor_weaknesses(由作者判断),不要放 major。 + +硬性规则(命中必须列 major 并打 hard_rule 标签,不论其他方面多完善;规则不可协商): +A. 组装类论文缺 BUSCO 完整格式报告 → hard_rule="busco_missing" +B. 宣称 chromosome-level/Hi-C 挂载但未报挂载率 → hard_rule="hic_missing" +C. 缺数据 Accession(SRA/GenBank/组装/GFF3;"will be deposited" 等承诺式表述视为缺) + → hard_rule="accession_missing" +D. 野生植物材料缺凭证标本(voucher)→ hard_rule="voucher_missing" +E. 涉及遗传资源但缺 ABS/名古屋合规声明 → hard_rule="abs_missing" +(核心进化结论所依赖的系统发育树若无任何节点支持率 → 列 major(非硬性,不打标签); + 附带性质的树无支持率 → 可列 minor。GWAS 缺多重校正等统计问题按同样原则判级。) + +presentation_format 覆盖:图表规范(图注含单位/误差棒说明)、参考文献格式、 +字数与结构、英文语法(若投英文刊)。 """ @@ -101,6 +133,8 @@ class WeaknessItem(BaseModel): detail: str = "" evidence: str = "" suggestion: str = "" + hard_rule: str | None = None # 仅 major 可带;白名单外置 None(见 coerce) + fatal: bool = False # 明确造假/伦理证据才可 true(触发 R0 拒稿) class SelfReviewReport(BaseModel): @@ -109,7 +143,9 @@ class SelfReviewReport(BaseModel): minor_weaknesses: list[WeaknessItem] = Field(default_factory=list) presentation_format: list[WeaknessItem] = Field(default_factory=list) questions_for_authors: list[str] = Field(default_factory=list) - recommendation: str = "major_revision" + # recommendation 由 recommend_engine 计算后写入;LLM 输出的同名字段一律忽略 + recommendation: str = "" + recommendation_rationale: dict[str, Any] = Field(default_factory=dict) def build_user_prompt(text: str, target_journal: str) -> str: @@ -129,27 +165,29 @@ def _coerce_items(raw: object) -> list[WeaknessItem]: detail = str(entry.get("detail") or "").strip() if not title and not detail: continue + hard_rule = str(entry.get("hard_rule") or "").strip() or None + if hard_rule not in HARD_RULE_TAGS: + hard_rule = None # 白名单外的标签丢弃(防 LLM 编造标签操纵引擎) items.append(WeaknessItem( title=title or "(未命名)", detail=detail, evidence=str(entry.get("evidence") or "").strip(), suggestion=str(entry.get("suggestion") or "").strip(), + hard_rule=hard_rule, + fatal=bool(entry.get("fatal")), )) return items def coerce_report(payload: dict) -> SelfReviewReport: + """宽容归一化 LLM 输出。不读 recommendation 字段(引擎唯一来源,防注入操纵)。""" questions = payload.get("questions_for_authors") or [] if not isinstance(questions, list): questions = [] - recommendation = str(payload.get("recommendation") or "").strip().lower() - if recommendation not in RECOMMENDATIONS: - recommendation = "major_revision" return SelfReviewReport( summary=str(payload.get("summary") or "").strip(), major_weaknesses=_coerce_items(payload.get("major_weaknesses")), minor_weaknesses=_coerce_items(payload.get("minor_weaknesses")), presentation_format=_coerce_items(payload.get("presentation_format")), questions_for_authors=[str(q).strip() for q in questions if str(q).strip()], - recommendation=recommendation, ) diff --git a/review_service/data/journals/journals.json b/review_service/data/journals/journals.json index fa98908..5c804c9 100644 --- a/review_service/data/journals/journals.json +++ b/review_service/data/journals/journals.json @@ -1,7 +1,7 @@ { "version": 1, "updated": "2026-10-10", - "notes": "植物基因组学领域种子期刊。acceptance_rate 无公开权威数据一律为 null(不编造);impact_factor / review_time_weeks 为公开 JCR/期刊官网常识近似值,以 last_updated 标注刷新时间,接入真实数据源前需人工核验。scope_desc 为英文范围描述,供 vstore 向量匹配使用。 format_rules_default 为通用默认规则(rules_source=default);精选 5 本 format_rules 已于 2026-10-10 按各刊官网投稿指南人工核验填入(rules_source=official,附 source_url / last_verified / notes),官网未规定的字段一律 null 不猜测。 新增标签字段(不进 SQLite,接口按 id 从本文件合并):scope_tags / application_tags / cross_domain 按 scope_desc 归类的标签;cas_partition / jcr_quartile 为占位(partition_source=placeholder),核实不确定的一律 null、不猜测,接入真实分区数据前需人工核验。 精选列表已更换为本环境可访问官网的 5 本(Genome Biology / BMC Plant Biology / Molecular Plant / Frontiers in Plant Science / Rice);原 OUP/Wiley/Springer 4 本(The Plant Cell / The Plant Journal / Theoretical and Applied Genetics / The Plant Genome)移出精选,仍保留在 journal_directory 1056 条全量目录中。", + "notes": "植物基因组学领域种子期刊。acceptance_rate 无公开权威数据一律为 null(不编造);impact_factor / review_time_weeks 为公开 JCR/期刊官网常识近似值,以 last_updated 标注刷新时间,接入真实数据源前需人工核验。scope_desc 为英文范围描述,供 vstore 向量匹配使用。 format_rules_default 为通用默认规则(rules_source=default);精选 5 本 format_rules 已于 2026-10-10 按各刊官网投稿指南人工核验填入(rules_source=official,附 source_url / last_verified / notes),官网未规定的字段一律 null 不猜测。 新增标签字段(不进 SQLite,接口按 id 从本文件合并):scope_tags / application_tags / cross_domain 按 scope_desc 归类的标签;cas_partition / jcr_quartile 为占位(partition_source=placeholder),核实不确定的一律 null、不猜测,接入真实分区数据前需人工核验。 精选列表已更换为本环境可访问官网的 5 本(Genome Biology / BMC Plant Biology / Molecular Plant / Frontiers in Plant Science / Rice);原 OUP/Wiley/Springer 4 本(The Plant Cell / The Plant Journal / Theoretical and Applied Genetics / The Plant Genome)移出精选,仍保留在 journal_directory 1056 条全量目录中。 [2026-10-12] 5 本精选期刊新增 review_tier(1=顶刊/2=主流,供 presubmit recommendation 分档判定;匹配失败默认 tier 2)。", "format_rules_default": { "rules_source": "default", "max_pages": 10, @@ -27,6 +27,7 @@ "application_tags": ["基础研究", "算法与方法"], "cas_partition": "1区", "jcr_quartile": "Q1", + "review_tier": 1, "partition_source": "placeholder", "cross_domain": ["进化生物学", "单细胞组学"], "format_rules": { @@ -58,6 +59,7 @@ "application_tags": ["基础研究"], "cas_partition": null, "jcr_quartile": null, + "review_tier": 2, "partition_source": "placeholder", "cross_domain": ["植物生物技术"], "format_rules": { @@ -89,6 +91,7 @@ "application_tags": ["基础研究", "分子育种"], "cas_partition": null, "jcr_quartile": null, + "review_tier": 1, "partition_source": "placeholder", "cross_domain": ["植物生物技术"], "format_rules": { @@ -120,6 +123,7 @@ "application_tags": ["基础研究"], "cas_partition": null, "jcr_quartile": null, + "review_tier": 2, "partition_source": "placeholder", "cross_domain": ["农业科学"], "format_rules": { @@ -151,6 +155,7 @@ "application_tags": ["基础研究", "分子育种", "种业"], "cas_partition": null, "jcr_quartile": null, + "review_tier": 2, "partition_source": "placeholder", "cross_domain": ["作物科学"], "format_rules": { diff --git a/review_service/tests/test_presubmit.py b/review_service/tests/test_presubmit.py index 5d4a1de..c96b4c7 100644 --- a/review_service/tests/test_presubmit.py +++ b/review_service/tests/test_presubmit.py @@ -1,6 +1,7 @@ """tests/test_presubmit.py —— presubmit 离线单元测试(mock 解析与 LLM,不依赖上游)。 -覆盖:非 PDF 校验 / 报告归一化 / LLM 失败降级 / 解析失败降级 / 非法 recommendation 回退。 +覆盖:非 PDF 校验 / 报告归一化 / LLM 失败降级 / 解析失败降级 / recommendation 规则 +引擎(R0-R5 + tier 分档 + rationale 可解释 + LLM recommendation 注入被忽略)。 运行: cd review_service && /bin/python -m pytest -q """ from __future__ import annotations @@ -10,6 +11,7 @@ from fastapi.testclient import TestClient from app.main import app from app.services import llm, paper_parse +from app.services.recommend_engine import decide_recommendation FAKE_PDF = b"%PDF-1.4 fake-bytes-for-test" @@ -21,6 +23,8 @@ LLM_PAYLOAD = { "detail": "未做多重检验校正", "evidence": "Methods 2.1 仅报 p<0.05,未见 FDR/Bonferroni", "suggestion": "补充 FDR 校正", + "hard_rule": None, + "fatal": False, }, "纯字符串条目", # 宽容归一化:字符串 → WeaknessItem(title=...) ], @@ -28,7 +32,7 @@ LLM_PAYLOAD = { "minor_weaknesses": [{"detail": "缺少缩写表", "title": "缩写"}], "presentation_format": [{"title": "图注不完整", "detail": "Fig.2 无单位", "suggestion": "补单位"}], "questions_for_authors": ["群体结构如何校正?", ""], - "recommendation": "MAJOR_REVISION", # 应归一化为小写 + "recommendation": "MAJOR_REVISION", # 引擎忽略此字段(防注入);归一化不再读取 } @@ -88,8 +92,14 @@ def test_normal_report_normalized(client: TestClient): body = _post(client, "p.pdf", FAKE_PDF, journal="Plant Cell").json() assert body["ok"] is True report = body["report"] - # recommendation 大写 → 小写 + # 2 条 soft major(无硬性标签)→ 引擎 R2 → major_revision(与 LLM 是否输出无关) assert report["recommendation"] == "major_revision" + # rationale:规则依据 + tier 字段 + explanation(用例 10) + rat = report["recommendation_rationale"] + assert rat["rule"] == "R2" + assert rat["major_count"] == 2 and rat["hard_count"] == 0 and rat["soft_count"] == 2 + assert rat["tier"] == 2 and rat["tier_label"] == "主流期刊" + assert rat["explanation"] # 字符串条目宽容归一化 assert len(report["major_weaknesses"]) == 2 assert report["major_weaknesses"][1]["title"] == "纯字符串条目" @@ -100,10 +110,12 @@ def test_normal_report_normalized(client: TestClient): # 空问题剔除 assert report["questions_for_authors"] == ["群体结构如何校正?"] assert report["minor_weaknesses"] and report["presentation_format"] and report["summary"] - # meta 透传 + # meta 透传 + tier 信息 assert body["meta"]["target_journal"] == "Plant Cell" assert body["meta"]["text_chars"] > 0 assert body["meta"]["truncated"] is False + assert body["meta"]["tier"] == 2 # Plant Cell 不在精选分档表 → 默认主流档 + assert body["meta"]["tier_source"] == "默认" def test_llm_failure_degrades(client: TestClient, monkeypatch): @@ -128,15 +140,153 @@ def test_parse_failure_degrades(client: TestClient, monkeypatch): assert body["error"]["degraded"] is True -def test_invalid_recommendation_falls_back(client: TestClient, monkeypatch): - async def weird(*args, **kwargs): - return {"summary": "x", "recommendation": "strong_accept", "major_weaknesses": "not-a-list"} +def test_llm_recommendation_injection_ignored(client: TestClient, monkeypatch): + """LLM 擅自输出 recommendation:"accept" → 引擎不读该字段,按条数判定(用例 8)。""" + async def evil(*args, **kwargs): + return { + "summary": "x", + "recommendation": "accept", # 注入企图 + "major_weaknesses": [ + {"title": "M1", "evidence": "Methods"}, + {"title": "M2", "evidence": "Results"}, + ], + "minor_weaknesses": [], + } - monkeypatch.setattr(llm, "complete_json", weird) + monkeypatch.setattr(llm, "complete_json", evil) body = _post(client, "p.pdf", FAKE_PDF).json() assert body["ok"] is True - assert body["report"]["recommendation"] == "major_revision" - assert body["report"]["major_weaknesses"] == [] + assert body["report"]["recommendation"] == "major_revision" # 2 soft → R2 + + +def test_empty_llm_payload_engine_accept(client: TestClient, monkeypatch): + """LLM 漏输出各 weakness 字段 → 不崩,引擎按 0/0/0 给 accept(用例 9)。""" + async def bare(*args, **kwargs): + return {"summary": "x"} + + monkeypatch.setattr(llm, "complete_json", bare) + body = _post(client, "p.pdf", FAKE_PDF).json() + assert body["ok"] is True + assert body["report"]["recommendation"] == "accept" + assert body["report"]["recommendation_rationale"]["rule"] == "R4" + + +# ================================================================ +# recommendation 规则引擎(离线:mock LLM payload / 直调引擎) +# ================================================================ + +def _post_payload(client: TestClient, monkeypatch, payload: dict, journal: str = ""): + async def fake(*args, **kwargs): + return dict(payload) + + monkeypatch.setattr(llm, "complete_json", fake) + return _post(client, "p.pdf", FAKE_PDF, journal=journal).json() + + +def test_strong_payload_not_major(client: TestClient, monkeypatch): + """用例 1:强稿形态 0 major / 4 minor → minor_revision(诊断回归:不得再判大修)。""" + body = _post_payload(client, monkeypatch, { + "summary": "完备稿", + "major_weaknesses": [], + "minor_weaknesses": [{"title": f"m{i}"} for i in range(4)], + }) + assert body["ok"] is True + assert body["report"]["recommendation"] == "minor_revision" + assert body["report"]["recommendation_rationale"]["rule"] == "R5" + + +def test_weak_payload_major(client: TestClient, monkeypatch): + """用例 2:弱稿形态 7 major(含 2 硬性标签)→ major_revision + hard_hits。""" + body = _post_payload(client, monkeypatch, { + "summary": "缺项稿", + "major_weaknesses": [ + {"title": "缺 BUSCO", "evidence": "Results 仅 N50", "hard_rule": "busco_missing"}, + {"title": "缺 Accession", "evidence": "Data Availability 未见", "hard_rule": "accession_missing"}, + *[{"title": f"s{i}", "evidence": "Methods"} for i in range(5)], + ], + "minor_weaknesses": [{"title": "m1"}], + }) + report = body["report"] + assert report["recommendation"] == "major_revision" + rat = report["recommendation_rationale"] + assert rat["rule"] == "R1" + assert set(rat["hard_hits"]) == {"busco_missing", "accession_missing"} + assert rat["hard_count"] == 2 and rat["soft_count"] == 5 + + +def test_hard_rule_whitelist_stripped(client: TestClient, monkeypatch): + """LLM 编造白名单外 hard_rule 标签 → coerce 丢弃,不得操纵引擎。""" + body = _post_payload(client, monkeypatch, { + "summary": "x", + "major_weaknesses": [ + {"title": "m1", "evidence": "Methods", "hard_rule": "made_up_tag"}, + ], + "minor_weaknesses": [], + }) + report = body["report"] + assert report["major_weaknesses"][0]["hard_rule"] is None + assert report["recommendation"] == "major_revision" # 按 soft 计 → R2 + assert report["recommendation_rationale"]["hard_hits"] == [] + + +def test_fatal_reject(client: TestClient, monkeypatch): + """用例 6:fatal=true → reject(R0),rationale 提示人工复核。""" + body = _post_payload(client, monkeypatch, { + "summary": "疑似造假", + "major_weaknesses": [{"title": "数据矛盾", "evidence": "Table 2 vs Fig.3", "fatal": True}], + }) + assert body["report"]["recommendation"] == "reject" + assert body["report"]["recommendation_rationale"]["rule"] == "R0" + assert "人工复核" in body["report"]["recommendation_rationale"]["explanation"] + + +def test_tier_matching_journals_json(client: TestClient, monkeypatch): + """精选期刊名匹配 journals.json review_tier;Genome Biology → tier 1。""" + body = _post_payload(client, monkeypatch, { + "summary": "x", + "major_weaknesses": [], + "minor_weaknesses": [{"title": "a"}], + }, journal="Genome Biology") + assert body["meta"]["tier"] == 1 + assert body["meta"]["tier_label"] == "顶刊" + assert body["meta"]["tier_source"] == "genome biology" + # tier1 accept 门槛更紧:1 minor 仍 accept(≤1) + assert body["report"]["recommendation"] == "accept" + + +# ---- 引擎直调(tier 分档与 rationale 分支)---- + +def test_engine_tier3_soft_major_downgrade(): + """用例 7:2 条 soft major——tier3 降级 minor_revision;tier2 仍 major_revision。""" + majors = [{"title": "m", "hard_rule": None, "fatal": False}] * 2 + d3 = decide_recommendation(majors, [], [], tier=3) + assert d3["recommendation"] == "minor_revision" + assert d3["rationale"]["rule"] == "R2'" + d2 = decide_recommendation(majors, [], [], tier=2) + assert d2["recommendation"] == "major_revision" + assert d2["rationale"]["rule"] == "R2" + + +def test_engine_tier_note_and_accept_thresholds(): + """用例 10:rationale 含 tier 说明句;tier1 accept 门槛 minor≤1。""" + one_soft = [{"title": "m"}] + d = decide_recommendation(one_soft, [], [], tier=2) + assert "tier 3 入门刊此稿可能判 minor_revision" in d["rationale"]["explanation"] + assert d["rationale"]["tier"] == 2 + # tier1:1 minor → accept;2 minor → minor_revision + assert decide_recommendation([], [{"title": "a"}], [], tier=1)["recommendation"] == "accept" + assert decide_recommendation([], [{"title": "a"}, {"title": "b"}], [], tier=1)["recommendation"] == "minor_revision" + # tier3:3 minor 仍 accept(≤3) + assert decide_recommendation([], [{"title": "a"}, {"title": "b"}, {"title": "c"}], [], tier=3)["recommendation"] == "accept" + + +def test_engine_hard_never_downgraded_by_tier(): + """硬性 major 不因 tier3 降级(不可协商)。""" + d = decide_recommendation( + [{"title": "缺 accession", "hard_rule": "accession_missing", "fatal": False}], + [], [], tier=3) + assert d["recommendation"] == "major_revision" + assert d["rationale"]["rule"] == "R1" def test_domain_system_prompt_wired(client: TestClient, monkeypatch): diff --git a/tests/frontend/unit/edu/presubmit.rationale.test.js b/tests/frontend/unit/edu/presubmit.rationale.test.js new file mode 100644 index 0000000..7af9021 --- /dev/null +++ b/tests/frontend/unit/edu/presubmit.rationale.test.js @@ -0,0 +1,116 @@ +// tests/frontend/unit/edu/presubmit.rationale.test.js —— PresubmitPanel 判定 rationale/档位/fatal 展示(Dev-E 判定链重构配套) +// 2026-10-12:recommendation 改由规则引擎计算后新增展示—— +// 1) rationale.explanation + 硬性 hard_hits chips +// 2) 判定档位徽标(tier + 期刊名;同稿投不同 tier 结果不同须显式告知) +// 3) reject → "建议人工复核"红标 + 顶部 AI 免责行(fatal 展示克制) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import PresubmitPanel from '../../../../ui/src/components/edu/panels/PresubmitPanel.vue'; + +vi.mock('../../../../ui/src/api/review.js', () => ({ + getJournals: vi.fn(), + getDirectory: vi.fn(), + getConferences: vi.fn(), + presubmit: vi.fn(), + recommend: vi.fn(), + formatCheck: vi.fn(), + formatErrorMessage: vi.fn(), + reformatTex: vi.fn(), + compileTex: vi.fn(), + reviewErrorMessage: vi.fn((e) => (e && e.message) || 'err'), +})); + +import { useEduStore } from '../../../../ui/src/stores/edu.js'; + +function makeReportPayload(over = {}) { + return { + ok: true, + report: { + summary: '总体尚可。', + major_weaknesses: [ + { + title: '缺 BUSCO 完整度', + detail: '仅报 N50', + evidence: 'Results', + suggestion: '补 BUSCO', + hard_rule: 'busco_missing', + fatal: false, + }, + ], + minor_weaknesses: [{ title: '缩写表', detail: '', evidence: '', suggestion: '' }], + presentation_format: [], + questions_for_authors: [], + recommendation: 'major_revision', + recommendation_rationale: { + rule: 'R1', + hard_hits: ['busco_missing'], + major_count: 1, hard_count: 1, soft_count: 0, minor_count: 1, + tier: 1, tier_label: '顶刊', + explanation: '命中硬性缺陷(BUSCO 完整度缺失)→ 大修(不可协商);另有大修级 0 条、小修级 1 条。', + }, + ...over.report, + }, + meta: { + filename: 'paper.pdf', target_journal: 'Genome Biology', + text_chars: 100, truncated: false, model: 'deepseek', + tier: 1, tier_label: '顶刊', tier_source: 'genome biology', + ...over.meta, + }, + }; +} + +describe('PresubmitPanel 判定 rationale / 档位 / fatal 克制展示', () => { + let store; + let pinia; + + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + store = useEduStore(); + }); + + it('展示 rationale.explanation 与硬性 hard_hits chips', () => { + store.report = makeReportPayload(); + const wrapper = mount(PresubmitPanel, { global: { plugins: [pinia] } }); + expect(wrapper.find('.pp-rationale-text').text()).toContain('命中硬性缺陷'); + expect(wrapper.find('.pp-rationale-text').text()).toContain('BUSCO 完整度缺失'); + const chips = wrapper.findAll('.pp-hard-chip'); + expect(chips.length).toBe(1); + expect(chips[0].text()).toBe('BUSCO 完整度缺失'); + // major 卡片上的"硬性"徽标 + expect(wrapper.find('.pp-hard-badge').exists()).toBe(true); + expect(wrapper.find('.pp-hard-badge').text()).toBe('硬性'); + }); + + it('判定档位徽标:期刊名 + tier + 档位名', () => { + store.report = makeReportPayload(); + const wrapper = mount(PresubmitPanel, { global: { plugins: [pinia] } }); + expect(wrapper.find('.pp-tier').text()).toBe('判定档位:Genome Biology(tier 1 顶刊)'); + }); + + it('reject:红标"建议人工复核" + 顶部 AI 免责行(fatal 克制展示)', () => { + store.report = makeReportPayload({ + report: { + recommendation: 'reject', + recommendation_rationale: { + rule: 'R0', hard_hits: [], major_count: 1, hard_count: 0, soft_count: 1, + minor_count: 0, tier: 2, tier_label: '主流期刊', + explanation: '存在 fatal 级问题(数据可靠性/伦理,须人工复核)→ 建议拒稿。', + }, + }, + }); + const wrapper = mount(PresubmitPanel, { global: { plugins: [pinia] } }); + expect(wrapper.find('.pp-rec').text()).toBe('拒稿'); + expect(wrapper.find('.pp-rec-warn').text()).toBe('建议人工复核'); + expect(wrapper.find('.pp-ai-note').text()).toContain('fatal 级判定请务必人工核对'); + }); + + it('无 reject 时不显示"建议人工复核"红标', () => { + store.report = makeReportPayload(); + const wrapper = mount(PresubmitPanel, { global: { plugins: [pinia] } }); + expect(wrapper.find('.pp-rec-warn').exists()).toBe(false); + // AI 免责行始终展示 + expect(wrapper.find('.pp-ai-note').exists()).toBe(true); + }); +}); diff --git a/ui/src/components/edu/panels/PresubmitPanel.vue b/ui/src/components/edu/panels/PresubmitPanel.vue index 308c0a2..52299dc 100644 --- a/ui/src/components/edu/panels/PresubmitPanel.vue +++ b/ui/src/components/edu/panels/PresubmitPanel.vue @@ -2,8 +2,11 @@ 链路:本地 PDF(form-data)→ edu.submitPresubmit(fd, {onUploadProgress}) → POST /api/review/presubmit(file + target_journal;≤50MB,vstore 解析 + 六维 LLM) → {ok, report{summary, major/minor_weaknesses[], presentation_format[], - questions_for_authors[], recommendation}, meta{filename,target_journal,text_chars,truncated,model}} - (真实响应结构写入前已 curl 8100 实测;self_review.py 六维 prompt 不动,本组件纯展示)。 + questions_for_authors[], recommendation(规则引擎计算), recommendation_rationale}, + meta{...,tier,tier_label,tier_source}} + 判定链(2026-10-12):LLM 只列 weakness(major 带 hard_rule/fatal 标签), + recommendation 由 recommend_engine 规则引擎计算并附 rationale(可解释); + fatal → reject 红标 + "建议人工复核"(克制展示,须人工核对)。 优先级:edu.report 存在 → 结果区(不退回上传区);否则 loading > error > 上传区。 结果/targetJournal 草稿存 store 跨菜单保留;PDF 文件为面板本地 ref(切菜单需重选)。 http.js FormData+onUploadProgress → XHR 分支,上传阶段显示真实百分比。 --> @@ -20,6 +23,13 @@ const REC = { major_revision: { label: '大修', cls: 'major' }, reject: { label: '拒稿', cls: 'reject' }, }; +const HARD_RULE_LABELS = { + busco_missing: 'BUSCO 完整度缺失', + hic_missing: 'Hi-C 挂载率缺失', + accession_missing: '数据 Accession 缺失', + voucher_missing: '凭证标本缺失', + abs_missing: 'ABS/名古屋合规声明缺失', +}; const fileInput = ref(null); const file = ref(null); // File 面板本地(不进 store) @@ -33,6 +43,22 @@ const errored = computed(() => edu.reportStatus === 'error'); const report = computed(() => edu.report?.report || null); const meta = computed(() => edu.report?.meta || null); const rec = computed(() => REC[report.value?.recommendation] || REC.major_revision); +const rationale = computed(() => report.value?.recommendation_rationale || null); +const isReject = computed(() => report.value?.recommendation === 'reject'); +/** 判定档位提示:同一论文投不同 tier 结果可能不同,必须显式告知 */ +const tierText = computed(() => { + const r = rationale.value; + const m = meta.value; + const tier = r?.tier ?? m?.tier; + if (!tier) return ''; + const label = r?.tier_label || m?.tier_label || ''; + const journal = (m?.target_journal || '').trim(); + const name = journal || (m?.tier_source && m.tier_source !== '默认' ? m.tier_source : '未指定期刊'); + return `判定档位:${name}(tier ${tier} ${label})`; +}); +const hardHits = computed(() => + (rationale.value?.hard_hits || []).map((t) => HARD_RULE_LABELS[t] || t) +); /** 上传阶段(pct<1)显示真实百分比;上传完成后是解析 + LLM 长等待 */ const phaseText = computed(() => @@ -110,11 +136,24 @@ function reset() { · 正文超限已截断至 6 万字符 +

本判定由 AI 生成,仅供投稿前参考;fatal 级判定请务必人工核对后再决定。

+
{{ rec.label }} + 建议人工复核 + {{ tierText }}
+ +
+
判定依据
+

{{ rationale.explanation }}

+
+ {{ h }} +
+
+

{{ report.summary }}

@@ -126,6 +165,8 @@ function reset() { >
{{ i + 1 }}{{ it.title }} + 硬性 + fatal

{{ it.detail }}

@@ -301,6 +342,81 @@ function reset() { .pp-rec.minor { color: var(--pa-info); } .pp-rec.major { color: var(--pa-warning); } .pp-rec.reject { color: var(--pa-error); } +.pp-ai-note { + margin: 0; + padding: 6px 10px; + border: 1px dashed var(--pa-hairline); + border-radius: var(--pa-r-sm); + font-size: 12px; + color: var(--pa-ink-subtle); +} +.pp-rec-warn { + flex: none; + padding: 2px 8px; + border-radius: var(--pa-r-sm); + background: var(--pa-error); + color: #fff; + font-size: 11px; + font-weight: 600; +} +.pp-tier { + flex: 1; + font-size: 12px; + color: var(--pa-ink-muted); +} +.pp-rationale { + padding: 10px 12px; + background: var(--pa-surface-1); + border: 1px solid var(--pa-hairline); + border-radius: var(--pa-r-md); +} +.pp-rationale-head { + font-size: 12px; + font-weight: 600; + color: var(--pa-ink-muted); + margin-bottom: 4px; +} +.pp-rationale-text { + margin: 0; + font-size: 13px; + line-height: 1.65; + color: var(--pa-ink); +} +.pp-hard-hits { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.pp-hard-chip { + padding: 1px 8px; + border: 1px solid var(--pa-error); + border-radius: var(--pa-r-pill); + font-size: 11.5px; + color: var(--pa-error); +} +.pp-hard-badge { + flex: none; + margin-left: 6px; + padding: 0 6px; + border-radius: var(--pa-r-sm); + border: 1px solid var(--pa-error); + font-size: 10.5px; + font-weight: 600; + color: var(--pa-error); + line-height: 1.6; +} +.pp-fatal-badge { + flex: none; + margin-left: 4px; + padding: 0 6px; + border-radius: var(--pa-r-sm); + background: var(--pa-error); + font-size: 10.5px; + font-weight: 700; + color: #fff; + line-height: 1.6; +} .pp-again { padding: 5px 14px; border: 1px solid var(--pa-hairline); -- 2.52.0 From cf5ccb3a37eb48181082fef3981412298e01bd4a Mon Sep 17 00:00:00 2001 From: potaser Date: Mon, 12 Oct 2026 16:38:28 +0800 Subject: [PATCH 2/4] feat(review): presubmit 4-bug fix + rebuttal Phase 1 backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4 bugs: truncation window / keyword scanner / tier heuristic / vocab - Scanner conflict demotion now truly affects recommendation (major → minor) - Rebuttal Phase 1: store / parse / draft / 10 endpoints - max_text_chars 150K + token guard + 400/413 retry - Journals tier override (13 journals) + jif heuristic (exclude reviews) --- review_service/app/config.py | 3 +- review_service/app/main.py | 8 + review_service/app/routers/presubmit.py | 198 +++++++++++- review_service/app/routers/rebuttal.py | 288 +++++++++++++++++ review_service/app/services/paper_scanner.py | 226 +++++++++++++ review_service/app/services/rebuttal_draft.py | 153 +++++++++ review_service/app/services/rebuttal_parse.py | 207 ++++++++++++ review_service/app/services/rebuttal_store.py | 162 ++++++++++ .../app/services/recommend_engine.py | 70 ++++- review_service/app/services/self_review.py | 50 ++- review_service/tests/test_presubmit.py | 296 +++++++++++++++++- review_service/tests/test_rebuttal.py | 198 ++++++++++++ 12 files changed, 1821 insertions(+), 38 deletions(-) create mode 100644 review_service/app/routers/rebuttal.py create mode 100644 review_service/app/services/paper_scanner.py create mode 100644 review_service/app/services/rebuttal_draft.py create mode 100644 review_service/app/services/rebuttal_parse.py create mode 100644 review_service/app/services/rebuttal_store.py create mode 100644 review_service/tests/test_rebuttal.py diff --git a/review_service/app/config.py b/review_service/app/config.py index 74c14b6..6485685 100644 --- a/review_service/app/config.py +++ b/review_service/app/config.py @@ -36,7 +36,8 @@ class Settings(BaseSettings): # ---- 自审稿(presubmit)---- presubmit_read_timeout: float = 360.0 # LLM 长调用读超时(秒) - max_text_chars: int = 60000 # 送 LLM 的正文截断上限(约 3 万汉字/6 万字符) + max_text_chars: int = 150000 # 送 LLM 的正文截断上限(REVIEW_MAX_TEXT_CHARS 覆盖; + # token 超 48K 自动降 100K,LLM 400/413 再降 80K 重试) max_pdf_bytes: int = 50 * 1024 * 1024 # 上传 PDF 上限 50MB(对齐 extraction FR-EXTRACT-004) # ---- 期刊情报与投稿推荐(recommend)---- diff --git a/review_service/app/main.py b/review_service/app/main.py index 6d7eca0..6d3b2c6 100644 --- a/review_service/app/main.py +++ b/review_service/app/main.py @@ -25,6 +25,7 @@ from .routers import ( journals, presubmit, reformat, + rebuttal, ) logging.basicConfig(level=logging.INFO) @@ -48,6 +49,12 @@ def create_app() -> FastAPI: # journals 推荐依赖独立 SQLite:启动即建表(幂等;测试可先 set_path 注入 # tmp 路径,本函数沿用注入路径) await init_db() + # rebuttal Phase 1:独立表,启动幂等建表(自含连接,不走 db.py) + try: + from .services.rebuttal_store import ensure_table + ensure_table() + except Exception: # noqa: BLE001 + logger.warning("rebuttal 建表失败(忽略)", exc_info=True) # 编译产物过期清扫:启动跑一次(失败不阻塞启动) try: from .services.latex_compile import sweep_expired @@ -104,6 +111,7 @@ def create_app() -> FastAPI: } app.include_router(presubmit.router) + app.include_router(rebuttal.router) app.include_router(journals.router) app.include_router(format_check.router) app.include_router(conferences.router) diff --git a/review_service/app/routers/presubmit.py b/review_service/app/routers/presubmit.py index 2285aff..b23efde 100644 --- a/review_service/app/routers/presubmit.py +++ b/review_service/app/routers/presubmit.py @@ -1,24 +1,147 @@ """routers/presubmit.py —— 投稿前自审稿(POST /api/review/presubmit)。 -流程:上传 PDF → paper_parse(vstore 8094 解析)→ LLM 结构化审稿 -(DeepSeek json_object,System Prompt 为植物基因组学六维专属清单, -见 services/self_review.py)→ 归一化为 SelfReviewReport。 +流程:上传 PDF → paper_parse(vstore 8094 解析)→ 段落感知装箱 + token 双层保护 +→ LLM 结构化审稿(DeepSeek json_object,System Prompt 为植物基因组学六维专属 +清单,见 services/self_review.py)→ coerce 归一化 → 关键词扫描器证伪硬性缺项 +(services/paper_scanner.py)→ 规则引擎计算 recommendation。 """ from __future__ import annotations import logging +import re from fastapi import APIRouter, File, Form, UploadFile from pydantic import BaseModel from ..config import get_settings -from ..services import llm, paper_parse, self_review +from ..services import llm, paper_parse, paper_scanner, self_review from ..services.recommend_engine import decide_recommendation, resolve_tier logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/review", tags=["review-presubmit"]) +# ---- token 双层保护(2026-10-12)---- +# 第一层:估算 token 超阈值 → 自动降到 TOKEN_GUARD_REPACK_CHARS 字符重装箱 +# 第二层:LLM 返回 HTTP 400/413 → 降到 LLM_REJECT_RETRY_CHARS 字符重试一次 +_EST_TOKENS_DIVISOR = 1.5 # 粗略上界:英文 ~4 字符/token,1.5 偏保守 +_TOKEN_GUARD_THRESHOLD = 48000 +_TOKEN_GUARD_REPACK_CHARS = 100000 +_LLM_REJECT_RETRY_CHARS = 80000 +# 无节标题可辨时的尾窗(捞页尾 Data Availability 块) +_TAIL_FALLBACK_CHARS = 5000 + +# 节标题(行首、允许编号前缀、独占一行);键 → 装箱分组名 +_SECTION_HEADER_RE = re.compile( + r"^[ \t]*(?:\d+(?:\.\d+)*\.?\s+)?" + r"(Abstract|Summary|Introduction|Results|Discussion|Methods" + r"|Materials\s+and\s+Methods" + r"|Data\s+(?:and\s+(?:code\s+)?)?[Aa]vailability" + r"|Availability\s+of\s+[Dd]ata" + r"|Data\s+and\s+[Mm]aterials\s+[Aa]vailability" + r"|References|Acknowledg(?:e)?ments?)\s*$", + re.IGNORECASE | re.MULTILINE, +) +_SECTION_GROUP = { + "abstract": "abstract", "summary": "abstract", + "introduction": "introduction", + "results": "results", + "discussion": "discussion", + "methods": "methods", "materials and methods": "methods", + "data availability": "data_availability", + "data and code availability": "data_availability", + "data availability statement": "data_availability", + "availability of data": "data_availability", + "data and materials availability": "data_availability", + "references": "references", "acknowledgements": "acknowledgements", + "acknowledgments": "acknowledgements", +} +# 装箱优先级(首条命中优先;methods 限 60% 预算——六维清单大量依赖 Methods) +_PACK_PRIORITY = ("abstract", "data_availability", "methods", "results", "discussion") +_METHODS_CAP_RATIO = 0.6 + + +def estimate_tokens(text: str) -> int: + """粗略 token 上界估计(len/1.5),无需 tokenizer 依赖。""" + return int(len(text) / _EST_TOKENS_DIVISOR) + + +def _split_segments(text: str) -> list[tuple[str, str]] | None: + """按节标题切段 → [(group, content)...]。无可辨标题 → None。""" + matches = list(_SECTION_HEADER_RE.finditer(text)) + if not matches: + return None + segments: list[tuple[str, str]] = [] + if matches[0].start() > 0: + segments.append(("preamble", text[:matches[0].start()])) + for i, m in enumerate(matches): + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + group = _SECTION_GROUP.get(m.group(1).casefold(), m.group(1).casefold()) + segments.append((group, text[m.start():end])) + return segments + + +def pack_feed_text(text: str, budget: int) -> tuple[str, list[str]]: + """段落感知装箱 → (feed_text, dropped_sections)。 + + 预算内:原文返回。超预算:按优先级保留 + ① Abstract ② Data Availability 全文 ③ Methods 前 60% ④ Results + ⑤ Discussion,其余按原序填满;未收段记入 dropped。 + 无节标题可辨 → 头 + 尾窗拼接(捞页尾 availability 块),不优于现状亦不劣于。 + """ + if len(text) <= budget: + return text, [] + segments = _split_segments(text) + if not segments: + sep = "\n[...]\n" + head = max(0, budget - _TAIL_FALLBACK_CHARS - len(sep)) + return ( + text[:head] + sep + text[-_TAIL_FALLBACK_CHARS:], + ["[无节标题回退:保留头+尾窗]"], + ) + methods_cap = int(budget * _METHODS_CAP_RATIO) + chosen: dict[int, str] = {} + used = 0 + for group in _PACK_PRIORITY: + for i, (g, content) in enumerate(segments): + if g != group or i in chosen: + continue + room = budget - used + if room <= 0: + break + piece = content[:methods_cap] if group == "methods" else content + if len(piece) > room: + piece = piece[:room] + if not piece: + break + chosen[i] = piece + used += len(piece) + break + for i, (_, content) in enumerate(segments): + if i in chosen or used >= budget: + continue + room = budget - used + chosen[i] = content if len(content) <= room else content[:room] + used += len(chosen[i]) + feed = "".join(chosen[i] for i in sorted(chosen)) + dropped: list[str] = [] + for i, (g, _) in enumerate(segments): + if i not in chosen and g not in dropped: + dropped.append(g) + return feed, dropped + + +def _fit_feed_text(text: str, budget: int) -> tuple[str, list[str]]: + """装箱 + 第一层 token 保护(估算超阈值自动降到 100K 字符重装)。""" + feed, dropped = pack_feed_text(text, budget) + if estimate_tokens(feed) > _TOKEN_GUARD_THRESHOLD and len(feed) > _TOKEN_GUARD_REPACK_CHARS: + logger.info( + "presubmit: token 估算 %d > %d,降到 %d 字符重装箱", + estimate_tokens(feed), _TOKEN_GUARD_THRESHOLD, _TOKEN_GUARD_REPACK_CHARS, + ) + feed, dropped = pack_feed_text(text, _TOKEN_GUARD_REPACK_CHARS) + return feed, dropped + class PresubmitResponse(BaseModel): ok: bool @@ -59,32 +182,71 @@ async def presubmit_review( return _error("PARSE_FAILED", str(exc), degraded=True) truncated = len(text) > settings.max_text_chars - feed_text = text[: settings.max_text_chars] + feed_text, dropped_sections = _fit_feed_text(text, settings.max_text_chars) # ---- LLM 结构化自审(植物基因组学六维清单)---- + # 第二层保护:HTTP 400/413(疑似超长/形态拒绝)→ 降 80K 字符重试一次 + user_prompt = self_review.build_user_prompt(feed_text, (target_journal or "").strip()) try: - payload = await llm.complete_json( - self_review.SYSTEM_PROMPT, - self_review.build_user_prompt(feed_text, (target_journal or "").strip()), - temperature=0.2, - max_tokens=8000, - timeout=settings.presubmit_read_timeout, - ) + try: + payload = await llm.complete_json( + self_review.SYSTEM_PROMPT, + user_prompt, + temperature=0.2, + max_tokens=8000, + timeout=settings.presubmit_read_timeout, + ) + except llm.LLMError as exc: + msg = str(exc) + if ("HTTP 400" in msg or "HTTP 413" in msg) and len(feed_text) > _LLM_REJECT_RETRY_CHARS: + logger.warning("presubmit: LLM 拒绝输入(%s),降到 %d 字符重试", + msg[:120], _LLM_REJECT_RETRY_CHARS) + feed_text, dropped_sections = pack_feed_text(text, _LLM_REJECT_RETRY_CHARS) + truncated = True + payload = await llm.complete_json( + self_review.SYSTEM_PROMPT, + self_review.build_user_prompt(feed_text, (target_journal or "").strip()), + temperature=0.2, + max_tokens=8000, + timeout=settings.presubmit_read_timeout, + ) + else: + raise except llm.LLMError as exc: logger.error("presubmit: LLM 审稿失败:%s", exc) return _error("UPSTREAM_DOWN", f"审稿模型调用失败:{exc}", degraded=True) report = self_review.coerce_report(payload) + + # ---- 关键词扫描器:证伪 LLM 的硬性缺项(有 X 却报缺 X → 真降级至 minor)---- + # A2(2026-10-12):被冲突的 major 不再留在 major 列表里只剥标签,而是移出 + # major、append 到 minor(scanner_downgraded=True)——major_count/引擎输入真实 + # 减少;fatal 条目例外(只记冲突不降级,须人工过目)。 + signals = paper_scanner.scan_paper_signals(feed_text) + kept_majors, demoted_minors, scanner_conflicts = paper_scanner.reconcile_hard_rules( + report.major_weaknesses, signals + ) + report.major_weaknesses = kept_majors + if demoted_minors: + report.minor_weaknesses = list(report.minor_weaknesses) + list(demoted_minors) + # recommendation 由规则引擎计算(LLM 输出的同名字段已被 coerce 忽略) - tier, tier_matched = resolve_tier((target_journal or "").strip()) + tier, tier_source = resolve_tier((target_journal or "").strip()) decision = decide_recommendation( majors=report.major_weaknesses, minors=report.minor_weaknesses, presentations=report.presentation_format, tier=tier, ) + rationale = decision["rationale"] + rationale["scanner_conflicts"] = scanner_conflicts + if scanner_conflicts: + rationale["explanation"] += ( + f" 关键词扫描器与审稿意见存在 {len(scanner_conflicts)} 处冲突," + f"已降级为小修级问题,请人工核对。" + ) report.recommendation = decision["recommendation"] - report.recommendation_rationale = decision["rationale"] + report.recommendation_rationale = rationale return PresubmitResponse( ok=True, report=report, @@ -93,9 +255,15 @@ async def presubmit_review( "target_journal": (target_journal or "").strip(), "text_chars": len(feed_text), "truncated": truncated, + "dropped_sections": dropped_sections, + "scanner": { + "accession_found": bool((signals.get("accession") or {}).get("found")), + "busco_level": (signals.get("busco") or {}).get("level"), + "hic_found": bool((signals.get("hic") or {}).get("found")), + }, "model": llm._llm_config()["model"], "tier": tier, "tier_label": decision["rationale"]["tier_label"], - "tier_source": tier_matched or "默认", + "tier_source": tier_source, }, ) diff --git a/review_service/app/routers/rebuttal.py b/review_service/app/routers/rebuttal.py new file mode 100644 index 0000000..253899a --- /dev/null +++ b/review_service/app/routers/rebuttal.py @@ -0,0 +1,288 @@ +"""routers/rebuttal.py —— Rebuttal Phase 1(审稿回复助手,10 端点)。 + +信封与其余 review 端点一致:业务错误 = HTTP 200 + {ok:false,error:{code,message,degraded}}。 +- 文本粘贴主入口 / PDF 次要入口(PDF 经 vstore 8094 解析); +- 逐条 response → draft(校验C前置)→ confirm(后端重算 human_ack)→ letter 拼装; +- letter.md 下载门槛 = human_ack;DELETE 手动清(无 TTL,决策 5)。 +""" +from __future__ import annotations + +import logging +from typing import Any, Literal + +from fastapi import APIRouter, File, UploadFile +from fastapi.responses import PlainTextResponse +from pydantic import BaseModel, Field + +from ..config import get_settings +from ..services import llm, paper_parse +from ..services.rebuttal_draft import PART_TYPES, assemble_letter, generate_draft, validate_parts +from ..services.rebuttal_parse import parse_review_text +from ..services import rebuttal_store + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/review/rebuttal", tags=["review-rebuttal"]) + +_MAX_RAW_CHARS = 200_000 # 粘贴文本上限(审稿意见通常 <20K,保守给大) + + +# ---------- 信封 ---------- +class RebuttalResponse(BaseModel): + ok: bool + job: dict[str, Any] | None = None + error: dict[str, Any] | None = None + meta: dict[str, Any] | None = None + + +def _error(code: str, message: str) -> RebuttalResponse: + return RebuttalResponse(ok=False, error={"code": code, "message": message, "degraded": False}) + + +def _ok(job: dict[str, Any], **meta: Any) -> RebuttalResponse: + return RebuttalResponse(ok=True, job=job, meta=meta or None) + + +# ---------- 请求体 ---------- +class JobCreateBody(BaseModel): + text: str = Field(min_length=1) + + +class PartBody(BaseModel): + type: Literal["new_data", "reanalysis", "location", "clarification", "rebuttal"] + content: str = "" + location_hint: str | None = None + + +class ResponseBody(BaseModel): + parts: list[PartBody] = Field(default_factory=list) + + +class StructureBody(BaseModel): + action: Literal["merge", "split"] + # merge: comment_ids(含当前 cid,≥2 条);split: items [{title, body}] + comment_ids: list[str] | None = None + items: list[dict[str, str]] | None = None + + +class ConfirmBody(BaseModel): + confirmed: bool + + +# ---------- 内部工具 ---------- +async def _messages_llm(messages: list[dict[str, str]], *, temperature: float = 0.3) -> dict[str, Any]: + """适配 rebuttal_parse/draft 的 messages 形态 → llm.complete_json。""" + system = next((m["content"] for m in messages if m.get("role") == "system"), "") + user = next((m["content"] for m in messages if m.get("role") == "user"), "") + return await llm.complete_json(system, user, temperature=temperature) + + +def _find_comment(job: dict[str, Any], cid: str) -> tuple[dict, dict] | None: + for r in job["reviewers"]: + for c in r["comments"]: + if c["id"] == cid: + return r, c + return None + + +async def _build_job(raw_text: str, *, kind: str, filename: str | None) -> RebuttalResponse: + raw_text = (raw_text or "").strip() + if not raw_text: + return _error("VALIDATION", "审稿意见文本为空") + if len(raw_text) > _MAX_RAW_CHARS: + return _error("VALIDATION", f"文本超过 {_MAX_RAW_CHARS} 字符上限") + reviewers = await parse_review_text(raw_text, _messages_llm) + if not reviewers: + return _error("PARSE_FAILED", "未能从文本中解析出任何审稿意见条目") + job = { + "id": rebuttal_store.new_id("rbt_"), + "status": "ready", + "source": {"kind": kind, "filename": filename, "raw_text": raw_text}, + "template": "generic", + "reviewers": reviewers, + "letter_md": None, + "validation": None, + "human_ack": False, + } + rebuttal_store.insert_job(job) + n_comments = sum(len(r["comments"]) for r in reviewers) + return _ok(job, reviewers=len(reviewers), comments=n_comments) + + +# ---------- 端点 ---------- +@router.post("/jobs", response_model=RebuttalResponse) +async def create_job(body: JobCreateBody) -> RebuttalResponse: + """文本粘贴主入口。""" + return await _build_job(body.text, kind="text", filename=None) + + +@router.post("/jobs/pdf", response_model=RebuttalResponse) +async def create_job_pdf(file: UploadFile = File(...)) -> RebuttalResponse: + """PDF 次要入口(批注版审稿 PDF;经 vstore 8094 解析)。""" + settings = get_settings() + filename = file.filename or "review.pdf" + if not filename.lower().endswith(".pdf"): + return _error("VALIDATION", "仅支持 PDF 文件(推荐直接粘贴文本)") + data = await file.read() + if not data: + return _error("VALIDATION", "上传文件为空") + if len(data) > settings.max_pdf_bytes: + return _error("VALIDATION", f"PDF 超过 {settings.max_pdf_bytes // (1024 * 1024)}MB 上限") + try: + text = await paper_parse.parse_pdf_bytes(data, filename=filename) + except Exception as exc: # noqa: BLE001 + return _error("UPSTREAM_DOWN", f"PDF 解析失败:{exc}") + return await _build_job(text, kind="pdf", filename=filename) + + +@router.get("/jobs/{job_id}", response_model=RebuttalResponse) +async def get_job(job_id: str) -> RebuttalResponse: + job = rebuttal_store.get_job(job_id) + if job is None: + return _error("NOT_FOUND", f"job {job_id} 不存在或已删除") + return _ok(job) + + +@router.patch("/jobs/{job_id}/comments/{cid}/response", response_model=RebuttalResponse) +async def patch_response(job_id: str, cid: str, body: ResponseBody) -> RebuttalResponse: + job = rebuttal_store.get_job(job_id) + if job is None: + return _error("NOT_FOUND", f"job {job_id} 不存在或已删除") + found = _find_comment(job, cid) + if found is None: + return _error("NOT_FOUND", f"comment {cid} 不存在") + _, comment = found + for p in body.parts: + if p.type not in PART_TYPES: + return _error("VALIDATION", f"未知 part.type: {p.type}") + comment["response"] = {"parts": [p.model_dump() for p in body.parts]} + # 改回复 → 旧 draft/validation 作废(保守:不保留过期草稿) + comment["draft"] = None + comment["validation"] = None + job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job + return _ok(job) + + +@router.post("/jobs/{job_id}/comments/{cid}/draft", response_model=RebuttalResponse) +async def post_draft(job_id: str, cid: str) -> RebuttalResponse: + job = rebuttal_store.get_job(job_id) + if job is None: + return _error("NOT_FOUND", f"job {job_id} 不存在或已删除") + found = _find_comment(job, cid) + if found is None: + return _error("NOT_FOUND", f"comment {cid} 不存在") + _, comment = found + parts = (comment.get("response") or {}).get("parts") or [] + if not parts or not any(str(p.get("content") or "").strip() for p in parts): + # 决策 4:生成前必须填 user_response;LLM 调用次数=0 + return _error("MISSING_RESPONSE", "请先填写回复要点(user_response)再生成草稿") + validation = validate_parts(parts) + comment["validation"] = validation + if not validation["passed"]: + job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job + return RebuttalResponse( + ok=False, job=job, + error={"code": "VALIDATION_FAILED", "message": "反幻觉校验未通过", + "degraded": False}, + meta={"validation": validation}, + ) + comment["draft"] = await generate_draft(_messages_llm, comment) + job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job + return _ok(job, validation=validation) + + +@router.patch("/jobs/{job_id}/comments/{cid}/structure", response_model=RebuttalResponse) +async def patch_structure(job_id: str, cid: str, body: StructureBody) -> RebuttalResponse: + job = rebuttal_store.get_job(job_id) + if job is None: + return _error("NOT_FOUND", f"job {job_id} 不存在或已删除") + found = _find_comment(job, cid) + if found is None: + return _error("NOT_FOUND", f"comment {cid} 不存在") + reviewer, comment = found + comments = reviewer["comments"] + idx = comments.index(comment) + if body.action == "merge": + ids = body.comment_ids or [] + if cid not in ids: + ids = [cid] + ids + if len(ids) < 2: + return _error("VALIDATION", "merge 至少需要 2 条 comment") + targets = [c for c in comments if c["id"] in ids] + if len(targets) < 2: + return _error("VALIDATION", "merge 目标中有效 comment 不足 2 条") + merged_body = "\n\n".join(c["body"] for c in targets) + comment["body"] = merged_body + comment["title"] = f"{comment['title']} (+{len(targets) - 1} merged)" + reviewer["comments"] = [ + c for c in comments + if c is comment or c["id"] not in ids + ] + else: # split + items = body.items or [] + items = [i for i in items if str(i.get("body") or "").strip()] + if len(items) < 2: + return _error("VALIDATION", "split 至少需要 2 个非空子项") + group_id = comment.get("group_id") or f"g_{cid}" + new_comments = [] + for n, it in enumerate(items, start=1): + c = dict(comment) + c["id"] = rebuttal_store.new_id(f"{cid}s{n}") + c["group_id"] = group_id + c["title"] = str(it.get("title") or f"{comment['title']} ({n})") + c["body"] = str(it.get("body") or "").strip() + c["sub_items"] = [] + c["response"] = {"parts": []} + c["draft"] = None + c["validation"] = None + c["human_confirmed"] = False + new_comments.append(c) + comments[idx:idx + 1] = new_comments + job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job + return _ok(job) + + +@router.patch("/jobs/{job_id}/comments/{cid}/confirm", response_model=RebuttalResponse) +async def patch_confirm(job_id: str, cid: str, body: ConfirmBody) -> RebuttalResponse: + job = rebuttal_store.get_job(job_id) + if job is None: + return _error("NOT_FOUND", f"job {job_id} 不存在或已删除") + found = _find_comment(job, cid) + if found is None: + return _error("NOT_FOUND", f"comment {cid} 不存在") + _, comment = found + comment["human_confirmed"] = bool(body.confirmed) + # human_ack 由 store 重算(决策 4:全勾才置位,不信前端) + job = rebuttal_store.update_job(job_id, reviewers=job["reviewers"]) or job + return _ok(job, human_ack=job["human_ack"]) + + +@router.post("/jobs/{job_id}/letter", response_model=RebuttalResponse) +async def post_letter(job_id: str) -> RebuttalResponse: + job = rebuttal_store.get_job(job_id) + if job is None: + return _error("NOT_FOUND", f"job {job_id} 不存在或已删除") + job = rebuttal_store.update_job(job_id, letter_md=assemble_letter(job)) or job + return _ok(job) + + +@router.get("/jobs/{job_id}/letter.md") +async def download_letter(job_id: str) -> PlainTextResponse: + job = rebuttal_store.get_job(job_id) + if job is None: + return PlainTextResponse( + "job not found", status_code=404, media_type="text/plain; charset=utf-8") + if not job.get("human_ack"): + # 信封口径与下载端点适配:GET 下载返回纯文本,业务拒绝用 400 + 明文 + return PlainTextResponse( + "HUMAN_ACK_REQUIRED: 请逐条勾选确认后再下载 letter.md", + status_code=400, media_type="text/plain; charset=utf-8") + md = job.get("letter_md") or assemble_letter(job) + return PlainTextResponse(md, media_type="text/markdown; charset=utf-8") + + +@router.delete("/jobs/{job_id}", response_model=RebuttalResponse) +async def delete_job(job_id: str) -> RebuttalResponse: + if not rebuttal_store.delete_job(job_id): + return _error("NOT_FOUND", f"job {job_id} 不存在或已删除") + return RebuttalResponse(ok=True, job=None, meta={"deleted": job_id}) diff --git a/review_service/app/services/paper_scanner.py b/review_service/app/services/paper_scanner.py new file mode 100644 index 0000000..5da794f --- /dev/null +++ b/review_service/app/services/paper_scanner.py @@ -0,0 +1,226 @@ +"""services/paper_scanner.py —— 关键词兜底扫描器(纯 stdlib,零 LLM)。 + +背景(2026-10-12):hard_rule 标签由 LLM 输出,存在"正文明明有 accession/BUSCO/挂载率 +却报缺失"的漏检风险(真实案例 s41588 Nature Genetics:正文有 PRJCA038097 等 + +BUSCO 98.57% + 94.18% anchored,仍被判 busco/accession/hic_missing → 大修)。 +本模块做**程序层证伪**:LLM 说缺某项、扫描器在文中找到该项 → 剥掉 hard_rule +降级为非硬性 major,并在 rationale 记录冲突(前端黄色提示,请人工核对)。 + +边界(刻意保守,不引入新误判): +- 只做"证伪硬性缺项",不做"证实"——扫不到不等于真缺(可能同义表述),不升级任何条目 +- accession 只在 Data Availability 段内扫(用户指令):正文引用他人数据的 + accession 不得误认为"自己有" +""" +from __future__ import annotations + +import re +from typing import Any + +# ---- accession(只在 DA 段扫)---- +ACCESSION_RE = re.compile( + r"\b(PRJNA|PRJEB|PRJCA|SRP|ERP|DRP|CRA|SRR|ERR|DRR|GCA_|GCF_)\s?(\d[\d._]*)", + re.IGNORECASE, +) + +# ---- DA 段定位:标题多变体(行首允许编号前缀)---- +_DA_START_RE = re.compile( + r"^[ \t]*(?:\d+(?:\.\d+)*\.?\s+)?" + r"(?:Data\s+(?:and\s+(?:code\s+)?)?[Aa]vailability" + r"|Availability\s+of\s+(?:data|Data)" + r"|Data\s+and\s+[Mm]aterials\s+[Aa]vailability" + r"|Data\s+availability\s+statement)", + re.MULTILINE, +) +# DA 段结束 = 下一个一级节标题 +_NEXT_HEADER_RE = re.compile( + r"^[ \t]*(?:\d+(?:\.\d+)*\.?\s+)?" + r"(?:Abstract|Summary|Introduction|Results|Discussion|Methods" + r"|Materials\s+and\s+Methods|References|Acknowledg(?:e)?ments?" + r"|Author\s+[Ii]nformation|Rights\s+and\s+permissions|Change\s+history" + r"|Data\s+(?:and\s+(?:code\s+)?)?[Aa]vailability" + r"|Availability\s+of\s+[Dd]ata|Data\s+and\s+[Mm]aterials\s+[Aa]vailability)" + r"\s*$", + re.MULTILINE, +) +# 回退窗:DA 无行首标题时,短语出现处 + 上下文 +_DA_PHRASE_RE = re.compile(r"data\s+availability", re.IGNORECASE) +_DA_FALLBACK_WINDOW = 3000 + +# ---- BUSCO(全文)---- +_BUSCO_MENTION_RE = re.compile(r"\bBUSCO\b", re.IGNORECASE) +_BUSCO_FULL_RE = re.compile( + r"(?:\bS\s*:\s*\d|\bSingle[- ]copy)" + r".{0,200}?" + r"(?:\bD\s*:\s*\d|\bDuplicat)", + re.IGNORECASE | re.DOTALL, +) + +# ---- Hi-C 挂载率(全文)---- +HIC_RE = re.compile( + r"(anchored\s+to\s+chromosomes?" + r"|anchoring\s+rate" + r"|pseudo[- ]?chromosomes?" + r"|chromosome[- ]level" + r"|%[^.\n]{0,40}anchored)", + re.IGNORECASE, +) + +_MAX_HITS = 20 + + +def _extract_da_section(text: str) -> str: + """定位 Data Availability 段正文(标题后 → 下一个一级节标题)。 + + 无行首标题时回退:短语出现处向后 3000 字符窗。均未命中 → ""。 + """ + m = _DA_START_RE.search(text) + if m: + nxt = _NEXT_HEADER_RE.search(text, m.end()) + end = nxt.start() if nxt else min(len(text), m.end() + _DA_FALLBACK_WINDOW) + return text[m.start():end] + m2 = _DA_PHRASE_RE.search(text) + if m2: + return text[m2.start():min(len(text), m2.start() + _DA_FALLBACK_WINDOW)] + return "" + + +def _scan_accession(da_text: str) -> dict[str, Any]: + if not da_text: + return {"found": False, "hits": [], "in_da_section": False} + hits: list[str] = [] + seen: set[str] = set() + for m in ACCESSION_RE.finditer(da_text): + token = (m.group(1) + m.group(2)).upper() + if token not in seen: + seen.add(token) + hits.append(token) + if len(hits) >= _MAX_HITS: + break + return {"found": bool(hits), "hits": hits, "in_da_section": True} + + +def _scan_busco(text: str) -> dict[str, Any]: + m = _BUSCO_MENTION_RE.search(text) + if not m: + return {"level": "none", "excerpt": ""} + excerpt = text[m.start():m.start() + 200] + if _BUSCO_FULL_RE.search(text): + level = "full" + else: + level = "partial" # 只有百分比、无 S/D 分项 → prompt 已定为 minor + return {"level": level, "excerpt": excerpt} + + +def _scan_hic(text: str) -> dict[str, Any]: + m = HIC_RE.search(text) + if not m: + return {"found": False, "excerpt": ""} + start = max(0, m.start() - 40) + return {"found": True, "excerpt": text[start:m.end() + 80]} + + +def scan_paper_signals(text: str) -> dict[str, Any]: + """全文 → 结构化信号。accession 只在 DA 段;busco/hic 全文。""" + if not text: + return { + "accession": {"found": False, "hits": [], "in_da_section": False}, + "busco": {"level": "none", "excerpt": ""}, + "hic": {"found": False, "excerpt": ""}, + } + return { + "accession": _scan_accession(_extract_da_section(text)), + "busco": _scan_busco(text), + "hic": _scan_hic(text), + } + + +def _get_hr(item: Any) -> Any: + if isinstance(item, dict): + return item.get("hard_rule") + return getattr(item, "hard_rule", None) + + +def _item_fatal(item: Any) -> bool: + if isinstance(item, dict): + return bool(item.get("fatal")) + return bool(getattr(item, "fatal", False)) + + +def _set_downgraded(item: Any) -> None: + if isinstance(item, dict): + item["scanner_downgraded"] = True + else: + try: + item.scanner_downgraded = True + except Exception: # pydantic 严格模式等极端场景:吞掉,冲突记录仍保留 + pass + + +def _set_hr_none(item: Any) -> None: + if isinstance(item, dict): + item["hard_rule"] = None + else: + try: + item.hard_rule = None + except Exception: # pydantic 严格模式等极端场景:吞掉,冲突记录仍保留 + pass + + +def _item_title(item: Any) -> str: + if isinstance(item, dict): + return str(item.get("title") or "") + return str(getattr(item, "title", "") or "") + + +# 冲突映射:hard_rule 标签 → (信号检查, 冲突描述) +def _conflict_for(tag: str, signals: dict[str, Any]) -> str | None: + acc = signals.get("accession") or {} + busco = signals.get("busco") or {} + hic = signals.get("hic") or {} + if tag == "accession_missing" and acc.get("found"): + hits = ", ".join(acc.get("hits") or [])[:120] + return f"Data Availability 段已检出 accession({hits})" + if tag == "busco_missing" and busco.get("level") in ("partial", "full"): + return f"正文已提及 BUSCO({busco.get('level')} 格式)" + if tag == "hic_missing" and hic.get("found"): + excerpt = (hic.get("excerpt") or "").strip()[:80] + return f"正文已检出挂载率描述({excerpt})" + return None + + +def reconcile_hard_rules( + majors: list[Any], signals: dict[str, Any] +) -> tuple[list[Any], list[Any], list[dict[str, Any]]]: + """对带 hard_rule 的 major 做扫描器证伪:命中 → 剥标签 + **移出 major 降级至 + minor**(A2 语义,2026-10-12)→ 记冲突。 + + 返回 (kept_majors, demoted_minors, conflicts): + - kept_majors:未冲突/未处理的 major(原序) + - demoted_minors:被冲突降级的条目(hard_rule 置 None、scanner_downgraded=True) + - conflicts:{hard_rule, title, reason}——fatal 条目只记冲突不降级 + (拒稿级问题须人工过目,扫描器证伪不了伦理判断)。 + """ + kept: list[Any] = [] + demoted: list[Any] = [] + conflicts: list[dict[str, Any]] = [] + for m in majors: + tag = _get_hr(m) + if not isinstance(tag, str): + kept.append(m) + continue + reason = _conflict_for(tag, signals) + if reason is None: + kept.append(m) + continue + conflicts.append({ + "hard_rule": tag, + "title": _item_title(m), + "reason": reason, + }) + if _item_fatal(m): + kept.append(m) # fatal 保留原位(含其 hard_rule 供引擎 R1/R0 使用) + continue + _set_hr_none(m) + _set_downgraded(m) + demoted.append(m) + return kept, demoted, conflicts diff --git a/review_service/app/services/rebuttal_draft.py b/review_service/app/services/rebuttal_draft.py new file mode 100644 index 0000000..611d1cf --- /dev/null +++ b/review_service/app/services/rebuttal_draft.py @@ -0,0 +1,153 @@ +"""services/rebuttal_draft.py —— 5 类回复片段 Prompt + 反幻觉校验 + letter 拼装。 + +- part.type 枚举(决策 2,无 auto):new_data | reanalysis | location | clarification | rebuttal。 +- 反幻觉校验(补充 2 收窄版,只做负向证伪,不做泛化打分): + * location:出现 "shows that / as shown" 但无 "added/新增" 迹证 → FAIL + ("S3 shows that..." 红 FAIL;"we have added ... in S3" 过)。 + * rebuttal:命中负向词表 we agree / we accept / we concede / you are right / + we were wrong → FAIL;无立场词(maintain/disagree/respectfully/坚持/不同意…) + → FAIL;"respectfully maintain" 过。 + * 其他三类:Phase 1 仅查非空。 +- 校验前置(决策:校验C前置):draft 生成前先跑校验,FAIL 则不调 LLM。 +- letter 拼装:按 reviewer → comment 顺序,原文 + 草稿;无草稿留 "[待补充]"。 +""" +from __future__ import annotations + +import re +from typing import Any, Callable + +LLMCallable = Callable[..., Any] + +PART_TYPES = ("new_data", "reanalysis", "location", "clarification", "rebuttal") + +_TYPE_FRAGMENTS: dict[str, str] = { + "new_data": ( + "你的回复属于【补充新数据】类:说明新增了哪些实验/数据(图/表编号、关键数值)," + "并明确回应原审稿关切。不得虚构未提供的数据——只用 user_response 给出的事实。" + ), + "reanalysis": ( + "你的回复属于【重新分析】类:说明重跑了哪些分析、方法/参数变化与新结论要点。" + "不得虚构未提供的结果——只用 user_response 给出的事实。" + ), + "location": ( + "你的回复属于【定位/指出位置】类:明确指出修改或证据在稿件中的位置" + "(如 Section 3 / Fig. S2 / Methods 段落),与 user_response 给出的位置一致。" + "不得虚构未提供的位置。" + ), + "clarification": ( + "你的回复属于【澄清说明】类:针对误解给出准确、简洁的解释," + "只用 user_response 与原评论内容,不引入新主张。" + ), + "rebuttal": ( + "你的回复属于【礼貌反驳】类:礼貌但明确地表达不同立场(maintain/disagree)," + "给出理由(基于 user_response 与原文),不得虚构证据。" + ), +} + +_BASELINE = ( + "通用底线:只基于原评论与 user_response 起草;不得编造数据、图表编号、" + "统计结果或已做修改;不确定处用占位([XX])而非猜测。" + "输出 JSON:{\"draft_md\": \"英文正式回复正文(markdown,≤200词)\"}。只输出 JSON。" +) + +_STANCE = re.compile( + r"\b(maintain|disagree|respectfully|cannot\s+agree|dispute|stand\s+by|" + r"hold\s+to)\b|坚持|不同意|保留|异议", + re.IGNORECASE, +) +_NEGATIVE = re.compile( + r"\bwe\s+agree\b|\bwe\s+accept\b|\bwe\s+concede\b|\byou\s+are\s+right\b|" + r"\bwe\s+were\s+wrong\b", + re.IGNORECASE, +) +_LOC_SHOWS = re.compile(r"shows?\s+that|as\s+shown|表明", re.IGNORECASE) +_LOC_ADDED = re.compile(r"\badded\b|新增|have\s+added|we\s+have\s+added", re.IGNORECASE) + + +def validate_parts(parts: list[dict[str, Any]]) -> dict[str, Any]: + """反幻觉校验 → {passed: bool, findings: [{part_index, code, message}]}。""" + findings: list[dict[str, Any]] = [] + for i, p in enumerate(parts): + ptype = str(p.get("type") or "") + content = str(p.get("content") or "").strip() + if not content: + findings.append({"part_index": i, "code": "EMPTY_CONTENT", + "message": "回复内容不能为空"}) + continue + if ptype == "location": + if _LOC_SHOWS.search(content) and not _LOC_ADDED.search(content): + findings.append({ + "part_index": i, "code": "LOCATION_EVIDENCE_WEAK", + "message": "location 类回复出现“shows that/如所示”但未给出新增位置;" + "请写明“we have added … in S{n}”式定位", + }) + elif ptype == "rebuttal": + if _NEGATIVE.search(content): + findings.append({ + "part_index": i, "code": "REBUTTAL_NEGATIVE", + "message": "rebuttal 类回复出现让步词(we agree/accept/concede…);" + "该类应礼貌但明确地表达不同立场", + }) + elif not _STANCE.search(content): + findings.append({ + "part_index": i, "code": "REBUTTAL_NO_STANCE", + "message": "rebuttal 类回复缺少立场词(maintain/disagree/respectfully…)", + }) + # new_data / reanalysis / clarification:Phase 1 仅非空 + return {"passed": not findings, "findings": findings} + + +def _draft_system(parts: list[dict[str, Any]]) -> str: + frags = [_TYPE_FRAGMENTS.get(str(p.get("type") or ""), "") for p in parts] + frags = [f for f in frags if f] + return " ".join(frags) + " " + _BASELINE if frags else _BASELINE + + +async def generate_draft(llm: LLMCallable, comment: dict[str, Any]) -> str: + """按 comment.response.parts 起草 draft_md(调用方须先过 validate_parts)。""" + parts = (comment.get("response") or {}).get("parts") or [] + user_lines = [] + for p in parts: + hint = str(p.get("location_hint") or "").strip() + line = f"- [{p.get('type')}] {p.get('content')}" + if hint: + line += f" (location_hint: {hint})" + user_lines.append(line) + user_msg = ( + f"原审稿意见:\n{comment.get('body') or comment.get('title') or ''}\n\n" + f"作者拟回复要点(user_response):\n" + "\n".join(user_lines) + ) + try: + data = await llm( + [{"role": "system", "content": _draft_system(parts)}, + {"role": "user", "content": user_msg}], + temperature=0.2, + ) + draft = str((data or {}).get("draft_md") or "").strip() + return draft or "[draft generation returned empty]" + except Exception: # noqa: BLE001 + return "[draft generation failed — 请重试]" + + +def assemble_letter(job: dict[str, Any]) -> str: + """拼装整封 response letter markdown(Phase 1 纯 .md,docx 放 Phase 2)。""" + lines: list[str] = ["# Response to Reviewers", ""] + lines.append(f"_Job: {job.get('id')} · template: {job.get('template') or 'generic'}_") + lines.append("") + for r in job.get("reviewers") or []: + lines.append(f"## {r.get('label') or 'Reviewer'}") + lines.append("") + for c in r.get("comments") or []: + title = c.get("title") or c.get("id") or "Comment" + lines.append(f"### {title}") + lines.append("") + lines.append("**Original comment:**") + lines.append("") + lines.append(str(c.get("body") or "").strip()) + lines.append("") + lines.append("**Our response:**") + lines.append("") + draft = str(c.get("draft") or "").strip() + lines.append(draft if draft else "[待补充]") + lines.append("") + return "\n".join(lines) diff --git a/review_service/app/services/rebuttal_parse.py b/review_service/app/services/rebuttal_parse.py new file mode 100644 index 0000000..df0992c --- /dev/null +++ b/review_service/app/services/rebuttal_parse.py @@ -0,0 +1,207 @@ +"""services/rebuttal_parse.py —— 审稿意见文本 → 结构化 Comment 树。 + +流程(保守:正则先切骨架,LLM 只做分类/拆子条,行数交叉核对不匹配则回退正则): +1. 归一化换行;正则识别 Reviewer 段头(Reviewer 1 / 审稿人 2 / Review A 等), + 无段头 → 单 Reviewer(label=Reviewer)。 +2. 每个 Reviewer 段内按编号行切 comment 段("1." "2)" "(3)" 等); + 切不出编号 → 整段作 1 条。 +3. LLM complete_json 对每段做:{title, severity, sub_items:[{title,body,confidence}]}。 + - LLM 返回段数 != 提交段数 → 全量回退正则(title=首 60 字,severity 默认 major)。 + - sub_items ≥ 2 且最高 confidence ≥ 0.7 → 拆多条(共享 group_id);否则单条。 +4. cross_refs:正文里 "see comment 3 / 参见第 2 条" → 解析目标;解析不到 + target_id=null 保留 raw(决策:不猜)。 + +不做:auto 分类(已砍);severity 以外的任何 LLM 自由发挥字段。 +""" +from __future__ import annotations + +import re +from typing import Any, Callable + +# LLM 依赖注入(测试打桩):async (messages, temperature=..) -> dict +LLMCallable = Callable[..., Any] + +_REVIEWER_HEADER = re.compile( + r"^(?:reviewers?\s*#?\s*([0-9]+|[A-Z])|审稿人\s*#?\s*([0-9A-Z]+)|review\s+([A-Z]))\s*[::.))]?", + re.IGNORECASE | re.MULTILINE, +) +_COMMENT_NUM = re.compile( + r"(?:(?<=\n)|(?<=\.\s)|^)\s*(?:[#((]?\s*(\d{1,2})\s*[)).:、]|[((]\s*([ivx]{1,4}|[a-z])\s*[))])\s+", + re.IGNORECASE | re.MULTILINE, +) +_CROSS_REF = re.compile( + r"(?:see|noted\s+in|refer(?:ring)?\s+to|per)\s+comment\s*#?\s*(\d+)|" + r"(?:please\s+)?refer\s+to\s+comment\s*#?\s*(\d+)|" + r"参见\s*第?\s*(\d+)\s*条|见\s*comment\s*#?\s*(\d+)|同上(?:一条)?评论", + re.IGNORECASE, +) + +_SPLIT_CONFIDENCE = 0.7 # 决策 3:confidence ≥ 0.7 才拆 + + +def _norm(text: str) -> str: + return text.replace("\r\n", "\n").replace("\r", "\n").strip() + + +def split_reviewers(text: str) -> list[tuple[str, str]]: + """→ [(label, block_text)];无段头 → [(Reviewer, 全文)]。""" + text = _norm(text) + matches = list(_REVIEWER_HEADER.finditer(text)) + if not matches: + return [("Reviewer", text)] + out: list[tuple[str, str]] = [] + for i, m in enumerate(matches): + raw = next(g for g in m.groups() if g is not None) + label = raw.upper() if raw.isalpha() else f"Reviewer {raw}" + start = m.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + block = text[start:end].strip() + if block: + out.append((label, block)) + if not out: # 段头全空块 → 兜底 + return [("Reviewer", text)] + # 段头前的引言(无段头内容)丢弃——保守:不猜归属 + return out + + +def split_comments(block: str) -> list[str]: + """段内按编号行切 comment 块;无编号 → [整段]。""" + block = _norm(block) + matches = list(_COMMENT_NUM.finditer(block)) + if len(matches) < 2: # 单个编号或无编号 → 整段一条 + return [block] if block else [] + out: list[str] = [] + for i, m in enumerate(matches): + start = m.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(block) + chunk = block[start:end].strip() + if chunk: + out.append(chunk) + return out or [block] + + +def _fallback_segment(seg: str) -> dict[str, Any]: + title = re.sub(r"\s+", " ", seg)[:60] + return { + "title": title, + "severity": "major", + "sub_items": [{"title": title, "body": seg, "confidence": 1.0}], + } + + +_SEG_SYSTEM = ( + "你是学术审稿意见解析助手。输入是一段审稿意见原文。输出 JSON:" + '{"title": "≤12词标题", "severity": "major|minor|question", ' + '"sub_items": [{"title": "...", "body": "该子问题完整原文", "confidence": 0.0-1.0}]}。' + "severity:major=要求补实验/补数据/重分析;minor=格式/表述小问题;" + "question=仅提问、无需修改稿件。" + "若该段含多个可独立回复的子问题则拆成多个 sub_items(confidence 给拆分把握度)," + "否则 1 个 sub_items(confidence=1.0)。只输出 JSON。" +) + + +async def _ask_llm_segment(llm: LLMCallable, seg: str) -> dict[str, Any] | None: + try: + data = await llm( + [{"role": "system", "content": _SEG_SYSTEM}, + {"role": "user", "content": seg}], + temperature=0.0, + ) + if not isinstance(data, dict) or not isinstance(data.get("sub_items"), list): + return None + sev = str(data.get("severity", "")).lower() + title = str(data.get("title") or "").strip() or seg[:60] + subs: list[dict[str, Any]] = [] + for s in data["sub_items"][:8]: + if not isinstance(s, dict): + continue + body = str(s.get("body") or "").strip() + if not body: + continue + try: + conf = float(s.get("confidence", 1.0)) + except (TypeError, ValueError): + conf = 0.0 + subs.append({ + "title": str(s.get("title") or "").strip() or title, + "body": body, + "confidence": max(0.0, min(1.0, conf)), + }) + if not subs: + return None + return {"title": title, + "severity": sev if sev in ("major", "minor", "question") else "major", + "sub_items": subs} + except Exception: # noqa: BLE001 — LLM 失败即回退正则,不猜测 + return None + + +def _resolve_cross_refs(body: str, ordinal_map: dict[int, str]) -> list[dict[str, Any]]: + refs: list[dict[str, Any]] = [] + for m in _CROSS_REF.finditer(body): + raw = m.group(0).strip() + num = next((int(g) for g in m.groups() if g and g.isdigit()), None) + if num is None and "同上" in raw: + num = _prev_ordinal(ordinal_map, m.start()) # 同上 → 上一条,保守仅前一条 + target = ordinal_map.get(num) if num is not None else None + refs.append({"raw": raw, "target_id": target}) + return refs + + +def _prev_ordinal(ordinal_map: dict[int, str], _pos: int) -> int | None: + # 简化:同上 → 已知最大编号(即前一条) + return max(ordinal_map) if ordinal_map else None + + +async def parse_review_text(text: str, llm: LLMCallable) -> list[dict[str, Any]]: + """→ reviewers=[{id, label, ordinal, comments:[Comment]}](不含 job 包装)。""" + reviewers_out: list[dict[str, Any]] = [] + cid_seq = 0 + for r_ord, (label, block) in enumerate(split_reviewers(text), start=1): + segs = split_comments(block) + if not segs: + continue + comments: list[dict[str, Any]] = [] + ordinal_map: dict[int, str] = {} + parsed_seg: list[dict[str, Any]] = [] + for seg in segs: + llm_res = await _ask_llm_segment(llm, seg) + # 交叉核对:LLM 无法给出结构 → 该段回退正则 + parsed_seg.append(llm_res if llm_res else _fallback_segment(seg)) + for seg_idx, (seg, p) in enumerate(zip(segs, parsed_seg), start=1): + subs = p["sub_items"] + split = len(subs) >= 2 and max(s["confidence"] for s in subs) >= _SPLIT_CONFIDENCE + group_id = f"g{r_ord}-{seg_idx}" + items = subs if split else [subs[0]] + if not split and len(subs) > 1: + # 未达阈值 → 多子问题并回一条(body 用整段原文) + items = [{"title": p["title"], "body": seg, "confidence": 1.0}] + for it in items: + cid_seq += 1 + cid = f"c{cid_seq:04d}" + # 保守:不拆时 body 保留该段原文(不采信 LLM 改写);拆时用子项 body + body = it["body"] if split else seg + ordinal_map[seg_idx] = cid + comments.append({ + "id": cid, + "group_id": group_id if split else None, + "severity": p["severity"], + "title": it["title"], + "body": body, + "sub_items": [s["title"] for s in subs] if split else [], + "cross_refs": _resolve_cross_refs(body, ordinal_map), + "response": {"parts": []}, + "draft": None, + "validation": None, + "human_confirmed": False, + "scanner_downgraded": False, + }) + # cross_refs 需全量 comment id 后再扫一遍(前面扫时 map 未齐) + for c in comments: + c["cross_refs"] = _resolve_cross_refs(c["body"], ordinal_map) + if comments: + reviewers_out.append({ + "id": f"r{r_ord}", "label": label, "ordinal": r_ord, + "comments": comments, + }) + return reviewers_out diff --git a/review_service/app/services/rebuttal_store.py b/review_service/app/services/rebuttal_store.py new file mode 100644 index 0000000..2f65225 --- /dev/null +++ b/review_service/app/services/rebuttal_store.py @@ -0,0 +1,162 @@ +"""services/rebuttal_store.py —— Rebuttal Phase 1 任务持久化(SQLite,自含连接)。 + +设计(2026-10-12 定稿): +- 表 rebuttal_jobs 落在 review_service/review.db(与 journals 同库不同表); + 本模块自开短连接(低频 CRUD,WAL 下足够),不改既有 app/db.py。 +- Job/Comment 全量以 JSON 存 reviewers_json 列(Phase 1 规模 ≤ 数十条, + 整读整写最简单且可测;并发写靠 updated_at + 单进程串行)。 +- human_ack 不信前端置位:confirm 端点改完 comment 后由本模块重算—— + 所有 comment.human_confirmed 全 true 才置 1(决策 4)。 +- 不做 TTL 自动清理(决策 5);DELETE 端点手动删。 +""" +from __future__ import annotations + +import json +import sqlite3 +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from ..config import get_settings + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS rebuttal_jobs ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + source_kind TEXT, + filename TEXT, + raw_text TEXT, + template TEXT DEFAULT 'generic', + reviewers_json TEXT NOT NULL, + letter_md TEXT, + validation_json TEXT, + human_ack INTEGER DEFAULT 0, + created_at TEXT, + updated_at TEXT +); +""" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def new_id(prefix: str = "") -> str: + return prefix + uuid.uuid4().hex[:12] + + +def _connect() -> sqlite3.Connection: + db_path: Path = get_settings().db_path_abs + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path), timeout=5) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def ensure_table() -> None: + with _connect() as conn: + conn.executescript(_SCHEMA) + + +def _row_to_job(row: sqlite3.Row) -> dict[str, Any]: + return { + "id": row["id"], + "status": row["status"], + "source": { + "kind": row["source_kind"] or "text", + "filename": row["filename"], + "raw_text": row["raw_text"] or "", + }, + "template": row["template"] or "generic", + "reviewers": json.loads(row["reviewers_json"] or "[]"), + "letter_md": row["letter_md"], + "validation": json.loads(row["validation_json"]) if row["validation_json"] else None, + "human_ack": bool(row["human_ack"]), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + +def insert_job(job: dict[str, Any]) -> None: + """写入完整 job(reviewers 等结构由调用方构造好)。""" + now = job.get("created_at") or _now() + with _connect() as conn: + conn.execute( + "INSERT INTO rebuttal_jobs (id, status, source_kind, filename, raw_text," + " template, reviewers_json, letter_md, validation_json, human_ack," + " created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + ( + job["id"], job.get("status") or "ready", + (job.get("source") or {}).get("kind"), + (job.get("source") or {}).get("filename"), + (job.get("source") or {}).get("raw_text"), + job.get("template") or "generic", + json.dumps(job.get("reviewers") or [], ensure_ascii=False), + job.get("letter_md"), + json.dumps(job.get("validation"), ensure_ascii=False) + if job.get("validation") is not None else None, + 1 if job.get("human_ack") else 0, + now, now, + ), + ) + + +def get_job(job_id: str) -> dict[str, Any] | None: + with _connect() as conn: + row = conn.execute( + "SELECT * FROM rebuttal_jobs WHERE id=?", (job_id,) + ).fetchone() + return _row_to_job(row) if row else None + + +def update_job(job_id: str, *, reviewers: list[dict[str, Any]] | None = None, + letter_md: str | None = None, status: str | None = None) -> dict[str, Any] | None: + """局部更新;reviewers 变更时重算 human_ack(全勾才 true)。""" + job = get_job(job_id) + if job is None: + return None + if reviewers is not None: + job["reviewers"] = reviewers + if letter_md is not None: + job["letter_md"] = letter_md + if status is not None: + job["status"] = status + job["human_ack"] = all_comments_confirmed(job["reviewers"]) + job["updated_at"] = _now() + with _connect() as conn: + conn.execute( + "UPDATE rebuttal_jobs SET reviewers_json=?, letter_md=?, status=?," + " human_ack=?, updated_at=? WHERE id=?", + ( + json.dumps(job["reviewers"], ensure_ascii=False), + job.get("letter_md"), + job["status"], + 1 if job["human_ack"] else 0, + job["updated_at"], + job_id, + ), + ) + return job + + +def delete_job(job_id: str) -> bool: + with _connect() as conn: + cur = conn.execute("DELETE FROM rebuttal_jobs WHERE id=?", (job_id,)) + return cur.rowcount > 0 + + +def all_comments(reviewers: list[dict[str, Any]]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for r in reviewers or []: + out.extend(r.get("comments") or []) + return out + + +def all_comments_confirmed(reviewers: list[dict[str, Any]]) -> bool: + comments = all_comments(reviewers) + # 空 job(未解析出任何 comment)不置位 human_ack,避免"全勾"平凡真 + if not comments: + return False + return all(bool(c.get("human_confirmed")) for c in comments) diff --git a/review_service/app/services/recommend_engine.py b/review_service/app/services/recommend_engine.py index 397635f..39414ca 100644 --- a/review_service/app/services/recommend_engine.py +++ b/review_service/app/services/recommend_engine.py @@ -18,6 +18,8 @@ from __future__ import annotations import json +import re +import sqlite3 from functools import lru_cache from pathlib import Path from typing import Any, Iterable @@ -47,6 +49,20 @@ TIER_LABELS = {1: "顶刊", 2: "主流期刊", 3: "入门期刊"} TIER_ACCEPT_MAX_MINOR = {1: 1, 2: 2, 3: 3} TIER3_SOFT_MAJOR_AS_MINOR_MAX = 2 +# ---- tier 查找优先级(2026-10-12):override → featured → jif → 默认 ---- +# ① 用户高频投稿顶刊硬编码(精确名匹配,不受 jif/综述刊启发式影响; +# "The " 前缀归一化,"Plant Cell"/"The Plant Cell" 互认) +TIER1_OVERRIDES = frozenset( + re.sub(r"^the\s+", "", name.casefold()) for name in ( + "Nature", "Nature Genetics", "Nature Plants", "Nature Biotechnology", + "Nature Methods", "Cell", "Science", "Genome Biology", "Molecular Plant", + "The Plant Cell", "Nature Communications", "Science Advances", "Cell Research", + ) +) +# 综述刊不因高 jif 升 tier1(Annual Review of… / Trends in… / …Reviews) +_REVIEW_JOURNAL_RE = re.compile(r"reviews?\b|\btrends\b|annual\s+review", re.IGNORECASE) +_JIF_TIER1_MIN = 15.0 # jif >= 15 → tier1;5 ≤ jif < 15 → tier2;<5/NULL → 默认 tier2 + @lru_cache(maxsize=1) def _tier_by_name() -> dict[str, int]: @@ -67,13 +83,55 @@ def _tier_by_name() -> dict[str, int]: return out +@lru_cache(maxsize=256) +def _jif_by_name(name_bare_cf: str) -> float | None: + """journal_directory 按名查 jif(只读一列;表缺失/查不到 → None)。""" + db = get_settings().db_path_abs + if not Path(db).exists(): + return None + try: + conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=2) + try: + row = conn.execute( + "SELECT jif FROM journal_directory WHERE lang='en' " + "AND (lower(name)=? OR lower(name)=?) " + "ORDER BY jif DESC LIMIT 1", + (name_bare_cf, "the " + name_bare_cf), + ).fetchone() + finally: + conn.close() + except sqlite3.Error: + return None + if not row or row[0] is None: + return None + try: + return float(row[0]) + except (TypeError, ValueError): + return None + + def resolve_tier(target_journal: str) -> tuple[int, str]: - """目标期刊 → (tier, matched_name)。空/未命中 → (2, "") 默认主流档。""" - key = (target_journal or "").strip().casefold() - if not key: - return 2, "" - tier = _tier_by_name().get(key) - return (tier, key) if isinstance(tier, int) else (2, "") + """目标期刊 → (tier, source)。source ∈ override/featured/jif/默认。 + + 查找优先级:① 硬编码 13 本顶刊 ② journals.json review_tier + ③ journal_directory jif 启发式(综述刊除外)④ 默认 tier 2。 + """ + raw = (target_journal or "").strip() + if not raw: + return 2, "默认" + key = raw.casefold() + bare = re.sub(r"^the\s+", "", key) + if bare in TIER1_OVERRIDES: + return 1, "override" + featured = _tier_by_name() + tier = featured.get(key, featured.get(bare)) + if isinstance(tier, int) and tier in (1, 2, 3): + return tier, "featured" + if not _REVIEW_JOURNAL_RE.search(bare): + jif = _jif_by_name(bare) + if jif is not None: + return (1 if jif >= _JIF_TIER1_MIN else 2), "jif" + return 2, "默认" def _item_field(item: Any, key: str) -> Any: diff --git a/review_service/app/services/self_review.py b/review_service/app/services/self_review.py index 07e653b..653ea6b 100644 --- a/review_service/app/services/self_review.py +++ b/review_service/app/services/self_review.py @@ -32,7 +32,15 @@ Genome Biology、Molecular Plant、Plant Journal 等期刊审稿。请对用户 "Complete 95.3% [S:93.1%, D:2.2%], Missing 4.7%, n=1614 (embryophyta_odb10)" (含 Complete/Single-copy/Duplicated/Missing 四项与数据集版本、n 值) - Hi-C 挂载率(anchoring rate,chromosome-level 比例)及挂载到染色体的 scaffold 比例 -判定规则:若论文只报 N50 而未报 BUSCO 完整度或 Hi-C 挂载率(组装类论文),列为 +BUSCO 完整度三级判定: +- 完全未提 BUSCO → Major Weakness,hard_rule="busco_missing" +- 提了 BUSCO 但只有总百分比(如 98.57%),缺 Single-copy/Duplicated/Missing + 分项或数据集版本(如 n=1614, embryophyta_odb10)→ 列 Minor(不打 hard_rule) +- 完整格式(如 Complete 95.3% [S:93.1%, D:2.2%], Missing 4.7%, n=1614)→ 通过,不列 +Hi-C/挂载率报告识别(命中任一且给出数值即视为已报告,不得判缺): +anchoring rate / anchored to chromosome(s) / % of sequences anchored / +pseudo-chromosome(s) / chromosome-level 挂载比例。 +判定规则:若组装类论文只报 N50 而未报 BUSCO 且未报 Hi-C 挂载率,列为 Major Weakness 并打对应 hard_rule 标签(BUSCO 缺失→"busco_missing";宣称 chromosome-level/Hi-C 挂载但未报挂载率→"hic_missing")。同时检查组装是否为 pseudo-chromosome 级别、污染评估(如 k-mer/coverage 检查)与 gap 比例。 @@ -45,18 +53,25 @@ pseudo-chromosome 级别、污染评估(如 k-mer/coverage 检查)与 gap - 注释质量评估:BRAKER/AED 分值、BUSCO 在蛋白集上的复核 【维度三:比较基因组学与进化】检查: -- 系统发育树:每个节点必须标注 Bootstrap 支持率(建议 ≥1000 次重复)或贝叶斯后验概率; - 未标注或仅"高/低支持"的描述 → Major 或 Minor(视严重程度) +- 系统发育树:每个节点建议标注 Bootstrap 支持率(建议 ≥1000 次重复)或贝叶斯后验概率。 + 分诊: + * 完全未做节点支持率分析(方法与正文均无任何声明)且支撑核心进化结论 → Major + * 方法/正文声明已做 bootstrap 或后验概率,仅图中未标注数值 → presentation_format + (图注补标即可,不需重跑分析) + * 附带性质的树无支持率 → Minor - 直系同源基因鉴定策略是否说明清楚:OrthoFinder/OrthoMCL/RBX 等工具、版本、参数、 聚类阈值(inflation/coverage)、single-copy 标记基因的筛选方式 - 分子进化分析是否规范:dN/dS 检验方法与多重校正、共线性(synteny)分析参数、 分化时间估算是否校准(化石/二次校准点) -【维度四:数据可用性合规——极其重要】必查: -- 原始测序数据是否上传 NCBI SRA / BioProject(或 ENA/DDBJ),是否给出 Accession 号 - (PRJNA/PRJEB/ SRP 开头) -- 基因组组装是否有 GenBank/ENA accession(GCA_/GCF_),GFF3 注释文件、蛋白/CDS 文件 - 是否有可公开获取的位置(仓库 accession 或补充数据链接) +【维度四:数据可用性合规——极其重要】必查(在 Data Availability 节或 +Methods 数据声明处核对,不因正文引用他人数据的 accession 而误判): +- 原始测序数据是否上传公共数据库并给出 Accession 号。前缀清单: + PRJNA/PRJEB(NCBI/ENA BioProject)、PRJCA/CRA(中国 NGDC/GSA)、 + SRP/ERP/DRP(SRA 项目)、SRR/ERR/DRR(run 级号)——命中任一即视为已有 + 原始数据 accession +- 基因组组装是否有 GenBank/ENA accession(GCA_/GCF_),GFF3 注释文件、 + 蛋白/CDS 文件是否有可公开获取的位置(仓库 accession 或补充数据链接) - 转录组数据是否同样有 SRA accession 判定规则:组装/注释类论文若缺失上述任何一环(含 "will be deposited" 等未给号的 承诺式表述),列为 Major Weakness 并打 hard_rule 标签 "accession_missing"。 @@ -101,6 +116,16 @@ pseudo-chromosome 级别、污染评估(如 k-mer/coverage 检查)与 gap - presentation_format:仅呈现层面的问题(图表样式、图注格式、参考文献体例、语法)。 严禁把呈现问题升格为 major。 +【负向清单——以下情况严禁列为 major_weaknesses,按对应类别归档】 +- 图表未标注统计量(bootstrap 数值/误差棒/显著性标记/P 值),但方法或正文已声明 + 做过该分析 → presentation_format 或 minor_weaknesses +- 方法描述不够详细,但所用方法是领域标准做法且论证逻辑完整 → minor_weaknesses +- 阈值/参数未给出具体数值,但逻辑完整且结论不依赖精确数值 → minor_weaknesses +- 引用格式/排版/图注体例/语法 → presentation_format +- 标准流程的合理省略(如 IQ-TREE/DESeq2 等工具默认参数未逐一列出)→ 不列 +总纲:只有"需要补做实验/补数据/重跑分析/改核心论证"的问题才允许列为 major; + 其余一律 minor 或 presentation。与上文各维度冲突时以本节为准。 + 分级与条数规则: 1. major_weaknesses、minor_weaknesses、presentation_format 均【不设下限,可以为 0 条】。 论文确实完善时输出空数组是正确答案;严禁为凑数而降低分级标准或制造问题。 @@ -120,8 +145,10 @@ C. 缺数据 Accession(SRA/GenBank/组装/GFF3;"will be deposited" 等承诺 → hard_rule="accession_missing" D. 野生植物材料缺凭证标本(voucher)→ hard_rule="voucher_missing" E. 涉及遗传资源但缺 ABS/名古屋合规声明 → hard_rule="abs_missing" -(核心进化结论所依赖的系统发育树若无任何节点支持率 → 列 major(非硬性,不打标签); - 附带性质的树无支持率 → 可列 minor。GWAS 缺多重校正等统计问题按同样原则判级。) +(核心进化结论所依赖的系统发育树若**完全未做**节点支持率分析 → 列 major(非硬性, + 不打标签);若方法/正文已声明分析但图中未标数值 → presentation/minor;附带性质 + 的树无支持率 → minor。GWAS 缺多重校正等统计问题按同样原则判级:阈值/方法完全 + 缺失且影响结论 → major;已声明校正逻辑仅缺细节 → minor。) presentation_format 覆盖:图表规范(图注含单位/误差棒说明)、参考文献格式、 字数与结构、英文语法(若投英文刊)。 @@ -135,6 +162,9 @@ class WeaknessItem(BaseModel): suggestion: str = "" hard_rule: str | None = None # 仅 major 可带;白名单外置 None(见 coerce) fatal: bool = False # 明确造假/伦理证据才可 true(触发 R0 拒稿) + # 扫描器冲突降级标记(程序层置位,LLM 输出不读/coerce 不透传 → 不可伪造): + # True 表示该条原为 major,因关键词扫描器证伪其 hard_rule 缺项声明被降级至 minor + scanner_downgraded: bool = False class SelfReviewReport(BaseModel): diff --git a/review_service/tests/test_presubmit.py b/review_service/tests/test_presubmit.py index c96b4c7..6397c36 100644 --- a/review_service/tests/test_presubmit.py +++ b/review_service/tests/test_presubmit.py @@ -98,7 +98,7 @@ def test_normal_report_normalized(client: TestClient): rat = report["recommendation_rationale"] assert rat["rule"] == "R2" assert rat["major_count"] == 2 and rat["hard_count"] == 0 and rat["soft_count"] == 2 - assert rat["tier"] == 2 and rat["tier_label"] == "主流期刊" + assert rat["tier"] == 1 and rat["tier_label"] == "顶刊" assert rat["explanation"] # 字符串条目宽容归一化 assert len(report["major_weaknesses"]) == 2 @@ -110,12 +110,12 @@ def test_normal_report_normalized(client: TestClient): # 空问题剔除 assert report["questions_for_authors"] == ["群体结构如何校正?"] assert report["minor_weaknesses"] and report["presentation_format"] and report["summary"] - # meta 透传 + tier 信息 + # meta 透传 + tier 信息(The Plant Cell 在 13 本 override 名单 → tier 1) assert body["meta"]["target_journal"] == "Plant Cell" assert body["meta"]["text_chars"] > 0 assert body["meta"]["truncated"] is False - assert body["meta"]["tier"] == 2 # Plant Cell 不在精选分档表 → 默认主流档 - assert body["meta"]["tier_source"] == "默认" + assert body["meta"]["tier"] == 1 + assert body["meta"]["tier_source"] == "override" def test_llm_failure_degrades(client: TestClient, monkeypatch): @@ -241,7 +241,7 @@ def test_fatal_reject(client: TestClient, monkeypatch): def test_tier_matching_journals_json(client: TestClient, monkeypatch): - """精选期刊名匹配 journals.json review_tier;Genome Biology → tier 1。""" + """Genome Biology 在 13 本 override 名单 → tier 1(override 优先于 featured)。""" body = _post_payload(client, monkeypatch, { "summary": "x", "major_weaknesses": [], @@ -249,11 +249,20 @@ def test_tier_matching_journals_json(client: TestClient, monkeypatch): }, journal="Genome Biology") assert body["meta"]["tier"] == 1 assert body["meta"]["tier_label"] == "顶刊" - assert body["meta"]["tier_source"] == "genome biology" + assert body["meta"]["tier_source"] == "override" # tier1 accept 门槛更紧:1 minor 仍 accept(≤1) assert body["report"]["recommendation"] == "accept" +def test_tier_featured_fallback(client: TestClient, monkeypatch): + """不在 override 名单的精选刊走 journals.json review_tier(featured)。""" + body = _post_payload(client, monkeypatch, { + "summary": "x", "major_weaknesses": [], "minor_weaknesses": [], + }, journal="BMC Plant Biology") + assert body["meta"]["tier"] == 2 + assert body["meta"]["tier_source"] == "featured" + + # ---- 引擎直调(tier 分档与 rationale 分支)---- def test_engine_tier3_soft_major_downgrade(): @@ -309,5 +318,280 @@ def test_domain_system_prompt_wired(client: TestClient, monkeypatch): "GWAS", "多重检验校正", "生物学重复", # 维度5 统计设计 "凭证标本", "ABS", "惠益分享", # 维度6 伦理合规 "evidence", "章节", # Evidence 硬性要求 + "负向清单", "严禁列为 major", "以本节为准", # 2026-10-12 负向清单(soft major 收紧) + "需要补做实验/补数据/重跑分析/改核心论证", # major 总纲 + "仅图中未标注数值", # 系统发育树图注级降级 ): assert marker in system, f"System Prompt 缺少领域标记: {marker}" + + +# ================================================================ +# 2026-10-12 四 Bug 修复:截断装箱 / 扫描器 / tier 分档(离线单测) +# ================================================================ + +from app.routers.presubmit import ( # noqa: E402 + _fit_feed_text, estimate_tokens, pack_feed_text, +) +from app.services import paper_scanner # noqa: E402 +from app.services.recommend_engine import resolve_tier # noqa: E402 + + +# ---- Bug 1:段落感知装箱 + token 估算 ---- + +_SECTIONED = ( + "Abstract\n本研究组装了黄瓜泛基因组。\n" + "Introduction\n引言内容。\n" + "Results\n主要结果。\n" + "Methods\n" + ("方法细节。" * 4000) + "\n" + "Data Availability\nAll data are available under PRJCA038097.\n" + "Discussion\n讨论内容。\n" + "References\n[1] Someone 2020.\n" +) + + +def test_estimate_tokens(): + assert estimate_tokens("a" * 3000) == 2000 + + +def test_pack_under_budget_untouched(): + feed, dropped = pack_feed_text("short text", 1000) + assert feed == "short text" and dropped == [] + + +def test_pack_keeps_data_availability_when_over_budget(): + """超预算时 Data Availability 必须保留(bug:30 页论文 DA 在 Page 17 被截)。""" + budget = 8000 + assert len(_SECTIONED) > budget + feed, dropped = pack_feed_text(_SECTIONED, budget) + assert "PRJCA038097" in feed + assert "Abstract" in feed + assert len(feed) <= budget + # Methods 超出 60% 预算上限被截;预算仍有余量时低优先级节(introduction) + # 照常保留,dropped 只记真正被丢弃的段 + assert "引言内容" in feed # introduction 标题在文中原序保留 + assert dropped == [] + # 预算极紧时:低优先级段被丢弃并记入 dropped + feed2, dropped2 = pack_feed_text(_SECTIONED, 100) + assert len(feed2) <= 100 + assert "references" in dropped2 and "discussion" in dropped2 + + +def test_pack_fallback_without_sections(): + text = "x" * 20000 + "y" * 4000 + "\nDATA AVAILABILITY PRJNA12345" + feed, dropped = pack_feed_text(text, 10000) + assert "PRJNA12345" in feed # 尾窗捞回 + assert dropped and dropped[0].startswith("[无节标题回退") + + +def test_fit_feed_text_token_guard(): + """第一层保护:估算超 48K token → 自动降到 100K 字符重装箱。""" + huge = "A" * 300000 # 估算 200K token + feed, _ = _fit_feed_text(huge, 150000) + assert len(feed) <= 100000 + # 150K 字符内(估算 100K token? 150000/1.5=100000 > 48000 → 也会降) + # 60K 字符(40K token)不触发 + mid = "B" * 60000 + feed2, _ = _fit_feed_text(mid, 150000) + assert len(feed2) == 60000 + + +# ---- Bug 4:关键词扫描器 + 冲突降级 ---- + +_SCANNER_TEXT = ( + "Abstract\nCucumber pan-genome.\n" + "Results\nBUSCO completeness was 98.57%. " + "94.18% of sequences anchored to chromosomes.\n" + "Data Availability\nThe raw data have been deposited under " + "PRJCA038097, PRJCA043228 and CRA002855 in GSA.\n" + "Discussion\nDone.\n" +) + + +def test_scanner_signals(): + s = paper_scanner.scan_paper_signals(_SCANNER_TEXT) + assert s["accession"]["found"] is True + assert "PRJCA038097" in s["accession"]["hits"] + assert "CRA002855" in s["accession"]["hits"] + assert s["busco"]["level"] == "partial" # 只有百分比,无 S/D 分项 + assert s["hic"]["found"] is True + + +def test_scanner_accession_only_in_da_section(): + """正文引用他人的 PRJNA 出现在非 DA 段 → 不得算"已有 accession"。""" + text = ( + "Introduction\nWe reused PRJNA9999999 from a prior study.\n" + "Results\nAssembly OK.\n" + # 无 Data Availability 节 + ) + s = paper_scanner.scan_paper_signals(text) + assert s["accession"]["found"] is False + + +def test_scanner_busco_levels(): + assert paper_scanner.scan_paper_signals("")["busco"]["level"] == "none" + partial = "BUSCO completeness was 98.57% compared with embryophyta." + assert paper_scanner.scan_paper_signals(partial)["busco"]["level"] == "partial" + full = "BUSCO analysis: Complete 95.3% [S:93.1%, D:2.2%], Missing 4.7%, n=1614." + assert paper_scanner.scan_paper_signals(full)["busco"]["level"] == "full" + + +def test_reconcile_downgrades_conflicting_hard_rules(): + majors = [ + {"title": "缺 BUSCO", "hard_rule": "busco_missing", "fatal": False}, + {"title": "缺 accession", "hard_rule": "accession_missing", "fatal": False}, + {"title": "缺 Hi-C", "hard_rule": "hic_missing", "fatal": False}, + {"title": "其他硬性", "hard_rule": "voucher_missing", "fatal": False}, + ] + signals = paper_scanner.scan_paper_signals(_SCANNER_TEXT) + kept, demoted, conflicts = paper_scanner.reconcile_hard_rules(majors, signals) + # 三项被证伪的硬性 major 真降级移出 kept,进入 demoted(A2) + assert [m["title"] for m in kept] == ["其他硬性"] + assert kept[0]["hard_rule"] == "voucher_missing" + assert [m["title"] for m in demoted] == ["缺 BUSCO", "缺 accession", "缺 Hi-C"] + # 降级项:剥标签 + 打 scanner_downgraded + 保留 title/detail + assert all(m["hard_rule"] is None for m in demoted) + assert all(m["scanner_downgraded"] is True for m in demoted) + assert demoted[0]["title"] == "缺 BUSCO" + assert {c["hard_rule"] for c in conflicts} == { + "busco_missing", "accession_missing", "hic_missing"} + + +def test_reconcile_fatal_not_demoted(): + """fatal 条目即使冲突也只记冲突、不降级(须人工过目)。""" + majors = [{ + "title": "疑似数据造假", "hard_rule": "accession_missing", "fatal": True, + }] + signals = paper_scanner.scan_paper_signals(_SCANNER_TEXT) + kept, demoted, conflicts = paper_scanner.reconcile_hard_rules(majors, signals) + assert len(kept) == 1 and kept[0]["hard_rule"] == "accession_missing" + assert demoted == [] + assert len(conflicts) == 1 # 冲突仍记录(透明) + + +def test_reconcile_no_conflict_untouched(): + majors = [{"title": "真缺", "hard_rule": "accession_missing", "fatal": False}] + signals = paper_scanner.scan_paper_signals("只有正文,没有 DA 段。") + kept, demoted, conflicts = paper_scanner.reconcile_hard_rules(majors, signals) + assert kept[0]["hard_rule"] == "accession_missing" + assert demoted == [] and conflicts == [] + + +def test_http_scanner_conflict_downgrades(client: TestClient, monkeypatch): + """端到端:LLM 报三项缺失 + 正文实有 → 冲突记录、hard_count=0。""" + async def fake_parse(file_bytes: bytes, filename: str = "paper.pdf") -> str: + return _SCANNER_TEXT + + async def fake_llm(*args, **kwargs): + return { + "summary": "x", + "major_weaknesses": [ + {"title": "缺 BUSCO 完整格式", "evidence": "Results", + "hard_rule": "busco_missing", "fatal": False}, + {"title": "缺数据 accession", "evidence": "全文未见", + "hard_rule": "accession_missing", "fatal": False}, + {"title": "缺 Hi-C 挂载率", "evidence": "Results", + "hard_rule": "hic_missing", "fatal": False}, + ], + "minor_weaknesses": [], + } + + monkeypatch.setattr(paper_parse, "parse_pdf_bytes", fake_parse) + monkeypatch.setattr(llm, "complete_json", fake_llm) + body = _post(client, "p.pdf", FAKE_PDF, journal="Genome Biology").json() + assert body["ok"] is True + report = body["report"] + rat = report["recommendation_rationale"] + # A2:3 条冲突 major 全部真降级移出 major → 0 major(非"剥标签留原地") + assert rat["major_count"] == 0 and rat["hard_count"] == 0 and rat["soft_count"] == 0 + assert report["major_weaknesses"] == [] + # 降级项落到 minor,带 scanner_downgraded 标记(前端黄徽标) + downgraded = [m for m in report["minor_weaknesses"] if m.get("scanner_downgraded")] + assert len(downgraded) == 3 + assert all(m["hard_rule"] is None for m in downgraded) + # 0 major + 3 minor(tier1 门槛≤1)→ R5 minor_revision(不是 R2 大修) + assert rat["rule"] == "R5" + assert report["recommendation"] == "minor_revision" + assert len(rat["scanner_conflicts"]) == 3 + assert "冲突" in rat["explanation"] and "小修级" in rat["explanation"] + assert body["meta"]["scanner"]["accession_found"] is True + assert body["meta"]["scanner"]["hic_found"] is True + assert rat["hard_hits"] == [] + + +def test_http_partial_conflict_leaves_residual_soft(client: TestClient, monkeypatch): + """1 条冲突降级 + 1 条真 soft 残留 → major_count=1、仍 R2(引擎语义不变的对照)。""" + async def fake_parse(file_bytes: bytes, filename: str = "paper.pdf") -> str: + return _SCANNER_TEXT + + async def fake_llm(*args, **kwargs): + return { + "summary": "x", + "major_weaknesses": [ + {"title": "缺数据 accession", "evidence": "全文未见", + "hard_rule": "accession_missing", "fatal": False}, + {"title": "GWAS 校正描述不完整", "evidence": "Methods", + "hard_rule": None, "fatal": False}, + ], + "minor_weaknesses": [], + } + + monkeypatch.setattr(paper_parse, "parse_pdf_bytes", fake_parse) + monkeypatch.setattr(llm, "complete_json", fake_llm) + body = _post(client, "p.pdf", FAKE_PDF, journal="Genome Biology").json() + rat = body["report"]["recommendation_rationale"] + assert rat["major_count"] == 1 and rat["soft_count"] == 1 + assert rat["rule"] == "R2" + assert body["report"]["recommendation"] == "major_revision" + assert len(rat["scanner_conflicts"]) == 1 + + +def test_llm_400_retry_with_smaller_feed(client: TestClient, monkeypatch): + """第二层保护:LLM 报 HTTP 400 → 降 80K 重试一次成功。""" + long_text = "Introduction\n" + ("word " * 40000) # 200K 字符 + calls: list[int] = [] + + async def fake_parse(file_bytes: bytes, filename: str = "paper.pdf") -> str: + return long_text + + async def flaky(system: str, user: str, **kwargs): + calls.append(len(user)) + if len(calls) == 1: + raise llm.LLMError("LLM 调用失败(HTTP 400):Request Entity Too Large") + return {"summary": "x", "major_weaknesses": [], "minor_weaknesses": []} + + monkeypatch.setattr(paper_parse, "parse_pdf_bytes", fake_parse) + monkeypatch.setattr(llm, "complete_json", flaky) + body = _post(client, "p.pdf", FAKE_PDF).json() + assert body["ok"] is True + assert len(calls) == 2 # 重试恰好一次 + assert body["meta"]["truncated"] is True + + +# ---- Bug 2:tier 分档优先级 ---- + +def test_resolve_tier_priority(): + # ① override:13 本顶刊 + "The " 前缀互认 + assert resolve_tier("Nature Genetics") == (1, "override") + assert resolve_tier("NATURE GENETICS") == (1, "override") + assert resolve_tier("The Plant Cell") == (1, "override") + assert resolve_tier("Plant Cell") == (1, "override") + assert resolve_tier("Nature Communications") == (1, "override") + # ② featured:journals.json review_tier + assert resolve_tier("BMC Plant Biology") == (2, "featured") + assert resolve_tier("Rice") == (2, "featured") + # ③ jif 启发式(directory 实数据) + assert resolve_tier("Nucleic Acids Research") == (1, "jif") # jif=15.0 + assert resolve_tier("Euphytica") == (2, "jif") # jif=1.8 + # 综述刊即便 jif=26.4 也不升 tier1 + assert resolve_tier("Annual Review of Plant Biology") == (2, "默认") + # ④ 未命中 → 默认 + assert resolve_tier("Totally Fake Journal") == (2, "默认") + assert resolve_tier("") == (2, "默认") + + +def test_prompt_has_new_vocab(): + """Bug 3:SYSTEM_PROMPT 词表扩充(PRJCA/CRA + BUSCO 三级 + anchored 同义词)。""" + from app.services.self_review import SYSTEM_PROMPT + assert "PRJCA" in SYSTEM_PROMPT and "CRA" in SYSTEM_PROMPT + assert "anchored to chromosome" in SYSTEM_PROMPT + assert "98.57%" in SYSTEM_PROMPT # BUSCO 仅百分比 → minor 示例 diff --git a/review_service/tests/test_rebuttal.py b/review_service/tests/test_rebuttal.py new file mode 100644 index 0000000..ab00da3 --- /dev/null +++ b/review_service/tests/test_rebuttal.py @@ -0,0 +1,198 @@ +"""tests/test_rebuttal.py —— Rebuttal Phase 1 离线单测(mock LLM 与 PDF 解析)。 + +覆盖(对齐任务清单 9 条):单/多 Reviewer 归属、子条拆分(0.7 阈值)、 +location/rebuttal 反幻觉校验、MISSING_RESPONSE 防 LLM 空转、交叉引用、 +信封错误、human_ack 下载门槛。 +运行: cd review_service && /bin/python -m pytest tests/test_rebuttal.py -q +""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +import app.db as db_mod +from app import main as app_main +from app.config import get_settings +from app.services import llm, paper_parse + +BASE = "/api/review/rebuttal" + +REVIEW_TEXT = """Reviewer 1: +1. The statistical methods are inappropriate; multiple-testing correction is missing. +2. Figure 3 is blurry and the legends are incomplete. See comment 1 for related concern. + +Reviewer 2: +1. The novelty is limited. 2. The writing needs improvement. 3. Please clarify the sampling site. +""" + +SEG_LLMA = { + "title": "Statistical methods inappropriate", + "severity": "major", + "sub_items": [{"title": "Missing correction", "body": "Multiple-testing correction is missing.", "confidence": 0.9}], +} +SEG_LLM_MULTI = { + "title": "Multiple issues", + "severity": "minor", + "sub_items": [ + {"title": "Blurry figure", "body": "Figure 3 is blurry.", "confidence": 0.95}, + {"title": "Incomplete legends", "body": "Legends are incomplete.", "confidence": 0.9}, + {"title": "Related", "body": "See comment 1 for related concern.", "confidence": 0.5}, + ], +} + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + db_path = tmp_path / "rebuttal_test.db" + settings = get_settings() + monkeypatch.setattr(settings, "db_path", str(db_path)) + db_mod.set_path(db_path) # 进 lifespan 前注入 + # LLM 打桩:按输入返回预置结构(计数供 MISSING_RESPONSE 用例断言) + calls = {"n": 0} + + async def fake_complete_json(system: str, user: str, **kwargs): + calls["n"] += 1 + if "Multiple issues" in user or "Figure 3" in user: + return dict(SEG_LLM_MULTI) + return dict(SEG_LLMA) + + async def fake_parse_pdf(file_bytes: bytes, filename: str = "paper.pdf") -> str: + return REVIEW_TEXT + + monkeypatch.setattr(llm, "complete_json", fake_complete_json) + monkeypatch.setattr(paper_parse, "parse_pdf_bytes", fake_parse_pdf) + monkeypatch.setattr(app_main, "_REBUTTAL_TEST_CALLS", calls, raising=False) + with TestClient(app_main.app) as c: + c.state_llm_calls = calls # type: ignore[attr-defined] + yield c + + +def _create(client, text=REVIEW_TEXT): + r = client.post(f"{BASE}/jobs", json={"text": text}) + assert r.status_code == 200 + payload = r.json() + assert payload["ok"] is True, payload + return payload + + +def _all_comments(job): + return [c for rev in job["reviewers"] for c in rev["comments"]] + + +def test_single_reviewer_single_comment(client): + payload = _create(client, "Reviewer 1:\n1. Please add FDR correction.") + job = payload["job"] + assert len(job["reviewers"]) == 1 + assert len(job["reviewers"][0]["comments"]) == 1 + assert job["reviewers"][0]["comments"][0]["severity"] == "major" + + +def test_multi_reviewer_attribution(client): + payload = _create(client) + job = payload["job"] + labels = [r["label"] for r in job["reviewers"]] + assert labels == ["Reviewer 1", "Reviewer 2"] + # 无编号段落不被误切:Reviewer 2 段内三个编号 → ≥2 条 + assert len(job["reviewers"][1]["comments"]) >= 2 + + +def test_subitem_split_shares_group_id(client): + payload = _create(client) + job = payload["job"] + split_group = [c for c in _all_comments(job) if c.get("group_id")] + assert len(split_group) >= 2 # LLM confidence ≥0.7 拆出多条 + assert len({c["group_id"] for c in split_group}) == 1 # 同组 + + +def test_location_validation_fail_and_pass(client): + payload = _create(client, "Reviewer 1:\n1. Where is the data?\n2. Where is the code?") + job = payload["job"] + c0, c1 = _all_comments(job)[:2] + # FAIL:shows that 无 added + r = client.patch(f"{BASE}/jobs/{job['id']}/comments/{c0['id']}/response", + json={"parts": [{"type": "location", "content": "S3 shows that the data supports our claim."}]}) + assert r.json()["ok"] is True + r = client.post(f"{BASE}/jobs/{job['id']}/comments/{c0['id']}/draft") + body = r.json() + assert body["ok"] is False and body["error"]["code"] == "VALIDATION_FAILED" + assert any(f["code"] == "LOCATION_EVIDENCE_WEAK" for f in body["meta"]["validation"]["findings"]) + # PASS:we have added ... in S3 + client.patch(f"{BASE}/jobs/{job['id']}/comments/{c1['id']}/response", + json={"parts": [{"type": "location", "content": "We have added the table in S3."}]}) + r = client.post(f"{BASE}/jobs/{job['id']}/comments/{c1['id']}/draft") + assert r.json()["ok"] is True + + +def test_rebuttal_validation(client): + payload = _create(client, "Reviewer 1:\n1. The claim is unsupported.\n2. The design is weak.\n3. The model is wrong.") + job = payload["job"] + cs = _all_comments(job)[:3] + # 负向词 FAIL + client.patch(f"{BASE}/jobs/{job['id']}/comments/{cs[0]['id']}/response", + json={"parts": [{"type": "rebuttal", "content": "We agree with the reviewer on this point."}]}) + r = client.post(f"{BASE}/jobs/{job['id']}/comments/{cs[0]['id']}/draft") + assert r.json()["error"]["code"] == "VALIDATION_FAILED" + # 缺立场词 FAIL + client.patch(f"{BASE}/jobs/{job['id']}/comments/{cs[1]['id']}/response", + json={"parts": [{"type": "rebuttal", "content": "The method follows standard practice in the field."}]}) + r = client.post(f"{BASE}/jobs/{job['id']}/comments/{cs[1]['id']}/draft") + assert r.json()["error"]["code"] == "VALIDATION_FAILED" + # respectfully maintain 过 + client.patch(f"{BASE}/jobs/{job['id']}/comments/{cs[2]['id']}/response", + json={"parts": [{"type": "rebuttal", "content": "We respectfully maintain our conclusion based on the updated analysis."}]}) + r = client.post(f"{BASE}/jobs/{job['id']}/comments/{cs[2]['id']}/draft") + assert r.json()["ok"] is True + + +def test_missing_response_no_llm_call(client): + payload = _create(client, "Reviewer 1:\n1. Please clarify.") + job = payload["job"] + c = _all_comments(job)[0] + before = client.state_llm_calls["n"] + r = client.post(f"{BASE}/jobs/{job['id']}/comments/{c['id']}/draft") + body = r.json() + assert body["ok"] is False and body["error"]["code"] == "MISSING_RESPONSE" + assert client.state_llm_calls["n"] == before # 未空转 LLM + + +def test_cross_ref_unresolved_keeps_raw(client): + payload = _create(client, "Reviewer 1:\n1. First issue here.\n2. Related concern, see comment 9.") + job = payload["job"] + cs = _all_comments(job) + refs = cs[1].get("cross_refs") or [] + assert refs and refs[0]["raw"] # raw 保留 + assert refs[0]["target_id"] is None # 解析不到 → null(不猜) + + +def test_envelope_errors(client): + # job 不存在 + r = client.get(f"{BASE}/jobs/rbt_nope") + assert r.status_code == 200 and r.json()["error"]["code"] == "NOT_FOUND" + # 非 PDF + r = client.post(f"{BASE}/jobs/pdf", files={"file": ("a.txt", b"hello", "text/plain")}) + assert r.json()["error"]["code"] == "VALIDATION" + # PDF 超限(> max_pdf_bytes)——用超限字节直接触发大小校验(mock 解析不会被调) + settings = get_settings() + big = b"%PDF" + b"0" * (settings.max_pdf_bytes + 1) + r = client.post(f"{BASE}/jobs/pdf", files={"file": ("a.pdf", big, "application/pdf")}) + assert r.json()["error"]["code"] == "VALIDATION" + assert "上限" in r.json()["error"]["message"] + + +def test_human_ack_gate_on_letter_download(client): + payload = _create(client, "Reviewer 1:\n1. Issue A.\n2. Issue B.") + job = payload["job"] + cs = _all_comments(job) + for c in cs: + client.patch(f"{BASE}/jobs/{job['id']}/comments/{c['id']}/response", + json={"parts": [{"type": "clarification", "content": "We clarified in the revised Methods."}]}) + client.post(f"{BASE}/jobs/{job['id']}/comments/{c['id']}/draft") + # 未全勾 → human_ack=false → 下载 400 + r = client.get(f"{BASE}/jobs/{job['id']}/letter.md") + assert r.status_code == 400 and "HUMAN_ACK_REQUIRED" in r.text + # 全勾 → human_ack=true → 200 + for c in cs: + client.patch(f"{BASE}/jobs/{job['id']}/comments/{c['id']}/confirm", json={"confirmed": True}) + r = client.get(f"{BASE}/jobs/{job['id']}/letter.md") + assert r.status_code == 200 + assert "Response to Reviewers" in r.text -- 2.52.0 From c4dc344a313ba8a4cec050cd3a7568b80c9ed7a6 Mon Sep 17 00:00:00 2001 From: potaser Date: Mon, 12 Oct 2026 16:38:28 +0800 Subject: [PATCH 3/4] feat(edu): presubmit UI + rebuttal assistant + tracker removal - PresubmitPanel: rationale / conflict chips / file persistence - RebuttalPanel: Phase 1 UI (3-column, 5-type form, stance pre-check) - ReformatPanel / ReportPanel: hide internal fields (job id / version) - EduSideNav: SVG icon, remove tracker menu item - EduWorkspace: unmount tracker - edu.js: rebuttal state + presubmitFile + SECTIONS (drop tracker) --- ui/src/api/review.js | 70 +- ui/src/components/edu/EduSideNav.vue | 9 +- ui/src/components/edu/EduWorkspace.vue | 6 +- .../components/edu/panels/PresubmitPanel.vue | 88 ++- .../components/edu/panels/RebuttalPanel.vue | 635 ++++++++++++++++++ .../components/edu/panels/ReformatPanel.vue | 33 +- ui/src/components/edu/panels/ReportPanel.vue | 15 +- ui/src/components/edu/panels/TrackerPanel.vue | 252 ++++--- ui/src/stores/edu.js | 144 +++- 9 files changed, 1148 insertions(+), 104 deletions(-) create mode 100644 ui/src/components/edu/panels/RebuttalPanel.vue diff --git a/ui/src/api/review.js b/ui/src/api/review.js index e4419a1..ca4bddb 100644 --- a/ui/src/api/review.js +++ b/ui/src/api/review.js @@ -6,7 +6,7 @@ // 而 http.js 只对非 2xx 抛 ApiError —— 200 信封会正常 resolve。 // unwrap() 把两条错误路径(200 信封 / 非 2xx http.js 抛出)归一成 ApiError, // 调用方只需 catch + reviewErrorMessage(err) 一个口径。 -import { get, post, ApiError } from './http.js'; +import { get, post, patch, del, ApiError } from './http.js'; const BASE = '/api/review'; @@ -133,3 +133,71 @@ export function reviewErrorMessage(err) { } return '服务异常,请稍后重试'; } + +/* —— Rebuttal Phase 1(审稿回复助手,10 端点)—— */ +const RB = `${BASE}/rebuttal`; + +/** POST /rebuttal/jobs {text} → {ok, job, meta:{reviewers,comments}} 文本粘贴主入口 */ +export function rebuttalCreateJob(text, opts = {}) { + return unwrap(post(`${RB}/jobs`, { text }, opts)); +} + +/** POST /rebuttal/jobs/pdf(multipart)→ 同上;PDF 次要入口(批注版审稿 PDF) */ +export function rebuttalCreateFromPdf(formData, opts = {}) { + return unwrap(post(`${RB}/jobs/pdf`, formData, opts)); +} + +/** GET /rebuttal/jobs/{id} → {ok, job} 全量还原 */ +export function rebuttalGetJob(id, opts = {}) { + return unwrap(get(`${RB}/jobs/${id}`, {}, opts)); +} + +/** PATCH /rebuttal/jobs/{id}/comments/{cid}/response {parts} → {ok, job} */ +export function rebuttalSaveResponse(id, cid, parts, opts = {}) { + return unwrap(patch(`${RB}/jobs/${id}/comments/${cid}/response`, { parts }, opts)); +} + +/** POST /rebuttal/jobs/{id}/comments/{cid}/draft → {ok, job} 或校验失败 + * {ok:false, error:{code:'VALIDATION_FAILED'}, job, meta:{validation}}(校验C前置)。 + * 不走 unwrap:校验失败信封里 job/meta 也是有效载荷,需原样返回给面板。 */ +export function rebuttalDraft(id, cid, opts = {}) { + return post(`${RB}/jobs/${id}/comments/${cid}/draft`, {}, opts).then((payload) => { + if (payload && payload.ok === false && payload.error) { + const err = new ApiError(payload.error.message || '草稿生成失败', { + code: payload.error.code || 'INTERNAL', + status: 200, + degraded: !!payload.error.degraded, + }); + err.payload = payload; // 信封全量(含 job + meta.validation) + throw err; + } + return payload; + }); +} + +/** PATCH /rebuttal/jobs/{id}/comments/{cid}/confirm {confirmed} → {ok, job, meta.human_ack} */ +export function rebuttalConfirm(id, cid, confirmed, opts = {}) { + return unwrap(patch(`${RB}/jobs/${id}/comments/${cid}/confirm`, { confirmed }, opts)); +} + +/** POST /rebuttal/jobs/{id}/letter → {ok, job}(job.letter_md 已拼装) */ +export function rebuttalLetter(id, opts = {}) { + return unwrap(post(`${RB}/jobs/${id}/letter`, {}, opts)); +} + +/** GET /rebuttal/jobs/{id}/letter.md —— 纯文本下载(human_ack 门槛,非信封)。 + * 返回 Promise;400 时抛 ApiError(code 取自响应文本前缀)。 */ +export async function rebuttalDownloadLetterMd(id) { + const resp = await fetch(`${RB}/jobs/${id}/letter.md`); + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + const code = text.startsWith('HUMAN_ACK_REQUIRED') ? 'HUMAN_ACK_REQUIRED' : 'DOWNLOAD_FAILED'; + throw new ApiError(text || `下载失败(HTTP ${resp.status})`, { code, status: resp.status }); + } + return resp.text(); +} + +/** DELETE /rebuttal/jobs/{id} → {ok:true, meta:{deleted}} 手动清(无 TTL) */ +export function rebuttalDeleteJob(id, opts = {}) { + return unwrap(del(`${RB}/jobs/${id}`, opts)); +} diff --git a/ui/src/components/edu/EduSideNav.vue b/ui/src/components/edu/EduSideNav.vue index 4e89d4f..1219aa0 100644 --- a/ui/src/components/edu/EduSideNav.vue +++ b/ui/src/components/edu/EduSideNav.vue @@ -16,7 +16,8 @@ const ITEMS = [ { key: 'report', label: '专业审稿意见报告', icon: '📝' }, { key: 'format', label: '论文排版与规范', icon: '📐' }, { key: 'reformat', label: '论文自动重排版', icon: '🧩' }, - { key: 'tracker', label: '投稿进度与返修', icon: '📮' }, + // SVG 内联(U+1F4AC/U+2709 在用户 Ubuntu 浏览器均无字形 → 方框;SVG 不依赖字体) + { key: 'rebuttal', label: '审稿回复助手', icon: '' }, { key: 'calendar', label: '会议与投稿日历', icon: '📅' }, ]; @@ -35,7 +36,9 @@ const ITEMS = [ :title="it.label" @click="edu.setSection(it.key)" > - + +