mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11:58 +00:00
7b04162540
四件事,其中 Excel 那条不是"多支持一种格式",是补一个和地基冲突的缺口。
**Excel / CSV → 可以用 SQL 查的表。**
这个项目有条不可回退的决策:叙述层无算术权限,所有算术下沉到 SQL 或口径卡,
叙述只能引用(engine/issuance.py)。表格传进知识库只能被切块检索,数字就成了
模型从片段里"读"出来的——复核层拦得住凭空多出的数字,拦不住"从一堆片段里
读错了一格"。所以表格必须变成表。
不新增数据源类型:kind='sqlite' 早就支持,build_url 拿 database 当文件路径。
落成 .db 文件再建一条 DataSource 指过去,下游一行都不用改——SQL 守卫、结构
探查、db_query__<name> 工具、查询快照进工件库、Copilot 的数据源清单,全都白拿。
建表走 sqlite3 直连而不是 run_query:guard 的 _ALWAYS_DENIED 里有 create,
那是对的(模型不该能建表),而导入是我们自己发起的可信操作。
四个语义是这块的全部分量:
- **类型要推,不能全存 TEXT。** SQLite 是动态类型,但列亲和性决定排序和比较:
整数列落成 TEXT 的话 '10' < '9',SUM() 在混入空串时静默出错,而没有任何
地方会报。逐列扫描推断 INTEGER/REAL/TEXT,空单元格存 NULL 不存 "",
日期存 ISO(字典序恰好等于时间序)。测试里那条 ORDER BY 要是 9<10 而不是
'10'<'9',守的就是整块的意义。
- **表头取第 1 行,但不猜。** 不做"自动找表头"的启发式:猜错的表现是列名变成
一行数据,没有任何征兆,直到某天有人发现汇总一直少一行。改成 header_row
可填,并把推断出的列名和类型当场回显——错了一眼看见。
- **同名重传原地替换,id 和 name 都不变。** 名字会成为工具名(db_query__sales)
而工具名写进了保存过的图,另起一个 sales_2 等于悄悄让那些图失效。
- **中文原样保留。** 一开始把 sheet 名清洗成了 ASCII,结果「明细」「汇总」都变成
sheet、sheet_2,模型和用户都认不出哪张是哪张——而 SQLite 的标识符本来就允许
非 ASCII,那道清洗纯属自找麻烦。现在只替换会断句的空白和标点。
实测:两 sheet 的中文 xlsx → 两张表,金额列 INTEGER,跨表 JOIN + SUM 跑通,
Copilot 的工具清单里带着表名。CSV 按 utf-8-sig / utf-8 / gb18030 依次试——
GBK 的 CSV 是 Excel 另存为的默认行为,按 UTF-8 读会得到乱码列名,而乱码不报错。
知识库那边顺手改了指路:传 .xlsx/.csv 进去不再说"不是 UTF-8 文本",
而是告诉你该传到数据源那边、以及为什么。
**PPT。** 文本框和表格分开取(表格的 has_text_frame 是 False),幻灯片之间
留空行当段落边界。.ppt/.doc/.xls 这些老二进制格式认出来再拒,说清楚"另存为
新格式"——以前它们会掉进"不是 UTF-8 文本"那个兜底,对着一个 .ppt 说这话,
用户根本不知道该怎么办。
**问数据页的「接着跑」。** 挂了那一轮不该只剩一行红字:断点一直在 checkpoint
里躺着。按钮挂在 approvalsFor 那个每轮插槽里(本来就托着「看执行过程」和
「跑一下」)。不带图——问数据页改不了图,这里就是"用原来的配置重试失败的
那一步",配置本身错了要到画布上改。实测:续的是同一次运行(8→13 条事件),
只跑了 lp 往后,轨迹里有 run.resumed。
**文档 / 工具的地址。** /knowledge/kb/:docId 是新页面——文档原来连详情视图都
没有,而切块这层做了不少事(按行边界留重叠、跨块续表头),结果一处都看不见。
现在逐段列出来带字数和 token 数,还标出"这份文档里混着两个模型建的向量"
(换模型只重建了一部分时真会发生,而那会让这部分内容悄悄退回关键词检索)。
新增 GET /kb/documents/{id}。工具那边 tab key 从 tools 改成 library,
否则地址是 /tools/tools/<id>。
验证:后端 265 passed(新增 test_tabular.py 16 条);tsc 干净;三个 check 全过,
check-ui 补了文档/工具两节;四块都在真浏览器/真后端上端到端跑过。
开发库里造的演示数据源、会话和运行已清干净。
454 lines
16 KiB
Python
454 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from fastapi import (
|
||
APIRouter, BackgroundTasks, Depends, File, HTTPException, Query, UploadFile,
|
||
)
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.config import settings
|
||
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
|
||
#: ready | processing | failed。前端据此显示"处理中 3200/7914 段"
|
||
status: str = "ready"
|
||
error: str = ""
|
||
meta: dict[str, Any] = Field(default_factory=dict)
|
||
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(
|
||
background: BackgroundTasks,
|
||
file: UploadFile = File(...),
|
||
collection: str = "default",
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> Document:
|
||
# 边读边数,超了立刻停——原来是 raw = await file.read() 读完再判断,
|
||
# 传个 1GB 的文件,内存在报 413 之前就已经吃掉了。那个检查拦的是"入库",
|
||
# 不是"占内存"
|
||
limit = settings.max_upload_mb * 1024 * 1024
|
||
chunks: list[bytes] = []
|
||
total = 0
|
||
while piece := await file.read(1024 * 1024):
|
||
total += len(piece)
|
||
if total > limit:
|
||
raise HTTPException(413, f"文件超过 {settings.max_upload_mb}MB")
|
||
chunks.append(piece)
|
||
raw = b"".join(chunks)
|
||
|
||
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
|
||
doc = await kb.create_document(
|
||
session,
|
||
collection=collection,
|
||
title=file.filename or "上传文档",
|
||
content=content,
|
||
source=file.filename or "",
|
||
mime=file.content_type or "text/plain",
|
||
status="processing",
|
||
)
|
||
# 立刻返回,切块和算向量在后台跑。实测一个 10MB 文档用本地哈希要两分半,
|
||
# 换成远端 embedding 是几十分钟——压在请求里必然超时,而后端其实还在跑,
|
||
# 界面显示失败、数据其实成功,比直接拒绝还糟
|
||
background.add_task(_process_in_background, doc.id)
|
||
return doc
|
||
|
||
|
||
async def _process_in_background(doc_id: str) -> None:
|
||
"""后台切块。进度写回文档行,失败也写回——不然它会永远停在"处理中"。"""
|
||
from app.db.base import SessionLocal
|
||
|
||
async with SessionLocal() as session:
|
||
doc = await session.get(Document, doc_id)
|
||
if not doc:
|
||
return
|
||
try:
|
||
# 进度由 process_document 自己按批提交——它就在主事务里,
|
||
# 不会和另一个 session 抢 SQLite 的写锁
|
||
await kb.process_document(session, doc)
|
||
except Exception as e: # noqa: BLE001
|
||
await session.rollback()
|
||
doc = await session.get(Document, doc_id)
|
||
if doc:
|
||
doc.status = "failed"
|
||
doc.error = f"{type(e).__name__}: {e}"[:500]
|
||
await session.commit()
|
||
|
||
|
||
@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", ""),
|
||
"base_url": saved.get("base_url", ""),
|
||
"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 = ""
|
||
#: 任何讲 OpenAI /v1/embeddings 的地址:本机 LM Studio / Ollama / vLLM / TEI,
|
||
#: 或者你自己的网关。留空才走 api.openai.com
|
||
base_url: 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, payload.base_url)
|
||
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,
|
||
"base_url": payload.base_url}
|
||
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.get("/embedding/probe")
|
||
async def probe_embedding(base_url: str) -> dict[str, Any]:
|
||
"""问问这个地址上有哪些模型。
|
||
|
||
自己填模型名太容易写错(LM Studio 里叫
|
||
text-embedding-qwen3-embedding-4b,不是 Qwen3-Embedding-4B),
|
||
而填错的表现是切换时报一句 404,人还以为是服务没起。
|
||
"""
|
||
import httpx
|
||
|
||
url = base_url.rstrip("/") + "/models"
|
||
try:
|
||
async with httpx.AsyncClient(timeout=8) as client:
|
||
data = (await client.get(url)).json()
|
||
except Exception as e: # noqa: BLE001
|
||
raise HTTPException(400, f"连不上 {url}:{type(e).__name__}: {e}") from e
|
||
models = [m.get("id", "") for m in (data.get("data") or []) if m.get("id")]
|
||
return {"base_url": base_url, "models": models}
|
||
|
||
|
||
@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
|
||
|
||
|
||
#: 单段返回多少字。切块本来就在千字量级,详情页是给人扫一眼"切得对不对"的,
|
||
#: 不是给人通读原文的——整篇几千段全量吐出去,一份大文档能有几兆
|
||
_CHUNK_PREVIEW = 1200
|
||
|
||
|
||
@kb_router.get("/documents/{doc_id}")
|
||
async def get_document(
|
||
doc_id: str, session: AsyncSession = Depends(get_session)
|
||
) -> dict[str, Any]:
|
||
"""一份文档被切成了什么样。
|
||
|
||
切块这一层做了不少事——按行边界留重叠、跨块把表头续上(memory/kb.py 的
|
||
chunk_text),而在此之前结果在界面上一处都看不见。检索不准的时候第一个
|
||
该看的就是它:表头有没有续上、重叠对不对、哪一段被截断了。
|
||
"""
|
||
from app.db.models import Chunk
|
||
|
||
doc = await session.get(Document, doc_id)
|
||
if not doc:
|
||
raise HTTPException(404, "文档不存在")
|
||
|
||
rows = list((await session.execute(
|
||
select(Chunk).where(Chunk.document_id == doc_id).order_by(Chunk.ordinal)
|
||
)).scalars())
|
||
|
||
return {
|
||
"document": DocumentOut.model_validate(doc).model_dump(mode="json"),
|
||
"chunks": [
|
||
{
|
||
"id": c.id,
|
||
"ordinal": c.ordinal,
|
||
"content": c.content[:_CHUNK_PREVIEW],
|
||
"truncated": len(c.content) > _CHUNK_PREVIEW,
|
||
"chars": len(c.content),
|
||
"token_len": c.token_len,
|
||
# 哪条向量是谁建的。一份文档里混着两个模型建的向量是真会发生的
|
||
# (换模型之后只重建了一部分),而那会让检索悄悄退回关键词
|
||
"embed_model": c.embed_model or "",
|
||
"has_vector": c.embedding is not None,
|
||
}
|
||
for c in rows
|
||
],
|
||
}
|
||
|
||
|
||
@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()
|