411 lines
28 KiB
Markdown
411 lines
28 KiB
Markdown
# Componentes de Forja y su interrelación (bajo nivel)
|
||
|
||
> **Nota (post-simplificación 2026):** Este documento describe el cableado del
|
||
> núcleo. La UI ahora es HTMX embebida en el propio core (no hay dashboard
|
||
> separado). La API REST está bajo el prefijo `/api`.
|
||
>
|
||
> - ¿Quieres la historia y el "por qué"? → [`docs/explicacion.md`](explicacion.md).
|
||
> - ¿Las decisiones técnicas resumidas? → [`ARCHITECTURE.md`](../ARCHITECTURE.md).
|
||
> - ¿Cómo arrancarlo? → [`README.md`](../README.md).
|
||
>
|
||
> Rutas relativas a `core/src/forja_core/` salvo que se diga otra cosa.
|
||
|
||
---
|
||
|
||
## 1. Grafo de dependencias de módulos (imports internos)
|
||
|
||
Edges = "X importa de Y". El grafo es un DAG; las capas de abajo no importan nada
|
||
de las de arriba. Nivel = profundidad topológica.
|
||
|
||
```
|
||
NIVEL 0 (no importan nada del proyecto)
|
||
config ← Settings (pydantic-settings, lee .env)
|
||
observability.logging ← configure_logging / bind_trace_id / clear_trace_id
|
||
domain.agent ← AgentDefinition, LLMConfig, AgentVersionMeta
|
||
domain.guardrail ← GuardrailViolation
|
||
domain.policy ← PolicyDefinition, PolicyValidator, PolicyVersionMeta
|
||
registry.versioning ← compute_hash, unified_diff, DiffResult
|
||
llm.base ← LLMProvider (Protocol), Message, CompletionResult
|
||
runtime.state ← AgentState (TypedDict)
|
||
runtime.checkpointer ← build_checkpointer (AsyncSqliteSaver)
|
||
|
||
NIVEL 1
|
||
domain.execution ⇐ domain.guardrail
|
||
llm.mock / llm.azure / llm.openai ⇐ llm.base
|
||
registry.repository ⇐ domain.agent, registry.versioning
|
||
registry.policy_store ⇐ domain.policy
|
||
guardrails.validators ⇐ domain.guardrail
|
||
guardrails.base ⇐ domain.guardrail, domain.policy
|
||
api.persistence ⇐ domain.execution, domain.guardrail
|
||
api.middlewares ⇐ observability.logging
|
||
|
||
NIVEL 2
|
||
llm.factory ⇐ config, llm.base, llm.{mock,azure,openai}
|
||
registry.factory ⇐ config, registry.{repository,policy_store}
|
||
guardrails.guardrails_ai ⇐ domain.{guardrail,policy}, guardrails.validators
|
||
guardrails.composite ⇐ domain.{guardrail,policy}, guardrails.base
|
||
guardrails.nemo ⇐ domain.{guardrail,policy}
|
||
|
||
NIVEL 3
|
||
guardrails.factory ⇐ config, guardrails.{base,composite,guardrails_ai,nemo}
|
||
runtime.nodes ⇐ domain.{agent,policy}, guardrails.base, llm.base, runtime.state
|
||
runtime.graph ⇐ domain.{agent,policy}, guardrails.base, llm.base, runtime.{nodes,state}
|
||
|
||
NIVEL 4
|
||
runtime.orchestrator ⇐ domain.{agent,execution,guardrail,policy}, guardrails.base, llm.base, runtime.{checkpointer,graph}
|
||
|
||
NIVEL 5
|
||
api.deps ⇐ config, guardrails.{base,factory}, llm.{base,factory}, registry.{factory,policy_store,repository}, runtime.orchestrator
|
||
api.agents ⇐ api.deps, domain.agent, registry.versioning
|
||
api.policies ⇐ api.deps, domain.policy
|
||
api.violations ⇐ api.deps, api.persistence, domain.guardrail
|
||
api.executions ⇐ api.deps, api.persistence, domain.{agent,execution,policy}, registry.{policy_store,repository}, runtime.orchestrator
|
||
|
||
NIVEL 6
|
||
main ⇐ api (routers), api.middlewares, config, observability.logging → create_app(), app
|
||
|
||
(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.
|
||
- **Solo `api/` importa de `runtime.orchestrator`** (y `deps.py` lo construye). El resto de `api/` no toca LangGraph; `runtime/` no toca `api/`.
|
||
- **Solo los `factory.py` y `main.py` importan `config.Settings`.** El resto recibe los objetos ya construidos.
|
||
- **`guardrails.composite` no conoce a sus sub-engines concretos** (solo `GuardrailEngine`); los ensambla `guardrails.factory`.
|
||
|
||
---
|
||
|
||
## 2. Inyección de dependencias — `api/deps.py`
|
||
|
||
Cinco objetos del dominio, cada uno construido **una sola vez** por proceso
|
||
(`@lru_cache(maxsize=1)`), más el orchestrator que los compone:
|
||
|
||
```
|
||
get_settings() ──────────────────────────────────────────────► Settings() (pydantic-settings ← .env)
|
||
│
|
||
├──► get_registry() = build_agent_registry(settings) ──► FileSystemAgentRegistry(settings.agents_dir)
|
||
├──► get_policy_store() = build_policy_store(settings) ──► FileSystemPolicyStore(settings.policies_dir)
|
||
├──► get_llm_provider() = build_llm_provider(settings) ──► Mock | AzureOpenAI | OpenAI (según settings.llm_provider)
|
||
├──► get_guardrail_engine() = build_guardrail_engine(settings)──► CompositeGuardrailEngine([GuardrailsAIEngine(), (NeMoGuardrailsEngine() si settings.guardrails_nemo_enabled)])
|
||
│
|
||
└──► get_orchestrator() = AgentOrchestrator(
|
||
provider = get_llm_provider(),
|
||
engine = get_guardrail_engine(),
|
||
data_dir = get_settings().data_dir,
|
||
)
|
||
```
|
||
|
||
Aliases que los routers piden por parámetro (`Annotated[T, Depends(get_*)]`):
|
||
|
||
| Alias | Tipo | Lo usan |
|
||
|-------|------|---------|
|
||
| `SettingsDep` | `Settings` | `executions` (todos los endpoints), `violations` |
|
||
| `RegistryDep` | `FileSystemAgentRegistry` | `agents` (todos), `executions` (`invoke`, `get`, `list`, `approve`, `reject`) |
|
||
| `PolicyStoreDep` | `FileSystemPolicyStore` | `policies` (todos), `executions` (`invoke`, `get`, `list`, `approve`, `reject`) |
|
||
| `OrchestratorDep` | `AgentOrchestrator` | `executions` (`invoke`, `get`, `list`, `approve`, `reject`) |
|
||
|
||
> No hay `LLMProviderDep` ni `GuardrailEngineDep`: el provider y el engine **solo**
|
||
> se inyectan al `AgentOrchestrator` (vía `get_orchestrator`), nunca a un router.
|
||
>
|
||
> Lifecycle: las `get_*` son perezosas → se llaman en la primera request que las
|
||
> necesita. Los tests hacen `deps.get_settings.cache_clear()` (etc.) tras
|
||
> `monkeypatch.setenv("DATA_DIR", tmp_path)` para reconstruir todo apuntando a un
|
||
> directorio temporal. `main.create_app()` llama a `Settings()` *directamente*
|
||
> (para configurar el logging), no a `deps.get_settings()`; son instancias
|
||
> distintas pero leen la misma config.
|
||
|
||
---
|
||
|
||
## 3. Contratos entre componentes (firmas exactas)
|
||
|
||
### 3.1 Proveedor LLM — `llm/base.py`
|
||
|
||
```python
|
||
class Message(BaseModel): role: Literal["system","user","assistant"]; content: str
|
||
class CompletionResult(BaseModel): content: str; model: str; tokens_in: int; tokens_out: int; latency_ms: int
|
||
|
||
class LLMProvider(Protocol):
|
||
name: str
|
||
async def complete(self, messages: list[Message], schema: dict[str,Any] | None = None,
|
||
temperature: float = 0.2, max_tokens: int = 2000) -> CompletionResult: ...
|
||
```
|
||
|
||
Implementaciones: `MockProvider` (`llm/mock.py`, determinista — elige una de
|
||
`_CANONICAL_RESPONSES = {"sip":…, "mos":…, "hss":…}` buscando esas subcadenas en el
|
||
input, en ese orden; si no, una respuesta genérica), `AzureOpenAIProvider`
|
||
(`llm/azure.py`), `OpenAIProvider` (`llm/openai.py`). Selector: `build_llm_provider(settings)`
|
||
en `llm/factory.py` (`match settings.llm_provider`). El nodo `llm_reason` lo invoca
|
||
así: `await provider.complete(messages=[Message("system", agent_def.system_prompt), Message("user", state["user_input"])], temperature=agent_def.llm.temperature, max_tokens=agent_def.llm.max_tokens)`.
|
||
|
||
### 3.2 Motor de guardrails — `guardrails/base.py`
|
||
|
||
```python
|
||
class GuardrailEngine(Protocol):
|
||
name: str
|
||
async def validate_input (self, payload: str, policy: PolicyDefinition, trace_id: UUID) -> list[GuardrailViolation]: ...
|
||
async def validate_output(self, payload: dict[str, Any], policy: PolicyDefinition, trace_id: UUID) -> list[GuardrailViolation]: ...
|
||
```
|
||
|
||
```python
|
||
class GuardrailViolation(BaseModel):
|
||
trace_id: UUID; timestamp: datetime
|
||
stage: Literal["input","output"]; validator: str
|
||
severity: Literal["info","warning","block"]; message: str; blocked: bool
|
||
```
|
||
|
||
Implementaciones:
|
||
- **`CompositeGuardrailEngine(engines: list[GuardrailEngine])`** (`guardrails/composite.py`) — `validate_*` hace `asyncio.gather(*(e.validate_*(...) for e in engines))` y aplana las listas. Lanza `ValueError` si `engines` está vacío.
|
||
- **`GuardrailsAIEngine()`** (`guardrails/guardrails_ai.py`) — el real. Mantiene dos registros `type → función`:
|
||
- `INPUT_VALIDATORS = {"detect_pii", "prompt_injection", "toxic_language", "forbidden_topics"}`
|
||
- `OUTPUT_VALIDATORS = {"schema_match", "pii_leakage", "forbidden_action_keywords", "telco_safety_rules"}`
|
||
|
||
`_run(...)` recorre `policy.input_validators` / `policy.output_validators` (cada uno un `PolicyValidator(type, config)`), busca `type` en el registro, y llama `fn(payload, validator.config, trace_id, kind)`. Si `fn` lanza y `policy.on_validator_error == "fail_closed"` ⇒ añade una `GuardrailViolation(severity="block", blocked=True, message=f"validator failed: {exc}", validator=v.type)`. Si el `type` no existe en el registro ⇒ log warning y continúa (no bloquea).
|
||
- **`NeMoGuardrailsEngine(allowed_keywords)`** (`guardrails/nemo.py`) — stub; solo se añade al composite si `settings.guardrails_nemo_enabled`. `validate_input`: si el texto no menciona ningún keyword permitido ⇒ una violación `severity="warning"`, `blocked=False`. `validate_output`: siempre `[]`.
|
||
|
||
Selector: `build_guardrail_engine(settings)` en `guardrails/factory.py`.
|
||
|
||
**Contrato de una función validadora** (todas las de `guardrails/validators.py`):
|
||
|
||
```python
|
||
def <validador>(payload: <str|dict>, config: dict[str, Any], trace_id: UUID, stage: str) -> list[GuardrailViolation]
|
||
```
|
||
|
||
- Entrada (`validate_input`): `payload` es `str` (el texto del usuario). `detect_pii`, `prompt_injection`, `toxic_language`, `forbidden_topics`.
|
||
- Salida (`validate_output`): `payload` es `dict` (el JSON parseado del LLM). `schema_match`, `pii_leakage`, `forbidden_action_keywords`, `telco_safety_rules`.
|
||
- `config` viene literal del YAML de la política (p. ej. `{entities:[...], severity_on_match:"block"}`).
|
||
- `detect_pii`: usa `_presidio_analyzer()` (singleton perezoso de `presidio_analyzer.AnalyzerEngine` con el modelo spaCy `en_core_web_sm`); si Presidio no está instalado o el modelo no carga, hace fallback a `_pii_regex_fallback` (regex de EMAIL / PHONE 3-3-3 / ES_NIF `\d{8}[A-HJ-NP-TV-Z]` / IP).
|
||
|
||
### 3.3 Registry de agentes — `registry/repository.py` → `FileSystemAgentRegistry(root: Path)`
|
||
|
||
| Método | Devuelve | Lee |
|
||
|--------|----------|-----|
|
||
| `list_agents()` | `list[AgentDefinition]` | recorre `root/*/index.yaml`, devuelve la versión activa de cada uno |
|
||
| `get_agent(name, version=None)` | `AgentDefinition` | `index.yaml["active_version"]` (o `version`) → `get_version` |
|
||
| `get_version(name, version)` | `AgentDefinition` | `root/<name>/versions/<version>.yaml` (lanza `FileNotFoundError`) |
|
||
| `list_versions(name)` | `list[AgentVersionMeta]` | `index.yaml["versions"]` |
|
||
| `upsert_version(name, body, message, author)` | `AgentVersionMeta` | escribe `versions/<id>.yaml` (con `compute_hash`) y actualiza `index.yaml` (si `body.state=="active"` cambia `active_version`) |
|
||
| `diff_versions(name, v1, v2)` | `DiffResult` | `versioning.unified_diff` entre los dos YAML |
|
||
|
||
### 3.4 Store de políticas — `registry/policy_store.py` → `FileSystemPolicyStore(root: Path)`
|
||
|
||
`list_policies() -> list[PolicyDefinition]`, `get_policy(name, version=None) -> PolicyDefinition` (lanza `FileNotFoundError`), `list_versions(name) -> list[PolicyVersionMeta]`.
|
||
|
||
```python
|
||
class PolicyValidator(BaseModel): type: str; config: dict[str,Any] = {}
|
||
class PolicyDefinition(BaseModel):
|
||
name: str; version: str; description: str
|
||
input_validators: list[PolicyValidator]
|
||
output_validators: list[PolicyValidator]
|
||
on_validator_error: Literal["fail_open","fail_closed"] = "fail_closed"
|
||
class PolicyVersionMeta(BaseModel): id: str; hash: str; author: str; message: str; created_at: datetime
|
||
```
|
||
|
||
### 3.5 Orchestrator — `runtime/orchestrator.py` → `AgentOrchestrator`
|
||
|
||
```python
|
||
AgentOrchestrator(*, provider: LLMProvider, engine: GuardrailEngine, data_dir: Path)
|
||
|
||
async invoke (*, agent_def: AgentDefinition, policy: PolicyDefinition, user_input: str,
|
||
trace_id: UUID | None = None) -> AgentExecution
|
||
async resume (*, agent_def, policy, trace_id: UUID, decision: dict[str,Any]) -> AgentExecution
|
||
async snapshot(*, agent_def, policy, trace_id: UUID) -> AgentExecution | None
|
||
```
|
||
|
||
- Cada uno abre su **propio** `AsyncSqliteSaver` (`runtime/checkpointer.build_checkpointer(data_dir)` → `data_dir/checkpoints.sqlite`, context manager async) y construye el grafo con `build_graph(...)`.
|
||
- `invoke`: arma el `AgentState` inicial (`status="running"`, listas vacías…) y hace `graph.ainvoke(state, config={"configurable": {"thread_id": str(trace_id)}})`. Si el grafo lanza, marca `crashed=True`. Luego `_snapshot(...)` → `AgentExecution`.
|
||
- `resume`: `graph.ainvoke(Command(resume=decision), config={thread_id: trace_id})` — reanuda el `interrupt()` con `decision`.
|
||
- `snapshot`: `graph.aget_state(config)`; si `state.values` está vacío → `None`; si no, `_build_execution(...)`.
|
||
|
||
### 3.6 El grafo — `runtime/graph.py` + `runtime/nodes.py` + `runtime/state.py`
|
||
|
||
```python
|
||
# runtime/state.py
|
||
class AgentState(TypedDict, total=False):
|
||
trace_id: str; agent_name: str; agent_version: str; user_input: str
|
||
messages: list[dict]; raw_llm_output: str | None; parsed_output: dict | None
|
||
proposed_actions: list[dict]; violations: list[dict]
|
||
decision_path: Annotated[list[dict], operator.add] # ← reducer: cada nodo AÑADE pasos
|
||
status: str; error: str | None
|
||
human_decision: dict | None; final_output: dict | None
|
||
```
|
||
|
||
```python
|
||
# runtime/nodes.py
|
||
NodeFn = Callable[[AgentState], Awaitable[dict[str, Any]]]
|
||
# factories que devuelven un NodeFn, parametrizadas con engine/policy/provider/agent_def:
|
||
build_node_validate_input(engine, policy) build_node_llm_reason(provider, agent_def)
|
||
build_node_validate_output(engine, policy) build_node_propose_actions()
|
||
build_node_approve_gate(agent_def) build_node_finalize()
|
||
# cada nodo añade un DecisionStep a decision_path: {step, timestamp, duration_ms, detail}
|
||
```
|
||
|
||
```python
|
||
# runtime/graph.py
|
||
build_graph(*, agent_def, policy, provider, engine, checkpointer: BaseCheckpointSaver) -> CompiledGraph
|
||
```
|
||
|
||
Cableado (nodos y aristas):
|
||
|
||
```
|
||
START ─► validate_input
|
||
validate_input ──(conditional)──► END si status == "blocked_by_guardrail"
|
||
llm_reason en otro caso
|
||
llm_reason ──(conditional)──────► END si status == "failed"
|
||
validate_output en otro caso
|
||
validate_output ──(conditional)─► END si status in {"blocked_by_guardrail","failed"}
|
||
propose_actions en otro caso
|
||
propose_actions ──► approve_gate ──► finalize ──► END (aristas fijas)
|
||
```
|
||
|
||
- `approve_gate`: si hay acciones con `risk_score >= agent_def.risk_threshold_for_hitl` **o** `requires_approval` ⇒ `decision = interrupt({"awaiting_actions": risky})` → el grafo se **pausa** ahí (LangGraph persiste el estado en el checkpointer); al reanudar con `Command(resume=decision)`, `interrupt()` devuelve `decision` y el nodo continúa, escribiendo `human_decision`.
|
||
- `finalize`: si `human_decision.rejected` ⇒ `status="failed"`, `error="rejected_by_human"`. Si no, `final_output = {**parsed_output, "approved_actions": <acciones cuyo id está en approved_action_ids; o todas si no hubo HITL>}`, `status="completed"`.
|
||
|
||
### 3.7 De `StateSnapshot` a `AgentExecution` — `orchestrator._build_execution`
|
||
|
||
```python
|
||
class AgentExecution(BaseModel):
|
||
trace_id: UUID; agent_name: str; agent_version: str
|
||
status: Literal["running","awaiting_approval","blocked_by_guardrail","completed","failed"]
|
||
started_at: datetime; finished_at: datetime | None
|
||
decision_path: list[DecisionStep]; violations: list[GuardrailViolation]
|
||
proposed_actions: list[ProposedAction]; needs_human_for: list[ProposedAction] | None
|
||
final_output: dict[str,Any] | None; error: str | None
|
||
class DecisionStep(BaseModel): step: str; timestamp: datetime; duration_ms: int; detail: dict[str,Any]
|
||
class ProposedAction(BaseModel): id: str; action: str; target: str; risk_score: int (1..5); rollback_plan: str; requires_approval: bool
|
||
class AgentExecutionSummary(BaseModel): trace_id; agent_name; agent_version; status; started_at; finished_at; n_violations: int; n_proposed_actions: int
|
||
```
|
||
|
||
Reglas para derivar `status` a partir del `StateSnapshot` de LangGraph:
|
||
1. `status = state.values.get("status", "running")`.
|
||
2. Si `crashed` y `status` no es terminal ⇒ `status = "failed"` (`error = error_del_state or "internal_error"`).
|
||
3. Si `state.next` (hay un nodo pendiente) y `status` no es terminal ⇒ `status = "awaiting_approval"` (es el `interrupt()` de `approve_gate`).
|
||
4. `needs_human_for`: solo si `status == "awaiting_approval"` ⇒ `[a for a in proposed_actions if a.risk_score >= agent_def.risk_threshold_for_hitl or a.requires_approval]`; en otro caso `None`.
|
||
5. `started_at = decision_path[0].timestamp` (o `now` si vacío); `finished_at = now` solo si `status` es terminal.
|
||
6. Terminales = `{"completed","failed","blocked_by_guardrail"}`.
|
||
|
||
---
|
||
|
||
## 4. Cadenas de llamada por endpoint (`api/`)
|
||
|
||
Todos pasan primero por `TraceIdMiddleware` (lee/crea `X-Trace-Id`, lo bind-ea a structlog, lo devuelve en la respuesta). Montaje (en `main.create_app`): `agents.router→/agents`, `executions.invoke_router→/agents`, `executions.router→/executions`, `policies.router→/policies`, `violations.router→/violations`, más `GET /health`.
|
||
|
||
| Endpoint | Handler (`api/…`) | Deps inyectadas | Llama a → Escribe |
|
||
|----------|-------------------|-----------------|-------------------|
|
||
| `POST /agents/{name}/invoke` | `executions.invoke_agent` | Registry, PolicyStore, Orchestrator, Settings | `registry.get_agent(name)` → `policies.get_policy(agent.guardrails[0])` → `orchestrator.invoke(agent_def, policy, body.input)` → **`_record_execution(data_dir, trace_id, name, version)`** (escribe `execution_index.json`) → si `status` terminal: **`append_execution`** (`executions.jsonl`) → por cada violación: **`append_violation`** (`violations.jsonl`). 404 si el agente no existe; 422 si no tiene política. |
|
||
| `GET /executions` | `executions.list_executions` | Registry, PolicyStore, Orchestrator, Settings | `read_execution_summaries(data_dir)` (lee `executions.jsonl`) + por cada `trace_id` en `execution_index.json` no presente ya: `registry.get_version` + `policies.get_policy` + `orchestrator.snapshot` → `AgentExecutionSummary`; fusiona. (Así aparecen también las `awaiting_approval`, que no están en el JSONL.) |
|
||
| `GET /executions/{trace_id}` | `executions.get_execution` | Registry, PolicyStore, Orchestrator, Settings | `_resolve(...)` (lee `execution_index.json` → reconstruye `agent_def`/`policy`; 404 si no está, 500 si la config referida no existe) → `orchestrator.snapshot(...)` → 404 si `None`. |
|
||
| `POST /executions/{trace_id}/approve` | `executions.approve_execution` | Registry, PolicyStore, Orchestrator, Settings | `_resolve` → `_ensure_awaiting` (`orchestrator.snapshot`; 409 si no está en `awaiting_approval`) → `orchestrator.resume(decision={approved_action_ids: body.approved_action_ids, comment: body.comment, rejected: False})` → **`append_execution`** (`executions.jsonl`). |
|
||
| `POST /executions/{trace_id}/reject` | `executions.reject_execution` | Registry, PolicyStore, Orchestrator, Settings | `_resolve` → `_ensure_awaiting` → `orchestrator.resume(decision={approved_action_ids: [], rejected: True, reason: body.reason})` → **`append_execution`**. |
|
||
| `GET /agents` | `agents.list_agents` | Registry | `registry.list_agents()` |
|
||
| `GET /agents/{name}` | `agents.get_agent` | Registry | `registry.get_agent(name)` (404 si no existe) |
|
||
| `GET /agents/{name}/versions` | `agents.list_versions` | Registry | `registry.list_versions(name)` (404) |
|
||
| `GET /agents/{name}/versions/{version}` | `agents.get_version` | Registry | `registry.get_version(name, version)` (404) |
|
||
| `GET /agents/{name}/versions/{v_from}/diff/{v_to}` | `agents.diff_versions` | Registry | `registry.diff_versions(...)` → `DiffResult` |
|
||
| `GET /policies` | `policies.list_policies` | PolicyStore | `store.list_policies()` |
|
||
| `GET /policies/{name}/versions` | `policies.list_versions` | PolicyStore | `store.list_versions(name)` (404) |
|
||
| `GET /violations?trace_id=&severity=` | `violations.list_violations` | Settings | `read_violations(data_dir)` (lee `violations.jsonl`) + filtros opcionales por `trace_id` / `severity`. |
|
||
| `GET /health` | (en `main.py`) | — | `{"status":"ok"}` |
|
||
|
||
Modelos de petición (en `executions.py`): `InvokeRequest{input: str, version: str | None}`, `ApproveRequest{approved_action_ids: list[str]=[], comment: str | None}`, `RejectRequest{reason: str}`. Modelos de respuesta = los de `domain/` (`response_model=…`).
|
||
|
||
Helpers internos de `executions.py` (índice de ejecuciones, en `data_dir/execution_index.json`):
|
||
`_index_path`, `_load_index`, `_record_execution(data_dir, trace_id, agent_name, version)`,
|
||
`_resolve(registry, policies, data_dir, trace_id) → (AgentDefinition, PolicyDefinition)` (con `HTTPException` 404/500),
|
||
`_ensure_awaiting(orchestrator, agent_def, policy, trace_id)` (`HTTPException` 404/409).
|
||
|
||
---
|
||
|
||
## 5. Persistencia: quién escribe / lee qué
|
||
|
||
| Artefacto | Formato | Lo escribe | Lo lee |
|
||
|-----------|---------|------------|--------|
|
||
| `agents/<n>/versions/<v>.yaml`, `agents/<n>/index.yaml` | YAML | `FileSystemAgentRegistry.upsert_version` (a mano en este MVP) | `FileSystemAgentRegistry.get_*` / `list_*` / `diff_versions` |
|
||
| `policies/<n>/versions/<v>.yaml`, `policies/<n>/index.yaml` | YAML | a mano | `FileSystemPolicyStore.get_policy` / `list_*` |
|
||
| `data/checkpoints.sqlite` | SQLite (LangGraph `AsyncSqliteSaver`) | el grafo, en cada `ainvoke`/transición de nodo (incl. el `interrupt()`) — vía `runtime/checkpointer.build_checkpointer(data_dir)` | `orchestrator.snapshot` / `resume` (`graph.aget_state` / `Command(resume=…)`) |
|
||
| `data/execution_index.json` | JSON `{trace_id: {agent_name, version}}` | `executions._record_execution` — en **cada** `invoke` | `executions._resolve`, `executions.list_executions` |
|
||
| `data/executions.jsonl` | JSONL append-only (`AgentExecution` por línea) | `api/persistence.append_execution` — en `invoke` **si el status es terminal** y siempre tras `approve`/`reject` | `api/persistence.read_execution_summaries` (endpoint `GET /executions`) |
|
||
| `data/violations.jsonl` | JSONL append-only (`GuardrailViolation` por línea) | `api/persistence.append_violation` — en `invoke`, una por violación de la ejecución | `api/persistence.read_violations` (endpoint `GET /violations`) |
|
||
|
||
Hashing/versionado: `registry/versioning.compute_hash(yaml_text)` = SHA-256 del YAML con espacios finales recortados; `unified_diff(a, b, label_a, label_b)` = `difflib.unified_diff`. (En los `index.yaml` de ejemplo el `hash` es `"pending"` y nadie lo valida al cargar.)
|
||
|
||
---
|
||
|
||
## 6. UI HTMX embebida en Core
|
||
|
||
La interfaz es **una sola página** (`GET /`) servida desde el mismo proceso
|
||
`forja-core` (Jinja2 + HTMX vía CDN, sin servicio separado), organizada como
|
||
las seis etapas del ciclo de vida: Define → Run → Approve → Train → Promote →
|
||
Audit. Cada etapa es un fragmento (`GET /fragments/*`) que se re-renderiza
|
||
cuando cualquier botón de acción dispara el evento global `forja-refresh`.
|
||
Los botones llaman directamente a los endpoints `/api/*` (con `json-enc`);
|
||
no hay cliente HTTP intermedio ni lógica duplicada. Las rutas multipágina
|
||
antiguas redirigen con 301 a las anclas de su etapa.
|
||
|
||
---
|
||
|
||
## 7. Arranque y ciclo de vida
|
||
|
||
**Proceso core** (`uvicorn forja_core.main:app`):
|
||
1. Import de ` forja_core.main` ⇒ se ejecuta `app = create_app()`:
|
||
`Settings()` → `configure_logging(level=settings.log_level)` → `FastAPI(...)` →
|
||
`app.add_middleware(TraceIdMiddleware)` → registra `GET /health` →
|
||
`from forja_core.api import agents, executions, policies, violations` →
|
||
`include_router` ×5.
|
||
2. Las dependencias (`deps.get_registry`, `get_policy_store`, `get_llm_provider`,
|
||
`get_guardrail_engine`, `get_orchestrator`) **no** se construyen aún; se
|
||
construyen y cachean en la **primera request** que las inyecta.
|
||
3. Cada request: `TraceIdMiddleware.dispatch` → router → resuelve `Depends(...)` (que
|
||
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` 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`:
|
||
|
||
| Campo | Env var | Default | Lo consume |
|
||
|-------|---------|---------|------------|
|
||
| `llm_provider` | `LLM_PROVIDER` | `mock` | `build_llm_provider` |
|
||
| `llm_fallback_provider` | `LLM_FALLBACK_PROVIDER` | `""` | `build_llm_provider` → `FallbackLLMProvider` (vacío = sin fallback) |
|
||
| `azure_openai_*` | `AZURE_OPENAI_*` | `""` / `2024-08-01-preview` | `AzureOpenAIProvider` |
|
||
| `openai_api_key` / `openai_model` | `OPENAI_API_KEY` / `OPENAI_MODEL` | `""` / `gpt-4o` | `OpenAIProvider` |
|
||
| `guardrails_nemo_enabled` | `GUARDRAILS_NEMO_ENABLED` | `False` | `build_guardrail_engine` |
|
||
| `guardrails_nemo_allowed_keywords` | `GUARDRAILS_NEMO_ALLOWED_KEYWORDS` | `""` | keywords CSV del stub topical-rails (vacío = sin chequeo) |
|
||
| `log_level` | `LOG_LEVEL` | `INFO` | `configure_logging` |
|
||
| `training_backend` | `TRAINING_BACKEND` | `mock` | `build_training_backend` |
|
||
| `azure_ml_*` | `AZURE_ML_*` | varios | `AzureMLTrainingBackend` (stub si falta workspace) |
|
||
| `data_dir` | `DATA_DIR` | `./data` | checkpointer, logs JSONL, training runs, evaluaciones, promociones |
|
||
| `agents_dir` | `AGENTS_DIR` | `./agents` | `build_agent_registry`, escenarios de evaluación |
|
||
| `policies_dir` | `POLICIES_DIR` | `./policies` | `build_policy_store` |
|
||
| `datasets_dir` | `DATASETS_DIR` | `./datasets` | `build_dataset_store` |
|
||
| `models_dir` | `MODELS_DIR` | `./models` | `build_model_store` |
|
||
|
||
---
|
||
|
||
## 8. Aristas y "gotchas"
|
||
|
||
- **`llm_fallback_provider`**: si está configurado y difiere del primario,
|
||
`build_llm_provider` devuelve un `FallbackLLMProvider` que delega en el
|
||
secundario cuando el primario agota sus reintentos.
|
||
- **`_build_single`** usa `match` sin `case _:`; al ser el tipo un `Literal`
|
||
de tres valores es exhaustivo, pero un valor inesperado caería en "ninguna rama".
|
||
- **NeMo**: `NeMoGuardrailsEngine` solo entra al `CompositeGuardrailEngine` si
|
||
`GUARDRAILS_NEMO_ENABLED=true`; por defecto el composite tiene un solo engine.
|
||
- **`detect_pii`** se comporta distinto según el entorno: con `presidio-analyzer`
|
||
instalado (imagen Docker) usa Presidio + `en_core_web_sm`; sin él (venv local
|
||
típico), regex fallback. La política `default` pide solo recognizers de patrón
|
||
(`EMAIL_ADDRESS`, `ES_NIF`, `IP_ADDRESS`, `IBAN_CODE`) para evitar falsos
|
||
positivos del NER.
|
||
- **`AsyncSqliteSaver`** liga su conexión `aiosqlite` al event loop activo: por eso
|
||
`build_checkpointer` es un *async context manager* y el orchestrator lo abre y
|
||
cierra en cada operación (no se reusa entre llamadas). Requiere `aiosqlite<0.21`.
|
||
- **`data/`** debe existir y ser escribible por el proceso/usuario del contenedor
|
||
(`agent`, uid 1000); si no, `invoke` fallará al escribir el log/checkpoint.
|
||
- **`make test` / `make lint` / `make smoke`** asumen que el venv está activado
|
||
(los binarios `pytest`/`ruff`/`mypy`/`docker` en el `PATH`).
|
||
- **`tests/integration/`** monta la app con `TestClient` apuntando a los assets
|
||
*reales* del repo (`agents/`, `policies/`); `tests/unit/test_api_*` usan
|
||
`tests/fixtures/` (versiones mínimas). Ambos hacen `deps.*.cache_clear()`.
|