refactor: consolidate AI analyzers into shared lib/ai module and add journal entry reversal columns
Extract shared vision/document analysis logic into lib/ai (vision-client, document-analyzer, image preprocessing, validation helpers), simplifying invoice-analyzer, receipt-analyzer, and document classifier. Add migration 046 for journal entry reversal/correction link columns (reversed_by_id, reverses_id, correction_of_id) required by storno service. Update extension components and shared UI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6d647b956c
commit
3f255ea201
@@ -3,42 +3,29 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Mock Anthropic SDK - vi.hoisted ensures the variable is available before vi.mock hoisting
|
||||
const { mockCreate } = vi.hoisted(() => {
|
||||
const mockCreate = vi.fn()
|
||||
return { mockCreate }
|
||||
})
|
||||
// Mock the core document-analyzer module
|
||||
const { mockClassify } = vi.hoisted(() => ({
|
||||
mockClassify: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@anthropic-ai/sdk', () => {
|
||||
return {
|
||||
default: class MockAnthropic {
|
||||
messages = { create: mockCreate }
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/ai/document-analyzer', () => ({
|
||||
classifyDocument: mockClassify,
|
||||
}))
|
||||
|
||||
import { classifyDocument } from '../classifier'
|
||||
|
||||
function makeResponse(json: Record<string, unknown>) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(json) }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('classifyDocument', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('classifies a supplier invoice', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'supplier_invoice',
|
||||
confidence: 0.95,
|
||||
reasoning: 'Contains invoice number, bankgiro, and supplier details',
|
||||
isReverseCharge: false,
|
||||
})
|
||||
)
|
||||
mockClassify.mockResolvedValueOnce({
|
||||
type: 'supplier_invoice',
|
||||
confidence: 0.95,
|
||||
reasoning: 'Contains invoice number, bankgiro, and supplier details',
|
||||
isReverseCharge: false,
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
@@ -48,13 +35,11 @@ describe('classifyDocument', () => {
|
||||
})
|
||||
|
||||
it('classifies a receipt', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'receipt',
|
||||
confidence: 0.92,
|
||||
reasoning: 'Store receipt with line items and total',
|
||||
})
|
||||
)
|
||||
mockClassify.mockResolvedValueOnce({
|
||||
type: 'receipt',
|
||||
confidence: 0.92,
|
||||
reasoning: 'Store receipt with line items and total',
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
|
||||
@@ -64,13 +49,11 @@ describe('classifyDocument', () => {
|
||||
})
|
||||
|
||||
it('classifies a government letter', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'government_letter',
|
||||
confidence: 0.88,
|
||||
reasoning: 'Letter from Skatteverket',
|
||||
})
|
||||
)
|
||||
mockClassify.mockResolvedValueOnce({
|
||||
type: 'government_letter',
|
||||
confidence: 0.88,
|
||||
reasoning: 'Letter from Skatteverket',
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
@@ -80,13 +63,11 @@ describe('classifyDocument', () => {
|
||||
})
|
||||
|
||||
it('classifies unknown documents', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'unknown',
|
||||
confidence: 0.5,
|
||||
reasoning: 'Cannot determine document type',
|
||||
})
|
||||
)
|
||||
mockClassify.mockResolvedValueOnce({
|
||||
type: 'unknown',
|
||||
confidence: 0.5,
|
||||
reasoning: 'Cannot determine document type',
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/png')
|
||||
|
||||
@@ -95,14 +76,12 @@ describe('classifyDocument', () => {
|
||||
})
|
||||
|
||||
it('detects reverse charge on EU invoices', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'supplier_invoice',
|
||||
confidence: 0.93,
|
||||
reasoning: 'EU invoice with reverse charge',
|
||||
isReverseCharge: true,
|
||||
})
|
||||
)
|
||||
mockClassify.mockResolvedValueOnce({
|
||||
type: 'supplier_invoice',
|
||||
confidence: 0.93,
|
||||
reasoning: 'EU invoice with reverse charge',
|
||||
isReverseCharge: true,
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
@@ -110,129 +89,31 @@ describe('classifyDocument', () => {
|
||||
expect(result.isReverseCharge).toBe(true)
|
||||
})
|
||||
|
||||
it('retries on API error', async () => {
|
||||
mockCreate
|
||||
.mockRejectedValueOnce(new Error('API timeout'))
|
||||
.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'receipt',
|
||||
confidence: 0.9,
|
||||
reasoning: 'Receipt',
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.type).toBe('receipt')
|
||||
expect(mockCreate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('throws on JSON parse error without retry', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'not valid json' }],
|
||||
it('delegates to core classify', async () => {
|
||||
mockClassify.mockResolvedValueOnce({
|
||||
type: 'receipt',
|
||||
confidence: 0.9,
|
||||
reasoning: 'Receipt',
|
||||
})
|
||||
|
||||
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
|
||||
'Failed to parse AI response'
|
||||
)
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1)
|
||||
await classifyDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(mockClassify).toHaveBeenCalledWith('base64data', 'image/jpeg')
|
||||
})
|
||||
|
||||
it('throws on unsupported MIME type', async () => {
|
||||
it('propagates errors from core', async () => {
|
||||
mockClassify.mockRejectedValueOnce(new Error('Vision API call failed after 3 attempts'))
|
||||
|
||||
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
|
||||
'Vision API call failed after 3 attempts'
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates unsupported MIME type errors', async () => {
|
||||
mockClassify.mockRejectedValueOnce(new Error('Unsupported file type: text/plain'))
|
||||
|
||||
await expect(classifyDocument('base64data', 'text/plain')).rejects.toThrow(
|
||||
'Unsupported file type: text/plain'
|
||||
)
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to unknown for invalid type values', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'invalid_type',
|
||||
confidence: 0.8,
|
||||
reasoning: 'Test',
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.type).toBe('unknown')
|
||||
})
|
||||
|
||||
it('handles PDF content blocks correctly', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'supplier_invoice',
|
||||
confidence: 0.95,
|
||||
reasoning: 'PDF invoice',
|
||||
isReverseCharge: false,
|
||||
})
|
||||
)
|
||||
|
||||
await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'document' }),
|
||||
]),
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('handles image content blocks correctly', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'receipt',
|
||||
confidence: 0.9,
|
||||
reasoning: 'Image receipt',
|
||||
})
|
||||
)
|
||||
|
||||
await classifyDocument('base64data', 'image/png')
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'image' }),
|
||||
]),
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('strips markdown code blocks from response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '```json\n{"type":"receipt","confidence":0.9,"reasoning":"Test"}\n```',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
expect(result.type).toBe('receipt')
|
||||
})
|
||||
|
||||
it('throws after max retries', async () => {
|
||||
mockCreate
|
||||
.mockRejectedValueOnce(new Error('API error 1'))
|
||||
.mockRejectedValueOnce(new Error('API error 2'))
|
||||
.mockRejectedValueOnce(new Error('API error 3'))
|
||||
|
||||
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
|
||||
'Document classification failed after 3 attempts'
|
||||
)
|
||||
expect(mockCreate).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
+9
-146
@@ -1,30 +1,19 @@
|
||||
/**
|
||||
* Document Classifier using Claude Haiku Vision API
|
||||
* Document Classifier — thin wrapper around lib/ai/document-analyzer.
|
||||
*
|
||||
* SERVER-ONLY: This module uses the Anthropic SDK and must only be imported
|
||||
* in server components or API routes.
|
||||
* SERVER-ONLY: delegates to the shared vision client.
|
||||
*
|
||||
* Classifies documents as supplier invoices, receipts, government letters,
|
||||
* or unknown. Also detects EU reverse charge for supplier invoices.
|
||||
*
|
||||
* This module preserves the original public API — existing callers see no change.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type { DocumentClassificationType } from '@/types'
|
||||
import { classifyDocument as classifyCore } from '@/lib/ai/document-analyzer'
|
||||
|
||||
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
|
||||
}
|
||||
// Re-export the type so existing imports work
|
||||
export type { DocumentClassification } from '@/lib/ai/document-analyzer'
|
||||
|
||||
/**
|
||||
* Classify a document using Claude Haiku Vision.
|
||||
@@ -33,132 +22,6 @@ export interface DocumentClassification {
|
||||
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))
|
||||
) {
|
||||
return classifyCore(base64, mimeType)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user