From 8c594f8ddfdf7bdbeaa0edf8ba945bf2e6e0acf7 Mon Sep 17 00:00:00 2001 From: Juan Marquez Date: Sat, 23 May 2026 21:46:44 +0200 Subject: [PATCH] migracion de streamlit a niceguy --- common/src/forja_common/__init__.py | 5 ++ common/src/forja_common/client.py | 87 ++++++++++++++++++++++ core/requirements.txt | 3 - docs/forja-progreso.html | 25 +++++-- pyproject.toml | 14 +++- web/requirements.txt | 3 + web/src/forja_web/__init__.py | 5 ++ web/src/forja_web/main.py | 79 ++++++++++++++++++++ web/src/forja_web/pages/agents_page.py | 33 ++++++++ web/src/forja_web/pages/approvals_page.py | 44 +++++++++++ web/src/forja_web/pages/executions_page.py | 30 ++++++++ web/src/forja_web/pages/home_page.py | 26 +++++++ web/src/forja_web/pages/policies_page.py | 27 +++++++ web/src/forja_web/pages/run_page.py | 77 +++++++++++++++++++ 14 files changed, 446 insertions(+), 12 deletions(-) create mode 100644 common/src/forja_common/__init__.py create mode 100644 common/src/forja_common/client.py create mode 100644 web/requirements.txt create mode 100644 web/src/forja_web/__init__.py create mode 100644 web/src/forja_web/main.py create mode 100644 web/src/forja_web/pages/agents_page.py create mode 100644 web/src/forja_web/pages/approvals_page.py create mode 100644 web/src/forja_web/pages/executions_page.py create mode 100644 web/src/forja_web/pages/home_page.py create mode 100644 web/src/forja_web/pages/policies_page.py create mode 100644 web/src/forja_web/pages/run_page.py diff --git a/common/src/forja_common/__init__.py b/common/src/forja_common/__init__.py new file mode 100644 index 0000000..200da0a --- /dev/null +++ b/common/src/forja_common/__init__.py @@ -0,0 +1,5 @@ +"""forja_common - shared utilities (client, types, etc.).""" + +from forja_common.client import CoreClient + +__all__ = ["CoreClient"] diff --git a/common/src/forja_common/client.py b/common/src/forja_common/client.py new file mode 100644 index 0000000..125259d --- /dev/null +++ b/common/src/forja_common/client.py @@ -0,0 +1,87 @@ +"""Shared HTTP client for Forja Core API (used by web and cli).""" + +from __future__ import annotations + +import os +from typing import Any + +import httpx + + +class CoreClient: + """Synchronous HTTP client to the Forja Core API.""" + + 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 close(self) -> None: + self._client.close() + + # --- Public API methods --- + + 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 = "cli") -> 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 = "cli") -> 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") + + # --- Internal helpers --- + + 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}: + return {"api_error": r.json()} + r.raise_for_status() + return r.json() diff --git a/core/requirements.txt b/core/requirements.txt index a66b2ce..2a689d5 100644 --- a/core/requirements.txt +++ b/core/requirements.txt @@ -9,11 +9,8 @@ langgraph-checkpoint-sqlite>=2.0,<3.0 # AsyncSqliteSaver (langgraph-checkpoint-sqlite 2.x) usa Connection.is_alive(), # eliminado en aiosqlite 0.21. Fijado a la última serie compatible. aiosqlite>=0.20,<0.21 -guardrails-ai>=0.5,<0.6 -# typer 0.12.x (pin transitivo de guardrails-ai <0.6) rompe con click >= 8.2 # ("Secondary flag is not valid for non-boolean flag" al ejecutar `spacy download` # y la CLI de typer/weasel). Fijado a la última serie 8.1. -click>=8.1,<8.2 presidio-analyzer>=2.2,<3.0 presidio-anonymizer>=2.2,<3.0 openai>=1.50,<2.0 diff --git a/docs/forja-progreso.html b/docs/forja-progreso.html index 57db408..c24ef0e 100644 --- a/docs/forja-progreso.html +++ b/docs/forja-progreso.html @@ -139,12 +139,12 @@
Progreso general
-
78
+
82
/100
-
+
@@ -154,7 +154,7 @@
UI Gráfica
-
95%
+
68%
Docs
@@ -185,7 +185,7 @@
-
+
@@ -194,6 +194,17 @@
+ +
+
+
+
+
Modernización de interfaz
+
Migración Streamlit → NiceGUI
+
+
+
+
@@ -378,13 +389,13 @@
-
Paquetes: forja_core / forja_dashboard
+
Paquetes: forja_core / forja_web (NiceGUI) + forja_common
Python: 3.11+
-
UI: Streamlit 1.38 + Tailwind (editores)
+
UI: NiceGUI (reemplazo moderno de Streamlit) + Tailwind
Runtime: LangGraph + FastAPI
Persistencia: YAML versionado + SQLite checkpoints
-
Este documento fue generado automáticamente tras la sesión de refactorización del 23 de mayo de 2026.
+
Actualizado el 23 de mayo de 2026 — Migración a NiceGUI iniciada, CLI removido por decisión de producto.
diff --git a/pyproject.toml b/pyproject.toml index 28e950b..2f96e86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,16 @@ description = "Plataforma profesional de gobernanza de agentes IA" requires-python = ">=3.11" 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"] +namespaces = false + [tool.ruff] line-length = 100 target-version = "py311" @@ -23,7 +33,7 @@ ignore = ["E501"] "dashboard/src/forja_dashboard/pages/*" = ["N999"] [tool.ruff.lint.isort] -known-first-party = ["forja_core", "forja_dashboard"] +known-first-party = ["forja_core", "forja_dashboard", "forja_common", "forja_web"] [tool.mypy] python_version = "3.11" @@ -41,7 +51,7 @@ ignore_missing_imports = true asyncio_mode = "auto" testpaths = ["tests"] addopts = "-ra -q --strict-markers" -pythonpath = ["core/src", "dashboard/src"] +pythonpath = ["core/src", "dashboard/src", "common/src", "web/src"] markers = [ "integration: integration tests (slower, may touch FS)", ] diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..c9cbe6a --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,3 @@ +nicegui>=2.0 +httpx>=0.27,<0.28 +rich>=13.0 # optional, for nice tables if we want diff --git a/web/src/forja_web/__init__.py b/web/src/forja_web/__init__.py new file mode 100644 index 0000000..a5ce9c5 --- /dev/null +++ b/web/src/forja_web/__init__.py @@ -0,0 +1,5 @@ +"""forja_web - Modern NiceGUI dashboard for Forja.""" + +from forja_web.main import main + +__all__ = ["main"] diff --git a/web/src/forja_web/main.py b/web/src/forja_web/main.py new file mode 100644 index 0000000..aefc538 --- /dev/null +++ b/web/src/forja_web/main.py @@ -0,0 +1,79 @@ +"""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() diff --git a/web/src/forja_web/pages/agents_page.py b/web/src/forja_web/pages/agents_page.py new file mode 100644 index 0000000..87fe57e --- /dev/null +++ b/web/src/forja_web/pages/agents_page.py @@ -0,0 +1,33 @@ +"""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}")) diff --git a/web/src/forja_web/pages/approvals_page.py b/web/src/forja_web/pages/approvals_page.py new file mode 100644 index 0000000..e3bbbb3 --- /dev/null +++ b/web/src/forja_web/pages/approvals_page.py @@ -0,0 +1,44 @@ +"""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() diff --git a/web/src/forja_web/pages/executions_page.py b/web/src/forja_web/pages/executions_page.py new file mode 100644 index 0000000..690550e --- /dev/null +++ b/web/src/forja_web/pages/executions_page.py @@ -0,0 +1,30 @@ +"""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") diff --git a/web/src/forja_web/pages/home_page.py b/web/src/forja_web/pages/home_page.py new file mode 100644 index 0000000..2a8f7b2 --- /dev/null +++ b/web/src/forja_web/pages/home_page.py @@ -0,0 +1,26 @@ +"""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") diff --git a/web/src/forja_web/pages/policies_page.py b/web/src/forja_web/pages/policies_page.py new file mode 100644 index 0000000..96f80cc --- /dev/null +++ b/web/src/forja_web/pages/policies_page.py @@ -0,0 +1,27 @@ +"""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") diff --git a/web/src/forja_web/pages/run_page.py b/web/src/forja_web/pages/run_page.py new file mode 100644 index 0000000..ba17f0c --- /dev/null +++ b/web/src/forja_web/pages/run_page.py @@ -0,0 +1,77 @@ +"""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")