fix(paper): 取消路径(cancel_paper) + 引用元数据清洗(丢弃杜撰 DOI/PMID)

This commit is contained in:
2026-10-08 14:59:16 +08:00
parent 2e5372cf90
commit 8b228923bd
3 changed files with 60 additions and 6 deletions
@@ -10,10 +10,11 @@ from .cite import find_cite_numbers, render_gbt7714
_WRITE_SYSTEM = (
"你是植物学论文撰写助手,正在撰写论文的某一章节。"
"引用文献一律用 [n] 指代,n 必须取自下方提供的全局编号列表;如需引用新文献,编号按当前最大编号依次 +1。"
"禁止杜撰 DOI/PMID/作者/期刊——只能使用编号列表或知识库线索中真实给出的元数据。"
"你只能引用「知识库线索」中列出的文献;新引用的元数据只能照抄线索里的 title 和 doc_id。"
"知识库线索里没有 DOI/PMID/作者/期刊/年份,这些字段一律输出空字符串,禁止编造任何 DOI/PMID/作者/期刊。"
"只输出一个合法 JSON 对象:{\"markdown\": 章节正文(不含章标题), \"citations_used\": "
"[{\"temp_n\": 编号, \"title\": \"...\", \"authors\": \"...\", \"journal\": \"...\", "
"\"year\": \"...\", \"doi\": \"...\", \"pmid\": \"...\", \"source_doc_id\": \"...\", \"snippet\": \"...\"}]}。"
"[{\"temp_n\": 编号, \"title\": \"...\", \"authors\": \"\", \"journal\": \"\", "
"\"year\": \"\", \"doi\": \"\", \"pmid\": \"\", \"source_doc_id\": \"...\", \"snippet\": \"...\"}]}。"
"citations_used 必须列出本节正文中出现的每一个 [n] 引用(既有编号也列出其已有元数据)。"
)
@@ -76,6 +77,13 @@ def _build_write_user(paper: dict, outline: dict, sec: dict, cit_text: str, styl
parts.append("")
parts.append("全局已编号文献列表([n] 引用只能从这里取值,或从最大编号 +1 起新增):")
parts.append(cit_text)
brief = loads_json(paper.get("brief_json"), {})
kb_hints = brief.get("kb_hints") or []
if kb_hints:
parts.append("")
parts.append("知识库线索(新引用只能从这里选,元数据只照抄 title/doc_id):")
for h in kb_hints:
parts.append(f"- title={h.get('title', '')} | doc_id={h.get('doc_id', '')} | snippet={str(h.get('snippet', ''))[:120]}")
return "\n".join(parts)
@@ -91,6 +99,16 @@ def _kind_of(meta: dict[str, Any]) -> str:
return "title"
def _sanitize_citation(meta: dict[str, Any]) -> dict[str, Any]:
"""丢弃 LLM 可能杜撰的 doi/pmid/authors/journal/year(知识库线索无这些字段),
只保留 title/source_doc_id/snippet,强制走 title kind 降级格式。"""
return {
"title": (meta.get("title") or "").strip(),
"source_doc_id": meta.get("source_doc_id") or "",
"snippet": meta.get("snippet") or "",
}
async def _persist_new_citations(paper_id: str, section_id: str, used: list[Any], existing_nums: set[int]) -> list[dict[str, Any]]:
"""把 citations_used 中的新引用(temp_n 不在既有集合)落库,返回新增摘要。"""
new_citations: list[dict[str, Any]] = []
@@ -102,13 +120,14 @@ async def _persist_new_citations(paper_id: str, section_id: str, used: list[Any]
if not tn or tn in existing_nums or tn in seen_new:
continue
seen_new.add(tn)
ref_text = render_gbt7714(c)
meta = _sanitize_citation(c)
ref_text = render_gbt7714(meta)
await db.execute(
"INSERT INTO citations (paper_id, section_id, temp_n, kind, ref_text, source_doc_id, snippet, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
paper_id, section_id, tn, _kind_of(c), ref_text,
c.get("source_doc_id") or "", c.get("snippet") or "", now_iso(),
paper_id, section_id, tn, _kind_of(meta), ref_text,
meta.get("source_doc_id") or "", meta.get("snippet") or "", now_iso(),
),
)
new_citations.append({"temp_n": tn, "ref_text": ref_text})
+19
View File
@@ -139,6 +139,14 @@ async def _outline_message(db, session_id: str, outline: dict[str, Any]) -> list
return blocks
_CANCEL_WORDS = ("暂停", "取消", "停止", "不写了", "终止", "中止", "算了")
def _is_cancel_request(text: str) -> bool:
s = (text or "").strip()
return bool(s) and any(w in s for w in _CANCEL_WORDS)
async def _followup(db, session_id: str, text: str, paper_id: str, client: PaperMcpClient) -> list[dict[str, Any]]:
prog = await client.call_tool("get_paper_progress", {"paper_id": paper_id})
if not prog.get("ok"):
@@ -147,6 +155,14 @@ async def _followup(db, session_id: str, text: str, paper_id: str, client: Paper
return blocks
status = prog.get("status")
if _is_cancel_request(text):
res = await client.call_tool("cancel_paper", {"paper_id": paper_id})
if res.get("ok"):
blocks = [_text_block("论文已取消。如需重新撰写,请告诉我新主题。")]
else:
blocks = [_text_block(f"取消失败:{res.get('message', '')}")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
if status == "complete":
return await _revise(db, session_id, paper_id, text, client)
if status == "cancelled":
@@ -216,6 +232,9 @@ async def run_writing_loop(db, paper_id: str, session_id: str) -> None:
if not prog.get("ok"):
await _write_message(db, session_id, blocks=[_text_block("撰写进度查询失败。")], text="撰写失败")
return
if prog.get("status") == "cancelled":
await _write_message(db, session_id, blocks=[_text_block("论文已取消。")], text="已取消")
return
sections = prog.get("sections") or []
pending = [s for s in sections if s.get("status") != "done"]
if not pending:
+16
View File
@@ -82,6 +82,22 @@ def test_write_section_creates_citations(paper_db, fake_llm, fake_search):
assert "[1]" in sec["markdown"]
def test_write_section_drops_fabricated_doi(paper_db, fake_llm, fake_search):
"""杜撰的 doi/pmid/authors/journal 应被丢弃,强制 title kind 降级格式。"""
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({
"markdown": "正文[1]。",
"citations_used": [{"temp_n": 1, "title": "水稻SPL研究", "authors": "X", "journal": "J",
"year": "2020", "doi": "10.1234/fake", "pmid": "123", "source_doc_id": "d1"}],
})
r = _run(write.write_paper_section(paper_id, "s1"))
assert r["ok"] is True
cit = _run(dbmod.db.fetchone("SELECT * FROM citations WHERE temp_n = 1"))
assert cit["kind"] == "title"
assert "知识库资料" in cit["ref_text"]
assert "10.1234" not in cit["ref_text"]
def test_write_section_invalid_citation_retries_once(paper_db, fake_llm, fake_search):
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.calls.clear() # 重置计数,只统计写节调用(大纲已消费 1 次)