feat(delivery): add POST /delivery/parsePDF SSE route

- Accepts multipart/form-data PDF, streams LLM extraction via SSE
- Uses libllmorbiter (OpenRouterProvider) with API key from companysetting keys.openrouter
- Extracts manifest basedata, notes, and line items from invoices/delivery notes
- Events: status, llm_call, tool_call, usage, progress, result, error
- Extends generateApiDocs.js: multipart field table + x-sse-events table support
- Docs: docs/api/routes/delivery/parsePDF.md (auto-generated)
This commit is contained in:
seb
2026-09-15 07:45:32 +02:00
parent 367555bb59
commit 12f37ac4c0
8 changed files with 1963 additions and 156 deletions

View File

@@ -75,6 +75,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/customergroup/update.md`](routes/customergroup/update.md) | `PATCH /customergroup/{id}` |
| [`routes/delivery/list.md`](routes/delivery/list.md) | `GET /delivery` |
| [`routes/delivery/create.md`](routes/delivery/create.md) | `POST /delivery` |
| [`routes/delivery/parsePDF.md`](routes/delivery/parsePDF.md) | `POST /delivery/parsePDF` |
| [`routes/delivery/delete.md`](routes/delivery/delete.md) | `DELETE /delivery/{id}` |
| [`routes/delivery/get.md`](routes/delivery/get.md) | `GET /delivery/{id}` |
| [`routes/delivery/update.md`](routes/delivery/update.md) | `PATCH /delivery/{id}` |

View File

@@ -4040,6 +4040,110 @@
}
}
},
"/delivery/parsePDF": {
"post": {
"tags": [
"Delivery"
],
"summary": "Parse PDF (SSE)",
"description": "Accepts a PDF invoice or delivery note (`multipart/form-data`, field `pdf`)\nand streams extraction results as Server-Sent Events until parsing is complete.\n\nThe PDF is read via `pdftotext -layout` and fed page-by-page to an LLM\n(OpenRouter) which fills a structured manifest of header data and line items.\nThe OpenRouter API key is read from the authenticated user's company setting\n`keys.openrouter`; the endpoint returns `400` if the key is not configured.\n\nThe stream ends after the final `result` event (or an `error` event on failure).\n",
"security": [
{
"cookieAuth": []
}
],
"x-markdown": "delivery/parsePDF.md",
"x-sse-events": [
{
"name": "status",
"description": "PDF parse confirmation and LLM status messages",
"payload": "{ \"message\": \"…\" }"
},
{
"name": "llm_call",
"description": "Fired before each LLM request",
"payload": "{ \"model\": \"…\", \"promptSize\": 0, \"messages\": 0 }"
},
{
"name": "tool_call",
"description": "Fired when the output_manifest tool is invoked",
"payload": "{ \"name\": \"output_manifest\" }"
},
{
"name": "usage",
"description": "Token usage after each LLM response",
"payload": "{ \"input\": 0, \"cached\": 0, \"output\": 0, \"cost\": 0 }"
},
{
"name": "progress",
"description": "Intermediate accumulated extraction state (basedata / notes / items)",
"payload": "ManifestResult"
},
{
"name": "result",
"description": "Final accumulated extraction — emitted just before the stream closes",
"payload": "ManifestResult"
},
{
"name": "error",
"description": "Emitted on PDF parse or LLM failure; stream closes immediately after",
"payload": "{ \"message\": \"…\" }"
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"pdf"
],
"properties": {
"pdf": {
"type": "string",
"format": "binary",
"description": "PDF file to parse (max 50 MB, application/pdf)"
}
}
}
}
}
},
"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",
"content": {
"text/event-stream": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "No PDF uploaded or OpenRouter key not configured",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/delivery": {
"get": {
"tags": [

View File

@@ -0,0 +1,85 @@
# `POST /delivery/parsePDF`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Parse PDF (SSE)
Accepts a PDF invoice or delivery note (`multipart/form-data`, field `pdf`)
and streams extraction results as Server-Sent Events until parsing is complete.
The PDF is read via `pdftotext -layout` and fed page-by-page to an LLM
(OpenRouter) which fills a structured manifest of header data and line items.
The OpenRouter API key is read from the authenticated user's company setting
`keys.openrouter`; the endpoint returns `400` if the key is not configured.
The stream ends after the final `result` event (or an `error` event on failure).
## Body (`multipart/form-data`)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `pdf` | yes | file | PDF file to parse (max 50 MB, application/pdf) |
## SSE Events
The response is a `text/event-stream`. Events are emitted in order until the stream closes.
| Event | Description | Payload |
|-------|-------------|---------|
| `status` | PDF parse confirmation and LLM status messages | `{ "message": "…" }` |
| `llm_call` | Fired before each LLM request | `{ "model": "…", "promptSize": 0, "messages": 0 }` |
| `tool_call` | Fired when the output_manifest tool is invoked | `{ "name": "output_manifest" }` |
| `usage` | Token usage after each LLM response | `{ "input": 0, "cached": 0, "output": 0, "cost": 0 }` |
| `progress` | Intermediate accumulated extraction state (basedata / notes / items) | `ManifestResult` |
| `result` | Final accumulated extraction — emitted just before the stream closes | `ManifestResult` |
| `error` | Emitted on PDF parse or LLM failure; stream closes immediately after | `{ "message": "…" }` |
## Response `200`
`text/event-stream` — emits `status`, `llm_call`, `tool_call`, `usage`,
`progress`, `result` (and `error` on failure). See SSE Events table below.
**ManifestResult shape** (used by `progress` and `result`):
```json
{
"basedata": {
"date": "2026-01-15",
"kLieferant": 42,
"lieferantName": "Muster GmbH",
"manifestNr": "LS-2026-001",
"manifestType": "lieferschein"
},
"notes": ["Pos. 3: VPE-Angabe fehlte, Standardwert 1 angenommen."],
"items": {
"pagesFrom": 1,
"pagesTo": 2,
"items": [
{
"position": 1,
"count": 10,
"vpe": 1,
"unit": "Stk.",
"articleName": "Einhängesieb Stahl",
"liefArtNr": "ES-100",
"mwst": "19",
"eknetto": 450,
"page": 1
}
]
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No PDF uploaded or OpenRouter key not configured — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._