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);