2026-04-21 10:40:58 +02:00
|
|
|
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
|
|
|
import { env } from '$env/dynamic/private';
|
|
|
|
|
import {
|
|
|
|
|
RECIPE_EXTRACTION_SYSTEM_PROMPT,
|
2026-04-21 14:26:18 +02:00
|
|
|
RECIPE_EXTRACTION_USER_PROMPT,
|
2026-04-21 10:40:58 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCfg(): { apiKey: string; model: string; timeoutMs: number } {
|
|
|
|
|
const apiKey = env.GEMINI_API_KEY ?? process.env.GEMINI_API_KEY ?? '';
|
|
|
|
|
const model =
|
|
|
|
|
env.GEMINI_MODEL ?? process.env.GEMINI_MODEL ?? 'gemini-2.5-flash';
|
|
|
|
|
const rawTimeout =
|
|
|
|
|
env.GEMINI_TIMEOUT_MS ?? process.env.GEMINI_TIMEOUT_MS ?? '20000';
|
|
|
|
|
const timeoutMs = Number(rawTimeout) || 20000;
|
|
|
|
|
return { apiKey, model, timeoutMs };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function callGemini(
|
|
|
|
|
imageBuffer: Buffer,
|
|
|
|
|
mimeType: string,
|
|
|
|
|
appendUserNote?: string
|
|
|
|
|
): Promise<ExtractionResponse> {
|
|
|
|
|
const { apiKey, model: modelId, timeoutMs } = getCfg();
|
|
|
|
|
if (!apiKey) {
|
|
|
|
|
throw new GeminiError('AI_NOT_CONFIGURED', 'GEMINI_API_KEY is not set');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 }
|
2026-04-21 14:26:18 +02:00
|
|
|
> = [
|
|
|
|
|
{ inlineData: { data: imageBuffer.toString('base64'), mimeType } },
|
|
|
|
|
{ text: RECIPE_EXTRACTION_USER_PROMPT }
|
|
|
|
|
];
|
2026-04-21 10:40:58 +02:00
|
|
|
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, AI_TIMEOUT, or config errors.
|
|
|
|
|
export async function extractRecipeFromImage(
|
|
|
|
|
imageBuffer: Buffer,
|
|
|
|
|
mimeType: string
|
|
|
|
|
): Promise<ExtractionResponse> {
|
2026-04-21 14:08:10 +02:00
|
|
|
let firstMsg: string | null = null;
|
2026-04-21 10:40:58 +02:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 14:08:10 +02:00
|
|
|
firstMsg = e instanceof Error ? e.message : String(e);
|
|
|
|
|
console.warn(`[gemini-client] first attempt failed, retrying: ${firstMsg}`);
|
|
|
|
|
|
2026-04-21 10:40:58 +02:00
|
|
|
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) {
|
2026-04-21 14:08:10 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-21 10:40:58 +02:00
|
|
|
const retryStatus = getStatus(retryErr);
|
|
|
|
|
if (retryStatus === 429)
|
|
|
|
|
throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit on retry');
|
2026-04-21 14:08:10 +02:00
|
|
|
throw new GeminiError(
|
|
|
|
|
'AI_FAILED',
|
|
|
|
|
`retry failed: ${retryMsg} (first: ${firstMsg})`
|
|
|
|
|
);
|
2026-04-21 10:40:58 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|