forked from admin/PlantAI-rag-new
- paper_writer_service(8097):FastAPI + MCP streamable-http 单端点,9 工具
(propose_paper_outline/write_paper_section/get_paper_progress/search_refs/
revise_paper_section/revise_paper/get_paper/complete_paper/cancel_paper),
SQLite 三表(papers/paper_sections/citations,WAL),GB/T 7714 引用协议
- BFF:services/paper/{mcp_client,agent}.py(裸 JSON-RPC 客户端 + 规则状态机)、
routers/paper.py、schemas.py 4 个 Paper*Block + ArtifactBlock 加 paper_id/citation_count、
config.py 开关、main.py 条件注册、docgen.py 抽 insert_artifact 公共 helper
- 前端:intent.js paperIntent 纯函数、Composer docgen 分支 sub-route、sendPaper/轮询、
PaperBriefCard/PaperOutlineCard/PaperProgressCard、DocPane cite-chip
- deploy/paper_restart.sh + toggle_paper.sh;compile_sdk.sh 白名单追加 paper 文件
- tests/paper 26 条(工具逻辑 + MCP 协议 + BFF 状态机 + 块 schema + 前端 intent 单测)
257 lines
11 KiB
Python
257 lines
11 KiB
Python
"""tests/paper/test_paper_tools.py —— 论文服务工具逻辑单元测试(假 LLM + 假检索)。
|
||
|
||
覆盖:propose_paper_outline / write_paper_section(含引用校验重试)/ get_paper_progress /
|
||
revise_paper_section / revise_paper / get_paper / complete_paper(重排 + disclaimer)/ cancel_paper +
|
||
引用协议纯函数(GB/T 7714 渲染、[n] 重排)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from conftest import _run
|
||
from paper_writer_service.app import db as dbmod
|
||
from paper_writer_service.app.services.paper import cite, complete, outline, write
|
||
|
||
OUTLINE_RES = {
|
||
"title": "水稻SPL基因家族研究进展",
|
||
"abstract_expect": "综述水稻SPL基因家族的结构、功能与调控机制。",
|
||
"sections": [
|
||
{"section_id": "s1", "title": "引言", "key_points": ["SPL家族概述"], "target_words": 1500},
|
||
{"section_id": "s2", "title": "SPL家族结构与进化", "key_points": ["结构特征"], "target_words": 2000},
|
||
{"section_id": "s3", "title": "SPL功能与调控", "key_points": ["功能"], "target_words": 2000},
|
||
{"section_id": "s4", "title": "讨论与展望", "key_points": ["展望"], "target_words": 1500},
|
||
{"section_id": "s5", "title": "结论", "key_points": ["总结"], "target_words": 1000},
|
||
],
|
||
}
|
||
|
||
|
||
def _propose(fake_llm, fake_search):
|
||
fake_llm.responses.append(dict(OUTLINE_RES))
|
||
return _run(outline.propose_paper_outline("水稻SPL基因家族", paper_type="综述", language="中文"))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# propose_paper_outline
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_propose_outline_creates_paper_and_sections(paper_db, fake_llm, fake_search):
|
||
r = _propose(fake_llm, fake_search)
|
||
assert r["ok"] is True
|
||
assert r["title"] == "水稻SPL基因家族研究进展"
|
||
assert len(r["sections"]) == 5
|
||
|
||
papers = _run(dbmod.db.fetchall("SELECT * FROM papers"))
|
||
assert len(papers) == 1
|
||
secs = _run(dbmod.db.fetchall("SELECT * FROM paper_sections ORDER BY rowid"))
|
||
assert [s["section_id"] for s in secs] == ["s1", "s2", "s3", "s4", "s5"]
|
||
assert all(s["status"] == "pending" for s in secs)
|
||
|
||
|
||
def test_propose_outline_llm_error(paper_db, fake_llm, fake_search):
|
||
fake_llm.error = "deepseek 超时"
|
||
r = _run(outline.propose_paper_outline("水稻SPL"))
|
||
assert r["ok"] is False
|
||
assert r["code"] == "LLM_ERROR"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# write_paper_section(引用校验 + 落库)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_write_section_creates_citations(paper_db, fake_llm, fake_search):
|
||
paper_id = _propose(fake_llm, fake_search)["paper_id"]
|
||
fake_llm.responses.append({
|
||
"markdown": "SPL 是植物特有的转录因子家族[1],水稻有 19 个成员[2]。",
|
||
"citations_used": [
|
||
{"temp_n": 1, "title": "水稻SPL基因家族研究进展", "journal": "植物学报", "year": "2024",
|
||
"doi": "", "pmid": "", "source_doc_id": "d1", "snippet": "x"},
|
||
{"temp_n": 2, "title": "植物SPL转录因子功能", "journal": "", "year": "2023",
|
||
"doi": "", "pmid": "", "source_doc_id": "d2", "snippet": "y"},
|
||
],
|
||
})
|
||
r = _run(write.write_paper_section(paper_id, "s1"))
|
||
assert r["ok"] is True
|
||
assert r["status"] == "done"
|
||
assert len(r["new_citations"]) == 2
|
||
|
||
cits = _run(dbmod.db.fetchall("SELECT * FROM citations ORDER BY temp_n"))
|
||
assert [c["temp_n"] for c in cits] == [1, 2]
|
||
assert cits[0]["kind"] == "title"
|
||
sec = _run(dbmod.db.fetchone("SELECT * FROM paper_sections WHERE paper_id = ? AND section_id = 's1'", (paper_id,)))
|
||
assert sec["status"] == "done"
|
||
assert "[1]" in sec["markdown"]
|
||
|
||
|
||
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 次)
|
||
# 第一次引用非法编号 [99],第二次合法
|
||
fake_llm.responses.append({
|
||
"markdown": "SPL 家族[99]。",
|
||
"citations_used": [{"temp_n": 1, "title": "t", "source_doc_id": "d1"}],
|
||
})
|
||
fake_llm.responses.append({
|
||
"markdown": "SPL 家族[1]。",
|
||
"citations_used": [{"temp_n": 1, "title": "t", "source_doc_id": "d1"}],
|
||
})
|
||
r = _run(write.write_paper_section(paper_id, "s1"))
|
||
assert r["ok"] is True
|
||
assert len(fake_llm.calls) == 2 # 一次失败 + 一次重试成功
|
||
|
||
|
||
def test_write_section_invalid_citation_fails_after_retries(paper_db, fake_llm, fake_search):
|
||
paper_id = _propose(fake_llm, fake_search)["paper_id"]
|
||
fake_llm.responses.append({"markdown": "SPL 家族[99]。", "citations_used": []})
|
||
fake_llm.responses.append({"markdown": "SPL 家族[88]。", "citations_used": []})
|
||
r = _run(write.write_paper_section(paper_id, "s1"))
|
||
assert r["ok"] is False
|
||
assert r["code"] == "CITE_VALIDATION_FAILED"
|
||
|
||
|
||
def test_write_section_not_found(paper_db, fake_llm):
|
||
r = _run(write.write_paper_section("nope", "s1"))
|
||
assert r["ok"] is False
|
||
assert r["code"] == "NOT_FOUND"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# get_paper_progress / get_paper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_progress_reflects_done_section(paper_db, fake_llm, fake_search):
|
||
paper_id = _propose(fake_llm, fake_search)["paper_id"]
|
||
fake_llm.responses.append({
|
||
"markdown": "正文[1]。",
|
||
"citations_used": [{"temp_n": 1, "title": "t", "source_doc_id": "d1"}],
|
||
})
|
||
_run(write.write_paper_section(paper_id, "s1"))
|
||
r = _run(complete.get_paper_progress(paper_id))
|
||
assert r["ok"] is True
|
||
assert r["status"] == "writing"
|
||
s1 = next(s for s in r["sections"] if s["section_id"] == "s1")
|
||
assert s1["status"] == "done"
|
||
assert s1["citation_count"] == 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# revise_paper_section / revise_paper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_revise_section_appends_new_citation(paper_db, fake_llm, fake_search):
|
||
paper_id = _propose(fake_llm, fake_search)["paper_id"]
|
||
fake_llm.responses.append({
|
||
"markdown": "旧正文[1]。",
|
||
"citations_used": [{"temp_n": 1, "title": "旧文献", "source_doc_id": "d1"}],
|
||
})
|
||
_run(write.write_paper_section(paper_id, "s1"))
|
||
# 修订:新增 [2]
|
||
fake_llm.responses.append({
|
||
"markdown": "新正文[1][2]。",
|
||
"citations_used": [
|
||
{"temp_n": 1, "title": "旧文献", "source_doc_id": "d1"},
|
||
{"temp_n": 2, "title": "新文献", "source_doc_id": "d3"},
|
||
],
|
||
})
|
||
r = _run(write.revise_paper_section(paper_id, "s1", "更严谨"))
|
||
assert r["ok"] is True
|
||
assert r["citation_delta"] == 1
|
||
cits = _run(dbmod.db.fetchall("SELECT temp_n FROM citations ORDER BY temp_n"))
|
||
assert [c["temp_n"] for c in cits] == [1, 2]
|
||
|
||
|
||
def test_revise_paper_returns_affected(paper_db, fake_llm, fake_search):
|
||
paper_id = _propose(fake_llm, fake_search)["paper_id"]
|
||
fake_llm.responses.append({"affected_section_ids": ["s2", "s3"], "plan": "修订讨论与结论"})
|
||
r = _run(write.revise_paper(paper_id, "把讨论改严谨"))
|
||
assert r["ok"] is True
|
||
assert r["affected_section_ids"] == ["s2", "s3"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# complete_paper(重排 + GB/T 7714 + disclaimer)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_complete_renumbers_by_first_appearance(paper_db, fake_llm, fake_search):
|
||
paper_id = _propose(fake_llm, fake_search)["paper_id"]
|
||
# s1 先引用 [2],s2 后引用 [1] → complete 后 [2] 应重排为 [1]
|
||
fake_llm.responses.append({
|
||
"markdown": "引言提到 B[2]。",
|
||
"citations_used": [{"temp_n": 2, "title": "文献B", "source_doc_id": "d2"}],
|
||
})
|
||
_run(write.write_paper_section(paper_id, "s1"))
|
||
fake_llm.responses.append({
|
||
"markdown": "正文提到 A[1]。",
|
||
"citations_used": [{"temp_n": 1, "title": "文献A", "source_doc_id": "d1"}],
|
||
})
|
||
_run(write.write_paper_section(paper_id, "s2"))
|
||
|
||
r = _run(complete.complete_paper(paper_id))
|
||
assert r["ok"] is True
|
||
# 首次出现顺序:B(temp 2) 在 s1,A(temp 1) 在 s2 → 2→1, 1→2
|
||
assert r["renumber_map"] == {2: 1, 1: 2}
|
||
assert r["references"][0]["n"] == 1
|
||
assert r["references"][0]["text"].startswith("文献B")
|
||
assert r["disclaimer"] != "" # title kind → 降级 disclaimer
|
||
|
||
paper = _run(dbmod.db.fetchone("SELECT * FROM papers WHERE id = ?", (paper_id,)))
|
||
assert paper["status"] == "complete"
|
||
assert "[1]" in paper["final_markdown"] and "[2]" in paper["final_markdown"]
|
||
|
||
|
||
def test_complete_paper_no_citations_no_disclaimer(paper_db, fake_llm, fake_search):
|
||
paper_id = _propose(fake_llm, fake_search)["paper_id"]
|
||
fake_llm.responses.append({"markdown": "无引用正文。", "citations_used": []})
|
||
_run(write.write_paper_section(paper_id, "s1"))
|
||
r = _run(complete.complete_paper(paper_id))
|
||
assert r["ok"] is True
|
||
assert r["references"] == []
|
||
assert r["disclaimer"] == ""
|
||
|
||
|
||
def test_get_paper_returns_full_markdown(paper_db, fake_llm, fake_search):
|
||
paper_id = _propose(fake_llm, fake_search)["paper_id"]
|
||
fake_llm.responses.append({"markdown": "正文[1]。", "citations_used": [
|
||
{"temp_n": 1, "title": "t", "source_doc_id": "d1"}]})
|
||
_run(write.write_paper_section(paper_id, "s1"))
|
||
_run(complete.complete_paper(paper_id))
|
||
r = _run(complete.get_paper(paper_id))
|
||
assert r["ok"] is True
|
||
assert r["status"] == "complete"
|
||
assert "水稻SPL基因家族研究进展" in r["full_markdown"]
|
||
assert len(r["references"]) == 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# cancel_paper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_cancel_paper(paper_db, fake_llm, fake_search):
|
||
paper_id = _propose(fake_llm, fake_search)["paper_id"]
|
||
r = _run(complete.cancel_paper(paper_id))
|
||
assert r["ok"] is True
|
||
paper = _run(dbmod.db.fetchone("SELECT status FROM papers WHERE id = ?", (paper_id,)))
|
||
assert paper["status"] == "cancelled"
|
||
|
||
|
||
def test_cancel_paper_not_found(paper_db):
|
||
r = _run(complete.cancel_paper("nope"))
|
||
assert r["ok"] is False
|
||
assert r["code"] == "NOT_FOUND"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 引用协议纯函数(cite)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_gbt7714_doi_pmid_title():
|
||
assert "DOI:10.1000/x" in cite.render_gbt7714({"title": "T", "journal": "J", "year": "2024", "doi": "10.1000/x"})
|
||
assert "PMID:123" in cite.render_gbt7714({"title": "T", "journal": "J", "year": "2024", "pmid": "123"})
|
||
degraded = cite.render_gbt7714({"title": "知识库标题", "year": "2023"})
|
||
assert degraded.endswith("知识库资料, 2023. [EB/OL].")
|
||
|
||
|
||
def test_renumber_map_first_appearance():
|
||
mapping = cite.renumber_map([1, 2, 3], "见[3]和[1],以及[2]")
|
||
assert mapping == {3: 1, 1: 2, 2: 3}
|
||
assert cite.replace_numbers("见[3]和[1]", {3: 1, 1: 2}) == "见[1]和[2]"
|