ai-robot-core/ai-service/app/api/admin/intent_rules.py

318 lines
9.7 KiB
Python
Raw Normal View History

"""
Intent Rule Management API.
[AC-AISVC-65~AC-AISVC-68] Intent rule CRUD endpoints.
[AC-AISVC-96] Intent rule testing endpoint.
[AC-AISVC-116] Fusion config management endpoints.
[AC-AISVC-114] Intent vector generation endpoint.
"""
import logging
import uuid
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.entities import IntentRuleCreate, IntentRuleUpdate
from app.services.intent.models import DEFAULT_FUSION_CONFIG, FusionConfig
from app.services.intent.rule_service import IntentRuleService
from app.services.intent.tester import IntentRuleTester
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/intent-rules", tags=["Intent Rules"])
_fusion_config = FusionConfig()
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")
class IntentRuleTestRequest(BaseModel):
"""Request body for testing an intent rule."""
message: str
@router.post("/{rule_id}/test")
async def test_rule(
rule_id: uuid.UUID,
body: IntentRuleTestRequest,
tenant_id: str = Depends(get_tenant_id),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""
[AC-AISVC-96] Test an intent rule against a message.
Returns match result with conflict detection.
"""
logger.info(
f"[AC-AISVC-96] Testing intent rule for tenant={tenant_id}, "
f"rule_id={rule_id}, message='{body.message[:50]}...'"
)
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")
all_rules = await service.get_enabled_rules_for_matching(tenant_id)
tester = IntentRuleTester()
result = await tester.test_rule(rule, [body.message], all_rules)
return result.to_dict()
class FusionConfigUpdate(BaseModel):
"""Request body for updating fusion config."""
w_rule: float | None = None
w_semantic: float | None = None
w_llm: float | None = None
semantic_threshold: float | None = None
conflict_threshold: float | None = None
gray_zone_threshold: float | None = None
min_trigger_threshold: float | None = None
clarify_threshold: float | None = None
multi_intent_threshold: float | None = None
llm_judge_enabled: bool | None = None
semantic_matcher_enabled: bool | None = None
semantic_matcher_timeout_ms: int | None = None
llm_judge_timeout_ms: int | None = None
semantic_top_k: int | None = None
@router.get("/fusion-config")
async def get_fusion_config() -> dict[str, Any]:
"""
[AC-AISVC-116] Get current fusion configuration.
"""
logger.info("[AC-AISVC-116] Getting fusion config")
return _fusion_config.to_dict()
@router.put("/fusion-config")
async def update_fusion_config(
body: FusionConfigUpdate,
) -> dict[str, Any]:
"""
[AC-AISVC-116] Update fusion configuration.
"""
global _fusion_config
logger.info(f"[AC-AISVC-116] Updating fusion config: {body.model_dump()}")
current_dict = _fusion_config.to_dict()
update_dict = body.model_dump(exclude_none=True)
current_dict.update(update_dict)
_fusion_config = FusionConfig.from_dict(current_dict)
return _fusion_config.to_dict()
@router.post("/{rule_id}/generate-vector")
async def generate_intent_vector(
rule_id: uuid.UUID,
tenant_id: str = Depends(get_tenant_id),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
"""
[AC-AISVC-114] Generate intent vector for a rule.
Uses the rule's semantic_examples to generate an average vector.
If no semantic_examples exist, returns an error.
"""
logger.info(
f"[AC-AISVC-114] Generating intent vector for tenant={tenant_id}, rule_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")
if not rule.semantic_examples:
raise HTTPException(
status_code=400,
detail="Rule has no semantic_examples. Please add semantic_examples first."
)
try:
from app.core.dependencies import get_embedding_provider
embedding_provider = get_embedding_provider()
vectors = await embedding_provider.embed_batch(rule.semantic_examples)
import numpy as np
avg_vector = np.mean(vectors, axis=0).tolist()
update_data = IntentRuleUpdate(intent_vector=avg_vector)
updated_rule = await service.update_rule(tenant_id, rule_id, update_data)
logger.info(
f"[AC-AISVC-114] Generated intent vector for rule={rule_id}, "
f"dimension={len(avg_vector)}"
)
return {
"id": str(updated_rule.id),
"intent_vector": updated_rule.intent_vector,
"semantic_examples": updated_rule.semantic_examples,
}
except Exception as e:
logger.error(f"[AC-AISVC-114] Failed to generate intent vector: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to generate intent vector: {str(e)}"
)