feat(recipe-generation): Cibello Receptkatalog Fas A – STEG 1
- Nytt paket @app/recipe-generation med pipeline, verifieringsgrind, gaprapport och pilotbatch-script. - Ny AAMOS-task GENERATE_RECIPE_CANDIDATES i @app/ai-contracts. - GeminiAamosClient utökat med receptgenereringsstöd. - MockAamosClient uppdaterad med deterministiska kandidater. - Verifieringspipeline: ingrediensmappning (hård spärr), härledda allergener, beräknad näring via nutrition-engine, språk-/kvalitetskontroll, dedup. - Hermetiska tester: 10/10 gröna. - typecheck + test + build: gröna för hela workspace. Refs: docs/32-receptkatalog-buildout.md
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
type TaskOutput,
|
||||
detectedItemSchema,
|
||||
type DetectedItem,
|
||||
generateRecipeCandidatesOutput,
|
||||
} from "./tasks.js";
|
||||
import type { AamosCallOptions, AamosClient, AamosResult } from "./client.js";
|
||||
import type { LocaleContext } from "@app/shared-types";
|
||||
@@ -193,6 +194,8 @@ export class GeminiAamosClient implements AamosClient {
|
||||
case "ANALYZE_FRIDGE_IMAGE":
|
||||
case "ANALYZE_PANTRY_IMAGE":
|
||||
return this.analyzeStorageImage(taskType, parsedInput.data as TaskInput<"ANALYZE_FRIDGE_IMAGE">, options) as Promise<AamosResult<T>>;
|
||||
case "GENERATE_RECIPE_CANDIDATES":
|
||||
return this.generateRecipeCandidates(parsedInput.data as TaskInput<"GENERATE_RECIPE_CANDIDATES">, options) as Promise<AamosResult<T>>;
|
||||
default:
|
||||
return {
|
||||
status: "failed",
|
||||
@@ -292,6 +295,168 @@ export class GeminiAamosClient implements AamosClient {
|
||||
};
|
||||
}
|
||||
|
||||
private async generateRecipeCandidates(
|
||||
input: TaskInput<"GENERATE_RECIPE_CANDIDATES">,
|
||||
options: AamosCallOptions,
|
||||
): Promise<AamosResult<"GENERATE_RECIPE_CANDIDATES">> {
|
||||
const locale = options.localeContext ?? this.defaultLocale();
|
||||
const started = Date.now();
|
||||
|
||||
// Estimate cost: ~0.003 USD per candidate (text-only, pessimistic)
|
||||
const totalCandidates = input.targetMatrix.reduce((sum, t) => sum + t.count, 0);
|
||||
const estimatedCostUsd = totalCandidates * 0.003;
|
||||
if (await this.isOverBudget(estimatedCostUsd)) {
|
||||
return {
|
||||
status: "failed",
|
||||
output: null,
|
||||
error: "Global Gemini-dagsbudget är förbrukad.",
|
||||
};
|
||||
}
|
||||
|
||||
const prompt = this.buildRecipeGenerationPrompt(input, locale);
|
||||
const payload = {
|
||||
contents: [{ parts: [{ text: prompt }] }],
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json",
|
||||
temperature: 0.3,
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${GEMINI_API_BASE}/models/${this.cfg.model}:generateContent?key=${this.cfg.apiKey}`;
|
||||
const res = await this.cfg.fetchImpl(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(this.cfg.timeoutMs),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
return {
|
||||
status: "failed",
|
||||
output: null,
|
||||
error: `Gemini ${res.status}: ${body.slice(0, 500)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const geminiBody = (await res.json()) as {
|
||||
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
|
||||
usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number };
|
||||
};
|
||||
const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
|
||||
|
||||
let parsed: z.infer<typeof generateRecipeCandidatesOutput>;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
const safe = generateRecipeCandidatesOutput.safeParse(json);
|
||||
if (!safe.success) {
|
||||
return { status: "failed", output: null, error: `Gemini-svar matchar inte schema: ${safe.error.message}` };
|
||||
}
|
||||
parsed = safe.data;
|
||||
} catch {
|
||||
return { status: "failed", output: null, error: "Gemini-svar var inte giltig JSON." };
|
||||
}
|
||||
|
||||
const inputTokens = geminiBody.usageMetadata?.promptTokenCount ?? 0;
|
||||
const outputTokens = geminiBody.usageMetadata?.candidatesTokenCount ?? 0;
|
||||
const costUsd = this.estimateCostUsd(inputTokens, outputTokens);
|
||||
await this.recordSpend(costUsd);
|
||||
|
||||
const latencyMs = Date.now() - started;
|
||||
|
||||
return {
|
||||
status: parsed.candidates.length > 0 ? "ok" : "uncertain",
|
||||
output: parsed as TaskOutput<"GENERATE_RECIPE_CANDIDATES">,
|
||||
modelVersion: this.cfg.model,
|
||||
promptVersion: "gemini-recipe-gen-v1",
|
||||
latencyMs,
|
||||
costUsd,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
};
|
||||
}
|
||||
|
||||
private buildRecipeGenerationPrompt(
|
||||
input: TaskInput<"GENERATE_RECIPE_CANDIDATES">,
|
||||
locale: LocaleContext,
|
||||
): string {
|
||||
const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
|
||||
const catalog = input.canonicalIngredientsCatalog.map((i) =>
|
||||
`- ${i.id} (${i.nameSv}, ${i.category}, enhet: ${i.defaultUnit}, vegan: ${i.isVegan}, veg: ${i.isVegetarian}, gluten: ${i.containsGluten}, laktos: ${i.containsLactose})`
|
||||
).join("\n");
|
||||
|
||||
const targets = input.targetMatrix.map((t) =>
|
||||
`- ${t.mealType} × ${t.mainIngredientId} × ${t.dietVariant}: ${t.count} st`
|
||||
).join("\n");
|
||||
|
||||
const constraints = input.constraints;
|
||||
|
||||
return `Du är en erfaren svensk matskribent som skriver vardagsrecept för svenska hushåll.
|
||||
|
||||
VIKTIGAST: Använd ENDAST ingredienser från katalogen nedan. Hitta ALDRIG på ingredienser som inte finns i listan. Om ett recept behöver något som saknas, hoppa över det receptet och lägg till en notering i rejectedPrompts.
|
||||
|
||||
Tillåtna ingredienser (canonical_ingredients):
|
||||
${catalog}
|
||||
|
||||
Önskade recept:
|
||||
${targets}
|
||||
|
||||
Begränsningar:
|
||||
- max förberedelsetid: ${constraints.maxPrepTimeMinutes ?? 60} min
|
||||
- max koktid: ${constraints.maxCookTimeMinutes ?? 45} min
|
||||
- portioner: ${constraints.portions ?? 4}
|
||||
- max kryddnivå: ${constraints.spiceLevelMax ?? 3}
|
||||
- undvik: ${constraints.avoidIngredients.join(", ") || "(ingen)"}
|
||||
|
||||
Regler:
|
||||
- Recepten ska vara realistiska vardagsrätter för svenska hushåll.
|
||||
- Stegen ska vara tydliga, med rimliga tider och temperaturer.
|
||||
- Använd svenska mått (g, dl, msk, tsk, st).
|
||||
- Formuleringen ska vara mjölkprincips-vänlig: ingen svinnskam, inget "släng".
|
||||
- Varje ingrediens MÅSTE finnas i katalogen ovan.
|
||||
- Svara ENDAST med giltig JSON i exakt detta format:
|
||||
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"titleSv": "...",
|
||||
"descriptionSv": "...",
|
||||
"cuisine": "swedish",
|
||||
"mealTypes": ["dinner"],
|
||||
"prepTimeMinutes": 15,
|
||||
"cookTimeMinutes": 30,
|
||||
"portions": 4,
|
||||
"spiceLevel": 1,
|
||||
"ingredients": [
|
||||
{
|
||||
"canonicalIngredientId": "kycklingfile",
|
||||
"displayNameSv": "kycklingfilé",
|
||||
"quantity": 500,
|
||||
"unit": "GRAM",
|
||||
"optional": false,
|
||||
"note": null
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"instructionSv": "...",
|
||||
"timerSeconds": null,
|
||||
"temperatureC": null,
|
||||
"tip": null
|
||||
}
|
||||
],
|
||||
"storageGuidanceSv": "...",
|
||||
"mealPrepFriendly": false,
|
||||
"freezerFriendly": false,
|
||||
"confidence": 0.95
|
||||
}
|
||||
],
|
||||
"rejectedPrompts": []
|
||||
}
|
||||
|
||||
Språk: ${lang}.`;
|
||||
}
|
||||
|
||||
private buildFridgePrompt(
|
||||
taskType: "ANALYZE_FRIDGE_IMAGE" | "ANALYZE_PANTRY_IMAGE",
|
||||
locale: LocaleContext,
|
||||
|
||||
@@ -217,6 +217,59 @@ export function mockOutputFor(taskType: AamosTaskType, input: unknown): unknown
|
||||
],
|
||||
};
|
||||
|
||||
case "GENERATE_RECIPE_CANDIDATES": {
|
||||
// Deterministisk mock: returnerar en enkel kandidat per target-cell
|
||||
const inp = input as {
|
||||
targetMatrix?: Array<{ mealType: string; mainIngredientId: string; dietVariant: string; count: number }>;
|
||||
canonicalIngredientsCatalog?: Array<{ id: string; nameSv: string; category: string; defaultUnit: string }>;
|
||||
};
|
||||
const targets = inp.targetMatrix ?? [];
|
||||
const catalog = inp.canonicalIngredientsCatalog ?? [];
|
||||
const candidates = [];
|
||||
for (const t of targets) {
|
||||
const mainIng = catalog.find((c) => c.id === t.mainIngredientId);
|
||||
for (let i = 0; i < Math.min(t.count, 2); i++) {
|
||||
candidates.push({
|
||||
titleSv: `Mock-${mainIng?.nameSv ?? t.mainIngredientId} ${t.dietVariant} ${i + 1}`,
|
||||
descriptionSv: `Ett enkelt vardagsrecept med ${mainIng?.nameSv ?? t.mainIngredientId}.`,
|
||||
cuisine: "swedish",
|
||||
mealTypes: [t.mealType],
|
||||
prepTimeMinutes: 10,
|
||||
cookTimeMinutes: 20,
|
||||
portions: 4,
|
||||
spiceLevel: 1,
|
||||
ingredients: [
|
||||
{
|
||||
canonicalIngredientId: t.mainIngredientId,
|
||||
displayNameSv: mainIng?.nameSv ?? t.mainIngredientId,
|
||||
quantity: 500,
|
||||
unit: mainIng?.defaultUnit ?? "GRAM",
|
||||
optional: false,
|
||||
note: null,
|
||||
},
|
||||
{
|
||||
canonicalIngredientId: "onion_yellow",
|
||||
displayNameSv: "gul lök",
|
||||
quantity: 2,
|
||||
unit: "COUNT",
|
||||
optional: false,
|
||||
note: null,
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{ instructionSv: "Förbered ingredienserna.", timerSeconds: null, temperatureC: null, tip: null },
|
||||
{ instructionSv: "Stek och låt koka klart.", timerSeconds: 900, temperatureC: null, tip: null },
|
||||
],
|
||||
storageGuidanceSv: "Förvara i kylskåp upp till 3 dagar.",
|
||||
mealPrepFriendly: false,
|
||||
freezerFriendly: false,
|
||||
confidence: 0.85,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { candidates, rejectedPrompts: [] };
|
||||
}
|
||||
|
||||
case "RANK_RECIPES": {
|
||||
const ids =
|
||||
typeof input === "object" && input !== null
|
||||
|
||||
@@ -18,6 +18,7 @@ export const AAMOS_TASK_TYPES = [
|
||||
"DEDUPLICATE_INVENTORY",
|
||||
"STRUCTURE_RECIPE_TEXT",
|
||||
"GENERATE_RECIPE_OPTIONS",
|
||||
"GENERATE_RECIPE_CANDIDATES",
|
||||
"RANK_RECIPES",
|
||||
"PARSE_CRAVING",
|
||||
"UPDATE_USER_MEMORY",
|
||||
@@ -274,6 +275,82 @@ export const generateRecipeOptionsOutput = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GENERATE_RECIPE_CANDIDATES (docs/32): AI-genererade receptkandidater
|
||||
// konstruerade mot BEFINTLIGA canonical_ingredients. Ingen påhittad
|
||||
// ingrediens. Näring räknas ALDRIG här – det gör nutrition-engine.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const generateRecipeCandidatesInput = z.object({
|
||||
targetMatrix: z.array(
|
||||
z.object({
|
||||
mealType: z.string(),
|
||||
mainIngredientId: z.string(),
|
||||
dietVariant: z.enum(["standard", "vegetarian", "vegan", "gluten_free", "lactose_free"]),
|
||||
count: z.number().int().min(1).max(10),
|
||||
}),
|
||||
).min(1).max(20),
|
||||
canonicalIngredientsCatalog: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
nameSv: z.string(),
|
||||
category: z.string(),
|
||||
defaultUnit: unitSchema,
|
||||
isVegan: z.boolean(),
|
||||
isVegetarian: z.boolean(),
|
||||
containsGluten: z.boolean(),
|
||||
containsLactose: z.boolean(),
|
||||
allergens: z.array(z.string()),
|
||||
}),
|
||||
).min(1),
|
||||
constraints: z.object({
|
||||
maxPrepTimeMinutes: z.number().int().nullable().default(60),
|
||||
maxCookTimeMinutes: z.number().int().nullable().default(45),
|
||||
portions: z.number().int().default(4),
|
||||
spiceLevelMax: z.number().int().nullable().default(3),
|
||||
avoidIngredients: z.array(z.string()).default([]),
|
||||
}).default(() => ({ maxPrepTimeMinutes: 60, maxCookTimeMinutes: 45, portions: 4, spiceLevelMax: 3, avoidIngredients: [] })),
|
||||
marketLocale: z.string().default("sv-SE"),
|
||||
});
|
||||
|
||||
export const generateRecipeCandidatesOutput = z.object({
|
||||
candidates: z.array(
|
||||
z.object({
|
||||
titleSv: z.string(),
|
||||
descriptionSv: z.string(),
|
||||
cuisine: z.string().nullable(),
|
||||
mealTypes: z.array(z.string()),
|
||||
prepTimeMinutes: z.number().int(),
|
||||
cookTimeMinutes: z.number().int(),
|
||||
portions: z.number().int(),
|
||||
spiceLevel: z.number().int(),
|
||||
ingredients: z.array(
|
||||
z.object({
|
||||
canonicalIngredientId: z.string(),
|
||||
displayNameSv: z.string(),
|
||||
quantity: z.number(),
|
||||
unit: unitSchema,
|
||||
optional: z.boolean().default(false),
|
||||
note: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
steps: z.array(
|
||||
z.object({
|
||||
instructionSv: z.string(),
|
||||
timerSeconds: z.number().nullable(),
|
||||
temperatureC: z.number().nullable(),
|
||||
tip: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
storageGuidanceSv: z.string().nullable(),
|
||||
mealPrepFriendly: z.boolean().default(false),
|
||||
freezerFriendly: z.boolean().default(false),
|
||||
confidence: z.number().min(0).max(1),
|
||||
}),
|
||||
),
|
||||
rejectedPrompts: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const rankRecipesInput = z.object({
|
||||
candidateIds: z.array(z.string()).max(50),
|
||||
deterministicScores: z.record(z.string(), z.number()),
|
||||
@@ -454,6 +531,10 @@ export const TASK_CONTRACTS = {
|
||||
input: generateRecipeOptionsInput,
|
||||
output: generateRecipeOptionsOutput,
|
||||
},
|
||||
GENERATE_RECIPE_CANDIDATES: {
|
||||
input: generateRecipeCandidatesInput,
|
||||
output: generateRecipeCandidatesOutput,
|
||||
},
|
||||
RANK_RECIPES: { input: rankRecipesInput, output: rankRecipesOutput },
|
||||
PARSE_CRAVING: { input: parseCravingInput, output: parseCravingOutput },
|
||||
UPDATE_USER_MEMORY: { input: updateUserMemoryInput, output: updateUserMemoryOutput },
|
||||
|
||||
Reference in New Issue
Block a user