/** * Provider registry + escalation. * * Default: self-hosted only. When the LLM is enabled (env + non-secret DB * overrides), 'assisted' mode runs self-hosted first and only escalates to the * LLM when critical fields are missing/low-confidence (cost trends to zero as * the vendor profile learns); 'only' mode uses the LLM with a self-hosted * fallback. The API key always comes from env, never the DB. */ import { selfHostedProvider } from './selfHostedProvider' import { createLlmProvider } from './llmProvider' import type { ExtractionInput } from './types' import type { ExtractionResult } from '../fieldExtractors' import { getInboxConfig, type InboxCtx } from '../inboxDb' const CRITICAL = ['documentNo', 'issueDate', 'grandTotal'] interface LlmConfig { enabled: boolean provider: string baseUrl: string apiKey: string model: string mode: 'assisted' | 'only' threshold: number } const resolveLlmConfig = async (ctx?: InboxCtx): Promise => { const cfg: any = (useRuntimeConfig() as any)?.ap?.llm || {} // Non-secret overrides from the settings page (key stays env-only). let override: any = {} if (ctx) { try { override = (await getInboxConfig(ctx, 'llm')) || {} } catch {} } return { enabled: (override.enabled ?? cfg.enabled) === true, provider: override.provider || cfg.provider || 'compatible', baseUrl: override.baseUrl || cfg.baseUrl || '', apiKey: cfg.apiKey || '', // env only model: override.model || cfg.model || '', mode: (override.mode || cfg.mode || 'assisted') as 'assisted' | 'only', threshold: Number(override.threshold ?? cfg.threshold ?? 0.6) } } const isWeak = (r: ExtractionResult, threshold: number): boolean => CRITICAL.some(k => (r.confidence?.[k] ?? 0) < threshold) const merge = (base: ExtractionResult, extra: ExtractionResult): ExtractionResult => { const fields: any = { ...base.fields } const confidence: any = { ...base.confidence } for (const key of Object.keys(extra.fields || {})) { const ec = extra.confidence?.[key] ?? 0 const bc = confidence[key] ?? 0 if ((fields as any)[key] === undefined || ec > bc) { fields[key] = (extra.fields as any)[key] confidence[key] = ec } } return { fields, confidence, bbox: base.bbox } } export interface ExtractionRunResult extends ExtractionResult { engine: string } export const runExtraction = async (input: ExtractionInput, opts: { ctx?: InboxCtx } = {}): Promise => { const selfRes = await selfHostedProvider.extract(input) const llm = await resolveLlmConfig(opts.ctx) if (!llm.enabled || !llm.baseUrl || !llm.model || (llm.provider !== 'ollama' && !llm.apiKey)) { return { ...selfRes, engine: 'self-hosted' } } const llmProvider = createLlmProvider(llm) if (llm.mode === 'only') { const r = await llmProvider.extract(input) if (Object.keys(r.fields || {}).length) return { ...r, engine: llmProvider.name } return { ...selfRes, engine: 'self-hosted' } } // assisted: escalate only when self-hosted is weak on critical fields if (isWeak(selfRes, llm.threshold)) { const r = await llmProvider.extract(input) if (Object.keys(r.fields || {}).length) { return { ...merge(selfRes, r), engine: `self-hosted+${llmProvider.name}` } } } return { ...selfRes, engine: 'self-hosted' } } export { selfHostedProvider }