mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 19:12:00 +00:00
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。
108 lines
3.9 KiB
Python
108 lines
3.9 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any, AsyncIterator
|
|
|
|
from sqlalchemy import JSON, DateTime, MetaData, event
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
|
|
from app.core.config import settings
|
|
|
|
NAMING_CONVENTION = {
|
|
"ix": "ix_%(column_0_label)s",
|
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
"fk": "fk_%(table_name)s_%(column_0_name)s",
|
|
"pk": "pk_%(table_name)s",
|
|
}
|
|
|
|
|
|
def utcnow() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def new_id() -> str:
|
|
return uuid.uuid4().hex
|
|
|
|
|
|
class Base(DeclarativeBase):
|
|
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
|
|
|
# 图定义、节点配置、事件负载都是任意形状的 JSON,统一映射到 SQLite 的 JSON 列
|
|
type_annotation_map = {dict[str, Any]: JSON, list[Any]: JSON}
|
|
|
|
|
|
class TimestampMixin:
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
|
)
|
|
|
|
|
|
engine = create_async_engine(settings.db_url, echo=False, future=True)
|
|
|
|
|
|
@event.listens_for(engine.sync_engine, "connect")
|
|
def _set_sqlite_pragma(dbapi_conn, _record): # pragma: no cover - 驱动回调
|
|
"""WAL + 外键约束。WAL 让读写不互相阻塞,事件写入和前端轮询能并行。"""
|
|
cur = dbapi_conn.cursor()
|
|
cur.execute("PRAGMA journal_mode=WAL")
|
|
cur.execute("PRAGMA foreign_keys=ON")
|
|
cur.execute("PRAGMA busy_timeout=5000")
|
|
cur.close()
|
|
|
|
|
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
|
|
|
|
|
async def get_session() -> AsyncIterator[AsyncSession]:
|
|
"""FastAPI 依赖注入用的会话。"""
|
|
async with SessionLocal() as session:
|
|
yield session
|
|
|
|
|
|
# 轻量迁移:create_all 只建新表,不给已有表加列。SQLite 的 ADD COLUMN
|
|
# 便宜且带默认值回填,够用;等真需要改列类型再考虑 alembic。
|
|
_COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [
|
|
("runs", "run_class", "TEXT DEFAULT 'exploratory'"),
|
|
("runs", "version", "INTEGER"),
|
|
("runs", "version_hash", "VARCHAR(64)"),
|
|
("runs", "manifest_hash", "VARCHAR(64)"),
|
|
("runs", "started_by", "VARCHAR(100)"),
|
|
("workflows", "status", "TEXT DEFAULT 'draft'"),
|
|
("workflows", "published_version", "INTEGER"),
|
|
("workflows", "published_by", "VARCHAR(100)"),
|
|
("workflow_versions", "graph_hash", "VARCHAR(64)"),
|
|
("approvals", "resolved_by", "VARCHAR(100)"),
|
|
# 存量向量没有这两列,回填成空串 / 0 —— 正好表示"不知道是谁建的",
|
|
# 检索侧会把它们当成对不上当前 embedder 处理
|
|
("chunks", "embed_model", "VARCHAR(100) DEFAULT ''"),
|
|
("chunks", "embed_dim", "INTEGER DEFAULT 0"),
|
|
("memories", "embed_model", "VARCHAR(100) DEFAULT ''"),
|
|
("memories", "embed_dim", "INTEGER DEFAULT 0"),
|
|
("chunks", "token_len", "INTEGER DEFAULT 0"),
|
|
("documents", "status", "TEXT DEFAULT 'ready'"),
|
|
("documents", "error", "TEXT DEFAULT ''"),
|
|
# 存量轮次没有复核结论。NULL 正好表示"这一轮没复核过",和"复核过、没问题"
|
|
# (存 verdict=ok)是两回事,前端要分得开
|
|
("conversation_turns", "review", "JSON"),
|
|
]
|
|
|
|
|
|
def _migrate(conn) -> None: # pragma: no cover - 同步回调
|
|
from sqlalchemy import text
|
|
|
|
for table, column, ddl in _COLUMN_MIGRATIONS:
|
|
cols = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))}
|
|
if cols and column not in cols:
|
|
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}"))
|
|
|
|
|
|
async def init_db() -> None:
|
|
from app.db import models # noqa: F401 确保模型已注册到 metadata
|
|
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
await conn.run_sync(_migrate)
|