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>
276 lines
12 KiB
TypeScript
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' })
|
|
})
|
|
})
|