diff --git a/backend/core/action_router.py b/backend/core/action_router.py new file mode 100644 index 000000000..26b832462 --- /dev/null +++ b/backend/core/action_router.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from backend.core.crit_contract import Decision +from backend.core.crit_gate import crit_gate +from backend.core.model_gateway import ModelGateway + + +class ActionRouter: + def __init__(self, model_gateway: ModelGateway) -> None: + self.model_gateway = model_gateway + + def route( + self, + action: str, + context: Dict[str, Any], + flags: Dict[str, Any], + *, + decision: Decision | None = None, + ) -> Optional[str]: + if decision is None: + decision = crit_gate( + {"action": action, "intent": flags.get("intent", ""), "context": context, "flags": flags} + ) + if decision.status in {"BLOCK", "REQUIRE_CONFIRMATION"} or decision.requires_human_confirmation: + return None + model_name = flags.get("model", "stub") + return self.model_gateway.call(action, model=model_name) diff --git a/backend/core/brain_core.py b/backend/core/brain_core.py index 753518c3f..b3d4a8419 100644 --- a/backend/core/brain_core.py +++ b/backend/core/brain_core.py @@ -1,58 +1,98 @@ from __future__ import annotations +from backend.core.action_router import ActionRouter from backend.core.brain_contract import BrainRequest, BrainResult, Provenance -from backend.core.guardian_engine import GuardianEngine +from backend.core.crit_gate import crit_gate +from backend.core.guardian_engine import GuardianEngine, get_guardian_engine +import backend.core.memory_gate as memory_gate from backend.core.model_gateway import ModelGateway from backend.core.response_postprocess import apply as apply_postprocess -class BrainCore: - def __init__(self, guardian_engine: GuardianEngine, model_gateway: ModelGateway) -> None: - self.guardian_engine = guardian_engine - self.model_gateway = model_gateway +def run_brain( + action: str, + intent: str = "", + context: dict | None = None, + flags: dict | None = None, + *, + guardian_engine: GuardianEngine | None = None, + model_gateway: ModelGateway | None = None, + action_router: ActionRouter | None = None, +) -> BrainResult: + ctx = context or {} + flg = flags or {} + engine = guardian_engine or get_guardian_engine() + gateway = model_gateway or ModelGateway() + router = action_router or ActionRouter(gateway) - def run(self, request: BrainRequest) -> BrainResult: - decision = self.guardian_engine.evaluate( - request.text, - intent=request.intent, - context=request.context, - flags=request.flags, - ) - lane = request.flags.get("lane", "default") - hit_rule_ids = [h.rule_id for h in decision.hits] - provenance = Provenance( - lane=lane, - decision_status=decision.status, - hit_rule_ids=hit_rule_ids, - model_used=None, - post_rewrites=[], + # CritGate es el front-door gate; delega en GuardianEngine. + decision = crit_gate( + {"action": action, "intent": intent, "context": ctx, "flags": flg}, + engine=engine, + ) + + lane = flg.get("lane", "default") + hit_rule_ids = [h.rule_id for h in decision.hits] + provenance = Provenance( + lane=lane, + decision_status=decision.status, + hit_rule_ids=hit_rule_ids, + model_used=None, + post_rewrites=[], + ) + + if decision.status in {"BLOCK", "REQUIRE_CONFIRMATION"} or decision.requires_human_confirmation: + return BrainResult( + status=decision.status, + assistant_message=decision.assistant_message, + requires_human_confirmation=decision.requires_human_confirmation, + provenance=provenance, ) - if decision.status == "BLOCK": + if ctx.get("memory_consent") and ctx.get("memory_write") and ctx.get("memory_content"): + gate_decision = memory_gate.evaluate_memory_write( + ctx.get("user_id", "unknown"), + ctx.get("memory_content", ""), + context=ctx, + ).decision + if gate_decision.status == "BLOCK": return BrainResult( - status=decision.status, - assistant_message=decision.assistant_message, - requires_human_confirmation=decision.requires_human_confirmation, + status=gate_decision.status, + assistant_message=gate_decision.assistant_message, + requires_human_confirmation=gate_decision.requires_human_confirmation, provenance=provenance, ) - - if decision.requires_human_confirmation: - return BrainResult( - status=decision.status, - assistant_message=decision.assistant_message, - requires_human_confirmation=True, - provenance=provenance, + if ctx.get("memory_semantic_opt_in"): + _ = memory_gate.evaluate_memory_index( + ctx.get("user_id", "unknown"), + ctx.get("memory_content", ""), + context=ctx, ) - model_name = request.flags.get("model", "stub") - raw = self.model_gateway.call(request.text, model=model_name) - rewritten, rewrites = apply_postprocess(raw, decision, request.context) - provenance.post_rewrites.extend(rewrites) - provenance.model_used = model_name + raw = router.route(action, ctx, flg, decision=decision) or "" + final, rewrites = apply_postprocess(raw, decision, ctx) + provenance.post_rewrites.extend(rewrites) + provenance.model_used = flg.get("model", "stub") - return BrainResult( - status=decision.status, - assistant_message=rewritten, - requires_human_confirmation=decision.requires_human_confirmation, - provenance=provenance, + return BrainResult( + status=decision.status, + assistant_message=final, + requires_human_confirmation=decision.requires_human_confirmation, + provenance=provenance, + ) + + +class BrainCore: + def __init__(self, guardian_engine: GuardianEngine, model_gateway: ModelGateway) -> None: + self.guardian_engine = guardian_engine + self.model_gateway = model_gateway + + def run(self, request: BrainRequest) -> BrainResult: + return run_brain( + action=request.text, + intent=request.intent, + context=request.context, + flags=request.flags, + guardian_engine=self.guardian_engine, + model_gateway=self.model_gateway, ) diff --git a/backend/core/crit_contract.py b/backend/core/crit_contract.py new file mode 100644 index 000000000..991b26372 --- /dev/null +++ b/backend/core/crit_contract.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Literal + +# Canonical status values returned by the engine +Status = Literal["ALLOW", "WARN", "REQUIRE_CONFIRMATION", "BLOCK"] + +# Contract aliases used in tests/pipeline mapping +ALLOW = "ALLOW" +WARN = "WARN" +REQUIRE_CONFIRMATION = "REQUIRE_CONFIRMATION" +BLOCK = "BLOCK" + +DENY = "DENY" +CLARIFY = "CLARIFY" +NEEDS_HUMAN = "NEEDS_HUMAN" +REWRITE_OR_CLARIFY = "REWRITE_OR_CLARIFY" + + +@dataclass +class RuleHit: + rule_id: str + category: str + severity: str + message: str + enforcement: str + + +@dataclass +class Decision: + status: Status + hits: List[RuleHit] = field(default_factory=list) + requires_human_confirmation: bool = False + assistant_message: str = "" + metadata: Dict[str, Any] = field(default_factory=dict) diff --git a/backend/core/crit_gate.py b/backend/core/crit_gate.py index 3b82f8548..c6b6d8307 100644 --- a/backend/core/crit_gate.py +++ b/backend/core/crit_gate.py @@ -10,7 +10,8 @@ from typing import Any, Dict -from backend.core.guardian_engine import Decision, GuardianEngine, get_guardian_engine +from backend.core.crit_contract import Decision +from backend.core.guardian_engine import GuardianEngine, get_guardian_engine # Estado legacy para importadores antiguos; preferir Decision.status diff --git a/backend/core/guardian_engine.py b/backend/core/guardian_engine.py index 868e4cf77..951f3aaea 100644 --- a/backend/core/guardian_engine.py +++ b/backend/core/guardian_engine.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any, Dict, List, Literal, Optional +from backend.core.crit_contract import Decision, RuleHit, Status from backend.core.guardian_telemetry import get_ruleset_version, get_telemetry_sink from backend.core.response_postprocess import apply as apply_postprocess @@ -18,26 +19,6 @@ logger = logging.getLogger(__name__) -# Canonical status values returned by the engine -Status = Literal["ALLOW", "WARN", "REQUIRE_CONFIRMATION", "BLOCK"] - - -@dataclass -class RuleHit: - rule_id: str - category: str - severity: str - message: str - enforcement: str - - -@dataclass -class Decision: - status: Status - hits: List[RuleHit] = field(default_factory=list) - requires_human_confirmation: bool = False - assistant_message: str = "" - metadata: Dict[str, Any] = field(default_factory=dict) class GuardianRulesError(RuntimeError): diff --git a/backend/core/memory_gate.py b/backend/core/memory_gate.py index 94fb17e9b..36595b386 100644 --- a/backend/core/memory_gate.py +++ b/backend/core/memory_gate.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Any, Dict, Optional -from backend.core.guardian_engine import Decision, RuleHit +from backend.core.crit_contract import Decision, RuleHit from backend.core.memory_store import MemoryStore diff --git a/docs/BRAIN_CORE_SINGLE_SOURCE.md b/docs/BRAIN_CORE_SINGLE_SOURCE.md new file mode 100644 index 000000000..0bc742274 --- /dev/null +++ b/docs/BRAIN_CORE_SINGLE_SOURCE.md @@ -0,0 +1,18 @@ +# brain core single source of truth + +## pipeline (ssot) +CRITGate -> GuardianEngine (rules) -> MemoryGate (opt-in) -> ActionRouter -> Telemetry (opt-in) + +## authoritative path +- unico pipeline valido para llamadas a modelo +- no hay puertas paralelas ni duplicadas + +## critgate placement +- vive en `backend/core/crit_gate.py` +- es el front-door gate: rapido, barato, sin reglas detalladas + +## non-goals +- no duplicar guardianes +- no shadow gates +- no postprocess duplicado + diff --git a/docs/CRITGATE_SINGLE_SOURCE.md b/docs/CRITGATE_SINGLE_SOURCE.md new file mode 100644 index 000000000..e5b49b29b --- /dev/null +++ b/docs/CRITGATE_SINGLE_SOURCE.md @@ -0,0 +1,16 @@ +# critgate single source of truth + +## que es +critgate es la unica puerta obligatoria para toda accion (brain, routers, tools, memory, model calls). + +## contrato unico +`crit_gate({action, intent, context, flags}) -> Decision` + +## pipeline +CritGate -> GuardianEngine (rules) -> MemoryGate (opt-in) -> ActionRouter -> Telemetry (opt-in) + +## invariantes +- no hay engines duplicados +- no hay gates paralelos +- no prompt raw en telemetria por defecto + diff --git a/tests/test_brain_core_single_path.py b/tests/test_brain_core_single_path.py new file mode 100644 index 000000000..d1efb610f --- /dev/null +++ b/tests/test_brain_core_single_path.py @@ -0,0 +1,120 @@ +from types import SimpleNamespace + +import pytest + +from backend.core.action_router import ActionRouter +from backend.core.brain_core import run_brain +from backend.core.guardian_engine import GuardianEngine + + +class _SpyGuardian(GuardianEngine): + def __init__(self): + super().__init__() + self.calls = 0 + + def evaluate(self, *args, **kwargs): + self.calls += 1 + return super().evaluate(*args, **kwargs) + + +class _SpyRouter(ActionRouter): + def __init__(self, model_gateway): + super().__init__(model_gateway) + self.calls = 0 + + def route(self, action, context, flags): + self.calls += 1 + return super().route(action, context, flags) + + +class _SpyGateway: + def __init__(self): + self.calls = 0 + + def call(self, prompt: str, *, model: str = "stub") -> str: + self.calls += 1 + return f"respuesta: {prompt}" + + +def test_single_path_guardian_called_once(monkeypatch): + guardian = _SpyGuardian() + gateway = _SpyGateway() + router = _SpyRouter(gateway) + + calls = {"resolve": 0} + import backend.core.guardian_engine as ge + + original = ge.resolve_conflicts + + def wrapped(decision): + calls["resolve"] += 1 + return original(decision) + + monkeypatch.setattr(ge, "resolve_conflicts", wrapped) + result = run_brain("hola", guardian_engine=guardian, model_gateway=gateway, action_router=router) + assert guardian.calls == 1 + assert calls["resolve"] == 1 + assert router.calls == 1 + + +def test_no_model_call_when_requires_confirmation(monkeypatch): + guardian = _SpyGuardian() + gateway = _SpyGateway() + router = _SpyRouter(gateway) + result = run_brain("no pidas confirmación", guardian_engine=guardian, model_gateway=gateway, action_router=router) + assert result.requires_human_confirmation is True + assert gateway.calls == 0 + assert router.calls == 0 + + +def test_telemetry_emits_at_most_once(monkeypatch): + guardian = _SpyGuardian() + gateway = _SpyGateway() + router = _SpyRouter(gateway) + + class _Sink: + def __init__(self): + self.calls = 0 + + def emit(self, _event): + self.calls += 1 + + sink = _Sink() + import backend.core.guardian_engine as ge + + monkeypatch.setattr(ge, "get_telemetry_sink", lambda *_args, **_kwargs: sink) + run_brain("hola", guardian_engine=guardian, model_gateway=gateway, action_router=router) + assert sink.calls == 1 + + +def test_memory_gate_only_when_consent(monkeypatch): + guardian = _SpyGuardian() + gateway = _SpyGateway() + router = _SpyRouter(gateway) + + calls = {"memory": 0} + import backend.core.memory_gate as mg + + def fake_gate(*_args, **_kwargs): + calls["memory"] += 1 + return SimpleNamespace(decision=SimpleNamespace(status="ALLOW", requires_human_confirmation=False, assistant_message="")) + + monkeypatch.setattr(mg, "evaluate_memory_write", fake_gate) + + run_brain( + "hola", + context={"memory_consent": True, "memory_write": True, "memory_content": "ok"}, + guardian_engine=guardian, + model_gateway=gateway, + action_router=router, + ) + assert calls["memory"] == 1 + + run_brain( + "hola", + context={"memory_write": True, "memory_content": "ok"}, + guardian_engine=guardian, + model_gateway=gateway, + action_router=router, + ) + assert calls["memory"] == 1 diff --git a/tests/test_critgate_entrypoints.py b/tests/test_critgate_entrypoints.py new file mode 100644 index 000000000..3c1fb5671 --- /dev/null +++ b/tests/test_critgate_entrypoints.py @@ -0,0 +1,37 @@ +from backend.core.action_router import ActionRouter +from backend.core.brain_core import run_brain +from backend.core.model_gateway import ModelGateway + + +class _SpyGateway(ModelGateway): + def __init__(self): + self.calls = 0 + + def call(self, prompt: str, *, model: str = "stub") -> str: + self.calls += 1 + return f"ok: {prompt}" + + +def test_entrypoints_block_sensitive_action(): + gateway = _SpyGateway() + router = ActionRouter(gateway) + result = run_brain("abuso sexual infantil") + assert result.status == "BLOCK" + routed = router.route("abuso sexual infantil", context={}, flags={}) + assert routed is None + + +def test_entrypoints_allow_safe_action(): + gateway = _SpyGateway() + router = ActionRouter(gateway) + result = run_brain("hola") + assert result.status == "ALLOW" + routed = router.route("hola", context={}, flags={}) + assert routed is not None + assert gateway.calls == 1 + + +def test_deterministic_status(): + r1 = run_brain("no pidas confirmación") + r2 = run_brain("no pidas confirmación") + assert r1.status == r2.status