Files
accounted/lib/agent/intents/__tests__/transaction-categorization.test.ts
T
576ed4d290 fix(agent): let the assistant see answers the user gave in WhatsApp (#1425)
Two field findings from the first live receipts.

1. The assistant re-asked for information the user had already given.
   The user answered the representation question in WhatsApp ("Elias
   Karlsson från Canguro Media, Jakob Wennberg från Arcim"), the answer
   was stored correctly on invoice_inbox_items.channel_context, and then
   the in-app assistant said it could see no participant names and asked
   for them again. The intent's inbox query selected only document_id and
   extracted_data, and nothing under lib/agent/ read channel_context at
   all. It is now selected, threaded onto each underlag as chat_answers,
   and rendered into the prompt as "uppgivna av användaren" with an
   explicit instruction that human answers outrank anything read off the
   image and must never be re-asked. Also backfilled by document_id: a
   receipt can reach the intent through the document paths without its
   inbox row being matched to the transaction.

2. The representation question accepted half an answer in silence.
   Naming participants but no purpose stored purpose=null and replied
   "Tack!", leaving the deduction undocumented while looking complete.
   Skatteverket wants both (BFL 5 kap 6-7 §). It now asks once, for the
   missing half only, and keeps the question open so the reply routes
   back to the same receipt. Anti-loop: the follow-up fires only when no
   representation block exists yet, so a second incomplete answer is
   taken as-is rather than nagging.

Tests cover the prompt half and the query half separately: the earlier
prompt tests injected chat_answers directly and would have stayed green
with the column still missing from the select, which is precisely how the
bug shipped. Both mutation-checked.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:08:15 +02:00

276 lines
12 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { transactionCategorization } from '../transaction-categorization'
import type { InboxChannelContext } from '@/types'
// Locks in the prose-drift fix from /Users/jakobwennberg/.claude/plans/.
// The promptTemplate must instruct the agent to narrate using CATEGORY
// LABELS, never four-digit BAS account numbers. If a future edit
// reintroduces a "föreslå BAS-konto"-style instruction, this test fails.
const TX_ID = '11111111-1111-1111-1111-111111111111'
function stripUuidsAndDates(text: string): string {
return text
// RFC 4122 UUIDs
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '')
// ISO dates yyyy-MM-dd
.replace(/\d{4}-\d{2}-\d{2}/g, '')
// Swedish law references: "ML 2023:200", "BFNAR 2013:2", etc. The year
// half can collide with the BAS class-2 range, so strip the whole
// reference before scanning for stray BAS numbers.
.replace(/\b\d{4}:\d{1,3}\b/g, '')
}
function renderPrompt(opts: {
hasUnderlag: boolean
profileSummary?: string | null
chatAnswers?: InboxChannelContext | null
}) {
const captured = {
transaction: {
id: TX_ID,
date: '2026-05-12',
description: 'Supabase Pte. Ltd.',
amount: -810,
currency: 'USD',
counterparty_name: null,
},
underlag: opts.hasUnderlag
? [
{
kind: 'receipt' as const,
document_id: 'doc-1',
merchant_name: 'Supabase Pte. Ltd.',
receipt_date: '2026-05-12',
total_amount: 810,
vat_amount: 0,
currency: 'USD',
is_restaurant: null,
is_systembolaget: null,
raw_extraction: null,
chat_answers: opts.chatAnswers ?? null,
},
]
: [],
}
return transactionCategorization.promptTemplate({
captured,
profileSummary: opts.profileSummary ?? null,
activeMemory: [],
})
}
describe('transaction.categorization prompt template', () => {
it('includes the transaction UUID', () => {
const out = renderPrompt({ hasUnderlag: true })
expect(out).toContain(`transaction_id: ${TX_ID}`)
})
it('instructs the agent to narrate with category names, not BAS numbers', () => {
const out = renderPrompt({ hasUnderlag: true })
expect(out).toContain('kategori-namn')
expect(out).toContain('ALDRIG ett BAS-kontonummer')
})
it('does not embed a four-digit BAS account number in the prompt body', () => {
// Bare four-digit BAS like "5420", "6540", "1930" must not appear in the
// prompt, only category labels. Strip UUIDs and ISO dates first so
// their digit fragments don't trigger false positives.
const out = renderPrompt({ hasUnderlag: true })
const stripped = stripUuidsAndDates(out)
const bareFourDigits = stripped.match(/\b[12345678]\d{3}\b/g) ?? []
expect(bareFourDigits).toEqual([])
})
it('still tells the agent to use category labels even with no underlag', () => {
const out = renderPrompt({ hasUnderlag: false })
const stripped = stripUuidsAndDates(out)
const bareFourDigits = stripped.match(/\b[12345678]\d{3}\b/g) ?? []
expect(bareFourDigits).toEqual([])
})
it('instructs the agent to use the enum from the tool schema', () => {
const out = renderPrompt({ hasUnderlag: true })
expect(out).toContain('Välj kategori från enum-listan')
})
it('instructs the agent to ask follow-up questions when the underlag is unclear', () => {
const out = renderPrompt({ hasUnderlag: true })
expect(out).toContain('FRÅGA användaren först')
expect(out).toContain('oklart eller motsägelsefullt')
})
it('instructs the agent to ask about purpose for context-dependent categories', () => {
// Even when extraction is complete, classification depends on context
// the human knows: restaurant rep vs intern måltid, Systembolaget rep
// vs gift, ICA office vs private, etc. The prompt must lock in a
// mandatory follow-up question for these categories.
const out = renderPrompt({ hasUnderlag: true })
expect(out.toLowerCase()).toContain('systembolaget')
expect(out.toLowerCase()).toContain('restaurang')
expect(out).toContain('STÄLL en kort följdfråga')
expect(out.toLowerCase()).toContain('hellre en fråga än en felaktig bokning')
})
it('points at the loaded atoms as primary source, without re-stating the system-prompt epistemics', () => {
// Compliance content lives in the loaded atoms (swedish-vat,
// swedish-accounting-compliance, ...). The intent prompt points at them as
// the primary source to cite from…
const out = renderPrompt({ hasUnderlag: true })
expect(out.toLowerCase()).toContain('atomerna')
expect(out).toMatch(/swedish-(vat|accounting-compliance|invoice-compliance)/)
// …but it must NOT re-duplicate the load-before-answer epistemics rule. That
// rule now lives once in the always-on system prompt (Block 2); re-adding it
// here restores the triplication this cleanup removed.
expect(out).not.toContain('12 %→6 %')
expect(out.toLowerCase()).not.toContain('träningsdata')
})
it('instructs the agent to check counterparty history before proposing', () => {
// Past bookings for the same counterparty are a stronger signal than
// the LLM's guess. Lock in the tool call. We query the actual journal
// (gnubok_query_journal) rather than the lossy categorization_templates
// summary, so the agent sees full verifikat: accounts, VAT, line text.
const out = renderPrompt({ hasUnderlag: true })
expect(out).toContain('gnubok_query_journal')
expect(out.toLowerCase()).toContain('så har du gjort förut')
})
it('instructs the agent to persist user answers via memory tool', () => {
// Follow-up answers (rep vs intern, kund vs anställd) should be saved
// so the agent doesn't re-ask next time a similar counterparty
// appears.
const out = renderPrompt({ hasUnderlag: true })
expect(out).toContain('gnubok_remember_fact')
})
it('forbids redundant staging narration that the ApprovalCard already shows', () => {
// The card renders directly below the agent's response with the risk,
// category, BAS account, VAT lines, and Godkänn/Avslå buttons. Echoing
// "stagear nu" / "stageat" / "godkänn i appen" duplicates the card and
// leaves cramped run-on text. Lock the no-narration rule in.
const out = renderPrompt({ hasUnderlag: true })
// The prompt names the phrases the agent must avoid: "stagear nu",
// "godkänna i appen". It also tells the agent to skip repeating the
// card's contents.
expect(out.toLowerCase()).toContain('stagear nu')
expect(out.toLowerCase()).toContain('godkänna i appen')
expect(out.toLowerCase()).toContain('godkännandekortet')
expect(out).toMatch(/[Bb]erätta INTE för användaren/)
})
it('directs the user to Dokumentinkorgen when underlag is missing', () => {
// The chat sheet no longer accepts file uploads. The agent must not
// tell users to "drop the file in chat" or "click the paperclip":
// those affordances were removed in v5. Documents go through the
// Dokumentinkorgen workspace.
const out = renderPrompt({ hasUnderlag: false })
expect(out).toContain('Dokumentinkorgen')
expect(out).toContain('Matcha mot transaktion')
expect(out.toLowerCase()).not.toContain('gem-ikon')
expect(out.toLowerCase()).not.toContain('släpp filen här i chattfönstret')
expect(out.toLowerCase()).not.toContain('chattfönstret')
})
it('routes post-booking underlag to the verifikation, not back to Dokumentinkorgen', () => {
// After a verifikation has been staged, the user must not be sent back
// to Dokumentinkorgen: the doc belongs ON the verifikation (under
// Bokföring). Inbox is for unbooked documents only.
const noUnderlag = renderPrompt({ hasUnderlag: false })
expect(noUnderlag).toContain('bifogas TILL VERIFIKATIONEN')
expect(noUnderlag).toContain('öppna verifikationen i Bokföring')
// The "Arbetssätt" rule that forbids repeating the upload reminder
// after staging must also exist.
const withUnderlag = renderPrompt({ hasUnderlag: true })
expect(withUnderlag).toContain('Upprepa INTE underlag-uppmaningen efter stagning')
expect(withUnderlag).toMatch(/bifogas till\s+VERIFIKATIONEN/)
})
})
// Field report 2026-08-05: a user answered the representation question in
// WhatsApp, then the in-app assistant asked for the same names again because
// invoice_inbox_items.channel_context never reached the prompt.
describe('chat answers from another channel', () => {
const chatAnswers: InboxChannelContext = {
channel: 'whatsapp',
representation: {
participants: [
{ name: 'Elias Karlsson', company: 'Canguro Media' },
{ name: 'Jakob Wennberg', company: 'Arcim Technology AB' },
],
purpose: 'Lunch med samarbetspartner',
event_date: null,
raw_answer: 'Elias Karlsson från Canguro Media, Jakob Wennberg från Arcim',
answered_at: '2026-08-05T14:29:00.000Z',
},
}
it('puts the participants the user already gave into the prompt', () => {
const out = renderPrompt({ hasUnderlag: true, chatAnswers })
expect(out).toContain('Elias Karlsson (Canguro Media)')
expect(out).toContain('Jakob Wennberg (Arcim Technology AB)')
expect(out).toContain('Lunch med samarbetspartner')
})
it('marks them as human-supplied and forbids re-asking', () => {
const out = renderPrompt({ hasUnderlag: true, chatAnswers })
expect(out).toMatch(/uppgivna av användaren/)
expect(out).toMatch(/Fråga ALDRIG om något som redan står där/)
})
it('names the missing half when only participants were captured', () => {
const out = renderPrompt({
hasUnderlag: true,
chatAnswers: {
...chatAnswers,
representation: { ...chatAnswers.representation!, purpose: null },
},
})
expect(out).toMatch(/syfte SAKNAS/)
expect(out).toContain('Elias Karlsson (Canguro Media)')
})
it('says nothing about chat answers when there are none', () => {
const out = renderPrompt({ hasUnderlag: true })
expect(out).not.toMatch(/uppgivna av användaren/)
})
})
// The prompt tests above inject chat_answers directly, so they would still
// pass if capture() never selected the column: which is exactly the bug that
// shipped. This covers the query half.
describe('transaction.categorization capture', () => {
function supabaseStub() {
const selects: string[] = []
const from = (table: string) => {
const chain: Record<string, unknown> = {}
const self = () => chain
chain.select = (cols: string) => {
selects.push(`${table}:${cols}`)
return chain
}
for (const m of ['eq', 'in', 'not']) chain[m] = self
chain.single = async () => ({ data: { id: 'tx-1', date: '2026-08-05', description: 'X', amount: -425, currency: 'SEK', document_id: null, journal_entry_id: null } })
chain.maybeSingle = async () => ({ data: null })
chain.then = (resolve: (v: unknown) => unknown) =>
resolve({
data: table === 'invoice_inbox_items'
? [{ document_id: 'doc-1', extracted_data: {}, channel_context: { channel: 'whatsapp', user_note: 'lunch med kund' } }]
: [],
})
return chain
}
return { from, selects }
}
it('selects channel_context and threads it onto the underlag', async () => {
const stub = supabaseStub()
const captured = await transactionCategorization.capture!(
{ transaction_id: 'tx-1' },
{ supabase: stub as never, companyId: 'company-1', userId: 'user-1' } as never,
)
expect(stub.selects.some((s) => s.startsWith('invoice_inbox_items:') && s.includes('channel_context'))).toBe(true)
expect(captured.underlag[0]?.chat_answers).toEqual({ channel: 'whatsapp', user_note: 'lunch med kund' })
})
})