aeat.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 (injected, default-resolved) classifier 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 a BucketEventHistoryRepository as a ledger.transaction.classified event.

  • available_llm_providers() reports which subprocess providers have a usable CLI on PATH so the CLI can refuse instructively rather than crash.

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 (see 2026-06-04-llm-ledger-classification-adr). irpf_category remains operator-only.

available_llm_providers()[source]

Report which subprocess LLM providers are usable on this host.

Probes PATH for each provider’s CLI binary with shutil.which (no process is spawned). The CLI surfaces this so an operator can discover which providers are installed before classifying.

Return type:

tuple[LLMProviderAvailability, ...]

Returns:

One LLMProviderAvailability per LLMProvider, ordered by enum declaration.

is_llm_provider_available(provider)[source]

Return whether provider’s CLI binary is resolvable on PATH.

Return type:

bool

Parameters:

provider (LLMProvider)

suggest_llm_classification(*, bucket_id, transaction_id, provider, classifier=None, vision_classifier=None, vision_model=None, transaction_repository=None, read_evidence=False, evidence_acknowledged=False, settings=None)[source]

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

Loads the transaction, runs the injected classifier (default-resolved from provider 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.

  • provider (LLMProvider | None) – Subprocess provider to resolve when classifier is None.

  • classifier (LLMClassifier | None) – Injected classifier (dependency injection for tests). When None, resolved via resolve_classifier() for provider.

  • vision_classifier (LocalVisionLLMClassifier | 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 (TransactionCatalogueRepositoryProtocol | None) – 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.

  • evidence_acknowledged (bool) – Per-invocation acknowledgement that sending text-layer evidence to a cloud model is accepted; required by the cloud-upload consent gate for the text path (the on-host vision path needs no acknowledgement).

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

Return type:

LLMClassificationSuggestion

Returns:

A LLMClassificationSuggestion.

Raises:
apply_llm_classification(suggestion, *, bucket_id, business_pct=None, actor='operator', source_command='aeat app ledger classify --llm', transaction_repository=None, bucket_event_repository=None, 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, provider, classifier=None, vision_classifier=None, vision_model=None, transaction_repository=None, on_date=None, read_evidence=False, evidence_acknowledged=False, settings=None)[source]

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

Loads the transaction, runs the injected classifier (default-resolved from provider 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.

  • provider (LLMProvider | None) – Subprocess provider to resolve when classifier is None.

  • classifier (LLMClassifier | None) – Injected classifier (dependency injection for tests). When None, resolved via resolve_classifier() for provider with the saturation prompt spec.

  • vision_classifier (LocalVisionLLMClassifier | 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 (TransactionCatalogueRepositoryProtocol | None) – 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.

  • evidence_acknowledged (bool) – Per-invocation acknowledgement that sending the evidence to a cloud model is accepted; required by the cloud-upload consent gate when read_evidence is set.

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

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='aeat app ledger classify --llm --saturate --apply', transaction_repository=None, bucket_event_repository=None, 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:
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='aeat app ledger classify --iva-category --saturate', transaction_repository=None, bucket_event_repository=None, 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, provider, proposer=None, vision_classifier=None, vision_model=None, transaction_repository=None, on_date=None, read_evidence=True, evidence_acknowledged=False, settings=None)[source]

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

Loads the transaction, runs the injected proposer (default-resolved from provider 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.

  • provider (LLMProvider | None) – Subprocess provider to resolve when proposer is None.

  • proposer (LLMSplitProposer | None) – Injected split proposer (dependency injection for tests). When None, resolved via resolve_split_proposer() for provider.

  • vision_classifier (LocalVisionLLMClassifier | 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 (TransactionCatalogueRepositoryProtocol | None) – 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.

  • evidence_acknowledged (bool) – Per-invocation acknowledgement that sending the evidence to a cloud model is accepted; required by the cloud-upload consent gate when read_evidence is set and evidence is linked.

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

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='aeat app ledger split --llm --apply', transaction_repository=None, bucket_event_repository=None, occurred_at=None)[source]

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

Composes the established single writers rather than re-implementing them (composition-service-no-parallel-write-path): first split_transaction() redistributes the parent into children whose magnitudes sum exactly to the parent, then for each child update_manual_transaction_fields() stamps the model-selected expense category and IVA category, the registry-DERIVED regulated numbers, the parent invoice’s evidence link, and the llm:<model> provenance.

The split path enforces children-sum-to-parent and the non-negative-magnitude invariant; the per-child write enforces the gross == taxable_base + iva_amount invariant. The LLM never supplies a persisted euro amount or regulated number.

Parameters:
Return type:

LLMSplitApplyResult

Returns:

An LLMSplitApplyResult naming the split group and its children.

Raises:
apply_evidence_classification(suggestion, *, bucket_id, actor='operator', source_command='aeat app ledger classify --read-evidence --auto-split --apply', transaction_repository=None, bucket_event_repository=None, 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 parent invoice’s evidence link and the llm:<model> provenance — exactly the per-child write apply_evidence_split() performs, but without splitting. The model emits no euro amount or regulated number (llm-selects-system-derives-tax-numbers).

Parameters:
Return type:

ManualLedgerTransactionResult

Returns:

The ManualLedgerTransactionResult for the in-place classification.

Raises:
reject_llm_suggestion(suggestion, *, bucket_id, reason='', actor='operator', source_command='aeat app ledger classify --llm --reject', transaction_repository=None, bucket_event_repository=None, 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: