441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
/**
|
|
* 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<boolean> {
|
|
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<string, unknown> | null {
|
|
let cleanResponse = response.replace(/<think>[\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<IInvoice | null> {
|
|
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,
|
|
options: {
|
|
num_ctx: 32768, // Larger context for long invoices + thinking
|
|
temperature: 0, // Deterministic for JSON extraction
|
|
},
|
|
}),
|
|
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<string, string> = {
|
|
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();
|