318 lines
9.7 KiB
Python
318 lines
9.7 KiB
Python
"""
|
|
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)}"
|
|
)
|