2026-02-27 08:03:39 +00:00
|
|
|
"""
|
|
|
|
|
Guardrail Management API.
|
|
|
|
|
[AC-AISVC-78~AC-AISVC-85] Forbidden words and behavior rules CRUD endpoints.
|
feat(v0.7.0-window2): implement flow simulation and guardrail testing/monitoring
Refs: AC-AISVC-101, AC-AISVC-102, AC-AISVC-103, AC-AISVC-104, AC-AISVC-105, AC-AISVC-106, AC-AISVC-107
Refs: AC-ASA-59, AC-ASA-60, AC-ASA-61, AC-ASA-62, AC-ASA-63, AC-ASA-64
Backend changes:
- New: ai-service/app/services/flow/tester.py (ScriptFlowTester)
- New: ai-service/app/services/guardrail/tester.py (GuardrailTester)
- New: ai-service/app/services/monitoring/flow_monitor.py (FlowMonitor)
- New: ai-service/app/services/monitoring/guardrail_monitor.py (GuardrailMonitor)
- Modified: ai-service/app/api/admin/script_flows.py (add POST /{flowId}/simulate)
- Modified: ai-service/app/api/admin/guardrails.py (add POST /test)
- Modified: ai-service/app/api/admin/monitoring.py (add flow/guardrail stats endpoints)
Frontend changes:
- New: SimulateDialog.vue (flow simulation dialog)
- New: TestDialog.vue (guardrail test dialog)
- New: ScriptFlows.vue (flow monitoring page)
- New: Guardrails.vue (guardrail monitoring page)
- Extended: API services (monitoring.ts, script-flow.ts, guardrail.ts)
- Updated: Router with new monitoring routes
2026-02-27 15:11:59 +00:00
|
|
|
[AC-AISVC-105] Guardrail testing endpoint.
|
2026-02-27 08:03:39 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import uuid
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
feat(v0.7.0-window2): implement flow simulation and guardrail testing/monitoring
Refs: AC-AISVC-101, AC-AISVC-102, AC-AISVC-103, AC-AISVC-104, AC-AISVC-105, AC-AISVC-106, AC-AISVC-107
Refs: AC-ASA-59, AC-ASA-60, AC-ASA-61, AC-ASA-62, AC-ASA-63, AC-ASA-64
Backend changes:
- New: ai-service/app/services/flow/tester.py (ScriptFlowTester)
- New: ai-service/app/services/guardrail/tester.py (GuardrailTester)
- New: ai-service/app/services/monitoring/flow_monitor.py (FlowMonitor)
- New: ai-service/app/services/monitoring/guardrail_monitor.py (GuardrailMonitor)
- Modified: ai-service/app/api/admin/script_flows.py (add POST /{flowId}/simulate)
- Modified: ai-service/app/api/admin/guardrails.py (add POST /test)
- Modified: ai-service/app/api/admin/monitoring.py (add flow/guardrail stats endpoints)
Frontend changes:
- New: SimulateDialog.vue (flow simulation dialog)
- New: TestDialog.vue (guardrail test dialog)
- New: ScriptFlows.vue (flow monitoring page)
- New: Guardrails.vue (guardrail monitoring page)
- Extended: API services (monitoring.ts, script-flow.ts, guardrail.ts)
- Updated: Router with new monitoring routes
2026-02-27 15:11:59 +00:00
|
|
|
from pydantic import BaseModel
|
2026-02-27 08:03:39 +00:00
|
|
|
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
|
feat(v0.7.0-window2): implement flow simulation and guardrail testing/monitoring
Refs: AC-AISVC-101, AC-AISVC-102, AC-AISVC-103, AC-AISVC-104, AC-AISVC-105, AC-AISVC-106, AC-AISVC-107
Refs: AC-ASA-59, AC-ASA-60, AC-ASA-61, AC-ASA-62, AC-ASA-63, AC-ASA-64
Backend changes:
- New: ai-service/app/services/flow/tester.py (ScriptFlowTester)
- New: ai-service/app/services/guardrail/tester.py (GuardrailTester)
- New: ai-service/app/services/monitoring/flow_monitor.py (FlowMonitor)
- New: ai-service/app/services/monitoring/guardrail_monitor.py (GuardrailMonitor)
- Modified: ai-service/app/api/admin/script_flows.py (add POST /{flowId}/simulate)
- Modified: ai-service/app/api/admin/guardrails.py (add POST /test)
- Modified: ai-service/app/api/admin/monitoring.py (add flow/guardrail stats endpoints)
Frontend changes:
- New: SimulateDialog.vue (flow simulation dialog)
- New: TestDialog.vue (guardrail test dialog)
- New: ScriptFlows.vue (flow monitoring page)
- New: Guardrails.vue (guardrail monitoring page)
- Extended: API services (monitoring.ts, script-flow.ts, guardrail.ts)
- Updated: Router with new monitoring routes
2026-02-27 15:11:59 +00:00
|
|
|
from app.services.guardrail.tester import GuardrailTester
|
2026-02-27 08:03:39 +00:00
|
|
|
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")
|
feat(v0.7.0-window2): implement flow simulation and guardrail testing/monitoring
Refs: AC-AISVC-101, AC-AISVC-102, AC-AISVC-103, AC-AISVC-104, AC-AISVC-105, AC-AISVC-106, AC-AISVC-107
Refs: AC-ASA-59, AC-ASA-60, AC-ASA-61, AC-ASA-62, AC-ASA-63, AC-ASA-64
Backend changes:
- New: ai-service/app/services/flow/tester.py (ScriptFlowTester)
- New: ai-service/app/services/guardrail/tester.py (GuardrailTester)
- New: ai-service/app/services/monitoring/flow_monitor.py (FlowMonitor)
- New: ai-service/app/services/monitoring/guardrail_monitor.py (GuardrailMonitor)
- Modified: ai-service/app/api/admin/script_flows.py (add POST /{flowId}/simulate)
- Modified: ai-service/app/api/admin/guardrails.py (add POST /test)
- Modified: ai-service/app/api/admin/monitoring.py (add flow/guardrail stats endpoints)
Frontend changes:
- New: SimulateDialog.vue (flow simulation dialog)
- New: TestDialog.vue (guardrail test dialog)
- New: ScriptFlows.vue (flow monitoring page)
- New: Guardrails.vue (guardrail monitoring page)
- Extended: API services (monitoring.ts, script-flow.ts, guardrail.ts)
- Updated: Router with new monitoring routes
2026-02-27 15:11:59 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|