- 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 单测)
557 lines
17 KiB
Python
557 lines
17 KiB
Python
"""schemas.py —— Pydantic v2 模型(API.md 的契约形态)。
|
||
|
||
覆盖:块联合(§2)、SourceItem(§8)、GraphData / GraphNode / GraphEdge /
|
||
GraphChain(§9)、SessionOut / MessageOut(§5)、docgen 文档生成(§12)、
|
||
错误信封(§1)。
|
||
约定:模型 `extra="ignore"` + 全字段默认值,落库/返参前先经模型验证归一。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Literal, Union
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
|
||
|
||
def utcnow_iso() -> str:
|
||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# §2 块联合(判别字段 type)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TextBlock(BaseModel):
|
||
type: Literal["text"] = "text"
|
||
html: str
|
||
|
||
|
||
class TableBlock(BaseModel):
|
||
type: Literal["table"] = "table"
|
||
headers: list[str] = Field(default_factory=list)
|
||
rows: list[list[str]] = Field(default_factory=list)
|
||
|
||
|
||
class CodeBlock(BaseModel):
|
||
type: Literal["code"] = "code"
|
||
text: str
|
||
|
||
|
||
class StreamBlock(BaseModel):
|
||
"""瞬态块:只出现在流式过程中,绝不落库。"""
|
||
|
||
type: Literal["stream"] = "stream"
|
||
text: str
|
||
|
||
|
||
class PipelineStep(BaseModel):
|
||
name: str = ""
|
||
state: Literal["waiting", "running", "done"] = "waiting"
|
||
desc: str = ""
|
||
phase: str = "" # planning|retrieval|evidence|generation|done(API.md §4)
|
||
|
||
|
||
class PipelineBlock(BaseModel):
|
||
type: Literal["pipeline"] = "pipeline"
|
||
status: Literal["running", "done", "error"] = "running"
|
||
steps: list[PipelineStep] = Field(default_factory=list)
|
||
|
||
|
||
class ToolLog(BaseModel):
|
||
text: str = ""
|
||
cls: str = "log-ok" # log-ok|log-run
|
||
|
||
|
||
class ToolBlock(BaseModel):
|
||
type: Literal["tool"] = "tool"
|
||
icon: str = ""
|
||
name: str = ""
|
||
status: Literal["running", "done", "error"] = "running"
|
||
dur: str = ""
|
||
open: bool = True
|
||
params: str = ""
|
||
result: str = ""
|
||
logs: list[ToolLog] = Field(default_factory=list)
|
||
phase: str = "retrieval"
|
||
|
||
|
||
class SourcesBlock(BaseModel):
|
||
type: Literal["sources"] = "sources"
|
||
items: list["SourceItem"] = Field(default_factory=list)
|
||
|
||
|
||
class GraphBlock(BaseModel):
|
||
type: Literal["graph"] = "graph"
|
||
nodes: list["GraphNode"] = Field(default_factory=list)
|
||
edges: list["GraphEdge"] = Field(default_factory=list)
|
||
chains: list["GraphChain"] = Field(default_factory=list)
|
||
|
||
|
||
class ArtifactBlock(BaseModel):
|
||
"""docgen 制品块(§2 块联合扩展,P2):会话消息内挂载文档制品。
|
||
|
||
只有 artifact_id 是 BFF 落库键;markdown/source 键保证前端渲染兜底
|
||
(本地 mock 或制品文件缺失时仍可展示)。状态语义见 API.md 块状态约定。
|
||
"""
|
||
|
||
type: Literal["artifact"] = "artifact"
|
||
artifact_id: str = ""
|
||
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 = ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# §2b 生信块(BFF 仅做持久化校验,不参与渲染——字段契约归前端 store 与
|
||
# bioflow 服务 SSE 事件;union 判别靠 type 字面量,其余字段全默认值 +
|
||
# extra="allow" 宽松接收,生信功能只部署 rag 时零影响)。
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class BioflowPathBlock(BaseModel):
|
||
"""生信路径选择卡(FR-BIO-002/003/004):候选路径列表 + 选中项。"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
type: Literal["bioflow_path"] = "bioflow_path"
|
||
status: str = "running"
|
||
candidates: list[dict[str, Any]] = Field(default_factory=list)
|
||
selected: Any = None
|
||
autoFilled: list[str] = Field(default_factory=list)
|
||
reuseArchive: Any = None
|
||
reuseName: Any = None
|
||
fallback: Any = None
|
||
|
||
|
||
class BioflowProgressBlock(BaseModel):
|
||
"""生信阶段进度卡(FR-BIO-013/014):逐阶段状态流转 + 日志尾。"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
type: Literal["bioflow_progress"] = "bioflow_progress"
|
||
jobId: Any = None
|
||
jobStatus: str = "created"
|
||
stages: list[dict[str, Any]] = Field(default_factory=list)
|
||
autoContinue: bool = False
|
||
|
||
|
||
class BioflowFormBlock(BaseModel):
|
||
"""生信参数确认表单卡(FR-BIO-008/012):阶段参数可编辑表单。"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
type: Literal["bioflow_form"] = "bioflow_form"
|
||
stage: str = ""
|
||
fields: list[dict[str, Any]] = Field(default_factory=list)
|
||
autoContinue: bool = False
|
||
rerun: bool = False
|
||
submitting: bool = False
|
||
|
||
|
||
class BioflowFileBlock(BaseModel):
|
||
"""生信数据文件引导卡(FR-BIO-005/006):缺失文件提示 + 上传进度。"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
type: Literal["bioflow_file"] = "bioflow_file"
|
||
stage: str = ""
|
||
requiredFiles: list[str] = Field(default_factory=list)
|
||
message: str = ""
|
||
files: list[dict[str, Any]] = Field(default_factory=list)
|
||
|
||
|
||
class BioflowArchiveBlock(BaseModel):
|
||
"""生信存档提示卡(FR-BIO-020):任务完成后提示存档。"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
type: Literal["bioflow_archive"] = "bioflow_archive"
|
||
jobId: Any = None
|
||
status: str = "pending" # pending | archiving | done | dismissed | error
|
||
name: str = ""
|
||
description: str = ""
|
||
result: Any = None
|
||
error: Any = None
|
||
|
||
|
||
class BioflowQaBlock(BaseModel):
|
||
"""生信流程知识库咨询引用块(FR-BIO-026):引用携带 cite 供跳转。"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
type: Literal["bioflow_qa"] = "bioflow_qa"
|
||
question: str = ""
|
||
citations: list[dict[str, Any]] = Field(default_factory=list)
|
||
degraded: bool = False
|
||
|
||
|
||
class BioflowReuseBlock(BaseModel):
|
||
"""生信存档复用预填表单块(FR-BIO-023):复用存档方法发起新任务。"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
type: Literal["bioflow_reuse"] = "bioflow_reuse"
|
||
mode: str = "reuse"
|
||
stage: str = "reuse"
|
||
archiveId: Any = None
|
||
archiveName: str = ""
|
||
sessionId: str = ""
|
||
target: str = ""
|
||
fields: list[dict[str, Any]] = Field(default_factory=list)
|
||
prefill: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# §2c 私有资料问答块(FR-PRIV-004/005):BFF 仅做持久化校验,不参与渲染——
|
||
# 字段契约归前端 store 与 BFF private_docs 路由;union 判别靠 type 字面量,
|
||
# 其余字段全默认值 + extra="allow" 宽松接收(A1)。
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class PrivateDocItem(BaseModel):
|
||
"""私有资料命中条目(FR-PRIV-004 items 形态)。"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
doc_id: str = ""
|
||
name: str = ""
|
||
seq: int = 0
|
||
chunk_id: str = ""
|
||
snippet: str = ""
|
||
score: float = 0.0
|
||
category: str = ""
|
||
tags: str = ""
|
||
created_at: str = ""
|
||
ref: int = 0
|
||
|
||
|
||
class PrivateDocBlock(BaseModel):
|
||
"""私有资料参考块(FR-PRIV-005):标题「私有资料参考」+ 短答 + 条目列表。"""
|
||
|
||
model_config = ConfigDict(extra="allow")
|
||
|
||
type: Literal["private_doc"] = "private_doc"
|
||
answer: str = ""
|
||
items: list[PrivateDocItem] = Field(default_factory=list)
|
||
|
||
|
||
class PrivateQARequest(BaseModel):
|
||
"""POST /api/qa/private 请求体(FR-PRIV-004)。"""
|
||
|
||
question: str = Field(..., min_length=1) # BUG-PRIV-R1-02:空串 422(VALIDATION 信封)
|
||
session_id: str | None = None
|
||
top_k: int = 8
|
||
min_score: float = 0.30
|
||
|
||
|
||
Block = Union[
|
||
TextBlock, TableBlock, CodeBlock, StreamBlock,
|
||
PipelineBlock, ToolBlock, SourcesBlock, GraphBlock, ArtifactBlock,
|
||
PaperBriefBlock, PaperOutlineBlock, PaperProgressBlock, PaperRefsBlock,
|
||
BioflowPathBlock, BioflowProgressBlock, BioflowFormBlock, BioflowFileBlock,
|
||
BioflowArchiveBlock, BioflowQaBlock, BioflowReuseBlock,
|
||
PrivateDocBlock,
|
||
]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# §8 SourceItem —— 所有字段键必须存在(缺失为 null)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class SourceItem(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
id: str | None = None
|
||
title: str | None = None
|
||
paper_id: str | None = None
|
||
chunk_id: str | None = None
|
||
pmid: str | None = None
|
||
pmcid: str | None = None
|
||
doi: str | None = None
|
||
url: str | None = None
|
||
snippet: str | None = None
|
||
score: float | None = None
|
||
locator: str | None = None
|
||
section: str | None = None
|
||
support_count: int | None = None
|
||
type: str | None = None
|
||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# §9 图谱结构
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class GraphNode(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
id: str
|
||
label: str | None = None
|
||
type: str | None = None
|
||
|
||
|
||
class GraphEdge(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
source: str | None = None
|
||
target: str | None = None
|
||
relation: str | None = None
|
||
label: str | None = None
|
||
chain_id: str | None = None
|
||
hop: int | None = None
|
||
paper_id: str | None = None
|
||
pmid: str | None = None
|
||
pmcid: str | None = None
|
||
doi: str | None = None
|
||
|
||
|
||
class GraphHop(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
source: str | None = None
|
||
target: str | None = None
|
||
relation: str | None = None
|
||
source_type: str | None = None
|
||
target_type: str | None = None
|
||
paper_id: str | None = None
|
||
pmid: str | None = None
|
||
pmcid: str | None = None
|
||
doi: str | None = None
|
||
|
||
|
||
class GraphChain(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
chain_id: str | None = None
|
||
hops: list[GraphHop] = Field(default_factory=list)
|
||
|
||
|
||
class GraphData(BaseModel):
|
||
"""消息内 graph 块与 /api/kg/neighborhood 共用的 {nodes,edges,chains} 形态。"""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
nodes: list[GraphNode] = Field(default_factory=list)
|
||
edges: list[GraphEdge] = Field(default_factory=list)
|
||
chains: list[GraphChain] = Field(default_factory=list)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# §5 会话与消息
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class SessionMeta(BaseModel):
|
||
title_manual: bool = False
|
||
capabilities: dict[str, bool] = Field(default_factory=dict)
|
||
|
||
|
||
class SessionCreate(BaseModel):
|
||
title: str = "新会话"
|
||
|
||
def normalized_title(self) -> str:
|
||
return self.title.strip() or "新会话"
|
||
|
||
|
||
class SessionPatch(BaseModel):
|
||
title: str | None = None
|
||
status: Literal["running", "done", "error", "idle"] | None = None
|
||
meta: dict[str, Any] | None = None # S4:仅 merge meta.capabilities(白名单)
|
||
|
||
|
||
class SessionOut(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
id: str
|
||
title: str
|
||
status: str = "idle"
|
||
created_at: str = Field(default_factory=utcnow_iso)
|
||
updated_at: str = Field(default_factory=utcnow_iso)
|
||
message_count: int = 0
|
||
meta: SessionMeta = Field(default_factory=SessionMeta)
|
||
|
||
|
||
class SessionDetail(SessionOut):
|
||
messages: list["MessageOut"] = Field(default_factory=list)
|
||
|
||
|
||
class MessageCreate(BaseModel):
|
||
role: Literal["user", "assistant"]
|
||
text: str | None = None
|
||
kind: str = "qa"
|
||
status: str = "done"
|
||
blocks: list[Block] = Field(default_factory=list)
|
||
|
||
|
||
class MessagePatch(BaseModel):
|
||
"""流式检查点局部更新(PATCH /messages/{mid})。"""
|
||
|
||
status: str | None = None
|
||
blocks: list[Block] | None = None
|
||
raw: dict[str, Any] | None = None
|
||
|
||
|
||
class MessageOut(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
id: str
|
||
role: str
|
||
kind: str = "qa"
|
||
status: str = "done"
|
||
blocks: list[Block] = Field(default_factory=list)
|
||
raw: dict[str, Any] | None = None
|
||
text: str | None = None
|
||
created_at: str = Field(default_factory=utcnow_iso)
|
||
updated_at: str = Field(default_factory=utcnow_iso)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# §12 文档生成(docgen,P2)——BFF ↔ UI 契约模型
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class LibraryItemOut(BaseModel):
|
||
"""doc_library 表单行(主库管理返回形态)。"""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
id: str
|
||
name: str = ""
|
||
category: str = ""
|
||
tags: str = ""
|
||
description: str = ""
|
||
as_template: bool = False
|
||
active: bool = True
|
||
created_at: str = Field(default_factory=utcnow_iso)
|
||
|
||
|
||
class LibraryPatch(BaseModel):
|
||
"""PATCH /api/docgen/library/{id}:改 meta 或 as_template 开关。"""
|
||
|
||
name: str | None = None
|
||
category: str | None = None
|
||
tags: str | None = None
|
||
description: str | None = None
|
||
as_template: bool | None = None
|
||
active: bool | None = None
|
||
|
||
|
||
class ArtifactOut(BaseModel):
|
||
"""doc_artifacts 表单行(生成/查询返回形态)。"""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
id: str
|
||
session_id: str = ""
|
||
status: str = "running" # running|done|error
|
||
title: str = ""
|
||
template_name: str = ""
|
||
template_key: str = "" # 2026-09-04:内置模版 key(official/report/...)
|
||
has_html: bool = False # 模版 HTML 预览是否可用
|
||
file_name: str = "" # 2026-09-04:导出文件名基名(prompt 核心意思提炼)
|
||
error: str | None = None
|
||
created_at: str = Field(default_factory=utcnow_iso)
|
||
|
||
|
||
class DocgenGenerateRequest(BaseModel):
|
||
"""POST /api/docgen/generate 请求体(description 必填,上限 2000 字)。"""
|
||
|
||
description: str
|
||
session_id: str | None = None
|
||
library_ids: list[str] = Field(default_factory=list) # 空=全部主库资料
|
||
template_id: str | None = None
|
||
|
||
|
||
class DocgenEditRequest(BaseModel):
|
||
"""POST /api/docgen/artifacts/{id}/edit 请求体。"""
|
||
|
||
instruction: str
|
||
|
||
|
||
class ArtifactContentPatch(BaseModel):
|
||
"""PATCH /api/docgen/artifacts/{id}/content:保存编辑后的 markdown。
|
||
|
||
2026-09-04:letterhead 可选——红头文字就地编辑后一并持久化(None=不改)。
|
||
"""
|
||
|
||
markdown: str
|
||
letterhead: str | None = None
|
||
|
||
|
||
class DocgenExportRequest(BaseModel):
|
||
"""POST /api/docgen/artifacts/{id}/export 请求体。"""
|
||
|
||
format: Literal["pdf", "docx"]
|
||
|
||
|
||
class DocgenImportLine(BaseModel):
|
||
"""tooling/scripts/docgen_import.py CSV 单行(批量导入工具契约)。"""
|
||
|
||
name: str
|
||
category: str = ""
|
||
tags: str = ""
|
||
description: str = ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# §1 错误信封
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class ErrorBody(BaseModel):
|
||
code: str
|
||
message: str
|
||
degraded: bool = False
|
||
|
||
|
||
class ErrorEnvelope(BaseModel):
|
||
ok: Literal[False] = False
|
||
error: ErrorBody
|