añadido guidelines de karpathy y repensado del proyecto

This commit is contained in:
2026-05-25 19:28:58 +02:00
parent 8c594f8ddf
commit d95ed15766
51 changed files with 637 additions and 1215 deletions
+2
View File
@@ -30,6 +30,8 @@ USER agent
EXPOSE 8000
# Nota: Este servicio ahora sirve tanto la API REST como la UI HTMX (Opción A)
HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1
+4
View File
@@ -17,3 +17,7 @@ openai>=1.50,<2.0
PyYAML>=6.0,<7.0
jsonschema>=4.0,<5.0
nemoguardrails>=0.10,<0.12
# HTMX UI embebida (Opción A)
jinja2>=3.1,<4.0
python-multipart>=0.0.9,<0.1
+2 -1
View File
@@ -45,5 +45,6 @@ class AgentDefinition(BaseModel):
input_placeholder: str | None = None
category: str | None = None
tags: list[str] = Field(default_factory=list)
icon: str | None = None
icon: str | None = "🤖"
color: str | None = None
template: str = "governed_llm"
+6
View File
@@ -24,13 +24,19 @@ 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
# 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.
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"])
return app
+1
View File
@@ -0,0 +1 @@
"""Paquete de UI HTMX embebida (FastAPI + Jinja2 + HTMX)."""
@@ -0,0 +1,103 @@
<div class="space-y-6">
<!-- Header -->
<div>
<div class="flex items-center gap-3 flex-wrap">
<span class="text-3xl font-semibold tracking-tight">{{ agent.name }}</span>
<span class="text-sm px-2.5 py-0.5 rounded-lg bg-zinc-800 text-zinc-400">v{{ agent.version }}</span>
<span class="text-sm px-2.5 py-0.5 rounded-lg {% if agent.state == 'active' %}bg-emerald-900 text-emerald-300{% else %}bg-zinc-800 text-zinc-300{% endif %}">
{{ agent.state }}
</span>
</div>
<div class="text-zinc-400 mt-2 text-[15px] leading-snug">
{{ agent.purpose }}
</div>
</div>
<!-- Metadata principal -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
<div>
<div class="text-zinc-400 text-xs mb-1">Owner</div>
<div class="font-medium">{{ agent.owner }}</div>
</div>
<div>
<div class="text-zinc-400 text-xs mb-1">Risk threshold (HITL)</div>
<div class="font-medium">{{ agent.risk_threshold_for_hitl }}</div>
</div>
{% if agent.category %}
<div>
<div class="text-zinc-400 text-xs mb-1">Categoría</div>
<div>{{ agent.category }}</div>
</div>
{% endif %}
</div>
<!-- LLM + Guardrails -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="text-zinc-400 text-xs mb-1.5">LLM</div>
<div class="font-mono text-xs bg-zinc-950 border border-zinc-800 p-3 rounded-xl">
{{ agent.llm.provider }} / {{ agent.llm.model }}<br>
<span class="text-zinc-500">temp={{ agent.llm.temperature }} · max={{ agent.llm.max_tokens }}</span>
</div>
</div>
<div>
<div class="text-zinc-400 text-xs mb-1.5">Guardrails</div>
<div class="flex flex-wrap gap-1.5">
{% for g in agent.guardrails %}
<span class="text-xs px-3 py-1 bg-zinc-900 border border-zinc-800 rounded-full">{{ g }}</span>
{% endfor %}
</div>
</div>
</div>
<!-- Comportamiento (Prompt + Output Schema) -->
<div>
<div class="text-zinc-400 text-xs mb-1.5">Comportamiento</div>
<!-- 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 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>
<div class="mt-2 p-4 bg-black border border-zinc-800 rounded-xl text-sm whitespace-pre-wrap font-light leading-relaxed">
{{ agent.system_prompt }}
</div>
</details>
<!-- Output Schema -->
<div>
<div class="text-xs text-zinc-400 mb-1.5">Output schema</div>
<pre class="text-[10px] bg-zinc-950 border border-zinc-800 p-3 rounded-xl overflow-auto max-h-48 text-zinc-300">{{ agent.output_schema | tojson(indent=2) }}</pre>
</div>
</div>
<!-- Metadatos adicionales -->
{% if agent.input_label or agent.input_placeholder or agent.updated_at %}
<div class="pt-2 border-t border-zinc-800">
<div class="text-zinc-400 text-xs mb-2">Metadatos</div>
<div class="text-sm space-y-1 text-zinc-300">
{% if agent.input_label %}
<div><span class="text-zinc-500">Input label:</span> {{ agent.input_label }}</div>
{% endif %}
{% 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>
</div>
{% endif %}
{% if agent.tags %}
<div>
<div class="text-zinc-400 text-xs mb-1.5">Tags</div>
<div class="flex flex-wrap gap-1.5">
{% for tag in agent.tags %}
<span class="text-xs px-3 py-0.5 bg-zinc-900 border border-zinc-800 rounded-full">{{ tag }}</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-3xl">
<h1 class="text-2xl font-semibold mb-4">{{ agent.name }} <span class="text-sm text-zinc-400">v{{ agent.version }}</span></h1>
{% include "agent_detail.html" %}
</div>
{% endblock %}
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block content %}
<h1 class="text-3xl font-semibold mb-6">Agentes registrados</h1>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Lista de agentes -->
<div class="lg:col-span-1">
<div class="border border-zinc-800 bg-zinc-900 rounded-xl overflow-hidden">
{% for a in agents %}
<a href="/agents/{{ a.name }}"
hx-get="/agents/{{ a.name }}"
hx-target="#agent-detail"
hx-swap="innerHTML"
class="block px-4 py-3 border-b border-zinc-800 last:border-b-0 hover:bg-zinc-800 cursor-pointer">
<div class="flex items-center gap-3">
<span class="text-2xl flex-shrink-0"
{% if a.color %}style="color: {{ a.color }}"{% endif %}>
{{ a.icon or "🤖" }}
</span>
<div class="min-w-0">
<div class="font-medium truncate">{{ a.name }}</div>
<div class="text-xs text-zinc-400">v{{ a.version }} · {{ a.state }}</div>
</div>
</div>
</a>
{% else %}
<div class="px-4 py-6 text-zinc-400">No hay agentes registrados.</div>
{% endfor %}
</div>
</div>
<!-- Área de detalle -->
<div class="lg:col-span-2">
<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.
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block content %}
<h1 class="text-3xl font-semibold mb-6">🤝 Aprobaciones Pendientes (HITL)</h1>
<div class="space-y-4 max-w-4xl" id="approvals-list">
{% for ex in pending %}
<div class="border border-amber-800 bg-zinc-900 rounded-xl p-4">
<div class="flex justify-between items-start">
<div>
<div class="font-medium">{{ ex.agent_name }} v{{ ex.agent_version }}</div>
<div class="text-xs text-zinc-400">{{ ex.trace_id }}</div>
</div>
<div class="text-amber-400 text-xs">AWAITING APPROVAL</div>
</div>
<div class="mt-3 flex gap-2">
<button
hx-post="/approvals/{{ ex.trace_id }}/approve"
hx-target="#approvals-list"
hx-swap="outerHTML"
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-target="#approvals-list"
hx-swap="outerHTML"
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>
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Forja • {{ title or "Gobernanza de Agentes IA" }}</title>
<script src="https://cdn.tailwindcss.com"></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; }
</style>
</head>
<body class="bg-zinc-950 text-zinc-200">
<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">
{% block content %}{% endblock %}
</main>
</div>
</body>
</html>
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block content %}
<h1 class="text-3xl font-semibold mb-6">📜 Historial de Ejecuciones</h1>
<div class="space-y-3 max-w-4xl">
{% for ex in executions %}
<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="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>
{% else %}
<div class="text-zinc-400">No hay ejecuciones todavía.</div>
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,53 @@
{% 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>
<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.
</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>
</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
</a>
</div>
</div>
{% endblock %}
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block content %}
<h1 class="text-3xl font-semibold mb-6">📐 Políticas</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-xs mt-2 text-zinc-500">Validadores: {{ p.validators | length }}</div>
</div>
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,33 @@
{% 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>
<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>
<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>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm mb-1">Input / Escenario</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 class="htmx-indicator">...</span>
</button>
</form>
</div>
<div id="result" class="mt-6"></div>
{% endblock %}
+183
View File
@@ -0,0 +1,183 @@
"""HTMX UI router — montado dentro del propio forja-core (Opción A)."""
from __future__ import annotations
import asyncio
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from forja_core.api.deps import get_orchestrator, get_policy_store, get_registry, get_settings
from forja_core.api.persistence import read_execution_summaries
from forja_core.domain.execution import AgentExecution
router = APIRouter(prefix="", tags=["ui"])
# Ruta robusta a los templates (funciona tanto en dev como dentro del contenedor)
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",
]
def get_slogan() -> str:
import random
return random.choice(SLOGANS)
@router.get("/", response_class=HTMLResponse)
async def home(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
"home.html",
{"request": request, "title": "Inicio", "slogan": get_slogan()},
)
@router.get("/agents", response_class=HTMLResponse)
async def agents_page(request: Request) -> HTMLResponse:
registry = get_registry()
agents = registry.list_agents()
return templates.TemplateResponse(
"agents.html",
{"request": request, "title": "Agentes", "agents": agents, "slogan": get_slogan()},
)
@router.get("/agents/{name}", response_class=HTMLResponse)
async def agent_detail(request: Request, name: str) -> HTMLResponse:
registry = get_registry()
try:
agent = registry.get_agent(name)
except FileNotFoundError:
return HTMLResponse('<div class="text-red-400">Agente no encontrado</div>', status_code=404)
# Si viene por HTMX, devolvemos solo el fragmento (sin layout)
if request.headers.get("HX-Request"):
return templates.TemplateResponse(
"agent_detail.html",
{"request": request, "agent": agent, "slogan": get_slogan()},
)
# Acceso directo por navegador → página completa
return templates.TemplateResponse(
"agent_detail_full.html",
{"request": request, "title": agent.name, "agent": agent, "slogan": get_slogan()},
)
@router.get("/run", response_class=HTMLResponse)
async def run_page(request: Request) -> HTMLResponse:
registry = get_registry()
agents = registry.list_agents()
return templates.TemplateResponse(
"run.html",
{"request": request, "title": "Ejecutar", "agents": agents, "slogan": get_slogan()},
)
@router.post("/run", response_class=HTMLResponse)
async def run_invoke(
request: Request,
agent_name: str = Form(...),
input: str = Form(...),
) -> HTMLResponse:
"""Ejecuta el agente usando el orchestrator directamente (mismo proceso)."""
from forja_core.api.deps import get_policy_store
registry = get_registry()
policy_store = get_policy_store()
orchestrator = get_orchestrator()
try:
agent_def = registry.get_agent(agent_name) # resuelve la versión activa
policy_name = agent_def.guardrails[0] if agent_def.guardrails else "default"
policy = policy_store.get_policy(policy_name)
execution: AgentExecution = asyncio.run(
orchestrator.invoke(
agent_def=agent_def,
policy=policy,
user_input=input,
)
)
except Exception as e:
return HTMLResponse(f'<div class="text-red-400">Error: {str(e)[:300]}</div>', status_code=400)
# Resultado simple pero informativo
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>
"""
return HTMLResponse(html)
@router.get("/policies", response_class=HTMLResponse)
async def policies_page(request: Request) -> HTMLResponse:
policy_store = get_policy_store()
policies = policy_store.list_policies()
return templates.TemplateResponse(
"policies.html",
{"request": request, "title": "Políticas", "policies": policies, "slogan": get_slogan()},
)
@router.get("/history", response_class=HTMLResponse)
async def history_page(request: Request) -> HTMLResponse:
data_dir = get_settings().data_dir
executions = read_execution_summaries(data_dir)[:50]
return templates.TemplateResponse(
"history.html",
{"request": request, "title": "Historial", "executions": executions, "slogan": get_slogan()},
)
# ====================== APROBACIONES (HITL) ======================
@router.get("/approvals", response_class=HTMLResponse)
async def approvals_page(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()},
)
@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()},
)