15 bite-sized tasks mit TDD-Struktur, von deps+env ueber Gemini-Client, API-Endpoints bis UI und Release. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2238 lines
71 KiB
Markdown
2238 lines
71 KiB
Markdown
# Foto-Rezept-Magie Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Nutzer kann ein Foto eines gedruckten oder handgeschriebenen Rezepts aufnehmen — Gemini 2.5 Flash extrahiert die Felder, der Nutzer landet im vorausgefüllten `RecipeEditor` und speichert.
|
||
|
||
**Architecture:** Foto geht per Multipart an einen neuen Server-Endpoint, der es mit `sharp` runterskaliert, einmal an Gemini schickt (structured output + Zod-validiert, 1× Retry) und das extrahierte `Partial<Recipe>` zurückgibt — **ohne** DB-Write. Der Client hält das Ergebnis in State und rendert den bestehenden `RecipeEditor` mit `recipe.id === null`. Auf Save POSTet der Client die kompletten Felder an einen neuen `POST /api/recipes`-Endpoint, der `insertRecipe` wrappt. Weder das Original-Foto noch die AI-Payload werden persistiert oder geloggt.
|
||
|
||
**Tech Stack:** SvelteKit 2, Svelte 5, better-sqlite3, `sharp` (Image-Preprocess), `@google/generative-ai` (Gemini-SDK), `zod` (Validierung), Vitest (Unit + API), Playwright (E2E).
|
||
|
||
**Spec reference:** `docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md`
|
||
|
||
---
|
||
|
||
## Task 1: Dependencies & Env-Config
|
||
|
||
**Files:**
|
||
- Modify: `package.json` (add `@google/generative-ai`, `sharp`)
|
||
- Modify: `Dockerfile:3-10` (ergänze build deps für `sharp`)
|
||
- Modify: `.env.example`, `docker-compose.yml`, `docker-compose.prod.yml`
|
||
- Create: (nothing new)
|
||
|
||
Dies ist **Infrastruktur-Setup**. Keine Tests — die Deps-Installation verifiziert sich von selbst, der Dockerfile-Build durch CI im Follow-up.
|
||
|
||
- [ ] **Step 1: Deps installieren**
|
||
|
||
```bash
|
||
npm install @google/generative-ai sharp
|
||
```
|
||
|
||
Expected: beide in `dependencies` in `package.json`, `package-lock.json` aktualisiert.
|
||
|
||
- [ ] **Step 2: Dockerfile erweitern für sharp-arm64**
|
||
|
||
Ersetze Zeile 7 (`RUN apk add --no-cache python3 make g++ libc6-compat`) durch:
|
||
|
||
```dockerfile
|
||
# Alpine braucht Build-Tools für better-sqlite3 (native) und sharp (libvips);
|
||
# vips-dev liefert libheif-Support für HEIC-Input von iOS.
|
||
RUN apk add --no-cache python3 make g++ libc6-compat vips-dev
|
||
```
|
||
|
||
Runtime-Stage (Zeile 21) bleibt `libc6-compat` — sharp linkt libvips statisch in seinen npm-Prebuilds.
|
||
|
||
- [ ] **Step 3: Env-Vars ergänzen**
|
||
|
||
In `.env.example` ergänzen:
|
||
|
||
```
|
||
# Gemini Vision (Foto-Rezept-Magie). Ohne Key ist die Funktion deaktiviert.
|
||
GEMINI_API_KEY=
|
||
GEMINI_MODEL=gemini-2.5-flash
|
||
GEMINI_TIMEOUT_MS=20000
|
||
```
|
||
|
||
In `docker-compose.yml` und `docker-compose.prod.yml` im `environment`-Block des App-Services die drei Variablen ergänzen:
|
||
|
||
```yaml
|
||
environment:
|
||
# …bestehende Einträge…
|
||
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||
GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.5-flash}
|
||
GEMINI_TIMEOUT_MS: ${GEMINI_TIMEOUT_MS:-20000}
|
||
```
|
||
|
||
- [ ] **Step 4: Build verifizieren**
|
||
|
||
```bash
|
||
npm run check
|
||
```
|
||
|
||
Expected: PASS (keine neuen Type-Fehler durch die neuen Packages — sie werden noch nicht importiert).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add package.json package-lock.json Dockerfile .env.example docker-compose.yml docker-compose.prod.yml
|
||
git commit -m "chore(deps): Gemini-SDK und sharp fuer Foto-Rezept-Magie"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Description-Phrasen-Pool
|
||
|
||
**Files:**
|
||
- Create: `src/lib/server/ai/description-phrases.ts`
|
||
- Create: `src/lib/server/ai/description-phrases.test.ts`
|
||
|
||
Isolierter, reiner Helper. TDD: erst der Test, dann das Modul.
|
||
|
||
- [ ] **Step 1: Failing test schreiben**
|
||
|
||
`src/lib/server/ai/description-phrases.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from 'vitest';
|
||
import { DESCRIPTION_PHRASES, pickRandomPhrase } from './description-phrases';
|
||
|
||
describe('description-phrases', () => {
|
||
it('contains exactly 50 entries', () => {
|
||
expect(DESCRIPTION_PHRASES).toHaveLength(50);
|
||
});
|
||
|
||
it('has no empty or whitespace-only entries', () => {
|
||
for (const phrase of DESCRIPTION_PHRASES) {
|
||
expect(phrase.trim().length).toBeGreaterThan(0);
|
||
}
|
||
});
|
||
|
||
it('has no duplicates', () => {
|
||
const set = new Set(DESCRIPTION_PHRASES);
|
||
expect(set.size).toBe(DESCRIPTION_PHRASES.length);
|
||
});
|
||
|
||
it('pickRandomPhrase returns a member of the pool', () => {
|
||
const pool = new Set(DESCRIPTION_PHRASES);
|
||
for (let i = 0; i < 100; i++) {
|
||
expect(pool.has(pickRandomPhrase())).toBe(true);
|
||
}
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen (erwartet FAIL)**
|
||
|
||
```bash
|
||
npx vitest run src/lib/server/ai/description-phrases.test.ts
|
||
```
|
||
|
||
Expected: FAIL — Modul existiert noch nicht.
|
||
|
||
- [ ] **Step 3: Modul implementieren**
|
||
|
||
`src/lib/server/ai/description-phrases.ts`:
|
||
|
||
```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)];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen (erwartet PASS)**
|
||
|
||
```bash
|
||
npx vitest run src/lib/server/ai/description-phrases.test.ts
|
||
```
|
||
|
||
Expected: PASS — alle 4 Tests grün.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/server/ai/description-phrases.ts src/lib/server/ai/description-phrases.test.ts
|
||
git commit -m "feat(ai): 50er-Pool Magie-Phrasen fuer Foto-description"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Image-Preprocess (sharp-Wrapper)
|
||
|
||
**Files:**
|
||
- Create: `src/lib/server/ai/image-preprocess.ts`
|
||
- Create: `src/lib/server/ai/image-preprocess.test.ts`
|
||
|
||
Reine Funktion: Buffer rein, Buffer raus. Keine Side-Effects, keine I/O außerhalb von `sharp`.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`src/lib/server/ai/image-preprocess.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from 'vitest';
|
||
import sharp from 'sharp';
|
||
import { preprocessImage } from './image-preprocess';
|
||
|
||
async function makeTestImage(width: number, height: number, format: 'jpeg' | 'png' | 'webp' = 'jpeg'): Promise<Buffer> {
|
||
return sharp({
|
||
create: {
|
||
width,
|
||
height,
|
||
channels: 3,
|
||
background: { r: 128, g: 128, b: 128 }
|
||
}
|
||
})
|
||
.toFormat(format)
|
||
.toBuffer();
|
||
}
|
||
|
||
describe('preprocessImage', () => {
|
||
it('resizes a landscape image so long edge <= 1600px', async () => {
|
||
const input = await makeTestImage(4000, 2000);
|
||
const { buffer, mimeType } = await preprocessImage(input);
|
||
const meta = await sharp(buffer).metadata();
|
||
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1600);
|
||
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeGreaterThan(1000);
|
||
expect(mimeType).toBe('image/jpeg');
|
||
});
|
||
|
||
it('resizes a portrait image so long edge <= 1600px', async () => {
|
||
const input = await makeTestImage(2000, 4000);
|
||
const { buffer } = await preprocessImage(input);
|
||
const meta = await sharp(buffer).metadata();
|
||
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1600);
|
||
});
|
||
|
||
it('does not upscale smaller images', async () => {
|
||
const input = await makeTestImage(800, 600);
|
||
const { buffer } = await preprocessImage(input);
|
||
const meta = await sharp(buffer).metadata();
|
||
expect(meta.width).toBe(800);
|
||
expect(meta.height).toBe(600);
|
||
});
|
||
|
||
it('converts PNG input to JPEG output', async () => {
|
||
const input = await makeTestImage(1000, 1000, 'png');
|
||
const { buffer, mimeType } = await preprocessImage(input);
|
||
const meta = await sharp(buffer).metadata();
|
||
expect(meta.format).toBe('jpeg');
|
||
expect(mimeType).toBe('image/jpeg');
|
||
});
|
||
|
||
it('strips EXIF metadata', async () => {
|
||
const input = await sharp({
|
||
create: { width: 100, height: 100, channels: 3, background: '#888' }
|
||
})
|
||
.withMetadata({ exif: { IFD0: { Copyright: 'test' } } })
|
||
.jpeg()
|
||
.toBuffer();
|
||
const { buffer } = await preprocessImage(input);
|
||
const meta = await sharp(buffer).metadata();
|
||
expect(meta.exif).toBeUndefined();
|
||
});
|
||
|
||
it('rejects non-image buffers', async () => {
|
||
const notAnImage = Buffer.from('hello world');
|
||
await expect(preprocessImage(notAnImage)).rejects.toThrow();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen (erwartet FAIL)**
|
||
|
||
```bash
|
||
npx vitest run src/lib/server/ai/image-preprocess.test.ts
|
||
```
|
||
|
||
Expected: FAIL — Modul existiert noch nicht.
|
||
|
||
- [ ] **Step 3: Modul implementieren**
|
||
|
||
`src/lib/server/ai/image-preprocess.ts`:
|
||
|
||
```ts
|
||
import sharp from 'sharp';
|
||
|
||
const MAX_EDGE = 1600;
|
||
const JPEG_QUALITY = 85;
|
||
|
||
export type PreprocessedImage = {
|
||
buffer: Buffer;
|
||
mimeType: 'image/jpeg';
|
||
};
|
||
|
||
// Resize auf max 1600px lange Kante, JPEG re-encode, Metadata strippen.
|
||
// sharp liest HEIC/HEIF transparent wenn libheif/libvips mit Support gebaut wurde
|
||
// (ist in Alpine's vips-dev enthalten).
|
||
export async function preprocessImage(input: Buffer): Promise<PreprocessedImage> {
|
||
const pipeline = sharp(input, { failOn: 'error' }).rotate(); // respect EXIF orientation
|
||
const meta = await pipeline.metadata();
|
||
if (!meta.width || !meta.height) {
|
||
throw new Error('Unable to read image dimensions');
|
||
}
|
||
|
||
const longEdge = Math.max(meta.width, meta.height);
|
||
const resizer = longEdge > MAX_EDGE
|
||
? pipeline.resize({ width: meta.width >= meta.height ? MAX_EDGE : undefined,
|
||
height: meta.height > meta.width ? MAX_EDGE : undefined,
|
||
withoutEnlargement: true })
|
||
: pipeline;
|
||
|
||
const buffer = await resizer
|
||
.jpeg({ quality: JPEG_QUALITY, mozjpeg: true })
|
||
.withMetadata({}) // drops EXIF/IPTC/XMP
|
||
.toBuffer();
|
||
|
||
return { buffer, mimeType: 'image/jpeg' };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen (erwartet PASS)**
|
||
|
||
```bash
|
||
npx vitest run src/lib/server/ai/image-preprocess.test.ts
|
||
```
|
||
|
||
Expected: PASS — alle 6 Tests grün. Falls der EXIF-Strip-Test fehlschlägt: sharp's `withMetadata({})` muss leere Options haben, nicht weglassen.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/server/ai/image-preprocess.ts src/lib/server/ai/image-preprocess.test.ts
|
||
git commit -m "feat(ai): image-preprocess mit sharp (Resize + JPEG-Strip)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Extraction-Prompt + JSON-Schema + Zod-Validator
|
||
|
||
**Files:**
|
||
- Create: `src/lib/server/ai/recipe-extraction-prompt.ts`
|
||
- Create: `src/lib/server/ai/recipe-extraction-prompt.test.ts`
|
||
|
||
Hier liegt der extrahierte Prompt-Text plus das Gemini-`responseSchema` plus das Zod-Spiegelbild davon. Tests gegen die Zod-Validierung — den Gemini-Call selbst testen wir erst in Task 5.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`src/lib/server/ai/recipe-extraction-prompt.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
RECIPE_EXTRACTION_SYSTEM_PROMPT,
|
||
GEMINI_RESPONSE_SCHEMA,
|
||
extractionResponseSchema,
|
||
type ExtractionResponse
|
||
} from './recipe-extraction-prompt';
|
||
|
||
describe('recipe-extraction-prompt', () => {
|
||
it('system prompt is in German and mentions title+ingredients+steps', () => {
|
||
const p = RECIPE_EXTRACTION_SYSTEM_PROMPT.toLowerCase();
|
||
expect(p).toContain('rezept');
|
||
expect(p).toContain('zutaten');
|
||
expect(p).toContain('zubereitung');
|
||
});
|
||
|
||
it('Gemini response schema has required top-level keys', () => {
|
||
expect(GEMINI_RESPONSE_SCHEMA.type).toBe('object');
|
||
expect(Object.keys(GEMINI_RESPONSE_SCHEMA.properties)).toEqual(
|
||
expect.arrayContaining(['title', 'ingredients', 'steps'])
|
||
);
|
||
});
|
||
|
||
it('Zod validator accepts a well-formed response', () => {
|
||
const good: ExtractionResponse = {
|
||
title: 'Testrezept',
|
||
servings_default: 4,
|
||
servings_unit: 'Portionen',
|
||
prep_time_min: 15,
|
||
cook_time_min: 30,
|
||
total_time_min: null,
|
||
ingredients: [
|
||
{ quantity: 100, unit: 'g', name: 'Mehl', note: null }
|
||
],
|
||
steps: [
|
||
{ text: 'Mehl in eine Schüssel geben.' }
|
||
]
|
||
};
|
||
expect(() => extractionResponseSchema.parse(good)).not.toThrow();
|
||
});
|
||
|
||
it('Zod validator rejects missing title', () => {
|
||
const bad = {
|
||
servings_default: 4,
|
||
ingredients: [],
|
||
steps: []
|
||
};
|
||
expect(() => extractionResponseSchema.parse(bad)).toThrow();
|
||
});
|
||
|
||
it('Zod validator coerces quantity=null and unit=null', () => {
|
||
const ok: ExtractionResponse = {
|
||
title: 'Prise-Rezept',
|
||
servings_default: null,
|
||
servings_unit: null,
|
||
prep_time_min: null,
|
||
cook_time_min: null,
|
||
total_time_min: null,
|
||
ingredients: [{ quantity: null, unit: null, name: 'Salz', note: 'nach Geschmack' }],
|
||
steps: [{ text: 'Einfach so.' }]
|
||
};
|
||
expect(() => extractionResponseSchema.parse(ok)).not.toThrow();
|
||
});
|
||
|
||
it('Zod validator rejects unexpected extra top-level keys (strict)', () => {
|
||
const bad = {
|
||
title: 'x',
|
||
servings_default: null,
|
||
servings_unit: null,
|
||
prep_time_min: null,
|
||
cook_time_min: null,
|
||
total_time_min: null,
|
||
ingredients: [],
|
||
steps: [],
|
||
malicious_extra_field: 'pwned'
|
||
};
|
||
expect(() => extractionResponseSchema.parse(bad)).toThrow();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen (erwartet FAIL)**
|
||
|
||
```bash
|
||
npx vitest run src/lib/server/ai/recipe-extraction-prompt.test.ts
|
||
```
|
||
|
||
Expected: FAIL — Modul existiert noch nicht.
|
||
|
||
- [ ] **Step 3: Modul implementieren**
|
||
|
||
`src/lib/server/ai/recipe-extraction-prompt.ts`:
|
||
|
||
```ts
|
||
import { z } from 'zod';
|
||
import { SchemaType } from '@google/generative-ai';
|
||
|
||
// Hinweis: Das Gemini-SDK nutzt eigene Schema-Konstanten (SchemaType.STRING etc.).
|
||
// Wir halten das Schema hier rein-JS und konvertieren erst im Client.
|
||
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.
|
||
|
||
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, …).
|
||
- 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.`;
|
||
|
||
// Gemini responseSchema (Subset von OpenAPI). Wird an GenerativeModel.generateContent
|
||
// übergeben; Gemini respektiert die Struktur und liefert garantiert valides JSON.
|
||
export const GEMINI_RESPONSE_SCHEMA = {
|
||
type: SchemaType.OBJECT,
|
||
properties: {
|
||
title: { type: SchemaType.STRING, nullable: false },
|
||
servings_default: { type: SchemaType.INTEGER, nullable: true },
|
||
servings_unit: { type: SchemaType.STRING, nullable: true },
|
||
prep_time_min: { type: SchemaType.INTEGER, nullable: true },
|
||
cook_time_min: { type: SchemaType.INTEGER, nullable: true },
|
||
total_time_min: { type: SchemaType.INTEGER, nullable: true },
|
||
ingredients: {
|
||
type: SchemaType.ARRAY,
|
||
items: {
|
||
type: SchemaType.OBJECT,
|
||
properties: {
|
||
quantity: { type: SchemaType.NUMBER, nullable: true },
|
||
unit: { type: SchemaType.STRING, nullable: true },
|
||
name: { type: SchemaType.STRING, nullable: false },
|
||
note: { type: SchemaType.STRING, nullable: true }
|
||
},
|
||
required: ['name']
|
||
}
|
||
},
|
||
steps: {
|
||
type: SchemaType.ARRAY,
|
||
items: {
|
||
type: SchemaType.OBJECT,
|
||
properties: {
|
||
text: { type: SchemaType.STRING, nullable: false }
|
||
},
|
||
required: ['text']
|
||
}
|
||
}
|
||
},
|
||
required: ['title', 'ingredients', 'steps']
|
||
} as const;
|
||
|
||
// Zod-Spiegel des Schemas für server-seitige Validierung der Gemini-Antwort.
|
||
// .strict() verhindert, dass zusätzliche Keys (die Gemini theoretisch erfinden könnte)
|
||
// unbemerkt durchrutschen.
|
||
const ingredientSchema = z.object({
|
||
quantity: z.number().nullable(),
|
||
unit: z.string().max(30).nullable(),
|
||
name: z.string().min(1).max(200),
|
||
note: z.string().max(300).nullable()
|
||
}).strict();
|
||
|
||
const stepSchema = z.object({
|
||
text: z.string().min(1).max(4000)
|
||
}).strict();
|
||
|
||
export const extractionResponseSchema = z.object({
|
||
title: z.string().min(1).max(200),
|
||
servings_default: z.number().int().nonnegative().nullable(),
|
||
servings_unit: z.string().max(30).nullable(),
|
||
prep_time_min: z.number().int().nonnegative().nullable(),
|
||
cook_time_min: z.number().int().nonnegative().nullable(),
|
||
total_time_min: z.number().int().nonnegative().nullable(),
|
||
ingredients: z.array(ingredientSchema),
|
||
steps: z.array(stepSchema)
|
||
}).strict();
|
||
|
||
export type ExtractionResponse = z.infer<typeof extractionResponseSchema>;
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen (erwartet PASS)**
|
||
|
||
```bash
|
||
npx vitest run src/lib/server/ai/recipe-extraction-prompt.test.ts
|
||
```
|
||
|
||
Expected: PASS — alle 6 Tests grün.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/server/ai/recipe-extraction-prompt.ts src/lib/server/ai/recipe-extraction-prompt.test.ts
|
||
git commit -m "feat(ai): Extraction-Prompt + Gemini-Schema + Zod-Validator"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Gemini-Client (Call, Timeout, Retry)
|
||
|
||
**Files:**
|
||
- Create: `src/lib/server/ai/gemini-client.ts`
|
||
- Create: `src/lib/server/ai/gemini-client.test.ts`
|
||
|
||
Dünne Wrapper-Schicht, deren einziger Job ist: Image-Buffer + MIME → `ExtractionResponse` (oder wohldefinierter Fehler). Tests mocken das Gemini-SDK.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`src/lib/server/ai/gemini-client.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
|
||
// Mock das Gemini-SDK bevor der Client importiert wird.
|
||
const mockGenerateContent = vi.fn();
|
||
vi.mock('@google/generative-ai', async () => {
|
||
const actual = await vi.importActual<typeof import('@google/generative-ai')>('@google/generative-ai');
|
||
return {
|
||
...actual,
|
||
GoogleGenerativeAI: vi.fn().mockImplementation(() => ({
|
||
getGenerativeModel: () => ({ generateContent: mockGenerateContent })
|
||
}))
|
||
};
|
||
});
|
||
|
||
import { extractRecipeFromImage, GeminiError } from './gemini-client';
|
||
|
||
beforeEach(() => {
|
||
mockGenerateContent.mockReset();
|
||
process.env.GEMINI_API_KEY = 'test-key';
|
||
process.env.GEMINI_MODEL = 'gemini-2.5-flash';
|
||
process.env.GEMINI_TIMEOUT_MS = '5000';
|
||
});
|
||
|
||
const validResponse = {
|
||
title: 'Apfelkuchen',
|
||
servings_default: 8,
|
||
servings_unit: 'Stück',
|
||
prep_time_min: 20,
|
||
cook_time_min: 45,
|
||
total_time_min: null,
|
||
ingredients: [{ quantity: 500, unit: 'g', name: 'Mehl', note: null }],
|
||
steps: [{ text: 'Ofen auf 180 °C vorheizen.' }]
|
||
};
|
||
|
||
describe('extractRecipeFromImage', () => {
|
||
it('happy path: returns parsed recipe data', async () => {
|
||
mockGenerateContent.mockResolvedValueOnce({
|
||
response: { text: () => JSON.stringify(validResponse) }
|
||
});
|
||
const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg');
|
||
expect(result.title).toBe('Apfelkuchen');
|
||
expect(result.ingredients).toHaveLength(1);
|
||
});
|
||
|
||
it('retries once on schema-invalid JSON, then succeeds', async () => {
|
||
mockGenerateContent
|
||
.mockResolvedValueOnce({ response: { text: () => '{"title": "no arrays"}' } })
|
||
.mockResolvedValueOnce({ response: { text: () => JSON.stringify(validResponse) } });
|
||
const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg');
|
||
expect(result.title).toBe('Apfelkuchen');
|
||
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('throws AI_FAILED after schema-invalid + retry also invalid', async () => {
|
||
mockGenerateContent
|
||
.mockResolvedValueOnce({ response: { text: () => '{}' } })
|
||
.mockResolvedValueOnce({ response: { text: () => '{"title": "still bad"}' } });
|
||
await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg'))
|
||
.rejects.toMatchObject({ code: 'AI_FAILED' });
|
||
});
|
||
|
||
it('throws AI_RATE_LIMITED without retry on 429', async () => {
|
||
const err = new Error('429 Too Many Requests') as Error & { status?: number };
|
||
err.status = 429;
|
||
mockGenerateContent.mockRejectedValueOnce(err);
|
||
await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg'))
|
||
.rejects.toMatchObject({ code: 'AI_RATE_LIMITED' });
|
||
expect(mockGenerateContent).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('retries once on 5xx, then succeeds', async () => {
|
||
const err = new Error('500 Server Error') as Error & { status?: number };
|
||
err.status = 500;
|
||
mockGenerateContent
|
||
.mockRejectedValueOnce(err)
|
||
.mockResolvedValueOnce({ response: { text: () => JSON.stringify(validResponse) } });
|
||
const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg');
|
||
expect(result.title).toBe('Apfelkuchen');
|
||
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('throws AI_FAILED after 5xx + retry also fails', async () => {
|
||
const err = new Error('500') as Error & { status?: number };
|
||
err.status = 500;
|
||
mockGenerateContent.mockRejectedValue(err);
|
||
await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg'))
|
||
.rejects.toMatchObject({ code: 'AI_FAILED' });
|
||
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('throws AI_TIMEOUT when generateContent does not resolve within GEMINI_TIMEOUT_MS', async () => {
|
||
process.env.GEMINI_TIMEOUT_MS = '50';
|
||
mockGenerateContent.mockImplementation(() => new Promise(() => {})); // never resolves
|
||
await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg'))
|
||
.rejects.toMatchObject({ code: 'AI_TIMEOUT' });
|
||
});
|
||
|
||
it('throws AI_NOT_CONFIGURED when GEMINI_API_KEY is empty', async () => {
|
||
process.env.GEMINI_API_KEY = '';
|
||
await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg'))
|
||
.rejects.toMatchObject({ code: 'AI_NOT_CONFIGURED' });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen (erwartet FAIL)**
|
||
|
||
```bash
|
||
npx vitest run src/lib/server/ai/gemini-client.test.ts
|
||
```
|
||
|
||
Expected: FAIL — Modul existiert noch nicht.
|
||
|
||
- [ ] **Step 3: Modul implementieren**
|
||
|
||
`src/lib/server/ai/gemini-client.ts`:
|
||
|
||
```ts
|
||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||
import { env } from '$env/dynamic/private';
|
||
import {
|
||
RECIPE_EXTRACTION_SYSTEM_PROMPT,
|
||
GEMINI_RESPONSE_SCHEMA,
|
||
extractionResponseSchema,
|
||
type ExtractionResponse
|
||
} from './recipe-extraction-prompt';
|
||
|
||
export type GeminiErrorCode =
|
||
| 'AI_NOT_CONFIGURED'
|
||
| 'AI_RATE_LIMITED'
|
||
| 'AI_TIMEOUT'
|
||
| 'AI_FAILED';
|
||
|
||
export class GeminiError extends Error {
|
||
constructor(public readonly code: GeminiErrorCode, message: string) {
|
||
super(message);
|
||
this.name = 'GeminiError';
|
||
}
|
||
}
|
||
|
||
function getStatus(err: unknown): number | undefined {
|
||
if (err && typeof err === 'object' && 'status' in err) {
|
||
const s = (err as { status?: unknown }).status;
|
||
if (typeof s === 'number') return s;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => reject(new GeminiError('AI_TIMEOUT', `Gemini timeout after ${ms} ms`)), ms);
|
||
promise.then(
|
||
(v) => { clearTimeout(timer); resolve(v); },
|
||
(e) => { clearTimeout(timer); reject(e); }
|
||
);
|
||
});
|
||
}
|
||
|
||
// Call Gemini once. Throws GeminiError on known failures, rethrows network/status errors.
|
||
async function callGemini(
|
||
imageBuffer: Buffer,
|
||
mimeType: string,
|
||
appendUserNote?: string
|
||
): Promise<ExtractionResponse> {
|
||
const apiKey = env.GEMINI_API_KEY ?? process.env.GEMINI_API_KEY;
|
||
if (!apiKey) {
|
||
throw new GeminiError('AI_NOT_CONFIGURED', 'GEMINI_API_KEY is not set');
|
||
}
|
||
const modelId = env.GEMINI_MODEL ?? process.env.GEMINI_MODEL ?? 'gemini-2.5-flash';
|
||
const timeoutMs = Number(env.GEMINI_TIMEOUT_MS ?? process.env.GEMINI_TIMEOUT_MS ?? 20000);
|
||
|
||
const client = new GoogleGenerativeAI(apiKey);
|
||
const model = client.getGenerativeModel({
|
||
model: modelId,
|
||
systemInstruction: RECIPE_EXTRACTION_SYSTEM_PROMPT,
|
||
generationConfig: {
|
||
temperature: 0.1,
|
||
responseMimeType: 'application/json',
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
responseSchema: GEMINI_RESPONSE_SCHEMA as any
|
||
}
|
||
});
|
||
|
||
const parts: Array<{ inlineData: { data: string; mimeType: string } } | { text: string }> = [
|
||
{ inlineData: { data: imageBuffer.toString('base64'), mimeType } }
|
||
];
|
||
if (appendUserNote) parts.push({ text: appendUserNote });
|
||
|
||
const result = await withTimeout(
|
||
model.generateContent({ contents: [{ role: 'user', parts }] }),
|
||
timeoutMs
|
||
);
|
||
const text = result.response.text();
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(text);
|
||
} catch {
|
||
throw new GeminiError('AI_FAILED', 'Gemini returned non-JSON output');
|
||
}
|
||
const validated = extractionResponseSchema.safeParse(parsed);
|
||
if (!validated.success) {
|
||
throw new GeminiError('AI_FAILED', `Schema validation failed: ${validated.error.message}`);
|
||
}
|
||
return validated.data;
|
||
}
|
||
|
||
// Public entry: one retry on recoverable failures (5xx OR schema-invalid),
|
||
// no retry on 429 or config errors.
|
||
export async function extractRecipeFromImage(
|
||
imageBuffer: Buffer,
|
||
mimeType: string
|
||
): Promise<ExtractionResponse> {
|
||
try {
|
||
return await callGemini(imageBuffer, mimeType);
|
||
} catch (e) {
|
||
if (e instanceof GeminiError && e.code === 'AI_NOT_CONFIGURED') throw e;
|
||
if (e instanceof GeminiError && e.code === 'AI_TIMEOUT') throw e;
|
||
|
||
const status = getStatus(e);
|
||
if (status === 429) throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit');
|
||
|
||
const recoverable =
|
||
(e instanceof GeminiError && e.code === 'AI_FAILED') ||
|
||
(status !== undefined && status >= 500);
|
||
if (!recoverable) {
|
||
throw e instanceof GeminiError ? e : new GeminiError('AI_FAILED', String(e));
|
||
}
|
||
|
||
// 1x retry
|
||
await new Promise((r) => setTimeout(r, 500));
|
||
try {
|
||
return await callGemini(
|
||
imageBuffer,
|
||
mimeType,
|
||
'Dein vorheriger Output war ungültig. Bitte antworte ausschließlich mit JSON gemäß Schema.'
|
||
);
|
||
} catch (retryErr) {
|
||
if (retryErr instanceof GeminiError) 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));
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen (erwartet PASS)**
|
||
|
||
```bash
|
||
npx vitest run src/lib/server/ai/gemini-client.test.ts
|
||
```
|
||
|
||
Expected: PASS — alle 8 Tests grün. Falls Tests aufgrund des Env-Mocks flakig sind: sichergehen dass `process.env` im `beforeEach` rein sauber gesetzt wird.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/server/ai/gemini-client.ts src/lib/server/ai/gemini-client.test.ts
|
||
git commit -m "feat(ai): Gemini-Client mit Timeout, 1x-Retry und Fehler-Codes"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Rate-Limiter (In-Memory, pro IP)
|
||
|
||
**Files:**
|
||
- Create: `src/lib/server/ai/rate-limit.ts`
|
||
- Create: `src/lib/server/ai/rate-limit.test.ts`
|
||
|
||
Einfacher `Map<ip, {count, resetAt}>` — 10 Requests/Minute pro IP.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`src/lib/server/ai/rate-limit.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { createRateLimiter } from './rate-limit';
|
||
|
||
describe('rate-limit', () => {
|
||
beforeEach(() => vi.useFakeTimers());
|
||
|
||
it('allows first 10 requests, rejects 11th', () => {
|
||
const limiter = createRateLimiter({ windowMs: 60_000, max: 10 });
|
||
for (let i = 0; i < 10; i++) expect(limiter.check('1.2.3.4')).toBe(true);
|
||
expect(limiter.check('1.2.3.4')).toBe(false);
|
||
});
|
||
|
||
it('tracks per-IP independently', () => {
|
||
const limiter = createRateLimiter({ windowMs: 60_000, max: 2 });
|
||
expect(limiter.check('a')).toBe(true);
|
||
expect(limiter.check('a')).toBe(true);
|
||
expect(limiter.check('a')).toBe(false);
|
||
expect(limiter.check('b')).toBe(true);
|
||
});
|
||
|
||
it('resets after window elapses', () => {
|
||
const limiter = createRateLimiter({ windowMs: 1000, max: 1 });
|
||
expect(limiter.check('x')).toBe(true);
|
||
expect(limiter.check('x')).toBe(false);
|
||
vi.advanceTimersByTime(1001);
|
||
expect(limiter.check('x')).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen (erwartet FAIL)**
|
||
|
||
```bash
|
||
npx vitest run src/lib/server/ai/rate-limit.test.ts
|
||
```
|
||
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Modul implementieren**
|
||
|
||
`src/lib/server/ai/rate-limit.ts`:
|
||
|
||
```ts
|
||
export type RateLimiter = { check: (key: string) => boolean };
|
||
|
||
export function createRateLimiter(opts: { windowMs: number; max: number }): RateLimiter {
|
||
const store = new Map<string, { count: number; resetAt: number }>();
|
||
return {
|
||
check(key: string): boolean {
|
||
const now = Date.now();
|
||
const entry = store.get(key);
|
||
if (!entry || entry.resetAt <= now) {
|
||
store.set(key, { count: 1, resetAt: now + opts.windowMs });
|
||
return true;
|
||
}
|
||
if (entry.count >= opts.max) return false;
|
||
entry.count += 1;
|
||
return true;
|
||
}
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen (erwartet PASS)**
|
||
|
||
```bash
|
||
npx vitest run src/lib/server/ai/rate-limit.test.ts
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/server/ai/rate-limit.ts src/lib/server/ai/rate-limit.test.ts
|
||
git commit -m "feat(ai): simpler In-Memory-Ratelimiter pro IP"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: POST /api/recipes/extract-from-photo
|
||
|
||
**Files:**
|
||
- Create: `src/routes/api/recipes/extract-from-photo/+server.ts`
|
||
- Create: `src/routes/api/recipes/extract-from-photo/+server.test.ts`
|
||
|
||
Verknüpft alle vorigen Module: Validierung → preprocess → Gemini → Phrase → Response.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`src/routes/api/recipes/extract-from-photo/+server.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import sharp from 'sharp';
|
||
|
||
// Mock den Gemini-Client bevor der Endpoint importiert wird.
|
||
const mockExtract = vi.fn();
|
||
vi.mock('$lib/server/ai/gemini-client', () => ({
|
||
extractRecipeFromImage: mockExtract,
|
||
GeminiError: class GeminiError extends Error {
|
||
constructor(public readonly code: string, message: string) {
|
||
super(message);
|
||
}
|
||
}
|
||
}));
|
||
|
||
import { POST } from './+server';
|
||
import { GeminiError } from '$lib/server/ai/gemini-client';
|
||
|
||
async function makeJpeg(): Promise<Buffer> {
|
||
return sharp({ create: { width: 100, height: 100, channels: 3, background: '#888' } })
|
||
.jpeg()
|
||
.toBuffer();
|
||
}
|
||
|
||
function mkRequest(body: FormData, ip = '1.2.3.4'): Parameters<typeof POST>[0] {
|
||
return {
|
||
request: new Request('http://test/api/recipes/extract-from-photo', {
|
||
method: 'POST',
|
||
body
|
||
}),
|
||
getClientAddress: () => ip
|
||
} as unknown as Parameters<typeof POST>[0];
|
||
}
|
||
|
||
beforeEach(() => {
|
||
mockExtract.mockReset();
|
||
process.env.GEMINI_API_KEY = 'test-key';
|
||
});
|
||
|
||
const validAiResponse = {
|
||
title: 'Testrezept',
|
||
servings_default: 4,
|
||
servings_unit: 'Portionen',
|
||
prep_time_min: 10,
|
||
cook_time_min: 20,
|
||
total_time_min: null,
|
||
ingredients: [{ quantity: 1, unit: null, name: 'Apfel', note: null }],
|
||
steps: [{ text: 'Apfel schälen.' }]
|
||
};
|
||
|
||
describe('POST /api/recipes/extract-from-photo', () => {
|
||
it('happy path: 200 with recipe shape', async () => {
|
||
mockExtract.mockResolvedValueOnce(validAiResponse);
|
||
const fd = new FormData();
|
||
fd.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' }), 'x.jpg');
|
||
const res = await POST(mkRequest(fd));
|
||
expect(res.status).toBe(200);
|
||
const body = await res.json();
|
||
expect(body.recipe.title).toBe('Testrezept');
|
||
expect(body.recipe.description).toMatch(/\w/); // random phrase, non-empty
|
||
expect(body.recipe.image_path).toBeNull();
|
||
expect(body.recipe.ingredients[0].raw_text).toContain('Apfel');
|
||
});
|
||
|
||
it('413 when file exceeds 8 MB', async () => {
|
||
const big = Buffer.alloc(9 * 1024 * 1024);
|
||
const fd = new FormData();
|
||
fd.append('photo', new Blob([big], { type: 'image/jpeg' }));
|
||
const res = await POST(mkRequest(fd));
|
||
expect(res.status).toBe(413);
|
||
expect((await res.json()).code).toBe('PAYLOAD_TOO_LARGE');
|
||
});
|
||
|
||
it('415 when content-type not in whitelist', async () => {
|
||
const fd = new FormData();
|
||
fd.append('photo', new Blob([Buffer.from('hi')], { type: 'text/plain' }));
|
||
const res = await POST(mkRequest(fd));
|
||
expect(res.status).toBe(415);
|
||
expect((await res.json()).code).toBe('UNSUPPORTED_MEDIA_TYPE');
|
||
});
|
||
|
||
it('400 when no photo field', async () => {
|
||
const fd = new FormData();
|
||
const res = await POST(mkRequest(fd));
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('422 NO_RECIPE_IN_IMAGE when 0 ingredients AND 0 steps', async () => {
|
||
mockExtract.mockResolvedValueOnce({
|
||
...validAiResponse,
|
||
ingredients: [],
|
||
steps: []
|
||
});
|
||
const fd = new FormData();
|
||
fd.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' }));
|
||
const res = await POST(mkRequest(fd));
|
||
expect(res.status).toBe(422);
|
||
expect((await res.json()).code).toBe('NO_RECIPE_IN_IMAGE');
|
||
});
|
||
|
||
it('503 AI_NOT_CONFIGURED when GEMINI_API_KEY empty', async () => {
|
||
process.env.GEMINI_API_KEY = '';
|
||
mockExtract.mockRejectedValueOnce(new GeminiError('AI_NOT_CONFIGURED', 'no key'));
|
||
const fd = new FormData();
|
||
fd.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' }));
|
||
const res = await POST(mkRequest(fd));
|
||
expect(res.status).toBe(503);
|
||
expect((await res.json()).code).toBe('AI_NOT_CONFIGURED');
|
||
});
|
||
|
||
it('429 when rate limit exceeded for the same IP', async () => {
|
||
mockExtract.mockResolvedValue(validAiResponse);
|
||
const fd = () => {
|
||
const f = new FormData();
|
||
f.append('photo', new Blob([Buffer.from('tiny')], { type: 'image/jpeg' }));
|
||
return f;
|
||
};
|
||
// sharp wird für tiny-buffers werfen — aber rate-limit greift VOR preprocess,
|
||
// also füllen wir erst 10 OK-Calls auf, dann der 11. sollte 429 sein.
|
||
// Hack: wir erhöhen die rate-limit-Hürde nicht — wir prüfen nur, dass 429 zurückkommt.
|
||
for (let i = 0; i < 10; i++) {
|
||
const jpg = new FormData();
|
||
jpg.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' }));
|
||
await POST(mkRequest(jpg, '9.9.9.9'));
|
||
}
|
||
const limitHit = await POST(mkRequest(fd(), '9.9.9.9'));
|
||
expect(limitHit.status).toBe(429);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen (erwartet FAIL)**
|
||
|
||
```bash
|
||
npx vitest run src/routes/api/recipes/extract-from-photo/+server.test.ts
|
||
```
|
||
|
||
Expected: FAIL — Endpoint existiert noch nicht.
|
||
|
||
- [ ] **Step 3: Endpoint implementieren**
|
||
|
||
`src/routes/api/recipes/extract-from-photo/+server.ts`:
|
||
|
||
```ts
|
||
import type { RequestHandler } from './$types';
|
||
import { json } from '@sveltejs/kit';
|
||
import { extractRecipeFromImage, GeminiError } from '$lib/server/ai/gemini-client';
|
||
import { preprocessImage } from '$lib/server/ai/image-preprocess';
|
||
import { pickRandomPhrase } from '$lib/server/ai/description-phrases';
|
||
import { createRateLimiter } from '$lib/server/ai/rate-limit';
|
||
import type { Ingredient, Step } from '$lib/types';
|
||
|
||
const MAX_BYTES = 8 * 1024 * 1024;
|
||
const ALLOWED_MIME = new Set([
|
||
'image/jpeg',
|
||
'image/png',
|
||
'image/webp',
|
||
'image/heic',
|
||
'image/heif'
|
||
]);
|
||
|
||
// Globaler Singleton-Limiter. 10 Requests/Minute pro IP — verhindert versehentliche
|
||
// Kosten-Runaways (z.B. wenn ein Tester das Icon sturmklickt).
|
||
const limiter = createRateLimiter({ windowMs: 60_000, max: 10 });
|
||
|
||
function errJson(status: number, code: string, message: string) {
|
||
return json({ code, message }, { status });
|
||
}
|
||
|
||
function buildRawText(q: number | null, u: string | null, name: string): string {
|
||
const parts: string[] = [];
|
||
if (q !== null) parts.push(String(q).replace('.', ','));
|
||
if (u) parts.push(u);
|
||
parts.push(name);
|
||
return parts.join(' ');
|
||
}
|
||
|
||
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
||
const ip = getClientAddress();
|
||
if (!limiter.check(ip)) {
|
||
return errJson(429, 'RATE_LIMITED', 'Zu viele Anfragen — bitte einen Moment warten.');
|
||
}
|
||
|
||
let form: FormData;
|
||
try {
|
||
form = await request.formData();
|
||
} catch {
|
||
return errJson(400, 'BAD_REQUEST', 'Multipart body erwartet.');
|
||
}
|
||
const photo = form.get('photo');
|
||
if (!(photo instanceof Blob)) {
|
||
return errJson(400, 'BAD_REQUEST', 'Feld "photo" fehlt.');
|
||
}
|
||
if (photo.size > MAX_BYTES) {
|
||
return errJson(413, 'PAYLOAD_TOO_LARGE', `Foto zu groß (max ${MAX_BYTES / 1024 / 1024} MB).`);
|
||
}
|
||
if (!ALLOWED_MIME.has(photo.type)) {
|
||
return errJson(415, 'UNSUPPORTED_MEDIA_TYPE', `MIME "${photo.type}" nicht unterstützt.`);
|
||
}
|
||
|
||
const rawBuffer = Buffer.from(await photo.arrayBuffer());
|
||
let preprocessed: { buffer: Buffer; mimeType: 'image/jpeg' };
|
||
try {
|
||
preprocessed = await preprocessImage(rawBuffer);
|
||
} catch (e) {
|
||
return errJson(415, 'UNSUPPORTED_MEDIA_TYPE', `Bild konnte nicht gelesen werden: ${(e as Error).message}`);
|
||
}
|
||
|
||
const startedAt = Date.now();
|
||
let extracted;
|
||
try {
|
||
extracted = await extractRecipeFromImage(preprocessed.buffer, preprocessed.mimeType);
|
||
} catch (e) {
|
||
if (e instanceof GeminiError) {
|
||
const status =
|
||
e.code === 'AI_RATE_LIMITED' ? 429 :
|
||
e.code === 'AI_TIMEOUT' ? 503 :
|
||
e.code === 'AI_NOT_CONFIGURED' ? 503 :
|
||
503;
|
||
// Nur Code + Meta loggen, niemals den Prompt/Response-Inhalt.
|
||
console.warn(`[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes`);
|
||
return errJson(status, e.code, 'Die Bild-Analyse ist fehlgeschlagen.');
|
||
}
|
||
console.warn(`[extract-from-photo] UNEXPECTED ${(e as Error).message}`);
|
||
return errJson(503, 'AI_FAILED', 'Die Bild-Analyse ist fehlgeschlagen.');
|
||
}
|
||
|
||
// Minimum-Gültigkeit: Titel + (mind. 1 Zutat ODER mind. 1 Schritt)
|
||
if (!extracted.title.trim() ||
|
||
(extracted.ingredients.length === 0 && extracted.steps.length === 0)) {
|
||
return errJson(422, 'NO_RECIPE_IN_IMAGE', 'Ich konnte kein Rezept im Bild erkennen.');
|
||
}
|
||
|
||
const ingredients: Ingredient[] = extracted.ingredients.map((i, idx) => ({
|
||
position: idx + 1,
|
||
quantity: i.quantity,
|
||
unit: i.unit,
|
||
name: i.name,
|
||
note: i.note,
|
||
raw_text: buildRawText(i.quantity, i.unit, i.name),
|
||
section_heading: null
|
||
}));
|
||
|
||
const steps: Step[] = extracted.steps.map((s, idx) => ({
|
||
position: idx + 1,
|
||
text: s.text
|
||
}));
|
||
|
||
return json({
|
||
recipe: {
|
||
id: null,
|
||
title: extracted.title,
|
||
description: pickRandomPhrase(),
|
||
source_url: null,
|
||
source_domain: null,
|
||
image_path: null,
|
||
servings_default: extracted.servings_default,
|
||
servings_unit: extracted.servings_unit,
|
||
prep_time_min: extracted.prep_time_min,
|
||
cook_time_min: extracted.cook_time_min,
|
||
total_time_min: extracted.total_time_min,
|
||
cuisine: null,
|
||
category: null,
|
||
ingredients,
|
||
steps,
|
||
tags: []
|
||
}
|
||
});
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen (erwartet PASS)**
|
||
|
||
```bash
|
||
npx vitest run src/routes/api/recipes/extract-from-photo/+server.test.ts
|
||
```
|
||
|
||
Expected: PASS — alle 7 Tests grün.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/routes/api/recipes/extract-from-photo/
|
||
git commit -m "feat(api): POST /api/recipes/extract-from-photo"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: POST /api/recipes (neues Scratch-Insert)
|
||
|
||
**Files:**
|
||
- Create: `src/routes/api/recipes/+server.ts`
|
||
- Create: `src/routes/api/recipes/+server.test.ts`
|
||
|
||
Neuer Endpoint, der ein komplettes `Recipe`-Objekt als Body nimmt und via `insertRecipe` schreibt. Wird vom Save-Handler auf `/new/from-photo` aufgerufen. Der bestehende `/api/recipes/blank`-Endpoint bleibt für den klassischen „Leer anlegen"-Button unverändert.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`src/routes/api/recipes/+server.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import Database from 'better-sqlite3';
|
||
import { runMigrations } from '$lib/server/db/migrate';
|
||
import { POST } from './+server';
|
||
|
||
// Hinweis: Wir brauchen eine reale DB. SvelteKit's getDb muss auf eine In-Memory
|
||
// DB umgeleitet werden — das Muster dafür ist in bestehenden API-Tests
|
||
// (z.B. src/routes/api/recipes/import/+server.test.ts) zu finden und wird
|
||
// bei Bedarf adaptiert.
|
||
|
||
function mkReq(body: unknown): Parameters<typeof POST>[0] {
|
||
return {
|
||
request: new Request('http://test/api/recipes', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
})
|
||
} as unknown as Parameters<typeof POST>[0];
|
||
}
|
||
|
||
const validBody = {
|
||
title: 'Aus Foto erstellt',
|
||
description: 'Abrakadabra — Rezept da.',
|
||
servings_default: 4,
|
||
servings_unit: 'Portionen',
|
||
prep_time_min: 10,
|
||
cook_time_min: 20,
|
||
total_time_min: null,
|
||
ingredients: [
|
||
{ position: 1, quantity: 1, unit: null, name: 'Apfel', note: null, raw_text: '1 Apfel', section_heading: null }
|
||
],
|
||
steps: [{ position: 1, text: 'Apfel schneiden.' }]
|
||
};
|
||
|
||
describe('POST /api/recipes', () => {
|
||
it('happy path returns 201 + id', async () => {
|
||
const res = await POST(mkReq(validBody));
|
||
expect(res.status).toBe(201);
|
||
const body = await res.json();
|
||
expect(typeof body.id).toBe('number');
|
||
expect(body.id).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('400 on empty title', async () => {
|
||
const res = await POST(mkReq({ ...validBody, title: '' }));
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('400 on missing ingredients array', async () => {
|
||
const { ingredients: _, ...bad } = validBody;
|
||
void _;
|
||
const res = await POST(mkReq(bad));
|
||
expect(res.status).toBe(400);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen (erwartet FAIL)**
|
||
|
||
```bash
|
||
npx vitest run src/routes/api/recipes/+server.test.ts
|
||
```
|
||
|
||
Expected: FAIL — Endpoint existiert noch nicht.
|
||
|
||
- [ ] **Step 3: Endpoint implementieren**
|
||
|
||
`src/routes/api/recipes/+server.ts`:
|
||
|
||
```ts
|
||
import type { RequestHandler } from './$types';
|
||
import { json } from '@sveltejs/kit';
|
||
import { z } from 'zod';
|
||
import { getDb } from '$lib/server/db';
|
||
import { validateBody } from '$lib/server/api-helpers';
|
||
import { insertRecipe } from '$lib/server/recipes/repository';
|
||
|
||
const IngredientSchema = z.object({
|
||
position: z.number().int().nonnegative(),
|
||
quantity: z.number().nullable(),
|
||
unit: z.string().max(30).nullable(),
|
||
name: z.string().min(1).max(200),
|
||
note: z.string().max(300).nullable(),
|
||
raw_text: z.string().max(500),
|
||
section_heading: z.string().max(200).nullable()
|
||
});
|
||
|
||
const StepSchema = z.object({
|
||
position: z.number().int().positive(),
|
||
text: z.string().min(1).max(4000)
|
||
});
|
||
|
||
const CreateRecipeSchema = z.object({
|
||
title: z.string().min(1).max(200),
|
||
description: z.string().max(2000).nullable(),
|
||
servings_default: z.number().int().nonnegative().nullable(),
|
||
servings_unit: z.string().max(30).nullable(),
|
||
prep_time_min: z.number().int().nonnegative().nullable(),
|
||
cook_time_min: z.number().int().nonnegative().nullable(),
|
||
total_time_min: z.number().int().nonnegative().nullable(),
|
||
ingredients: z.array(IngredientSchema),
|
||
steps: z.array(StepSchema)
|
||
});
|
||
|
||
export const POST: RequestHandler = async ({ request }) => {
|
||
const body = await request.json().catch(() => null);
|
||
const p = validateBody(body, CreateRecipeSchema);
|
||
const id = insertRecipe(getDb(), {
|
||
id: null,
|
||
title: p.title,
|
||
description: p.description,
|
||
source_url: null,
|
||
source_domain: null,
|
||
image_path: null,
|
||
servings_default: p.servings_default,
|
||
servings_unit: p.servings_unit,
|
||
prep_time_min: p.prep_time_min,
|
||
cook_time_min: p.cook_time_min,
|
||
total_time_min: p.total_time_min,
|
||
cuisine: null,
|
||
category: null,
|
||
ingredients: p.ingredients,
|
||
steps: p.steps,
|
||
tags: []
|
||
});
|
||
return json({ id }, { status: 201 });
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen (erwartet PASS)**
|
||
|
||
```bash
|
||
npx vitest run src/routes/api/recipes/+server.test.ts
|
||
```
|
||
|
||
Expected: PASS — alle 3 Tests grün. Falls das DB-Setup im Test nicht klappt: Muster aus bestehenden Tests wie `src/routes/api/recipes/import/+server.test.ts` übernehmen.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/routes/api/recipes/+server.ts src/routes/api/recipes/+server.test.ts
|
||
git commit -m "feat(api): POST /api/recipes fuer Scratch-Insert aus Foto-Import"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: RecipeEditor — Nullable-ID-Support
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/components/RecipeEditor.svelte:136-144`
|
||
|
||
Minimal-Änderung: Bild-Block nur rendern wenn `recipe.id !== null`. Kein State-Management-Umbau nötig.
|
||
|
||
- [ ] **Step 1: Bild-Block conditional wrappen**
|
||
|
||
In `src/lib/components/RecipeEditor.svelte` den Bild-Block von:
|
||
|
||
```svelte
|
||
<section class="block">
|
||
<h2>Bild</h2>
|
||
<ImageUploadBox
|
||
recipeId={recipe.id!}
|
||
imagePath={recipe.image_path}
|
||
onchange={(p) => onimagechange?.(p)}
|
||
/>
|
||
</section>
|
||
```
|
||
|
||
ändern zu:
|
||
|
||
```svelte
|
||
{#if recipe.id !== null}
|
||
<section class="block">
|
||
<h2>Bild</h2>
|
||
<ImageUploadBox
|
||
recipeId={recipe.id}
|
||
imagePath={recipe.image_path}
|
||
onchange={(p) => onimagechange?.(p)}
|
||
/>
|
||
</section>
|
||
{:else}
|
||
<section class="block info">
|
||
<p class="hint">Bild kannst du nach dem Speichern hinzufügen.</p>
|
||
</section>
|
||
{/if}
|
||
```
|
||
|
||
Im `<style>`-Block am Ende der Datei ergänzen:
|
||
|
||
```css
|
||
.block.info {
|
||
background: #f6faf7;
|
||
border: 1px dashed #cfd9d1;
|
||
}
|
||
.hint {
|
||
color: #666;
|
||
margin: 0;
|
||
font-size: 0.9rem;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verifizieren: bestehende Tests + check**
|
||
|
||
```bash
|
||
npm run check
|
||
npx vitest run
|
||
```
|
||
|
||
Expected: keine Regression.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/lib/components/RecipeEditor.svelte
|
||
git commit -m "feat(editor): Bild-Block skip wenn recipe.id === null"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Photo-Upload Store
|
||
|
||
**Files:**
|
||
- Create: `src/lib/client/photo-upload.svelte.ts`
|
||
- Create: `src/lib/client/photo-upload.svelte.test.ts`
|
||
|
||
Lokaler Store-State für `/new/from-photo` — keine globale Instanz, pro Page-Mount frisch instanziiert.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`src/lib/client/photo-upload.svelte.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import { createPhotoUploadStore } from './photo-upload.svelte';
|
||
|
||
beforeEach(() => vi.restoreAllMocks());
|
||
|
||
const validRecipe = {
|
||
id: null,
|
||
title: 'T',
|
||
description: 'D',
|
||
source_url: null,
|
||
source_domain: null,
|
||
image_path: null,
|
||
servings_default: null,
|
||
servings_unit: null,
|
||
prep_time_min: null,
|
||
cook_time_min: null,
|
||
total_time_min: null,
|
||
cuisine: null,
|
||
category: null,
|
||
ingredients: [],
|
||
steps: [{ position: 1, text: 'S' }],
|
||
tags: []
|
||
};
|
||
|
||
describe('photo-upload store', () => {
|
||
it('starts in idle', () => {
|
||
const s = createPhotoUploadStore();
|
||
expect(s.status).toBe('idle');
|
||
});
|
||
|
||
it('transitions to loading then success on happy path', async () => {
|
||
global.fetch = vi.fn().mockResolvedValue(
|
||
new Response(JSON.stringify({ recipe: validRecipe }), { status: 200 })
|
||
);
|
||
const s = createPhotoUploadStore();
|
||
const blob = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/jpeg' });
|
||
const p = s.upload(new File([blob], 'x.jpg', { type: 'image/jpeg' }));
|
||
expect(s.status).toBe('loading');
|
||
await p;
|
||
expect(s.status).toBe('success');
|
||
expect(s.recipe?.title).toBe('T');
|
||
});
|
||
|
||
it('transitions to error with code on 422', async () => {
|
||
global.fetch = vi.fn().mockResolvedValue(
|
||
new Response(JSON.stringify({ code: 'NO_RECIPE_IN_IMAGE', message: 'nope' }), { status: 422 })
|
||
);
|
||
const s = createPhotoUploadStore();
|
||
await s.upload(new File([new Uint8Array([1])], 'x.jpg', { type: 'image/jpeg' }));
|
||
expect(s.status).toBe('error');
|
||
expect(s.errorCode).toBe('NO_RECIPE_IN_IMAGE');
|
||
});
|
||
|
||
it('reset() brings store back to idle', async () => {
|
||
global.fetch = vi.fn().mockResolvedValue(
|
||
new Response('{"code":"X"}', { status: 503 })
|
||
);
|
||
const s = createPhotoUploadStore();
|
||
await s.upload(new File([new Uint8Array([1])], 'x.jpg', { type: 'image/jpeg' }));
|
||
expect(s.status).toBe('error');
|
||
s.reset();
|
||
expect(s.status).toBe('idle');
|
||
expect(s.errorCode).toBeNull();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen (erwartet FAIL)**
|
||
|
||
```bash
|
||
npx vitest run src/lib/client/photo-upload.svelte.test.ts
|
||
```
|
||
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Store implementieren**
|
||
|
||
`src/lib/client/photo-upload.svelte.ts`:
|
||
|
||
```ts
|
||
import type { Recipe } from '$lib/types';
|
||
|
||
export type UploadStatus = 'idle' | 'loading' | 'success' | 'error';
|
||
|
||
export type PhotoUploadStore = {
|
||
readonly status: UploadStatus;
|
||
readonly recipe: Recipe | null;
|
||
readonly errorCode: string | null;
|
||
readonly errorMessage: string | null;
|
||
readonly lastFile: File | null;
|
||
upload: (file: File) => Promise<void>;
|
||
retry: () => Promise<void>;
|
||
reset: () => void;
|
||
abort: () => void;
|
||
};
|
||
|
||
export function createPhotoUploadStore(): PhotoUploadStore {
|
||
let status = $state<UploadStatus>('idle');
|
||
let recipe = $state<Recipe | null>(null);
|
||
let errorCode = $state<string | null>(null);
|
||
let errorMessage = $state<string | null>(null);
|
||
let lastFile = $state<File | null>(null);
|
||
let controller: AbortController | null = null;
|
||
|
||
async function doUpload(file: File) {
|
||
status = 'loading';
|
||
recipe = null;
|
||
errorCode = null;
|
||
errorMessage = null;
|
||
controller = new AbortController();
|
||
const fd = new FormData();
|
||
fd.append('photo', file);
|
||
try {
|
||
const res = await fetch('/api/recipes/extract-from-photo', {
|
||
method: 'POST',
|
||
body: fd,
|
||
signal: controller.signal
|
||
});
|
||
const body = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
status = 'error';
|
||
errorCode = typeof body.code === 'string' ? body.code : 'UNKNOWN';
|
||
errorMessage = typeof body.message === 'string' ? body.message : `HTTP ${res.status}`;
|
||
return;
|
||
}
|
||
recipe = body.recipe as Recipe;
|
||
status = 'success';
|
||
} catch (e) {
|
||
if ((e as Error).name === 'AbortError') {
|
||
status = 'idle';
|
||
return;
|
||
}
|
||
status = 'error';
|
||
errorCode = 'NETWORK';
|
||
errorMessage = (e as Error).message;
|
||
}
|
||
}
|
||
|
||
return {
|
||
get status() { return status; },
|
||
get recipe() { return recipe; },
|
||
get errorCode() { return errorCode; },
|
||
get errorMessage() { return errorMessage; },
|
||
get lastFile() { return lastFile; },
|
||
async upload(file: File) { lastFile = file; await doUpload(file); },
|
||
async retry() { if (lastFile) await doUpload(lastFile); },
|
||
reset() {
|
||
status = 'idle';
|
||
recipe = null;
|
||
errorCode = null;
|
||
errorMessage = null;
|
||
lastFile = null;
|
||
controller?.abort();
|
||
controller = null;
|
||
},
|
||
abort() { controller?.abort(); }
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen (erwartet PASS)**
|
||
|
||
```bash
|
||
npx vitest run src/lib/client/photo-upload.svelte.test.ts
|
||
```
|
||
|
||
Expected: PASS — alle 4 Tests grün.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/client/photo-upload.svelte.ts src/lib/client/photo-upload.svelte.test.ts
|
||
git commit -m "feat(client): photo-upload store mit idle/loading/success/error"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Page /new/from-photo
|
||
|
||
**Files:**
|
||
- Create: `src/routes/new/from-photo/+page.svelte`
|
||
- Create: `src/routes/new/from-photo/+page.server.ts`
|
||
- Modify: `src/service-worker.ts` (precache-Liste erweitern, siehe Step 6)
|
||
|
||
Hier wird alles zusammengeschaltet: File-Picker → Store → Editor → Save.
|
||
|
||
- [ ] **Step 1: Server-Load für Config-Gate**
|
||
|
||
`src/routes/new/from-photo/+page.server.ts`:
|
||
|
||
```ts
|
||
import type { PageServerLoad } from './$types';
|
||
import { error } from '@sveltejs/kit';
|
||
import { env } from '$env/dynamic/private';
|
||
|
||
export const load: PageServerLoad = async () => {
|
||
const apiKey = env.GEMINI_API_KEY;
|
||
if (!apiKey) {
|
||
error(503, { message: 'Foto-Import ist nicht konfiguriert (GEMINI_API_KEY fehlt).' });
|
||
}
|
||
return {};
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Page-Komponente**
|
||
|
||
`src/routes/new/from-photo/+page.svelte`:
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
import { Camera, Loader2, Wand2, AlertTriangle, RotateCw, FilePlus, X } from 'lucide-svelte';
|
||
import RecipeEditor from '$lib/components/RecipeEditor.svelte';
|
||
import { createPhotoUploadStore } from '$lib/client/photo-upload.svelte';
|
||
import { alertAction } from '$lib/client/confirm.svelte';
|
||
import { networkStore } from '$lib/client/network.svelte';
|
||
import type { Recipe } from '$lib/types';
|
||
|
||
const store = createPhotoUploadStore();
|
||
let saving = $state(false);
|
||
let fileInput: HTMLInputElement;
|
||
|
||
function onPick(e: Event) {
|
||
const file = (e.target as HTMLInputElement).files?.[0];
|
||
if (file) void store.upload(file);
|
||
}
|
||
|
||
async function onSave(patch: Parameters<RecipeEditorSavePatch>[0]) {
|
||
if (!store.recipe) return;
|
||
saving = true;
|
||
try {
|
||
const body = {
|
||
title: patch.title,
|
||
description: patch.description,
|
||
servings_default: patch.servings_default,
|
||
servings_unit: store.recipe.servings_unit,
|
||
prep_time_min: patch.prep_time_min,
|
||
cook_time_min: patch.cook_time_min,
|
||
total_time_min: patch.total_time_min,
|
||
ingredients: patch.ingredients,
|
||
steps: patch.steps
|
||
};
|
||
const res = await fetch('/api/recipes', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}));
|
||
await alertAction({ title: 'Speichern fehlgeschlagen', message: body.message ?? `HTTP ${res.status}` });
|
||
return;
|
||
}
|
||
const { id } = await res.json();
|
||
await goto(`/recipes/${id}`);
|
||
} finally {
|
||
saving = false;
|
||
}
|
||
}
|
||
|
||
function onCancel() {
|
||
history.back();
|
||
}
|
||
|
||
// Der Editor-Callback-Typ — wir deklarieren ihn lokal weil er nicht exportiert ist.
|
||
type RecipeEditorSavePatch = NonNullable<Parameters<typeof RecipeEditor>[0]['onsave']>;
|
||
</script>
|
||
|
||
<svelte:head><title>Rezept aus Foto — Kochwas</title></svelte:head>
|
||
|
||
{#if store.status === 'idle'}
|
||
<section class="picker">
|
||
<Camera size={48} strokeWidth={1.5} />
|
||
<h1>Rezept aus Foto</h1>
|
||
<p class="hint">
|
||
Fotografiere ein gedrucktes oder handgeschriebenes Rezept.
|
||
Eine Seite, scharf, gut ausgeleuchtet.
|
||
</p>
|
||
<button class="btn primary" onclick={() => fileInput.click()}>
|
||
<Camera size={18} strokeWidth={2} />
|
||
<span>Foto wählen oder aufnehmen</span>
|
||
</button>
|
||
<input
|
||
bind:this={fileInput}
|
||
type="file"
|
||
accept="image/*"
|
||
capture="environment"
|
||
hidden
|
||
onchange={onPick}
|
||
/>
|
||
{#if !networkStore.online}
|
||
<p class="offline">Offline — diese Funktion braucht Internet.</p>
|
||
{/if}
|
||
</section>
|
||
{:else if store.status === 'loading'}
|
||
<section class="state" aria-live="polite">
|
||
<Loader2 size={48} class="spin" />
|
||
<p>Lese das Rezept…</p>
|
||
<button class="btn ghost" onclick={() => store.abort()}>
|
||
<X size={18} /><span>Abbrechen</span>
|
||
</button>
|
||
</section>
|
||
{:else if store.status === 'error'}
|
||
{#if store.errorCode === 'NO_RECIPE_IN_IMAGE'}
|
||
<section class="state yellow" role="alert">
|
||
<AlertTriangle size={40} />
|
||
<h2>Kein Rezept im Bild</h2>
|
||
<p>Ich konnte auf dem Foto kein Rezept erkennen.</p>
|
||
<div class="row">
|
||
<button class="btn primary" onclick={() => { store.reset(); fileInput.click(); }}>
|
||
<Camera size={18} /><span>Anderes Foto</span>
|
||
</button>
|
||
<a class="btn ghost" href="/">
|
||
<FilePlus size={18} /><span>Leer anlegen</span>
|
||
</a>
|
||
</div>
|
||
</section>
|
||
{:else}
|
||
<section class="state red" role="alert">
|
||
<AlertTriangle size={40} />
|
||
<h2>Fehler</h2>
|
||
<p>{store.errorMessage ?? 'Unbekannter Fehler.'}</p>
|
||
<div class="row">
|
||
<button class="btn primary" onclick={() => store.retry()}>
|
||
<RotateCw size={18} /><span>Nochmal versuchen</span>
|
||
</button>
|
||
<button class="btn ghost" onclick={() => store.reset()}>
|
||
<Camera size={18} /><span>Anderes Foto</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
{/if}
|
||
{:else if store.status === 'success' && store.recipe}
|
||
<div class="banner">
|
||
<Wand2 size={18} />
|
||
<span>Aus Foto erstellt — bitte prüfen und ggf. korrigieren.</span>
|
||
</div>
|
||
<RecipeEditor recipe={store.recipe as Recipe} {saving} onsave={onSave} oncancel={onCancel} />
|
||
{/if}
|
||
|
||
<style>
|
||
.picker, .state {
|
||
text-align: center;
|
||
padding: 3rem 1rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
.hint {
|
||
color: #666;
|
||
max-width: 400px;
|
||
line-height: 1.45;
|
||
}
|
||
.btn {
|
||
padding: 0.8rem 1.1rem;
|
||
min-height: 48px;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
border: 0;
|
||
text-decoration: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
.btn.primary { background: #2b6a3d; color: white; }
|
||
.btn.ghost { background: white; color: #444; border: 1px solid #cfd9d1; }
|
||
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; }
|
||
.state.yellow { background: #fff6d7; border: 1px solid #e6d48a; border-radius: 12px; }
|
||
.state.red { background: #fde4e4; border: 1px solid #e6a0a0; border-radius: 12px; }
|
||
.banner {
|
||
display: flex; align-items: center; gap: 0.5rem;
|
||
padding: 0.7rem 1rem; background: #eef8ef; border: 1px solid #b7d9c0;
|
||
border-radius: 10px; margin: 0.75rem 0 1rem; color: #2b6a3d;
|
||
}
|
||
:global(.spin) { animation: spin 1s linear infinite; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
.offline { color: #a05b00; font-size: 0.9rem; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3: Typecheck**
|
||
|
||
```bash
|
||
npm run check
|
||
```
|
||
|
||
Expected: PASS. Falls der `RecipeEditorSavePatch`-Typ nicht auflöst: direkt die Feld-Signatur aus `RecipeEditor.svelte:13-22` kopieren.
|
||
|
||
- [ ] **Step 4: Quick Smoke-Test manuell**
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
Navigiere zu `http://localhost:5173/new/from-photo`. Erwartet: 503-Fehlerseite, weil `GEMINI_API_KEY` leer ist (server-load-Gate greift).
|
||
|
||
Mit Key: `GEMINI_API_KEY=x npm run dev`, dann sollte die Picker-Seite erscheinen.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/routes/new/from-photo/
|
||
git commit -m "feat(ui): /new/from-photo mit File-Picker, Lade- und Fehler-States"
|
||
```
|
||
|
||
- [ ] **Step 6: Service-Worker-Precache erweitern**
|
||
|
||
Öffne `src/service-worker.ts` und finde das Pre-Cache-Manifest (suche nach `precache` oder `buildSWManifest`). In der Shell-Liste `'/new/from-photo'` ergänzen, analog zu `/` und `/preview`.
|
||
|
||
```bash
|
||
npm run check && npx vitest run
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/service-worker.ts
|
||
git commit -m "feat(sw): /new/from-photo in Shell-Precache"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Camera-Icon im Header
|
||
|
||
**Files:**
|
||
- Modify: `src/routes/+layout.svelte`
|
||
- Modify: `src/routes/+layout.server.ts` (prüfen, ggf. erweitern für `geminiConfigured`-Flag)
|
||
|
||
Der Button wird nur gerendert, wenn der Server signalisiert, dass Gemini konfiguriert ist. Offline → disabled.
|
||
|
||
- [ ] **Step 1: Flag aus Layout-Load exponieren**
|
||
|
||
Öffne `src/routes/+layout.server.ts` (falls nicht vorhanden: neu anlegen):
|
||
|
||
```ts
|
||
import type { LayoutServerLoad } from './$types';
|
||
import { env } from '$env/dynamic/private';
|
||
|
||
export const load: LayoutServerLoad = async ({ parent }) => {
|
||
const parentData = await parent().catch(() => ({}));
|
||
return {
|
||
...parentData,
|
||
geminiConfigured: Boolean(env.GEMINI_API_KEY)
|
||
};
|
||
};
|
||
```
|
||
|
||
Falls die Datei bereits existiert und andere Keys liefert: nur `geminiConfigured: Boolean(env.GEMINI_API_KEY)` in das bestehende Return-Objekt ergänzen.
|
||
|
||
- [ ] **Step 2: Icon in den Header**
|
||
|
||
In `src/routes/+layout.svelte` die Header-Zeile erweitern: der Import ergänzt `Camera` zu bestehenden lucide-svelte-Imports, und die Button-Reihe im Header bekommt einen neuen Eintrag. Konkrete Integration:
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
// … bestehende Imports …
|
||
import { Camera } from 'lucide-svelte';
|
||
import { networkStore } from '$lib/client/network.svelte';
|
||
// … bestehender Props/Setup …
|
||
|
||
let { data, children } = $props();
|
||
</script>
|
||
|
||
<!-- … Header-Komponenten-Markup, suche nach dem bestehenden Profile/Search-Bereich … -->
|
||
|
||
{#if data.geminiConfigured}
|
||
<a
|
||
class="icon-btn"
|
||
class:disabled={!networkStore.online}
|
||
href={networkStore.online ? '/new/from-photo' : undefined}
|
||
aria-label="Rezept aus Foto erstellen"
|
||
title={networkStore.online ? 'Rezept aus Foto erstellen' : 'Offline — braucht Internet'}
|
||
onclick={(e) => { if (!networkStore.online) e.preventDefault(); }}
|
||
>
|
||
<Camera size={20} strokeWidth={2} />
|
||
</a>
|
||
{/if}
|
||
```
|
||
|
||
Im `<style>`-Block:
|
||
|
||
```css
|
||
.icon-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 40px; height: 40px;
|
||
border-radius: 50%;
|
||
color: #2b6a3d;
|
||
text-decoration: none;
|
||
}
|
||
.icon-btn:hover { background: #f0f5f1; }
|
||
.icon-btn.disabled { color: #999; pointer-events: none; }
|
||
```
|
||
|
||
- [ ] **Step 3: Smoke-Test**
|
||
|
||
```bash
|
||
npm run check
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
Manuell: `GEMINI_API_KEY=test npm run dev`, auf `/` Icon prüfen. Ohne Key: Icon verschwindet.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/routes/+layout.svelte src/routes/+layout.server.ts
|
||
git commit -m "feat(ui): Camera-Icon im Header mit Gemini-Config- und Offline-Gate"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: E2E-Test (Remote)
|
||
|
||
**Files:**
|
||
- Create: `tests/e2e/remote/photo-import.spec.ts`
|
||
- Create: `tests/fixtures/photo-recipe/sample-printed.jpg` (placeholder — kann ein beliebiges Test-JPEG sein, Inhalt egal weil wir den Endpoint stubben)
|
||
|
||
Der Test stubbt den Extract-Endpoint, damit kein echter Gemini-Call läuft (kein Kostenrisiko in CI).
|
||
|
||
- [ ] **Step 1: Fixture-Bild bereitstellen**
|
||
|
||
```bash
|
||
mkdir -p tests/fixtures/photo-recipe
|
||
# Ein beliebiges kleines JPEG; z.B. mit sharp ein graues Quadrat:
|
||
node -e "require('sharp')({create:{width:200,height:200,channels:3,background:'#999'}}).jpeg().toFile('tests/fixtures/photo-recipe/sample-printed.jpg')"
|
||
```
|
||
|
||
- [ ] **Step 2: E2E-Spec schreiben**
|
||
|
||
`tests/e2e/remote/photo-import.spec.ts`:
|
||
|
||
```ts
|
||
import { test, expect } from '@playwright/test';
|
||
import { resolve } from 'node:path';
|
||
|
||
// Achtung: serviceWorkers:block ist in playwright.config gesetzt; der SW wird
|
||
// hier nicht geladen. Der Test läuft gegen kochwas-dev.siegeln.net (siehe
|
||
// bestehende Remote-Tests).
|
||
|
||
test('Foto-Import: Happy-Path mit gestubtem Endpoint', async ({ page, context }) => {
|
||
// Alle Requests auf /api/recipes/extract-from-photo abfangen
|
||
await context.route('**/api/recipes/extract-from-photo', async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({
|
||
recipe: {
|
||
id: null,
|
||
title: 'E2E Testrezept',
|
||
description: 'Aus dem Bild herbeigezaubert.',
|
||
source_url: null,
|
||
source_domain: null,
|
||
image_path: null,
|
||
servings_default: 2,
|
||
servings_unit: 'Portionen',
|
||
prep_time_min: 5,
|
||
cook_time_min: 10,
|
||
total_time_min: null,
|
||
cuisine: null,
|
||
category: null,
|
||
ingredients: [
|
||
{ position: 1, quantity: 1, unit: 'Stk', name: 'Apfel', note: null, raw_text: '1 Stk Apfel', section_heading: null }
|
||
],
|
||
steps: [{ position: 1, text: 'Apfel waschen.' }],
|
||
tags: []
|
||
}
|
||
})
|
||
});
|
||
});
|
||
|
||
await page.goto('/new/from-photo');
|
||
await expect(page.getByRole('heading', { name: 'Rezept aus Foto' })).toBeVisible();
|
||
|
||
const fixture = resolve(__dirname, '../../fixtures/photo-recipe/sample-printed.jpg');
|
||
await page.locator('input[type="file"]').setInputFiles(fixture);
|
||
|
||
await expect(page.getByText('Aus Foto erstellt')).toBeVisible({ timeout: 5000 });
|
||
await expect(page.locator('input[type="text"]').first()).toHaveValue('E2E Testrezept');
|
||
|
||
// Save und danach redirect auf /recipes/<id>
|
||
await page.getByRole('button', { name: /speichern/i }).first().click();
|
||
await page.waitForURL(/\/recipes\/\d+/, { timeout: 5000 });
|
||
});
|
||
|
||
test('Camera-Icon verschwindet wenn offline', async ({ page, context }) => {
|
||
await page.goto('/');
|
||
// Vorher das Icon sichtbar machen — hängt davon ab, dass auf dev Gemini konfiguriert ist.
|
||
const icon = page.locator('[aria-label="Rezept aus Foto erstellen"]');
|
||
await expect(icon).toBeVisible();
|
||
await context.setOffline(true);
|
||
await page.waitForFunction(() => !navigator.onLine);
|
||
// Icon bleibt, aber disabled
|
||
await expect(icon).toHaveClass(/disabled/);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: E2E laufen lassen**
|
||
|
||
```bash
|
||
npx playwright test tests/e2e/remote/photo-import.spec.ts
|
||
```
|
||
|
||
Expected: PASS gegen `kochwas-dev.siegeln.net` (Deploy muss nach Tasks 1–12 durchgelaufen sein; s. Task 15).
|
||
|
||
Falls der zweite Test fehlschlägt, weil das Icon auf dev noch nicht existiert: Reihenfolge beachten — die Tasks 1–12 müssen gemerged und deployed sein.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add tests/e2e/remote/photo-import.spec.ts tests/fixtures/photo-recipe/
|
||
git commit -m "test(e2e): Foto-Import Happy-Path und Offline-Icon"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Dokumentation
|
||
|
||
**Files:**
|
||
- Modify: `docs/OPERATIONS.md`
|
||
- Modify: `docs/ARCHITECTURE.md`
|
||
- Modify: `CLAUDE.md`
|
||
|
||
- [ ] **Step 1: OPERATIONS.md erweitern**
|
||
|
||
In `docs/OPERATIONS.md` einen neuen Abschnitt `## Gemini / Foto-Rezept-Magie` am Ende einfügen:
|
||
|
||
```markdown
|
||
## Gemini / Foto-Rezept-Magie
|
||
|
||
Die Funktion „Foto → Rezept" ruft Google Gemini 2.5 Flash mit Vision auf.
|
||
|
||
**Env-Vars:**
|
||
|
||
| Variable | Default | Zweck |
|
||
|---|---|---|
|
||
| `GEMINI_API_KEY` | _(leer)_ | Ohne Key ist das Feature graceful deaktiviert — Camera-Icon erscheint nicht. |
|
||
| `GEMINI_MODEL` | `gemini-2.5-flash` | Modell-Wechsel ohne Rebuild (z.B. auf `gemini-2.5-pro` für härtere Handschrift). |
|
||
| `GEMINI_TIMEOUT_MS` | `20000` | Timeout für den Vision-Call. |
|
||
|
||
Änderungen an diesen Env-Vars greifen erst nach `docker compose up -d --force-recreate`, nicht nach `restart`.
|
||
|
||
**Privacy:** Das hochgeladene Foto geht einmal an Google Gemini und wird danach serverseitig nicht gespeichert. Google trainiert im Paid-Tier nicht auf API-Daten. Der Server loggt nur Status-Code, Dauer und Bildgröße — nie Prompt oder Response-Inhalt.
|
||
|
||
**Rate-Limit:** 10 Requests/Minute pro IP (in-memory, resettet mit Prozess-Restart).
|
||
|
||
**Key aus Gitea Secrets:** `GEMINI_API_KEY` als Secret in der Kochwas-CI-Umgebung hinterlegen — Deploy-Hook injiziert ihn in die compose-environment. Ablauf-Monitoring über die Google-Cloud-Konsole (≥1× pro Quartal checken).
|
||
```
|
||
|
||
- [ ] **Step 2: ARCHITECTURE.md erweitern**
|
||
|
||
In `docs/ARCHITECTURE.md` im passenden Abschnitt (Import/Recipes) einen Absatz ergänzen:
|
||
|
||
```markdown
|
||
### Foto-Rezept-Magie
|
||
|
||
`/new/from-photo` → `POST /api/recipes/extract-from-photo` (multipart) → `preprocessImage` (sharp, ≤1600px, JPEG) → `extractRecipeFromImage` (Gemini 2.5 Flash, structured output, Zod-validiert, 1× Retry) → Response mit `Partial<Recipe>` + zufälliger Magie-Phrase.
|
||
|
||
Das Client-State-Store `photo-upload.svelte.ts` hält das Ergebnis im Browser; der `RecipeEditor` wird mit `recipe.id === null` gerendert (ImageUploadBox ausgeblendet). Save ruft `POST /api/recipes` auf, das via `insertRecipe` atomar schreibt und redirected zu `/recipes/:id`.
|
||
|
||
Das Original-Foto wird nie persistiert. Der Server loggt keine Prompt/Response-Inhalte.
|
||
```
|
||
|
||
- [ ] **Step 3: CLAUDE.md Gotcha-Tabelle erweitern**
|
||
|
||
In `CLAUDE.md` die Tabelle „Wichtigste Gotchas" um zwei Zeilen ergänzen:
|
||
|
||
```markdown
|
||
| **Gemini-Key fehlt** | Wenn `GEMINI_API_KEY` leer ist, wird das Camera-Icon im Header nicht gerendert und `/new/from-photo` antwortet mit 503. Graceful Degradation. |
|
||
| **sharp braucht vips-dev** | Im `Dockerfile`-Builder-Stage ist `vips-dev` nötig, damit `sharp` mit libheif (für iOS-HEIC-Uploads) gebaut wird. |
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add docs/OPERATIONS.md docs/ARCHITECTURE.md CLAUDE.md
|
||
git commit -m "docs: Foto-Rezept-Magie in OPERATIONS/ARCHITECTURE/CLAUDE"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Release v1.3.0
|
||
|
||
**Files:**
|
||
- Modify: `package.json` (Version bump)
|
||
- Modify: `src/routes/+layout.svelte` (Header-Version-Anzeige, falls da)
|
||
- Modify: (optional) `CHANGELOG.md`
|
||
|
||
- [ ] **Step 1: Voller Test-Run**
|
||
|
||
```bash
|
||
npm run check && npx vitest run && npx playwright test
|
||
```
|
||
|
||
Expected: alles grün. E2E gegen dev läuft sowieso erst nachdem die Tasks gepusht und deployed sind — wenn nötig, den E2E-Task separat am Ende nach Deploy.
|
||
|
||
- [ ] **Step 2: Version bumpen**
|
||
|
||
```bash
|
||
npm version minor --no-git-tag-version
|
||
```
|
||
|
||
Expected: `package.json` Version auf `1.3.0`, `package-lock.json` mitgepflegt.
|
||
|
||
- [ ] **Step 3: Release-Commit + Tag**
|
||
|
||
```bash
|
||
git add package.json package-lock.json
|
||
git commit -m "chore(release): v1.3.0 — Foto-Rezept-Magie"
|
||
git tag v1.3.0
|
||
```
|
||
|
||
- [ ] **Step 4: Push**
|
||
|
||
```bash
|
||
git push && git push --tags
|
||
```
|
||
|
||
Expected: CI baut arm64-Image und pushed nach Gitea. Deploy via bestehenden Hook auf Pi 5.
|
||
|
||
- [ ] **Step 5: Post-Deploy Smoke-Test manuell**
|
||
|
||
Auf `https://kochwas.siegeln.net` im Header prüfen:
|
||
|
||
1. Camera-Icon sichtbar (heißt: `GEMINI_API_KEY` ist auf Prod gesetzt).
|
||
2. Klick → `/new/from-photo` lädt.
|
||
3. Ein echtes Foto von einer Kochbuchseite aufnehmen → Extraktion läuft → Editor füllt sich mit plausiblen Werten.
|
||
4. Speichern → Rezept erscheint in der Liste auf `/`.
|
||
5. Offline-Modus im Browser testen: Camera-Icon grau, Klick öffnet die Route nicht.
|
||
|
||
Wenn 1 fehlschlägt: `GEMINI_API_KEY` in Prod-env nachziehen und `docker compose up -d --force-recreate` auf dem Pi.
|
||
|
||
- [ ] **Step 6: Auto-Memory-Update**
|
||
|
||
Die Release-Stand-Memory aktualisieren: `~/.claude/projects/C--Users-Hendrik-Documents-projects-kochwas/memory/project_release_status.md` auf v1.3.0 / Datum / neue Features-Zeile. (Außerhalb des Repo, aber Teil der Abschluss-Routine.)
|
||
|
||
---
|
||
|
||
## Akzeptanz-Checkliste (aus Spec §12)
|
||
|
||
- [ ] Camera-Icon im Header sichtbar, führt zu `/new/from-photo`. → Task 12
|
||
- [ ] Camera-Icon unsichtbar wenn `GEMINI_API_KEY` leer. → Task 12
|
||
- [ ] Camera-Icon disabled wenn offline. → Task 12
|
||
- [ ] File-Picker öffnet mobile Rückkamera direkt. → Task 11 (`capture="environment"`)
|
||
- [ ] Fixture-Rezept wird (gemockt) in gültige Recipe-Shape überführt. → Task 7
|
||
- [ ] Handschrift-Fixture ebenso (Mock). → Task 7 (gleicher Pfad, Mock beliebig)
|
||
- [ ] No-Recipe-Fixture → 422 → UI zeigt Yellow-Box mit beiden Buttons. → Task 7 + Task 11
|
||
- [ ] Editor öffnet mit vorbefüllten Feldern, Speichern navigiert zu `/recipes/:id`. → Task 11 + Task 9
|
||
- [ ] Foto wird nach Request nicht auf Disk gefunden. → Task 7 (kein fs-Write, Test prüft implizit)
|
||
- [ ] Build im Dockerfile-arm64-Stage erfolgreich mit `sharp`. → Task 1 + CI-Run in Task 15
|
||
- [ ] `npm test` + `npm run check` grün. → jede Task schließt mit grünen Tests
|