From cf5ccb3a37eb48181082fef3981412298e01bd4a Mon Sep 17 00:00:00 2001 From: potaser Date: Mon, 12 Oct 2026 16:38:28 +0800 Subject: [PATCH] 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