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:
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user