- 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 单测)
91 lines
3.1 KiB
Python
91 lines
3.1 KiB
Python
"""routers/paper.py —— 论文撰写路由(对话大脑)。
|
|
|
|
条件注册(PAPER_WRITER_ENABLED 为假不注册,同 bioflow_proxy 模式)。
|
|
端点:health(探活反代)/ turns(一轮状态机)/ confirm / resume / progress / citations。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends
|
|
from pydantic import BaseModel
|
|
|
|
from ..config import get_settings
|
|
from ..db import Database, get_db
|
|
from ..services.paper import agent
|
|
from ..services.paper.mcp_client import PaperMcpClient
|
|
|
|
router = APIRouter(prefix="/api/paper", tags=["paper"])
|
|
|
|
|
|
class PaperTurnRequest(BaseModel):
|
|
session_id: str
|
|
text: str
|
|
paper_id: str | None = None
|
|
|
|
|
|
class PaperConfirmRequest(BaseModel):
|
|
session_id: str
|
|
|
|
|
|
def _client() -> PaperMcpClient:
|
|
s = get_settings()
|
|
return PaperMcpClient(s.paper_writer_url, s.paper_writer_read_timeout)
|
|
|
|
|
|
@router.get("/health")
|
|
async def paper_health() -> dict[str, Any]:
|
|
"""探活反代 8097 /health;离线返回 offline 标记(前端据此隐藏 paper 入口)。"""
|
|
s = get_settings()
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.get(f"{s.paper_writer_url.rstrip('/')}/health")
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
except Exception: # noqa: BLE001
|
|
return {"ok": False, "status": "offline"}
|
|
|
|
|
|
@router.post("/turns")
|
|
async def paper_turn(payload: PaperTurnRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
|
|
blocks = await agent.handle_turn(db, payload.session_id, payload.text, payload.paper_id)
|
|
return {"ok": True, "blocks": blocks}
|
|
|
|
|
|
@router.post("/papers/{paper_id}/confirm")
|
|
async def paper_confirm(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
|
|
asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id))
|
|
return {"ok": True, "paper_id": paper_id}
|
|
|
|
|
|
@router.post("/papers/{paper_id}/resume")
|
|
async def paper_resume(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
|
|
asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id))
|
|
return {"ok": True, "paper_id": paper_id}
|
|
|
|
|
|
@router.get("/papers/{paper_id}")
|
|
async def paper_progress(paper_id: str) -> dict[str, Any]:
|
|
client = _client()
|
|
try:
|
|
return await client.call_tool("get_paper_progress", {"paper_id": paper_id})
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"ok": False, "code": "UPSTREAM_DOWN", "message": str(exc)}
|
|
|
|
|
|
@router.get("/papers/{paper_id}/citations/{n}")
|
|
async def paper_citation(paper_id: str, n: int) -> dict[str, Any]:
|
|
client = _client()
|
|
try:
|
|
paper = await client.call_tool("get_paper", {"paper_id": paper_id})
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"ok": False, "code": "UPSTREAM_DOWN", "message": str(exc)}
|
|
if not paper.get("ok"):
|
|
return paper
|
|
for ref in paper.get("references") or []:
|
|
if ref.get("n") == n:
|
|
return {"ok": True, "citation": ref}
|
|
return {"ok": False, "code": "NOT_FOUND", "message": f"引用 [{n}] 不存在"}
|