Random-Auswahl server-seitig nach AI-Call; description steht nicht im Gemini-Schema, keine Halluzinationsflaeche. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
16 KiB
Foto-Rezept-Magie — Design Spec
Status: approved (brainstorming) Datum: 2026-04-21 Ziel-Release: v1.3.0
1. Motivation & Scope
Nutzer sollen ein gedrucktes oder handgeschriebenes Rezept fotografieren können. Das Foto wird an ein Vision-LLM (Gemini 2.5 Flash) gesendet, dort zu einer strukturierten Recipe-Shape extrahiert, und direkt in einen vorausgefüllten RecipeEditor gepackt — der Nutzer korrigiert bei Bedarf und speichert. Das Foto selbst wird nie persistiert.
In Scope (v1):
- Einzelnes Foto von gedrucktem Rezept ODER Handschrift.
- Extraktion von Titel, Portionen, Zeiten, Zutaten (mit Menge/Einheit/Name), Zubereitungsschritten.
- Auslöse-Button als
Camera-Icon (lucide) im Header, disabled wenn offline oder ohne API-Key. - Direkter Flow in den
RecipeEditor(kein separater Preview-Schritt). - Server-seitiger Gemini-Call mit structured-output-Schema, Zod-Validierung, 1× Retry bei Schema-Fehler.
- Hartes Nicht-Speichern des Fotos nach dem Call.
Explizit Out-of-Scope (v1):
- Multi-Foto (Kochbuch-Doppelseite): Endpoint nimmt ein Bild entgegen; Erweiterung auf Array bei Bedarf.
- Extraktion von
image_pathaus dem Bild (Dish-Crop aus Kochbuchseite). - Foto-Backup / Persistenz des Input-Fotos.
- Claude als Fallback.
- Interpretierende Felder:
cuisine,category,tags, freiedescription. - Foto-vom-Gericht → AI-erfindet-Rezept (anderer Use-Case).
2. User Flow
Header (Camera-Icon, lucide)
│
▼
/new/from-photo (File-Picker: <input type="file" accept="image/*" capture="environment">)
│
▼ Nutzer wählt/knipst Foto — File bleibt nur im Browser-State
│
▼ POST /api/recipes/extract-from-photo (multipart/form-data)
│ Server: MIME + Größe validieren, sharp-Preprocess, Gemini-Call, Response
│ Foto wird NICHT persistiert.
│
▼
Seite swappt Spinner → <RecipeEditor initialData={recipe}>
│
▼ Nutzer korrigiert, klickt „Speichern" (Editor-Save-Pfad — ob bestehend oder neu: siehe §11)
│
▼
/recipes/:id
Invarianten:
- Extraktion erzeugt kein DB-Record. Erst der Save-Klick im Editor schreibt.
- Bei Tab-Close während Extraktion: kein Müll in der DB, AbortController-fähiger Fetch.
- Offline: Kamera-Icon im Header nicht geklickbar; falls trotzdem Route geöffnet, klare Offline-Meldung.
3. Komponenten & Dateien
Neue Dateien
| Pfad | Zweck |
|---|---|
src/routes/new/from-photo/+page.svelte |
Shell. States: idle / loading / success / error:<code>. |
src/lib/server/ai/gemini-client.ts |
Thin wrapper: extractRecipeFromImage(buffer, mime): Promise<Partial<Recipe>>. Liest GEMINI_API_KEY, GEMINI_MODEL, GEMINI_TIMEOUT_MS aus $env/dynamic/private. |
src/lib/server/ai/recipe-extraction-prompt.ts |
System-Prompt (DE) + JSON-Schema für Gemini responseSchema. Isoliert, weil iterabel. |
src/lib/server/ai/description-phrases.ts |
50er-Pool von Magie-Phrasen für das description-Feld. Export pickRandomPhrase(): string. Siehe §5a. |
src/lib/server/ai/image-preprocess.ts |
sharp-basierter Resize (≤1600px lange Kante) + JPEG re-encode (quality 85) + Metadata-Strip. HEIC → JPEG. |
src/routes/api/recipes/extract-from-photo/+server.ts |
POST. Multipart-Parse, Validierung, preprocess, Gemini-Call, Zod-Validierung, Response. |
src/lib/client/photo-upload.svelte.ts |
Frontend-Store für Upload-Zustand. |
tests/unit/ai/recipe-extraction-prompt.test.ts |
Schema-Ping, Retry-Pfad, Zod-Ablehnung. |
tests/unit/ai/image-preprocess.test.ts |
Resize, HEIC, Metadata-Strip. |
tests/unit/ai/gemini-client.test.ts |
Timeout, 429-no-retry, 5xx-1x-retry, Network-Fehler. |
tests/unit/ai/description-phrases.test.ts |
Pool hat 50 Einträge, alle unique non-empty, pickRandomPhrase liefert nur Pool-Einträge. |
tests/api/extract-from-photo.test.ts |
Happy-Path, 413, 415, 422 (NO_RECIPE_IN_IMAGE). |
tests/e2e/remote/photo-import.spec.ts |
Kamera-Icon, Upload-Fixture (Endpoint gestubt), Editor-Prefill, Save, Offline-State. |
tests/fixtures/photo-recipe/ |
3 Fixture-Fotos: gedrucktes Rezept, Handschrift, No-Recipe-Bild. |
Geänderte Dateien
| Pfad | Änderung |
|---|---|
src/routes/+layout.svelte |
Header: Camera-Icon, aria-label="Rezept aus Foto erstellen". Nur gerendert wenn GEMINI_API_KEY gesetzt (Graceful Degradation). Disabled wenn offline (networkStore). Führt zu /new/from-photo. |
src/lib/components/RecipeEditor.svelte |
Akzeptiert optionale initialData?: Partial<Recipe>-Prop. Wenn gesetzt, Felder vorbefüllen, kein DB-Round-trip. Heute liest der Editor über eine Rezept-ID — dieser Pfad wird abstrahiert. |
Dockerfile |
sharp im Native-Build-Stage ergänzen (wie better-sqlite3). |
docker-compose.yml, docker-compose.prod.yml, .env.example |
Env-Vars GEMINI_API_KEY, GEMINI_MODEL, GEMINI_TIMEOUT_MS ergänzen. |
docs/OPERATIONS.md |
Abschnitt zu Gemini-Config + Recreate-Hinweis bei Env-Änderung. |
docs/ARCHITECTURE.md |
AI-Extraktionspfad ergänzen. |
CLAUDE.md |
Zeile in Gotcha-Tabelle: Graceful Degradation ohne Key + sharp im Build-Stage. |
Keine DB-Migration. Recipe-Shape bleibt; der Endpoint produziert ein Partial<Recipe> im Response-Body.
4. API-Contract
POST /api/recipes/extract-from-photo
Request: multipart/form-data
photo: File. Erlaubt:image/jpeg,image/png,image/webp,image/heic,image/heif.- Max 8 MB (vor Preprocess).
Response 200:
{
"recipe": {
"title": "Zürcher Geschnetzeltes",
"description": "Aus dem Bild herbeigezaubert.",
"servings_default": 4,
"servings_unit": "Portionen",
"prep_time_min": 20,
"cook_time_min": 15,
"total_time_min": null,
"cuisine": null,
"category": null,
"image_path": null,
"source_url": null,
"source_domain": null,
"ingredients": [
{ "position": 1, "quantity": 500, "unit": "g", "name": "Kalbsgeschnetzeltes", "note": null, "section": null },
{ "position": 2, "quantity": 200, "unit": "ml", "name": "Rahm", "note": null, "section": null }
],
"steps": [
{ "position": 1, "text": "Fleisch in heißer Pfanne kurz anbraten, herausnehmen." }
],
"tags": []
}
}
Response Fehler-Codes:
| Status | code |
Bedeutung |
|---|---|---|
| 413 | PAYLOAD_TOO_LARGE |
Photo > 8 MB. |
| 415 | UNSUPPORTED_MEDIA_TYPE |
MIME nicht in der Whitelist. |
| 422 | NO_RECIPE_IN_IMAGE |
AI-Output valide, aber title leer oder (ingredients.length === 0 UND steps.length === 0). |
| 429 | AI_RATE_LIMITED |
Gemini 429 durchgereicht. |
| 503 | AI_TIMEOUT |
Gemini-Timeout (Default 20 s). |
| 503 | AI_FAILED |
Gemini-5xx nach 1 Retry ODER Schema-Validierung nach 1 Retry fehlgeschlagen. |
| 503 | AI_NOT_CONFIGURED |
GEMINI_API_KEY leer — Endpoint sollte dann ohnehin nicht erreichbar sein via UI, belt-and-suspenders. |
5. Prompt-Strategie
Datei: src/lib/server/ai/recipe-extraction-prompt.ts
- Sprache: Deutsch.
- Rolle: „Du bist ein Rezept-Extraktions-Assistent."
- Regeln:
- Nur was lesbar auf dem Bild steht, ins Ergebnis. Sonst
nulloder leeres Array. - Zutatenmengen: Zahl in
quantity, Einheit separat (g,ml,EL,TL,Stück,Prise…). - Bruchteile (
½,¼,1 ½) zu Dezimalzahlen. - Zubereitungsschritte: pro erkennbarer Nummerierung/Absatz ein Schritt.
descriptionwird server-seitig nach dem AI-Call aus einem 50er-Pool zufällig gewählt (description-phrases.ts, siehe §5a). Die AI bekommtdescriptiongar nicht erst im Schema — keine Halluzinationsfläche.
- Nur was lesbar auf dem Bild steht, ins Ergebnis. Sonst
- Output: Gemini
responseMimeType: "application/json"+responseSchema. Strict-typed, keine zusätzlichen Keys. - Temperature:
0.1. - Retry bei Schema-Fehler: Genau 1 zusätzlicher Call mit Appendix „Dein letztes JSON war invalid. Schema: … Bitte nur JSON zurück." Dann
AI_FAILED.
Zod-Schema spiegelt das Response-Schema serverseitig und wird auf die Gemini-Antwort angewendet.
5a. Description-Phrasen-Pool
Datei: src/lib/server/ai/description-phrases.ts
50 deutsche Magie-Phrasen, zufällig gezogen pro Extraktions-Call. Die Auswahl geschieht server-seitig im Endpoint, nachdem die AI-Antwort validiert wurde. Der Nutzer kann die Phrase im Editor weiter editieren, sie ist also ein Starter, kein Lock-in.
export const DESCRIPTION_PHRASES: readonly string[] = [
'Mit dem Zauberstab aus dem Kochbuch geholt.',
'Foto-Magie frisch aus dem Ofen.',
'Aus dem Bild herbeigezaubert.',
'Ein Klick, ein Foto, fertig.',
'Knipsen statt Abtippen.',
'Von der Buchseite direkt in die Pfanne.',
'Die Kamera hat mitgelesen.',
'Abrakadabra — Rezept da.',
'Per Linse in die Küche teleportiert.',
'Von Oma abfotografiert, von der KI entziffert.',
'Frisch aus dem Bilderrahmen.',
'Klick, zisch, Rezept.',
'Das Foto wurde überredet, sich zu verraten.',
'Schnappschuss zur Schüssel.',
'Einmal lesen lassen, schon da.',
'Keine Hand hat dieses Rezept abgetippt.',
'Vom Bild in die Bratpfanne.',
'Papier ist geduldig, das Foto war es auch.',
'Eine Seite, ein Foto, ein Rezept.',
'Die KI hat drübergeschielt.',
'Handschriftlich entziffert — oder zumindest versucht.',
'Aus der Linse in die Liste.',
'Vom Küchentisch zur Kachel.',
'Knips und weg — zumindest der Zettel.',
'Das Bild hat geredet.',
'Keine Tippfehler, nur Sehfehler.',
'Per Foto eingebürgert.',
'Rezept-Übersetzung aus dem Bild.',
'Die Seite hat sich verraten.',
'Blitzlicht und dann Gulasch.',
'Ein Augenzwinkern der Kamera genügte.',
'Geknipst, gelesen, gespeichert.',
'Fotografische Gedächtnishilfe.',
'Aus der Schublade ans Licht.',
'Das Rezept stand schon da — wir haben nur hingeguckt.',
'Zaubertrick mit Kamera.',
'Vom Papier befreit.',
'Ein Foto sagt mehr als tausend Zutatenlisten.',
'Eingescannt, rausgelesen, reingeschrieben.',
'Die Kamera als Küchenhilfe.',
'Handy hoch, Rezept runter.',
'Aus dem Kochbuch gebeamt.',
'Ein scharfes Foto, ein klares Rezept.',
'Vom Regal zur App in einem Schritt.',
'Aus dem Bild geschöpft wie Suppe aus dem Topf.',
'Optisch erfasst, digital serviert.',
'Das Kleingedruckte hat die KI gelesen.',
'Vom Kladdenzettel in die Datenbank.',
'Kurz gezückt, schon gekocht.',
'Kein Schreibkrampf, nur ein Klick.'
];
export function pickRandomPhrase(): string {
return DESCRIPTION_PHRASES[Math.floor(Math.random() * DESCRIPTION_PHRASES.length)];
}
Invariant: Genau 50 Einträge, alle non-empty, alle unique. Unit-Test prüft das.
6. Fehlerbehandlung
Client-Zustände auf /new/from-photo:
| State | UI |
|---|---|
idle |
Camera-Button groß mittig, Text „Foto wählen oder aufnehmen". Hilfetext: „Gedrucktes Rezept oder Handschrift. Eine Seite, scharf, gut ausgeleuchtet." |
loading |
Loader2 (spin) + Text „Lese das Rezept…". X-Button für Abbrechen (AbortController). |
success |
<RecipeEditor initialData={recipe}>. Top-Banner mit Wand2: „Aus Foto erstellt — bitte prüfen und ggf. korrigieren." Verschwindet nach erstem Feld-Edit. |
error: NO_RECIPE_IN_IMAGE |
Yellow-Box. Buttons: Camera „Anderes Foto" (→ idle), FilePlus „Leer anlegen" (→ leerer Editor). |
error: AI_TIMEOUT / AI_RATE_LIMITED / AI_FAILED |
Red-Toast, Grund. RotateCw „Nochmal versuchen" — reused das gleiche File-Objekt, kein Re-Upload durch den Nutzer. |
error: PAYLOAD_TOO_LARGE |
Toast „Foto zu groß (max 8 MB). In besserer Beleuchtung neu aufnehmen." |
| Offline (auf Route) | Hinweis „Diese Funktion braucht Internet." |
A11y: Lade-State aria-live="polite", Fehler-Boxen role="alert", Kamera-Icon mit aria-label.
Server-Seite:
-
Gemini-Call mit
AbortSignal, Default-TimeoutGEMINI_TIMEOUT_MS(20000). -
Retry-Matrix:
Gemini-Signal Verhalten 429 AI_RATE_LIMITED, kein Retry.Network/5xx 1× Retry mit 500 ms backoff, dann AI_FAILED.Invalid JSON 1× Retry mit Append-Prompt, dann AI_FAILED.Valid JSON aber Schema-invalid gleicher Pfad wie Invalid JSON. Timeout AI_TIMEOUT, kein Retry. -
Logging:
console.warnmit{ code, durationMs, imageKB }— ohne Prompt/Response-Inhalt (Privacy).
Icons (alle aus lucide-svelte):
| Zweck | Icon |
|---|---|
| Header-Button | Camera |
| Lade-State | Loader2 (spin) |
| Erfolgs-Banner | Wand2 |
| Fehler | AlertTriangle |
| „Nochmal versuchen" | RotateCw |
| „Anderes Foto" | Camera |
| „Leer anlegen" | FilePlus |
| Abbrechen (Loading) | X |
7. Sicherheit / Missbrauch
- Rate-Limit: 10 Requests/Min pro IP, simple In-Memory-Throttle im Endpoint. Schützt vor versehentlichem Dauer-Tappen und Kosten-Runaways. Übertrieben für's Heimnetz, aber billig einzubauen.
- MIME-Validierung nicht blind client-seitig — Buffer-Header prüfen (
sharpmetadata) nach Empfang. .heic/.heiffunktioniert, wennsharpmitlibheifgebaut ist (beim offiziellen sharp-arm64-Build dabei). Fixture-Test dafür.- Kein Auth (Kochwas-Policy). Key stays server-side.
- Privacy-Statement im OPERATIONS.md: „Fotos gehen einmal an Google Gemini und werden danach nicht gespeichert. Gemini nutzt API-Daten im Paid-Tier nicht für Training."
8. Konfiguration
Env-Vars (alle in docker-compose.yml, docker-compose.prod.yml, .env.example ergänzen):
| Var | Default | Zweck |
|---|---|---|
GEMINI_API_KEY |
— (required) | Ohne Key: Feature graceful deaktiviert. |
GEMINI_MODEL |
gemini-2.5-flash |
Modell-Wechsel (z.B. auf gemini-2.5-pro) ohne Rebuild. |
GEMINI_TIMEOUT_MS |
20000 |
Timeout für Vision-Call. |
Wichtig: Env-Änderungen greifen erst nach docker compose up -d --force-recreate, nicht nach restart (siehe Auto-Memory project_deploy_env_recreate.md).
9. Testing
Unit (Vitest, mocked Gemini):
image-preprocess.test.ts: Resize, HEIC→JPEG, Metadata-Strip, JPEG-Qualität.recipe-extraction-prompt.test.ts: Prompt enthält Schema; Zod akzeptiert gültige Response; Zod lehnt invalide Response ab; Retry-Logik greift genau 1×.gemini-client.test.ts: Timeout, 429-no-retry, 5xx-1x-retry, Network-Fehler.
API (SvelteKit-Endpoint, gemockter Gemini-Client):
tests/api/extract-from-photo.test.ts: Happy-Path mit Fixture-JPEG; 413 bei >8MB; 415 bei nicht-Bild; 422 bei Titel-OK-aber-0-Ingredients-UND-0-Steps; 503 mitAI_NOT_CONFIGUREDwenn Key fehlt.
E2E (Playwright gegen kochwas-dev.siegeln.net):
tests/e2e/remote/photo-import.spec.ts: Kamera-Icon-Klick; File-Upload (Endpoint gestubt, kein echter Gemini-Call); Editor-Prefill; Save; Redirect auf/recipes/:id; Kamera-Icon-disabled beicontext.setOffline(true).
Fixtures: tests/fixtures/photo-recipe/: gedruckte Seite, Handschrift-Karte, No-Recipe-Bild.
Explizit nicht getestet: Die Gemini-Vision-Qualität selbst. Das ist Model-Verhalten, nicht unser Code. Manuelle Verifikation nach Deploy.
10. PWA / Service Worker
/new/from-photoin den Shell-Pre-Cache aufnehmen.- Feature funktioniert nur online — Offline-State wird bewusst gehandhabt (siehe §6).
- Service-Worker ändert nichts am Extract-Endpoint (keine SW-Cachung für
/api/recipes/extract-from-photo).
11. Offene Kleinigkeiten (in Planung zu entscheiden)
- Save-Endpoint: Ob der bestehende Editor-Save-Endpoint das Anlegen eines Rezepts aus Scratch unterstützt, oder ob
insertRecipeüber einen neuen POST/api/recipesexponiert werden muss — vor dem Planning prüfen. - Sharp Build-Stage: Verifizieren, dass das offizielle
sharp-npm-Package auf arm64 mit libheif-Support ausgeliefert wird; andernfalls Build-Stage-Rezept ähnlich zubetter-sqlite3. - Rate-Limit-Impl: In-Memory-LRU oder Redis-like überflüssig —
Map<ip, {count, resetAt}>reicht.
12. Akzeptanz-Kriterien
- Kamera-Icon in der Kopfzeile sichtbar, führt zu
/new/from-photo. - Kamera-Icon unsichtbar wenn
GEMINI_API_KEYleer. - Kamera-Icon disabled wenn offline (
networkStore.online === false). - File-Picker öffnet mobile Rückkamera direkt (
capture="environment"). - Gedrucktes Fixture-Rezept wird vom Prompt + Mock-Gemini-Response in gültige Recipe-Shape überführt.
- Handschrift-Fixture ebenso (Mock).
- No-Recipe-Fixture → 422
NO_RECIPE_IN_IMAGE→ UI zeigt Yellow-Box mit beiden Buttons. - Editor öffnet mit vorbefüllten Feldern, Nutzer kann editieren, Speichern navigiert zu
/recipes/:id. - Foto-Datei wird nach Request nicht auf Disk gefunden (Test-Assertion im API-Test).
- Build im Dockerfile-arm64-Stage erfolgreich mit
sharp. npm test+npm run checkgrün.