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
-3
View File
@@ -38,8 +38,5 @@ env/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Streamlit
.streamlit/secrets.toml
# Notas de hand-off entre sesiones de Claude Code # Notas de hand-off entre sesiones de Claude Code
claude-session.txt claude-session.txt
+4 -4
View File
@@ -4,16 +4,16 @@ help:
@echo "Targets: install lint format test test-all smoke up down logs clean" @echo "Targets: install lint format test test-all smoke up down logs clean"
install: install:
pip install -r core/requirements.txt -r dashboard/requirements.txt pip install -r core/requirements.txt
pip install pytest pytest-asyncio ruff mypy freezegun pip install pytest pytest-asyncio ruff mypy freezegun
lint: lint:
ruff check core/src dashboard/src tests ruff check core/src tests
mypy core/src mypy core/src
format: format:
ruff format core/src dashboard/src tests ruff format core/src tests
ruff check --fix core/src dashboard/src tests ruff check --fix core/src tests
test: test:
pytest tests/unit -v pytest tests/unit -v
@@ -37,4 +37,6 @@ output_schema:
type: object type: object
required: [id, action, target, risk_score, rollback_plan, requires_approval] required: [id, action, target, risk_score, rollback_plan, requires_approval]
risk_threshold_for_hitl: 4 risk_threshold_for_hitl: 4
icon: "🤖"
color: "#64748b"
updated_at: 2026-04-12T10:00:00Z updated_at: 2026-04-12T10:00:00Z
@@ -35,4 +35,6 @@ output_schema:
type: object type: object
required: [id, action, target, risk_score, rollback_plan, requires_approval] required: [id, action, target, risk_score, rollback_plan, requires_approval]
risk_threshold_for_hitl: 4 risk_threshold_for_hitl: 4
icon: "🤖"
color: "#f59e0b"
updated_at: 2026-05-01T12:00:00Z updated_at: 2026-05-01T12:00:00Z
+2
View File
@@ -30,6 +30,8 @@ USER agent
EXPOSE 8000 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 \ HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1 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 PyYAML>=6.0,<7.0
jsonschema>=4.0,<5.0 jsonschema>=4.0,<5.0
nemoguardrails>=0.10,<0.12 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 input_placeholder: str | None = None
category: str | None = None category: str | None = None
tags: list[str] = Field(default_factory=list) tags: list[str] = Field(default_factory=list)
icon: str | None = None icon: str | None = "🤖"
color: str | None = None
template: str = "governed_llm" template: str = "governed_llm"
+6
View File
@@ -24,13 +24,19 @@ def create_app() -> FastAPI:
async def health() -> dict[str, str]: async def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
from forja_core.web import ui
from forja_core.api import agents, executions, policies, violations from forja_core.api import agents, executions, policies, violations
# 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(agents.router, prefix="/agents", tags=["agents"])
app.include_router(executions.invoke_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(executions.router, prefix="/executions", tags=["executions"])
app.include_router(policies.router, prefix="/policies", tags=["policies"]) app.include_router(policies.router, prefix="/policies", tags=["policies"])
app.include_router(violations.router, prefix="/violations", tags=["violations"]) app.include_router(violations.router, prefix="/violations", tags=["violations"])
return app 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()},
)
-31
View File
@@ -1,31 +0,0 @@
# syntax=docker/dockerfile:1.7
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY dashboard/requirements.txt /app/requirements.txt
RUN pip install -r /app/requirements.txt
COPY dashboard/src /app/src
ENV PYTHONPATH=/app/src
RUN useradd --create-home --shell /bin/bash dash && chown -R dash:dash /app
USER dash
EXPOSE 8501
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
CMD curl -fsS http://localhost:8501/_stcore/health || exit 1
CMD ["streamlit", "run", "/app/src/forja_dashboard/app.py", \
"--server.address=0.0.0.0", "--server.port=8501", \
"--server.headless=true", "--browser.gatherUsageStats=false"]
-4
View File
@@ -1,4 +0,0 @@
streamlit>=1.38,<2.0
httpx>=0.27,<0.28
pydantic>=2.7,<3.0
PyYAML>=6.0,<7.0
-47
View File
@@ -1,47 +0,0 @@
"""Entry point del dashboard Streamlit con sidebar de branding y health."""
from __future__ import annotations
import streamlit as st
from forja_dashboard.client import CoreClient
@st.cache_resource
def get_client() -> CoreClient:
return CoreClient()
def main() -> None:
st.set_page_config(
page_title="Forja",
page_icon="🔨",
layout="wide",
initial_sidebar_state="expanded",
)
st.sidebar.markdown("## 🔨 Forja")
st.sidebar.caption("Forja de agentes IA — gobernanza para cualquier proyecto")
client = get_client()
try:
client.health()
st.sidebar.success("Core API: OK")
except Exception as exc:
st.sidebar.error(f"Core API unreachable: {exc}")
st.title("🔨 Forja")
st.markdown(
"""
Bienvenido a la forja de agentes IA.
Navega por la barra lateral:
- **Registro**: catálogo y versiones.
- **Ejecutar**: invocar con gobierno completo.
- **Aprobaciones** / **Historial**.
- **Politicas**.
- **🔨 Forjar Agente** y **🛡️ Forjar Politica**: editores gráficos (nuevo).
"""
)
if __name__ == "__main__":
main()
-85
View File
@@ -1,85 +0,0 @@
"""Cliente HTTP tipado al core. Encapsula httpx y mapea respuestas a dicts."""
from __future__ import annotations
import os
from typing import Any
import httpx
class CoreClient:
"""Cliente sincrono (Streamlit es sync). 2 retries con httpx Transport."""
def __init__(self, base_url: str | None = None, timeout: float = 30.0) -> None:
self.base_url = base_url or os.getenv("FORJA_CORE_URL", "http://localhost:8000")
transport = httpx.HTTPTransport(retries=2)
self._client = httpx.Client(base_url=self.base_url, timeout=timeout, transport=transport)
def health(self) -> dict:
return self._get("/health")
def list_agents(self) -> list[dict]:
return self._get("/agents")
def get_agent(self, name: str) -> dict:
return self._get(f"/agents/{name}")
def list_versions(self, name: str) -> list[dict]:
return self._get(f"/agents/{name}/versions")
def diff_versions(self, name: str, v_from: str, v_to: str) -> dict:
return self._get(f"/agents/{name}/versions/{v_from}/diff/{v_to}")
def invoke_agent(self, name: str, body: dict) -> dict:
return self._post(f"/agents/{name}/invoke", body)
def get_execution(self, trace_id: str) -> dict:
return self._get(f"/executions/{trace_id}")
def list_executions(self) -> list[dict]:
return self._get("/executions")
def approve(self, trace_id: str, body: dict) -> dict:
return self._post(f"/executions/{trace_id}/approve", body)
def reject(self, trace_id: str, body: dict) -> dict:
return self._post(f"/executions/{trace_id}/reject", body)
def list_violations(self, **filters: Any) -> list[dict]:
params = {k: v for k, v in filters.items() if v is not None}
return self._get("/violations", params=params)
def list_policies(self) -> list[dict]:
return self._get("/policies")
def list_policy_versions(self, name: str) -> list[dict]:
return self._get(f"/policies/{name}/versions")
def create_agent_version(self, name: str, agent_def: dict, message: str, author: str = "dashboard") -> dict:
body = {"agent": agent_def, "message": message, "author": author}
return self._post(f"/agents/{name}/versions", body)
def create_policy_version(self, name: str, policy_def: dict, message: str, author: str = "dashboard") -> dict:
body = {"policy": policy_def, "message": message, "author": author}
return self._post(f"/policies/{name}/versions", body)
def list_validators(self) -> dict:
return self._get("/policies/validators")
# ----- helpers privados -----
def _get(self, path: str, params: dict | None = None) -> Any:
r = self._client.get(path, params=params)
r.raise_for_status()
return r.json()
def _post(self, path: str, body: dict) -> Any:
r = self._client.post(path, json=body)
if r.status_code in {404, 409, 422}:
# Clave deliberadamente distinta de "error": el cuerpo de un AgentExecution
# correcto ya trae error=None, así que las páginas no podrían distinguir
# "ejecución sin error" de "la API rechazó la petición" si reusáramos "error".
return {"api_error": r.json()}
r.raise_for_status()
return r.json()
@@ -1,26 +0,0 @@
"""Componente para renderizar diffs unificados con coloreado básico."""
from __future__ import annotations
import streamlit as st
def render_unified_diff(diff_text: str) -> None:
if not diff_text.strip():
st.info("Sin diferencias entre las versiones seleccionadas.")
return
lines: list[str] = []
for line in diff_text.splitlines():
if line.startswith("+") and not line.startswith("+++"):
lines.append(f'<span style="color:#22c55e">{line}</span>')
elif line.startswith("-") and not line.startswith("---"):
lines.append(f'<span style="color:#ef4444">{line}</span>')
elif line.startswith("@@"):
lines.append(f'<span style="color:#3b82f6;font-weight:bold">{line}</span>')
else:
lines.append(line)
html = (
"<pre style='background:#0f172a;color:#e2e8f0;padding:12px;border-radius:6px;"
"font-size:12px;overflow:auto'>" + "\n".join(lines) + "</pre>"
)
st.markdown(html, unsafe_allow_html=True)
@@ -1,24 +0,0 @@
"""Renderiza el decision_path como timeline."""
from __future__ import annotations
import streamlit as st
def render_trace(decision_path: list[dict]) -> None:
if not decision_path:
st.info("Aún no hay traza disponible.")
return
st.subheader("📜 Decision path")
for step in decision_path:
with st.container(border=True):
cols = st.columns([2, 1, 1])
with cols[0]:
st.markdown(f"**{step['step']}**")
with cols[1]:
st.caption(step.get("timestamp", ""))
with cols[2]:
st.metric("ms", step.get("duration_ms", 0), label_visibility="collapsed")
if step.get("detail"):
with st.expander("detalle"):
st.json(step["detail"])
@@ -1,26 +0,0 @@
"""Renderiza violaciones de guardrails con badges de severidad."""
from __future__ import annotations
import streamlit as st
_SEV_BADGE = {
"info": "🟦",
"warning": "🟧",
"block": "🟥",
}
def render_violations(violations: list[dict]) -> None:
if not violations:
st.success("Sin violaciones.")
return
st.subheader("🛡️ Violaciones de guardrails")
for v in violations:
with st.container(border=True):
badge = _SEV_BADGE.get(v["severity"], "")
st.markdown(
f"{badge} **{v['validator']}** — `{v['stage']}` — "
f"severity=`{v['severity']}` — blocked=`{v['blocked']}`"
)
st.caption(v["message"])
@@ -1,61 +0,0 @@
"""Página de registro de agentes: lista, detalle, versiones y diff."""
from __future__ import annotations
import streamlit as st
from forja_dashboard.client import CoreClient
from forja_dashboard.components.diff_view import render_unified_diff
@st.cache_resource
def get_client() -> CoreClient:
return CoreClient()
client = get_client()
st.title("🏛️ Registro de Agentes")
st.caption("Catálogo central de agentes con versionado tipo Git.")
st.info("Usa **🔨 Forjar Agente** (barra lateral) para crear o versionar gráficamente.")
agents = client.list_agents()
if not agents:
st.warning("No hay agentes registrados. Coloca YAMLs bajo `agents/<name>/`.")
st.stop()
names = [a["name"] for a in agents]
selected_name = st.selectbox("Agente", names)
agent = client.get_agent(selected_name)
col1, col2 = st.columns([2, 1])
with col1:
st.subheader(f"{agent['name']} @ {agent['version']}")
st.write(f"**Estado:** `{agent['state']}` | **Owner:** {agent['owner']}")
st.write(f"**Propósito:** {agent['purpose']}")
st.write(f"**Guardrails activos:** {', '.join(agent['guardrails'])}")
st.write(f"**Threshold HITL:** risk_score ≥ {agent['risk_threshold_for_hitl']}")
with st.expander("System prompt"):
st.code(agent["system_prompt"], language="markdown")
with st.expander("Output schema"):
st.json(agent["output_schema"])
with col2:
st.subheader("LLM")
st.json(agent["llm"])
st.divider()
st.subheader("Historial de versiones")
versions = client.list_versions(selected_name)
st.dataframe(versions, hide_index=True, use_container_width=True)
if len(versions) >= 2:
st.subheader("Comparar versiones")
ids = [v["id"] for v in versions]
cf, ct = st.columns(2)
with cf:
v_from = st.selectbox("Desde", ids, index=0)
with ct:
v_to = st.selectbox("Hasta", ids, index=len(ids) - 1)
if v_from != v_to:
diff = client.diff_versions(selected_name, v_from, v_to)
render_unified_diff(diff["unified_diff"])
@@ -1,72 +0,0 @@
"""Página para invocar un agente y visualizar la traza completa."""
from __future__ import annotations
from pathlib import Path
import streamlit as st
from forja_dashboard.client import CoreClient
from forja_dashboard.components.trace_view import render_trace
from forja_dashboard.components.violation_view import render_violations
@st.cache_resource
def get_client() -> CoreClient:
return CoreClient()
client = get_client()
st.title("▶️ Ejecutar Agente")
st.caption("Lanza una ejecución con la cadena completa de gobierno.")
agents = client.list_agents()
if not agents:
st.warning("No hay agentes registrados.")
st.stop()
names = [a["name"] for a in agents]
agent_name = st.selectbox("Agente", names)
agent = client.get_agent(agent_name)
# Cargar escenarios pregrabados si existen en el disco montado.
examples_dir = Path("agents") / agent_name / "examples"
example_files = sorted(examples_dir.glob("*.txt")) if examples_dir.exists() else []
col_l, col_r = st.columns([3, 1])
with col_r:
st.markdown("**Escenarios**")
for ef in example_files:
if st.button(ef.stem, use_container_width=True):
st.session_state["input_text"] = ef.read_text(encoding="utf-8")
with col_l:
label = agent.get("input_label") or "Input para el agente"
ph = agent.get("input_placeholder") or "Escribe el input para el agente o usa un escenario..."
user_input = st.text_area(label, height=240, key="input_text", placeholder=ph)
if st.button("🚀 Invocar agente", type="primary", disabled=not user_input):
with st.spinner("Ejecutando..."):
result = client.invoke_agent(agent_name, {"input": user_input})
st.session_state["last_execution"] = result
execution = st.session_state.get("last_execution")
if execution and "api_error" in execution:
st.error(f"Error de la API: {execution['api_error']}")
elif execution:
st.divider()
st.markdown(f"**trace_id:** `{execution['trace_id']}`")
st.markdown(f"**Status:** `{execution['status']}`")
if execution["status"] == "awaiting_approval":
st.warning(
"Esta ejecución requiere aprobación humana. "
"Ve a la página **Aprobaciones** para revisar y decidir."
)
if execution.get("error"):
st.error(f"La ejecución terminó con error: `{execution['error']}`")
if execution.get("final_output"):
st.subheader("📦 Output final")
st.json(execution["final_output"])
render_violations(execution.get("violations", []))
render_trace(execution.get("decision_path", []))
@@ -1,82 +0,0 @@
"""Página de aprobaciones pendientes (HITL)."""
from __future__ import annotations
import streamlit as st
from forja_dashboard.client import CoreClient
@st.cache_resource
def get_client() -> CoreClient:
return CoreClient()
client = get_client()
st.title("🤝 Aprobaciones pendientes")
st.caption("Ejecuciones pausadas en HITL. Revisa cada acción propuesta y aprueba o rechaza.")
executions = client.list_executions()
pending = [e for e in executions if e["status"] == "awaiting_approval"]
if not pending:
st.success("No hay aprobaciones pendientes.")
st.stop()
selected = st.selectbox(
"Ejecución",
pending,
format_func=lambda e: f"{e['trace_id'][:8]}{e['agent_name']} @ {e['agent_version']}",
)
execution = client.get_execution(selected["trace_id"])
st.markdown(f"**trace_id:** `{execution['trace_id']}`")
st.markdown(f"**Agente:** `{execution['agent_name']}@{execution['agent_version']}`")
st.markdown(f"**Iniciada:** {execution['started_at']}")
if execution.get("final_output"):
with st.expander("Output bruto del LLM"):
st.json(execution["final_output"])
st.subheader("Acciones propuestas (requieren aprobación)")
needs = execution.get("needs_human_for") or []
if not needs:
st.info("No hay acciones que requieran aprobación.")
st.stop()
approved_ids: list[str] = []
for action in needs:
with st.container(border=True):
risk = action["risk_score"]
risk_color = {1: "🟩", 2: "🟩", 3: "🟨", 4: "🟧", 5: "🟥"}.get(risk, "")
st.markdown(f"### {risk_color} `{action['action']}` → `{action['target']}`")
st.write(f"**Risk score:** {risk}/5 | Requires approval: `{action['requires_approval']}`")
st.write(f"**Rollback plan:** {action['rollback_plan']}")
if st.checkbox("Aprobar esta acción", key=f"chk_{action['id']}"):
approved_ids.append(action["id"])
comment = st.text_input("Comentario (opcional)")
col_a, col_r = st.columns(2)
with col_a:
if st.button("✅ Aprobar seleccionadas", type="primary", disabled=not approved_ids):
with st.spinner("Aplicando aprobación..."):
r = client.approve(
execution["trace_id"],
{"approved_action_ids": approved_ids, "comment": comment},
)
if "api_error" in r:
st.error(r["api_error"])
else:
st.success(f"Status: {r['status']}")
st.rerun()
with col_r:
reason = st.text_input("Razón de rechazo")
if st.button("❌ Rechazar ejecución", disabled=not reason):
with st.spinner("Aplicando rechazo..."):
r = client.reject(execution["trace_id"], {"reason": reason})
if "api_error" in r:
st.error(r["api_error"])
else:
st.success(f"Status: {r['status']}")
st.rerun()
@@ -1,53 +0,0 @@
"""Página de historial de ejecuciones y log de violaciones."""
from __future__ import annotations
import streamlit as st
from forja_dashboard.client import CoreClient
from forja_dashboard.components.trace_view import render_trace
from forja_dashboard.components.violation_view import render_violations
@st.cache_resource
def get_client() -> CoreClient:
return CoreClient()
client = get_client()
st.title("📜 Historial")
st.caption("Ejecuciones registradas y log auditable de violaciones de guardrails.")
tabs = st.tabs(["Ejecuciones", "Violaciones"])
with tabs[0]:
executions = client.list_executions()
if not executions:
st.info("Aún no hay ejecuciones registradas.")
else:
st.dataframe(executions, hide_index=True, use_container_width=True)
trace_ids = [e["trace_id"] for e in executions]
selected = st.selectbox(
"Ver detalle",
trace_ids,
format_func=lambda t: t[:8] + "",
)
if selected:
detail = client.get_execution(selected)
st.markdown(f"**Status:** `{detail['status']}`")
if detail.get("error"):
st.error(f"Error: {detail['error']}")
if detail.get("final_output"):
st.subheader("Output final")
st.json(detail["final_output"])
render_violations(detail.get("violations", []))
render_trace(detail.get("decision_path", []))
with tabs[1]:
sev = st.selectbox("Filtrar severidad", ["(todas)", "info", "warning", "block"])
filter_kwargs = {} if sev == "(todas)" else {"severity": sev}
violations = client.list_violations(**filter_kwargs)
if not violations:
st.success("Sin violaciones registradas con ese filtro.")
else:
st.dataframe(violations, hide_index=True, use_container_width=True)
@@ -1,48 +0,0 @@
"""Página de políticas de guardrails y sus versiones."""
from __future__ import annotations
import streamlit as st
from forja_dashboard.client import CoreClient
@st.cache_resource
def get_client() -> CoreClient:
return CoreClient()
client = get_client()
st.title("📐 Politicas de guardrails")
st.caption("Inventario de políticas y sus versiones (estilo Git).")
st.info("Usa **🛡️ Forjar Politica** (barra lateral) para componer y versionar gráficamente.")
policies = client.list_policies()
if not policies:
st.warning("No hay políticas registradas.")
st.stop()
names = [p["name"] for p in policies]
selected = st.selectbox("Política", names)
policy = next(p for p in policies if p["name"] == selected)
st.subheader(f"{policy['name']} @ {policy['version']}")
st.write(f"**Descripción:** {policy['description']}")
st.write(f"**On validator error:** `{policy['on_validator_error']}`")
col_in, col_out = st.columns(2)
with col_in:
st.markdown("**Validadores de input**")
for v in policy["input_validators"]:
with st.expander(v["type"]):
st.json(v["config"])
with col_out:
st.markdown("**Validadores de output**")
for v in policy["output_validators"]:
with st.expander(v["type"]):
st.json(v["config"])
st.divider()
st.subheader("Historial de versiones")
versions = client.list_policy_versions(selected)
st.dataframe(versions, hide_index=True, use_container_width=True)
@@ -1,115 +0,0 @@
"""Página de editor gráfico para crear o versionar agentes (Forja)."""
from __future__ import annotations
import json
import streamlit as st
from forja_dashboard.client import CoreClient
@st.cache_resource
def get_client() -> CoreClient:
return CoreClient()
client = get_client()
st.title("🔨 Forjar Agente")
st.caption("Editor gráfico. Crea un agente nuevo o una nueva versión de uno existente. Todo se guarda como YAML versionado.")
agents = client.list_agents()
agent_names = [a["name"] for a in agents] if agents else []
mode = st.radio("Modo", ["Nuevo agente", "Nueva versión de existente"], horizontal=True)
base_name = None
if mode == "Nueva versión de existente" and agent_names:
base_name = st.selectbox("Agente base", agent_names)
elif mode == "Nueva versión de existente":
st.warning("No hay agentes. Crea uno nuevo.")
st.stop()
with st.form("forge_agent", clear_on_submit=False):
col1, col2 = st.columns(2)
with col1:
name = st.text_input("Nombre del agente", value=base_name or "mi-nuevo-agente")
version = st.text_input("Versión (ej. v1)", value="v1")
owner = st.text_input("Owner", value="usuario")
state = st.selectbox("Estado", ["draft", "active", "deprecated"], index=1)
with col2:
purpose = st.text_area("Propósito / descripción", value="Agente gobernado por Forja", height=80)
category = st.text_input("Categoría (opcional)", value="")
tags_str = st.text_input("Tags (coma separadas)", value="")
icon = st.text_input("Icono emoji (opcional)", value="🤖")
st.subheader("LLM")
llm_col1, llm_col2 = st.columns(4)
with llm_col1:
provider = st.selectbox("Provider", ["mock", "azure", "openai"], index=0)
with llm_col2:
model = st.text_input("Modelo", value="gpt-4o")
with llm_col3:
temperature = st.number_input("Temperature", 0.0, 2.0, 0.2, 0.1)
with llm_col4:
max_tokens = st.number_input("Max tokens", 1, 128000, 2000)
st.subheader("Prompt y Schema")
system_prompt = st.text_area("System prompt", value="Eres un asistente útil. Responde en JSON según el schema.", height=160)
schema_text = st.text_area("Output schema (JSON)", value=json.dumps({"type": "object", "properties": {"result": {"type": "string"}}}, indent=2), height=120)
st.subheader("Guardrails y HITL")
policies = client.list_policies()
policy_names = [p["name"] for p in policies] if policies else ["default"]
guardrails = st.multiselect("Políticas de guardrails activas", policy_names, default=policy_names[:1] if policy_names else [])
risk = st.slider("Risk threshold para HITL", 1, 5, 4)
st.subheader("Metadatos de versión")
message = st.text_input("Mensaje de versión (como commit)", value="Creación vía editor gráfico de Forja")
author = st.text_input("Autor", value="dashboard")
submitted = st.form_submit_button("🔨 Forjar / Guardar versión", type="primary")
if submitted:
try:
output_schema = json.loads(schema_text)
except Exception as e:
st.error(f"Schema JSON inválido: {e}")
st.stop()
tags = [t.strip() for t in tags_str.split(",") if t.strip()] if tags_str else []
agent_def = {
"name": name,
"version": version,
"owner": owner,
"purpose": purpose,
"state": state,
"guardrails": guardrails or ["default"],
"llm": {
"provider": provider,
"model": model,
"temperature": float(temperature),
"max_tokens": int(max_tokens),
},
"system_prompt": system_prompt,
"output_schema": output_schema,
"risk_threshold_for_hitl": int(risk),
"updated_at": "2026-05-23T00:00:00Z",
"input_label": "Input para el agente",
"input_placeholder": "Escribe aquí el input...",
"category": category or None,
"tags": tags,
"icon": icon or None,
"template": "governed_llm",
}
try:
res = client.create_agent_version(name, agent_def, message, author)
if "api_error" in res:
st.error(f"Error API: {res['api_error']}")
else:
st.success(f"Versión {version} de {name} creada correctamente. Recarga Registro para verla.")
st.json(res)
except Exception as exc:
st.error(f"Error al forjar: {exc}")
@@ -1,82 +0,0 @@
"""Página de editor gráfico para políticas de guardrails (Forja)."""
from __future__ import annotations
import json
import streamlit as st
from forja_dashboard.client import CoreClient
@st.cache_resource
def get_client() -> CoreClient:
return CoreClient()
client = get_client()
st.title("🛡️ Forjar Política")
st.caption("Compositor gráfico de políticas. Añade validadores de entrada/salida, configura y versiona.")
policies = client.list_policies()
policy_names = [p["name"] for p in policies] if policies else []
mode = st.radio("Modo", ["Nueva política", "Nueva versión de existente"], horizontal=True)
base = None
if mode == "Nueva versión de existente" and policy_names:
base = st.selectbox("Política base", policy_names)
validators_meta = client.list_validators()
input_types = validators_meta.get("input", [])
output_types = validators_meta.get("output", [])
def validator_form(stage: str, types: list[str], key_prefix: str) -> list[dict]:
st.markdown(f"**Validadores de {stage}**")
result = []
num = st.number_input(f"Número de validadores {stage}", 0, 12, 2, key=f"num_{key_prefix}")
for i in range(int(num)):
with st.expander(f"Validador {i+1} ({stage})", expanded=True):
t = st.selectbox("Tipo", types, key=f"type_{key_prefix}_{i}")
cfg_text = st.text_area("Config (JSON)", value="{}", key=f"cfg_{key_prefix}_{i}", height=80)
try:
cfg = json.loads(cfg_text)
except Exception:
cfg = {}
result.append({"type": t, "config": cfg})
return result
with st.form("forge_policy"):
name = st.text_input("Nombre de la política", value=base or "mi-politica")
version = st.text_input("Versión", value="v1")
description = st.text_area("Descripción", value="Política creada con el editor gráfico de Forja")
on_error = st.selectbox("on_validator_error", ["fail_closed", "fail_open"], index=0)
st.divider()
input_validators = validator_form("input", input_types, "in")
output_validators = validator_form("output", output_types, "out")
st.subheader("Metadatos de versión")
message = st.text_input("Mensaje", value="Creada vía UI gráfica")
author = st.text_input("Autor", value="dashboard")
submitted = st.form_submit_button("🛡️ Guardar versión de política", type="primary")
if submitted:
policy_def = {
"name": name,
"version": version,
"description": description,
"input_validators": input_validators,
"output_validators": output_validators,
"on_validator_error": on_error,
}
try:
res = client.create_policy_version(name, policy_def, message, author)
if "api_error" in res:
st.error(res["api_error"])
else:
st.success(f"Política {name}@{version} guardada.")
st.json(res)
except Exception as e:
st.error(str(e))
-17
View File
@@ -24,20 +24,3 @@ services:
retries: 3 retries: 3
start_period: 15s start_period: 15s
restart: unless-stopped restart: unless-stopped
dashboard:
build:
context: .
dockerfile: dashboard/Dockerfile
image: forja-dashboard:dev
container_name: forja-dashboard
depends_on:
core:
condition: service_healthy
ports:
- "8501:8501"
environment:
FORJA_CORE_URL: http://core:8000
volumes:
- ./agents:/app/agents:rw # rw para editores gráficos + escenarios
restart: unless-stopped
+11 -34
View File
@@ -66,8 +66,7 @@ NIVEL 5
NIVEL 6 NIVEL 6
main ⇐ api (routers), api.middlewares, config, observability.logging → create_app(), app main ⇐ api (routers), api.middlewares, config, observability.logging → create_app(), app
(separado, sin imports del paquete core) (UI HTMX co-locada dentro del propio forja-core; mismo proceso, sin cliente HTTP intermedio)
dashboard/src/ forja_dashboard/* ← habla con `main` por HTTP, no por import
``` ```
Reglas que se cumplen y conviene mantener: Reglas que se cumplen y conviene mantener:
@@ -333,33 +332,14 @@ Hashing/versionado: `registry/versioning.compute_hash(yaml_text)` = SHA-256 del
--- ---
## 6. Dashboard ↔ Core (` forja_dashboard`) ## 6. UI HTMX embebida en Core
El dashboard no comparte código con el core: solo lo llama por HTTP a través de Con la migración a Opción A (FastAPI + HTMX), la interfaz de usuario se sirve
`CoreClient` (`dashboard/src/ forja_dashboard/client.py`, httpx síncrono con 2 directamente desde el mismo proceso `forja-core` (Jinja2 + HTMX vía CDN, sin
retries, `base_url = FORJA_CORE_URL`; mapea 404/409/422 → `{"error": <json>}`). servicio separado). No hay `CoreClient` intermedio para las páginas; los
endpoints de UI llaman a los mismos servicios internos que la API REST.
| `CoreClient.<método>` | Endpoint del core | Página(s) que lo usan | La antigua sección de dashboard Streamlit ha sido eliminada.
|-----------------------|-------------------|-----------------------|
| `health()` | `GET /health` | `app.py` (sidebar) |
| `list_agents()` | `GET /agents` | Registro, Ejecutar |
| `get_agent(name)` | `GET /agents/{name}` | Registro |
| `list_versions(name)` | `GET /agents/{name}/versions` | Registro |
| `diff_versions(name, a, b)` | `GET /agents/{name}/versions/{a}/diff/{b}` | Registro (→ `components/diff_view`) |
| `invoke_agent(name, body)` | `POST /agents/{name}/invoke` | Ejecutar |
| `list_executions()` | `GET /executions` | Aprobaciones (filtra `status=="awaiting_approval"`), Historial |
| `get_execution(trace_id)` | `GET /executions/{trace_id}` | Aprobaciones, Historial (→ `components/trace_view`, `violation_view`) |
| `approve(trace_id, body)` | `POST /executions/{trace_id}/approve` | Aprobaciones |
| `reject(trace_id, body)` | `POST /executions/{trace_id}/reject` | Aprobaciones |
| `list_violations(**filters)` | `GET /violations?…` | Historial |
| `list_policies()` | `GET /policies` | Politicas |
| `list_policy_versions(name)` | `GET /policies/{name}/versions` | Politicas |
Páginas (Streamlit multipágina; el nº y el emoji del nombre del fichero son la
navegación): `app.py` (raíz), `pages/1_🏛️_Registro.py`, `pages/2_▶️_Ejecutar.py`,
`pages/3_🤝_Aprobaciones.py`, `pages/4_📜_Historial.py`, `pages/5_📐_Politicas.py`.
Componentes reutilizables: `components/diff_view.render_unified_diff(diff_text)`,
`components/trace_view.render_trace(decision_path)`, `components/violation_view.render_violations(violations)`.
--- ---
@@ -378,13 +358,10 @@ Componentes reutilizables: `components/diff_view.render_unified_diff(diff_text)`
pueden disparar la construcción perezosa) → handler → respuesta con `X-Trace-Id`. pueden disparar la construcción perezosa) → handler → respuesta con `X-Trace-Id`.
**Contenedores** (`docker-compose.yml`): servicio `core` (`core/Dockerfile`, **Contenedores** (`docker-compose.yml`): servicio `core` (`core/Dockerfile`,
`uvicorn forja_core.main:app --host 0.0.0.0 --port 8000`, `HEALTHCHECK` `uvicorn forja_core.main:app --host 0.0.0.0 --port 8000` para API + UI HTMX,
`curl /health`, monta `./agents:ro`, `./policies:ro`, `./data:rw`, env `HEALTHCHECK``curl /health`, monta `./agents:rw`, `./policies:rw`, `./data:rw`,
`DATA_DIR=/app/data`, `AGENTS_DIR=/app/agents`, `POLICIES_DIR=/app/policies`, env `DATA_DIR=/app/data`, `AGENTS_DIR=/app/agents`, `POLICIES_DIR=/app/policies`,
`env_file: .env`); servicio `dashboard` (`dashboard/Dockerfile`, `streamlit run `env_file: .env`). Un solo servicio.
app.py`, `HEALTHCHECK``/_stcore/health`, `depends_on: core: service_healthy`,
env `FORJA_CORE_URL=http://core:8000`, monta `./agents:ro` para leer los
`examples/*.txt`).
**`Settings` (env vars)** — `config.py`: **`Settings` (env vars)** — `config.py`:
+2 -5
View File
@@ -511,15 +511,12 @@ Forja no usa una sola base de datos; usa la herramienta adecuada para cada cosa.
│ │ ├── api/ ← FastAPI (middlewares, deps, routers, persistence) │ │ ├── api/ ← FastAPI (middlewares, deps, routers, persistence)
│ │ └── main.py ← create_app(): ensambla la app │ │ └── main.py ← create_app(): ensambla la app
│ ├── Dockerfile · requirements.txt │ ├── Dockerfile · requirements.txt
├── dashboard/
│ ├── src/ forja_dashboard/ ← Streamlit (client + app + pages + components)
│ ├── Dockerfile · requirements.txt
├── agents/incident_analyzer/ ← el agente de ejemplo (YAMLs + escenarios .txt) ├── agents/incident_analyzer/ ← el agente de ejemplo (YAMLs + escenarios .txt)
├── policies/default/ ← la política de guardrails de ejemplo ├── policies/default/ ← la política de guardrails de ejemplo
├── data/ ← estado runtime (gitignored) ├── data/ ← estado runtime (gitignored)
├── tests/ ← pytest: tests/unit/ y tests/integration/ (88 tests en total; el demo Streamlit no se testea con unit tests) ├── tests/ ← pytest: tests/unit/ y tests/integration/
├── docs/ ← este documento, manual_qa.md, futuro.md, superpowers/ ├── docs/ ← este documento, manual_qa.md, futuro.md, superpowers/
├── docker-compose.yml ← levanta core + dashboard ├── docker-compose.yml ← levanta core (API + UI HTMX en puerto 8000)
├── Makefile ← install / test / test-all / lint / smoke / up / down ├── Makefile ← install / test / test-all / lint / smoke / up / down
├── README.md · ARCHITECTURE.md ├── README.md · ARCHITECTURE.md
``` ```
+59
View File
@@ -0,0 +1,59 @@
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
+3 -11
View File
@@ -12,11 +12,8 @@ authors = [{name = "Juan", email = "jm0x@proton.me"}]
[project.dependencies] [project.dependencies]
httpx = ">=0.27,<0.28" httpx = ">=0.27,<0.28"
[project.optional-dependencies]
web = ["nicegui>=2.0"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["core/src", "common/src", "web/src"] where = ["core/src", "common/src"]
namespaces = false namespaces = false
[tool.ruff] [tool.ruff]
@@ -27,13 +24,8 @@ target-version = "py311"
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM", "ASYNC", "RUF"] select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM", "ASYNC", "RUF"]
ignore = ["E501"] ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
# Streamlit exige que las páginas se llamen "<n>_<emoji>_<Label>.py" para la
# navegación multipágina; eso choca con N999 (nombre de módulo no válido).
"dashboard/src/forja_dashboard/pages/*" = ["N999"]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
known-first-party = ["forja_core", "forja_dashboard", "forja_common", "forja_web"] known-first-party = ["forja_core", "forja_common"]
[tool.mypy] [tool.mypy]
python_version = "3.11" python_version = "3.11"
@@ -51,7 +43,7 @@ ignore_missing_imports = true
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
addopts = "-ra -q --strict-markers" addopts = "-ra -q --strict-markers"
pythonpath = ["core/src", "dashboard/src", "common/src", "web/src"] pythonpath = ["core/src", "common/src"]
markers = [ markers = [
"integration: integration tests (slower, may touch FS)", "integration: integration tests (slower, may touch FS)",
] ]
-59
View File
@@ -1,59 +0,0 @@
"""Tests del CoreClient: cómo mapea respuestas HTTP del core a dicts.
Importante para el dashboard: una ejecución correcta (``AgentExecution`` serializado)
incluye SIEMPRE el campo ``error`` (``str | None``). El wrapper que el cliente añade
ante un 4xx debe usar una clave distinta (``api_error``) para no colisionar con ese
campo; si usara ``error``, las páginas no podrían distinguir "el agente terminó sin
error" de "la API devolvió 404/409/422".
"""
from __future__ import annotations
from collections.abc import Callable
import httpx
import pytest
from forja_dashboard.client import CoreClient
def _client_with(handler: Callable[[httpx.Request], httpx.Response]) -> CoreClient:
client = CoreClient(base_url="http://test")
client._client.close()
client._client = httpx.Client(base_url="http://test", transport=httpx.MockTransport(handler))
return client
def test_post_2xx_devuelve_el_cuerpo_verbatim_incluido_error_none() -> None:
"""Un AgentExecution con ``error=None`` se devuelve tal cual, sin envolver."""
execution = {
"trace_id": "11111111-1111-1111-1111-111111111111",
"status": "completed",
"error": None,
"violations": [],
"decision_path": [],
}
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=execution)
result = _client_with(handler).invoke_agent("incident_analyzer", {"input": "x"})
assert result == execution
assert "api_error" not in result
@pytest.mark.parametrize("status", [404, 409, 422])
def test_post_4xx_envuelve_el_detalle_en_api_error(status: int) -> None:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(status, json={"detail": "boom"})
result = _client_with(handler).invoke_agent("incident_analyzer", {"input": "y"})
assert result == {"api_error": {"detail": "boom"}}
def test_post_5xx_propaga_la_excepcion() -> None:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(500, json={"detail": "kaput"})
with pytest.raises(httpx.HTTPStatusError):
_client_with(handler).invoke_agent("incident_analyzer", {"input": "z"})
-3
View File
@@ -1,3 +0,0 @@
nicegui>=2.0
httpx>=0.27,<0.28
rich>=13.0 # optional, for nice tables if we want
-5
View File
@@ -1,5 +0,0 @@
"""forja_web - Modern NiceGUI dashboard for Forja."""
from forja_web.main import main
__all__ = ["main"]
-79
View File
@@ -1,79 +0,0 @@
"""Forja Web - NiceGUI modern dashboard."""
from __future__ import annotations
from nicegui import ui
from forja_web.pages import agents_page, approvals_page, executions_page, home_page, policies_page, run_page
def main() -> None:
ui.page_title("Forja • Gobernanza de Agentes IA")
# Top bar
with ui.header().classes("bg-zinc-900 text-white items-center shadow-md"):
with ui.row().classes("w-full items-center px-6"):
ui.label("🔨 Forja").classes("text-2xl font-bold tracking-tight")
ui.space()
ui.label("Plataforma de gobernanza para agentes IA").classes("text-zinc-400 text-sm")
# Left drawer navigation
with ui.left_drawer(value=True, bordered=True).classes("bg-zinc-950 text-white"):
ui.label("Navegación").classes("text-xs uppercase tracking-widest text-zinc-500 px-2 pt-4 pb-1")
with ui.column().classes("gap-1"):
ui.link("Inicio", "/").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
ui.link("Agentes", "/agents").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
ui.link("Ejecutar", "/run").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
ui.link("Aprobaciones", "/approvals").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
ui.link("Historial", "/history").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
ui.link("Políticas", "/policies").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
ui.separator().classes("my-2")
ui.link("Forjar Agente", "/forge/agent").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
ui.link("Forjar Política", "/forge/policy").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
# Main content area with pages
@ui.page("/")
def home() -> None:
home_page.render()
@ui.page("/agents")
def agents() -> None:
agents_page.render()
@ui.page("/run")
def run() -> None:
run_page.render()
@ui.page("/approvals")
def approvals() -> None:
approvals_page.render()
@ui.page("/history")
def history() -> None:
executions_page.render()
@ui.page("/policies")
def policies() -> None:
policies_page.render()
@ui.page("/forge/agent")
def forge_agent() -> None:
ui.label("🔨 Forjar Agente").classes("text-3xl font-semibold")
ui.label("Editor gráfico moderno (en desarrollo)").classes("text-zinc-400 mt-2")
@ui.page("/forge/policy")
def forge_policy() -> None:
ui.label("🛡️ Forjar Política").classes("text-3xl font-semibold")
ui.label("Editor de políticas (en desarrollo)").classes("text-zinc-400 mt-2")
ui.run(
title="Forja",
host="0.0.0.0",
port=8501,
dark=True,
reload=True,
)
if __name__ == "__main__":
main()
-33
View File
@@ -1,33 +0,0 @@
"""Agents listing page."""
from nicegui import ui
from forja_common.client import CoreClient
def render() -> None:
ui.label("📚 Registro de Agentes").classes("text-3xl font-semibold mb-6")
client = CoreClient()
try:
agents = client.list_agents()
except Exception as e:
ui.label(f"Error cargando agentes: {e}").classes("text-red-400")
return
if not agents:
ui.label("No hay agentes registrados todavía.").classes("text-zinc-400")
return
for agent in agents:
with ui.card().classes("w-full mb-4"):
with ui.row().classes("items-center justify-between"):
ui.label(agent.get("name", "sin-nombre")).classes("text-xl font-bold")
ui.badge(agent.get("latest_version", "v?"), color="blue")
if "description" in agent:
ui.label(agent["description"]).classes("text-sm text-zinc-400")
with ui.row().classes("gap-2 mt-2"):
ui.button("Ver versiones", on_click=lambda n=agent["name"]: ui.navigate.to(f"/agents/{n}"))
-44
View File
@@ -1,44 +0,0 @@
"""Approvals page (HITL)."""
from nicegui import ui
from forja_common.client import CoreClient
def render() -> None:
ui.label("🤝 Aprobaciones pendientes").classes("text-3xl font-semibold mb-6")
client = CoreClient()
try:
executions = client.list_executions()
pending = [e for e in executions if e.get("status") in ("awaiting_approval", "hitl")]
except Exception as e:
ui.label(f"Error: {e}").classes("text-red-400")
return
if not pending:
ui.label("No hay ejecuciones esperando aprobación.").classes("text-emerald-400 text-lg")
return
for ex in pending:
with ui.card().classes("w-full mb-3"):
ui.label(f"Trace: {ex.get('trace_id')}").classes("font-mono text-sm")
ui.label(f"Agente: {ex.get('agent_name')}")
with ui.row():
ui.button("Aprobar", color="green", on_click=lambda t=ex["trace_id"]: approve(t))
ui.button("Rechazar", color="red", on_click=lambda t=ex["trace_id"]: reject(t))
def approve(trace_id: str) -> None:
client = CoreClient()
client.approve(trace_id, {"notes": "Approved from NiceGUI"})
ui.notify("Aprobado", type="positive")
ui.navigate.reload()
def reject(trace_id: str) -> None:
client = CoreClient()
client.reject(trace_id, {"reason": "Rejected from NiceGUI"})
ui.notify("Rechazado", type="negative")
ui.navigate.reload()
@@ -1,30 +0,0 @@
"""History / executions page."""
from nicegui import ui
from forja_common.client import CoreClient
def render() -> None:
ui.label("📜 Historial de Ejecuciones").classes("text-3xl font-semibold mb-6")
client = CoreClient()
try:
executions = client.list_executions()
except Exception as e:
ui.label(f"Error: {e}").classes("text-red-400")
return
if not executions:
ui.label("Aún no hay ejecuciones.").classes("text-zinc-400")
return
for ex in executions[:30]:
with ui.card().classes("w-full mb-2"):
with ui.row().classes("justify-between items-center"):
ui.label(ex.get("trace_id", "")[:20] + "...").classes("font-mono text-sm")
status = ex.get("status", "unknown")
color = "green" if status == "completed" else "orange"
ui.badge(status, color=color)
ui.label(f"{ex.get('agent_name')}{ex.get('created_at', '')}").classes("text-xs text-zinc-400")
-26
View File
@@ -1,26 +0,0 @@
"""Home page for the NiceGUI dashboard."""
from nicegui import ui
from forja_common.client import CoreClient
def render() -> None:
ui.label("Bienvenido a Forja").classes("text-4xl font-bold tracking-tight mt-8")
ui.label(
"Plataforma profesional de gobernanza para agentes de IA. "
"Versión moderna con NiceGUI."
).classes("text-lg text-zinc-400 mt-2")
ui.separator().classes("my-8")
with ui.card().classes("w-full max-w-2xl"):
ui.label("Estado del sistema").classes("text-xl font-semibold mb-4")
try:
client = CoreClient()
health = client.health()
ui.badge("Core API: Saludable", color="green").classes("text-lg")
ui.json_editor({"status": health})
except Exception as exc:
ui.badge(f"Core API unreachable: {exc}", color="red")
-27
View File
@@ -1,27 +0,0 @@
"""Policies page."""
from nicegui import ui
from forja_common.client import CoreClient
def render() -> None:
ui.label("🛡️ Políticas de Guardrails").classes("text-3xl font-semibold mb-6")
client = CoreClient()
try:
policies = client.list_policies()
except Exception as e:
ui.label(f"Error: {e}").classes("text-red-400")
return
if not policies:
ui.label("No hay políticas definidas.").classes("text-zinc-400")
return
for p in policies:
with ui.card().classes("w-full mb-3"):
ui.label(p.get("name")).classes("font-bold text-lg")
versions = client.list_policy_versions(p["name"])
ui.label(f"{len(versions)} versiones").classes("text-sm text-zinc-400")
-77
View File
@@ -1,77 +0,0 @@
"""Ejecutar agente page - modern invoke form."""
from __future__ import annotations
import json
from nicegui import ui
from forja_common.client import CoreClient
def render() -> None:
ui.label("▶️ Ejecutar Agente").classes("text-3xl font-semibold mb-2")
ui.label("Invoca un agente con todo el gobierno (guardrails + HITL + observabilidad)").classes("text-zinc-400 mb-6")
client = CoreClient()
# Load data
try:
agents = client.list_agents()
agent_names = [a["name"] for a in agents] if agents else []
policies = client.list_policies()
policy_names = [p["name"] for p in policies] if policies else []
except Exception as e:
ui.label(f"No se pudo conectar al Core: {e}").classes("text-red-400")
return
if not agent_names:
ui.label("No hay agentes registrados. Crea uno primero en 'Forjar Agente'.").classes("text-amber-400")
return
# Form state
selected_agent = ui.select(agent_names, label="Agente", value=agent_names[0]).classes("w-80")
scenario = ui.input("Escenario", value="01_sip_registration_drop").classes("w-80")
with ui.card().classes("w-full max-w-2xl mt-4"):
ui.label("Payload (JSON)").classes("font-medium mb-2")
payload_text = ui.textarea(
value=json.dumps({"scenario": "01_sip_registration_drop"}, indent=2),
placeholder='{"scenario": "..."}',
).classes("w-full font-mono text-sm").props("rows=6")
result_box = ui.column().classes("w-full max-w-2xl mt-4")
async def do_invoke() -> None:
result_box.clear()
try:
body = json.loads(payload_text.value or "{}")
if "scenario" not in body:
body["scenario"] = scenario.value
with result_box:
ui.spinner("dots", size="lg")
ui.label("Invocando agente con gobernanza...").classes("ml-2")
res = client.invoke_agent(selected_agent.value, body)
result_box.clear()
with result_box:
if "api_error" in res:
ui.label("Error de la API").classes("text-red-400 font-bold")
ui.json_editor(res["api_error"])
else:
ui.label("✅ Ejecución iniciada").classes("text-emerald-400 text-lg font-semibold")
ui.json_editor(res, options={"readOnly": True})
if "trace_id" in res:
trace = res["trace_id"]
ui.button(
"Ver traza completa",
on_click=lambda: ui.navigate.to(f"/executions/{trace}"),
).classes("mt-2")
except Exception as exc:
result_box.clear()
with result_box:
ui.label(f"Error: {exc}").classes("text-red-400")
ui.button("🚀 Invocar con Gobernanza", on_click=do_invoke, color="primary").classes("mt-4 px-8 py-2 text-lg")