mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:11:57 +00:00
2cdbf9a733
按期累积(新 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(部署本来就是单进程)
219 lines
9.8 KiB
Python
219 lines
9.8 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 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)
|