# 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/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/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": "Mit dem Zauberstab aus dem Kochbuch geholt.", "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` explizit auf festen Satz setzen (Default im Code: *final phrasing in planning — Kandidaten siehe §11*). Die AI ändert das nicht. - **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. ## 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) - **Finale Phrasing der `description`** — Kandidaten: - „Mit dem Zauberstab aus dem Kochbuch geholt." - „Foto-Magie frisch aus dem Ofen." - „Aus dem Bild herbeigezaubert." - „Ein Klick, ein Foto, fertig." - **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.