Files
AgentLab/backend/app/db/base.py
T
ThearchyHelios 2cdbf9a733 Excel 导入改造期 3:网格交互、自助修复、按期累积——每月的报表可以累积成一份跨月的表,漂移了点修复按钮就能接着导入,网格上框一块就能写进配方,版本页可以回滚、移除一期、清除原件
按期累积(新 recipe_accumulate;table_versions 扩展)
- 按统计期出的交叉表报表首次导入时默认「按期累积」,由人确认;一期一个构建,两期及以上物化成一份新的 SQLite 快照,不用视图
- 统计期完全相同要勾「替换该期」,部分重叠拒收,中间有空缺要确认;物化前逐期核对库哈希和行号,并集上另加行数守恒、主键唯一、日期落在所属统计期三条核对
- 配方演进分三档:新增列(早期各期为空值)、退役的列和表保留旧值(只在这次才退役时要勾)、不兼容变化只能显式「重新开始累积」
- 快照 id 在累积时含目标配方和模式;同样的输入物化两次库哈希相同,同一文件换名再传直接复用
- 说明按期合并,任何一期没核对过就不写「已核对」;各期维度取值不同、退役列、合计表都有提示,经过数字检查

修复按钮(新 recipe_fixes;执行器给问题带结构化参数)
- 加入标签、移除标签、声明为合计行、分段标题改名、编辑恒等式成员、按锚点忽略行/列/区域外文字、声明占位符、声明隐藏行处理、工作表改名
- 服务端算补丁,先预览新旧配方对照、再应用,可以撤销;补丁封闭,不引入正则、自由文本常量和写死坐标;修复进确认清单
- 只改分段标题、新标题仍含原来选的词时沿用已确认的常量,不算破坏性变更;修复后只查改动过的段的认领,没改的关系交给试运行核对

框选转锚点(新 recipe_select)
- 网格上框选(鼠标、键盘、区域输入),按八种用途换算成按文字认的锚点,立刻干跑比对;配方里不存坐标,泛化不了时列出原因
- 整张表挪位后同一配方照样认得;框进合计行、夹着小计、表下说明都有明确的处理

确认与对照(recipe_confirm、recipe_diff、新 recipe_compare)
- 改配方后确认清单只列有变化的项(同时按本期数据比较),不用每修一次重勾十来项;新旧配方对照界面;按规则重新起草并把名字对齐到现行配方
- 改配方后已体现在配方里的回答保留,矛盾的列出来请人重答
- 「排除的行」写进回执和导入清单:隐藏行、跳过的空行、按配方忽略的行、停止后的文字行、只核对不另存的合计行

版本页(新 source_versions、api/source_versions;前端 VersionsDialog)
- 快照与导入记录列表、启用旧版本(可写理由)、查看清单、移除一期、作废接受、清除原件;写操作带界面看到的当前版本,并发改动回 409,不静默丢期
- 除当前版本和被运行引用的版本外,再保留最近 3 个版本供回滚;遮罩列丢失要明确确认

期 1、期 2 遗留
- 重传撞上被改过的构建文件:能用本次上传恢复就隔离旧文件再恢复,否则回 409,不再是 500
- 跳过空行的列表不再把表下隔一行的说明读成数据;不像说明的单格行要人确认;受影响的配方构建换 id
- 版本存储加进程级文件锁:第二个进程拿不到锁时不清理、不回收,写操作回 503(部署本来就是单进程)
2026-10-02 07:38:44 +08:00

219 lines
9.8 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
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 sqlalchemy.types import TypeDecorator
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 UTCDateTime(TypeDecorator):
"""存 UTC、读出来也是带时区的 UTC。
SQLite 没有时区类型:DateTime(timezone=True) 写进去的只是一串不带偏移的
字面时间,读回来是 naive 的。API 于是吐出 '2026-09-26T01:11:19',浏览器把
不带偏移的 ISO 串当本地时间解析,运行列表整体慢了 8 小时。在这一层补上 UTC,
pydantic 序列化自然带 +00:00,所有接口一次修好。
写入前先换算成 UTC 再去掉时区:SQLite 方言格式化时直接丢弃 tzinfo,
一个 +08:00 的时间原样写进去,读回来就差出 8 小时。
"""
impl = DateTime
cache_ok = True
def process_bind_param(self, value: datetime | None, dialect: Any) -> datetime | None:
if value is None:
return None
if value.tzinfo is not None:
value = value.astimezone(timezone.utc).replace(tzinfo=None)
return value
def process_result_value(self, value: datetime | None, dialect: Any) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)
# 图定义、节点配置、事件负载都是任意形状的 JSON,统一映射到 SQLite 的 JSON 列
type_annotation_map = {dict[str, Any]: JSON, list[Any]: JSON, datetime: UTCDateTime}
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow)
def _stop_aiosqlite_once() -> None:
"""aiosqlite 连接的工作线程只停一次;之后再要求停的,拿到的是同一个结果。
aiosqlite 0.22 的 stop() 每调一次都往工作线程队列里放一个"关闭并退出线程"
的请求,而线程处理完第一个就退出了——之后放进去的请求没人处理,等它的一方
永远挂着。close() 的 finally 里会调 stop(),SQLAlchemy 的强制 terminate 也会
直接调;两个 close 并发、或者 close 撞上强制 terminate,就会卡死。实测同一
连接并发 close 两次,10 次卡 10 次。
在这里触发它的路径:并行的两条支路一条失败,LangGraph 取消另一条,而那一条
正在写工件。取消打进进行中的数据库操作,SQLAlchemy 在两条路径上各 terminate
一次同一条连接——节点任务取消不完,整个运行卡在 running,占着的并发名额也不
还。大约五次里有一次。连接池和 checkpointer 都走 aiosqlite,补在这一层都管得到。
"""
import weakref
import aiosqlite
original = aiosqlite.Connection.stop
if getattr(original, "_agentlab_once", False):
return
stopping: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
def stop(self): # type: ignore[no-untyped-def]
if self not in stopping:
stopping[self] = original(self)
return stopping[self]
stop._agentlab_once = True # type: ignore[attr-defined]
aiosqlite.Connection.stop = stop # type: ignore[method-assign]
_stop_aiosqlite_once()
# pool_pre_ping:取连接前先探一下活。取消异常打进 SQLAlchemy 的清理流程时
# (_close_connection 记完日志会把 CancelledError 原样抛出,跳过了把连接记录
# 清空的那一行),已经关掉的连接会留在池里,下一个取到它的人拿到的是
# "no active connection"。探活失败会被当成断开,换一条新的——SQLite 本地一次
# SELECT 1 的代价,换池子不被污染。
engine = create_async_engine(settings.db_url, echo=False, future=True, pool_pre_ping=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", "manifest_seq", "INTEGER"),
("runs", "started_by", "VARCHAR(100)"),
# 续跑、恢复要沿用第一次发起时的记忆域、知识库和审批默认值;以前只在第一段
# _drive 里传过一次,恢复后回落成 "default",同一次运行前后两段查的不是同一个库
("runs", "memory_scope", "VARCHAR(100)"),
("runs", "collection", "VARCHAR(100)"),
("runs", "approval_default", "VARCHAR(20)"),
("runs", "tool_trust", "JSON"),
("runs", "agent_limits", "JSON"),
("runs", "error_node_id", "VARCHAR(64)"),
("workflows", "status", "TEXT DEFAULT 'draft'"),
("workflows", "published_version", "INTEGER"),
("workflows", "published_by", "VARCHAR(100)"),
("workflow_versions", "graph_hash", "VARCHAR(64)"),
# 存量版本回填成 NULL:不知道当时按哪一档发布的,正式运行照旧看工作流的 status
("workflow_versions", "level", "VARCHAR(20)"),
("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"),
("conversation_turns", "meta", "JSON"),
("data_sources", "last_check", "JSON"),
("mcp_servers", "last_check", "JSON"),
# 存量的源一律先记成 manual;老的上传源由 table_versions.startup 按路径认出来,
# 补上 v0 版本后再改成 upload
("data_sources", "origin", "TEXT DEFAULT 'manual'"),
("data_sources", "current_snapshot_id", "VARCHAR(64)"),
# 存量运行没有钉过版本,NULL 正好表示「升级前发起的」
("runs", "data_versions", "JSON"),
# 按配方导入(期 2)。新表 table_recipes、import_stagings 由 create_all 建;这些是已有表的新列,
# 存量行一律 NULL:存量的源都是简单导入或手工源,存量的导入记录都不是按配方导入的
("data_sources", "current_recipe_id", "VARCHAR(32)"),
("table_imports", "recipe_id", "VARCHAR(32)"),
("table_imports", "period_start", "VARCHAR(10)"),
("table_imports", "period_end", "VARCHAR(10)"),
("table_imports", "context", "JSON"),
("table_imports", "checks", "JSON"),
("table_imports", "overrides", "JSON"),
("table_imports", "waivers", "JSON"),
("table_imports", "confirmations", "JSON"),
("table_imports", "manifest_artifact", "VARCHAR(64)"),
("table_imports", "signed_by", "VARCHAR(100)"),
("table_imports", "staging_id", "VARCHAR(32)"),
# 期 3:按期累积、版本页(P3-SPEC 9.6)。新表 snapshot_activations 由 create_all 建;存量行一律 NULL,
# 含义是「期 3 之前的」:快照的 mode 按 replace、recipe_id 取最后一期导入的、activated_at 按 created_at
("source_snapshots", "mode", "VARCHAR(20)"),
("source_snapshots", "recipe_id", "VARCHAR(32)"),
("source_snapshots", "activated_at", "DATETIME"),
("source_snapshots", "manifest_artifact", "VARCHAR(64)"),
("table_imports", "revoked", "JSON"),
("import_stagings", "edits", "JSON"),
("import_stagings", "answers_dropped", "JSON"),
("import_stagings", "redraft_sha256", "VARCHAR(64)"),
]
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)