From 1532880cd5388e5dedaa885ade25ccf2e1287132 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:22:52 +0200 Subject: [PATCH] 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) --- .../2026-04-21-photo-recipe-magic-design.md | 78 +++++++++++++++++-- 1 file changed, 71 insertions(+), 7 deletions(-) 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 index 0596962..fdb0506 100644 --- a/docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md +++ b/docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md @@ -64,12 +64,14 @@ Seite swappt Spinner → | `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/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. | @@ -103,7 +105,7 @@ Response 200: { "recipe": { "title": "Zürcher Geschnetzeltes", - "description": "Mit dem Zauberstab aus dem Kochbuch geholt.", + "description": "Aus dem Bild herbeigezaubert.", "servings_default": 4, "servings_unit": "Portionen", "prep_time_min": 20, @@ -149,13 +151,80 @@ Datei: `src/lib/server/ai/recipe-extraction-prompt.ts` - 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. + - `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. + +```ts +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`:** @@ -248,11 +317,6 @@ Env-Vars (alle in `docker-compose.yml`, `docker-compose.prod.yml`, `.env.example ## 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.