500 lines
23 KiB
JavaScript
500 lines
23 KiB
JavaScript
'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 <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') });
|
|
|
|
// ─── 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 <api-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);
|
|
}); |