/** * Optional LLM extraction provider (OFF by default). * * Configured entirely via runtimeConfig.ap.llm (env-backed): provider, baseUrl, * apiKey, model. Built against an OpenAI-compatible chat/JSON interface so it * works with OpenAI, OpenRouter, a local Ollama/vLLM server, or Anthropic * (native Messages API). The API key stays server-side. Fail-soft: any error * returns an empty extraction so the pipeline keeps the self-hosted result. */ import type { ExtractionProvider, ExtractionInput } from './types' import type { ExtractionResult } from '../fieldExtractors' import type { ZugferdInvoiceData } from '../zugferd/buildInvoiceXml' const SYSTEM_PROMPT = `You extract structured data from a single supplier invoice. Return ONLY a compact JSON object, no prose, with these keys (omit unknown ones): documentNo (string), issueDate (yyyy-mm-dd), dueDate (yyyy-mm-dd), currency (ISO e.g. EUR), sellerName, sellerVatId, iban, grandTotal (number, gross), netTotal (number), taxTotal (number), taxRate (number, percent), isTaxIncluded (boolean), lines (array of {name, qty, netPrice, lineTotal, taxRate}). Use dot as decimal separator. Do not invent values you cannot find.` const LLM_CONFIDENCE = 0.85 const mapJsonToResult = (j: any): ExtractionResult => { const fields: Partial = {} const confidence: Record = {} const put = (key: string, v: any) => { if (v !== undefined && v !== null && v !== '') { (fields as any)[key] = v; confidence[key] = LLM_CONFIDENCE } } put('documentNo', j.documentNo) put('issueDate', j.issueDate) put('dueDate', j.dueDate) put('currency', j.currency || 'EUR') if (typeof j.grandTotal === 'number') put('grandTotal', j.grandTotal) if (typeof j.netTotal === 'number') { put('lineTotal', j.netTotal); (fields as any).taxBasisTotal = j.netTotal } if (typeof j.taxTotal === 'number') put('taxTotal', j.taxTotal) if (typeof j.isTaxIncluded === 'boolean') put('isTaxIncluded', j.isTaxIncluded) const seller: any = {} if (j.sellerName) seller.name = j.sellerName if (j.sellerVatId) seller.vatId = j.sellerVatId if (Object.keys(seller).length) { fields.seller = seller; if (seller.name) confidence['seller.name'] = LLM_CONFIDENCE; if (seller.vatId) confidence['seller.vatId'] = LLM_CONFIDENCE } if (j.iban) { fields.paymentIban = String(j.iban).replace(/\s+/g, ''); confidence['paymentIban'] = LLM_CONFIDENCE } if (typeof j.taxRate === 'number' || typeof j.taxTotal === 'number') { fields.taxes = [{ rate: Number(j.taxRate) || 0, amount: Number(j.taxTotal) || 0, basis: Number(j.netTotal) || 0, category: (Number(j.taxRate) || 0) > 0 ? 'S' : 'Z' }] confidence['taxes'] = LLM_CONFIDENCE } if (Array.isArray(j.lines)) { fields.lines = j.lines.map((l: any, i: number) => ({ lineId: i + 1, name: String(l.name || '-'), qty: Number(l.qty) || 1, unitCode: 'C62', netPrice: Number(l.netPrice) || 0, lineTotal: Number(l.lineTotal) || 0, taxRate: Number(l.taxRate) || Number(j.taxRate) || 0, taxCategory: (Number(l.taxRate) || 0) > 0 ? 'S' : 'Z' })) confidence['lines'] = LLM_CONFIDENCE } return { fields, confidence, bbox: {} } } const extractJson = (raw: string): any => { if (!raw) return null const fence = raw.match(/```(?:json)?\s*([\s\S]*?)```/i) const candidate = fence ? fence[1] : raw const start = candidate.indexOf('{') const end = candidate.lastIndexOf('}') if (start === -1 || end === -1) return null try { return JSON.parse(candidate.slice(start, end + 1)) } catch { return null } } export const createLlmProvider = (cfg: { provider: string; baseUrl: string; apiKey: string; model: string }): ExtractionProvider => ({ name: `llm:${cfg.provider}`, async extract(input: ExtractionInput): Promise { const empty: ExtractionResult = { fields: {}, confidence: {}, bbox: {} } const userText = (input.text || '').slice(0, 16000) if (!userText.trim()) return empty try { if (cfg.provider === 'anthropic') { const res: any = await $fetch(`${cfg.baseUrl.replace(/\/$/, '')}/v1/messages`, { method: 'POST', headers: { 'x-api-key': cfg.apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, body: { model: cfg.model, max_tokens: 1500, system: SYSTEM_PROMPT, messages: [{ role: 'user', content: `Invoice text:\n\n${userText}` }] } }) const text = Array.isArray(res?.content) ? res.content.map((c: any) => c.text || '').join('') : '' const json = extractJson(text) return json ? mapJsonToResult(json) : empty } // OpenAI-compatible (openai | compatible | ollama) const res: any = await $fetch(`${cfg.baseUrl.replace(/\/$/, '')}/chat/completions`, { method: 'POST', headers: { Authorization: `Bearer ${cfg.apiKey}`, 'content-type': 'application/json' }, body: { model: cfg.model, temperature: 0, messages: [ { role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: `Invoice text:\n\n${userText}` } ] } }) const text = res?.choices?.[0]?.message?.content || '' const json = extractJson(text) return json ? mapJsonToResult(json) : empty } catch (e: any) { console.warn('[Inbox] LLM provider failed (keeping self-hosted result):', e?.message || e) return empty } } }) export default createLlmProvider