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 单测)
124 lines
5.4 KiB
Python
124 lines
5.4 KiB
Python
"""tests/paper/test_paper_bff_agent.py —— BFF 论文大脑状态机测试(假 MCP server + 假 LLM)。
|
|
|
|
跑通 brief→outline→confirm(写节循环)→complete→artifact 全状态机。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from conftest import _run
|
|
from app import db as bff_db
|
|
from app.routers import docgen as docgen_mod
|
|
from app.services.paper import agent
|
|
|
|
|
|
class FakePaperService:
|
|
"""脚本化 paper 服务:按工具名返回预设结果,模拟写节循环推进。"""
|
|
|
|
def __init__(self):
|
|
self.paper_id = "p1"
|
|
self.s1_done = False
|
|
self.s2_done = False
|
|
|
|
async def call_tool(self, name, arguments):
|
|
if name == "propose_paper_outline":
|
|
return {"ok": True, "paper_id": self.paper_id, "title": "水稻SPL基因家族研究进展",
|
|
"abstract_expect": "综述", "sections": [
|
|
{"section_id": "s1", "title": "引言", "key_points": [], "target_words": 1500},
|
|
{"section_id": "s2", "title": "讨论", "key_points": [], "target_words": 1500},
|
|
]}
|
|
if name == "get_paper_progress":
|
|
sections = [
|
|
{"section_id": "s1", "title": "引言", "status": "done" if self.s1_done else "pending",
|
|
"word_count": 0, "citation_count": 0},
|
|
{"section_id": "s2", "title": "讨论", "status": "done" if self.s2_done else "pending",
|
|
"word_count": 0, "citation_count": 0},
|
|
]
|
|
return {"ok": True, "paper_id": self.paper_id, "status": "writing",
|
|
"sections": sections, "current_section_id": None}
|
|
if name == "write_paper_section":
|
|
sid = arguments["section_id"]
|
|
if sid == "s1":
|
|
self.s1_done = True
|
|
else:
|
|
self.s2_done = True
|
|
return {"ok": True, "section_id": sid, "status": "done",
|
|
"markdown": "内容" * 50, "citations_used": [], "new_citations": []}
|
|
if name == "complete_paper":
|
|
return {"ok": True, "full_markdown": "# 全文\n内容",
|
|
"references": [{"n": 1, "text": "X.", "kind": "title", "source_doc_id": "d1"}],
|
|
"renumber_map": {}, "disclaimer": "d"}
|
|
if name == "get_paper":
|
|
return {"ok": True, "paper_id": self.paper_id, "title": "水稻SPL基因家族研究进展",
|
|
"status": "complete", "full_markdown": "# 全文",
|
|
"references": [{"n": 1, "text": "X.", "kind": "title", "source_doc_id": "d1"}],
|
|
"disclaimer": "d"}
|
|
if name == "cancel_paper":
|
|
return {"ok": True}
|
|
if name == "revise_paper":
|
|
return {"ok": True, "affected_section_ids": ["s1"], "plan": "修订引言"}
|
|
if name == "revise_paper_section":
|
|
return {"ok": True, "section_id": arguments["section_id"], "markdown": "改后", "citations_used": []}
|
|
raise AssertionError(f"unexpected tool: {name}")
|
|
|
|
|
|
@pytest.fixture
|
|
def bff_database(tmp_path):
|
|
_run(bff_db.init_db(tmp_path / "bff.db"))
|
|
_run(bff_db.db.execute(
|
|
"INSERT INTO sessions (id, title, status, meta, created_at, updated_at) "
|
|
"VALUES ('s1', 't', 'idle', '{}', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')"))
|
|
yield bff_db.db
|
|
_run(bff_db.close_db())
|
|
|
|
|
|
def _fake_brief(complete=True):
|
|
async def _complete_json(system, user, **kwargs):
|
|
if complete:
|
|
return {"topic": "水稻SPL基因家族", "paper_type": "综述", "language": "中文",
|
|
"length": "5000字", "format_req": "GB/T 7714", "style": "学术严谨", "requirements": ""}
|
|
return {"topic": "水稻SPL", "paper_type": "", "language": "", "length": ""}
|
|
return _complete_json
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_insert_artifact(monkeypatch):
|
|
async def _fake(db, **kwargs):
|
|
return "art-fake"
|
|
monkeypatch.setattr(docgen_mod, "insert_artifact", _fake)
|
|
return _fake
|
|
|
|
|
|
def test_trigger_complete_brief_proposes_outline(bff_database, monkeypatch, fake_insert_artifact):
|
|
svc = FakePaperService()
|
|
monkeypatch.setattr(agent, "_new_client", lambda: svc)
|
|
monkeypatch.setattr(agent, "llm_complete_json", _fake_brief(complete=True))
|
|
|
|
blocks = _run(agent.handle_turn(bff_database, "s1", "帮我写一篇水稻SPL基因家族综述"))
|
|
assert blocks[-1]["type"] == "paper_outline"
|
|
assert blocks[-1]["paper_id"] == "p1"
|
|
|
|
rows = _run(bff_database.fetchall("SELECT * FROM messages WHERE session_id = 's1'"))
|
|
assert len(rows) == 1
|
|
assert rows[0]["kind"] == "paper"
|
|
|
|
|
|
def test_trigger_incomplete_brief_returns_form(bff_database, monkeypatch):
|
|
monkeypatch.setattr(agent, "_new_client", lambda: FakePaperService())
|
|
monkeypatch.setattr(agent, "llm_complete_json", _fake_brief(complete=False))
|
|
blocks = _run(agent.handle_turn(bff_database, "s1", "帮我写论文"))
|
|
assert blocks[-1]["type"] == "paper_brief"
|
|
assert blocks[-1]["status"] == "pending"
|
|
|
|
|
|
def test_writing_loop_completes_to_artifact(bff_database, monkeypatch, fake_insert_artifact):
|
|
svc = FakePaperService()
|
|
monkeypatch.setattr(agent, "_new_client", lambda: svc)
|
|
_run(agent.run_writing_loop(bff_database, "p1", "s1"))
|
|
|
|
assert svc.s1_done and svc.s2_done
|
|
rows = _run(bff_database.fetchall("SELECT * FROM messages WHERE session_id = 's1' ORDER BY rowid"))
|
|
assert "paper_progress" in rows[0]["blocks"]
|
|
assert "artifact" in rows[-1]["blocks"]
|
|
assert "art-fake" in rows[-1]["blocks"]
|