Files
accounted/lib/agent/intents/transaction-categorization.ts
T
Jakob WennbergandClaude Sonnet 5 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

335 lines
17 KiB
TypeScript

import { defineAgentIntent } from './types'
import { SONNET_MODEL, THINKING_BUDGET_STANDARD } from '@/lib/agent/composer/client'
// transaction.categorization: "Fråga om denna transaktion" on a transaction
// row.
//
// Declarative atom mode: loads VAT + invoice compliance + accounting
// compliance upfront, plus the company's vertical and modifier atoms. That's
// the loadout needed to confidently propose a BAS account for a typical
// expense or income line.
//
// Tool scope intentionally narrow: the agent should resolve the
// categorization at the row in question, not wander.
//
// Plan refs: §8 (intent system, V1 #1), §8 ("gather information first,
// propose second": encoded in the prompt template below).
interface TransactionCategorizationArgs {
transaction_id: string
}
interface CapturedTransaction {
transaction: {
id: string
date: string | null
description: string | null
amount: number | null
currency: string | null
counterparty_name: string | null
} | null
// Each linked receipt/invoice in a flattened "what we already know" shape.
// Empty when the user has not attached anything yet.
underlag: {
kind: 'receipt' | 'invoice_inbox'
document_id: string | null
merchant_name: string | null
receipt_date: string | null
total_amount: number | null
vat_amount: number | null
currency: string | null
is_restaurant: boolean | null
is_systembolaget: boolean | null
// Raw extracted fields from the upload pipeline: passed verbatim so the
// agent can paraphrase context-specific signals (line items, dates,
// payment reference) without us pre-modeling every field.
raw_extraction: Record<string, unknown> | null
}[]
}
export const transactionCategorization = defineAgentIntent<
TransactionCategorizationArgs,
CapturedTransaction
>({
id: 'transaction.categorization',
buttonLabel: 'Fråga om denna transaktion',
sheetTitle: 'Hjälp med transaktion',
atoms: {
mode: 'declarative',
horizontal: ['swedish-vat', 'swedish-invoice-compliance', 'swedish-accounting-compliance'],
includeCompanyVertical: true,
includeCompanyModifiers: true,
},
tools: [
'gnubok_categorize_transaction',
'gnubok_query_journal',
'gnubok_match_transaction_to_invoice',
'gnubok_get_document_content',
'gnubok_load_skill',
'gnubok_search_tools',
'gnubok_remember_fact',
'gnubok_forget_fact',
],
model: SONNET_MODEL,
// Reason before proposing: read underlag + history and work out the VAT
// treatment in the thinking channel, so the visible reply is one short
// motivation, not a play-by-play of each tool call.
thinking: { budgetTokens: THINKING_BUDGET_STANDARD },
capture: async ({ transaction_id }, { supabase, companyId }) => {
const { data: tx } = await supabase
.from('transactions')
.select('id, date, description, amount, currency, document_id, journal_entry_id')
.eq('id', transaction_id)
.eq('company_id', companyId)
.single()
// Pull every underlag we can find for this transaction in parallel:
// 1. receipts.matched_transaction_id → receipt-scan extracted fields
// 2. invoice_inbox_items.matched_transaction_id → inbox extension
// 3. document_attachments.extracted_data via the transaction's own
// document_id and (when posted) any docs linked to the journal
// entry: populated by the document-extraction extension on
// upload.
const journalEntryId = (tx?.journal_entry_id as string | null) ?? null
const directDocumentId = (tx?.document_id as string | null) ?? null
const [
{ data: receipts },
{ data: inboxItems },
{ data: directDoc },
{ data: entryDocs },
] = await Promise.all([
supabase
.from('receipts')
.select(
'document_id, merchant_name, receipt_date, total_amount, vat_amount, currency, is_restaurant, is_systembolaget, raw_extraction',
)
.eq('company_id', companyId)
.eq('matched_transaction_id', transaction_id),
supabase
.from('invoice_inbox_items')
.select('document_id, extracted_data')
.eq('company_id', companyId)
.eq('matched_transaction_id', transaction_id),
directDocumentId
? supabase
.from('document_attachments')
.select('id, file_name, mime_type, extracted_data, extraction_model')
.eq('id', directDocumentId)
.eq('company_id', companyId)
.maybeSingle()
: Promise.resolve({ data: null }),
journalEntryId
? supabase
.from('document_attachments')
.select('id, file_name, mime_type, extracted_data, extraction_model')
.eq('journal_entry_id', journalEntryId)
.eq('company_id', companyId)
.eq('is_current_version', true)
: Promise.resolve({ data: [] }),
])
const underlag: CapturedTransaction['underlag'] = []
for (const r of (receipts ?? []) as {
document_id: string | null
merchant_name: string | null
receipt_date: string | null
total_amount: number | null
vat_amount: number | null
currency: string | null
is_restaurant: boolean | null
is_systembolaget: boolean | null
raw_extraction: Record<string, unknown> | null
}[]) {
underlag.push({
kind: 'receipt',
document_id: r.document_id,
merchant_name: r.merchant_name,
receipt_date: r.receipt_date,
total_amount: r.total_amount,
vat_amount: r.vat_amount,
currency: r.currency,
is_restaurant: r.is_restaurant,
is_systembolaget: r.is_systembolaget,
raw_extraction: r.raw_extraction,
})
}
for (const it of (inboxItems ?? []) as {
document_id: string | null
extracted_data: Record<string, unknown> | null
}[]) {
const ex = it.extracted_data ?? {}
const supplier = (ex.supplier as { name?: string | null } | undefined) ?? null
const invoice = (ex.invoice as { invoiceDate?: string | null; currency?: string | null } | undefined) ?? null
const totals = (ex.totals as { total?: number | null; vatAmount?: number | null } | undefined) ?? null
underlag.push({
kind: 'invoice_inbox',
document_id: it.document_id,
merchant_name: supplier?.name ?? null,
receipt_date: invoice?.invoiceDate ?? null,
total_amount: totals?.total ?? null,
vat_amount: totals?.vatAmount ?? null,
currency: invoice?.currency ?? null,
is_restaurant: null,
is_systembolaget: null,
raw_extraction: ex,
})
}
// document_attachments.extracted_data: populated by the
// document-extraction extension for any upload path (booking dialog,
// quick review, journal entry, etc.). Dedupe by document_id against
// rows we already collected above.
const seenDocIds = new Set(underlag.map((u) => u.document_id).filter(Boolean))
const docCandidates: {
id: string | null
extracted_data: Record<string, unknown> | null
}[] = []
if (directDoc && (directDoc as { id?: string }).id) {
docCandidates.push({
id: ((directDoc as { id: string }).id) ?? null,
extracted_data:
((directDoc as { extracted_data: Record<string, unknown> | null }).extracted_data) ?? null,
})
}
for (const d of (entryDocs ?? []) as {
id: string
extracted_data: Record<string, unknown> | null
}[]) {
docCandidates.push({ id: d.id, extracted_data: d.extracted_data ?? null })
}
for (const d of docCandidates) {
if (!d.id || seenDocIds.has(d.id)) continue
const ex = d.extracted_data
if (!ex) {
// Attached but extraction hasn't run (or failed): surface as
// "underlag attached, extraction pending" so the prompt can
// suggest calling gnubok_get_document_content directly.
underlag.push({
kind: 'receipt',
document_id: d.id,
merchant_name: null,
receipt_date: null,
total_amount: null,
vat_amount: null,
currency: null,
is_restaurant: null,
is_systembolaget: null,
raw_extraction: null,
})
continue
}
const supplier = (ex.supplier as { name?: string | null } | undefined) ?? null
const invoice = (ex.invoice as { invoiceDate?: string | null; currency?: string | null } | undefined) ?? null
const totals = (ex.totals as { total?: number | null; vatAmount?: number | null } | undefined) ?? null
underlag.push({
kind: 'receipt',
document_id: d.id,
merchant_name: supplier?.name ?? null,
receipt_date: invoice?.invoiceDate ?? null,
total_amount: totals?.total ?? null,
vat_amount: totals?.vatAmount ?? null,
currency: invoice?.currency ?? null,
is_restaurant: null,
is_systembolaget: null,
raw_extraction: ex,
})
}
return {
transaction: tx
? {
id: tx.id,
date: (tx.date as string | null) ?? null,
description: (tx.description as string | null) ?? null,
amount: tx.amount as number | null,
currency: tx.currency as string | null,
counterparty_name: null,
}
: null,
underlag,
}
},
promptTemplate: ({ captured, profileSummary }) => {
if (!captured.transaction) {
return [
'Användaren öppnade hjälpfönstret från en transaktionsrad, men transaktionen kunde inte hittas.',
'Be om mer information och försök hjälpa till på generell nivå.',
].join(' ')
}
const tx = captured.transaction
const lines: string[] = []
if (profileSummary) lines.push(`Företagets profil: ${profileSummary}`, '')
lines.push('Hjälp användaren med denna transaktion:')
lines.push(`- transaction_id: ${tx.id}`)
lines.push(`- Datum: ${tx.date ?? 'okänt'}`)
lines.push(`- Beskrivning: ${tx.description ?? '(saknas)'}`)
lines.push(
`- Belopp: ${tx.amount != null ? `${tx.amount.toLocaleString('sv-SE')} ${tx.currency ?? 'SEK'}` : '(okänt)'}`,
)
lines.push('')
if (captured.underlag.length === 0) {
// No underlag yet. The chat sheet no longer accepts file uploads:
// documents live in Dokumentinkorgen. Direct the user there. The
// user must then match the inbox item to this transaction (or to
// any transaction) before booking can use the underlag.
lines.push('UNDERLAG: saknas.')
lines.push('')
lines.push('BFL 7 kap kräver ett underlag för varje affärshändelse. Innan du föreslår bokföring:')
lines.push('1. Säg till användaren att vi behöver underlaget (kvitto eller faktura).')
lines.push('2. Beskriv KORT hur de får in det:')
lines.push(' • **Gå till Dokumentinkorgen** (i sidomenyn) och dra in PDF:en eller bilden där. AI:n läser dokumentet automatiskt.')
lines.push(' • Alternativt: vidarebefordra fakturan/kvittot via e-post till företagets inbox-adress: det landar i samma inkorg.')
lines.push(' • När underlaget är i inkorgen klickar de "Matcha mot transaktion" och väljer denna transaktion. Då dyker det upp här som UNDERLAG på nästa fråga.')
lines.push('3. Om användaren ändå är säker på vad det är (t.ex. en återkommande mjukvaruprenumeration), erbjud att bokföra utan underlag mot en uttrycklig notering, och förklara att underlaget måste bifogas TILL VERIFIKATIONEN i efterhand (öppna verifikationen i Bokföring och ladda upp där). Skicka INTE användaren tillbaka till Dokumentinkorgen efter att en verifikation skapats: inkorgen är för dokument som inte ännu är kopplade till en bokföring.')
lines.push('')
lines.push('Skicka ALDRIG användaren till chatten för att ladda upp filen: den vägen är borttagen.')
} 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:`)
for (const u of captured.underlag) {
const parts: string[] = []
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}`)
if (u.total_amount != null) {
parts.push(`total=${u.total_amount.toLocaleString('sv-SE')} ${u.currency ?? 'SEK'}`)
}
if (u.vat_amount != null) {
parts.push(`moms=${u.vat_amount.toLocaleString('sv-SE')} ${u.currency ?? 'SEK'}`)
}
if (u.is_restaurant) parts.push('restaurang=ja')
if (u.is_systembolaget) parts.push('systembolaget=ja')
lines.push(` • ${u.kind}: ${parts.join(', ') || '(ingen extraherad data: läs underlaget med gnubok_get_document_content)'}`)
}
lines.push('')
lines.push('VIKTIGT: extraktionen ovan är det vi REDAN VET. Återupprepa inte frågor som "vilken leverantör är det?" eller "vad var beloppet?": det står ovan. Använd uppgifterna direkt och föreslå kategori + moms-behandling.')
}
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.')
lines.push('- Atomerna i systemprompten (swedish-vat, swedish-accounting-compliance, swedish-invoice-compliance + företagets vertikal/modifier-atomer) är din primärkälla: citera BFL / ML 2023:200 / BFNAR / BAS därifrån i din korta motivering. (Disciplinen kring satser och gränser, ladda-före-svar, styrs av systemprompten.)')
lines.push('- KOLLA HUR MOTPARTEN BOKFÖRTS FÖRUT innan du föreslår kategori. Anropa gnubok_query_journal({ text: "<motpartens namn>", limit: 5 }): använd det renaste namn-signalen du har (underlagets leverantörsnamn när det finns, annars ett kort utdrag ur transaktionsbeskrivningen utan adress/stad-cruft, t.ex. "Linear" inte "LINEAR.APP*HQ STOCKHOLM"). Granska de returnerade raderna: vilka BAS-konton användes, vilken momsbehandling, samma summor i samma härad? Om det finns ett tydligt mönster: följ det om inte underlaget motsäger det. "Så har du gjort förut" är ett starkare argument än vad du själv tycker borde gälla. Om query_journal returnerar 0 träffar är motparten ny: grunda förslaget på atomerna (nämn att den är ny bara om det är relevant, i din korta motivering: skriv ingen separat rad om sökresultatet).')
lines.push('- Om underlaget inte är extraherat tillräckligt djupt (t.ex. saknar momsbelopp), läs PDF/bilden med gnubok_get_document_content(document_id=…) och fyll i luckorna.')
lines.push('- Om något i underlaget är oklart eller motsägelsefullt (t.ex. moms saknas men säljaren är svensk, eller belopp inte stämmer med transaktionen), FRÅGA användaren först innan du stagear.')
lines.push('- STÄLL en kort följdfråga (2-3 alternativ) när kategorin beror på syfte som inte syns på kvittot: restaurang/café, Systembolaget, detaljhandel (ICA/Clas Ohlson/Apoteket), resor, drivmedel, gåvor. Hellre en fråga än en felaktig bokning. Spara användarens svar via gnubok_remember_fact så du inte behöver fråga igen nästa gång liknande motpart dyker upp.')
lines.push('- REPRESENTATION (måltid): fånga ANTAL deltagare, vilka (namn + företag) och syftet innan du bokför: antalet styr momsavdraget (tak per person på underlaget). Använd kvittots FAKTISKA momssats, gissa aldrig. Fråga "Hur många var ni, och vilka?" om det inte framgår. När du har uppgifterna: (1) anropa gnubok_remember_fact med content som beskriver deltagare + syfte; (2) stagea med notes="X deltagare: [namn + företag]. Syfte: [text]." så det landar i verifikationen. Saknas uppgifterna medges inget momsavdrag: säg det.')
lines.push(`- När du är säker, staga via gnubok_categorize_transaction med transaction_id=${tx.id} (ALDRIG document_id). Välj kategori från enum-listan i verktygets schema.`)
lines.push('- Förklara dina val kort på svenska: använd kategori-namn (t.ex. "Mjukvara/IT-tjänster", "Tele & internet"), ALDRIG ett BAS-kontonummer. Verktyget mappar kategori → konto, och godkännandekortet visar det faktiska BAS-kontot.')
lines.push('- Berätta INTE för användaren att du "stagear nu", att operationen är "stagead", att de ska "godkänna i appen", eller upprepa siffror som ändå visas i godkännandekortet (kategori, BAS-konto, momsbelopp). Kortet renderas direkt under ditt svar och säger allt det. Avsluta i stället med en mening eller två om VARFÖR du valde som du valde, och stanna där.')
lines.push('- Upprepa INTE underlag-uppmaningen efter stagning. Om du redan har bett användaren ladda upp via Dokumentinkorgen (i pre-stage-meddelandet) räcker det: påminn inte igen efter Godkänn-kortet. Och om underlag saknas och bokningen ändå stagas: säg att det ska bifogas till VERIFIKATIONEN (öppna den i Bokföring), inte till Dokumentinkorgen. Inkorgen är för dokument som inte ännu hör till en verifikation.')
lines.push('')
lines.push('Svara på svenska och var direkt: ditt första svar är det första användaren ser.')
return lines.join('\n')
},
})