diff --git a/.gitignore b/.gitignore index 91463bc..a88e8fb 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile index 87d082a..b2c3f7c 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/agents/incident_analyzer/versions/v1.yaml b/agents/incident_analyzer/versions/v1.yaml index 8e0d30f..e34f6c5 100644 --- a/agents/incident_analyzer/versions/v1.yaml +++ b/agents/incident_analyzer/versions/v1.yaml @@ -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 diff --git a/agents/incident_analyzer/versions/v2.yaml b/agents/incident_analyzer/versions/v2.yaml index beafc3f..3dedecd 100644 --- a/agents/incident_analyzer/versions/v2.yaml +++ b/agents/incident_analyzer/versions/v2.yaml @@ -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 diff --git a/core/Dockerfile b/core/Dockerfile index 68cca4b..b4e67ee 100644 --- a/core/Dockerfile +++ b/core/Dockerfile @@ -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 diff --git a/core/requirements.txt b/core/requirements.txt index 2a689d5..1c470a1 100644 --- a/core/requirements.txt +++ b/core/requirements.txt @@ -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 diff --git a/core/src/forja_core/domain/agent.py b/core/src/forja_core/domain/agent.py index 8513b2f..cf54e20 100644 --- a/core/src/forja_core/domain/agent.py +++ b/core/src/forja_core/domain/agent.py @@ -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" diff --git a/core/src/forja_core/main.py b/core/src/forja_core/main.py index 458ca49..b9d1349 100644 --- a/core/src/forja_core/main.py +++ b/core/src/forja_core/main.py @@ -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 diff --git a/core/src/forja_core/web/__init__.py b/core/src/forja_core/web/__init__.py new file mode 100644 index 0000000..4434155 --- /dev/null +++ b/core/src/forja_core/web/__init__.py @@ -0,0 +1 @@ +"""Paquete de UI HTMX embebida (FastAPI + Jinja2 + HTMX).""" diff --git a/core/src/forja_core/web/templates/agent_detail.html b/core/src/forja_core/web/templates/agent_detail.html new file mode 100644 index 0000000..95b75b2 --- /dev/null +++ b/core/src/forja_core/web/templates/agent_detail.html @@ -0,0 +1,103 @@ +
+ +
+
+ {{ agent.name }} + v{{ agent.version }} + + {{ agent.state }} + +
+
+ {{ agent.purpose }} +
+
+ + +
+
+
Owner
+
{{ agent.owner }}
+
+
+
Risk threshold (HITL)
+
{{ agent.risk_threshold_for_hitl }}
+
+ {% if agent.category %} +
+
Categoría
+
{{ agent.category }}
+
+ {% endif %} +
+ + +
+
+
LLM
+
+ {{ agent.llm.provider }} / {{ agent.llm.model }}
+ temp={{ agent.llm.temperature }} · max={{ agent.llm.max_tokens }} +
+
+ +
+
Guardrails
+
+ {% for g in agent.guardrails %} + {{ g }} + {% endfor %} +
+
+
+ + +
+
Comportamiento
+ + +
+ + Ver system prompt + expandir + + +
+ {{ agent.system_prompt }} +
+
+ + +
+
Output schema
+
{{ agent.output_schema | tojson(indent=2) }}
+
+
+ + + {% if agent.input_label or agent.input_placeholder or agent.updated_at %} +
+
Metadatos
+
+ {% if agent.input_label %} +
Input label: {{ agent.input_label }}
+ {% endif %} + {% if agent.input_placeholder %} +
Placeholder: {{ agent.input_placeholder }}
+ {% endif %} +
Última actualización: {{ agent.updated_at.strftime('%Y-%m-%d %H:%M') if agent.updated_at else '—' }}
+
+
+ {% endif %} + + {% if agent.tags %} +
+
Tags
+
+ {% for tag in agent.tags %} + {{ tag }} + {% endfor %} +
+
+ {% endif %} +
diff --git a/core/src/forja_core/web/templates/agent_detail_full.html b/core/src/forja_core/web/templates/agent_detail_full.html new file mode 100644 index 0000000..f6ac41a --- /dev/null +++ b/core/src/forja_core/web/templates/agent_detail_full.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block content %} +
+

{{ agent.name }} v{{ agent.version }}

+ {% include "agent_detail.html" %} +
+{% endblock %} diff --git a/core/src/forja_core/web/templates/agents.html b/core/src/forja_core/web/templates/agents.html new file mode 100644 index 0000000..d8e5333 --- /dev/null +++ b/core/src/forja_core/web/templates/agents.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block content %} +

Agentes registrados

+ +
+ +
+
+ {% for a in agents %} + +
+ + {{ a.icon or "🤖" }} + +
+
{{ a.name }}
+
v{{ a.version }} · {{ a.state }}
+
+
+
+ {% else %} +
No hay agentes registrados.
+ {% endfor %} +
+
+ + +
+
+
+
+ Selecciona un agente de la lista para ver su configuración y comportamiento. +
+
+
+
+
+{% endblock %} diff --git a/core/src/forja_core/web/templates/approvals.html b/core/src/forja_core/web/templates/approvals.html new file mode 100644 index 0000000..453cbb0 --- /dev/null +++ b/core/src/forja_core/web/templates/approvals.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} + +{% block content %} +

🤝 Aprobaciones Pendientes (HITL)

+ +
+{% for ex in pending %} +
+
+
+
{{ ex.agent_name }} v{{ ex.agent_version }}
+
{{ ex.trace_id }}
+
+
AWAITING APPROVAL
+
+ +
+ + +
+
+{% else %} +
No hay ejecuciones esperando aprobación.
+{% endfor %} +
+{% endblock %} diff --git a/core/src/forja_core/web/templates/base.html b/core/src/forja_core/web/templates/base.html new file mode 100644 index 0000000..f699a3b --- /dev/null +++ b/core/src/forja_core/web/templates/base.html @@ -0,0 +1,42 @@ + + + + + + Forja • {{ title or "Gobernanza de Agentes IA" }} + + + + + +
+
+
+
+ 🔨 Forja + {% if slogan %} + {{ slogan }} + {% endif %} +
+ +
+
+ +
+ {% block content %}{% endblock %} +
+
+ + diff --git a/core/src/forja_core/web/templates/history.html b/core/src/forja_core/web/templates/history.html new file mode 100644 index 0000000..4834b23 --- /dev/null +++ b/core/src/forja_core/web/templates/history.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block content %} +

📜 Historial de Ejecuciones

+ +
+{% for ex in executions %} +
+
+
{{ ex.trace_id }}
+
{{ ex.agent_name }} v{{ ex.agent_version }}
+
+
Status: {{ ex.status }}
+
+{% else %} +
No hay ejecuciones todavía.
+{% endfor %} +
+{% endblock %} diff --git a/core/src/forja_core/web/templates/home.html b/core/src/forja_core/web/templates/home.html new file mode 100644 index 0000000..76123c0 --- /dev/null +++ b/core/src/forja_core/web/templates/home.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} + +{% block content %} +
+ +

+ Forja +

+ +

+ Gobernanza profesional para agentes de IA +

+ + +
+ Plataforma de control para agentes IA.
+ Versionado de definiciones y políticas, guardrails en ejecución, + aprobaciones humanas y observabilidad completa. +
+ + +
+
+
Versionado Git-like
+
Agentes y políticas como YAML versionados. Diffs, histórico y control explícito de cambios.
+
+
+
Guardrails runtime
+
Validación automática de entradas y salidas con Presidio y reglas declarativas antes de ejecutar.
+
+
+
Human-in-the-Loop
+
Pausa automática en acciones de alto riesgo. Aprobación o rechazo con trazabilidad completa.
+
+
+ + +
+ + Ver Agentes + + + Ejecutar Agente + + + Aprobaciones + +
+
+{% endblock %} diff --git a/core/src/forja_core/web/templates/policies.html b/core/src/forja_core/web/templates/policies.html new file mode 100644 index 0000000..691b1f8 --- /dev/null +++ b/core/src/forja_core/web/templates/policies.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block content %} +

📐 Políticas

+ +
+{% for p in policies %} +
+
{{ p.name }} v{{ p.version }}
+
{{ p.description or 'Sin descripción' }}
+
Validadores: {{ p.validators | length }}
+
+{% endfor %} +
+{% endblock %} diff --git a/core/src/forja_core/web/templates/run.html b/core/src/forja_core/web/templates/run.html new file mode 100644 index 0000000..3fd8625 --- /dev/null +++ b/core/src/forja_core/web/templates/run.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block content %} +

▶️ Ejecutar Agente

+

Invoca un agente con todo el gobierno (guardrails + HITL + observabilidad).

+ +
+
+
+ + +
+ +
+ + +
+ + +
+
+ +
+{% endblock %} diff --git a/core/src/forja_core/web/ui.py b/core/src/forja_core/web/ui.py new file mode 100644 index 0000000..12afb2f --- /dev/null +++ b/core/src/forja_core/web/ui.py @@ -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('
Agente no encontrado
', 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'
Error: {str(e)[:300]}
', status_code=400) + + # Resultado simple pero informativo + html = f""" +
+
Ejecución completada
+
trace_id: {execution.trace_id}
+
Status: {execution.status}
+
+ Ver resultado completo +
{execution.model_dump_json(indent=2)}
+
+
+ """ + 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()}, + ) diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile deleted file mode 100644 index c65968f..0000000 --- a/dashboard/Dockerfile +++ /dev/null @@ -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"] diff --git a/dashboard/requirements.txt b/dashboard/requirements.txt deleted file mode 100644 index 85290fd..0000000 --- a/dashboard/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -streamlit>=1.38,<2.0 -httpx>=0.27,<0.28 -pydantic>=2.7,<3.0 -PyYAML>=6.0,<7.0 diff --git a/dashboard/src/forja_dashboard/__init__.py b/dashboard/src/forja_dashboard/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/dashboard/src/forja_dashboard/app.py b/dashboard/src/forja_dashboard/app.py deleted file mode 100644 index e9b612c..0000000 --- a/dashboard/src/forja_dashboard/app.py +++ /dev/null @@ -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() diff --git a/dashboard/src/forja_dashboard/client.py b/dashboard/src/forja_dashboard/client.py deleted file mode 100644 index 3cf8d67..0000000 --- a/dashboard/src/forja_dashboard/client.py +++ /dev/null @@ -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() diff --git a/dashboard/src/forja_dashboard/components/__init__.py b/dashboard/src/forja_dashboard/components/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/dashboard/src/forja_dashboard/components/diff_view.py b/dashboard/src/forja_dashboard/components/diff_view.py deleted file mode 100644 index f5d6b24..0000000 --- a/dashboard/src/forja_dashboard/components/diff_view.py +++ /dev/null @@ -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'{line}') - elif line.startswith("-") and not line.startswith("---"): - lines.append(f'{line}') - elif line.startswith("@@"): - lines.append(f'{line}') - else: - lines.append(line) - html = ( - "
" + "\n".join(lines) + "
" - ) - st.markdown(html, unsafe_allow_html=True) diff --git a/dashboard/src/forja_dashboard/components/trace_view.py b/dashboard/src/forja_dashboard/components/trace_view.py deleted file mode 100644 index 94aff8e..0000000 --- a/dashboard/src/forja_dashboard/components/trace_view.py +++ /dev/null @@ -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"]) diff --git a/dashboard/src/forja_dashboard/components/violation_view.py b/dashboard/src/forja_dashboard/components/violation_view.py deleted file mode 100644 index 584a22e..0000000 --- a/dashboard/src/forja_dashboard/components/violation_view.py +++ /dev/null @@ -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"]) diff --git a/dashboard/src/forja_dashboard/pages/1_🏛️_Registro.py b/dashboard/src/forja_dashboard/pages/1_🏛️_Registro.py deleted file mode 100644 index 0e36a47..0000000 --- a/dashboard/src/forja_dashboard/pages/1_🏛️_Registro.py +++ /dev/null @@ -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//`.") - 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"]) diff --git a/dashboard/src/forja_dashboard/pages/2_▶️_Ejecutar.py b/dashboard/src/forja_dashboard/pages/2_▶️_Ejecutar.py deleted file mode 100644 index d8368e4..0000000 --- a/dashboard/src/forja_dashboard/pages/2_▶️_Ejecutar.py +++ /dev/null @@ -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", [])) diff --git a/dashboard/src/forja_dashboard/pages/3_🤝_Aprobaciones.py b/dashboard/src/forja_dashboard/pages/3_🤝_Aprobaciones.py deleted file mode 100644 index 7683d2f..0000000 --- a/dashboard/src/forja_dashboard/pages/3_🤝_Aprobaciones.py +++ /dev/null @@ -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() diff --git a/dashboard/src/forja_dashboard/pages/4_📜_Historial.py b/dashboard/src/forja_dashboard/pages/4_📜_Historial.py deleted file mode 100644 index df72d43..0000000 --- a/dashboard/src/forja_dashboard/pages/4_📜_Historial.py +++ /dev/null @@ -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) diff --git a/dashboard/src/forja_dashboard/pages/5_📐_Politicas.py b/dashboard/src/forja_dashboard/pages/5_📐_Politicas.py deleted file mode 100644 index 6537fbe..0000000 --- a/dashboard/src/forja_dashboard/pages/5_📐_Politicas.py +++ /dev/null @@ -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) diff --git a/dashboard/src/forja_dashboard/pages/6_🔨_Forjar_Agente.py b/dashboard/src/forja_dashboard/pages/6_🔨_Forjar_Agente.py deleted file mode 100644 index acdd559..0000000 --- a/dashboard/src/forja_dashboard/pages/6_🔨_Forjar_Agente.py +++ /dev/null @@ -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}") diff --git a/dashboard/src/forja_dashboard/pages/7_🛡️_Forjar_Politica.py b/dashboard/src/forja_dashboard/pages/7_🛡️_Forjar_Politica.py deleted file mode 100644 index 83c2f52..0000000 --- a/dashboard/src/forja_dashboard/pages/7_🛡️_Forjar_Politica.py +++ /dev/null @@ -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)) diff --git a/dashboard/src/forja_dashboard/pages/__init__.py b/dashboard/src/forja_dashboard/pages/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/docker-compose.yml b/docker-compose.yml index f27edd5..72655ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/componentes.md b/docs/componentes.md index f1cfd90..8aca47a 100644 --- a/docs/componentes.md +++ b/docs/componentes.md @@ -66,9 +66,8 @@ 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: - **`domain/` no importa nada del resto del proyecto.** Es el vocabulario; todos dependen de él. @@ -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": }`). +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.` | 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`: diff --git a/docs/explicacion.md b/docs/explicacion.md index 22d3318..8ef5e8a 100644 --- a/docs/explicacion.md +++ b/docs/explicacion.md @@ -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 ``` diff --git a/karpathy.md b/karpathy.md new file mode 100644 index 0000000..05ddc06 --- /dev/null +++ b/karpathy.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 2f96e86..d4445e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "__