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 {
RECIPE_EXTRACTION_SYSTEM_PROMPT,
RECIPE_EXTRACTION_USER_PROMPT,
GEMINI_RESPONSE_SCHEMA,
extractionResponseSchema,
type ExtractionResponse
@@ -84,7 +85,10 @@ async function callGemini(
const parts: Array<
{ 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 });
const result = await withTimeout(
@@ -114,6 +118,7 @@ export async function extractRecipeFromImage(
imageBuffer: Buffer,
mimeType: string
): Promise<ExtractionResponse> {
let firstMsg: string | null = null;
try {
return await callGemini(imageBuffer, mimeType);
} catch (e) {
@@ -132,6 +137,9 @@ export async function extractRecipeFromImage(
: 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));
try {
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.'
);
} 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);
if (retryStatus === 429)
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 { SchemaType } from '@google/generative-ai';
export const RECIPE_EXTRACTION_SYSTEM_PROMPT = `Du bist ein Rezept-Extraktions-Assistent.
Du bekommst ein Foto eines gedruckten oder handgeschriebenen Rezepts und gibst ein strukturiertes JSON zurück.
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.
Regeln:
- Extrahiere nur, was tatsächlich auf dem Bild lesbar ist. Sonst Feld auf null (oder leeres Array).
- 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, …).
SPRACHE:
- 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.
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.
- Zeiten in Minuten (ganze Zahl). "1 Stunde" = 60.
- Ignoriere Werbung, Foto-Bildunterschriften, Einleitungstexte. Nur das Rezept selbst.
- 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.`;
- Zeit: Alle Angaben strikt in Minuten (Integer). "1 Stunde" = 60.
- Rauschen ignorieren: Keine Werbung, Einleitungstexte oder Bildunterschriften extrahieren.
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
// ü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'
? 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(
`[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.');
}