From af8036c2d2fe3bf2eb38c263dd05b9b207211a0d Mon Sep 17 00:00:00 2001 From: Juan Date: Sun, 10 May 2026 10:51:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(guardrails):=20a=C3=B1ade=20NeMo=20stub,?= =?UTF-8?q?=20CompositeEngine=20paralelo=20y=20factory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../agentforge_core/guardrails/composite.py | 38 ++++++++++++ .../src/agentforge_core/guardrails/factory.py | 33 +++++++++++ core/src/agentforge_core/guardrails/nemo.py | 59 +++++++++++++++++++ tests/unit/test_guardrails_composite.py | 44 ++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 core/src/agentforge_core/guardrails/composite.py create mode 100644 core/src/agentforge_core/guardrails/factory.py create mode 100644 core/src/agentforge_core/guardrails/nemo.py create mode 100644 tests/unit/test_guardrails_composite.py diff --git a/core/src/agentforge_core/guardrails/composite.py b/core/src/agentforge_core/guardrails/composite.py new file mode 100644 index 0000000..4f6b510 --- /dev/null +++ b/core/src/agentforge_core/guardrails/composite.py @@ -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] diff --git a/core/src/agentforge_core/guardrails/factory.py b/core/src/agentforge_core/guardrails/factory.py new file mode 100644 index 0000000..d198257 --- /dev/null +++ b/core/src/agentforge_core/guardrails/factory.py @@ -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) diff --git a/core/src/agentforge_core/guardrails/nemo.py b/core/src/agentforge_core/guardrails/nemo.py new file mode 100644 index 0000000..7d064af --- /dev/null +++ b/core/src/agentforge_core/guardrails/nemo.py @@ -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 [] diff --git a/tests/unit/test_guardrails_composite.py b/tests/unit/test_guardrails_composite.py new file mode 100644 index 0000000..9229f22 --- /dev/null +++ b/tests/unit/test_guardrails_composite.py @@ -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([])