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 typedLLMClassificationSuggestionwithout persisting anything. Rejecting a suggestion is simply not applying it.apply_llm_classification()persists an accepted suggestion through the established classification write (set_classification()), stampingclassified_bywith the classifier’sdecided_by(llm:<model>provenance, distinct from manual /rule:) and recording the model’sconfidenceandreason. The accepted decision is appended to the profile audit trail through aBucketEventHistoryRepositoryas aledger.transaction.classifiedevent.available_llm_providers()reports which subprocess providers have a usable CLI onPATHso 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
PATHfor each provider’s CLI binary withshutil.which(no process is spawned). The CLI surfaces this so an operator can discover which providers are installed before classifying.- Return type:
- Returns:
One
LLMProviderAvailabilityperLLMProvider, ordered by enum declaration.
- is_llm_provider_available(provider)[source]¶
Return whether
provider’s CLI binary is resolvable onPATH.- Return type:
- 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
providerwith 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 whenclassifieris None.classifier (
LLMClassifier|None) – Injected classifier (dependency injection for tests). When None, resolved viaresolve_classifier()forprovider.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;Noneuses 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 toload_settings().
- Return type:
- Returns:
- Raises:
TransactionNotFoundError – When the transaction id is unknown.
LLMClassifierError – When the classifier fails (e.g. provider CLI unavailable, hallucinated out-of-allow-list value).
- 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(), stampingclassified_bywith the suggestion’sprovenance(the classifier’sdecided_by, e.g.llm:<model>) and recording the model’sconfidenceandreason. Persists the catalogue and emits aLEDGER_TRANSACTION_CLASSIFIEDevent atomically.The MVP persists only the non-regulated
business_classificationand optional expensecategory. It never sets a regulated tax value.A
MIXEDsuggestion requires an explicitbusiness_pct(the LLM does not produce one); apply refuses instructively when it is absent. The expensecategoryis recorded only forBUSINESS/MIXEDclassifications.- Parameters:
suggestion (
LLMClassificationSuggestion) – The acceptedLLMClassificationSuggestion.bucket_id (
str) – Active profile bucket id.business_pct (
Decimal|None) – Required whensuggestion.classificationisMIXED.actor (
str) – Operator identity for the audit event.source_command (
str) – Source-command label for the audit event.transaction_repository (
TransactionCatalogueRepositoryProtocol|None) – Injected catalogue repository.bucket_event_repository (
BucketEventHistoryRepositoryProtocol|None) – Injected audit-event repository.occurred_at (
datetime|None) – Override clock for deterministic tests.
- Return type:
- Returns:
A
ManualLedgerTransactionResultreflecting the persisted decision.- Raises:
TransactionNotFoundError – When the transaction id is unknown.
TransactionValidationError – When the transaction is not ACTIVE or a
MIXEDsuggestion is applied without abusiness_pct.
- 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
providerwith the saturation prompt spec), then DERIVES the regulated tax substrate from the model’s selectedIvaCategoryusing 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 whenclassifieris None.classifier (
LLMClassifier|None) – Injected classifier (dependency injection for tests). When None, resolved viaresolve_classifier()forproviderwith 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;Noneuses 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 whenread_evidenceis set.settings (
Settings|None) – Injected settings; defaults toload_settings().
- Return type:
- Returns:
A
LLMSaturatedSuggestioncarrying the model’s selections and the system-derived euro substrate.- Raises:
TransactionNotFoundError – When the transaction id is unknown.
LLMClassifierError – When the classifier fails (provider CLI unavailable, hallucinated out-of-allow-list value).
- 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 thegross == taxable_base + iva_amountinvariant, and stampsclassified_bywith the suggestion’sllm:<model>provenance viaclassified_by_override.The non-regulated business decision (classification, expense category) and the model-selected
iva_categoryare persisted; the regulated euro figures are persisted only when the category was derivable (a non-derivable category leaves the operator to complete the numbers). AMIXEDsuggestion requires a business percentage — the model’s proposedbusiness_pctis used unless the caller overrides it; apply refuses instructively when neither is present.- Parameters:
suggestion (
LLMSaturatedSuggestion) – The acceptedLLMSaturatedSuggestion.bucket_id (
str) – Active profile bucket id.business_pct (
Decimal|None) – Operator override for the MIXED business percentage; falls back to the model’s proposedbusiness_pct.actor (
str) – Operator identity for the audit event.source_command (
str) – Source-command label recording the operator’s verb.transaction_repository (
TransactionCatalogueRepositoryProtocol|None) – Injected catalogue repository.bucket_event_repository (
BucketEventHistoryRepositoryProtocol|None) – Injected audit-event repository.occurred_at (
datetime|None) – Override clock for deterministic tests.
- Return type:
- Returns:
A
ManualLedgerTransactionResultreflecting the persisted state.- Raises:
TransactionValidationError – When a
MIXEDsuggestion 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 (returnsunknown) or the operator simply knows the category. Given a transaction already classified BUSINESS or MIXED and the selectedIvaCategory, it resolves the registry rate, splits the gross into taxable base and IVA amount, and persists them through the manual write withderived: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:
- Returns:
The
OperatorIvaDerivationResultrecording the persisted IVA substrate, or an explanatory note when the category is non-derivable.- Raises:
TransactionNotFoundError – When the transaction id is unknown.
TransactionValidationError – When the transaction is not classified BUSINESS or MIXED (IVA applies only to business activity).
- Parameters:
bucket_id (str)
transaction_id (str)
iva_category (IvaCategory)
on_date (date | None)
actor (str)
source_command (str)
transaction_repository (TransactionCatalogueRepositoryProtocol | None)
bucket_event_repository (BucketEventHistoryRepositoryProtocol | None)
occurred_at (datetime | None)
- 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
providerwith 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 whenproposeris None.proposer (
LLMSplitProposer|None) – Injected split proposer (dependency injection for tests). When None, resolved viaresolve_split_proposer()forprovider.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;Noneuses 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 whenread_evidenceis set and evidence is linked.settings (
Settings|None) – Injected settings; defaults toload_settings().
- Return type:
- Returns:
A
LLMSplitSuggestionwhose 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): firstsplit_transaction()redistributes the parent into children whose magnitudes sum exactly to the parent, then for each childupdate_manual_transaction_fields()stamps the model-selected expense category and IVA category, the registry-DERIVED regulated numbers, the parent invoice’s evidence link, and thellm:<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_amountinvariant. The LLM never supplies a persisted euro amount or regulated number.- Parameters:
suggestion (
LLMSplitSuggestion) – The acceptedLLMSplitSuggestion.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.transaction_repository (
TransactionCatalogueRepositoryProtocol|None) – Injected catalogue repository.bucket_event_repository (
BucketEventHistoryRepositoryProtocol|None) – Injected audit-event repository.occurred_at (
datetime|None) – Override clock for deterministic tests.
- Return type:
- Returns:
An
LLMSplitApplyResultnaming the split group and its children.- Raises:
TransactionNotFoundError – When the parent transaction id is unknown.
TransactionValidationError – When the split invariants are violated.
- 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_amountfor the whole gross. This stamps them on the parent through the single-writerupdate_manual_transaction_fields(), with the parent invoice’s evidence link and thellm:<model>provenance — exactly the per-child writeapply_evidence_split()performs, but without splitting. The model emits no euro amount or regulated number (llm-selects-system-derives-tax-numbers).- Parameters:
suggestion (
LLMSplitSuggestion) – A no-splitLLMSplitSuggestion(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.transaction_repository (
TransactionCatalogueRepositoryProtocol|None) – Injected catalogue repository.bucket_event_repository (
BucketEventHistoryRepositoryProtocol|None) – Injected audit-event repository.occurred_at (
datetime|None) – Override clock for deterministic tests.
- Return type:
- Returns:
The
ManualLedgerTransactionResultfor the in-place classification.- Raises:
TransactionValidationError – When the suggestion recommends a split (use
apply_evidence_split()).TransactionNotFoundError – When the transaction id is unknown.
- 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_REJECTEDbucket event, and mutates nothing — the transaction’s classification, numbers, and lifecycle are untouched, so its review status stayspending(it is still unclassified). No regulated number is written; the model emitted none and reject writes none.- Parameters:
suggestion (
LLMClassificationSuggestion|LLMSaturatedSuggestion|LLMSplitSuggestion) – The captured proposal being rejected — a stage-1 classification, a saturated suggestion, or an evidence-driven split.bucket_id (
str) – Active profile bucket id.reason (
str) – The operator’s free-text reason for rejecting (optional).actor (
str) – Operator identity for the audit event.source_command (
str) – Source-command label recording the operator’s verb.transaction_repository (
TransactionCatalogueRepositoryProtocol|None) – Injected catalogue repository.bucket_event_repository (
BucketEventHistoryRepositoryProtocol|None) – Injected audit-event repository.occurred_at (
datetime|None) – Override clock for deterministic tests.
- Return type:
- Returns:
An
LLMSuggestionRejectionResultnaming the recorded event.- Raises:
TransactionNotFoundError – When the transaction id is unknown.
TransactionValidationError – When the transaction is not active.