ec27228a8e
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>
186 lines
8.1 KiB
TypeScript
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/)
|
|
})
|
|
})
|