forked from admin/PlantAI-rag-new
feat(review): presubmit 4-bug fix + rebuttal Phase 1 backend
- 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)
This commit is contained in:
@@ -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)----
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 示例
|
||||
|
||||
@@ -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 && <venv>/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
|
||||
Reference in New Issue
Block a user