""" 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)}" )