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