feat(ai): Extraction-Prompt + Gemini-Schema + Zod-Validator
This commit is contained in:
85
src/lib/server/ai/recipe-extraction-prompt.ts
Normal file
85
src/lib/server/ai/recipe-extraction-prompt.ts
Normal file
@@ -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<typeof extractionResponseSchema>;
|
||||
71
tests/unit/recipe-extraction-prompt.test.ts
Normal file
71
tests/unit/recipe-extraction-prompt.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user