Compare commits

...
Author SHA1 Message Date
admin 60375b04d7 chore: .gitignore 忽略含凭据的部署方案文档 DEPLOY_PAPER_176.md 2026-10-10 18:53:25 +08:00
admin 1bd4aea5fd fix(build): render_active_library_env 输出 GENE_FAMILY_ROOT 而非未定义的 QA_ROOT
active_library.env 里 $QA_ROOT 从未定义,QA start.sh 在 set -u 下 source 时报
unbound variable 导致 8091 起不来。改用 prod.env 中已定义的 GENE_FAMILY_ROOT。
2026-10-10 18:52:15 +08:00
admin 51532a30bc fix(ui): 工作台「提取任务」tab 与离线提示改为「上传文献」 2026-10-10 18:27:06 +08:00
admin d37e0d9c3a fix(paper): 确认大纲后即时 seed 进度卡并逐节刷新
- 后端写稿循环不再落空 paper_progress 块(重载后出现空卡),改落开始撰写文本
- 前端 confirmPaper 时用大纲节标题本地 seed 进度卡,2s 轮询 patch 逐节状态
- 当前节按 current_section_id 标 writing(…动画)
2026-10-08 17:10:50 +08:00
admin c923419d2d fix(paper): 大纲阶段修改意见路由回 sendPaper + 用原始 brief 重新生成大纲 2026-10-08 16:23:28 +08:00
admin 43a7c6c132 feat(paper): UI 优化(卡片文字颜色/去模式按钮) + 模板下拉加论文选项并接线渲染 2026-10-08 15:17:44 +08:00
admin 8b228923bd fix(paper): 取消路径(cancel_paper) + 引用元数据清洗(丢弃杜撰 DOI/PMID) 2026-10-08 14:59:16 +08:00
admin 2e5372cf90 fix(paper): 论文产物渲染为 HTML + 文末追加参考文献列表/disclaimer 2026-10-08 14:54:00 +08:00
admin 91f6a1c242 feat: 新增论文撰写功能 paper-writer(独立 MCP 服务 + BFF 大脑 + 前端三卡)
- paper_writer_service(8097):FastAPI + MCP streamable-http 单端点,9 工具
  (propose_paper_outline/write_paper_section/get_paper_progress/search_refs/
  revise_paper_section/revise_paper/get_paper/complete_paper/cancel_paper),
  SQLite 三表(papers/paper_sections/citations,WAL),GB/T 7714 引用协议
- BFF:services/paper/{mcp_client,agent}.py(裸 JSON-RPC 客户端 + 规则状态机)、
  routers/paper.py、schemas.py 4 个 Paper*Block + ArtifactBlock 加 paper_id/citation_count、
  config.py 开关、main.py 条件注册、docgen.py 抽 insert_artifact 公共 helper
- 前端:intent.js paperIntent 纯函数、Composer docgen 分支 sub-route、sendPaper/轮询、
  PaperBriefCard/PaperOutlineCard/PaperProgressCard、DocPane cite-chip
- deploy/paper_restart.sh + toggle_paper.sh;compile_sdk.sh 白名单追加 paper 文件
- tests/paper 26 条(工具逻辑 + MCP 协议 + BFF 状态机 + 块 schema + 前端 intent 单测)
2026-09-30 22:29:22 +08:00
53 changed files with 3366 additions and 72 deletions
+9
View File
@@ -36,3 +36,12 @@ 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
# 部署方案文档(含主机凭据,不入库)
docs/DEPLOY_PAPER_176.md
+10
View File
@@ -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)"
+51
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""paper_writer_service —— 论文撰写独立 MCP 服务(8097)。"""
+53
View File
@@ -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()
+168
View File
@@ -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()
+84
View File
@@ -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()
+48
View File
@@ -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()
+10
View File
@@ -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,109 @@
"""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
]
brief = loads_json(paper.get("brief_json"), {})
return {
"ok": True, "paper_id": paper_id, "title": paper["title"], "status": paper["status"],
"template_key": brief.get("template_key") or "document",
"brief": brief,
"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,113 @@
"""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,
template_key: str = "",
) -> 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, "template_key": template_key,
}),
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,251 @@
"""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。"
"你只能引用「知识库线索」中列出的文献;新引用的元数据只能照抄线索里的 title 和 doc_id。"
"知识库线索里没有 DOI/PMID/作者/期刊/年份,这些字段一律输出空字符串,禁止编造任何 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)
brief = loads_json(paper.get("brief_json"), {})
kb_hints = brief.get("kb_hints") or []
if kb_hints:
parts.append("")
parts.append("知识库线索(新引用只能从这里选,元数据只照抄 title/doc_id):")
for h in kb_hints:
parts.append(f"- title={h.get('title', '')} | doc_id={h.get('doc_id', '')} | snippet={str(h.get('snippet', ''))[:120]}")
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"
def _sanitize_citation(meta: dict[str, Any]) -> dict[str, Any]:
"""丢弃 LLM 可能杜撰的 doi/pmid/authors/journal/year(知识库线索无这些字段),
只保留 title/source_doc_id/snippet,强制走 title kind 降级格式。"""
return {
"title": (meta.get("title") or "").strip(),
"source_doc_id": meta.get("source_doc_id") or "",
"snippet": meta.get("snippet") or "",
}
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)
meta = _sanitize_citation(c)
ref_text = render_gbt7714(meta)
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(meta), ref_text,
meta.get("source_doc_id") or "", meta.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", "")}
+119
View File
@@ -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"
+6
View File
@@ -0,0 +1,6 @@
[pytest]
python_files = test_*.py
markers =
paper_unit: 论文服务单元测试(引用协议/大纲/写节/重排/complete)
paper_mcp: MCP 协议互操作测试
asyncio_mode = auto
+34
View File
@@ -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"
+5
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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 = [
+91
View File
@@ -0,0 +1,91 @@
"""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
template_key: str = ""
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, payload.template_key)
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}] 不存在"}
+53
View File
@@ -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,
View File
+314
View File
@@ -0,0 +1,314 @@
"""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, template_key: str = "") -> list[dict[str, Any]]:
"""一轮对话:推进状态机并落库助手消息,返回消息的 blocks 列表。"""
client = _new_client()
if not paper_id:
return await _trigger(db, session_id, text, client, template_key)
return await _followup(db, session_id, text, paper_id, client, template_key)
async def _trigger(db, session_id: str, text: str, client: PaperMcpClient, template_key: str = "") -> 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, "template_key": template_key})
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
_CANCEL_WORDS = ("暂停", "取消", "停止", "不写了", "终止", "中止", "算了")
def _is_cancel_request(text: str) -> bool:
s = (text or "").strip()
return bool(s) and any(w in s for w in _CANCEL_WORDS)
async def _followup(db, session_id: str, text: str, paper_id: str, client: PaperMcpClient, template_key: str = "") -> 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 _is_cancel_request(text):
res = await client.call_tool("cancel_paper", {"paper_id": paper_id})
if res.get("ok"):
blocks = [_text_block("论文已取消。如需重新撰写,请告诉我新主题。")]
else:
blocks = [_text_block(f"取消失败:{res.get('message', '')}")]
await _write_message(db, session_id, blocks=blocks, text=blocks[0]["html"])
return blocks
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"):
# 大纲/撰写阶段:修改意见 → 用原始 brief + 修改意见重新生成大纲(原大纲作废)
paper = await client.call_tool("get_paper", {"paper_id": paper_id})
ob = (paper.get("brief") or {}) if paper.get("ok") else {}
outline = await client.call_tool("propose_paper_outline", {
"topic": ob.get("topic") or paper.get("title") or text,
"requirements": ";".join(x for x in [ob.get("requirements"), text] if x),
"paper_type": ob.get("paper_type") or "",
"language": ob.get("language") or "",
"length": ob.get("length") or "",
"format_req": ob.get("format_req") or "",
"template_key": template_key,
})
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()
# 进度卡由前端确认时本地 seed + 2s 轮询 get_paper_progress 驱动(不落库,避免重载后出现
# 空的「撰写中」卡);后端只落「开始撰写」文本 + 逐节完成小结 + 最终 artifact。
await _write_message(db, session_id, blocks=[_text_block("开始逐节撰写……")], 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
if prog.get("status") == "cancelled":
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="撰写失败")
def _build_full_markdown(paper: dict[str, Any]) -> str:
"""把 full_markdown + 文末参考文献列表 + disclaimer 拼成完整 markdown(供渲染 HTML 与落盘)。"""
md = paper.get("full_markdown", "")
refs = paper.get("references") or []
if refs:
md += "\n\n## 参考文献\n\n" + "\n".join(
f"[{r.get('n', '')}] {r.get('text', '')}".strip() for r in refs
)
disclaimer = (paper.get("disclaimer") or "").strip()
if disclaimer:
md += f"\n\n> {disclaimer}"
return md
async def _artifact_message(db, session_id: str, paper: dict[str, Any], nl: str) -> list[dict[str, Any]]:
from ...routers.docgen import insert_artifact # 惰性导入
from ..docgen import templating # 惰性导入:复用 docgen markdown→HTML 渲染链
refs = paper.get("references") or []
title = paper.get("title", "")
full_md = _build_full_markdown(paper)
html, css = templating.render_html(full_md, title=title, key=paper.get("template_key") or "document")
artifact_id = await insert_artifact(
db, session_id=session_id, title=title,
markdown=full_md, template_name="论文", template_key=paper.get("template_key") or "document",
template_css=css, html=html,
)
blocks = [
_text_block(nl),
{"type": "artifact", "artifact_id": artifact_id, "title": 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
+104
View File
@@ -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
+80
View File
@@ -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
+33
View File
@@ -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 按首次出现顺序重排编号;正文与文末列表同源生成。
+5
View File
@@ -0,0 +1,5 @@
[pytest]
python_files = test_*.py
markers =
paper_unit: 论文服务单元测试(引用协议/大纲/写节/重排/complete)
paper_mcp: MCP 协议层测试(list_tools/call_tool)
+12
View File
@@ -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"
+69
View File
@@ -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
+124
View File
@@ -0,0 +1,124 @@
"""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"))
# 进度卡由前端确认时本地 seed + 轮询驱动,后端只落「开始撰写」文本 + 逐节小结 + artifact
assert "开始逐节撰写" in rows[0]["blocks"]
assert "artifact" in rows[-1]["blocks"]
assert "art-fake" in rows[-1]["blocks"]
+35
View File
@@ -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);
+272
View File
@@ -0,0 +1,272 @@
"""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_drops_fabricated_doi(paper_db, fake_llm, fake_search):
"""杜撰的 doi/pmid/authors/journal 应被丢弃,强制 title kind 降级格式。"""
paper_id = _propose(fake_llm, fake_search)["paper_id"]
fake_llm.responses.append({
"markdown": "正文[1]。",
"citations_used": [{"temp_n": 1, "title": "水稻SPL研究", "authors": "X", "journal": "J",
"year": "2020", "doi": "10.1234/fake", "pmid": "123", "source_doc_id": "d1"}],
})
r = _run(write.write_paper_section(paper_id, "s1"))
assert r["ok"] is True
cit = _run(dbmod.db.fetchone("SELECT * FROM citations WHERE temp_n = 1"))
assert cit["kind"] == "title"
assert "知识库资料" in cit["ref_text"]
assert "10.1234" not in cit["ref_text"]
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]"
+5 -5
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# 渲染 active_library.env:读 <库目录>/manifest.yaml,输出五触点 env。
# 用法: render_active_library_env.sh <manifest.yaml> <输出文件>
# config 类路径:manifest 值为相对路径(如 configs/rag/entity_alias.yaml)时渲染为 $QA_ROOT/<值>;
# 为空时回退 $QA_ROOT/configs/rag/<缺省文件名>。registry_path 为空则整行省略。
# config 类路径:manifest 值为相对路径(如 configs/rag/entity_alias.yaml)时渲染为 $GENE_FAMILY_ROOT/<值>;
# 为空时回退 $GENE_FAMILY_ROOT/configs/rag/<缺省文件名>。registry_path 为空则整行省略。
set -euo pipefail
MANIFEST="$1"; OUT="$2"
release_id="$(awk -F': *' '/^release_id:/ {gsub(/[" ]/, "", $2); print $2; exit}' "$MANIFEST")"
@@ -15,8 +15,8 @@ tmp="$OUT.tmp"
{
echo "NORMALIZED_RELEASE_ID=$release_id"
echo "V15_QDRANT_COLLECTION=${collection:-$release_id}"
[ -n "$alias_cfg" ] && echo "QA_V1_ALIAS_CONFIG=\$QA_ROOT/$alias_cfg" || echo "QA_V1_ALIAS_CONFIG=\$QA_ROOT/configs/rag/entity_alias.yaml"
[ -n "$tax_cfg" ] && echo "QA_V1_TAXONOMY_CONFIG=\$QA_ROOT/$tax_cfg" || echo "QA_V1_TAXONOMY_CONFIG=\$QA_ROOT/configs/rag/relation_taxonomy.yaml"
[ -n "$registry" ] && echo "QA_PAPER_METADATA_REGISTRY=\$QA_ROOT/$registry"
[ -n "$alias_cfg" ] && echo "QA_V1_ALIAS_CONFIG=\$GENE_FAMILY_ROOT/$alias_cfg" || echo "QA_V1_ALIAS_CONFIG=\$GENE_FAMILY_ROOT/configs/rag/entity_alias.yaml"
[ -n "$tax_cfg" ] && echo "QA_V1_TAXONOMY_CONFIG=\$GENE_FAMILY_ROOT/$tax_cfg" || echo "QA_V1_TAXONOMY_CONFIG=\$GENE_FAMILY_ROOT/configs/rag/relation_taxonomy.yaml"
[ -n "$registry" ] && echo "QA_PAPER_METADATA_REGISTRY=\$GENE_FAMILY_ROOT/$registry"
} > "$tmp"
mv "$tmp" "$OUT"
+1 -1
View File
@@ -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,实测编译后无法导入——纯数据契约面,无敏感逻辑,明文保留);
+34
View File
@@ -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)}`);
}
+59 -1
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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>
+79 -23
View File
@@ -13,16 +13,17 @@
// qa → 原 emit('send') 路径(附件注记进消息文本)。
// 路由收敛在本组件内,ChatPanel 等上游组件零改动。
import { ref, computed, onMounted } from 'vue';
import { MODES as MODE_LABELS, T } from '../../i18n/labels.js';
import { 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(() =>
@@ -124,6 +128,36 @@ const hasBioflowContext = computed(() => {
}
});
/** 论文上下文(当前会话消息含 paper 块;用于把修改意见路由回 sendPaper) */
const hasPaperContext = computed(() => {
try {
const chat = useChatStore();
const list = chat.messages || [];
return list.some((m) => m && m.role === 'assistant' && Array.isArray(m.blocks)
&& m.blocks.some((b) => b && ['paper_brief', 'paper_outline', 'paper_progress'].includes(b.type)));
} catch {
return false;
}
});
/** 当前活跃论文 id(从最近的 paper_outline/paper_progress 块取) */
const activePaperId = computed(() => {
try {
const chat = useChatStore();
const list = chat.messages || [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
for (const b of m.blocks) {
if (b && (b.type === 'paper_outline' || b.type === 'paper_progress') && b.paper_id) return b.paper_id;
}
}
return null;
} catch {
return null;
}
});
/** 生信可用(探活显隐 FR-BIO-025;未探活视为可用不阻断分流——后端不注册时由请求失败兜底) */
const bioflowEnabled = computed(() => {
try {
@@ -134,6 +168,16 @@ const bioflowEnabled = computed(() => {
}
});
/** 论文撰写可用(探活显隐;离线时触发词落 docgen 单发降级——后端不注册时由请求失败兜底) */
const paperEnabled = computed(() => {
try {
const st = usePaperStore();
return st.availability !== 'offline';
} catch {
return true;
}
});
const hintText = computed(() =>
attachments.value.length ? T.composerHint : `${T.composerHint} · ${DG.composerDropHint}`,
);
@@ -291,17 +335,34 @@ const submit = () => {
emit('send', text); // 生信 store 未就绪(测试/降级):漏判安全退化为 QA 路径
}
} else if (intent === 'docgen') {
// 文档生成分支:chat.sendDocgen(用户消息落库 + 骨架 + 异步生成 + 自动切文档 TAB)
// 缺陷 #42:选中模板经 { templateId } 传入(空串/未选 = 自动推荐)
// 文档生成分支:论文撰写 sub-route(paperIntent && paperEnabled → sendPaper),
// 否则 sendDocgen(降级路径零改动——paper 未启用/未命中时触发词落 docgen 单发)。
let paperRoute = false;
try {
useChatStore().sendDocgen(text, readyAttachments.value.map((a) => a.libraryId), {
templateId: selectedTemplateId.value || '',
}).catch(() => {
/* sendDocgen 内部已按失败可见原则标注错误,此处兜底吞掉重复抛出 */
});
paperRoute = paperEnabled.value && paperIntent(text);
} catch {
/* chat store 未就绪(测试/降级):漏判安全退化为 QA 路径 */
emit('send', text);
paperRoute = false;
}
if (paperRoute) {
try {
useChatStore().sendPaper(text, { templateId: selectedTemplateId.value || '' }).catch(() => {
/* sendPaper 内部已按失败可见原则标注错误 */
});
} catch {
emit('send', text); // chat store 未就绪(测试/降级):漏判安全退化为 QA 路径
}
} else {
// 缺陷 #42:选中模板经 { templateId } 传入(空串/未选 = 自动推荐)
try {
useChatStore().sendDocgen(text, readyAttachments.value.map((a) => a.libraryId), {
templateId: selectedTemplateId.value || '',
}).catch(() => {
/* sendDocgen 内部已按失败可见原则标注错误,此处兜底吞掉重复抛出 */
});
} catch {
/* chat store 未就绪(测试/降级):漏判安全退化为 QA 路径 */
emit('send', text);
}
}
} else if (intent === 'edit') {
// 编辑分支:预填 DocPane「用 prompt 修改」并切文档 TAB
@@ -313,6 +374,13 @@ const submit = () => {
const dg = useDocgenStore();
dg.requestExport();
dg.switchToDocTab();
} else if (hasPaperContext.value && paperEnabled.value) {
// 活跃论文(outline/writing/complete 阶段)的修改意见 → sendPaper(带 paper_id)
try {
useChatStore().sendPaper(text, { paperId: activePaperId.value || '' }).catch(() => {});
} catch {
emit('send', text);
}
} else {
// QA 原路径:附件注记进消息文本
const note = attachmentNoteText();
@@ -359,18 +427,6 @@ defineExpose({ clear: () => { draft.value = ''; }, focus: () => textarea.value?.
@drop="onDrop"
>
<div class="composer">
<div class="mode-pills">
<button
v-for="pm in MODE_LABELS"
:key="pm.key"
type="button"
:class="['mode-pill', { on: mode === pm.key }]"
:disabled="busy || disabled"
@click="emit('change-mode', pm.key)"
>
{{ pm.label }}
</button>
</div>
<!-- 缺陷 #42:docgen 模式模板下拉(「自动推荐」+ 内置模板 + as_template 资料,懒加载) -->
<div v-if="isDocgenMode" class="template-row">
<label class="tpl-label" for="pa-template-select">{{ DG.docPaneTemplateLabel }}</label>
+64
View File
@@ -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: #374151; 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: #111827; }
.pbc-input { flex: 1; padding: 4px 8px; border: 1px solid #d1d5db; border-radius: 4px; color: #111827; }
.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>
+2
View File
@@ -30,6 +30,8 @@ export const BUILTIN_TEMPLATES = {
speech: '讲话稿',
plan: '方案计划',
document: '通用文档',
paper_review: '综述论文',
paper_research: '研究论文',
};
export const DG = {
+2 -2
View File
@@ -57,7 +57,7 @@ export const STAGE_PHASE = {
export const BENCH_TABS = [
{ key: 'retrieval', label: '检索过程' },
{ key: 'sources', label: '文献来源' },
{ key: 'extract', label: '提取任务' },
{ key: 'extract', label: '上传文献' },
{ key: 'kg', label: '知识图谱' },
];
@@ -124,7 +124,7 @@ export const T = {
citationError: '引用校验失败,请重试',
qaDown: '知识问答服务当前不可用,请稍后重试',
kgDown: '知识图谱服务离线,实体搜索与邻域浏览暂不可用',
extractionDown: '提取任务服务离线',
extractionDown: '上传文献服务离线',
sourcesTitle: '文献来源',
sourceEmpty: '本次检索未命中文献',
retrievalEmpty: '发起提问后,这里会实时展示检索过程',
+28
View File
@@ -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;
+11
View File
@@ -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;
+210
View File
@@ -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,208 @@ 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, templateId, paperId } = {}) {
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, paper_id: paperId || null, template_key: templateId || '' });
} 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++;
},
/** 确认大纲 → 本地先 seed 进度卡 → POST confirm 启动后台写节循环 → 2s 轮询进度 */
async confirmPaper(paperId) {
const sessionId = this.sessionId;
if (!paperId || !sessionId) return;
this._seedPaperProgress(sessionId, paperId);
try {
await confirmPaperApi(paperId, sessionId);
} catch { /* 启动失败:卡片可见错误 */ }
this._startPaperPoll(paperId, sessionId);
},
/** 确认后立即在消息流上一条 paper_progress 块(用大纲节标题做种子,全部 pending),
* 使 _startPaperPoll 的 _patchPaperProgress 有块可 patch,进度卡在撰写期间逐节刷新,
* 而不是等 complete 重载时一次性出现。 */
_seedPaperProgress(sessionId, paperId) {
const list = this.sessionMsgs[sessionId] || this.messages || [];
for (const m of list) {
if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
if (m.blocks.some((b) => b && b.type === 'paper_progress' && b.paper_id === paperId)) return;
}
let sections = [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
const ob = m.blocks.find((b) => b && b.type === 'paper_outline' && b.paper_id === paperId);
if (ob) {
sections = (Array.isArray(ob.sections) ? ob.sections : []).map((sec) => ({
section_id: sec.section_id, title: sec.title || '', status: 'pending',
}));
break;
}
}
const raw = {
id: null, role: 'assistant', kind: 'paper', status: 'done', text: null,
blocks: [{ type: 'paper_progress', paper_id: paperId, status: 'writing', sections, current_section_id: '' }],
raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
};
list.push(raw);
this.scrollSeq++;
},
_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] || [];
const current = prog.current_section_id || '';
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 = current;
// 后端节状态只有 pending/done;当前节用 current_section_id 标为 writing(…动画)
pb.sections = (prog.sections || []).map((sec) => ({
section_id: sec.section_id, title: sec.title,
status: (current && sec.section_id === current) ? 'writing' : sec.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
+36
View File
@@ -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;
},
},
});