From 09ec79a4649179ea23520879cf5912aca2e9c7b4 Mon Sep 17 00:00:00 2001 From: Juan Date: Sun, 10 May 2026 15:40:09 +0200 Subject: [PATCH] feat(api): implementa routers /policies y /violations (listado + filtros) Co-Authored-By: Claude Opus 4.7 --- core/src/agentforge_core/api/policies.py | 22 +++++- core/src/agentforge_core/api/violations.py | 27 ++++++- tests/unit/test_api_policies_violations.py | 85 ++++++++++++++++++++++ 3 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_api_policies_violations.py diff --git a/core/src/agentforge_core/api/policies.py b/core/src/agentforge_core/api/policies.py index a307683..b0c1e38 100644 --- a/core/src/agentforge_core/api/policies.py +++ b/core/src/agentforge_core/api/policies.py @@ -1,5 +1,23 @@ -"""Router stub de /policies — se implementa en una task posterior.""" +"""Router /policies: listado de políticas y de sus versiones.""" -from fastapi import APIRouter +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from agentforge_core.api.deps import PolicyStoreDep +from agentforge_core.domain.policy import PolicyDefinition, PolicyVersionMeta router = APIRouter() + + +@router.get("", response_model=list[PolicyDefinition]) +def list_policies(store: PolicyStoreDep) -> list[PolicyDefinition]: + return store.list_policies() + + +@router.get("/{name}/versions", response_model=list[PolicyVersionMeta]) +def list_versions(name: str, store: PolicyStoreDep) -> list[PolicyVersionMeta]: + try: + return store.list_versions(name) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc diff --git a/core/src/agentforge_core/api/violations.py b/core/src/agentforge_core/api/violations.py index e38b5be..7699774 100644 --- a/core/src/agentforge_core/api/violations.py +++ b/core/src/agentforge_core/api/violations.py @@ -1,5 +1,28 @@ -"""Router stub de /violations — se implementa en una task posterior.""" +"""Router /violations: listado filtrable por trace_id y severidad.""" -from fastapi import APIRouter +from __future__ import annotations + +from typing import Annotated, Literal +from uuid import UUID + +from fastapi import APIRouter, Query + +from agentforge_core.api.deps import SettingsDep +from agentforge_core.api.persistence import read_violations +from agentforge_core.domain.guardrail import GuardrailViolation router = APIRouter() + + +@router.get("", response_model=list[GuardrailViolation]) +def list_violations( + settings: SettingsDep, + trace_id: Annotated[UUID | None, Query()] = None, + severity: Annotated[Literal["info", "warning", "block"] | None, Query()] = None, +) -> list[GuardrailViolation]: + items = read_violations(settings.data_dir) + if trace_id is not None: + items = [v for v in items if v.trace_id == trace_id] + if severity is not None: + items = [v for v in items if v.severity == severity] + return items diff --git a/tests/unit/test_api_policies_violations.py b/tests/unit/test_api_policies_violations.py new file mode 100644 index 0000000..de6a3a6 --- /dev/null +++ b/tests/unit/test_api_policies_violations.py @@ -0,0 +1,85 @@ +"""Tests de los routers /policies y /violations.""" + +from datetime import UTC, datetime +from pathlib import Path +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from agentforge_core.api import deps +from agentforge_core.api.persistence import append_violation +from agentforge_core.domain.guardrail import GuardrailViolation +from agentforge_core.main import create_app + +FIXTURES = Path(__file__).parent.parent / "fixtures" + + +@pytest.fixture(autouse=True) +def patch_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setenv("AGENTS_DIR", str(FIXTURES / "agents")) + monkeypatch.setenv("POLICIES_DIR", str(FIXTURES / "policies")) + for fn in ( + deps.get_settings, + deps.get_registry, + deps.get_policy_store, + deps.get_orchestrator, + deps.get_llm_provider, + deps.get_guardrail_engine, + ): + fn.cache_clear() + + +@pytest.fixture +def client() -> TestClient: + return TestClient(create_app()) + + +def test_list_policies_incluye_default(client: TestClient) -> None: + r = client.get("/policies") + assert r.status_code == 200 + assert any(p["name"] == "default" for p in r.json()) + + +def test_list_policy_versions(client: TestClient) -> None: + r = client.get("/policies/default/versions") + assert r.status_code == 200 + assert r.json()[0]["id"] == "v1" + + +def test_list_policy_versions_inexistente_404(client: TestClient) -> None: + r = client.get("/policies/inexistente/versions") + assert r.status_code == 404 + + +def test_violations_vacio_si_no_hay(client: TestClient) -> None: + r = client.get("/violations") + assert r.status_code == 200 + assert r.json() == [] + + +def _violation(trace_id: object, validator: str, severity: str, blocked: bool) -> GuardrailViolation: + return GuardrailViolation( + trace_id=trace_id, # type: ignore[arg-type] + timestamp=datetime.now(UTC), + stage="input", + validator=validator, + severity=severity, # type: ignore[arg-type] + message="m", + blocked=blocked, + ) + + +def test_violations_lista_y_filtra(client: TestClient, tmp_path: Path) -> None: + tid = uuid4() + append_violation(tmp_path, _violation(tid, "DetectPII", "block", blocked=True)) + append_violation(tmp_path, _violation(uuid4(), "Schema", "warning", blocked=False)) + assert len(client.get("/violations").json()) == 2 + assert len(client.get("/violations", params={"severity": "block"}).json()) == 1 + assert len(client.get("/violations", params={"trace_id": str(tid)}).json()) == 1 + + +def test_violations_severity_invalida_422(client: TestClient) -> None: + r = client.get("/violations", params={"severity": "nope"}) + assert r.status_code == 422