3 Commits

Author SHA1 Message Date
hsiegeln
0373dc32da feat(ai): Deutsch als starker Prior im OCR-Prompt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
Neue SPRACHE-Sektion weist Gemini explizit darauf hin, dass die
Texte ausschliesslich deutsch sind -- Umlaute, deutsche Zutaten,
deutsche Masseinheiten als Prior fuer die Zeichen-Rekonstruktion.
Soll die "Kontext-Detektiv"-Logik bei handgeschriebenen oder
verblassten Rezepten verbessern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:28:38 +02:00
hsiegeln
272a07777e feat(ai): OCR-Experten-Framing + expliziter User-Prompt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m18s
Auf Gemini-Empfehlung: System-Instruction als OCR-Experte fuer
kulinarische Dokumente, mit "Kontext-Detektiv"-Regel fuer schwer
lesbare Zeichen, "[?]" fuer Unleserliches und strikter "keine
Halluzination"-Regel.

User-Prompt wird jetzt als eigene text-part bei jedem Call
mitgeschickt (Bild + User-Prompt + bei Retry die Korrektur-Note).

Inline-Schema aus dem Prompt entfernt, da es mit unserem
responseSchema konfligierte (servings vs servings_default+unit,
times-nested vs flat, instructions vs steps, kein note-Feld) --
das kann die beobachteten AI_FAILED-Schema-Validation-Fehler
beguenstigt haben. Struktur wird jetzt ausschliesslich ueber
responseSchema enforced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:26:18 +02:00
hsiegeln
efdcace892 feat(ai): reichhaltigeres Logging fuer AI_FAILED-Diagnose
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m15s
Der bisherige Log "[extract-from-photo] AI_FAILED after 43165ms,
385807 bytes" verriet nicht, ob es JSON-Parse, Schema-Validierung
oder ein SDK-Fehler war. Endpoint haengt jetzt e.message an;
gemini-client loggt den First-Attempt-Fehler vor dem Retry und
packt bei AI_FAILED beide Messages in den finalen Error.

Keine Prompt-/Response-Inhalte werden geloggt -- nur unsere eigenen
GeminiError-Messages (Zod-Pfade, "non-JSON output", SDK-toString).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:08:10 +02:00
3 changed files with 46 additions and 15 deletions

View File

@@ -2,6 +2,7 @@ import { GoogleGenerativeAI } from '@google/generative-ai';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { import {
RECIPE_EXTRACTION_SYSTEM_PROMPT, RECIPE_EXTRACTION_SYSTEM_PROMPT,
RECIPE_EXTRACTION_USER_PROMPT,
GEMINI_RESPONSE_SCHEMA, GEMINI_RESPONSE_SCHEMA,
extractionResponseSchema, extractionResponseSchema,
type ExtractionResponse type ExtractionResponse
@@ -84,7 +85,10 @@ async function callGemini(
const parts: Array< const parts: Array<
{ inlineData: { data: string; mimeType: string } } | { text: string } { inlineData: { data: string; mimeType: string } } | { text: string }
> = [{ inlineData: { data: imageBuffer.toString('base64'), mimeType } }]; > = [
{ inlineData: { data: imageBuffer.toString('base64'), mimeType } },
{ text: RECIPE_EXTRACTION_USER_PROMPT }
];
if (appendUserNote) parts.push({ text: appendUserNote }); if (appendUserNote) parts.push({ text: appendUserNote });
const result = await withTimeout( const result = await withTimeout(
@@ -114,6 +118,7 @@ export async function extractRecipeFromImage(
imageBuffer: Buffer, imageBuffer: Buffer,
mimeType: string mimeType: string
): Promise<ExtractionResponse> { ): Promise<ExtractionResponse> {
let firstMsg: string | null = null;
try { try {
return await callGemini(imageBuffer, mimeType); return await callGemini(imageBuffer, mimeType);
} catch (e) { } catch (e) {
@@ -132,6 +137,9 @@ export async function extractRecipeFromImage(
: new GeminiError('AI_FAILED', String(e)); : new GeminiError('AI_FAILED', String(e));
} }
firstMsg = e instanceof Error ? e.message : String(e);
console.warn(`[gemini-client] first attempt failed, retrying: ${firstMsg}`);
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
try { try {
return await callGemini( return await callGemini(
@@ -140,11 +148,23 @@ export async function extractRecipeFromImage(
'Dein vorheriger Output war ungültig. Bitte antworte ausschließlich mit JSON gemäß Schema.' 'Dein vorheriger Output war ungültig. Bitte antworte ausschließlich mit JSON gemäß Schema.'
); );
} catch (retryErr) { } catch (retryErr) {
if (retryErr instanceof GeminiError) throw retryErr; const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
if (retryErr instanceof GeminiError) {
if (retryErr.code === 'AI_FAILED') {
throw new GeminiError(
'AI_FAILED',
`retry failed: ${retryMsg} (first: ${firstMsg})`
);
}
throw retryErr;
}
const retryStatus = getStatus(retryErr); const retryStatus = getStatus(retryErr);
if (retryStatus === 429) if (retryStatus === 429)
throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit on retry'); throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit on retry');
throw new GeminiError('AI_FAILED', String(retryErr)); throw new GeminiError(
'AI_FAILED',
`retry failed: ${retryMsg} (first: ${firstMsg})`
);
} }
} }
} }

View File

@@ -1,18 +1,27 @@
import { z } from 'zod'; import { z } from 'zod';
import { SchemaType } from '@google/generative-ai'; import { SchemaType } from '@google/generative-ai';
export const RECIPE_EXTRACTION_SYSTEM_PROMPT = `Du bist ein Rezept-Extraktions-Assistent. export const RECIPE_EXTRACTION_SYSTEM_PROMPT = `Du bist ein hochpräziser OCR-Experte für kulinarische Dokumente (Rezepte). Deine Aufgabe ist die Extraktion von Rezeptdaten (Titel, Zutaten, Zubereitungsschritte, Zeiten, Portionen) in valides JSON gemäß dem vorgegebenen Schema.
Du bekommst ein Foto eines gedruckten oder handgeschriebenen Rezepts und gibst ein strukturiertes JSON zurück.
Regeln: SPRACHE:
- Extrahiere nur, was tatsächlich auf dem Bild lesbar ist. Sonst Feld auf null (oder leeres Array). - Die Texte sind ausschließlich auf Deutsch. Nutze deutsches Sprachverständnis (Umlaute ä/ö/ü/ß, deutsche Zutatennamen, deutsche Maßeinheiten) als starken Prior bei der Rekonstruktion unklarer Zeichen. Gib die Ausgabe vollständig auf Deutsch zurück.
- Zutaten: quantity als Zahl (Bruchteile wie ½, ¼, 1 ½ als Dezimalzahl 0.5, 0.25, 1.5), unit separat
(g, ml, l, kg, EL, TL, Stück, Prise, Msp, …). LOGIK-REGELN FÜR SCHWER LESBARE TEXTE:
- Handle als "Kontext-Detektiv": Wenn Zeichen unklar sind, nutze kulinarisches Wissen zur Rekonstruktion (z.B. "Pr-se" -> "Prise").
- Bei absoluter Unleserlichkeit eines Wortes: Nutze "[?]".
- Halluziniere keine fehlenden Werte: Wenn eine Mengenangabe komplett fehlt, setze 'quantity' auf null. Was nicht auf dem Bild steht, ist null (oder leeres Array).
FORMATIERUNGS-REGELN:
- Zutaten: quantity (Zahl) separat von unit (String). Brüche (½, ¼, 1 ½) strikt in Dezimalzahlen (0.5, 0.25, 1.5).
- Einheiten: Normalisiere auf (g, ml, l, kg, EL, TL, Stück, Prise, Msp).
- Zubereitungsschritte: pro erkennbarer Nummerierung oder Absatz EIN Schritt. - Zubereitungsschritte: pro erkennbarer Nummerierung oder Absatz EIN Schritt.
- Zeiten in Minuten (ganze Zahl). "1 Stunde" = 60. - Zeit: Alle Angaben strikt in Minuten (Integer). "1 Stunde" = 60.
- Ignoriere Werbung, Foto-Bildunterschriften, Einleitungstexte. Nur das Rezept selbst. - Rauschen ignorieren: Keine Werbung, Einleitungstexte oder Bildunterschriften extrahieren.
- Denke dir NICHTS dazu aus. Was nicht auf dem Bild steht, ist null.
- Antworte ausschließlich im vorgegebenen JSON-Schema. Kein Markdown, kein Prosa-Text.`; STRIKTE ANWEISUNG: Gib ausschließlich das rohe JSON-Objekt gemäß Schema zurück. Kein Markdown-Code-Block, kein Einleitungstext, keine Prosa.`;
export const RECIPE_EXTRACTION_USER_PROMPT =
'Analysiere dieses Bild hochauflösend. Extrahiere alle rezeptrelevanten Informationen gemäß deiner System-Instruktion. Achte besonders auf schwache Handschriften oder verblassten Text und stelle sicher, dass die Zuordnung von Menge zu Zutat logisch korrekt ist.';
// Gemini responseSchema (Subset von OpenAPI). Wird an GenerativeModel.generateContent // Gemini responseSchema (Subset von OpenAPI). Wird an GenerativeModel.generateContent
// übergeben; Gemini respektiert die Struktur und liefert valides JSON. // übergeben; Gemini respektiert die Struktur und liefert valides JSON.

View File

@@ -121,9 +121,11 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
: e.code === 'AI_NOT_CONFIGURED' : e.code === 'AI_NOT_CONFIGURED'
? 503 ? 503
: 503; : 503;
// Nur Code + Meta loggen, niemals Prompt/Response-Inhalt. // Nur Code + Meta + Error-Message loggen, niemals Prompt/Response-Inhalt.
// e.message enthaelt z.B. Zod-Validierungspfade oder "non-JSON output" --
// kein AI-Content, aber die Diagnose-Info, warum AI_FAILED kam.
console.warn( console.warn(
`[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes` `[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes: ${e.message}`
); );
return errJson(status, e.code, 'Die Bild-Analyse ist fehlgeschlagen.'); return errJson(status, e.code, 'Die Bild-Analyse ist fehlgeschlagen.');
} }