Files
kochwas/docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md
hsiegeln aa7f0eff11 docs: spec fuer Foto-Rezept-Magie (v1.3)
Design-Spec fuer Gemini-basierten Foto->Rezept-Import:
Kamera-Icon im Header, Extraktion auf Server, Editor-Prefill
ohne DB-Record, Foto wird nicht persistiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:16:35 +02:00

14 KiB
Raw Blame History

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: <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/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<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": "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 <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-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<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_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.