feat: invoice-inbox polish + SIE source voucher traceability (#299)

* fix: consolidate commit_journal_entry to single 4-arg signature

Replaces the phantom-overload drop migration with an idempotent consolidation
that leaves only the 4-arg-with-defaults signature, callable with either 2 or
4 named args. Fixes the "Could not choose the best candidate function"
ambiguity caused when the commit-metadata migration CREATE OR REPLACE'd a
4-arg version alongside the existing 2-arg one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: preserve SIE source voucher identity on journal entries

Adds source_voucher_series / source_voucher_number columns to journal_entries
so per-verifikat traceability survives the importer's skip-empty-voucher
logic. The SIE importer populates the original series/number even when
skipped vouchers cause gnubok's target numbering to drift from the source
file's sequence. Required for BFNAR 2013:2 kap 8 behandlingshistorik.

- Migration adds columns + partial index + extends immutability trigger
- importVouchers() records rawSeries/rawNumber per voucher
- JournalEntry type + test fixtures gain the new fields
- Bookkeeping detail page surfaces "Ursprungligt verifikat" when present

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: polish invoice-inbox workspace for production use

- Bedrock image fit: shrink images > 5 MB via sharp before Bedrock upload
  so HEIC/high-res phone photos don't fail with the 5 MB cap
- Swedish error mapping: toSwedishInboxError translates Bedrock /
  infrastructure errors to Swedish sentences stored in error_message
- History timeline endpoint (GET /items/:id/history) returns the
  processing_history events correlated to the inbox item
- Workspace UI: inline diagnostic timeline inside the convert dialog,
  same-email row grouping ("+N dokument" chip), inferred-VAT affordance
  with "needs review" signalling, Riksbanken exchange-rate prefill for
  foreign-currency invoices so the supplier-invoice create path populates
  *_sek audit columns

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: extend inbox-smart-match to supplier invoices

Both receipts and supplier invoices expose structurally identical match
anchors (date, amount, currency, counterparty name) so the matcher can
reuse the same narrowing + LLM prompt. Adds getMatchAnchors() as a shared
extractor across ReceiptExtractionResult / InvoiceExtractionResult, and
updates the event handlers to process supplier_invoice items alongside
receipts. LLM prompt re-phrased as "dokument" rather than "kvitto" and
loosened the date-window heuristic since invoice payments can lag behind
the invoice date by weeks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: drop unused category selector from TransactionForm

The manual "Lägg till transaktion" dialog predates the current categorization
flow (SwipeCategorizationView, BatchCategorySelector, AI suggestions). The
category dropdown here never drove journal-entry creation — onSubmit fanned
it out to CreateTransactionInput.category, which is optional. Removes the
dropdown, the unused watch() hook, and the categories lookup table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrations): restore drop-phantom file and rebump timestamps

Supabase branch DB failed with PK violation on schema_migrations because
my two migrations collided with timestamps already on main:
  20260421120000 → journal_entries_with_related_rpc (PR #298)
  20260421130000 → drop_legacy_supplier_invoice_user_id_uniqueness (PR #296)

Rebumped to 20260421140000 and 20260421150000 so each migration has a
unique version (Supabase uses only the 14-digit prefix as the PK).

Also restored the 20260420130000_drop_phantom_commit_journal_entry_overload
migration I had deleted — CLAUDE.md rule #5 forbids modifying existing
migrations. My consolidate migration is still compatible: drop_phantom
drops the 4-arg overload (no-op where absent), then consolidate recreates
it with defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(inbox-smart-match): anchor invoices on dueDate with wider window

The original ±7d window around invoiceDate filtered out all real payments
for invoices with standard 30–60 day terms — the matcher would see zero
candidates before the LLM was called, making the supplier-invoice matcher
effectively dead.

New anchor selection:
- Receipts: receipt date ±7 days (unchanged; paid on the spot)
- Invoices with dueDate: dueDate ±14 days (covers early/late payments)
- Invoices without dueDate: invoiceDate -7/+45 days (covers 30-day terms)

MatchAnchors now carries windowDaysBefore/After so the window can vary per
document shape. Added three getMatchAnchors tests asserting window sizes.

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:
Jakob Wennberg
2026-04-21 14:33:38 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 129664533c
commit b5df2fb292
17 changed files with 832 additions and 123 deletions
@@ -1,7 +1,10 @@
import { describe, it, expect } from 'vitest'
import { fetchCandidateTransactions } from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
import {
fetchCandidateTransactions,
getMatchAnchors,
} from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { ReceiptExtractionResult } from '@/types'
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
function makeReceipt(overrides?: Partial<ReceiptExtractionResult>): ReceiptExtractionResult {
return {
@@ -74,6 +77,46 @@ describe('fetchCandidateTransactions', () => {
expect(ids).toContain('t3')
})
it('anchors invoices on dueDate with a ±14d window', () => {
const invoice: InvoiceExtractionResult = {
supplier: { name: 'Acme AB', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
invoice: { invoiceNumber: 'INV-1', invoiceDate: '2026-03-01', dueDate: '2026-03-31', paymentReference: null, currency: 'SEK' },
lineItems: [],
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
vatBreakdown: [],
confidence: 0.9,
}
const anchors = getMatchAnchors(invoice)
expect(anchors).not.toBeNull()
expect(anchors!.date).toBe('2026-03-31')
expect(anchors!.windowDaysBefore).toBe(14)
expect(anchors!.windowDaysAfter).toBe(14)
})
it('anchors invoices without dueDate on invoiceDate with a -7/+45 day window', () => {
const invoice: InvoiceExtractionResult = {
supplier: { name: 'Acme AB', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
invoice: { invoiceNumber: 'INV-1', invoiceDate: '2026-03-01', dueDate: null, paymentReference: null, currency: 'SEK' },
lineItems: [],
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
vatBreakdown: [],
confidence: 0.9,
}
const anchors = getMatchAnchors(invoice)
expect(anchors).not.toBeNull()
expect(anchors!.date).toBe('2026-03-01')
expect(anchors!.windowDaysBefore).toBe(7)
expect(anchors!.windowDaysAfter).toBe(45)
})
it('anchors receipts on receipt date with a ±7d window', () => {
const anchors = getMatchAnchors(makeReceipt())
expect(anchors).not.toBeNull()
expect(anchors!.date).toBe('2026-04-15')
expect(anchors!.windowDaysBefore).toBe(7)
expect(anchors!.windowDaysAfter).toBe(7)
})
it('uses amount_sek when receipt is foreign currency', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [] }) // no already-matched
@@ -30,8 +30,11 @@ export const inboxSmartMatchExtension: Extension = {
{
eventType: 'inbox_item.classified',
handler: async (payload: EventPayload<'inbox_item.classified'>) => {
// Only act on receipts for v1
if (payload.documentType !== 'receipt') return
// Match both receipts and supplier invoices — other document types
// (government letters, unknown) have nothing to match against.
if (payload.documentType !== 'receipt' && payload.documentType !== 'supplier_invoice') {
return
}
const supabase = getServiceSupabase()
@@ -64,14 +67,14 @@ export const inboxSmartMatchExtension: Extension = {
const supabase = getServiceSupabase()
// Find receipts in pending state for this company.
// Cap at 10 per sync so one big bank import doesn't time out the
// handler; leftover pending items pick up on the next sync.
// Find pending receipts/invoices for this company. Cap at 10 per sync
// so one big bank import doesn't time out the handler; leftover pending
// items pick up on the next sync.
const { data: pendingItems, error } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('company_id', payload.companyId)
.eq('document_type', 'receipt')
.in('document_type', ['receipt', 'supplier_invoice'])
.eq('status', 'ready')
.eq('match_method', 'pending_transaction')
.order('created_at', { ascending: false })
@@ -1,15 +1,20 @@
/**
* Candidate transaction fetcher — deterministic narrowing before the LLM call.
*
* Pulls unbooked expense transactions within ±7 days of the receipt date,
* ordered by how close their amount is to the receipt total. Limits to top 5
* Pulls unbooked expense transactions near the document's payment date,
* ordered by how close their amount is to the document total. Limits to top 5
* so the LLM has a focused candidate set and the token cost stays bounded.
*
* Anchor date selection:
* - Receipts: receipt date ±7 days (paid on the spot)
* - Invoices with dueDate: dueDate ±14 days (covers early and late payments)
* - Invoices without dueDate: invoiceDate, window shifted forward to cover
* standard 30-day terms (invoiceDate-7 .. invoiceDate+45)
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ReceiptExtractionResult } from '@/types'
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
const DATE_WINDOW_DAYS = 7
const MAX_CANDIDATES = 5
export interface CandidateTransaction {
@@ -22,40 +27,80 @@ export interface CandidateTransaction {
merchant_name: string | null
}
/**
* Extract the reference date and absolute amount from a classified receipt's
* extracted data. Returns null if required fields are missing.
*/
function getReceiptMatchAnchors(
extracted: ReceiptExtractionResult | null
): { date: string; amount: number; currency: string } | null {
if (!extracted) return null
const date = extracted.receipt?.date ?? null
const amount = extracted.totals?.total ?? null
const currency = extracted.receipt?.currency ?? 'SEK'
if (!date || amount == null || amount <= 0) return null
return { date, amount, currency }
export type ExtractedDocument = ReceiptExtractionResult | InvoiceExtractionResult
export interface MatchAnchors {
date: string
amount: number
currency: string
counterpartyName: string | null
windowDaysBefore: number
windowDaysAfter: number
}
function isInvoiceExtraction(e: ExtractedDocument): e is InvoiceExtractionResult {
return 'invoice' in e && typeof (e as InvoiceExtractionResult).invoice === 'object'
}
/**
* Fetch up to MAX_CANDIDATES unbooked expense transactions near the receipt's
* Extract the reference date and absolute amount from a classified document's
* extracted data. Returns null if required fields are missing.
*/
export function getMatchAnchors(extracted: ExtractedDocument | null): MatchAnchors | null {
if (!extracted) return null
let date: string | null
let currency: string
let counterpartyName: string | null
let windowDaysBefore: number
let windowDaysAfter: number
if (isInvoiceExtraction(extracted)) {
const dueDate = extracted.invoice?.dueDate ?? null
const invoiceDate = extracted.invoice?.invoiceDate ?? null
if (dueDate) {
date = dueDate
windowDaysBefore = 14
windowDaysAfter = 14
} else {
date = invoiceDate
windowDaysBefore = 7
windowDaysAfter = 45
}
currency = extracted.invoice?.currency ?? 'SEK'
counterpartyName = extracted.supplier?.name ?? null
} else {
date = extracted.receipt?.date ?? null
currency = extracted.receipt?.currency ?? 'SEK'
counterpartyName = extracted.merchant?.name ?? null
windowDaysBefore = 7
windowDaysAfter = 7
}
const amount = extracted.totals?.total ?? null
if (!date || amount == null || amount <= 0) return null
return { date, amount, currency, counterpartyName, windowDaysBefore, windowDaysAfter }
}
/**
* Fetch up to MAX_CANDIDATES unbooked expense transactions near the document's
* date + amount. Ordering prefers exact amount matches first.
*/
export async function fetchCandidateTransactions(
supabase: SupabaseClient,
companyId: string,
extracted: ReceiptExtractionResult | null
extracted: ExtractedDocument | null
): Promise<CandidateTransaction[]> {
const anchors = getReceiptMatchAnchors(extracted)
const anchors = getMatchAnchors(extracted)
if (!anchors) return []
const receiptDate = new Date(anchors.date)
if (isNaN(receiptDate.getTime())) return []
const anchorDate = new Date(anchors.date)
if (isNaN(anchorDate.getTime())) return []
const windowStart = new Date(receiptDate)
windowStart.setUTCDate(windowStart.getUTCDate() - DATE_WINDOW_DAYS)
const windowEnd = new Date(receiptDate)
windowEnd.setUTCDate(windowEnd.getUTCDate() + DATE_WINDOW_DAYS)
const windowStart = new Date(anchorDate)
windowStart.setUTCDate(windowStart.getUTCDate() - anchors.windowDaysBefore)
const windowEnd = new Date(anchorDate)
windowEnd.setUTCDate(windowEnd.getUTCDate() + anchors.windowDaysAfter)
// Exclude transactions already claimed by any other inbox item in this
// company. The partial unique index on (company_id, matched_transaction_id)
@@ -99,14 +144,14 @@ export async function fetchCandidateTransactions(
: data
if (filtered.length === 0) return []
// Rank candidates by amount proximity. For SEK receipts we compare directly,
// for other currencies we prefer amount_sek if the receipt amount has been converted.
const receiptAbs = Math.abs(anchors.amount)
// Rank candidates by amount proximity. For SEK documents we compare directly,
// for other currencies we prefer amount_sek if the document amount has been converted.
const anchorAbs = Math.abs(anchors.amount)
const scored = filtered.map((tx) => {
const txAmount = Math.abs(Number(tx.amount) || 0)
const txSek = tx.amount_sek == null ? null : Math.abs(Number(tx.amount_sek))
const primaryDiff = Math.abs(txAmount - receiptAbs)
const sekDiff = txSek == null ? Infinity : Math.abs(txSek - receiptAbs)
const primaryDiff = Math.abs(txAmount - anchorAbs)
const sekDiff = txSek == null ? Infinity : Math.abs(txSek - anchorAbs)
const bestDiff = Math.min(primaryDiff, sekDiff)
return { tx, diff: bestDiff }
})
@@ -12,8 +12,8 @@ import {
type Message,
type ToolConfiguration,
} from '@aws-sdk/client-bedrock-runtime'
import type { ReceiptExtractionResult } from '@/types'
import type { CandidateTransaction } from './fetch-candidates'
import type { CandidateTransaction, ExtractedDocument } from './fetch-candidates'
import { getMatchAnchors } from './fetch-candidates'
export interface ReceiptMatchResult {
matched: boolean
@@ -38,19 +38,18 @@ function getClient(): BedrockRuntimeClient {
return _client
}
const SYSTEM_PROMPT = `Du är en expert på att matcha svenska kvitton mot banktransaktioner.
const SYSTEM_PROMPT = `Du är en expert på att matcha svenska bokföringsdokument (kvitton, fakturor) mot banktransaktioner.
Du får:
- Kvittodata (handlare, belopp, valuta, datum) från AI-extraktion
- En lista med kandidat-banktransaktioner (id, beskrivning, belopp, valuta, datum, MCC)
- Dokumentdata (handlare/leverantör, belopp, valuta, datum) från AI-extraktion
- En lista med kandidat-banktransaktioner (id, beskrivning, belopp, valuta, datum)
Uppgift: identifiera vilken (om någon) banktransaktion som motsvarar kvittot.
Uppgift: identifiera vilken (om någon) banktransaktion som motsvarar dokumentet.
Resonera utifrån:
- Belopp: bör vara identiskt eller mycket nära (ta hänsyn till valutaväxling om olika valutor)
- Datum: banktransaktion bokförs ofta 0-3 dagar efter kvittot
- Handlare: bankens beskrivning är ofta förkortad/versaler ("WILLYS SÖDERM" = "Willys Hemma Södermalm"). Matcha semantiskt, inte bokstavligt
- MCC-koder kan bekräfta branschtyp
- Datum: för kvitton bokförs banktransaktionen ofta 0-3 dagar efter köpet; för leverantörsfakturor kan betalningen ske flera dagar till veckor efter fakturadatum
- Handlare/leverantör: bankens beskrivning är ofta förkortad/versaler ("WILLYS SÖDERM" = "Willys Hemma Södermalm"). Matcha semantiskt, inte bokstavligt
Om inget förslag är trovärdigt — returnera matched=false.
Anropa ALLTID verktyget match_receipt med resultatet.
@@ -95,7 +94,7 @@ const MATCH_TOOL: ToolConfiguration = {
}
export interface MatchReceiptInput {
extracted: ReceiptExtractionResult
extracted: ExtractedDocument
candidates: CandidateTransaction[]
}
@@ -107,11 +106,12 @@ export interface MatchReceiptInput {
export async function matchReceiptToCandidate(
input: MatchReceiptInput
): Promise<ReceiptMatchResult> {
const anchors = getMatchAnchors(input.extracted)
const receiptBrief = {
merchant: input.extracted.merchant?.name ?? null,
amount: input.extracted.totals?.total ?? null,
currency: input.extracted.receipt?.currency ?? 'SEK',
date: input.extracted.receipt?.date ?? null,
merchant: anchors?.counterpartyName ?? null,
amount: anchors?.amount ?? null,
currency: anchors?.currency ?? 'SEK',
date: anchors?.date ?? null,
vat_amount: input.extracted.totals?.vatAmount ?? null,
}
@@ -125,13 +125,13 @@ export async function matchReceiptToCandidate(
merchant_name: c.merchant_name,
}))
const userPrompt = `Kvitto:
const userPrompt = `Dokument:
${JSON.stringify(receiptBrief, null, 2)}
Kandidat-transaktioner:
${JSON.stringify(candidateLines, null, 2)}
Vilken transaktion matchar kvittot? Om ingen matchar, returnera matched=false.`
Vilken transaktion matchar dokumentet? Om ingen matchar, returnera matched=false.`
const messages: Message[] = [
{
@@ -5,9 +5,9 @@
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { InvoiceInboxItem, ReceiptExtractionResult } from '@/types'
import type { InvoiceInboxItem } from '@/types'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { fetchCandidateTransactions } from './fetch-candidates'
import { fetchCandidateTransactions, type ExtractedDocument } from './fetch-candidates'
import { matchReceiptToCandidate } from './match-receipt'
export interface MatchContext {
@@ -36,8 +36,10 @@ export async function processInboxItemMatch(
): Promise<MatchOutcome> {
const tag = `[inbox-smart-match] item=${item.id} trigger=${ctx.triggerReason}`
// We only operate on receipts for v1
if (item.document_type !== 'receipt') {
// Match both receipts and supplier invoices — both have comparable anchors
// (date, amount, counterparty, currency) and the downstream LLM prompt is
// shape-agnostic.
if (item.document_type !== 'receipt' && item.document_type !== 'supplier_invoice') {
return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' }
}
if (item.status !== 'ready') {
@@ -62,7 +64,7 @@ export async function processInboxItemMatch(
}
}
const extracted = item.extracted_data as unknown as ReceiptExtractionResult
const extracted = item.extracted_data as unknown as ExtractedDocument
const candidates = await fetchCandidateTransactions(ctx.supabase, ctx.companyId, extracted)
// Append DeterministicMatch event — records that the narrowing ran
+43 -1
View File
@@ -16,6 +16,7 @@ import {
getActiveInbox,
composeInboxAddress,
} from './lib/inbox-provisioning'
import { toSwedishInboxError } from './lib/error-messages'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
import { appendProcessingHistory } from '@/lib/processing-history/append'
@@ -97,7 +98,9 @@ async function uploadAndClassify(
fileName: file.name,
})
} catch (err) {
classificationError = err instanceof Error ? err.message : 'Classification failed'
// Keep the technical message in the server log; present Swedish to users.
console.error('[invoice-inbox/classify] Bedrock classify failed:', err)
classificationError = toSwedishInboxError(err)
}
// Audit: DocumentExtractionAttempted (fires whether classification succeeded or failed)
@@ -343,6 +346,7 @@ export const invoiceInboxExtension: Extension = {
.select(`
id, status, document_type, confidence, source, created_at, extracted_data,
matched_supplier_id, document_id, email_from, email_subject, error_message,
resend_email_id,
matched_transaction_id, match_confidence, match_method, match_reasoning,
matched_transaction:transactions!matched_transaction_id(id, description, amount, currency, date)
`)
@@ -360,6 +364,44 @@ export const invoiceInboxExtension: Extension = {
},
},
// ── Get processing_history timeline for an inbox item ───
{
method: 'GET',
path: '/items/:id/history',
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 })
// Resolve correlation_id via the inbox item (also enforces company scope)
const { data: item } = await ctx.supabase
.from('invoice_inbox_items')
.select('id, correlation_id, company_id')
.eq('id', id)
.eq('company_id', ctx.companyId)
.maybeSingle()
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (!item.correlation_id) {
// Legacy rows created before the correlation_id column have no history
return NextResponse.json({ data: { events: [] } })
}
const { data: events, error } = await ctx.supabase
.from('processing_history')
.select('event_id, event_type, occurred_at, payload, actor, causation_id')
.eq('company_id', ctx.companyId)
.eq('correlation_id', item.correlation_id)
.order('occurred_at', { ascending: true })
.limit(100)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data: { events: events ?? [] } })
},
},
// ── Get single inbox item ───────────────────────────────
{
method: 'GET',
@@ -68,6 +68,45 @@ const MIME_TO_IMAGE_FORMAT: Record<string, string> = {
'image/gif': 'gif',
}
// Bedrock rejects image bytes > 5 MB. Keep headroom under that ceiling.
const BEDROCK_IMAGE_BYTE_LIMIT = 4_500_000
// Shrink an image until it fits Bedrock's 5 MB cap. Steps down the longest edge
// and JPEG quality in sequence — preserves legibility of receipt text while
// guaranteeing we stay under the limit (or throwing if a photo is so dense it
// can't be compressed enough, which in practice never happens below 500px).
async function fitImageForBedrock(
buffer: Buffer,
mimeType: string
): Promise<{ buffer: Buffer; format: 'jpeg' | 'png' | 'webp' | 'gif' }> {
const originalFormat = MIME_TO_IMAGE_FORMAT[mimeType] as 'jpeg' | 'png' | 'webp' | 'gif'
if (buffer.byteLength <= BEDROCK_IMAGE_BYTE_LIMIT) {
return { buffer, format: originalFormat }
}
// Re-encode to JPEG while shrinking. PNG at receipt-scale is usually 3-5×
// larger than an equivalent JPEG, so JPEG is the right target format even
// for PNG input.
const dimensionSteps = [2400, 1800, 1400, 1000, 800]
const qualitySteps = [85, 75, 60]
for (const maxDim of dimensionSteps) {
for (const quality of qualitySteps) {
const candidate = await sharp(buffer)
.rotate() // respect EXIF orientation
.resize({ width: maxDim, height: maxDim, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality, mozjpeg: true })
.toBuffer()
if (candidate.byteLength <= BEDROCK_IMAGE_BYTE_LIMIT) {
return { buffer: candidate, format: 'jpeg' }
}
}
}
throw new Error('Bilden kunde inte komprimeras tillräckligt för AI-tolkning.')
}
// ── System prompt ────────────────────────────────────────────
const SYSTEM_PROMPT = `Du är en svensk bokföringsdokumentklassificerare och dataextraktor.
@@ -260,24 +299,26 @@ async function buildContentBlock(input: ClassificationInput): Promise<ContentBlo
}
}
// HEIC → convert to JPEG via sharp
// HEIC → convert to JPEG via sharp, then fit to Bedrock's byte limit
if (mimeType === 'image/heic' || mimeType === 'image/heif') {
const jpegBuffer = await sharp(fileBuffer).jpeg({ quality: 90 }).toBuffer()
const jpegBuffer = await sharp(fileBuffer).rotate().jpeg({ quality: 90, mozjpeg: true }).toBuffer()
const fitted = await fitImageForBedrock(jpegBuffer, 'image/jpeg')
return {
image: {
format: 'jpeg',
source: { bytes: new Uint8Array(jpegBuffer) },
format: fitted.format,
source: { bytes: new Uint8Array(fitted.buffer) },
},
}
}
// Standard image formats
// Standard image formats — downscale if the buffer exceeds Bedrock's 5 MB cap
const imageFormat = MIME_TO_IMAGE_FORMAT[mimeType]
if (imageFormat) {
const fitted = await fitImageForBedrock(fileBuffer, mimeType)
return {
image: {
format: imageFormat as 'jpeg' | 'png' | 'webp' | 'gif',
source: { bytes: new Uint8Array(fileBuffer) },
format: fitted.format,
source: { bytes: new Uint8Array(fitted.buffer) },
},
}
}
@@ -0,0 +1,45 @@
/**
* Maps raw AWS Bedrock / infrastructure errors to Swedish user-facing sentences
* for the invoice-inbox error_message column. We keep this local to the
* extension rather than in lib/errors so the patterns can evolve with the
* Bedrock SDK without churning the shared helper.
*/
const PATTERNS: Array<[RegExp, (match: RegExpMatchArray) => string]> = [
[
/image exceeds 5 MB maximum: (\d+) bytes/i,
(m) => {
const mb = (Number(m[1]) / 1024 / 1024).toFixed(1)
return `Bilden är för stor för AI-tolkning (${mb} MB, max 5 MB). Skicka ett mindre foto eller en PDF.`
},
],
[
/image exceeds .+ maximum/i,
() => 'Bilden är för stor för AI-tolkning. Skicka ett mindre foto eller en PDF.',
],
[/ThrottlingException|TooManyRequestsException|Rate exceeded/i, () => 'AI-tjänsten är överbelastad just nu. Försök igen om en stund.'],
[/AccessDeniedException/i, () => 'Åtkomst till AI-tjänsten nekades. Kontakta support.'],
[/ValidationException.+modelId/i, () => 'AI-modellen är felkonfigurerad. Kontakta support.'],
[/InternalServerException|ServiceUnavailable/i, () => 'AI-tjänsten är tillfälligt otillgänglig. Försök igen om en stund.'],
[/Unsupported MIME type: (.+)/i, (m) => `Filformatet stöds inte (${m[1]}). Använd PDF, JPEG, PNG, HEIC eller WebP.`],
[/No content in Bedrock response|No tool use result in Bedrock response/i, () => 'AI-tjänsten svarade inte med strukturerad data. Försök igen.'],
[/Failed to fetch received email/i, () => 'Kunde inte hämta e-postmeddelandet från inkorgstjänsten. Försök igen.'],
[/Failed to fetch attachment|Download URL returned/i, () => 'Kunde inte ladda ner bilagan från inkorgstjänsten.'],
]
export function toSwedishInboxError(raw: unknown): string {
const message = raw instanceof Error ? raw.message : typeof raw === 'string' ? raw : 'Okänt fel'
for (const [pattern, build] of PATTERNS) {
const match = message.match(pattern)
if (match) return build(match)
}
// Preserve any message that's already Swedish (heuristic: contains å/ä/ö
// or a known Swedish word). Otherwise surface a generic fallback and log
// the technical detail through stderr rather than the user's screen.
if (/[åäö]|bild|faktura|inkorg|leverant/i.test(message)) {
return message
}
return 'Kunde inte bearbeta dokumentet. Försök igen eller kontakta support.'
}