/** * Invoice extraction tuning - uses pre-generated markdown files * * Skips OCR stage, only runs GPT-OSS extraction on existing .debug.md files. * Use this to quickly iterate on extraction prompts and logic. * * Run with: tstest test/test.invoices.extraction.ts --verbose */ import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as fs from 'fs'; import * as path from 'path'; import { ensureMiniCpm } from './helpers/docker.js'; const OLLAMA_URL = 'http://localhost:11434'; const EXTRACTION_MODEL = 'gpt-oss:20b'; // Test these specific invoices (must have .debug.md files) const TEST_INVOICES = [ 'consensus_2021-09', 'hetzner_2022-04', 'qonto_2021-08', 'qonto_2021-09', ]; interface IInvoice { invoice_number: string; invoice_date: string; vendor_name: string; currency: string; net_amount: number; vat_amount: number; total_amount: number; } interface ITestCase { name: string; markdownPath: string; jsonPath: string; } // JSON extraction prompt for GPT-OSS 20B (sent AFTER the invoice text is provided) const JSON_EXTRACTION_PROMPT = `Extract key fields from the invoice. Return ONLY valid JSON. WHERE TO FIND DATA: - invoice_number, invoice_date, vendor_name: Look in the HEADER section at the TOP of PAGE 1 (near "Invoice no.", "Invoice date:", "Rechnungsnummer") - net_amount, vat_amount, total_amount: Look in the SUMMARY section at the BOTTOM (look for "Total", "Amount due", "Gesamtbetrag") RULES: 1. invoice_number: Extract ONLY the value (e.g., "R0015632540"), NOT the label "Invoice no." 2. invoice_date: Convert to YYYY-MM-DD format (e.g., "14/04/2022" → "2022-04-14") 3. vendor_name: The company issuing the invoice 4. currency: EUR, USD, or GBP 5. net_amount: Total before tax 6. vat_amount: Tax amount 7. total_amount: Final total with tax JSON only: {"invoice_number":"X","invoice_date":"YYYY-MM-DD","vendor_name":"X","currency":"EUR","net_amount":0,"vat_amount":0,"total_amount":0}`; /** * Ensure GPT-OSS 20B model is available */ async function ensureExtractionModel(): Promise { try { const response = await fetch(`${OLLAMA_URL}/api/tags`); if (response.ok) { const data = await response.json(); const models = data.models || []; if (models.some((m: { name: string }) => m.name === EXTRACTION_MODEL)) { console.log(` [Ollama] Model available: ${EXTRACTION_MODEL}`); return true; } } } catch { return false; } console.log(` [Ollama] Pulling ${EXTRACTION_MODEL}...`); const pullResponse = await fetch(`${OLLAMA_URL}/api/pull`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: EXTRACTION_MODEL, stream: false }), }); return pullResponse.ok; } /** * Parse amount from string (handles European format) */ function parseAmount(s: string | number | undefined): number { if (s === undefined || s === null) return 0; if (typeof s === 'number') return s; const match = s.match(/([\d.,]+)/); if (!match) return 0; const numStr = match[1]; const normalized = numStr.includes(',') && numStr.indexOf(',') > numStr.lastIndexOf('.') ? numStr.replace(/\./g, '').replace(',', '.') : numStr.replace(/,/g, ''); return parseFloat(normalized) || 0; } /** * Extract invoice number - minimal normalization */ function extractInvoiceNumber(s: string | undefined): string { if (!s) return ''; return s.replace(/\*\*/g, '').replace(/`/g, '').trim(); } /** * Extract date (YYYY-MM-DD) from response */ function extractDate(s: string | undefined): string { if (!s) return ''; let clean = s.replace(/\*\*/g, '').replace(/`/g, '').trim(); const isoMatch = clean.match(/(\d{4}-\d{2}-\d{2})/); if (isoMatch) return isoMatch[1]; const dmyMatch = clean.match(/(\d{1,2})[\/.](\d{1,2})[\/.](\d{4})/); if (dmyMatch) { return `${dmyMatch[3]}-${dmyMatch[2].padStart(2, '0')}-${dmyMatch[1].padStart(2, '0')}`; } return clean.replace(/[^\d-]/g, '').trim(); } /** * Extract currency */ function extractCurrency(s: string | undefined): string { if (!s) return 'EUR'; const upper = s.toUpperCase(); if (upper.includes('EUR') || upper.includes('€')) return 'EUR'; if (upper.includes('USD') || upper.includes('$')) return 'USD'; if (upper.includes('GBP') || upper.includes('£')) return 'GBP'; return 'EUR'; } /** * Extract JSON from response */ function extractJsonFromResponse(response: string): Record | null { let cleanResponse = response.replace(/[\s\S]*?<\/think>/g, '').trim(); const codeBlockMatch = cleanResponse.match(/```(?:json)?\s*([\s\S]*?)```/); const jsonStr = codeBlockMatch ? codeBlockMatch[1].trim() : cleanResponse; try { return JSON.parse(jsonStr); } catch { const jsonMatch = jsonStr.match(/\{[\s\S]*\}/); if (jsonMatch) { try { return JSON.parse(jsonMatch[0]); } catch { return null; } } return null; } } /** * Parse JSON response into IInvoice */ function parseJsonToInvoice(response: string): IInvoice | null { const parsed = extractJsonFromResponse(response); if (!parsed) return null; return { invoice_number: extractInvoiceNumber(String(parsed.invoice_number || '')), invoice_date: extractDate(String(parsed.invoice_date || '')), vendor_name: String(parsed.vendor_name || '').replace(/\*\*/g, '').replace(/`/g, '').trim(), currency: extractCurrency(String(parsed.currency || '')), net_amount: parseAmount(parsed.net_amount as string | number), vat_amount: parseAmount(parsed.vat_amount as string | number), total_amount: parseAmount(parsed.total_amount as string | number), }; } /** * Extract invoice from markdown using GPT-OSS 20B (streaming) */ async function extractInvoiceFromMarkdown(markdown: string, queryId: string): Promise { const startTime = Date.now(); console.log(` [${queryId}] Invoice: ${markdown.length} chars, Prompt: ${JSON_EXTRACTION_PROMPT.length} chars`); const response = await fetch(`${OLLAMA_URL}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: EXTRACTION_MODEL, messages: [ { role: 'user', content: 'Hi there, how are you?' }, { role: 'assistant', content: 'Good, how can I help you today?' }, { role: 'user', content: `Here is an invoice document:\n\n${markdown}` }, { role: 'assistant', content: 'I have read the invoice document you provided. I can see all the text content. What would you like me to do with it?' }, { role: 'user', content: JSON_EXTRACTION_PROMPT }, ], stream: true, }), signal: AbortSignal.timeout(120000), // 2 min timeout }); if (!response.ok) { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(` [${queryId}] ERROR: ${response.status} (${elapsed}s)`); throw new Error(`Ollama API error: ${response.status}`); } // Stream the response let content = ''; let thinkingContent = ''; let thinkingStarted = false; let outputStarted = false; const reader = response.body!.getReader(); const decoder = new TextDecoder(); try { while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); for (const line of chunk.split('\n').filter(l => l.trim())) { try { const json = JSON.parse(line); const thinking = json.message?.thinking || ''; if (thinking) { if (!thinkingStarted) { process.stdout.write(` [${queryId}] THINKING: `); thinkingStarted = true; } process.stdout.write(thinking); thinkingContent += thinking; } const token = json.message?.content || ''; if (token) { if (!outputStarted) { if (thinkingStarted) process.stdout.write('\n'); process.stdout.write(` [${queryId}] OUTPUT: `); outputStarted = true; } process.stdout.write(token); content += token; } } catch { // Ignore parse errors for partial chunks } } } } finally { if (thinkingStarted || outputStarted) process.stdout.write('\n'); } const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(` [${queryId}] Done: ${thinkingContent.length} thinking, ${content.length} output (${elapsed}s)`); return parseJsonToInvoice(content); } /** * Normalize date to YYYY-MM-DD */ function normalizeDate(dateStr: string | null): string { if (!dateStr) return ''; if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr; const monthMap: Record = { JAN: '01', FEB: '02', MAR: '03', APR: '04', MAY: '05', JUN: '06', JUL: '07', AUG: '08', SEP: '09', OCT: '10', NOV: '11', DEC: '12', }; let match = dateStr.match(/^(\d{1,2})-([A-Z]{3})-(\d{4})$/i); if (match) { return `${match[3]}-${monthMap[match[2].toUpperCase()] || '01'}-${match[1].padStart(2, '0')}`; } match = dateStr.match(/^(\d{1,2})[\/.](\d{1,2})[\/.](\d{4})$/); if (match) { return `${match[3]}-${match[2].padStart(2, '0')}-${match[1].padStart(2, '0')}`; } return dateStr; } /** * Normalize invoice number for comparison (remove spaces, lowercase) */ function normalizeInvoiceNumber(s: string): string { return s.replace(/\s+/g, '').toLowerCase(); } /** * Compare extracted invoice against expected */ function compareInvoice( extracted: IInvoice, expected: IInvoice ): { match: boolean; errors: string[] } { const errors: string[] = []; // Invoice number - normalize spaces for comparison const extNum = normalizeInvoiceNumber(extracted.invoice_number || ''); const expNum = normalizeInvoiceNumber(expected.invoice_number || ''); if (extNum !== expNum) { errors.push(`invoice_number: expected "${expected.invoice_number}", got "${extracted.invoice_number}"`); } if (normalizeDate(extracted.invoice_date) !== normalizeDate(expected.invoice_date)) { errors.push(`invoice_date: expected "${expected.invoice_date}", got "${extracted.invoice_date}"`); } if (Math.abs(extracted.total_amount - expected.total_amount) > 0.02) { errors.push(`total_amount: expected ${expected.total_amount}, got ${extracted.total_amount}`); } if (extracted.currency?.toUpperCase() !== expected.currency?.toUpperCase()) { errors.push(`currency: expected "${expected.currency}", got "${extracted.currency}"`); } return { match: errors.length === 0, errors }; } /** * Find test cases with existing debug markdown */ function findTestCases(): ITestCase[] { const invoicesDir = path.join(process.cwd(), '.nogit/invoices'); if (!fs.existsSync(invoicesDir)) return []; const testCases: ITestCase[] = []; for (const invoiceName of TEST_INVOICES) { const markdownPath = path.join(invoicesDir, `${invoiceName}.debug.md`); const jsonPath = path.join(invoicesDir, `${invoiceName}.json`); if (fs.existsSync(markdownPath) && fs.existsSync(jsonPath)) { testCases.push({ name: invoiceName, markdownPath, jsonPath, }); } else { if (!fs.existsSync(markdownPath)) { console.warn(`Warning: Missing markdown: ${markdownPath}`); } if (!fs.existsSync(jsonPath)) { console.warn(`Warning: Missing JSON: ${jsonPath}`); } } } return testCases; } // ============ TESTS ============ const testCases = findTestCases(); console.log(`\n========================================`); console.log(` EXTRACTION TUNING TEST`); console.log(` (Skips OCR, uses existing .debug.md)`); console.log(`========================================`); console.log(` Testing ${testCases.length} invoices:`); for (const tc of testCases) { console.log(` - ${tc.name}`); } console.log(`========================================\n`); tap.test('Setup Ollama + GPT-OSS 20B', async () => { const ollamaOk = await ensureMiniCpm(); expect(ollamaOk).toBeTrue(); const extractionOk = await ensureExtractionModel(); expect(extractionOk).toBeTrue(); }); let passedCount = 0; let failedCount = 0; for (const tc of testCases) { tap.test(`Extract ${tc.name}`, async () => { const expected: IInvoice = JSON.parse(fs.readFileSync(tc.jsonPath, 'utf-8')); const markdown = fs.readFileSync(tc.markdownPath, 'utf-8'); console.log(`\n ========================================`); console.log(` === ${tc.name} ===`); console.log(` ========================================`); console.log(` EXPECTED: ${expected.invoice_number} | ${expected.invoice_date} | ${expected.total_amount} ${expected.currency}`); console.log(` Markdown: ${markdown.length} chars`); const startTime = Date.now(); const extracted = await extractInvoiceFromMarkdown(markdown, tc.name); if (!extracted) { failedCount++; console.log(`\n Result: ✗ FAILED TO PARSE (${((Date.now() - startTime) / 1000).toFixed(1)}s)`); return; } const elapsedMs = Date.now() - startTime; console.log(` EXTRACTED: ${extracted.invoice_number} | ${extracted.invoice_date} | ${extracted.total_amount} ${extracted.currency}`); const result = compareInvoice(extracted, expected); if (result.match) { passedCount++; console.log(`\n Result: ✓ MATCH (${(elapsedMs / 1000).toFixed(1)}s)`); } else { failedCount++; console.log(`\n Result: ✗ MISMATCH (${(elapsedMs / 1000).toFixed(1)}s)`); console.log(` ERRORS:`); result.errors.forEach(e => console.log(` - ${e}`)); } }); } tap.test('Summary', async () => { const totalInvoices = testCases.length; const accuracy = totalInvoices > 0 ? (passedCount / totalInvoices) * 100 : 0; console.log(`\n========================================`); console.log(` Extraction Tuning Summary`); console.log(`========================================`); console.log(` Model: ${EXTRACTION_MODEL}`); console.log(` Passed: ${passedCount}/${totalInvoices}`); console.log(` Failed: ${failedCount}/${totalInvoices}`); console.log(` Accuracy: ${accuracy.toFixed(1)}%`); console.log(`========================================\n`); }); export default tap.start();