Files
PlantAI-rag-new/sdk/app/services/paper/agent.py
T
admin 91f6a1c242 feat: 新增论文撰写功能 paper-writer(独立 MCP 服务 + BFF 大脑 + 前端三卡)
- 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 单测)
2026-09-30 22:29:22 +08:00

268 lines
12 KiB
Python

"""services/paper/agent.py —— 论文对话规则状态机(brief→outline→writing→complete/revise)。
LLM 仅做 brief 字段抽取(complete_json);大纲/正文/引用/合并全部由 paper 服务完成。
不引入 function calling(BFF 无工具循环先例)。助手消息由本模块进程内直写 messages 表。
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
from ...config import get_settings
from ..docgen.llm import complete_json as llm_complete_json
from .mcp_client import PaperMcpClient, PaperMcpError
logger = logging.getLogger(__name__)
BRIEF_FIELDS = [
{"key": "topic", "label": "论文主题", "options": None},
{"key": "paper_type", "label": "论文类型", "options": ["综述", "研究论文", "开题报告", "实验报告"]},
{"key": "language", "label": "语言", "options": ["中文", "英文"]},
{"key": "length", "label": "篇幅(字数)", "options": ["3000字", "5000字", "8000字"]},
{"key": "format_req", "label": "格式要求", "options": ["GB/T 7714", "APA", "MLA"]},
{"key": "style", "label": "写作风格", "options": ["学术严谨", "通俗易懂"]},
{"key": "requirements", "label": "其他要求", "options": None},
]
REQUIRED_BRIEF_KEYS = ("topic", "paper_type", "language", "length")
_BRIEF_SYSTEM = (
"你是论文撰写需求抽取助手。从用户输入中抽取论文写作 brief。"
"只输出一个合法 JSON 对象:{\"topic\":\"\",\"paper_type\":\"\",\"language\":\"\","
"\"length\":\"\",\"format_req\":\"\",\"style\":\"\",\"requirements\":\"\"}。"
"抽取不到的字段填空字符串。"
)
def _db_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _text_block(html: str) -> dict[str, Any]:
return {"type": "text", "html": html}
def _new_client() -> PaperMcpClient:
s = get_settings()
return PaperMcpClient(s.paper_writer_url, s.paper_writer_read_timeout)
async def _write_message(
db,
session_id: str,
*,
kind: str = "paper",
status: str = "done",
blocks: list[dict[str, Any]],
text: str | None = None,
) -> str:
"""直写助手消息(kind=paper),返回 message_id。"""
mid = str(uuid.uuid4())
now = _db_now()
await db.execute(
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, created_at, updated_at) "
"VALUES (?, ?, 'assistant', ?, ?, ?, NULL, ?, ?, ?)",
(mid, session_id, kind, status, json.dumps(blocks, ensure_ascii=False), text, now, now),
)
await db.execute(
"UPDATE sessions SET status = CASE WHEN status = 'idle' THEN 'running' ELSE status END, updated_at = ? WHERE id = ?",
(now, session_id),
)
return mid
async def extract_brief(text: str) -> dict[str, str]:
"""LLM 抽取 brief 字段(失败返回全空,走 paper_brief 卡兜底追问)。"""
try:
brief = await llm_complete_json(_BRIEF_SYSTEM, text, max_tokens=1000)
except Exception as exc: # noqa: BLE001
logger.warning("paper: brief 抽取失败:%s", exc)
brief = {}
return {f["key"]: str((brief or {}).get(f["key"]) or "").strip() for f in BRIEF_FIELDS}
def _missing_brief_keys(brief: dict[str, str]) -> list[str]:
return [k for k in REQUIRED_BRIEF_KEYS if not brief.get(k)]
# ---------------------------------------------------------------------------
# 状态机入口
# ---------------------------------------------------------------------------
async def handle_turn(db, session_id: str, text: str, paper_id: str | None = None) -> list[dict[str, Any]]:
"""一轮对话:推进状态机并落库助手消息,返回消息的 blocks 列表。"""
client = _new_client()
if not paper_id:
return await _trigger(db, session_id, text, client)
return await _followup(db, session_id, text, paper_id, client)
async def _trigger(db, session_id: str, text: str, client: PaperMcpClient) -> list[dict[str, Any]]:
brief = await extract_brief(text)
missing = _missing_brief_keys(brief)
if missing:
fields = [{**f, "value": brief.get(f["key"], "")} for f in BRIEF_FIELDS]
blocks = [
_text_block("好的,我来帮你写这篇论文。请补充以下信息(也可以直接打字告诉我):"),
{"type": "paper_brief", "paper_id": "", "status": "pending", "fields": fields},
]
await _write_message(db, session_id, blocks=blocks, text="请补充论文信息")
return blocks
outline = await client.call_tool("propose_paper_outline", brief)
if not outline.get("ok"):
blocks = [_text_block(f"论文大纲生成失败:{outline.get('message', '服务未就绪')},请重试。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
return await _outline_message(db, session_id, outline)
async def _outline_message(db, session_id: str, outline: dict[str, Any]) -> list[dict[str, Any]]:
paper_id = outline["paper_id"]
sections = [
{"section_id": s.get("section_id"), "title": s.get("title", ""),
"key_points": s.get("key_points") or [], "selected": True}
for s in (outline.get("sections") or [])
]
blocks = [
_text_block(
f"已为你生成论文大纲《{outline.get('title', '')}》。"
"你可以勾选/编辑章节,或直接说修改意见;确认后我开始逐节撰写。"
),
{"type": "paper_outline", "paper_id": paper_id, "title": outline.get("title", ""),
"status": "pending", "sections": sections},
]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
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"):
blocks = [_text_block(f"未找到论文({paper_id}),请重新发起撰写。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
status = prog.get("status")
if status == "complete":
return await _revise(db, session_id, paper_id, text, client)
if status == "cancelled":
blocks = [_text_block("论文已取消。如需重新撰写,请告诉我新主题。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
if status in ("outline", "writing"):
# 大纲/撰写阶段:把 text 视为补充要求,重新生成大纲(原大纲作废)
brief = await extract_brief(text)
outline = await client.call_tool("propose_paper_outline", {**brief, "topic": brief.get("topic") or text})
if outline.get("ok"):
await client.call_tool("cancel_paper", {"paper_id": paper_id})
return await _outline_message(db, session_id, outline)
blocks = [_text_block("大纲修改失败,请重试。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
blocks = [_text_block("论文撰写中,请稍候。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
async def _revise(db, session_id: str, paper_id: str, text: str, client: PaperMcpClient) -> list[dict[str, Any]]:
plan = await client.call_tool("revise_paper", {"paper_id": paper_id, "instruction": text})
if not plan.get("ok"):
blocks = [_text_block(f"修订计划生成失败:{plan.get('message', '')}")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
affected = plan.get("affected_section_ids") or []
if not affected:
blocks = [_text_block("没有需要修改的章节。")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
for sid in affected:
res = await client.call_tool(
"revise_paper_section", {"paper_id": paper_id, "section_id": sid, "instruction": text}
)
if not res.get("ok"):
blocks = [_text_block(f"章节修订失败:{res.get('message', '')}")]
await _write_message(db, session_id, blocks=blocks, text="修订失败")
return blocks
comp = await client.call_tool("complete_paper", {"paper_id": paper_id})
paper = await client.call_tool("get_paper", {"paper_id": paper_id})
if comp.get("ok") and paper.get("ok"):
return await _artifact_message(db, session_id, paper, "已按你的意见修改,文献编号已重排。")
blocks = [_text_block("修改后重新生成失败,请重试。")]
await _write_message(db, session_id, blocks=blocks, text="修改失败")
return blocks
# ---------------------------------------------------------------------------
# 写节循环(confirm/resume 后台任务)
# ---------------------------------------------------------------------------
async def run_writing_loop(db, paper_id: str, session_id: str) -> None:
"""后台逐节写节循环:写完→complete→get_paper→写 artifact→artifact 块。"""
from ...routers.docgen import insert_artifact # 惰性导入,避免服务层→路由层常驻耦合
client = _new_client()
await _write_message(db, session_id, blocks=[
{"type": "paper_progress", "paper_id": paper_id, "status": "writing", "sections": [], "current_section_id": ""},
], text="开始撰写")
try:
while True:
prog = await client.call_tool("get_paper_progress", {"paper_id": paper_id})
if not prog.get("ok"):
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:
break
sec = pending[0]
res = await client.call_tool("write_paper_section", {"paper_id": paper_id, "section_id": sec["section_id"]})
if not res.get("ok"):
await _write_message(db, session_id, blocks=[_text_block(
f"章节「{sec.get('title', '')}」撰写失败:{res.get('message', '')}"
)], text="撰写失败")
return
wc = len(res.get("markdown") or "")
await _write_message(db, session_id, blocks=[_text_block(
f"「{sec.get('title', '')}」已完成,约 {wc} 字。"
)], text=f"「{sec.get('title', '')}」已完成")
comp = await client.call_tool("complete_paper", {"paper_id": paper_id})
if not comp.get("ok"):
await _write_message(db, session_id, blocks=[_text_block(f"论文合并失败:{comp.get('message', '')}")], text="合并失败")
return
paper = await client.call_tool("get_paper", {"paper_id": paper_id})
if not paper.get("ok"):
await _write_message(db, session_id, blocks=[_text_block("论文拉取失败。")], text="拉取失败")
return
await _artifact_message(db, session_id, paper, "论文撰写完成!你可以在右侧文档页查看全文,或用自然语言提出修改意见。")
except PaperMcpError as exc:
logger.warning("paper: 写节循环失败:%s", exc)
await _write_message(db, session_id, blocks=[_text_block(f"论文服务异常:{exc}")], text="论文服务异常")
except Exception as exc: # noqa: BLE001
logger.exception("paper: 写节循环异常")
await _write_message(db, session_id, blocks=[_text_block(f"撰写失败:{exc}")], text="撰写失败")
async def _artifact_message(db, session_id: str, paper: dict[str, Any], nl: str) -> list[dict[str, Any]]:
from ...routers.docgen import insert_artifact # 惰性导入
refs = paper.get("references") or []
artifact_id = await insert_artifact(
db, session_id=session_id, title=paper.get("title", ""),
markdown=paper.get("full_markdown", ""), template_name="论文",
)
blocks = [
_text_block(nl),
{"type": "artifact", "artifact_id": artifact_id, "title": paper.get("title", ""),
"template_name": "论文", "status": "done", "paper_id": paper.get("paper_id", ""),
"citation_count": len(refs)},
]
await _write_message(db, session_id, blocks=blocks, text=nl)
return blocks