Files
AgentLab/backend/app/db/models.py
T
ThearchyHelios cc608f56ec 跑完先过一道复核再交给你:异常说破、数字不许现编、出错自动改一次
「跑完了」和「答得对」之间一直没有任何一层。库里 264 条 succeeded 里,
14 条带着 warn、4 条 agent 步数用满,而交到用户手上的答案对此只字不提。
最糟的一次(run dd9927e6)交出去的干脆是「本轮只执行了第一个工具」——
一句内部管道文案冒充结论。

出具体系(issuance.py)本来就是干这个的,但它挂在输出节点的可选契约上,
还要配口径卡提供受控指标集;问数据页生成的图基本都是 input → agent → output,
走不到那条路。所以补一层轻的。

**干净的运行一次模型都不调。** 先用规则扫一遍事件流(engine/review.py:scan),
没扫出东西就到此为止。实测 15ms 返回。这是整层设计的前提,测试里用
「monkeypatch 一个会抛异常的模型,断言它没被碰过」来守。

**分类挂在 code 上,不是中文消息串。** 12 处 warn 发射点都带上 code。
这样「流式失败,回退非流式」能归成 benign 直接跳过——它对答案内容零影响,
为它叫一次模型等于每次网络抖一下就多花一笔;而「步数用满」归 broken。
原来这两者在事件流里长得一模一样。工具报错之后同一个工具又跑成了,
算 degraded 不算 broken——否则参数自愈每修一次就报一次「答案不可信」。

**复核不许现编数字。** 它手上只有原答案和一份异常清单,没有任何原始数据,
写出来的新数字只能是凭空来的。直接复用 issuance.trace_numbers,把原答案的
数字当受控指标集,对不上就整条退回原答案、只留说明。硬拦截,不是提示。

**规则管重试资格,模型只有否决权。** 一开始写成两边取并,实测 loop_limit
时模型判 retry=true——而重跑一遍循环还是会撞上限,白烧一次建图加一次跑图。
反过来之后,「检索什么都没查到」这类也不会被模型强推重跑。

**重试带着失败的那张图,不是推倒重来。** 原来传 base_graph=null,模型只看到
「原问题 + 一句失败原因」,很可能原样再建出同一张坏图。而且它连自己上次建了
什么都看不到——会话历史按 status != running 过滤,正在重试的这一轮恰好被
排除在外。现在交回去走后端的改图分支,调大步数、改正参数就是改一个字段。
上限一次。

**答案等复核完成再显示。** 端到端抓到的:答案先摆出来,十秒后说明才追上去——
这十秒里用户读的是一个没人核对过的结论,正是要修的那件事本身。改成落库立刻
(防关标签页丢结果)、显示等复核,并给复核加 25s 上限。说明排在成果上方,
broken 用报警色、degraded 用普通边框——混成一个样子用户就学会了一概忽略。

复核结论跟着轮次落库,刷新后还在;也进下一轮的对话历史,否则模型会拿一个
已知不可信的结论当事实继续往下推。

验证:后端 242 passed(新增 test_review.py 25 条);tsc 干净;
check-decode / check-stream / check-ui 全过,check-stream 新增 9 条渲染断言。
两条端到端都在真浏览器 + 真模型上跑通:检索降级那次说明正确排在答案上方;
重试那次建图请求 2 次、第二次 base_graph 带着失败的 3 个节点、第一版没漏出去。

复核结果不是事件流,decode.ts 翻译不到它,所以渲染测试放在 check-stream。
2026-09-20 11:18:53 +08:00

432 lines
21 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.
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import (
DateTime, Float, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base, TimestampMixin, new_id, utcnow
# --------------------------------------------------------------------------
# 设置:模型供应商 / 用户偏好 / MCP / 自定义工具
# --------------------------------------------------------------------------
class Provider(Base, TimestampMixin):
"""一个模型接入点。api_key 落库前经 app.core.crypto 加密。"""
__tablename__ = "providers"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(100), unique=True)
kind: Mapped[str] = mapped_column(String(32)) # anthropic | openai | openai_compatible | mock
base_url: Mapped[str | None] = mapped_column(String(500), default=None)
api_key: Mapped[str | None] = mapped_column(Text, default=None) # 密文
models: Mapped[list[Any]] = mapped_column(default=list) # [{id, label, context, pricing}]
default_model: Mapped[str | None] = mapped_column(String(200), default=None)
enabled: Mapped[bool] = mapped_column(default=True)
extra: Mapped[dict[str, Any]] = mapped_column(default=dict)
class DataSource(Base, TimestampMixin):
"""一个数据库接入点。password 落库前经 app.core.crypto 加密,和 Provider 同一套。
readonly 默认为真,是刻意的安全基线:这里的 SQL 由模型生成,不是人写的。
要写库就另建一个显式关掉只读的源,并且写操作会走人工审批——把"能写"这件事
变成一个需要两次明确动作的决定,而不是默认状态。
"""
__tablename__ = "data_sources"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(100), unique=True)
kind: Mapped[str] = mapped_column(String(32)) # mysql | postgres | oracle | sqlite
host: Mapped[str | None] = mapped_column(String(255), default=None)
port: Mapped[int | None] = mapped_column(Integer, default=None)
database: Mapped[str | None] = mapped_column(String(255), default=None)
username: Mapped[str | None] = mapped_column(String(128), default=None)
password: Mapped[str | None] = mapped_column(Text, default=None) # 密文
# 驱动差异塞这里:Oracle 的 service_name/sid、MySQL 的 charset、SSL 参数…
# 不给每种数据库加一列,否则表会长成各家方言的并集
options: Mapped[dict[str, Any]] = mapped_column(default=dict)
readonly: Mapped[bool] = mapped_column(default=True)
# 给 Copilot 看的一句话:"销售库,含订单/客户/产品"。它据此判断该查哪个源
description: Mapped[str] = mapped_column(Text, default="")
# 探查到的表结构。缓存下来,否则每次建图都要连一次生产库
schema_cache: Mapped[dict[str, Any]] = mapped_column(default=dict)
schema_synced_at: Mapped[datetime | None] = mapped_column(default=None)
enabled: Mapped[bool] = mapped_column(default=True)
class Setting(Base, TimestampMixin):
"""键值形式的用户设置,前端 Settings 页直接读写。"""
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[dict[str, Any]] = mapped_column(default=dict)
class McpServer(Base, TimestampMixin):
__tablename__ = "mcp_servers"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(100), unique=True)
transport: Mapped[str] = mapped_column(String(20), default="stdio") # stdio | http
command: Mapped[str | None] = mapped_column(String(500), default=None)
args: Mapped[list[Any]] = mapped_column(default=list)
env: Mapped[dict[str, Any]] = mapped_column(default=dict)
url: Mapped[str | None] = mapped_column(String(500), default=None)
enabled: Mapped[bool] = mapped_column(default=True)
# 最近一次探活的结果与工具清单缓存
status: Mapped[str] = mapped_column(String(20), default="unknown")
last_error: Mapped[str | None] = mapped_column(Text, default=None)
tools_cache: Mapped[list[Any]] = mapped_column(default=list)
class CustomTool(Base, TimestampMixin):
"""用户自定义工具:HTTP 调用模板,或跑在沙箱里的一段 Python。"""
__tablename__ = "custom_tools"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(100), unique=True)
description: Mapped[str] = mapped_column(Text, default="")
kind: Mapped[str] = mapped_column(String(20), default="http") # http | python
parameters: Mapped[dict[str, Any]] = mapped_column(default=dict) # JSON Schema
config: Mapped[dict[str, Any]] = mapped_column(default=dict)
enabled: Mapped[bool] = mapped_column(default=True)
# --------------------------------------------------------------------------
# 工作流与版本
# --------------------------------------------------------------------------
class Workflow(Base, TimestampMixin):
__tablename__ = "workflows"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(200))
description: Mapped[str] = mapped_column(Text, default="")
graph: Mapped[dict[str, Any]] = mapped_column(default=dict) # {nodes, edges, viewport}
tags: Mapped[list[Any]] = mapped_column(default=list)
version: Mapped[int] = mapped_column(Integer, default=1)
is_template: Mapped[bool] = mapped_column(default=False)
# 治理状态机:draft(随便改)→ published(有正式版本可跑)→ governed(发布需过治理 lint)
status: Mapped[str] = mapped_column(String(20), default="draft")
# 正式运行默认解析到这个版本;为空表示还没发布过
published_version: Mapped[int | None] = mapped_column(Integer, default=None)
published_by: Mapped[str | None] = mapped_column(String(100), default=None)
versions: Mapped[list["WorkflowVersion"]] = relationship(
back_populates="workflow", cascade="all, delete-orphan", lazy="selectin"
)
class WorkflowVersion(Base, TimestampMixin):
"""每次保存留一份快照,便于回滚和对比不同编排的效果。"""
__tablename__ = "workflow_versions"
__table_args__ = (UniqueConstraint("workflow_id", "version"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
workflow_id: Mapped[str] = mapped_column(ForeignKey("workflows.id", ondelete="CASCADE"))
version: Mapped[int] = mapped_column(Integer)
graph: Mapped[dict[str, Any]] = mapped_column(default=dict)
note: Mapped[str] = mapped_column(Text, default="")
# 图内容的 sha256 指纹(剔除 viewport)。formal run 钉住的就是它。
graph_hash: Mapped[str | None] = mapped_column(String(64), default=None)
workflow: Mapped[Workflow] = relationship(back_populates="versions")
# --------------------------------------------------------------------------
# 运行、事件、审批
# --------------------------------------------------------------------------
class Run(Base, TimestampMixin):
__tablename__ = "runs"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
workflow_id: Mapped[str | None] = mapped_column(
ForeignKey("workflows.id", ondelete="SET NULL"), default=None
)
workflow_name: Mapped[str] = mapped_column(String(200), default="")
# LangGraph checkpointer 的 thread_id —— 持久化与恢复的锚点
thread_id: Mapped[str] = mapped_column(String(64), index=True, default=new_id)
# queued | running | interrupted | succeeded | failed | cancelled
status: Mapped[str] = mapped_column(String(20), default="queued", index=True)
graph: Mapped[dict[str, Any]] = mapped_column(default=dict) # 执行时的图快照
input: Mapped[dict[str, Any]] = mapped_column(default=dict)
output: Mapped[dict[str, Any]] = mapped_column(default=dict)
error: Mapped[str | None] = mapped_column(Text, default=None)
usage: Mapped[dict[str, Any]] = mapped_column(default=dict) # tokens / cost / 各节点耗时
started_at: Mapped[datetime | None] = mapped_column(default=None)
finished_at: Mapped[datetime | None] = mapped_column(default=None)
last_seq: Mapped[int] = mapped_column(Integer, default=0)
# formal:从不可变的 WorkflowVersion 发起,可复现、可出具
# exploratory:画布试跑 / 裸 graph / 追问,结果只标探索性
run_class: Mapped[str] = mapped_column(String(20), default="exploratory")
version: Mapped[int | None] = mapped_column(Integer, default=None)
version_hash: Mapped[str | None] = mapped_column(String(64), default=None)
# 结束时对全部事件计算的清单哈希,事后改动事件流会被它戳穿
manifest_hash: Mapped[str | None] = mapped_column(String(64), default=None)
started_by: Mapped[str | None] = mapped_column(String(100), default=None)
events: Mapped[list["RunEvent"]] = relationship(
back_populates="run", cascade="all, delete-orphan"
)
class RunEvent(Base):
"""完整事件流落库 —— 这既是 trace,也是页面刷新后重建时间线的依据。"""
__tablename__ = "run_events"
__table_args__ = (Index("ix_run_events_run_seq", "run_id", "seq"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id", ondelete="CASCADE"))
seq: Mapped[int] = mapped_column(Integer, default=0)
type: Mapped[str] = mapped_column(String(40))
node_id: Mapped[str | None] = mapped_column(String(64), default=None)
ts: Mapped[float] = mapped_column(Float)
data: Mapped[dict[str, Any]] = mapped_column(default=dict)
run: Mapped[Run] = relationship(back_populates="events")
class Approval(Base, TimestampMixin):
"""一次人工介入请求。对应 LangGraph 的 interrupt。"""
__tablename__ = "approvals"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
run_id: Mapped[str] = mapped_column(ForeignKey("runs.id", ondelete="CASCADE"), index=True)
node_id: Mapped[str] = mapped_column(String(64))
interrupt_id: Mapped[str | None] = mapped_column(String(64), default=None)
mode: Mapped[str] = mapped_column(String(20), default="approve") # approve | input | edit
title: Mapped[str] = mapped_column(String(300), default="")
payload: Mapped[dict[str, Any]] = mapped_column(default=dict) # 给人看的上下文
schema_: Mapped[dict[str, Any]] = mapped_column("schema", default=dict)
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
response: Mapped[dict[str, Any]] = mapped_column(default=dict)
resolved_at: Mapped[datetime | None] = mapped_column(default=None)
# 责任归属:谁批的。没有 actor 的治理是空转。
resolved_by: Mapped[str | None] = mapped_column(String(100), default=None)
class Artifact(Base, TimestampMixin):
"""工件引用表。
文件本体按内容哈希存在 data/artifacts/ 下(同内容只一份);
这张表记录"哪个 run 的哪个节点产出了它"——溯源需要的正是这层归属。
复合主键:同一工件被多个 run 产出时各记一行引用。
"""
__tablename__ = "artifacts"
__table_args__ = (Index("ix_artifacts_run", "run_id"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
run_id: Mapped[str] = mapped_column(String(32), primary_key=True, default="")
kind: Mapped[str] = mapped_column(String(32), default="node_output") # node_output | tool_snapshot | metric_set
node_id: Mapped[str | None] = mapped_column(String(64), default=None)
size: Mapped[int] = mapped_column(Integer, default=0)
meta: Mapped[dict[str, Any]] = mapped_column(default=dict)
# --------------------------------------------------------------------------
# 记忆、知识库、Skill
# --------------------------------------------------------------------------
class MemoryItem(Base, TimestampMixin):
"""长期记忆。scope 用来隔离不同 agent / 会话的记忆空间。"""
__tablename__ = "memories"
__table_args__ = (Index("ix_memories_scope_kind", "scope", "kind"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
scope: Mapped[str] = mapped_column(String(100), default="default")
kind: Mapped[str] = mapped_column(String(32), default="fact") # fact | preference | episode
content: Mapped[str] = mapped_column(Text)
meta: Mapped[dict[str, Any]] = mapped_column(default=dict)
embedding: Mapped[bytes | None] = mapped_column(LargeBinary, default=None)
# 这条向量是谁产出的。没有它就无法判断存量向量还能不能和当前查询比对——
# 换 embedder 之后维度对不上,cosine 直接 ValueError(512 vs 1536),
# 而在此之前代码里没有任何地方能看出"这条是旧模型建的"
embed_model: Mapped[str] = mapped_column(String(100), default="")
embed_dim: Mapped[int] = mapped_column(Integer, default=0)
importance: Mapped[float] = mapped_column(Float, default=0.5)
last_used_at: Mapped[datetime | None] = mapped_column(default=None)
use_count: Mapped[int] = mapped_column(Integer, default=0)
class Document(Base, TimestampMixin):
"""知识库里的一份原始文档。"""
__tablename__ = "documents"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
collection: Mapped[str] = mapped_column(String(100), default="default", index=True)
title: Mapped[str] = mapped_column(String(300), default="")
source: Mapped[str] = mapped_column(String(500), default="") # 文件名 / URL
mime: Mapped[str] = mapped_column(String(100), default="text/plain")
content: Mapped[str] = mapped_column(Text, default="")
meta: Mapped[dict[str, Any]] = mapped_column(default=dict)
chunk_count: Mapped[int] = mapped_column(Integer, default=0)
# 切块+算向量是在后台跑的:一个 10MB 文档配上远端 embedding 要几十分钟,
# 压在 HTTP 请求里必然超时——而后端其实还在跑,界面显示失败、数据其实成功,
# 比直接拒绝还糟。ready | processing | failed
status: Mapped[str] = mapped_column(String(20), default="ready")
error: Mapped[str] = mapped_column(Text, default="")
chunks: Mapped[list["Chunk"]] = relationship(
back_populates="document", cascade="all, delete-orphan"
)
class Chunk(Base):
__tablename__ = "chunks"
__table_args__ = (Index("ix_chunks_collection", "collection"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
document_id: Mapped[str] = mapped_column(ForeignKey("documents.id", ondelete="CASCADE"))
collection: Mapped[str] = mapped_column(String(100), default="default")
ordinal: Mapped[int] = mapped_column(Integer, default=0)
content: Mapped[str] = mapped_column(Text)
embedding: Mapped[bytes | None] = mapped_column(LargeBinary, default=None)
# 这条向量是谁产出的。没有它就无法判断存量向量还能不能和当前查询比对——
# 换 embedder 之后维度对不上,cosine 直接 ValueError(512 vs 1536),
# 而在此之前代码里没有任何地方能看出"这条是旧模型建的"
embed_model: Mapped[str] = mapped_column(String(100), default="")
embed_dim: Mapped[int] = mapped_column(Integer, default=0)
#: 分词后的 token 数。BM25 要按文档长度归一,存下来省得为算 avg_len 再扫一遍
token_len: Mapped[int] = mapped_column(Integer, default=0)
meta: Mapped[dict[str, Any]] = mapped_column(default=dict)
document: Mapped[Document] = relationship(back_populates="chunks")
class ChunkTerm(Base):
"""倒排表:词 → 包含它的片段。
在此之前每次检索都 `select(Chunk)` 全量载入,再在 Python 里**重建**一遍
BM25 倒排——几百段无感,上万段就是秒级,而那正是知识库开始有用的规模。
没用 SQLite 的 FTS5:它的 trigram 分词器要求查询至少三个字符,中文里
「商家」「订单」「权限」这些两字词一个都召不回(实测)。而现有的分词器
本来就做中文单字 + 二元组,自己存一张倒排表既保住召回,也不把知识库
锁死在 SQLite 上。
"""
__tablename__ = "chunk_terms"
__table_args__ = (Index("ix_chunk_terms_lookup", "collection", "term"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
chunk_id: Mapped[str] = mapped_column(
ForeignKey("chunks.id", ondelete="CASCADE"), index=True
)
collection: Mapped[str] = mapped_column(String(100), default="default")
term: Mapped[str] = mapped_column(String(64))
tf: Mapped[int] = mapped_column(Integer, default=1)
class Skill(Base, TimestampMixin):
"""可复用的方法论:一段指令 + 可选的示例和推荐工具。
节点可以挂载 skill,运行时把内容注入 system prompt —— 把"怎么做事"
从工作流结构里抽出来单独管理和迭代。
"""
__tablename__ = "skills"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
name: Mapped[str] = mapped_column(String(100), unique=True)
description: Mapped[str] = mapped_column(Text, default="")
instructions: Mapped[str] = mapped_column(Text, default="")
examples: Mapped[list[Any]] = mapped_column(default=list)
suggested_tools: Mapped[list[Any]] = mapped_column(default=list)
tags: Mapped[list[Any]] = mapped_column(default=list)
enabled: Mapped[bool] = mapped_column(default=True)
# --------------------------------------------------------------------------
# 会话:把一串问答串成一次对话
# --------------------------------------------------------------------------
class Conversation(Base, TimestampMixin):
"""一次对话。
在此之前"对话"只是前端内存里的一个数组:刷新页面就没了,而每一轮又各自
从零建图、各起一条 checkpoint 线程,所以"上一轮问过什么"在系统里根本
无处可查。追问「再按月份拆一下」会重新找一遍数据源,可能接到别的表。
落成一张表之后,它同时承担两件事:给用户看的历史列表,和给模型看的上下文。
kind 分两种,因为这两件事像但不是一回事:
chat —— 问数据页,一轮 = 一次「建图 → 跑图 → 出答案」,进左侧列表
canvas —— 画布右栏的 Copilot,依附某张图,一轮 = 一次改图,不进列表
"""
__tablename__ = "conversations"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
title: Mapped[str] = mapped_column(String(200), default="")
kind: Mapped[str] = mapped_column(String(20), default="chat", index=True)
workflow_id: Mapped[str | None] = mapped_column(
ForeignKey("workflows.id", ondelete="CASCADE"), default=None, index=True
)
archived: Mapped[bool] = mapped_column(default=False)
# 列表按活跃度排。不能用 updated_at —— 改个标题就会把这条顶到最前面,
# 而用户对"最近聊过的"的预期是按说话时间排,不是按编辑时间
last_active_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, index=True
)
turns: Mapped[list["ConversationTurn"]] = relationship(
back_populates="conversation",
cascade="all, delete-orphan",
order_by="ConversationTurn.seq",
)
class ConversationTurn(Base, TimestampMixin):
"""一轮问答。
run_id 可以为空:建图阶段就失败时压根没有 run,但这一轮仍然发生过,
用户也该在历史里看到它失败了——只记成功的轮次,历史就成了一份美化过的
记录,而人再回来时最想搞清楚的恰恰是上次卡在哪。
"""
__tablename__ = "conversation_turns"
__table_args__ = (Index("ix_conversation_turns_conv_seq", "conversation_id", "seq"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
conversation_id: Mapped[str] = mapped_column(
ForeignKey("conversations.id", ondelete="CASCADE"), index=True
)
seq: Mapped[int] = mapped_column(Integer, default=0)
question: Mapped[str] = mapped_column(Text, default="")
answer: Mapped[str] = mapped_column(Text, default="")
# Copilot 对这张图的说明。画布那条路径上只有它,没有 answer
explanation: Mapped[str] = mapped_column(Text, default="")
graph: Mapped[dict[str, Any] | None] = mapped_column(default=None)
run_id: Mapped[str | None] = mapped_column(
ForeignKey("runs.id", ondelete="SET NULL"), default=None
)
# running | done | error
status: Mapped[str] = mapped_column(String(20), default="running")
error: Mapped[str] = mapped_column(Text, default="")
# 跑完之后的复核结论(engine/review.py):判定档位、给用户的异常说明、
# 命中的信号清单。落库是因为它是答案的一部分——刷新页面后只剩一个
# 看起来很完整的答案、而"它哪里不可靠"没了,比不复核更糟
review: Mapped[dict[str, Any] | None] = mapped_column(default=None)
conversation: Mapped[Conversation] = relationship(back_populates="turns")