cadrumo.adapters.outbound.llm.column_role_mapping module¶
Semantic column-role mapping: observed headers to the closed FieldRole set.
A delimited export is already a text representation, so reading one is not a
transcription problem but a naming problem: the file states what each column
holds, in whichever vocabulary its producer chose. A Spanish libro registro
names its taxable base base_imponible; a bank export names its booked date
fecha_operacion; an ERP names neither. Every field the product needs is
present in all three, and not one of the names matches an importer column token
– which is why an exact-header importer refuses the whole file.
This module establishes what the columns MEAN, once per file, and nothing
else. It is a selection over a closed allow-list, not a generation task: the
model is handed the observed headers and the enumerated
FieldRole members and chooses among them. It never sees, copies,
transforms or emits a cell value – deterministic code does that downstream,
under the mapping this module produces. That split is the tabular
anti-fabrication guarantee: a model that cannot touch a value cannot invent one.
Never refuse whole. A header no role fits becomes
UNMAPPED and is reported on
ColumnRoleProposal.unmapped_columns; the file still imports without that
column. The same holds for every way a reply can be wrong about a column: a role
token outside the allow-list, a role a previous column already claimed, a claim
about a column the table does not have. Each is recorded on its own field of the
proposal and costs exactly the column it concerns. Only a reply that is not
parseable at all raises, because there is then no proposal to report.
Consuming this from the tabular lane. The proposal carries positional
roles, which is precisely the shape
adapters.inbound.financial.ColumnRoleMapping takes. The wiring belongs on
the consumer’s side of the boundary – this package sits beside
cadrumo.adapters in the layering contract rather than beneath it, so the
import runs consumer-to-here and never the reverse:
proposal = SemanticColumnRoleMapper().map(table.headers)
mapping = ColumnRoleMapping(roles=proposal.roles)
Accuracy is not claimed here. This module is gated on structure, refusal behaviour and determinism; how often the mapping is right is a measured figure owned by the measurement lane.
- COLUMN_ROLE_MAPPING_PROMPT_ID¶
Identifier under which this capability’s calls are recorded in LLM usage and run telemetry, so a mapping call is attributable separately from a read.
- class ObservedColumn(**data)[source]¶
Bases:
BaseModelOne column of the source table, addressed by position and printed name.
- Parameters:
data (Any)
- column_index: int¶
- header: str¶
- class RejectedRoleProposal(**data)[source]¶
Bases:
BaseModelA claim naming a role token that is not a
FieldRolemember.The allow-list refusal. The token is kept verbatim so an operator or a measurement run can see what was actually proposed rather than a redacted “invalid role”; the column itself falls back to
UNMAPPED.- Parameters:
data (Any)
- column_index: int¶
- header: str¶
- proposed_role: str¶
- class DiscardedDuplicateClaim(**data)[source]¶
Bases:
BaseModelA claim that lost to an earlier one and was therefore not applied.
Covers both shapes of double claim: a second column claiming a role an earlier column already holds, and a second claim about a column already decided. First claim wins in both, deterministically, and the loser is reported here rather than silently dropped.
- Parameters:
data (Any)
- column_index: int¶
- header: str¶
- role: FieldRole¶
- kept_column_index: int¶
- class UnknownColumnClaim(**data)[source]¶
Bases:
BaseModelA claim about a column position the table does not carry.
- Parameters:
data (Any)
- column_index: int¶
- proposed_role: str¶
- class ColumnRoleProposal(**data)[source]¶
Bases:
BaseModelWhat each column of one table means, plus everything that was not established.
rolesis positional and always carries exactly one entry per observed column, so it can be handed to a positional consumer without further checking. Every column the mapping did not establish holdsUNMAPPEDthere AND appears onunmapped_columns, whichever way it came to be unmapped – the remaining fields say why, and are the operator-reportable record of what the mapping call got wrong.- Parameters:
data (Any)
- roles: tuple[FieldRole, ...]¶
- unmapped_columns: tuple[ObservedColumn, ...]¶
- rejected_role_proposals: tuple[RejectedRoleProposal, ...]¶
- discarded_duplicate_claims: tuple[DiscardedDuplicateClaim, ...]¶
- unknown_column_claims: tuple[UnknownColumnClaim, ...]¶
- class ProposedColumnRole(**data)[source]¶
Bases:
BaseModelOne column-to-role claim as the model stated it.
roleis typedstrrather thanFieldRoleon purpose. Validating it as the enum here would make an out-of-allow-list token a whole-reply validation failure – refusing the file over one bad column, which is the exact defect this lane exists to remove. Keeping it a string moves the allow-list check intoparse_column_role_mapping_response(), where it costs one column and is reported.- Parameters:
data (Any)
- column_index: int¶
- role: str¶
- class ColumnRoleMappingReply(**data)[source]¶
Bases:
BaseModelThe whole reply shape the mapping prompt asks for.
Closed keys: a reply carrying anything beyond
assignmentsis not the reply that was asked for, and accepting stray keys is how an injected instruction rides in alongside a well-formed answer.- Parameters:
data (Any)
- assignments: tuple[ProposedColumnRole, ...]¶
- permitted_column_roles()[source]¶
Return every role a column may be assigned, in declaration order.
Derived from
FieldRoleitself rather than from a list held here, so a role added to the enum is offered to the model, accepted by the parser and rejected-if-absent by the allow-list without one edit in this module. A hand-kept copy would drift the day the enum grows – and the enum is expected to grow.
- build_column_role_mapping_prompt(headers)[source]¶
Build the instruction the model receives for one table’s headers.
Separate from the transport so the exact instruction is assertable without dispatching a request – and so the enumerated allow-list can be proved to come from the enum rather than from a template literal.
The instruction is written for the smallest capable model: one task, an explicit closed vocabulary, a stated escape hatch (
unmapped) so the model never has to force a fit, and one literal reply shape. No cell value appears anywhere in it.- Parameters:
headers (
Sequence[str]) – The header cells, in column order, exactly as the file printed them.- Returns:
the task, the permitted roles, the observed columns, and the required reply shape.
- Return type:
The full prompt
- Raises:
LLMValidationError – When
headersis empty, which would ask a model to label nothing and invite it to invent columns.
- parse_column_role_mapping_response(text, headers)[source]¶
Turn one model reply into a proposal over
headers.Every claim is checked against the allow-list derived from
FieldRoleand against the table’s actual columns. A claim that fails either check is recorded on the proposal and the column it concerns staysUNMAPPED; the remaining columns are unaffected. First claim wins on any collision, so the same reply always yields the same proposal.- Parameters:
- Return type:
- Returns:
The positional mapping plus the full record of what was not established.
- Raises:
LLMValidationError – When
headersis empty, when the reply carries no JSON object, or when that object is not the reply shape that was asked for. These leave nothing to report a column against; every per-column problem is reported instead of raised.
- class SemanticColumnRoleMapper(*, model=None, provider=None, client=None, settings=None)[source]¶
Bases:
objectEstablish one table’s column roles with a language model, once per file.
Binds to
LLMClientand nothing lower, so which engine answers – an on-host runtime or a gated hosted one – is configuration rather than a fact this class holds.The model comes from the role, never from the general default. Left to itself the client would answer a mapping call on
cadrumo_llm_model– a frontier hosted model – for a task that is selecting among a handful of short strings. So the model is resolved throughselect_model_for_role()againstCOLUMN_ROLE_MAPPING, which names the WEAKEST catalogued candidate clearing the capability, licence and headroom bars on this machine. Re-pointing that role in the catalogue re-points this lane with no edit here.A role-resolved model is an on-host runtime id, so it is paired with the LOCAL provider: the pairing is the point, and sending an Ollama id to a hosted API would fail at the transport. A caller that pins
modelexplicitly owns that pairing and gets no provider override; a caller that pinsprovidergets exactly what it asked for. That is the seam the measurement lane runs its gated cloud engine through, and it is the only way off-host.- Parameters:
model (
str|None) – Optional runtime id, honoured over the role resolution. Passed to selection as the operator override, so a licence-barred or uncatalogued choice is still honoured – and still advised on.provider (
LLMProvider|None) – Optional provider override.Nonepairs a role-resolved model with LOCAL, and leaves an explicitly pinnedmodelon whichever provider is configured.client (
LLMClient|None) – Injected client (dependency injection for callers holding one); default-constructed against the resolved settings otherwise.settings (
Settings|None) – Injected settings; defaults toload_settings().
- property decided_by: str¶
Provenance stamp naming the transport and model a mapping was reached with.
This stamp previously named no transport at all, which had two costs. It could not say whether a mapping had left the host – and this is the one reader besides the extractors that accepts a provider, so it is the one that can. And its reader name parsed AS a transport, so a survey classifying artefacts by that segment read
columnwhere it expected a transport.
- map(headers)[source]¶
Establish the roles of
headers, one call for the whole file.- Parameters:
headers (
Sequence[str]) – The header cells, in column order, exactly as the file printed them. No cell value is passed, and none is accepted.- Return type:
- Returns:
The positional mapping plus every column that was not established.
- Raises:
LLMValidationError – When
headersis empty or the reply carries no usable object.
- map_column_roles(headers, *, model=None, provider=None, settings=None)[source]¶
Convenience wrapper: build a
SemanticColumnRoleMapperand map once.- Parameters:
- Return type:
- Returns:
The positional mapping plus every column that was not established.
- resolve_column_roles(table)[source]¶
Resolve a normalized table for the inward column-role port.
The concrete mapper stays owned by this outbound adapter. Composition roots bind this function to the application port, while inbound financial providers consume only the inward contract. Missing optional capability remains a typed refusal; an installed mapper that cannot establish roles declines the table.
- Return type:
- Parameters:
table (NormalizedTable)