i1-integrator-holded

I1 — Integrador Holded

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)


1. Responsabilidad

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.

2. NO es su responsabilidad

  • No decide semánticamente. Si el intent dice "deducible 50%, IRPF 15%", I1 traduce; no recalcula.
  • No persiste el libro diario. Holded es el SoR. I1 persiste su propio SoR pequeño (Plan Store + executed_plan_history) que NO compite con el ledger Holded.
  • No habla con otros ERPs. Cuando llegue Sage, será I2 paralelo a I1, no extensión de I1.

3. Sub-componentes

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

4. El Plan Generator (agente LLM)

Cómo funciona

  1. Recibe del Motor: (StandaloneIntent, InternalAccountingPlan, ContextualDecision) + erp_context_snapshot reciente.
  2. Lookup en plan_store por (intent_signature, erp_context_signature). Hit → return ExecutionPlan cached. Mark como served_by: rule_<id>.
  3. Miss → llama al agente LLM con:
    • Prompt sistema corto (rol, formato output esperado).
    • Las guías Holded markdown relevantes (no todas — pre-filtro por type del documento + estado del ERP).
    • El intent del Motor.
    • Acceso a tool_wrapper.holded_* para leer estado del ERP en runtime si necesita (e.g. holded.read_existing_document_state(external_id)).
  4. El agente razona, hace read calls al ERP via tool, produce un ExecutionPlan estructurado.
  5. Marca served_by: agent_v<X> + confidence + reasoning_trace.

Qué contiene ExecutionPlan

Lista 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.

Cómo aprende el Plan Generator

El agente NO se re-entrena. Lo que cambia con el tiempo:

  1. Las guías Holded markdown evolucionan. Cuando Holded cambia su API o cuando Intelia descubre un nuevo workaround, se actualiza la guía. Versión nueva entra en producción tras review humano.
  2. El Plan Store crece con calcificación (vía T3). Cada vez más matchs → cada vez menos llamadas al agente.
  3. Las reglas calcificadas se invalidan vía T4 cuando el contable las contradice repetidamente.

Frontera con las guías

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.

  • ❌ Mal: "Si la factura es un ISP, hacer PUT con tax_key: s_iva_isp y sin recargo." (razonamiento semántico)
  • ✅ Bien: "Cuando el intent declara 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.

5. El Plan Store

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:

  • Por T3 cuando una regla recibe muchas correcciones → marca como inactive, mantiene historial para audit.
  • Por T4 cuando guide_version cambia → reglas con dependent_guide_versions afectadas pasan a re-shadow.
  • Manual desde dashboard Intelia ops.

6. El Executor

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:

  • Cada operación es una activity Temporal o varias agrupadas según el dominio de fallo (LLM 3×, provider API 5-10×, DB 10×).
  • Idempotencia por dedupe_key en cada operación.
  • Compensación: si falla en step N, el executor revierte 1..N-1 (cuando es posible) y produce compensation_required si no.
  • El Executor no decide si una operación es segura o no. Eso ya lo decidió el Plan Generator vía la confidence score y el PlanState declarado.

7. Final Validation

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.

8. Facts snapshot producer

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.

9. Riesgos arquitectónicos y mitigaciones

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

10. Lo que puede salir mal

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.

11. Open questions

  1. Schema del intent_signature. Subset del Intent que determina la regla. Demasiado fino → casi no hay hits. Demasiado grueso → reglas falsas.

  2. ¿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.

  3. ¿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.

  4. 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?

  5. 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).

  6. ¿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/.

12. Métricas operacionales

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

Próximos docs