From 60e71d4a5769b28c3a9f470a6b225f66e7cdde8e Mon Sep 17 00:00:00 2001 From: Kenearos Date: Tue, 12 May 2026 00:14:38 +0200 Subject: [PATCH] feat(image-import): add callVisionAPI with OpenRouter fetch + typed HTTP errors --- image-import.js | 77 +++++++++++++++++++++++++++++++++++++++++++++++++ test-suite.js | 73 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/image-import.js b/image-import.js index f1ffdf3..37414ad 100644 --- a/image-import.js +++ b/image-import.js @@ -62,8 +62,85 @@ class ImageImporter { URL.revokeObjectURL(objUrl); } } + + /** + * POST to OpenRouter chat/completions and return the assistant message content (raw string). + * @param {string} dataUrl - 'data:image/jpeg;base64,...' + * @param {string} apiKey + * @param {string} modelId + * @param {AbortSignal} [signal] + * @returns {Promise} raw assistant content (still markdown-fenced/JSON; parse later) + */ + async callVisionAPI(dataUrl, apiKey, modelId, signal) { + const body = { + model: modelId, + temperature: 0, + response_format: { type: 'json_object' }, + messages: [ + { + role: 'system', + content: ImageImporter.SYSTEM_PROMPT + }, + { + role: 'user', + content: [ + { type: 'text', text: 'Extrahiere alle Assistenzarzt-Dienste aus dieser Dienstplan-Tabelle.' }, + { type: 'image_url', image_url: { url: dataUrl } } + ] + } + ] + }; + + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': (typeof window !== 'undefined' && window.location) ? window.location.origin : '', + 'X-Title': 'Dienstplan-Pro' + }, + body: JSON.stringify(body), + signal: signal + }); + + if (!response.ok) { + const err = new Error(`OpenRouter HTTP ${response.status}`); + err.name = 'OpenRouterError'; + err.status = response.status; + throw err; + } + + const json = await response.json(); + const content = json && json.choices && json.choices[0] && json.choices[0].message + ? json.choices[0].message.content + : ''; + return typeof content === 'string' ? content : JSON.stringify(content); + } } +// Verbatim system prompt — German with Umlaute (per spec §7.3). +ImageImporter.SYSTEM_PROMPT = `Du extrahierst Dienstpläne aus Tabellenbildern für eine deutsche Klinik. + +Regeln: +- Die Tabelle listet pro Datum die diensthabenden Ärzte. +- Es gibt Assistenzärzte und Oberärzte. Extrahiere NUR Assistenzärzte. Oberärzte werden ignoriert. +- Wenn du nicht sicher bist, ob ein Name zu einem Assistenzarzt oder Oberarzt gehört, vermerke dies in \`notes\`. +- Wenn in einer Zelle NUR EIN Name steht: share = 1.0 für diesen Arzt. +- Wenn in einer Zelle ZWEI Namen stehen: share = 0.5 für jeden der beiden. +- Datum stets im ISO-Format YYYY-MM-DD. +- Wenn das Bild einen Monatstitel zeigt (z.B. „November 2025"), gib \`month\` (1–12) und \`year\` (vierstellig) in der Antwort an. Sonst null. +- Wenn ein Name unklar zu lesen ist, übernimm deinen besten Ratevorschlag und vermerke es in \`notes\`. + +Antworte STRIKT in diesem JSON-Schema und sonst nichts: +{ + "month": number | null, + "year": number | null, + "entries": [ + { "name": "string", "date": "YYYY-MM-DD", "share": 1.0 | 0.5 } + ], + "notes": ["string", ...] +}`; + // Make available globally window.ImageImporter = ImageImporter; diff --git a/test-suite.js b/test-suite.js index d00278f..30409b2 100644 --- a/test-suite.js +++ b/test-suite.js @@ -573,6 +573,79 @@ runner.test('Preprocess: Output ist immer JPEG', async (t) => { t.assertTrue(result.dataUrl.length > 1000, 'Output-Laenge > 1KB'); }); +// ============================================================================ +// ImageImporter Tests - callVisionAPI (Feature A) +// ============================================================================ + +/** + * Helper to mock fetch and restore it. + */ +function withMockedFetch(mockFn, fn) { + const originalFetch = globalThis.fetch; + globalThis.fetch = mockFn; + const restore = () => { globalThis.fetch = originalFetch; }; + return Promise.resolve(fn()).finally(restore); +} + +runner.test('CallVisionAPI: erfolgreicher 200-Response', async (t) => { + const importer = new ImageImporter(null); + let capturedUrl = null; + let capturedInit = null; + const mockFetch = async (url, init) => { + capturedUrl = url; + capturedInit = init; + return new Response(JSON.stringify({ + choices: [{ message: { content: '{"entries":[]}' } }] + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }; + await withMockedFetch(mockFetch, async () => { + const content = await importer.callVisionAPI('data:image/jpeg;base64,AAA', 'sk-test', 'anthropic/claude-sonnet-4.6'); + t.assertEqual(content, '{"entries":[]}', 'Content extrahiert'); + t.assertEqual(capturedUrl, 'https://openrouter.ai/api/v1/chat/completions', 'Endpoint korrekt'); + t.assertTrue(capturedInit.headers['Authorization'] === 'Bearer sk-test', 'Auth-Header korrekt'); + }); +}); + +runner.test('CallVisionAPI: 401 wirft mit Status', async (t) => { + const importer = new ImageImporter(null); + const mockFetch = async () => new Response('', { status: 401 }); + await withMockedFetch(mockFetch, async () => { + try { + await importer.callVisionAPI('data:image/jpeg;base64,AAA', 'bad', 'anthropic/claude-sonnet-4.6'); + t.assertTrue(false, 'Sollte werfen'); + } catch (e) { + t.assertEqual(e.status, 401, 'Status auf Error'); + t.assertEqual(e.name, 'OpenRouterError', 'Typisierter Fehler'); + } + }); +}); + +runner.test('CallVisionAPI: 429 wirft mit Status', async (t) => { + const importer = new ImageImporter(null); + const mockFetch = async () => new Response('', { status: 429 }); + await withMockedFetch(mockFetch, async () => { + try { + await importer.callVisionAPI('data:image/jpeg;base64,AAA', 'k', 'm'); + t.assertTrue(false, 'Sollte werfen'); + } catch (e) { + t.assertEqual(e.status, 429, '429 wird durchgereicht'); + } + }); +}); + +runner.test('CallVisionAPI: 503 wirft mit Status', async (t) => { + const importer = new ImageImporter(null); + const mockFetch = async () => new Response('', { status: 503 }); + await withMockedFetch(mockFetch, async () => { + try { + await importer.callVisionAPI('data:image/jpeg;base64,AAA', 'k', 'm'); + t.assertTrue(false, 'Sollte werfen'); + } catch (e) { + t.assertEqual(e.status, 503, '503 wird durchgereicht'); + } + }); +}); + // ============================================================================ // Display Functions // ============================================================================