cambios profundos

This commit is contained in:
2026-05-27 16:29:02 +02:00
parent d95ed15766
commit 34b11dfc66
29 changed files with 594 additions and 11102 deletions
+12 -15
View File
@@ -1,26 +1,23 @@
# Forja — Arquitectura técnica # Forja — Arquitectura técnica
> Documento "vivo" con las decisiones técnicas. El spec original está en > Documento vivo con las decisiones técnicas.
> [`docs/superpowers/specs/2026-05-09- forja-design.md`](docs/superpowers/specs/2026-05-09- forja-design.md).
## Visión general ## Visión general
Two-tier: Forja es un **único servicio FastAPI** (:8000):
- `forja-core` — FastAPI 8000. Dominio de gobierno, runtime LangGraph, - Todo el dominio de gobierno, runtime LangGraph, guardrails y persistencia.
guardrails y persistencia. - UI embebida (HTMX) para uso humano + API REST completa bajo `/api`.
- `forja-dashboard` — Streamlit 8501. Cliente HTTP del core.
## Diagrama ## Diagrama
``` ```
┌─────────────────────┐ HTTP/JSON ┌─────────────────────┐ ┌────────────────────────────────────────────────────────────┐
Dashboard (Stream.) │ ───────────► │ Core (FastAPI) forja-core :8000
└─────────────────────┘ └─────────┬───────────┘ │ UI HTMX (humanos) + REST API (/api) + dominio completo │
┌─────────────────────────┬────────┴──────┬────────────┐ │ LLM (strategy) │ Guardrails (strategy) │ LangGraph │
▼ ▼ ▼ ▼ │ │ │ + checkpoints│
LLM layer Guardrails layer LangGraph State └────────────────────────────────────────────────────────────┘
(Strategy pattern) (Strategy pattern) Runtime JSON+SQL+JSONL
``` ```
## Patrones aplicados ## Patrones aplicados
@@ -50,7 +47,7 @@ Se usa la API dinámica `interrupt(payload)` de LangGraph ≥0.2 dentro del
nodo `approve_gate`. Sólo pausa cuando hay acciones de alto riesgo nodo `approve_gate`. Sólo pausa cuando hay acciones de alto riesgo
(`risk_score >= threshold` o `requires_approval=True`). El cliente envía (`risk_score >= threshold` o `requires_approval=True`). El cliente envía
`Command(resume={"approved_action_ids": [...]})` vía `Command(resume={"approved_action_ids": [...]})` vía
`POST /executions/{trace_id}/approve`. `POST /api/executions/{trace_id}/approve`.
## Observabilidad ## Observabilidad
+24 -27
View File
@@ -7,30 +7,28 @@ ejecución controlada con runtime stateful, Human-in-the-Loop y observabilidad c
## Arquitectura ## Arquitectura
Forja es un **único servicio** (FastAPI en :8000) que expone:
- API REST completa bajo `/api` (agentes, políticas, ejecuciones, aprobaciones, violaciones).
- UI embebida moderna (HTMX + Tailwind + Jinja) en rutas amigables (`/agents`, `/run`, `/approvals`, `/history`, `/policies`).
``` ```
┌───────────────────────── docker-compose ──────────────────────────┐ ┌───────────────────────────────────────────────────────────────┐
forja-core :8000
│ ┌─────────────────────┐ HTTP/JSON ┌────────────────────┐ │ │ ┌─────────────────────────────────────────────────────────┐
│ │ forja-dashboard │ ────────────────► │ forja-core │ │ │ │ UI HTMX (navegador humano) │ API REST (/api/*)
│ Streamlit :8501 │ ◄──────────────── │ FastAPI :8000 │ └─────────────────────────────────────────────────────────┘
└─────────────────────┘ └────────────────────┘ Strategy factories (LLM / Guardrails / Registry)
LangGraph runtime + SQLite checkpoints (HITL real)
Strategy pattern para LLMProvider, GuardrailEngine, Registry. Persistencia: YAML versionado + JSONL + SQLite
│ Persistencia: YAML (defs versionadas), JSONL (logs), SQLite │ └───────────────────────────────────────────────────────────────┘
│ (checkpoints LangGraph + HITL). │
└────────────────────────────────────────────────────────────────────┘
``` ```
Detalles completos en [`ARCHITECTURE.md`](ARCHITECTURE.md). Además: Documentación viva:
- [`docs/walkthrough.html`](docs/walkthrough.html) — **walkthrough HTML autocontenido** - [`ARCHITECTURE.md`](ARCHITECTURE.md) — decisiones técnicas.
(de alto a bajo nivel, con diagramas). Ábrelo directamente en el navegador - [`docs/explicacion.md`](docs/explicacion.md) — explicación didáctica completa.
(`xdg-open docs/walkthrough.html`); no requiere servidor. - [`docs/componentes.md`](docs/componentes.md) — referencia de cableado a bajo nivel.
- [`docs/explicacion.md`](docs/explicacion.md) — explicación didáctica de extremo a - [`docs/futuro.md`](docs/futuro.md) — roadmap.
extremo (conceptos, recorrido por todos los módulos y sus interrelaciones, y el
viaje de una petición de principio a fin).
- [`docs/componentes.md`](docs/componentes.md) — referencia de cableado a bajo nivel
(grafo de dependencias de módulos, inyección de dependencias, firmas de los
contratos entre capas, cadenas de llamada de cada endpoint).
## Quickstart ## Quickstart
@@ -39,7 +37,7 @@ cp .env.example .env
docker compose up docker compose up
``` ```
Abre [http://localhost:8501](http://localhost:8501). Abre [http://localhost:8000](http://localhost:8000).
Funciona out-of-the-box (`LLM_PROVIDER=mock`, sin API keys). Si quieres usar Funciona out-of-the-box (`LLM_PROVIDER=mock`, sin API keys). Si quieres usar
Azure OpenAI real, edita `.env`. Azure OpenAI real, edita `.env`.
@@ -64,8 +62,8 @@ Azure OpenAI real, edita `.env`.
| LangGraph + HITL + checkpoints | `core/src/forja_core/runtime/` | | LangGraph + HITL + checkpoints | `core/src/forja_core/runtime/` |
| Observabilidad (trace + structlog) | `core/src/forja_core/observability/` | | Observabilidad (trace + structlog) | `core/src/forja_core/observability/` |
| LLM abstraction | `core/src/forja_core/llm/` | | LLM abstraction | `core/src/forja_core/llm/` |
| Editores gráficos (nuevo) | dashboard páginas Forjar Agente / Política | | UI embebida (HTMX) | `core/src/forja_core/web/` (ruta raíz) |
| API REST + dashboard | forja-core :8000 + forja-dashboard :8501 | | API REST completa | bajo `/api` en forja-core :8000 |
## Variables de entorno ## Variables de entorno
@@ -79,13 +77,12 @@ Ver [`docs/futuro.md`](docs/futuro.md).
``` ```
forja/ forja/
├── core/ servicio FastAPI (gobernanza + runtime) ├── core/ servicio FastAPI único (API + UI HTMX embebida)
├── dashboard/ Streamlit (UI + editores gráficos)
├── agents/ definiciones de agentes (YAML versionados) ├── agents/ definiciones de agentes (YAML versionados)
├── policies/ políticas de guardrails (YAML versionados) ├── policies/ políticas de guardrails (YAML versionados)
├── data/ runtime state (gitignored) ├── data/ runtime state (gitignored)
├── tests/ ├── tests/
└── docs/ └── docs/ (explicacion.md, componentes.md, futuro.md, ARCHITECTURE.md)
``` ```
## Tests ## Tests
+8 -1
View File
@@ -216,12 +216,19 @@ async def approve_execution(
) -> AgentExecution: ) -> AgentExecution:
agent_def, policy = _resolve(registry, policies, settings.data_dir, trace_id) agent_def, policy = _resolve(registry, policies, settings.data_dir, trace_id)
await _ensure_awaiting(orchestrator, agent_def, policy, trace_id) await _ensure_awaiting(orchestrator, agent_def, policy, trace_id)
# Empty approved_action_ids from UI "Aprobar todo" → approve everything that was waiting
approved = body.approved_action_ids
if not approved:
# Try to resolve the actual ids from current snapshot (best effort)
snap = await orchestrator.snapshot(agent_def=agent_def, policy=policy, trace_id=trace_id)
if snap and snap.needs_human_for:
approved = [a.id for a in snap.needs_human_for]
execution = await orchestrator.resume( execution = await orchestrator.resume(
agent_def=agent_def, agent_def=agent_def,
policy=policy, policy=policy,
trace_id=trace_id, trace_id=trace_id,
decision={ decision={
"approved_action_ids": body.approved_action_ids, "approved_action_ids": approved,
"comment": body.comment, "comment": body.comment,
"rejected": False, "rejected": False,
}, },
+8 -8
View File
@@ -24,18 +24,18 @@ def create_app() -> FastAPI:
async def health() -> dict[str, str]: async def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
from forja_core.web import ui
from forja_core.api import agents, executions, policies, violations from forja_core.api import agents, executions, policies, violations
from forja_core.web import ui
# UI HTMX embebida (Opción A) — se registra primero para que las páginas # UI HTMX embebida (Opción A) en rutas amigables para humanos.
# tengan preferencia sobre los endpoints JSON cuando se accede desde navegador. # Toda la API REST vive bajo /api para eliminar ambigüedad y conflictos de ruta.
app.include_router(ui.router) app.include_router(ui.router)
app.include_router(agents.router, prefix="/agents", tags=["agents"]) app.include_router(agents.router, prefix="/api/agents", tags=["agents"])
app.include_router(executions.invoke_router, prefix="/agents", tags=["agents"]) app.include_router(executions.invoke_router, prefix="/api/agents", tags=["agents"])
app.include_router(executions.router, prefix="/executions", tags=["executions"]) app.include_router(executions.router, prefix="/api/executions", tags=["executions"])
app.include_router(policies.router, prefix="/policies", tags=["policies"]) app.include_router(policies.router, prefix="/api/policies", tags=["policies"])
app.include_router(violations.router, prefix="/violations", tags=["violations"]) app.include_router(violations.router, prefix="/api/violations", tags=["violations"])
return app return app
@@ -25,7 +25,7 @@
</div> </div>
{% if agent.category %} {% if agent.category %}
<div> <div>
<div class="text-zinc-400 text-xs mb-1">Categoría</div> <div class="text-zinc-400 text-xs mb-1">Category</div>
<div>{{ agent.category }}</div> <div>{{ agent.category }}</div>
</div> </div>
{% endif %} {% endif %}
@@ -58,7 +58,7 @@
<!-- System Prompt colapsado --> <!-- System Prompt colapsado -->
<details class="mb-3 group"> <details class="mb-3 group">
<summary class="cursor-pointer select-none text-sm font-medium px-4 py-2 bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 rounded-xl flex items-center justify-between"> <summary class="cursor-pointer select-none text-sm font-medium px-4 py-2 bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 rounded-xl flex items-center justify-between">
<span>Ver system prompt</span> <span>View system prompt</span>
<span class="text-xs text-zinc-500 group-open:hidden">expandir</span> <span class="text-xs text-zinc-500 group-open:hidden">expandir</span>
<span class="text-xs text-zinc-500 hidden group-open:inline">colapsar</span> <span class="text-xs text-zinc-500 hidden group-open:inline">colapsar</span>
</summary> </summary>
@@ -85,7 +85,7 @@
{% if agent.input_placeholder %} {% if agent.input_placeholder %}
<div><span class="text-zinc-500">Placeholder:</span> {{ agent.input_placeholder }}</div> <div><span class="text-zinc-500">Placeholder:</span> {{ agent.input_placeholder }}</div>
{% endif %} {% endif %}
<div><span class="text-zinc-500">Última actualización:</span> {{ agent.updated_at.strftime('%Y-%m-%d %H:%M') if agent.updated_at else '—' }}</div> <div><span class="text-zinc-500">Last updated:</span> {{ agent.updated_at.strftime('%Y-%m-%d %H:%M') if agent.updated_at else '—' }}</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<h1 class="text-3xl font-semibold mb-6">Agentes registrados</h1> <h1 class="text-3xl font-semibold mb-6">Registered Agents</h1>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Lista de agentes --> <!-- Lista de agentes -->
@@ -25,7 +25,7 @@
</div> </div>
</a> </a>
{% else %} {% else %}
<div class="px-4 py-6 text-zinc-400">No hay agentes registrados.</div> <div class="px-4 py-6 text-zinc-400">No agents registered.</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -35,7 +35,7 @@
<div id="agent-detail" class="border border-zinc-800 bg-zinc-900 rounded-xl p-6 h-full"> <div id="agent-detail" class="border border-zinc-800 bg-zinc-900 rounded-xl p-6 h-full">
<div class="h-full flex items-center justify-center"> <div class="h-full flex items-center justify-center">
<div class="text-zinc-400 text-sm text-center"> <div class="text-zinc-400 text-sm text-center">
Selecciona un agente de la lista para ver su configuración y comportamiento. Select an agent from the list to see its configuration and behavior.
</div> </div>
</div> </div>
</div> </div>
@@ -1,7 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<h1 class="text-3xl font-semibold mb-6">🤝 Aprobaciones Pendientes (HITL)</h1> <h1 class="text-3xl font-semibold mb-6">🤝 Pending Approvals (HITL)</h1>
<p class="text-sm text-zinc-400 mb-4 max-w-2xl">Executions with high-risk actions are paused here. Approve or reject so the graph can continue (or fail). The list updates after every action.</p>
<div class="space-y-4 max-w-4xl" id="approvals-list"> <div class="space-y-4 max-w-4xl" id="approvals-list">
{% for ex in pending %} {% for ex in pending %}
@@ -16,23 +17,27 @@
<div class="mt-3 flex gap-2"> <div class="mt-3 flex gap-2">
<button <button
hx-post="/approvals/{{ ex.trace_id }}/approve" hx-post="/api/executions/{{ ex.trace_id }}/approve"
hx-vals='{"approved_action_ids": [], "comment": "Aprobado desde UI"}'
hx-target="#approvals-list" hx-target="#approvals-list"
hx-swap="outerHTML" hx-swap="none"
hx-on::after-request="setTimeout(() => location.reload(), 400)"
class="px-3 py-1 text-xs bg-emerald-600 hover:bg-emerald-500 rounded"> class="px-3 py-1 text-xs bg-emerald-600 hover:bg-emerald-500 rounded">
Aprobar todo Aprobar todo
</button> </button>
<button <button
hx-post="/approvals/{{ ex.trace_id }}/reject" hx-post="/api/executions/{{ ex.trace_id }}/reject"
hx-vals='{"reason": "Rechazado desde interfaz web"}'
hx-target="#approvals-list" hx-target="#approvals-list"
hx-swap="outerHTML" hx-swap="none"
hx-on::after-request="setTimeout(() => location.reload(), 400)"
class="px-3 py-1 text-xs bg-red-600 hover:bg-red-500 rounded"> class="px-3 py-1 text-xs bg-red-600 hover:bg-red-500 rounded">
Rechazar Rechazar
</button> </button>
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="text-zinc-400">No hay ejecuciones esperando aprobación.</div> <div class="text-zinc-400">No executions awaiting approval.</div>
{% endfor %} {% endfor %}
</div> </div>
{% endblock %} {% endblock %}
+66 -22
View File
@@ -3,38 +3,82 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Forja • {{ title or "Gobernanza de Agentes IA" }}</title> <title>Forja • {{ title or "Cadena de Montaje" }}</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script>
// Tokyo Night Tailwind config (dark purple + yellow highlights)
function initializeTokyoNight() {
if (window.tailwind && window.tailwind.config) {
window.tailwind.config = {
theme: {
extend: {
colors: {
'tokyo': {
'bg': '#1a1b26',
'surface': '#24283b',
'surface2': '#1f2335',
'border': '#414868',
'purple': '#bb9af7',
'blue': '#7aa2f7',
'yellow': '#e0af68',
'green': '#9ece6a',
'red': '#f7768e',
'text': '#c0caf5',
'muted': '#565f89',
}
}
}
}
};
}
}
window.onload = initializeTokyoNight;
</script>
<script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js"></script> <script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js"></script>
<style> <style>
body { font-family: ui-sans-serif, system-ui, sans-serif; } body { font-family: ui-sans-serif, system-ui, sans-serif; }
.htmx-indicator { display: none; } .htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; } .htmx-request .htmx-indicator { display: inline; }
.htmx-request.htmx-indicator { display: inline; } .htmx-request.htmx-indicator { display: inline; }
/* Tokyo Night Theme - dark + purple + yellow highlights */
:root {
--tokyo-bg: #1a1b26;
--tokyo-surface: #24283b;
--tokyo-surface2: #1f2335;
--tokyo-border: #414868;
--tokyo-purple: #bb9af7;
--tokyo-blue: #7aa2f7;
--tokyo-yellow: #e0af68;
--tokyo-green: #9ece6a;
--tokyo-red: #f7768e;
--tokyo-text: #c0caf5;
--tokyo-muted: #565f89;
}
body {
background-color: var(--tokyo-bg);
color: var(--tokyo-text);
}
.tokyo-header {
background-color: var(--tokyo-surface);
border-color: var(--tokyo-border);
}
.tokyo-card {
background-color: var(--tokyo-surface);
border-color: var(--tokyo-border);
}
.tokyo-purple { color: var(--tokyo-purple); }
.tokyo-yellow { color: var(--tokyo-yellow); }
.tokyo-blue { color: var(--tokyo-blue); }
</style> </style>
</head> </head>
<body class="bg-zinc-950 text-zinc-200"> <body>
<div class="min-h-screen"> <div class="min-h-screen">
<header class="border-b border-zinc-800 bg-zinc-900"> <main class="max-w-6xl mx-auto px-6 py-8 text-tokyo-text">
<div class="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xl font-semibold tracking-tight">🔨 Forja</span>
{% if slogan %}
<span class="text-xs text-zinc-500 italic">{{ slogan }}</span>
{% endif %}
</div>
<nav class="flex gap-5 text-sm">
<a href="/" class="hover:text-white">Inicio</a>
<a href="/agents" class="hover:text-white">Agentes</a>
<a href="/run" class="hover:text-white">Ejecutar</a>
<a href="/approvals" class="hover:text-white">Aprobaciones</a>
<a href="/history" class="hover:text-white">Historial</a>
<a href="/policies" class="hover:text-white">Políticas</a>
</nav>
</div>
</header>
<main class="max-w-6xl mx-auto px-6 py-8">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</div> </div>
@@ -1,19 +1,23 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<h1 class="text-3xl font-semibold mb-6">📜 Historial de Ejecuciones</h1> <h1 class="text-3xl font-semibold mb-6">📜 Execution History</h1>
<div class="space-y-3 max-w-4xl"> <div class="space-y-3 max-w-4xl">
{% for ex in executions %} {% for ex in executions %}
{% set sc = 'emerald' if ex.status == 'completed' else ('amber' if 'awaiting' in ex.status else 'red') %}
<div class="border border-zinc-800 bg-zinc-900 rounded-xl p-4 text-sm"> <div class="border border-zinc-800 bg-zinc-900 rounded-xl p-4 text-sm">
<div class="flex justify-between"> <div class="flex justify-between items-center">
<div><span class="font-mono">{{ ex.trace_id }}</span></div> <div><span class="font-mono text-xs">{{ ex.trace_id }}</span></div>
<div class="text-xs text-zinc-400">{{ ex.agent_name }} v{{ ex.agent_version }}</div> <div class="text-xs text-zinc-400">{{ ex.agent_name }} v{{ ex.agent_version }}</div>
</div> </div>
<div class="mt-1">Status: <span class="font-mono">{{ ex.status }}</span></div> <div class="mt-1 flex items-center gap-2">
<span class="text-xs px-2 py-px rounded bg-{{ sc }}-500/10 text-{{ sc }}-400 font-mono">{{ ex.status }}</span>
<span class="text-[10px] text-zinc-500">{{ ex.n_proposed_actions }} acciones</span>
</div>
</div> </div>
{% else %} {% else %}
<div class="text-zinc-400">No hay ejecuciones todavía.</div> <div class="text-zinc-400">No executions yet.</div>
{% endfor %} {% endfor %}
</div> </div>
{% endblock %} {% endblock %}
+78 -42
View File
@@ -1,53 +1,89 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="max-w-4xl mx-auto pt-16 pb-20 text-center"> <div class="max-w-[1200px] mx-auto pb-12">
<!-- Headline -->
<h1 class="text-7xl font-semibold tracking-tighter text-white mb-4">
Forja
</h1>
<p class="text-2xl text-zinc-400 mb-8"> <!-- Branding -->
Gobernanza profesional para agentes de IA <div class="pt-6 pb-6">
</p> <div>
<span class="text-6xl font-semibold tracking-tighter">Forja</span>
<!-- Purpose -->
<div class="max-w-2xl mx-auto text-lg text-zinc-400 leading-relaxed mb-12">
Plataforma de control para agentes IA. <br>
Versionado de definiciones y políticas, guardrails en ejecución,
aprobaciones humanas y observabilidad completa.
</div>
<!-- Key capabilities -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 max-w-3xl mx-auto mb-16 text-left">
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<div class="font-medium mb-2">Versionado Git-like</div>
<div class="text-sm text-zinc-400">Agentes y políticas como YAML versionados. Diffs, histórico y control explícito de cambios.</div>
</div> </div>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<div class="font-medium mb-2">Guardrails runtime</div> <!-- Small legend -->
<div class="text-sm text-zinc-400">Validación automática de entradas y salidas con Presidio y reglas declarativas antes de ejecutar.</div> <div class="flex flex-wrap gap-x-5 gap-y-1 text-xs mt-4 text-tokyo-muted">
</div> <div><span class="inline-block w-2.5 h-2.5 rounded bg-tokyo-purple mr-1.5 align-middle"></span>Preparation</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-5"> <div><span class="inline-block w-2.5 h-2.5 rounded bg-tokyo-blue mr-1.5 align-middle"></span>Execution Pipeline</div>
<div class="font-medium mb-2">Human-in-the-Loop</div> <div><span class="inline-block w-2.5 h-2.5 rounded bg-tokyo-yellow mr-1.5 align-middle"></span>Human Oversight &amp; Completion</div>
<div class="text-sm text-zinc-400">Pausa automática en acciones de alto riesgo. Aprobación o rechazo con trazabilidad completa.</div>
</div> </div>
</div> </div>
<!-- CTAs --> <!-- Phases (no enclosing boxes) -->
<div class="flex flex-wrap justify-center gap-4">
<a href="/agents" <!-- PHASE 1 -->
class="px-6 py-3 bg-white text-black rounded-xl font-medium hover:bg-zinc-200 transition"> <div class="mb-8">
Ver Agentes <div class="mb-4">
</a> <span class="text-xs tracking-[2px] text-tokyo-purple font-semibold">PHASE 1 — PREPARATION</span>
<a href="/run" <div class="text-xl font-semibold tracking-tight mt-0.5">Definition &amp; Guardrails</div>
class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-xl font-medium transition"> </div>
Ejecutar Agente
</a> <div class="flex flex-wrap gap-4">
<a href="/approvals" <div class="flex-1 min-w-[260px] bg-[#24283b] border border-tokyo-purple/60 rounded-2xl px-5 py-4 hover:border-tokyo-purple hover:shadow-[0_0_0_1px_#bb9af7] transition">
class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-xl font-medium transition"> <div class="font-semibold">1. Agent Definition</div>
Aprobaciones <div class="text-xs text-tokyo-muted mt-1">Versioned YAML with prompt, model, output schema and risk threshold</div>
</a> </div>
<div class="flex-1 min-w-[260px] bg-[#24283b] border border-tokyo-purple/60 rounded-2xl px-5 py-4 hover:border-tokyo-purple hover:shadow-[0_0_0_1px_#bb9af7] transition">
<div class="font-semibold">2. Policy Application</div>
<div class="text-xs text-tokyo-muted mt-1">Guardrails policy loaded and attached to the agent</div>
</div>
</div>
</div> </div>
<!-- PHASE 2 -->
<div class="mb-8">
<div class="mb-4">
<span class="text-xs tracking-[2px] text-white font-semibold">PHASE 2 — EXECUTION PIPELINE</span>
<div class="text-xl font-semibold tracking-tight mt-0.5 text-white">The Main Processing Line</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<div class="bg-[#24283b] border border-tokyo-border rounded-2xl px-4 py-4 hover:border-tokyo-purple hover:shadow-md transition text-sm">
<span class="text-[#bb9af7] font-semibold">3. Input Guardrails</span>
<div class="text-[10px] text-tokyo-muted mt-1 leading-snug">Validates input against policy before it reaches the LLM</div>
</div>
<div class="bg-[#24283b] border border-tokyo-border rounded-2xl px-4 py-4 hover:border-tokyo-purple hover:shadow-md transition text-sm">
<span class="text-[#e0af68] font-semibold">4. LLM Reasoning</span>
<div class="text-[10px] text-tokyo-muted mt-1 leading-snug">The agent generates structured output using the configured model</div>
</div>
<div class="bg-[#24283b] border border-tokyo-border rounded-2xl px-4 py-4 hover:border-tokyo-purple hover:shadow-md transition text-sm">
<span class="text-[#9ece6a] font-semibold">5. Output Guardrails</span>
<div class="text-[10px] text-tokyo-muted mt-1 leading-snug">Validates the LLM response for safety and correctness</div>
</div>
<div class="bg-[#24283b] border border-tokyo-border rounded-2xl px-4 py-4 hover:border-tokyo-purple hover:shadow-md transition text-sm">
<span class="text-[#f7768e] font-semibold">6. Action Proposal</span>
<div class="text-[10px] text-tokyo-muted mt-1 leading-snug">Generates actions with risk scores and rollback plans</div>
</div>
</div>
</div>
<!-- PHASE 3 -->
<div>
<div class="mb-4">
<span class="text-xs tracking-[2px] text-tokyo-yellow font-semibold">PHASE 3 — HUMAN OVERSIGHT &amp; COMPLETION</span>
<div class="text-xl font-semibold tracking-tight mt-0.5 text-tokyo-yellow">Critical Gate + Finalization</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<a href="/approvals" class="col-span-1 md:col-span-1 bg-[#2a2f45] border-2 border-tokyo-yellow rounded-2xl px-5 py-5 hover:border-tokyo-yellow hover:shadow-[0_0_0_1px_#e0af68] hover:brightness-105 transition group">
<div class="font-semibold text-tokyo-yellow text-lg">7. Human Approval Gate</div>
<div class="text-xs text-tokyo-muted mt-2 leading-snug">High-risk actions pause here. Human must approve or reject.</div>
<div class="mt-3 text-xs text-tokyo-yellow group-hover:underline">Enter approval bay →</div>
</a>
<div class="bg-[#1f2335] border border-tokyo-green/60 rounded-2xl px-5 py-5 hover:border-tokyo-green hover:shadow-[0_0_0_1px_#9ece6a] transition">
<div class="font-semibold">8. Finalization</div>
<div class="text-xs text-tokyo-muted mt-1.5">Execution completes with full decision path.</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
@@ -1,13 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<h1 class="text-3xl font-semibold mb-6">📐 Políticas</h1> <h1 class="text-3xl font-semibold mb-6">📐 Policies</h1>
<div class="grid gap-4 max-w-3xl"> <div class="grid gap-4 max-w-3xl">
{% for p in policies %} {% for p in policies %}
<div class="border border-zinc-800 bg-zinc-900 rounded-xl p-4"> <div class="border border-zinc-800 bg-zinc-900 rounded-xl p-4">
<div class="font-medium">{{ p.name }} <span class="text-xs text-zinc-500">v{{ p.version }}</span></div> <div class="font-medium">{{ p.name }} <span class="text-xs text-zinc-500">v{{ p.version }}</span></div>
<div class="text-sm text-zinc-400 mt-1">{{ p.description or 'Sin descripción' }}</div> <div class="text-sm text-zinc-400 mt-1">{{ p.description or 'No description' }}</div>
<div class="text-xs mt-2 text-zinc-500">Validadores: {{ p.validators | length }}</div> <div class="text-xs mt-2 text-zinc-500">Validadores: {{ p.validators | length }}</div>
</div> </div>
{% endfor %} {% endfor %}
+5 -5
View File
@@ -1,13 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<h1 class="text-3xl font-semibold mb-2">▶️ Ejecutar Agente</h1> <h1 class="text-3xl font-semibold mb-2">▶️ Run Agent</h1>
<p class="text-zinc-400 mb-6">Invoca un agente con todo el gobierno (guardrails + HITL + observabilidad).</p> <p class="text-zinc-400 mb-6">Invoke an agent with full governance (guardrails + HITL + observability).</p>
<div class="max-w-2xl"> <div class="max-w-2xl">
<form hx-post="/run" hx-target="#result" hx-swap="innerHTML" class="space-y-4"> <form hx-post="/run" hx-target="#result" hx-swap="innerHTML" class="space-y-4">
<div> <div>
<label class="block text-sm mb-1">Agente</label> <label class="block text-sm mb-1">Agent</label>
<select name="agent_name" class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-sm"> <select name="agent_name" class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-sm">
{% for a in agents %} {% for a in agents %}
<option value="{{ a.name }}">{{ a.name }} (v{{ a.version }})</option> <option value="{{ a.name }}">{{ a.name }} (v{{ a.version }})</option>
@@ -16,14 +16,14 @@
</div> </div>
<div> <div>
<label class="block text-sm mb-1">Input / Escenario</label> <label class="block text-sm mb-1">Input / Scenario</label>
<textarea name="input" rows="4" class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 font-mono text-sm" <textarea name="input" rows="4" class="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 font-mono text-sm"
placeholder='{"scenario": "01_sip_registration_drop"}'>{"scenario": "01_sip_registration_drop"}</textarea> placeholder='{"scenario": "01_sip_registration_drop"}'>{"scenario": "01_sip_registration_drop"}</textarea>
</div> </div>
<button type="submit" <button type="submit"
class="px-4 py-2 bg-white text-black rounded-lg text-sm font-medium flex items-center gap-2 hover:bg-zinc-200"> class="px-4 py-2 bg-white text-black rounded-lg text-sm font-medium flex items-center gap-2 hover:bg-zinc-200">
<span>Invocar</span> <span>Invoke</span>
<span class="htmx-indicator">...</span> <span class="htmx-indicator">...</span>
</button> </button>
</form> </form>
+69 -45
View File
@@ -20,12 +20,12 @@ TEMPLATES_DIR = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
SLOGANS = [ SLOGANS = [
"Forjando agentes con criterio", "Forging agents with judgment",
"Agentes con forja, no con fe", "Agents forged, not guessed",
"Versión. Valida. Aprueba.", "Version. Validate. Approve.",
"Cada decisión bien templada", "Every decision well-tempered",
"Gobernanza que protege", "Governance that protects",
"Del prototipo al control real", "From prototype to real control",
] ]
def get_slogan() -> str: def get_slogan() -> str:
@@ -37,7 +37,7 @@ def get_slogan() -> str:
async def home(request: Request) -> HTMLResponse: async def home(request: Request) -> HTMLResponse:
return templates.TemplateResponse( return templates.TemplateResponse(
"home.html", "home.html",
{"request": request, "title": "Inicio", "slogan": get_slogan()}, {"request": request, "title": "Home", "slogan": get_slogan()},
) )
@@ -47,7 +47,7 @@ async def agents_page(request: Request) -> HTMLResponse:
agents = registry.list_agents() agents = registry.list_agents()
return templates.TemplateResponse( return templates.TemplateResponse(
"agents.html", "agents.html",
{"request": request, "title": "Agentes", "agents": agents, "slogan": get_slogan()}, {"request": request, "title": "Agents", "agents": agents, "slogan": get_slogan()},
) )
@@ -57,7 +57,7 @@ async def agent_detail(request: Request, name: str) -> HTMLResponse:
try: try:
agent = registry.get_agent(name) agent = registry.get_agent(name)
except FileNotFoundError: except FileNotFoundError:
return HTMLResponse('<div class="text-red-400">Agente no encontrado</div>', status_code=404) return HTMLResponse('<div class="text-red-400">Agent not found</div>', status_code=404)
# Si viene por HTMX, devolvemos solo el fragmento (sin layout) # Si viene por HTMX, devolvemos solo el fragmento (sin layout)
if request.headers.get("HX-Request"): if request.headers.get("HX-Request"):
@@ -79,7 +79,7 @@ async def run_page(request: Request) -> HTMLResponse:
agents = registry.list_agents() agents = registry.list_agents()
return templates.TemplateResponse( return templates.TemplateResponse(
"run.html", "run.html",
{"request": request, "title": "Ejecutar", "agents": agents, "slogan": get_slogan()}, {"request": request, "title": "Run", "agents": agents, "slogan": get_slogan()},
) )
@@ -111,16 +111,64 @@ async def run_invoke(
except Exception as e: except Exception as e:
return HTMLResponse(f'<div class="text-red-400">Error: {str(e)[:300]}</div>', status_code=400) return HTMLResponse(f'<div class="text-red-400">Error: {str(e)[:300]}</div>', status_code=400)
# Resultado simple pero informativo # Resultado enriquecido (FASE I)
status_colors = {
"completed": "emerald",
"awaiting_approval": "amber",
"failed": "red",
"blocked_by_guardrail": "red",
}
color = status_colors.get(execution.status, "zinc")
steps_html = ""
for step in execution.decision_path:
dur = f"{step.duration_ms}ms" if step.duration_ms else ""
extra = ""
if step.n_approved is not None:
extra = f" · {step.n_approved} acciones aprobadas"
if step.rejected:
extra = " · RECHAZADO"
if step.hitl:
extra = " · HITL"
steps_html += f'<div class="flex justify-between text-xs py-0.5 border-b border-zinc-800"><span class="font-mono">{step.node}</span><span class="text-zinc-500">{dur}{extra}</span></div>'
violations_html = ""
if execution.violations:
for v in execution.violations:
sev = "red" if v.severity == "block" else "amber"
violations_html += f'<div class="text-xs text-{sev}-400">• {v.validator}: {v.message} ({v.severity})</div>'
actions_html = ""
if execution.proposed_actions:
for a in execution.proposed_actions:
risk = a.risk_score
badge = "emerald" if risk < 3 else ("amber" if risk < 5 else "red")
actions_html += f'<div class="text-xs bg-zinc-950 border border-zinc-700 rounded p-2">• {a.description} <span class="font-mono text-{badge}-400">(risk={risk})</span></div>'
needs_html = ""
if execution.needs_human_for:
needs_html = f'<div class="mt-2 text-amber-400 text-sm">⏸️ Paused for human approval ({len(execution.needs_human_for)} action(s)). Go to <a href="/approvals" class="underline">Approvals</a>.</div>'
html = f""" html = f"""
<div class="border border-zinc-700 rounded-xl p-4 bg-zinc-900"> <div class="border border-zinc-700 rounded-xl p-4 bg-zinc-900">
<div class="font-medium">Ejecución completada</div> <div class="flex items-center gap-2">
<div class="text-xs text-zinc-400 mt-1">trace_id: {execution.trace_id}</div> <span class="font-semibold">Execution</span>
<div class="mt-2 text-sm">Status: <span class="font-mono">{execution.status}</span></div> <span class="text-xs px-2 py-0.5 rounded bg-{color}-500/20 text-{color}-400 font-mono">{execution.status}</span>
<details class="mt-3"> </div>
<summary class="cursor-pointer text-xs text-zinc-400">Ver resultado completo</summary> <div class="text-[10px] text-zinc-500 mt-0.5">trace_id: {execution.trace_id}</div>
<pre class="text-[10px] bg-black p-2 mt-1 rounded overflow-auto">{execution.model_dump_json(indent=2)}</pre>
</details> <div class="mt-3">
<div class="text-xs uppercase tracking-wider text-zinc-500 mb-1">Decision path</div>
<div class="font-mono text-[10px] bg-black/60 rounded p-2">{steps_html or '<span class="text-zinc-500">no steps</span>'}</div>
</div>
{f'<div class="mt-3"><div class="text-xs uppercase tracking-wider text-red-500 mb-1">Violations</div>{violations_html}</div>' if violations_html else ''}
{f'<div class="mt-3"><div class="text-xs uppercase tracking-wider text-amber-500 mb-1">Proposed actions</div><div class="space-y-1">{actions_html}</div></div>' if actions_html else ''}
{needs_html}
{f'<div class="mt-3 text-emerald-400 text-sm">✓ Completed with {len(execution.proposed_actions or [])} actions.</div>' if execution.status == 'completed' else ''}
</div> </div>
""" """
return HTMLResponse(html) return HTMLResponse(html)
@@ -132,7 +180,7 @@ async def policies_page(request: Request) -> HTMLResponse:
policies = policy_store.list_policies() policies = policy_store.list_policies()
return templates.TemplateResponse( return templates.TemplateResponse(
"policies.html", "policies.html",
{"request": request, "title": "Políticas", "policies": policies, "slogan": get_slogan()}, {"request": request, "title": "Policies", "policies": policies, "slogan": get_slogan()},
) )
@@ -142,7 +190,7 @@ async def history_page(request: Request) -> HTMLResponse:
executions = read_execution_summaries(data_dir)[:50] executions = read_execution_summaries(data_dir)[:50]
return templates.TemplateResponse( return templates.TemplateResponse(
"history.html", "history.html",
{"request": request, "title": "Historial", "executions": executions, "slogan": get_slogan()}, {"request": request, "title": "History", "executions": executions, "slogan": get_slogan()},
) )
@@ -155,29 +203,5 @@ async def approvals_page(request: Request) -> HTMLResponse:
pending = [e for e in all_execs if e.status == "awaiting_approval"] pending = [e for e in all_execs if e.status == "awaiting_approval"]
return templates.TemplateResponse( return templates.TemplateResponse(
"approvals.html", "approvals.html",
{"request": request, "title": "Aprobaciones", "pending": pending, "slogan": get_slogan()}, {"request": request, "title": "Approvals", "pending": pending, "slogan": get_slogan()},
)
@router.post("/approvals/{trace_id}/approve", response_class=HTMLResponse)
async def approve_execution(trace_id: str, request: Request) -> HTMLResponse:
# Nota: La reanudación completa de HITL desde la UI requiere reconstruir
# agent_def + policy a partir del trace_id. Por ahora mostramos lista actualizada.
data_dir = get_settings().data_dir
all_execs = read_execution_summaries(data_dir)
pending = [e for e in all_execs if e.status == "awaiting_approval"]
return templates.TemplateResponse(
"approvals.html",
{"request": request, "title": "Aprobaciones", "pending": pending, "slogan": get_slogan()},
)
@router.post("/approvals/{trace_id}/reject", response_class=HTMLResponse)
async def reject_execution(trace_id: str, request: Request) -> HTMLResponse:
data_dir = get_settings().data_dir
all_execs = read_execution_summaries(data_dir)
pending = [e for e in all_execs if e.status == "awaiting_approval"]
return templates.TemplateResponse(
"approvals.html",
{"request": request, "title": "Aprobaciones", "pending": pending, "slogan": get_slogan()},
) )
+4 -6
View File
@@ -1,16 +1,14 @@
# Componentes de Forja y su interrelación (bajo nivel) # Componentes de Forja y su interrelación (bajo nivel)
> **Alcance.** Este documento es la **referencia de cableado**: módulos exactos, > **Nota (post-simplificación 2026):** Este documento describe el cableado del
> firmas, el grafo de dependencias de imports, el grafo de inyección de > núcleo. La UI ahora es HTMX embebida en el propio core (no hay dashboard
> dependencias, los contratos entre capas y las cadenas de llamada de cada > separado). La API REST está bajo el prefijo `/api`.
> endpoint. Es preciso, no narrativo.
> >
> - ¿Quieres la historia y el "por qué"? → [`docs/explicacion.md`](explicacion.md). > - ¿Quieres la historia y el "por qué"? → [`docs/explicacion.md`](explicacion.md).
> - ¿Las decisiones técnicas resumidas? → [`ARCHITECTURE.md`](../ARCHITECTURE.md). > - ¿Las decisiones técnicas resumidas? → [`ARCHITECTURE.md`](../ARCHITECTURE.md).
> - ¿Cómo arrancarlo? → [`README.md`](../README.md). > - ¿Cómo arrancarlo? → [`README.md`](../README.md).
> >
> Rutas relativas a `core/src/ forja_core/` salvo que se diga otra cosa. > Rutas relativas a `core/src/forja_core/` salvo que se diga otra cosa.
> Refleja el estado del repo en `v0.1.0`.
--- ---
+9 -5
View File
@@ -1,14 +1,18 @@
# Forja explicado de principio a fin # Forja explicado de principio a fin
> **Nota de estado (mayo 2026):** Este documento fue escrito para la arquitectura
> anterior (dos servicios + Streamlit/NiceGUI). Forja ahora es un **único servicio
> FastAPI** con UI HTMX embebida. El núcleo (runtime, guardrails, HITL, versionado)
> sigue siendo el mismo. Algunas secciones de UI y despliegue están desactualizadas.
>
> Si solo quieres arrancarlo, ve al [`README.md`](../README.md).
> **Para quién es esto.** Una guía didáctica para alguien que llega nuevo al > **Para quién es esto.** Una guía didáctica para alguien que llega nuevo al
> proyecto —técnico o no— y quiere entender *qué hace*, *por qué está hecho así* > proyecto —técnico o no— y quiere entender *qué hace*, *por qué está hecho así*
> y *cómo encajan las piezas* sin tener que leer todo el código primero. > y *cómo encajan las piezas* sin tener que leer todo el código primero.
> >
> Si solo quieres arrancarlo, ve al [`README.md`](../README.md). Si quieres las > Decisiones técnicas: [`ARCHITECTURE.md`](../ARCHITECTURE.md).
> decisiones técnicas en bruto, ve a [`ARCHITECTURE.md`](../ARCHITECTURE.md). Si > Cableado de bajo nivel: [`docs/componentes.md`](componentes.md).
> quieres la referencia de cableado a bajo nivel (módulos, firmas, grafos de
> dependencias, cadenas de llamada), ve a [`docs/componentes.md`](componentes.md).
> Este documento está en medio: cuenta la historia.
--- ---
-430
View File
@@ -1,430 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forja — Progreso y Próximos Pasos</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=Space+Grotesk:wght@500;600&amp;display=swap');
:root {
--primary: #0ea5e9;
}
body {
font-family: 'Inter', system_ui, sans-serif;
}
.font-display {
font-family: 'Space Grotesk', 'Inter', sans-serif;
font-weight: 600;
}
.section-header {
font-family: 'Space Grotesk', 'Inter', sans-serif;
letter-spacing: -0.025em;
}
.status-badge {
font-size: 0.75rem;
padding: 0.125rem 0.625rem;
border-radius: 9999px;
font-weight: 600;
}
.forja-gradient {
background: linear-gradient(135deg, #0ea5e9, #3b82f6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.card {
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.05), 0 8px 10px -6px rgb(0 0 0 / 0.05);
}
.metric {
font-variant-numeric: tabular-nums;
}
.nav-active {
border-bottom: 3px solid #0ea5e9;
font-weight: 600;
}
.progress-bar {
transition: width 1s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.feature-icon {
width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
}
</style>
</head>
<body class="bg-zinc-950 text-zinc-200">
<!-- Header -->
<div class="border-b border-zinc-800 bg-zinc-900/70 backdrop-blur-lg sticky top-0 z-50">
<div class="max-w-6xl mx-auto px-6 py-5 flex items-center justify-between">
<div class="flex items-center gap-x-3">
<div class="w-10 h-10 rounded-2xl bg-sky-500 flex items-center justify-center shadow-inner">
<i class="fa-solid fa-hammer text-white text-3xl"></i>
</div>
<div>
<span class="font-display text-3xl font-semibold tracking-tighter">Forja</span>
</div>
</div>
<div class="flex items-center gap-x-2 text-sm">
<div class="px-3 py-1.5 bg-zinc-900 border border-zinc-800 rounded-2xl flex items-center gap-x-2">
<div class="w-2 h-2 bg-emerald-400 rounded-full animate-pulse"></div>
<span class="font-medium text-emerald-400 text-xs tracking-wider">v0.2 — EN PRODUCCIÓN</span>
</div>
<div class="text-zinc-500 text-xs px-2">23 mayo 2026</div>
</div>
</div>
</div>
<div class="max-w-6xl mx-auto px-6 pt-10 pb-24">
<!-- Hero -->
<div class="flex flex-col lg:flex-row gap-8 items-start">
<div class="flex-1">
<div class="inline-flex items-center gap-x-2 px-3 py-1 rounded-3xl bg-zinc-900 border border-zinc-800 text-xs mb-4">
<i class="fa-solid fa-sync fa-spin-pulse text-sky-400"></i>
<span class="font-semibold tracking-widest">GRAN REFACTORIZACIÓN COMPLETADA</span>
</div>
<h1 class="font-display text-6xl lg:text-7xl font-semibold tracking-tighter leading-none">
Forja<br>
<span class="forja-gradient">está lista</span>
</h1>
<p class="mt-4 max-w-lg text-xl text-zinc-400">
Plataforma de gobernanza genérica para agentes IA de cualquier tipo.
Renombrada, generalizada y con editores gráficos completos.
</p>
<div class="flex items-center gap-x-3 mt-8">
<a href="https://github.com/anomalyco/opencode"
class="inline-flex items-center gap-x-2 px-5 py-3 rounded-3xl bg-white text-zinc-950 font-semibold text-sm hover:bg-zinc-100 transition-colors">
<i class="fa-brands fa-github"></i>
<span>Ver en GitHub</span>
</a>
<button onclick="document.getElementById('proximos-pasos').scrollIntoView({behavior:'smooth'})"
class="inline-flex items-center gap-x-2 px-5 py-3 rounded-3xl border border-zinc-700 hover:bg-zinc-900 text-sm font-medium transition-colors">
<span>Ver próximos pasos</span>
<i class="fa-solid fa-arrow-down"></i>
</button>
</div>
</div>
<div class="lg:w-80 w-full">
<div class="bg-zinc-900 border border-zinc-800 rounded-3xl p-5">
<div class="text-xs uppercase tracking-[1px] text-zinc-500 mb-3 font-medium">Progreso general</div>
<div class="flex items-baseline gap-x-2">
<div class="text-6xl font-semibold tabular-nums tracking-tighter">82</div>
<div class="text-2xl font-medium text-zinc-400">/100</div>
</div>
<div class="h-2.5 bg-zinc-800 rounded-full mt-3 overflow-hidden">
<div class="h-2.5 bg-gradient-to-r from-sky-400 to-blue-500 rounded-full progress-bar" style="width: 82%"></div>
</div>
<div class="grid grid-cols-3 gap-4 mt-6 text-center">
<div>
<div class="text-xs text-zinc-500">Core</div>
<div class="font-semibold text-xl">100%</div>
</div>
<div>
<div class="text-xs text-zinc-500">UI Gráfica</div>
<div class="font-semibold text-xl">68%</div>
</div>
<div>
<div class="text-xs text-zinc-500">Docs</div>
<div class="font-semibold text-xl">65%</div>
</div>
</div>
</div>
</div>
</div>
<!-- Métricas rápidas -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mt-12">
<div class="bg-zinc-900 border border-zinc-800 rounded-3xl p-4">
<div class="flex items-center gap-x-3">
<div class="feature-icon bg-emerald-500/10 text-emerald-400"><i class="fa-solid fa-check-double"></i></div>
<div>
<div class="text-xs text-zinc-400">Renombrado global</div>
<div class="font-semibold">483 → 0 referencias antiguas</div>
</div>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-3xl p-4">
<div class="flex items-center gap-x-3">
<div class="feature-icon bg-amber-500/10 text-amber-400"><i class="fa-solid fa-edit"></i></div>
<div>
<div class="text-xs text-zinc-400">Editores gráficos</div>
<div class="font-semibold">2 páginas nuevas funcionales</div>
</div>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-3xl p-4">
<div class="flex items-center gap-x-3">
<div class="feature-icon bg-violet-500/10 text-violet-400"><i class="fa-solid fa-globe"></i></div>
<div>
<div class="text-xs text-zinc-400">Generalización</div>
<div class="font-semibold">Cualquier tipo de agente</div>
</div>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-3xl p-4">
<div class="flex items-center gap-x-3">
<div class="feature-icon bg-emerald-500/10 text-emerald-400"><i class="fa-solid fa-palette"></i></div>
<div>
<div class="text-xs text-zinc-400">Modernización de interfaz</div>
<div class="font-semibold">Migración Streamlit → NiceGUI</div>
</div>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-3xl p-4">
<div class="flex items-center gap-x-3">
<div class="feature-icon bg-sky-500/10 text-sky-400"><i class="fa-solid fa-database"></i></div>
<div>
<div class="text-xs text-zinc-400">Write APIs</div>
<div class="font-semibold">POST /versions + upsert</div>
</div>
</div>
</div>
</div>
<!-- Qué se hizo -->
<div class="mt-16">
<h2 class="section-header text-3xl font-semibold tracking-tight mb-6 flex items-center gap-x-3">
<span>Lo que se completó</span>
<span class="text-xs px-3 py-1 bg-emerald-400/10 text-emerald-400 rounded-2xl font-mono tracking-wider">FASE 1 + 2 + 3</span>
</h2>
<div class="grid md:grid-cols-2 gap-4">
<!-- Columna 1 -->
<div class="space-y-4">
<div class="card bg-zinc-900 border border-zinc-800 rounded-3xl p-6">
<div class="font-semibold flex items-center gap-x-2 text-lg">
<i class="fa-solid fa-sync text-sky-400"></i>
<span>Renombrado completo a "Forja"</span>
</div>
<ul class="mt-4 text-sm space-y-2 text-zinc-300">
<li class="flex gap-x-2"><span class="text-sky-400 mt-0.5"></span> Paquetes Python: <span class="mono font-medium">forja_core</span> y <span class="mono font-medium">forja_dashboard</span></li>
<li class="flex gap-x-2"><span class="text-sky-400 mt-0.5"></span> Imágenes Docker, contenedores y URLs (<code>forja-core:dev</code>)</li>
<li class="flex gap-x-2"><span class="text-sky-400 mt-0.5"></span> Variables de entorno (<code>FORJA_CORE_URL</code>)</li>
<li class="flex gap-x-2"><span class="text-sky-400 mt-0.5"></span> pyproject, Dockerfiles, docker-compose, Makefile</li>
<li class="flex gap-x-2"><span class="text-sky-400 mt-0.5"></span> 300+ archivos actualizados (código + docs activos)</li>
</ul>
</div>
<div class="card bg-zinc-900 border border-zinc-800 rounded-3xl p-6">
<div class="font-semibold flex items-center gap-x-2 text-lg">
<i class="fa-solid fa-universal-access text-violet-400"></i>
<span>Generalización para cualquier tipo de agente</span>
</div>
<div class="mt-4 text-sm text-zinc-300">
<div class="flex flex-wrap gap-2">
<div class="px-3 py-1 bg-zinc-800 rounded-2xl text-xs">Campos UI opcionales en AgentDefinition</div>
<div class="px-3 py-1 bg-zinc-800 rounded-2xl text-xs">input_label / placeholder / category / tags / icon</div>
<div class="px-3 py-1 bg-zinc-800 rounded-2xl text-xs">Execute page 100% genérico</div>
<div class="px-3 py-1 bg-zinc-800 rounded-2xl text-xs">Posicionamiento como plano de control reutilizable</div>
</div>
</div>
</div>
</div>
<!-- Columna 2 -->
<div class="space-y-4">
<div class="card bg-zinc-900 border border-zinc-800 rounded-3xl p-6">
<div class="font-semibold flex items-center gap-x-2 text-lg">
<i class="fa-solid fa-pencil-ruler text-amber-400"></i>
<span>Editores gráficos completos (la gran novedad)</span>
</div>
<div class="mt-4 grid grid-cols-1 gap-3">
<div class="bg-zinc-950 border border-zinc-800 p-4 rounded-2xl">
<div class="font-medium text-amber-300 flex items-center gap-x-2">
<i class="fa-solid fa-hammer"></i>
<span>6_🔨_Forjar_Agente.py</span>
</div>
<div class="text-xs text-zinc-400 mt-1">Form completo + LLM config + schema JSON + multiselect de guardrails + fork + versionado</div>
</div>
<div class="bg-zinc-950 border border-zinc-800 p-4 rounded-2xl">
<div class="font-medium text-amber-300 flex items-center gap-x-2">
<i class="fa-solid fa-shield-halved"></i>
<span>7_🛡️_Forjar_Politica.py</span>
</div>
<div class="text-xs text-zinc-400 mt-1">Constructor dinámico de validadores (input/output) + /validators endpoint + configs JSON</div>
</div>
</div>
</div>
<div class="card bg-zinc-900 border border-zinc-800 rounded-3xl p-6">
<div class="font-semibold text-lg">Backend de escritura</div>
<div class="text-sm mt-3 space-y-1.5 text-zinc-300">
<div><code class="mono text-xs">POST /agents/{name}/versions</code> + upsert</div>
<div><code class="mono text-xs">POST /policies/{name}/versions</code> + upsert simétrico</div>
<div><code class="mono text-xs">GET /policies/validators</code> (metadata para UI)</div>
<div><code class="mono text-xs">FileSystemPolicyStore.upsert_version()</code></div>
<div>✓ Docker mounts cambiados a <strong>:rw</strong></div>
</div>
</div>
</div>
</div>
</div>
<!-- Estado actual -->
<div class="mt-16">
<h2 class="section-header text-3xl font-semibold tracking-tight mb-6">Estado actual (23 mayo 2026)</h2>
<div class="bg-zinc-900 border border-zinc-800 rounded-3xl p-7">
<div class="grid md:grid-cols-3 gap-8">
<div>
<div class="uppercase text-xs font-semibold tracking-wider text-emerald-400">Compila y funciona</div>
<ul class="mt-3 space-y-2 text-sm">
<li class="flex items-start gap-x-2"><i class="fa-solid fa-check text-emerald-400 mt-0.5"></i> <span>Todos los imports y paquetes renombrados</span></li>
<li class="flex items-start gap-x-2"><i class="fa-solid fa-check text-emerald-400 mt-0.5"></i> <span>py_compile 100% limpio</span></li>
<li class="flex items-start gap-x-2"><i class="fa-solid fa-check text-emerald-400 mt-0.5"></i> <span>Editores guardan YAMLs reales</span></li>
<li class="flex items-start gap-x-2"><i class="fa-solid fa-check text-emerald-400 mt-0.5"></i> <span>Execute page usa metadata del agente</span></li>
</ul>
</div>
<div>
<div class="uppercase text-xs font-semibold tracking-wider text-amber-400">Listo para usar</div>
<ul class="mt-3 space-y-2 text-sm">
<li class="flex items-start gap-x-2"><i class="fa-solid fa-check text-amber-400 mt-0.5"></i> <span><code>docker compose up</code> (con :rw)</span></li>
<li class="flex items-start gap-x-2"><i class="fa-solid fa-check text-amber-400 mt-0.5"></i> <span>Registro + Ejecución + Aprobaciones</span></li>
<li class="flex items-start gap-x-2"><i class="fa-solid fa-check text-amber-400 mt-0.5"></i> <span>Editores en barra lateral (páginas 6 y 7)</span></li>
<li class="flex items-start gap-x-2"><i class="fa-solid fa-check text-amber-400 mt-0.5"></i> <span>README y docs principales actualizados</span></li>
</ul>
</div>
<div>
<div class="uppercase text-xs font-semibold tracking-wider text-zinc-400">Pendiente de verificación completa</div>
<ul class="mt-3 space-y-2 text-sm text-zinc-400">
<li class="flex items-start gap-x-2"><i class="fa-solid fa-clock mt-0.5"></i> <span>make test-all (requiere deps pesadas)</span></li>
<li class="flex items-start gap-x-2"><i class="fa-solid fa-clock mt-0.5"></i> <span>Smoke test con Docker real (guardrails-ai)</span></li>
<li class="flex items-start gap-x-2"><i class="fa-solid fa-clock mt-0.5"></i> <span>Actualización de walkthrough.html</span></li>
<li class="flex items-start gap-x-2"><i class="fa-solid fa-clock mt-0.5"></i> <span>Tests específicos de los nuevos endpoints</span></li>
</ul>
</div>
</div>
</div>
</div>
<!-- Próximos pasos -->
<div id="proximos-pasos" class="mt-16">
<h2 class="section-header text-3xl font-semibold tracking-tight mb-6">Próximos pasos recomendados</h2>
<div class="space-y-3">
<!-- Paso 1 -->
<div class="card bg-zinc-900 border border-zinc-800 rounded-3xl p-5 flex gap-5 items-start">
<div class="w-7 h-7 flex-shrink-0 rounded-2xl bg-blue-500 flex items-center justify-center text-xs font-bold text-white mt-0.5">1</div>
<div class="flex-1">
<div class="font-semibold">Verificación completa de calidad</div>
<div class="text-sm text-zinc-400 mt-1">Ejecutar <span class="mono bg-zinc-950 px-1.5 py-px rounded">make install &amp;&amp; make lint &amp;&amp; make test-all</span> + smoke con docker compose en entorno limpio.</div>
</div>
</div>
<!-- Paso 2 -->
<div class="card bg-zinc-900 border border-zinc-800 rounded-3xl p-5 flex gap-5 items-start">
<div class="w-7 h-7 flex-shrink-0 rounded-2xl bg-blue-500 flex items-center justify-center text-xs font-bold text-white mt-0.5">2</div>
<div class="flex-1">
<div class="font-semibold">Mejora de los editores (UX)</div>
<div class="text-sm text-zinc-400 mt-1">Añadir editor JSON más amigable (ace o monaco), validación en tiempo real de schemas, preview de agente antes de guardar, y botón “Probar inmediatamente”.</div>
</div>
</div>
<!-- Paso 3 -->
<div class="card bg-zinc-900 border border-zinc-800 rounded-3xl p-5 flex gap-5 items-start">
<div class="w-7 h-7 flex-shrink-0 rounded-2xl bg-blue-500 flex items-center justify-center text-xs font-bold text-white mt-0.5">3</div>
<div class="flex-1">
<div class="font-semibold">Añadir más plantillas de agentes</div>
<div class="text-sm text-zinc-400 mt-1">Crear 2-3 agentes genéricos de ejemplo (resumidor, revisor de código, chatbot con guardrails) + sus políticas base.</div>
</div>
</div>
<div class="card bg-zinc-900 border border-zinc-800 rounded-3xl p-5 flex gap-5 items-start">
<div class="w-7 h-7 flex-shrink-0 rounded-2xl bg-blue-500 flex items-center justify-center text-xs font-bold text-white mt-0.5">4</div>
<div class="flex-1">
<div class="font-semibold">Soporte de namespaces / multi-proyecto</div>
<div class="text-sm text-zinc-400 mt-1">Estructura opcional <span class="mono text-xs">agents/&lt;proyecto&gt;/&lt;agente&gt;</span> para que una sola instancia de Forja sirva a múltiples equipos.</div>
</div>
</div>
<div class="card bg-zinc-900 border border-zinc-800 rounded-3xl p-5 flex gap-5 items-start">
<div class="w-7 h-7 flex-shrink-0 rounded-2xl bg-blue-500 flex items-center justify-center text-xs font-bold text-white mt-0.5">5</div>
<div class="flex-1">
<div class="font-semibold">Documentación y walkthrough actualizado</div>
<div class="text-sm text-zinc-400 mt-1">Regenerar o actualizar <span class="mono">docs/walkthrough.html</span> y <span class="mono">docs/explicacion.md</span> con los editores y el nuevo nombre.</div>
</div>
</div>
</div>
</div>
<!-- Notas técnicas -->
<div class="mt-16 text-xs text-zinc-500 border-t border-zinc-800 pt-8">
<div class="flex flex-wrap gap-x-8 gap-y-2">
<div><strong>Paquetes:</strong> <span class="mono">forja_core</span> / <span class="mono">forja_web</span> (NiceGUI) + <span class="mono">forja_common</span></div>
<div><strong>Python:</strong> 3.11+</div>
<div><strong>UI:</strong> NiceGUI (reemplazo moderno de Streamlit) + Tailwind</div>
<div><strong>Runtime:</strong> LangGraph + FastAPI</div>
<div><strong>Persistencia:</strong> YAML versionado + SQLite checkpoints</div>
</div>
<div class="mt-3 text-[10px] text-zinc-600">Actualizado el 23 de mayo de 2026 — Migración a NiceGUI iniciada, CLI removido por decisión de producto.</div>
</div>
</div>
<script>
// Tailwind script
function initializeTailwind() {
document.documentElement.style.setProperty('--accent', '#0ea5e9');
}
// Simple confetti on load for celebration
function celebrate() {
if (Math.random() > 0.7) {
console.log('%c[Forja] ¡Refactorización completada con éxito!', 'color:#64748b;font-size:9px');
}
}
window.onload = function() {
initializeTailwind();
celebrate();
};
// Keyboard hint
document.addEventListener('keydown', function(e) {
if (e.key === '/' && document.activeElement.tagName === 'BODY') {
e.preventDefault();
const next = document.getElementById('proximos-pasos');
if (next) next.scrollIntoView({behavior: 'smooth'});
}
});
</script>
</body>
</html>
+242
View File
@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forja — Seguimiento de Progreso</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=Space+Grotesk:wght@500;600&amp;display=swap');
:root {
--tokyo-bg: #1a1b26;
--tokyo-surface: #24283b;
--tokyo-border: #414868;
--tokyo-purple: #bb9af7;
--tokyo-blue: #7aa2f7;
--tokyo-yellow: #e0af68;
--tokyo-text: #c0caf5;
--tokyo-muted: #565f89;
}
body {
font-family: 'Inter', system_ui, sans-serif;
background-color: var(--tokyo-bg);
color: var(--tokyo-text);
}
.font-display {
font-family: 'Space Grotesk', 'Inter', sans-serif;
font-weight: 600;
}
.tokyo-card {
background-color: var(--tokyo-surface);
border: 1px solid var(--tokyo-border);
}
.section-header {
font-family: 'Space Grotesk', 'Inter', sans-serif;
letter-spacing: -0.025em;
}
.progress-bar {
transition: width 1s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.station {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.milestone {
position: relative;
}
.milestone::before {
content: '';
position: absolute;
left: 15px;
top: 0;
bottom: -24px;
width: 2px;
background: var(--tokyo-border);
}
.milestone:last-child::before {
display: none;
}
</style>
</head>
<body class="min-h-screen">
<div class="max-w-5xl mx-auto px-6 py-12">
<!-- Header -->
<div class="flex items-center gap-4 mb-10">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#bb9af7] to-[#7aa2f7] flex items-center justify-center">
<span class="text-3xl">🔨</span>
</div>
<div>
<h1 class="font-display text-5xl font-semibold tracking-tighter">Forja</h1>
<p class="text-[#565f89] text-xl">Seguimiento de Progreso</p>
</div>
</div>
<!-- Current Vision -->
<div class="tokyo-card rounded-3xl p-8 mb-10">
<div class="flex items-center gap-3 mb-4">
<div class="px-3 py-1 bg-[#bb9af7]/10 text-[#bb9af7] rounded-full text-sm font-medium">Visión Actual</div>
</div>
<h2 class="text-3xl font-semibold tracking-tight mb-3">Cadena de Montaje (Assembly Line)</h2>
<p class="text-[#c0caf5]/80 text-lg max-w-3xl">
La interfaz se ha transformado en una <strong>vista única de fábrica</strong>.
El objetivo es que el usuario entienda de un vistazo cómo se "forja" un agente,
mostrando solo los bloques que generan <strong>inputs configurables</strong> o
<strong>outputs significativos</strong>.
</p>
</div>
<!-- Timeline -->
<div class="mb-12">
<h3 class="font-display text-2xl font-semibold tracking-tight mb-6">Evolución Principal</h3>
<div class="space-y-6">
<!-- Milestone 1 -->
<div class="milestone flex gap-4">
<div class="w-8 h-8 rounded-full bg-[#bb9af7] flex-shrink-0 flex items-center justify-center text-[#1a1b26] font-bold text-sm">1</div>
<div class="tokyo-card rounded-2xl p-5 flex-1">
<div class="flex justify-between items-start">
<div>
<div class="font-semibold">Eliminación de la Navbar</div>
<div class="text-sm text-[#565f89] mt-1">Se eliminó completamente la navegación superior tradicional.</div>
</div>
<div class="text-xs text-[#565f89] font-mono">Paso 1</div>
</div>
</div>
</div>
<!-- Milestone 2 -->
<div class="milestone flex gap-4">
<div class="w-8 h-8 rounded-full bg-[#bb9af7] flex-shrink-0 flex items-center justify-center text-[#1a1b26] font-bold text-sm">2</div>
<div class="tokyo-card rounded-2xl p-5 flex-1">
<div class="font-semibold">Paso a Vista Única de Fábrica</div>
<div class="text-sm text-[#565f89] mt-1">Toda la experiencia se concentra en una "cadena de montaje" visual en lugar de páginas separadas.</div>
</div>
</div>
<!-- Milestone 3 -->
<div class="milestone flex gap-4">
<div class="w-8 h-8 rounded-full bg-[#bb9af7] flex-shrink-0 flex items-center justify-center text-[#1a1b26] font-bold text-sm">3</div>
<div class="tokyo-card rounded-2xl p-5 flex-1">
<div class="font-semibold">Eliminación de las Cajas de Fases</div>
<div class="text-sm text-[#565f89] mt-1">Se quitaron los contenedores grandes que englobaban cada fase. Ahora solo quedan los encabezados de fase + las estaciones sueltas.</div>
</div>
</div>
<!-- Milestone 4 -->
<div class="milestone flex gap-4">
<div class="w-8 h-8 rounded-full bg-[#bb9af7] flex-shrink-0 flex items-center justify-center text-[#1a1b26] font-bold text-sm">4</div>
<div class="tokyo-card rounded-2xl p-5 flex-1">
<div class="font-semibold">Unificación de Fondo + Colores Llamativos en Texto</div>
<div class="text-sm text-[#565f89] mt-1">Todas las cajas de la línea de ensamblado usan el mismo gris. Los títulos tienen colores muy saturados y distintos (cyan, púrpura, amarillo, verde, rojo).</div>
</div>
</div>
<!-- Milestone 5 -->
<div class="milestone flex gap-4">
<div class="w-8 h-8 rounded-full bg-[#bb9af7] flex-shrink-0 flex items-center justify-center text-[#1a1b26] font-bold text-sm">5</div>
<div class="tokyo-card rounded-2xl p-5 flex-1">
<div class="font-semibold">Limpieza de Estaciones sin Valor de I/O</div>
<div class="text-sm text-[#565f89] mt-1">
Se eliminaron los bloques que no generan inputs configurables ni outputs significativos:
<span class="font-medium text-[#e0af68]">Input Intake</span> y
<span class="font-medium text-[#e0af68]">Audit &amp; Logging</span>.
</div>
</div>
</div>
</div>
</div>
<!-- Current Assembly Line -->
<div class="mb-12">
<h3 class="font-display text-2xl font-semibold tracking-tight mb-6">Estado Actual de la Cadena de Montaje</h3>
<div class="tokyo-card rounded-3xl p-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Phase 1 -->
<div>
<div class="text-[#bb9af7] text-xs tracking-[1.5px] font-semibold mb-2">FASE 1 — PREPARACIÓN</div>
<div class="space-y-2">
<div class="bg-[#24283b] border border-[#bb9af7]/40 rounded-xl px-4 py-3 text-sm">1. Agent Definition</div>
<div class="bg-[#24283b] border border-[#bb9af7]/40 rounded-xl px-4 py-3 text-sm">2. Policy Application</div>
</div>
</div>
<!-- Phase 2 -->
<div>
<div class="text-[#7aa2f7] text-xs tracking-[1.5px] font-semibold mb-2">FASE 2 — LÍNEA DE ENSAMBLADO</div>
<div class="space-y-2">
<div class="bg-[#24283b] border border-[#7aa2f7]/40 rounded-xl px-4 py-3 text-sm">
<span class="text-[#bb9af7]">3.</span> Input Guardrails
</div>
<div class="bg-[#24283b] border border-[#7aa2f7]/40 rounded-xl px-4 py-3 text-sm">
<span class="text-[#e0af68]">4.</span> LLM Reasoning
</div>
<div class="bg-[#24283b] border border-[#7aa2f7]/40 rounded-xl px-4 py-3 text-sm">
<span class="text-[#9ece6a]">5.</span> Output Guardrails
</div>
<div class="bg-[#24283b] border border-[#7aa2f7]/40 rounded-xl px-4 py-3 text-sm">
<span class="text-[#f7768e]">6.</span> Action Proposal
</div>
</div>
</div>
<!-- Phase 3 -->
<div>
<div class="text-[#e0af68] text-xs tracking-[1.5px] font-semibold mb-2">FASE 3 — SUPERVISIÓN HUMANA</div>
<div class="space-y-2">
<div class="bg-[#24283b] border border-[#e0af68]/40 rounded-xl px-4 py-3 text-sm">7. Human Approval Gate</div>
<div class="bg-[#24283b] border border-[#9ece6a]/40 rounded-xl px-4 py-3 text-sm">8. Finalization</div>
</div>
</div>
</div>
<div class="mt-6 pt-6 border-t border-[#414868] text-xs text-[#565f89]">
Total de estaciones activas: <span class="font-semibold text-[#c0caf5]">8</span>
(todas producen inputs configurables o outputs significativos)
</div>
</div>
</div>
<!-- Principles -->
<div class="grid md:grid-cols-2 gap-6">
<div class="tokyo-card rounded-3xl p-6">
<div class="text-sm font-medium text-[#bb9af7] mb-2">PRINCIPIOS APLICADOS</div>
<ul class="space-y-2 text-sm">
<li class="flex gap-2"><span>Strict adherence to <strong>karpathy.md</strong> guidelines</span></li>
<li class="flex gap-2"><span>Simplicity First</span></li>
<li class="flex gap-2"><span>Surgical changes (solo tocar lo necesario)</span></li>
<li class="flex gap-2"><span>Eliminar lo que no aporta valor al flujo visual</span></li>
</ul>
</div>
<div class="tokyo-card rounded-3xl p-6">
<div class="text-sm font-medium text-[#bb9af7] mb-2">PRÓXIMOS PASOS (SUGERIDOS)</div>
<div class="text-sm text-[#565f89]">
<p class="mb-3">Este documento puede servir como registro vivo del proyecto.</p>
<p>Añade aquí nuevas decisiones, experimentos visuales o cambios de rumbo cuando ocurran.</p>
</div>
</div>
</div>
<div class="mt-12 text-center text-xs text-[#565f89]">
Documento generado para seguimiento del proyecto Forja •
<span class="font-mono">Tokyo Night Theme</span>
</div>
</div>
</body>
</html>
-58
View File
@@ -1,58 +0,0 @@
# Smoke manual del dashboard
> Esta lista cubre los flujos no automatizados (Streamlit). Ejecutar tras
> cambios visuales o estructurales del dashboard.
## Setup
```bash
cp .env.example .env
docker compose up -d
sleep 15
```
Abrir [http://localhost:8501](http://localhost:8501).
## 1) Registro
- [ ] Aparece `incident_analyzer` en el desplegable.
- [ ] Detalle muestra version, owner, propósito, guardrails, system_prompt.
- [ ] Tabla de versiones lista `v1` y `v2`.
- [ ] Diff `v1 → v2` muestra cambios coloreados.
## 2) Ejecutar
- [ ] Botones de escenarios cargan texto en el textarea.
- [ ] Invocar `02_mos_degradation_pool_sbc` → status=`completed`, sin HITL,
decision_path con 6 steps.
- [ ] Invocar `01_sip_registration_drop` → status=`awaiting_approval`,
banner amarillo redirige a Aprobaciones.
## 3) Aprobaciones
- [ ] La ejecución pendiente aparece en el desplegable.
- [ ] Cada acción muestra risk_score con color, target y rollback_plan.
- [ ] Aprobar acciones seleccionadas → status=`completed`.
- [ ] Rechazo con razón → status=`failed`, error=`rejected_by_human`.
## 4) Historial
- [ ] Tab "Ejecuciones" lista todas las ejecuciones con summary.
- [ ] Detalle muestra trace + violations.
- [ ] Tab "Violaciones" filtrable por severity.
## 5) Politicas
- [ ] `default` aparece con sus validadores de input/output.
- [ ] Cada validador expandible con su config.
## Bloqueo por PII (input)
- [ ] Pegar `El cliente con NIF 12345678Z reporta caída`.
- [ ] Invocar → status=`blocked_by_guardrail`, violación `DetectPII`.
## Persistencia
- [ ] `docker compose down && docker compose up` → ejecuciones previas
siguen accesibles vía Historial; aprobaciones pendientes siguen
pendientes.
File diff suppressed because it is too large Load Diff
@@ -1,631 +0,0 @@
# AgentForge — Documento de diseño
**Fecha:** 2026-05-09
**Estado:** aprobado — listo para plan de implementación
**Autor:** Juan
**Revisión:** v1.0
---
## 1. Resumen ejecutivo
**AgentForge** es una plataforma profesional de **gobernanza de agentes IA** orientada a entornos de producción. Cubre el ciclo de vida completo: catalogación, versionado de prompts y políticas, validación con guardrails en runtime, ejecución stateful con checkpointing, Human-in-the-Loop nativo, y trazabilidad end-to-end.
La plataforma se compone de dos servicios:
- **`agentforge-core`** — API REST (FastAPI) que materializa el dominio de gobierno: registry, versionado, ejecución de agentes vía LangGraph, validación de guardrails y persistencia.
- **`agentforge-dashboard`** — UI ejecutiva (Streamlit) cliente del core; nunca habla con LLMs ni guardrails directamente.
Una imagen Docker por servicio, orquestadas con `docker-compose`. Arranque sin claves API (proveedor LLM mock por defecto).
## 2. Problema y motivación
Poner agentes IA en producción sin una capa de gobernanza produce sistemas opacos:
- Prompts que cambian en caliente sin historial.
- Validaciones inconsistentes según quién despliega.
- Acciones de alto impacto (rollbacks, cambios de configuración, ejecución de comandos) propuestas sin supervisión humana ni rollback plan.
- Decisiones del agente no auditables — imposible reconstruir por qué se tomó una acción concreta.
AgentForge define el plano de control mínimo que un equipo de plataforma necesita antes de operar agentes con impacto real. El proyecto no resuelve la inteligencia del agente; resuelve su **operabilidad y gobierno**.
## 3. Decisiones de diseño (resumen)
| Eje | Decisión | Justificación |
|---|---|---|
| Topología | Two-tier: FastAPI core + Streamlit dashboard | Separación de responsabilidades; el core es API-first y consumible por terceros (RPA, n8n, otros agentes). Patrón de plataformas reales. |
| LLM | Interfaz `LLMProvider` con `mock` (default), `azure`, `openai` | Demo arranca sin secrets. Demuestra portabilidad de proveedor. |
| Guardrails | Interfaz `GuardrailEngine` con `GuardrailsAI` (default) y `NeMo` (opt-in vía flag) | Lo mejor de ambos mundos: arquitectura extensible visible + imagen ligera por defecto. |
| Persistencia | Mezcla por capa: JSON (registry, definiciones), JSONL (logs append-only), YAML (versiones), SQLite (checkpoints LangGraph) | Cada capa con la herramienta correcta. Definiciones humanas en YAML; estado runtime en formatos legibles por máquina. |
| Versionado | Simulación tipo Git en sistema de ficheros: `agents/<n>/versions/v1.yaml` + `index.yaml` con hashes y mensajes | No requiere git real; reproduce la disciplina de versionado y diff. |
| Estado | LangGraph con `SqliteSaver` + `interrupt()` dinámico para HITL | Persistencia entre reinicios crítica para esperas humanas. |
| Auth | Ninguna en MVP; placeholder hook `Depends(get_current_user)` | Pluggable a OAuth2/OIDC/MSAL en futuro. |
| Comentarios | Español | Coherente con el autor. |
## 4. Arquitectura
```
┌───────────────────────── docker-compose ──────────────────────────┐
│ │
│ ┌─────────────────────┐ HTTP/JSON ┌────────────────────┐ │
│ │ agentforge-dashboard│ ────────────────► │ agentforge-core │ │
│ │ Streamlit :8501 │ ◄──────────────── │ FastAPI :8000 │ │
│ └─────────────────────┘ └─────────┬──────────┘ │
│ │ │
│ ┌────────────────────────────────────────┼──────────┐ │
│ ▼ ▼ ▼ ▼ │
│ LLM layer Guardrail layer Runtime State│
│ ┌──────────────────┐ ┌────────────────────┐ ┌─────────┐ ┌───┐│
│ │ LLMProvider (P) │ │ GuardrailEngine(P) │ │LangGraph│ │JSN││
│ │ ├ MockProvider │ │ ├ GuardrailsAIEng │ │ + HITL │ │SQL││
│ │ ├ AzureOpenAI │ │ ├ NeMoEngine (opt) │ │ + ckpt │ │JNL││
│ │ └ OpenAI │ │ └ CompositeEngine │ └─────────┘ └───┘│
│ └──────────────────┘ └────────────────────┘ │
│ │
│ Strategy pattern con Pydantic v2 + Protocol typing │
└────────────────────────────────────────────────────────────────────┘
```
**Principios:**
- **API-first:** el core es invocable por cualquier cliente HTTP; el dashboard es un consumidor sustituible.
- **Configuration over code:** definiciones de agentes y políticas en YAML editables sin redeploy.
- **Strategy/Plugin:** todas las dependencias externas (LLM, guardrails, registry) detrás de `Protocol`.
- **Fail-closed por defecto:** errores en validadores se traducen a violación que bloquea la ejecución.
- **Trazabilidad obligatoria:** cada ejecución tiene un `trace_id` UUID propagado por logs, API, dashboard y persistencia.
## 5. Estructura de repositorio
```
agentforge/
├── README.md
├── ARCHITECTURE.md
├── docker-compose.yml
├── .env.example
├── .gitignore
├── Makefile # up / down / test / lint / format / smoke
├── pyproject.toml # ruff + mypy + pytest config
├── core/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── src/agentforge_core/
│ ├── main.py # FastAPI app + middlewares
│ ├── config.py # Pydantic Settings, .env
│ ├── api/
│ │ ├── agents.py
│ │ ├── executions.py
│ │ ├── policies.py
│ │ └── violations.py
│ ├── domain/ # modelos Pydantic v2
│ │ ├── agent.py
│ │ ├── execution.py
│ │ ├── guardrail.py
│ │ └── policy.py
│ ├── registry/
│ │ ├── repository.py
│ │ └── versioning.py
│ ├── llm/
│ │ ├── base.py # Protocol LLMProvider
│ │ ├── mock.py
│ │ ├── azure.py
│ │ ├── openai.py
│ │ └── factory.py
│ ├── guardrails/
│ │ ├── base.py # Protocol GuardrailEngine
│ │ ├── guardrails_ai.py
│ │ ├── nemo.py
│ │ ├── composite.py
│ │ └── factory.py
│ ├── runtime/
│ │ ├── graph.py # build_graph(agent_def, policy)
│ │ ├── state.py # AgentState (TypedDict)
│ │ ├── nodes.py # cada nodo del grafo
│ │ └── checkpointer.py # SqliteSaver wrapper
│ └── observability/
│ └── logging.py # structlog + trace_id propagation
├── dashboard/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── src/agentforge_dashboard/
│ ├── app.py
│ ├── client.py # httpx client tipado al core
│ ├── pages/
│ │ ├── 1_🏛️_Registro.py
│ │ ├── 2_▶️_Ejecutar.py
│ │ ├── 3_🤝_Aprobaciones.py
│ │ ├── 4_📜_Historial.py
│ │ └── 5_📐_Politicas.py
│ └── components/
│ ├── trace_view.py
│ ├── violation_view.py
│ └── diff_view.py
├── agents/
│ └── incident_analyzer/
│ ├── index.yaml # versiones disponibles + metadata
│ ├── versions/
│ │ ├── v1.yaml
│ │ └── v2.yaml
│ └── examples/
│ ├── 01_sip_registration_drop.txt
│ ├── 02_mos_degradation_pool_sbc.txt
│ └── 03_hss_capacity_active_active.txt
├── policies/
│ └── default/
│ ├── index.yaml
│ └── versions/
│ └── v1.yaml
├── data/ # gitignored
│ ├── registry.json
│ ├── checkpoints.sqlite
│ ├── violations.jsonl
│ └── executions.jsonl
├── docs/
│ ├── architecture.md
│ ├── futuro.md
│ ├── manual_qa.md
│ └── superpowers/specs/
│ └── 2026-05-09-agentforge-design.md
└── tests/
├── unit/
└── integration/
```
**Notas:**
- Pydantic models residen en `domain/`, no en `api/`. Los routers solo serializan.
- Las factories (`llm/factory.py`, `guardrails/factory.py`) son la frontera donde el `.env` decide la implementación.
- YAML para definiciones humanas (legibles, comentables, mejor diff). JSON/JSONL/SQLite para estado runtime.
- `__init__.py` mínimos, sin re-exports masivos.
## 6. Modelos de dominio
```python
class LLMConfig(BaseModel):
provider: Literal["mock", "azure", "openai"] = "mock"
model: str = "gpt-4o"
temperature: float = 0.2
max_tokens: int = 2000
class AgentVersionMeta(BaseModel):
id: str # ej. "v2"
hash: str # SHA-256 del YAML normalizado
author: str
message: str
created_at: datetime
class AgentDefinition(BaseModel):
name: str
version: str # semver o vN
owner: str
purpose: str
state: Literal["draft", "active", "deprecated"]
guardrails: list[str] # nombres de policies
llm: LLMConfig
system_prompt: str
output_schema: dict # JSON Schema del output esperado
risk_threshold_for_hitl: int = 4 # risk_score ≥ X exige aprobación humana
updated_at: datetime
class GuardrailViolation(BaseModel):
trace_id: UUID
timestamp: datetime
stage: Literal["input", "output"]
validator: str # ej. "DetectPII"
severity: Literal["info", "warning", "block"]
message: str
blocked: bool # True si detuvo la ejecución
class ProposedAction(BaseModel):
id: str # generado, único dentro de la ejecución
action: str # ej. "rollback_image"
target: str # ej. "cscf-cluster-aravaca-01"
risk_score: int = Field(ge=1, le=5)
rollback_plan: str
requires_approval: bool
class DecisionStep(BaseModel):
step: str # validate_input | llm_reason | ...
timestamp: datetime
duration_ms: int
detail: dict # libre por nodo
class AgentExecution(BaseModel):
trace_id: UUID
agent_name: str
agent_version: str
status: Literal[
"running",
"awaiting_approval",
"blocked_by_guardrail",
"completed",
"failed",
]
started_at: datetime
finished_at: datetime | None
decision_path: list[DecisionStep]
violations: list[GuardrailViolation]
proposed_actions: list[ProposedAction]
needs_human_for: list[ProposedAction] | None
final_output: dict | None
error: str | None
class AgentExecutionSummary(BaseModel):
"""Versión ligera para listados (sin decision_path ni violations completas)."""
trace_id: UUID
agent_name: str
agent_version: str
status: str
started_at: datetime
finished_at: datetime | None
n_violations: int
n_proposed_actions: int
class PolicyValidator(BaseModel):
type: str # "detect_pii", "prompt_injection", ...
config: dict # parámetros del validador
class PolicyDefinition(BaseModel):
name: str
version: str
description: str
input_validators: list[PolicyValidator]
output_validators: list[PolicyValidator]
on_validator_error: Literal["fail_open", "fail_closed"] = "fail_closed"
```
## 7. API REST
```
GET /health
GET /agents → list[AgentDefinition]
GET /agents/{name} → AgentDefinition (versión activa)
GET /agents/{name}/versions → list[AgentVersionMeta]
GET /agents/{name}/versions/{v} → AgentDefinition (versión concreta)
GET /agents/{name}/versions/{v}/diff/{w} → DiffResult (unified diff)
POST /agents/{name}/invoke → AgentExecution
body: {"input": str, "version": str?}
GET /executions → list[AgentExecutionSummary]
GET /executions/{trace_id} → AgentExecution
POST /executions/{trace_id}/approve → AgentExecution
body: {"approved_action_ids": list[str],
"comment": str?}
POST /executions/{trace_id}/reject → AgentExecution
body: {"reason": str}
GET /violations → list[GuardrailViolation]
query: trace_id?, severity?, since?
GET /policies → list[PolicyDefinition]
GET /policies/{name}/versions → list[PolicyVersionMeta]
```
Códigos de error relevantes:
- `404` agente o ejecución no existe
- `409` `/approve` o `/reject` sobre ejecución no en `awaiting_approval`
- `422` body inválido (Pydantic)
- `500` fallo interno (checkpoint corrupto, configuración inválida)
Todas las respuestas incluyen header `X-Trace-Id` (eco del request o generado).
## 8. Interfaces clave (Strategy)
```python
class Message(BaseModel):
role: Literal["system", "user", "assistant"]
content: str
class CompletionResult(BaseModel):
content: str
model: str
tokens_in: int
tokens_out: int
latency_ms: int
class LLMProvider(Protocol):
name: str
async def complete(
self,
messages: list[Message],
schema: dict | None = None,
temperature: float = 0.2,
max_tokens: int = 2000,
) -> CompletionResult: ...
class GuardrailEngine(Protocol):
name: str
async def validate_input(
self, payload: str, policy: PolicyDefinition, trace_id: UUID
) -> list[GuardrailViolation]: ...
async def validate_output(
self, payload: dict, policy: PolicyDefinition, trace_id: UUID
) -> list[GuardrailViolation]: ...
class AgentRegistry(Protocol):
def list_agents(self) -> list[AgentDefinition]: ...
def get_agent(self, name: str, version: str | None = None) -> AgentDefinition: ...
def list_versions(self, name: str) -> list[AgentVersionMeta]: ...
def get_version(self, name: str, version: str) -> AgentDefinition: ...
def diff_versions(self, name: str, v1: str, v2: str) -> DiffResult: ...
def upsert_version(
self, name: str, body: AgentDefinition, message: str, author: str
) -> AgentVersionMeta: ...
class PolicyStore(Protocol):
def list_policies(self) -> list[PolicyDefinition]: ...
def get_policy(self, name: str, version: str | None = None) -> PolicyDefinition: ...
def list_versions(self, name: str) -> list[PolicyVersionMeta]: ...
```
## 9. Flujo de ejecución (LangGraph)
**Topología del grafo (común para todos los agentes; varía la `AgentDefinition` y `Policy`):**
```
┌──────────────┐
│ validate_in │── violations.severity=block ──► [STATUS: blocked_by_guardrail] ─► END
└──────┬───────┘
│ pass
┌──────────────┐
│ llm_reason │── 3xretry → fallback → giveup ─► [STATUS: failed] ─► END
└──────┬───────┘
│ ok
┌──────────────┐
│ validate_out │── violations.severity=block ──► [STATUS: blocked_by_guardrail] ─► END
└──────┬───────┘
│ pass
┌──────────────────┐
│ propose_actions │
└──────┬───────────┘
┌──────────────────┐ ─ any(risk≥threshold or requires_approval) ─┐
│ approve_gate │ │
│ (dyn. interrupt)│ ─ todas seguras ──┐ │
└──────────────────┘ │ │
▼ ▼
┌──────────────┐ [interrupt(payload)]
│ finalize │ status=awaiting_approval
└──────┬───────┘ │
▼ │ POST /approve
[STATUS: completed] │ POST /reject
[Command(resume={...}) → finalize → completed
o status=failed, error="rejected_by_human"]
```
**Estado del grafo (TypedDict de LangGraph):**
```python
class AgentState(TypedDict):
trace_id: str
agent_name: str
agent_version: str
user_input: str
messages: list[dict]
raw_llm_output: str | None
parsed_output: dict | None
proposed_actions: list[dict]
violations: list[dict]
decision_path: Annotated[list[dict], operator.add] # acumulativo
status: str
error: str | None
human_decision: dict | None # rellena en /approve|/reject
```
`Annotated[..., operator.add]` permite que cada nodo añada pasos sin pisar lo previo (semántica nativa de LangGraph para reducers).
**Checkpointing:** `SqliteSaver("data/checkpoints.sqlite")` con `thread_id = trace_id`. El estado se persiste tras cada nodo. Si el dashboard cierra durante un `awaiting_approval`, `GET /executions/{trace_id}` reconstruye el snapshot exacto.
**Recorrido típico — `POST /agents/incident_analyzer/invoke`:**
1. API genera `trace_id` (UUID4); resuelve `AgentDefinition` vía `AgentRegistry`; resuelve `PolicyDefinition` referenciada en `agent_def.guardrails`; loga `execution.started` con `trace_id`.
2. `runtime.graph.build_graph(agent_def, policy)` compila el grafo:
- Inyecta `LLMProvider` vía factory (override por `LLM_PROVIDER` env).
- Inyecta `GuardrailEngine` vía factory (`Composite[GuardrailsAI]`, +`NeMo` si flag).
- Checkpointer = `SqliteSaver`. El nodo `approve_gate` invoca dinámicamente `interrupt(payload)` (API de LangGraph ≥0.2) solo cuando hay acciones que requieren aprobación; si todas son seguras, transiciona a `finalize` sin pausa. Esto evita el `interrupt_before` estático y mantiene el flujo declarativo.
3. `graph.invoke({...}, config={"configurable":{"thread_id": trace_id}})` ejecuta hasta:
- (a) END por guardrail block en `validate_input` o `validate_output`.
- (b) END por LLM unrecoverable failure.
- (c) INTERRUPT en `approve_gate` (HITL).
- (d) END normal (`finalize`) si no hay acciones de alto riesgo.
4. API serializa `AgentExecution` y devuelve 200. Append a `data/executions.jsonl` y violaciones a `data/violations.jsonl`.
5. (caso HITL) Dashboard pinta cards con cada `ProposedAction` (action, target, badge `risk_score`, `rollback_plan`, botones `[Approve]`/`[Reject]`). Operador pulsa Approve.
6. Dashboard `POST /executions/{trace_id}/approve` con body `{"approved_action_ids": ["..."], "comment": "..."}`.
7. API: `graph.invoke(Command(resume={"human_decision": {...}}), config={"configurable":{"thread_id": trace_id}})`. Nodo `finalize` lee `state.human_decision`, compone `final_output`. `status="completed"`, `finished_at=now`. Append final a `executions.jsonl`.
## 10. Guardrails (capa runtime)
**Estructura `policies/default/versions/v1.yaml`:**
```yaml
name: default
version: v1
description: Política base para agentes de operación de plataforma de voz
input_validators:
- type: detect_pii
entities: [PERSON, EMAIL, PHONE_NUMBER, ES_NIF, IP_ADDRESS, IBAN_CODE]
severity_on_match: block
- type: prompt_injection
severity_on_match: block
- type: toxic_language
threshold: 0.7
severity_on_match: warning
- type: forbidden_topics
topics: ["instrucciones de explotación", "credenciales", "código malicioso"]
severity_on_match: block
output_validators:
- type: schema_match
schema_ref: agent.output_schema # del agent_def en runtime
severity_on_mismatch: block
- type: pii_leakage
severity_on_match: block
- type: forbidden_action_keywords
keywords: ["DROP TABLE", "rm -rf", "shutdown -h now", "delete production"]
severity_on_match: block
- type: telco_safety_rules # validador custom
rules:
- never_propose_action_targeting_production_without_rollback
- never_propose_mass_action_without_canary
on_validator_error: fail_closed
```
**Validadores (Guardrails AI engine):**
- `DetectPII` — usa `presidio_analyzer` + entidades configuradas; `ES_NIF` como recognizer custom (regex documentada).
- `PromptInjection` — heurística de patrones (lista mantenible) + clasificador ligero. En modo `mock` LLM, lista de strings exactos para reproducibilidad de tests.
- `ToxicLanguage` — validator del Guardrails Hub.
- `ForbiddenTopics` — substring matcher en MVP. `docs/futuro.md` documenta upgrade a similarity con embeddings.
- `SchemaMatch` — Pydantic parse contra `agent.output_schema`.
- `PIILeakage` — Presidio sobre output stringificado.
- `ForbiddenActionKeywords` — substring matcher sobre acciones propuestas.
- `TelcoSafetyRules` — validador custom; reglas declarativas evaluadas sobre `proposed_actions[]`.
**Comportamiento del engine:**
- Toda violación se persiste a `violations.jsonl` (incluso `info`/`warning`).
- Solo `severity=block` con `blocked=True` detiene el grafo.
- `on_validator_error` decide qué pasa cuando un validador lanza excepción (`fail_closed` por defecto).
- Composite engine ejecuta sub-engines en paralelo y agrega resultados.
**NeMo Guardrails (opt-in):** activado con `GUARDRAILS_NEMO_ENABLED=true`. Aporta topical rails declarados en Colang (`policies/default/nemo/rails.co`). MVP entrega configuración mínima (off-topic refusal); el flag por defecto está en `false` para mantener la imagen ligera.
## 11. Versionado de prompts y políticas (simulación tipo Git)
**Estructura `agents/<name>/index.yaml`:**
```yaml
name: incident_analyzer
versions:
- id: v1
hash: 7c9a4f...
author: Juan
message: "Versión inicial; cobertura básica de SIP/IMS"
created_at: 2026-04-12T10:00:00Z
- id: v2
hash: a1b2c3...
author: Juan
message: "Añade detección de codec mismatch; sube risk_threshold a 4"
created_at: 2026-05-01T12:00:00Z
active_version: v2
```
**Operaciones:**
- `Registry.upsert_version(name, body, message, author)` — calcula SHA-256 del YAML normalizado, valida formato, escribe `versions/vN.yaml`, actualiza `index.yaml`.
- `Registry.diff_versions(name, v1, v2)``difflib.unified_diff` sobre los YAMLs.
- `Registry.get_agent(name)` — devuelve la versión `active_version`.
- Promoción de `draft``active` cambia `index.yaml.active_version`.
**Estructura idéntica para `policies/<name>/`.**
El dashboard ofrece:
- Vista de "Versiones" del agente con autor, mensaje, hash, fecha.
- Botón "Comparar con anterior" → render del diff con sintaxis coloreada.
## 12. Observabilidad
- **Logging**: `structlog` con renderer JSON. Cada log incluye `trace_id`, `agent_name`, `agent_version`, `step`, `duration_ms` cuando aplica.
- **Trace-id propagation**: middleware FastAPI inyecta `X-Trace-Id` (genera UUID4 si no viene). `structlog` lo bind-ea en context (`structlog.contextvars`). Dashboard incluye `X-Trace-Id` en approve/reject para correlar logs.
- **Eventos persistidos**:
- `data/executions.jsonl` — un append por ejecución completada/fallida (no por step).
- `data/violations.jsonl` — un append por violación (cualquier severidad).
- **Métricas/Tracing OTel**: fuera de MVP. Documentado en `docs/futuro.md`.
## 13. Manejo de errores
| Tipo de fallo | Respuesta |
|---|---|
| Azure OpenAI 5xx / timeout | Retry exponencial (1s, 2s, 4s; 3 intentos). Si agota, fallback a `LLM_FALLBACK_PROVIDER` si está configurado. Si falla, `status=failed`, `error="llm_unavailable"`. La API devuelve `200` con la ejecución fallida (el dominio sí ha respondido). |
| Guardrail validator excepción interna | Por defecto `fail_closed` → cuenta como `severity=block`, `blocked=True`. Configurable por policy. |
| Pydantic validation falla en LLM output | 1 retry con prompt extendido `"Respond strictly with JSON matching this schema: ..."`. Si falla, `status=failed`, `error="output_schema_mismatch"`. |
| Checkpoint SQLite corrupto | Log `error.checkpoint_corrupt`, devolver `500`, `error_class="checkpoint_unreadable"`. Sin auto-recovery (mejor falla explícita). |
| Agente solicitado no existe | `404` con `{"detail":"agent 'X' not found"}`. |
| `/approve` o `/reject` sobre estado no válido | `409` con `{"detail":"execution status 'X' does not allow approve"}`. |
| Dashboard pierde conexión con core | `httpx` retry simple (2 intentos, 1s). Si sigue fallando, banner `st.error("Core API unreachable")`. |
| Variable `.env` requerida ausente | `Pydantic Settings` falla en arranque con mensaje claro (no en runtime: el contenedor no debe estar "vivo y roto"). |
## 14. Testing
```
tests/
├── unit/
│ ├── test_llm_mock.py
│ ├── test_guardrails_ai.py
│ ├── test_registry.py
│ ├── test_runtime_state.py
│ └── test_policy_loader.py
├── integration/
│ ├── test_invoke_happy_path.py
│ ├── test_invoke_hitl.py
│ ├── test_invoke_pii_block.py
│ └── test_invoke_resume_after_restart.py
└── fixtures/
├── agents/incident_analyzer_test.yaml
├── policies/test_policy.yaml
└── inputs/
```
- **Stack**: `pytest` + `pytest-asyncio` + `httpx.AsyncClient` (FastAPI TestClient) + `freezegun`.
- **MockProvider** con lookup determinista (`hash(input) → respuesta`).
- **Sin tests Streamlit** en MVP. Smoke manual en `docs/manual_qa.md`.
- **CI** (`.github/workflows/ci.yml`): `ruff check` + `ruff format --check` + `mypy` + `pytest`.
- **Cobertura objetivo** ~70% sobre `core/src/agentforge_core`.
## 15. Despliegue
- `docker-compose.yml` con dos servicios: `core` (uvicorn, `:8000`) y `dashboard` (Streamlit, `:8501`).
- `Dockerfile.core` y `Dockerfile.dashboard` separados; cada uno con sus deps mínimas.
- Healthchecks: `core` `GET /health`; `dashboard` `GET /` (Streamlit responde 200 cuando el server arranca).
- Volume mount `./data:/app/data` y `./agents:/app/agents:ro`, `./policies:/app/policies:ro`.
- `.env.example` con todas las variables documentadas y comentadas.
- `LLM_PROVIDER=mock` por defecto → arranque sin claves.
- Variables relevantes: `LLM_PROVIDER`, `LLM_FALLBACK_PROVIDER`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_DEPLOYMENT`, `OPENAI_API_KEY`, `GUARDRAILS_NEMO_ENABLED`, `LOG_LEVEL`, `DATA_DIR`.
## 16. README — estructura
1. **Qué es AgentForge** — Plataforma de gobernanza de agentes IA: catalogación, versionado de prompts y políticas, guardrails runtime, ejecución stateful con HITL, observabilidad.
2. **Problema que resuelve** — Sin gobernanza, los agentes en producción son cajas negras (ver §2).
3. **Diagrama de arquitectura** — Mermaid en línea + PNG renderizado en `docs/`.
4. **Quickstart**: `cp .env.example .env && docker compose up`. Funciona out-of-the-box (`LLM_PROVIDER=mock`).
5. **Demo guiada en 3 pasos**: 1) registro; 2) ejecuta `incident_analyzer` con `02_mos_degradation_pool_sbc.txt`; 3) aprueba el rollback en HITL.
6. **Capacidades implementadas** — tabla `feature → ubicación en código`.
7. **Roadmap** — link a `docs/futuro.md`.
## 17. Roadmap (`docs/futuro.md`)
- NeMo Guardrails con configuración rica (Colang KB).
- Autenticación: OAuth2/OIDC con MSAL para entornos Azure AD.
- OpenTelemetry: spans por nodo, métricas de violaciones por validador.
- Multi-tenant: aislamiento por `tenant_id`, registry por tenant.
- Evaluadores LLM-as-judge automatizados: regression suite que ejecuta cada agente sobre escenarios canónicos y mide deriva.
- UI de aprobación con SLA, reasignación entre operadores, cola Kanban.
- Persistencia migrable a Postgres + pgvector.
## 18. No-objetivos (out of scope MVP)
- Aprendizaje/fine-tuning de modelos.
- Integración con sistemas reales de orquestación (RPA, Ansible, Kubernetes).
- Inteligencia avanzada del agente (RAG, multi-step planning con tool use complejo).
- Tests automatizados de UI Streamlit.
- Soporte multi-idioma del dashboard.
- High-availability del core (1 réplica suficiente para MVP).
## 19. Decisiones pospuestas
- **Postgres vs SQLite para checkpoints en multi-instance**: SQLite suficiente para MVP single-node; cambio a Postgres documentado en `docs/futuro.md`.
- **NeMo full integration**: feature flag preparado, pero la configuración Colang completa es post-MVP.
- **Métricas**: estructura de logging permite extracción posterior; no se entregan dashboards Grafana.
## 20. Referencias
- [LangGraph documentation](https://langchain-ai.github.io/langgraph/) — patrón checkpointer + interrupt.
- [Guardrails AI](https://www.guardrailsai.com/) — validators hub.
- [NVIDIA NeMo Guardrails](https://github.com/NVIDIA/NeMo-Guardrails) — Colang DSL.
- [Microsoft Presidio](https://microsoft.github.io/presidio/) — PII detection.
- [Pydantic v2](https://docs.pydantic.dev/latest/) — modelos y settings.
- [FastAPI](https://fastapi.tiangolo.com/) — API REST.
- [Streamlit](https://streamlit.io/) — dashboard.
- [structlog](https://www.structlog.org/) — logging estructurado.
File diff suppressed because it is too large Load Diff
-1901
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -12,7 +12,7 @@ EXAMPLES = (
@pytest.mark.integration @pytest.mark.integration
def test_happy_path_completa_sin_hitl(integration_client) -> None: # type: ignore[no-untyped-def] def test_happy_path_completa_sin_hitl(integration_client) -> None: # type: ignore[no-untyped-def]
payload = (EXAMPLES / "02_mos_degradation_pool_sbc.txt").read_text(encoding="utf-8") payload = (EXAMPLES / "02_mos_degradation_pool_sbc.txt").read_text(encoding="utf-8")
r = integration_client.post("/agents/incident_analyzer/invoke", json={"input": payload}) r = integration_client.post("/api/agents/incident_analyzer/invoke", json={"input": payload})
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert body["status"] == "completed" assert body["status"] == "completed"
+4 -4
View File
@@ -12,7 +12,7 @@ EXAMPLES = (
@pytest.mark.integration @pytest.mark.integration
def test_hitl_approve_completa(integration_client) -> None: # type: ignore[no-untyped-def] def test_hitl_approve_completa(integration_client) -> None: # type: ignore[no-untyped-def]
payload = (EXAMPLES / "01_sip_registration_drop.txt").read_text(encoding="utf-8") payload = (EXAMPLES / "01_sip_registration_drop.txt").read_text(encoding="utf-8")
r = integration_client.post("/agents/incident_analyzer/invoke", json={"input": payload}) r = integration_client.post("/api/agents/incident_analyzer/invoke", json={"input": payload})
body = r.json() body = r.json()
assert body["status"] == "awaiting_approval" assert body["status"] == "awaiting_approval"
assert body["needs_human_for"] assert body["needs_human_for"]
@@ -20,7 +20,7 @@ def test_hitl_approve_completa(integration_client) -> None: # type: ignore[no-u
pending = [a["id"] for a in body["needs_human_for"]] pending = [a["id"] for a in body["needs_human_for"]]
r2 = integration_client.post( r2 = integration_client.post(
f"/executions/{trace_id}/approve", f"/api/executions/{trace_id}/approve",
json={"approved_action_ids": pending, "comment": "OK rollback"}, json={"approved_action_ids": pending, "comment": "OK rollback"},
) )
assert r2.status_code == 200 assert r2.status_code == 200
@@ -31,10 +31,10 @@ def test_hitl_approve_completa(integration_client) -> None: # type: ignore[no-u
@pytest.mark.integration @pytest.mark.integration
def test_hitl_reject_marca_failed(integration_client) -> None: # type: ignore[no-untyped-def] def test_hitl_reject_marca_failed(integration_client) -> None: # type: ignore[no-untyped-def]
payload = (EXAMPLES / "01_sip_registration_drop.txt").read_text(encoding="utf-8") payload = (EXAMPLES / "01_sip_registration_drop.txt").read_text(encoding="utf-8")
r = integration_client.post("/agents/incident_analyzer/invoke", json={"input": payload}) r = integration_client.post("/api/agents/incident_analyzer/invoke", json={"input": payload})
trace_id = r.json()["trace_id"] trace_id = r.json()["trace_id"]
r2 = integration_client.post( r2 = integration_client.post(
f"/executions/{trace_id}/reject", json={"reason": "rollback no procede"} f"/api/executions/{trace_id}/reject", json={"reason": "rollback no procede"}
) )
assert r2.status_code == 200 assert r2.status_code == 200
assert r2.json()["status"] == "failed" assert r2.json()["status"] == "failed"
+1 -1
View File
@@ -6,7 +6,7 @@ import pytest
@pytest.mark.integration @pytest.mark.integration
def test_pii_es_nif_bloquea(integration_client) -> None: # type: ignore[no-untyped-def] def test_pii_es_nif_bloquea(integration_client) -> None: # type: ignore[no-untyped-def]
pii_input = "El cliente con NIF 12345678Z reporta caída de servicio. Email juan@example.com" pii_input = "El cliente con NIF 12345678Z reporta caída de servicio. Email juan@example.com"
r = integration_client.post("/agents/incident_analyzer/invoke", json={"input": pii_input}) r = integration_client.post("/api/agents/incident_analyzer/invoke", json={"input": pii_input})
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert body["status"] == "blocked_by_guardrail" assert body["status"] == "blocked_by_guardrail"
@@ -44,7 +44,7 @@ def test_resume_tras_recreacion_del_app(tmp_path: Path, monkeypatch: pytest.Monk
# 1) Primera "ejecución" del core: invoca y queda en awaiting_approval. # 1) Primera "ejecución" del core: invoca y queda en awaiting_approval.
client_a = _fresh() client_a = _fresh()
r = client_a.post("/agents/incident_analyzer/invoke", json={"input": payload}) r = client_a.post("/api/agents/incident_analyzer/invoke", json={"input": payload})
body = r.json() body = r.json()
assert body["status"] == "awaiting_approval" assert body["status"] == "awaiting_approval"
trace_id = body["trace_id"] trace_id = body["trace_id"]
@@ -54,7 +54,7 @@ def test_resume_tras_recreacion_del_app(tmp_path: Path, monkeypatch: pytest.Monk
# 2) Recreamos el app (simula restart del contenedor) — DATA_DIR persiste. # 2) Recreamos el app (simula restart del contenedor) — DATA_DIR persiste.
client_b = _fresh() client_b = _fresh()
r2 = client_b.post( r2 = client_b.post(
f"/executions/{trace_id}/approve", f"/api/executions/{trace_id}/approve",
json={"approved_action_ids": pending}, json={"approved_action_ids": pending},
) )
assert r2.status_code == 200 assert r2.status_code == 200
+7 -7
View File
@@ -28,41 +28,41 @@ def patch_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_list_agents_incluye_incident_analyzer() -> None: def test_list_agents_incluye_incident_analyzer() -> None:
r = TestClient(create_app()).get("/agents") r = TestClient(create_app()).get("/api/agents")
assert r.status_code == 200 assert r.status_code == 200
assert any(a["name"] == "incident_analyzer" for a in r.json()) assert any(a["name"] == "incident_analyzer" for a in r.json())
def test_get_agent_activo_devuelve_v1() -> None: def test_get_agent_activo_devuelve_v1() -> None:
r = TestClient(create_app()).get("/agents/incident_analyzer") r = TestClient(create_app()).get("/api/agents/incident_analyzer")
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["version"] == "v1" assert r.json()["version"] == "v1"
def test_get_agent_inexistente_404() -> None: def test_get_agent_inexistente_404() -> None:
r = TestClient(create_app()).get("/agents/inexistente") r = TestClient(create_app()).get("/api/agents/inexistente")
assert r.status_code == 404 assert r.status_code == 404
def test_list_versions_devuelve_meta() -> None: def test_list_versions_devuelve_meta() -> None:
r = TestClient(create_app()).get("/agents/incident_analyzer/versions") r = TestClient(create_app()).get("/api/agents/incident_analyzer/versions")
assert r.status_code == 200 assert r.status_code == 200
assert r.json()[0]["id"] == "v1" assert r.json()[0]["id"] == "v1"
def test_get_version_concreta() -> None: def test_get_version_concreta() -> None:
r = TestClient(create_app()).get("/agents/incident_analyzer/versions/v1") r = TestClient(create_app()).get("/api/agents/incident_analyzer/versions/v1")
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["name"] == "incident_analyzer" assert r.json()["name"] == "incident_analyzer"
def test_get_version_inexistente_404() -> None: def test_get_version_inexistente_404() -> None:
r = TestClient(create_app()).get("/agents/incident_analyzer/versions/v999") r = TestClient(create_app()).get("/api/agents/incident_analyzer/versions/v999")
assert r.status_code == 404 assert r.status_code == 404
def test_diff_misma_version_da_diff_vacio() -> None: def test_diff_misma_version_da_diff_vacio() -> None:
r = TestClient(create_app()).get("/agents/incident_analyzer/versions/v1/diff/v1") r = TestClient(create_app()).get("/api/agents/incident_analyzer/versions/v1/diff/v1")
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert body["from_version"] == "v1" assert body["from_version"] == "v1"
+17 -17
View File
@@ -34,7 +34,7 @@ def client() -> TestClient:
def test_invoke_devuelve_execution_completada(client: TestClient) -> None: def test_invoke_devuelve_execution_completada(client: TestClient) -> None:
r = client.post("/agents/incident_analyzer/invoke", json={"input": "degradación MOS pool SBC"}) r = client.post("/api/agents/incident_analyzer/invoke", json={"input": "degradación MOS pool SBC"})
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert body["status"] == "completed" assert body["status"] == "completed"
@@ -44,12 +44,12 @@ def test_invoke_devuelve_execution_completada(client: TestClient) -> None:
def test_invoke_agente_inexistente_404(client: TestClient) -> None: def test_invoke_agente_inexistente_404(client: TestClient) -> None:
r = client.post("/agents/inexistente/invoke", json={"input": "x"}) r = client.post("/api/agents/inexistente/invoke", json={"input": "x"})
assert r.status_code == 404 assert r.status_code == 404
def test_invoke_riesgo_alto_pausa_hitl(client: TestClient) -> None: def test_invoke_riesgo_alto_pausa_hitl(client: TestClient) -> None:
r = client.post("/agents/incident_analyzer/invoke", json={"input": "caída registros sip"}) r = client.post("/api/agents/incident_analyzer/invoke", json={"input": "caída registros sip"})
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert body["status"] == "awaiting_approval" assert body["status"] == "awaiting_approval"
@@ -59,21 +59,21 @@ def test_invoke_riesgo_alto_pausa_hitl(client: TestClient) -> None:
def test_get_execution_existe_tras_invoke(client: TestClient) -> None: def test_get_execution_existe_tras_invoke(client: TestClient) -> None:
trace_id = client.post( trace_id = client.post(
"/agents/incident_analyzer/invoke", json={"input": "degradación MOS pool SBC"} "/api/agents/incident_analyzer/invoke", json={"input": "degradación MOS pool SBC"}
).json()["trace_id"] ).json()["trace_id"]
r = client.get(f"/executions/{trace_id}") r = client.get(f"/api/executions/{trace_id}")
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["trace_id"] == trace_id assert r.json()["trace_id"] == trace_id
def test_get_execution_inexistente_404(client: TestClient) -> None: def test_get_execution_inexistente_404(client: TestClient) -> None:
r = client.get("/executions/00000000-0000-0000-0000-000000000000") r = client.get("/api/executions/00000000-0000-0000-0000-000000000000")
assert r.status_code == 404 assert r.status_code == 404
def test_list_executions_incluye_la_completada(client: TestClient) -> None: def test_list_executions_incluye_la_completada(client: TestClient) -> None:
client.post("/agents/incident_analyzer/invoke", json={"input": "degradación MOS pool SBC"}) client.post("/api/agents/incident_analyzer/invoke", json={"input": "degradación MOS pool SBC"})
r = client.get("/executions") r = client.get("/api/executions")
assert r.status_code == 200 assert r.status_code == 200
rows = r.json() rows = r.json()
assert len(rows) == 1 assert len(rows) == 1
@@ -85,9 +85,9 @@ def test_list_executions_incluye_la_awaiting(client: TestClient) -> None:
"""Una ejecución pausada en HITL no se escribe en el JSONL; aún así debe listarse """Una ejecución pausada en HITL no se escribe en el JSONL; aún así debe listarse
(la página de Aprobaciones del dashboard depende de ello).""" (la página de Aprobaciones del dashboard depende de ello)."""
trace_id = client.post( trace_id = client.post(
"/agents/incident_analyzer/invoke", json={"input": "caída registros sip"} "/api/agents/incident_analyzer/invoke", json={"input": "caída registros sip"}
).json()["trace_id"] ).json()["trace_id"]
rows = client.get("/executions").json() rows = client.get("/api/executions").json()
awaiting = [r for r in rows if r["trace_id"] == trace_id] awaiting = [r for r in rows if r["trace_id"] == trace_id]
assert len(awaiting) == 1 assert len(awaiting) == 1
assert awaiting[0]["status"] == "awaiting_approval" assert awaiting[0]["status"] == "awaiting_approval"
@@ -95,12 +95,12 @@ def test_list_executions_incluye_la_awaiting(client: TestClient) -> None:
def test_approve_resume_completa_ejecucion(client: TestClient) -> None: def test_approve_resume_completa_ejecucion(client: TestClient) -> None:
invoked = client.post( invoked = client.post(
"/agents/incident_analyzer/invoke", json={"input": "caída registros sip"} "/api/agents/incident_analyzer/invoke", json={"input": "caída registros sip"}
).json() ).json()
trace_id = invoked["trace_id"] trace_id = invoked["trace_id"]
action_ids = [a["id"] for a in invoked["needs_human_for"]] action_ids = [a["id"] for a in invoked["needs_human_for"]]
r = client.post( r = client.post(
f"/executions/{trace_id}/approve", f"/api/executions/{trace_id}/approve",
json={"approved_action_ids": action_ids, "comment": "OK adelante"}, json={"approved_action_ids": action_ids, "comment": "OK adelante"},
) )
assert r.status_code == 200 assert r.status_code == 200
@@ -111,9 +111,9 @@ def test_approve_resume_completa_ejecucion(client: TestClient) -> None:
def test_reject_marca_failed(client: TestClient) -> None: def test_reject_marca_failed(client: TestClient) -> None:
trace_id = client.post( trace_id = client.post(
"/agents/incident_analyzer/invoke", json={"input": "caída registros sip"} "/api/agents/incident_analyzer/invoke", json={"input": "caída registros sip"}
).json()["trace_id"] ).json()["trace_id"]
r = client.post(f"/executions/{trace_id}/reject", json={"reason": "no procede ahora"}) r = client.post(f"/api/executions/{trace_id}/reject", json={"reason": "no procede ahora"})
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert body["status"] == "failed" assert body["status"] == "failed"
@@ -122,15 +122,15 @@ def test_reject_marca_failed(client: TestClient) -> None:
def test_approve_sobre_estado_no_pausado_da_409(client: TestClient) -> None: def test_approve_sobre_estado_no_pausado_da_409(client: TestClient) -> None:
trace_id = client.post( trace_id = client.post(
"/agents/incident_analyzer/invoke", json={"input": "degradación MOS pool SBC"} "/api/agents/incident_analyzer/invoke", json={"input": "degradación MOS pool SBC"}
).json()["trace_id"] ).json()["trace_id"]
r = client.post(f"/executions/{trace_id}/approve", json={"approved_action_ids": []}) r = client.post(f"/api/executions/{trace_id}/approve", json={"approved_action_ids": []})
assert r.status_code == 409 assert r.status_code == 409
def test_approve_trace_id_desconocido_da_404(client: TestClient) -> None: def test_approve_trace_id_desconocido_da_404(client: TestClient) -> None:
r = client.post( r = client.post(
"/executions/11111111-1111-1111-1111-111111111111/approve", "/api/executions/11111111-1111-1111-1111-111111111111/approve",
json={"approved_action_ids": []}, json={"approved_action_ids": []},
) )
assert r.status_code == 404 assert r.status_code == 404
+9 -9
View File
@@ -1,4 +1,4 @@
"""Tests de los routers /policies y /violations.""" """Tests de los routers /api/policies y /api/violations."""
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
@@ -37,24 +37,24 @@ def client() -> TestClient:
def test_list_policies_incluye_default(client: TestClient) -> None: def test_list_policies_incluye_default(client: TestClient) -> None:
r = client.get("/policies") r = client.get("/api/policies")
assert r.status_code == 200 assert r.status_code == 200
assert any(p["name"] == "default" for p in r.json()) assert any(p["name"] == "default" for p in r.json())
def test_list_policy_versions(client: TestClient) -> None: def test_list_policy_versions(client: TestClient) -> None:
r = client.get("/policies/default/versions") r = client.get("/api/policies/default/versions")
assert r.status_code == 200 assert r.status_code == 200
assert r.json()[0]["id"] == "v1" assert r.json()[0]["id"] == "v1"
def test_list_policy_versions_inexistente_404(client: TestClient) -> None: def test_list_policy_versions_inexistente_404(client: TestClient) -> None:
r = client.get("/policies/inexistente/versions") r = client.get("/api/policies/inexistente/versions")
assert r.status_code == 404 assert r.status_code == 404
def test_violations_vacio_si_no_hay(client: TestClient) -> None: def test_violations_vacio_si_no_hay(client: TestClient) -> None:
r = client.get("/violations") r = client.get("/api/violations")
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == [] assert r.json() == []
@@ -75,11 +75,11 @@ def test_violations_lista_y_filtra(client: TestClient, tmp_path: Path) -> None:
tid = uuid4() tid = uuid4()
append_violation(tmp_path, _violation(tid, "DetectPII", "block", blocked=True)) append_violation(tmp_path, _violation(tid, "DetectPII", "block", blocked=True))
append_violation(tmp_path, _violation(uuid4(), "Schema", "warning", blocked=False)) append_violation(tmp_path, _violation(uuid4(), "Schema", "warning", blocked=False))
assert len(client.get("/violations").json()) == 2 assert len(client.get("/api/violations").json()) == 2
assert len(client.get("/violations", params={"severity": "block"}).json()) == 1 assert len(client.get("/api/violations", params={"severity": "block"}).json()) == 1
assert len(client.get("/violations", params={"trace_id": str(tid)}).json()) == 1 assert len(client.get("/api/violations", params={"trace_id": str(tid)}).json()) == 1
def test_violations_severity_invalida_422(client: TestClient) -> None: def test_violations_severity_invalida_422(client: TestClient) -> None:
r = client.get("/violations", params={"severity": "nope"}) r = client.get("/api/violations", params={"severity": "nope"})
assert r.status_code == 422 assert r.status_code == 422