This commit is contained in:
seb
2026-09-20 07:22:14 +02:00
parent fb2ab77ab8
commit 043facff48
3 changed files with 43 additions and 11 deletions

View File

@@ -4330,7 +4330,7 @@
},
"responses": {
"200": {
"description": "`text/event-stream` — emits `status`, `llm_call`, `tool_call`, `usage`,\n`progress`, `result` (and `error` on failure). See SSE Events table below.\n\n**ManifestResult shape** (used by `progress` and `result`):\n\n```json\n{\n \"basedata\": {\n \"date\": \"2026-01-15\",\n \"kLieferant\": 42,\n \"lieferantName\": \"Muster GmbH\",\n \"manifestNr\": \"LS-2026-001\",\n \"manifestType\": \"lieferschein\"\n },\n \"notes\": [\"Pos. 3: VPE-Angabe fehlte, Standardwert 1 angenommen.\"],\n \"items\": {\n \"pagesFrom\": 1,\n \"pagesTo\": 2,\n \"items\": [\n {\n \"position\": 1,\n \"count\": 10,\n \"vpe\": 1,\n \"unit\": \"Stk.\",\n \"articleName\": \"Einhängesieb Stahl\",\n \"liefArtNr\": \"ES-100\",\n \"mwst\": \"19\",\n \"eknetto\": 450,\n \"page\": 1\n }\n ]\n }\n}\n```\n",
"description": "`text/event-stream` — emits `status`, `llm_call`, `tool_call`, `usage`,\n`progress`, `result` (and `error` on failure). See SSE Events table below.\n\n**ManifestResult shape** (used by `progress` and `result`):\n\n```json\n{\n \"basedata\": {\n \"date\": \"2026-01-15\",\n \"lieferant_id\": 42,\n \"lieferantName\": \"Muster GmbH\",\n \"manifestNr\": \"LS-2026-001\",\n \"manifestType\": \"lieferschein\"\n },\n \"notes\": [\"Pos. 3: VPE-Angabe fehlte, Standardwert 1 angenommen.\"],\n \"items\": {\n \"pagesFrom\": 1,\n \"pagesTo\": 2,\n \"items\": [\n {\n \"position\": 1,\n \"count\": 10,\n \"vpe\": 1,\n \"unit\": \"Stk.\",\n \"articleName\": \"Einhängesieb Stahl\",\n \"liefArtNr\": \"ES-100\",\n \"mwst\": \"19\",\n \"eknetto\": 450,\n \"page\": 1\n }\n ]\n }\n}\n```\n",
"content": {
"text/event-stream": {
"schema": {

View File

@@ -51,7 +51,7 @@ The response is a `text/event-stream`. Events are emitted in order until the str
{
"basedata": {
"date": "2026-01-15",
"kLieferant": 42,
"lieferant_id": 42,
"lieferantName": "Muster GmbH",
"manifestNr": "LS-2026-001",
"manifestType": "lieferschein"

View File

@@ -4,6 +4,7 @@ import { execFileSync } from 'child_process';
import multer from 'multer';
import { LLMOrbiter, OpenRouterProvider, defineTool, DirectReturnError } from 'libllmorbiter';
import * as dbCompanySetting from '../../db/companysetting.js';
import * as dbSupplier from '../../db/supplier.js';
// ─── Multer — memory storage (no temp files); accepts PDF or text ───────────────
const upload = multer({
@@ -67,7 +68,7 @@ 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.
- Match supplier against the provided list for lieferant_id and lieferantName. For unknown suppliers, extract lieferantName and set lieferant_id 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.
@@ -89,6 +90,17 @@ Unit (ME) rules:
- 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).`;
function buildSystemPrompt(suppliers = []) {
const supplierList = suppliers.length > 0
? suppliers.map((s) => `${s.id} | ${s.name}`).join('\n')
: '(no suppliers registered)';
return `${BASE_PROMPT}
Suppliers (lieferant_id | lieferant_name):
${supplierList}`;
}
// ─── Shared SSE manifest extraction (POST /delivery/parse) ─────────────────────
/**
* Fetches the OpenRouter API key, opens the SSE stream, and runs the LLM
@@ -113,6 +125,14 @@ async function parseDocumentManifest({ req, res, pages, sourceLabel }) {
return res.status(400).json({ error: 'OpenRouter API key not configured (companysetting: keys.openrouter)' });
}
// ── Fetch suppliers from Lieferanten group ────────────────────────
let suppliers = [];
try {
suppliers = await dbSupplier.list(req.user.id);
} catch (err) {
console.warn('delivery parse: failed to list suppliers:', err?.message || err);
}
// ── Open SSE stream ──────────────────────────────────────────────
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
@@ -124,7 +144,7 @@ async function parseDocumentManifest({ req, res, pages, sourceLabel }) {
// ── Accumulated state ────────────────────────────────────────────
const manifestBasedata = {
date: null,
kLieferant: null,
lieferant_id: null,
lieferantName: null,
manifestNr: null,
manifestType: null,
@@ -151,7 +171,7 @@ async function parseDocumentManifest({ req, res, pages, sourceLabel }) {
additionalProperties: false,
properties: {
date: { type: ['string', 'null'], description: 'Document date (ISO 8601)' },
kLieferant: { type: ['number', 'null'], description: 'Lieferant ID' },
lieferant_id: { type: ['number', 'null'], description: 'Lieferant ID' },
lieferantName: { type: 'string', description: 'Lieferant name' },
manifestNr: { type: ['string', 'null'], description: 'Lieferscheinnummer or Rechnungsnummer' },
manifestType: {
@@ -219,9 +239,15 @@ async function parseDocumentManifest({ req, res, pages, sourceLabel }) {
// Merge basedata
if (args.basedata && typeof args.basedata === 'object') {
for (const key of ['date', 'kLieferant', 'lieferantName', 'manifestNr', 'manifestType']) {
for (const key of ['date', 'lieferant_id', 'lieferantName', 'manifestNr', 'manifestType']) {
if (args.basedata[key] != null) manifestBasedata[key] = args.basedata[key];
}
if (args.basedata.kLieferant != null && manifestBasedata.lieferant_id == null) {
manifestBasedata.lieferant_id = args.basedata.kLieferant;
}
if (args.basedata.lieferant_name != null && manifestBasedata.lieferantName == null) {
manifestBasedata.lieferantName = args.basedata.lieferant_name;
}
}
// Merge notes
if (Array.isArray(args.notes)) {
@@ -343,9 +369,9 @@ async function parseDocumentManifest({ req, res, pages, sourceLabel }) {
.map((p, idx) => `--- Page ${idx + 1} of ${pages.length} ---\n${p}`)
.join('\n\n');
const prompt = `${BASE_PROMPT}
const systemPrompt = buildSystemPrompt(suppliers);
You are processing all ${pages.length} page(s) of this document.
const prompt = `You are processing all ${pages.length} page(s) of this document.
Current manifest_basedata (fill in what is still missing):
${JSON.stringify(manifestBasedata, null, 2)}
@@ -359,9 +385,15 @@ ${JSON.stringify(manifestItems, null, 2)}
Page content:
${pageContent}`;
console.log(`[llmorbiter] Starting run: model=${model}, pages=${pages.length} (${sourceLabel})`);
console.log(`[llmorbiter] Starting run: model=${model}, pages=${pages.length} (${sourceLabel}), suppliers=${suppliers.length}`);
currentPage = 1;
await orbiter.run({ prompt, model, tools: [outputManifestTool], signal: abortController.signal });
await orbiter.run({
prompt,
systemPrompt,
model,
tools: [outputManifestTool],
signal: abortController.signal,
});
console.log(`[llmorbiter] Run completed successfully. Extracted ${manifestItems.items.length} item(s), ${manifestNotes.length} note(s)`);
} catch (err) {
if (err.name === 'AbortError' || abortController.signal.aborted) {
@@ -468,7 +500,7 @@ export function register(app, basePath = '') {
* {
* "basedata": {
* "date": "2026-01-15",
* "kLieferant": 42,
* "lieferant_id": 42,
* "lieferantName": "Muster GmbH",
* "manifestNr": "LS-2026-001",
* "manifestType": "lieferschein"