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
> Documento "vivo" con las decisiones técnicas. El spec original está en
> [`docs/superpowers/specs/2026-05-09- forja-design.md`](docs/superpowers/specs/2026-05-09- forja-design.md).
> Documento vivo con las decisiones técnicas.
## Visión general
Two-tier:
- `forja-core` — FastAPI 8000. Dominio de gobierno, runtime LangGraph,
guardrails y persistencia.
- `forja-dashboard` — Streamlit 8501. Cliente HTTP del core.
Forja es un **único servicio FastAPI** (:8000):
- Todo el dominio de gobierno, runtime LangGraph, guardrails y persistencia.
- UI embebida (HTMX) para uso humano + API REST completa bajo `/api`.
## Diagrama
```
┌─────────────────────┐ HTTP/JSON ┌─────────────────────┐
Dashboard (Stream.) │ ───────────► │ Core (FastAPI)
└─────────────────────┘ └─────────┬───────────┘
┌─────────────────────────┬────────┴──────┬────────────┐
▼ ▼ ▼ ▼
LLM layer Guardrails layer LangGraph State
(Strategy pattern) (Strategy pattern) Runtime JSON+SQL+JSONL
┌────────────────────────────────────────────────────────────┐
forja-core :8000
│ UI HTMX (humanos) + REST API (/api) + dominio completo │
│ LLM (strategy) │ Guardrails (strategy) │ LangGraph │
│ │ │ + checkpoints│
└────────────────────────────────────────────────────────────┘
```
## 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
(`risk_score >= threshold` o `requires_approval=True`). El cliente envía
`Command(resume={"approved_action_ids": [...]})` vía
`POST /executions/{trace_id}/approve`.
`POST /api/executions/{trace_id}/approve`.
## Observabilidad
+24 -27
View File
@@ -7,30 +7,28 @@ ejecución controlada con runtime stateful, Human-in-the-Loop y observabilidad c
## 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 ──────────────────────────┐
│ ┌─────────────────────┐ HTTP/JSON ┌────────────────────┐ │
│ │ forja-dashboard │ ────────────────► │ forja-core │ │
│ Streamlit :8501 │ ◄──────────────── │ FastAPI :8000 │
└─────────────────────┘ └────────────────────┘
Strategy pattern para LLMProvider, GuardrailEngine, Registry.
│ Persistencia: YAML (defs versionadas), JSONL (logs), SQLite │
│ (checkpoints LangGraph + HITL). │
└────────────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────┐
forja-core :8000
│ ┌─────────────────────────────────────────────────────────┐
│ │ UI HTMX (navegador humano) │ API REST (/api/*)
└─────────────────────────────────────────────────────────┘
Strategy factories (LLM / Guardrails / Registry)
LangGraph runtime + SQLite checkpoints (HITL real)
Persistencia: YAML versionado + JSONL + SQLite
└───────────────────────────────────────────────────────────────┘
```
Detalles completos en [`ARCHITECTURE.md`](ARCHITECTURE.md). Además:
- [`docs/walkthrough.html`](docs/walkthrough.html) — **walkthrough HTML autocontenido**
(de alto a bajo nivel, con diagramas). Ábrelo directamente en el navegador
(`xdg-open docs/walkthrough.html`); no requiere servidor.
- [`docs/explicacion.md`](docs/explicacion.md) — explicación didáctica de extremo a
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).
Documentación viva:
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — decisiones técnicas.
- [`docs/explicacion.md`](docs/explicacion.md) — explicación didáctica completa.
- [`docs/componentes.md`](docs/componentes.md) — referencia de cableado a bajo nivel.
- [`docs/futuro.md`](docs/futuro.md) — roadmap.
## Quickstart
@@ -39,7 +37,7 @@ cp .env.example .env
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
Azure OpenAI real, edita `.env`.
@@ -64,8 +62,8 @@ Azure OpenAI real, edita `.env`.
| LangGraph + HITL + checkpoints | `core/src/forja_core/runtime/` |
| Observabilidad (trace + structlog) | `core/src/forja_core/observability/` |
| LLM abstraction | `core/src/forja_core/llm/` |
| Editores gráficos (nuevo) | dashboard páginas Forjar Agente / Política |
| API REST + dashboard | forja-core :8000 + forja-dashboard :8501 |
| UI embebida (HTMX) | `core/src/forja_core/web/` (ruta raíz) |
| API REST completa | bajo `/api` en forja-core :8000 |
## Variables de entorno
@@ -79,13 +77,12 @@ Ver [`docs/futuro.md`](docs/futuro.md).
```
forja/
├── core/ servicio FastAPI (gobernanza + runtime)
├── dashboard/ Streamlit (UI + editores gráficos)
├── core/ servicio FastAPI único (API + UI HTMX embebida)
├── agents/ definiciones de agentes (YAML versionados)
├── policies/ políticas de guardrails (YAML versionados)
├── data/ runtime state (gitignored)
├── tests/
└── docs/
└── docs/ (explicacion.md, componentes.md, futuro.md, ARCHITECTURE.md)
```
## Tests
+8 -1
View File
@@ -216,12 +216,19 @@ async def approve_execution(
) -> AgentExecution:
agent_def, policy = _resolve(registry, policies, settings.data_dir, 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(
agent_def=agent_def,
policy=policy,
trace_id=trace_id,
decision={
"approved_action_ids": body.approved_action_ids,
"approved_action_ids": approved,
"comment": body.comment,
"rejected": False,
},
+8 -8
View File
@@ -24,18 +24,18 @@ def create_app() -> FastAPI:
async def health() -> dict[str, str]:
return {"status": "ok"}
from forja_core.web import ui
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
# tengan preferencia sobre los endpoints JSON cuando se accede desde navegador.
# UI HTMX embebida (Opción A) en rutas amigables para humanos.
# Toda la API REST vive bajo /api para eliminar ambigüedad y conflictos de ruta.
app.include_router(ui.router)
app.include_router(agents.router, prefix="/agents", tags=["agents"])
app.include_router(executions.invoke_router, prefix="/agents", tags=["agents"])
app.include_router(executions.router, prefix="/executions", tags=["executions"])
app.include_router(policies.router, prefix="/policies", tags=["policies"])
app.include_router(violations.router, prefix="/violations", tags=["violations"])
app.include_router(agents.router, prefix="/api/agents", tags=["agents"])
app.include_router(executions.invoke_router, prefix="/api/agents", tags=["agents"])
app.include_router(executions.router, prefix="/api/executions", tags=["executions"])
app.include_router(policies.router, prefix="/api/policies", tags=["policies"])
app.include_router(violations.router, prefix="/api/violations", tags=["violations"])
return app
@@ -25,7 +25,7 @@
</div>
{% if agent.category %}
<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>
{% endif %}
@@ -58,7 +58,7 @@
<!-- System Prompt colapsado -->
<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">
<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 hidden group-open:inline">colapsar</span>
</summary>
@@ -85,7 +85,7 @@
{% if agent.input_placeholder %}
<div><span class="text-zinc-500">Placeholder:</span> {{ agent.input_placeholder }}</div>
{% 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>
{% endif %}
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% 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">
<!-- Lista de agentes -->
@@ -25,7 +25,7 @@
</div>
</a>
{% 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 %}
</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 class="h-full flex items-center justify-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>
@@ -1,7 +1,8 @@
{% extends "base.html" %}
{% 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">
{% for ex in pending %}
@@ -16,23 +17,27 @@
<div class="mt-3 flex gap-2">
<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-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">
Aprobar todo
</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-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">
Rechazar
</button>
</div>
</div>
{% else %}
<div class="text-zinc-400">No hay ejecuciones esperando aprobación.</div>
<div class="text-zinc-400">No executions awaiting approval.</div>
{% endfor %}
</div>
{% endblock %}
+66 -22
View File
@@ -3,38 +3,82 @@
<head>
<meta charset="utf-8">
<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>
// 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>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; }
.htmx-indicator { display: none; }
.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>
</head>
<body class="bg-zinc-950 text-zinc-200">
<body>
<div class="min-h-screen">
<header class="border-b border-zinc-800 bg-zinc-900">
<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">
<main class="max-w-6xl mx-auto px-6 py-8 text-tokyo-text">
{% block content %}{% endblock %}
</main>
</div>
@@ -1,19 +1,23 @@
{% extends "base.html" %}
{% 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">
{% 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="flex justify-between">
<div><span class="font-mono">{{ ex.trace_id }}</span></div>
<div class="flex justify-between items-center">
<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>
<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>
{% else %}
<div class="text-zinc-400">No hay ejecuciones todavía.</div>
<div class="text-zinc-400">No executions yet.</div>
{% endfor %}
</div>
{% endblock %}
+76 -40
View File
@@ -1,53 +1,89 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-4xl mx-auto pt-16 pb-20 text-center">
<!-- Headline -->
<h1 class="text-7xl font-semibold tracking-tighter text-white mb-4">
Forja
</h1>
<div class="max-w-[1200px] mx-auto pb-12">
<p class="text-2xl text-zinc-400 mb-8">
Gobernanza profesional para agentes de IA
</p>
<!-- 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.
<!-- Branding -->
<div class="pt-6 pb-6">
<div>
<span class="text-6xl font-semibold tracking-tighter">Forja</span>
</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 class="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<div class="font-medium mb-2">Guardrails runtime</div>
<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>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<div class="font-medium mb-2">Human-in-the-Loop</div>
<div class="text-sm text-zinc-400">Pausa automática en acciones de alto riesgo. Aprobación o rechazo con trazabilidad completa.</div>
<!-- Small legend -->
<div class="flex flex-wrap gap-x-5 gap-y-1 text-xs mt-4 text-tokyo-muted">
<div><span class="inline-block w-2.5 h-2.5 rounded bg-tokyo-purple mr-1.5 align-middle"></span>Preparation</div>
<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><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>
</div>
<!-- CTAs -->
<div class="flex flex-wrap justify-center gap-4">
<a href="/agents"
class="px-6 py-3 bg-white text-black rounded-xl font-medium hover:bg-zinc-200 transition">
Ver Agentes
</a>
<a href="/run"
class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-xl font-medium transition">
Ejecutar Agente
</a>
<a href="/approvals"
class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-xl font-medium transition">
Aprobaciones
<!-- Phases (no enclosing boxes) -->
<!-- PHASE 1 -->
<div class="mb-8">
<div class="mb-4">
<span class="text-xs tracking-[2px] text-tokyo-purple font-semibold">PHASE 1 — PREPARATION</span>
<div class="text-xl font-semibold tracking-tight mt-0.5">Definition &amp; Guardrails</div>
</div>
<div class="flex flex-wrap gap-4">
<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">1. Agent Definition</div>
<div class="text-xs text-tokyo-muted mt-1">Versioned YAML with prompt, model, output schema and risk threshold</div>
</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>
<!-- 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>
{% endblock %}
@@ -1,13 +1,13 @@
{% extends "base.html" %}
{% 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">
{% for p in policies %}
<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="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>
{% endfor %}
+5 -5
View File
@@ -1,13 +1,13 @@
{% extends "base.html" %}
{% block content %}
<h1 class="text-3xl font-semibold mb-2">▶️ Ejecutar Agente</h1>
<p class="text-zinc-400 mb-6">Invoca un agente con todo el gobierno (guardrails + HITL + observabilidad).</p>
<h1 class="text-3xl font-semibold mb-2">▶️ Run Agent</h1>
<p class="text-zinc-400 mb-6">Invoke an agent with full governance (guardrails + HITL + observability).</p>
<div class="max-w-2xl">
<form hx-post="/run" hx-target="#result" hx-swap="innerHTML" class="space-y-4">
<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">
{% for a in agents %}
<option value="{{ a.name }}">{{ a.name }} (v{{ a.version }})</option>
@@ -16,14 +16,14 @@
</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"
placeholder='{"scenario": "01_sip_registration_drop"}'>{"scenario": "01_sip_registration_drop"}</textarea>
</div>
<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">
<span>Invocar</span>
<span>Invoke</span>
<span class="htmx-indicator">...</span>
</button>
</form>
+69 -45
View File
@@ -20,12 +20,12 @@ TEMPLATES_DIR = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
SLOGANS = [
"Forjando agentes con criterio",
"Agentes con forja, no con fe",
"Versión. Valida. Aprueba.",
"Cada decisión bien templada",
"Gobernanza que protege",
"Del prototipo al control real",
"Forging agents with judgment",
"Agents forged, not guessed",
"Version. Validate. Approve.",
"Every decision well-tempered",
"Governance that protects",
"From prototype to real control",
]
def get_slogan() -> str:
@@ -37,7 +37,7 @@ def get_slogan() -> str:
async def home(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
"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()
return templates.TemplateResponse(
"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:
agent = registry.get_agent(name)
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)
if request.headers.get("HX-Request"):
@@ -79,7 +79,7 @@ async def run_page(request: Request) -> HTMLResponse:
agents = registry.list_agents()
return templates.TemplateResponse(
"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:
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"""
<div class="border border-zinc-700 rounded-xl p-4 bg-zinc-900">
<div class="font-medium">Ejecución completada</div>
<div class="text-xs text-zinc-400 mt-1">trace_id: {execution.trace_id}</div>
<div class="mt-2 text-sm">Status: <span class="font-mono">{execution.status}</span></div>
<details class="mt-3">
<summary class="cursor-pointer text-xs text-zinc-400">Ver resultado completo</summary>
<pre class="text-[10px] bg-black p-2 mt-1 rounded overflow-auto">{execution.model_dump_json(indent=2)}</pre>
</details>
<div class="flex items-center gap-2">
<span class="font-semibold">Execution</span>
<span class="text-xs px-2 py-0.5 rounded bg-{color}-500/20 text-{color}-400 font-mono">{execution.status}</span>
</div>
<div class="text-[10px] text-zinc-500 mt-0.5">trace_id: {execution.trace_id}</div>
<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>
"""
return HTMLResponse(html)
@@ -132,7 +180,7 @@ async def policies_page(request: Request) -> HTMLResponse:
policies = policy_store.list_policies()
return templates.TemplateResponse(
"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]
return templates.TemplateResponse(
"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"]
return templates.TemplateResponse(
"approvals.html",
{"request": request, "title": "Aprobaciones", "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()},
{"request": request, "title": "Approvals", "pending": pending, "slogan": get_slogan()},
)
+3 -5
View File
@@ -1,16 +1,14 @@
# Componentes de Forja y su interrelación (bajo nivel)
> **Alcance.** Este documento es la **referencia de cableado**: módulos exactos,
> firmas, el grafo de dependencias de imports, el grafo de inyección de
> dependencias, los contratos entre capas y las cadenas de llamada de cada
> endpoint. Es preciso, no narrativo.
> **Nota (post-simplificación 2026):** Este documento describe el cableado del
> núcleo. La UI ahora es HTMX embebida en el propio core (no hay dashboard
> separado). La API REST está bajo el prefijo `/api`.
>
> - ¿Quieres la historia y el "por qué"? → [`docs/explicacion.md`](explicacion.md).
> - ¿Las decisiones técnicas resumidas? → [`ARCHITECTURE.md`](../ARCHITECTURE.md).
> - ¿Cómo arrancarlo? → [`README.md`](../README.md).
>
> 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
> **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
> 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.
>
> Si solo quieres arrancarlo, ve al [`README.md`](../README.md). Si quieres las
> decisiones técnicas en bruto, ve a [`ARCHITECTURE.md`](../ARCHITECTURE.md). Si
> 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.
> Decisiones técnicas: [`ARCHITECTURE.md`](../ARCHITECTURE.md).
> Cableado de bajo nivel: [`docs/componentes.md`](componentes.md).
---
-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
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")
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
body = r.json()
assert body["status"] == "completed"
+4 -4
View File
@@ -12,7 +12,7 @@ EXAMPLES = (
@pytest.mark.integration
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")
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()
assert body["status"] == "awaiting_approval"
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"]]
r2 = integration_client.post(
f"/executions/{trace_id}/approve",
f"/api/executions/{trace_id}/approve",
json={"approved_action_ids": pending, "comment": "OK rollback"},
)
assert r2.status_code == 200
@@ -31,10 +31,10 @@ def test_hitl_approve_completa(integration_client) -> None: # type: ignore[no-u
@pytest.mark.integration
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")
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"]
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.json()["status"] == "failed"
+1 -1
View File
@@ -6,7 +6,7 @@ import pytest
@pytest.mark.integration
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"
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
body = r.json()
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.
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()
assert body["status"] == "awaiting_approval"
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.
client_b = _fresh()
r2 = client_b.post(
f"/executions/{trace_id}/approve",
f"/api/executions/{trace_id}/approve",
json={"approved_action_ids": pending},
)
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:
r = TestClient(create_app()).get("/agents")
r = TestClient(create_app()).get("/api/agents")
assert r.status_code == 200
assert any(a["name"] == "incident_analyzer" for a in r.json())
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.json()["version"] == "v1"
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
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.json()[0]["id"] == "v1"
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.json()["name"] == "incident_analyzer"
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
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
body = r.json()
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:
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
body = r.json()
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:
r = client.post("/agents/inexistente/invoke", json={"input": "x"})
r = client.post("/api/agents/inexistente/invoke", json={"input": "x"})
assert r.status_code == 404
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
body = r.json()
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:
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"]
r = client.get(f"/executions/{trace_id}")
r = client.get(f"/api/executions/{trace_id}")
assert r.status_code == 200
assert r.json()["trace_id"] == trace_id
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
def test_list_executions_incluye_la_completada(client: TestClient) -> None:
client.post("/agents/incident_analyzer/invoke", json={"input": "degradación MOS pool SBC"})
r = client.get("/executions")
client.post("/api/agents/incident_analyzer/invoke", json={"input": "degradación MOS pool SBC"})
r = client.get("/api/executions")
assert r.status_code == 200
rows = r.json()
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
(la página de Aprobaciones del dashboard depende de ello)."""
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"]
rows = client.get("/executions").json()
rows = client.get("/api/executions").json()
awaiting = [r for r in rows if r["trace_id"] == trace_id]
assert len(awaiting) == 1
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:
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()
trace_id = invoked["trace_id"]
action_ids = [a["id"] for a in invoked["needs_human_for"]]
r = client.post(
f"/executions/{trace_id}/approve",
f"/api/executions/{trace_id}/approve",
json={"approved_action_ids": action_ids, "comment": "OK adelante"},
)
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:
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"]
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
body = r.json()
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:
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"]
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
def test_approve_trace_id_desconocido_da_404(client: TestClient) -> None:
r = client.post(
"/executions/11111111-1111-1111-1111-111111111111/approve",
"/api/executions/11111111-1111-1111-1111-111111111111/approve",
json={"approved_action_ids": []},
)
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 pathlib import Path
@@ -37,24 +37,24 @@ def client() -> TestClient:
def test_list_policies_incluye_default(client: TestClient) -> None:
r = client.get("/policies")
r = client.get("/api/policies")
assert r.status_code == 200
assert any(p["name"] == "default" for p in r.json())
def test_list_policy_versions(client: TestClient) -> None:
r = client.get("/policies/default/versions")
r = client.get("/api/policies/default/versions")
assert r.status_code == 200
assert r.json()[0]["id"] == "v1"
def test_list_policy_versions_inexistente_404(client: TestClient) -> None:
r = client.get("/policies/inexistente/versions")
r = client.get("/api/policies/inexistente/versions")
assert r.status_code == 404
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.json() == []
@@ -75,11 +75,11 @@ def test_violations_lista_y_filtra(client: TestClient, tmp_path: Path) -> None:
tid = uuid4()
append_violation(tmp_path, _violation(tid, "DetectPII", "block", blocked=True))
append_violation(tmp_path, _violation(uuid4(), "Schema", "warning", blocked=False))
assert len(client.get("/violations").json()) == 2
assert len(client.get("/violations", params={"severity": "block"}).json()) == 1
assert len(client.get("/violations", params={"trace_id": str(tid)}).json()) == 1
assert len(client.get("/api/violations").json()) == 2
assert len(client.get("/api/violations", params={"severity": "block"}).json()) == 1
assert len(client.get("/api/violations", params={"trace_id": str(tid)}).json()) == 1
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