mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11: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 且配置不变。
317 lines
10 KiB
Python
317 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.base import get_session
|
|
from app.db.models import Document, MemoryItem, Skill
|
|
from app.memory import kb, store
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 长期记忆
|
|
# --------------------------------------------------------------------------
|
|
|
|
memory_router = APIRouter(prefix="/api/memory", tags=["memory"])
|
|
|
|
|
|
class MemoryIn(BaseModel):
|
|
content: str = Field(min_length=1)
|
|
scope: str = "default"
|
|
kind: str = "fact"
|
|
importance: float = Field(default=0.5, ge=0, le=1)
|
|
meta: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class MemoryOut(BaseModel):
|
|
id: str
|
|
scope: str
|
|
kind: str
|
|
content: str
|
|
importance: float
|
|
use_count: int
|
|
meta: dict[str, Any]
|
|
created_at: Any = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
@memory_router.get("/scopes")
|
|
async def memory_scopes(session: AsyncSession = Depends(get_session)) -> list[dict[str, Any]]:
|
|
return await store.list_scopes(session)
|
|
|
|
|
|
@memory_router.get("", response_model=list[MemoryOut])
|
|
async def list_memory(
|
|
scope: str | None = None,
|
|
limit: int = Query(default=200, le=500),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> list[MemoryItem]:
|
|
return await store.list_memories(session, scope=scope, limit=limit)
|
|
|
|
|
|
@memory_router.post("", response_model=MemoryOut, status_code=201)
|
|
async def add_memory(
|
|
payload: MemoryIn, session: AsyncSession = Depends(get_session)
|
|
) -> MemoryItem:
|
|
return await store.remember(
|
|
session,
|
|
scope=payload.scope,
|
|
content=payload.content,
|
|
kind=payload.kind,
|
|
importance=payload.importance,
|
|
meta=payload.meta,
|
|
)
|
|
|
|
|
|
@memory_router.get("/search")
|
|
async def search_memory(
|
|
q: str,
|
|
scope: str = "default",
|
|
limit: int = 10,
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict[str, Any]:
|
|
hits = await store.recall(session, scope=scope, query=q, limit=limit)
|
|
return {"query": q, "scope": scope, "results": hits}
|
|
|
|
|
|
@memory_router.delete("/{memory_id}", status_code=204)
|
|
async def delete_memory(
|
|
memory_id: str, session: AsyncSession = Depends(get_session)
|
|
) -> None:
|
|
if not await store.forget(session, memory_id):
|
|
raise HTTPException(404, "记忆不存在")
|
|
|
|
|
|
@memory_router.delete("/scope/{scope}")
|
|
async def clear_scope(scope: str, session: AsyncSession = Depends(get_session)) -> dict[str, int]:
|
|
return {"removed": await store.clear_scope(session, scope)}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 知识库
|
|
# --------------------------------------------------------------------------
|
|
|
|
kb_router = APIRouter(prefix="/api/kb", tags=["knowledge"])
|
|
|
|
|
|
class DocumentOut(BaseModel):
|
|
id: str
|
|
collection: str
|
|
title: str
|
|
source: str
|
|
mime: str
|
|
chunk_count: int
|
|
created_at: Any = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
@kb_router.get("/collections")
|
|
async def collections(session: AsyncSession = Depends(get_session)) -> list[dict[str, Any]]:
|
|
return await kb.list_collections(session)
|
|
|
|
|
|
@kb_router.get("/documents", response_model=list[DocumentOut])
|
|
async def list_documents(
|
|
collection: str | None = None,
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> list[Document]:
|
|
stmt = select(Document).order_by(Document.created_at.desc()).limit(300)
|
|
if collection:
|
|
stmt = stmt.where(Document.collection == collection)
|
|
return list((await session.execute(stmt)).scalars())
|
|
|
|
|
|
class IngestIn(BaseModel):
|
|
collection: str = "default"
|
|
title: str = ""
|
|
content: str = Field(min_length=1)
|
|
source: str = ""
|
|
|
|
|
|
@kb_router.post("/documents", response_model=DocumentOut, status_code=201)
|
|
async def ingest(payload: IngestIn, session: AsyncSession = Depends(get_session)) -> Document:
|
|
return await kb.ingest_document(
|
|
session,
|
|
collection=payload.collection,
|
|
title=payload.title,
|
|
content=payload.content,
|
|
source=payload.source,
|
|
)
|
|
|
|
|
|
@kb_router.post("/upload", response_model=DocumentOut, status_code=201)
|
|
async def upload(
|
|
file: UploadFile = File(...),
|
|
collection: str = "default",
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> Document:
|
|
raw = await file.read()
|
|
if len(raw) > 10 * 1024 * 1024:
|
|
raise HTTPException(413, "文件超过 10MB")
|
|
try:
|
|
content = raw.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
raise HTTPException(415, "目前只支持 UTF-8 文本文件(txt / md / json / csv 等)") from None
|
|
return await kb.ingest_document(
|
|
session,
|
|
collection=collection,
|
|
title=file.filename or "上传文档",
|
|
content=content,
|
|
source=file.filename or "",
|
|
mime=file.content_type or "text/plain",
|
|
)
|
|
|
|
|
|
@kb_router.get("/search")
|
|
async def search_kb(
|
|
q: str,
|
|
collection: str | None = None,
|
|
limit: int = 5,
|
|
alpha: float = Query(default=0.5, ge=0, le=1, description="1=纯向量, 0=纯关键词"),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict[str, Any]:
|
|
notes: list[str] = []
|
|
hits = await kb.search(session, collection=collection, query=q, limit=limit,
|
|
alpha=alpha, on_degrade=notes.append)
|
|
# 退回关键词这件事要跟着结果一起交出去,而不是只写进服务端日志——
|
|
# 调用方(设置页的"试一下"、外部脚本)看到的是结果变差,得知道为什么
|
|
return {"query": q, "collection": collection, "results": hits, "degraded": notes}
|
|
|
|
|
|
@kb_router.get("/embedding")
|
|
async def embedding_status(
|
|
collection: str | None = None, session: AsyncSession = Depends(get_session)
|
|
) -> dict[str, Any]:
|
|
"""当前用的是哪个 embedder,以及有多少条向量已经对不上了。"""
|
|
from app.memory.embeddings import EMBEDDING_SETTING_KEY, embedder_dim, embedder_id
|
|
from app.db.models import Setting
|
|
|
|
row = await session.get(Setting, EMBEDDING_SETTING_KEY)
|
|
saved = (row.value if row else {}) or {}
|
|
return {
|
|
"embedder": embedder_id(),
|
|
"dim": embedder_dim(),
|
|
"configured": bool(saved.get("kind")),
|
|
"kind": saved.get("kind", "local"),
|
|
"model": saved.get("model", ""),
|
|
"stale_chunks": await kb.stale_count(session, collection),
|
|
}
|
|
|
|
|
|
class EmbeddingIn(BaseModel):
|
|
kind: str = Field(default="local", pattern="^(local|openai)$")
|
|
model: str = ""
|
|
|
|
|
|
@kb_router.put("/embedding")
|
|
async def set_embedding(
|
|
payload: EmbeddingIn, session: AsyncSession = Depends(get_session)
|
|
) -> dict[str, Any]:
|
|
"""换 embedding 模型。
|
|
|
|
建不起来就 400 原样交回原因——选了远端却静默退回本地哈希向量,
|
|
用户会以为自己在用语义检索,而搜不出同义表达时完全无从怀疑到这里。
|
|
"""
|
|
from app.memory import embeddings as emb
|
|
from app.db.models import Setting
|
|
|
|
try:
|
|
emb.configure(payload.kind, payload.model)
|
|
except emb.EmbedderUnavailable as e:
|
|
raise HTTPException(400, str(e)) from e
|
|
|
|
row = await session.get(Setting, emb.EMBEDDING_SETTING_KEY)
|
|
value = {"kind": payload.kind, "model": payload.model}
|
|
if row:
|
|
row.value = value
|
|
else:
|
|
session.add(Setting(key=emb.EMBEDDING_SETTING_KEY, value=value))
|
|
await session.commit()
|
|
return {
|
|
"embedder": emb.embedder_id(),
|
|
"dim": emb.embedder_dim(),
|
|
"stale_chunks": await kb.stale_count(session),
|
|
}
|
|
|
|
|
|
@kb_router.post("/reindex")
|
|
async def reindex(
|
|
collection: str | None = None, session: AsyncSession = Depends(get_session)
|
|
) -> dict[str, Any]:
|
|
"""用当前 embedder 重算向量。换了模型之后唯一的恢复手段。"""
|
|
return await kb.reindex(session, collection)
|
|
|
|
|
|
@kb_router.delete("/documents/{doc_id}", status_code=204)
|
|
async def delete_document(doc_id: str, session: AsyncSession = Depends(get_session)) -> None:
|
|
if not await kb.delete_document(session, doc_id):
|
|
raise HTTPException(404, "文档不存在")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Skill(方法论)
|
|
# --------------------------------------------------------------------------
|
|
|
|
skills_router = APIRouter(prefix="/api/skills", tags=["knowledge"])
|
|
|
|
|
|
class SkillIn(BaseModel):
|
|
name: str = Field(min_length=1, max_length=100)
|
|
description: str = ""
|
|
instructions: str = ""
|
|
examples: list[dict[str, Any]] = Field(default_factory=list)
|
|
suggested_tools: list[str] = Field(default_factory=list)
|
|
tags: list[str] = Field(default_factory=list)
|
|
enabled: bool = True
|
|
|
|
|
|
class SkillOut(SkillIn):
|
|
id: str
|
|
created_at: Any = None
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
@skills_router.get("", response_model=list[SkillOut])
|
|
async def list_skills(session: AsyncSession = Depends(get_session)) -> list[Skill]:
|
|
return list((await session.execute(select(Skill).order_by(Skill.created_at.desc()))).scalars())
|
|
|
|
|
|
@skills_router.post("", response_model=SkillOut, status_code=201)
|
|
async def create_skill(payload: SkillIn, session: AsyncSession = Depends(get_session)) -> Skill:
|
|
if (await session.execute(select(Skill).where(Skill.name == payload.name))).scalar_one_or_none():
|
|
raise HTTPException(409, "同名 Skill 已存在")
|
|
row = Skill(**payload.model_dump())
|
|
session.add(row)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
return row
|
|
|
|
|
|
@skills_router.patch("/{skill_id}", response_model=SkillOut)
|
|
async def update_skill(
|
|
skill_id: str, payload: SkillIn, session: AsyncSession = Depends(get_session)
|
|
) -> Skill:
|
|
row = await session.get(Skill, skill_id)
|
|
if not row:
|
|
raise HTTPException(404, "Skill 不存在")
|
|
for key, value in payload.model_dump().items():
|
|
setattr(row, key, value)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
return row
|
|
|
|
|
|
@skills_router.delete("/{skill_id}", status_code=204)
|
|
async def delete_skill(skill_id: str, session: AsyncSession = Depends(get_session)) -> None:
|
|
row = await session.get(Skill, skill_id)
|
|
if not row:
|
|
raise HTTPException(404, "Skill 不存在")
|
|
await session.delete(row)
|
|
await session.commit()
|