forked from admin/PlantAI-rag-new
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)
This commit is contained in:
@@ -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 条需人工核验后另行入库。
|
||||
@@ -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 "默认",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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} 条 → 建议小修。")
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 && <venv>/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):
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
<span v-if="meta.truncated" class="pp-truncated">· 正文超限已截断至 6 万字符</span>
|
||||
</div>
|
||||
|
||||
<p class="pp-ai-note">本判定由 AI 生成,仅供投稿前参考;fatal 级判定请务必人工核对后再决定。</p>
|
||||
|
||||
<div class="pp-topline">
|
||||
<span class="pp-rec" :class="rec.cls">{{ rec.label }}</span>
|
||||
<span v-if="isReject" class="pp-rec-warn">建议人工复核</span>
|
||||
<span v-if="tierText" class="pp-tier">{{ tierText }}</span>
|
||||
<button class="pp-again" type="button" @click="reset">重新上传</button>
|
||||
</div>
|
||||
|
||||
<!-- 判定依据(规则引擎 rationale:可解释、可复现) -->
|
||||
<div v-if="rationale" class="pp-rationale">
|
||||
<div class="pp-rationale-head">判定依据</div>
|
||||
<p class="pp-rationale-text">{{ rationale.explanation }}</p>
|
||||
<div v-if="hardHits.length" class="pp-hard-hits">
|
||||
<span v-for="(h, i) in hardHits" :key="'hh-' + i" class="pp-hard-chip">{{ h }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="pp-summary">{{ report.summary }}</p>
|
||||
|
||||
<!-- 主要问题(重视觉) -->
|
||||
@@ -126,6 +165,8 @@ function reset() {
|
||||
>
|
||||
<div class="pp-card-title">
|
||||
<span class="pp-idx">{{ i + 1 }}</span>{{ it.title }}
|
||||
<span v-if="it.hard_rule" class="pp-hard-badge" title="硬性规则命中,不可协商">硬性</span>
|
||||
<span v-if="it.fatal" class="pp-fatal-badge" title="fatal 级:请人工核对证据">fatal</span>
|
||||
</div>
|
||||
<p class="pp-detail">{{ it.detail }}</p>
|
||||
<div v-if="it.evidence" class="pp-evidence">
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user