From 8b228923bd9759262628f1631d2137e5ec7d15c5 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 8 Oct 2026 14:59:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(paper):=20=E5=8F=96=E6=B6=88=E8=B7=AF?= =?UTF-8?q?=E5=BE=84(cancel=5Fpaper)=20+=20=E5=BC=95=E7=94=A8=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=B8=85=E6=B4=97(=E4=B8=A2=E5=BC=83?= =?UTF-8?q?=E6=9D=9C=E6=92=B0=20DOI/PMID)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/services/paper/write.py | 31 +++++++++++++++---- sdk/app/services/paper/agent.py | 19 ++++++++++++ tests/paper/test_paper_tools.py | 16 ++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/paper_writer_service/app/services/paper/write.py b/paper_writer_service/app/services/paper/write.py index 4787749..eb9dbfa 100644 --- a/paper_writer_service/app/services/paper/write.py +++ b/paper_writer_service/app/services/paper/write.py @@ -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}) diff --git a/sdk/app/services/paper/agent.py b/sdk/app/services/paper/agent.py index 6af8a24..218a638 100644 --- a/sdk/app/services/paper/agent.py +++ b/sdk/app/services/paper/agent.py @@ -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: diff --git a/tests/paper/test_paper_tools.py b/tests/paper/test_paper_tools.py index 353a9c5..26d3aed 100644 --- a/tests/paper/test_paper_tools.py +++ b/tests/paper/test_paper_tools.py @@ -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 次)