feat(invoice-inbox): AI extraction (Bedrock Sonnet 4.6) + editable fields (#415)
* feat(invoice-inbox): AI extraction via Bedrock + editable fields Two big changes that go together: 1. Replace the regex extractor with Claude Sonnet 4.6 via AWS Bedrock. The PDF or image is sent directly to the model — no unpdf, no DOM stubs, no worker bundling. Sonnet handles English receipts (Anthropic, AWS, Stripe), USD/EUR currency symbols, scanned PDFs and image receipts, and Subtotal vs Total disambiguation that the regex layer couldn't. Output is JSON, validated with Zod; anything that doesn't parse falls back to an empty result so the inbox row still lands. 2. Make the extracted fields editable inline in the workspace. Each field is an Input bound to local draft state with debounced auto-save (800ms) to a new PATCH /items/:id/fields route. The route refuses edits once the item is converted to a supplier invoice. The parent updates both the selected-item view and the list rail when a field saves so the summary stays in sync. Drops `unpdf`, adds `@anthropic-ai/bedrock-sdk`. Requires three env vars in Vercel prod (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION) — not yet set; when missing, the extractor logs a warning and returns empty so the upload still works. All 43 invoice-inbox unit tests pass. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(invoice-inbox): loading state + #415 review fixes Loading state for AI extraction: - Optimistic placeholder row inserted into the workspace list the moment an upload starts. Shows the file name + a "Tolkar dokument med AI…" spinner instead of timestamp/amount. - Document preview pane shows a centered loader + same caption while the placeholder is selected. - Fields rail shows 6 skeleton inputs + the same caption. - Action buttons are hidden until the real row arrives. - Placeholder is removed on success or failure (toast on error). PR #415 review follow-ups (Greptile + Swedish compliance bot): - Bump max_tokens 1500 → 4096 so multi-line invoices don't silently truncate mid-JSON and land with all-null fields. - Tighten NullableDate to range-checked regex + Date.parse refine — rejects 2026-13-45 / 2026-02-30 with a clean 400 instead of a 500. - Make vatRate representation consistent: percent integer (25, 12, 6, 0) for both lineItems[].vatRate AND vatBreakdown[].rate. Previously the AI was instructed to emit decimals for one and integers for the other. - EditableFieldsList re-seeds drafts when the parent passes a new data snapshot, but only on fields where the local draft still matches the previous server value — so a server-normalised currency upper-cases cleanly without clobbering an in-progress edit. - 409 conflict (item already linked to a supplier invoice) shows the server's specific Swedish message ("Posten är låst") instead of the generic "Kunde inte spara". Out of scope (separate tickets if needed): live updates for email-arrived items, AWS_SESSION_TOKEN plumbing, 6 % livsmedel rate transition validation, server-side totals consistency check. All 43 invoice-inbox unit tests pass. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): tighten extraction schema per compliance bot review Three follow-ups from the Swedish compliance bot's second pass on #415: 1. lineItems[].vatRate and vatBreakdown[].rate now refine to 0–100, blocking AI hallucinations like 5000% or negative rates while still accepting non-Swedish rates (UK 20, DE 19) since gnubok stores foreign invoices for reference. The strict Swedish [0, 6, 12, 25] allowlist is not enforced here on purpose — that check belongs in the supplier- invoice-creation step where the data hits the ledger. 2. accountSuggestion is now coerced to null at parse time via .transform, eliminating the brief intermediate window where a hallucinated string could appear in the parsed object before the post-validation .map() nulled it. Removes the redundant .map() afterwards. 3. PATCH /items/:id/fields currency now requires ISO 4217 format (^[A-Z]{3}$). Previously accepted any 3–8 char string, which would flow into supplier-invoice creation and produce a faktura with an invalid currency. Test fixture vatRate updated 0.25 → 25 to match the percent-integer convention introduced in the previous commit. All 43 invoice-inbox unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
@@ -47,6 +48,10 @@ interface InboxItem {
|
||||
matched_supplier_id: string | null
|
||||
created_supplier_invoice_id: string | null
|
||||
error_message: string | null
|
||||
// Set client-side only while a manual upload is in flight. Replaced by a
|
||||
// real server-side row once the AI extraction completes.
|
||||
isPlaceholder?: boolean
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
interface InboxAddress {
|
||||
@@ -185,6 +190,28 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
// ── Upload ─────────────────────────────────────────────────
|
||||
|
||||
const uploadFile = useCallback(async (file: File) => {
|
||||
// Optimistic placeholder — gives the user an immediate visual response
|
||||
// for the 3–8s while Bedrock extracts. Removed once the real row arrives.
|
||||
const tempId = `temp-${crypto.randomUUID()}`
|
||||
const placeholder: InboxItem = {
|
||||
id: tempId,
|
||||
status: 'received',
|
||||
source: 'upload',
|
||||
created_at: new Date().toISOString(),
|
||||
email_from: null,
|
||||
email_subject: null,
|
||||
email_received_at: null,
|
||||
document_id: null,
|
||||
extracted_data: null,
|
||||
matched_supplier_id: null,
|
||||
created_supplier_invoice_id: null,
|
||||
error_message: null,
|
||||
isPlaceholder: true,
|
||||
fileName: file.name,
|
||||
}
|
||||
setItems((prev) => [placeholder, ...prev])
|
||||
setSelectedId(tempId)
|
||||
setSelected(placeholder)
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
@@ -196,11 +223,15 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json.error ?? 'Uppladdning misslyckades')
|
||||
toast({ title: 'Dokument uppladdat', description: file.name })
|
||||
setItems((prev) => prev.filter((it) => it.id !== tempId))
|
||||
await fetchItems()
|
||||
if (json.data?.inbox_item_id) {
|
||||
await handleSelect(json.data.inbox_item_id)
|
||||
}
|
||||
} catch (err) {
|
||||
setItems((prev) => prev.filter((it) => it.id !== tempId))
|
||||
setSelectedId((prev) => (prev === tempId ? null : prev))
|
||||
setSelected((prev) => (prev?.id === tempId ? null : prev))
|
||||
toast({
|
||||
title: 'Uppladdning misslyckades',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
@@ -397,7 +428,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
{/* Document preview (hero) */}
|
||||
<main className="overflow-hidden bg-muted/10 relative">
|
||||
{selected ? (
|
||||
<DocumentPreview docUrl={docUrl} docMime={docMime} />
|
||||
<DocumentPreview docUrl={docUrl} docMime={docMime} isProcessing={!!selected.isPlaceholder} />
|
||||
) : (
|
||||
<EmptyPreview
|
||||
onUploadClick={() => fileInputRef.current?.click()}
|
||||
@@ -420,6 +451,14 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
onDelete={() => handleDelete(selected.id)}
|
||||
onAttach={() => setAttachOpen(true)}
|
||||
isDeleting={isDeleting}
|
||||
onFieldsUpdated={(nextData) => {
|
||||
setSelected((prev) => (prev ? { ...prev, extracted_data: nextData } : prev))
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.id === selected.id ? { ...it, extracted_data: nextData } : it
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
@@ -619,26 +658,33 @@ function InboxRow({
|
||||
const supplierName = pickSupplierName(item)
|
||||
const isErrored = item.status === 'error'
|
||||
const isProcessed = !!item.created_supplier_invoice_id
|
||||
const isPlaceholder = !!item.isPlaceholder
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isPlaceholder}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 border-b transition-colors flex flex-col gap-0.5',
|
||||
selected ? 'bg-background border-l-2 border-l-primary' : 'hover:bg-background',
|
||||
isErrored && !selected && 'bg-destructive/[0.03]'
|
||||
isErrored && !selected && 'bg-destructive/[0.03]',
|
||||
isPlaceholder && 'cursor-default'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{item.source === 'email' ? (
|
||||
{isPlaceholder ? (
|
||||
<Loader2 className="h-3 w-3 text-muted-foreground shrink-0 animate-spin" />
|
||||
) : item.source === 'email' ? (
|
||||
<Mail className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Upload className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate flex-1 min-w-0">
|
||||
{supplierName ?? item.email_subject ?? 'Okänt dokument'}
|
||||
{isPlaceholder
|
||||
? (item.fileName ?? 'Nytt dokument')
|
||||
: (supplierName ?? item.email_subject ?? 'Okänt dokument')}
|
||||
</span>
|
||||
{isErrored && (
|
||||
<AlertTriangle className="h-3 w-3 text-destructive shrink-0" />
|
||||
@@ -648,8 +694,12 @@ function InboxRow({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="truncate">{timeAgo(item.email_received_at ?? item.created_at)}</span>
|
||||
{amount != null && (
|
||||
{isPlaceholder ? (
|
||||
<span className="italic">Tolkar dokument med AI…</span>
|
||||
) : (
|
||||
<span className="truncate">{timeAgo(item.email_received_at ?? item.created_at)}</span>
|
||||
)}
|
||||
{!isPlaceholder && amount != null && (
|
||||
<span className="tabular-nums shrink-0">
|
||||
{formatCurrency(amount, pickCurrency(item))}
|
||||
</span>
|
||||
@@ -665,10 +715,20 @@ function InboxRow({
|
||||
function DocumentPreview({
|
||||
docUrl,
|
||||
docMime,
|
||||
isProcessing = false,
|
||||
}: {
|
||||
docUrl: string | null
|
||||
docMime: string | null
|
||||
isProcessing?: boolean
|
||||
}) {
|
||||
if (isProcessing) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span>Tolkar dokument med AI…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!docUrl) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
@@ -750,11 +810,13 @@ function FieldsRail({
|
||||
onDelete,
|
||||
onAttach,
|
||||
isDeleting,
|
||||
onFieldsUpdated,
|
||||
}: {
|
||||
item: InboxItem
|
||||
onDelete: () => void
|
||||
onAttach: () => void
|
||||
isDeleting: boolean
|
||||
onFieldsUpdated: (data: InvoiceExtractionResult) => void
|
||||
}) {
|
||||
const data = item.extracted_data
|
||||
const isProcessed = !!item.created_supplier_invoice_id
|
||||
@@ -800,16 +862,28 @@ function FieldsRail({
|
||||
<h3 className="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-3">
|
||||
Extraherade fält
|
||||
</h3>
|
||||
{data ? (
|
||||
<ExtractedFieldsList data={data} />
|
||||
{item.isPlaceholder ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground italic flex items-center gap-2 mb-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Tolkar dokument med AI…
|
||||
</div>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Kunde inte läsa text — manuell registrering krävs.
|
||||
</p>
|
||||
<EditableFieldsList
|
||||
itemId={item.id}
|
||||
data={data ?? emptyExtraction()}
|
||||
disabled={isProcessed}
|
||||
onUpdated={onFieldsUpdated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{/* Actions — hidden while AI extraction is in flight */}
|
||||
{!item.isPlaceholder && (
|
||||
<div className="border-t px-4 py-3 space-y-2">
|
||||
{isProcessed && item.created_supplier_invoice_id ? (
|
||||
<Link href={`/supplier-invoices/${item.created_supplier_invoice_id}`} className="block">
|
||||
@@ -860,66 +934,282 @@ function FieldsRail({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Extracted fields list ────────────────────────────────────
|
||||
|
||||
function ExtractedFieldsList({ data }: { data: InvoiceExtractionResult }) {
|
||||
const fields: Array<{ label: string; value: string | null }> = [
|
||||
{ label: 'Leverantör', value: data.supplier?.name ?? null },
|
||||
{ label: 'Org.nr', value: data.supplier?.orgNumber ?? null },
|
||||
{ label: 'VAT-nr', value: data.supplier?.vatNumber ?? null },
|
||||
{ label: 'Bankgiro', value: data.supplier?.bankgiro ?? null },
|
||||
{ label: 'Plusgiro', value: data.supplier?.plusgiro ?? null },
|
||||
{ label: 'Fakturanr', value: data.invoice?.invoiceNumber ?? null },
|
||||
{ label: 'OCR/Referens', value: data.invoice?.paymentReference ?? null },
|
||||
{ label: 'Fakturadatum', value: data.invoice?.invoiceDate ?? null },
|
||||
{ label: 'Förfallodatum', value: data.invoice?.dueDate ?? null },
|
||||
{
|
||||
label: 'Totalt',
|
||||
value: data.totals?.total != null ? formatCurrency(data.totals.total, data.invoice?.currency ?? 'SEK') : null,
|
||||
function emptyExtraction(): InvoiceExtractionResult {
|
||||
return {
|
||||
supplier: { name: null, orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
||||
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
|
||||
lineItems: [],
|
||||
totals: { subtotal: null, vatAmount: null, total: null },
|
||||
vatBreakdown: [],
|
||||
confidence: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Inline edit + debounced auto-save. The field set mirrors the
|
||||
// UpdateExtractedDataSchema in extensions/general/invoice-inbox/index.ts.
|
||||
type FieldKey =
|
||||
| 'supplier.name'
|
||||
| 'supplier.orgNumber'
|
||||
| 'supplier.vatNumber'
|
||||
| 'supplier.bankgiro'
|
||||
| 'supplier.plusgiro'
|
||||
| 'invoice.invoiceNumber'
|
||||
| 'invoice.paymentReference'
|
||||
| 'invoice.invoiceDate'
|
||||
| 'invoice.dueDate'
|
||||
| 'invoice.currency'
|
||||
| 'totals.total'
|
||||
| 'totals.vatAmount'
|
||||
|
||||
interface FieldDef {
|
||||
key: FieldKey
|
||||
label: string
|
||||
type: 'text' | 'date' | 'number'
|
||||
inputMode?: 'numeric' | 'decimal'
|
||||
}
|
||||
|
||||
const FIELD_DEFS: FieldDef[] = [
|
||||
{ key: 'supplier.name', label: 'Leverantör', type: 'text' },
|
||||
{ key: 'supplier.orgNumber', label: 'Org.nr', type: 'text' },
|
||||
{ key: 'supplier.vatNumber', label: 'VAT-nr', type: 'text' },
|
||||
{ key: 'supplier.bankgiro', label: 'Bankgiro', type: 'text' },
|
||||
{ key: 'supplier.plusgiro', label: 'Plusgiro', type: 'text' },
|
||||
{ key: 'invoice.invoiceNumber', label: 'Fakturanr', type: 'text' },
|
||||
{ key: 'invoice.paymentReference', label: 'OCR/Referens', type: 'text' },
|
||||
{ key: 'invoice.invoiceDate', label: 'Fakturadatum', type: 'date' },
|
||||
{ key: 'invoice.dueDate', label: 'Förfallodatum', type: 'date' },
|
||||
{ key: 'invoice.currency', label: 'Valuta', type: 'text' },
|
||||
{ key: 'totals.total', label: 'Totalt', type: 'number', inputMode: 'decimal' },
|
||||
{ key: 'totals.vatAmount', label: 'Moms', type: 'number', inputMode: 'decimal' },
|
||||
]
|
||||
|
||||
function readField(data: InvoiceExtractionResult, key: FieldKey): string {
|
||||
const [group, name] = key.split('.') as [keyof InvoiceExtractionResult, string]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const value = (data[group] as any)?.[name]
|
||||
if (value == null) return ''
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function buildPatchBody(key: FieldKey, raw: string, currency: string) {
|
||||
const [group, name] = key.split('.')
|
||||
const trimmed = raw.trim()
|
||||
|
||||
if (group === 'totals') {
|
||||
const num = trimmed === '' ? null : Number(trimmed.replace(',', '.'))
|
||||
if (num != null && !Number.isFinite(num)) return null
|
||||
return { totals: { [name]: num } }
|
||||
}
|
||||
if (group === 'invoice' && (name === 'invoiceDate' || name === 'dueDate')) {
|
||||
const value = trimmed === '' ? null : trimmed
|
||||
return { invoice: { [name]: value } }
|
||||
}
|
||||
if (group === 'invoice' && name === 'currency') {
|
||||
return { invoice: { currency: trimmed === '' ? currency : trimmed.toUpperCase() } }
|
||||
}
|
||||
return { [group]: { [name]: trimmed === '' ? null : trimmed } }
|
||||
}
|
||||
|
||||
function EditableFieldsList({
|
||||
itemId,
|
||||
data,
|
||||
disabled,
|
||||
onUpdated,
|
||||
}: {
|
||||
itemId: string
|
||||
data: InvoiceExtractionResult
|
||||
disabled: boolean
|
||||
onUpdated: (data: InvoiceExtractionResult) => void
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const [drafts, setDrafts] = useState<Record<FieldKey, string>>(() =>
|
||||
Object.fromEntries(FIELD_DEFS.map((f) => [f.key, readField(data, f.key)])) as Record<FieldKey, string>
|
||||
)
|
||||
const timersRef = useRef<Partial<Record<FieldKey, ReturnType<typeof setTimeout>>>>({})
|
||||
// Last-known server values per field. Used to detect when the server
|
||||
// normalises a value (currency upper-cased, whitespace trimmed) so we can
|
||||
// pick up the canonical value into the input without clobbering an
|
||||
// in-progress edit.
|
||||
const lastServerRef = useRef<Record<FieldKey, string>>(
|
||||
Object.fromEntries(FIELD_DEFS.map((f) => [f.key, readField(data, f.key)])) as Record<FieldKey, string>
|
||||
)
|
||||
|
||||
// Reset drafts when the user switches to a different inbox item.
|
||||
useEffect(() => {
|
||||
const seeded = Object.fromEntries(
|
||||
FIELD_DEFS.map((f) => [f.key, readField(data, f.key)])
|
||||
) as Record<FieldKey, string>
|
||||
setDrafts(seeded)
|
||||
lastServerRef.current = seeded
|
||||
return () => {
|
||||
for (const t of Object.values(timersRef.current)) {
|
||||
if (t) clearTimeout(t)
|
||||
}
|
||||
timersRef.current = {}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemId])
|
||||
|
||||
// Re-seed drafts when the server returns normalised values (e.g. uppercased
|
||||
// currency, trimmed strings). Only update fields where the local draft
|
||||
// matches the previous server value — i.e. the user hasn't typed anything
|
||||
// newer that we'd otherwise clobber.
|
||||
useEffect(() => {
|
||||
let dirty = false
|
||||
const next: Record<FieldKey, string> = { ...lastServerRef.current }
|
||||
setDrafts((prev) => {
|
||||
const updated = { ...prev }
|
||||
for (const f of FIELD_DEFS) {
|
||||
const newServer = readField(data, f.key)
|
||||
const prevServer = lastServerRef.current[f.key]
|
||||
if (newServer !== prevServer) {
|
||||
next[f.key] = newServer
|
||||
// Only sync into the input if the user hadn't started a new edit.
|
||||
if (prev[f.key] === prevServer) {
|
||||
updated[f.key] = newServer
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return dirty ? updated : prev
|
||||
})
|
||||
lastServerRef.current = next
|
||||
}, [data])
|
||||
|
||||
const currency = data.invoice?.currency ?? 'SEK'
|
||||
|
||||
const persist = useCallback(
|
||||
async (key: FieldKey, raw: string) => {
|
||||
const body = buildPatchBody(key, raw, currency)
|
||||
if (!body) {
|
||||
toast({ variant: 'destructive', title: 'Ogiltigt värde' })
|
||||
setDrafts((prev) => ({ ...prev, [key]: readField(data, key) }))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/invoice-inbox/items/${itemId}/fields`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
// 409 means the item is already linked to a supplier invoice and
|
||||
// the server has rejected the edit. Surface the specific Swedish
|
||||
// message ("Posten är redan kopplad…") instead of the generic
|
||||
// fallback so the user understands why the field locked.
|
||||
const isConflict = res.status === 409
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: isConflict ? 'Posten är låst' : 'Kunde inte spara',
|
||||
description: json.error ?? 'Försök igen',
|
||||
})
|
||||
setDrafts((prev) => ({ ...prev, [key]: readField(data, key) }))
|
||||
return
|
||||
}
|
||||
if (json.data?.extracted_data) {
|
||||
onUpdated(json.data.extracted_data as InvoiceExtractionResult)
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Nätverksfel',
|
||||
description: err instanceof Error ? err.message : 'Kunde inte spara',
|
||||
})
|
||||
setDrafts((prev) => ({ ...prev, [key]: readField(data, key) }))
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Moms',
|
||||
value: data.totals?.vatAmount != null ? formatCurrency(data.totals.vatAmount, data.invoice?.currency ?? 'SEK') : null,
|
||||
[itemId, currency, data, onUpdated, toast]
|
||||
)
|
||||
|
||||
const onChange = useCallback(
|
||||
(key: FieldKey, raw: string) => {
|
||||
setDrafts((prev) => ({ ...prev, [key]: raw }))
|
||||
const existing = timersRef.current[key]
|
||||
if (existing) clearTimeout(existing)
|
||||
timersRef.current[key] = setTimeout(() => {
|
||||
timersRef.current[key] = undefined
|
||||
if (raw === readField(data, key)) return
|
||||
void persist(key, raw)
|
||||
}, 800)
|
||||
},
|
||||
]
|
||||
[data, persist]
|
||||
)
|
||||
|
||||
const onBlur = useCallback(
|
||||
(key: FieldKey) => {
|
||||
const pending = timersRef.current[key]
|
||||
if (pending) {
|
||||
clearTimeout(pending)
|
||||
timersRef.current[key] = undefined
|
||||
const raw = drafts[key]
|
||||
if (raw !== readField(data, key)) void persist(key, raw)
|
||||
}
|
||||
},
|
||||
[data, drafts, persist]
|
||||
)
|
||||
|
||||
const vatRows = useMemo(() => data.vatBreakdown ?? [], [data.vatBreakdown])
|
||||
|
||||
return (
|
||||
<dl className="space-y-2">
|
||||
{fields.map((f) => (
|
||||
<div key={f.label} className="flex flex-col gap-0.5">
|
||||
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground/80">{f.label}</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'text-sm break-all',
|
||||
f.value == null && 'text-muted-foreground/50 italic'
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{FIELD_DEFS.map((f) => (
|
||||
<div key={f.key} className="flex flex-col gap-0.5">
|
||||
<label
|
||||
htmlFor={`field-${f.key}`}
|
||||
className="text-[10px] uppercase tracking-wide text-muted-foreground/80"
|
||||
>
|
||||
{f.value ?? '—'}
|
||||
</dd>
|
||||
{f.label}
|
||||
</label>
|
||||
<Input
|
||||
id={`field-${f.key}`}
|
||||
type={f.type}
|
||||
inputMode={f.inputMode}
|
||||
value={drafts[f.key]}
|
||||
onChange={(e) => onChange(f.key, e.target.value)}
|
||||
onBlur={() => onBlur(f.key)}
|
||||
disabled={disabled}
|
||||
placeholder="—"
|
||||
className={cn(
|
||||
'h-8 text-sm border-transparent bg-transparent px-2 -mx-2 hover:border-border focus-visible:border-ring',
|
||||
drafts[f.key] === '' && 'text-muted-foreground/50 italic'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{data.vatBreakdown && data.vatBreakdown.length > 0 && (
|
||||
{vatRows.length > 0 && (
|
||||
<div className="pt-2 border-t mt-3">
|
||||
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground/80 mb-1.5">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground/80 mb-1.5">
|
||||
Momsfördelning
|
||||
</dt>
|
||||
<dd className="space-y-1">
|
||||
{data.vatBreakdown.map((row, i) => (
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{vatRows.map((row, i) => (
|
||||
<div key={i} className="text-xs flex justify-between">
|
||||
<span className="text-muted-foreground">{row.rate}%</span>
|
||||
<span className="tabular-nums">
|
||||
{formatCurrency(row.base, data.invoice?.currency ?? 'SEK')} +{' '}
|
||||
{formatCurrency(row.amount, data.invoice?.currency ?? 'SEK')}
|
||||
{formatCurrency(row.base, currency)} +{' '}
|
||||
{formatCurrency(row.amount, currency)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
{disabled && (
|
||||
<p className="text-[10px] text-muted-foreground/70 pt-2">
|
||||
Posten är kopplad till en leverantörsfaktura — fälten kan inte ändras.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,133 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
||||
|
||||
// Mock unpdf so we can drive the regex extractors with canned text
|
||||
// without building actual PDF binaries.
|
||||
const mockExtractText = vi.fn()
|
||||
// Mock the Bedrock SDK so tests drive the JSON parser without
|
||||
// network/credential needs.
|
||||
const mockCreate = vi.fn()
|
||||
|
||||
vi.mock('unpdf', () => ({
|
||||
extractText: (...args: unknown[]) => mockExtractText(...args),
|
||||
}))
|
||||
vi.mock('@anthropic-ai/bedrock-sdk', () => {
|
||||
class FakeBedrock {
|
||||
messages = { create: mockCreate }
|
||||
}
|
||||
return { default: FakeBedrock }
|
||||
})
|
||||
|
||||
function fakePdf(text: string) {
|
||||
return Promise.resolve({ totalPages: 1, text })
|
||||
const ORIG_AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID
|
||||
const ORIG_AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY
|
||||
|
||||
function aiResponse(json: string | object) {
|
||||
const text = typeof json === 'string' ? json : JSON.stringify(json)
|
||||
return Promise.resolve({
|
||||
content: [{ type: 'text', text }],
|
||||
})
|
||||
}
|
||||
|
||||
const VALID_RESULT = {
|
||||
supplier: {
|
||||
name: 'Anthropic, PBC',
|
||||
orgNumber: null,
|
||||
vatNumber: null,
|
||||
address: '548 Market Street, San Francisco, CA 94104',
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: '06655767-0007',
|
||||
invoiceDate: '2026-02-13',
|
||||
dueDate: null,
|
||||
paymentReference: null,
|
||||
currency: 'USD',
|
||||
},
|
||||
lineItems: [
|
||||
{
|
||||
description: 'One-time credit purchase',
|
||||
quantity: 1,
|
||||
unitPrice: 5,
|
||||
lineTotal: 5,
|
||||
vatRate: 25,
|
||||
accountSuggestion: null,
|
||||
},
|
||||
],
|
||||
totals: { subtotal: 5, vatAmount: 1.25, total: 6.25 },
|
||||
vatBreakdown: [{ rate: 25, base: 5, amount: 1.25 }],
|
||||
}
|
||||
|
||||
describe('extractInvoiceFields', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.AWS_ACCESS_KEY_ID = 'test-key'
|
||||
process.env.AWS_SECRET_ACCESS_KEY = 'test-secret'
|
||||
})
|
||||
|
||||
it('returns empty result for non-PDF mime type', async () => {
|
||||
it('returns empty result for unsupported mime type (HEIC)', async () => {
|
||||
const { data, rawText } = await extractInvoiceFields({
|
||||
buffer: Buffer.from(''),
|
||||
mimeType: 'image/png',
|
||||
fileName: 'foo.png',
|
||||
mimeType: 'image/heic',
|
||||
fileName: 'photo.heic',
|
||||
})
|
||||
expect(rawText).toBeNull()
|
||||
expect(data.totals.total).toBeNull()
|
||||
expect(data.supplier.orgNumber).toBeNull()
|
||||
expect(data.supplier.name).toBeNull()
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty result when unpdf extracts no text (image-only PDF)', async () => {
|
||||
mockExtractText.mockReturnValueOnce(fakePdf(''))
|
||||
it('returns empty result and skips API when AWS creds are missing', async () => {
|
||||
delete process.env.AWS_ACCESS_KEY_ID
|
||||
delete process.env.AWS_SECRET_ACCESS_KEY
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'f.pdf',
|
||||
})
|
||||
expect(data.totals.total).toBeNull()
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('parses a valid AI response into InvoiceExtractionResult', async () => {
|
||||
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
|
||||
const { data, rawText } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'scan.pdf',
|
||||
fileName: 'anthropic-receipt.pdf',
|
||||
})
|
||||
expect(rawText).toBe('')
|
||||
expect(rawText).toContain('Anthropic')
|
||||
expect(data.supplier.name).toBe('Anthropic, PBC')
|
||||
expect(data.invoice.currency).toBe('USD')
|
||||
expect(data.invoice.invoiceNumber).toBe('06655767-0007')
|
||||
expect(data.totals.total).toBe(6.25)
|
||||
expect(data.vatBreakdown).toHaveLength(1)
|
||||
expect(data.lineItems).toHaveLength(1)
|
||||
expect(data.confidence).toBe(1)
|
||||
})
|
||||
|
||||
it('sends image content for an image upload', async () => {
|
||||
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
|
||||
await extractInvoiceFields({
|
||||
buffer: Buffer.from('JPEG'),
|
||||
mimeType: 'image/jpeg',
|
||||
fileName: 'photo.jpg',
|
||||
})
|
||||
const call = mockCreate.mock.calls[0][0]
|
||||
const content = call.messages[0].content
|
||||
expect(content[0].type).toBe('image')
|
||||
expect(content[0].source.media_type).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
it('sends document content for a PDF upload', async () => {
|
||||
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
|
||||
await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'invoice.pdf',
|
||||
})
|
||||
const call = mockCreate.mock.calls[0][0]
|
||||
const content = call.messages[0].content
|
||||
expect(content[0].type).toBe('document')
|
||||
expect(content[0].source.media_type).toBe('application/pdf')
|
||||
})
|
||||
|
||||
it('returns empty result when AI response is not valid JSON', async () => {
|
||||
mockCreate.mockReturnValueOnce(aiResponse('Sorry, I cannot read this PDF.'))
|
||||
const { data, rawText } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'f.pdf',
|
||||
})
|
||||
expect(rawText).toBe('Sorry, I cannot read this PDF.')
|
||||
expect(data.totals.total).toBeNull()
|
||||
expect(data.supplier.name).toBeNull()
|
||||
})
|
||||
|
||||
it('extracts a Luhn-valid org number', async () => {
|
||||
// 5560125790 is a valid Swedish AB org-nr (Luhn-checked)
|
||||
mockExtractText.mockReturnValueOnce(fakePdf('Lev: Acme AB Org.nr 556012-5790 Faktura'))
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'f.pdf',
|
||||
})
|
||||
expect(data.supplier.orgNumber).toBe('5560125790')
|
||||
})
|
||||
|
||||
it('rejects org-nrs with bad Luhn digit', async () => {
|
||||
mockExtractText.mockReturnValueOnce(fakePdf('Org.nr 556012-5791'))
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'f.pdf',
|
||||
})
|
||||
expect(data.supplier.orgNumber).toBeNull()
|
||||
})
|
||||
|
||||
it('extracts a Luhn-valid OCR reference', async () => {
|
||||
mockExtractText.mockReturnValueOnce(fakePdf('OCR-nummer: 12345674'))
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'f.pdf',
|
||||
})
|
||||
expect(data.invoice.paymentReference).toBe('12345674')
|
||||
})
|
||||
|
||||
it('extracts a Luhn-valid bankgiro', async () => {
|
||||
// 991-2346 is the canonical test bankgiro (Luhn-valid) used in lib/bankgiro/__tests__
|
||||
mockExtractText.mockReturnValueOnce(fakePdf('Bankgiro 991-2346 Plusgiro'))
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'f.pdf',
|
||||
})
|
||||
expect(data.supplier.bankgiro).toBe('991-2346')
|
||||
})
|
||||
|
||||
it('parses Swedish-formatted totals', async () => {
|
||||
mockExtractText.mockReturnValueOnce(
|
||||
fakePdf('Att betala 12 345,67 kr')
|
||||
it('returns empty result when AI response fails schema validation', async () => {
|
||||
mockCreate.mockReturnValueOnce(
|
||||
aiResponse({ supplier: { name: 'X' } /* missing required keys */ })
|
||||
)
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'f.pdf',
|
||||
})
|
||||
expect(data.totals.total).toBe(12345.67)
|
||||
expect(data.totals.total).toBeNull()
|
||||
expect(data.supplier.name).toBeNull()
|
||||
})
|
||||
|
||||
it('parses Förfallodatum and normalizes to ISO', async () => {
|
||||
mockExtractText.mockReturnValueOnce(fakePdf('Förfallodatum 2026-06-15'))
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'f.pdf',
|
||||
})
|
||||
expect(data.invoice.dueDate).toBe('2026-06-15')
|
||||
})
|
||||
|
||||
it('extracts an invoice number after Fakturanr', async () => {
|
||||
mockExtractText.mockReturnValueOnce(fakePdf('Fakturanr F-2024-001 Datum'))
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'f.pdf',
|
||||
})
|
||||
expect(data.invoice.invoiceNumber).toBe('F-2024-001')
|
||||
})
|
||||
|
||||
it('keeps SEK as default currency when no foreign code is present', async () => {
|
||||
mockExtractText.mockReturnValueOnce(fakePdf('Total 100 kr'))
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'f.pdf',
|
||||
})
|
||||
expect(data.invoice.currency).toBe('SEK')
|
||||
})
|
||||
|
||||
it('returns empty result when unpdf throws', async () => {
|
||||
mockExtractText.mockImplementationOnce(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
it('returns empty result when Bedrock throws', async () => {
|
||||
mockCreate.mockRejectedValueOnce(new Error('throttled'))
|
||||
const { data, rawText } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
@@ -136,4 +161,35 @@ describe('extractInvoiceFields', () => {
|
||||
expect(rawText).toBeNull()
|
||||
expect(data.totals.total).toBeNull()
|
||||
})
|
||||
|
||||
it('forces accountSuggestion to null even if the model returns a value', async () => {
|
||||
mockCreate.mockReturnValueOnce(
|
||||
aiResponse({
|
||||
...VALID_RESULT,
|
||||
lineItems: [
|
||||
{
|
||||
...VALID_RESULT.lineItems[0],
|
||||
accountSuggestion: '5410', // model attempting BAS suggestion
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
const { data } = await extractInvoiceFields({
|
||||
buffer: Buffer.from('%PDF'),
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'f.pdf',
|
||||
})
|
||||
expect(data.lineItems[0].accountSuggestion).toBeNull()
|
||||
})
|
||||
|
||||
// Restore env vars so other test files aren't affected.
|
||||
afterAll(() => {
|
||||
if (ORIG_AWS_ACCESS_KEY_ID) process.env.AWS_ACCESS_KEY_ID = ORIG_AWS_ACCESS_KEY_ID
|
||||
else delete process.env.AWS_ACCESS_KEY_ID
|
||||
if (ORIG_AWS_SECRET_ACCESS_KEY) process.env.AWS_SECRET_ACCESS_KEY = ORIG_AWS_SECRET_ACCESS_KEY
|
||||
else delete process.env.AWS_SECRET_ACCESS_KEY
|
||||
})
|
||||
})
|
||||
|
||||
// vitest doesn't auto-import afterAll
|
||||
import { afterAll } from 'vitest'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { z } from 'zod'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { extractInvoiceFields } from './lib/extract-invoice-fields'
|
||||
import {
|
||||
@@ -23,6 +24,57 @@ import type { InvoiceExtractionResult, InvoiceInboxItem, SupplierInvoice, Suppli
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
// Partial-update schema for the /items/:id/fields PATCH route. Only the
|
||||
// scalar fields the UI exposes for inline editing — line items and
|
||||
// vatBreakdown stay AI-managed for now and are preserved by the merge.
|
||||
const NullableString = z.string().trim().max(500).nullable()
|
||||
const NullableDate = z
|
||||
.string()
|
||||
.regex(
|
||||
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
|
||||
'Invalid date — expected YYYY-MM-DD'
|
||||
)
|
||||
// Catch impossible calendar dates like 2026-02-30 that pass the regex.
|
||||
.refine((v) => !Number.isNaN(Date.parse(v)), 'Invalid calendar date')
|
||||
.nullable()
|
||||
const NullableNumber = z.number().nullable()
|
||||
|
||||
const UpdateExtractedDataSchema = z.object({
|
||||
supplier: z
|
||||
.object({
|
||||
name: NullableString,
|
||||
orgNumber: NullableString,
|
||||
vatNumber: NullableString,
|
||||
address: NullableString,
|
||||
bankgiro: NullableString,
|
||||
plusgiro: NullableString,
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
invoice: z
|
||||
.object({
|
||||
invoiceNumber: NullableString,
|
||||
invoiceDate: NullableDate,
|
||||
dueDate: NullableDate,
|
||||
paymentReference: NullableString,
|
||||
// ISO 4217 — three uppercase letters. We accept the user's edit only
|
||||
// if it looks like a real currency code; loose strings would otherwise
|
||||
// flow into the supplier-invoice-creation step and produce a faktura
|
||||
// with an invalid currency (cf. ML 17 kap 24§ p.9).
|
||||
currency: z.string().regex(/^[A-Z]{3}$/, 'Currency must be a 3-letter ISO 4217 code'),
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
totals: z
|
||||
.object({
|
||||
subtotal: NullableNumber,
|
||||
vatAmount: NullableNumber,
|
||||
total: NullableNumber,
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const UPLOAD_ALLOWED_MIME_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
@@ -326,6 +378,71 @@ export const invoiceInboxExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// ── Update extracted_data fields (manual user edits) ────
|
||||
{
|
||||
method: 'PATCH',
|
||||
path: '/items/:id/fields',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const id = url.searchParams.get('_id')
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
|
||||
let body: z.infer<typeof UpdateExtractedDataSchema>
|
||||
try {
|
||||
const json = await request.json()
|
||||
body = UpdateExtractedDataSchema.parse(json)
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid request body' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: item } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, extracted_data, created_supplier_invoice_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
if (item.created_supplier_invoice_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Posten är redan kopplad till en leverantörsfaktura och kan inte ändras.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Merge user edits into existing extracted_data so we don't lose
|
||||
// line items, vatBreakdown, or AI-confidence on partial updates.
|
||||
const current = (item.extracted_data ?? {}) as InvoiceExtractionResult
|
||||
const merged: InvoiceExtractionResult = {
|
||||
supplier: { ...current.supplier, ...body.supplier },
|
||||
invoice: { ...current.invoice, ...body.invoice },
|
||||
totals: { ...current.totals, ...body.totals },
|
||||
lineItems: current.lineItems ?? [],
|
||||
vatBreakdown: current.vatBreakdown ?? [],
|
||||
confidence: current.confidence ?? 0,
|
||||
}
|
||||
|
||||
const { data: updated, error: updateError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ extracted_data: merged as unknown as Record<string, unknown> })
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.select('id, extracted_data')
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: updated })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Attach a source document to an existing inbox item ──
|
||||
{
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
// Deterministic Swedish invoice field extraction.
|
||||
// AI-driven invoice/receipt field extraction.
|
||||
//
|
||||
// Replaces the deleted AI classifier. We pull text out of the PDF with
|
||||
// unpdf (a serverless-friendly pdfjs wrapper) and run regex extractors
|
||||
// against it. Each extractor is independent — a missing field stays null
|
||||
// rather than dragging down a neighbour. Validators (Luhn for
|
||||
// org-nr/OCR/bankgiro) keep false positives near zero.
|
||||
// Sends the uploaded document directly to Claude Sonnet 4.6 via AWS
|
||||
// Bedrock and asks for a structured InvoiceExtractionResult JSON. Sonnet
|
||||
// reads PDFs, images, and scans natively, which the previous regex
|
||||
// extractor couldn't — that's why English receipts (Anthropic, AWS,
|
||||
// Stripe, …) and image-only PDFs came back empty.
|
||||
//
|
||||
// Image-only PDFs and non-PDF mime types come back with all fields null.
|
||||
// The inbox item is still created so the user can register manually.
|
||||
// The AI output is validated against a Zod schema; anything that doesn't
|
||||
// parse falls back to an empty result so the inbox row still lands and
|
||||
// the user can fill the fields in manually.
|
||||
|
||||
import AnthropicBedrock from '@anthropic-ai/bedrock-sdk'
|
||||
import { z } from 'zod'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
||||
import { validateOcrReference, validateBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
import { extractText } from 'unpdf'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
// Below this we treat the document as image-only / unreadable and skip
|
||||
// regex extraction. The PDF text extractor returns near-zero text for
|
||||
// scanned PDFs.
|
||||
const MIN_TEXT_CHARS_FOR_EXTRACTION = 10
|
||||
const log = createLogger('invoice-inbox-extract')
|
||||
|
||||
const MODEL = 'eu.anthropic.claude-sonnet-4-6'
|
||||
// 4096 covers a 10-15 line invoice plus VAT breakdown comfortably. The JSON
|
||||
// skeleton alone is ~200 tokens; 1500 left only ~1300 for content and
|
||||
// silently truncated complex documents (response cut mid-JSON →
|
||||
// JSON.parse throws → row lands with all-null fields).
|
||||
const MAX_TOKENS = 4096
|
||||
|
||||
// Bedrock supports these document/image media types directly. HEIC/HEIF
|
||||
// are not on the list, so we skip AI for those — the inbox row still
|
||||
// lands and the user can edit fields manually or replace the file.
|
||||
const SUPPORTED_MEDIA_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
])
|
||||
|
||||
export interface ExtractionInput {
|
||||
buffer: Buffer
|
||||
@@ -27,47 +43,115 @@ export interface ExtractionInput {
|
||||
|
||||
export interface ExtractionOutput {
|
||||
data: InvoiceExtractionResult
|
||||
/** Pulled from the PDF; null when the file isn't a text-based PDF. */
|
||||
/** The raw JSON string returned by the model, or null on failure. */
|
||||
rawText: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract invoice fields from a PDF buffer. Returns an InvoiceExtractionResult
|
||||
* whether or not anything matched — empty fields are null, lineItems is [],
|
||||
* and totals are null. Never throws on parse failure (returns empty result).
|
||||
*/
|
||||
export async function extractInvoiceFields(input: ExtractionInput): Promise<ExtractionOutput> {
|
||||
const text = await tryExtractPdfText(input)
|
||||
const ExtractionSchema = z.object({
|
||||
supplier: z.object({
|
||||
name: z.string().nullable(),
|
||||
orgNumber: z.string().nullable(),
|
||||
vatNumber: z.string().nullable(),
|
||||
address: z.string().nullable(),
|
||||
bankgiro: z.string().nullable(),
|
||||
plusgiro: z.string().nullable(),
|
||||
}),
|
||||
invoice: z.object({
|
||||
invoiceNumber: z.string().nullable(),
|
||||
invoiceDate: z.string().nullable(),
|
||||
dueDate: z.string().nullable(),
|
||||
paymentReference: z.string().nullable(),
|
||||
currency: z.string(),
|
||||
}),
|
||||
lineItems: z.array(
|
||||
z.object({
|
||||
description: z.string(),
|
||||
quantity: z.number(),
|
||||
unitPrice: z.number().nullable(),
|
||||
lineTotal: z.number(),
|
||||
// Sane range for any real-world VAT rate. We allow non-Swedish rates
|
||||
// (UK 20, DE 19, NO 25, ...) since gnubok stores foreign invoices
|
||||
// for reference; the strict Swedish allowlist applies later when the
|
||||
// user converts to a supplier invoice.
|
||||
vatRate: z.number().min(0).max(100).nullable(),
|
||||
// accountSuggestion is forcibly null at parse time — we never
|
||||
// delegate BAS account assignment to an unvalidated AI output.
|
||||
// .transform coerces a hallucinated string to null without
|
||||
// failing the whole document parse, and eliminates the
|
||||
// post-validation null-forcing pattern that left a brief window
|
||||
// where a non-null value could appear in the parsed object.
|
||||
accountSuggestion: z.union([z.string(), z.null()]).transform(() => null as null),
|
||||
})
|
||||
),
|
||||
totals: z.object({
|
||||
subtotal: z.number().nullable(),
|
||||
vatAmount: z.number().nullable(),
|
||||
total: z.number().nullable(),
|
||||
}),
|
||||
vatBreakdown: z.array(
|
||||
z.object({
|
||||
rate: z.number().min(0).max(100),
|
||||
base: z.number(),
|
||||
amount: z.number(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
if (!text || text.length < MIN_TEXT_CHARS_FOR_EXTRACTION) {
|
||||
return { data: emptyResult(), rawText: text }
|
||||
}
|
||||
const SYSTEM_PROMPT = `You extract invoice and receipt fields from a single document for a Swedish accounting system.
|
||||
|
||||
const data: InvoiceExtractionResult = {
|
||||
supplier: {
|
||||
name: extractSupplierName(text),
|
||||
orgNumber: extractOrgNumber(text),
|
||||
vatNumber: extractVatNumber(text),
|
||||
address: null,
|
||||
bankgiro: extractBankgiro(text),
|
||||
plusgiro: extractPlusgiro(text),
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: extractInvoiceNumber(text),
|
||||
invoiceDate: extractDate(text, /faktura(?:datum|date)|utfärdat/i),
|
||||
dueDate: extractDate(text, /förfallo(?:datum|dag)|due\s*date|betala\s*senast/i),
|
||||
paymentReference: extractOcrReference(text),
|
||||
currency: extractCurrency(text),
|
||||
},
|
||||
lineItems: [],
|
||||
totals: extractTotals(text),
|
||||
vatBreakdown: extractVatBreakdown(text),
|
||||
confidence: 0,
|
||||
}
|
||||
Return ONLY a single JSON object that matches this schema exactly. No prose, no markdown fences, no commentary.
|
||||
|
||||
return { data, rawText: text }
|
||||
{
|
||||
"supplier": {
|
||||
"name": string | null,
|
||||
"orgNumber": string | null, // 10 digits, no hyphen, only when issued by a Swedish entity
|
||||
"vatNumber": string | null, // ISO format, e.g. "SE556012579001" or "DE123456789"
|
||||
"address": string | null, // multi-line allowed
|
||||
"bankgiro": string | null, // Swedish bankgiro, with hyphen, e.g. "991-2346"
|
||||
"plusgiro": string | null // Swedish plusgiro, with hyphen, e.g. "12345-6"
|
||||
},
|
||||
"invoice": {
|
||||
"invoiceNumber": string | null, // include any suffix, e.g. "06655767-0007"
|
||||
"invoiceDate": string | null, // ISO date YYYY-MM-DD
|
||||
"dueDate": string | null, // ISO date YYYY-MM-DD
|
||||
"paymentReference": string | null, // OCR / payment reference
|
||||
"currency": string // ISO 4217 (SEK, USD, EUR, ...). Default "SEK" only if truly indeterminate.
|
||||
},
|
||||
"lineItems": [
|
||||
{
|
||||
"description": string,
|
||||
"quantity": number,
|
||||
"unitPrice": number | null,
|
||||
"lineTotal": number,
|
||||
"vatRate": number | null, // percent integer: 25, 12, 6, or 0. Same convention as vatBreakdown.rate.
|
||||
"accountSuggestion": null // always null — leave Swedish BAS suggestion to the user
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"subtotal": number | null, // amount excluding VAT
|
||||
"vatAmount": number | null, // total VAT
|
||||
"total": number | null // amount including VAT — what the buyer pays
|
||||
},
|
||||
"vatBreakdown": [
|
||||
{ "rate": number, "base": number, "amount": number } // rate as percent integer, e.g. 25 for 25%
|
||||
]
|
||||
}
|
||||
|
||||
VAT rate convention: BOTH lineItems[].vatRate AND vatBreakdown[].rate use the same percent-integer format (25, 12, 6, 0). Never use the decimal form (0.25, 0.12).
|
||||
|
||||
Rules:
|
||||
- Output JSON only. The first character must be '{' and the last must be '}'.
|
||||
- Currency: detect from the document (symbol $/€/kr or explicit code). Use the ISO 4217 code. Do NOT default to SEK if the document clearly shows another currency.
|
||||
- "total" is the amount the buyer must pay (look for "Att betala", "Total", "Amount paid", "Amount due", "Balance"). Prefer this over Subtotal.
|
||||
- Dates: convert any format to YYYY-MM-DD. If the document only shows month/year, leave null.
|
||||
- Bankgiro/Plusgiro: only set when the document is for a Swedish supplier on a Swedish bank rail. Do not invent.
|
||||
- Org.nr: only set when it is an actual Swedish organisation number (10 digits, Luhn-valid). For US/EU companies leave null even if they list an EIN/VAT number.
|
||||
- VAT number: include the country prefix.
|
||||
- Numbers: parse with the document's locale (Swedish "1 234,56" = 1234.56; English "$1,234.56" = 1234.56). Output as plain JSON numbers.
|
||||
- If a field is missing or unreadable, set it to null. Never invent values.
|
||||
- lineItems: include every line. Empty array is fine if the document has no itemised lines.
|
||||
- vatBreakdown: include one entry per distinct VAT rate. Empty array is fine.`
|
||||
|
||||
function emptyResult(): InvoiceExtractionResult {
|
||||
return {
|
||||
supplier: {
|
||||
@@ -92,202 +176,85 @@ function emptyResult(): InvoiceExtractionResult {
|
||||
}
|
||||
}
|
||||
|
||||
// ── PDF text extraction ─────────────────────────────────────────────
|
||||
|
||||
async function tryExtractPdfText(input: ExtractionInput): Promise<string | null> {
|
||||
if (input.mimeType !== 'application/pdf') return null
|
||||
|
||||
try {
|
||||
const { text } = await extractText(new Uint8Array(input.buffer), { mergePages: true })
|
||||
return text.replace(/[ \t]+/g, ' ').trim()
|
||||
} catch (err) {
|
||||
console.warn('[invoice-inbox/extract] pdf text extraction failed:', err instanceof Error ? err.message : err)
|
||||
return null
|
||||
function buildContent(input: ExtractionInput) {
|
||||
const base64 = input.buffer.toString('base64')
|
||||
if (input.mimeType === 'application/pdf') {
|
||||
return [
|
||||
{
|
||||
type: 'document' as const,
|
||||
source: { type: 'base64' as const, media_type: 'application/pdf' as const, data: base64 },
|
||||
},
|
||||
{ type: 'text' as const, text: 'Extract the fields per the schema. JSON only.' },
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: input.mimeType as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif',
|
||||
data: base64,
|
||||
},
|
||||
},
|
||||
{ type: 'text' as const, text: 'Extract the fields per the schema. JSON only.' },
|
||||
]
|
||||
}
|
||||
|
||||
// ── Field extractors ────────────────────────────────────────────────
|
||||
|
||||
function extractOrgNumber(text: string): string | null {
|
||||
const candidates = text.match(/\b\d{6}-?\d{4}\b/g) ?? []
|
||||
for (const c of candidates) {
|
||||
const normalized = normalizeOrgNumber(c)
|
||||
if (normalized) return normalized
|
||||
/**
|
||||
* Extract invoice fields by sending the document directly to Claude
|
||||
* Sonnet 4.6 via AWS Bedrock. Never throws on extraction failure —
|
||||
* always returns an InvoiceExtractionResult. Empty fields are null.
|
||||
*/
|
||||
export async function extractInvoiceFields(
|
||||
input: ExtractionInput
|
||||
): Promise<ExtractionOutput> {
|
||||
if (!SUPPORTED_MEDIA_TYPES.has(input.mimeType)) {
|
||||
return { data: emptyResult(), rawText: null }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractVatNumber(text: string): string | null {
|
||||
const m = text.match(/\bSE\d{10}\d{2}\b/i)
|
||||
return m ? m[0].toUpperCase() : null
|
||||
}
|
||||
|
||||
function extractOcrReference(text: string): string | null {
|
||||
// Anchor on "OCR" / "Referens" / "Bet.ref" labels; widen to any digit
|
||||
// run on the same logical line if no labelled hit found.
|
||||
const labelled = text.match(
|
||||
/(?:OCR(?:-?nummer)?|Referens(?:nummer)?|Bet\.?\s*ref\.?|Betalningsreferens)[^\d\n]{0,40}(\d[\d\s]{3,30}\d)/i
|
||||
)
|
||||
if (labelled) {
|
||||
const digits = labelled[1].replace(/\s/g, '')
|
||||
if (validateOcrReference(digits)) return digits
|
||||
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) {
|
||||
log.warn('AWS Bedrock credentials missing — returning empty extraction', {
|
||||
fileName: input.fileName,
|
||||
})
|
||||
return { data: emptyResult(), rawText: null }
|
||||
}
|
||||
// Fallback: look for any standalone digit run that passes Luhn (4-25 digits)
|
||||
const candidates = text.match(/\b\d{4,25}\b/g) ?? []
|
||||
for (const c of candidates) {
|
||||
if (validateOcrReference(c)) return c
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractBankgiro(text: string): string | null {
|
||||
const labelled = text.match(/Bankgiro(?:nr)?[^\d\n]{0,20}(\d{3,4}-?\d{4})/i)
|
||||
if (labelled && validateBankgiroNumber(labelled[1])) {
|
||||
return labelled[1].includes('-') ? labelled[1] : insertBankgiroHyphen(labelled[1])
|
||||
}
|
||||
// Fallback: any 7-8 digit number with hyphen that passes Luhn
|
||||
const candidates = text.match(/\b\d{3,4}-\d{4}\b/g) ?? []
|
||||
for (const c of candidates) {
|
||||
if (validateBankgiroNumber(c)) return c
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function insertBankgiroHyphen(digits: string): string {
|
||||
if (digits.length === 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`
|
||||
if (digits.length === 8) return `${digits.slice(0, 4)}-${digits.slice(4)}`
|
||||
return digits
|
||||
}
|
||||
|
||||
function extractPlusgiro(text: string): string | null {
|
||||
const m = text.match(/Plusgiro(?:nr)?[^\d\n]{0,20}(\d{1,8}-\d)/i)
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
function extractInvoiceNumber(text: string): string | null {
|
||||
const m = text.match(
|
||||
/(?:Faktura(?:nr|nummer)?|Invoice\s*(?:no|number|#))[^\w\n]{0,8}([A-Z0-9][A-Z0-9\-/]{2,20})/i
|
||||
)
|
||||
return m ? m[1].trim() : null
|
||||
}
|
||||
|
||||
function extractDate(text: string, anchor: RegExp): string | null {
|
||||
// Look for a date within ~40 chars of the anchor
|
||||
const re = new RegExp(
|
||||
`(?:${anchor.source})[^\\d\\n]{0,40}(\\d{4}[-/.]\\d{1,2}[-/.]\\d{1,2}|\\d{1,2}[-/.]\\d{1,2}[-/.]\\d{4})`,
|
||||
'i'
|
||||
)
|
||||
const m = text.match(re)
|
||||
if (!m) return null
|
||||
return normalizeDate(m[1])
|
||||
}
|
||||
|
||||
function normalizeDate(raw: string): string | null {
|
||||
const sep = raw.match(/[-/.]/)
|
||||
if (!sep) return null
|
||||
const parts = raw.split(/[-/.]/).map((p) => p.trim())
|
||||
if (parts.length !== 3) return null
|
||||
let yyyy: string, mm: string, dd: string
|
||||
if (parts[0].length === 4) {
|
||||
[yyyy, mm, dd] = parts
|
||||
} else if (parts[2].length === 4) {
|
||||
[dd, mm, yyyy] = parts
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
const m = mm.padStart(2, '0')
|
||||
const d = dd.padStart(2, '0')
|
||||
if (!/^\d{4}$/.test(yyyy) || !/^\d{2}$/.test(m) || !/^\d{2}$/.test(d)) return null
|
||||
// Sanity check
|
||||
const month = parseInt(m, 10)
|
||||
const day = parseInt(d, 10)
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null
|
||||
return `${yyyy}-${m}-${d}`
|
||||
}
|
||||
|
||||
function extractCurrency(text: string): string {
|
||||
// Default SEK; only switch if a 3-letter currency code appears with an amount nearby
|
||||
const m = text.match(/\b(EUR|USD|GBP|NOK|DKK|CHF)\b/i)
|
||||
return m ? m[1].toUpperCase() : 'SEK'
|
||||
}
|
||||
|
||||
function extractTotals(text: string): { subtotal: number | null; vatAmount: number | null; total: number | null } {
|
||||
const total = findAmountNear(text, /(?:Att\s*betala|Totalt\s*att\s*betala|Summa\s*att\s*betala|Total(?:summa)?|Belopp\s*att\s*betala)/i)
|
||||
const vatAmount = findAmountNear(text, /(?:Total\s*moms|Moms(?:\s*totalt)?|VAT(?:\s*total)?)/i)
|
||||
const subtotal = findAmountNear(text, /(?:Netto(?:summa)?|Subtotal|Summa\s*excl(?:\.|usive)?\s*moms|Belopp\s*excl(?:\.|usive)?\s*moms)/i)
|
||||
return { subtotal, vatAmount, total }
|
||||
}
|
||||
|
||||
function findAmountNear(text: string, anchor: RegExp): number | null {
|
||||
const re = new RegExp(`(?:${anchor.source})[^\\d\\n-]{0,60}([0-9][\\d\\s.,]*[0-9])`, 'i')
|
||||
const m = text.match(re)
|
||||
if (!m) return null
|
||||
return parseSwedishAmount(m[1])
|
||||
}
|
||||
|
||||
function parseSwedishAmount(raw: string): number | null {
|
||||
// Swedish uses space as thousands sep and comma as decimal: "1 234,56".
|
||||
// Also tolerate "1,234.56" (international) and "1234.56".
|
||||
const cleaned = raw.replace(/\s/g, '')
|
||||
let normalized: string
|
||||
if (/,/.test(cleaned) && /\./.test(cleaned)) {
|
||||
// Both present — assume thousands+decimal. Decide by last separator.
|
||||
const lastComma = cleaned.lastIndexOf(',')
|
||||
const lastDot = cleaned.lastIndexOf('.')
|
||||
if (lastComma > lastDot) {
|
||||
normalized = cleaned.replace(/\./g, '').replace(',', '.')
|
||||
} else {
|
||||
normalized = cleaned.replace(/,/g, '')
|
||||
}
|
||||
} else if (/,/.test(cleaned)) {
|
||||
// Only comma — Swedish decimal
|
||||
normalized = cleaned.replace(',', '.')
|
||||
} else {
|
||||
normalized = cleaned
|
||||
}
|
||||
const n = parseFloat(normalized)
|
||||
return Number.isFinite(n) ? Math.round(n * 100) / 100 : null
|
||||
}
|
||||
|
||||
function extractVatBreakdown(text: string): Array<{ rate: number; base: number; amount: number }> {
|
||||
const out: Array<{ rate: number; base: number; amount: number }> = []
|
||||
// Match patterns like "Moms 25% 800,00 200,00" or "25% moms 200,00"
|
||||
const lineRe = /(?:Moms\s*)?(\d{1,2})\s*%[^\n\d-]{0,30}([0-9][\d\s.,]*[0-9])(?:[^\n\d-]{0,30}([0-9][\d\s.,]*[0-9]))?/gi
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = lineRe.exec(text)) !== null) {
|
||||
const rate = parseInt(m[1], 10)
|
||||
if (![25, 12, 6, 0].includes(rate)) continue
|
||||
const a = parseSwedishAmount(m[2])
|
||||
const b = m[3] ? parseSwedishAmount(m[3]) : null
|
||||
if (a == null) continue
|
||||
// Two amounts: base then VAT amount. One amount: just VAT, derive base.
|
||||
if (b != null) {
|
||||
out.push({ rate, base: a, amount: b })
|
||||
} else if (rate > 0) {
|
||||
const base = Math.round((a / (rate / 100)) * 100) / 100
|
||||
out.push({ rate, base, amount: a })
|
||||
}
|
||||
}
|
||||
// Dedup by rate (keep first hit)
|
||||
const seen = new Set<number>()
|
||||
return out.filter((row) => {
|
||||
if (seen.has(row.rate)) return false
|
||||
seen.add(row.rate)
|
||||
return true
|
||||
const client = new AnthropicBedrock({
|
||||
awsRegion: process.env.AWS_REGION || 'eu-north-1',
|
||||
awsAccessKey: process.env.AWS_ACCESS_KEY_ID,
|
||||
awsSecretKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
})
|
||||
}
|
||||
|
||||
function extractSupplierName(text: string): string | null {
|
||||
// Heuristic: first non-blank, non-numeric line in the first 500 chars,
|
||||
// skipping obvious header words.
|
||||
const head = text.slice(0, 500)
|
||||
const lines = head.split(/\n|(?:\s{4,})/).map((l) => l.trim()).filter(Boolean)
|
||||
const skip = /^(faktura|invoice|kvitto|receipt|sida|page|datum|date)$/i
|
||||
for (const line of lines) {
|
||||
if (skip.test(line)) continue
|
||||
if (/^\d/.test(line)) continue
|
||||
if (line.length < 3 || line.length > 80) continue
|
||||
return line
|
||||
let rawText: string | null = null
|
||||
try {
|
||||
const resp = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: MAX_TOKENS,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [{ role: 'user', content: buildContent(input) }],
|
||||
})
|
||||
|
||||
rawText = resp.content
|
||||
.flatMap((b) => (b.type === 'text' ? [b.text] : []))
|
||||
.join('')
|
||||
.trim()
|
||||
|
||||
const parsed = JSON.parse(rawText)
|
||||
const validated = ExtractionSchema.parse(parsed)
|
||||
|
||||
return {
|
||||
// accountSuggestion is null at this point — enforced by the schema's
|
||||
// .transform — so no post-validation coercion is needed.
|
||||
data: { ...validated, confidence: 1 },
|
||||
rawText,
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('AI extraction failed', {
|
||||
fileName: input.fileName,
|
||||
mimeType: input.mimeType,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
hasRawText: rawText != null,
|
||||
})
|
||||
return { data: emptyResult(), rawText }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
Generated
+3748
-15
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -15,6 +15,7 @@
|
||||
"test:pg": "vitest run --project pg-real"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.29.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
@@ -56,7 +57,6 @@
|
||||
"sharp": "^0.34.5",
|
||||
"svix": "^1.85.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"unpdf": "^1.6.2",
|
||||
"web-push": "^3.6.7",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
Reference in New Issue
Block a user