cadrumo.domain.transactions.llm module¶
LLM classifier contracts with a parametric prompt builder.
Defines the LLMClassifier and LLMSplitProposer protocols and
the allow-list-guarded response parsing every classifier answer passes through.
Concrete transports live outside this module: the on-host readers in the gated
inference package, and a subprocess harness on the test side. The subprocess
CLI implementations that once lived here were the off-host route, and they were
deleted with it.
This module keeps the part that is a SAFETY contract rather than a transport –
the prompt spec and the parse that confine a model to selecting
classification / category / iva_category and never emitting a
regulated number. That belongs in the domain, not behind an optional install.
The prompt is built
PROGRAMMATICALLY from the available enum values so the LLM prompt
stays in sync with cadrumo.domain.transactions.enums.BusinessClassification:
adding a new value automatically requires a developer to decide
whether it belongs in the default LLM choice set.
The prompt spec is parametrized:
classifications: whichcadrumo.domain.transactions.enums.BusinessClassificationvalues the LLM may pick. Defaults to the four decision states (BUSINESS/PERSONAL/MIXED/PROCESSED_UNCLASSIFIED). Pipeline-state values (NOT_YET_PROCESSED,SKIPPED_BY_RULE,FAILED_VALIDATION) are excluded because they are not LLM decisions – they are internal pipeline bookkeeping.categories: optionalcadrumo.domain.categories.spending_category.SpendingCategoryvalues the LLM may additionally attach. Empty by default (classification-only). When populated, the response includes acategoryfield.
Every decision the LLM emits is validated against the spec’s
allow-list via parse_response(): a response that picks a value
outside the allowed set raises LLMClassifierError, so a
hallucinating model cannot corrupt the catalogue.
- class LLMClassificationResponse(**data)[source]¶
Bases:
BaseModelOne LLM-emitted classification result for a transaction.
- Parameters:
data (Any)
- classification: BusinessClassification¶
- confidence: Decimal¶
- reason: str¶
- category: SpendingCategory | None¶
- iva_category: IvaCategory | None¶
- business_pct: Decimal | None¶
- multiple_components: bool | None¶
True when the attached invoice carries multiple distinct rate/category lines that warrant a split into independently filable base/IVA children.
Nonewhen no evidence was read (the model cannot judge multiplicity from the bank row alone). A boolean judgement, not an allow-list value, so hallucination containment is unaffected; it only drives a non-blocking split recommendation, never a write.- Type:
Evidence-read multiplicity judgement
- class LLMSplitChild(**data)[source]¶
Bases:
BaseModelOne proposed child of an evidence-driven transaction split.
The model proposes a proportion of the parent (a fraction, like
business_pct) plus the selections for that child; the application derives each child’s euro amount from the parent gross and the regulated tax substrate from the registry. The model never emits a euro amount or a regulated number (llm-selects-system-derives-tax-numbers).- Parameters:
data (Any)
- proportion: Decimal¶
- category: SpendingCategory | None¶
- iva_category: IvaCategory | None¶
- evidence_citation: str¶
- class LLMSplitResponse(**data)[source]¶
Bases:
BaseModelAn evidence-driven split proposal: children whose proportions sum to one.
A proposal of one child (proportion
1.0) is the “no split warranted” verdict — the model read the invoice and judged it a single line/rate. The application surfaces that verdict for review and never applies a degenerate one-way split. Two or more children is a genuine split recommendation.- Parameters:
data (Any)
- children: tuple[LLMSplitChild, ...]¶
- reason: str¶
- class LLMClassifier(*args, **kwargs)[source]¶
Bases:
ProtocolClassify one transaction with an LLM-generated decision.
- classify(transaction, *, evidence_text=None)[source]¶
Return one classification decision for
transaction.- Parameters:
transaction (
Transaction) – The transaction to classify.evidence_text (
str|None) – Optional on-host-extracted attached-evidence text to inject into the prompt. Gating of whether evidence may reach a given (on-host vs cloud) classifier is the caller’s responsibility.
- Return type:
- Returns:
A
LLMClassificationResponsewith the classification result.
- class LLMSplitProposer(*args, **kwargs)[source]¶
Bases:
ProtocolPropose an evidence-driven N-way split for one transaction.
- propose_split(transaction, *, evidence_text=None)[source]¶
Return an N-way split proposal for
transaction.- Parameters:
transaction (
Transaction) – The transaction to split.evidence_text (
str|None) – Optional on-host-extracted attached-evidence text to inject into the prompt.
- Return type:
- Returns:
A validated
LLMSplitResponse.
- class ClassificationChoice(value, hint)[source]¶
Bases:
objectOne allowed
BusinessClassificationpaired with an LLM-facing hint.- Parameters:
value (BusinessClassification)
hint (str)
-
value:
BusinessClassification¶
- class CategoryChoice(value, hint)[source]¶
Bases:
objectOne allowed
SpendingCategorypaired with an LLM-facing hint.- Parameters:
value (SpendingCategory)
hint (str)
-
value:
SpendingCategory¶
- class IvaCategoryChoice(value, hint)[source]¶
Bases:
objectOne allowed
cadrumo.domain.iva.schema.IvaCategorypaired with an LLM-facing hint.- Parameters:
value (IvaCategory)
hint (str)
-
value:
IvaCategory¶
- default_classification_choices()[source]¶
Return the default allowed-classifications tuple used by the prompt.
- Return type:
- Returns:
Tuple of
ClassificationChoiceobjects for each allowed category.
- class PromptSpec(classifications=<factory>, categories=(), iva_categories=(), header="You are classifying a Spanish autónomo's bank transaction for tax purposes.")[source]¶
Bases:
objectParametrized classification prompt spec.
The prompt and the response allow-list are derived from the same tuple of choices so they cannot drift. A response whose classification is not in
allowed_classifications()is rejected byparse_response()regardless of how well-formed the JSON is.- Parameters:
classifications (tuple[ClassificationChoice, ...])
categories (tuple[CategoryChoice, ...])
iva_categories (tuple[IvaCategoryChoice, ...])
header (str)
-
classifications:
tuple[ClassificationChoice,...]¶
-
categories:
tuple[CategoryChoice,...]¶
-
iva_categories:
tuple[IvaCategoryChoice,...]¶
- allowed_classifications()[source]¶
Return the set of
BusinessClassificationvalues the LLM is allowed to emit.- Return type:
- allowed_categories()[source]¶
Return the set of category values the LLM is allowed to emit (empty = none).
- Return type:
- Returns:
Frozenset of
SpendingCategoryvalues the LLM may emit.
- allowed_iva_categories()[source]¶
Return the set of
cadrumo.domain.iva.schema.IvaCategoryvalues the LLM may emit (empty = none).- Return type:
- Returns:
Frozenset of
cadrumo.domain.iva.schema.IvaCategoryvalues the LLM may select from; empty when the spec does not ask for an IVA category.
- render(transaction, *, evidence_text=None, evidence_image_present=False)[source]¶
Render the prompt for
transactionagainst this spec.- Parameters:
transaction (
Transaction) – The transaction to classify.evidence_text (
str|None) – Optional on-host-extracted text of an attached evidence document (e.g. a purchase invoice). When present it is injected into the prompt for the model to read; the model uses it only to select the classification/category/iva_category and must never copy a euro figure from it (the regulated numbers stay registry-derived).evidence_image_present (
bool) – Set when the evidence is attached as an image (the on-host vision-read path) instead of inlined text; the prompt then points the model at the attached image.
- Return type:
- default_prompt_spec()[source]¶
Return the default
PromptSpec: classification-only, four decision states.- Return type:
- prompt_spec_with_every_spending_category(*, year, operation, classifications=None)[source]¶
Return a prompt spec that also asks the LLM to suggest a SpendingCategory.
Pulls authoritative Spanish display labels from the category profile registry for
yearrather than inventing ad-hoc hints from the enum value – the LLM picks categories far more accurately against the real AEAT terminology than against mangled snake_case. Categories with no registered profile (none today; everycadrumo.domain.categories.spending_category.SpendingCategorymember is covered) fall back to the humanised enum value.- Parameters:
year (
int) – Filing year whose category profiles supply the hints. Passed explicitly because the proportionality kind a category carries is year-versioned regulatory data.operation (
PinnedAuthorityOperation) – The generation-pinned authority operation used for both the dated category vocabulary and its profiles.classifications (
tuple[ClassificationChoice,...] |None) – Optional override for the classification choices; defaults todefault_classification_choices().
- Return type:
- Returns:
A
PromptSpecwhosecategoriestuple covers every registeredcadrumo.domain.categories.spending_category.SpendingCategory.
- default_iva_category_choices(*, operation)[source]¶
Return the grounded IVA-category choices for the saturation prompt.
The allow-list and concise hints are projected from the dated IVA category facts registry. The model SELECTS a category only; every regulated euro figure is derived downstream from the registry rate, never emitted by the model.
- Return type:
- Returns:
One
IvaCategoryChoiceper registry-declared IVA category, in authored order.- Parameters:
operation (
PinnedAuthorityOperation) – The generation-pinned authority operation used to resolve the IVA category catalogue.
- prompt_spec_with_saturation_fields(*, year, operation, classifications=None)[source]¶
Return a prompt spec for full saturation: spending + IVA category selection.
Extends
prompt_spec_with_every_spending_category()with the registry-grounded IVA-category allow-list (and invites a proposed MIXEDbusiness_pct) so one reviewed suggestion can carry the rich tax metadata. The model selects categories only; the regulated rate, taxable base, and IVA amount are derived downstream from the registry, never emitted by the model.- Parameters:
year (
int) – Filing year whose category profiles supply the hints. Passed explicitly because the proportionality kind a category carries is year-versioned regulatory data.operation (
PinnedAuthorityOperation) – The generation-pinned authority operation used for the spending and IVA category projections.classifications (
tuple[ClassificationChoice,...] |None) – Optional override for the classification choices; defaults todefault_classification_choices().
- Return type:
- Returns:
A
PromptSpeccarrying both the spending-category and the IVA-category allow-lists.
- parse_response(stdout, *, spec=None)[source]¶
Extract a valid JSON object from LLM stdout, validate, enforce spec.
Iterates every JSON-object candidate in the output and returns the first one that validates against the schema AND passes the spec’s allow-list. Guards against an LLM that echoes a prompt-injected JSON block before emitting its real answer: a malformed or disallowed first candidate no longer poisons the result.
- Parameters:
stdout (
str) – Raw stdout captured from the LLM CLI.spec (
PromptSpec|None) – Prompt spec the response should conform to. When provided, reject classifications or categories outside the allow-list.
- Return type:
- Returns:
A validated
LLMClassificationResponse.- Raises:
LLMClassifierError – If no candidate JSON object exists, or if none of the candidates passes both schema validation and the spec’s allow-list.
- build_split_prompt(transaction, *, spec=None, evidence_text=None, evidence_image_present=False)[source]¶
Build a prompt asking the model to propose an evidence-driven N-way split.
The model reads the attached invoice and proposes per-child proportions plus selections; it must never emit a euro amount (the application derives the amounts from the parent gross and the tax substrate from the registry). The invoice is supplied as inlined text (
evidence_text) or, on the on-host vision-read path, as an attached image (evidence_image_present).- Return type:
- Parameters:
transaction (Transaction)
spec (PromptSpec | None)
evidence_text (str | None)
evidence_image_present (bool)
- parse_split_response(stdout, *, spec=None)[source]¶
Extract and validate an N-way split proposal from LLM stdout.
Finds the first balanced JSON object, validates it as
LLMSplitResponse, and rejects any child whosecategoryoriva_categoryfalls outside the spec’s allow-list – the same hallucination guardparse_response()applies to a flat classification.- Parameters:
stdout (
str) – Raw stdout captured from the LLM CLI.spec (
PromptSpec|None) – Prompt spec whose allow-lists each child must satisfy.
- Return type:
- Returns:
A validated
LLMSplitResponse.- Raises:
LLMClassifierError – When no JSON object is present, the schema is violated, or a child selection is outside the allow-list.