Files
PlantAI-rag-new/paper_writer_service/app/services/paper/write.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

233 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""write.py —— 章节撰写与修订(write_paper_section / revise_paper_section / revise_paper)。"""
from __future__ import annotations
from typing import Any
from ...db import db, dumps_json, loads_json, now_iso
from . import llm
from .cite import find_cite_numbers, render_gbt7714
_WRITE_SYSTEM = (
"你是植物学论文撰写助手,正在撰写论文的某一章节。"
"引用文献一律用 [n] 指代,n 必须取自下方提供的全局编号列表;如需引用新文献,编号按当前最大编号依次 +1。"
"禁止杜撰 DOI/PMID/作者/期刊——只能使用编号列表或知识库线索中真实给出的元数据。"
"只输出一个合法 JSON 对象:{\"markdown\": 章节正文(不含章标题), \"citations_used\": "
"[{\"temp_n\": 编号, \"title\": \"...\", \"authors\": \"...\", \"journal\": \"...\", "
"\"year\": \"...\", \"doi\": \"...\", \"pmid\": \"...\", \"source_doc_id\": \"...\", \"snippet\": \"...\"}]}。"
"citations_used 必须列出本节正文中出现的每一个 [n] 引用(既有编号也列出其已有元数据)。"
)
_REVISE_SYSTEM = (
"你是植物学论文撰写助手,正在按用户意见修订论文的某一章节。"
"保持引用协议不变:[n] 指代,n 只能取自全局编号列表或最大编号 +1;禁止杜撰元数据。"
"只输出一个合法 JSON 对象:{\"markdown\": 修订后的章节正文, \"citations_used\": [...]}。"
)
# ---------------------------------------------------------------------------
# 读取辅助
# ---------------------------------------------------------------------------
async def _get_paper(paper_id: str) -> dict[str, Any] | None:
row = await db.fetchone("SELECT * FROM papers WHERE id = ?", (paper_id,))
return dict(row) if row else None
async def _get_sections(paper_id: str) -> list[dict[str, Any]]:
rows = await db.fetchall(
"SELECT * FROM paper_sections WHERE paper_id = ? ORDER BY rowid", (paper_id,)
)
return [dict(r) for r in rows]
async def _get_citations(paper_id: str) -> list[dict[str, Any]]:
rows = await db.fetchall(
"SELECT * FROM citations WHERE paper_id = ? ORDER BY temp_n", (paper_id,)
)
return [dict(r) for r in rows]
def _global_citations_text(citations: list[dict[str, Any]]) -> str:
if not citations:
return "(当前无已编号文献;新引用从 [1] 开始)"
return "\n".join(f"[{c['temp_n']}] {c['ref_text']}" for c in citations)
def _max_temp_n(citations: list[dict[str, Any]]) -> int:
return max((c.get("temp_n") or 0 for c in citations), default=0)
def _build_write_user(paper: dict, outline: dict, sec: dict, cit_text: str, style: str, cite_mode: str) -> str:
parts: list[str] = []
parts.append(f"论文标题:{paper.get('title', '')}")
parts.append(f"论文类型:{paper.get('paper_type') or '综述'}")
parts.append(f"语言:{paper.get('language') or '中文'}")
parts.append(f"格式要求:{paper.get('format_req') or '无'}")
parts.append(f"写作风格:{style or paper.get('style') or '学术严谨'}")
parts.append("")
parts.append("论文大纲:")
for s in outline.get("sections") or []:
mark = "(当前撰写节)" if s.get("section_id") == sec.get("section_id") else ""
parts.append(f"- {s.get('section_id')}: {s.get('title')}{mark} 要点:{';'.join(s.get('key_points') or [])}")
parts.append("")
parts.append(f"现在撰写章节「{sec.get('title', '')}」(section_id={sec.get('section_id')})。")
if sec.get("key_points"):
parts.append("本节要点:" + ";".join(loads_json(sec.get("key_points"), [])))
parts.append("")
parts.append("全局已编号文献列表([n] 引用只能从这里取值,或从最大编号 +1 起新增):")
parts.append(cit_text)
return "\n".join(parts)
def _validate_numbers(markdown: str, valid: set[int]) -> list[int]:
return [n for n in find_cite_numbers(markdown) if n not in valid]
def _kind_of(meta: dict[str, Any]) -> str:
if meta.get("doi"):
return "doi"
if meta.get("pmid"):
return "pmid"
return "title"
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]] = []
seen_new: set[int] = set()
for c in used:
if not isinstance(c, dict):
continue
tn = int(c.get("temp_n") or 0)
if not tn or tn in existing_nums or tn in seen_new:
continue
seen_new.add(tn)
ref_text = render_gbt7714(c)
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(),
),
)
new_citations.append({"temp_n": tn, "ref_text": ref_text})
return new_citations
# ---------------------------------------------------------------------------
# 写节
# ---------------------------------------------------------------------------
async def write_paper_section(paper_id: str, section_id: str, style: str = "", cite_mode: str = "") -> dict[str, Any]:
paper = await _get_paper(paper_id)
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
sections = await _get_sections(paper_id)
sec = next((s for s in sections if s["section_id"] == section_id), None)
if not sec:
return {"ok": False, "code": "NOT_FOUND", "message": "章节不存在"}
citations = await _get_citations(paper_id)
outline = loads_json(paper.get("outline_json"), {})
cit_text = _global_citations_text(citations)
existing_nums = {c.get("temp_n") for c in citations}
user = _build_write_user(paper, outline, sec, cit_text, style, cite_mode)
markdown = ""
used: list[Any] = []
bad: list[int] = []
for attempt in range(2):
try:
res = await llm.complete_json(_WRITE_SYSTEM, user, max_tokens=8000)
except llm.LLMError as exc:
return {"ok": False, "code": "LLM_ERROR", "message": f"写节 LLM 失败:{exc}"}
markdown = res.get("markdown") or ""
used = res.get("citations_used") or []
used_nums = {int(c.get("temp_n") or 0) for c in used if isinstance(c, dict)}
valid = existing_nums | used_nums
bad = _validate_numbers(markdown, valid)
if not bad:
break
user += f"\n(上次正文出现的引用编号 {sorted(bad)} 非法;只能使用已有编号或从 {_max_temp_n(citations) + 1} 起的新编号,重试。)"
else:
return {"ok": False, "code": "CITE_VALIDATION_FAILED", "message": f"引用编号非法:{sorted(bad)}"}
new_citations = await _persist_new_citations(paper_id, section_id, used, existing_nums)
await db.execute(
"UPDATE paper_sections SET markdown = ?, word_count = ?, status = 'done', updated_at = ? WHERE paper_id = ? AND section_id = ?",
(markdown, len(markdown), now_iso(), paper_id, section_id),
)
await db.execute("UPDATE papers SET status = 'writing', updated_at = ? WHERE id = ?", (now_iso(), paper_id))
return {"ok": True, "section_id": section_id, "status": "done", "markdown": markdown,
"citations_used": used, "new_citations": new_citations}
# ---------------------------------------------------------------------------
# 修订
# ---------------------------------------------------------------------------
async def revise_paper_section(paper_id: str, section_id: str, instruction: str) -> dict[str, Any]:
paper = await _get_paper(paper_id)
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
sections = await _get_sections(paper_id)
sec = next((s for s in sections if s["section_id"] == section_id), None)
if not sec:
return {"ok": False, "code": "NOT_FOUND", "message": "章节不存在"}
citations = await _get_citations(paper_id)
outline = loads_json(paper.get("outline_json"), {})
existing_nums = {c.get("temp_n") for c in citations}
user = _build_write_user(paper, outline, sec, _global_citations_text(citations), "", "")
user += "\n\n原章节正文:\n" + (sec.get("markdown") or "")
user += f"\n\n修订要求:{instruction}"
markdown = ""
used: list[Any] = []
bad: list[int] = []
for attempt in range(2):
try:
res = await llm.complete_json(_REVISE_SYSTEM, user, max_tokens=8000)
except llm.LLMError as exc:
return {"ok": False, "code": "LLM_ERROR", "message": f"修订 LLM 失败:{exc}"}
markdown = res.get("markdown") or ""
used = res.get("citations_used") or []
used_nums = {int(c.get("temp_n") or 0) for c in used if isinstance(c, dict)}
valid = existing_nums | used_nums
bad = _validate_numbers(markdown, valid)
if not bad:
break
user += f"\n(引用编号 {sorted(bad)} 非法,重试。)"
else:
return {"ok": False, "code": "CITE_VALIDATION_FAILED", "message": f"引用编号非法:{sorted(bad)}"}
new_citations = await _persist_new_citations(paper_id, section_id, used, existing_nums)
await db.execute(
"UPDATE paper_sections SET markdown = ?, word_count = ?, status = 'done', updated_at = ? WHERE paper_id = ? AND section_id = ?",
(markdown, len(markdown), now_iso(), paper_id, section_id),
)
return {"ok": True, "section_id": section_id, "markdown": markdown,
"citations_used": used, "citation_delta": len(new_citations), "new_citations": new_citations}
async def revise_paper(paper_id: str, instruction: str) -> dict[str, Any]:
"""LLM 判定受影响章节,返回受影响节清单 + 修订计划(由调用方逐节 revise_paper_section)。"""
paper = await _get_paper(paper_id)
if not paper:
return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"}
sections = await _get_sections(paper_id)
outline = loads_json(paper.get("outline_json"), {})
system = (
"你是论文修订计划助手。根据用户修订意见与论文大纲,判定需要修改哪些章节。"
"只输出一个合法 JSON 对象:{\"affected_section_ids\": [\"s1\", ...], \"plan\": 修订计划(一句话)}。"
)
sec_list = "\n".join(f"{s['section_id']}: {s['title']}" for s in sections)
user = f"论文标题:{paper.get('title')}\n大纲章节:\n{sec_list}\n\n修订意见:{instruction}"
try:
res = await llm.complete_json(system, user, max_tokens=2000)
except llm.LLMError as exc:
return {"ok": False, "code": "LLM_ERROR", "message": f"修订计划 LLM 失败:{exc}"}
affected = [s for s in (res.get("affected_section_ids") or []) if any(x["section_id"] == s for x in sections)]
return {"ok": True, "affected_section_ids": affected, "plan": res.get("plan", "")}