diff --git a/.gitignore b/.gitignore index 661b8f0..3fc8ecb 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,9 @@ tests/extension_cases_18/ *.bak-* bioflow_service/bioflow.db *.before-* + +# paper_writer_service 运行时产物(不入库) +paper_writer_service/paper.db +paper_writer_service/paper.db-shm +paper_writer_service/paper.db-wal +paper_writer_service/paper.log diff --git a/deploy/paper_restart.sh b/deploy/paper_restart.sh new file mode 100755 index 0000000..23737b3 --- /dev/null +++ b/deploy/paper_restart.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# deploy/paper_restart.sh —— 论文撰写服务重启(8097,dev 树明文;不触碰 8090/8091/8098)。 +# 用法:bash deploy/paper_restart.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +bash "$REPO_ROOT/paper_writer_service/restart.sh" +echo "paper_writer_service 重启已触发(端口 8097,日志 paper_writer_service/paper.log)" diff --git a/deploy/toggle_paper.sh b/deploy/toggle_paper.sh new file mode 100755 index 0000000..ba0e96b --- /dev/null +++ b/deploy/toggle_paper.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# deploy/toggle_paper.sh —— 论文撰写快速开关(dev 树 175;不触碰生产 176 /srv/plantai)。 +# 用法:bash deploy/toggle_paper.sh off|on +# 原理:BFF main.py 按 PAPER_WRITER_ENABLED 决定是否注册 /api/paper 路由; +# 不注册时 /api/paper/health 返回 404,前端探活落 offline,触发词落 docgen 单发降级。 +set -uo pipefail + +ACTION="${1:-}" +SDK_DIR="/home/xzq/5090/teamWorkspace/zh/PlantAI-rag-new/sdk" +ENV_FILE="$SDK_DIR/.env" +PY="/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python" +BIND="${PAPER_BFF_BIND:-0.0.0.0}" + +if [ "$ACTION" != "off" ] && [ "$ACTION" != "on" ]; then + echo "用法: $0 " >&2 + exit 2 +fi + +VAL="true" +[ "$ACTION" = "off" ] && VAL="false" + +# 1. 改 env(追加或覆盖) +if grep -q "^PAPER_WRITER_ENABLED=" "$ENV_FILE"; then + sed -i "s/^PAPER_WRITER_ENABLED=.*/PAPER_WRITER_ENABLED=$VAL/" "$ENV_FILE" +else + echo "PAPER_WRITER_ENABLED=$VAL" >> "$ENV_FILE" +fi +echo "PAPER_WRITER_ENABLED -> $VAL" + +# 2. 重启 BFF 8090(dev 树;仅取 LISTEN 者;venv 必须绝对路径,cd sdk 后才能 import app.main) +kill $(lsof -ti tcp:8090 -sTCP:LISTEN) 2>/dev/null || true +sleep 2 +cd "$SDK_DIR" && set -a && . .env && set +a +( cd "$SDK_DIR" && setsid "$PY" -m uvicorn app.main:app --host "$BIND" --port 8090 \ + > /tmp/plantai-bff.log 2>&1 < /dev/null & ) && disown -a + +# 3. 自检 +for i in $(seq 1 20); do + sleep 2 + curl -s -m 3 http://127.0.0.1:8090/api/health >/dev/null 2>&1 && break +done +curl -s -m 10 http://127.0.0.1:8090/api/health | head -c 120 +echo +if [ "$ACTION" = "off" ]; then + CODE=$(curl -s -o /dev/null -w "%{http_code}" -m 8 http://127.0.0.1:8090/api/paper/health) + echo "paper/health HTTP $CODE (off 期望 404)" + [ "$CODE" = "404" ] && echo "OFF_OK" || echo "OFF_CHECK_FAIL" +else + curl -s -m 8 http://127.0.0.1:8090/api/paper/health | head -c 150 + echo +fi diff --git a/paper_writer_service/__init__.py b/paper_writer_service/__init__.py new file mode 100644 index 0000000..bafbf10 --- /dev/null +++ b/paper_writer_service/__init__.py @@ -0,0 +1 @@ +"""paper_writer_service —— 论文撰写独立 MCP 服务(8097)。""" diff --git a/paper_writer_service/app/__init__.py b/paper_writer_service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/paper_writer_service/app/config.py b/paper_writer_service/app/config.py new file mode 100644 index 0000000..2f3fe8e --- /dev/null +++ b/paper_writer_service/app/config.py @@ -0,0 +1,53 @@ +"""config.py —— paper 服务(8097)环境变量驱动配置(pydantic-settings)。 + +全部键可用环境变量覆盖(前缀 PAPER_;LLM 沿用 docgen 的 DOCGEN_LLM_* 直连模式, +由 services/paper/llm.py 直接读 os.environ)。零硬编码宿主路径:相对路径按 __file__ 推导。 +""" +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + +_SERVICE_ROOT = Path(__file__).resolve().parent.parent # paper_writer_service/ + + +def _resolve(v: str) -> str: + """仓库内相对路径按 __file__ 推导服务根解析为绝对路径。""" + p = Path(v).expanduser() + if not p.is_absolute(): + p = _SERVICE_ROOT / p + return str(p) + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + env_prefix="PAPER_", + ) + + app_name: str = "plantai-paper-writer-service" + version: str = "0.1.0" + + db_path: str = "paper.db" # 独立 SQLite(与 BFF 完全隔离) + vstore_base_url: str = "http://127.0.0.1:8094" + read_timeout: float = 360.0 # write_paper_section 同步长调用读超时 + + @property + def db_path_abs(self) -> str: + return _resolve(self.db_path) + + @property + def vstore_base(self) -> str: + return self.vstore_base_url.rstrip("/") + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/paper_writer_service/app/db.py b/paper_writer_service/app/db.py new file mode 100644 index 0000000..3c00d53 --- /dev/null +++ b/paper_writer_service/app/db.py @@ -0,0 +1,168 @@ +"""db.py —— paper 服务独立 SQLite(aiosqlite + WAL,无 ORM),三张表。 + +- papers:论文主表(brief/outline/final_markdown/references/disclaimer) +- paper_sections:章节表(逐节 markdown + 状态) +- citations:结构化引用(temp_n 生成期全局递增,final_n complete 按出现序重排写回) + +模式沿用 bioflow/docgen 同款:CREATE TABLE IF NOT EXISTS + aiosqlite 单连接 + WAL + 写锁。 +""" +from __future__ import annotations + +import asyncio +import json +import logging +import time +from collections.abc import AsyncIterator, Iterable +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import aiosqlite + +from .config import get_settings + +logger = logging.getLogger(__name__) + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS papers ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + paper_type TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT '', + format_req TEXT NOT NULL DEFAULT '', + style TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'outline', + brief_json TEXT NOT NULL DEFAULT '{}', + outline_json TEXT NOT NULL DEFAULT '{}', + final_markdown TEXT NOT NULL DEFAULT '', + references_json TEXT NOT NULL DEFAULT '[]', + disclaimer TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_papers_session ON papers(session_id); +CREATE INDEX IF NOT EXISTS idx_papers_status ON papers(status); + +CREATE TABLE IF NOT EXISTS paper_sections ( + id TEXT PRIMARY KEY, + paper_id TEXT NOT NULL REFERENCES papers(id) ON DELETE CASCADE, + section_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + key_points TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'pending', + markdown TEXT NOT NULL DEFAULT '', + word_count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + UNIQUE(paper_id, section_id) +); +CREATE INDEX IF NOT EXISTS idx_paper_sections_paper ON paper_sections(paper_id); + +CREATE TABLE IF NOT EXISTS citations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + paper_id TEXT NOT NULL REFERENCES papers(id) ON DELETE CASCADE, + section_id TEXT NOT NULL DEFAULT '', + temp_n INTEGER, + final_n INTEGER, + kind TEXT NOT NULL DEFAULT 'title', + ref_text TEXT NOT NULL DEFAULT '', + source_doc_id TEXT NOT NULL DEFAULT '', + snippet TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_citations_paper ON citations(paper_id); +""" + + +def now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def dumps_json(value: Any, default: Any = None) -> str: + try: + return json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + return json.dumps(default, ensure_ascii=False) + + +def loads_json(text: str | None, default: Any = None) -> Any: + if text is None: + return default + try: + return json.loads(text) + except (TypeError, ValueError): + return default + + +class Database: + """aiosqlite 单连接封装(WAL;写串行化,同 bioflow 模式)。""" + + def __init__(self, path: str | Path | None = None) -> None: + self._path: Path | None = Path(path) if path is not None else None + self._conn: aiosqlite.Connection | None = None + self._lock = asyncio.Lock() + + @property + def path(self) -> Path: + if self._path is None: + self._path = Path(get_settings().db_path_abs) + return self._path + + async def connect(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + self._conn = await aiosqlite.connect(self.path) + self._conn.row_factory = aiosqlite.Row + await self._conn.execute("PRAGMA journal_mode=WAL") + await self._conn.execute("PRAGMA foreign_keys=ON") + await self._conn.execute("PRAGMA busy_timeout=5000") + await self._conn.executescript(_SCHEMA) + await self._conn.commit() + + async def close(self) -> None: + if self._conn is not None: + await self._conn.close() + self._conn = None + + @property + def conn(self) -> aiosqlite.Connection: + if self._conn is None: + raise RuntimeError("Database 未初始化:先 await init_db()") + return self._conn + + async def fetchall(self, sql: str, params: Iterable[Any] = ()) -> list[aiosqlite.Row]: + async with self._conn.execute(sql, tuple(params)) as cursor: + return list(await cursor.fetchall()) + + async def fetchone(self, sql: str, params: Iterable[Any] = ()) -> aiosqlite.Row | None: + async with self._conn.execute(sql, tuple(params)) as cursor: + return await cursor.fetchone() + + async def execute(self, sql: str, params: Iterable[Any] = ()) -> int: + async with self._lock: + cur = await self._conn.execute(sql, tuple(params)) + await self._conn.commit() + return cur.lastrowid + + +db = Database() + + +async def init_db(path: str | Path | None = None) -> Database: + # 原地改单例路径后连接(不做 db = Database(path) 重赋值): + # 服务模块以 `from ...db import db` 持有了该单例引用,重赋值会让它们拿到旧对象。 + if path is not None: + db._path = Path(path) + await db.connect() + return db + + +async def get_db() -> AsyncIterator[Database]: + yield db + + +async def close_db() -> None: + await db.close() + + +def monotonic() -> float: + return time.monotonic() diff --git a/paper_writer_service/app/main.py b/paper_writer_service/app/main.py new file mode 100644 index 0000000..efc2bae --- /dev/null +++ b/paper_writer_service/app/main.py @@ -0,0 +1,84 @@ +"""main.py —— paper 服务应用入口(uvicorn 入口,明文白名单文件)。 + +- 独立部署树 paper_writer_service/(FR:独立进程 8097 + 独立 SQLite paper.db)。 +- lifespan:init_db(幂等建表)→ 显式进入 MCP session_manager.run()(Starlette 不自动 + 运行子 Mount 的 lifespan,streamable-http 处理请求时要求 task group 已初始化)→ 清理。 +- 端点:/health(GET,BFF 探活反代)+ /mcp(MCP streamable-http)。 +- 不依赖 BFF/生产服务(8090 等绝不探测/连接)。 +""" +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .config import get_settings +from .db import close_db, db, init_db +from .mcp import paper_mcp +from .services.paper import search + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# MCP streamable-http app(调用即创建 session_manager,供 lifespan 进入 run())。 +# 端点地址:/mcp(streamable_http_path="/" + 挂载前缀 /mcp)。 +_paper_mcp_app = paper_mcp.streamable_http_app(streamable_http_path="/") + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI(title=settings.app_name, version=settings.version) + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], + ) + + @asynccontextmanager + async def _lifespan(_app: FastAPI): + # 测试注入:db 已由测试预置(init_db(tmp_path)),跳过启动初始化。 + try: + await init_db() + except Exception: + logger.exception("main: 数据库初始化失败(health 将报 db 降级)") + + # MCP session manager 的 task group 必须在服务运行期内保持活跃。 + async with paper_mcp.session_manager.run(): + try: + yield + finally: + try: + await close_db() + except Exception: + pass + await search.close_client() + + app.router.lifespan_context = _lifespan + + @app.get("/health") + async def health() -> dict[str, object]: + db_ok = True + db_error: str | None = None + try: + await db.fetchone("SELECT 1 AS ok") + except Exception as exc: # noqa: BLE001 + db_ok = False + db_error = str(exc) + return { + "ok": True, + "status": "ok" if db_ok else "degraded", + "db": {"connected": db_ok, "error": db_error}, + "version": settings.version, + } + + # paper MCP 能力外挂(mcp 2.x:MCPServer.streamable_http_app)。 + app.mount("/mcp", _paper_mcp_app) + return app + + +app = create_app() diff --git a/paper_writer_service/app/mcp.py b/paper_writer_service/app/mcp.py new file mode 100644 index 0000000..a754fe9 --- /dev/null +++ b/paper_writer_service/app/mcp.py @@ -0,0 +1,48 @@ +"""app/mcp.py —— paper_writer_service MCP server(单 streamable-http 端点 /mcp)。 + +9 个工具:propose_paper_outline / write_paper_section / get_paper_progress / search_refs / +revise_paper_section / revise_paper / get_paper / complete_paper / cancel_paper。 + +mcp 2.x 注意:MCPServer(非 FastMCP,来自 mcp.server.mcpserver);工具返回 dict,由 MCP +序列化为 CallToolResult(structuredContent + content:[{type:"text",text:""}])。 +本文件位于 app/ 层(非 services/),可用 pydantic(mcp 自身依赖 pydantic 构造 schema)。 +""" +from __future__ import annotations + +from typing import Any + +from .services.paper import complete, outline, search, write + + +def build_paper_mcp() -> Any: + from mcp.server.mcpserver import MCPServer + + mcp = MCPServer( + "paper-writer", + title="PlantAI 论文撰写", + description="论文撰写能力单元:大纲生成/逐节撰写/文献引用/全文合并重排/修订。", + ) + + mcp.tool(name="propose_paper_outline", + description="根据论文主题与写作要求生成大纲并建论文(内部检索知识库注入选题线索)。")(outline.propose_paper_outline) + mcp.tool(name="write_paper_section", + description="撰写论文某一章节(同步长调用,90-180s)。按全局引用编号表生成正文与引用。")(write.write_paper_section) + mcp.tool(name="get_paper_progress", + description="查询论文与各章节撰写进度/字数/引用数。")(complete.get_paper_progress) + mcp.tool(name="search_refs", + description="在知识库(vstore)中检索与查询相关的文献片段。")(search.search_refs) + mcp.tool(name="revise_paper_section", + description="按用户意见重写论文某一章节,新引用追加到引用表末尾。")(write.revise_paper_section) + mcp.tool(name="revise_paper", + description="按用户意见判定需要修改哪些章节,返回受影响节清单与修订计划。")(write.revise_paper) + mcp.tool(name="get_paper", + description="拉取论文全文 markdown、参考文献列表与免责声明。")(complete.get_paper) + mcp.tool(name="complete_paper", + description="合并全文、按首次出现顺序重排引用编号、渲染 GB/T 7714 文献列表。")(complete.complete_paper) + mcp.tool(name="cancel_paper", + description="取消论文(置 cancelled)。")(complete.cancel_paper) + + return mcp + + +paper_mcp = build_paper_mcp() diff --git a/paper_writer_service/app/schemas.py b/paper_writer_service/app/schemas.py new file mode 100644 index 0000000..8f3ca22 --- /dev/null +++ b/paper_writer_service/app/schemas.py @@ -0,0 +1,10 @@ +"""schemas.py —— paper 服务共享常量(明文白名单文件)。 + +MCP 工具入参/出参均为 dict(由 mcp 层序列化为 content:[{type:text,text:}]), +本文件只放状态/引用类型常量,避免服务层散落魔法字面量。 +""" +from __future__ import annotations + +PAPER_STATUSES = ("outline", "writing", "complete", "cancelled", "error") +SECTION_STATUSES = ("pending", "writing", "done", "error") +CITATION_KINDS = ("doi", "pmid", "title") diff --git a/paper_writer_service/app/services/__init__.py b/paper_writer_service/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/paper_writer_service/app/services/paper/__init__.py b/paper_writer_service/app/services/paper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/paper_writer_service/app/services/paper/cite.py b/paper_writer_service/app/services/paper/cite.py new file mode 100644 index 0000000..ec205c5 --- /dev/null +++ b/paper_writer_service/app/services/paper/cite.py @@ -0,0 +1,65 @@ +"""cite.py —— 引用协议:GB/T 7714 渲染、[n] 校验、complete 按出现序重排。""" +from __future__ import annotations + +import re +from typing import Any + +_CITE_RE = re.compile(r"\[(\d+)\]") + + +def find_cite_numbers(markdown: str | None) -> list[int]: + """正文中出现的所有 [n] 编号(含重复,按出现顺序)。""" + return [int(m) for m in _CITE_RE.findall(markdown or "")] + + +def render_gbt7714(meta: dict[str, Any], *, fallback_year: str = "2026") -> str: + """按可用元数据渲染 GB/T 7714 近似条目。 + + - 有 DOI → [J/OL] + DOI + - 有 PMID → [J] + PMID + - 无 DOI/PMID(知识库资料)→ 降级格式 + [EB/OL] + """ + title = (meta.get("title") or "").strip() + journal = (meta.get("journal") or "").strip() + authors = (meta.get("authors") or "").strip() + year = (meta.get("year") or fallback_year).strip() + doi = (meta.get("doi") or "").strip() + pmid = (meta.get("pmid") or "").strip() + head = f"{authors} " if authors else "" + if doi: + jpart = f"{journal}, {year}." if journal else f"{year}." + return f"{head}{title}[J/OL]. {jpart} DOI:{doi}." + if pmid: + jpart = f"{journal}, {year}." if journal else f"{year}." + return f"{head}{title}[J]. {jpart} PMID:{pmid}." + return f"{head}{title}. 知识库资料, {year}. [EB/OL]." + + +def render_disclaimer() -> str: + return "参考文献依据知识库资料生成,格式为 GB/T 7714 近似,建议人工核对。" + + +def first_appearance_order(full_markdown: str, temp_numbers: list[int]) -> list[int]: + """按全文首次出现顺序返回 temp_n;未出现的排最后(保持原序)。""" + seen: dict[int, int] = {} + for m in _CITE_RE.finditer(full_markdown or ""): + n = int(m.group(1)) + if n not in seen and n in temp_numbers: + seen[n] = m.start() + ordered = sorted((n for n in temp_numbers if n in seen), key=lambda n: seen[n]) + ordered += [n for n in temp_numbers if n not in seen] + return ordered + + +def renumber_map(temp_numbers: list[int], full_markdown: str) -> dict[int, int]: + """temp_n → final_n(按全文首次出现顺序 1..N)。""" + ordered = first_appearance_order(full_markdown, temp_numbers) + return {temp: final for final, temp in enumerate(ordered, start=1)} + + +def replace_numbers(markdown: str | None, mapping: dict[int, int]) -> str: + def _sub(m: re.Match[str]) -> str: + n = int(m.group(1)) + return f"[{mapping.get(n, n)}]" + + return _CITE_RE.sub(_sub, markdown or "") diff --git a/paper_writer_service/app/services/paper/complete.py b/paper_writer_service/app/services/paper/complete.py new file mode 100644 index 0000000..f88c24d --- /dev/null +++ b/paper_writer_service/app/services/paper/complete.py @@ -0,0 +1,106 @@ +"""complete.py —— get_paper / get_paper_progress / complete_paper / cancel_paper。""" +from __future__ import annotations + +from typing import Any + +from ...db import db, dumps_json, loads_json, now_iso +from .cite import render_disclaimer, renumber_map, replace_numbers + + +async def _load(paper_id: str) -> dict[str, Any]: + paper = await db.fetchone("SELECT * FROM papers WHERE id = ?", (paper_id,)) + if not paper: + return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"} + sections = [dict(r) for r in await db.fetchall( + "SELECT * FROM paper_sections WHERE paper_id = ? ORDER BY rowid", (paper_id,))] + citations = [dict(r) for r in await db.fetchall( + "SELECT * FROM citations WHERE paper_id = ? ORDER BY temp_n", (paper_id,))] + return {"ok": True, "paper": dict(paper), "sections": sections, "citations": citations} + + +def _merge_sections(paper: dict[str, Any], sections: list[dict[str, Any]]) -> str: + parts = [f"# {paper.get('title', '')}"] + for s in sections: + parts.append(f"\n## {s['title']}\n\n{s.get('markdown', '')}") + return "\n".join(parts) + + +async def get_paper(paper_id: str, format: str = "md") -> dict[str, Any]: + p = await _load(paper_id) + if not p.get("ok"): + return p + paper, sections, citations = p["paper"], p["sections"], p["citations"] + full_markdown = paper.get("final_markdown") or _merge_sections(paper, sections) + references = loads_json(paper.get("references_json"), []) or [ + {"n": c.get("final_n") or c.get("temp_n"), "text": c["ref_text"], "kind": c["kind"], + "source_doc_id": c["source_doc_id"]} for c in citations + ] + return { + "ok": True, "paper_id": paper_id, "title": paper["title"], "status": paper["status"], + "sections": [ + {"section_id": s["section_id"], "title": s["title"], "status": s["status"], + "word_count": s["word_count"]} for s in sections + ], + "full_markdown": full_markdown, "references": references, + "disclaimer": paper.get("disclaimer", ""), + } + + +async def get_paper_progress(paper_id: str) -> dict[str, Any]: + paper = await db.fetchone("SELECT * FROM papers WHERE id = ?", (paper_id,)) + if not paper: + return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"} + paper = dict(paper) + sections = [dict(r) for r in await db.fetchall( + "SELECT * FROM paper_sections WHERE paper_id = ? ORDER BY rowid", (paper_id,))] + cit_rows = [dict(r) for r in await db.fetchall( + "SELECT section_id, COUNT(*) AS c FROM citations WHERE paper_id = ? GROUP BY section_id", (paper_id,))] + cit_by_sec = {r["section_id"]: r["c"] for r in cit_rows} + current = next((s["section_id"] for s in sections if s["status"] != "done"), None) + return { + "ok": True, "paper_id": paper_id, "status": paper["status"], "current_section_id": current, + "sections": [ + {"section_id": s["section_id"], "title": s["title"], "status": s["status"], + "word_count": s["word_count"], "citation_count": cit_by_sec.get(s["section_id"], 0)} + for s in sections + ], + } + + +async def complete_paper(paper_id: str) -> dict[str, Any]: + p = await _load(paper_id) + if not p.get("ok"): + return p + paper, sections, citations = p["paper"], p["sections"], p["citations"] + full = _merge_sections(paper, sections) + temp_numbers = [c.get("temp_n") for c in citations if c.get("temp_n")] + mapping = renumber_map(temp_numbers, full) + + final_full = replace_numbers(full, mapping) + for c in citations: + await db.execute("UPDATE citations SET final_n = ? WHERE id = ?", (mapping.get(c.get("temp_n")), c["id"])) + for s in sections: + await db.execute("UPDATE paper_sections SET markdown = ? WHERE id = ?", + (replace_numbers(s.get("markdown"), mapping), s["id"])) + + ordered = sorted(citations, key=lambda c: mapping.get(c.get("temp_n"), 0)) + references = [ + {"n": mapping.get(c.get("temp_n"), c.get("temp_n")), "text": c["ref_text"], + "kind": c["kind"], "source_doc_id": c["source_doc_id"]} for c in ordered + ] + has_degraded = any(c["kind"] == "title" for c in citations) + disclaimer = render_disclaimer() if has_degraded else "" + await db.execute( + "UPDATE papers SET status = 'complete', final_markdown = ?, references_json = ?, disclaimer = ?, updated_at = ? WHERE id = ?", + (final_full, dumps_json(references), disclaimer, now_iso(), paper_id), + ) + return {"ok": True, "full_markdown": final_full, "references": references, + "renumber_map": mapping, "disclaimer": disclaimer} + + +async def cancel_paper(paper_id: str) -> dict[str, Any]: + paper = await db.fetchone("SELECT id FROM papers WHERE id = ?", (paper_id,)) + if not paper: + return {"ok": False, "code": "NOT_FOUND", "message": "论文不存在"} + await db.execute("UPDATE papers SET status = 'cancelled', updated_at = ? WHERE id = ?", (now_iso(), paper_id)) + return {"ok": True} diff --git a/paper_writer_service/app/services/paper/llm.py b/paper_writer_service/app/services/paper/llm.py new file mode 100644 index 0000000..b5d75c6 --- /dev/null +++ b/paper_writer_service/app/services/paper/llm.py @@ -0,0 +1,187 @@ +"""services/paper/llm.py —— LLM 调用(httpx 裸调,拷贝自 sdk/app/services/docgen/llm.py)。 + +- complete_text(system, user, *, temperature=0.3, max_tokens=8000) -> str +- complete_json(...)(response_format json_object + 一次 JSON 修复重试) +- 4 次尝试(1.5s / 3s / 6s 指数退避,429 / 5xx / 超时) +- env 键:DOCGEN_LLM_BASE_URL / DOCGEN_LLM_API_KEY / DOCGEN_LLM_MODEL / DOCGEN_LLM_TIMEOUT +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +DEFAULT_LLM_BASE_URL = "https://api.deepseek.com/v1" +DEFAULT_LLM_MODEL = "deepseek-chat" +DEFAULT_LLM_TIMEOUT = 300.0 +BACKOFF_SECONDS = (1.5, 3.0, 6.0) +MAX_ATTEMPTS = 4 + + +class LLMError(Exception): + """LLM 调用失败(重试尽/响应异常)。""" + + +def _llm_config() -> dict[str, Any]: + return { + "base_url": (os.environ.get("DOCGEN_LLM_BASE_URL") or DEFAULT_LLM_BASE_URL).rstrip("/"), + "api_key": os.environ.get("DOCGEN_LLM_API_KEY", ""), + "model": os.environ.get("DOCGEN_LLM_MODEL") or DEFAULT_LLM_MODEL, + "timeout": float(os.environ.get("DOCGEN_LLM_TIMEOUT") or DEFAULT_LLM_TIMEOUT), + } + + +def _extract_text(payload: dict[str, Any]) -> str: + try: + choices = payload.get("choices") or [] + if choices: + content = choices[0].get("message", {}).get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + return "".join( + str(c.get("text", "")) if isinstance(c, dict) else str(c) for c in content + ) + except Exception: + pass + return "" + + +async def _chat_once( + client: httpx.AsyncClient, + cfg: dict[str, Any], + system: str, + user: str, + *, + temperature: float, + max_tokens: int, + json_mode: bool, + timeout: float | None = None, +) -> str: + headers = {"Content-Type": "application/json"} + if cfg["api_key"]: + headers["Authorization"] = f"Bearer {cfg['api_key']}" + body: dict[str, Any] = { + "model": cfg["model"], + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "temperature": temperature, + "max_tokens": max_tokens, + } + if json_mode: + body["response_format"] = {"type": "json_object"} + resp = await client.post( + f"{cfg['base_url']}/chat/completions", + headers=headers, + json=body, + timeout=timeout if timeout is not None else cfg["timeout"], + ) + resp.raise_for_status() + payload = resp.json() + if not isinstance(payload, dict): + raise LLMError("LLM 响应形态异常(非 JSON 对象)") + text = _extract_text(payload) + if not text: + raise LLMError("LLM 响应缺少 choices[0].message.content") + return text + + +async def _complete( + system: str, + user: str, + *, + temperature: float, + max_tokens: int, + json_mode: bool, + timeout: float | None = None, +) -> str: + cfg = _llm_config() + last_exc: BaseException | None = None + async with httpx.AsyncClient() as client: + for attempt in range(MAX_ATTEMPTS): + try: + return await _chat_once( + client, cfg, system, user, + temperature=temperature, max_tokens=max_tokens, json_mode=json_mode, + timeout=timeout, + ) + except LLMError: + raise + except (httpx.HTTPStatusError, httpx.HTTPError, httpx.TimeoutException) as exc: + last_exc = exc + status = getattr(getattr(exc, "response", None), "status_code", None) + retryable = status is None or status == 429 or status >= 500 + if not retryable or attempt == MAX_ATTEMPTS - 1: + raise LLMError(f"LLM 调用失败(HTTP {status or '网络'}):{exc}") from exc + await asyncio.sleep(BACKOFF_SECONDS[min(attempt, len(BACKOFF_SECONDS) - 1)]) + raise LLMError(f"LLM 调用失败:{last_exc}") + + +async def complete_text( + system: str, + user: str, + *, + temperature: float = 0.3, + max_tokens: int = 8000, + timeout: float | None = None, +) -> str: + """文本补全。失败抛 LLMError。""" + return await _complete( + system, user, temperature=temperature, max_tokens=max_tokens, json_mode=False, + timeout=timeout, + ) + + +def _repair_json(text: str) -> str: + text = text.strip() + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:] + text = text.strip() + if not text: + raise LLMError("LLM 返回空内容") + if text.startswith("{"): + return text + start = text.find("{") + end = text.rfind("}") + if start >= 0 and end > start: + return text[start:end + 1] + raise LLMError("LLM 输出中未找到 JSON 对象") + + +async def complete_json( + system: str, + user: str, + *, + temperature: float = 0.3, + max_tokens: int = 8000, +) -> dict[str, Any]: + """JSON 补全(json_object + 一次修复重试)。""" + text = await _complete( + system, user, temperature=temperature, max_tokens=max_tokens, json_mode=True + ) + try: + return json.loads(text) + except ValueError: + pass + text2 = await _complete( + system + "\n(重要:你的回答必须是单个合法 JSON 对象,不要输出其他任何内容)", + user, + temperature=temperature, + max_tokens=max_tokens, + json_mode=True, + ) + try: + return json.loads(text2) + except ValueError: + repaired = _repair_json(text2) + return json.loads(repaired) diff --git a/paper_writer_service/app/services/paper/outline.py b/paper_writer_service/app/services/paper/outline.py new file mode 100644 index 0000000..2bac884 --- /dev/null +++ b/paper_writer_service/app/services/paper/outline.py @@ -0,0 +1,112 @@ +"""outline.py —— propose_paper_outline:建 papers 行 → search_refs 注入线索 → LLM 出大纲 → 预填 sections。""" +from __future__ import annotations + +import uuid +from typing import Any + +from ...db import db, dumps_json, now_iso +from . import llm, search + +_OUTLINE_SYSTEM = ( + "你是植物学论文撰写助手。根据用户给出的论文主题与写作要求,生成论文大纲。" + "只输出一个合法 JSON 对象,结构为:" + '{"title": 论文标题, "abstract_expect": 摘要预期内容(一句话), ' + '"sections": [{"section_id": "s1", "title": "节标题", "key_points": ["要点1", "要点2"], "target_words": 1200}, ...]}。' + "章节 5-6 节,覆盖 引言/材料与方法/结果/讨论/结论 等综述常见结构;target_words 每节 1200-2500。" +) + +_DEFAULT_SECTIONS = [ + ("s1", "引言"), + ("s2", "材料与方法"), + ("s3", "结果"), + ("s4", "讨论"), + ("s5", "结论与展望"), +] + + +async def propose_paper_outline( + topic: str, + requirements: str = "", + paper_type: str = "", + language: str = "", + length: str = "", + format_req: str = "", + style: str = "", + kb_hints: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + # 1. 知识库线索:优先用调用方传入的 kb_hints,否则现场 search_refs(失败静默降级) + hints = list(kb_hints or []) + if not hints: + try: + r = await search.search_refs(query=topic, top_k=8) + hints = list(r.get("items") or []) + except Exception: + hints = [] + + # 2. LLM 出大纲 + user = f"论文主题:{topic}\n" + if paper_type: + user += f"论文类型:{paper_type}\n" + if requirements: + user += f"写作要求:{requirements}\n" + if language: + user += f"语言:{language}\n" + if length: + user += f"篇幅:{length}\n" + if format_req: + user += f"格式:{format_req}\n" + if style: + user += f"风格:{style}\n" + if hints: + user += "可参考知识库文献线索(仅选题依据,非硬性引用):\n" + "\n".join( + f"- {h.get('title', '')}: {str(h.get('snippet', ''))[:120]}" for h in hints[:5] + ) + try: + outline = await llm.complete_json(_OUTLINE_SYSTEM, user) + except llm.LLMError as exc: + return {"ok": False, "code": "LLM_ERROR", "message": f"大纲生成失败:{exc}"} + + title = str(outline.get("title") or topic).strip() or topic + sections = outline.get("sections") or [] + if not sections: + sections = [ + {"section_id": sid, "title": t, "key_points": [], "target_words": 1500} + for sid, t in _DEFAULT_SECTIONS + ] + + # 3. 落库 papers + paper_sections + paper_id = uuid.uuid4().hex[:16] + await db.execute( + "INSERT INTO papers (id, title, paper_type, language, format_req, style, status, brief_json, outline_json, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, 'outline', ?, ?, ?, ?)", + ( + paper_id, title, paper_type, language, format_req, style, + dumps_json({ + "topic": topic, "requirements": requirements, "length": length, + "kb_hints": hints, + }), + dumps_json({ + "title": title, + "abstract_expect": outline.get("abstract_expect", ""), + "sections": sections, + }), + now_iso(), now_iso(), + ), + ) + for s in sections: + await db.execute( + "INSERT INTO paper_sections (id, paper_id, section_id, title, key_points, status, updated_at) " + "VALUES (?, ?, ?, ?, ?, 'pending', ?)", + ( + uuid.uuid4().hex[:16], paper_id, + s.get("section_id") or "s1", s.get("title", ""), + dumps_json(s.get("key_points") or []), now_iso(), + ), + ) + return { + "ok": True, + "paper_id": paper_id, + "title": title, + "abstract_expect": outline.get("abstract_expect", ""), + "sections": sections, + } diff --git a/paper_writer_service/app/services/paper/search.py b/paper_writer_service/app/services/paper/search.py new file mode 100644 index 0000000..3eb7f08 --- /dev/null +++ b/paper_writer_service/app/services/paper/search.py @@ -0,0 +1,101 @@ +"""search.py —— vstore 8094 知识库检索客户端(search_refs 后端)。 + +- httpx 裸调 GET /api/vstore/search-chunks +- 失败不抛异常:返回 {"ok": False, "error": {...}},工具层降级为空结果 +- vstore chunk 条目无 doi/pmid/journal/year(仅 doc_id/doc_name/snippet/score), + 故 search_refs 的 meta 为尽力而为(通常为空)→ 引用多为 title kind → 降级格式 + disclaimer +""" +from __future__ import annotations + +import asyncio +import os +from typing import Any + +import httpx + +DEFAULT_VSTORE_BASE_URL = "http://127.0.0.1:8094" +RETRY_BACKOFF = (0.5, 1.0, 2.0) +MAX_RETRIES = 2 +DEFAULT_TIMEOUT = 30.0 + +_client: httpx.AsyncClient | None = None + + +def _base_url() -> str: + return (os.environ.get("VSTORE_BASE_URL") or DEFAULT_VSTORE_BASE_URL).rstrip("/") + + +def get_client() -> httpx.AsyncClient: + global _client + if _client is None: + _client = httpx.AsyncClient( + base_url=_base_url(), timeout=httpx.Timeout(DEFAULT_TIMEOUT, connect=5.0) + ) + return _client + + +def set_client(client: httpx.AsyncClient) -> None: + """注入 client(测试用 MockTransport)。""" + global _client + _client = client + + +async def close_client() -> None: + global _client + if _client is not None: + await _client.aclose() + _client = None + + +async def search_chunks( + query: str, + *, + top_k: int = 8, + min_score: float = 0.30, + timeout: float = 12.0, +) -> dict[str, Any]: + """GET /api/vstore/search-chunks。失败返回 {ok:False, error:{...}}(不抛)。""" + client = get_client() + last_exc: BaseException | None = None + for attempt in range(MAX_RETRIES + 1): + try: + resp = await client.get( + "/api/vstore/search-chunks", + params={"q": query, "top_k": top_k, "min_score": min_score}, + timeout=timeout, + ) + if resp.status_code < 500: + try: + payload = resp.json() + except ValueError: + return {"ok": False, "error": {"code": "BAD_RESPONSE", "message": "vstore 响应非 JSON"}} + if not isinstance(payload, dict): + return {"ok": False, "error": {"code": "BAD_RESPONSE", "message": "vstore 响应形态异常"}} + return payload + last_exc = RuntimeError(f"vstore {resp.status_code}") + except (httpx.HTTPError, httpx.TimeoutException) as exc: + last_exc = exc + if attempt < MAX_RETRIES: + await asyncio.sleep(RETRY_BACKOFF[min(attempt, len(RETRY_BACKOFF) - 1)]) + return {"ok": False, "error": {"code": "UPSTREAM_DOWN", "message": f"vstore 不可达:{last_exc}"}} + + +async def search_refs(paper_id: str = "", query: str = "", top_k: int = 8) -> dict[str, Any]: + """search_refs 工具:透传 vstore 检索,映射为 {doc_id,title,snippet,score,meta}。""" + if not query: + return {"ok": False, "code": "BAD_REQUEST", "message": "缺少检索词 query"} + r = await search_chunks(query, top_k=max(1, min(int(top_k), 20))) + if not r.get("ok"): + return {"ok": True, "items": [], "warning": (r.get("error") or {}).get("message", "")} + items: list[dict[str, Any]] = [] + for it in r.get("items") or []: + if not isinstance(it, dict): + continue + items.append({ + "doc_id": it.get("doc_id") or "", + "title": it.get("doc_name") or it.get("doc_id") or "", + "snippet": it.get("snippet") or "", + "score": it.get("score"), + "meta": {}, # vstore chunk 无 doi/pmid/journal/year,尽力为空 + }) + return {"ok": True, "items": items} diff --git a/paper_writer_service/app/services/paper/write.py b/paper_writer_service/app/services/paper/write.py new file mode 100644 index 0000000..4787749 --- /dev/null +++ b/paper_writer_service/app/services/paper/write.py @@ -0,0 +1,232 @@ +"""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", "")} diff --git a/paper_writer_service/build.sh b/paper_writer_service/build.sh new file mode 100755 index 0000000..663d69b --- /dev/null +++ b/paper_writer_service/build.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# build.sh —— PAPER 服务编译硬化(复制自 bioflow_service/build.sh,白名单改造)。 +# 白名单(明文):app/main.py(uvicorn 入口)、app/config.py(运维可读面)、 +# app/schemas.py(常量面)、app/db.py(aiosqlite 协程取消热区——Cython 协程断流断言 +# abort 是既有教训,明文保留)、app/mcp.py(MCP 工具层,mcp 依赖 pydantic + async 工具)、 +# services/paper/{llm,search,write,outline,complete}.py(长 LLM/httpx/aiosqlite 调用, +# 断流崩溃高危点,明文走 CPython 原生协程)+ 全部 __init__.py。 +# 仅纯同步逻辑(cite.py 等)可编译。 +# 用法:build.sh [python] +set -euo pipefail +umask 027 + +SOURCE_ROOT="${1:?Usage: $0 [python]}" +OUTPUT_DIR="${2:?Usage: $0 [python]}" +PYTHON="${3:-python3}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VISIBILITY_TOOL="${ELF_VISIBILITY_TOOL:-$SCRIPT_DIR/../tooling/compile/tools/elf_visibility.py}" + +SOURCE_ROOT="$(realpath "$SOURCE_ROOT")" +OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")" + +[[ -d "$SOURCE_ROOT" ]] || { echo "Paper service source missing: $SOURCE_ROOT" >&2; exit 2; } +[[ -x "$PYTHON" ]] || { echo "Python is not executable: $PYTHON" >&2; exit 2; } +[[ -f "$VISIBILITY_TOOL" ]] || { echo "ELF visibility tool missing: $VISIBILITY_TOOL" >&2; exit 2; } +for command_name in gcc strip rsync; do command -v "$command_name" >/dev/null; done + +case "$OUTPUT_DIR" in + "/"|"$(realpath -m "$HOME")"|"$SOURCE_ROOT") + echo "Unsafe output directory: $OUTPUT_DIR" >&2; exit 2 ;; +esac +[[ ! -e "$OUTPUT_DIR" ]] || { echo "Output exists; refusing to overwrite: $OUTPUT_DIR" >&2; exit 2; } + +echo "[PAPER BUILD 1/5] Copying source (excluding tests)" +mkdir -p "$OUTPUT_DIR" +rsync -a --exclude '__pycache__' --exclude 'test_*.py' --exclude 'data' --exclude '.env' \ + --exclude 'build.sh' --exclude 'restart.sh' --exclude 'pytest.ini' \ + "$SOURCE_ROOT/" "$OUTPUT_DIR/" + +WHITELIST_PY=( + "app/main.py" + "app/config.py" + "app/schemas.py" + "app/db.py" + "app/mcp.py" + "app/services/paper/llm.py" + "app/services/paper/search.py" + "app/services/paper/write.py" + "app/services/paper/outline.py" + "app/services/paper/complete.py" +) +_is_whitelisted() { + local rel="$1" + [[ "$(basename "$rel")" == "__init__.py" ]] && return 0 + for w in "${WHITELIST_PY[@]}"; do + case "$rel" in + $w) return 0 ;; + esac + done + return 1 +} + +echo "[PAPER BUILD 2/5] Discovering Python modules" +PY_FILES=() +while IFS= read -r -d '' path; do PY_FILES+=("$path"); done \ + < <(find "$OUTPUT_DIR" -type f -name '*.py' -print0) +FILTERED=() +for py_file in "${PY_FILES[@]}"; do + rel="${py_file#"$OUTPUT_DIR"/}" + if _is_whitelisted "$rel"; then + echo " [whitelist] $rel (明文保留)" + continue + fi + FILTERED+=("$py_file") +done +PY_FILES=("${FILTERED[@]}") +TOTAL="${#PY_FILES[@]}" +(( TOTAL > 0 )) || { echo "No paper service modules selected" >&2; exit 3; } + +INCLUDE_PY="$("$PYTHON" -c 'import sysconfig; print(sysconfig.get_config_var("INCLUDEPY") or "")')" +EXT_SUFFIX="$("$PYTHON" -c 'import sysconfig; print(sysconfig.get_config_var("EXT_SUFFIX") or ".so")')" + +echo "[PAPER BUILD 3/5] Cython preflight ($TOTAL modules)" +COUNT=0; FAILURES=0 +for py_file in "${PY_FILES[@]}"; do + COUNT=$((COUNT+1)) + directory="$(dirname "$py_file")" + module="$(basename "$py_file" .py)" + c_file="$directory/$module.c" + echo " [$COUNT/$TOTAL] ${py_file#"$OUTPUT_DIR"/}" + if ! "$PYTHON" -m cython -3 \ + --directive always_allow_keywords=true,annotation_typing=False \ + --no-docstrings "$py_file" -o "$c_file" 2>/dev/null; then + FAILURES=$((FAILURES+1)) + fi +done +(( FAILURES == 0 )) || { echo "Paper service Cython failed: $FAILURES" >&2; exit 3; } + +echo "[PAPER BUILD 4/5] GCC hidden visibility + strip" +COUNT=0 +for py_file in "${PY_FILES[@]}"; do + COUNT=$((COUNT+1)) + directory="$(dirname "$py_file")" + module="$(basename "$py_file" .py)" + c_file="$directory/$module.c" + so_file="$directory/$module$EXT_SUFFIX" + gcc -shared -pthread -fPIC -O2 -fvisibility=hidden \ + -I"$INCLUDE_PY" -o "$so_file" "$c_file" + strip --strip-unneeded "$so_file" + "$PYTHON" -B "$VISIBILITY_TOOL" --hide-non-pyinit --check "$so_file" >/dev/null + rm -f -- "$c_file" "$py_file" +done + +echo "[PAPER BUILD 5/5] Final checks" +SO_COUNT="$(find "$OUTPUT_DIR" -type f -name '*.so' | wc -l)" +[[ "$SO_COUNT" -eq "$TOTAL" ]] || { echo "count mismatch: $TOTAL vs $SO_COUNT" >&2; exit 4; } +if find "$OUTPUT_DIR" -type f -name '*.c' -print -quit | grep -q .; then + echo "Generated C source remains" >&2; exit 4 +fi +echo "Paper service compile+harden complete: modules=$SO_COUNT output=$OUTPUT_DIR" diff --git a/paper_writer_service/pytest.ini b/paper_writer_service/pytest.ini new file mode 100644 index 0000000..4087298 --- /dev/null +++ b/paper_writer_service/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +python_files = test_*.py +markers = + paper_unit: 论文服务单元测试(引用协议/大纲/写节/重排/complete) + paper_mcp: MCP 协议互操作测试 +asyncio_mode = auto diff --git a/paper_writer_service/restart.sh b/paper_writer_service/restart.sh new file mode 100755 index 0000000..07f74c4 --- /dev/null +++ b/paper_writer_service/restart.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# restart.sh —— PAPER 服务重启脚本(8097;不触碰生产服务 8090/8091/8092/8093/8094/8098)。 +# 零硬编码:venv/python/仓库路径全部从脚本位置推导或环境变量覆盖。 +# 用法: restart.sh [python] [port] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PYTHON="${1:-$REPO_ROOT/../plantai-rag-new-venv/bin/python}" +PORT="${2:-8097}" +HOST="${PAPER_BIND_HOST:-127.0.0.1}" + +[[ -x "$PYTHON" ]] || { echo "Python not found: $PYTHON" >&2; exit 2; } + +APP_MODULE="app.main:app" +if command -v lsof >/dev/null 2>&1; then + PID="$(lsof -ti tcp:"$PORT" -sTCP:LISTEN 2>/dev/null || true)" + if [[ -n "$PID" ]]; then + echo "[restart] 停止 $PORT 旧进程 pid=$PID" + kill $PID 2>/dev/null || true + sleep 2 + fi +fi + +cd "$SCRIPT_DIR" +# 加载运行时配置(.env 不入 git):services/paper/llm.py 直接读 DOCGEN_LLM_* 裸键, +# search.py 读 VSTORE_BASE_URL——需 set -a 显式导出(同 bioflow_service/restart.sh 先例)。 +set -a +[ -f .env ] && source .env +set +a +echo "[restart] 启动论文撰写服务 :$PORT($PYTHON)" +nohup "$PYTHON" -m uvicorn "$APP_MODULE" --host "$HOST" --port "$PORT" \ + > "$SCRIPT_DIR/paper.log" 2>&1 & +echo "[restart] 完成;日志 paper_writer_service/paper.log" diff --git a/sdk/app/config.py b/sdk/app/config.py index 6aa7222..caa9685 100644 --- a/sdk/app/config.py +++ b/sdk/app/config.py @@ -76,6 +76,11 @@ class Settings(BaseSettings): bioflow_enabled: bool = False # 三形态部署开关(默认关:只部署 rag 无生信痕迹) bioflow_service_url: str = "http://127.0.0.1:8098" # 生信服务 8098(BFF 流式反代目标) + # ---- 论文撰写服务(paper_writer_service 8097;PAPER_WRITER_ENABLED 为假时路由不注册)---- + paper_writer_enabled: bool = False + paper_writer_url: str = "http://127.0.0.1:8097" + paper_writer_read_timeout: float = 360.0 # write_paper_section 同步长调用读超时(秒) + # ---- 生产形态:BFF 静态托管 ui/dist(ARCHITECTURE.md §3,M6 挂载)---- frontend_dist_dir: str = "ui/dist" # 生产形态:静态托管 ui/dist diff --git a/sdk/app/main.py b/sdk/app/main.py index c494a7f..d15fac9 100644 --- a/sdk/app/main.py +++ b/sdk/app/main.py @@ -24,6 +24,7 @@ from .config import get_settings from .db import close_db, init_db from .routers import capabilities, docgen, extraction, figures, health, kg, legacy, qa, sessions from .routers import bioflow_proxy +from .routers import paper from .routers import private_docs from .upstream import close_client @@ -59,6 +60,8 @@ def create_app() -> FastAPI: app.include_router(private_docs.router) # 私有资料问答(FR-PRIV-004) if get_settings().bioflow_enabled: app.include_router(bioflow_proxy.router) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028) + if get_settings().paper_writer_enabled: + app.include_router(paper.router) # 论文撰写(PAPER_WRITER_ENABLED 开关) @app.exception_handler(RequestValidationError) async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse: diff --git a/sdk/app/routers/docgen.py b/sdk/app/routers/docgen.py index 9e8c236..bceea50 100644 --- a/sdk/app/routers/docgen.py +++ b/sdk/app/routers/docgen.py @@ -184,6 +184,60 @@ def _read_artifact_file(settings: Any, rel: str) -> bytes | None: return None +async def insert_artifact( + db: Database, + *, + session_id: str, + title: str, + markdown: str, + md_rel: str | None = None, + template_name: str = "", + template_key: str = "", + template_css: str = "", + html: str = "", + letterhead: str = "", + file_name: str = "", +) -> str: + """写制品 md(+可选 html)落盘 + INSERT doc_artifacts,返回 artifact_id。 + + docgen 与论文撰写(paper)共用:paper 仅传 markdown/title/template_name。 + """ + artifact_id = str(uuid.uuid4()) + artifact_dir = _artifact_dir(get_settings()) + if not md_rel: + md_rel = f"artifacts/{artifact_id}.md" + if markdown: + safe_rel = _safe_rel(md_rel) + if safe_rel: + path = artifact_dir / safe_rel + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(markdown, encoding="utf-8") + except OSError as exc: + logger.warning("docgen: 制品落盘失败(不影响返回): %s", exc) + html_rel = "" + if html: + html_rel = f"artifacts/{artifact_id}.html" + safe_rel = _safe_rel(html_rel) + if safe_rel: + path = artifact_dir / safe_rel + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(html, encoding="utf-8") + except OSError as exc: + logger.warning("docgen: 模版 HTML 落盘失败(不影响返回): %s", exc) + html_rel = "" + await db.execute( + "INSERT INTO doc_artifacts (id, session_id, status, title, template_name, " + "template_key, template_css, md_path, docx_path, pdf_path, html_path, letterhead, file_name, error, created_at) " + "VALUES (?, ?, 'done', ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL, ?)", + (artifact_id, session_id, title, + template_name, template_key, template_css, md_rel, + html_rel or None, letterhead, file_name, _db_now()), + ) + return artifact_id + + # --------------------------------------------------------------------------- # 制品目录配额 / TTL 惰性清理(TL 裁决 2026-09-03:V1 由 BFF 实现) # --------------------------------------------------------------------------- @@ -516,13 +570,7 @@ async def _run_docgen_task( on_progress=on_progress, ) - # ---- 以下与修复前同步路径一致:落盘 + artifact 落库 + 配额复查 ---- - artifact_id = str(uuid.uuid4()) - markdown = str(result.get("markdown") or "") - # pipeline 的 md_path 在 DOCGEN_ROOT 为绝对路径时也是绝对路径—— - # 落库要求相对路径(_safe_rel 拒绝绝对路径,否则导出报「制品内容缺失」, - # 2026-09-10 dev 自测实测)。绝对且在制品根内 → 相对化;否则回退 - # artifacts/.md + # ---- 落盘 + artifact 落库(抽公共 insert_artifact,论文撰写共用)---- artifact_dir = _artifact_dir(get_settings()) md_rel = "" raw_md_path = str(result.get("md_path") or "") @@ -535,17 +583,6 @@ async def _run_docgen_task( md_rel = raw_md_path except ValueError: md_rel = "" - if not md_rel: - md_rel = f"artifacts/{artifact_id}.md" - if markdown: - safe_rel = _safe_rel(md_rel) - if safe_rel: - path = artifact_dir / safe_rel - try: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(markdown, encoding="utf-8") - except OSError as exc: - logger.warning("docgen: 制品落盘失败(不影响返回): %s", exc) template_key = str(result.get("template_key") or "") if not template_key and template is not None and template.get("source") == "builtin": @@ -557,34 +594,26 @@ async def _run_docgen_task( (template or {}).get("name", "") or (template or {}).get("title", "") ) html = str(result.get("html") or "") - html_rel = "" - if html: - html_rel = f"artifacts/{artifact_id}.html" - safe_rel = _safe_rel(html_rel) - if safe_rel: - path = _artifact_dir(get_settings()) / safe_rel - try: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(html, encoding="utf-8") - except OSError as exc: - logger.warning("docgen: 模版 HTML 落盘失败(不影响返回): %s", exc) - html_rel = "" + try: quota_after = await _check_quota(db, get_settings()) if quota_after is not None: logger.warning("docgen: 生成后制品目录已超配额(下一次 generate/export 将被拒绝)") except Exception as exc: logger.warning("docgen: 生成后配额复查失败(忽略): %s", exc) - await db.execute( - "INSERT INTO doc_artifacts (id, session_id, status, title, template_name, " - "template_key, template_css, md_path, docx_path, pdf_path, html_path, letterhead, file_name, error, created_at) " - "VALUES (?, ?, 'done', ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL, ?)", - (artifact_id, session_id, title, - tpl_display, template_key, template_css, md_rel, - html_rel or None, - str(result.get("letterhead") or "")[:60], - str(result.get("file_name") or name_for_prompt(description) or title or "")[:60], - _db_now()), + + artifact_id = await insert_artifact( + db, + session_id=session_id, + title=title, + markdown=str(result.get("markdown") or ""), + md_rel=md_rel or None, + template_name=tpl_display, + template_key=template_key, + template_css=template_css, + html=html, + letterhead=str(result.get("letterhead") or "")[:60], + file_name=str(result.get("file_name") or name_for_prompt(description) or title or "")[:60], ) # 终态 progress 快照:全部节 done(限频写库可能截断最后一帧) final_progress = [ diff --git a/sdk/app/routers/paper.py b/sdk/app/routers/paper.py new file mode 100644 index 0000000..b95adf3 --- /dev/null +++ b/sdk/app/routers/paper.py @@ -0,0 +1,90 @@ +"""routers/paper.py —— 论文撰写路由(对话大脑)。 + +条件注册(PAPER_WRITER_ENABLED 为假不注册,同 bioflow_proxy 模式)。 +端点:health(探活反代)/ turns(一轮状态机)/ confirm / resume / progress / citations。 +""" +from __future__ import annotations + +import asyncio +from typing import Any + +import httpx +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from ..config import get_settings +from ..db import Database, get_db +from ..services.paper import agent +from ..services.paper.mcp_client import PaperMcpClient + +router = APIRouter(prefix="/api/paper", tags=["paper"]) + + +class PaperTurnRequest(BaseModel): + session_id: str + text: str + paper_id: str | None = None + + +class PaperConfirmRequest(BaseModel): + session_id: str + + +def _client() -> PaperMcpClient: + s = get_settings() + return PaperMcpClient(s.paper_writer_url, s.paper_writer_read_timeout) + + +@router.get("/health") +async def paper_health() -> dict[str, Any]: + """探活反代 8097 /health;离线返回 offline 标记(前端据此隐藏 paper 入口)。""" + s = get_settings() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(f"{s.paper_writer_url.rstrip('/')}/health") + resp.raise_for_status() + return resp.json() + except Exception: # noqa: BLE001 + return {"ok": False, "status": "offline"} + + +@router.post("/turns") +async def paper_turn(payload: PaperTurnRequest, db: Database = Depends(get_db)) -> dict[str, Any]: + blocks = await agent.handle_turn(db, payload.session_id, payload.text, payload.paper_id) + return {"ok": True, "blocks": blocks} + + +@router.post("/papers/{paper_id}/confirm") +async def paper_confirm(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]: + asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id)) + return {"ok": True, "paper_id": paper_id} + + +@router.post("/papers/{paper_id}/resume") +async def paper_resume(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]: + asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id)) + return {"ok": True, "paper_id": paper_id} + + +@router.get("/papers/{paper_id}") +async def paper_progress(paper_id: str) -> dict[str, Any]: + client = _client() + try: + return await client.call_tool("get_paper_progress", {"paper_id": paper_id}) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "code": "UPSTREAM_DOWN", "message": str(exc)} + + +@router.get("/papers/{paper_id}/citations/{n}") +async def paper_citation(paper_id: str, n: int) -> dict[str, Any]: + client = _client() + try: + paper = await client.call_tool("get_paper", {"paper_id": paper_id}) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "code": "UPSTREAM_DOWN", "message": str(exc)} + if not paper.get("ok"): + return paper + for ref in paper.get("references") or []: + if ref.get("n") == n: + return {"ok": True, "citation": ref} + return {"ok": False, "code": "NOT_FOUND", "message": f"引用 [{n}] 不存在"} diff --git a/sdk/app/schemas.py b/sdk/app/schemas.py index 2b3a3ad..f71a157 100644 --- a/sdk/app/schemas.py +++ b/sdk/app/schemas.py @@ -99,6 +99,58 @@ class ArtifactBlock(BaseModel): title: str = "" template_name: str = "" status: str = "done" # running|done|error(生成中/已就绪/失败) + paper_id: str = "" # 论文撰写功能:关联论文 id(DocPane 据此拉 citation popover) + citation_count: int = 0 # 论文撰写功能:引用条数 + + +# --------------------------------------------------------------------------- +# §2c 论文块(paper-writer;BFF 仅持久化校验,字段契约归前端 Paper*Card) +# --------------------------------------------------------------------------- + +class PaperBriefBlock(BaseModel): + """论文引导表单卡:预填已抽取 brief 字段,缺失字段待补。""" + + model_config = ConfigDict(extra="allow") + + type: Literal["paper_brief"] = "paper_brief" + paper_id: str = "" + status: str = "pending" # pending|submitted + fields: list[dict[str, Any]] = Field(default_factory=list) + + +class PaperOutlineBlock(BaseModel): + """论文大纲确认卡:章节勾选/编辑要点。""" + + model_config = ConfigDict(extra="allow") + + type: Literal["paper_outline"] = "paper_outline" + paper_id: str = "" + title: str = "" + status: str = "pending" # pending|confirmed + sections: list[dict[str, Any]] = Field(default_factory=list) + + +class PaperProgressBlock(BaseModel): + """论文节级进度卡:逐节撰写状态。""" + + model_config = ConfigDict(extra="allow") + + type: Literal["paper_progress"] = "paper_progress" + paper_id: str = "" + status: str = "writing" # writing|done|error|cancelled + sections: list[dict[str, Any]] = Field(default_factory=list) + current_section_id: str = "" + + +class PaperRefsBlock(BaseModel): + """论文参考文献卡:文末 GB/T 7714 列表 + disclaimer。""" + + model_config = ConfigDict(extra="allow") + + type: Literal["paper_refs"] = "paper_refs" + paper_id: str = "" + references: list[dict[str, Any]] = Field(default_factory=list) + disclaimer: str = "" # --------------------------------------------------------------------------- @@ -245,6 +297,7 @@ class PrivateQARequest(BaseModel): Block = Union[ TextBlock, TableBlock, CodeBlock, StreamBlock, PipelineBlock, ToolBlock, SourcesBlock, GraphBlock, ArtifactBlock, + PaperBriefBlock, PaperOutlineBlock, PaperProgressBlock, PaperRefsBlock, BioflowPathBlock, BioflowProgressBlock, BioflowFormBlock, BioflowFileBlock, BioflowArchiveBlock, BioflowQaBlock, BioflowReuseBlock, PrivateDocBlock, diff --git a/sdk/app/services/paper/__init__.py b/sdk/app/services/paper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sdk/app/services/paper/agent.py b/sdk/app/services/paper/agent.py new file mode 100644 index 0000000..eea4cc8 --- /dev/null +++ b/sdk/app/services/paper/agent.py @@ -0,0 +1,267 @@ +"""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 diff --git a/sdk/app/services/paper/mcp_client.py b/sdk/app/services/paper/mcp_client.py new file mode 100644 index 0000000..ed71e14 --- /dev/null +++ b/sdk/app/services/paper/mcp_client.py @@ -0,0 +1,104 @@ +"""services/paper/mcp_client.py —— 裸 JSON-RPC over httpx 客户端(BFF pyproject 无 mcp 依赖,零新增)。 + +与 paper_writer_service /mcp/ 端点通信:initialize → tools/call。 +响应为 SSE(text/event-stream):`event: message\ndata: \n\n`;会话由 mcp-session-id 头维系。 +端点注意:/mcp 无斜杠会 307 重定向,必须请求 /mcp/。 +""" +from __future__ import annotations + +import json +import logging +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +PROTOCOL_VERSION = "2025-03-26" + + +class PaperMcpError(Exception): + """paper 服务 RPC/工具调用失败。""" + + def __init__(self, message: str, *, cause: BaseException | None = None) -> None: + super().__init__(message) + self.cause = cause + + +def _parse_sse_body(text: str) -> dict[str, Any] | None: + """从 SSE 响应抽取 `data:` 行的 JSON;非 SSE 则整段按 JSON 解析。""" + for line in text.splitlines(): + line = line.strip() + if line.startswith("data:"): + try: + return json.loads(line[5:].strip()) + except ValueError: + return None + try: + return json.loads(text) + except ValueError: + return None + + +class PaperMcpClient: + """paper 服务 MCP 客户端:维护 mcp-session-id,逐个 JSON-RPC 调用。""" + + def __init__(self, base_url: str, timeout: float = 360.0) -> None: + self.base_url = (base_url or "http://127.0.0.1:8097").rstrip("/") + self.timeout = timeout + self._session_id: str | None = None + self._rpc_id = 0 + self._initialized = False + + async def _post(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + self._rpc_id += 1 + body = {"jsonrpc": "2.0", "id": self._rpc_id, "method": method, "params": params} + headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + } + if self._session_id: + headers["mcp-session-id"] = self._session_id + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.post(f"{self.base_url}/mcp/", headers=headers, json=body) + resp.raise_for_status() + sid = resp.headers.get("mcp-session-id") + if sid: + self._session_id = sid + text = resp.text + payload = _parse_sse_body(text) + if payload is None: + raise PaperMcpError(f"paper 服务响应无法解析:{text[:200]}") + if payload.get("error"): + raise PaperMcpError(f"paper 服务 RPC 错误:{payload['error']}") + return payload.get("result") or {} + + async def initialize(self) -> dict[str, Any]: + return await self._post("initialize", { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "plantai-bff-paper", "version": "0.1.0"}, + }) + + async def _ensure_initialized(self) -> None: + """MCP streamable-http 要求先 initialize(建立会话 + 取 mcp-session-id), + 否则 tools/call 会被服务端 400 拒绝。""" + if not self._initialized: + await self.initialize() + self._initialized = True + + async def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + await self._ensure_initialized() + result = await self._post("tools/call", {"name": name, "arguments": arguments}) + if result.get("isError"): + raise PaperMcpError(f"工具 {name} 返回错误:{result}") + sc = result.get("structuredContent") + if sc is not None: + return sc + for block in result.get("content") or []: + if isinstance(block, dict) and block.get("type") == "text" and block.get("text"): + try: + return json.loads(block["text"]) + except ValueError: + return {"raw": block["text"]} + return result diff --git a/tests/paper/conftest.py b/tests/paper/conftest.py new file mode 100644 index 0000000..139a5f3 --- /dev/null +++ b/tests/paper/conftest.py @@ -0,0 +1,80 @@ +"""tests/paper/conftest.py —— 论文服务测试共享夹具(tmp db + 假 LLM + 假检索)。 + +全部 mock LLM/vstore,不依赖生产服务(8090/8094 等绝不探测)。 +镜像 bioflow_service/tests/conftest.py 的 _run(新建事件循环)+ 临时库模式。 +""" +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +for _p in (str(REPO_ROOT), str(REPO_ROOT / "sdk")): + if _p not in sys.path: + sys.path.insert(0, _p) + +from paper_writer_service.app import db as dbmod # noqa: E402 +from paper_writer_service.app.services.paper import llm as llmmod # noqa: E402 +from paper_writer_service.app.services.paper import search as searchmod # noqa: E402 + + +def _run(coro): + """同步跑一个协程(新建 event loop;bioflow/docgen 测试同款可靠模式)。""" + return asyncio.new_event_loop().run_until_complete(coro) + + +class FakeLLM: + """可编程假 LLM:按顺序返回预设 dict;或抛 LLMError。记录全部调用。""" + + def __init__(self, responses=None, error=None): + self.responses = list(responses or []) + self.error = error + self.calls: list[dict] = [] + + async def complete_json(self, system, user, **kwargs): + self.calls.append({"system": system, "user": user}) + if self.error: + raise llmmod.LLMError(self.error) + return self.responses.pop(0) if self.responses else {} + + async def complete_text(self, system, user, **kwargs): + self.calls.append({"system": system, "user": user}) + if self.error: + raise llmmod.LLMError(self.error) + return self.responses.pop(0) if self.responses else "" + + +# --------------------------------------------------------------------------- +# DB:单例改路径重连(服务模块以 from ...db import db 持有单例引用) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def paper_db(tmp_path): + dbmod.db._path = Path(tmp_path) / "paper.db" + _run(dbmod.db.connect()) + yield dbmod.db + _run(dbmod.db.close()) + + +@pytest.fixture +def fake_llm(monkeypatch): + fake = FakeLLM() + monkeypatch.setattr(llmmod, "complete_json", fake.complete_json) + monkeypatch.setattr(llmmod, "complete_text", fake.complete_text) + return fake + + +@pytest.fixture +def fake_search(monkeypatch): + async def _search_refs(paper_id="", query="", top_k=8): + return {"ok": True, "items": [ + {"doc_id": "d1", "title": "水稻SPL基因家族研究进展", "snippet": "SPL 是植物特有转录因子家族。", "score": 0.9, "meta": {}}, + {"doc_id": "d2", "title": "植物SPL转录因子功能", "snippet": "SPL 调控开花与株型。", "score": 0.8, "meta": {}}, + ]} + + monkeypatch.setattr(searchmod, "search_refs", _search_refs) + return _search_refs diff --git a/tests/paper/manual_acceptance.md b/tests/paper/manual_acceptance.md new file mode 100644 index 0000000..f6848de --- /dev/null +++ b/tests/paper/manual_acceptance.md @@ -0,0 +1,33 @@ +# 论文撰写功能 手工验收手册 + +## 前置(175 服务器,dev 树明文) + +- `paper_writer_service` 8097 已拉起(`bash deploy/paper_restart.sh`)。 +- BFF 8090 已启用论文路由(`bash deploy/toggle_paper.sh on`,即 `.env` 里 `PAPER_WRITER_ENABLED=true`)。 +- 前端 Vite dev(5180)或 BFF 静态托管 dist 就绪,Mac 侧走 SSH 隧道访问。 + +探活自检: + +```bash +curl -s http://127.0.0.1:8097/health # {"ok":true,...,"db":{"connected":true}} +curl -s http://127.0.0.1:8090/api/paper/health # 应透传 8097 /health +curl -s http://127.0.0.1:8090/api/paper/papers/nope # {"ok":false,...,"code":"NOT_FOUND"}(路由已注册) +``` + +## 验收剧本(10 步) + +1. **触发**:输入「帮我写一篇水稻 SPL 基因家族的综述论文」→ 出现 NL 引导 + `paper_brief` 表单卡(预填已抽取字段,缺失项待补)。 +2. **补全 brief**:类型=综述 / 篇幅=5000字 / 语言=中文 / 格式=GB/T 7714 → 点「生成大纲」→ 出现 `paper_outline` 大纲卡(5-6 节)。 +3. **NL 改大纲**:输入「把展望合并到讨论」→ 系统复述确认并更新大纲卡。 +4. **确认**:点「确认并开始撰写」→ 出现 `paper_progress` 进度卡,逐节推进,每节完成有 NL 小结。 +5. **完成**:全部节完成后自动挂 `artifact` 块并切右侧「文档」TAB;全文含文末 GB/T 7714 参考文献列表 + 降级 disclaimer(无 DOI/PMID 的知识库资料)。 +6. **引用点击**:点击正文 `[3]` → popover 显示标题 / GB 文本 / 来源片段。 +7. **修改**:输入「把讨论部分第二段改得更严谨」→ 修改确认,文献编号已重排。 +8. **导出**:文档 TAB 点「导出 PDF」/「导出 DOCX」→ 成功下载。 +9. **关服务降级**:`bash deploy/paper_restart.sh` 停掉后(或 kill 8097)→ 前端触发词落 docgen 单发(无 paper 入口)。 +10. **恢复续写**:重启 paper 服务 + BFF 后 resume → 跳过已完成节继续。 + +## 备注 + +- 引用为知识库(vstore 8094)自动检索注入;知识库 chunk 无 DOI/PMID 元数据,故多为降级格式 + disclaimer(见方案 §3.3)。 +- 长文一致性:citations 全局只增不改,complete 按首次出现顺序重排编号;正文与文末列表同源生成。 diff --git a/tests/paper/pytest.ini b/tests/paper/pytest.ini new file mode 100644 index 0000000..ddc06ec --- /dev/null +++ b/tests/paper/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +python_files = test_*.py +markers = + paper_unit: 论文服务单元测试(引用协议/大纲/写节/重排/complete) + paper_mcp: MCP 协议层测试(list_tools/call_tool) diff --git a/tests/paper/run_all.sh b/tests/paper/run_all.sh new file mode 100755 index 0000000..50efff9 --- /dev/null +++ b/tests/paper/run_all.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# tests/paper/run_all.sh —— 论文撰写功能测试总入口(后端 pytest + 前端 intent 单测) +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +PY="${PAPER_TEST_PY:-/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python}" + +echo "== 论文后端(pytest:工具逻辑 + MCP 协议 + BFF 状态机 + 块 schema) ==" +"$PY" -m pytest "$ROOT/tests/paper/" -c "$ROOT/tests/paper/pytest.ini" -q + +echo +echo "== 论文前端 intent 单测(node) ==" +node "$ROOT/tests/paper/test_paper_intent_frontend.mjs" diff --git a/tests/paper/test_mcp_protocol.py b/tests/paper/test_mcp_protocol.py new file mode 100644 index 0000000..eed6dff --- /dev/null +++ b/tests/paper/test_mcp_protocol.py @@ -0,0 +1,69 @@ +"""tests/paper/test_mcp_protocol.py —— MCP 协议层测试(list_tools / call_tool 往返)。 + +验证:9 个工具正确暴露、call_tool 返回可解析的 JSON(structuredContent 或 content 文本块)。 +镜像 bioflow_service/tests/test_mcp_server.py 的 call_tool 集成模式。 +""" +from __future__ import annotations + +import json + +from conftest import _run +from paper_writer_service.app.mcp import paper_mcp + +EXPECTED_TOOLS = { + "propose_paper_outline", "write_paper_section", "get_paper_progress", "search_refs", + "revise_paper_section", "revise_paper", "get_paper", "complete_paper", "cancel_paper", +} + + +def _content_text(result): + """从 CallToolResult 抽取文本内容(无 structuredContent 时)。""" + for block in getattr(result, "content", None) or []: + text = getattr(block, "text", None) + if text: + return json.loads(text) + return {} + + +def _call(name, args): + r = _run(paper_mcp.call_tool(name, args)) + return getattr(r, "structuredContent", None) or _content_text(r) + + +def test_list_tools_exposes_nine(): + tools = _run(paper_mcp.list_tools()) + names = {getattr(t, "name", None) for t in tools} + assert EXPECTED_TOOLS == names + + +def test_call_tool_propose_outline_roundtrip(paper_db, fake_llm, fake_search): + fake_llm.responses.append({ + "title": "水稻SPL基因家族研究进展", + "abstract_expect": "综述", + "sections": [{"section_id": "s1", "title": "引言", "key_points": [], "target_words": 1500}], + }) + r = _call("propose_paper_outline", {"topic": "水稻SPL基因家族", "paper_type": "综述"}) + assert r.get("ok") is True + assert r["paper_id"] + assert r["title"] == "水稻SPL基因家族研究进展" + + +def test_call_tool_not_found(paper_db): + r = _call("get_paper", {"paper_id": "nope"}) + assert r.get("ok") is False + assert r.get("code") == "NOT_FOUND" + + +def test_call_tool_content_is_json_string(paper_db, fake_llm, fake_search): + """验证 content[0].text 是合法 JSON 字符串(BFF 裸 JSON-RPC 客户端解析面)。""" + fake_llm.responses.append({ + "title": "T", "abstract_expect": "A", + "sections": [{"section_id": "s1", "title": "引言", "key_points": [], "target_words": 1500}], + }) + raw = _run(paper_mcp.call_tool("propose_paper_outline", {"topic": "水稻SPL"})) + blocks = getattr(raw, "content", None) or [] + assert blocks, "CallToolResult 无 content 块" + text = getattr(blocks[0], "text", None) + assert text is not None + payload = json.loads(text) # 应为合法 JSON 字符串 + assert payload.get("ok") is True diff --git a/tests/paper/test_paper_bff_agent.py b/tests/paper/test_paper_bff_agent.py new file mode 100644 index 0000000..f567881 --- /dev/null +++ b/tests/paper/test_paper_bff_agent.py @@ -0,0 +1,123 @@ +"""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"] diff --git a/tests/paper/test_paper_blocks_schema.py b/tests/paper/test_paper_blocks_schema.py new file mode 100644 index 0000000..3abc1b0 --- /dev/null +++ b/tests/paper/test_paper_blocks_schema.py @@ -0,0 +1,35 @@ +"""tests/paper/test_paper_blocks_schema.py —— 块 schema 回归(未知块拒绝 + 新块通过)。""" +from __future__ import annotations + +import pytest +from pydantic import TypeAdapter + +from app import schemas + +_BLOCK = TypeAdapter(schemas.Block) + + +def test_paper_blocks_validate(): + cases = [ + {"type": "paper_brief", "paper_id": "p1", "status": "pending", "fields": []}, + {"type": "paper_outline", "paper_id": "p1", "title": "t", "status": "pending", "sections": []}, + {"type": "paper_progress", "paper_id": "p1", "status": "writing", "sections": [], "current_section_id": ""}, + {"type": "paper_refs", "paper_id": "p1", "references": [], "disclaimer": ""}, + ] + for c in cases: + b = _BLOCK.validate_python(c) + assert b.type == c["type"] + + +def test_artifact_block_paper_fields(): + b = _BLOCK.validate_python({ + "type": "artifact", "artifact_id": "a1", "title": "t", + "paper_id": "p1", "citation_count": 3, + }) + assert b.paper_id == "p1" + assert b.citation_count == 3 + + +def test_unknown_block_rejected(): + with pytest.raises(Exception): + _BLOCK.validate_python({"type": "totally_unknown_block"}) diff --git a/tests/paper/test_paper_intent_frontend.mjs b/tests/paper/test_paper_intent_frontend.mjs new file mode 100644 index 0000000..d3e263d --- /dev/null +++ b/tests/paper/test_paper_intent_frontend.mjs @@ -0,0 +1,42 @@ +// tests/paper/test_paper_intent_frontend.mjs —— 论文撰写意图前端单测(node 直测) +// 验证 paperIntent 纯函数 + route() 零变化抽样(30 条全量由 test_intent_frontend.mjs 兜底)。 +import { pathToFileURL } from 'node:url'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(here, '../..'); +const { paperIntent, route } = await import(pathToFileURL(path.join(repo, 'ui/src/intent.js')).href); + +const cases = [ + ['帮我写一篇水稻 SPL 基因家族综述论文', true], + ['撰写一篇关于小麦育种的论文', true], + ['拟一份综述', true], + ['帮我写一个关于疫情防控的通知', false], + ['写一份项目总结报告', false], + ['写一首诗', false], + ['水稻基因是什么?', false], + ['', false], +]; +let fail = 0; +for (const [t, exp] of cases) { + const got = paperIntent(t); + const ok = got === exp; + if (!ok) fail++; + console.log(`${ok ? 'PASS' : 'FAIL'} paperIntent(${JSON.stringify(t)}) = ${got}(期望 ${exp})`); +} + +const routeChecks = [ + ['给我写一篇关于小麦育种的论文', false, false, 'docgen'], + ['写一段水稻基因的介绍', false, false, 'qa'], +]; +for (const [t, a, b, exp] of routeChecks) { + const got = route(t, a, b); + const ok = got === exp; + if (!ok) fail++; + console.log(`${ok ? 'PASS' : 'FAIL'} route(${JSON.stringify(t)}) = ${got}(期望 ${exp})`); +} + +const total = cases.length + routeChecks.length; +console.log(`===== paperIntent 前端单测:${total - fail}/${total} PASS =====`); +process.exit(fail === 0 ? 0 : 1); diff --git a/tests/paper/test_paper_tools.py b/tests/paper/test_paper_tools.py new file mode 100644 index 0000000..353a9c5 --- /dev/null +++ b/tests/paper/test_paper_tools.py @@ -0,0 +1,256 @@ +"""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]" diff --git a/tooling/compile/compile_sdk.sh b/tooling/compile/compile_sdk.sh index fdd6ad7..e7cc003 100755 --- a/tooling/compile/compile_sdk.sh +++ b/tooling/compile/compile_sdk.sh @@ -31,7 +31,7 @@ rsync -a --exclude '__pycache__' --exclude 'test_*.py' --exclude 'data' --exclud "$SOURCE_ROOT/" "$OUTPUT_DIR/" # 白名单明文:main.py + config.py + __init__.py(编译前从发现集排除) -WHITELIST_PY=("app/main.py" "app/config.py" "app/schemas.py" "app/db.py" "app/routers/qa.py" "app/services/sse_relay.py" "app/routers/bioflow_proxy.py") +WHITELIST_PY=("app/main.py" "app/config.py" "app/schemas.py" "app/db.py" "app/routers/qa.py" "app/services/sse_relay.py" "app/routers/bioflow_proxy.py" "app/routers/paper.py" "app/services/paper/agent.py" "app/services/paper/mcp_client.py") # 白名单理由:main.py=uvicorn 入口;config.py=运维可读面; # schemas.py=pydantic v2 模型(validator 方法被 Cython 转 cyfunction 触发 # PydanticUserError,实测编译后无法导入——纯数据契约面,无敏感逻辑,明文保留); diff --git a/ui/src/api/paper.js b/ui/src/api/paper.js new file mode 100644 index 0000000..0b52825 --- /dev/null +++ b/ui/src/api/paper.js @@ -0,0 +1,34 @@ +// api/paper.js —— 论文撰写端点(paper-writer) +// 全部经 http.js 封装。turns 短同步;confirm/resume 启动后台写节循环; +// progress/citations 透传 paper 服务。 +import { get, post } from './http.js'; + +/** 探活(前端 30s 轮询;离线时触发词落 docgen 单发降级) */ +export function paperHealth() { + return get('/api/paper/health'); +} + +/** 一轮对话(短同步):POST /api/paper/turns → {ok, blocks} */ +export function paperTurn(payload) { + return post('/api/paper/turns', payload); +} + +/** 确认大纲并启动后台写节循环 */ +export function confirmPaper(paperId, sessionId) { + return post(`/api/paper/papers/${encodeURIComponent(paperId)}/confirm`, { session_id: sessionId }); +} + +/** 断写后重启循环(跳过已完成节) */ +export function resumePaper(paperId, sessionId) { + return post(`/api/paper/papers/${encodeURIComponent(paperId)}/resume`, { session_id: sessionId }); +} + +/** 节级进度(写稿期 2s 轮询) */ +export function paperProgress(paperId) { + return get(`/api/paper/papers/${encodeURIComponent(paperId)}`); +} + +/** 单条引用来源(正文 [n] 点击 popover) */ +export function paperCitation(paperId, n) { + return get(`/api/paper/papers/${encodeURIComponent(paperId)}/citations/${encodeURIComponent(n)}`); +} diff --git a/ui/src/components/bench/DocPane.vue b/ui/src/components/bench/DocPane.vue index 3f3c74c..0c9a849 100644 --- a/ui/src/components/bench/DocPane.vue +++ b/ui/src/components/bench/DocPane.vue @@ -15,6 +15,8 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue' import { useDocgenStore } from '../../stores/docgen.js'; import { DG, BUILTIN_TEMPLATES } from '../../i18n/docgen.js'; import { renderMarkdownPlain, sanitizeHtml } from '../chat/markdown.js'; +// 论文撰写:正文 [n] → cite-chip,点击拉 citation popover +import { paperCitation } from '../../api/paper.js'; // FR-PRIV-006:「我的资料」管理面板(挂空态区顶部,UI_SPEC §9.2) import PrivateLibraryPane from './PrivateLibraryPane.vue'; @@ -70,10 +72,66 @@ function mountShadow() { // 缺陷 #7b:md 回退预览不注入标题编号——序列化会把标题写回正本,编号不得进源文 inner = `
${renderMarkdownPlain(docgen.md)}
`; } + if (docgen.activeArtifact && docgen.activeArtifact.paper_id) { + inner = addCiteChips(inner); // 论文制品:[n] → cite-chip + } root.innerHTML = inner; if (editing.value) activateEditDom(); } +// ---------- 论文引用 cite-chip ---------- + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +function addCiteChips(inner) { + return inner.replace(/\[(\d+)\]/g, (m, n) => + `[${n}]`); +} + +function removeCitePopover() { + const host = hostEl.value; + if (host) { + const p = host.querySelector(':scope > .cite-popover'); + if (p) p.remove(); + } +} + +function showCitePopover(chip, c) { + removeCitePopover(); + const host = hostEl.value; + if (!host || !chip) return; + const rect = chip.getBoundingClientRect(); + const hostRect = host.getBoundingClientRect(); + const pop = document.createElement('div'); + pop.className = 'cite-popover'; + pop.innerHTML = `
${escapeHtml(c.title || c.text || '')}
` + + `
${escapeHtml(c.text || '')}
` + + (c.snippet ? `
${escapeHtml(c.snippet)}
` : ''); + pop.style.cssText = `position:absolute;left:${rect.left - hostRect.left}px;top:${rect.bottom - hostRect.top + 4}px;max-width:360px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.12);padding:8px 10px;z-index:50;font-size:12px;`; + host.appendChild(pop); +} + +function wireCiteChips() { + const root = shadowRoot(); + if (!root || root._citeWired) return; + root._citeWired = true; + root.addEventListener('click', (e) => { + const chip = e.target && e.target.closest ? e.target.closest('.cite-chip') : null; + if (!chip) { removeCitePopover(); return; } + const n = chip.getAttribute('data-n'); + const paperId = docgen.activeArtifact && docgen.activeArtifact.paper_id; + if (!paperId || !n) return; + paperCitation(paperId, n).then((data) => { + const c = data && data.citation; + if (c) showCitePopover(chip, c); + }).catch(() => {}); + }); +} + watch(() => [docgen.html, docgen.css, docgen.renderNonce], () => { // 2026-09-04 问题5:宿主元素可能随 loading 态被 v-if 卸载重建, // nextTick 后 mountShadow 才能拿到新宿主;renderNonce 兜底 html 拉取失败 @@ -90,7 +148,7 @@ watch(() => docgen.md, () => { if (mode.value === 'preview') mountShadow(); }); }); -onMounted(() => nextTick(mountShadow)); +onMounted(() => nextTick(() => { mountShadow(); wireCiteChips(); })); // ---------- HTML 所见即所得编辑 ---------- diff --git a/ui/src/components/chat/AssistantMessage.vue b/ui/src/components/chat/AssistantMessage.vue index c627bd2..e257186 100644 --- a/ui/src/components/chat/AssistantMessage.vue +++ b/ui/src/components/chat/AssistantMessage.vue @@ -28,6 +28,11 @@ import ExtensionConfirmCard from './ExtensionConfirmCard.vue'; import ExtensionRunCard from './ExtensionRunCard.vue'; // FR-PRIV-005:私有资料参考块(独立 i18n 文件,不碰 labels.js 红线) import { PRIV } from '../../i18n/private.js'; +// 论文撰写:四类块(brief 表单卡 / outline 大纲卡 / progress 进度卡 / refs 内联列表) +import { PP } from '../../i18n/paper.js'; +import PaperBriefCard from './PaperBriefCard.vue'; +import PaperOutlineCard from './PaperOutlineCard.vue'; +import PaperProgressCard from './PaperProgressCard.vue'; const props = defineProps({ message: { type: Object, required: true }, @@ -298,6 +303,21 @@ const degradedNote = computed(() => props.message.degradedNote || null); + + + + + + + +
+
{{ PP.paperRefsTitle }}
+
    +
  1. [{{ r.n }}] {{ r.text }}
  2. +
+
{{ b.disclaimer }}
+
+
{{ T.sourcesTitle }} diff --git a/ui/src/components/chat/Composer.vue b/ui/src/components/chat/Composer.vue index 47422d2..6ca3741 100644 --- a/ui/src/components/chat/Composer.vue +++ b/ui/src/components/chat/Composer.vue @@ -16,13 +16,14 @@ import { ref, computed, onMounted } from 'vue'; import { MODES as MODE_LABELS, T } from '../../i18n/labels.js'; import { DG, BUILTIN_TEMPLATES } from '../../i18n/docgen.js'; import { BF } from '../../i18n/bioflow.js'; -import { route } from '../../intent.js'; +import { route, paperIntent } from '../../intent.js'; import { route as routeBioflow, isExtensionCreateIntent } from '../../utils/intent.js'; import { uploadLibrary, listLibrary } from '../../api/docgen.js'; import { uploadBioflowFile } from '../../api/bioflow.js'; import { useDocgenStore } from '../../stores/docgen.js'; import { useChatStore } from '../../stores/chat.js'; import { useBioflowStore } from '../../stores/bioflow.js'; +import { usePaperStore } from '../../stores/paper.js'; import { useSettingsStore } from '../../stores/settings.js'; const props = defineProps({ @@ -59,6 +60,9 @@ onMounted(() => { settings.load(sid).catch(() => { /* 静默:失败不阻断(设置面板打开时会重试) */ }); } } catch { /* settings store 未就绪(测试/降级):不阻断 */ } + try { + usePaperStore().start(); // 论文撰写探活(30s;离线时触发词落 docgen 单发降级) + } catch { /* paper store 未就绪:不阻断 */ } }); const canSend = computed(() => @@ -134,6 +138,16 @@ const bioflowEnabled = computed(() => { } }); +/** 论文撰写可用(探活显隐;离线时触发词落 docgen 单发降级——后端不注册时由请求失败兜底) */ +const paperEnabled = computed(() => { + try { + const st = usePaperStore(); + return st.availability !== 'offline'; + } catch { + return true; + } +}); + const hintText = computed(() => attachments.value.length ? T.composerHint : `${T.composerHint} · ${DG.composerDropHint}`, ); @@ -291,17 +305,34 @@ const submit = () => { emit('send', text); // 生信 store 未就绪(测试/降级):漏判安全退化为 QA 路径 } } else if (intent === 'docgen') { - // 文档生成分支:chat.sendDocgen(用户消息落库 + 骨架 + 异步生成 + 自动切文档 TAB) - // 缺陷 #42:选中模板经 { templateId } 传入(空串/未选 = 自动推荐) + // 文档生成分支:论文撰写 sub-route(paperIntent && paperEnabled → sendPaper), + // 否则 sendDocgen(降级路径零改动——paper 未启用/未命中时触发词落 docgen 单发)。 + let paperRoute = false; try { - useChatStore().sendDocgen(text, readyAttachments.value.map((a) => a.libraryId), { - templateId: selectedTemplateId.value || '', - }).catch(() => { - /* sendDocgen 内部已按失败可见原则标注错误,此处兜底吞掉重复抛出 */ - }); + paperRoute = paperEnabled.value && paperIntent(text); } catch { - /* chat store 未就绪(测试/降级):漏判安全退化为 QA 路径 */ - emit('send', text); + paperRoute = false; + } + if (paperRoute) { + try { + useChatStore().sendPaper(text).catch(() => { + /* sendPaper 内部已按失败可见原则标注错误 */ + }); + } catch { + emit('send', text); // chat store 未就绪(测试/降级):漏判安全退化为 QA 路径 + } + } else { + // 缺陷 #42:选中模板经 { templateId } 传入(空串/未选 = 自动推荐) + try { + useChatStore().sendDocgen(text, readyAttachments.value.map((a) => a.libraryId), { + templateId: selectedTemplateId.value || '', + }).catch(() => { + /* sendDocgen 内部已按失败可见原则标注错误,此处兜底吞掉重复抛出 */ + }); + } catch { + /* chat store 未就绪(测试/降级):漏判安全退化为 QA 路径 */ + emit('send', text); + } } } else if (intent === 'edit') { // 编辑分支:预填 DocPane「用 prompt 修改」并切文档 TAB diff --git a/ui/src/components/chat/PaperBriefCard.vue b/ui/src/components/chat/PaperBriefCard.vue new file mode 100644 index 0000000..7bbf872 --- /dev/null +++ b/ui/src/components/chat/PaperBriefCard.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/ui/src/components/chat/PaperOutlineCard.vue b/ui/src/components/chat/PaperOutlineCard.vue new file mode 100644 index 0000000..fea02c2 --- /dev/null +++ b/ui/src/components/chat/PaperOutlineCard.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/ui/src/components/chat/PaperProgressCard.vue b/ui/src/components/chat/PaperProgressCard.vue new file mode 100644 index 0000000..f4c1c08 --- /dev/null +++ b/ui/src/components/chat/PaperProgressCard.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/ui/src/i18n/paper.js b/ui/src/i18n/paper.js new file mode 100644 index 0000000..f5b2489 --- /dev/null +++ b/ui/src/i18n/paper.js @@ -0,0 +1,28 @@ +// i18n/paper.js —— 论文撰写(paper-writer)全部新文案(独立文件不碰 labels.js 红线) + +export const PAPER_STEPS = [ + { name: '抽取需求', desc: '解析论文主题与写作要求', phase: 'planning' }, + { name: '生成大纲', desc: '检索知识库并规划章节', phase: 'retrieval' }, + { name: '逐节撰写', desc: '按大纲逐节生成正文', phase: 'generation' }, + { name: '文献引用', desc: '自动引用知识库文献并编号', phase: 'evidence' }, + { name: '合成全文', desc: '合并全文并渲染参考文献', phase: 'done' }, +]; + +export const PP = { + paperBriefTitle: '论文撰写引导', + paperBriefHint: '请补充以下信息(也可直接打字告诉我)', + paperBriefSubmit: '生成大纲', + paperBriefSubmitting: '生成中…', + paperOutlineTitle: '论文大纲', + paperOutlineConfirm: '确认并开始撰写', + paperOutlineConfirming: '启动中…', + paperOutlineModify: '说一句修改意见,如「把展望合并到讨论」', + paperProgressTitle: '撰写进度', + paperProgressWriting: '撰写中…', + paperProgressDone: '撰写完成', + paperRefsTitle: '参考文献', + paperRefsDisclaimer: '参考文献依据知识库资料生成,格式为 GB/T 7714 近似,建议人工核对。', + paperCiteTitle: '引用来源', +}; + +export default PP; diff --git a/ui/src/intent.js b/ui/src/intent.js index e51b6f6..153a5de 100644 --- a/ui/src/intent.js +++ b/ui/src/intent.js @@ -107,4 +107,15 @@ export function route(text, hasAttachments, hasActiveArtifact) { return 'qa'; } +// —— 论文撰写意图(paper-writer):在 docgen 分支内做 sub-route —— +// 「论文/综述」主题词 + 写类动词 → 论文撰写(结构化引用体系), +// 其余 docgen(报告/通知等)仍走原 sendDocgen。纯函数,node 直测。 +const PAPER_TYPE_WORDS = ['论文', '综述']; + +export function paperIntent(text) { + const s = String(text == null ? '' : text).trim(); + if (!s) return false; + return PAPER_TYPE_WORDS.some((w) => s.includes(w)) && hasAnyWord(s, WRITE_VERB); +} + export default route; diff --git a/ui/src/stores/chat.js b/ui/src/stores/chat.js index 438a468..2140670 100644 --- a/ui/src/stores/chat.js +++ b/ui/src/stores/chat.js @@ -26,6 +26,10 @@ import { generate, getTask } from '../api/docgen.js'; import { useSettingsStore } from './settings.js'; import { useDocgenStore } from './docgen.js'; +// ---- 论文撰写(paper-writer):独立分支(send/_runStream/sendDocgen 零改动) ---- +import { paperTurn, confirmPaper as confirmPaperApi, paperProgress as paperProgressApi } from '../api/paper.js'; +import { PP as PP_LABELS } from '../i18n/paper.js'; + // ---- P2 生信流程编排:独立分支(send/_runStream/sendDocgen 零改动) ---- import { BF } from '../i18n/bioflow.js'; import { route as routeBioflow } from '../utils/intent.js'; @@ -901,6 +905,10 @@ function newStreamCtx(sessionId, msgList) { const DOCGEN_TASK_POLL_MS = 2000; const docgenTaskPolls = new Map(); // task_id → { timer, sessionId, aMsg } +// 论文撰写(paper-writer)写稿期轮询注册表:paper_id → {timer, sessionId} +const PAPER_POLL_MS = 2000; +const paperPolls = new Map(); + export const useChatStore = defineStore('chat', { state: () => ({ sessionId: null, // 当前会话 id(与 sessions.activeId 对应) @@ -1566,6 +1574,172 @@ export const useChatStore = defineStore('chat', { entry.timer = setTimeout(tick, DOCGEN_TASK_POLL_MS); }, + // =============================================================== + // 论文撰写(paper-writer):sendPaper —— 用户消息落库 → POST /api/paper/turns + // (短同步,BFF agent 直写助手消息并返回 blocks)→ 本地回填助手消息。 + // 写稿期由 confirmPaper 触发后台循环,前端 2s 轮询 paperProgress 更新进度卡, + // complete 后重载会话取 agent 直写的 artifact 块并接管到文档 TAB。 + // =============================================================== + async sendPaper(description, { userMsg } = {}) { + const q = String(description == null ? '' : description).trim(); + if (!q || this.busy) return; + const sessions = useSessionsStore(); + if (!this.sessionId) { + try { + const sess = await sessions.create(); + this.sessionId = sess.id; + } catch { + return; // 创建失败:列表页可见错误 + } + } + if (this.sessionId && !sessions.items.some((s) => s.id === this.sessionId)) { + sessions.items.unshift({ id: this.sessionId, title: q.slice(0, 30), status: 'running', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0, meta: {} }); + } + const sessionId = this.sessionId; + const msgList = this.messages; + this.sessionMsgs[sessionId] = msgList; + const ctx = newStreamCtx(sessionId, msgList); + this.streamCtx[sessionId] = ctx; + this.lastResult = null; + + // 1. 用户消息入列 + 落库 + let userId = null; + let userMsgRef = null; + if (userMsg) { + userMsgRef = userMsg; + userId = userMsg.id; + } else { + const raw = { id: null, role: 'user', kind: 'paper', status: 'done', text: q, mode: 'ask', meta: {}, blocks: [{ type: 'text', html: q }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; + msgList.push(raw); + userMsgRef = msgList[msgList.length - 1]; + try { + const saved = await createMessage(sessionId, { role: 'user', text: q }); + userId = saved.id; + userMsgRef.id = userId; + } catch (err) { + userMsgRef.persistError = err; + this._finishCtx(ctx, { ok: false, error: err, message: userMsgRef, silentAbort: false }); + return; + } + } + sessions.patchLocal(sessionId, { status: 'running' }); + try { await updateSessionQuiet(sessionId, { status: 'running' }); } catch { /* 状态 PATCH 失败不阻断 */ } + + // 2. POST turns(短同步) + let payload; + try { + payload = await paperTurn({ session_id: sessionId, text: q }); + } catch (err) { + const marker = this._errorMarker(err.code); + const text = err.message || (marker && marker.text) || T.upstreamError; + const raw = { id: null, role: 'assistant', kind: 'paper', status: 'error', streamErr: (marker && marker.ann) || 'message', errText: text, blocks: [{ type: 'text', html: text }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; + msgList.push(raw); + const am = msgList[msgList.length - 1]; + this._persistFinal(am, sessionId, 'error', {}).catch(() => {}); + this._finishCtx(ctx, { ok: false, error: { code: err.code || 'NETWORK', message: text, retryable: true }, message: am, sessionId, userMessageId: userId, assistantMessageId: am.id }); + return; + } + + // 3. 助手消息(agent 已落库;本地回填 blocks) + const blocks = Array.isArray(payload && payload.blocks) ? payload.blocks : []; + const raw = { id: null, role: 'assistant', kind: 'paper', status: 'done', text: null, blocks: blocks.length ? blocks : [{ type: 'text', html: '(论文服务未返回内容)' }], raw: payload || null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; + msgList.push(raw); + const aMsg = msgList[msgList.length - 1]; + ctx.aMsg = aMsg; + this.runningMsgId = null; + try { + const saved = await createMessage(sessionId, { role: 'assistant', kind: 'paper', status: 'done', blocks: raw.blocks }); + aMsg.id = saved.id; + } catch { + aMsg.id = null; + } + delete this.streamCtx[sessionId]; // 释放 busy(无流) + this.lastResult = { ok: true, payload, message: aMsg, sessionId, userMessageId: userId, assistantMessageId: aMsg.id }; + this.scrollSeq++; + }, + + /** 确认大纲 → POST confirm 启动后台写节循环 → 2s 轮询进度 */ + async confirmPaper(paperId) { + const sessionId = this.sessionId; + if (!paperId || !sessionId) return; + try { + await confirmPaperApi(paperId, sessionId); + } catch { /* 启动失败:卡片可见错误 */ } + this._startPaperPoll(paperId, sessionId); + }, + + _startPaperPoll(paperId, sessionId) { + if (paperPolls.has(paperId)) return; + const entry = { timer: null, sessionId }; + paperPolls.set(paperId, entry); + const clear = () => { + if (entry.timer != null) clearTimeout(entry.timer); + if (paperPolls.get(paperId) === entry) paperPolls.delete(paperId); + }; + const tick = async () => { + if (!paperPolls.has(paperId) || paperPolls.get(paperId) !== entry) return; + let prog = null; + try { + prog = await paperProgressApi(paperId); + } catch { + prog = null; // 瞬时失败:继续轮询 + } + if (!paperPolls.has(paperId) || paperPolls.get(paperId) !== entry) return; + if (prog && prog.ok) { + this._patchPaperProgress(sessionId, prog); + if (prog.status === 'complete' || prog.status === 'cancelled' || prog.status === 'error') { + clear(); + if (prog.status === 'complete') { + try { + await this.loadSession(sessionId); // 重载取 agent 直写的 artifact 块 + await this._adoptPaperArtifact(sessionId); + } catch { /* 重载失败不阻断 */ } + } + return; + } + } + entry.timer = setTimeout(tick, PAPER_POLL_MS); + }; + entry.timer = setTimeout(tick, PAPER_POLL_MS); + }, + + /** 把进度同步进消息流里的 paper_progress 块 */ + _patchPaperProgress(sessionId, prog) { + const list = this.sessionMsgs[sessionId] || []; + for (const m of list) { + if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue; + const pb = m.blocks.find((b) => b && b.type === 'paper_progress'); + if (pb) { + pb.status = prog.status; + pb.current_section_id = prog.current_section_id || ''; + pb.sections = (prog.sections || []).map((s) => ({ section_id: s.section_id, title: s.title, status: s.status })); + } + } + this.scrollSeq++; + }, + + /** 找到最新含 paper_id 的 artifact 块并接管到文档 TAB */ + async _adoptPaperArtifact(sessionId) { + const list = this.sessionMsgs[sessionId] || []; + for (const m of list) { + if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue; + const art = m.blocks.find((b) => b && b.type === 'artifact' && b.paper_id); + if (art) { + try { + const { useDocgenStore } = await import('../stores/docgen.js'); + await useDocgenStore().adoptArtifact(art, { initialMd: '' }); + } catch { /* docgen store 未就绪不阻断 */ } + if (this.sessionId === sessionId) { + try { + const { useBenchStore } = await import('../stores/bench.js'); + useBenchStore().setActiveTab('doc'); + } catch { /* bench 未就绪不阻断 */ } + } + return; + } + } + }, + // --------------------------------------------------------------- // sendBioflow(P2):生信对话式编排独立分支——用户消息落库(既有机制)→ // 生信骨架(path 块)→ POST /api/bioflow/chat/stream(SSE 经 sse/parser.js diff --git a/ui/src/stores/paper.js b/ui/src/stores/paper.js new file mode 100644 index 0000000..edc82f8 --- /dev/null +++ b/ui/src/stores/paper.js @@ -0,0 +1,36 @@ +// stores/paper.js —— 论文撰写探活 + 开关(镜像 health.js 的 30s 周期探活) +// availability: 'online' | 'offline' | 'unknown'。offline 时 Composer 触发词落 docgen 单发降级。 +import { defineStore } from 'pinia'; +import { paperHealth } from '../api/paper.js'; + +const HEALTH_INTERVAL = 30000; + +export const usePaperStore = defineStore('paper', { + state: () => ({ + availability: 'unknown', + polling: false, + }), + actions: { + async probe() { + try { + const data = await paperHealth(); + this.availability = data && data.ok ? 'online' : 'offline'; + } catch { + this.availability = 'offline'; + } + }, + start() { + if (this.polling) return; + this.polling = true; + this.probe(); + this._timer = setInterval(() => { + if (document.hidden) return; + this.probe(); + }, HEALTH_INTERVAL); + }, + stop() { + clearInterval(this._timer); + this.polling = false; + }, + }, +});