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
@@ -68,6 +68,25 @@ describe('selectProposals', () => {
|
||||
expect(selectProposals([tx()], [], noSuppression)).toEqual([])
|
||||
})
|
||||
|
||||
it('never proposes on the prominent-amounts fallback', () => {
|
||||
// A bankintyg (documentKind "other", no invoice-style total) whose printed
|
||||
// "Insatt belopp" happens to equal a same-day outflow scores 0.85 on the
|
||||
// shared matcher, which would clear CERTAIN_CONFIDENCE and skip
|
||||
// adjudication, on a pairing that is wrong by construction: the hunt scans
|
||||
// outflows only, and "Insatt belopp" labels an inflow. Fallback-scored
|
||||
// candidates are for the picker and the agent, never the nightly hunt.
|
||||
const bankintyg = item(
|
||||
{ id: 'item-intyg', document_id: 'doc-intyg' },
|
||||
{
|
||||
supplier: { name: null },
|
||||
totals: { total: null, vatAmount: null },
|
||||
documentKind: 'other',
|
||||
prominentAmounts: [{ amount: 438.75, label: 'Insatt belopp' }],
|
||||
},
|
||||
)
|
||||
expect(selectProposals([tx()], [bankintyg], noSuppression)).toEqual([])
|
||||
})
|
||||
|
||||
it('skips a transaction that already has a live proposal', () => {
|
||||
const result = selectProposals([tx()], [item()], {
|
||||
claimedTransactionIds: new Set(['tx-1']),
|
||||
|
||||
@@ -172,6 +172,15 @@ export function selectProposals(
|
||||
const scored = scoreUnderlagCandidates(tx, pool as never[]).filter(
|
||||
(candidate) =>
|
||||
candidate.document_id != null &&
|
||||
// Never propose on the prominent-amounts fallback (non-invoice
|
||||
// documents: bankintyg, avtal). The hunt's thresholds were calibrated
|
||||
// for invoice-style totals; a fallback pair can reach 0.85 on date +
|
||||
// printed-figure alone, which clears CERTAIN_CONFIDENCE and skips
|
||||
// adjudication, and its preview would show document_amount null. The
|
||||
// hunt also scans outflows only, so an inflow-labeled figure
|
||||
// ("Insatt belopp") is guaranteed to pair with the wrong row. Those
|
||||
// documents stay reachable through the picker and agent candidates.
|
||||
candidate.amountSource !== 'prominent' &&
|
||||
!spentDocumentIds.has(candidate.document_id) &&
|
||||
!suppression.claimedDocumentIds.has(candidate.document_id) &&
|
||||
!suppression.rejectedPairs.has(pairKey(tx.id, candidate.document_id)),
|
||||
|
||||
Reference in New Issue
Block a user