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>
This commit is contained in:
hsiegeln
2026-04-21 10:22:52 +02:00
parent aa7f0eff11
commit 1532880cd5

View File

@@ -64,12 +64,14 @@ Seite swappt Spinner → <RecipeEditor initialData={recipe}>
| `src/routes/new/from-photo/+page.svelte` | Shell. States: `idle` / `loading` / `success` / `error:<code>`. | | `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/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/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/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/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. | | `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/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/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/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/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/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. | | `tests/fixtures/photo-recipe/` | 3 Fixture-Fotos: gedrucktes Rezept, Handschrift, No-Recipe-Bild. |
@@ -103,7 +105,7 @@ Response 200:
{ {
"recipe": { "recipe": {
"title": "Zürcher Geschnetzeltes", "title": "Zürcher Geschnetzeltes",
"description": "Mit dem Zauberstab aus dem Kochbuch geholt.", "description": "Aus dem Bild herbeigezaubert.",
"servings_default": 4, "servings_default": 4,
"servings_unit": "Portionen", "servings_unit": "Portionen",
"prep_time_min": 20, "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`…). - Zutatenmengen: Zahl in `quantity`, Einheit separat (`g`, `ml`, `EL`, `TL`, `Stück`, `Prise`…).
- Bruchteile (`½`, `¼`, `1 ½`) zu Dezimalzahlen. - Bruchteile (`½`, `¼`, `1 ½`) zu Dezimalzahlen.
- Zubereitungsschritte: pro erkennbarer Nummerierung/Absatz ein Schritt. - 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. - **Output:** Gemini `responseMimeType: "application/json"` + `responseSchema`. Strict-typed, keine zusätzlichen Keys.
- **Temperature:** `0.1`. - **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`. - **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. 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 ## 6. Fehlerbehandlung
**Client-Zustände auf `/new/from-photo`:** **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) ## 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. - **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`. - **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. - **Rate-Limit-Impl:** In-Memory-LRU oder Redis-like überflüssig — `Map<ip, {count, resetAt}>` reicht.