diff --git a/docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md b/docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md new file mode 100644 index 0000000..0596962 --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md @@ -0,0 +1,272 @@ +# 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.