diff --git a/src/lib/server/ai/recipe-extraction-prompt.ts b/src/lib/server/ai/recipe-extraction-prompt.ts new file mode 100644 index 0000000..f956f51 --- /dev/null +++ b/src/lib/server/ai/recipe-extraction-prompt.ts @@ -0,0 +1,85 @@ +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. + +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 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. .strict() verhindert, dass Gemini zusätzliche Keys +// unbemerkt durchschmuggelt. +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; diff --git a/tests/unit/recipe-extraction-prompt.test.ts b/tests/unit/recipe-extraction-prompt.test.ts new file mode 100644 index 0000000..8334aa5 --- /dev/null +++ b/tests/unit/recipe-extraction-prompt.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { + RECIPE_EXTRACTION_SYSTEM_PROMPT, + GEMINI_RESPONSE_SCHEMA, + extractionResponseSchema, + type ExtractionResponse +} from '../../src/lib/server/ai/recipe-extraction-prompt'; + +describe('recipe-extraction-prompt', () => { + it('system prompt is in German and mentions Rezept + Zutaten + Zubereitung', () => { + 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).toBeDefined(); + 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 accepts 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(); + }); +});