Files
kochwas/docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md
hsiegeln 1532880cd5 docs: 50er-Phrasenpool fuer Foto-Rezept-description
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>
2026-04-21 10:22:52 +02:00

16 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/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 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.

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-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<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.