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>
This commit is contained in:
hsiegeln
2026-04-21 10:16:35 +02:00
parent 26018eee7f
commit aa7f0eff11

View File

@@ -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: <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:
```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` | `<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.