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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user