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

71 KiB
Raw Blame History

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
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:

# 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:

    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
npm run check

Expected: PASS (keine neuen Type-Fehler durch die neuen Packages — sie werden noch nicht importiert).

  • Step 5: Commit
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:

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)
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:

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)
npx vitest run src/lib/server/ai/description-phrases.test.ts

Expected: PASS — alle 4 Tests grün.

  • Step 5: Commit
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:

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)
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:

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)
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
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:

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)
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:

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)
npx vitest run src/lib/server/ai/recipe-extraction-prompt.test.ts

Expected: PASS — alle 6 Tests grün.

  • Step 5: Commit
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:

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)
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:

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)
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
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:

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)
npx vitest run src/lib/server/ai/rate-limit.test.ts

Expected: FAIL.

  • Step 3: Modul implementieren

src/lib/server/ai/rate-limit.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)
npx vitest run src/lib/server/ai/rate-limit.test.ts

Expected: PASS.

  • Step 5: Commit
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:

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)
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:

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)
npx vitest run src/routes/api/recipes/extract-from-photo/+server.test.ts

Expected: PASS — alle 7 Tests grün.

  • Step 5: Commit
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:

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)
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:

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)
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
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:

  <section class="block">
    <h2>Bild</h2>
    <ImageUploadBox
      recipeId={recipe.id!}
      imagePath={recipe.image_path}
      onchange={(p) => onimagechange?.(p)}
    />
  </section>

ändern zu:

  {#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:

  .block.info {
    background: #f6faf7;
    border: 1px dashed #cfd9d1;
  }
  .hint {
    color: #666;
    margin: 0;
    font-size: 0.9rem;
  }
  • Step 2: Verifizieren: bestehende Tests + check
npm run check
npx vitest run

Expected: keine Regression.

  • Step 3: Commit
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:

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)
npx vitest run src/lib/client/photo-upload.svelte.test.ts

Expected: FAIL.

  • Step 3: Store implementieren

src/lib/client/photo-upload.svelte.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)
npx vitest run src/lib/client/photo-upload.svelte.test.ts

Expected: PASS — alle 4 Tests grün.

  • Step 5: Commit
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:

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:

<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
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
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
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.

npm run check && npx vitest run

Expected: PASS.

  • Step 7: Commit
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):

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:

<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:

  .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
npm run check

Expected: PASS.

Manuell: GEMINI_API_KEY=test npm run dev, auf / Icon prüfen. Ohne Key: Icon verschwindet.

  • Step 4: Commit
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
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:

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
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
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:

## 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:

### 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:

| **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
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

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
npm version minor --no-git-tag-version

Expected: package.json Version auf 1.3.0, package-lock.json mitgepflegt.

  • Step 3: Release-Commit + Tag
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
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