6 Commits

Author SHA1 Message Date
hsiegeln
fd5d759336 docs(plan): Implementierungs-Plan fuer Einkaufsliste
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 31s
24 Tasks, TDD, bite-sized. Reihenfolge: Migration -> Repository -> Quantity-Formatter
-> API -> Service-Worker -> Client-Store -> Header-Badge -> Wunschlisten-Relayout
-> Shopping-List-Seite -> E2E.

E2E-Tests laufen erst nach Deploy gegen kochwas-dev.siegeln.net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:37:04 +02:00
hsiegeln
956357d5ca docs(spec): Einkaufsliste-Design
Neue Spec fuer das Einkaufslisten-Feature:
- Globale (haushaltsweite) Einkaufsliste, aus Rezepten der Wunschliste gefuellt
- Portionen zentral auf der Listen-Seite skalierbar
- Flache Aggregation via (LOWER(TRIM(name)), LOWER(TRIM(unit)))
- Abhaken persistiert, Cleanup manuell
- Header-Badge zaehlt nicht-abgehakte Zeilen
- Relayout der Wunschlisten-Karte: Action-Icons horizontal oben, Quell-Domain raus
- Kein Fuzzy-Matching, keine manuellen Eintraege (YAGNI fuer v1)

E2E-Tests erst nach Deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:25:46 +02:00
hsiegeln
d9490c8073 refactor(search): local search ignores domain filter
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 3m11s
Der Domain-Filter im Header-Dropdown wirkt ab jetzt ausschliesslich auf
die Web-Suche (SearXNG). Die Suche in gespeicherten Rezepten liefert
immer alle Treffer, unabhaengig von der Quelldomain -- wer ein Rezept
gespeichert hat, will es finden, selbst wenn er die Domain aus dem
Filter ausgeschlossen hat.

- SearchStore: filterParam -> webFilterParam, nur noch an Web-Calls
- /api/recipes/search: domains-Query-Param wird nicht mehr gelesen
- searchLocal(): domains-Parameter + SQL-Branch entfernt
- Tests entsprechend angepasst

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:59:48 +02:00
hsiegeln
0373dc32da feat(ai): Deutsch als starker Prior im OCR-Prompt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
Neue SPRACHE-Sektion weist Gemini explizit darauf hin, dass die
Texte ausschliesslich deutsch sind -- Umlaute, deutsche Zutaten,
deutsche Masseinheiten als Prior fuer die Zeichen-Rekonstruktion.
Soll die "Kontext-Detektiv"-Logik bei handgeschriebenen oder
verblassten Rezepten verbessern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:28:38 +02:00
hsiegeln
272a07777e feat(ai): OCR-Experten-Framing + expliziter User-Prompt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m18s
Auf Gemini-Empfehlung: System-Instruction als OCR-Experte fuer
kulinarische Dokumente, mit "Kontext-Detektiv"-Regel fuer schwer
lesbare Zeichen, "[?]" fuer Unleserliches und strikter "keine
Halluzination"-Regel.

User-Prompt wird jetzt als eigene text-part bei jedem Call
mitgeschickt (Bild + User-Prompt + bei Retry die Korrektur-Note).

Inline-Schema aus dem Prompt entfernt, da es mit unserem
responseSchema konfligierte (servings vs servings_default+unit,
times-nested vs flat, instructions vs steps, kein note-Feld) --
das kann die beobachteten AI_FAILED-Schema-Validation-Fehler
beguenstigt haben. Struktur wird jetzt ausschliesslich ueber
responseSchema enforced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:26:18 +02:00
hsiegeln
efdcace892 feat(ai): reichhaltigeres Logging fuer AI_FAILED-Diagnose
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m15s
Der bisherige Log "[extract-from-photo] AI_FAILED after 43165ms,
385807 bytes" verriet nicht, ob es JSON-Parse, Schema-Validierung
oder ein SDK-Fehler war. Endpoint haengt jetzt e.message an;
gemini-client loggt den First-Attempt-Fehler vor dem Retry und
packt bei AI_FAILED beide Messages in den finalen Error.

Keine Prompt-/Response-Inhalte werden geloggt -- nur unsere eigenen
GeminiError-Messages (Zod-Pfade, "non-JSON output", SDK-toString).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:08:10 +02:00
12 changed files with 2659 additions and 55 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,295 @@
# Einkaufsliste — Design-Spec
**Datum**: 2026-04-21
**Status**: Spec, vor Implementierung
## Ziel
Aus Rezepten auf der Wunschliste eine flache, aggregierte Einkaufsliste erzeugen. Die Liste ist haushaltsweit geteilt, mobil-first, im Supermarkt abhakbar. Portionen sind pro Rezept anpassbar. Identische Zutaten (gleicher Name + gleiche Einheit) werden über mehrere Rezepte hinweg summiert.
## Entscheidungen (aus Brainstorming)
| Thema | Entscheidung |
|---|---|
| Sichtbarkeit | Global, eine Liste für alle Profile |
| Portionen | Default `servings_default` beim Hinzufügen; zentral auf der Einkaufslisten-Seite anpassbar |
| Aggregation | Flache Liste, exaktes Matching auf `(LOWER(TRIM(name)), LOWER(TRIM(unit)))`. Keine Fuzzy-Matches — lieber zwei Zeilen als falsche Summen. Rezept-Herkunft pro Zeile sichtbar. |
| Abhaken | Checkbox, durchgestrichen, sortiert ans Ende. Manuelles Cleanup via „Erledigte entfernen" / „Liste leeren" |
| Kopplung | Komplett entkoppelt von Wunschliste und `cooking_log`. Abhaken beeinflusst nur die Einkaufsliste. |
| Header-Badge | Zählt **nicht-abgehakte** aggregierte Zutaten-Zeilen. Versteckt sich bei Count = 0. |
| Manuelle Einträge | Out of scope. Nur rezeptbasiert. |
## Datenmodell
Migration `013_shopping_list.sql`:
```sql
CREATE TABLE shopping_cart_recipe (
recipe_id INTEGER PRIMARY KEY REFERENCES recipe(id) ON DELETE CASCADE,
servings INTEGER NOT NULL,
added_by_profile_id INTEGER REFERENCES profile(id) ON DELETE SET NULL,
added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE shopping_cart_check (
name_key TEXT NOT NULL,
unit_key TEXT NOT NULL,
checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (name_key, unit_key)
);
```
**Derivation-Prinzip**: Die aggregierte Liste wird **nicht materialisiert**. Sie wird bei jedem Lesen aus `shopping_cart_recipe JOIN recipe JOIN ingredient` plus Skalierungs-Faktor berechnet. Vorteil: Rezept-Edits wirken live auf die Liste.
**Abhaken pro aggregierter Zeile**: `(name_key, unit_key)` — nicht pro Rezept-Zutat. Wenn zwei Rezepte beide „Mehl, g" haben, gibt es eine Zeile „400 g Mehl", und ein Haken reicht. Wird eines der Rezepte entfernt, bleibt „200 g Mehl" mit Haken sichtbar.
**Orphan-Checks** (aggregierter Schlüssel ist nicht mehr durch ein Rezept im Cart abgedeckt): Werden nicht aktiv gelöscht, tauchen aber in der Ausgabe von `listShoppingList` nicht auf (der Join erzeugt keine Zeile). Späteres Cleanup optional via `clearCart` / `clearCheckedItems`.
### Aggregations-SQL (Kern)
```sql
SELECT
LOWER(TRIM(i.name)) AS name_key,
LOWER(TRIM(COALESCE(i.unit, ''))) AS unit_key,
MIN(i.name) AS display_name,
MIN(i.unit) AS display_unit,
SUM(i.quantity * cr.servings * 1.0 / r.servings_default) AS total_quantity,
GROUP_CONCAT(DISTINCT r.title) AS from_recipes,
EXISTS(SELECT 1 FROM shopping_cart_check c
WHERE c.name_key = LOWER(TRIM(i.name))
AND c.unit_key = LOWER(TRIM(COALESCE(i.unit, '')))) AS checked
FROM shopping_cart_recipe cr
JOIN recipe r ON r.id = cr.recipe_id
JOIN ingredient i ON i.recipe_id = r.id
GROUP BY name_key, unit_key
ORDER BY checked ASC, display_name COLLATE NOCASE;
```
**Edge Cases**:
- `i.quantity IS NULL``total_quantity` bleibt NULL, UI rendert ohne Mengenangabe.
- `r.servings_default IS NULL` → Division through-by-NULL → `total_quantity` NULL; defensiver: `COALESCE(r.servings_default, cr.servings)` (Faktor = 1, wenn kein Default bekannt).
- `i.unit IS NULL``unit_key = ''`, Anzeige ohne Einheit.
- Rezept hat keine Zutaten (sehr selten) → kein Beitrag zur Liste, Rezept-Chip erscheint trotzdem (Signal: „ups, keine Zutaten").
## Server-Module
### `src/lib/server/shopping/repository.ts`
Neue Typen:
```ts
export type ShoppingCartRecipe = {
recipe_id: number;
title: string;
image_path: string | null;
servings: number;
servings_default: number;
};
export type ShoppingListRow = {
name_key: string;
unit_key: string;
display_name: string;
display_unit: string | null;
total_quantity: number | null;
from_recipes: string; // comma-separated recipe titles
checked: 0 | 1;
};
export type ShoppingListSnapshot = {
recipes: ShoppingCartRecipe[];
rows: ShoppingListRow[];
uncheckedCount: number;
};
```
Funktionen:
- `addRecipeToCart(db, recipeId, profileId, servings?)``INSERT … ON CONFLICT(recipe_id) DO UPDATE SET servings = excluded.servings`. Wenn `servings` fehlt, nimmt `COALESCE(recipe.servings_default, 4)`.
- `removeRecipeFromCart(db, recipeId)`
- `setCartServings(db, recipeId, servings)` — App-seitig validiert: `1 ≤ servings ≤ 50`. SQL-Level `CHECK (servings > 0)` zusätzlich als Sicherheitsnetz.
- `listShoppingList(db) → ShoppingListSnapshot` — liefert Cart-Rezepte, aggregierte Zeilen und `uncheckedCount` in einer Transaktion.
- `toggleCheck(db, nameKey, unitKey, checked: boolean)` — Insert bzw. Delete in `shopping_cart_check`.
- `clearCheckedItems(db)` — transaktional:
1. Aggregation laufen lassen und `recipe_id`s finden, deren sämtliche aggregierten Zeilen abgehakt sind (ein Rezept zählt als „erledigt", wenn all seine `(name_key, unit_key)`-Beiträge in `shopping_cart_check` stehen)
2. Diese Rezepte via `DELETE FROM shopping_cart_recipe WHERE recipe_id IN (…)` entfernen
3. Check-Einträge, die jetzt keinen Bezug mehr haben, mit `DELETE FROM shopping_cart_check WHERE (name_key, unit_key) NOT IN (<aktive Keys nach Step 2>)` aufräumen
- `clearCart(db)``DELETE FROM shopping_cart_recipe; DELETE FROM shopping_cart_check;`
### Routen
| Methode + Pfad | Body/Params | Zweck |
|---|---|---|
| `GET /api/shopping-list` | — | Snapshot holen |
| `POST /api/shopping-list/recipe` | `{ recipe_id, servings?, profile_id? }` | Rezept in Cart; idempotent |
| `PATCH /api/shopping-list/recipe/:recipe_id` | `{ servings }` | Portionen ändern |
| `DELETE /api/shopping-list/recipe/:recipe_id` | — | Rezept raus |
| `POST /api/shopping-list/check` | `{ name_key, unit_key }` | Abhaken |
| `DELETE /api/shopping-list/check` | `{ name_key, unit_key }` | Haken weg |
| `DELETE /api/shopping-list/checked` | — | Erledigte entfernen |
| `DELETE /api/shopping-list` | — | Liste leeren |
Error-Handling: 404 wenn `recipe_id` nicht im Cart (nur bei DELETE/PATCH auf spezifischem Rezept), 400 bei Validation-Fehlern (servings ≤ 0, fehlende Felder), 500 mit JSON-Body `{ message }` bei DB-Fehlern.
## Client-Store
`src/lib/client/shopping-cart.svelte.ts` — analog zu `wishlist.svelte.ts`:
```ts
class ShoppingCartStore {
uncheckedCount = $state(0);
recipeIds = $state<Set<number>>(new Set()); // für „ist dieses Rezept im Cart?"
loaded = $state(false);
async refresh(): Promise<void>;
async addRecipe(recipeId: number): Promise<void>;
async removeRecipe(recipeId: number): Promise<void>;
isInCart(recipeId: number): boolean;
}
```
- `refresh()` ruft `GET /api/shopping-list` auf und extrahiert `recipeIds` + `uncheckedCount` aus dem Snapshot. Ein separater Leichtgewichts-Count-Endpoint ist nicht nötig; der Snapshot ist klein.
- Store wird in `+layout.svelte` beim `onMount` initialisiert (wie `wishlistStore.refresh()`).
- Nach jedem Mutating-Call (add/remove/toggle/clear) wird `refresh()` vom aufrufenden Code getriggert.
## UI
### (a) Wunschlisten-Karte — Relayout
Aktuell drücken zwei rechts-gestapelte Buttons den Titel-Text auf Handys zusammen. Neues Layout:
```
┌──────────┬─────────────────────────────┐
│ │ [Utensils|3] [Cart] [Trash] │ Action-Leiste oben, horizontal
│ Bild │ Titel (fett, 2 Zeilen max) │
│ 96px │ Hendrik, Verena, Leana │ wanted_by + ★
│ │ ★ 4.5 │
└──────────┴─────────────────────────────┘
```
Konkret in `src/routes/wishlist/+page.svelte`:
- `.actions` wird horizontal, als erste Zeile über dem Titel rechts-bündig.
- `source_domain`-Span aus der `.meta`-Zeile entfernt (Platz).
- Neuer Cart-Button zwischen Utensils und Trash:
- Nicht im Cart: neutral (Icon grau), aria-label „In den Einkaufswagen"
- Im Cart: grün gefüllt, Häkchen-Badge unten rechts, aria-label „Aus Einkaufswagen entfernen"
- Alle drei Buttons ≥ 44 × 44 px (mobile Tap-Target).
Vergleichbare Reorg in `src/routes/recipes/[id]/+page.svelte` nötig? — **Nein**. Der Cart-Button erscheint nur auf der Wunschliste. (Begründung: Rezept-Detail hat schon ein volles Action-Menü; das Hinzufügen zum Cart passiert bewusst aus der Wunschlisten-Perspektive.)
### (b) Header-Badge
`src/routes/+layout.svelte` — rechts neben dem bestehenden Kochtopf-Icon:
- Icon `ShoppingCart` aus `lucide-svelte`
- Badge-Kreis oben rechts mit `shoppingCartStore.uncheckedCount`
- Nur sichtbar wenn `uncheckedCount > 0`
- Klick → `goto('/shopping-list')`
- Gleicher Visual-Style wie der CookingPot (Farb-Konsistenz grün)
### (c) Seite `/shopping-list`
Datei: `src/routes/shopping-list/+page.svelte`
```
┌──────────────────────────────────────┐
│ Einkaufsliste │ Header
│ 12 noch zu besorgen · 3 Rezepte │
├──────────────────────────────────────┤
│ [Carbonara 4p- +] [Lasagne 6p- +] … │ Rezept-Chips, horizontal scrollbar
│ │ (Titel + Portions-Stepper + X)
├──────────────────────────────────────┤
│ ☐ 400 g Mehl │
│ aus Carbonara, Lasagne │
│ ☐ 6 Stk Eier │
│ aus Carbonara │
│ … │
│ ☑ 200 g Butter (durchgestrichen) │ Abgehakt, ans Ende
├──────────────────────────────────────┤
│ [Erledigte entfernen] [Liste leeren] │ Sticky Footer
└──────────────────────────────────────┘
```
**Komponenten** (neue Svelte-Dateien):
- `src/lib/components/ShoppingCartChip.svelte` — Rezept-Chip mit Stepper + Remove
- `src/lib/components/ShoppingListRow.svelte` — eine Zutatenzeile mit Checkbox
**Portions-Stepper**: - und + Buttons, mittig die Zahl. Min 1, Max 50 (sanity). Klick sendet PATCH, triggert Store-Refresh → Liste rerendert.
**Zutaten-Reihenfolge**: Erst nicht-abgehakt, dann abgehakt; innerhalb jeder Gruppe alphabetisch (`display_name COLLATE NOCASE`). Abgehakt = durchgestrichen + grauer Text.
**Mengen-Formatierung** (`src/lib/quantity-format.ts`, neu):
- `formatQuantity(q: number | null): string`
- `null``''`
- Ganz-nahe-Ganzzahl (Epsilon 0.01) → Integer
- Sonst auf max. 2 Nachkommastellen, trailing Nullen weg
- Beispiele: `400 → "400"`, `0.5 → "0.5"`, `0.333 → "0.33"`, `null → ""`
**Aktionen im Footer**:
- „Erledigte entfernen" — sichtbar wenn ≥ 1 Check, kein Confirm (reversibel genug)
- „Liste leeren" — Confirm via `confirmAction`: „Komplette Einkaufsliste löschen? Das macht nicht rückgängig."
**Empty State**: Icon `ShoppingCart` (große Version), „Einkaufswagen ist leer", Hint „Lege Rezepte auf der Wunschliste in den Wagen, um sie hier zu sehen."
**Offline-Verhalten**: Wie die Wunschliste — alle Mutating-Calls via `requireOnline()`. Service-Worker cached nichts von `/api/shopping-list/*` (network-only analog zu Wishlist). Die PWA-Seite selbst wird vom SW-Shell-Cache serviert, aber ohne Daten. Offline-Robustheit (local queue + sync) ist **out of scope** für v1.
## Testing
### Unit/Integration-Tests (Vitest, in-memory DB)
- `tests/integration/shopping-repository.test.ts`:
- `addRecipeToCart` idempotent, `ON CONFLICT` überschreibt `servings`
- Aggregation: gleiche `(name_key, unit_key)` summiert; unterschiedliche unit_keys bleiben getrennt
- Portions-Skalierung: `servings_default=4`, `servings=2` → alle Mengen halbiert
- Nulls: `quantity IS NULL``total_quantity IS NULL`; `unit IS NULL``unit_key=''`
- `toggleCheck` persistiert über `listShoppingList`-Aufrufe
- Abgehakt-Status überlebt Entfernen eines Rezepts, solange Schlüssel von einem anderen kommt
- `clearCheckedItems`: entfernt nur vollständig abgehakte Rezepte + räumt Orphan-Checks
- `countUncheckedItems` nach diversen Ops korrekt
- `clearCart` cleant beide Tabellen
- `tests/unit/shopping-cart-store.test.ts`:
- Mock-Fetch, testet refresh-Trigger nach add/remove
- `isInCart(id)` reflektiert aktuellen Zustand
- `uncheckedCount` reactive nach refresh
- `tests/unit/quantity-format.test.ts`:
- `formatQuantity(400) === "400"`
- `formatQuantity(0.5) === "0.5"`
- `formatQuantity(0.333333) === "0.33"`
- `formatQuantity(400.001) === "400"` (Epsilon)
- `formatQuantity(null) === ""`
### E2E-Tests (Playwright, `tests/e2e/remote/shopping.spec.ts`)
**Wichtig**: E2E-Tests laufen gegen `kochwas-dev.siegeln.net` und erfordern einen erfolgreichen Deploy des Features. Werden nach dem Feature-Merge manuell ausgelöst, nicht im Rahmen der Implementierungs-Phase.
Abgedeckt:
- Rezept auf Wunschliste → Cart-Button klicken → Header-Badge erscheint
- Navigation zu `/shopping-list`, Portions-Stepper hoch/runter → Zutatenmengen reagieren
- Zutat abhaken → Badge-Count sinkt, Zeile durchgestrichen, Reload persistiert
- „Erledigte entfernen" → vollständig abgehakte Rezepte weg, teilweise abgehakte bleiben
- „Liste leeren" → Empty-State, Badge verschwindet
- Zwei Rezepte mit gleicher Zutat (Fixture-Setup) → aggregierte Zeile mit Summe
- Cleanup-Fixture entfernt Cart + Checks nach jedem Test
**Nicht getestet**: exakte CSS-Styles, Animationen — visuelle Kontrolle beim Deploy.
## Implementierungs-Reihenfolge (Hinweis für Plan)
1. Migration 013 + Repository + Unit-Tests
2. API-Routen + Integrationstests
3. Client-Store
4. Header-Badge-Icon
5. Wunschlisten-Karte Relayout + Cart-Button
6. Seite `/shopping-list` (Chips → Rows → Footer → Empty State)
7. Quantity-Formatter + Tests
8. Service-Worker network-only für `/api/shopping-list/*`
9. Deploy, dann E2E-Tests nachschieben
## Out of Scope (für v1)
- Manuelle Einträge („Klopapier")
- Supermarkt-Abteilungs-Sortierung
- Offline-Queue (add/check während offline, sync später)
- Synonym/Fuzzy-Matching von Zutaten-Namen (der User harmonisiert langfristig händisch)
- Auto-Kopplung zu `cooking_log` / Wunschliste-Remove
- Teilen per Link / Export

View File

@@ -17,7 +17,7 @@ export type SearchStoreOptions = {
debounceMs?: number;
filterDebounceMs?: number;
minQueryLength?: number;
filterParam?: () => string;
webFilterParam?: () => string;
fetchImpl?: typeof fetch;
};
@@ -38,7 +38,7 @@ export class SearchStore {
private readonly debounceMs: number;
private readonly filterDebounceMs: number;
private readonly minQueryLength: number;
private readonly filterParam: () => string;
private readonly webFilterParam: () => string;
private readonly fetchImpl: typeof fetch;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private skipNextDebounce = false;
@@ -48,7 +48,7 @@ export class SearchStore {
this.debounceMs = opts.debounceMs ?? 300;
this.filterDebounceMs = opts.filterDebounceMs ?? 150;
this.minQueryLength = opts.minQueryLength ?? 4;
this.filterParam = opts.filterParam ?? (() => '');
this.webFilterParam = opts.webFilterParam ?? (() => '');
this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a));
}
@@ -80,7 +80,7 @@ export class SearchStore {
this.webExhausted = false;
try {
const res = await this.fetchImpl(
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}${this.filterParam()}`
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}`
);
const body = (await res.json()) as { hits: SearchHit[] };
if (this.query.trim() !== q) return;
@@ -99,7 +99,7 @@ export class SearchStore {
this.webSearching = true;
try {
const res = await this.fetchImpl(
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.filterParam()}`
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.webFilterParam()}`
);
if (this.query.trim() !== q) return;
if (!res.ok) {
@@ -125,7 +125,7 @@ export class SearchStore {
try {
if (!this.localExhausted) {
const res = await this.fetchImpl(
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}${this.filterParam()}`
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}`
);
const body = (await res.json()) as { hits: SearchHit[] };
if (this.query.trim() !== q) return;
@@ -140,7 +140,7 @@ export class SearchStore {
if (wasEmpty) this.webSearching = true;
try {
const res = await this.fetchImpl(
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.filterParam()}`
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.webFilterParam()}`
);
if (this.query.trim() !== q) return;
if (!res.ok) {

View File

@@ -2,6 +2,7 @@ import { GoogleGenerativeAI } from '@google/generative-ai';
import { env } from '$env/dynamic/private';
import {
RECIPE_EXTRACTION_SYSTEM_PROMPT,
RECIPE_EXTRACTION_USER_PROMPT,
GEMINI_RESPONSE_SCHEMA,
extractionResponseSchema,
type ExtractionResponse
@@ -84,7 +85,10 @@ async function callGemini(
const parts: Array<
{ inlineData: { data: string; mimeType: string } } | { text: string }
> = [{ inlineData: { data: imageBuffer.toString('base64'), mimeType } }];
> = [
{ inlineData: { data: imageBuffer.toString('base64'), mimeType } },
{ text: RECIPE_EXTRACTION_USER_PROMPT }
];
if (appendUserNote) parts.push({ text: appendUserNote });
const result = await withTimeout(
@@ -114,6 +118,7 @@ export async function extractRecipeFromImage(
imageBuffer: Buffer,
mimeType: string
): Promise<ExtractionResponse> {
let firstMsg: string | null = null;
try {
return await callGemini(imageBuffer, mimeType);
} catch (e) {
@@ -132,6 +137,9 @@ export async function extractRecipeFromImage(
: new GeminiError('AI_FAILED', String(e));
}
firstMsg = e instanceof Error ? e.message : String(e);
console.warn(`[gemini-client] first attempt failed, retrying: ${firstMsg}`);
await new Promise((r) => setTimeout(r, 500));
try {
return await callGemini(
@@ -140,11 +148,23 @@ export async function extractRecipeFromImage(
'Dein vorheriger Output war ungültig. Bitte antworte ausschließlich mit JSON gemäß Schema.'
);
} catch (retryErr) {
if (retryErr instanceof GeminiError) throw retryErr;
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
if (retryErr instanceof GeminiError) {
if (retryErr.code === 'AI_FAILED') {
throw new GeminiError(
'AI_FAILED',
`retry failed: ${retryMsg} (first: ${firstMsg})`
);
}
throw retryErr;
}
const retryStatus = getStatus(retryErr);
if (retryStatus === 429)
throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit on retry');
throw new GeminiError('AI_FAILED', String(retryErr));
throw new GeminiError(
'AI_FAILED',
`retry failed: ${retryMsg} (first: ${firstMsg})`
);
}
}
}

View File

@@ -1,18 +1,27 @@
import { z } from 'zod';
import { SchemaType } from '@google/generative-ai';
export const RECIPE_EXTRACTION_SYSTEM_PROMPT = `Du bist ein Rezept-Extraktions-Assistent.
Du bekommst ein Foto eines gedruckten oder handgeschriebenen Rezepts und gibst ein strukturiertes JSON zurück.
export const RECIPE_EXTRACTION_SYSTEM_PROMPT = `Du bist ein hochpräziser OCR-Experte für kulinarische Dokumente (Rezepte). Deine Aufgabe ist die Extraktion von Rezeptdaten (Titel, Zutaten, Zubereitungsschritte, Zeiten, Portionen) in valides JSON gemäß dem vorgegebenen Schema.
Regeln:
- Extrahiere nur, was tatsächlich auf dem Bild lesbar ist. Sonst Feld auf null (oder leeres Array).
- Zutaten: quantity als Zahl (Bruchteile wie ½, ¼, 1 ½ als Dezimalzahl 0.5, 0.25, 1.5), unit separat
(g, ml, l, kg, EL, TL, Stück, Prise, Msp, …).
SPRACHE:
- Die Texte sind ausschließlich auf Deutsch. Nutze deutsches Sprachverständnis (Umlaute ä/ö/ü/ß, deutsche Zutatennamen, deutsche Maßeinheiten) als starken Prior bei der Rekonstruktion unklarer Zeichen. Gib die Ausgabe vollständig auf Deutsch zurück.
LOGIK-REGELN FÜR SCHWER LESBARE TEXTE:
- Handle als "Kontext-Detektiv": Wenn Zeichen unklar sind, nutze kulinarisches Wissen zur Rekonstruktion (z.B. "Pr-se" -> "Prise").
- Bei absoluter Unleserlichkeit eines Wortes: Nutze "[?]".
- Halluziniere keine fehlenden Werte: Wenn eine Mengenangabe komplett fehlt, setze 'quantity' auf null. Was nicht auf dem Bild steht, ist null (oder leeres Array).
FORMATIERUNGS-REGELN:
- Zutaten: quantity (Zahl) separat von unit (String). Brüche (½, ¼, 1 ½) strikt in Dezimalzahlen (0.5, 0.25, 1.5).
- Einheiten: Normalisiere auf (g, ml, l, kg, EL, TL, Stück, Prise, Msp).
- Zubereitungsschritte: pro erkennbarer Nummerierung oder Absatz EIN Schritt.
- Zeiten in Minuten (ganze Zahl). "1 Stunde" = 60.
- Ignoriere Werbung, Foto-Bildunterschriften, Einleitungstexte. Nur das Rezept selbst.
- Denke dir NICHTS dazu aus. Was nicht auf dem Bild steht, ist null.
- Antworte ausschließlich im vorgegebenen JSON-Schema. Kein Markdown, kein Prosa-Text.`;
- Zeit: Alle Angaben strikt in Minuten (Integer). "1 Stunde" = 60.
- Rauschen ignorieren: Keine Werbung, Einleitungstexte oder Bildunterschriften extrahieren.
STRIKTE ANWEISUNG: Gib ausschließlich das rohe JSON-Objekt gemäß Schema zurück. Kein Markdown-Code-Block, kein Einleitungstext, keine Prosa.`;
export const RECIPE_EXTRACTION_USER_PROMPT =
'Analysiere dieses Bild hochauflösend. Extrahiere alle rezeptrelevanten Informationen gemäß deiner System-Instruktion. Achte besonders auf schwache Handschriften oder verblassten Text und stelle sicher, dass die Zuordnung von Menge zu Zutat logisch korrekt ist.';
// Gemini responseSchema (Subset von OpenAPI). Wird an GenerativeModel.generateContent
// übergeben; Gemini respektiert die Struktur und liefert valides JSON.

View File

@@ -30,15 +30,12 @@ export function searchLocal(
db: Database.Database,
query: string,
limit = 30,
offset = 0,
domains: string[] = []
offset = 0
): SearchHit[] {
const fts = buildFtsQuery(query);
if (!fts) return [];
// bm25: lower is better. Use weights: title > tags > ingredients > description
const hasFilter = domains.length > 0;
const placeholders = hasFilter ? domains.map(() => '?').join(',') : '';
const sql = `SELECT r.id,
r.title,
r.description,
@@ -49,13 +46,9 @@ export function searchLocal(
FROM recipe r
JOIN recipe_fts f ON f.rowid = r.id
WHERE recipe_fts MATCH ?
${hasFilter ? `AND r.source_domain IN (${placeholders})` : ''}
ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0)
LIMIT ? OFFSET ?`;
const params = hasFilter
? [fts, ...domains, limit, offset]
: [fts, limit, offset];
return db.prepare(sql).all(...params) as SearchHit[];
return db.prepare(sql).all(fts, limit, offset) as SearchHit[];
}
export function listRecentRecipes(

View File

@@ -31,7 +31,7 @@
const navStore = new SearchStore({
pageSize: 30,
filterParam: () => {
webFilterParam: () => {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}

View File

@@ -16,7 +16,7 @@
const store = new SearchStore({
pageSize: LOCAL_PAGE,
filterParam: () => {
webFilterParam: () => {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}

View File

@@ -121,9 +121,11 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
: e.code === 'AI_NOT_CONFIGURED'
? 503
: 503;
// Nur Code + Meta loggen, niemals Prompt/Response-Inhalt.
// Nur Code + Meta + Error-Message loggen, niemals Prompt/Response-Inhalt.
// e.message enthaelt z.B. Zod-Validierungspfade oder "non-JSON output" --
// kein AI-Content, aber die Diagnose-Info, warum AI_FAILED kam.
console.warn(
`[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes`
`[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes: ${e.message}`
);
return errJson(status, e.code, 'Die Bild-Analyse ist fehlgeschlagen.');
}

View File

@@ -7,13 +7,9 @@ export const GET: RequestHandler = async ({ url }) => {
const q = url.searchParams.get('q')?.trim() ?? '';
const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100);
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0));
const domains = (url.searchParams.get('domains') ?? '')
.split(',')
.map((d) => d.trim())
.filter(Boolean);
const hits =
q.length >= 1
? searchLocal(getDb(), q, limit, offset, domains)
? searchLocal(getDb(), q, limit, offset)
: offset === 0
? listRecentRecipes(getDb(), limit)
: [];

View File

@@ -69,20 +69,11 @@ describe('searchLocal', () => {
expect(searchLocal(db, ' ')).toEqual([]);
});
it('filters by domain when supplied', () => {
it('ignores source_domain — local search is domain-agnostic', () => {
const db = openInMemoryForTest();
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
const hits = searchLocal(db, 'apfel', 10, 0, ['chefkoch.de']);
expect(hits.length).toBe(1);
expect(hits[0].source_domain).toBe('chefkoch.de');
});
it('no domain filter when array is empty', () => {
const db = openInMemoryForTest();
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
const hits = searchLocal(db, 'apfel', 10, 0, []);
const hits = searchLocal(db, 'apfel');
expect(hits.length).toBe(2);
});

View File

@@ -202,7 +202,7 @@ describe('SearchStore', () => {
expect(round).toEqual(snap);
});
it('filterParam option: gets appended to both local and web requests', async () => {
it('webFilterParam option: only appended to web requests, never to local', async () => {
vi.useFakeTimers();
const fetchImpl = mockFetch([
{ body: { hits: [] } },
@@ -211,13 +211,15 @@ describe('SearchStore', () => {
const store = new SearchStore({
fetchImpl,
debounceMs: 10,
filterParam: () => '&domains=chefkoch.de'
webFilterParam: () => '&domains=chefkoch.de'
});
store.query = 'curry';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
expect(fetchImpl.mock.calls[0][0]).toMatch(/&domains=chefkoch\.de/);
expect(fetchImpl.mock.calls[0][0]).not.toMatch(/domains=/);
expect(fetchImpl.mock.calls[0][0]).toMatch(/\/api\/recipes\/search\?/);
expect(fetchImpl.mock.calls[1][0]).toMatch(/\/api\/recipes\/search\/web\?/);
expect(fetchImpl.mock.calls[1][0]).toMatch(/&domains=chefkoch\.de/);
});
@@ -243,22 +245,25 @@ describe('SearchStore', () => {
const fetchImpl = mockFetch([
{ body: { hits: [] } },
{ body: { hits: [] } },
{ body: { hits: [{ id: 1, title: 'filtered', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } }
{ body: { hits: [] } },
{ body: { hits: [{ url: 'https://chefkoch.de/x', title: 'filtered', domain: 'chefkoch.de', snippet: null, thumbnail: null }] } }
]);
const store = new SearchStore({
fetchImpl,
debounceMs: 10,
filterDebounceMs: 5,
filterParam: () => filter
webFilterParam: () => filter
});
store.query = 'broth';
store.runDebounced();
await vi.advanceTimersByTimeAsync(15);
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
filter = '&domains=chefkoch.de';
store.reSearch();
await vi.advanceTimersByTimeAsync(10);
await vi.waitFor(() => expect(store.hits).toHaveLength(1));
await vi.waitFor(() => expect(store.webHits).toHaveLength(1));
const last = fetchImpl.mock.calls.at(-1)?.[0] as string;
expect(last).toMatch(/\/api\/recipes\/search\/web\?/);
expect(last).toMatch(/&domains=chefkoch\.de/);
});
});