Files
accounted/lib/documents/classifier.ts
T
Jakob Wennberg 39e407644d feat: unified document inbox, full BAS 2026, and document-transaction matching
- Expand BAS reference from ~180 to ~1,276 accounts (full BAS Kontoplan 2026)
  with K2 exclusion flags, per-class data files, and computed SRU codes
- Evolve invoice inbox into unified document inbox handling invoices, receipts,
  and government letters with AI-powered classification (Claude Haiku Vision)
- Add multi-pass document-to-transaction matching engine with greedy assignment
  for both supplier invoices (reference/amount/date/name) and receipts
  (weighted amount/merchant/date scoring)
- Add supplier invoice matching in transaction ingest pipeline
- Inject booking template suggestions into AI extraction prompts
- Surface matched documents in swipe categorization UI with one-tap booking
- Auto-activate missing BAS accounts during SIE import against full reference
- Add K2 filter toggle in Chart of Accounts manager
- Add receipt confirmation route with BFNAR representation fields
- Add database migrations for K2 support and document matching columns
- Remove obsolete extension migration scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:59:02 +01:00

165 lines
5.2 KiB
TypeScript

/**
* Document Classifier using Claude Haiku Vision API
*
* SERVER-ONLY: This module uses the Anthropic SDK and must only be imported
* in server components or API routes.
*
* Classifies documents as supplier invoices, receipts, government letters,
* or unknown. Also detects EU reverse charge for supplier invoices.
*/
import 'server-only'
import Anthropic from '@anthropic-ai/sdk'
import type { DocumentClassificationType } from '@/types'
const anthropic = new Anthropic()
const MAX_RETRIES = 3
const RETRY_DELAY_MS = 1000
type ImageMediaType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
export interface DocumentClassification {
type: DocumentClassificationType
confidence: number
reasoning: string
isReverseCharge?: boolean
}
/**
* Classify a document using Claude Haiku Vision.
* Determines if it's a supplier invoice, receipt, government letter, or unknown.
*/
export async function classifyDocument(
base64: string,
mimeType: string
): Promise<DocumentClassification> {
const systemPrompt = `Du är expert på att klassificera svenska affärsdokument.
Din uppgift är att avgöra vilken typ av dokument som visas.
DOKUMENTTYPER:
- supplier_invoice: Leverantörsfaktura (har fakturanummer, bankgiro/plusgiro, förfallodatum, leverantörsuppgifter)
- receipt: Kvitto (butiks-/restaurangkvitto, kort betalningsbevis med artikelrader)
- government_letter: Myndighetspost (från Skatteverket, Bolagsverket, Försäkringskassan, kommun, etc.)
- unknown: Annat dokument som inte passar ovan
FÖR LEVERANTÖRSFAKTUROR - kontrollera även:
- Är fakturan från en utländsk/EU-leverantör utan svensk moms?
- Nämner dokumentet "reverse charge", "omvänd skattskyldighet", eller "artikel 196"?
- Har leverantören ett VAT-nummer som INTE börjar med SE?
Om ja: flagga isReverseCharge = true`
const userPrompt = `Klassificera detta dokument. Returnera ENDAST ett JSON-objekt:
{
"type": "supplier_invoice" | "receipt" | "government_letter" | "unknown",
"confidence": 0.95,
"reasoning": "Kort förklaring",
"isReverseCharge": false
}
Returnera ENDAST JSON-objektet, ingen annan text.`
const isPdf = mimeType === 'application/pdf'
const isImage = mimeType.startsWith('image/')
if (!isPdf && !isImage) {
throw new Error(`Unsupported file type: ${mimeType}`)
}
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const contentBlocks: Anthropic.MessageCreateParams['messages'][0]['content'] = isPdf
? [
{
type: 'document' as const,
source: {
type: 'base64' as const,
media_type: 'application/pdf' as const,
data: base64,
},
},
{ type: 'text' as const, text: userPrompt },
]
: [
{
type: 'image' as const,
source: {
type: 'base64' as const,
media_type: mimeType as ImageMediaType,
data: base64,
},
},
{ type: 'text' as const, text: userPrompt },
]
const message = await anthropic.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1024,
messages: [{ role: 'user', content: contentBlocks }],
system: systemPrompt,
})
const content = message.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type from AI')
}
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 validateClassification(parsed)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
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))
}
}
}
throw new Error(`Document classification failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
}
const VALID_TYPES: DocumentClassificationType[] = [
'supplier_invoice',
'receipt',
'government_letter',
'unknown',
]
function validateClassification(raw: unknown): DocumentClassification {
if (!raw || typeof raw !== 'object') {
throw new Error('Invalid classification result: not an object')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = raw as any
const type = VALID_TYPES.includes(data.type) ? data.type : 'unknown'
const confidence = typeof data.confidence === 'number' ? data.confidence : 0.5
const reasoning = typeof data.reasoning === 'string' ? data.reasoning : ''
const isReverseCharge = type === 'supplier_invoice' ? Boolean(data.isReverseCharge) : undefined
return { type, confidence, reasoning, isReverseCharge }
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}