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>
14 KiB
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_pathaus dem Bild (Dish-Crop aus Kochbuchseite). - Foto-Backup / Persistenz des Input-Fotos.
- Claude als Fallback.
- Interpretierende Felder:
cuisine,category,tags, freiedescription. - 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
nulloder 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.
descriptionexplizit auf festen Satz setzen (Default im Code: final phrasing in planning — Kandidaten siehe §11). Die AI ändert das nicht.
- Nur was lesbar auf dem Bild steht, ins Ergebnis. Sonst
- 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-TimeoutGEMINI_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.warnmit{ 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 (
sharpmetadata) nach Empfang. .heic/.heiffunktioniert, wennsharpmitlibheifgebaut 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 mitAI_NOT_CONFIGUREDwenn 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 beicontext.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-photoin 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/recipesexponiert 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 zubetter-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_KEYleer. - 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 checkgrün.