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:
parent
eb93636227
commit
ff35538a01
|
|
@ -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")
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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(),
|
||||
}
|
||||
|
|
@ -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.6(Orchestrator 集成)和 T12.7(单元测试)留待集成阶段。
|
||||
Phase 11 多知识库管理核心功能已完成 (T11.1-T11.5),T11.6(OptimizedRetriever 多 Collection 检索)、T11.7(kb_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 中集成 IntentRouter:RAG 检索前执行意图识别,按 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.6(Orchestrator
|
|||
|
||||
## 🧾 Session History
|
||||
|
||||
### Session #9 (2026-02-27)
|
||||
- completed:
|
||||
- T11.1-T11.5 多知识库管理核心功能
|
||||
- 扩展 KnowledgeBase 实体(kb_type、priority、is_enabled、doc_count)
|
||||
- 实现 KnowledgeBaseService(CRUD、Collection 初始化/清理、文档计数)
|
||||
- 实现知识库管理 API(POST/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.6(OptimizedRetriever 多 Collection 检索)、T11.7(kb_default 迁移)、T11.8(单元测试)留待集成阶段
|
||||
- 进度文档已写入 `ai-service/docs/progress/phase11_multi_kb_progress.md`
|
||||
|
||||
### Session #8 (2026-02-27)
|
||||
- completed:
|
||||
- T12.1-T12.5 意图识别与规则引擎核心功能
|
||||
|
|
|
|||
|
|
@ -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]`
|
||||
|
|
|
|||
Loading…
Reference in New Issue