feat(agent): offer unmatched inbox receipts as confirmable underlag (#1436)
The originally reported scenario is still broken after #1425 and its backfill-by-document_id: a user photographs a receipt into WhatsApp, answers the bot's questions, then opens the app, clicks the bank transaction and asks the assistant to book it, and is told "UNDERLAG: saknas" about a receipt we are holding, then asked everything again. WhatsApp intake writes neither invoice_inbox_items.matched_transaction_id (process-inbound.ts passes uploadAndExtract's matchedTransactionId as undefined) nor transactions.document_id (that mirror is written by the manual match route). Only TransactionMatchPicker fills either column. So the underlag list comes back empty, and a backfill that keys on document_id has nothing to key on. Unmatched, unconsumed inbox items are now scored against the transaction with the same pure scorer the picker uses and the strongest few are surfaced as TROLIGT UNDERLAG, carrying their captured chat answers. Proposals only: nothing writes matched_transaction_id, and the prompt tells the agent to get the match confirmed and to book only against a confirmed one. Setting the link at intake above a confidence bar is the obvious alternative and is deliberately left open. An uncomparable amount (cross-currency with no rate) disqualifies a candidate, because calculateMatchConfidence drops the amount signal in that case and date + merchant alone then score a confident match nobody checked the sums for. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Opus 5
parent
0f7147a078
commit
43386b4852
@@ -805,3 +805,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-06] Supplier credit notes under kontantmetoden now reverse when the ORIGINAL was already booked (paid), not only under faktureringsmetoden: skipping left the expense and the 2641 ingaende moms deduction overstated with no accounting trace. Mirrors the customer-side creditNoteNeedsJournalEntry(). The v1 route's GDPR-minimised projection had to re-add registration_journal_entry_id/payment_journal_entry_id/paid_at/paid_amount: status alone misses a part-paid-but-booked original.
|
||||
[2026-08-06] Kontantmetoden year-end cut-off (BFL 5 kap 2 §) books moms to the VILANDE accounts (2618/2628/2638 ut, 2648 in), never 2611/2641: vilande accounts are deliberately absent from ACCOUNT_RUTA/ACCOUNT_TO_BOX, so the moms stays out of the momsdeklaration until payment, which is what bokslutsmetoden requires. 2647 was considered and rejected: it is domestic omvand betalningsskyldighet, unrelated. Cut-off posts as two AGGREGATE verifikat reversed on day 1 of the next period, and deliberately does NOT set invoices.journal_entry_id: the payment flows route on that link, so per-invoice linking would send every new-year payment down the accrual clearing path against a receivable the vandning already removed, booking the settlement twice.
|
||||
[2026-08-06] Prompt clarifications render from a structured summary (lib/agent-context/chat-clarifications.ts), never off the raw channel_context blob. A WhatsApp "nej" stores representation with participants:[] and purpose:null and denied:true, so branching on `!purpose` reads a settled denial as a half answer: the shipped renderer emitted "syfte SAKNAS: fråga bara efter syftet" about a meal the user had just said was not representation. `denied` and the genuine half answer (participants named, purpose missing, which BFL 5 kap 6-7 § does want completed) are now separate states. Also: the photo caption no longer reaches the prompt, for the reason already written down in channel-context-notes.ts (nobody was asked for it, nobody reviewed it), and free text passes through flattenMemoryContent because promptTemplate output is seeded as a user message and wrapToolResult only wraps tool results.
|
||||
[2026-08-06] Unmatched underlag are PROPOSED to the assistant, never auto-linked. WhatsApp intake writes neither invoice_inbox_items.matched_transaction_id nor transactions.document_id, so a chat-captured receipt is invisible to every lookup and #1425's backfill-by-document_id has nothing to backfill. Scoring unmatched items at read time (lib/agent-context/underlag-candidates.ts, reusing core-receipt-matcher) closes that with no migration and no link written by a machine, preserving the human confirm step; setting matched_transaction_id at intake above a confidence bar remains the open alternative and is a founder call. An uncomparable cross-currency amount disqualifies a candidate outright, because the matcher drops the amount signal there and date + merchant alone score 1.0.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
CANDIDATE_MIN_CONFIDENCE,
|
||||
scoreUnderlagCandidates,
|
||||
} from '../underlag-candidates'
|
||||
import type { InboxChannelContext, InvoiceExtractionResult } from '@/types'
|
||||
|
||||
function ctx(partial: Partial<InboxChannelContext>): InboxChannelContext {
|
||||
return { channel: 'whatsapp', ...partial }
|
||||
}
|
||||
|
||||
function extraction(partial: {
|
||||
supplier?: string | null
|
||||
date?: string | null
|
||||
total?: number | null
|
||||
vat?: number | null
|
||||
currency?: string
|
||||
}): InvoiceExtractionResult {
|
||||
return {
|
||||
supplier: {
|
||||
name: partial.supplier ?? null,
|
||||
orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null,
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: null, invoiceDate: partial.date ?? null, dueDate: null,
|
||||
paymentReference: null, currency: partial.currency ?? 'SEK',
|
||||
},
|
||||
lineItems: [],
|
||||
totals: { subtotal: null, vatAmount: partial.vat ?? null, total: partial.total ?? null },
|
||||
vatBreakdown: [],
|
||||
confidence: 0.9,
|
||||
} as InvoiceExtractionResult
|
||||
}
|
||||
|
||||
describe('scoreUnderlagCandidates', () => {
|
||||
const tx = {
|
||||
id: 'tx-1',
|
||||
date: '2026-05-12',
|
||||
description: 'ESPRESSO HOUSE 1234 STOCKHOLM',
|
||||
merchant_name: 'Espresso House',
|
||||
amount: -184,
|
||||
currency: 'SEK',
|
||||
amount_sek: null,
|
||||
exchange_rate: null,
|
||||
}
|
||||
|
||||
it('surfaces an unmatched WhatsApp receipt that matches the transaction', () => {
|
||||
// The reported bug: this item is real, sitting in the inbox, and invisible
|
||||
// to every lookup because matched_transaction_id is NULL.
|
||||
const out = scoreUnderlagCandidates(tx, [
|
||||
{
|
||||
id: 'item-1',
|
||||
document_id: 'doc-1',
|
||||
extracted_data: extraction({
|
||||
supplier: 'Espresso House',
|
||||
date: '2026-05-12',
|
||||
total: 184,
|
||||
}),
|
||||
channel_context: ctx({
|
||||
representation: {
|
||||
participants: [{ name: 'Anna Berg', company: 'Volvo' }],
|
||||
purpose: 'kundmöte',
|
||||
event_date: null,
|
||||
raw_answer: 'jag och Anna',
|
||||
answered_at: '2026-05-12T13:00:00Z',
|
||||
},
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
expect(out).toHaveLength(1)
|
||||
expect(out[0].inbox_item_id).toBe('item-1')
|
||||
expect(out[0].confidence).toBeGreaterThanOrEqual(CANDIDATE_MIN_CONFIDENCE)
|
||||
// and it brings the captured answers along with it
|
||||
expect(out[0].channelContext?.representation?.purpose).toBe('kundmöte')
|
||||
})
|
||||
|
||||
it('rejects a same-amount receipt from a different month', () => {
|
||||
const out = scoreUnderlagCandidates(tx, [
|
||||
{
|
||||
id: 'item-2',
|
||||
document_id: 'doc-2',
|
||||
extracted_data: extraction({ supplier: 'Okänd', date: '2026-01-02', total: 184 }),
|
||||
channel_context: null,
|
||||
},
|
||||
])
|
||||
expect(out).toEqual([])
|
||||
})
|
||||
|
||||
it('does not propose a receipt whose amount cannot be compared', () => {
|
||||
// 184 EUR is not 184 SEK, and with no stored rate the two are not
|
||||
// comparable at all. Without an amount signal the score would rest on date
|
||||
// + merchant alone and come back as a confident match, so an uncomparable
|
||||
// amount disqualifies the candidate outright. Same-merchant same-day is a
|
||||
// ranking hint for a human, not evidence of the same economic event.
|
||||
const out = scoreUnderlagCandidates(tx, [
|
||||
{
|
||||
id: 'item-3',
|
||||
document_id: 'doc-3',
|
||||
extracted_data: extraction({
|
||||
supplier: 'Espresso House',
|
||||
date: '2026-05-12',
|
||||
total: 184,
|
||||
currency: 'EUR',
|
||||
}),
|
||||
channel_context: null,
|
||||
},
|
||||
])
|
||||
expect(out).toEqual([])
|
||||
})
|
||||
|
||||
it('skips extractions with no usable signal', () => {
|
||||
const out = scoreUnderlagCandidates(tx, [
|
||||
{
|
||||
id: 'item-4',
|
||||
document_id: 'doc-4',
|
||||
extracted_data: extraction({ supplier: 'Espresso House' }),
|
||||
channel_context: null,
|
||||
},
|
||||
])
|
||||
expect(out).toEqual([])
|
||||
})
|
||||
|
||||
it('returns the strongest candidates first', () => {
|
||||
const out = scoreUnderlagCandidates(tx, [
|
||||
{
|
||||
id: 'weak',
|
||||
document_id: 'doc-w',
|
||||
extracted_data: extraction({ supplier: null, date: '2026-05-13', total: 184 }),
|
||||
channel_context: null,
|
||||
},
|
||||
{
|
||||
id: 'strong',
|
||||
document_id: 'doc-s',
|
||||
extracted_data: extraction({ supplier: 'Espresso House', date: '2026-05-12', total: 184 }),
|
||||
channel_context: null,
|
||||
},
|
||||
])
|
||||
expect(out[0].inbox_item_id).toBe('strong')
|
||||
})
|
||||
|
||||
it('returns nothing for a transaction with no date or amount', () => {
|
||||
expect(
|
||||
scoreUnderlagCandidates({ ...tx, date: null }, [
|
||||
{
|
||||
id: 'item-5',
|
||||
document_id: 'doc-5',
|
||||
extracted_data: extraction({ supplier: 'Espresso House', date: '2026-05-12', total: 184 }),
|
||||
channel_context: null,
|
||||
},
|
||||
]),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Probable underlag for a bank transaction, among the inbox items nobody has
|
||||
* matched to anything yet.
|
||||
*
|
||||
* Why this is needed at all: every surface finds underlag through
|
||||
* invoice_inbox_items.matched_transaction_id, and WhatsApp intake never writes
|
||||
* it. process-inbound.ts calls uploadAndExtract with its matchedTransactionId
|
||||
* argument undefined, so upload-and-extract.ts inserts NULL, and it does not
|
||||
* set transactions.document_id either (that mirror is written by the manual
|
||||
* match route). Only TransactionMatchPicker fills either column. So a user who
|
||||
* photographs a receipt into WhatsApp, opens the app and clicks the bank
|
||||
* transaction has an underlag that no lookup can reach: the assistant reports
|
||||
* "UNDERLAG: saknas" about a receipt we are holding, and asks again for answers
|
||||
* they already gave in chat.
|
||||
*
|
||||
* Candidates are PROPOSALS, never links. Nothing here writes
|
||||
* matched_transaction_id; the caller surfaces the candidate and a human
|
||||
* confirms it, so the existing "a person links the underlag" step stays intact
|
||||
* rather than being quietly automated.
|
||||
*
|
||||
* Core lib: must not import from @/extensions. The scoring half is pure so the
|
||||
* ranking is unit-testable without a database.
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
amountVarianceForMatch,
|
||||
calculateMatchConfidence,
|
||||
calculateMerchantSimilarity,
|
||||
} from '@/lib/documents/core-receipt-matcher'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import type { InboxChannelContext, InvoiceExtractionResult } from '@/types'
|
||||
|
||||
/**
|
||||
* Confidence floor for surfacing an unmatched item as a probable underlag.
|
||||
* Deliberately above core-receipt-matcher's MIN_MATCH_CONFIDENCE (0.4): that
|
||||
* floor governs a picker where a human reads a ranked list and judges, whereas
|
||||
* a candidate named here is read by an agent that will reason from it. A wrong
|
||||
* receipt on the wrong transaction is a mis-booking, so this surface trades
|
||||
* recall for precision and leaves the rest to the picker.
|
||||
*/
|
||||
export const CANDIDATE_MIN_CONFIDENCE = 0.6
|
||||
|
||||
/** How many probable-underlag candidates to surface at most. */
|
||||
export const CANDIDATE_LIMIT = 3
|
||||
|
||||
/**
|
||||
* How many still-unmatched items to score. Bounded rather than paginated:
|
||||
* candidates are ranked by confidence and only the strongest few are used, so
|
||||
* reading deeper into an old backlog cannot change the answer for a recent
|
||||
* transaction.
|
||||
*/
|
||||
const CANDIDATE_SCAN_LIMIT = 50
|
||||
|
||||
export interface UnderlagCandidate {
|
||||
inbox_item_id: string
|
||||
document_id: string | null
|
||||
merchant_name: string | null
|
||||
receipt_date: string | null
|
||||
total_amount: number | null
|
||||
vat_amount: number | null
|
||||
currency: string | null
|
||||
/** 0-1 from the shared receipt matcher. */
|
||||
confidence: number
|
||||
/** Swedish reasons the match scored, for display. */
|
||||
matchReasons: string[]
|
||||
/** Answers already captured for this item, so they travel with it. */
|
||||
channelContext: InboxChannelContext | null
|
||||
}
|
||||
|
||||
/** The transaction fields the scorer needs. */
|
||||
export interface CandidateTransaction {
|
||||
id: string
|
||||
date: string | null
|
||||
description: string | null
|
||||
merchant_name?: string | null
|
||||
amount: number | null
|
||||
currency: string | null
|
||||
amount_sek?: number | null
|
||||
exchange_rate?: number | null
|
||||
}
|
||||
|
||||
interface ScorableItem {
|
||||
id: string
|
||||
document_id: string | null
|
||||
extracted_data: InvoiceExtractionResult | null
|
||||
channel_context: InboxChannelContext | null
|
||||
}
|
||||
|
||||
/** Pull the fields the matcher needs out of an extraction blob. */
|
||||
function extractionSignals(extracted: InvoiceExtractionResult | null | undefined) {
|
||||
return {
|
||||
supplier: extracted?.supplier?.name?.trim() || null,
|
||||
date: extracted?.invoice?.invoiceDate ?? null,
|
||||
total: extracted?.totals?.total ?? null,
|
||||
vat: extracted?.totals?.vatAmount ?? null,
|
||||
currency: (extracted?.invoice?.currency || 'SEK').toUpperCase(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Score unmatched items against a transaction and return the strongest few.
|
||||
* Pure: the DB read is the caller's job.
|
||||
*/
|
||||
export function scoreUnderlagCandidates(
|
||||
tx: CandidateTransaction,
|
||||
items: ScorableItem[],
|
||||
): UnderlagCandidate[] {
|
||||
if (tx.amount == null || !tx.date) return []
|
||||
|
||||
const txCurrency = (tx.currency ?? 'SEK').toUpperCase()
|
||||
const txSek =
|
||||
txCurrency === 'SEK'
|
||||
? tx.amount
|
||||
: resolveSekAmount(tx.amount, tx.amount_sek, tx.currency, tx.exchange_rate)
|
||||
const txDateMs = new Date(tx.date).getTime()
|
||||
const txMerchant = tx.merchant_name || tx.description || ''
|
||||
|
||||
const scored: UnderlagCandidate[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const sig = extractionSignals(item.extracted_data)
|
||||
// An extraction with neither a date nor a total carries no signal the
|
||||
// matcher can use; scoring it returns noise dressed as confidence.
|
||||
if (!sig.date && sig.total == null) continue
|
||||
|
||||
const amountVariance = amountVarianceForMatch(
|
||||
sig.total,
|
||||
sig.currency,
|
||||
// No stored SEK value on the inbox item, so cross-currency pairs are
|
||||
// deliberately not comparable and the matcher drops the amount signal
|
||||
// rather than matching 750 EUR to 750 SEK.
|
||||
null,
|
||||
tx.amount,
|
||||
txCurrency,
|
||||
txSek,
|
||||
)
|
||||
|
||||
// No comparable amount means no candidate. calculateMatchConfidence drops
|
||||
// the amount signal when it cannot normalise the currencies, which leaves
|
||||
// date + merchant carrying the whole normalised score: a same-day receipt
|
||||
// from the same merchant then scores 1.0 without anyone having checked
|
||||
// that the sums agree. That is a fair ranking hint in the picker, where a
|
||||
// human reads both amounts, but here it would hand the agent a "certain"
|
||||
// underlag whose total is in another currency. Those still reach the user
|
||||
// through the picker; they are just not proposed.
|
||||
if (amountVariance == null) continue
|
||||
|
||||
const dateVariance = sig.date
|
||||
? Math.abs((new Date(sig.date).getTime() - txDateMs) / (1000 * 60 * 60 * 24))
|
||||
: Number.POSITIVE_INFINITY
|
||||
const similarity = sig.supplier ? calculateMerchantSimilarity(sig.supplier, txMerchant) : 0
|
||||
|
||||
const { confidence, matchReasons } = calculateMatchConfidence(
|
||||
dateVariance,
|
||||
amountVariance,
|
||||
similarity,
|
||||
)
|
||||
if (confidence < CANDIDATE_MIN_CONFIDENCE) continue
|
||||
|
||||
scored.push({
|
||||
inbox_item_id: item.id,
|
||||
document_id: item.document_id,
|
||||
merchant_name: sig.supplier,
|
||||
receipt_date: sig.date,
|
||||
total_amount: sig.total,
|
||||
vat_amount: sig.vat,
|
||||
currency: sig.currency,
|
||||
confidence,
|
||||
matchReasons,
|
||||
channelContext: item.channel_context ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
scored.sort((a, b) => b.confidence - a.confidence)
|
||||
return scored.slice(0, CANDIDATE_LIMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find probable underlag for a transaction among the company's unconsumed
|
||||
* inbox items.
|
||||
*
|
||||
* Only unconsumed items are considered: one already booked, already turned into
|
||||
* a supplier invoice, or already matched elsewhere belongs to a different
|
||||
* economic event, and proposing it here would invite a double booking.
|
||||
*/
|
||||
export async function findUnderlagCandidates(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
tx: CandidateTransaction,
|
||||
): Promise<UnderlagCandidate[]> {
|
||||
if (tx.amount == null || !tx.date) return []
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id, extracted_data, channel_context')
|
||||
.eq('company_id', companyId)
|
||||
.is('matched_transaction_id', null)
|
||||
.is('created_journal_entry_id', null)
|
||||
.is('created_supplier_invoice_id', null)
|
||||
.not('document_id', 'is', null)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(CANDIDATE_SCAN_LIMIT)
|
||||
|
||||
if (error || !data) return []
|
||||
return scoreUnderlagCandidates(tx, data as unknown as ScorableItem[])
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { transactionCategorization } from '../transaction-categorization'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* The reported bug, end to end.
|
||||
*
|
||||
* A user photographs a restaurant receipt into WhatsApp. The bot asks who was
|
||||
* there and why, the user answers, and the answer is stored on the inbox item's
|
||||
* channel_context. The user then opens the app, clicks the bank transaction and
|
||||
* asks the assistant to book it, and is asked the same question again.
|
||||
*
|
||||
* Two independent causes, both covered here:
|
||||
* 1. The capture never selected channel_context, so the answers were invisible.
|
||||
* 2. The capture finds underlag only via matched_transaction_id, and WhatsApp
|
||||
* intake never sets that column, so for a chat-captured receipt there was
|
||||
* nothing to attach the answers to in the first place.
|
||||
*/
|
||||
|
||||
const TX_ID = '11111111-1111-1111-1111-111111111111'
|
||||
const COMPANY_ID = '22222222-2222-2222-2222-222222222222'
|
||||
|
||||
const TX_ROW = {
|
||||
id: TX_ID,
|
||||
date: '2026-05-12',
|
||||
description: 'ESPRESSO HOUSE 1234 STOCKHOLM',
|
||||
merchant_name: 'Espresso House',
|
||||
amount: -184,
|
||||
currency: 'SEK',
|
||||
amount_sek: null,
|
||||
exchange_rate: null,
|
||||
document_id: null,
|
||||
journal_entry_id: null,
|
||||
}
|
||||
|
||||
const ANSWERED_REPRESENTATION = {
|
||||
channel: 'whatsapp',
|
||||
representation: {
|
||||
participants: [{ name: 'Anna Berg', company: 'Volvo' }],
|
||||
purpose: 'kundmöte om Q3-leveransen',
|
||||
event_date: null,
|
||||
raw_answer: 'jag och Anna Berg från Volvo, kundmöte',
|
||||
answered_at: '2026-05-12T13:00:00Z',
|
||||
},
|
||||
}
|
||||
|
||||
const WHATSAPP_ITEM = {
|
||||
id: 'item-1',
|
||||
document_id: 'doc-1',
|
||||
extracted_data: {
|
||||
supplier: { name: 'Espresso House' },
|
||||
invoice: { invoiceDate: '2026-05-12', currency: 'SEK' },
|
||||
totals: { total: 184, vatAmount: 22 },
|
||||
},
|
||||
channel_context: ANSWERED_REPRESENTATION,
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the real capture. Queue order matches the query order in capture():
|
||||
* transaction, receipts, matched inbox items, then the candidate scan (only
|
||||
* reached when nothing was linked).
|
||||
*/
|
||||
async function captureWith(opts: {
|
||||
matchedItems?: unknown[]
|
||||
unmatchedItems?: unknown[]
|
||||
}) {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
const matched = opts.matchedItems ?? []
|
||||
enqueueMany([
|
||||
{ data: TX_ROW },
|
||||
{ data: [] },
|
||||
{ data: matched },
|
||||
// #1425's backfill-by-document_id issues a query only when an underlag was
|
||||
// found and lacks chat_answers; the candidate scan runs only when nothing
|
||||
// was found at all, so exactly one of the two consumes this slot.
|
||||
...(matched.length > 0 ? [{ data: [] }] : [{ data: opts.unmatchedItems ?? [] }]),
|
||||
])
|
||||
return transactionCategorization.capture(
|
||||
{ transaction_id: TX_ID },
|
||||
{
|
||||
supabase: supabase as unknown as SupabaseClient,
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function render(captured: Awaited<ReturnType<typeof captureWith>>) {
|
||||
return transactionCategorization.promptTemplate({
|
||||
captured,
|
||||
profileSummary: null,
|
||||
activeMemory: [],
|
||||
})
|
||||
}
|
||||
|
||||
describe('ask-once: WhatsApp answers reach the in-app assistant', () => {
|
||||
it('carries answers from a matched WhatsApp underlag into the prompt', async () => {
|
||||
const captured = await captureWith({ matchedItems: [WHATSAPP_ITEM] })
|
||||
const out = render(captured)
|
||||
|
||||
expect(out).toContain('deltagare (uppgivna av användaren)')
|
||||
expect(out).toContain('Anna Berg (Volvo)')
|
||||
expect(out).toContain('kundmöte om Q3-leveransen')
|
||||
// and the instruction that makes the agent act on them
|
||||
expect(out).toContain('Fråga ALDRIG om något som redan står där')
|
||||
})
|
||||
|
||||
it('finds an UNMATCHED WhatsApp receipt, which is the reported scenario', async () => {
|
||||
// matched_transaction_id is NULL on every WhatsApp item, so the matched
|
||||
// query returns nothing and the candidate scan is what saves the user.
|
||||
const captured = await captureWith({ matchedItems: [], unmatchedItems: [WHATSAPP_ITEM] })
|
||||
|
||||
expect(captured.underlag).toHaveLength(1)
|
||||
expect(captured.underlag[0].match).toBe('candidate')
|
||||
|
||||
const out = render(captured)
|
||||
expect(out).not.toContain('UNDERLAG: saknas')
|
||||
expect(out).toContain('TROLIGT UNDERLAG')
|
||||
expect(out).toContain('Anna Berg (Volvo)')
|
||||
// A candidate is a proposal, not a link: the agent must get it confirmed.
|
||||
expect(out).toContain('en människa måste bekräfta kopplingen')
|
||||
})
|
||||
|
||||
it('does not re-ask about representation after the user answered "nej"', async () => {
|
||||
const denied = {
|
||||
...WHATSAPP_ITEM,
|
||||
channel_context: {
|
||||
channel: 'whatsapp',
|
||||
representation: {
|
||||
participants: [],
|
||||
purpose: null,
|
||||
event_date: null,
|
||||
raw_answer: 'nej',
|
||||
answered_at: '2026-05-12T13:00:00Z',
|
||||
denied: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
const captured = await captureWith({ matchedItems: [denied] })
|
||||
const out = render(captured)
|
||||
|
||||
// The verifikat renderer produces nothing at all for a denial, which is
|
||||
// why the prompt is built from the structured record instead.
|
||||
expect(out).toContain('INTE representation')
|
||||
})
|
||||
|
||||
it('still asks when nothing has been captured', async () => {
|
||||
const captured = await captureWith({
|
||||
matchedItems: [
|
||||
{ id: 'item-2', document_id: 'doc-2', extracted_data: WHATSAPP_ITEM.extracted_data, channel_context: null },
|
||||
],
|
||||
})
|
||||
const out = render(captured)
|
||||
|
||||
// No captured answer, so no data line. ("REDAN BESVARAT" still appears in
|
||||
// the standing instruction, hence matching on the rendered marker.)
|
||||
expect(out).not.toContain('uppgivna av användaren')
|
||||
// The original instruction survives for the genuinely unanswered case.
|
||||
expect(out).toContain('Hur många var ni, och vilka?')
|
||||
})
|
||||
|
||||
it('surfaces an unanswered question the chat gave up on, and only that one', async () => {
|
||||
const moved = {
|
||||
...WHATSAPP_ITEM,
|
||||
channel_context: {
|
||||
channel: 'whatsapp',
|
||||
pending_question: {
|
||||
type: 'representation',
|
||||
asked_at: '2026-05-12T13:00:00Z',
|
||||
status: 'moved_to_app',
|
||||
},
|
||||
},
|
||||
}
|
||||
const captured = await captureWith({ matchedItems: [moved] })
|
||||
const out = render(captured)
|
||||
|
||||
expect(out).toContain('OBESVARAD FRÅGA')
|
||||
expect(out).toContain('ägs nu av appen')
|
||||
expect(out).toContain('ställ exakt den frågan och ingen annan')
|
||||
})
|
||||
|
||||
it('falls back to the ask-for-underlag branch when nothing matches at all', async () => {
|
||||
const captured = await captureWith({ matchedItems: [], unmatchedItems: [] })
|
||||
expect(captured.underlag).toHaveLength(0)
|
||||
expect(render(captured)).toContain('UNDERLAG: saknas')
|
||||
})
|
||||
})
|
||||
@@ -49,6 +49,7 @@ function renderPrompt(opts: {
|
||||
is_systembolaget: null,
|
||||
raw_extraction: null,
|
||||
chat_answers: opts.chatAnswers ?? null,
|
||||
match: 'linked' as const,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
renderClarificationLines,
|
||||
summariseClarifications,
|
||||
} from '@/lib/agent-context/chat-clarifications'
|
||||
import { findUnderlagCandidates } from '@/lib/agent-context/underlag-candidates'
|
||||
import type { InboxChannelContext } from '@/types'
|
||||
|
||||
// transaction.categorization: "Fråga om denna transaktion" on a transaction
|
||||
@@ -59,6 +60,22 @@ interface CapturedTransaction {
|
||||
* system that already holds the answer can do.
|
||||
*/
|
||||
chat_answers: InboxChannelContext | null
|
||||
/**
|
||||
* `linked` = tied to this transaction by a human or an explicit flow.
|
||||
* `candidate` = scored as probably the same economic event but NOT linked.
|
||||
*
|
||||
* Candidates exist because WhatsApp intake writes neither
|
||||
* matched_transaction_id nor transactions.document_id, so a chat-captured
|
||||
* receipt reaches neither the matched query nor the document backfill and
|
||||
* the assistant reports "UNDERLAG: saknas" about a receipt we hold.
|
||||
*/
|
||||
match: 'linked' | 'candidate'
|
||||
/** Only set for candidates: 0-1 from the shared receipt matcher. */
|
||||
confidence?: number
|
||||
/** Only set for candidates: Swedish reasons the match scored. */
|
||||
match_reasons?: string[]
|
||||
/** Set for inbox-sourced underlag so the agent can propose the match. */
|
||||
inbox_item_id?: string | null
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -98,7 +115,11 @@ export const transactionCategorization = defineAgentIntent<
|
||||
capture: async ({ transaction_id }, { supabase, companyId }) => {
|
||||
const { data: tx } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, description, amount, currency, document_id, journal_entry_id')
|
||||
.select(
|
||||
// merchant_name / amount_sek / exchange_rate feed the candidate scorer
|
||||
// (currency-aware amount comparison + merchant similarity).
|
||||
'id, date, description, merchant_name, amount, currency, amount_sek, exchange_rate, document_id, journal_entry_id',
|
||||
)
|
||||
.eq('id', transaction_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
@@ -173,6 +194,7 @@ export const transactionCategorization = defineAgentIntent<
|
||||
is_systembolaget: r.is_systembolaget,
|
||||
raw_extraction: r.raw_extraction,
|
||||
chat_answers: null,
|
||||
match: 'linked',
|
||||
})
|
||||
}
|
||||
for (const it of (inboxItems ?? []) as {
|
||||
@@ -196,6 +218,7 @@ export const transactionCategorization = defineAgentIntent<
|
||||
is_systembolaget: null,
|
||||
raw_extraction: ex,
|
||||
chat_answers: it.channel_context ?? null,
|
||||
match: 'linked',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -240,6 +263,7 @@ export const transactionCategorization = defineAgentIntent<
|
||||
is_systembolaget: null,
|
||||
raw_extraction: null,
|
||||
chat_answers: null,
|
||||
match: 'linked',
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -258,6 +282,7 @@ export const transactionCategorization = defineAgentIntent<
|
||||
is_systembolaget: null,
|
||||
raw_extraction: ex,
|
||||
chat_answers: null,
|
||||
match: 'linked',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -288,6 +313,45 @@ export const transactionCategorization = defineAgentIntent<
|
||||
}
|
||||
}
|
||||
|
||||
// Still nothing. That is the ordinary state for a receipt captured in
|
||||
// WhatsApp: intake writes neither invoice_inbox_items.matched_transaction_id
|
||||
// nor transactions.document_id (both are set only when a human matches in
|
||||
// the picker), so the item reaches neither the matched query nor the
|
||||
// backfill above, and the user is told "UNDERLAG: saknas" about a receipt
|
||||
// we are holding. Score the unmatched items and offer the strongest for a
|
||||
// human to confirm.
|
||||
if (underlag.length === 0 && tx) {
|
||||
const candidates = await findUnderlagCandidates(supabase, companyId, {
|
||||
id: tx.id as string,
|
||||
date: (tx.date as string | null) ?? null,
|
||||
description: (tx.description as string | null) ?? null,
|
||||
merchant_name: (tx.merchant_name as string | null) ?? null,
|
||||
amount: tx.amount as number | null,
|
||||
currency: (tx.currency as string | null) ?? null,
|
||||
amount_sek: (tx.amount_sek as number | null) ?? null,
|
||||
exchange_rate: (tx.exchange_rate as number | null) ?? null,
|
||||
})
|
||||
for (const c of candidates) {
|
||||
underlag.push({
|
||||
kind: 'invoice_inbox',
|
||||
match: 'candidate',
|
||||
confidence: c.confidence,
|
||||
match_reasons: c.matchReasons,
|
||||
inbox_item_id: c.inbox_item_id,
|
||||
chat_answers: c.channelContext,
|
||||
document_id: c.document_id,
|
||||
merchant_name: c.merchant_name,
|
||||
receipt_date: c.receipt_date,
|
||||
total_amount: c.total_amount,
|
||||
vat_amount: c.vat_amount,
|
||||
currency: c.currency,
|
||||
is_restaurant: null,
|
||||
is_systembolaget: null,
|
||||
raw_extraction: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
transaction: tx
|
||||
? {
|
||||
@@ -343,12 +407,23 @@ export const transactionCategorization = defineAgentIntent<
|
||||
} else {
|
||||
// Underlag IS attached: read the extracted metadata and use it
|
||||
// directly. Don't ask the user for things the extraction already nailed.
|
||||
lines.push(`UNDERLAG: ${captured.underlag.length} st bifogat. Extraherade fält:`)
|
||||
const linkedCount = captured.underlag.filter((u) => u.match === 'linked').length
|
||||
const candidateCount = captured.underlag.length - linkedCount
|
||||
lines.push(
|
||||
linkedCount > 0
|
||||
? `UNDERLAG: ${linkedCount} st bifogat. Extraherade fält:`
|
||||
: `UNDERLAG: inget är kopplat till transaktionen, men ${candidateCount} st i Dokumentinkorgen liknar den starkt (TROLIGT UNDERLAG, ej bekräftat). Extraherade fält:`,
|
||||
)
|
||||
// Set when at least one underlag actually contributed a clarification
|
||||
// line, which is what the guidance paragraph below refers to.
|
||||
let renderedClarifications = false
|
||||
for (const u of captured.underlag) {
|
||||
const parts: string[] = []
|
||||
if (u.match === 'candidate') {
|
||||
parts.push(`TROLIGT UNDERLAG (${Math.round((u.confidence ?? 0) * 100)}% säkerhet)`)
|
||||
if (u.match_reasons?.length) parts.push(u.match_reasons.join(' + '))
|
||||
if (u.inbox_item_id) parts.push(`inbox_item_id=${u.inbox_item_id}`)
|
||||
}
|
||||
if (u.document_id) parts.push(`document_id=${u.document_id}`)
|
||||
if (u.merchant_name) parts.push(`leverantör=${u.merchant_name}`)
|
||||
if (u.receipt_date) parts.push(`datum=${u.receipt_date}`)
|
||||
@@ -390,6 +465,9 @@ export const transactionCategorization = defineAgentIntent<
|
||||
if (renderedClarifications) {
|
||||
lines.push('Rader märkta "uppgivna av användaren" kommer från en tidigare konversation om samma underlag (t.ex. WhatsApp när kvittot skickades in). Det är MÄNSKLIGT bekräftade uppgifter och väger tyngre än vad du själv läser ut ur bilden. Fråga ALDRIG om något som redan står där; behöver du komplettera, fråga bara om den del som faktiskt saknas. Står det "OBESVARAD FRÅGA": ställ exakt den frågan och ingen annan. När du stagear: ta med deltagare och syfte i notes så de följer med till verifikationen.')
|
||||
}
|
||||
if (candidateCount > 0) {
|
||||
lines.push('TROLIGT UNDERLAG är INTE kopplat ännu: en människa måste bekräfta kopplingen. Fråga kort om det är rätt underlag (nämn leverantör, datum, belopp) och be användaren koppla det i Dokumentinkorgen via "Matcha mot transaktion". Bokför inte mot ett troligt underlag som användaren inte bekräftat, men använd gärna dess uppgifter för att föreslå kategori under tiden.')
|
||||
}
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('Arbetssätt: hämta information via verktygsanrop FÖRST (tyst: statusraderna visar att du söker, och ditt resonemang sker i tankekanalen), föreslå sedan. Skriv din förklaring EN gång efteråt, inte i flera block runt anropen.')
|
||||
|
||||
Reference in New Issue
Block a user