feat(ai): Extraction-Prompt + Gemini-Schema + Zod-Validator

This commit is contained in:
hsiegeln
2026-04-21 10:40:03 +02:00
parent 0cca9a699c
commit d479fd61d8
2 changed files with 156 additions and 0 deletions

View 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>;

View 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();
});
});