feat(ai-service): implement intent recognition and rule engine (Phase 12 T12.1-T12.5)

[AC-AISVC-65~AC-AISVC-70] Intent recognition with keyword and regex matching

- Add IntentRule SQLModel entity with tenant isolation
- Implement IntentRuleService for CRUD operations with hit statistics
- Implement IntentRouter matching engine (priority DESC, keyword then regex)
- Add rule caching by tenant_id with TTL=60s and CRUD invalidation
- Add intent rules management API (POST/GET/PUT/DELETE /admin/intent-rules)
- Support four response types: fixed/rag/flow/transfer

T12.6 (Orchestrator integration) and T12.7 (unit tests) pending for integration phase
This commit is contained in:
MerCry 2026-02-27 14:20:31 +08:00
parent eb93636227
commit ff35538a01
6 changed files with 684 additions and 18 deletions

View File

@ -0,0 +1,166 @@
"""
Intent Rule Management API.
[AC-AISVC-65~AC-AISVC-68] Intent rule CRUD endpoints.
"""
import logging
import uuid
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.entities import IntentRuleCreate, IntentRuleUpdate
from app.services.intent.rule_service import IntentRuleService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/intent-rules", tags=["Intent Rules"])
def get_tenant_id(x_tenant_id: str = Header(..., alias="X-Tenant-Id")) -> str:
"""Extract tenant ID from header."""
if not x_tenant_id:
raise HTTPException(status_code=400, detail="X-Tenant-Id header is required")
return x_tenant_id
@router.get("")
async def list_rules(
tenant_id: str = Depends(get_tenant_id),
response_type: str | None = None,
is_enabled: bool | None = None,
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""
[AC-AISVC-66] List all intent rules for a tenant.
"""
logger.info(
f"[AC-AISVC-66] Listing intent rules for tenant={tenant_id}, "
f"response_type={response_type}, is_enabled={is_enabled}"
)
service = IntentRuleService(session)
rules = await service.list_rules(tenant_id, response_type, is_enabled)
data = []
for rule in rules:
data.append(await service.rule_to_info_dict(rule))
return {"data": data}
@router.post("", status_code=201)
async def create_rule(
body: IntentRuleCreate,
tenant_id: str = Depends(get_tenant_id),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""
[AC-AISVC-65] Create a new intent rule.
"""
valid_response_types = ["fixed", "rag", "flow", "transfer"]
if body.response_type not in valid_response_types:
raise HTTPException(
status_code=400,
detail=f"Invalid response_type. Must be one of: {valid_response_types}"
)
if body.response_type == "rag" and not body.target_kb_ids:
logger.warning(
f"[AC-AISVC-65] Creating rag rule without target_kb_ids: tenant={tenant_id}"
)
if body.response_type == "flow" and not body.flow_id:
raise HTTPException(
status_code=400,
detail="flow_id is required when response_type is 'flow'"
)
if body.response_type == "fixed" and not body.fixed_reply:
raise HTTPException(
status_code=400,
detail="fixed_reply is required when response_type is 'fixed'"
)
if body.response_type == "transfer" and not body.transfer_message:
raise HTTPException(
status_code=400,
detail="transfer_message is required when response_type is 'transfer'"
)
logger.info(
f"[AC-AISVC-65] Creating intent rule for tenant={tenant_id}, name={body.name}"
)
service = IntentRuleService(session)
rule = await service.create_rule(tenant_id, body)
return await service.rule_to_info_dict(rule)
@router.get("/{rule_id}")
async def get_rule(
rule_id: uuid.UUID,
tenant_id: str = Depends(get_tenant_id),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""
[AC-AISVC-66] Get intent rule detail.
"""
logger.info(f"[AC-AISVC-66] Getting intent rule for tenant={tenant_id}, id={rule_id}")
service = IntentRuleService(session)
rule = await service.get_rule(tenant_id, rule_id)
if not rule:
raise HTTPException(status_code=404, detail="Intent rule not found")
return await service.rule_to_info_dict(rule)
@router.put("/{rule_id}")
async def update_rule(
rule_id: uuid.UUID,
body: IntentRuleUpdate,
tenant_id: str = Depends(get_tenant_id),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""
[AC-AISVC-67] Update an intent rule.
"""
valid_response_types = ["fixed", "rag", "flow", "transfer"]
if body.response_type is not None and body.response_type not in valid_response_types:
raise HTTPException(
status_code=400,
detail=f"Invalid response_type. Must be one of: {valid_response_types}"
)
logger.info(f"[AC-AISVC-67] Updating intent rule for tenant={tenant_id}, id={rule_id}")
service = IntentRuleService(session)
rule = await service.update_rule(tenant_id, rule_id, body)
if not rule:
raise HTTPException(status_code=404, detail="Intent rule not found")
return await service.rule_to_info_dict(rule)
@router.delete("/{rule_id}", status_code=204)
async def delete_rule(
rule_id: uuid.UUID,
tenant_id: str = Depends(get_tenant_id),
session: AsyncSession = Depends(get_session),
) -> None:
"""
[AC-AISVC-68] Delete an intent rule.
"""
logger.info(f"[AC-AISVC-68] Deleting intent rule for tenant={tenant_id}, id={rule_id}")
service = IntentRuleService(session)
success = await service.delete_rule(tenant_id, rule_id)
if not success:
raise HTTPException(status_code=404, detail="Intent rule not found")

View File

@ -0,0 +1,13 @@
"""
Intent recognition and routing services.
[AC-AISVC-65~AC-AISVC-70] Intent rule management and matching engine.
"""
from app.services.intent.rule_service import IntentRuleService
from app.services.intent.router import IntentRouter, IntentMatchResult
__all__ = [
"IntentRuleService",
"IntentRouter",
"IntentMatchResult",
]

View File

@ -0,0 +1,170 @@
"""
Intent router for AI Service.
[AC-AISVC-69, AC-AISVC-70] Intent matching engine with keyword and regex support.
"""
import logging
import re
from dataclasses import dataclass
from typing import Any
from app.models.entities import IntentRule
logger = logging.getLogger(__name__)
@dataclass
class IntentMatchResult:
"""
[AC-AISVC-69] Result of intent matching.
Contains the matched rule and match details.
"""
rule: IntentRule
match_type: str
matched: str
def to_dict(self) -> dict[str, Any]:
return {
"rule_id": str(self.rule.id),
"rule_name": self.rule.name,
"match_type": self.match_type,
"matched": self.matched,
"response_type": self.rule.response_type,
"target_kb_ids": self.rule.target_kb_ids or [],
"flow_id": str(self.rule.flow_id) if self.rule.flow_id else None,
"fixed_reply": self.rule.fixed_reply,
"transfer_message": self.rule.transfer_message,
}
class IntentRouter:
"""
[AC-AISVC-69] Intent matching engine.
Matching algorithm:
1. Load rules ordered by priority DESC
2. For each rule, try keyword matching first
3. If no keyword match, try regex pattern matching
4. Return first match (highest priority)
5. If no match, return None (fallback to default RAG)
"""
def __init__(self):
pass
def match(
self,
message: str,
rules: list[IntentRule],
) -> IntentMatchResult | None:
"""
[AC-AISVC-69] Match user message against intent rules.
Args:
message: User input message
rules: List of enabled rules ordered by priority DESC
Returns:
IntentMatchResult if matched, None otherwise
"""
if not message or not rules:
return None
message_lower = message.lower()
for rule in rules:
if not rule.is_enabled:
continue
keyword_result = self._match_keywords(message, message_lower, rule)
if keyword_result:
logger.info(
f"[AC-AISVC-69] Intent matched by keyword: "
f"rule={rule.name}, matched='{keyword_result.matched}'"
)
return keyword_result
regex_result = self._match_patterns(message, rule)
if regex_result:
logger.info(
f"[AC-AISVC-69] Intent matched by regex: "
f"rule={rule.name}, matched='{regex_result.matched}'"
)
return regex_result
logger.debug(f"[AC-AISVC-70] No intent matched, will fallback to default RAG")
return None
def _match_keywords(
self,
message: str,
message_lower: str,
rule: IntentRule,
) -> IntentMatchResult | None:
"""
Match message against rule keywords.
Any keyword match returns a result.
"""
keywords = rule.keywords or []
if not keywords:
return None
for keyword in keywords:
if not keyword:
continue
if keyword.lower() in message_lower:
return IntentMatchResult(
rule=rule,
match_type="keyword",
matched=keyword,
)
return None
def _match_patterns(
self,
message: str,
rule: IntentRule,
) -> IntentMatchResult | None:
"""
Match message against rule regex patterns.
Any pattern match returns a result.
"""
patterns = rule.patterns or []
if not patterns:
return None
for pattern in patterns:
if not pattern:
continue
try:
if re.search(pattern, message, re.IGNORECASE):
return IntentMatchResult(
rule=rule,
match_type="regex",
matched=pattern,
)
except re.error as e:
logger.warning(
f"Invalid regex pattern in rule {rule.id}: {pattern}, error: {e}"
)
continue
return None
def match_with_stats(
self,
message: str,
rules: list[IntentRule],
) -> tuple[IntentMatchResult | None, str | None]:
"""
[AC-AISVC-69] Match and return rule_id for statistics update.
Returns:
Tuple of (match_result, rule_id_for_stats)
"""
result = self.match(message, rules)
if result:
return result, str(result.rule.id)
return None, None

View File

@ -0,0 +1,298 @@
"""
Intent rule service for AI Service.
[AC-AISVC-65~AC-AISVC-68] Intent rule CRUD and hit statistics management.
"""
import logging
import time
import uuid
from datetime import datetime
from typing import Any, Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col
from app.models.entities import (
IntentRule,
IntentRuleCreate,
IntentRuleUpdate,
)
logger = logging.getLogger(__name__)
RULE_CACHE_TTL_SECONDS = 60
class RuleCache:
"""
[AC-AISVC-69] In-memory cache for intent rules.
Key: tenant_id
Value: (rules_list, cached_at)
TTL: 60 seconds
"""
def __init__(self, ttl_seconds: int = RULE_CACHE_TTL_SECONDS):
self._cache: dict[str, tuple[list[IntentRule], float]] = {}
self._ttl = ttl_seconds
def get(self, tenant_id: str) -> list[IntentRule] | None:
"""Get cached rules if not expired."""
if tenant_id in self._cache:
rules, cached_at = self._cache[tenant_id]
if time.time() - cached_at < self._ttl:
return rules
else:
del self._cache[tenant_id]
return None
def set(self, tenant_id: str, rules: list[IntentRule]) -> None:
"""Cache rules for a tenant."""
self._cache[tenant_id] = (rules, time.time())
def invalidate(self, tenant_id: str) -> None:
"""Invalidate cache for a tenant."""
if tenant_id in self._cache:
del self._cache[tenant_id]
logger.debug(f"Invalidated rule cache for tenant={tenant_id}")
_rule_cache = RuleCache()
class IntentRuleService:
"""
[AC-AISVC-65~AC-AISVC-68] Service for managing intent rules.
Features:
- Rule CRUD with tenant isolation
- Hit count statistics
- In-memory caching with TTL
- Cache invalidation on CRUD operations
"""
def __init__(self, session: AsyncSession):
self._session = session
self._cache = _rule_cache
async def create_rule(
self,
tenant_id: str,
create_data: IntentRuleCreate,
) -> IntentRule:
"""
[AC-AISVC-65] Create a new intent rule.
"""
flow_id_uuid = None
if create_data.flow_id:
try:
flow_id_uuid = uuid.UUID(create_data.flow_id)
except ValueError:
pass
rule = IntentRule(
tenant_id=tenant_id,
name=create_data.name,
keywords=create_data.keywords or [],
patterns=create_data.patterns or [],
priority=create_data.priority,
response_type=create_data.response_type,
target_kb_ids=create_data.target_kb_ids or [],
flow_id=flow_id_uuid,
fixed_reply=create_data.fixed_reply,
transfer_message=create_data.transfer_message,
is_enabled=True,
hit_count=0,
)
self._session.add(rule)
await self._session.flush()
self._cache.invalidate(tenant_id)
logger.info(
f"[AC-AISVC-65] Created intent rule: tenant={tenant_id}, "
f"id={rule.id}, name={rule.name}, response_type={rule.response_type}"
)
return rule
async def list_rules(
self,
tenant_id: str,
response_type: str | None = None,
is_enabled: bool | None = None,
) -> Sequence[IntentRule]:
"""
[AC-AISVC-66] List rules for a tenant with optional filters.
"""
stmt = select(IntentRule).where(
IntentRule.tenant_id == tenant_id
)
if response_type is not None:
stmt = stmt.where(IntentRule.response_type == response_type)
if is_enabled is not None:
stmt = stmt.where(IntentRule.is_enabled == is_enabled)
stmt = stmt.order_by(col(IntentRule.priority).desc(), col(IntentRule.created_at).desc())
result = await self._session.execute(stmt)
return result.scalars().all()
async def get_rule(
self,
tenant_id: str,
rule_id: uuid.UUID,
) -> IntentRule | None:
"""
[AC-AISVC-66] Get rule by ID with tenant isolation.
"""
stmt = select(IntentRule).where(
IntentRule.tenant_id == tenant_id,
IntentRule.id == rule_id,
)
result = await self._session.execute(stmt)
return result.scalar_one_or_none()
async def update_rule(
self,
tenant_id: str,
rule_id: uuid.UUID,
update_data: IntentRuleUpdate,
) -> IntentRule | None:
"""
[AC-AISVC-67] Update an intent rule.
"""
rule = await self.get_rule(tenant_id, rule_id)
if not rule:
return None
if update_data.name is not None:
rule.name = update_data.name
if update_data.keywords is not None:
rule.keywords = update_data.keywords
if update_data.patterns is not None:
rule.patterns = update_data.patterns
if update_data.priority is not None:
rule.priority = update_data.priority
if update_data.response_type is not None:
rule.response_type = update_data.response_type
if update_data.target_kb_ids is not None:
rule.target_kb_ids = update_data.target_kb_ids
if update_data.flow_id is not None:
try:
rule.flow_id = uuid.UUID(update_data.flow_id)
except ValueError:
rule.flow_id = None
if update_data.fixed_reply is not None:
rule.fixed_reply = update_data.fixed_reply
if update_data.transfer_message is not None:
rule.transfer_message = update_data.transfer_message
if update_data.is_enabled is not None:
rule.is_enabled = update_data.is_enabled
rule.updated_at = datetime.utcnow()
await self._session.flush()
self._cache.invalidate(tenant_id)
logger.info(
f"[AC-AISVC-67] Updated intent rule: tenant={tenant_id}, id={rule_id}"
)
return rule
async def delete_rule(
self,
tenant_id: str,
rule_id: uuid.UUID,
) -> bool:
"""
[AC-AISVC-68] Delete an intent rule.
"""
rule = await self.get_rule(tenant_id, rule_id)
if not rule:
return False
await self._session.delete(rule)
await self._session.flush()
self._cache.invalidate(tenant_id)
logger.info(
f"[AC-AISVC-68] Deleted intent rule: tenant={tenant_id}, id={rule_id}"
)
return True
async def increment_hit_count(
self,
tenant_id: str,
rule_id: uuid.UUID,
) -> bool:
"""
[AC-AISVC-66] Increment hit count for a rule.
"""
rule = await self.get_rule(tenant_id, rule_id)
if not rule:
return False
rule.hit_count += 1
rule.updated_at = datetime.utcnow()
await self._session.flush()
logger.debug(
f"[AC-AISVC-66] Incremented hit count for rule: tenant={tenant_id}, "
f"id={rule_id}, hit_count={rule.hit_count}"
)
return True
async def get_enabled_rules_for_matching(
self,
tenant_id: str,
) -> list[IntentRule]:
"""
[AC-AISVC-69] Get enabled rules for matching, ordered by priority DESC.
Uses cache for performance.
"""
cached = self._cache.get(tenant_id)
if cached is not None:
logger.debug(f"[AC-AISVC-69] Cache hit for rules: tenant={tenant_id}")
return cached
stmt = (
select(IntentRule)
.where(
IntentRule.tenant_id == tenant_id,
IntentRule.is_enabled == True,
)
.order_by(col(IntentRule.priority).desc())
)
result = await self._session.execute(stmt)
rules = list(result.scalars().all())
self._cache.set(tenant_id, rules)
logger.info(
f"[AC-AISVC-69] Loaded {len(rules)} enabled rules from DB: tenant={tenant_id}"
)
return rules
def invalidate_cache(self, tenant_id: str) -> None:
"""Manually invalidate cache for a tenant."""
self._cache.invalidate(tenant_id)
async def rule_to_info_dict(self, rule: IntentRule) -> dict[str, Any]:
"""Convert rule entity to API response dict."""
return {
"id": str(rule.id),
"name": rule.name,
"keywords": rule.keywords or [],
"patterns": rule.patterns or [],
"priority": rule.priority,
"response_type": rule.response_type,
"target_kb_ids": rule.target_kb_ids or [],
"flow_id": str(rule.flow_id) if rule.flow_id else None,
"fixed_reply": rule.fixed_reply,
"transfer_message": rule.transfer_message,
"is_enabled": rule.is_enabled,
"hit_count": rule.hit_count,
"created_at": rule.created_at.isoformat(),
"updated_at": rule.updated_at.isoformat(),
}

View File

@ -33,7 +33,7 @@
- [x] Phase 8: LLM 配置与 RAG 调试输出 (100%) ✅
- [x] Phase 9: 租户管理与 RAG 优化 (100%) ✅
- [x] Phase 10: Prompt 模板化 (80%) 🔄 (T10.1-T10.8 完成T10.9-T10.10 待集成阶段)
- [ ] Phase 11: 多知识库管理 (0%) ⏳
- [x] Phase 11: 多知识库管理 (63%) 🔄 (T11.1-T11.5 完成T11.6-T11.8 待集成阶段)
- [x] Phase 12: 意图识别与规则引擎 (71%) 🔄 (T12.1-T12.5 完成T12.6-T12.7 待集成阶段)
---
@ -41,20 +41,21 @@
## 🔄 Current Phase
### Goal
Phase 12 意图识别与规则引擎核心功能已完成T12.6Orchestrator 集成)和 T12.7(单元测试)留待集成阶段。
Phase 11 多知识库管理核心功能已完成 (T11.1-T11.5)T11.6OptimizedRetriever 多 Collection 检索、T11.7kb_default 迁移、T11.8(单元测试)留待集成阶段。
### Completed Tasks (Phase 12)
### Completed Tasks (Phase 11)
- [x] T12.1 定义 `IntentRule` SQLModel 实体,创建数据库表 `[AC-AISVC-65]` ✅
- [x] T12.2 实现 `IntentRuleService`:规则 CRUD + 命中统计更新 `[AC-AISVC-65~AC-AISVC-68]` ✅
- [x] T12.3 实现 `IntentRouter`:按优先级遍历规则,关键词+正则匹配 `[AC-AISVC-69]` ✅
- [x] T12.4 实现规则缓存:按 tenant_id 缓存规则列表CRUD 操作时主动失效 `[AC-AISVC-69]` ✅
- [x] T12.5 实现意图规则管理 API`POST/GET/PUT/DELETE /admin/intent-rules` `[AC-AISVC-65~AC-AISVC-68]` ✅
- [x] T11.1 扩展 `KnowledgeBase` 实体:新增 `kb_type`、`priority`、`is_enabled`、`doc_count` 字段 `[AC-AISVC-59]` ✅
- [x] T11.2 实现知识库 CRUD 服务:创建时初始化 Qdrant Collection删除时清理 Collection `[AC-AISVC-59, AC-AISVC-61, AC-AISVC-62]` ✅
- [x] T11.3 实现知识库管理 API`POST/GET/PUT/DELETE /admin/kb/knowledge-bases` `[AC-AISVC-59~AC-AISVC-62]` ✅
- [x] T11.4 升级 Qdrant Collection 命名:`kb_{tenant_id}_{kb_id}`,兼容现有 `kb_{tenant_id}` `[AC-AISVC-63]` ✅
- [x] T11.5 修改文档上传流程:支持指定 `kbId` 参数,索引到对应 Collection `[AC-AISVC-63]` ✅
### Pending Tasks (Phase 12 - 集成阶段)
### Pending Tasks (Phase 11 - 集成阶段)
- [ ] T12.6 在 Orchestrator 中集成 IntentRouterRAG 检索前执行意图识别,按 response_type 路由 `[AC-AISVC-69, AC-AISVC-70]`
- [ ] T12.7 编写意图识别服务单元测试 `[AC-AISVC-65~AC-AISVC-70]`
- [ ] T11.6 修改 `OptimizedRetriever`:支持 `target_kb_ids` 参数,实现多 Collection 并行检索 `[AC-AISVC-64]`
- [ ] T11.7 实现 `kb_default` 自动迁移:首次启动时为现有数据创建默认知识库记录 `[AC-AISVC-59]`
- [ ] T11.8 编写多知识库服务单元测试 `[AC-AISVC-59~AC-AISVC-64]`
---
@ -101,6 +102,24 @@ Phase 12 意图识别与规则引擎核心功能已完成T12.6Orchestrator
## 🧾 Session History
### Session #9 (2026-02-27)
- completed:
- T11.1-T11.5 多知识库管理核心功能
- 扩展 KnowledgeBase 实体kb_type、priority、is_enabled、doc_count
- 实现 KnowledgeBaseServiceCRUD、Collection 初始化/清理、文档计数)
- 实现知识库管理 APIPOST/GET/PUT/DELETE
- 升级 Qdrant Collection 命名kb_{tenantId}_{kbId},兼容旧格式)
- 修改文档上传流程(支持 kbId 参数,索引到对应 Collection
- changes:
- 新增 `app/models/entities.py` KBType 枚举、KnowledgeBaseCreate/Update Schema
- 新增 `app/services/knowledge_base_service.py` 知识库 CRUD 服务
- 更新 `app/core/qdrant_client.py` 多知识库 Collection 管理方法
- 更新 `app/api/admin/kb.py` 知识库管理 API 和文档上传流程
- 更新 `spec/ai-service/tasks.md` 标记任务完成
- notes:
- T11.6OptimizedRetriever 多 Collection 检索、T11.7kb_default 迁移、T11.8(单元测试)留待集成阶段
- 进度文档已写入 `ai-service/docs/progress/phase11_multi_kb_progress.md`
### Session #8 (2026-02-27)
- completed:
- T12.1-T12.5 意图识别与规则引擎核心功能

View File

@ -97,13 +97,13 @@ last_updated: "2026-02-27"
| Phase 8 | LLM 配置与 RAG 调试输出 | 10 | ✅ 完成 |
| Phase 9 | 租户管理与 RAG 优化 | 10 | ✅ 完成 |
| Phase 10 | Prompt 模板化 | 10 | 🔄 进行中 (8/10) |
| Phase 11 | 多知识库管理 | 8 | ⏳ 待处理 |
| Phase 11 | 多知识库管理 | 8 | 🔄 进行中 (5/8) |
| Phase 12 | 意图识别与规则引擎 | 7 | 🔄 进行中 (5/7) |
| Phase 13 | 话术流程引擎 | 7 | ⏳ 待处理 |
| Phase 14 | 输出护栏 | 8 | ⏳ 待处理 |
| Phase 15 | 智能 RAG 增强与编排升级 | 8 | ⏳ 待处理 |
**已完成: 86 个任务**
**已完成: 91 个任务**
---
@ -181,11 +181,11 @@ last_updated: "2026-02-27"
> 目标:从单个 kb_default 升级为多知识库分类管理,支持按类型和优先级组织知识。
- [ ] T11.1 扩展 `KnowledgeBase` 实体:新增 `kb_type`、`priority`、`is_enabled`、`doc_count` 字段 `[AC-AISVC-59]`
- [ ] T11.2 实现知识库 CRUD 服务:创建时初始化 Qdrant Collection删除时清理 Collection `[AC-AISVC-59, AC-AISVC-61, AC-AISVC-62]`
- [ ] T11.3 实现知识库管理 API`POST/GET/PUT/DELETE /admin/kb/knowledge-bases` `[AC-AISVC-59, AC-AISVC-60, AC-AISVC-61, AC-AISVC-62]`
- [ ] T11.4 升级 Qdrant Collection 命名:`kb_{tenant_id}_{kb_id}`,兼容现有 `kb_{tenant_id}` `[AC-AISVC-63]`
- [ ] T11.5 修改文档上传流程:支持指定 `kbId` 参数,索引到对应 Collection `[AC-AISVC-63]`
- [x] T11.1 扩展 `KnowledgeBase` 实体:新增 `kb_type`、`priority`、`is_enabled`、`doc_count` 字段 `[AC-AISVC-59]`
- [x] T11.2 实现知识库 CRUD 服务:创建时初始化 Qdrant Collection删除时清理 Collection `[AC-AISVC-59, AC-AISVC-61, AC-AISVC-62]`
- [x] T11.3 实现知识库管理 API`POST/GET/PUT/DELETE /admin/kb/knowledge-bases` `[AC-AISVC-59, AC-AISVC-60, AC-AISVC-61, AC-AISVC-62]`
- [x] T11.4 升级 Qdrant Collection 命名:`kb_{tenant_id}_{kb_id}`,兼容现有 `kb_{tenant_id}` `[AC-AISVC-63]`
- [x] T11.5 修改文档上传流程:支持指定 `kbId` 参数,索引到对应 Collection `[AC-AISVC-63]`
- [ ] T11.6 修改 `OptimizedRetriever`:支持 `target_kb_ids` 参数,实现多 Collection 并行检索 `[AC-AISVC-64]`
- [ ] T11.7 实现 `kb_default` 自动迁移:首次启动时为现有数据创建默认知识库记录 `[AC-AISVC-59]`
- [ ] T11.8 编写多知识库服务单元测试 `[AC-AISVC-59~AC-AISVC-64]`