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>
184 lines
5.7 KiB
TypeScript
184 lines
5.7 KiB
TypeScript
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';
|
|
|
|
// 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',
|
|
'image/webp',
|
|
'image/heic',
|
|
'image/heif'
|
|
]);
|
|
|
|
// Singleton-Limiter: 10 Requests/Minute pro IP. Verhindert Kosten-Runaways
|
|
// bei versehentlichem Dauer-Tappen.
|
|
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.'
|
|
);
|
|
}
|
|
|
|
// 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 (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,
|
|
'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 + 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: ${e.message}`
|
|
);
|
|
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: []
|
|
}
|
|
});
|
|
};
|