35 KiB
Forja explicado de principio a fin
Nota de estado (junio 2026): Forja es un único servicio FastAPI con UI HTMX embebida. Además del runtime gobernado, incluye la capa MLOps: datasets y modelos versionados, training runs contra backends externos, evaluación post-training y promoción gobernada draft → active.
Si solo quieres arrancarlo, ve al
README.md.
Para quién es esto. Una guía didáctica para alguien que llega nuevo al proyecto —técnico o no— y quiere entender qué hace, por qué está hecho así y cómo encajan las piezas sin tener que leer todo el código primero.
Decisiones técnicas:
ARCHITECTURE.md. Cableado de bajo nivel:docs/componentes.md.
1. El problema, en una frase
Poner agentes de IA en producción sin una capa de gobierno produce sistemas opacos: prompts que cambian sin historial, validaciones inconsistentes, acciones de alto impacto sin supervisión y ninguna auditoría de lo que decidió el agente.
Forja es el "plano de control" que pones delante de tus agentes antes de dejarlos tocar nada importante. No es un framework para construir agentes; es la capa que los cataloga, versiona, valida, ejecuta de forma supervisada y audita.
El caso de ejemplo que trae el repo es un agente de operaciones de telco
(incident_analyzer): recibe la descripción de un incidente de plataforma de voz
(caída de registros SIP, degradación de MOS, saturación de HSS...) y propone
acciones con análisis de riesgo y plan de rollback. Acciones de riesgo alto quedan
pausadas esperando aprobación humana. Todo queda registrado.
2. Las seis ideas grandes
Si entiendes estas seis ideas, entiendes el proyecto. Todo lo demás son detalles.
| # | Idea | Dónde vive |
|---|---|---|
| 1 | Agentes y políticas como ficheros declarativos, versionados como Git. Un agente es un YAML (prompt, modelo, esquema de salida, umbral de aprobación). Cambias el YAML → nueva versión, con hash y diff. Sin redeploy. | agents/, policies/, registry/ |
| 2 | Los guardrails son una política, no código disperso. Una política lista validadores de entrada y de salida con su configuración. El motor los aplica; "qué se valida" es configuración. | policies/, guardrails/ |
| 3 | La ejecución del agente es un grafo de estados con checkpoints. No es "llama al LLM y ya"; es un flujo: validar entrada → razonar → validar salida → proponer acciones → puerta de aprobación → finalizar. Cada paso se persiste. | runtime/ (LangGraph) |
| 4 | Human-in-the-Loop (HITL) de verdad. Si el agente propone algo arriesgado, el grafo se pausa en mitad de la ejecución, el estado se guarda en disco, y se reanuda más tarde —incluso tras reiniciar el proceso— cuando un humano aprueba o rechaza. | nodo approve_gate + endpoints /approve, /reject |
| 5 | Trazabilidad obligatoria. Cada petición lleva un trace_id (UUID) que se propaga por el middleware → los logs → la API → los ficheros de auditoría. Cada paso del grafo deja una entrada en el decision_path con su duración. |
observability/, domain/execution.py |
| 6 | Todo lo "intercambiable" está detrás de una interfaz + un factory. El proveedor de LLM, el motor de guardrails, el registry... son Protocols con varias implementaciones. Un factory elige cuál según la configuración. Cambias .env, no el código. |
llm/, guardrails/, registry/ (los factory.py) |
3. Vista de pájaro: un único servicio
Forja es un solo proceso FastAPI (puerto 8000) con dos caras:
┌───────────────────────────── docker-compose ──────────────────────────────┐
│ forja-core :8000 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ UI HTMX (humanos, Jinja2) │ API REST (/api/*) │ │
│ └────────────────────────────┬────────────────────────────────────┘ │
│ ▼ │
│ capa LLM capa Guardrails runtime LangGraph │
│ (Strategy+factory) (Strategy+factory) (grafo + checkpointer) │
│ │ │ │ │
│ └──────────────────┴───────────┬──────────┘ │
│ ▼ │
│ Persistencia: YAML · JSON · JSONL · SQLite │
└────────────────────────────────────────────────────────────────────────────┘
- La API REST (bajo
/api) es el contrato para máquinas: CLI, pipelines, otros servicios. - La UI HTMX es una sola página (
/) organizada como las seis etapas del ciclo de vida (Define → Run → Approve → Train → Promote → Audit). Se sirve desde el mismo proceso y llama a los mismos servicios internos — no hay un cliente HTTP intermedio ni lógica de negocio duplicada. Las rutas antiguas (/agents,/run, ...) redirigen a su etapa.
Las capas del core (de fuera hacia dentro)
HTTP ─► middlewares ─► routers (api/) ─► dependencias (deps.py)
│ │ ← aquí se inyectan los
│ │ objetos del dominio
▼ ▼
orchestrator ──► graph (LangGraph) ──► nodes
│ │
│ ├─► llm/ (¿qué dice el LLM?)
│ ├─► guardrails/ (¿pasa los filtros?)
│ └─► domain/ (¿qué forma tienen los datos?)
▼
checkpointer (SQLite) + persistence (JSONL) + registry (YAML/JSON)
4. Recorrido por los módulos (y quién depende de quién)
El código del core vive bajo core/src/ forja_core/. Lo agrupo por capas, de
las más internas (sin dependencias) a las más externas.
4.1 domain/ — el vocabulario del sistema
Modelos Pydantic puros. No dependen de nada del proyecto; todo lo demás depende de ellos. Son el "idioma común".
| Fichero | Qué define |
|---|---|
agent.py |
AgentDefinition (prompt, modelo, output_schema, guardrails, risk_threshold_for_hitl, ...), LLMConfig, AgentVersionMeta. |
policy.py |
PolicyDefinition (listas de PolicyValidator de entrada y de salida, on_validator_error), PolicyVersionMeta. |
guardrail.py |
GuardrailViolation (trace_id, stage input/output, validator, severity, message, blocked). |
execution.py |
AgentExecution (el "expediente" de una ejecución: status, decision_path, violations, proposed_actions, needs_human_for, final_output, error), ProposedAction, DecisionStep, AgentExecutionSummary. |
Pista didáctica: si quieres entender el sistema rápido, empieza leyendo
domain/execution.py. Te dice exactamente qué información se produce y se guarda.
4.2 config.py y observability/logging.py — los cimientos transversales
config.py→Settings(pydantic-settings): lee.env(proveedor LLM, rutas deagents//policies//data/, nivel de log, claves de Azure/OpenAI, flags de NeMo). Es el único sitio que sabe de variables de entorno. Todos losfactory.pyreciben unSettings.observability/logging.py: configurastructlogcon salida JSON y un contexto donde se "bind-ea" eltrace_id. Lo usa todo el código que loguea (log = structlog.get_logger(__name__)).
Dependen: de nada del proyecto. Dependen de ellos: prácticamente todo.
4.3 llm/ — la capa de proveedores de LLM (Strategy pattern)
| Fichero | Rol |
|---|---|
base.py |
El Protocol LLMProvider (async complete(messages, temperature, max_tokens) -> CompletionResult) y los tipos Message, CompletionResult. La interfaz. |
mock.py |
MockProvider: determinista, sin claves. Elige una respuesta canónica buscando subcadenas ("sip", "mos", "hss") en el input. Es lo que hace que el demo funcione out-of-the-box. |
azure.py, openai.py |
Implementaciones reales (Azure OpenAI / OpenAI) con reintentos exponenciales y, opcionalmente, fallback entre proveedores. |
factory.py |
build_llm_provider(settings): devuelve el provider según LLM_PROVIDER (mock / azure / openai), y envuelve un fallback opcional. |
Depende de: domain/, config.py. Dependen de él: el runtime/ (el nodo
llm_reason) y el deps.py de la API.
4.4 registry/ — catálogo y versionado de agentes y políticas
| Fichero | Rol |
|---|---|
repository.py |
FileSystemAgentRegistry: lee agents/<nombre>/index.yaml + versions/<id>.yaml. CRUD de agentes y versiones, lectura de la versión "activa". |
policy_store.py |
FileSystemPolicyStore: lo mismo para policies/. Devuelve PolicyDefinitions. |
versioning.py |
compute_hash(yaml_text) (SHA-256 sobre el contenido normalizado) y el diff unificado entre dos versiones. Es la "magia tipo Git": versiones inmutables identificadas por hash, comparables. |
factory.py |
build_registry(settings) y build_policy_store(settings). |
Depende de: domain/, config.py. Dependen de él: la API (/agents, /policies),
el orchestrator (para resolver qué agente/política ejecutó una traza dada).
4.5 guardrails/ — el motor de validación
Aquí está el corazón del "gobierno". La estructura sigue otra vez Protocol + implementaciones + composite + factory:
GuardrailEngine (Protocol) ← base.py: validate_input / validate_output
▲
┌────────────┼─────────────┐
│ │
GuardrailsAIEngine NeMoGuardrailsEngine ← guardrails_ai.py / nemo.py
(el real: aplica los (stub; desactivado
validadores de la por defecto)
política)
│
└──────────┐
▼
CompositeGuardrailEngine ← composite.py: corre N sub-engines
(corre los sub-engines en paralelo en paralelo (asyncio.gather) y
con asyncio y agrega las violaciones) une las listas de violaciones
factory.py: build_guardrail_engine(settings) → ensambla el Composite
validators.py: las funciones concretas — detect_pii, prompt_injection,
toxic_language, forbidden_topics, schema_match, pii_leakage,
forbidden_action_keywords, telco_safety_rules
Cómo se conecta una política con un validador: la política
(policies/default/versions/v1.yaml) lista, por ejemplo:
input_validators:
- type: detect_pii
config: { entities: [EMAIL_ADDRESS, ES_NIF, IP_ADDRESS, IBAN_CODE], severity_on_match: block }
- type: prompt_injection
config: { severity_on_match: block }
...
on_validator_error: fail_closed
El GuardrailsAIEngine recorre esa lista, busca cada type en su registro de
funciones de validators.py, la llama con el texto y el config, y junta las
GuardrailViolation que devuelva. on_validator_error: fail_closed significa que
si un validador peta, cuenta como bloqueo (seguridad antes que disponibilidad).
Sobre
detect_pii: usa Presidio (con el modelo spaCyen_core_web_sm) si está instalado; si no, cae a una detección por regex (email, teléfono, NIF español, IP). Por eso el comportamiento en local (sin Presidio) y en Docker (con Presidio) puede diferir — la política solo pide recognizers de patrón fiables para evitar falsos positivos del NER.
Depende de: domain/, config.py. Dependen de él: el runtime/ (los nodos
validate_input/validate_output) y el deps.py.
4.6 runtime/ — la ejecución como grafo de estados (LangGraph)
Esta es la capa más "viva". Modela una ejecución del agente como un grafo dirigido.
| Fichero | Rol |
|---|---|
state.py |
AgentState: un TypedDict con todo lo que fluye por el grafo (input, salida del LLM, acciones propuestas, violaciones acumuladas, decision_path, status, decisión humana, ...). Algunos campos usan reducers (Annotated[list, operator.add]) para que cada nodo añada en vez de sobrescribir. |
nodes.py |
Las funciones-nodo, construidas por factories parametrizadas con el agente, la política, el motor de guardrails y el provider LLM: validate_input, llm_reason, validate_output, propose_actions, approve_gate, finalize. Cada nodo añade un DecisionStep con su duración. |
graph.py |
build_graph(...): cablea los nodos y las aristas condicionales (p. ej. si validate_input bloqueó → salta directo al final). Compila el grafo con un checkpointer. |
checkpointer.py |
build_checkpointer(data_dir): un AsyncSqliteSaver de LangGraph sobre data_dir/checkpoints.sqlite, expuesto como context manager asíncrono. Es lo que hace que un awaiting_approval sobreviva a un reinicio. |
orchestrator.py |
AgentOrchestrator: la única puerta de entrada al runtime. Tres operaciones: invoke() (lanza), resume() (reanuda un HITL con la decisión), snapshot() (lee el estado actual sin avanzarlo). Cada llamada abre su propio checkpointer y traduce el StateSnapshot de LangGraph a un AgentExecution del dominio. |
El grafo, dibujado:
START
│
▼
┌─────────────────┐ bloqueada (PII, injection...)
│ validate_input │ ─────────────────────────────────────────► END
└────────┬────────┘
│ ok
▼
┌─────────────────┐ LLM no disponible / error
│ llm_reason │ ─────────────────────────────────────────► END
└────────┬────────┘
│ ok
▼
┌─────────────────┐ salida no cumple el esquema / PII en la salida
│ validate_output │ ─────────────────────────────────────────► END
└────────┬────────┘
│ ok
▼
┌─────────────────┐
│ propose_actions │ (extrae las acciones propuestas del JSON del LLM)
└────────┬────────┘
│
▼
┌─────────────────┐ ¿hay alguna acción con risk_score ≥ umbral
│ approve_gate │ o requires_approval=True?
└────────┬────────┘
│
┌────┴───────────────────────────────┐
│ no │ sí
▼ ▼
┌──────────┐ interrupt({...}) ──► el grafo SE PAUSA aquí.
│ finalize │ El estado queda en checkpoints.sqlite.
└────┬─────┘ Más tarde llega resume(decision={...})
│ y se reanuda en este mismo punto.
▼ │
END ▼
┌──────────┐
│ finalize │ (filtra a las acciones aprobadas;
└────┬─────┘ si fue rechazo → status=failed)
▼
END
Depende de: llm/, guardrails/, domain/, config.py (vía los objetos que le
inyectan). Dependen de él: la API (api/executions.py solo conoce el
AgentOrchestrator, no LangGraph).
4.7 api/ — la fachada HTTP (FastAPI)
| Fichero | Rol |
|---|---|
middlewares.py |
TraceIdMiddleware: lee X-Trace-Id de la petición (o genera uno), lo bind-ea al contexto de structlog, y lo devuelve en la respuesta. Es el origen del hilo de trazabilidad. |
deps.py |
Las dependencias inyectables. Cada get_* (get_settings, get_registry, get_policy_store, get_llm_provider, get_guardrail_engine, get_orchestrator) está cacheada con lru_cache(maxsize=1): la app construye cada cosa una sola vez. Expone aliases (RegistryDep = Annotated[..., Depends(get_registry)], etc.) que los routers piden por parámetro. Los tests llaman a .cache_clear() para reconstruir todo apuntando a un DATA_DIR temporal. |
persistence.py |
Helpers de log append-only en JSONL: append_execution, append_violation, read_execution_summaries. Inmutable, auditable, fácil de "shipear" a un sistema de logs. |
agents.py |
Router /agents: list, get, versiones, GET /agents/{n}/versions/{a}/diff/{b}. |
executions.py |
El más cargado: POST /agents/{n}/invoke, GET /executions, GET /executions/{trace_id}, POST /executions/{trace_id}/approve, POST /executions/{trace_id}/reject. Mantiene además execution_index.json (mapa trace_id → agente/versión) para poder reanudar tras un reinicio. |
policies.py, violations.py |
Routers /policies y /violations (este con filtros por severidad, stage, etc.). |
Y la raíz de la app: core/src/ forja_core/main.py → create_app() instancia
FastAPI, añade el TraceIdMiddleware, monta los routers, y expone /health.
Depende de: todo lo de arriba (vía deps.py). Dependen de él: la UI HTMX
(mismo proceso) y los tests (TestClient).
4.8 web/ — la UI HTMX embebida (una página)
| Fichero | Rol |
|---|---|
ui.py |
GET / (la página completa), POST /run, GET /fragments/{agents,approvals,training,promotions,history} y redirects 301 de las rutas antiguas. Llama a los mismos singletons de deps.py que la API; no hay cliente HTTP intermedio. |
templates/index.html |
La página única: seis etapas (Define → Run → Approve → Train → Promote → Audit), cada una con su explicación y su panel. |
templates/_*.html |
Fragmentos por etapa. Cada panel lleva hx-get="/fragments/X" hx-trigger="forja-refresh from:body": cualquier botón de acción dispara el evento forja-refresh al terminar y todos los paneles se re-renderizan — el ciclo entero se recorre con clicks, sin recargar la página. |
Los botones de acción (approve/reject/train/evaluate/promote) hacen hx-post
con la extensión json-enc directamente contra los endpoints /api/*; el botón
Run demo incident lanza un incidente pregrabado que pausa en HITL.
4.9 Lo que no es código: agents/, policies/, data/
agents/incident_analyzer/— el agente de ejemplo:index.yaml(catálogo de versiones),versions/v1.yamlyv2.yaml, yexamples/*.txt(tres escenarios de incidente de telco que el demo usa).policies/default/— la política de guardrails de ejemplo (index.yaml+versions/v1.yaml).data/— estado runtime (gitignored):checkpoints.sqlite,executions.jsonl,violations.jsonl,execution_index.json. Se crea sola.
5. El patrón que se repite: Protocol + factory + Settings
Tres veces (LLM, guardrails, registry) verás la misma estructura:
base.py → un Protocol (la interfaz: "qué se puede hacer")
<impl_a>.py → una implementación (p. ej. mock)
<impl_b>.py → otra implementación (p. ej. azure)
factory.py → build_X(settings: Settings) -> X ← elige y monta
¿Por qué? Porque permite cambiar el comportamiento sin tocar el código: pones
LLM_PROVIDER=azure en .env y el factory te da el provider de Azure; pones
mock y tienes un demo determinista sin claves. Lo mismo con guardrails (puedes
añadir el engine de NeMo) y con el registry (hoy es de ficheros; mañana podría ser
de base de datos). Es el principio de "configuración antes que código".
Y todo se enchufa una sola vez al arrancar, en api/deps.py (los lru_cache):
el registry, el policy store, el provider, el motor de guardrails y el orchestrator
son singletons del proceso. Los routers solo los piden; no saben construirlos.
6. El recorrido de UNA petición, de principio a fin
Esta es la sección que conviene leer despacio: aquí se ve cómo encaja todo. Sigamos
POST /agents/incident_analyzer/invoke con el escenario SIP (el que dispara HITL).
Acto 1 — la petición entra y se ejecuta el grafo
Cliente (UI o curl)
│ POST /agents/incident_analyzer/invoke { "input": "<texto del incidente SIP>" }
▼
TraceIdMiddleware ............ genera trace_id = UUID, lo bind-ea al log
▼
router invoke_agent (api/executions.py)
│ pide por inyección: RegistryDep, PolicyStoreDep, OrchestratorDep, SettingsDep
│ registry.get_agent("incident_analyzer") → AgentDefinition (versión activa = v2)
│ policies.get_policy(agent.guardrails[0]) → PolicyDefinition ("default")
▼
orchestrator.invoke(agent_def, policy, user_input)
│ abre el checkpointer (AsyncSqliteSaver sobre data/checkpoints.sqlite)
│ build_graph(agent_def, policy, provider, engine, checkpointer)
│ graph.ainvoke(estado_inicial, config={thread_id: trace_id})
▼
┌──── el grafo ────────────────────────────────────────────────────────────┐
│ validate_input → engine.validate_input(texto, policy, trace_id) │
│ recorre los input_validators de la política en paralelo │
│ (detect_pii, prompt_injection, ...) → 0 violaciones │
│ añade DecisionStep("validate_input", ...) │
│ llm_reason → provider.complete([system_prompt, user_input], ...) │
│ (MockProvider ve "sip" → respuesta canónica SIP) │
│ añade DecisionStep("llm_reason", model, tokens, ...) │
│ validate_output → parsea el JSON del LLM; engine.validate_output(...) │
│ (schema_match, pii_leakage, forbidden_action_keywords, │
│ telco_safety_rules) → 0 violaciones │
│ propose_actions → extrae proposed_actions del JSON: [act-1 (risk 4, │
│ requires_approval), act-2 (risk 3)] │
│ approve_gate → ¿alguna acción con risk ≥ 4 (umbral del agente) o │
│ requires_approval? SÍ (act-1) → interrupt({...}) │
│ ⇒ el grafo SE DETIENE. El estado se escribe en SQLite. │
└──────────────────────────────────────────────────────────────────────────┘
▼
orchestrator._snapshot(...) → LangGraph reporta "hay un nodo pendiente"
⇒ status = "awaiting_approval"
⇒ needs_human_for = [act-1] (y devuelve un AgentExecution)
▼
router: status no es terminal → NO se escribe en executions.jsonl,
pero SÍ se registra en execution_index.json: { trace_id → (incident_analyzer, v2) }
▼
respuesta 200 { status: "awaiting_approval", trace_id, needs_human_for: [act-1], decision_path: [...], ... }
En la UI, la página Runs muestra el timeline y un aviso "Open Approvals".
La página Approvals reconstruye las ejecuciones awaiting_approval desde el
índice + el checkpointer y muestra act-1 con su risk_score, target y
rollback_plan.
Acto 2 — el humano decide; el grafo se reanuda
Humano (en la página Aprobaciones, o curl)
│ POST /executions/<trace_id>/approve { approved_action_ids: ["act-1"], comment: "ok rollback" }
▼
router approve_execution (api/executions.py)
│ _resolve(...) → lee execution_index.json → sabe que fue (incident_analyzer, v2)
│ reconstruye el AgentDefinition y la PolicyDefinition
│ _ensure_awaiting(...) → orchestrator.snapshot(...) confirma que sigue en awaiting_approval
▼
orchestrator.resume(agent_def, policy, trace_id, decision={approved_action_ids:["act-1"], rejected:false})
│ abre OTRA VEZ el checkpointer (mismo data_dir) — el estado pausado sigue ahí,
│ aunque hubiera habido un reinicio del proceso entremedias
│ graph.ainvoke(Command(resume=decision), config={thread_id: trace_id})
▼
┌──── el grafo continúa desde donde se quedó ──────────────────────────────┐
│ approve_gate → el interrupt() devuelve la `decision` del humano │
│ finalize → final_actions = acciones cuyo id está aprobado = [act-1] │
│ final_output = { ...salida del LLM..., approved_actions:[act-1] }│
│ status = "completed" │
└──────────────────────────────────────────────────────────────────────────┘
▼
router: status terminal → append_execution(...) escribe el AgentExecution en executions.jsonl
▼
respuesta 200 { status: "completed", final_output: { ..., approved_actions: [act-1] }, ... }
(Si en vez de approve se llama a reject, el nodo finalize ve rejected: true →
status = "failed", error = "rejected_by_human". También terminal → al JSONL.)
El mismo recorrido, como diagrama de secuencia
sequenceDiagram
participant U as Cliente / UI
participant MW as TraceIdMiddleware
participant API as router (api/executions.py)
participant ORCH as AgentOrchestrator
participant G as Grafo LangGraph
participant CP as checkpoints.sqlite
participant LOG as executions.jsonl / index.json
U->>MW: POST /agents/incident_analyzer/invoke {input}
MW->>API: + trace_id
API->>API: registry.get_agent · policies.get_policy
API->>ORCH: invoke(agent, policy, input)
ORCH->>G: ainvoke(estado, thread_id=trace_id)
G->>G: validate_input → llm_reason → validate_output → propose_actions
G->>G: approve_gate: hay riesgo alto → interrupt()
G->>CP: persiste estado pausado
ORCH-->>API: AgentExecution(status=awaiting_approval, needs_human_for=[act-1])
API->>LOG: index.json[trace_id] = (incident_analyzer, v2)
API-->>U: 200 {status: awaiting_approval, trace_id, ...}
Note over U,CP: ...más tarde (incluso tras reiniciar el core)...
U->>API: POST /executions/{trace_id}/approve {approved_action_ids:[act-1]}
API->>LOG: lee index.json → (incident_analyzer, v2)
API->>ORCH: resume(agent, policy, trace_id, decision)
ORCH->>CP: reabre checkpointer (estado pausado sigue ahí)
ORCH->>G: ainvoke(Command(resume=decision), thread_id=trace_id)
G->>G: approve_gate (recibe decisión) → finalize → status=completed
ORCH-->>API: AgentExecution(status=completed, final_output)
API->>LOG: append a executions.jsonl
API-->>U: 200 {status: completed, final_output, ...}
7. Persistencia: cuatro formas, cuatro razones
Forja no usa una sola base de datos; usa la herramienta adecuada para cada cosa.
| Qué | Cómo | Por qué así |
|---|---|---|
| Definiciones de agentes y políticas | YAML por versión + index.yaml |
Lo escribe un humano (legible, comentable) y lo cataloga la máquina. |
| Identidad de versiones / comparación | hash SHA-256 del contenido + difflib |
Versiones inmutables identificables y comparables — "tipo Git". |
Mapa trace_id → agente/versión |
JSON (execution_index.json) |
Necesario para reanudar un HITL sabiendo qué configuración lo ejecutó; debe sobrevivir a reinicios. |
| Log de ejecuciones terminales y de violaciones | JSONL append-only | Inmutable, auditable, trivial de "shipear" a un sistema de logs. |
| Estado intermedio del grafo (incl. pausas HITL) | SQLite (checkpoints.sqlite, vía LangGraph) |
Es lo que LangGraph espera; permite reanudar una ejecución pausada entre reinicios del proceso. |
| Training runs, evaluaciones y promociones | JSON por entidad bajo data/ (+ JSONL para promociones aprobadas) |
Estado mutable consultable por id; la auditoría de promociones es append-only. |
8. Glosario
| Término | Significado en este proyecto |
|---|---|
| Agente | Una configuración declarativa (YAML): prompt de sistema, modelo LLM, esquema de salida, lista de políticas de guardrails, umbral de riesgo para HITL. No es código. |
| Política (de guardrails) | Un YAML con la lista de validadores de entrada y de salida y su configuración, más on_validator_error (fail_closed/fail_open). |
| Validador / guardrail | Una función que mira el texto de entrada o la salida del LLM y devuelve cero o más GuardrailViolation. Ej.: detect_pii, prompt_injection, schema_match, telco_safety_rules. |
| Violación | El resultado de un validador: stage (input/output), validator, severity (info/warning/block), message, blocked. |
Ejecución (AgentExecution) |
El "expediente" de una invocación: trace_id, status, decision_path, violations, proposed_actions, needs_human_for, final_output, error. |
decision_path |
La lista de pasos por los que pasó el grafo, cada uno con su step, timestamp, duration_ms y un detail. La "caja negra" de la ejecución. |
| HITL (Human-in-the-Loop) | El patrón de pausar la ejecución cuando hay una acción arriesgada y esperar a que un humano apruebe o rechace. Implementado con interrupt() de LangGraph. |
| Checkpointer | El componente de LangGraph que persiste el estado del grafo (aquí, en SQLite). Lo que hace posible reanudar un HITL tras un reinicio. |
trace_id |
UUID que identifica una petición/ejecución y se propaga por middleware → logs → API → ficheros de auditoría. |
| Orchestrator | La única clase que habla con LangGraph (invoke/resume/snapshot); aísla al resto del sistema del runtime. |
| Factory | Función build_X(settings) que monta la implementación de X adecuada según la configuración. |
| Status de ejecución | running, awaiting_approval (pausada en HITL), blocked_by_guardrail (la frenó un validador), completed, failed. |
| Estados de un agente | draft, active, deprecated (metadato en su YAML). |
| Training run | Job de fine-tuning enviado a un backend externo (mock o Azure ML) con dataset, modelo base y versión candidata del agente. |
| Evaluación post-training | Ejecutar los escenarios canónicos del agente (examples/*.txt) por el runtime gobernado; pasa si no hay violaciones bloqueantes. Es el gate de promoción. |
| Promoción | Petición de gobernanza para pasar una versión draft a active; requiere run succeeded + evaluación passed de esa misma versión + aprobación humana. |
9. Mapa del repositorio y por dónde empezar a leer
forja/
├── core/
│ ├── src/ forja_core/
│ │ ├── domain/ ← los modelos de datos (empieza por execution.py)
│ │ ├── config.py · observability/ ← cimientos transversales
│ │ ├── llm/ ← proveedores LLM (Protocol + impls + fallback + factory)
│ │ ├── registry/ ← catálogo y versionado (base yaml_store.py + 4 stores)
│ │ ├── guardrails/ ← motor de validación (Protocol + composite + validators)
│ │ ├── runtime/ ← el grafo LangGraph (state, nodes, graph, checkpointer, orchestrator)
│ │ ├── training/ ← backends de fine-tuning (mock, Azure ML)
│ │ ├── evaluation/ ← escenarios canónicos + evaluación post-training
│ │ ├── api/ ← FastAPI (middlewares, deps, routers, persistence)
│ │ ├── web/ ← UI HTMX (ui.py + templates/)
│ │ └── main.py ← create_app(): ensambla la app
│ ├── Dockerfile · requirements.txt
├── agents/incident_analyzer/ ← el agente de ejemplo (YAMLs + escenarios .txt)
├── policies/default/ ← la política de guardrails de ejemplo
├── datasets/ · models/ ← datasets y modelos base versionados (YAML)
├── data/ ← estado runtime (gitignored)
├── tests/ ← pytest: tests/unit/ y tests/integration/
├── docs/ ← este documento, componentes.md, futuro.md, product-voice.md
├── docker-compose.yml ← levanta core (API + UI HTMX en puerto 8000)
├── Makefile ← install / test / test-all / lint / smoke / up / down
├── README.md · ARCHITECTURE.md
Ruta de lectura sugerida (1 hora):
domain/execution.pyydomain/agent.py— qué datos hay.policies/default/versions/v1.yamlyagents/incident_analyzer/versions/v2.yaml— cómo se declara todo.runtime/nodes.pyyruntime/graph.py— el flujo de ejecución.api/executions.py— cómo se expone (invoke / approve / reject).tests/integration/test_invoke_hitl.py— el ciclo completo, en ~40 líneas.- Levanta
docker compose upy recorre la página única de arriba abajo: los 6 clicks de la demo del README cubren el ciclo completo.
10. Lo que aún no hace (a propósito)
Es un MVP. La detección de PII más fina (modelos grandes, español), autenticación,
OpenTelemetry, multi-tenant, persistencia en Postgres, evaluadores LLM-as-judge,
una cola de aprobaciones con SLA... están en el roadmap: docs/futuro.md.
El estado actual y cómo verificarlo: README.md.