Files
kochwas/docs/superpowers/plans/2026-04-21-photo-recipe-magic.md
hsiegeln 783b782608 docs: implementation plan fuer Foto-Rezept-Magie
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>
2026-04-21 10:35:36 +02:00

2238 lines
71 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 112 durchgelaufen sein; s. Task 15).
Falls der zweite Test fehlschlägt, weil das Icon auf dev noch nicht existiert: Reihenfolge beachten — die Tasks 112 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