Files
fit-boilerplate-wox/web/_default_fastapi_htmx/app/main.py
Felipe Arentsen b089c2ef18
Some checks failed
CI — Lint + Evals / lint (push) Has been cancelled
CI — Lint + Evals / smoke (push) Has been cancelled
feat: v1 — boilerplate WxO + web
Boilerplate completo para construir soluciones agénticas sobre IBM
watsonx Orchestrate (ADK 2.x) con una capa web encima.

Destilado de cotemar-poc-n1 y dun-casos-prueba. Incluye:

- 11 docs (best practices, architecture patterns, ADK cheatsheet,
  observability, tool authoring, deployment, RUNBOOK, DEPLOY_TO_NEW_WOX,
  known-issues con 16 errores reales y fix, eval strategy, INDEX)
- 13 templates WxO (orchestrator/specialist/single-meta agents,
  3 connections, KB + runbook, observable_tool decorator, coercion
  helpers, Python tools, OpenAPI spec, backend filter endpoint, webhook
  validator HMAC, MCP connection)
- 6 scripts (deploy idempotente con fallback ADK, undeploy, reset,
  check-adk-version, new-specialist scaffold, eval-agents runner)
- 4 eval pieces (linter de best practices, runner, smoke-test, direct
  backend probe) + scenario templates
- 8 subagentes Claude (.claude/agents/) — wxo-architect, wxo-agent-author,
  wxo-tool-author, runbook-author, mock-builder, backend-tool-builder,
  eval-author, web-layer-builder
- Skill bundle fit-wxo-bootstrap/ (SKILL.md + 3 templates) listo para
  copiar a ~/.claude/skills/
- Web layer default FastAPI+HTMX con vista timeline observable + endpoint
  receptor de trazas
- Docker compose Coolify-ready (healthcheck wget -qO-, sin labels Traefik,
  network split internal:true)
- CI Gitea workflow con lint + smoke

Best practices enforced por evals/lint_wxo_yaml.py:
- A1: máx 10 tools por agente
- A3: orchestrator sin tools de remediación
- A6: agente sin propósito (react sin tools ni KB)
- T1: @observable_tool obligatorio, no @tool directo
- T3: _compat shim inline en tools.py
- D1: docker-compose sin wget --spider
- I-005: OpenAPI con description per-operation
- I-009: sin labels Traefik manuales

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:59:44 +00:00

142 lines
4.5 KiB
Python

"""Web layer default — FastAPI + Jinja2 + HTMX.
Estructura mínima viable para demos / control planes. Incluye:
- / landing con embed WxO
- /traces timeline observable de tool calls
- /api/traces endpoint que recibe trazas del decorator @observable_tool
- /health healthcheck para Coolify/Traefik
Reemplazá lo que necesites o usá esto como base para extender.
"""
from __future__ import annotations
import json
import os
import sqlite3
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
BASE_DIR = Path(__file__).resolve().parent
TEMPLATES_DIR = BASE_DIR / "templates"
STATIC_DIR = BASE_DIR / "static"
TRACES_DB = os.environ.get("TRACES_DB_PATH", "/data/traces.db")
# WxO embed config (capturar tras deploy)
WXO_AGENT_ID = os.environ.get("WXO_AGENT_ID", "")
WXO_INSTANCE_URL = os.environ.get("WXO_INSTANCE_URL", "")
def _init_db():
"""Crea la tabla de trazas si no existe."""
Path(TRACES_DB).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(TRACES_DB)
conn.execute("""
CREATE TABLE IF NOT EXISTS traces (
trace_id TEXT PRIMARY KEY,
tool TEXT,
domain TEXT,
agent_caller TEXT,
correlation_id TEXT,
started_at TEXT,
duration_ms INTEGER,
payload TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS ix_started ON traces (started_at)")
conn.execute("CREATE INDEX IF NOT EXISTS ix_agent ON traces (agent_caller)")
conn.commit()
conn.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
_init_db()
yield
app = FastAPI(title="Boilerplate Web Layer", lifespan=lifespan)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# ─── Routes ─────────────────────────────────────────────────────────────────
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/", response_class=HTMLResponse)
def landing(request: Request):
return templates.TemplateResponse("index.html", {
"request": request,
"wxo_agent_id": WXO_AGENT_ID,
"wxo_instance_url": WXO_INSTANCE_URL,
})
@app.get("/traces", response_class=HTMLResponse)
def traces_page(request: Request):
return templates.TemplateResponse("traces.html", {"request": request})
@app.get("/api/traces/recent")
def api_traces_recent(since: str | None = None, limit: int = 100):
"""JSON endpoint — devuelve las últimas N trazas, opcionalmente desde un ISO timestamp."""
conn = sqlite3.connect(TRACES_DB)
if since:
rows = conn.execute(
"SELECT payload FROM traces WHERE started_at > ? ORDER BY started_at DESC LIMIT ?",
(since, limit)
).fetchall()
else:
rows = conn.execute(
"SELECT payload FROM traces ORDER BY started_at DESC LIMIT ?",
(limit,)
).fetchall()
conn.close()
return {"traces": [json.loads(r[0]) for r in rows]}
@app.post("/api/traces")
async def api_traces_post(request: Request):
"""Endpoint que recibe trazas del decorator @observable_tool con TRACE_SINK=http."""
trace: dict[str, Any] = await request.json()
conn = sqlite3.connect(TRACES_DB)
conn.execute(
"INSERT OR REPLACE INTO traces VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
trace.get("trace_id"), trace.get("tool"), trace.get("domain"),
trace.get("agent_caller"), trace.get("correlation_id"),
trace.get("started_at"), trace.get("duration_ms"),
json.dumps(trace),
),
)
conn.commit()
conn.close()
return {"ok": True}
@app.get("/partials/timeline", response_class=HTMLResponse)
def partial_timeline(request: Request):
"""HTMX partial — la tabla de trazas para refrescar via hx-trigger."""
conn = sqlite3.connect(TRACES_DB)
rows = conn.execute(
"SELECT payload FROM traces ORDER BY started_at DESC LIMIT 50"
).fetchall()
conn.close()
traces = [json.loads(r[0]) for r in rows]
return templates.TemplateResponse("_timeline_rows.html", {
"request": request, "traces": traces,
})