feat(guardrails): añade NeMo stub, CompositeEngine paralelo y factory
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
"""Engine que ejecuta varios sub-engines en paralelo y agrega resultados."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from agentforge_core.domain.guardrail import GuardrailViolation
|
||||
from agentforge_core.domain.policy import PolicyDefinition
|
||||
from agentforge_core.guardrails.base import GuardrailEngine
|
||||
|
||||
|
||||
class CompositeGuardrailEngine:
|
||||
"""Compone varios GuardrailEngine y agrega sus violaciones."""
|
||||
|
||||
name = "composite"
|
||||
|
||||
def __init__(self, engines: list[GuardrailEngine]) -> None:
|
||||
if not engines:
|
||||
raise ValueError("CompositeGuardrailEngine requiere al menos un engine.")
|
||||
self._engines = engines
|
||||
|
||||
async def validate_input(
|
||||
self, payload: str, policy: PolicyDefinition, trace_id: UUID
|
||||
) -> list[GuardrailViolation]:
|
||||
results = await asyncio.gather(
|
||||
*(e.validate_input(payload, policy, trace_id) for e in self._engines)
|
||||
)
|
||||
return [v for sub in results for v in sub]
|
||||
|
||||
async def validate_output(
|
||||
self, payload: dict[str, Any], policy: PolicyDefinition, trace_id: UUID
|
||||
) -> list[GuardrailViolation]:
|
||||
results = await asyncio.gather(
|
||||
*(e.validate_output(payload, policy, trace_id) for e in self._engines)
|
||||
)
|
||||
return [v for sub in results for v in sub]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Factory que ensambla el GuardrailEngine según settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agentforge_core.config import Settings
|
||||
from agentforge_core.guardrails.base import GuardrailEngine
|
||||
from agentforge_core.guardrails.composite import CompositeGuardrailEngine
|
||||
from agentforge_core.guardrails.guardrails_ai import GuardrailsAIEngine
|
||||
from agentforge_core.guardrails.nemo import NeMoGuardrailsEngine
|
||||
|
||||
|
||||
def build_guardrail_engine(settings: Settings) -> GuardrailEngine:
|
||||
"""Ensambla el engine: GuardrailsAIEngine siempre; NeMo si está habilitado."""
|
||||
engines: list[GuardrailEngine] = [GuardrailsAIEngine()]
|
||||
if settings.guardrails_nemo_enabled:
|
||||
engines.append(
|
||||
NeMoGuardrailsEngine(
|
||||
allowed_keywords=[
|
||||
"sip",
|
||||
"ims",
|
||||
"cscf",
|
||||
"sbc",
|
||||
"mos",
|
||||
"hss",
|
||||
"registro",
|
||||
"incidente",
|
||||
"voz",
|
||||
"codec",
|
||||
"rollback",
|
||||
],
|
||||
)
|
||||
)
|
||||
return CompositeGuardrailEngine(engines)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Stub de NeMo Guardrails. Solo se carga si GUARDRAILS_NEMO_ENABLED=true.
|
||||
|
||||
En MVP se entrega como capa adicional de topical rails. La integración Colang
|
||||
completa queda documentada en docs/futuro.md como evolución natural.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from agentforge_core.domain.guardrail import GuardrailViolation
|
||||
from agentforge_core.domain.policy import PolicyDefinition
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class NeMoGuardrailsEngine:
|
||||
"""Implementación mínima: detecta off-topic vía heurística básica.
|
||||
|
||||
Cuando se active la integración Colang real, este engine cargará un YAML
|
||||
de configuración y delegará a ``nemoguardrails.LLMRails``. Por ahora aplica
|
||||
una regla simple: si el input no menciona ninguno de los keywords de
|
||||
``allowed_keywords``, marca off-topic con severity warning.
|
||||
"""
|
||||
|
||||
name = "nemo"
|
||||
|
||||
def __init__(self, allowed_keywords: list[str] | None = None) -> None:
|
||||
self._allowed = [k.lower() for k in (allowed_keywords or [])]
|
||||
|
||||
async def validate_input(
|
||||
self, payload: str, policy: PolicyDefinition, trace_id: UUID
|
||||
) -> list[GuardrailViolation]:
|
||||
if not self._allowed:
|
||||
return []
|
||||
lower = payload.lower()
|
||||
if any(k in lower for k in self._allowed):
|
||||
return []
|
||||
log.info("nemo_offtopic", trace_id=str(trace_id))
|
||||
return [
|
||||
GuardrailViolation(
|
||||
trace_id=trace_id,
|
||||
timestamp=datetime.now(UTC),
|
||||
stage="input",
|
||||
validator="NeMoTopicalRails",
|
||||
severity="warning",
|
||||
message="Input fuera de los temas permitidos.",
|
||||
blocked=False,
|
||||
)
|
||||
]
|
||||
|
||||
async def validate_output(
|
||||
self, payload: dict[str, Any], policy: PolicyDefinition, trace_id: UUID
|
||||
) -> list[GuardrailViolation]:
|
||||
return []
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Tests del CompositeGuardrailEngine."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from agentforge_core.domain.policy import PolicyDefinition
|
||||
from agentforge_core.guardrails.composite import CompositeGuardrailEngine
|
||||
from agentforge_core.guardrails.guardrails_ai import GuardrailsAIEngine
|
||||
from agentforge_core.guardrails.nemo import NeMoGuardrailsEngine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def policy() -> PolicyDefinition:
|
||||
return PolicyDefinition(
|
||||
name="t",
|
||||
version="v1",
|
||||
description="t",
|
||||
input_validators=[],
|
||||
output_validators=[],
|
||||
)
|
||||
|
||||
|
||||
async def test_composite_agrega_violaciones_de_todos(policy: PolicyDefinition) -> None:
|
||||
engine = CompositeGuardrailEngine(
|
||||
[GuardrailsAIEngine(), NeMoGuardrailsEngine(allowed_keywords=["sip"])]
|
||||
)
|
||||
violations = await engine.validate_input("texto sin tema", policy, uuid4())
|
||||
assert isinstance(violations, list)
|
||||
# NeMo debe marcar off-topic (warning, no bloqueo)
|
||||
assert any(v.validator == "NeMoTopicalRails" for v in violations)
|
||||
|
||||
|
||||
async def test_composite_no_agrega_si_match_topic(policy: PolicyDefinition) -> None:
|
||||
engine = CompositeGuardrailEngine(
|
||||
[GuardrailsAIEngine(), NeMoGuardrailsEngine(allowed_keywords=["sip"])]
|
||||
)
|
||||
violations = await engine.validate_input("incidente sip en cscf", policy, uuid4())
|
||||
assert not [v for v in violations if v.validator == "NeMoTopicalRails"]
|
||||
|
||||
|
||||
def test_composite_rechaza_lista_vacia() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
CompositeGuardrailEngine([])
|
||||
Reference in New Issue
Block a user