cadrumo.application.ledger.llm_classification module

LLM-assisted ledger classification: suggest / apply / provider availability.

Wires the existing LLMClassifier engine into the operator suggest -> review -> confirm / override / reject loop without rebuilding the classifier. The contract is deliberately thin:

  • suggest_llm_classification() loads one transaction, runs the caller-composed classifier port with the category-enabled prompt spec, and returns a typed LLMClassificationSuggestion without persisting anything. Rejecting a suggestion is simply not applying it.

  • apply_llm_classification() persists an accepted suggestion through the established classification write (set_classification()), stamping classified_by with the classifier’s decided_by (llm:<model> provenance, distinct from manual / rule:) and recording the model’s confidence and reason. The accepted decision is appended to the profile audit trail through the caller-injected bucket-event repository as a ledger.transaction.classified event.

Hallucination containment stays inside the engine: the classifier’s classify runs the allow-list-guarded parse_response(), so an out-of-allow-list value is rejected before it ever reaches this module.

Stage-1 constraint. suggest_llm_classification() / apply_llm_classification() persist only the non-regulated business_classification and optional expense category; they never set a regulated tax value.

Stage-2 saturation. saturate_llm_classification() / apply_saturated_llm_classification() additionally persist the model-selected iva_category and the system-DERIVED taxable_base / iva_rate / iva_amount. The model still never emits a number — the rate is looked up from the registry and the base and amount are derived with round_to_cents. irpf_category remains operator-only.

Core types: TransactionCatalogue.

class ResolvedEvidence(reference, text, images)[source]

Bases: object

A transaction’s linked evidence resolved for an on-host read.

Exactly one read mode is populated: text for a text-layer PDF (inlined into the prompt and fed to the cloud subprocess classifier, consent-gated) or images for a scan-only PDF / image (read in memory by the LOCAL vision model, on-host, gestor-allowed). The images are transient FINANCIAL-derived bytes and MUST never be persisted or logged (sensitive-financial-data-secure-storage-only).

Parameters:
reference: str
text: str | None
images: tuple[EvidenceImage, ...]
property is_images: bool

Whether this evidence routes to the on-host vision reader.

resolve_llm_evidence(transaction, *, bucket_id, settings, ports)[source]

Resolve a transaction’s linked evidence to an on-host read, or None.

Returns None when the transaction has no linked evidence. A text-layer PDF yields text and routes to the cloud subprocess classifier, so it is gated by the cloud-upload consent posture (default-off, gestor-barred, per-invocation). A scan-only PDF or an image yields base64 images and is read in memory by the LOCAL Ollama vision model – fully on-host, needing no cloud consent and permitted for gestor deployments. Bytes are read from secure storage into memory only; nothing is written to disk (sensitive-financial-data-secure-storage-only).

Raises:

PurchaseInvoiceEvidenceInputError – When a text-layer read would transmit to a cloud model but the per-invocation consent gate is not satisfied.

Return type:

ResolvedEvidence | None

Parameters:
classify_with_evidence(transaction, evidence, *, text_classifier, spec, vision_classifier, vision_model, settings, ports)[source]

Classify, routing scan/image evidence to the on-host vision classifier.

Returns (response, provenance). Image evidence is read by the local vision model (llm:local-vision:<model> provenance) and needs no text_classifier; text or no evidence runs the cloud subprocess text_classifier (llm:<provider>:<model> provenance), which must be present. vision_model overrides the settings default vision model for this read.

Raises:
Return type:

tuple[LLMClassificationResponse, str]

Parameters:
suggest_llm_classification(*, bucket_id, transaction_id, operation, classifier=None, vision_classifier=None, vision_model=None, transaction_repository, read_evidence=False, settings, ports)[source]

Run the LLM classifier for one transaction and return a suggestion.

Loads the transaction, runs the injected classifier (default-resolved with the category-enabled prompt spec), and returns the typed suggestion. Persists nothing — this is the suggest step of the suggest / review / confirm / reject loop.

Parameters:
  • bucket_id (str) – Active profile bucket id.

  • transaction_id (str) – Stable id of the transaction to classify.

  • operation (PinnedAuthorityOperation) – Generation-pinned authority operation for the prompt’s dated category vocabulary and profile hints.

  • classifier (LLMClassifier | None) – Injected classifier (dependency injection for tests). With the cloud transports deleted the on-host reader is the default, so an injected classifier is the only non-default case.

  • vision_classifier (VisionClassifier | None) – Injected on-host vision classifier used when the evidence is a scan-only PDF or image; default-resolved otherwise.

  • vision_model (str | None) – Overrides the settings default local vision model (e.g. qwen2.5vl:7b) for an image/scan read; None uses the default.

  • transaction_repository (TransactionCatalogueCoCommitWriterProtocol) – Injected catalogue repository.

  • read_evidence (bool) – When True, resolve the transaction’s linked evidence and read it on-host — a text-layer PDF is inlined and sent to the cloud classifier (consent-gated), a scan-only PDF or image is read by the local vision model (no consent needed). Off by default.

  • settings (Settings) – Injected settings; defaults to load_settings().

  • ports (LLMClassificationPorts) – Injected evidence-reading and classifier execution ports.

Return type:

LLMClassificationSuggestion

Returns:

A LLMClassificationSuggestion.

Raises:
apply_llm_classification(suggestion, *, bucket_id, business_pct=None, actor='operator', source_command, transaction_repository, bucket_event_repository, occurred_at=None)[source]

Persist an accepted LLM suggestion with llm: provenance.

Writes the decision through set_classification(), stamping classified_by with the suggestion’s provenance (the classifier’s decided_by, e.g. llm:<model>) and recording the model’s confidence and reason. Persists the catalogue and emits a LEDGER_TRANSACTION_CLASSIFIED event atomically.

The MVP persists only the non-regulated business_classification and optional expense category. It never sets a regulated tax value.

A MIXED suggestion requires an explicit business_pct (the LLM does not produce one); apply refuses instructively when it is absent. The expense category is recorded only for BUSINESS / MIXED classifications.

Parameters:
Return type:

ManualLedgerTransactionResult

Returns:

A ManualLedgerTransactionResult reflecting the persisted decision.

Raises:
saturate_llm_classification(*, bucket_id, transaction_id, operation, classifier=None, vision_classifier=None, vision_model=None, transaction_repository, on_date=None, read_evidence=False, settings, ports)[source]

Run the saturating LLM classifier for one transaction and return a suggestion.

Loads the transaction, runs the injected classifier (default-resolved with the saturation prompt spec), then DERIVES the regulated tax substrate from the model’s selected IvaCategory using the registry rate and a deterministic inverse split. Persists nothing — this is the suggest step; rejecting a suggestion is simply not applying it.

Parameters:
  • bucket_id (str) – Active profile bucket id.

  • transaction_id (str) – Stable id of the transaction to classify.

  • operation (PinnedAuthorityOperation) – Generation-pinned authority operation for the prompt’s dated category and IVA projections.

  • classifier (LLMClassifier | None) – Injected classifier (dependency injection for tests). With the cloud transports deleted the on-host reader is the default; an injected classifier overrides it, with the saturation prompt spec.

  • vision_classifier (VisionClassifier | None) – Injected on-host vision classifier used when the evidence is a scan-only PDF or image; default-resolved otherwise.

  • vision_model (str | None) – Overrides the settings default local vision model (e.g. qwen2.5vl:7b) for an image/scan read; None uses the default.

  • transaction_repository (TransactionCatalogueCoCommitWriterProtocol) – Injected catalogue repository.

  • on_date (date | None) – Effective date used to resolve the registry rate; defaults to the transaction’s value date (or booked date).

  • read_evidence (bool) – When True, resolve the transaction’s linked evidence, extract its text on-host, and inject it into the prompt. Off by default.

  • settings (Settings) – Injected settings; defaults to load_settings().

  • ports (LLMClassificationPorts) – Injected evidence-reading and classifier execution ports.

Return type:

LLMSaturatedSuggestion

Returns:

A LLMSaturatedSuggestion carrying the model’s selections and the system-derived euro substrate.

Raises:
apply_saturated_llm_classification(suggestion, *, bucket_id, business_pct=None, actor='operator', source_command, ports, occurred_at=None)[source]

Persist an accepted saturated suggestion through the manual write path.

Composes the established single-writer manual-command write (update_manual_transaction_fields()) rather than re-implementing it, so the regulated fields land with their existing validators plus the gross == taxable_base + iva_amount invariant, and stamps classified_by with the suggestion’s llm:<model> provenance via classified_by_override.

The non-regulated business decision (classification, expense category) and the model-selected iva_category are persisted; the regulated euro figures are persisted only when the category was derivable (a non-derivable category leaves the operator to complete the numbers). A MIXED suggestion requires a business percentage — the model’s proposed business_pct is used unless the caller overrides it; apply refuses instructively when neither is present.

Parameters:
  • suggestion (LLMSaturatedSuggestion) – The accepted LLMSaturatedSuggestion.

  • bucket_id (str) – Active profile bucket id.

  • business_pct (Decimal | None) – Operator override for the MIXED business percentage; falls back to the model’s proposed business_pct.

  • actor (str) – Operator identity for the audit event.

  • source_command (str) – Source-command label recording the operator’s verb.

  • ports (LedgerActionPorts) – Injected ledger action ports for the catalogue and audit-event repositories.

  • occurred_at (datetime | None) – Override clock for deterministic tests.

Return type:

ManualLedgerTransactionResult

Returns:

A ManualLedgerTransactionResult reflecting the persisted state.

Raises:

TransactionValidationError – When a MIXED suggestion is applied with no business percentage available.

derive_operator_iva_substrate(*, bucket_id, transaction_id, iva_category, on_date=None, actor='operator', source_command, ports, occurred_at=None)[source]

Derive and persist the IVA substrate for an OPERATOR-chosen category.

The same grounded derivation the saturating LLM path uses (resolve_category_rate() + split_gross_at_rate()), but initiated by the operator rather than the model — the fallback for when the model declines (returns unknown) or the operator simply knows the category. Given a transaction already classified BUSINESS or MIXED and the selected IvaCategory, it resolves the registry rate, splits the gross into taxable base and IVA amount, and persists them through the manual write with derived: provenance. Only the IVA substrate is touched; the business classification stays as-is. A non-derivable category persists nothing and returns an explanatory note.

Return type:

OperatorIvaDerivationResult

Returns:

The OperatorIvaDerivationResult recording the persisted IVA substrate, or an explanatory note when the category is non-derivable.

Raises:
Parameters:
suggest_evidence_split(*, bucket_id, transaction_id, operation, proposer=None, vision_classifier=None, vision_model=None, transaction_repository, on_date=None, read_evidence=True, settings, ports)[source]

Propose an evidence-driven N-way split for one transaction.

Loads the transaction, runs the injected proposer (default-resolved with the saturation prompt spec) over the optional on-host evidence text, DERIVES each child’s euro amount from the parent gross and the model’s proportion (summing exactly to the parent), and DERIVES each child’s regulated tax substrate from the registry rate for the model-selected IVA category. Persists nothing — this is the suggest step.

Parameters:
  • bucket_id (str) – Active profile bucket id.

  • transaction_id (str) – Stable id of the transaction to split.

  • operation (PinnedAuthorityOperation) – Generation-pinned authority operation for the prompt’s dated category and IVA projections.

  • proposer (LLMSplitProposer | None) – Injected split proposer (dependency injection for tests). With the cloud transports deleted the on-host reader is the default, so an injected proposer is the only non-default case.

  • vision_classifier (VisionClassifier | None) – Injected on-host vision classifier used when the evidence is a scan-only PDF or image; default-resolved otherwise.

  • vision_model (str | None) – Overrides the settings default local vision model (e.g. qwen2.5vl:7b) for an image/scan read; None uses the default.

  • transaction_repository (TransactionCatalogueCoCommitWriterProtocol) – Injected catalogue repository.

  • on_date (date | None) – Effective date used to resolve each child’s registry rate; defaults to the transaction’s value date (or booked date).

  • read_evidence (bool) – When True (default for splitting), resolve the transaction’s linked evidence, extract its text on-host, and inject it into the prompt.

  • settings (Settings) – Injected settings; defaults to load_settings().

  • ports (LLMClassificationPorts) – Injected evidence-reading and classifier execution ports.

Return type:

LLMSplitSuggestion

Returns:

A LLMSplitSuggestion whose child amounts sum exactly to the parent.

Raises:
  • TransactionNotFoundError – When the transaction id is unknown.

  • LLMClassifierError – When the proposer fails (provider CLI unavailable, hallucinated out-of-allow-list value, or a malformed split response).

apply_evidence_split(suggestion, *, bucket_id, actor='operator', source_command, ports, occurred_at=None)[source]

Apply a reviewed evidence-driven split through the single-writer split path.

Delegates the whole mutation to the established single writer rather than re-implementing it: split_transaction_with_classified_children() redistributes the parent into children whose magnitudes sum exactly to the parent and stamps each child’s model-selected expense and IVA category, the registry-DERIVED regulated numbers, the parent’s inherited evidence link, and the llm:<model> provenance. This function only translates the reviewed suggestion into that writer’s arguments; it performs no write of its own and no follow-up per-child field patch.

Because the writer persists the parent transition, every classified child, and all bucket events in one transaction, a child can never come to rest split but unclassified or missing its evidence link. The writer enforces children-sum-to-parent, the non-negative-magnitude invariant, and gross == taxable_base + iva_amount per child. The LLM never supplies a persisted euro amount or regulated number.

Parameters:
  • suggestion (LLMSplitSuggestion) – The accepted LLMSplitSuggestion.

  • bucket_id (str) – Active profile bucket id.

  • actor (str) – Operator identity for the audit events.

  • source_command (str) – Source-command label recording the operator’s verb.

  • ports (LedgerActionPorts) – Injected ledger action ports for the catalogue and audit-event repositories.

  • occurred_at (datetime | None) – Override clock for deterministic tests.

Return type:

LLMSplitApplyResult

Returns:

An LLMSplitApplyResult naming the split group and its children.

Raises:
apply_evidence_classification(suggestion, *, bucket_id, actor='operator', source_command, ports, occurred_at=None)[source]

Apply a no-split (single-child) evidence suggestion in place on the parent.

The auto-split router uses one model call — the split proposer — to decide whether to split. When the proposer returns a single child (the “no split warranted” verdict), that child already carries the model-selected expense and IVA categories and the registry-DERIVED taxable_base / iva_rate / iva_amount for the whole gross. This stamps them on the parent through the single-writer update_manual_transaction_fields() with the llm:<model> provenance — the same values apply_evidence_split() hands its children, but applied in place to one existing row instead of redistributed across new ones. The parent’s own evidence link is preserved verbatim by that write and is never re-set here. The model emits no euro amount or regulated number (llm-selects-system-derives-tax-numbers).

Parameters:
  • suggestion (LLMSplitSuggestion) – A no-split LLMSplitSuggestion (exactly one child).

  • bucket_id (str) – Active profile bucket id.

  • actor (str) – Operator identity for the audit event.

  • source_command (str) – Source-command label recording the operator’s verb.

  • ports (LedgerActionPorts) – Injected ledger action ports for the catalogue and audit-event repositories.

  • occurred_at (datetime | None) – Override clock for deterministic tests.

Return type:

ManualLedgerTransactionResult

Returns:

The ManualLedgerTransactionResult for the in-place classification.

Raises:
reject_llm_suggestion(suggestion, *, bucket_id, reason='', actor='operator', source_command, transaction_repository, bucket_event_repository, occurred_at=None)[source]

Record an explicit, audit-trailed rejection of an LLM suggestion.

This is the fourth decision terminal of the suggest -> review -> decide loop (after approve = apply and update = manual override). It captures what the model proposed and the operator’s reason in a LEDGER_TRANSACTION_LLM_SUGGESTION_REJECTED bucket event, and mutates nothing — the transaction’s classification, numbers, and lifecycle are untouched, so its review status stays pending (it is still unclassified). No regulated number is written; the model emitted none and reject writes none.

Parameters:
Return type:

LLMSuggestionRejectionResult

Returns:

An LLMSuggestionRejectionResult naming the recorded event.

Raises: