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
@@ -242,7 +242,19 @@ function timeAgo(iso: string): string {
|
||||
}
|
||||
|
||||
function pickAmount(item: InboxItem): number | null {
|
||||
return item.extracted_data?.totals?.total ?? null
|
||||
const total = item.extracted_data?.totals?.total
|
||||
if (total != null) return total
|
||||
// Non-invoice documents (bankintyg, avtal) have no total; when exactly one
|
||||
// distinct amount was read off the document, that is the amount to show.
|
||||
// Two or more stay ambiguous and render as no amount.
|
||||
const distinct = [
|
||||
...new Set(
|
||||
(item.extracted_data?.prominentAmounts ?? [])
|
||||
.map((a) => a.amount)
|
||||
.filter((a) => Number.isFinite(a) && a !== 0),
|
||||
),
|
||||
]
|
||||
return distinct.length === 1 ? distinct[0] : null
|
||||
}
|
||||
|
||||
function pickCurrency(item: InboxItem): string {
|
||||
@@ -271,14 +283,18 @@ function hasAnyExtractedField(data: InvoiceExtractionResult | null): boolean {
|
||||
s?.name || s?.orgNumber || s?.vatNumber || s?.bankgiro || s?.plusgiro ||
|
||||
inv?.invoiceNumber || inv?.invoiceDate || inv?.dueDate || inv?.paymentReference ||
|
||||
t?.subtotal != null || t?.vatAmount != null || t?.total != null ||
|
||||
(data.lineItems?.length ?? 0) > 0 || (data.vatBreakdown?.length ?? 0) > 0
|
||||
(data.lineItems?.length ?? 0) > 0 || (data.vatBreakdown?.length ?? 0) > 0 ||
|
||||
(data.prominentAmounts?.length ?? 0) > 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields the extraction is scored against, in the order a person reads
|
||||
* them. Deliberately the same list hasAnyExtractedField checks, so the summary
|
||||
* cannot claim a field the "is anything here at all" test does not count.
|
||||
* them. A subset of what hasAnyExtractedField checks (that test also counts
|
||||
* lineItems, vatBreakdown and prominentAmounts), so the "fält ifyllda" counter
|
||||
* never claims a field the "is anything here at all" test does not count: the
|
||||
* reverse can differ, e.g. a bankintyg with only prominentAmounts has fields
|
||||
* but counts 0 here.
|
||||
*/
|
||||
const EXTRACTED_FIELD_ACCESSORS: ((d: InvoiceExtractionResult) => unknown)[] = [
|
||||
(d) => d.supplier?.name,
|
||||
@@ -2893,7 +2909,10 @@ function FieldsRail({
|
||||
{/* AI classification: what kind of document this is and how it was
|
||||
paid. Read-only context above the editable fields; absent for
|
||||
extractions from before the fields existed. */}
|
||||
{(data?.documentKind || data?.payment?.method || data?.pages) && (
|
||||
{(data?.documentKind ||
|
||||
data?.payment?.method ||
|
||||
data?.pages ||
|
||||
(data?.totals?.total == null && (data?.prominentAmounts?.length ?? 0) > 0)) && (
|
||||
<div className="border-b px-4 py-3 text-xs space-y-1">
|
||||
{data?.documentKind && (
|
||||
<div className="flex gap-2">
|
||||
@@ -2911,6 +2930,23 @@ function FieldsRail({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Amounts read off a non-invoice document (bankintyg, avtal):
|
||||
without this row the empty "Totalt" field reads as if extraction
|
||||
missed them. */}
|
||||
{data?.totals?.total == null && (data?.prominentAmounts?.length ?? 0) > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground w-14 shrink-0">{t('prominent_amounts_label')}</span>
|
||||
<span className="tabular-nums">
|
||||
{(data?.prominentAmounts ?? [])
|
||||
.map((a) =>
|
||||
a.label
|
||||
? `${a.label}: ${formatCurrency(a.amount, data?.invoice?.currency ?? 'SEK')}`
|
||||
: formatCurrency(a.amount, data?.invoice?.currency ?? 'SEK'),
|
||||
)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data?.pages && (
|
||||
<div className="text-muted-foreground">
|
||||
{t('pages_partial_note', {
|
||||
|
||||
Reference in New Issue
Block a user