Files
PlantAI-rag-new/sdk/app/schemas.py
T
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

557 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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