mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:31:58 +00:00
df935599f2
三件事,都是"这三个模块一条测试都没有"的后果。 **换 embedder 会让检索整体 500。** 库里是 512 维的本地向量、查询是 1536 维的 远端向量时,cosine 直接 `shapes (1536,) and (512,) not aligned`。from_blob 有 dim 参数,但 kb.py 和 memory/store.py 调用时都没传;而且存量向量属于哪个模型 代码里没有任何地方记着,所以也无从降级。 Chunk / MemoryItem 各加 embed_model + embed_dim(走现成的 _COLUMN_MIGRATIONS, 存量回填空值,正好表示"不知道是谁建的")。检索遇到对不上的向量整体退回纯 关键词并说破,不是把它们当 0 分静默沉底——那样用户看到的是结果莫名其妙变差, 而不是"这里有一批索引该重建了"。配套 kb.reindex + POST /api/kb/reindex。 长期记忆那份一起修了。判重那条更危险:维度碰巧相同但模型不同时 cosine 算出来 是随机数,会把不相关的旧记忆认成"重复",然后把新内容丢掉。 **默认 embedder 没有语义能力,而开关藏在一个环境变量里。** LocalEmbedder 是 特征哈希 + TF,和 BM25 吃同一批词;alpha 默认 0.5 等于给了它一半权重,同义 改写一条都召不回。开启真模型的唯一开关是 AGENTLAB_USE_OPENAI_EMBEDDINGS—— 没界面、没人会发现。改成设置项(照 COPILOT_SETTING_KEY 的路子),知识库页顶部 能选能看能重建。 配置失败必须响:第一版写成静默退回本地,但选了 OpenAI 却拿到哈希向量的人, 在搜不出同义表达时完全无从怀疑到这里。现在抛 EmbedderUnavailable,API 400 原样交回"检查 OPENAI_API_KEY 是否配了",且不会把配置改掉。 **检索结果不进工件库。** db_query、工具节点、agent 工具调用都落 put_json 快照, run_retrieve 什么都不落——于是结论只要来自知识库,"这个数是哪来的"就断在这儿, 而数字回指正是这个项目的签名能力。现在落 kind="retrieval_snapshot",并发一条 retrieve.end 带 artifact id。解码层补了正式映射(原来会掉进"未知事件"兜底, 画成一坨原始 JSON)。 新增 test_kb.py 11 条,补上这三个模块此前的零覆盖,包括 embeddings.py 里 注释记着、但一直没有测试守的那个归一化边界(单条候选被 min_score 误杀)。 端到端在真实服务上验过:撞上存量向量返回 200 + 降级说明(以前 500)、 重建后 stale 归零、配不起来的远端模型 400 且配置不变。
102 lines
3.5 KiB
Python
102 lines
3.5 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"),
|
|
]
|
|
|
|
|
|
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)
|