forked from admin/PlantAI-rag-new
feat: 新增论文撰写功能 paper-writer(独立 MCP 服务 + BFF 大脑 + 前端三卡)
- paper_writer_service(8097):FastAPI + MCP streamable-http 单端点,9 工具
(propose_paper_outline/write_paper_section/get_paper_progress/search_refs/
revise_paper_section/revise_paper/get_paper/complete_paper/cancel_paper),
SQLite 三表(papers/paper_sections/citations,WAL),GB/T 7714 引用协议
- BFF:services/paper/{mcp_client,agent}.py(裸 JSON-RPC 客户端 + 规则状态机)、
routers/paper.py、schemas.py 4 个 Paper*Block + ArtifactBlock 加 paper_id/citation_count、
config.py 开关、main.py 条件注册、docgen.py 抽 insert_artifact 公共 helper
- 前端:intent.js paperIntent 纯函数、Composer docgen 分支 sub-route、sendPaper/轮询、
PaperBriefCard/PaperOutlineCard/PaperProgressCard、DocPane cite-chip
- deploy/paper_restart.sh + toggle_paper.sh;compile_sdk.sh 白名单追加 paper 文件
- tests/paper 26 条(工具逻辑 + MCP 协议 + BFF 状态机 + 块 schema + 前端 intent 单测)
This commit is contained in:
@@ -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
|
||||
|
||||
Executable
+10
@@ -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)"
|
||||
Executable
+51
@@ -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 <off|on>" >&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
|
||||
@@ -0,0 +1 @@
|
||||
"""paper_writer_service —— 论文撰写独立 MCP 服务(8097)。"""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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:"<json>"}])。
|
||||
本文件位于 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()
|
||||
@@ -0,0 +1,10 @@
|
||||
"""schemas.py —— paper 服务共享常量(明文白名单文件)。
|
||||
|
||||
MCP 工具入参/出参均为 dict(由 mcp 层序列化为 content:[{type:text,text:<json>}]),
|
||||
本文件只放状态/引用类型常量,避免服务层散落魔法字面量。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
PAPER_STATUSES = ("outline", "writing", "complete", "cancelled", "error")
|
||||
SECTION_STATUSES = ("pending", "writing", "done", "error")
|
||||
CITATION_KINDS = ("doi", "pmid", "title")
|
||||
@@ -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 "")
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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", "")}
|
||||
Executable
+119
@@ -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 <source-root> <output-dir> [python]
|
||||
set -euo pipefail
|
||||
umask 027
|
||||
|
||||
SOURCE_ROOT="${1:?Usage: $0 <source-root> <output-dir> [python]}"
|
||||
OUTPUT_DIR="${2:?Usage: $0 <source-root> <output-dir> [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"
|
||||
@@ -0,0 +1,6 @@
|
||||
[pytest]
|
||||
python_files = test_*.py
|
||||
markers =
|
||||
paper_unit: 论文服务单元测试(引用协议/大纲/写节/重排/complete)
|
||||
paper_mcp: MCP 协议互操作测试
|
||||
asyncio_mode = auto
|
||||
Executable
+34
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+69
-40
@@ -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/<artifact_id>.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 = [
|
||||
|
||||
@@ -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}] 不存在"}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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: <json>\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
|
||||
@@ -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
|
||||
@@ -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 按首次出现顺序重排编号;正文与文末列表同源生成。
|
||||
@@ -0,0 +1,5 @@
|
||||
[pytest]
|
||||
python_files = test_*.py
|
||||
markers =
|
||||
paper_unit: 论文服务单元测试(引用协议/大纲/写节/重排/complete)
|
||||
paper_mcp: MCP 协议层测试(list_tools/call_tool)
|
||||
Executable
+12
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"})
|
||||
@@ -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);
|
||||
@@ -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]"
|
||||
@@ -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,实测编译后无法导入——纯数据契约面,无敏感逻辑,明文保留);
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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 = `<div class="doc-body md-text">${renderMarkdownPlain(docgen.md)}</div>`;
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function addCiteChips(inner) {
|
||||
return inner.replace(/\[(\d+)\]/g, (m, n) =>
|
||||
`<sup class="cite-chip" data-n="${n}" style="color:#2563eb;cursor:pointer;font-weight:600;margin:0 1px;">[${n}]</sup>`);
|
||||
}
|
||||
|
||||
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 = `<div class="cp-title">${escapeHtml(c.title || c.text || '')}</div>` +
|
||||
`<div class="cp-text">${escapeHtml(c.text || '')}</div>` +
|
||||
(c.snippet ? `<div class="cp-snippet">${escapeHtml(c.snippet)}</div>` : '');
|
||||
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 所见即所得编辑 ----------
|
||||
|
||||
|
||||
@@ -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);
|
||||
<!-- artifact:文档产物卡(P3;「打开预览」→ docgen store + bench 文档 TAB) -->
|
||||
<DocCard v-if="b.type === 'artifact'" :block="b" />
|
||||
|
||||
<!-- 论文撰写:brief 引导表单卡 -->
|
||||
<PaperBriefCard v-if="b.type === 'paper_brief'" :block="b" />
|
||||
<!-- 论文撰写:大纲确认卡 -->
|
||||
<PaperOutlineCard v-if="b.type === 'paper_outline'" :block="b" />
|
||||
<!-- 论文撰写:节级进度卡 -->
|
||||
<PaperProgressCard v-if="b.type === 'paper_progress'" :block="b" />
|
||||
<!-- 论文撰写:参考文献内联列表 -->
|
||||
<div v-if="b.type === 'paper_refs'" class="paper-refs">
|
||||
<div class="pr-title">{{ PP.paperRefsTitle }}</div>
|
||||
<ol class="pr-list">
|
||||
<li v-for="r in b.references" :key="r.n">[{{ r.n }}] {{ r.text }}</li>
|
||||
</ol>
|
||||
<div v-if="b.disclaimer" class="pr-disclaimer">{{ b.disclaimer }}</div>
|
||||
</div>
|
||||
|
||||
<!-- sources:内联引用 chip(FR-CHAT-015) -->
|
||||
<div v-if="b.type === 'sources' && b.items && b.items.length" class="inline-sources">
|
||||
<span class="src-label">{{ T.sourcesTitle }}</span>
|
||||
|
||||
@@ -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,7 +305,23 @@ const submit = () => {
|
||||
emit('send', text); // 生信 store 未就绪(测试/降级):漏判安全退化为 QA 路径
|
||||
}
|
||||
} else if (intent === 'docgen') {
|
||||
// 文档生成分支:chat.sendDocgen(用户消息落库 + 骨架 + 异步生成 + 自动切文档 TAB)
|
||||
// 文档生成分支:论文撰写 sub-route(paperIntent && paperEnabled → sendPaper),
|
||||
// 否则 sendDocgen(降级路径零改动——paper 未启用/未命中时触发词落 docgen 单发)。
|
||||
let paperRoute = false;
|
||||
try {
|
||||
paperRoute = paperEnabled.value && paperIntent(text);
|
||||
} catch {
|
||||
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), {
|
||||
@@ -303,6 +333,7 @@ const submit = () => {
|
||||
/* chat store 未就绪(测试/降级):漏判安全退化为 QA 路径 */
|
||||
emit('send', text);
|
||||
}
|
||||
}
|
||||
} else if (intent === 'edit') {
|
||||
// 编辑分支:预填 DocPane「用 prompt 修改」并切文档 TAB
|
||||
const dg = useDocgenStore();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
// PaperBriefCard.vue —— 论文引导表单卡(预填已抽取 brief,缺失字段待补)
|
||||
// 卡片辅助、NL 主导:提交时把已填字段拼成一句 NL,回 Composer 走 sendPaper 提交。
|
||||
import { ref, computed } from 'vue';
|
||||
import { PP } from '../../i18n/paper.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({ block: { type: Object, required: true } });
|
||||
|
||||
const fields = computed(() => (Array.isArray(props.block.fields) ? props.block.fields : []));
|
||||
const submitting = ref(false);
|
||||
const values = ref({});
|
||||
|
||||
for (const f of fields.value) {
|
||||
if (!(f.key in values.value)) values.value[f.key] = f.value || '';
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (submitting.value || props.block.submitted) return;
|
||||
submitting.value = true;
|
||||
const parts = fields.value
|
||||
.filter((f) => values.value[f.key] && String(values.value[f.key]).trim())
|
||||
.map((f) => `${f.label}:${String(values.value[f.key]).trim()}`);
|
||||
const text = `帮我写一篇论文:${parts.join(';')}`;
|
||||
props.block.submitted = true;
|
||||
try {
|
||||
useChatStore().sendPaper(text).catch(() => { /* 内部已标注错误 */ });
|
||||
} catch { /* chat store 未就绪:降级 */ }
|
||||
submitting.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="paper-brief-card">
|
||||
<div class="pbc-title">{{ PP.paperBriefTitle }}</div>
|
||||
<div class="pbc-hint">{{ PP.paperBriefHint }}</div>
|
||||
<div v-for="f in fields" :key="f.key" class="pbc-field">
|
||||
<label class="pbc-label">{{ f.label }}</label>
|
||||
<select v-if="Array.isArray(f.options) && f.options.length" v-model="values[f.key]" class="pbc-input">
|
||||
<option value=""></option>
|
||||
<option v-for="o in f.options" :key="o" :value="o">{{ o }}</option>
|
||||
</select>
|
||||
<input v-else v-model="values[f.key]" class="pbc-input" type="text" />
|
||||
</div>
|
||||
<div class="pbc-footer">
|
||||
<button v-if="!props.block.submitted" class="pbc-submit" type="button" :disabled="submitting" @click="submit">
|
||||
{{ submitting ? PP.paperBriefSubmitting : PP.paperBriefSubmit }}
|
||||
</button>
|
||||
<span v-else class="pbc-done">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.paper-brief-card { border: 1px solid var(--border, #e5e7eb); border-radius: 8px; padding: 12px; margin: 8px 0; }
|
||||
.pbc-title { font-weight: 600; margin-bottom: 4px; }
|
||||
.pbc-hint { color: #6b7280; font-size: 12px; margin-bottom: 8px; }
|
||||
.pbc-field { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.pbc-label { min-width: 72px; font-size: 13px; color: #374151; }
|
||||
.pbc-input { flex: 1; padding: 4px 8px; border: 1px solid #d1d5db; border-radius: 4px; }
|
||||
.pbc-footer { margin-top: 8px; }
|
||||
.pbc-submit { padding: 6px 14px; border: none; border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; }
|
||||
.pbc-done { color: #16a34a; }
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
// PaperOutlineCard.vue —— 论文大纲确认卡(章节勾选/编辑要点 + 确认开写)
|
||||
import { ref, computed } from 'vue';
|
||||
import { PP } from '../../i18n/paper.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
|
||||
const props = defineProps({ block: { type: Object, required: true } });
|
||||
|
||||
const sections = computed(() => (Array.isArray(props.block.sections) ? props.block.sections : []));
|
||||
const confirming = ref(false);
|
||||
|
||||
function confirm() {
|
||||
if (confirming.value || props.block.status === 'confirmed') return;
|
||||
confirming.value = true;
|
||||
props.block.status = 'confirmed';
|
||||
try {
|
||||
useChatStore().confirmPaper(props.block.paper_id);
|
||||
} catch { /* chat store 未就绪:降级 */ }
|
||||
confirming.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="paper-outline-card">
|
||||
<div class="poc-title">{{ PP.paperOutlineTitle }}:{{ props.block.title }}</div>
|
||||
<div v-for="s in sections" :key="s.section_id" class="poc-section">
|
||||
<input type="checkbox" :checked="s.selected !== false" @change="s.selected = $event.target.checked" />
|
||||
<span class="poc-sec-title">{{ s.title }}</span>
|
||||
<span v-if="s.key_points && s.key_points.length" class="poc-sec-pts">{{ s.key_points.join(';') }}</span>
|
||||
</div>
|
||||
<div class="poc-footer">
|
||||
<button v-if="props.block.status !== 'confirmed'" class="poc-confirm" type="button" :disabled="confirming" @click="confirm">
|
||||
{{ confirming ? PP.paperOutlineConfirming : PP.paperOutlineConfirm }}
|
||||
</button>
|
||||
<span v-else class="poc-confirmed">✓ 已启动撰写</span>
|
||||
</div>
|
||||
<div class="poc-modify-hint">{{ PP.paperOutlineModify }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.paper-outline-card { border: 1px solid var(--border, #e5e7eb); border-radius: 8px; padding: 12px; margin: 8px 0; }
|
||||
.poc-title { font-weight: 600; margin-bottom: 8px; }
|
||||
.poc-section { display: flex; align-items: baseline; gap: 6px; margin-bottom: 4px; }
|
||||
.poc-sec-title { font-weight: 500; }
|
||||
.poc-sec-pts { color: #6b7280; font-size: 12px; }
|
||||
.poc-footer { margin-top: 10px; }
|
||||
.poc-confirm { padding: 6px 14px; border: none; border-radius: 6px; background: #16a34a; color: #fff; cursor: pointer; }
|
||||
.poc-confirmed { color: #16a34a; }
|
||||
.poc-modify-hint { margin-top: 6px; color: #9ca3af; font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
// PaperProgressCard.vue —— 论文节级进度卡(逐节撰写状态,写稿期 2s 轮询驱动更新)
|
||||
import { computed } from 'vue';
|
||||
import { PP } from '../../i18n/paper.js';
|
||||
|
||||
const props = defineProps({ block: { type: Object, required: true } });
|
||||
|
||||
const sections = computed(() => (Array.isArray(props.block.sections) ? props.block.sections : []));
|
||||
const statusLabel = computed(() => {
|
||||
if (props.block.status === 'done') return PP.paperProgressDone;
|
||||
if (props.block.status === 'error') return '撰写失败';
|
||||
if (props.block.status === 'cancelled') return '已取消';
|
||||
return PP.paperProgressWriting;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="paper-progress-card">
|
||||
<div class="ppc-title">{{ PP.paperProgressTitle }} · {{ statusLabel }}</div>
|
||||
<div v-for="s in sections" :key="s.section_id" class="ppc-section" :class="'is-' + s.status">
|
||||
<span class="ppc-dot">{{ s.status === 'done' ? '✓' : s.status === 'writing' ? '…' : '○' }}</span>
|
||||
<span class="ppc-sec-title">{{ s.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.paper-progress-card { border: 1px solid var(--border, #e5e7eb); border-radius: 8px; padding: 12px; margin: 8px 0; }
|
||||
.ppc-title { font-weight: 600; margin-bottom: 8px; }
|
||||
.ppc-section { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.ppc-dot { width: 16px; text-align: center; color: #9ca3af; }
|
||||
.ppc-section.is-done .ppc-dot { color: #16a34a; }
|
||||
.ppc-section.is-writing .ppc-dot { color: #2563eb; }
|
||||
</style>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user