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:
Sven (AAMOS AI)
2026-08-09 19:47:03 +07:00
parent 9197d93a92
commit 4b7dec2759
15 changed files with 1554 additions and 0 deletions
+165
View File
@@ -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,
+53
View File
@@ -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
+81
View File
@@ -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 },