15 Commits

Author SHA1 Message Date
hsiegeln
0373dc32da feat(ai): Deutsch als starker Prior im OCR-Prompt
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
Neue SPRACHE-Sektion weist Gemini explizit darauf hin, dass die
Texte ausschliesslich deutsch sind -- Umlaute, deutsche Zutaten,
deutsche Masseinheiten als Prior fuer die Zeichen-Rekonstruktion.
Soll die "Kontext-Detektiv"-Logik bei handgeschriebenen oder
verblassten Rezepten verbessern.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:08:10 +02:00
hsiegeln
fb7c2f0e9b feat(photo-upload): zwei Buttons fuer Kamera vs. Datei-Picker
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
Android-Chrome auf Tablet verhaelt sich zickig: mit capture="environment"
nur Kamera, ohne capture nur Datei-Picker -- nie beide. Zwei separate
Buttons (mit jeweils eigenem Input-Element) machen die Wahl explizit
und funktionieren ueberall eindeutig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:45:37 +02:00
hsiegeln
33ee6fbf2e feat(photo-upload): Picker ohne capture -> auch gespeicherte Fotos
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m26s
capture="environment" zwang Mobile-Browser in den Kamera-Modus. Ohne
das Attribut zeigt der Browser auf Mobile die volle Auswahl
(Kamera / Fotomediathek / Datei) -- besser fuer Tablets und User,
die ein schon existierendes Kochbuch-Foto verwenden wollen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:39:07 +02:00
hsiegeln
e2713913e7 feat(photo-upload): Logging fuer Upload-Parse-Fehler
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Der bisherige Endpoint verschluckte den formData()-Fehler mit einem
generischen "Multipart erwartet" — wir wissen nicht, warum Chrome auf
dem Tablet scheitert. Jetzt wird beim Fehler Content-Type, -Length und
User-Agent geloggt, plus die konkrete Error-Message in der Response.
Kein Foto-Inhalt im Log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:37:42 +02:00
hsiegeln
3bc7fa16e2 feat(photo-upload): Limits hochschrauben fuer Tablet-Fotos
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m16s
Tablet- und iPad-Pro-Kameras liefern JPEGs/HEICs bis 15 MB. Mit den
alten 8-/10-MB-Limits scheiterte das Upload beim SvelteKit-Body-Parser
mit "Multipart erwartet" (undurchsichtiger Fehler, weil SvelteKit den
Body frueher abweist als unser Endpoint-Check).

- Endpoint MAX_BYTES: 8 -> 20 MB
- BODY_SIZE_LIMIT: 10 -> 25 MB (mit Multipart-Overhead)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:31:34 +02:00
hsiegeln
173d9d138d fix(ai): sharp via createRequire, nicht ES-Import
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 28s
ES-Dynamic-Import mit @vite-ignore reichte nicht -- adapter-node's
Rollup-Step extrahiert sharp trotzdem in einen shared chunk und
bundelt sharp's interne dynamic-requires kaputt.

createRequire(import.meta.url) plus require('sharp') ist pure Node-
Runtime-Logik, die Rollup komplett ignoriert. sharp wird regulaer aus
node_modules geladen -- inkl. seiner Plattform-.node-Binary aus
@img/sharp-linuxmusl-arm64.

Verifikation: Build-Output enthaelt 0 Vorkommen von "dynamicRequireTargets"
und "sharp.node" (waren vorher in einem 319KB shared chunk).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:08:53 +02:00
hsiegeln
5492d4dc24 fix(deploy): BODY_SIZE_LIMIT=10MB fuer Foto-Upload
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m17s
adapter-node limitiert Request-Bodies per Default auf 512 KB.
Unsere Rezept-Fotos sind bis 8 MB gross -- der Upload scheitert
sonst vor dem Endpoint-Check mit "Multipart body erwartet", weil
SvelteKit den Body frueher abweist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:05:09 +02:00
hsiegeln
39de08abf9 fix(ai): sharp via dynamic import, nicht top-level
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m28s
Der vorige Versuch mit ssr.external in vite.config.ts war ein No-op:
adapter-node macht einen eigenen Rollup-Bundle-Schritt nach Vite und
ignoriert ssr.external komplett. Ergebnis: sharp's dynamic-require
fuer die native .node-Binary landet kaputt im Server-Bundle (332KB
Bundle-Chunk, 297 sharp-Referenzen).

Dynamic import mit /* @vite-ignore */ verhindert, dass Rollup sharp
aufloest — die Require geht stattdessen zur Laufzeit regulaer an
Node und findet @img/sharp-linuxmusl-arm64 in node_modules.

Ergebnis lokal: Server-Chunk von 332KB auf 14KB geschrumpft, nur noch
2 Referenzen auf den Paketnamen (der Import-String selbst).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:33:59 +02:00
hsiegeln
fd7884e1b2 fix(vite): sharp als ssr.external markieren
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 2m17s
Der Server-Bundle-Schritt (Rollup via adapter-node) kann sharp's
dynamic-require fuer die native Plattform-.node-Binary nicht aufloesen
und bundelt kaputten Code ins Image. ssr.external sorgt dafuer, dass
sharp zur Laufzeit regulaer aus node_modules geladen wird, wo der
Docker-Build die @img/sharp-linuxmusl-arm64-Binary korrekt abgelegt hat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:27:28 +02:00
hsiegeln
13728f9252 fix(docker): expliziter Plattform-Install fuer sharp-Prebuilts
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 3m5s
Finaler Anlauf gegen den arm64-Build-Fehler. Bisher scheiterte
npm daran, @img/sharp-linuxmusl-arm64 unter Docker-Buildx-QEMU zu
installieren, trotz --include=optional. Mehrschichtiger Fix:

1. --cpu=arm64 --os=linux --libc=musl auf npm install: umgeht QEMU-
   bezogene Detection-Bugs (sharp's offiziell empfohlener Fix).
2. Expliziter Zusatz-Install von @img/sharp-linuxmusl-arm64 und
   @img/sharp-libvips-linuxmusl-arm64: zwingt die Prebuilts auf Disk,
   unabhaengig von der Lockfile-Resolution.
3. --ignore-scripts beim Install + npm rebuild danach: loest den Race,
   wo sharp's postinstall laeuft bevor der Prebuilt-Tarball fertig ist.
4. node-addon-api + node-gyp als Runtime-Deps: from-source-Build-
   Fallback falls alle Prebuilt-Pfade scheitern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:52:55 +02:00
hsiegeln
83f5b88d94 fix(docker): node-addon-api + ignore-scripts/rebuild fuer sharp
Some checks failed
Build & Publish Docker Image / build-and-push (push) Failing after 51s
Drei Schichten Absicherung gegen den arm64-Build-Fehler:

- --ignore-scripts beim npm install verhindert, dass sharp's postinstall
  check.js laeuft, bevor das @img/sharp-linuxmusl-arm64-Paket entpackt
  ist (Race in parallelem Install).
- npm rebuild danach: alle Deps sind jetzt auf Disk, Postinstalls laufen
  sauber in Dependency-Reihenfolge.
- node-addon-api als Runtime-Dep: falls die Prebuilt-Binary im npm-Tree
  nicht landet, kann sharp from-source bauen (vips-dev + python3 + make
  + g++ sind im Dockerfile bereits installiert).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:49:41 +02:00
hsiegeln
cb93725139 fix(docker): npm install statt npm ci fuer sharp-Prebuilts
Some checks failed
Build & Publish Docker Image / build-and-push (push) Failing after 48s
Der vorige Fix (ignore-scripts + rebuild, plus Fresh-ci im Builder) hat
den sharp-Prebuilt trotzdem nicht installiert. Ursache: der Windows-
generierte Lockfile markiert @img/sharp-linuxmusl-arm64 als "dev": true,
sodass npm ci die Prebuilt-Binary konsistent auslaesst — egal ob mit
--include=optional. npm install dagegen resolvt Optional-Deps frisch fuer
die Build-Plattform (linux-arm64-musl im Docker) und findet die Prebuilts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:46:55 +02:00
hsiegeln
80c72b6e5b fix(docker): sharp-Prebuilts beim CI-Build korrekt installieren
Some checks failed
Build & Publish Docker Image / build-and-push (push) Failing after 51s
Zwei Fixes gegen den arm64-Build-Fehler:

1. npm ci mit --ignore-scripts + npm rebuild danach — vermeidet
   eine Race, bei der sharp's postinstall check.js laeuft bevor die
   Plattform-Prebuilt-Binary vollstaendig entpackt ist.

2. Statt npm prune --omit=dev ein Fresh-Install via npm ci
   --omit=dev. Grund: Der Lockfile wird auf Windows generiert und
   markiert die linux-musl-arm64-Prebuilts als "dev": true, obwohl
   sie fuer's Runtime gebraucht werden. Prune wuerde sie kappen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:44:05 +02:00
11 changed files with 602 additions and 424 deletions

View File

@@ -8,13 +8,39 @@ WORKDIR /app
RUN apk add --no-cache python3 make g++ libc6-compat vips-dev RUN apk add --no-cache python3 make g++ libc6-compat vips-dev
COPY package*.json ./ COPY package*.json ./
RUN npm ci # Sharp-Prebuilt-Install unter Docker-Buildx-QEMU war trotz aller Flag-
# Varianten unzuverlaessig. Finale Strategie:
# - --cpu/--os/--libc explizit setzen: sharp's offizielle Doc-Empfehlung
# fuer Cross-Platform-Docker-Builds (siehe sharp-Install-Doku),
# umgeht QEMU-Detection-Bugs.
# - --ignore-scripts + npm rebuild: loest das Parallel-Install-Race,
# bei dem sharp's install-Skript vor dem Entpacken der Prebuilt-Binary
# laeuft.
# - Explizites Nachinstallieren der Prebuilts als Sicherheit: falls (A)
# noch nicht reicht, zwingt (B) die Plattform-Pakete auf Disk.
# - node-addon-api + node-gyp als Runtime-Deps: falls am Ende doch alles
# nicht klappt und sharp from-source baut (mit dem oben installierten
# python3 + make + g++ + vips-dev).
RUN npm install --cpu=arm64 --os=linux --libc=musl \
--ignore-scripts --include=optional --no-audit --no-fund
RUN npm install --cpu=arm64 --os=linux --libc=musl \
--ignore-scripts --no-save --no-audit --no-fund \
@img/sharp-linuxmusl-arm64@0.34.5 \
@img/sharp-libvips-linuxmusl-arm64@1.2.4
RUN npm rebuild
COPY . . COPY . .
RUN npm run build RUN npm run build
# Remove dev dependencies for the runtime image # Fresh-Install fuer den Runtime-Stage: nur Produktions-Deps, gleiche Strategie.
RUN npm prune --omit=dev RUN rm -rf node_modules \
&& npm install --cpu=arm64 --os=linux --libc=musl \
--ignore-scripts --omit=dev --include=optional --no-audit --no-fund \
&& npm install --cpu=arm64 --os=linux --libc=musl \
--ignore-scripts --no-save --no-audit --no-fund \
@img/sharp-linuxmusl-arm64@0.34.5 \
@img/sharp-libvips-linuxmusl-arm64@1.2.4 \
&& npm rebuild
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app

View File

@@ -17,6 +17,10 @@ services:
- GEMINI_API_KEY=${GEMINI_API_KEY:-} - GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash} - GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash}
- GEMINI_TIMEOUT_MS=${GEMINI_TIMEOUT_MS:-20000} - GEMINI_TIMEOUT_MS=${GEMINI_TIMEOUT_MS:-20000}
# adapter-node-Default ist 512 KB. Tablet- und iPad-Pro-Kameras liefern
# JPEGs/HEICs bis 15 MB. Endpoint-Limit ist 20 MB; hier 25 MB fuer den
# Multipart-Overhead.
- BODY_SIZE_LIMIT=25000000
depends_on: depends_on:
- searxng - searxng
restart: unless-stopped restart: unless-stopped

822
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,8 @@
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",
"linkedom": "^0.18.5", "linkedom": "^0.18.5",
"lucide-svelte": "^1.0.1", "lucide-svelte": "^1.0.1",
"node-addon-api": "^8.7.0",
"node-gyp": "^12.3.0",
"yauzl": "^3.3.0", "yauzl": "^3.3.0",
"zod": "^3.23.8" "zod": "^3.23.8"
} }

View File

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

View File

@@ -1,4 +1,5 @@
import sharp from 'sharp'; import type SharpType from 'sharp';
import { createRequire } from 'node:module';
const MAX_EDGE = 1600; const MAX_EDGE = 1600;
const JPEG_QUALITY = 85; const JPEG_QUALITY = 85;
@@ -8,10 +9,25 @@ export type PreprocessedImage = {
mimeType: 'image/jpeg'; mimeType: 'image/jpeg';
}; };
// sharp per Node-Runtime-require laden, nicht via ES-Import: adapter-node
// bundelt ES-Imports (auch dynamische, auch mit @vite-ignore) ins Server-
// Bundle, was sharp's internes dynamic-require fuer die Plattform-.node-Binary
// zerstoert. createRequire + require() ist pure Node-Runtime-Logik, die
// Rollup nicht anfasst -- sharp wird regulaer aus node_modules geladen.
const nodeRequire = createRequire(import.meta.url);
let sharpModule: typeof SharpType | null = null;
function loadSharp(): typeof SharpType {
if (!sharpModule) {
sharpModule = nodeRequire('sharp') as typeof SharpType;
}
return sharpModule;
}
// Resize auf max 1600px lange Kante, JPEG re-encode, Metadata strippen. // Resize auf max 1600px lange Kante, JPEG re-encode, Metadata strippen.
// sharp liest HEIC/HEIF transparent, wenn libheif im libvips-Build enthalten ist // sharp liest HEIC/HEIF transparent, wenn libheif im libvips-Build enthalten ist
// (in Alpine's vips-dev + in den offiziellen sharp-Prebuilds). // (in Alpine's vips-dev + in den offiziellen sharp-Prebuilds).
export async function preprocessImage(input: Buffer): Promise<PreprocessedImage> { export async function preprocessImage(input: Buffer): Promise<PreprocessedImage> {
const sharp = loadSharp();
const pipeline = sharp(input, { failOn: 'error' }).rotate(); // respect EXIF orientation const pipeline = sharp(input, { failOn: 'error' }).rotate(); // respect EXIF orientation
const meta = await pipeline.metadata(); const meta = await pipeline.metadata();
if (!meta.width || !meta.height) { if (!meta.width || !meta.height) {

View File

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

View File

@@ -6,7 +6,11 @@ import { pickRandomPhrase } from '$lib/server/ai/description-phrases';
import { createRateLimiter } from '$lib/server/ai/rate-limit'; import { createRateLimiter } from '$lib/server/ai/rate-limit';
import type { Ingredient, Step } from '$lib/types'; import type { Ingredient, Step } from '$lib/types';
const MAX_BYTES = 8 * 1024 * 1024; // 20 MB deckt auch Tablet- und iPad-Pro-Fotos ab (oft 10-15 MB JPEG/HEIC).
// Muss zusammen mit BODY_SIZE_LIMIT (docker-compose.prod.yml) hochgezogen werden --
// SvelteKit rejected groessere Bodies frueher und wirft dann undurchsichtige
// "Multipart erwartet"-Fehler.
const MAX_BYTES = 20 * 1024 * 1024;
const ALLOWED_MIME = new Set([ const ALLOWED_MIME = new Set([
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',
@@ -41,16 +45,38 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
); );
} }
// Header-Snapshot fuer Diagnose beim Upload-Parse-Fehler. Wir loggen
// Content-Type, -Length und User-Agent — nichts, was Inhalt verraet.
const contentType = request.headers.get('content-type') ?? '(missing)';
const contentLength = request.headers.get('content-length') ?? '(missing)';
const userAgent = request.headers.get('user-agent')?.slice(0, 120) ?? '(missing)';
let form: FormData; let form: FormData;
try { try {
form = await request.formData(); form = await request.formData();
} catch { } catch (e) {
return errJson(400, 'BAD_REQUEST', 'Multipart body erwartet.'); const err = e as Error;
console.warn(
`[extract-from-photo] formData() failed: name=${err.name} msg=${err.message} ` +
`ct="${contentType}" len=${contentLength} ua="${userAgent}"`
);
return errJson(
400,
'BAD_REQUEST',
`Upload konnte nicht gelesen werden (${err.name}: ${err.message}).`
);
} }
const photo = form.get('photo'); const photo = form.get('photo');
if (!(photo instanceof Blob)) { if (!(photo instanceof Blob)) {
console.warn(
`[extract-from-photo] photo field missing or not a Blob. ct="${contentType}" ` +
`len=${contentLength} fields=${[...form.keys()].join(',')}`
);
return errJson(400, 'BAD_REQUEST', 'Feld "photo" fehlt.'); return errJson(400, 'BAD_REQUEST', 'Feld "photo" fehlt.');
} }
console.info(
`[extract-from-photo] received photo size=${photo.size} mime="${photo.type}" ua="${userAgent}"`
);
if (photo.size > MAX_BYTES) { if (photo.size > MAX_BYTES) {
return errJson( return errJson(
413, 413,
@@ -95,9 +121,11 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
: e.code === 'AI_NOT_CONFIGURED' : e.code === 'AI_NOT_CONFIGURED'
? 503 ? 503
: 503; : 503;
// Nur Code + Meta loggen, niemals Prompt/Response-Inhalt. // Nur Code + Meta + Error-Message loggen, niemals Prompt/Response-Inhalt.
// e.message enthaelt z.B. Zod-Validierungspfade oder "non-JSON output" --
// kein AI-Content, aber die Diagnose-Info, warum AI_FAILED kam.
console.warn( console.warn(
`[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes` `[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes: ${e.message}`
); );
return errJson(status, e.code, 'Die Bild-Analyse ist fehlgeschlagen.'); return errJson(status, e.code, 'Die Bild-Analyse ist fehlgeschlagen.');
} }

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { import {
Camera, Camera,
ImageUp,
Loader2, Loader2,
Wand2, Wand2,
AlertTriangle, AlertTriangle,
@@ -17,6 +18,7 @@
const store = new PhotoUploadStore(); const store = new PhotoUploadStore();
let saving = $state(false); let saving = $state(false);
let cameraInput = $state<HTMLInputElement | null>(null);
let fileInput = $state<HTMLInputElement | null>(null); let fileInput = $state<HTMLInputElement | null>(null);
function onPick(e: Event) { function onPick(e: Event) {
@@ -85,20 +87,42 @@
Fotografiere ein gedrucktes oder handgeschriebenes Rezept. Eine Seite, Fotografiere ein gedrucktes oder handgeschriebenes Rezept. Eine Seite,
scharf, gut ausgeleuchtet. scharf, gut ausgeleuchtet.
</p> </p>
<div class="row">
<button <button
type="button" type="button"
class="btn primary" class="btn primary"
onclick={() => fileInput?.click()} onclick={() => cameraInput?.click()}
disabled={!network.online} disabled={!network.online}
> >
<Camera size={18} strokeWidth={2} /> <Camera size={18} strokeWidth={2} />
<span>Foto wählen oder aufnehmen</span> <span>Kamera</span>
</button> </button>
<button
type="button"
class="btn ghost"
onclick={() => fileInput?.click()}
disabled={!network.online}
>
<ImageUp size={18} strokeWidth={2} />
<span>Aus Dateien</span>
</button>
</div>
<!-- Zwei separate Inputs: capture="environment" oeffnet direkt die Kamera,
das andere zeigt den Datei-/Fotomediathek-Picker. Android-Chrome auf
Tablet zeigt sonst bei capture="environment" nur die Kamera; ohne
capture dagegen nur den Datei-Picker. Explizite Wahl ist eindeutig. -->
<input
bind:this={cameraInput}
type="file"
accept="image/*"
capture="environment"
hidden
onchange={onPick}
/>
<input <input
bind:this={fileInput} bind:this={fileInput}
type="file" type="file"
accept="image/*" accept="image/*"
capture="environment"
hidden hidden
onchange={onPick} onchange={onPick}
/> />

View File

@@ -70,8 +70,8 @@ describe('POST /api/recipes/extract-from-photo', () => {
expect(body.recipe.id).toBeNull(); expect(body.recipe.id).toBeNull();
}); });
it('413 when file exceeds 8 MB', async () => { it('413 when file exceeds 20 MB', async () => {
const big = Buffer.alloc(9 * 1024 * 1024); const big = Buffer.alloc(21 * 1024 * 1024);
const fd = new FormData(); const fd = new FormData();
fd.append('photo', new Blob([new Uint8Array(big)], { type: 'image/jpeg' })); fd.append('photo', new Blob([new Uint8Array(big)], { type: 'image/jpeg' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -3,6 +3,13 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
// sharp muss extern bleiben: der Server-Bundle-Schritt kann sharp's
// dynamic-require fuer die native .node-Binary nicht aufloesen. Wenn
// sharp nicht gebundelt wird, laedt Node es zur Laufzeit regulaer aus
// node_modules/@img/sharp-linuxmusl-arm64, das dann funktioniert.
ssr: {
external: ['sharp']
},
test: { test: {
include: ['tests/**/*.test.ts'], include: ['tests/**/*.test.ts'],
globals: false, globals: false,