feat(inbox): match non-invoice documents via prominent amounts (#2048)
* feat(inbox): match non-invoice documents via prominent amounts Bankintyg, bank agreements and other documentKind "other" PDFs carry no invoice-style total, so extraction correctly left totals.total null and the document became structurally unmatchable: findUnderlagCandidates hard-drops items without a comparable amount and the picker lost the 40% amount signal. - extraction: new prominentAmounts[] field (amount + document's own label), populated only when totals.total is null; account/org/phone/reference numbers and zero amounts excluded. totals.total semantics untouched. - matching: bestProminentAmountVariance() tries each printed amount and feeds calculateMatchConfidence at reduced weight (0.3 vs 0.4) in both the agent candidate scorer and TransactionMatchPicker. - UI: inbox rail shows the detected amounts read-only for such documents, list falls back to a single distinct prominent amount, and extraction no longer reads as "found nothing". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(inbox): discount prominent-amount fallback instead of reweighting it Skeptic pass refutations on the first commit: normalized weighting made a reduced amount weight self-defeating. Date + exact fallback amount with no merchant scored (0.25+0.3)/0.55 = 1.0 ("100% sakerhet" on a wrong same-day transaction), and a DISAGREEING fallback amount scored above a disagreeing invoice total (0.67 vs 0.60) because shrinking the weight also shrank the penalty. - score fallbacks at full amount weight, then multiply by a flat FALLBACK_CONFIDENCE_FACTOR (0.85): agreement caps below certainty, disagreement stays at least as damning as for a real total. - agent candidate surface additionally requires the document date within DATE_TOLERANCE_DAYS, so an avtal listing 349 kr no longer matches every future 349 kr charge from the same counterparty. - bestProminentAmountVariance returns which amount matched + its document label, and the match reason names it ("Exakt belopp i dokumentet: 2 500 SEK (Engangspris)"): no more bare "Exakt belopp" reaching the agent while total_amount is null. - prompt: prominentAmounts restricted to non-invoice documentKinds, and never a parking spot for an unreadable invoice total. - fix the stale "deliberately the same list" comment on EXTRACTED_FIELD_ACCESSORS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(receipt-hunt): never propose on the prominent-amounts fallback Second skeptic pass: the nightly hunt is a third consumer of scoreUnderlagCandidates and inherited the fallback unaware. A bankintyg whose printed "Insatt belopp" equals a same-day outflow scores 0.85, which clears CERTAIN_CONFIDENCE (0.8) and skips LLM adjudication, on a pairing wrong by construction (the hunt scans outflows only; "Insatt belopp" labels an inflow), with document_amount null in the approval preview. UnderlagCandidate now carries amountSource ('total' | 'prominent') and selectProposals drops fallback-scored candidates. Non-invoice documents stay reachable through the manual picker and the agent candidate surface, both of which have a human reading the amounts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb * fix(inbox): round fallback confidence via roundOre, not the naive pattern The two confidence discounts (and their test) tripped the naive-ore-round antipattern ratchet (625 vs baseline 622); use the sanctioned helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
84ba8323b4
commit
516e8b62ff
@@ -15,6 +15,7 @@ function extraction(partial: {
|
||||
total?: number | null
|
||||
vat?: number | null
|
||||
currency?: string
|
||||
prominentAmounts?: { amount: number; label: string | null }[]
|
||||
}): InvoiceExtractionResult {
|
||||
return {
|
||||
supplier: {
|
||||
@@ -28,6 +29,7 @@ function extraction(partial: {
|
||||
lineItems: [],
|
||||
totals: { subtotal: null, vatAmount: partial.vat ?? null, total: partial.total ?? null },
|
||||
vatBreakdown: [],
|
||||
prominentAmounts: partial.prominentAmounts,
|
||||
confidence: 0.9,
|
||||
} as InvoiceExtractionResult
|
||||
}
|
||||
@@ -71,6 +73,7 @@ describe('scoreUnderlagCandidates', () => {
|
||||
expect(out).toHaveLength(1)
|
||||
expect(out[0].inbox_item_id).toBe('item-1')
|
||||
expect(out[0].confidence).toBeGreaterThanOrEqual(CANDIDATE_MIN_CONFIDENCE)
|
||||
expect(out[0].amountSource).toBe('total')
|
||||
// and it brings the captured answers along with it
|
||||
expect(out[0].channelContext?.representation?.purpose).toBe('kundmöte')
|
||||
})
|
||||
@@ -139,6 +142,110 @@ describe('scoreUnderlagCandidates', () => {
|
||||
expect(out[0].inbox_item_id).toBe('strong')
|
||||
})
|
||||
|
||||
it('proposes a non-invoice document via its prominent amounts', () => {
|
||||
// The Robotministeriet case: an SEB account agreement (documentKind
|
||||
// "other") has no "Att betala" total, only "Anslutnings-/Engångspris
|
||||
// 2 500". The bank charges AVGIFT -2500 the same day. Before the
|
||||
// prominentAmounts fallback this document was structurally unmatchable.
|
||||
const avgiftTx = {
|
||||
...tx,
|
||||
description: 'AVGIFT',
|
||||
merchant_name: null,
|
||||
amount: -2500,
|
||||
date: '2026-08-26',
|
||||
}
|
||||
const out = scoreUnderlagCandidates(avgiftTx, [
|
||||
{
|
||||
id: 'item-avtal',
|
||||
document_id: 'doc-avtal',
|
||||
extracted_data: extraction({
|
||||
supplier: 'SEB',
|
||||
date: '2026-08-26',
|
||||
total: null,
|
||||
prominentAmounts: [
|
||||
{ amount: 2500, label: 'Anslutnings-/Engångspris' },
|
||||
],
|
||||
}),
|
||||
channel_context: null,
|
||||
},
|
||||
])
|
||||
expect(out).toHaveLength(1)
|
||||
expect(out[0].inbox_item_id).toBe('item-avtal')
|
||||
expect(out[0].confidence).toBeGreaterThanOrEqual(CANDIDATE_MIN_CONFIDENCE)
|
||||
// ...but never as certainty: a printed figure is not an invoice total.
|
||||
expect(out[0].confidence).toBeLessThan(1)
|
||||
// Tagged so low-scrutiny consumers (the nightly hunt) can exclude it.
|
||||
expect(out[0].amountSource).toBe('prominent')
|
||||
// The reason names WHICH figure matched, since total_amount stays null.
|
||||
expect(out[0].total_amount).toBeNull()
|
||||
// toLocaleString('sv-SE') groups with a non-breaking space (U+00A0).
|
||||
expect(out[0].matchReasons.join(' ')).toContain(`2${' '}500`)
|
||||
expect(out[0].matchReasons.join(' ')).toContain('Anslutnings-/Engångspris')
|
||||
})
|
||||
|
||||
it('does not let a prominent amount alone carry a dateless document over the floor', () => {
|
||||
// Amount agreement without a date is weaker than a total + date pair; the
|
||||
// candidate surface trades recall for precision, so this stays in the
|
||||
// manual picker only.
|
||||
const out = scoreUnderlagCandidates({ ...tx, amount: -25000 }, [
|
||||
{
|
||||
id: 'item-intyg',
|
||||
document_id: 'doc-intyg',
|
||||
extracted_data: extraction({
|
||||
supplier: 'SEB',
|
||||
date: null,
|
||||
total: null,
|
||||
prominentAmounts: [{ amount: 25000, label: 'Insatt belopp' }],
|
||||
}),
|
||||
channel_context: null,
|
||||
},
|
||||
])
|
||||
expect(out).toEqual([])
|
||||
})
|
||||
|
||||
it('does not match an avtal to a later charge on amount + merchant alone', () => {
|
||||
// A Telia avtal listing 349 kr must not surface for every future 349 kr
|
||||
// Telia charge: the fallback requires the document date to agree within
|
||||
// the normal tolerance.
|
||||
const out = scoreUnderlagCandidates(
|
||||
{ ...tx, description: 'TELIA SVERIGE AB', merchant_name: 'Telia Sverige AB', amount: -349 },
|
||||
[
|
||||
{
|
||||
id: 'item-telia',
|
||||
document_id: 'doc-telia',
|
||||
extracted_data: extraction({
|
||||
supplier: 'Telia Sverige AB',
|
||||
date: '2026-01-15',
|
||||
total: null,
|
||||
prominentAmounts: [{ amount: 349, label: 'Månadspris' }],
|
||||
}),
|
||||
channel_context: null,
|
||||
},
|
||||
],
|
||||
)
|
||||
expect(out).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a non-invoice document whose prominent amounts all disagree', () => {
|
||||
// Same-day, same merchant, but the printed amounts match nothing: the
|
||||
// discount keeps this under the floor, where a real disagreeing invoice
|
||||
// total would sit exactly at it.
|
||||
const out = scoreUnderlagCandidates(tx, [
|
||||
{
|
||||
id: 'item-wrong',
|
||||
document_id: 'doc-wrong',
|
||||
extracted_data: extraction({
|
||||
supplier: 'Espresso House',
|
||||
date: '2026-05-12',
|
||||
total: null,
|
||||
prominentAmounts: [{ amount: 9999, label: 'Pris' }],
|
||||
}),
|
||||
channel_context: null,
|
||||
},
|
||||
])
|
||||
expect(out).toEqual([])
|
||||
})
|
||||
|
||||
it('returns nothing for a transaction with no date or amount', () => {
|
||||
expect(
|
||||
scoreUnderlagCandidates({ ...tx, date: null }, [
|
||||
|
||||
Reference in New Issue
Block a user