""" Guardrail Management API. [AC-AISVC-78~AC-AISVC-85] Forbidden words and behavior rules CRUD endpoints. [AC-AISVC-105] Guardrail testing 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 ( BehaviorRuleCreate, BehaviorRuleUpdate, ForbiddenWordCreate, ForbiddenWordUpdate, ) from app.services.guardrail.behavior_service import BehaviorRuleService from app.services.guardrail.tester import GuardrailTester from app.services.guardrail.word_service import ForbiddenWordService logger = logging.getLogger(__name__) router = APIRouter(prefix="/admin/guardrails", tags=["Guardrails"]) 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("/forbidden-words") async def list_forbidden_words( tenant_id: str = Depends(get_tenant_id), category: str | None = None, is_enabled: bool | None = None, session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: """ [AC-AISVC-79] List all forbidden words for a tenant. """ logger.info( f"[AC-AISVC-79] Listing forbidden words for tenant={tenant_id}, " f"category={category}, is_enabled={is_enabled}" ) service = ForbiddenWordService(session) words = await service.list_words(tenant_id, category, is_enabled) data = [] for word in words: data.append(await service.word_to_info_dict(word)) return {"data": data} @router.post("/forbidden-words", status_code=201) async def create_forbidden_word( body: ForbiddenWordCreate, tenant_id: str = Depends(get_tenant_id), session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: """ [AC-AISVC-78] Create a new forbidden word. """ valid_categories = ["competitor", "sensitive", "political", "custom"] if body.category not in valid_categories: raise HTTPException( status_code=400, detail=f"Invalid category. Must be one of: {valid_categories}" ) valid_strategies = ["mask", "replace", "block"] if body.strategy not in valid_strategies: raise HTTPException( status_code=400, detail=f"Invalid strategy. Must be one of: {valid_strategies}" ) if body.strategy == "replace" and not body.replacement: raise HTTPException( status_code=400, detail="replacement is required when strategy is 'replace'" ) logger.info( f"[AC-AISVC-78] Creating forbidden word for tenant={tenant_id}, word={body.word}" ) service = ForbiddenWordService(session) try: word = await service.create_word(tenant_id, body) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) return await service.word_to_info_dict(word) @router.get("/forbidden-words/{word_id}") async def get_forbidden_word( word_id: uuid.UUID, tenant_id: str = Depends(get_tenant_id), session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: """ [AC-AISVC-79] Get forbidden word detail. """ logger.info(f"[AC-AISVC-79] Getting forbidden word for tenant={tenant_id}, id={word_id}") service = ForbiddenWordService(session) word = await service.get_word(tenant_id, word_id) if not word: raise HTTPException(status_code=404, detail="Forbidden word not found") return await service.word_to_info_dict(word) @router.put("/forbidden-words/{word_id}") async def update_forbidden_word( word_id: uuid.UUID, body: ForbiddenWordUpdate, tenant_id: str = Depends(get_tenant_id), session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: """ [AC-AISVC-80] Update a forbidden word. """ valid_categories = ["competitor", "sensitive", "political", "custom"] if body.category is not None and body.category not in valid_categories: raise HTTPException( status_code=400, detail=f"Invalid category. Must be one of: {valid_categories}" ) valid_strategies = ["mask", "replace", "block"] if body.strategy is not None and body.strategy not in valid_strategies: raise HTTPException( status_code=400, detail=f"Invalid strategy. Must be one of: {valid_strategies}" ) logger.info(f"[AC-AISVC-80] Updating forbidden word for tenant={tenant_id}, id={word_id}") service = ForbiddenWordService(session) try: word = await service.update_word(tenant_id, word_id, body) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) if not word: raise HTTPException(status_code=404, detail="Forbidden word not found") return await service.word_to_info_dict(word) @router.delete("/forbidden-words/{word_id}", status_code=204) async def delete_forbidden_word( word_id: uuid.UUID, tenant_id: str = Depends(get_tenant_id), session: AsyncSession = Depends(get_session), ) -> None: """ [AC-AISVC-81] Delete a forbidden word. """ logger.info(f"[AC-AISVC-81] Deleting forbidden word for tenant={tenant_id}, id={word_id}") service = ForbiddenWordService(session) success = await service.delete_word(tenant_id, word_id) if not success: raise HTTPException(status_code=404, detail="Forbidden word not found") @router.get("/behavior-rules") async def list_behavior_rules( tenant_id: str = Depends(get_tenant_id), category: str | None = None, session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: """ [AC-AISVC-85] List all behavior rules for a tenant. """ logger.info( f"[AC-AISVC-85] Listing behavior rules for tenant={tenant_id}, category={category}" ) service = BehaviorRuleService(session) rules = await service.list_rules(tenant_id, category) data = [] for rule in rules: data.append(await service.rule_to_info_dict(rule)) return {"data": data} @router.post("/behavior-rules", status_code=201) async def create_behavior_rule( body: BehaviorRuleCreate, tenant_id: str = Depends(get_tenant_id), session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: """ [AC-AISVC-84] Create a new behavior rule. """ valid_categories = ["compliance", "tone", "boundary", "custom"] if body.category not in valid_categories: raise HTTPException( status_code=400, detail=f"Invalid category. Must be one of: {valid_categories}" ) logger.info( f"[AC-AISVC-84] Creating behavior rule for tenant={tenant_id}, category={body.category}" ) service = BehaviorRuleService(session) try: rule = await service.create_rule(tenant_id, body) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) return await service.rule_to_info_dict(rule) @router.get("/behavior-rules/{rule_id}") async def get_behavior_rule( rule_id: uuid.UUID, tenant_id: str = Depends(get_tenant_id), session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: """ [AC-AISVC-85] Get behavior rule detail. """ logger.info(f"[AC-AISVC-85] Getting behavior rule for tenant={tenant_id}, id={rule_id}") service = BehaviorRuleService(session) rule = await service.get_rule(tenant_id, rule_id) if not rule: raise HTTPException(status_code=404, detail="Behavior rule not found") return await service.rule_to_info_dict(rule) @router.put("/behavior-rules/{rule_id}") async def update_behavior_rule( rule_id: uuid.UUID, body: BehaviorRuleUpdate, tenant_id: str = Depends(get_tenant_id), session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: """ [AC-AISVC-85] Update a behavior rule. """ valid_categories = ["compliance", "tone", "boundary", "custom"] if body.category is not None and body.category not in valid_categories: raise HTTPException( status_code=400, detail=f"Invalid category. Must be one of: {valid_categories}" ) logger.info(f"[AC-AISVC-85] Updating behavior rule for tenant={tenant_id}, id={rule_id}") service = BehaviorRuleService(session) try: rule = await service.update_rule(tenant_id, rule_id, body) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) if not rule: raise HTTPException(status_code=404, detail="Behavior rule not found") return await service.rule_to_info_dict(rule) @router.delete("/behavior-rules/{rule_id}", status_code=204) async def delete_behavior_rule( rule_id: uuid.UUID, tenant_id: str = Depends(get_tenant_id), session: AsyncSession = Depends(get_session), ) -> None: """ [AC-AISVC-85] Delete a behavior rule. """ logger.info(f"[AC-AISVC-85] Deleting behavior rule for tenant={tenant_id}, id={rule_id}") service = BehaviorRuleService(session) success = await service.delete_rule(tenant_id, rule_id) if not success: raise HTTPException(status_code=404, detail="Behavior rule not found") class GuardrailTestRequest(BaseModel): """Request body for guardrail testing.""" testTexts: list[str] @router.post("/test") async def test_guardrail( body: GuardrailTestRequest, tenant_id: str = Depends(get_tenant_id), session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: """ [AC-AISVC-105] Test forbidden word detection and filtering. This endpoint tests texts against the tenant's forbidden words without modifying any database state. It returns: - Detection results for each text - Filtered text (with mask/replace applied) - Summary statistics """ logger.info( f"[AC-AISVC-105] Testing guardrail for tenant={tenant_id}, " f"texts_count={len(body.testTexts)}" ) tester = GuardrailTester(session) result = await tester.test_guardrail(tenant_id, body.testTexts) return result.to_dict()