feat: add inline supplier creation in invoice inbox and remove dead code
- Add inline new supplier form in InboxDetailDialog with pre-populated fields from AI extraction - Support new_supplier payload in confirm endpoint to create suppliers with user-editable fields (type, org number, bankgiro, etc.) - Improve line item amount calculation using cross-checked extraction totals - Remove unused estimateProductValue function and LangChain imports from receipt-analyzer - Add debug logging to invoice inbox confirm flow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3f255ea201
commit
9cd950de88
@@ -118,9 +118,17 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirm(itemId: string, supplierId?: string) {
|
||||
const body: Record<string, string> = {}
|
||||
if (supplierId) body.supplier_id = supplierId
|
||||
async function handleConfirm(
|
||||
itemId: string,
|
||||
supplierId?: string,
|
||||
newSupplierData?: import('@/components/extensions/general/invoice-inbox/InboxDetailDialog').NewSupplierData
|
||||
) {
|
||||
const body: Record<string, unknown> = {}
|
||||
if (supplierId) {
|
||||
body.supplier_id = supplierId
|
||||
} else if (newSupplierData) {
|
||||
body.new_supplier = newSupplierData
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/confirm`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { InvoiceInboxItem, Supplier } from '@/types'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { InvoiceInboxItem, Supplier, SupplierType } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -34,13 +36,24 @@ import {
|
||||
getStatusVariant,
|
||||
getConfidenceLabel,
|
||||
} from '@/lib/extensions/invoice-inbox-utils'
|
||||
import { Loader2, RefreshCw, Check, X } from 'lucide-react'
|
||||
import { Loader2, RefreshCw, Check, X, ChevronDown } from 'lucide-react'
|
||||
|
||||
export interface NewSupplierData {
|
||||
name: string
|
||||
supplier_type: SupplierType
|
||||
org_number: string
|
||||
vat_number: string
|
||||
bankgiro: string
|
||||
plusgiro: string
|
||||
default_expense_account: string
|
||||
default_currency: string
|
||||
}
|
||||
|
||||
interface InboxDetailDialogProps {
|
||||
item: InvoiceInboxItem | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (itemId: string, supplierId?: string) => Promise<void>
|
||||
onConfirm: (itemId: string, supplierId?: string, newSupplierData?: NewSupplierData) => Promise<void>
|
||||
onReject: (itemId: string) => Promise<void>
|
||||
onReprocess: (itemId: string) => Promise<void>
|
||||
suppliers: Supplier[]
|
||||
@@ -67,6 +80,34 @@ export default function InboxDetailDialog({
|
||||
}: InboxDetailDialogProps) {
|
||||
const [loading, setLoading] = useState<'confirm' | 'reject' | 'reprocess' | null>(null)
|
||||
const [selectedSupplierId, setSelectedSupplierId] = useState<string | undefined>(undefined)
|
||||
const [supplierFormOpen, setSupplierFormOpen] = useState(false)
|
||||
const [newSupplier, setNewSupplier] = useState<NewSupplierData>({
|
||||
name: '',
|
||||
supplier_type: 'swedish_business',
|
||||
org_number: '',
|
||||
vat_number: '',
|
||||
bankgiro: '',
|
||||
plusgiro: '',
|
||||
default_expense_account: '6200',
|
||||
default_currency: 'SEK',
|
||||
})
|
||||
|
||||
// Pre-populate supplier form when item changes
|
||||
const extractionForEffect = item?.extracted_data as unknown as InvoiceExtractionResult | null
|
||||
useEffect(() => {
|
||||
if (!extractionForEffect?.supplier) return
|
||||
const s = extractionForEffect.supplier
|
||||
setNewSupplier({
|
||||
name: s.name ?? '',
|
||||
supplier_type: 'swedish_business',
|
||||
org_number: s.orgNumber ?? '',
|
||||
vat_number: s.vatNumber ?? '',
|
||||
bankgiro: s.bankgiro ?? '',
|
||||
plusgiro: s.plusgiro ?? '',
|
||||
default_expense_account: '6200',
|
||||
default_currency: extractionForEffect.invoice?.currency || 'SEK',
|
||||
})
|
||||
}, [extractionForEffect])
|
||||
|
||||
if (!item) return null
|
||||
|
||||
@@ -78,16 +119,25 @@ export default function InboxDetailDialog({
|
||||
|
||||
const matchedSupplierName = (item.supplier as { name?: string } | undefined)?.name
|
||||
const supplierId = selectedSupplierId ?? item.matched_supplier_id ?? undefined
|
||||
const isCreatingNewSupplier = !supplierId
|
||||
|
||||
const canConfirm = item.status === 'ready' && extraction != null
|
||||
const canReprocess = item.status !== 'confirmed'
|
||||
const canReject = item.status !== 'confirmed' && item.status !== 'rejected'
|
||||
|
||||
function updateSupplierField<K extends keyof NewSupplierData>(field: K, value: NewSupplierData[K]) {
|
||||
setNewSupplier((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
async function handleAction(action: 'confirm' | 'reject' | 'reprocess') {
|
||||
setLoading(action)
|
||||
try {
|
||||
if (action === 'confirm') {
|
||||
await onConfirm(item!.id, supplierId)
|
||||
await onConfirm(
|
||||
item!.id,
|
||||
supplierId,
|
||||
isCreatingNewSupplier ? newSupplier : undefined
|
||||
)
|
||||
} else if (action === 'reject') {
|
||||
await onReject(item!.id)
|
||||
} else {
|
||||
@@ -185,6 +235,82 @@ export default function InboxDetailDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Inline new supplier form */}
|
||||
{isCreatingNewSupplier && (
|
||||
<div className="rounded-md border bg-muted/30 p-3 space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between text-sm font-medium"
|
||||
onClick={() => setSupplierFormOpen((o) => !o)}
|
||||
>
|
||||
<span>Granska leverantörsuppgifter</span>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${supplierFormOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{supplierFormOpen && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-xs">Namn</Label>
|
||||
<Input
|
||||
value={newSupplier.name}
|
||||
onChange={(e) => updateSupplierField('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Typ</Label>
|
||||
<Select
|
||||
value={newSupplier.supplier_type}
|
||||
onValueChange={(v) => updateSupplierField('supplier_type', v as SupplierType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="swedish_business">Svenskt företag</SelectItem>
|
||||
<SelectItem value="eu_business">EU-företag</SelectItem>
|
||||
<SelectItem value="non_eu_business">Utomeuropeiskt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Org.nr</Label>
|
||||
<Input
|
||||
value={newSupplier.org_number}
|
||||
onChange={(e) => updateSupplierField('org_number', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Bankgiro</Label>
|
||||
<Input
|
||||
value={newSupplier.bankgiro}
|
||||
onChange={(e) => updateSupplierField('bankgiro', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Plusgiro</Label>
|
||||
<Input
|
||||
value={newSupplier.plusgiro}
|
||||
onChange={(e) => updateSupplierField('plusgiro', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Kostnadskonto</Label>
|
||||
<Input
|
||||
value={newSupplier.default_expense_account}
|
||||
onChange={(e) => updateSupplierField('default_expense_account', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Valuta</Label>
|
||||
<Input
|
||||
value={newSupplier.default_currency}
|
||||
onChange={(e) => updateSupplierField('default_currency', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -604,34 +604,53 @@ async function handleConfirmInboxItem(
|
||||
const body = await request.json().catch(() => ({}))
|
||||
|
||||
try {
|
||||
console.log('[invoice-inbox] Confirm: starting for item', id, 'extraction keys:', Object.keys(extraction))
|
||||
|
||||
// Resolve supplier: use matched, use body override, or create new
|
||||
let supplierId = body.supplier_id || inboxItem.matched_supplier_id
|
||||
|
||||
if (!supplierId) {
|
||||
// Create new supplier from extracted data
|
||||
const supplierName = extraction.supplier?.name
|
||||
// Use frontend-provided supplier data if available, otherwise fall back to extraction
|
||||
const frontendSupplier = body.new_supplier as {
|
||||
name?: string
|
||||
supplier_type?: string
|
||||
org_number?: string
|
||||
vat_number?: string
|
||||
bankgiro?: string
|
||||
plusgiro?: string
|
||||
default_expense_account?: string
|
||||
default_currency?: string
|
||||
} | undefined
|
||||
|
||||
const supplierName = frontendSupplier?.name || extraction.supplier?.name
|
||||
if (!supplierName) {
|
||||
return NextResponse.json({ error: 'Supplier name is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validSupplierTypes = ['swedish_business', 'eu_business', 'non_eu_business']
|
||||
const supplierType = frontendSupplier?.supplier_type && validSupplierTypes.includes(frontendSupplier.supplier_type)
|
||||
? frontendSupplier.supplier_type
|
||||
: 'swedish_business'
|
||||
|
||||
const { data: newSupplier, error: supplierError } = await supabase
|
||||
.from('suppliers')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
name: supplierName,
|
||||
supplier_type: 'swedish_business',
|
||||
org_number: extraction.supplier?.orgNumber || null,
|
||||
vat_number: extraction.supplier?.vatNumber || null,
|
||||
bankgiro: extraction.supplier?.bankgiro || null,
|
||||
plusgiro: extraction.supplier?.plusgiro || null,
|
||||
default_expense_account: '6200',
|
||||
supplier_type: supplierType,
|
||||
org_number: frontendSupplier?.org_number || extraction.supplier?.orgNumber || null,
|
||||
vat_number: frontendSupplier?.vat_number || extraction.supplier?.vatNumber || null,
|
||||
bankgiro: frontendSupplier?.bankgiro || extraction.supplier?.bankgiro || null,
|
||||
plusgiro: frontendSupplier?.plusgiro || extraction.supplier?.plusgiro || null,
|
||||
default_expense_account: frontendSupplier?.default_expense_account || '6200',
|
||||
default_payment_terms: 30,
|
||||
default_currency: extraction.invoice?.currency || 'SEK',
|
||||
default_currency: frontendSupplier?.default_currency || extraction.invoice?.currency || 'SEK',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (supplierError || !newSupplier) {
|
||||
console.error('[invoice-inbox] Confirm: supplier creation failed:', supplierError)
|
||||
return NextResponse.json({ error: 'Failed to create supplier' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -647,6 +666,7 @@ async function handleConfirmInboxItem(
|
||||
.single()
|
||||
|
||||
if (supplierCheckError || !supplier) {
|
||||
console.error('[invoice-inbox] Confirm: supplier check failed:', supplierCheckError, 'supplierId=', supplierId)
|
||||
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
@@ -655,45 +675,99 @@ async function handleConfirmInboxItem(
|
||||
.rpc('get_next_arrival_number', { p_user_id: userId })
|
||||
|
||||
if (arrivalError) {
|
||||
console.error('[invoice-inbox] Confirm: arrival number error:', arrivalError)
|
||||
return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Build line items from extraction
|
||||
const items = (extraction.lineItems || []).map((item, index) => {
|
||||
const vatRate = item.vatRate != null ? item.vatRate / 100 : 0.25
|
||||
const lineTotal = Math.round(item.lineTotal * 100) / 100
|
||||
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
|
||||
return {
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity || 1,
|
||||
unit: 'st',
|
||||
unit_price: item.unitPrice != null ? item.unitPrice : lineTotal,
|
||||
line_total: lineTotal,
|
||||
account_number: item.accountSuggestion || supplier.default_expense_account || '6200',
|
||||
vat_code: null,
|
||||
vat_rate: vatRate,
|
||||
vat_amount: vatAmount,
|
||||
console.log('[invoice-inbox] Confirm: supplierId=', supplierId, 'arrivalNum=', arrivalNum)
|
||||
|
||||
// Build line items from extraction.
|
||||
// The AI extraction may return lineTotal as gross (incl. VAT) or net.
|
||||
// Cross-check with extraction.totals to determine the correct split.
|
||||
const extractedTotals = extraction.totals
|
||||
const rawLineItems = extraction.lineItems || []
|
||||
|
||||
const items: Array<{
|
||||
sort_order: number
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
line_total: number
|
||||
account_number: string
|
||||
vat_code: null
|
||||
vat_rate: number
|
||||
vat_amount: number
|
||||
}> = []
|
||||
|
||||
if (rawLineItems.length > 0 && extractedTotals?.subtotal != null && extractedTotals?.vatAmount != null) {
|
||||
// We have both line items and reliable totals — use totals for amounts
|
||||
// and distribute proportionally across line items
|
||||
const rawSum = rawLineItems.reduce((s, i) => s + (i.lineTotal || 0), 0)
|
||||
const knownSubtotal = extractedTotals.subtotal
|
||||
const knownVat = extractedTotals.vatAmount
|
||||
const knownVatRate = knownSubtotal > 0
|
||||
? Math.round((knownVat / knownSubtotal) * 100) / 100
|
||||
: 0.25
|
||||
|
||||
for (let index = 0; index < rawLineItems.length; index++) {
|
||||
const item = rawLineItems[index]
|
||||
// Distribute the known subtotal proportionally by each line's share
|
||||
const proportion = rawSum > 0 ? (item.lineTotal || 0) / rawSum : 1 / rawLineItems.length
|
||||
const lineNet = Math.round(knownSubtotal * proportion * 100) / 100
|
||||
const lineVat = Math.round(knownVat * proportion * 100) / 100
|
||||
items.push({
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity || 1,
|
||||
unit: 'st',
|
||||
unit_price: lineNet,
|
||||
line_total: lineNet,
|
||||
account_number: item.accountSuggestion || supplier.default_expense_account || '6200',
|
||||
vat_code: null,
|
||||
vat_rate: knownVatRate,
|
||||
vat_amount: lineVat,
|
||||
})
|
||||
}
|
||||
})
|
||||
} else if (rawLineItems.length > 0) {
|
||||
// Line items but no reliable totals — use per-line vatRate
|
||||
for (let index = 0; index < rawLineItems.length; index++) {
|
||||
const item = rawLineItems[index]
|
||||
const vatRate = item.vatRate != null ? item.vatRate / 100 : 0.25
|
||||
const lineTotal = Math.round(item.lineTotal * 100) / 100
|
||||
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
|
||||
items.push({
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity || 1,
|
||||
unit: 'st',
|
||||
unit_price: item.unitPrice != null ? item.unitPrice : lineTotal,
|
||||
line_total: lineTotal,
|
||||
account_number: item.accountSuggestion || supplier.default_expense_account || '6200',
|
||||
vat_code: null,
|
||||
vat_rate: vatRate,
|
||||
vat_amount: vatAmount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If no line items, create a single item from totals
|
||||
if (items.length === 0 && extraction.totals?.total) {
|
||||
const total = extraction.totals.total
|
||||
const vatAmount = extraction.totals.vatAmount || 0
|
||||
const subtotal = extraction.totals.subtotal || total - vatAmount
|
||||
const vatRate = subtotal > 0 ? Math.round((vatAmount / subtotal) * 100) / 100 : 0.25
|
||||
if (items.length === 0 && extractedTotals?.total) {
|
||||
const total = extractedTotals.total
|
||||
const vatAmt = extractedTotals.vatAmount || 0
|
||||
const sub = extractedTotals.subtotal || total - vatAmt
|
||||
const vatRate = sub > 0 ? Math.round((vatAmt / sub) * 100) / 100 : 0.25
|
||||
items.push({
|
||||
sort_order: 0,
|
||||
description: 'Fakturabelopp',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: subtotal,
|
||||
line_total: subtotal,
|
||||
unit_price: sub,
|
||||
line_total: sub,
|
||||
account_number: supplier.default_expense_account || '6200',
|
||||
vat_code: null,
|
||||
vat_rate: vatRate,
|
||||
vat_amount: Math.round(vatAmount * 100) / 100,
|
||||
vat_amount: Math.round(vatAmt * 100) / 100,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -708,6 +782,8 @@ async function handleConfirmInboxItem(
|
||||
else if (primaryVatRate === 0.06) vatTreatment = 'reduced_6'
|
||||
else if (primaryVatRate === 0) vatTreatment = 'exempt'
|
||||
|
||||
console.log('[invoice-inbox] Confirm: items=', items.length, 'subtotal=', subtotal, 'vat=', vatAmount, 'total=', total, 'vatTreatment=', vatTreatment)
|
||||
|
||||
// Insert supplier invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
@@ -733,9 +809,12 @@ async function handleConfirmInboxItem(
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
console.error('[invoice-inbox] Confirm: invoice insert error:', invoiceError)
|
||||
return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 })
|
||||
}
|
||||
|
||||
console.log('[invoice-inbox] Confirm: invoice created id=', invoice.id)
|
||||
|
||||
// Insert line items
|
||||
const itemInserts = items.map((item) => ({
|
||||
supplier_invoice_id: invoice.id,
|
||||
@@ -747,6 +826,7 @@ async function handleConfirmInboxItem(
|
||||
.insert(itemInserts)
|
||||
|
||||
if (itemsError) {
|
||||
console.error('[invoice-inbox] Confirm: items insert error:', itemsError, 'payload:', JSON.stringify(itemInserts))
|
||||
await supabase.from('supplier_invoices').delete().eq('id', invoice.id)
|
||||
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
||||
}
|
||||
@@ -784,7 +864,7 @@ async function handleConfirmInboxItem(
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[invoice-inbox] Confirm failed:', error)
|
||||
console.error('[invoice-inbox] Confirm failed:', error instanceof Error ? error.stack : error)
|
||||
return NextResponse.json({ error: 'Confirmation failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,9 @@
|
||||
* 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 {
|
||||
@@ -26,10 +21,6 @@ export {
|
||||
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.
|
||||
@@ -102,83 +93,3 @@ function detectForeignMerchant(
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user