7 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
hsiegeln
fb7c2f0e9b feat(photo-upload): zwei Buttons fuer Kamera vs. Datei-Picker
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
Android-Chrome auf Tablet verhaelt sich zickig: mit capture="environment"
nur Kamera, ohne capture nur Datei-Picker -- nie beide. Zwei separate
Buttons (mit jeweils eigenem Input-Element) machen die Wahl explizit
und funktionieren ueberall eindeutig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:45:37 +02:00
hsiegeln
33ee6fbf2e feat(photo-upload): Picker ohne capture -> auch gespeicherte Fotos
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m26s
capture="environment" zwang Mobile-Browser in den Kamera-Modus. Ohne
das Attribut zeigt der Browser auf Mobile die volle Auswahl
(Kamera / Fotomediathek / Datei) -- besser fuer Tablets und User,
die ein schon existierendes Kochbuch-Foto verwenden wollen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:39:07 +02:00
hsiegeln
e2713913e7 feat(photo-upload): Logging fuer Upload-Parse-Fehler
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Der bisherige Endpoint verschluckte den formData()-Fehler mit einem
generischen "Multipart erwartet" — wir wissen nicht, warum Chrome auf
dem Tablet scheitert. Jetzt wird beim Fehler Content-Type, -Length und
User-Agent geloggt, plus die konkrete Error-Message in der Response.
Kein Foto-Inhalt im Log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:37:42 +02:00
hsiegeln
3bc7fa16e2 feat(photo-upload): Limits hochschrauben fuer Tablet-Fotos
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m16s
Tablet- und iPad-Pro-Kameras liefern JPEGs/HEICs bis 15 MB. Mit den
alten 8-/10-MB-Limits scheiterte das Upload beim SvelteKit-Body-Parser
mit "Multipart erwartet" (undurchsichtiger Fehler, weil SvelteKit den
Body frueher abweist als unser Endpoint-Check).

- Endpoint MAX_BYTES: 8 -> 20 MB
- BODY_SIZE_LIMIT: 10 -> 25 MB (mit Multipart-Overhead)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:31:34 +02:00
6 changed files with 115 additions and 33 deletions

View File

@@ -17,9 +17,10 @@ services:
- GEMINI_API_KEY=${GEMINI_API_KEY:-} - GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash} - GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash}
- GEMINI_TIMEOUT_MS=${GEMINI_TIMEOUT_MS:-20000} - GEMINI_TIMEOUT_MS=${GEMINI_TIMEOUT_MS:-20000}
# adapter-node-Default ist 512 KB; Rezept-Fotos koennen bis 8 MB sein. # adapter-node-Default ist 512 KB. Tablet- und iPad-Pro-Kameras liefern
# Multipart-Overhead einrechnen -> 10 MB gibt etwas Puffer. # JPEGs/HEICs bis 15 MB. Endpoint-Limit ist 20 MB; hier 25 MB fuer den
- BODY_SIZE_LIMIT=10000000 # Multipart-Overhead.
- BODY_SIZE_LIMIT=25000000
depends_on: depends_on:
- searxng - searxng
restart: unless-stopped restart: unless-stopped

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

@@ -6,7 +6,11 @@ import { pickRandomPhrase } from '$lib/server/ai/description-phrases';
import { createRateLimiter } from '$lib/server/ai/rate-limit'; import { createRateLimiter } from '$lib/server/ai/rate-limit';
import type { Ingredient, Step } from '$lib/types'; import type { Ingredient, Step } from '$lib/types';
const MAX_BYTES = 8 * 1024 * 1024; // 20 MB deckt auch Tablet- und iPad-Pro-Fotos ab (oft 10-15 MB JPEG/HEIC).
// Muss zusammen mit BODY_SIZE_LIMIT (docker-compose.prod.yml) hochgezogen werden --
// SvelteKit rejected groessere Bodies frueher und wirft dann undurchsichtige
// "Multipart erwartet"-Fehler.
const MAX_BYTES = 20 * 1024 * 1024;
const ALLOWED_MIME = new Set([ const ALLOWED_MIME = new Set([
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',
@@ -41,16 +45,38 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
); );
} }
// Header-Snapshot fuer Diagnose beim Upload-Parse-Fehler. Wir loggen
// Content-Type, -Length und User-Agent — nichts, was Inhalt verraet.
const contentType = request.headers.get('content-type') ?? '(missing)';
const contentLength = request.headers.get('content-length') ?? '(missing)';
const userAgent = request.headers.get('user-agent')?.slice(0, 120) ?? '(missing)';
let form: FormData; let form: FormData;
try { try {
form = await request.formData(); form = await request.formData();
} catch { } catch (e) {
return errJson(400, 'BAD_REQUEST', 'Multipart body erwartet.'); const err = e as Error;
console.warn(
`[extract-from-photo] formData() failed: name=${err.name} msg=${err.message} ` +
`ct="${contentType}" len=${contentLength} ua="${userAgent}"`
);
return errJson(
400,
'BAD_REQUEST',
`Upload konnte nicht gelesen werden (${err.name}: ${err.message}).`
);
} }
const photo = form.get('photo'); const photo = form.get('photo');
if (!(photo instanceof Blob)) { if (!(photo instanceof Blob)) {
console.warn(
`[extract-from-photo] photo field missing or not a Blob. ct="${contentType}" ` +
`len=${contentLength} fields=${[...form.keys()].join(',')}`
);
return errJson(400, 'BAD_REQUEST', 'Feld "photo" fehlt.'); return errJson(400, 'BAD_REQUEST', 'Feld "photo" fehlt.');
} }
console.info(
`[extract-from-photo] received photo size=${photo.size} mime="${photo.type}" ua="${userAgent}"`
);
if (photo.size > MAX_BYTES) { if (photo.size > MAX_BYTES) {
return errJson( return errJson(
413, 413,
@@ -95,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.');
} }

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { import {
Camera, Camera,
ImageUp,
Loader2, Loader2,
Wand2, Wand2,
AlertTriangle, AlertTriangle,
@@ -17,6 +18,7 @@
const store = new PhotoUploadStore(); const store = new PhotoUploadStore();
let saving = $state(false); let saving = $state(false);
let cameraInput = $state<HTMLInputElement | null>(null);
let fileInput = $state<HTMLInputElement | null>(null); let fileInput = $state<HTMLInputElement | null>(null);
function onPick(e: Event) { function onPick(e: Event) {
@@ -85,20 +87,42 @@
Fotografiere ein gedrucktes oder handgeschriebenes Rezept. Eine Seite, Fotografiere ein gedrucktes oder handgeschriebenes Rezept. Eine Seite,
scharf, gut ausgeleuchtet. scharf, gut ausgeleuchtet.
</p> </p>
<button <div class="row">
type="button" <button
class="btn primary" type="button"
onclick={() => fileInput?.click()} class="btn primary"
disabled={!network.online} onclick={() => cameraInput?.click()}
> disabled={!network.online}
<Camera size={18} strokeWidth={2} /> >
<span>Foto wählen oder aufnehmen</span> <Camera size={18} strokeWidth={2} />
</button> <span>Kamera</span>
</button>
<button
type="button"
class="btn ghost"
onclick={() => fileInput?.click()}
disabled={!network.online}
>
<ImageUp size={18} strokeWidth={2} />
<span>Aus Dateien</span>
</button>
</div>
<!-- Zwei separate Inputs: capture="environment" oeffnet direkt die Kamera,
das andere zeigt den Datei-/Fotomediathek-Picker. Android-Chrome auf
Tablet zeigt sonst bei capture="environment" nur die Kamera; ohne
capture dagegen nur den Datei-Picker. Explizite Wahl ist eindeutig. -->
<input
bind:this={cameraInput}
type="file"
accept="image/*"
capture="environment"
hidden
onchange={onPick}
/>
<input <input
bind:this={fileInput} bind:this={fileInput}
type="file" type="file"
accept="image/*" accept="image/*"
capture="environment"
hidden hidden
onchange={onPick} onchange={onPick}
/> />

View File

@@ -70,8 +70,8 @@ describe('POST /api/recipes/extract-from-photo', () => {
expect(body.recipe.id).toBeNull(); expect(body.recipe.id).toBeNull();
}); });
it('413 when file exceeds 8 MB', async () => { it('413 when file exceeds 20 MB', async () => {
const big = Buffer.alloc(9 * 1024 * 1024); const big = Buffer.alloc(21 * 1024 * 1024);
const fd = new FormData(); const fd = new FormData();
fd.append('photo', new Blob([new Uint8Array(big)], { type: 'image/jpeg' })); fd.append('photo', new Blob([new Uint8Array(big)], { type: 'image/jpeg' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any