añadido guidelines de karpathy y repensado del proyecto
This commit is contained in:
@@ -38,8 +38,5 @@ env/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
|
||||
# Notas de hand-off entre sesiones de Claude Code
|
||||
claude-session.txt
|
||||
|
||||
@@ -4,16 +4,16 @@ help:
|
||||
@echo "Targets: install lint format test test-all smoke up down logs clean"
|
||||
|
||||
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
|
||||
|
||||
lint:
|
||||
ruff check core/src dashboard/src tests
|
||||
ruff check core/src tests
|
||||
mypy core/src
|
||||
|
||||
format:
|
||||
ruff format core/src dashboard/src tests
|
||||
ruff check --fix core/src dashboard/src tests
|
||||
ruff format core/src tests
|
||||
ruff check --fix core/src tests
|
||||
|
||||
test:
|
||||
pytest tests/unit -v
|
||||
|
||||
@@ -37,4 +37,6 @@ output_schema:
|
||||
type: object
|
||||
required: [id, action, target, risk_score, rollback_plan, requires_approval]
|
||||
risk_threshold_for_hitl: 4
|
||||
icon: "🤖"
|
||||
color: "#64748b"
|
||||
updated_at: 2026-04-12T10:00:00Z
|
||||
|
||||
@@ -35,4 +35,6 @@ output_schema:
|
||||
type: object
|
||||
required: [id, action, target, risk_score, rollback_plan, requires_approval]
|
||||
risk_threshold_for_hitl: 4
|
||||
icon: "🤖"
|
||||
color: "#f59e0b"
|
||||
updated_at: 2026-05-01T12:00:00Z
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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()},
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -1,4 +0,0 @@
|
||||
streamlit>=1.38,<2.0
|
||||
httpx>=0.27,<0.28
|
||||
pydantic>=2.7,<3.0
|
||||
PyYAML>=6.0,<7.0
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -24,20 +24,3 @@ services:
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
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
@@ -66,8 +66,7 @@ NIVEL 5
|
||||
NIVEL 6
|
||||
main ⇐ api (routers), api.middlewares, config, observability.logging → create_app(), app
|
||||
|
||||
(separado, sin imports del paquete core)
|
||||
dashboard/src/ forja_dashboard/* ← habla con `main` por HTTP, no por import
|
||||
(UI HTMX co-locada dentro del propio forja-core; mismo proceso, sin cliente HTTP intermedio)
|
||||
```
|
||||
|
||||
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
|
||||
`CoreClient` (`dashboard/src/ forja_dashboard/client.py`, httpx síncrono con 2
|
||||
retries, `base_url = FORJA_CORE_URL`; mapea 404/409/422 → `{"error": <json>}`).
|
||||
Con la migración a Opción A (FastAPI + HTMX), la interfaz de usuario se sirve
|
||||
directamente desde el mismo proceso `forja-core` (Jinja2 + HTMX vía CDN, sin
|
||||
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 |
|
||||
|-----------------------|-------------------|-----------------------|
|
||||
| `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)`.
|
||||
La antigua sección de dashboard Streamlit ha sido eliminada.
|
||||
|
||||
---
|
||||
|
||||
@@ -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`.
|
||||
|
||||
**Contenedores** (`docker-compose.yml`): servicio `core` (`core/Dockerfile`,
|
||||
`uvicorn forja_core.main:app --host 0.0.0.0 --port 8000`, `HEALTHCHECK` →
|
||||
`curl /health`, monta `./agents:ro`, `./policies:ro`, `./data:rw`, env
|
||||
`DATA_DIR=/app/data`, `AGENTS_DIR=/app/agents`, `POLICIES_DIR=/app/policies`,
|
||||
`env_file: .env`); servicio `dashboard` (`dashboard/Dockerfile`, `streamlit run
|
||||
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`).
|
||||
`uvicorn forja_core.main:app --host 0.0.0.0 --port 8000` para API + UI HTMX,
|
||||
`HEALTHCHECK` → `curl /health`, monta `./agents:rw`, `./policies:rw`, `./data:rw`,
|
||||
env `DATA_DIR=/app/data`, `AGENTS_DIR=/app/agents`, `POLICIES_DIR=/app/policies`,
|
||||
`env_file: .env`). Un solo servicio.
|
||||
|
||||
**`Settings` (env vars)** — `config.py`:
|
||||
|
||||
|
||||
+2
-5
@@ -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)
|
||||
│ │ └── main.py ← create_app(): ensambla la app
|
||||
│ ├── 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)
|
||||
├── policies/default/ ← la política de guardrails de ejemplo
|
||||
├── 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/
|
||||
├── 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
|
||||
├── README.md · ARCHITECTURE.md
|
||||
```
|
||||
|
||||
+59
@@ -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
@@ -12,11 +12,8 @@ authors = [{name = "Juan", email = "jm0x@proton.me"}]
|
||||
[project.dependencies]
|
||||
httpx = ">=0.27,<0.28"
|
||||
|
||||
[project.optional-dependencies]
|
||||
web = ["nicegui>=2.0"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["core/src", "common/src", "web/src"]
|
||||
where = ["core/src", "common/src"]
|
||||
namespaces = false
|
||||
|
||||
[tool.ruff]
|
||||
@@ -27,13 +24,8 @@ target-version = "py311"
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM", "ASYNC", "RUF"]
|
||||
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]
|
||||
known-first-party = ["forja_core", "forja_dashboard", "forja_common", "forja_web"]
|
||||
known-first-party = ["forja_core", "forja_common"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
@@ -51,7 +43,7 @@ ignore_missing_imports = true
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
addopts = "-ra -q --strict-markers"
|
||||
pythonpath = ["core/src", "dashboard/src", "common/src", "web/src"]
|
||||
pythonpath = ["core/src", "common/src"]
|
||||
markers = [
|
||||
"integration: integration tests (slower, may touch FS)",
|
||||
]
|
||||
|
||||
@@ -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"})
|
||||
@@ -1,3 +0,0 @@
|
||||
nicegui>=2.0
|
||||
httpx>=0.27,<0.28
|
||||
rich>=13.0 # optional, for nice tables if we want
|
||||
@@ -1,5 +0,0 @@
|
||||
"""forja_web - Modern NiceGUI dashboard for Forja."""
|
||||
|
||||
from forja_web.main import main
|
||||
|
||||
__all__ = ["main"]
|
||||
@@ -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()
|
||||
@@ -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}"))
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user