New classification logic etc

This commit is contained in:
Jakob Wennberg
2026-02-24 11:33:22 +01:00
parent a3903f8428
commit 6cef4e11eb
41 changed files with 1193 additions and 134 deletions
@@ -5,7 +5,9 @@
* in server components or API routes.
*
* Provider-abstracted AI categorization for Swedish BAS account mapping.
* Default implementation uses Claude Haiku for cost efficiency.
* Uses Claude Haiku with structured tool outputs for reliable JSON.
* Accepts pre-filtered candidate templates from embedding search (Tier 2)
* instead of dumping all ~100 templates into the prompt.
*/
import 'server-only'
@@ -27,11 +29,29 @@ export interface TransactionForCategorization {
currency: string
}
export interface AccountUsageEntry {
account_number: string
count: number
}
export interface MerchantHistoryEntry {
merchant_name: string
category: string
template_id: string | null
count: number
}
export interface CategorizationContext {
entityType: EntityType
recentHistory: { description: string; category: string }[]
}
export interface EnrichedCategorizationContext extends CategorizationContext {
candidateTemplates: BookingTemplate[]
userAccountUsage: AccountUsageEntry[]
merchantHistory: MerchantHistoryEntry[]
}
export interface CategorizationSuggestion {
transactionId: string
category: TransactionCategory
@@ -46,7 +66,7 @@ export interface CategorizationSuggestion {
export interface CategorizationProvider {
categorize(
transactions: TransactionForCategorization[],
context: CategorizationContext
context: CategorizationContext | EnrichedCategorizationContext
): Promise<CategorizationSuggestion[]>
}
@@ -76,11 +96,18 @@ function getCategoryAccountMap(entityType: EntityType): Record<string, { account
}
/**
* Build an abbreviated template reference for the AI prompt.
* Filters by direction (expense/income) to keep prompt concise.
* Build template reference from candidate templates (pre-filtered by embeddings)
* or fall back to full template list if no candidates provided.
*/
function getTemplateReference(direction: 'expense' | 'income'): string {
return BOOKING_TEMPLATES
function getTemplateReference(
direction: 'expense' | 'income',
candidateTemplates?: BookingTemplate[]
): string {
const templates = candidateTemplates && candidateTemplates.length > 0
? candidateTemplates
: BOOKING_TEMPLATES
return templates
.filter((t) => t.direction === direction || t.direction === 'transfer')
.map((t) => `${t.id}: ${t.name_sv} → ${t.debit_account}/${t.credit_account}`)
.join('\n')
@@ -97,6 +124,38 @@ ICKE-AVDRAGSGILLA KOSTNADER (svensk skatterätt):
- Telefon/dator vid blandad användning: Bara yrkesmässig del avdragsgill
`
// ============================================================
// Classify Transaction Tool Schema
// ============================================================
const CLASSIFY_TOOL: Anthropic.Tool = {
name: 'classify_transactions',
description: 'Classify a batch of bank transactions into Swedish BAS accounts and booking templates.',
input_schema: {
type: 'object' as const,
properties: {
suggestions: {
type: 'array',
items: {
type: 'object',
properties: {
transactionId: { type: 'string', description: 'Transaction ID' },
templateId: { type: 'string', description: 'Booking template ID (from the provided templates list)' },
category: { type: 'string', description: 'Transaction category (e.g. expense_software, income_services, private)' },
basAccount: { type: 'string', description: 'BAS account number (4 digits)' },
taxCode: { type: ['string', 'null'], description: 'Tax code: MPI for deductible expenses with VAT, MP1 for income with VAT, null for VAT-exempt/private' },
confidence: { type: 'number', description: 'Confidence score 0.0-1.0' },
reasoning: { type: 'string', description: 'Short reasoning in Swedish' },
isPrivate: { type: 'boolean', description: 'Whether this is a private expense' },
},
required: ['transactionId', 'category', 'basAccount', 'confidence', 'reasoning', 'isPrivate'],
},
},
},
required: ['suggestions'],
},
}
// ============================================================
// Anthropic Provider
// ============================================================
@@ -116,23 +175,40 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
async categorize(
transactions: TransactionForCategorization[],
context: CategorizationContext
context: CategorizationContext | EnrichedCategorizationContext
): Promise<CategorizationSuggestion[]> {
// Cap batch size
const batch = transactions.slice(0, MAX_BATCH_SIZE)
if (batch.length === 0) return []
const enriched = isEnrichedContext(context) ? context : null
const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
const categoryAccountMap = getCategoryAccountMap(context.entityType)
// Build template references based on batch direction (most transactions will be same direction)
// Build template references — use candidate templates if available
const hasExpenses = batch.some((t) => t.amount < 0)
const hasIncome = batch.some((t) => t.amount > 0)
const candidates = enriched?.candidateTemplates
const templateRef = [
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense')}` : '',
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income')}` : '',
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense', candidates)}` : '',
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income', candidates)}` : '',
].filter(Boolean).join('\n\n')
// Build account usage context
const accountUsageContext = enriched?.userAccountUsage && enriched.userAccountUsage.length > 0
? `\nAnvändarens mest använda konton:\n${enriched.userAccountUsage
.slice(0, 15)
.map((a) => `- ${a.account_number} (${a.count} bokningar)`)
.join('\n')}`
: ''
// Build merchant history context
const merchantHistoryContext = enriched?.merchantHistory && enriched.merchantHistory.length > 0
? `\nTidigare kategorisering av dessa handlare:\n${enriched.merchantHistory
.map((m) => `- "${m.merchant_name}" → ${m.category}${m.template_id ? ` (mall: ${m.template_id})` : ''} (${m.count}x)`)
.join('\n')}`
: ''
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
Din uppgift är att kategorisera varje transaktion till rätt mall-ID (templateId) och BAS-konto.
@@ -181,29 +257,11 @@ REGLER:
)
.join('\n\n')
const userPrompt = `Kategorisera följande transaktioner:
${historyContext}
const userPrompt = `Kategorisera följande transaktioner med classify_transactions-verktyget:
${historyContext}${accountUsageContext}${merchantHistoryContext}
TRANSAKTIONER:
${transactionList}
Returnera ett JSON-objekt med följande struktur:
{
"suggestions": [
{
"transactionId": "id",
"category": "expense_software",
"templateId": "it_saas_subscription",
"basAccount": "5420",
"taxCode": "MPI",
"confidence": 0.9,
"reasoning": "Spotify-prenumeration, typisk programvarukostnad",
"isPrivate": false
}
]
}
Returnera ENDAST JSON-objektet, ingen annan text.`
${transactionList}`
let lastError: Error | null = null
@@ -212,7 +270,15 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
const message = await this.client.messages.create({
model: this.model,
max_tokens: 4096,
system: systemPrompt,
system: [
{
type: 'text',
text: systemPrompt,
cache_control: { type: 'ephemeral' },
},
],
tools: [CLASSIFY_TOOL],
tool_choice: { type: 'tool', name: 'classify_transactions' },
messages: [
{
role: 'user',
@@ -221,33 +287,20 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
],
})
const content = message.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type from AI')
// Extract tool_use block from response
const toolUseBlock = message.content.find(
(block) => block.type === 'tool_use' && block.name === 'classify_transactions'
)
if (!toolUseBlock || toolUseBlock.type !== 'tool_use') {
throw new Error('No tool_use block in AI response')
}
// Strip markdown code blocks if present
let jsonText = content.text.trim()
if (jsonText.startsWith('```json')) {
jsonText = jsonText.slice(7)
} else if (jsonText.startsWith('```')) {
jsonText = jsonText.slice(3)
}
if (jsonText.endsWith('```')) {
jsonText = jsonText.slice(0, -3)
}
jsonText = jsonText.trim()
const parsed = JSON.parse(jsonText)
return this.validateSuggestions(parsed.suggestions || [], batch, context.entityType)
const input = toolUseBlock.input as { suggestions?: unknown[] }
return this.validateSuggestions(input.suggestions || [], batch, context.entityType)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
// Don't retry on parse errors
if (error instanceof SyntaxError) {
throw new Error(`Failed to parse AI response: ${lastError.message}`)
}
if (attempt < MAX_RETRIES - 1) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
@@ -297,6 +350,12 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
}
}
function isEnrichedContext(
ctx: CategorizationContext | EnrichedCategorizationContext
): ctx is EnrichedCategorizationContext {
return 'candidateTemplates' in ctx
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+98 -5
View File
@@ -5,9 +5,13 @@ import {
AnthropicCategorizationProvider,
type CategorizationProvider,
type TransactionForCategorization,
type CategorizationContext,
type EnrichedCategorizationContext,
type CategorizationSuggestion,
type AccountUsageEntry,
type MerchantHistoryEntry,
} from './categorizer'
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
// ============================================================
// Settings
@@ -117,7 +121,7 @@ export async function categorizeTransactions(
currency: t.currency,
}))
const context = await buildContext(userId, supabase)
const context = await buildEnrichedContext(userId, supabase, batch)
const aiProvider = getProvider(settings.providerModel)
const suggestions = await aiProvider.categorize(batch, context)
@@ -168,7 +172,7 @@ async function handleTransactionSynced(
currency: t.currency,
}))
const context = await buildContext(userId, supabase)
const context = await buildEnrichedContext(userId, supabase, batch)
const aiProvider = getProvider(settings.providerModel)
const suggestions = await aiProvider.categorize(batch, context)
@@ -194,7 +198,11 @@ async function handleTransactionSynced(
// ============================================================
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function buildContext(userId: string, supabase: any): Promise<CategorizationContext> {
async function buildEnrichedContext(
userId: string,
supabase: any,
transactions: TransactionForCategorization[]
): Promise<EnrichedCategorizationContext> {
// Fetch entity type
const { data: companySettings } = await supabase
.from('company_settings')
@@ -221,7 +229,92 @@ async function buildContext(userId: string, supabase: any): Promise<Categorizati
})
)
return { entityType, recentHistory }
// Fetch user's most-used accounts (top 30)
const { data: accountUsageRows } = await supabase
.from('journal_entry_lines')
.select('account_number')
.eq('user_id', userId)
const accountCounts = new Map<string, number>()
if (accountUsageRows) {
for (const row of accountUsageRows as { account_number: string }[]) {
accountCounts.set(row.account_number, (accountCounts.get(row.account_number) || 0) + 1)
}
}
const userAccountUsage: AccountUsageEntry[] = Array.from(accountCounts.entries())
.map(([account_number, count]) => ({ account_number, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 30)
// Fetch merchant history for this batch's merchants
const merchantNames = [...new Set(
transactions
.map((t) => t.merchant_name)
.filter((n): n is string => n !== null && n.length > 0)
)]
let merchantHistory: MerchantHistoryEntry[] = []
if (merchantNames.length > 0) {
const { data: merchantRows } = await supabase
.from('transactions')
.select('merchant_name, category, template_id')
.eq('user_id', userId)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.in('merchant_name', merchantNames)
.limit(200)
if (merchantRows) {
const merchantMap = new Map<string, MerchantHistoryEntry>()
for (const row of merchantRows as { merchant_name: string; category: string; template_id: string | null }[]) {
const key = `${row.merchant_name}:${row.category}`
const existing = merchantMap.get(key)
if (existing) {
existing.count++
} else {
merchantMap.set(key, {
merchant_name: row.merchant_name,
category: row.category,
template_id: row.template_id,
count: 1,
})
}
}
merchantHistory = Array.from(merchantMap.values())
.sort((a, b) => b.count - a.count)
}
}
// Find candidate templates via embedding search
// Use a representative subset of transactions to find candidates
const representativeTransactions = transactions.slice(0, 5)
const candidateMap = new Map<string, BookingTemplate>()
for (const tx of representativeTransactions) {
try {
const matches = await findSimilarTemplates(
tx as unknown as Transaction,
entityType
)
for (const m of matches) {
if (!candidateMap.has(m.template.id)) {
candidateMap.set(m.template.id, m.template)
}
}
} catch {
// Embedding search failed — continue without candidates
}
}
const candidateTemplates = Array.from(candidateMap.values())
return {
entityType,
recentHistory,
candidateTemplates,
userAccountUsage,
merchantHistory,
}
}
async function storeSuggestions(