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
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 . .
RUN npm run build
# Remove dev dependencies for the runtime image
RUN npm prune --omit=dev
# Fresh-Install fuer den Runtime-Stage: nur Produktions-Deps, gleiche Strategie.
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
WORKDIR /app

View File

@@ -17,6 +17,10 @@ services:
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash}
- 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:
- searxng
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",
"linkedom": "^0.18.5",
"lucide-svelte": "^1.0.1",
"node-addon-api": "^8.7.0",
"node-gyp": "^12.3.0",
"yauzl": "^3.3.0",
"zod": "^3.23.8"
}

View File

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

View File

@@ -1,4 +1,5 @@
import sharp from 'sharp';
import type SharpType from 'sharp';
import { createRequire } from 'node:module';
const MAX_EDGE = 1600;
const JPEG_QUALITY = 85;
@@ -8,10 +9,25 @@ export type PreprocessedImage = {
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.
// sharp liest HEIC/HEIF transparent, wenn libheif im libvips-Build enthalten ist
// (in Alpine's vips-dev + in den offiziellen sharp-Prebuilds).
export async function preprocessImage(input: Buffer): Promise<PreprocessedImage> {
const sharp = loadSharp();
const pipeline = sharp(input, { failOn: 'error' }).rotate(); // respect EXIF orientation
const meta = await pipeline.metadata();
if (!meta.width || !meta.height) {

View File

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

View File

@@ -6,7 +6,11 @@ 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;
// 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([
'image/jpeg',
'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;
try {
form = await request.formData();
} catch {
return errJson(400, 'BAD_REQUEST', 'Multipart body erwartet.');
} catch (e) {
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');
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.');
}
console.info(
`[extract-from-photo] received photo size=${photo.size} mime="${photo.type}" ua="${userAgent}"`
);
if (photo.size > MAX_BYTES) {
return errJson(
413,
@@ -95,9 +121,11 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
: e.code === 'AI_NOT_CONFIGURED'
? 503
: 503;
// Nur Code + Meta loggen, niemals Prompt/Response-Inhalt.
// Nur Code + Meta + Error-Message loggen, niemals Prompt/Response-Inhalt.
// e.message enthaelt z.B. Zod-Validierungspfade oder "non-JSON output" --
// kein AI-Content, aber die Diagnose-Info, warum AI_FAILED kam.
console.warn(
`[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes`
`[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes: ${e.message}`
);
return errJson(status, e.code, 'Die Bild-Analyse ist fehlgeschlagen.');
}

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import {
Camera,
ImageUp,
Loader2,
Wand2,
AlertTriangle,
@@ -17,6 +18,7 @@
const store = new PhotoUploadStore();
let saving = $state(false);
let cameraInput = $state<HTMLInputElement | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
function onPick(e: Event) {
@@ -85,20 +87,42 @@
Fotografiere ein gedrucktes oder handgeschriebenes Rezept. Eine Seite,
scharf, gut ausgeleuchtet.
</p>
<button
type="button"
class="btn primary"
onclick={() => fileInput?.click()}
disabled={!network.online}
>
<Camera size={18} strokeWidth={2} />
<span>Foto wählen oder aufnehmen</span>
</button>
<div class="row">
<button
type="button"
class="btn primary"
onclick={() => cameraInput?.click()}
disabled={!network.online}
>
<Camera size={18} strokeWidth={2} />
<span>Kamera</span>
</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
bind:this={fileInput}
type="file"
accept="image/*"
capture="environment"
hidden
onchange={onPick}
/>

View File

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

View File

@@ -3,6 +3,13 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
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: {
include: ['tests/**/*.test.ts'],
globals: false,