New classification logic etc
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user