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