Files
accounted/extensions/general/receipt-ocr/lib/receipt-analyzer.ts
T
Jakob WennbergandClaude Opus 4.6 3f255ea201 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>
2026-02-26 17:14:44 +01:00

185 lines
5.9 KiB
TypeScript

/**
* Receipt Analyzer — delegates to lib/ai/document-analyzer for extraction,
* then applies receipt-specific validation and enhancement.
*
* SERVER-ONLY: uses the shared vision client via document-analyzer.
*
* Preserved public API: analyzeReceipt() and estimateProductValue().
*/
import 'server-only'
import { ChatAnthropic } from '@langchain/anthropic'
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
import { z } from 'zod'
import type { ReceiptExtractionResult } from '@/types'
import { extractReceipt } from '@/lib/ai/document-analyzer'
import {
SYSTEMBOLAGET_PATTERNS,
RESTAURANT_PATTERNS,
} from './receipt-utils'
// Re-export client-safe functions for convenience
export {
calculateRepresentationLimits,
isRestaurantMCC,
detectSystembolaget,
detectRestaurant,
} from './receipt-utils'
// Retry config for estimateProductValue (still uses LangChain, not vision-client)
const MAX_RETRIES = 3
const RETRY_DELAY_MS = 1000
/**
* Analyze a receipt image using Claude Haiku Vision.
* Delegates extraction to the shared core, then applies receipt-specific enhancements.
*/
export async function analyzeReceipt(
imageBase64: string,
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' = 'image/jpeg'
): Promise<ReceiptExtractionResult> {
const raw = await extractReceipt(imageBase64, mimeType)
return validateAndEnhanceResult(raw)
}
/**
* Receipt-specific validation and enhancement.
* Applies Systembolaget/restaurant/foreign merchant detection
* on top of the core extraction result.
*/
function validateAndEnhanceResult(result: ReceiptExtractionResult): ReceiptExtractionResult {
// Detect special merchants using local patterns
const merchantName = (result.merchant.name || '').toLowerCase()
const isSystembolaget = result.flags.isSystembolaget || detectSystembolagetLocal(merchantName)
const isRestaurant = result.flags.isRestaurant || detectRestaurantLocal(merchantName)
const isForeign = result.flags.isForeignMerchant || detectForeignMerchant(result.merchant, result.receipt.currency)
return {
...result,
merchant: {
...result.merchant,
isForeign,
},
flags: {
isRestaurant,
isSystembolaget,
isForeignMerchant: isForeign,
},
}
}
/**
* Detect if merchant is Systembolaget (local version using imported patterns)
*/
function detectSystembolagetLocal(merchantName: string): boolean {
return SYSTEMBOLAGET_PATTERNS.some((pattern) => merchantName.includes(pattern))
}
/**
* Detect if merchant is a restaurant (local version using imported patterns)
*/
function detectRestaurantLocal(merchantName: string): boolean {
return RESTAURANT_PATTERNS.some((pattern) => merchantName.includes(pattern))
}
/**
* Detect if merchant is foreign (non-Swedish)
*/
function detectForeignMerchant(
merchant: ReceiptExtractionResult['merchant'],
currency?: string
): boolean {
if (currency && currency !== 'SEK') return true
const orgNumber = merchant.orgNumber || ''
if (orgNumber) {
const digits = orgNumber.replace(/\D/g, '')
if (digits.length !== 10) return true
}
const vatNumber = merchant.vatNumber || ''
if (vatNumber && !vatNumber.toUpperCase().startsWith('SE')) return true
return false
}
// ============================================================
// Product Value Estimation (LangChain — separate from receipt analysis)
// ============================================================
const ProductEstimationSchema = z.object({
estimatedValue: z.number().describe('Uppskattat marknadsvärde i SEK'),
confidence: z.number().min(0).max(1).describe('Hur säker du är på uppskattningen (0.0-1.0)'),
description: z.string().describe('Kort beskrivning av produkten'),
brand: z.string().nullable().describe('Varumärke om identifierbart, annars null'),
})
/**
* Estimate product value from image using Claude Vision via LangChain.
* Used for gift/product registration without receipt.
*/
export async function estimateProductValue(
imageBase64: string,
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' = 'image/jpeg'
): Promise<{
estimatedValue: number
confidence: number
description: string
brand: string | null
}> {
const model = new ChatAnthropic({
modelName: 'claude-haiku-4-5-20251001',
maxTokens: 1024,
temperature: 0,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
})
const structuredModel = model.withStructuredOutput(ProductEstimationSchema)
const systemPrompt = `Du är expert på att uppskatta marknadsvärdet på produkter baserat på bilder.
Din uppgift är att identifiera produkten och ge en rimlig uppskattning av dess marknadsvärde i svenska kronor.`
const userPrompt = `Analysera produkten i bilden och uppskatta dess marknadsvärde i SEK. Identifiera varumärke om möjligt.`
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const result = await structuredModel.invoke([
new SystemMessage(systemPrompt),
new HumanMessage({
content: [
{
type: 'image_url',
image_url: { url: `data:${mimeType};base64,${imageBase64}` },
},
{
type: 'text',
text: userPrompt,
},
],
}),
])
return {
estimatedValue: result.estimatedValue || 0,
confidence: result.confidence || 0.5,
description: result.description || 'Okänd produkt',
brand: result.brand,
}
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
if (attempt < MAX_RETRIES - 1) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
}
}
throw new Error(`Product value estimation failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}