# 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_path` aus dem Bild (Dish-Crop aus Kochbuchseite). - Foto-Backup / Persistenz des Input-Fotos. - Claude als Fallback. - Interpretierende Felder: `cuisine`, `category`, `tags`, freie `description`. - Foto-vom-Gericht → AI-erfindet-Rezept (anderer Use-Case). ## 2. User Flow ``` Header (Camera-Icon, lucide) │ ▼ /new/from-photo (File-Picker: ) │ ▼ 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 → │ ▼ 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:`. | | `src/lib/server/ai/gemini-client.ts` | Thin wrapper: `extractRecipeFromImage(buffer, mime): Promise>`. 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`-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` 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: ```json { "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 `null` oder 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. - `description` wird server-seitig **nach** dem AI-Call aus einem 50er-Pool zufällig gewählt (`description-phrases.ts`, siehe §5a). Die AI bekommt `description` gar nicht erst im Schema — keine Halluzinationsfläche. - **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. ```ts 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` | ``. 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-Timeout `GEMINI_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.warn` mit `{ 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 (`sharp` metadata) nach Empfang. - **`.heic`/`.heif`** funktioniert, wenn `sharp` mit `libheif` gebaut 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 mit `AI_NOT_CONFIGURED` wenn 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 bei `context.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-photo` in 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/recipes` exponiert 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 zu `better-sqlite3`. - **Rate-Limit-Impl:** In-Memory-LRU oder Redis-like überflüssig — `Map` reicht. ## 12. Akzeptanz-Kriterien - [ ] Kamera-Icon in der Kopfzeile sichtbar, führt zu `/new/from-photo`. - [ ] Kamera-Icon unsichtbar wenn `GEMINI_API_KEY` leer. - [ ] 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 check` grün.