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

332 lines
10 KiB
Python

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