mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 17:42:00 +00:00
33c7c41336
查会话 cbe09f4b(「我是谁」)时发现的。用户说了「我叫张三」,下一轮问
「我是谁」答不上来——记忆在库里好好的,只是读错了地方:
写入 run 6cd33864 memory 节点 {"action":"write","scope":"user"} → write @ user
读取 run c71fa1bc memory 节点 {"action":"recall"} 没写 scope → recall @ default
同一个会话里 Copilot 生成的两张图,写的时候自己发明了一个 user 域,读的时候
忘了写、落回 default。根子在节点参考把 scope 列成了可填字段却没说该填什么:
- memory:… config: {action, query, content, scope, assign_to}
对比一下,memory_recall / memory_write 这两个**工具**压根没有 scope 参数,
一律用 ctx.memory_scope,所以走 agent 那条路从来不会错位。只有节点会。
现在从可填字段里去掉,并写明后果。节点的 scope 配置本身保留——以后接 project
时从 run.memory_scope 传进去即可,不用改节点。
顺带补上两个上一轮埋的口子。给 Chunk 和 MemoryItem 都加了 embed_model/embed_dim,
对不上就退回关键词,但**只给知识库做了 reindex**:
- store.reindex / stale_count 补齐,记忆终于有了恢复手段
- store.recall 加 on_degrade。以前退得悄无声息——知识库那边至少发一条 warn,
记忆这边只是"最近想不起事",人根本不会想到要去重建索引
- 重建一次管两边。它们共用一个 embedder,换模型时一起失效;分成两个按钮的
结果是点了一个、以为好了,另一个还在悄悄退回关键词
- 没建倒排的片段也归这个按钮管。它们走全表扫,结果对但慢,而重建正好把倒排
一起建了;按钮只认"向量对不上"的话,这些片段永远等不到人来点
实机验证:一次重建收拾了 2 段知识 + 1 条记忆 + 2 个倒排缺口,三个计数归零。
数据侧另行清理(不在本提交里):身份记忆从 scope=user 移到 default,删掉
zz_fix_* 等测试残留,删除前整表导出到 data/backups/。
340 lines
11 KiB
Python
340 lines
11 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 inverted, 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")
|
|
|
|
from app.memory.parsing import UnsupportedDocument, extract
|
|
|
|
try:
|
|
content = extract(raw, file.filename or "", file.content_type or "")
|
|
except UnsupportedDocument as e:
|
|
# 原样交回:里面写的是"装哪个包"或"这是扫描件",都能照着做
|
|
raise HTTPException(415, str(e)) 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 | None = Query(default=None, ge=0, le=1,
|
|
description="1=纯向量, 0=纯关键词;不传则按 embedder 能力取默认"),
|
|
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, default_alpha, embedder_dim, embedder_id, has_semantics,
|
|
)
|
|
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),
|
|
# 记忆和知识库共用一个 embedder,换模型时一起失效——报一个漏一个
|
|
# 的话,用户点完重建还是想不起事,而且不知道为什么
|
|
"stale_memories": await store.stale_count(session),
|
|
"has_semantics": has_semantics(),
|
|
"default_alpha": default_alpha(),
|
|
# 没建倒排的片段会走全表扫——结果对,但慢
|
|
"unindexed_chunks": await inverted.missing_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),
|
|
"stale_memories": await store.stale_count(session),
|
|
}
|
|
|
|
|
|
@kb_router.post("/reindex")
|
|
async def reindex(
|
|
collection: str | None = None, session: AsyncSession = Depends(get_session)
|
|
) -> dict[str, Any]:
|
|
"""用当前 embedder 重算向量。换了模型之后唯一的恢复手段。
|
|
|
|
知识库和长期记忆一起重建:它们共用一个 embedder,换模型时一起失效。
|
|
分成两个按钮的结果是点了一个、以为好了,另一个还在悄悄退回关键词——
|
|
真踩过,记忆那边连个提示都没有。
|
|
"""
|
|
out = await kb.reindex(session, collection)
|
|
# 记忆不按 collection 分,重建就是全量
|
|
out["memories_reindexed"] = await store.reindex(session)
|
|
return out
|
|
|
|
|
|
@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()
|