fix(AISVC): 修复 knowledge-bases 接口 500 错误 [AC-AISVC-60]
- 添加 KnowledgeBaseService 服务类用于知识库 CRUD 操作 - 添加数据库迁移脚本,补充 knowledge_bases 表缺失字段 - kb_type: 知识库类型 - priority: 优先级 - is_enabled: 是否启用 - doc_count: 文档数量 - 修复前端 intent-rules 页面加载时知识库接口报错问题
This commit is contained in:
parent
c06e0dd15c
commit
e4dbcda150
|
|
@ -0,0 +1,303 @@
|
||||||
|
"""
|
||||||
|
Knowledge Base CRUD service for AI Service.
|
||||||
|
[AC-AISVC-59~AC-AISVC-64] Multi-knowledge-base management with Qdrant Collection integration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlmodel import col
|
||||||
|
|
||||||
|
from app.core.qdrant_client import get_qdrant_client
|
||||||
|
from app.models.entities import (
|
||||||
|
Document,
|
||||||
|
KBType,
|
||||||
|
KnowledgeBase,
|
||||||
|
KnowledgeBaseCreate,
|
||||||
|
KnowledgeBaseUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseService:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-59~AC-AISVC-64] Knowledge Base CRUD service.
|
||||||
|
Handles KB creation with Qdrant Collection initialization,
|
||||||
|
KB updates, deletion with Collection cleanup, and listing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def create_knowledge_base(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
kb_create: KnowledgeBaseCreate,
|
||||||
|
) -> KnowledgeBase:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-59] Create a new knowledge base.
|
||||||
|
Initializes corresponding Qdrant Collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant identifier
|
||||||
|
kb_create: Knowledge base creation data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created KnowledgeBase entity
|
||||||
|
"""
|
||||||
|
kb = KnowledgeBase(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=kb_create.name,
|
||||||
|
kb_type=kb_create.kb_type,
|
||||||
|
description=kb_create.description,
|
||||||
|
priority=kb_create.priority,
|
||||||
|
is_enabled=True,
|
||||||
|
doc_count=0,
|
||||||
|
)
|
||||||
|
self._session.add(kb)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
qdrant = await get_qdrant_client()
|
||||||
|
await qdrant.ensure_kb_collection_exists(tenant_id, str(kb.id))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-59] Created knowledge base: tenant={tenant_id}, "
|
||||||
|
f"kb_id={kb.id}, name={kb.name}, type={kb.kb_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return kb
|
||||||
|
|
||||||
|
async def get_knowledge_base(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
kb_id: str,
|
||||||
|
) -> KnowledgeBase | None:
|
||||||
|
"""
|
||||||
|
Get a knowledge base by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant identifier
|
||||||
|
kb_id: Knowledge base ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
KnowledgeBase entity or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stmt = select(KnowledgeBase).where(
|
||||||
|
KnowledgeBase.tenant_id == tenant_id,
|
||||||
|
KnowledgeBase.id == uuid.UUID(kb_id),
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def list_knowledge_bases(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
kb_type: str | None = None,
|
||||||
|
is_enabled: bool | None = None,
|
||||||
|
) -> Sequence[KnowledgeBase]:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-60] List knowledge bases for a tenant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant identifier
|
||||||
|
kb_type: Filter by knowledge base type (optional)
|
||||||
|
is_enabled: Filter by enabled status (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of KnowledgeBase entities
|
||||||
|
"""
|
||||||
|
stmt = select(KnowledgeBase).where(
|
||||||
|
KnowledgeBase.tenant_id == tenant_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if kb_type:
|
||||||
|
stmt = stmt.where(KnowledgeBase.kb_type == kb_type)
|
||||||
|
if is_enabled is not None:
|
||||||
|
stmt = stmt.where(KnowledgeBase.is_enabled == is_enabled)
|
||||||
|
|
||||||
|
stmt = stmt.order_by(
|
||||||
|
col(KnowledgeBase.priority).desc(),
|
||||||
|
col(KnowledgeBase.created_at).desc()
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def update_knowledge_base(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
kb_id: str,
|
||||||
|
kb_update: KnowledgeBaseUpdate,
|
||||||
|
) -> KnowledgeBase | None:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-61] Update a knowledge base.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant identifier
|
||||||
|
kb_id: Knowledge base ID
|
||||||
|
kb_update: Update data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated KnowledgeBase entity or None
|
||||||
|
"""
|
||||||
|
kb = await self.get_knowledge_base(tenant_id, kb_id)
|
||||||
|
if not kb:
|
||||||
|
return None
|
||||||
|
|
||||||
|
update_data = kb_update.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(kb, key, value)
|
||||||
|
|
||||||
|
kb.updated_at = datetime.utcnow()
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-61] Updated knowledge base: tenant={tenant_id}, "
|
||||||
|
f"kb_id={kb_id}, fields={list(update_data.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return kb
|
||||||
|
|
||||||
|
async def delete_knowledge_base(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
kb_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-62] Delete a knowledge base.
|
||||||
|
Also deletes associated documents and Qdrant Collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant identifier
|
||||||
|
kb_id: Knowledge base ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted successfully
|
||||||
|
"""
|
||||||
|
kb = await self.get_knowledge_base(tenant_id, kb_id)
|
||||||
|
if not kb:
|
||||||
|
return False
|
||||||
|
|
||||||
|
doc_stmt = select(Document).where(
|
||||||
|
Document.tenant_id == tenant_id,
|
||||||
|
Document.kb_id == kb_id,
|
||||||
|
)
|
||||||
|
doc_result = await self._session.execute(doc_stmt)
|
||||||
|
documents = doc_result.scalars().all()
|
||||||
|
|
||||||
|
for doc in documents:
|
||||||
|
await self._session.delete(doc)
|
||||||
|
|
||||||
|
await self._session.delete(kb)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
qdrant = await get_qdrant_client()
|
||||||
|
await qdrant.delete_kb_collection(tenant_id, kb_id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-62] Deleted knowledge base: tenant={tenant_id}, "
|
||||||
|
f"kb_id={kb_id}, docs_deleted={len(documents)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def update_doc_count(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
kb_id: str,
|
||||||
|
delta: int = 1,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Update document count for a knowledge base.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant identifier
|
||||||
|
kb_id: Knowledge base ID
|
||||||
|
delta: Change in document count (positive or negative)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if updated successfully
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
kb = await self.get_knowledge_base(tenant_id, kb_id)
|
||||||
|
if kb:
|
||||||
|
kb.doc_count = max(0, kb.doc_count + delta)
|
||||||
|
kb.updated_at = datetime.utcnow()
|
||||||
|
await self._session.flush()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating doc count: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def recalculate_doc_counts(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""
|
||||||
|
Recalculate document counts for all knowledge bases of a tenant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping kb_id to doc_count
|
||||||
|
"""
|
||||||
|
count_stmt = (
|
||||||
|
select(Document.kb_id, func.count(Document.id).label("count"))
|
||||||
|
.where(Document.tenant_id == tenant_id)
|
||||||
|
.group_by(Document.kb_id)
|
||||||
|
)
|
||||||
|
result = await self._session.execute(count_stmt)
|
||||||
|
counts = {row.kb_id: row.count for row in result}
|
||||||
|
|
||||||
|
kb_stmt = select(KnowledgeBase).where(KnowledgeBase.tenant_id == tenant_id)
|
||||||
|
kb_result = await self._session.execute(kb_stmt)
|
||||||
|
knowledge_bases = kb_result.scalars().all()
|
||||||
|
|
||||||
|
for kb in knowledge_bases:
|
||||||
|
kb_id_str = str(kb.id)
|
||||||
|
kb.doc_count = counts.get(kb_id_str, 0)
|
||||||
|
kb.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
return {str(kb.id): kb.doc_count for kb in knowledge_bases}
|
||||||
|
|
||||||
|
async def get_or_create_default_kb(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> KnowledgeBase:
|
||||||
|
"""
|
||||||
|
Get or create the default knowledge base for a tenant.
|
||||||
|
This is used for backward compatibility with existing data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Default KnowledgeBase entity
|
||||||
|
"""
|
||||||
|
stmt = select(KnowledgeBase).where(
|
||||||
|
KnowledgeBase.tenant_id == tenant_id,
|
||||||
|
).limit(1)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
existing_kb = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_kb:
|
||||||
|
return existing_kb
|
||||||
|
|
||||||
|
kb_create = KnowledgeBaseCreate(
|
||||||
|
name="Default Knowledge Base",
|
||||||
|
kb_type=KBType.GENERAL.value,
|
||||||
|
description="Default knowledge base for backward compatibility",
|
||||||
|
priority=0,
|
||||||
|
)
|
||||||
|
return await self.create_knowledge_base(tenant_id, kb_create)
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
-- Migration: Add kb_type, priority, is_enabled, doc_count to knowledge_bases
|
||||||
|
-- Date: 2026-02-27
|
||||||
|
-- Issue: [AC-AISVC-59] Multi-KB management fields missing
|
||||||
|
|
||||||
|
-- Add kb_type column
|
||||||
|
ALTER TABLE knowledge_bases
|
||||||
|
ADD COLUMN IF NOT EXISTS kb_type VARCHAR DEFAULT 'general';
|
||||||
|
|
||||||
|
-- Add priority column
|
||||||
|
ALTER TABLE knowledge_bases
|
||||||
|
ADD COLUMN IF NOT EXISTS priority INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
-- Add is_enabled column
|
||||||
|
ALTER TABLE knowledge_bases
|
||||||
|
ADD COLUMN IF NOT EXISTS is_enabled BOOLEAN DEFAULT TRUE;
|
||||||
|
|
||||||
|
-- Add doc_count column
|
||||||
|
ALTER TABLE knowledge_bases
|
||||||
|
ADD COLUMN IF NOT EXISTS doc_count INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
-- Add index for tenant + kb_type
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_knowledge_bases_tenant_kb_type
|
||||||
|
ON knowledge_bases (tenant_id, kb_type);
|
||||||
|
|
||||||
|
-- Update existing records to have default values
|
||||||
|
UPDATE knowledge_bases
|
||||||
|
SET kb_type = 'general',
|
||||||
|
priority = 0,
|
||||||
|
is_enabled = TRUE,
|
||||||
|
doc_count = 0
|
||||||
|
WHERE kb_type IS NULL OR priority IS NULL OR is_enabled IS NULL OR doc_count IS NULL;
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""
|
||||||
|
Migration script to add kb_type, priority, is_enabled, doc_count to knowledge_bases.
|
||||||
|
Run: python scripts/migrations/run_migration.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migration():
|
||||||
|
"""Run the migration to add new columns to knowledge_bases table."""
|
||||||
|
settings = get_settings()
|
||||||
|
engine = create_async_engine(settings.database_url, echo=True)
|
||||||
|
async_session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
migration_sql = """
|
||||||
|
-- Add kb_type column
|
||||||
|
ALTER TABLE knowledge_bases
|
||||||
|
ADD COLUMN IF NOT EXISTS kb_type VARCHAR DEFAULT 'general';
|
||||||
|
|
||||||
|
-- Add priority column
|
||||||
|
ALTER TABLE knowledge_bases
|
||||||
|
ADD COLUMN IF NOT EXISTS priority INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
-- Add is_enabled column
|
||||||
|
ALTER TABLE knowledge_bases
|
||||||
|
ADD COLUMN IF NOT EXISTS is_enabled BOOLEAN DEFAULT TRUE;
|
||||||
|
|
||||||
|
-- Add doc_count column
|
||||||
|
ALTER TABLE knowledge_bases
|
||||||
|
ADD COLUMN IF NOT EXISTS doc_count INTEGER DEFAULT 0;
|
||||||
|
"""
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
for statement in migration_sql.strip().split(';'):
|
||||||
|
statement = statement.strip()
|
||||||
|
if statement and not statement.startswith('--'):
|
||||||
|
try:
|
||||||
|
await session.execute(text(statement))
|
||||||
|
print(f"Executed: {statement[:50]}...")
|
||||||
|
except Exception as e:
|
||||||
|
if "already exists" in str(e).lower() or "duplicate" in str(e).lower():
|
||||||
|
print(f"Skipped (already exists): {statement[:50]}...")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
print("\nMigration completed successfully!")
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run_migration())
|
||||||
Loading…
Reference in New Issue