'use strict'; /** * Example: PDF → per-page LLM processing. * * Demonstrates reading a PDF with `pdftotext -layout`, splitting the output * into pages on the form-feed character (\f), and feeding each page through * LLMOrbiter in a foreach loop with a simple prompt. * * Usage: * OPENROUTER_API_KEY=sk-or-... node examples/pdf-pages.js * node examples/pdf-pages.js --key sk-or-... --model */ import { execFileSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; import { LLMOrbiter, OpenRouterProvider, defineTool, DirectReturnError } from '../src/index.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: path.resolve(__dirname, '.env'), quiet: true }); // ─── Prompt logfile ───────────────────────────────────────────────────────────── // Appends the full prompt of every LLM call so we can debug prompt composition. const logFile = path.resolve(__dirname, 'prompts.log'); function logPrompt(prompt) { fs.appendFileSync(logFile, `\n${'='.repeat(80)}\n${new Date().toISOString()}\n${'='.repeat(80)}\n${prompt}\n`); } function logEntry(label, data) { fs.appendFileSync(logFile, `\n--- ${label} @ ${new Date().toISOString()} ---\n${typeof data === 'string' ? data : JSON.stringify(data, null, 2)}\n`); } // ─── CLI args ─────────────────────────────────────────────────────────────────── const args = process.argv.slice(2); function getArg(flag) { const idx = args.indexOf(flag); return idx !== -1 && args[idx + 1] ? args[idx + 1] : null; } const apiKey = getArg('--key') || process.env.OPENROUTER_API_KEY; //const model = getArg('--model') || '~deepseek/deepseek-flash-latest'; //const model = getArg('--model') || 'moonshotai/kimi-k3'; //const model = getArg('--model') || '~openai/gpt-terra-latest:floor'; //const model = getArg('--model') || '~openai/gpt-sol-latest:floor'; //const model = getArg('--model') || '~anthropic/claude-sonnet-latest'; //const model = getArg('--model') || 'google/gemini-3.8-flash'; const model = getArg('--model') || '~openai/gpt-luna-latest:floor'; const pdfPath = getArg('--pdf') || path.resolve(__dirname, 'invoice.pdf'); if (!apiKey) { console.error('Error: Missing API key. Set OPENROUTER_API_KEY in examples/.env or pass --key '); process.exit(1); } // ─── Accumulated manifest state ───────────────────────────────────────────────── // Split into two parts that mirror the two tools, so the prompt sections match // the tool names and don't confuse the LLM. const manifestBasedata = { date: null, kLieferant: null, lieferantName: null, manifestNr: null, manifestType: null, }; const manifestNotes = []; const manifestItems = { items: [], pagesFrom: null, pagesTo: null, }; // Current page being processed (set in the loop before each run). let currentPage = 0; // ─── Tools ────────────────────────────────────────────────────────────────────── // A single tool with three strictly optional components. Only include the // components that have new data for this batch; omit the rest. const outputManifestTool = defineTool({ name: 'output_manifest', description: 'Stores extracted invoice/delivery-note data: header metadata (basedata), notes, and product line items. Each component is strictly optional — only include the ones that carry new data.', strict: false, parameters: { type: 'object', additionalProperties: false, required: [], properties: { basedata: { anyOf: [ { type: 'object', additionalProperties: false, properties: { date: { type: ['string', 'null'], description: 'Document date (ISO 8601)', }, kLieferant: { type: ['number', 'null'], description: 'Lieferant ID', }, lieferantName: { type: 'string', description: 'Lieferant name', }, manifestNr: { type: ['string', 'null'], description: 'Lieferscheinnummer or Rechnungsnummer', }, manifestType: { anyOf: [ { type: 'string', enum: ['rechnung', 'lieferschein'] }, { type: 'null' }, ], description: 'Document type', }, }, }, { type: 'null' }, ], description: 'Header/metadata. Only include fields that are currently missing or changed.', }, notes: { anyOf: [ { type: 'array', items: { type: 'string' } }, { type: 'null' }, ], description: 'New noteworthy details (in German), for a human to double-check the result. Only include notes not already present.', }, items: { anyOf: [ { type: 'object', additionalProperties: false, properties: { numberOfItemsToAdd: { type: 'number', description: 'Number of NEW line items being added in this call (not the running total). Helps you keep track of how many items you are adding.', }, items: { type: 'array', description: 'Invoice/delivery product line items', items: { type: 'object', additionalProperties: false, required: ['position', 'count', 'vpe', 'unit', 'articleName', 'liefArtNr', 'mwst', 'eknetto'], properties: { position: { type: 'number', description: 'Position' }, count: { type: 'number', description: 'Quantity (Menge)' }, vpe: { type: 'number', description: 'Packaging unit size (VPE). Default: 1. (count * vpe = total individual sellable items). A bulk pack like VE100 means vpe = 100.', }, unit: { type: 'string', description: 'Unit of the individual sellable item (e.g. Stk., Beutel, Flasche, Box, Set). NEVER "VE" or "VPE".' }, articleName: { type: 'string', description: 'Article name' }, liefArtNr: { type: 'string', description: 'Supplier article number (Art. Nr.)' }, mwst: { anyOf: [ { type: 'string', enum: ['7', '19'] }, { type: 'null' }, ], description: 'VAT rate (no % sign); null if missing', }, eknetto: { type: ['number', 'null'], description: 'Net price per item or per vpe in euro cents; null if missing; DO NOT USE THE TOTAL, USE THE PER PIECE PRICE', }, }, }, }, }, }, { type: 'null' }, ], description: 'New product line items. Only include items not already present.', }, }, }, invoke: async (args) => { // 1. basedata — merge only the fields the LLM actually filled in. if (args.basedata && typeof args.basedata === 'object') { for (const key of ['date', 'kLieferant', 'lieferantName', 'manifestNr', 'manifestType']) { if (args.basedata[key] != null) manifestBasedata[key] = args.basedata[key]; } } // 2. notes — accept an array of strings; dedupe so re-sent notes don't accumulate. if (Array.isArray(args.notes)) { for (const n of args.notes) { if (n && !manifestNotes.includes(n)) manifestNotes.push(n); } } // 3. items — add only NEW items (dedupe by position). if (args.items && typeof args.items === 'object' && Array.isArray(args.items.items)) { const existing = new Set(manifestItems.items.map((it) => it.position)); for (const it of args.items.items) { if (!existing.has(it.position)) { manifestItems.items.push({ ...it, page: currentPage }); existing.add(it.position); } } // numberOfItemsToAdd is intentionally ignored — it only helps the LLM // keep track of how many items it is adding in this call. // Track the page range the current items come from. const pages = manifestItems.items.map((it) => it.page).filter((p) => p != null); if (pages.length) { manifestItems.pagesFrom = Math.min(...pages); manifestItems.pagesTo = Math.max(...pages); } } // Stop the LLM loop and return the accumulated data to the caller. throw new DirectReturnError({ basedata: manifestBasedata, notes: manifestNotes, items: manifestItems, }); }, }); // ─── Base extraction prompt ───────────────────────────────────────────────────── const BASE_PROMPT = `Extract metadata and product line items from the provided invoice or delivery note. Call the output_manifest tool with the extracted data. Do not answer with plain text once extraction is complete. Rules: - manifestType: "rechnung" or "lieferschein". For "lieferschein", prices and taxes must be null. - Exclude non-product lines (shipping, handling, etc.). - Line items: Extract count, vpe (default: 1), liefArtNr, mwst (7 or 19), and eknetto per piece(euro cents). - Match supplier against the provided list for kLieferant and lieferantName. For unknown suppliers, extract lieferantName and set kLieferant to null. - Write notes strictly in German. - The output_manifest tool has three strictly optional components: basedata, notes, items. Only include a component when it carries genuinely new data for this batch; omit the rest. - Include basedata ONLY when at least one of its fields is currently null/empty and you can fill it in, or when a value actually changes. If all basedata fields are already set and unchanged, omit basedata. - Include items ONLY when this batch contains new product line items not already present. If there are no new items, omit items. - Include notes ONLY when there is a genuinely new noteworthy detail not already in the notes list. These notes are for a human to double-check the extraction result, so record anything ambiguous, unusual, or worth verifying (e.g. packaging interpretations, excluded lines, unavailable articles). VPE (Verpackungseinheit) Logic: VPE represents the number of individually sellable units inside a bulk item. If not explicitly split for individual sale, default to 1. - "20 Boxen á 10 Stück" -> VPE = 20. - "10 Gläser á 100 Filter" -> VPE = 10 (we sell the 10 glasses individually). - "100 Stück" -> VPE = 1 (sold as one bulk pack). - We don't sell the individual filters or Pieces, but typical VPEs like Beutel, Glässer, Boxen, Hefte - That menas that in "Y Hefte á X Blatt" the VPE is NOT X but: Y - a so called "Display" also means VPE. So "20er Display" means VPE = 20 - eknetto is the price of one full vpe Unit (ME) rules: - The unit (ME) must ALWAYS refer to the individual sellable item (e.g. Stk., Beutel, Flasche, Box, Set). It must NEVER be "VE" or "VPE". - A packaging size like "VE100" means the bulk pack contains 100 individual items: set vpe = 100 and unit = the individual item (e.g. Stk.). - Example: "Einhängesiebe Stahl, VE100" -> vpe = 100, unit = "Stk.", count = 1 (one bulk pack of 100 sieves).`; // ─── Read PDF and split into pages ────────────────────────────────────────────── function readPdfPages(file) { // pdftotext -layout preserves column layout; pages are separated by \f (form feed) const raw = execFileSync('pdftotext', ['-layout', file, '-'], { encoding: 'utf8' }); // Split on form feed, drop any leading/trailing empty chunks return raw .split('\f') .map((p) => p.trim()) .filter((p) => p.length > 0); } // ─── Write output table ───────────────────────────────────────────────────────── // Meta base data at the top, then a table of the items. function writeOutputTable() { const lines = []; lines.push('=== Manifest Base Data ==='); lines.push(`Date: ${manifestBasedata.date ?? ''}`); lines.push(`Lieferant ID: ${manifestBasedata.kLieferant ?? ''}`); lines.push(`Lieferant Name: ${manifestBasedata.lieferantName ?? ''}`); lines.push(`Manifest Nr: ${manifestBasedata.manifestNr ?? ''}`); lines.push(`Manifest Type: ${manifestBasedata.manifestType ?? ''}`); lines.push(`Number of Items: ${manifestItems.items.length}`); if (manifestNotes.length) { lines.push('Notes:'); for (const n of manifestNotes) lines.push(` - ${n}`); } lines.push(''); lines.push('=== Items ==='); const headers = ['Pos', 'Art. Nr.', 'Article Name', 'Count', 'VPE', 'Unit', 'MwSt', 'EK Netto (ct)']; const rows = manifestItems.items.map((it) => [ it.position, it.liefArtNr, it.articleName, it.count, it.vpe, it.unit, it.mwst, it.eknetto, ]); // Column widths const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] ?? '').length))); const fmtRow = (cells) => cells.map((c, i) => String(c ?? '').padEnd(widths[i])).join(' '); lines.push(fmtRow(headers)); lines.push(widths.map((w) => '-'.repeat(w)).join(' ')); for (const r of rows) lines.push(fmtRow(r)); const outFile = path.resolve(__dirname, 'outputTable.txt'); fs.writeFileSync(outFile, lines.join('\n') + '\n'); console.log(`\n📄 Wrote ${outFile}`); } // ─── Write verification JSON ──────────────────────────────────────────────────── // Manifest Nr, Manifest Type, and items (without article name / date) for // verification. If the file already exists, report whether it is identical. function writeVerificationJson() { const payload = { manifestNr: manifestBasedata.manifestNr, manifestType: manifestBasedata.manifestType, items: manifestItems.items.map((it) => ({ position: it.position, liefArtNr: it.liefArtNr, count: it.count, vpe: it.vpe, mwst: it.mwst, eknetto: it.eknetto, })), }; const outFile = path.resolve(__dirname, 'outputVerification.json'); const json = JSON.stringify(payload, null, 2) + '\n'; if (fs.existsSync(outFile)) { const existing = fs.readFileSync(outFile, 'utf8'); if (existing === json) { console.log(`\n✅ ${outFile} already exists and is identical.`); } else { console.log(`\n⚠️ ${outFile} already exists but DIFFERS from the new result.`); printJsonDiff(existing, json); } } else { fs.writeFileSync(outFile, json); console.log(`\n📄 Wrote ${outFile}`); } } // ─── Print a structured diff between two verification JSONs ───────────────────── // Parses both and compares item-by-item (keyed by position), showing the item's // article number and exactly which fields differ. function printJsonDiff(oldStr, newStr) { let oldObj, newObj; try { oldObj = JSON.parse(oldStr); newObj = JSON.parse(newStr); } catch { // Fall back to a plain line diff if JSON parsing fails. const oldLines = oldStr.split('\n'); const newLines = newStr.split('\n'); const max = Math.max(oldLines.length, newLines.length); console.log(`\n${'─'.repeat(60)}`); for (let i = 0; i < max; i++) { const a = oldLines[i]; const b = newLines[i]; if (a === b) continue; if (a === undefined) console.log(` ${' '.repeat(3)}${b}`); else if (b === undefined) console.log(`- ${a}`); else { console.log(`- ${a}`); console.log(`+ ${b}`); } } console.log(`${'─'.repeat(60)}`); return; } console.log(`\n${'─'.repeat(60)}`); // Top-level scalar fields for (const key of ['manifestNr', 'manifestType']) { if (oldObj[key] !== newObj[key]) { console.log(`${key}: ${JSON.stringify(oldObj[key])} -> ${JSON.stringify(newObj[key])}`); } } // Items, keyed by position const oldItems = new Map((oldObj.items || []).map((it) => [it.position, it])); const newItems = new Map((newObj.items || []).map((it) => [it.position, it])); const allPositions = new Set([...oldItems.keys(), ...newItems.keys()]); for (const pos of [...allPositions].sort((a, b) => a - b)) { const o = oldItems.get(pos); const n = newItems.get(pos); if (!o) { console.log(`+ item ${pos} (${n.liefArtNr}) ADDED`); continue; } if (!n) { console.log(`- item ${pos} (${o.liefArtNr}) REMOVED`); continue; } const diffs = []; for (const key of ['liefArtNr', 'count', 'vpe', 'mwst', 'eknetto']) { if (o[key] !== n[key]) diffs.push(`${key}: ${JSON.stringify(o[key])} -> ${JSON.stringify(n[key])}`); } if (diffs.length) { console.log(`item ${pos} (${n.liefArtNr}):`); for (const d of diffs) console.log(` ${d}`); } } console.log(`${'─'.repeat(60)}`); } // ─── Main ─────────────────────────────────────────────────────────────────────── async function main() { console.log(`Reading PDF: ${pdfPath}`); const pages = readPdfPages(pdfPath); console.log(`Found ${pages.length} page(s).\n`); const provider = new OpenRouterProvider({ apiKey }); let llmCallStart = null; const orbiter = new LLMOrbiter(provider, { onLLMCall: (call) => { llmCallStart = Date.now(); console.log(`🤖 LLM call: model="${call.model}" promptSize=${call.promptSize} messages=${call.messages}`); }, onStatus: (status) => console.log(`⚡ ${status}`), onToolCall: (call) => { console.log(`🔧 Tool called: ${call.name}(${JSON.stringify(call.args)})`); logEntry(`TOOL CALL ${call.name}`, call.args); }, onToolResult: (res) => logEntry(`TOOL RESULT ${res.name}`, res.result), onToolError: (err) => logEntry(`TOOL ERROR ${err.name}`, err.error), onUsage: (usage) => { const elapsed = llmCallStart != null ? ((Date.now() - llmCallStart) / 1000).toFixed(3) : '?'; console.log(`📊 usage: in=${usage.input} cached=${usage.cached} out=${usage.output} cost=${usage.cost} (${elapsed}s)`); }, }); // Feed pages through LLMOrbiter. If there are fewer than 5 pages, process all // of them in a single run; otherwise batch them 3 pages per call. const batchSize = pages.length;//< 5 ? pages.length : 3; const numBatches = Math.ceil(pages.length / batchSize); for (let b = 0; b < numBatches; b++) { const start = b * batchSize; const end = Math.min(start + batchSize, pages.length); const batchPages = pages.slice(start, end); const firstPage = start + 1; const lastPage = end; currentPage = firstPage; console.log(`\n▸ Processing pages ${firstPage}-${lastPage}/${pages.length}...`); const itemPages = manifestItems.pagesFrom != null ? `page ${manifestItems.pagesFrom} to ${manifestItems.pagesTo}` : 'no items yet'; const pageContent = batchPages .map((p, idx) => `--- Page ${firstPage + idx} of ${pages.length} ---\n${p}`) .join('\n\n'); const prompt = `${BASE_PROMPT} You are currently processing pages ${firstPage} to ${lastPage} of ${pages.length}. Current manifest_basedata (fill in what is still missing): ${JSON.stringify(manifestBasedata, null, 2)} Current notes (add only NEW notes not already present): ${JSON.stringify(manifestNotes, null, 2)} Current manifest_items (add only NEW items not already present; current items are from ${itemPages}): ${JSON.stringify(manifestItems, null, 2)} Page content (pages ${firstPage} to ${lastPage} of ${pages.length}): ${pageContent}`; logPrompt(prompt); const result = await orbiter.run({ prompt, model, tools: [outputManifestTool], }); logEntry(`LLM OUTPUT pages ${firstPage}-${lastPage}`, result.content); // The tool throws DirectReturnError to stop the loop and hand back the // accumulated data. If that happened, we're done — no more batches. if (result.directReturn) { console.log(`\n⏹️ Direct return after pages ${firstPage}-${lastPage}.`); break; } } console.log('\n✅ Done.'); writeOutputTable(); writeVerificationJson(); } main().catch((err) => { console.error('FATAL:', err); process.exit(1); });