Ai/full autonomous flow (#359)
* Refactor bookkeeping error handling and introduce new error classes - Introduced new error classes for better error categorization: - JournalEntryNotBalancedError - FiscalPeriodNotFoundError - EntryDateOutsideFiscalPeriodError - JournalEntryNotFoundError - CannotReverseNonPostedError - CannotCorrectNonPostedError - EntryAlreadyReversedError - CurrencyRevaluationAlreadyExistsError - InvalidMappingResultError - BookkeepingDatabaseError - Updated existing functions in engine.ts and transaction-entries.ts to throw specific errors instead of generic ones. - Enhanced error response handling in get-error-message.ts to provide localized messages for new error types. - Added unit tests for new error classes and error handling functions to ensure correctness and coverage. * feat(ai): implement AI proposal application and persistence - Add apply.ts to handle the application of AI proposals, including match and booking steps. - Introduce persist.ts for inserting and managing AI requests and proposals, ensuring unique constraints. - Create re-validate.ts for validating proposals before acceptance, checking for stale conditions. - Define database migrations for ai_requests and ai_proposals tables, including constraints and indexes. - Enhance journal_entries with AI provenance tracking, linking entries to AI proposals. - Update categorization_templates to distinguish AI-corrected templates. - Add company settings for toggling AI flow and managing backfill processes. - Extend processing_history to include AI-related events for better tracking. * feat: add uncategorized transactions API and UI for transaction selection - Implemented a new API endpoint for fetching uncategorized transactions with pagination and filtering options. - Created ChangeTransactionDialog component for selecting alternative transactions based on AI proposals. - Developed ReceiptDetailDialog to display detailed information about receipts, including upload functionality. - Added TransactionDetailDialog for viewing transaction details with links to the transaction list. - Introduced receipt quality assessment logic to evaluate extracted receipt data. - Implemented feature flagging for the AI bookkeeping agent to control availability in different environments. * feat: add manual receipt extraction dialog and integrate AWS Textract for expense analysis - Added ManualExtractDialog component for user input when AI fails to extract receipt data. - Implemented ReceiptsList component to manage and display uploaded receipts, including upload and rescan functionalities. - Introduced Textract integration for analyzing expenses, extracting fields like total, vendor, and date. - Updated package.json to include @aws-sdk/client-textract dependency. * fix(ai): handle livsmedel VAT transition (12% → 6%) in booking prompt and re-validate guard Add date-aware guidance to BOOKING_SYSTEM_PROMPT for the temporary livsmedel VAT cut (Prop. 2025/26:55, 2026-04-01 to 2027-12-31), with restaurang/servering carve-out at 12%. Add a re-validate safety net that rejects clearly-stale rate labels for grocery-chain merchants relative to the entry date. 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:
co-authored by
Claude Opus 4.7
parent
ab63da8324
commit
1af977950b
@@ -0,0 +1,15 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import { registerAIProposalService } from '@/lib/ai/proposal-service'
|
||||
import { BedrockAIProposalService } from './lib/bedrock-service'
|
||||
|
||||
// Register the Bedrock-backed implementation at extension load time.
|
||||
// The orchestrator (lib/ai/orchestrator.ts) calls getAIProposalService() at
|
||||
// event-handle time and will get this instance whenever the extension is
|
||||
// enabled in extensions.config.json.
|
||||
registerAIProposalService(new BedrockAIProposalService())
|
||||
|
||||
export const aiAgentExtension: Extension = {
|
||||
id: 'ai-agent',
|
||||
name: 'AI-agent (beta)',
|
||||
version: '0.1.0',
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Shared Bedrock Converse client for the ai-agent extension.
|
||||
* Mirrors inbox-smart-match's setup so the model + env var conventions stay
|
||||
* consistent across all LLM-backed extensions.
|
||||
*/
|
||||
|
||||
import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'
|
||||
|
||||
let _client: BedrockRuntimeClient | null = null
|
||||
|
||||
export function getBedrockClient(): BedrockRuntimeClient {
|
||||
if (!_client) {
|
||||
_client = new BedrockRuntimeClient({
|
||||
region: process.env.AWS_REGION || 'eu-north-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
})
|
||||
}
|
||||
return _client
|
||||
}
|
||||
|
||||
export function getModelId(): string {
|
||||
return process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
|
||||
}
|
||||
|
||||
export function getMaxTokens(): number {
|
||||
return parseInt(process.env.BEDROCK_MAX_TOKENS || '2048', 10)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* BedrockAIProposalService — the AIProposalService implementation registered
|
||||
* by the ai-agent extension. Each method dispatches to the relevant generator
|
||||
* and returns whatever the generator produced (proposal / request / null).
|
||||
*/
|
||||
|
||||
import type {
|
||||
AIProposalService,
|
||||
AIRequestResult,
|
||||
BookingProposalResult,
|
||||
GenerateBookingContext,
|
||||
GenerateMatchContext,
|
||||
MatchProposalResult,
|
||||
} from '@/lib/ai/proposal-service'
|
||||
import { generateMatchForExtension } from './generate-match'
|
||||
import { generateBookingForExtension } from './generate-booking'
|
||||
|
||||
export class BedrockAIProposalService implements AIProposalService {
|
||||
isEnabled(): boolean {
|
||||
// The extension only loads when enabled in extensions.config.json, so any
|
||||
// registered instance is enabled by definition. We still gate on AWS
|
||||
// credentials so a misconfigured env surfaces as "null -> needs_manual"
|
||||
// rather than a Bedrock exception per call.
|
||||
return Boolean(
|
||||
process.env.AWS_ACCESS_KEY_ID &&
|
||||
process.env.AWS_SECRET_ACCESS_KEY &&
|
||||
process.env.AWS_REGION
|
||||
)
|
||||
}
|
||||
|
||||
async generateMatchProposal(
|
||||
ctx: GenerateMatchContext
|
||||
): Promise<MatchProposalResult | AIRequestResult | null> {
|
||||
if (!this.isEnabled()) return null
|
||||
return generateMatchForExtension(ctx)
|
||||
}
|
||||
|
||||
async generateBookingProposal(
|
||||
ctx: GenerateBookingContext
|
||||
): Promise<BookingProposalResult | AIRequestResult | null> {
|
||||
if (!this.isEnabled()) return null
|
||||
return generateBookingForExtension(ctx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Booking proposal generator for the ai-agent extension.
|
||||
*
|
||||
* Takes a matched receipt + transaction and returns a balanced journal-entry
|
||||
* proposal in the BookingProposalPayload shape. Uses existing counterparty
|
||||
* templates as seeds in the prompt so recurring merchants converge fast.
|
||||
*
|
||||
* Returns an AIRequestResult when the LLM chooses to clarify (e.g.,
|
||||
* can't tell business vs private), or null on outage.
|
||||
*
|
||||
* Also verifies the proposed lines balance (sum debits = sum credits); when
|
||||
* the LLM returns unbalanced lines the result is degraded to a clarify ask
|
||||
* rather than being silently wrong.
|
||||
*/
|
||||
|
||||
import {
|
||||
ConverseCommand,
|
||||
type ContentBlock,
|
||||
type Message,
|
||||
} from '@aws-sdk/client-bedrock-runtime'
|
||||
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { createClient as createServiceClient } from '@supabase/supabase-js'
|
||||
import type {
|
||||
AIRequestResult,
|
||||
BookingProposalResult,
|
||||
GenerateBookingContext,
|
||||
} from '@/lib/ai/proposal-service'
|
||||
import type {
|
||||
BookingProposalLine,
|
||||
BookingProposalCounterpartyTemplate,
|
||||
BookingProposalPayload,
|
||||
VatTreatment,
|
||||
} from '@/types'
|
||||
import { getBedrockClient, getModelId, getMaxTokens } from './bedrock-client'
|
||||
import {
|
||||
BOOKING_PROMPT_VERSION,
|
||||
BOOKING_SYSTEM_PROMPT,
|
||||
BOOKING_TOOL_CONFIG,
|
||||
} from './prompts/booking-prompt'
|
||||
|
||||
export async function generateBookingForExtension(
|
||||
ctx: GenerateBookingContext
|
||||
): Promise<BookingProposalResult | AIRequestResult | null> {
|
||||
const serviceClient = createServiceClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
)
|
||||
|
||||
// Resolve fiscal period for the transaction date — a prerequisite for any booking.
|
||||
const fiscalPeriodId = await findFiscalPeriod(
|
||||
serviceClient,
|
||||
ctx.companyId,
|
||||
ctx.matchedTransaction.date
|
||||
)
|
||||
|
||||
if (!fiscalPeriodId) {
|
||||
return {
|
||||
kind: 'request',
|
||||
request: {
|
||||
request_type: 'needs_manual',
|
||||
message:
|
||||
'Ingen öppen räkenskapsperiod täcker transaktionens datum. Skapa perioden eller bokför manuellt.',
|
||||
},
|
||||
provenance: { prompt_version: BOOKING_PROMPT_VERSION },
|
||||
}
|
||||
}
|
||||
|
||||
// Brief the LLM with receipt + transaction + relevant templates.
|
||||
const extracted = ctx.inboxItem.extracted_data as Record<string, unknown> | null
|
||||
const relevantTemplates = ctx.existingTemplates
|
||||
.filter((t) => t.is_active)
|
||||
.slice(0, 20)
|
||||
.map((t) => ({
|
||||
counterparty: t.counterparty_name,
|
||||
debit: t.debit_account,
|
||||
credit: t.credit_account,
|
||||
vat_treatment: t.vat_treatment,
|
||||
category: t.category,
|
||||
source: t.source,
|
||||
occurrences: t.occurrence_count,
|
||||
}))
|
||||
|
||||
const userPrompt = `Kvittodata (extraherad):
|
||||
${JSON.stringify(extracted, null, 2)}
|
||||
|
||||
Matchad banktransaktion:
|
||||
${JSON.stringify(
|
||||
{
|
||||
id: ctx.matchedTransaction.id,
|
||||
date: ctx.matchedTransaction.date,
|
||||
description: ctx.matchedTransaction.description,
|
||||
amount: ctx.matchedTransaction.amount,
|
||||
amount_sek: ctx.matchedTransaction.amount_sek,
|
||||
currency: ctx.matchedTransaction.currency,
|
||||
merchant_name: ctx.matchedTransaction.merchant_name,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
|
||||
Företagstyp: ${ctx.entityType}
|
||||
|
||||
Befintliga motpartsmallar (upp till 20):
|
||||
${JSON.stringify(relevantTemplates, null, 2)}
|
||||
|
||||
Föreslå ett balanserat verifikat. Transaktionens belopp är bruttobeloppet som betalas från 1930.`
|
||||
|
||||
const messages: Message[] = [{ role: 'user', content: [{ text: userPrompt }] }]
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await getBedrockClient().send(
|
||||
new ConverseCommand({
|
||||
modelId: getModelId(),
|
||||
messages,
|
||||
system: [{ text: BOOKING_SYSTEM_PROMPT }],
|
||||
toolConfig: BOOKING_TOOL_CONFIG,
|
||||
inferenceConfig: { maxTokens: getMaxTokens(), temperature: 0 },
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[ai-agent/booking] Bedrock call failed:', err)
|
||||
return null
|
||||
}
|
||||
|
||||
const usage = {
|
||||
input_tokens: response.usage?.inputTokens ?? 0,
|
||||
output_tokens: response.usage?.outputTokens ?? 0,
|
||||
}
|
||||
|
||||
const toolUse = response.output?.message?.content?.find(
|
||||
(b): b is ContentBlock.ToolUseMember => 'toolUse' in b && b.toolUse !== undefined
|
||||
)
|
||||
|
||||
if (!toolUse?.toolUse?.input) return null
|
||||
|
||||
const raw = toolUse.toolUse.input as Record<string, unknown>
|
||||
const action = raw.action === 'clarify_business_private' ? 'clarify_business_private' : 'propose'
|
||||
const confidence = clampConfidence(Number(raw.confidence))
|
||||
const reasoning = typeof raw.reasoning === 'string' ? raw.reasoning.trim() : ''
|
||||
|
||||
if (action === 'clarify_business_private') {
|
||||
return {
|
||||
kind: 'request',
|
||||
request: {
|
||||
request_type: 'clarify_business_private',
|
||||
message:
|
||||
typeof raw.clarify_message === 'string' && raw.clarify_message.trim().length > 0
|
||||
? raw.clarify_message.trim()
|
||||
: 'Är detta en affärsutgift eller privat?',
|
||||
required_fields: { is_business: 'boolean' },
|
||||
},
|
||||
provenance: {
|
||||
model: getModelId(),
|
||||
prompt_version: BOOKING_PROMPT_VERSION,
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const proposalRaw = raw.proposal as Record<string, unknown> | null | undefined
|
||||
if (!proposalRaw) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawLines = extractLines(proposalRaw.lines)
|
||||
const vatTreatment = extractVatTreatment(proposalRaw.vat_treatment)
|
||||
const defaultPrivate = Boolean(proposalRaw.default_private)
|
||||
const counterpartyTpl = extractCounterpartyTemplate(proposalRaw.counterparty_template_proposal)
|
||||
|
||||
// Claude often returns lines that are off by a cent or two due to the way
|
||||
// it does 25% VAT math on awkward totals (e.g. 183,30 split as net 146,64
|
||||
// + VAT 36,66 — fine — but sometimes 146,64 + 36,67 from rounding up).
|
||||
// Repair those silently; the journal engine can't post unbalanced entries
|
||||
// anyway, and the human-facing answer (same accounts, same rate) is identical.
|
||||
const { lines, repaired } = repairRounding(rawLines)
|
||||
|
||||
if (!linesBalanced(lines)) {
|
||||
const totalDebit = lines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const totalCredit = lines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
console.warn('[ai-agent/generate-booking] unbalanced proposal', {
|
||||
totalDebit, totalCredit, diff: totalDebit - totalCredit, lines,
|
||||
})
|
||||
return {
|
||||
kind: 'request',
|
||||
request: {
|
||||
request_type: 'needs_manual',
|
||||
message:
|
||||
`AI:n producerade ett obalanserat verifikat (debet ${totalDebit.toFixed(2)} vs kredit ${totalCredit.toFixed(2)}). Bokför manuellt eller försök igen via Bearbeta befintliga.`,
|
||||
},
|
||||
provenance: {
|
||||
model: getModelId(),
|
||||
prompt_version: BOOKING_PROMPT_VERSION,
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (repaired) {
|
||||
console.log('[ai-agent/generate-booking] auto-repaired rounding on booking lines')
|
||||
}
|
||||
|
||||
const payload: BookingProposalPayload = {
|
||||
lines,
|
||||
vat_treatment: vatTreatment,
|
||||
default_private: defaultPrivate,
|
||||
counterparty_template_proposal: counterpartyTpl,
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: ctx.matchedTransaction.date,
|
||||
description: buildDescription(ctx),
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'proposal',
|
||||
proposal: payload,
|
||||
confidence,
|
||||
reasoning,
|
||||
provenance: {
|
||||
model: getModelId(),
|
||||
prompt_version: BOOKING_PROMPT_VERSION,
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function clampConfidence(raw: number): number {
|
||||
if (!isFinite(raw)) return 0
|
||||
return Math.min(1, Math.max(0, raw / 100))
|
||||
}
|
||||
|
||||
function extractLines(raw: unknown): BookingProposalLine[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.map((item) => item as Record<string, unknown>)
|
||||
.filter((item) => typeof item.account_number === 'string')
|
||||
.map((item) => ({
|
||||
account_number: String(item.account_number),
|
||||
debit_amount: Number(item.debit_amount) || 0,
|
||||
credit_amount: Number(item.credit_amount) || 0,
|
||||
description: typeof item.description === 'string' ? item.description : '',
|
||||
}))
|
||||
}
|
||||
|
||||
function extractVatTreatment(raw: unknown): VatTreatment | null {
|
||||
const allowed: VatTreatment[] = [
|
||||
'standard_25',
|
||||
'reduced_12',
|
||||
'reduced_6',
|
||||
'reverse_charge',
|
||||
'export',
|
||||
'exempt',
|
||||
]
|
||||
if (typeof raw !== 'string') return null
|
||||
return (allowed as string[]).includes(raw) ? (raw as VatTreatment) : null
|
||||
}
|
||||
|
||||
function extractCounterpartyTemplate(
|
||||
raw: unknown
|
||||
): BookingProposalCounterpartyTemplate | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const r = raw as Record<string, unknown>
|
||||
if (
|
||||
typeof r.counterparty_name !== 'string' ||
|
||||
typeof r.debit_account !== 'string' ||
|
||||
typeof r.credit_account !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
counterparty_name: r.counterparty_name,
|
||||
debit_account: r.debit_account,
|
||||
credit_account: r.credit_account,
|
||||
vat_treatment: extractVatTreatment(r.vat_treatment),
|
||||
category:
|
||||
typeof r.category === 'string' && r.category.length > 0
|
||||
? (r.category as BookingProposalCounterpartyTemplate['category'])
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function linesBalanced(lines: BookingProposalLine[]): boolean {
|
||||
if (lines.length < 2) return false
|
||||
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
return Math.abs(totalDebit - totalCredit) < 0.005 && totalDebit > 0
|
||||
}
|
||||
|
||||
// Adjust sub-5-öre discrepancies silently by nudging the largest debit
|
||||
// line. Only repairs imbalances up to 0.05 kr — anything larger is treated
|
||||
// as a real error (Claude got confused, not just a rounding quirk) and
|
||||
// bubbles up via the existing needs_manual fallback.
|
||||
function repairRounding(lines: BookingProposalLine[]): { lines: BookingProposalLine[]; repaired: boolean } {
|
||||
if (lines.length < 2) return { lines, repaired: false }
|
||||
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
const diff = totalDebit - totalCredit
|
||||
const absDiff = Math.abs(diff)
|
||||
|
||||
if (absDiff < 0.005) return { lines, repaired: false }
|
||||
if (absDiff > 0.05) return { lines, repaired: false }
|
||||
|
||||
// Pick the single biggest debit line to absorb the adjustment — usually
|
||||
// the expense account, not the VAT line. Subtract if debit is over,
|
||||
// add if debit is under. Round to öre precision.
|
||||
const withIndex = lines.map((l, idx) => ({ l, idx }))
|
||||
const biggestDebit = withIndex
|
||||
.filter((x) => x.l.debit_amount > 0)
|
||||
.sort((a, b) => b.l.debit_amount - a.l.debit_amount)[0]
|
||||
if (!biggestDebit) return { lines, repaired: false }
|
||||
|
||||
const adjusted = [...lines]
|
||||
const current = adjusted[biggestDebit.idx]
|
||||
adjusted[biggestDebit.idx] = {
|
||||
...current,
|
||||
debit_amount: Math.round((current.debit_amount - diff) * 100) / 100,
|
||||
}
|
||||
return { lines: adjusted, repaired: true }
|
||||
}
|
||||
|
||||
function buildDescription(ctx: GenerateBookingContext): string {
|
||||
const merchant =
|
||||
ctx.matchedTransaction.merchant_name ||
|
||||
ctx.matchedTransaction.description ||
|
||||
'Okänd handlare'
|
||||
return `AI-förslag: ${merchant}`
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Match proposal generator for the ai-agent extension.
|
||||
*
|
||||
* Returns a MatchProposalResult when the LLM identifies a good candidate,
|
||||
* an AIRequestResult when input is insufficient (bad extraction) or no
|
||||
* candidates are available (user must upload the missing transaction first),
|
||||
* or null on Bedrock outage so the orchestrator emits a 'needs_manual' ask.
|
||||
*/
|
||||
|
||||
import {
|
||||
ConverseCommand,
|
||||
type ContentBlock,
|
||||
type Message,
|
||||
} from '@aws-sdk/client-bedrock-runtime'
|
||||
import { createClient as createServiceClient } from '@supabase/supabase-js'
|
||||
import { fetchCandidateTransactions, getMatchAnchors } from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
|
||||
import type { ExtractedDocument } from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
|
||||
import type {
|
||||
AIRequestResult,
|
||||
GenerateMatchContext,
|
||||
MatchProposalResult,
|
||||
} from '@/lib/ai/proposal-service'
|
||||
import type { MatchProposalAlternative } from '@/types'
|
||||
import { getBedrockClient, getModelId, getMaxTokens } from './bedrock-client'
|
||||
import {
|
||||
MATCH_PROMPT_VERSION,
|
||||
MATCH_SYSTEM_PROMPT,
|
||||
MATCH_TOOL_CONFIG,
|
||||
} from './prompts/match-prompt'
|
||||
|
||||
export async function generateMatchForExtension(
|
||||
ctx: GenerateMatchContext
|
||||
): Promise<MatchProposalResult | AIRequestResult | null> {
|
||||
const extracted = ctx.inboxItem.extracted_data as unknown as ExtractedDocument | null
|
||||
|
||||
// Guard: extraction quality.
|
||||
const anchors = getMatchAnchors(extracted)
|
||||
if (!anchors) {
|
||||
return {
|
||||
kind: 'request',
|
||||
request: {
|
||||
request_type: 'reupload_document',
|
||||
message:
|
||||
'Jag kunde inte läsa av datum eller belopp från kvittot. Ladda upp en tydligare bild så försöker jag igen.',
|
||||
},
|
||||
provenance: { prompt_version: MATCH_PROMPT_VERSION },
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch candidates using the shared deterministic narrowing.
|
||||
const serviceClient = createServiceClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
)
|
||||
|
||||
let candidates
|
||||
try {
|
||||
candidates = await fetchCandidateTransactions(serviceClient, ctx.companyId, extracted)
|
||||
} catch (err) {
|
||||
console.error('[ai-agent/match] fetchCandidateTransactions failed:', err)
|
||||
return null
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
kind: 'request',
|
||||
request: {
|
||||
request_type: 'pick_transaction',
|
||||
message:
|
||||
'Jag hittade ingen matchande banktransaktion. Vänta på nästa banksync eller välj manuellt.',
|
||||
options: { candidates: [] },
|
||||
},
|
||||
provenance: { prompt_version: MATCH_PROMPT_VERSION },
|
||||
}
|
||||
}
|
||||
|
||||
// Call Bedrock.
|
||||
const receiptBrief = {
|
||||
merchant: anchors.counterpartyName,
|
||||
amount: anchors.amount,
|
||||
currency: anchors.currency,
|
||||
date: anchors.date,
|
||||
vat_amount: extracted?.totals?.vatAmount ?? null,
|
||||
}
|
||||
|
||||
const candidateLines = candidates.map((c) => ({
|
||||
id: c.id,
|
||||
date: c.date,
|
||||
description: c.description,
|
||||
amount: c.amount,
|
||||
amount_sek: c.amount_sek,
|
||||
currency: c.currency,
|
||||
merchant_name: c.merchant_name,
|
||||
}))
|
||||
|
||||
const userPrompt = `Kvitto:
|
||||
${JSON.stringify(receiptBrief, null, 2)}
|
||||
|
||||
Kandidat-transaktioner:
|
||||
${JSON.stringify(candidateLines, null, 2)}
|
||||
|
||||
Vilken matchar? Om ingen matchar, returnera matched=false.`
|
||||
|
||||
const messages: Message[] = [{ role: 'user', content: [{ text: userPrompt }] }]
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await getBedrockClient().send(
|
||||
new ConverseCommand({
|
||||
modelId: getModelId(),
|
||||
messages,
|
||||
system: [{ text: MATCH_SYSTEM_PROMPT }],
|
||||
toolConfig: MATCH_TOOL_CONFIG,
|
||||
inferenceConfig: { maxTokens: getMaxTokens(), temperature: 0 },
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[ai-agent/match] Bedrock call failed:', err)
|
||||
return null
|
||||
}
|
||||
|
||||
const usage = {
|
||||
input_tokens: response.usage?.inputTokens ?? 0,
|
||||
output_tokens: response.usage?.outputTokens ?? 0,
|
||||
}
|
||||
|
||||
const toolUse = response.output?.message?.content?.find(
|
||||
(b): b is ContentBlock.ToolUseMember => 'toolUse' in b && b.toolUse !== undefined
|
||||
)
|
||||
|
||||
if (!toolUse?.toolUse?.input) {
|
||||
return null
|
||||
}
|
||||
|
||||
const raw = toolUse.toolUse.input as Record<string, unknown>
|
||||
const matched = Boolean(raw.matched)
|
||||
const rawId = typeof raw.transaction_id === 'string' ? raw.transaction_id : null
|
||||
const confidence = clampConfidence(Number(raw.confidence))
|
||||
const reasoning = typeof raw.reasoning === 'string' ? raw.reasoning.trim() : ''
|
||||
|
||||
// Resolve alternatives, filtering to only valid candidate IDs.
|
||||
const candidateIds = new Set(candidates.map((c) => c.id))
|
||||
const rawAlts = Array.isArray(raw.alternatives) ? raw.alternatives : []
|
||||
const alternatives: MatchProposalAlternative[] = rawAlts
|
||||
.map((a) => a as Record<string, unknown>)
|
||||
.filter((a) => typeof a.transaction_id === 'string' && candidateIds.has(a.transaction_id as string))
|
||||
.map((a) => ({
|
||||
transaction_id: a.transaction_id as string,
|
||||
confidence: clampConfidence(Number(a.confidence)),
|
||||
reasoning: typeof a.reasoning === 'string' ? a.reasoning.trim() : '',
|
||||
}))
|
||||
.slice(0, 3)
|
||||
|
||||
if (!matched || !rawId || !candidateIds.has(rawId)) {
|
||||
// LLM declined or returned unresolvable ID — degrade to pick_transaction ask.
|
||||
return {
|
||||
kind: 'request',
|
||||
request: {
|
||||
request_type: 'pick_transaction',
|
||||
message:
|
||||
'AI:n är osäker på matchning. Välj manuellt bland kandidaterna eller vänta på fler banktransaktioner.',
|
||||
options: { candidates: candidateLines },
|
||||
},
|
||||
provenance: {
|
||||
model: getModelId(),
|
||||
prompt_version: MATCH_PROMPT_VERSION,
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'proposal',
|
||||
proposal: {
|
||||
matched_transaction_id: rawId,
|
||||
alternatives,
|
||||
top_confidence: confidence,
|
||||
},
|
||||
confidence,
|
||||
reasoning,
|
||||
provenance: {
|
||||
model: getModelId(),
|
||||
prompt_version: MATCH_PROMPT_VERSION,
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function clampConfidence(raw: number): number {
|
||||
if (!isFinite(raw)) return 0
|
||||
return Math.min(1, Math.max(0, raw / 100))
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Booking prompt — given an extracted receipt + matched transaction + any
|
||||
* existing counterparty templates, propose a complete journal entry.
|
||||
*
|
||||
* The v1 schema is deliberately narrow: standard expense with input VAT
|
||||
* (optional) paid from 1930. Reverse-charge / EU / import paths are out
|
||||
* of scope for the first receipts-only release; those still funnel to
|
||||
* manual via a clarify_business_private request if the LLM is unsure.
|
||||
*/
|
||||
|
||||
import type { ToolConfiguration } from '@aws-sdk/client-bedrock-runtime'
|
||||
|
||||
export const BOOKING_PROMPT_VERSION = '2026-04-27-v3'
|
||||
|
||||
export const BOOKING_SYSTEM_PROMPT = `Du är en expert på svensk bokföring enligt BAS-kontoplanen. Du föreslår hur ett kvitto ska bokföras mot en matchad banktransaktion.
|
||||
|
||||
Indata:
|
||||
- Extraherad kvittodata (handlare, belopp, moms, datum)
|
||||
- Matchad banktransaktion (beskrivning, belopp, datum)
|
||||
- Företagstyp (enskild firma eller aktiebolag)
|
||||
- Befintliga mallar för samma motpart (om några)
|
||||
|
||||
Uppgift: föreslå ett balanserat verifikat (Debet = Kredit). Verifikatet MÅSTE balansera: summan av alla debet-rader ska vara EXAKT lika med summan av alla kredit-rader.
|
||||
|
||||
Mönstret för en standardutgift med svensk moms:
|
||||
Debet 5xxx/6xxx (kostnadskonto, nettobelopp)
|
||||
Debet 2641 Ingående moms (om standardmoms 25%, 12% eller 6%)
|
||||
Kredit 1930 Företagskonto (bruttobelopp)
|
||||
|
||||
Mönstret för en utgift utan svensk moms (utländsk leverantör, momsfritt kvitto):
|
||||
Debet 5xxx/6xxx (kostnadskonto, hela beloppet)
|
||||
Kredit 1930 Företagskonto (hela beloppet)
|
||||
|
||||
Om privat uttag (enskild firma) — använd 2013 istället för kostnadskontot.
|
||||
|
||||
Riktlinjer:
|
||||
- Välj lämpligt BAS-kostnadskonto utifrån typ av inköp (t.ex. 5410 IT-utrustning, 5611 Drivmedel, 5810 Representation, 6540 IT-tjänster)
|
||||
- Momsavdrag: standard 25% → 2641, 12% → 2641, 6% → 2641. Sätt vat_treatment 'standard_25' / 'reduced_12' / 'reduced_6'.
|
||||
- Om kvittot saknar momsspecifikation men är svensk handelsrelaterad — anta standard_25
|
||||
- Om kvittot är från en utländsk leverantör (ej svensk org/momsnummer) och INTE visar någon moms — använd mönstret utan moms. Sätt vat_treatment='exempt'. Använd INTE kontona 2614, 2615, 2645, 2646, 2647, 2648 — omvänd skattskyldighet är utanför scope i v1.
|
||||
- Om inköpet troligen är privat (t.ex. matvaror för hushåll, nöjen) och företagstyp = enskild firma — sätt default_private=true och använd 2013
|
||||
- Representation: endast 50% moms avdragsgillt — för v1, föreslå utan reducering och flagga i reasoning att användaren bör kontrollera
|
||||
- Om du är osäker på business vs private, eller om fakturan ser ut att kräva omvänd skattskyldighet (t.ex. EU-leverantör med momsnummer men 0% moms) — returnera hellre ett ai_request av typ 'clarify_business_private' än att gissa
|
||||
|
||||
VIKTIGT — momsövergång på livsmedel (Prop. 2025/26:55):
|
||||
- Från och med 1 april 2026 (t.o.m. 31 december 2027) sänks momsen på livsmedel från 12 % till 6 %. Återgår till 12 % den 1 januari 2028.
|
||||
- Avgör momssats utifrån KVITTOTS DATUM (matchad transaktionsdatum):
|
||||
* Livsmedel/dagligvaror (ICA, Coop, Hemköp, Willys, Lidl, City Gross, Tempo, Mathem, Netto, Mat.se m.fl.):
|
||||
- Datum < 2026-04-01: vat_treatment='reduced_12'
|
||||
- Datum 2026-04-01 — 2027-12-31: vat_treatment='reduced_6'
|
||||
- Datum >= 2028-01-01: vat_treatment='reduced_12'
|
||||
* Restaurang/servering (eat-in på restaurang, café, lunchställe, bistro): ALLTID vat_treatment='reduced_12' (omfattas inte av sänkningen).
|
||||
* Take-away/avhämtning räknas som livsmedel — följ datumlogiken ovan.
|
||||
* Alkohol är alltid 25 % oavsett — om kvittot uppenbart är alkohol, vat_treatment='standard_25'.
|
||||
- Om det är otydligt om kvittot är livsmedel eller servering (t.ex. ICA med både matvaror och deli), välj den dominerande posten utifrån beloppet och förklara valet i reasoning.
|
||||
|
||||
KONTROLLERA innan du returnerar: addera alla debit_amount, addera alla credit_amount, verifiera att summorna är EXAKT lika. Om de inte är det — räkna om.
|
||||
|
||||
Resonera på svenska. Var konkret: vilket konto och varför.
|
||||
|
||||
Anropa ALLTID verktyget propose_booking med resultatet.`
|
||||
|
||||
export const BOOKING_TOOL_CONFIG: ToolConfiguration = {
|
||||
tools: [
|
||||
{
|
||||
toolSpec: {
|
||||
name: 'propose_booking',
|
||||
description: 'Returnera ett balanserat verifikatförslag eller en fråga till användaren',
|
||||
inputSchema: {
|
||||
json: {
|
||||
type: 'object',
|
||||
required: ['action', 'confidence', 'reasoning'],
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['propose', 'clarify_business_private'],
|
||||
description:
|
||||
'propose = konkret förslag. clarify_business_private = be användaren avgöra om privat/business.',
|
||||
},
|
||||
confidence: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
},
|
||||
reasoning: {
|
||||
type: 'string',
|
||||
description: '1-3 meningar på svenska som förklarar förslaget.',
|
||||
},
|
||||
proposal: {
|
||||
type: ['object', 'null'],
|
||||
description: 'Endast när action=propose.',
|
||||
required: ['lines', 'vat_treatment', 'default_private'],
|
||||
properties: {
|
||||
lines: {
|
||||
type: 'array',
|
||||
minItems: 2,
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['account_number', 'debit_amount', 'credit_amount', 'description'],
|
||||
properties: {
|
||||
account_number: {
|
||||
type: 'string',
|
||||
pattern: '^\\d{4}$',
|
||||
description: '4-siffrigt BAS-kontonummer',
|
||||
},
|
||||
debit_amount: { type: 'number', minimum: 0 },
|
||||
credit_amount: { type: 'number', minimum: 0 },
|
||||
description: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
vat_treatment: {
|
||||
type: ['string', 'null'],
|
||||
enum: [
|
||||
'standard_25',
|
||||
'reduced_12',
|
||||
'reduced_6',
|
||||
'reverse_charge',
|
||||
'export',
|
||||
'exempt',
|
||||
null,
|
||||
],
|
||||
},
|
||||
default_private: {
|
||||
type: 'boolean',
|
||||
description: 'true för privat uttag (enskild firma 2013)',
|
||||
},
|
||||
counterparty_template_proposal: {
|
||||
type: ['object', 'null'],
|
||||
description:
|
||||
'Föreslå en motpartsmall om handlaren är återkommande och bokföringsmönstret är tydligt.',
|
||||
required: ['counterparty_name', 'debit_account', 'credit_account'],
|
||||
properties: {
|
||||
counterparty_name: { type: 'string' },
|
||||
debit_account: { type: 'string', pattern: '^\\d{4}$' },
|
||||
credit_account: { type: 'string', pattern: '^\\d{4}$' },
|
||||
vat_treatment: {
|
||||
type: ['string', 'null'],
|
||||
enum: [
|
||||
'standard_25',
|
||||
'reduced_12',
|
||||
'reduced_6',
|
||||
'reverse_charge',
|
||||
'export',
|
||||
'exempt',
|
||||
null,
|
||||
],
|
||||
},
|
||||
category: { type: ['string', 'null'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
clarify_message: {
|
||||
type: ['string', 'null'],
|
||||
description:
|
||||
'Endast när action=clarify_business_private. Kort fråga på svenska till användaren.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
toolChoice: { any: {} },
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Match prompt — given an extracted receipt + candidate transactions,
|
||||
* the LLM picks the best match (or explains that none fit).
|
||||
*
|
||||
* Bump MATCH_PROMPT_VERSION on any prompt change so the pinned version on
|
||||
* stored proposals remains accurate for audit + drift analysis.
|
||||
*/
|
||||
|
||||
import type { ToolConfiguration } from '@aws-sdk/client-bedrock-runtime'
|
||||
|
||||
export const MATCH_PROMPT_VERSION = '2026-04-23-v1'
|
||||
|
||||
export const MATCH_SYSTEM_PROMPT = `Du är en expert på svensk bokföring. Du matchar kvitton mot banktransaktioner för ett företag som använder gnubok.
|
||||
|
||||
Indata:
|
||||
- Extraherad kvittodata (handlare, belopp, valuta, datum, momsbelopp)
|
||||
- Upp till 5 kandidat-banktransaktioner (id, beskrivning, belopp, valuta, datum)
|
||||
|
||||
Uppgift: identifiera vilken (om någon) banktransaktion motsvarar kvittot.
|
||||
|
||||
Riktlinjer:
|
||||
- Belopp bör vara identiskt eller väldigt nära (valutaväxling tillkommer om olika valutor)
|
||||
- Datum: banktransaktionen bokförs ofta 0-3 dagar efter kvittodatumet
|
||||
- Bankbeskrivningar är ofta förkortade versaler — matcha semantiskt, inte bokstavligt
|
||||
- Om inget är trovärdigt, returnera matched=false med en kort motivering
|
||||
- Motivera alltid kort på svenska varför du valde (eller inte valde)
|
||||
|
||||
Anropa ALLTID verktyget match_receipt_for_agent med resultatet.`
|
||||
|
||||
export const MATCH_TOOL_CONFIG: ToolConfiguration = {
|
||||
tools: [
|
||||
{
|
||||
toolSpec: {
|
||||
name: 'match_receipt_for_agent',
|
||||
description: 'Returnera den bäst matchande kandidaten eller förklara att ingen matchar',
|
||||
inputSchema: {
|
||||
json: {
|
||||
type: 'object',
|
||||
required: ['matched', 'confidence', 'reasoning', 'alternatives'],
|
||||
properties: {
|
||||
matched: {
|
||||
type: 'boolean',
|
||||
description: 'true om en kandidat matchar, annars false',
|
||||
},
|
||||
transaction_id: {
|
||||
type: ['string', 'null'],
|
||||
description: 'id för vald kandidat (null när matched=false)',
|
||||
},
|
||||
confidence: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
description: 'Säkerhet 0-100. Sätt lågt när matched=false.',
|
||||
},
|
||||
reasoning: {
|
||||
type: 'string',
|
||||
description: '1-2 meningar på svenska som förklarar valet.',
|
||||
},
|
||||
alternatives: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Upp till 3 övriga kandidater som användaren kan välja istället, rankade efter sannolikhet (endast tillagda om matched=true).',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['transaction_id', 'confidence', 'reasoning'],
|
||||
properties: {
|
||||
transaction_id: { type: 'string' },
|
||||
confidence: { type: 'integer', minimum: 0, maximum: 100 },
|
||||
reasoning: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
toolChoice: { any: {} },
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "ai-agent",
|
||||
"sector": "general",
|
||||
"exportName": "aiAgentExtension",
|
||||
"entryPoint": "@/extensions/general/ai-agent",
|
||||
"requiredEnvVars": [
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_REGION"
|
||||
],
|
||||
"optionalEnvVars": ["BEDROCK_MODEL_ID", "BEDROCK_MAX_TOKENS"],
|
||||
"npmDependencies": ["@aws-sdk/client-bedrock-runtime"],
|
||||
"definition": {
|
||||
"name": "AI-agent (beta)",
|
||||
"category": "operations",
|
||||
"icon": "Sparkles",
|
||||
"dataPattern": "core",
|
||||
"hasOwnData": false,
|
||||
"readsCoreTables": ["invoice_inbox_items", "transactions", "ai_proposals", "ai_requests", "processing_history"],
|
||||
"description": "Autonom bokföring — AI föreslår match + bokföring, du godkänner.",
|
||||
"longDescription": "När ett kvitto kommer in föreslår AI-agenten först vilken banktransaktion som matchar, sedan hur det ska bokföras. Du granskar och godkänner varje steg — inget bokförs automatiskt. Om AI:n inte kan producera ett förslag (oläslig bild, ingen matchande transaktion, osäker moms) frågar den dig specifikt vad som behövs."
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { classifyDocument } from './lib/classify-document'
|
||||
import { analyzeExpenseWithTextract, checkTotalsAgreement } from './lib/textract-expense'
|
||||
import type { TextractExpenseResult, AgreementResult } from './lib/textract-expense'
|
||||
import {
|
||||
verifyInboundWebhook,
|
||||
fetchReceivingEmail,
|
||||
@@ -88,20 +90,13 @@ async function uploadAndClassify(
|
||||
console.error('[invoice-inbox] Failed to append DocumentIngested:', err)
|
||||
}
|
||||
|
||||
// Classify with AI
|
||||
let classificationResult
|
||||
let classificationError: string | null = null
|
||||
try {
|
||||
classificationResult = await classifyDocument({
|
||||
fileBuffer: Buffer.from(file.buffer),
|
||||
mimeType: file.type,
|
||||
fileName: file.name,
|
||||
})
|
||||
} catch (err) {
|
||||
// Keep the technical message in the server log; present Swedish to users.
|
||||
console.error('[invoice-inbox/classify] Bedrock classify failed:', err)
|
||||
classificationError = toSwedishInboxError(err)
|
||||
}
|
||||
// Classify with AI (Claude) + OCR (Textract) in parallel, then cross-check.
|
||||
const verification = await extractWithVerification(
|
||||
Buffer.from(file.buffer),
|
||||
file.type,
|
||||
file.name
|
||||
)
|
||||
const { classificationResult, classificationError, textract, agreement, needsReview } = verification
|
||||
|
||||
// Audit: DocumentExtractionAttempted (fires whether classification succeeded or failed)
|
||||
try {
|
||||
@@ -163,11 +158,13 @@ async function uploadAndClassify(
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: classificationError ? 'error' : 'ready',
|
||||
// OCR-only rows land as 'ready' with the Textract numbers populated —
|
||||
// still usable, just without Claude's semantic layer.
|
||||
status: classificationError && !textract ? 'error' : 'ready',
|
||||
source,
|
||||
document_id: doc.id,
|
||||
document_type: classificationResult?.documentType || 'unknown',
|
||||
extracted_data: classificationResult?.extractedData || null,
|
||||
document_type: classificationResult?.documentType || (textract ? 'receipt' : 'unknown'),
|
||||
extracted_data: enrichExtractedData(classificationResult, textract, agreement),
|
||||
raw_llm_response: classificationResult?.rawResponse || null,
|
||||
confidence: classificationResult?.confidence
|
||||
? classificationResult.confidence / 100
|
||||
@@ -182,13 +179,18 @@ async function uploadAndClassify(
|
||||
raw_email_payload: emailMeta?.messageId
|
||||
? { messageId: emailMeta.messageId, filename: file.name }
|
||||
: null,
|
||||
error_message: classificationError,
|
||||
// Only surface the error when both reads failed. OCR fallback is a
|
||||
// successful outcome from the user's perspective.
|
||||
error_message: classificationError && !textract ? classificationError : null,
|
||||
correlation_id: correlationId,
|
||||
})
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
|
||||
if (needsReview) {
|
||||
console.log('[invoice-inbox] OCR disagrees with Claude on inbox', inbox.id, agreement)
|
||||
}
|
||||
|
||||
// Audit: DocumentClassified (only when classification succeeded)
|
||||
if (!classificationError && classificationResult) {
|
||||
@@ -260,6 +262,225 @@ async function uploadAndClassify(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extraction + OCR cross-check helper ──────────────────────
|
||||
//
|
||||
// Runs Claude (vision) and Textract (receipt-specialized OCR) in parallel on
|
||||
// the same file and returns an agreement verdict. Claude owns structure and
|
||||
// semantics (merchant, line items, VAT treatment); Textract owns the raw
|
||||
// numbers as a hallucination anchor. Disagreement on the total by more than
|
||||
// 1 öre downgrades the row to needs_review so the UI can flag it.
|
||||
//
|
||||
// The two calls are independent; either can fail without killing the other.
|
||||
// Claude-only is the historical default and still works if Textract skips
|
||||
// (unsupported mime, too-large file, missing AWS perms).
|
||||
interface ExtractionWithVerification {
|
||||
classificationResult: Awaited<ReturnType<typeof classifyDocument>> | undefined
|
||||
classificationError: string | null
|
||||
textract: TextractExpenseResult | null
|
||||
agreement: AgreementResult | null
|
||||
needsReview: boolean
|
||||
}
|
||||
|
||||
async function extractWithVerification(
|
||||
fileBuffer: Buffer,
|
||||
mimeType: string,
|
||||
fileName: string
|
||||
): Promise<ExtractionWithVerification> {
|
||||
const claudePromise = classifyDocument({ fileBuffer, mimeType, fileName })
|
||||
.then((r) => ({ ok: true as const, value: r }))
|
||||
.catch((err) => {
|
||||
console.error('[invoice-inbox/classify] Bedrock classify failed:', err)
|
||||
return { ok: false as const, error: toSwedishInboxError(err) }
|
||||
})
|
||||
|
||||
const textractPromise = analyzeExpenseWithTextract(fileBuffer, mimeType)
|
||||
|
||||
const [claudeResult, textract] = await Promise.all([claudePromise, textractPromise])
|
||||
|
||||
const classificationResult = claudeResult.ok ? claudeResult.value : undefined
|
||||
const classificationError = claudeResult.ok ? null : claudeResult.error
|
||||
|
||||
// Pull Claude's receipt/invoice total for comparison. Handles both shapes.
|
||||
const data = classificationResult?.extractedData as
|
||||
| { totals?: { total?: number | null } }
|
||||
| null
|
||||
| undefined
|
||||
const claudeTotal = data?.totals?.total ?? null
|
||||
const agreement = checkTotalsAgreement(claudeTotal, textract)
|
||||
|
||||
// needs_review when the two reads disagree. Missing-total cases are handled
|
||||
// elsewhere (rowNeedsRescan) and shouldn't double-flag here.
|
||||
const needsReview = agreement !== null && !agreement.agrees
|
||||
|
||||
return { classificationResult, classificationError, textract, agreement, needsReview }
|
||||
}
|
||||
|
||||
// Build the enriched extracted_data JSON: Claude's output plus an _ocr and
|
||||
// _verification block. Stays non-breaking — existing consumers read the
|
||||
// top-level fields unchanged; new consumers (UI, audit) can read the nested
|
||||
// verification block.
|
||||
function enrichExtractedData(
|
||||
classificationResult: Awaited<ReturnType<typeof classifyDocument>> | undefined,
|
||||
textract: TextractExpenseResult | null,
|
||||
agreement: AgreementResult | null
|
||||
): Record<string, unknown> | null {
|
||||
if (!classificationResult?.extractedData) {
|
||||
// Claude failed but Textract may have run. Surface OCR as a fallback so
|
||||
// the UI can still show _something_ from the receipt.
|
||||
if (!textract) return null
|
||||
return {
|
||||
merchant: textract.vendor ? { name: textract.vendor.value } : null,
|
||||
receipt: textract.date ? { date: textract.date.value, currency: textract.currency } : null,
|
||||
totals: textract.total
|
||||
? { total: textract.total.value, vatAmount: textract.tax?.value ?? null, subtotal: textract.subtotal?.value ?? null }
|
||||
: null,
|
||||
_ocr: textract,
|
||||
_verification: { claude_available: false },
|
||||
_source: 'ocr_only',
|
||||
}
|
||||
}
|
||||
return {
|
||||
...(classificationResult.extractedData as unknown as Record<string, unknown>),
|
||||
_ocr: textract,
|
||||
_verification: agreement,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rescan helper ────────────────────────────────────────────
|
||||
|
||||
// Re-runs classification on an existing inbox item's source file. Used by the
|
||||
// per-row "Skanna igen" button and the batch "Skanna oskannade" action so
|
||||
// users can recover rows that errored or came back with incomplete data
|
||||
// without reuploading. The row is updated in place — same id, same document,
|
||||
// refreshed extracted_data and status.
|
||||
async function rescanInboxItem(
|
||||
supabase: import('@supabase/supabase-js').SupabaseClient,
|
||||
companyId: string,
|
||||
inboxItemId: string
|
||||
): Promise<{ ok: true; id: string } | { ok: false; id: string; error: string }> {
|
||||
const { data: item } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, company_id, document_id, correlation_id, status')
|
||||
.eq('id', inboxItemId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!item) return { ok: false, id: inboxItemId, error: 'Inbox item not found' }
|
||||
if (!item.document_id) return { ok: false, id: inboxItemId, error: 'No source document attached' }
|
||||
if (item.status === 'confirmed') return { ok: false, id: inboxItemId, error: 'Already booked' }
|
||||
|
||||
const { data: doc } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('storage_path, mime_type, file_name')
|
||||
.eq('id', item.document_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!doc || !doc.storage_path) return { ok: false, id: inboxItemId, error: 'Document file missing' }
|
||||
|
||||
const { data: blob, error: dlError } = await supabase.storage
|
||||
.from('documents')
|
||||
.download(doc.storage_path)
|
||||
if (dlError || !blob) return { ok: false, id: inboxItemId, error: dlError?.message || 'Download failed' }
|
||||
|
||||
const buffer = Buffer.from(await blob.arrayBuffer())
|
||||
|
||||
const verification = await extractWithVerification(
|
||||
buffer,
|
||||
doc.mime_type ?? 'application/octet-stream',
|
||||
doc.file_name ?? 'document'
|
||||
)
|
||||
const { classificationResult, classificationError, textract, agreement } = verification
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: classificationError && !textract ? 'error' : 'ready',
|
||||
document_type: classificationResult?.documentType ?? (textract ? 'receipt' : 'unknown'),
|
||||
extracted_data: enrichExtractedData(classificationResult, textract, agreement),
|
||||
raw_llm_response: classificationResult?.rawResponse ?? null,
|
||||
confidence: classificationResult?.confidence ? classificationResult.confidence / 100 : null,
|
||||
error_message: classificationError && !textract ? classificationError : null,
|
||||
})
|
||||
.eq('id', inboxItemId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (updateError) return { ok: false, id: inboxItemId, error: updateError.message }
|
||||
|
||||
// Audit — same event shape as initial classify so the history timeline is consistent.
|
||||
if (item.correlation_id) {
|
||||
try {
|
||||
await appendProcessingHistory({
|
||||
companyId,
|
||||
correlationId: item.correlation_id,
|
||||
aggregateType: 'Document',
|
||||
aggregateId: item.document_id,
|
||||
eventType: 'DocumentExtractionAttempted',
|
||||
payload: {
|
||||
document_id: item.document_id,
|
||||
inbox_item_id: inboxItemId,
|
||||
succeeded: !classificationError,
|
||||
document_type: classificationResult?.documentType ?? null,
|
||||
confidence: classificationResult?.confidence ? classificationResult.confidence / 100 : null,
|
||||
llm_input_tokens: classificationResult?.usage?.inputTokens ?? 0,
|
||||
llm_output_tokens: classificationResult?.usage?.outputTokens ?? 0,
|
||||
error: classificationError,
|
||||
retry: true,
|
||||
},
|
||||
actor: { type: 'llm', id: 'classify-document' },
|
||||
occurredAt: new Date(),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[invoice-inbox/rescan] appendProcessingHistory failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-emit classified event on any successful extraction (Claude or OCR
|
||||
// fallback) so the AI orchestrator can generate a match proposal.
|
||||
const succeeded = classificationResult != null || textract != null
|
||||
if (succeeded) {
|
||||
try {
|
||||
const { data: refreshed } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('id', inboxItemId)
|
||||
.maybeSingle()
|
||||
if (refreshed) {
|
||||
await eventBus.emit({
|
||||
type: 'inbox_item.classified',
|
||||
payload: {
|
||||
inboxItem: refreshed as unknown as InvoiceInboxItem,
|
||||
documentType: refreshed.document_type,
|
||||
confidence: refreshed.confidence,
|
||||
correlationId: item.correlation_id ?? crypto.randomUUID(),
|
||||
userId: (refreshed as { user_id: string }).user_id,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch { /* non-blocking */ }
|
||||
}
|
||||
|
||||
return succeeded
|
||||
? { ok: true, id: inboxItemId }
|
||||
: { ok: false, id: inboxItemId, error: classificationError ?? 'Extraction failed' }
|
||||
}
|
||||
|
||||
// A row needs rescanning when extraction failed, or when it succeeded but
|
||||
// came back without the fields we actually need to propose a match (total +
|
||||
// date). Keep the heuristic permissive — false positives just mean a user
|
||||
// clicking "Skanna igen" on a good row, which is harmless.
|
||||
function needsRescan(row: {
|
||||
status: string | null
|
||||
extracted_data: unknown
|
||||
}): boolean {
|
||||
if (row.status === 'error') return true
|
||||
const data = row.extracted_data as { totals?: { total?: number | null } } | null
|
||||
if (!data) return true
|
||||
if (data.totals?.total == null) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Admin/owner check helper ──────────────────────────────────
|
||||
|
||||
async function isCompanyAdmin(
|
||||
@@ -329,6 +550,179 @@ export const invoiceInboxExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// ── Rescan (per-row or batch) ───────────────────────────
|
||||
// Re-runs the LLM classifier against the already-uploaded source file.
|
||||
// Body shapes:
|
||||
// { inbox_item_ids: uuid[] } → rescan those rows (must belong to company)
|
||||
// {} → rescan all receipt rows that look stuck
|
||||
// (status='error' or missing extracted totals)
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/rescan',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
let body: { inbox_item_ids?: string[] } = {}
|
||||
try { body = await request.json() } catch { /* allow empty body */ }
|
||||
|
||||
let ids: string[] = []
|
||||
if (Array.isArray(body.inbox_item_ids) && body.inbox_item_ids.length > 0) {
|
||||
ids = body.inbox_item_ids.filter((id): id is string => typeof id === 'string')
|
||||
} else {
|
||||
const { data: rows } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, status, extracted_data')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.eq('document_type', 'receipt')
|
||||
.not('status', 'eq', 'confirmed')
|
||||
.not('document_id', 'is', null)
|
||||
.limit(100)
|
||||
ids = (rows ?? []).filter((r) => needsRescan(r)).map((r) => r.id)
|
||||
}
|
||||
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json({ data: { rescanned: 0, failed: 0, outcomes: [] } })
|
||||
}
|
||||
|
||||
// Serial rather than parallel — Bedrock rate limits kick in hard past
|
||||
// ~5 concurrent. 100-receipt rescan × 3s each = 5min, acceptable as a
|
||||
// backgrounded action. The UI fires and refreshes, doesn't block.
|
||||
const outcomes: Array<{ id: string; ok: boolean; error?: string }> = []
|
||||
for (const id of ids) {
|
||||
const res = await rescanInboxItem(ctx.supabase, ctx.companyId, id)
|
||||
outcomes.push(res.ok ? { id, ok: true } : { id, ok: false, error: res.error })
|
||||
}
|
||||
|
||||
const rescanned = outcomes.filter((o) => o.ok).length
|
||||
const failed = outcomes.length - rescanned
|
||||
|
||||
return NextResponse.json({ data: { rescanned, failed, outcomes } })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Manual extraction fallback ──────────────────────────
|
||||
// Lets the user type merchant/date/total when the LLM can't read the image.
|
||||
// The picture is untouched (still attached as source document); we just
|
||||
// overwrite extracted_data with user-supplied values and flip status to
|
||||
// 'ready' so downstream matching can proceed.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/manual-extract',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
let body: {
|
||||
inbox_item_id?: string
|
||||
merchant?: string
|
||||
date?: string
|
||||
total?: number
|
||||
currency?: string
|
||||
vat_amount?: number | null
|
||||
} = {}
|
||||
try { body = await request.json() } catch { /* fall through to validation */ }
|
||||
|
||||
const { inbox_item_id, merchant, date, total, currency } = body
|
||||
if (!inbox_item_id || !merchant || !date || typeof total !== 'number') {
|
||||
return NextResponse.json(
|
||||
{ error: 'inbox_item_id, merchant, date och total krävs.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: item } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id, correlation_id, status')
|
||||
.eq('id', inbox_item_id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
if (!item) return NextResponse.json({ error: 'Kvittot hittades inte.' }, { status: 404 })
|
||||
if (!item.document_id) {
|
||||
return NextResponse.json({ error: 'Kvittobild saknas — ladda upp en bild först.' }, { status: 400 })
|
||||
}
|
||||
if (item.status === 'confirmed') {
|
||||
return NextResponse.json({ error: 'Redan bokfört.' }, { status: 409 })
|
||||
}
|
||||
|
||||
const extracted_data = {
|
||||
merchant: { name: merchant },
|
||||
receipt: { date, currency: currency ?? 'SEK' },
|
||||
totals: {
|
||||
total,
|
||||
vatAmount: typeof body.vat_amount === 'number' ? body.vat_amount : null,
|
||||
subtotal: null,
|
||||
},
|
||||
lineItems: null,
|
||||
flags: null,
|
||||
_entry_method: 'manual',
|
||||
}
|
||||
|
||||
const { error: updateError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
document_type: 'receipt',
|
||||
extracted_data,
|
||||
confidence: 1,
|
||||
error_message: null,
|
||||
})
|
||||
.eq('id', inbox_item_id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
|
||||
if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
|
||||
if (item.correlation_id) {
|
||||
try {
|
||||
await appendProcessingHistory({
|
||||
companyId: ctx.companyId,
|
||||
correlationId: item.correlation_id,
|
||||
aggregateType: 'Document',
|
||||
aggregateId: item.document_id,
|
||||
eventType: 'DocumentExtractionAttempted',
|
||||
payload: {
|
||||
document_id: item.document_id,
|
||||
inbox_item_id,
|
||||
succeeded: true,
|
||||
document_type: 'receipt',
|
||||
confidence: 1,
|
||||
llm_input_tokens: 0,
|
||||
llm_output_tokens: 0,
|
||||
error: null,
|
||||
manual: true,
|
||||
},
|
||||
actor: { type: 'user', id: ctx.userId },
|
||||
occurredAt: new Date(),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[invoice-inbox/manual-extract] appendProcessingHistory failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fire match-proposal generation via the classified event.
|
||||
try {
|
||||
const { data: refreshed } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('id', inbox_item_id)
|
||||
.maybeSingle()
|
||||
if (refreshed) {
|
||||
await eventBus.emit({
|
||||
type: 'inbox_item.classified',
|
||||
payload: {
|
||||
inboxItem: refreshed as unknown as InvoiceInboxItem,
|
||||
documentType: 'receipt',
|
||||
confidence: 1,
|
||||
correlationId: item.correlation_id ?? crypto.randomUUID(),
|
||||
userId: ctx.userId,
|
||||
companyId: ctx.companyId,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch { /* non-blocking */ }
|
||||
|
||||
return NextResponse.json({ data: { ok: true } })
|
||||
},
|
||||
},
|
||||
|
||||
// ── List inbox items ────────────────────────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -427,6 +821,113 @@ export const invoiceInboxExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// ── Attach a source document to an existing inbox item ──
|
||||
// For rows that ended up without a picture (e.g. email came through but
|
||||
// attachment extraction failed, or manually-created rows). Uploads the
|
||||
// file, links document_id on the row, then runs classify so the numbers
|
||||
// get extracted in the same request.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/items/:id/attach-document',
|
||||
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 })
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: `File too large (max ${MAX_FILE_SIZE / 1024 / 1024} MB)` }, { status: 400 })
|
||||
}
|
||||
if (!UPLOAD_ALLOWED_MIME_TYPES.has(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported file type: ${file.type}. Allowed: PDF, JPEG, PNG, HEIC, WebP` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: item } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id, status, correlation_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!item) return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
|
||||
if (item.status === 'confirmed') {
|
||||
return NextResponse.json({ error: 'Redan bokfört — kan inte ersätta bilden.' }, { status: 409 })
|
||||
}
|
||||
if (item.document_id) {
|
||||
return NextResponse.json({ error: 'Kvittot har redan en bild.' }, { status: 409 })
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer()
|
||||
const doc = await uploadDocument(ctx.supabase, ctx.userId, ctx.companyId, {
|
||||
name: file.name,
|
||||
buffer,
|
||||
type: file.type,
|
||||
}, {
|
||||
upload_source: 'file_upload',
|
||||
})
|
||||
|
||||
const { error: linkError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ document_id: doc.id })
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
if (linkError) {
|
||||
return NextResponse.json({ error: linkError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Audit the ingest — mirrors the initial-upload path.
|
||||
if (item.correlation_id) {
|
||||
try {
|
||||
await appendProcessingHistory({
|
||||
companyId: ctx.companyId,
|
||||
correlationId: item.correlation_id,
|
||||
aggregateType: 'Document',
|
||||
aggregateId: doc.id,
|
||||
eventType: 'DocumentIngested',
|
||||
payload: {
|
||||
channel: 'upload',
|
||||
document_id: doc.id,
|
||||
inbox_item_id: id,
|
||||
mime_type: file.type,
|
||||
size_bytes: file.size,
|
||||
attached_to_existing: true,
|
||||
},
|
||||
actor: { type: 'user', id: ctx.userId },
|
||||
occurredAt: new Date(),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[invoice-inbox/attach-document] appendProcessingHistory failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Now run classification on the freshly-attached image.
|
||||
const rescan = await rescanInboxItem(ctx.supabase, ctx.companyId, id)
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
document_id: doc.id,
|
||||
inbox_item_id: id,
|
||||
classified: rescan.ok,
|
||||
error: rescan.ok ? null : rescan.error,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[invoice-inbox/attach-document] Failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Attach failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── Get this company's inbox address ────────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* AWS Textract AnalyzeExpense — deterministic field extraction for receipts
|
||||
* and invoices. Runs in parallel with the Claude vision pass; numbers from
|
||||
* Textract act as an anti-hallucination anchor for the final cross-check.
|
||||
*
|
||||
* Why receipt-specialized OCR over generic AnalyzeDocument: AnalyzeExpense is
|
||||
* tuned for the expense-document family (SUMMARY_FIELDS like TOTAL, TAX,
|
||||
* VENDOR_NAME, INVOICE_RECEIPT_DATE with field-level confidence scores).
|
||||
* Generic OCR returns raw text and positions — useful for nothing on its own.
|
||||
*
|
||||
* Failure model: every path is best-effort. If Textract returns an error, is
|
||||
* unsupported for this mime type, or the file is over the sync-API limit,
|
||||
* we return null and the caller falls back to Claude-only. Never throws.
|
||||
*/
|
||||
|
||||
import {
|
||||
TextractClient,
|
||||
AnalyzeExpenseCommand,
|
||||
type ExpenseDocument,
|
||||
type ExpenseField,
|
||||
} from '@aws-sdk/client-textract'
|
||||
|
||||
let _client: TextractClient | null = null
|
||||
|
||||
function getClient(): TextractClient {
|
||||
if (!_client) {
|
||||
_client = new TextractClient({
|
||||
region: process.env.AWS_REGION || 'eu-north-1',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
})
|
||||
}
|
||||
return _client
|
||||
}
|
||||
|
||||
// Sync AnalyzeExpense caps at 5 MB per document. Anything bigger we skip
|
||||
// rather than fall back to the async API — that adds S3 polling complexity
|
||||
// for a tail case. The common-path receipt is <1 MB.
|
||||
const MAX_SYNC_BYTES = 5 * 1024 * 1024
|
||||
|
||||
// Textract supports: PNG, JPEG, PDF, TIFF. HEIC/WebP → skip (Claude handles
|
||||
// them fine; a second read isn't worth converting the image).
|
||||
const SUPPORTED_MIMES = new Set(['application/pdf', 'image/jpeg', 'image/png', 'image/tiff'])
|
||||
|
||||
export interface TextractExpenseResult {
|
||||
total: { value: number; confidence: number } | null
|
||||
subtotal: { value: number; confidence: number } | null
|
||||
tax: { value: number; confidence: number } | null
|
||||
vendor: { value: string; confidence: number } | null
|
||||
date: { value: string; confidence: number } | null
|
||||
currency: string | null
|
||||
// Raw summary fields kept for audit and future use (e.g., line items).
|
||||
raw_summary: Array<{ type: string; value: string; confidence: number }>
|
||||
}
|
||||
|
||||
export async function analyzeExpenseWithTextract(
|
||||
fileBuffer: Buffer,
|
||||
mimeType: string
|
||||
): Promise<TextractExpenseResult | null> {
|
||||
if (!SUPPORTED_MIMES.has(mimeType)) return null
|
||||
if (fileBuffer.byteLength > MAX_SYNC_BYTES) return null
|
||||
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) return null
|
||||
|
||||
try {
|
||||
const client = getClient()
|
||||
const response = await client.send(
|
||||
new AnalyzeExpenseCommand({
|
||||
Document: { Bytes: fileBuffer },
|
||||
})
|
||||
)
|
||||
|
||||
const doc: ExpenseDocument | undefined = response.ExpenseDocuments?.[0]
|
||||
if (!doc) return null
|
||||
|
||||
const summary = doc.SummaryFields ?? []
|
||||
return parseSummaryFields(summary)
|
||||
} catch (err) {
|
||||
// Don't let OCR failure break the pipeline — the Claude pass still runs.
|
||||
// Log so we can see rate-limit / auth issues but return null to caller.
|
||||
console.error('[textract-expense] AnalyzeExpense failed:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseSummaryFields(fields: ExpenseField[]): TextractExpenseResult {
|
||||
const raw_summary = fields
|
||||
.map((f) => ({
|
||||
type: f.Type?.Text ?? 'UNKNOWN',
|
||||
value: f.ValueDetection?.Text ?? '',
|
||||
confidence: (f.ValueDetection?.Confidence ?? 0) / 100,
|
||||
}))
|
||||
.filter((f) => f.value)
|
||||
|
||||
const pickNumber = (type: string): { value: number; confidence: number } | null => {
|
||||
const field = fields.find((f) => f.Type?.Text === type)
|
||||
if (!field?.ValueDetection?.Text) return null
|
||||
const parsed = parseMoneyString(field.ValueDetection.Text)
|
||||
if (parsed == null) return null
|
||||
return { value: parsed, confidence: (field.ValueDetection.Confidence ?? 0) / 100 }
|
||||
}
|
||||
|
||||
const pickString = (type: string): { value: string; confidence: number } | null => {
|
||||
const field = fields.find((f) => f.Type?.Text === type)
|
||||
if (!field?.ValueDetection?.Text) return null
|
||||
return {
|
||||
value: field.ValueDetection.Text.trim(),
|
||||
confidence: (field.ValueDetection.Confidence ?? 0) / 100,
|
||||
}
|
||||
}
|
||||
|
||||
const rawDate = pickString('INVOICE_RECEIPT_DATE')
|
||||
return {
|
||||
total: pickNumber('TOTAL'),
|
||||
subtotal: pickNumber('SUBTOTAL'),
|
||||
tax: pickNumber('TAX'),
|
||||
vendor: pickString('VENDOR_NAME'),
|
||||
date: rawDate ? { value: normalizeDate(rawDate.value), confidence: rawDate.confidence } : null,
|
||||
currency: pickString('CURRENCY')?.value ?? null,
|
||||
raw_summary,
|
||||
}
|
||||
}
|
||||
|
||||
// Textract returns money strings like "123,45 kr", "$123.45", "1 234,56 SEK".
|
||||
// Strip everything but digits + separators, then normalize to period as
|
||||
// decimal. Returns null when we can't confidently parse.
|
||||
function parseMoneyString(raw: string): number | null {
|
||||
const cleaned = raw.replace(/[^\d.,-]/g, '').trim()
|
||||
if (!cleaned) return null
|
||||
|
||||
// Swedish: 1 234,56 → 1234.56 (comma = decimal, space/period = thousands)
|
||||
// US: 1,234.56 → 1234.56 (comma = thousands, period = decimal)
|
||||
// Heuristic: if both , and . present, the rightmost is the decimal.
|
||||
const lastComma = cleaned.lastIndexOf(',')
|
||||
const lastDot = cleaned.lastIndexOf('.')
|
||||
|
||||
let normalized: string
|
||||
if (lastComma === -1 && lastDot === -1) {
|
||||
normalized = cleaned
|
||||
} else if (lastComma > lastDot) {
|
||||
// Comma is decimal separator
|
||||
normalized = cleaned.replace(/\./g, '').replace(',', '.')
|
||||
} else {
|
||||
// Period is decimal separator
|
||||
normalized = cleaned.replace(/,/g, '')
|
||||
}
|
||||
|
||||
const num = Number(normalized)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
// Textract returns dates in many formats ("2024-03-14", "14/3/24", "March 14,
|
||||
// 2024"). We coerce to ISO where possible; leave the original string as a
|
||||
// fallback. The Claude pass will have its own date, so imperfect parse here
|
||||
// is fine — cross-check falls back to fuzzy matching if needed.
|
||||
function normalizeDate(raw: string): string {
|
||||
const trimmed = raw.trim()
|
||||
// Already ISO
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return trimmed.slice(0, 10)
|
||||
const parsed = new Date(trimmed)
|
||||
if (!isNaN(parsed.getTime())) return parsed.toISOString().slice(0, 10)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// Compares a Claude-extracted total against the Textract-extracted total.
|
||||
// Agreement tolerance is 1 öre (0.01 SEK) — anything more is a real
|
||||
// disagreement worth flagging, not rounding noise. Returns null when either
|
||||
// side didn't produce a total (no basis for comparison).
|
||||
export interface AgreementResult {
|
||||
agrees: boolean
|
||||
claude_total: number | null
|
||||
ocr_total: number | null
|
||||
ocr_confidence: number | null
|
||||
delta: number | null
|
||||
}
|
||||
|
||||
export function checkTotalsAgreement(
|
||||
claudeTotal: number | null | undefined,
|
||||
textract: TextractExpenseResult | null
|
||||
): AgreementResult | null {
|
||||
if (claudeTotal == null || !textract?.total) return null
|
||||
const delta = Math.abs(claudeTotal - textract.total.value)
|
||||
return {
|
||||
agrees: delta <= 0.01,
|
||||
claude_total: claudeTotal,
|
||||
ocr_total: textract.total.value,
|
||||
ocr_confidence: textract.total.confidence,
|
||||
delta,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user