Files
accounted/lib/agent/intents/__tests__/transaction-categorization.test.ts
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

186 lines
8.1 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { transactionCategorization } from '../transaction-categorization'
// 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
}) {
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,
},
]
: [],
}
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/)
})
})