Categoría: Conector + SoR pequeño propio (Plan Store + executed_plan_history) Estado: Conector hipertrofiado (por defecto, en V3 honesta — la cicatriz Holded vive aquí, contenida) Tamaño esperado: ~10-12k LOC (incluye Plan Generator + agente, Plan Store, Executor, Final Validation, adapter HTTP)
Recibir una decisión semántica (StandaloneIntent + InternalAccountingPlan + ContextualDecision) del Motor M1 y producir el estado deseado en Holded, capturando readback para verificación.
Toda la cicatriz Holded vive aquí. Cero menciones a Holded fuera de I1.
I2 paralelo a I1, no extensión de I1.holded_integration/
├── plan_generator/ # agente LLM + guías Holded + tool wrapper
│ ├── agent.py
│ ├── guides/ # markdown editable, sin razonamiento semántico
│ │ ├── purchase_creation.md
│ │ ├── recreate_vs_update.md
│ │ ├── period_lock_workaround.md
│ │ └── ...
│ └── tool_wrapper.py # HoldedAdapter para que el LLM lea/escriba estado
├── plan_store/ # reglas calcificadas DE INTEGRACIÓN Holded (no semánticas)
│ ├── store.py
│ └── lookup.py
├── executor/ # Temporal-durable, dumb
│ ├── workflow.py
│ ├── activities.py
│ └── retries.py
├── final_validation/ # PlanState vs readback de Holded
│ ├── comparator.py
│ └── drift_profile.py # tabla {(expected, tax_key): allowed_observed}
├── facts_snapshot/ # producer de facts/erp_snapshot/ para M1
│ ├── chart_loader.py
│ ├── period_lock_loader.py
│ ├── ledger_history_loader.py
│ └── reconciliation_loader.py
└── adapter/ # HTTP/auth/retry primitives
├── holded_internal.py
└── holded_public.py
(StandaloneIntent, InternalAccountingPlan, ContextualDecision) + erp_context_snapshot reciente.plan_store por (intent_signature, erp_context_signature). Hit → return ExecutionPlan cached. Mark como served_by: rule_<id>.tool_wrapper.holded_* para leer estado del ERP en runtime si necesita (e.g. holded.read_existing_document_state(external_id)).ExecutionPlan estructurado.served_by: agent_v<X> + confidence + reasoning_trace.ExecutionPlanLista ordenada de operaciones Holded declarativas:
ExecutionPlan {
operations: [
EnsureContact { contact_id_external?: "abc", body: {...}, primary_key: nif },
CreatePurchase { contact_ref: ..., lines: [...], date: ..., tax_keys: [...] },
AttachDocument { document_ref: ..., file_url: ... },
CreatePayment { document_ref: ..., amount: 100, account: "57200000" },
ReconcileBankTransaction { payment_ref: ..., transaction_id: "..." },
PostComment { document_ref: ..., body: "[Intelia] plan_id=X ...", dedupe_key: "..." },
AddTags { document_ref: ..., tags: ["intelia-plan-id-X", "duda-cuenta"] }
],
plan_state_declared: PlanState { /* qué properties esperamos ver tras ejecución */ },
confidence: 0.95,
served_by: "rule_R-1234" | "agent_v3.4",
reasoning_trace: "..." (solo si agent)
}
Las operaciones son declarativas — no son llamadas API directas. El Executor las ejecuta en su orden topológico.
El agente NO se re-entrena. Lo que cambia con el tiempo:
Las guías Holded son markdown editable por Intelia ops. Política dura: las guías contienen cómo se hace en Holded, nunca qué es semánticamente.
tax_key: s_iva_isp y sin recargo." (razonamiento semántico)vat_application_mechanism: REVERSE_CHARGE, usar tax_key: s_iva_isp y NO añadir recargo en payload." (traducción)El agente NO debe inferir si algo es ISP — lo declara el Motor. Si la guía obliga al agente a razonar "esto parece ISP porque...", el Motor está incompleto.
Cache + rule store del integrador. Schema:
PlanRule {
rule_id: UUID,
integrator: "holded",
rule_type: "execution_plan",
intent_signature: str, # hash determinista de subset del Intent
erp_context_signature: str, # hash de subset del erp_snapshot relevante
execution_plan: ExecutionPlan (template),
confidence: float,
promoted_at: datetime,
promoted_from: "shadow" | "bootstrap" | "manual",
bootstrap_origin: {client_id, sector}?, # si vino de bootstrap cruzado
invalidation_signals: [
"GUIDE_VERSION_CHANGED",
"CRITERION_DRIFT_TRIGGERED",
"EDITED_BY_HUMAN_RATE>0.2",
],
served_count: int,
last_served_at: datetime
}
Lookup: dado (intent_signature, erp_context_signature) → retorna match exacta o None. Sin fuzzy match. La fuzziness vive en la signature, no en el lookup: la signature normaliza inputs (e.g. amount → bucket de magnitudes).
Invalidación:
inactive, mantiene historial para audit.guide_version cambia → reglas con dependent_guide_versions afectadas pasan a re-shadow.Temporal-durable. Dumb. Recibe ExecutionPlan y lo ejecuta.
ExecutorActivity {
input: ExecutionPlan,
output: ExecutionResult {
executed_operations: [...],
skipped: [...],
failed_at: index?,
observed_state: dict (readback),
compensations_applied: [...]
}
}
Reglas:
dedupe_key en cada operación.compensation_required si no.Compara PlanState (declarado por el Plan Generator) vs observed_state (readback de Holded después de ejecutar).
FinalValidationResult {
status: PASSED | PASSED_WITH_WARNINGS | FAILED | COMPENSATION_REQUIRED,
findings: [
Finding {
property_path: "document.lines[0].account_code",
expected: Expected[str](value="62980000", comparator=EXACT_MATCH),
observed: "62980000",
comparator_result: MATCH,
drift_profile_consulted: true,
},
...
]
}
El drift_profile es declarativo: {(expected_account, tax_key): allowed_observed_accounts}. Es donde Holded mete su personalidad (e.g. "para tax_key=s_iva_21 + cuenta 62980000, Holded a veces devuelve 62989000"). Esta tabla es deuda Holded explícita y versionada. Vive en drift_profile.py con git history.
I1 también es responsable de producir facts/erp_snapshot/ para que el Motor lo consuma como input. Esto incluye:
chart_loader.py: lee chart of accounts de Holded.period_lock_loader.py: parsea el HTML de Holded para extraer locks (con TTL explícito, cache local).ledger_history_loader.py: lee asientos previos relacionados con el documento.reconciliation_loader.py: lee transacciones bancarias reconciliadas.Cada loader tiene política de refresh (lazy / on-rejection / scheduled). El Motor NO conoce estos loaders — recibe el snapshot ya empaquetado.
| Riesgo | Mitigación |
|---|---|
| El agente Plan Generator empieza a decidir semantics porque las guías lo invitan | Code review de guías obligatoria. Lint custom busca palabras como "decidir", "evaluar", "interpretar" en las guías. Si aparecen, la guía está rota |
| El Plan Store guarda planes Holded con shape variable que rompen comparación | ExecutionPlan tiene schema Pydantic estricto. Planes con shape distinto no son comparables y T3 no los puede calcificar. Forces shape uniformity |
| Reglas calcificadas se vuelven obsoletas tras cambio de Holded API | guide_version por regla + invalidación automática cuando se actualiza guía. Período de shadow obligatorio post-invalidación |
| Drift profile crece a 10k entries y se vuelve ingobernable | Owner explícito por entry. Retention policy: entries no usadas en 6 meses se archivan. Audit cuatrimestral |
| Latencia del Plan Generator (15-30s) destruye UX en cold start | Plan Store como protección. Cold start solo en cliente nuevo / patrón nuevo. Bootstrap cruzado reduce cold a primeras semanas |
| Concurrencia entre Intelia escribiendo y contable editando en el mismo documento | Optimistic lock por last_modified_at en Holded. Si conflict → Final Validation FAIL → revisión humana |
Patología 1: Plan Generator se enamora del razonamiento. El LLM genera planes brillantes con reasoning_trace extenso, pero el equipo deja de calcificar porque "el agente lo hace bien". A los 6 meses, 95% de docs siguen llamando al agente (5€ cada uno), y T3 no ha promovido ni una regla. Coste LLM x 10. Vigilancia: KPI obligatorio %_served_by_rule. Si <30% a los 3 meses, alarma + revisión de T3.
Patología 2: las guías acaban siendo prompts de 50 páginas. Cada workaround Holded entra como párrafo nuevo. A los 18 meses, la guía es ilegible, el LLM se confunde con context window saturado, los planes generados empeoran. Vigilancia: tamaño de cada guía bounded (max 500 líneas). Si una excede, dividir por subdominio.
Patología 3: el drift profile se vuelve la nueva cicatriz. Cada vez que Holded da un readback distinto del esperado, alguien añade entrada al drift profile en lugar de arreglar la causa raíz. A los 12 meses hay 8k entries y nadie sabe cuáles son legítimas. Vigilancia: ratio drift_profile_size / total_holded_writes. Si crece, alarma. Cada entry debe tener justification text + owner.
Patología 4: el agente lee Holded constantemente vía tool y lo satura. Con 1000 docs/día, cada uno haciendo 3-5 read calls a Holded API via tool, son 5000 calls/día. Rate limit + coste. Mitigación: cache local agresiva en tool_wrapper. Las lecturas frecuentes (chart, lock_state) se cachean con TTL corto. El agente cree que lee fresh; en realidad lee cache.
Schema del intent_signature. Subset del Intent que determina la regla. Demasiado fino → casi no hay hits. Demasiado grueso → reglas falsas.
¿Plan Generator es agente Temporal-aware o syncronous? Si es agente Temporal (long-running con sleeps), facilita reintentos y debugging. Si es syncronous, latencia menor pero menos durabilidad. Decisión.
¿Las guías se versionan en git o en DB? Git da audit + PR review. DB permite edición desde dashboard sin commit. Trade-off.
Política de "qué hacer cuando el agente declara confidence baja sobre un caso edge". ¿Siempre va a dashboard? ¿Hay umbral? ¿Hay tipos de caso (e.g. ISP) que siempre van a humano hasta calcificar N veces?
El executor.compensation — qué se compensa automáticamente y qué no. El Executor sabe que un CreatePayment falló post-CreatePurchase; ¿deshace el CreatePurchase? Depende de si la operación es "primary" o "cascading" (concepto del target documentado V2 que aquí se preserva).
¿Holded como surface (link + tag) merece su propio sub-componente? Hoy lo mezclo en executor.activities.py. Si crece, podría ser holded_signals_projector/.
| Métrica | Target |
|---|---|
i1_plan_store_hit_rate_per_client |
Crece a >70% a los 6 meses |
i1_plan_generator_llm_calls_per_doc |
Decrece con calcificación; baseline 1.0 → target 0.2 |
i1_executor_latency_p95 |
<10s para plans calcificados, <30s para plans con agente |
i1_final_validation_failed_rate |
<2%. Si sube, drift_profile incompleto o bug |
i1_compensation_triggered_per_month |
Bajo. Cada uno requiere audit individual |
holded_tool_calls_per_doc |
<5. Si sube, agente leyendo demasiado, optimizar cache |
t3-calcification.md — cómo T3 propone reglas a I1.Plan Storet4-reverse-sync.md — cómo T4 invalida reglas de I1.Plan Store