Implement knowledge base management and enhance assistant configuration

Add CRUD functionality for knowledge bases, including routes for listing, creating, updating, and deleting knowledge bases. Update the assistant model to include foreign key references to knowledge bases and modify the assistant configuration to handle external API keys securely. Refactor related services and routes to accommodate these changes, ensuring proper handling of credential resolution and configuration normalization.
This commit is contained in:
Xin Wang
2026-06-09 08:31:39 +08:00
parent 34fba494a3
commit b444ea777c
6 changed files with 304 additions and 56 deletions

View File

@@ -1,6 +1,7 @@
"""助手 CRUD。前端「助手列表 / 创建 / 编辑」对接这里。
助手配置不含 key,所以无需打码。
模型/KB 以 FK 引用注册表;外部类型(dify/fastgpt/opencode)的 config.apiKey 是私有密钥,
读时打码、写时哨兵(复用 services/masking)。
"""
import uuid
@@ -8,24 +9,47 @@ import uuid
from db.models import Assistant
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from schemas import AssistantOut, AssistantUpsert
from schemas import EXTERNAL_TYPES, AssistantOut, AssistantUpsert
from services.masking import mask, resolve_incoming_key
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/assistants", tags=["assistants"])
def _mask_config(type_: str, config: dict) -> dict:
"""读取返回前:外部类型的 apiKey 打码,其余原样。"""
if type_ in EXTERNAL_TYPES and config.get("apiKey"):
return {**config, "apiKey": mask(config["apiKey"])}
return config
def _merge_config(type_: str, incoming: dict, stored: dict) -> dict:
"""写入时:外部类型若回传打码占位符/空 apiKey → 保留旧 key。"""
if type_ in EXTERNAL_TYPES and "apiKey" in incoming:
incoming = {
**incoming,
"apiKey": resolve_incoming_key(
incoming.get("apiKey"), stored.get("apiKey", "")
),
}
return incoming
def _to_out(a: Assistant) -> AssistantOut:
return AssistantOut(
id=a.id,
name=a.name,
greeting=a.greeting,
prompt=a.prompt,
type=a.type, # type: ignore[arg-type]
runtime_mode=a.runtime_mode, # type: ignore[arg-type]
model=a.model,
asr=a.asr,
voice=a.voice,
greeting=a.greeting,
enable_interrupt=a.enable_interrupt,
llm_credential_id=a.llm_credential_id,
asr_credential_id=a.asr_credential_id,
tts_credential_id=a.tts_credential_id,
realtime_credential_id=a.realtime_credential_id,
knowledge_base_id=a.knowledge_base_id,
config=_mask_config(a.type, a.config or {}),
updated_at=a.updated_at.isoformat() if a.updated_at else None,
)
@@ -68,7 +92,10 @@ async def update_assistant(
a = await session.get(Assistant, assistant_id)
if not a:
raise HTTPException(404, "助手不存在")
for k, v in body.model_dump().items():
data = body.model_dump()
# 外部类型 apiKey 写时哨兵:打码占位符 → 保留旧 key(在改 a.config 前用旧值)
data["config"] = _merge_config(body.type, data["config"], a.config or {})
for k, v in data.items():
setattr(a, k, v)
await session.commit()
await session.refresh(a)

View File

@@ -0,0 +1,90 @@
"""知识库 CRUD。前端助手编辑页的"知识库"下拉对接这里。
KB 自身引用一个 Embedding 凭证(embeddingCredentialId)。被助手引用时禁止删除
(DB 层 ON DELETE RESTRICT),这里把外键冲突翻译成 409。
"""
import uuid
from db.models import KnowledgeBase
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from schemas import KnowledgeBaseOut, KnowledgeBaseUpsert
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/knowledge-bases", tags=["knowledge-bases"])
def _to_out(kb: KnowledgeBase) -> KnowledgeBaseOut:
return KnowledgeBaseOut(
id=kb.id,
name=kb.name,
description=kb.description,
embedding_credential_id=kb.embedding_credential_id,
status=kb.status,
updated_at=kb.updated_at.isoformat() if kb.updated_at else None,
)
@router.get("", response_model=list[KnowledgeBaseOut])
async def list_knowledge_bases(session: AsyncSession = Depends(get_session)):
rows = (
await session.execute(select(KnowledgeBase).order_by(KnowledgeBase.name))
).scalars().all()
return [_to_out(kb) for kb in rows]
@router.post("", response_model=KnowledgeBaseOut)
async def create_knowledge_base(
body: KnowledgeBaseUpsert, session: AsyncSession = Depends(get_session)
):
kb = KnowledgeBase(id=f"kb_{uuid.uuid4().hex[:12]}", **body.model_dump())
session.add(kb)
await session.commit()
await session.refresh(kb)
return _to_out(kb)
@router.get("/{kb_id}", response_model=KnowledgeBaseOut)
async def get_knowledge_base(
kb_id: str, session: AsyncSession = Depends(get_session)
):
kb = await session.get(KnowledgeBase, kb_id)
if not kb:
raise HTTPException(404, "知识库不存在")
return _to_out(kb)
@router.put("/{kb_id}", response_model=KnowledgeBaseOut)
async def update_knowledge_base(
kb_id: str,
body: KnowledgeBaseUpsert,
session: AsyncSession = Depends(get_session),
):
kb = await session.get(KnowledgeBase, kb_id)
if not kb:
raise HTTPException(404, "知识库不存在")
for k, v in body.model_dump().items():
setattr(kb, k, v)
await session.commit()
await session.refresh(kb)
return _to_out(kb)
@router.delete("/{kb_id}")
async def delete_knowledge_base(
kb_id: str, session: AsyncSession = Depends(get_session)
):
kb = await session.get(KnowledgeBase, kb_id)
if not kb:
raise HTTPException(404, "知识库不存在")
try:
await session.delete(kb)
await session.commit()
except IntegrityError:
# 被助手引用(ON DELETE RESTRICT):先解绑再删
await session.rollback()
raise HTTPException(409, "知识库正被助手引用,无法删除")
return {"ok": True}