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 },
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@app/recipe-generation",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AI-assisterad receptgenerering med verifieringspipeline för Cibello (docs/32)",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./pipeline": "./src/pipeline.ts",
|
||||
"./verification": "./src/verification.ts",
|
||||
"./gap-report": "./src/gap-report.ts",
|
||||
"./gemini-recipe": "./src/gemini-recipe.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@app/ai-contracts": "workspace:*",
|
||||
"@app/database": "workspace:*",
|
||||
"@app/nutrition-engine": "workspace:*",
|
||||
"@app/recipe-engine": "workspace:*",
|
||||
"@app/shared-types": "workspace:*",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"zod": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* PILOTBATCH – Receptkatalog Fas A (docs/32).
|
||||
*
|
||||
* Genererar ~20–30 recept över en skiva av matrisen:
|
||||
* middag × mejeri/kyckling/köttfärs × allätare+veg
|
||||
*
|
||||
* Använder Gemini i staging/dev (AAMOS_MODE=gemini) eller mock (AAMOS_MODE=mock).
|
||||
* Ingen påfyllning i prod. Resultatet skrivs till stdout som JSON + markdown.
|
||||
*
|
||||
* Körning:
|
||||
* AAMOS_MODE=mock pnpm tsx packages/recipe-generation/scripts/pilot-batch.ts
|
||||
* AAMOS_MODE=gemini GEMINI_API_KEY=... pnpm tsx packages/recipe-generation/scripts/pilot-batch.ts
|
||||
*/
|
||||
|
||||
import { createAamosClient } from "@app/ai-contracts";
|
||||
import { SEED_INGREDIENTS } from "@app/database/seed";
|
||||
import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation";
|
||||
import type { CanonicalIngredientLookup, SimilarityLookup } from "@app/recipe-generation";
|
||||
|
||||
// ── 1. Bygg katalog från seed-data ─────────────────────────────────────────
|
||||
const catalog: PipelineIngredient[] = SEED_INGREDIENTS.map((i) => ({
|
||||
id: i.id,
|
||||
nameSv: i.nameSv,
|
||||
category: i.category,
|
||||
defaultUnit: i.defaultUnit,
|
||||
isVegan: i.isVegan,
|
||||
isVegetarian: i.isVegetarian,
|
||||
containsGluten: i.containsGluten,
|
||||
containsLactose: i.containsLactose,
|
||||
allergens: i.allergens,
|
||||
}));
|
||||
|
||||
// ── 2. Bygg lookup för verifiering ─────────────────────────────────────────
|
||||
const ingredientLookup: CanonicalIngredientLookup = {
|
||||
getById(id: string) {
|
||||
const ing = SEED_INGREDIENTS.find((i) => i.id === id);
|
||||
if (!ing) return undefined;
|
||||
return {
|
||||
id: ing.id,
|
||||
nutritionPer100: ing.nutritionPer100,
|
||||
defaultUnit: ing.defaultUnit,
|
||||
densityGPerMl: ing.densityGPerMl ?? null,
|
||||
gramsPerPiece: ing.gramsPerPiece ?? null,
|
||||
allergens: ing.allergens,
|
||||
isVegan: ing.isVegan,
|
||||
isVegetarian: ing.isVegetarian,
|
||||
containsGluten: ing.containsGluten,
|
||||
containsLactose: ing.containsLactose,
|
||||
isPork: ing.isPork,
|
||||
isBeef: ing.isBeef,
|
||||
isAlcohol: ing.isAlcohol,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ── 3. Dedup-lookup (enkel, baserad på titel) ──────────────────────────────
|
||||
const knownTitles = new Set<string>();
|
||||
const similarityLookup: SimilarityLookup = {
|
||||
async hasSimilarity(title: string) {
|
||||
if (knownTitles.has(title.toLowerCase())) return true;
|
||||
knownTitles.add(title.toLowerCase());
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
// ── 4. Definiera pilot-matris ──────────────────────────────────────────────
|
||||
const targets: PipelineTarget[] = [
|
||||
// Mejeri × allätare
|
||||
{ mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "standard", count: 3 },
|
||||
{ mealType: "dinner", mainIngredientId: "cream", dietVariant: "standard", count: 2 },
|
||||
// Kyckling × allätare
|
||||
{ mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "standard", count: 4 },
|
||||
{ mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 2 },
|
||||
// Köttfärs × allätare
|
||||
{ mealType: "dinner", mainIngredientId: "ground_beef", dietVariant: "standard", count: 3 },
|
||||
{ mealType: "dinner", mainIngredientId: "ground_pork", dietVariant: "standard", count: 2 },
|
||||
// Veg-varianter
|
||||
{ mealType: "dinner", mainIngredientId: "tofu_firm", dietVariant: "vegan", count: 3 },
|
||||
{ mealType: "dinner", mainIngredientId: "lentils_red", dietVariant: "vegetarian", count: 2 },
|
||||
{ mealType: "dinner", mainIngredientId: "chickpeas", dietVariant: "vegan", count: 2 },
|
||||
];
|
||||
|
||||
// ── 5. Kör pipelinen ───────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
const client = createAamosClient(process.env);
|
||||
console.error("[pilot-batch] Startar generering...");
|
||||
console.error(`[pilot-batch] Katalog: ${catalog.length} ingredienser`);
|
||||
console.error(`[pilot-batch] Mål: ${targets.length} matris-celler, ~${targets.reduce((s, t) => s + t.count, 0)} recept`);
|
||||
|
||||
const result = await runPipeline(
|
||||
client,
|
||||
targets,
|
||||
catalog,
|
||||
ingredientLookup,
|
||||
similarityLookup,
|
||||
{ maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 },
|
||||
);
|
||||
|
||||
// ── 6. Rapportera ────────────────────────────────────────────────────────
|
||||
const report = {
|
||||
batchId: `pilot-${Date.now()}`,
|
||||
generatedAt: new Date().toISOString(),
|
||||
targetMatrix: targets,
|
||||
geminiStatus: result.geminiResult.status,
|
||||
geminiCostUsd: result.geminiResult.costUsd ?? 0,
|
||||
candidatesGenerated: result.candidates.length,
|
||||
verifiedCount: result.verifiedCount,
|
||||
unverifiedCount: result.unverifiedCount,
|
||||
rejectedCount: result.rejectedCount,
|
||||
candidates: result.verificationResults.map((r) => ({
|
||||
title: r.candidate.titleSv,
|
||||
status: r.status,
|
||||
reasons: r.reasons,
|
||||
allergens: r.allergens,
|
||||
nutritionPerPortion: r.nutritionPerPortion,
|
||||
ingredients: r.candidate.ingredients.map((i) => i.canonicalIngredientId),
|
||||
})),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
|
||||
// Markdown-sammanfattning till stderr
|
||||
console.error("\n# Pilotbatch-sammanfattning\n");
|
||||
console.error(`- Genererade kandidater: ${result.candidates.length}`);
|
||||
console.error(`- Verified: ${result.verifiedCount}`);
|
||||
console.error(`- Unverified: ${result.unverifiedCount}`);
|
||||
console.error(`- Rejected: ${result.rejectedCount}`);
|
||||
console.error(`- Gemini-kostnad: $${(result.geminiResult.costUsd ?? 0).toFixed(4)}`);
|
||||
|
||||
if (result.geminiResult.error) {
|
||||
console.error(`\n**Fel:** ${result.geminiResult.error}`);
|
||||
}
|
||||
|
||||
if (result.candidates.length === 0 && result.geminiResult.status === "ok") {
|
||||
console.error("\n**Notering:** Gemini returnerade ok men inga kandidater. " +
|
||||
"Mock-klienten stödjer inte GENERATE_RECIPE_CANDIDATES ännu — " +
|
||||
"kör med AAMOS_MODE=gemini för live-generering.");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[pilot-batch] Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Katalog-gaprapport (docs/32 §4).
|
||||
*
|
||||
* Byggs ur consent-gatad nollträff-bankning + cook/rating-signaler.
|
||||
* Identifierar vad som saknas i receptkatalogen så påfyllningen kan
|
||||
* styras mot verklig efterfrågan.
|
||||
*/
|
||||
|
||||
import type { GapReport, GapReportEntry } from "./types.js";
|
||||
|
||||
export interface AnalyticsEvent {
|
||||
eventName: string;
|
||||
occurredAt: Date;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RecipeSignal {
|
||||
recipeId: string | null;
|
||||
titleSv: string | null;
|
||||
cookCount: number;
|
||||
ratingAverage: number | null;
|
||||
ratingCount: number;
|
||||
searchQuery?: string | null;
|
||||
}
|
||||
|
||||
export interface GapReportOptions {
|
||||
/** Minsta antal nollträffar för att en sökning ska räknas. */
|
||||
minMissThreshold?: number;
|
||||
/** Hur långt bak i tiden (dagar). */
|
||||
lookbackDays?: number;
|
||||
/** Max antal entries i rapporten. */
|
||||
maxEntries?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_GAP_OPTIONS: Required<GapReportOptions> = {
|
||||
minMissThreshold: 2,
|
||||
lookbackDays: 30,
|
||||
maxEntries: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* Bygg gaprapport ur analytics-events och receptsignaler.
|
||||
*
|
||||
* @param searchMissEvents productAnalyticsEvents med eventName = "recipe_search_zero_results"
|
||||
* @param cookSignals recipe_cooks aggregerade per recept
|
||||
* @param ratingSignals recipe_ratings aggregerade per recept
|
||||
* @param options Filter och begränsningar
|
||||
*/
|
||||
export function buildGapReport(
|
||||
searchMissEvents: AnalyticsEvent[],
|
||||
cookSignals: RecipeSignal[],
|
||||
ratingSignals: RecipeSignal[],
|
||||
options: GapReportOptions = {},
|
||||
): GapReport {
|
||||
const opts = { ...DEFAULT_GAP_OPTIONS, ...options };
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - opts.lookbackDays);
|
||||
|
||||
// ── 1. Samla nollträffar ─────────────────────────────────────────────────
|
||||
const missMap = new Map<string, { count: number; lastAt: Date; queries: Set<string> }>();
|
||||
|
||||
for (const ev of searchMissEvents) {
|
||||
if (ev.occurredAt < cutoff) continue;
|
||||
|
||||
const query = String(ev.properties?.query ?? "").toLowerCase().trim();
|
||||
if (!query) continue;
|
||||
|
||||
const ingredientId = ev.properties?.suggestedIngredientId
|
||||
? String(ev.properties.suggestedIngredientId)
|
||||
: null;
|
||||
const mealType = ev.properties?.mealType
|
||||
? String(ev.properties.mealType)
|
||||
: null;
|
||||
|
||||
const key = `${query}::${ingredientId ?? "_"}::${mealType ?? "_"}`;
|
||||
const existing = missMap.get(key);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
existing.queries.add(query);
|
||||
if (ev.occurredAt > existing.lastAt) existing.lastAt = ev.occurredAt;
|
||||
} else {
|
||||
missMap.set(key, {
|
||||
count: 1,
|
||||
lastAt: ev.occurredAt,
|
||||
queries: new Set([query]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Filtrera och rangordna ────────────────────────────────────────────
|
||||
const entries: GapReportEntry[] = [];
|
||||
|
||||
for (const [key, data] of missMap) {
|
||||
if (data.count < opts.minMissThreshold) continue;
|
||||
|
||||
const parts = key.split("::");
|
||||
const ingredientId = parts[1] ?? null;
|
||||
const mealType = parts[2] ?? null;
|
||||
|
||||
entries.push({
|
||||
searchQuery: [...data.queries][0] ?? "",
|
||||
missCount: data.count,
|
||||
lastMissedAt: data.lastAt,
|
||||
suggestedIngredientId: ingredientId === "_" ? null : ingredientId,
|
||||
suggestedMealType: mealType === "_" ? null : mealType,
|
||||
priority: data.count >= 10 ? "high" : data.count >= 5 ? "medium" : "low",
|
||||
});
|
||||
}
|
||||
|
||||
// Sortera: high → medium → low, sedan antal
|
||||
entries.sort((a, b) => {
|
||||
const pOrder = { high: 0, medium: 1, low: 2 };
|
||||
if (pOrder[a.priority] !== pOrder[b.priority]) {
|
||||
return pOrder[a.priority] - pOrder[b.priority];
|
||||
}
|
||||
return b.missCount - a.missCount;
|
||||
});
|
||||
|
||||
const limitedEntries = entries.slice(0, opts.maxEntries);
|
||||
|
||||
// ── 3. Aggregera topp-saknade ────────────────────────────────────────────
|
||||
const ingredientCounts = new Map<string | null, number>();
|
||||
const mealTypeCounts = new Map<string | null, number>();
|
||||
|
||||
for (const e of limitedEntries) {
|
||||
ingredientCounts.set(e.suggestedIngredientId, (ingredientCounts.get(e.suggestedIngredientId) ?? 0) + e.missCount);
|
||||
mealTypeCounts.set(e.suggestedMealType, (mealTypeCounts.get(e.suggestedMealType) ?? 0) + e.missCount);
|
||||
}
|
||||
|
||||
const topMissingIngredients = [...ingredientCounts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([id, count]) => ({ canonicalIngredientId: id as string | null, count }));
|
||||
|
||||
const topMissingMealTypes = [...mealTypeCounts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([mealType, count]) => ({ mealType: mealType as string | null, count }));
|
||||
|
||||
return {
|
||||
generatedAt: new Date(),
|
||||
entries: limitedEntries,
|
||||
topMissingIngredients,
|
||||
topMissingMealTypes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatera gaprapporten som läsbar text för granskning.
|
||||
*/
|
||||
export function formatGapReport(report: GapReport): string {
|
||||
const lines: string[] = [
|
||||
`# Katalog-gaprapport`,
|
||||
`Genererad: ${report.generatedAt.toISOString()}`,
|
||||
``,
|
||||
`## Topp-saknade ingredienser`,
|
||||
...report.topMissingIngredients.map(
|
||||
(i) => `- ${i.canonicalIngredientId ?? "(okänd)"}: ${i.count} nollträffar`,
|
||||
),
|
||||
``,
|
||||
`## Topp-saknade måltidstyper`,
|
||||
...report.topMissingMealTypes.map(
|
||||
(m) => `- ${m.mealType ?? "(okänd)"}: ${m.count} nollträffar`,
|
||||
),
|
||||
``,
|
||||
`## Detaljer (${report.entries.length} entries)`,
|
||||
...report.entries.map(
|
||||
(e) =>
|
||||
`- [${e.priority.toUpperCase()}] "${e.searchQuery}" — ${e.missCount} missar, senast ${e.lastMissedAt.toISOString().slice(0, 10)}${e.suggestedIngredientId ? ` (ingrediens: ${e.suggestedIngredientId})` : ""}${e.suggestedMealType ? ` (måltid: ${e.suggestedMealType})` : ""}`,
|
||||
),
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./types.js";
|
||||
export * from "./verification.js";
|
||||
export * from "./pipeline.js";
|
||||
export * from "./gap-report.js";
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Genereringspipeline för receptkandidater (docs/32 §2–3).
|
||||
*
|
||||
* - Gemini genererar svenska vardagsrecept KONSTRUERADE mot BEFINTLIGA
|
||||
* canonical_ingredients.
|
||||
* - Hitta aldrig på ingredienser utanför katalogen.
|
||||
* - Varje kandidat passerar verifieringspipelinen.
|
||||
* - Ingen kandidat blir verified utan att passera alla grindar.
|
||||
*/
|
||||
|
||||
import type { AamosClient, AamosResult } from "@app/ai-contracts";
|
||||
import type {
|
||||
TaskInput,
|
||||
TaskOutput,
|
||||
} from "@app/ai-contracts";
|
||||
import type { RecipeCandidate, VerificationResult } from "./types.js";
|
||||
import { verifyCandidate, type CanonicalIngredientLookup, type SimilarityLookup } from "./verification.js";
|
||||
|
||||
export interface PipelineIngredient {
|
||||
id: string;
|
||||
nameSv: string;
|
||||
category: string;
|
||||
defaultUnit: string;
|
||||
isVegan: boolean;
|
||||
isVegetarian: boolean;
|
||||
containsGluten: boolean;
|
||||
containsLactose: boolean;
|
||||
allergens: string[];
|
||||
}
|
||||
|
||||
export interface PipelineTarget {
|
||||
mealType: string;
|
||||
mainIngredientId: string;
|
||||
dietVariant: "standard" | "vegetarian" | "vegan" | "gluten_free" | "lactose_free";
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PipelineOptions {
|
||||
/** Max förberedelsetid i minuter. */
|
||||
maxPrepTimeMinutes?: number;
|
||||
/** Max koktid i minuter. */
|
||||
maxCookTimeMinutes?: number;
|
||||
/** Antal portioner. */
|
||||
portions?: number;
|
||||
/** Max kryddnivå. */
|
||||
spiceLevelMax?: number;
|
||||
/** Ingredienser att undvika. */
|
||||
avoidIngredients?: string[];
|
||||
/** Locale, default sv-SE. */
|
||||
marketLocale?: string;
|
||||
}
|
||||
|
||||
export interface PipelineResult {
|
||||
candidates: RecipeCandidate[];
|
||||
verificationResults: VerificationResult[];
|
||||
verifiedCount: number;
|
||||
unverifiedCount: number;
|
||||
rejectedCount: number;
|
||||
geminiResult: AamosResult<"GENERATE_RECIPE_CANDIDATES">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kör genereringspipelinen: Gemini → verifiering.
|
||||
*
|
||||
* @param client AamosClient (Gemini, mock, eller HTTP)
|
||||
* @param targets Vad som ska genereras
|
||||
* @param catalog Tillgängliga canonical_ingredients
|
||||
* @param ingredients Lookup för verifiering
|
||||
* @param similarity Lookup för dedup
|
||||
* @param options Begränsningar
|
||||
*/
|
||||
export async function runPipeline(
|
||||
client: AamosClient,
|
||||
targets: PipelineTarget[],
|
||||
catalog: PipelineIngredient[],
|
||||
ingredients: CanonicalIngredientLookup,
|
||||
similarity: SimilarityLookup | null,
|
||||
options: PipelineOptions = {},
|
||||
): Promise<PipelineResult> {
|
||||
const input: TaskInput<"GENERATE_RECIPE_CANDIDATES"> = {
|
||||
targetMatrix: targets.map((t) => ({
|
||||
mealType: t.mealType,
|
||||
mainIngredientId: t.mainIngredientId,
|
||||
dietVariant: t.dietVariant,
|
||||
count: t.count,
|
||||
})),
|
||||
canonicalIngredientsCatalog: catalog.map((i) => ({
|
||||
id: i.id,
|
||||
nameSv: i.nameSv,
|
||||
category: i.category,
|
||||
defaultUnit: i.defaultUnit as import("@app/shared-types").Unit,
|
||||
isVegan: i.isVegan,
|
||||
isVegetarian: i.isVegetarian,
|
||||
containsGluten: i.containsGluten,
|
||||
containsLactose: i.containsLactose,
|
||||
allergens: i.allergens,
|
||||
})),
|
||||
constraints: {
|
||||
maxPrepTimeMinutes: options.maxPrepTimeMinutes ?? 60,
|
||||
maxCookTimeMinutes: options.maxCookTimeMinutes ?? 45,
|
||||
portions: options.portions ?? 4,
|
||||
spiceLevelMax: options.spiceLevelMax ?? 3,
|
||||
avoidIngredients: options.avoidIngredients ?? [],
|
||||
},
|
||||
marketLocale: options.marketLocale ?? "sv-SE",
|
||||
};
|
||||
|
||||
const geminiResult = await client.runTask("GENERATE_RECIPE_CANDIDATES", input);
|
||||
|
||||
if (geminiResult.status !== "ok" || !geminiResult.output) {
|
||||
return {
|
||||
candidates: [],
|
||||
verificationResults: [],
|
||||
verifiedCount: 0,
|
||||
unverifiedCount: 0,
|
||||
rejectedCount: 0,
|
||||
geminiResult,
|
||||
};
|
||||
}
|
||||
|
||||
const output = geminiResult.output as TaskOutput<"GENERATE_RECIPE_CANDIDATES">;
|
||||
|
||||
// Mappa Gemini-output till interna typer
|
||||
const candidates: RecipeCandidate[] = output.candidates.map((c, idx) => ({
|
||||
titleSv: c.titleSv,
|
||||
descriptionSv: c.descriptionSv,
|
||||
cuisine: c.cuisine,
|
||||
mealTypes: c.mealTypes,
|
||||
prepTimeMinutes: c.prepTimeMinutes,
|
||||
cookTimeMinutes: c.cookTimeMinutes,
|
||||
totalTimeMinutes: c.prepTimeMinutes + c.cookTimeMinutes,
|
||||
portions: c.portions,
|
||||
spiceLevel: c.spiceLevel,
|
||||
ingredients: c.ingredients.map((ing) => ({
|
||||
canonicalIngredientId: ing.canonicalIngredientId,
|
||||
displayNameSv: ing.displayNameSv,
|
||||
quantity: ing.quantity,
|
||||
unit: ing.unit,
|
||||
optional: ing.optional,
|
||||
note: ing.note,
|
||||
})),
|
||||
steps: c.steps.map((s, sIdx) => ({
|
||||
stepNumber: sIdx + 1,
|
||||
instructionSv: s.instructionSv,
|
||||
timerSeconds: s.timerSeconds,
|
||||
temperatureC: s.temperatureC,
|
||||
tip: s.tip,
|
||||
})),
|
||||
storageGuidanceSv: c.storageGuidanceSv,
|
||||
mealPrepFriendly: c.mealPrepFriendly,
|
||||
freezerFriendly: c.freezerFriendly,
|
||||
sourceType: "ai_generated" as const,
|
||||
confidence: c.confidence,
|
||||
}));
|
||||
|
||||
// Kör verifieringspipelinen
|
||||
const verificationResults = await Promise.all(
|
||||
candidates.map((c) => verifyCandidate(c, ingredients, similarity)),
|
||||
);
|
||||
|
||||
const verifiedCount = verificationResults.filter((r) => r.status === "verified").length;
|
||||
const unverifiedCount = verificationResults.filter((r) => r.status === "unverified").length;
|
||||
const rejectedCount = verificationResults.filter((r) => r.status === "rejected").length;
|
||||
|
||||
return {
|
||||
candidates,
|
||||
verificationResults,
|
||||
verifiedCount,
|
||||
unverifiedCount,
|
||||
rejectedCount,
|
||||
geminiResult,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Typer för AI-assisterad receptgenerering (docs/32).
|
||||
* Ingen PII. Alla receptkandidater passerar verifieringspipelinen.
|
||||
*/
|
||||
|
||||
export interface RecipeCandidate {
|
||||
titleSv: string;
|
||||
descriptionSv: string;
|
||||
cuisine: string | null;
|
||||
mealTypes: string[];
|
||||
prepTimeMinutes: number;
|
||||
cookTimeMinutes: number;
|
||||
totalTimeMinutes: number;
|
||||
portions: number;
|
||||
spiceLevel: number;
|
||||
ingredients: RecipeCandidateIngredient[];
|
||||
steps: RecipeCandidateStep[];
|
||||
storageGuidanceSv: string | null;
|
||||
mealPrepFriendly: boolean;
|
||||
freezerFriendly: boolean;
|
||||
sourceType: "ai_generated";
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface RecipeCandidateIngredient {
|
||||
canonicalIngredientId: string;
|
||||
displayNameSv: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
optional: boolean;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface RecipeCandidateStep {
|
||||
stepNumber: number;
|
||||
instructionSv: string;
|
||||
timerSeconds: number | null;
|
||||
temperatureC: number | null;
|
||||
tip: string | null;
|
||||
}
|
||||
|
||||
export interface VerificationResult {
|
||||
candidate: RecipeCandidate;
|
||||
status: "verified" | "unverified" | "rejected";
|
||||
reasons: string[];
|
||||
nutritionPerPortion: import("@app/shared-types").NutritionValues | null;
|
||||
allergens: string[];
|
||||
canonicalIngredientIds: string[];
|
||||
}
|
||||
|
||||
export interface GapReportEntry {
|
||||
searchQuery: string;
|
||||
missCount: number;
|
||||
lastMissedAt: Date;
|
||||
suggestedIngredientId: string | null;
|
||||
suggestedMealType: string | null;
|
||||
priority: "high" | "medium" | "low";
|
||||
}
|
||||
|
||||
export interface GapReport {
|
||||
generatedAt: Date;
|
||||
entries: GapReportEntry[];
|
||||
topMissingIngredients: Array<{ canonicalIngredientId: string | null; count: number }>;
|
||||
topMissingMealTypes: Array<{ mealType: string | null; count: number }>;
|
||||
}
|
||||
|
||||
export interface PilotBatchResult {
|
||||
batchId: string;
|
||||
generatedAt: Date;
|
||||
targetMatrix: Array<{
|
||||
mealType: string;
|
||||
mainIngredientId: string;
|
||||
dietVariant: string;
|
||||
count: number;
|
||||
}>;
|
||||
candidates: VerificationResult[];
|
||||
verifiedCount: number;
|
||||
unverifiedCount: number;
|
||||
rejectedCount: number;
|
||||
totalGeminiCostUsd: number;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Verifieringspipeline för AI-genererade receptkandidater (docs/32 §3).
|
||||
*
|
||||
* Obligatorisk grind — inget blir verified utan att passera alla steg:
|
||||
* 1. Ingrediensmappning mot canonical_ingredients (hård spärr).
|
||||
* 2. Allergener HÄRLEDDA ur ingredienserna (aldrig AI-påstådda).
|
||||
* 3. Näring BERÄKNAD via nutrition-engine (aldrig AI-siffra).
|
||||
* 4. Språk-/kvalitetskontroll (rimliga steg/tider).
|
||||
* 5. Dedup mot recipe_similarities.
|
||||
*
|
||||
* Först då verificationStatus=verified; annars unverified & osynlig.
|
||||
*/
|
||||
|
||||
import {
|
||||
computeRecipeNutrition,
|
||||
type RecipeIngredientForCalc,
|
||||
type IngredientNutritionSource,
|
||||
} from "@app/nutrition-engine";
|
||||
import {
|
||||
deriveRecipeAllergens,
|
||||
type IngredientSafetyInfo,
|
||||
} from "@app/recipe-engine";
|
||||
import type { NutritionValues, Allergen } from "@app/shared-types";
|
||||
import type { RecipeCandidate, VerificationResult } from "./types.js";
|
||||
|
||||
export interface CanonicalIngredientLookup {
|
||||
getById(id: string):
|
||||
| {
|
||||
id: string;
|
||||
nutritionPer100: import("@app/shared-types").NutritionDeclaration;
|
||||
defaultUnit: import("@app/shared-types").Unit;
|
||||
densityGPerMl?: number | null;
|
||||
gramsPerPiece?: number | null;
|
||||
allergens: Allergen[];
|
||||
isVegan: boolean;
|
||||
isVegetarian: boolean;
|
||||
containsGluten: boolean;
|
||||
containsLactose: boolean;
|
||||
isPork: boolean;
|
||||
isBeef: boolean;
|
||||
isAlcohol: boolean;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export interface SimilarityLookup {
|
||||
hasSimilarity(recipeTitle: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface VerificationOptions {
|
||||
/** Max tillåten förberedelsetid (minuter). */
|
||||
maxPrepTimeMinutes?: number;
|
||||
/** Max tillåten koktid (minuter). */
|
||||
maxCookTimeMinutes?: number;
|
||||
/** Max total tid (minuter). */
|
||||
maxTotalTimeMinutes?: number;
|
||||
/** Minsta antal steg. */
|
||||
minSteps?: number;
|
||||
/** Minsta antal ingredienser. */
|
||||
minIngredients?: number;
|
||||
/** Om true, krävs att receptet har minst en icke-valfri protein-källa. */
|
||||
requireProtein?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<VerificationOptions> = {
|
||||
maxPrepTimeMinutes: 120,
|
||||
maxCookTimeMinutes: 180,
|
||||
maxTotalTimeMinutes: 240,
|
||||
minSteps: 2,
|
||||
minIngredients: 3,
|
||||
requireProtein: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Kör en kandidat genom verifieringspipelinen.
|
||||
* Returnerar alltid ett VerificationResult; status sätts beroende på om
|
||||
* alla grindar passerades.
|
||||
*/
|
||||
export async function verifyCandidate(
|
||||
candidate: RecipeCandidate,
|
||||
ingredients: CanonicalIngredientLookup,
|
||||
similarity: SimilarityLookup | null,
|
||||
options: VerificationOptions = {},
|
||||
): Promise<VerificationResult> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const reasons: string[] = [];
|
||||
|
||||
// ── 1. Ingrediensmappning (hård spärr) ───────────────────────────────────
|
||||
const canonicalIds: string[] = [];
|
||||
const nutritionSources = new Map<string, IngredientNutritionSource>();
|
||||
const safetyInfos = new Map<string, IngredientSafetyInfo>();
|
||||
|
||||
for (const ing of candidate.ingredients) {
|
||||
const canonical = ingredients.getById(ing.canonicalIngredientId);
|
||||
if (!canonical) {
|
||||
reasons.push(
|
||||
`Ingrediens "${ing.canonicalIngredientId}" finns inte i canonical_ingredients — hård spärr.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
canonicalIds.push(ing.canonicalIngredientId);
|
||||
|
||||
// Bygg nutrition source för steg 3
|
||||
nutritionSources.set(ing.canonicalIngredientId, {
|
||||
densityGPerMl: canonical.densityGPerMl ?? undefined,
|
||||
gramsPerPiece: canonical.gramsPerPiece ?? undefined,
|
||||
nutritionPer100: canonical.nutritionPer100,
|
||||
});
|
||||
|
||||
// Bygg safety info för steg 2
|
||||
safetyInfos.set(ing.canonicalIngredientId, {
|
||||
id: canonical.id,
|
||||
allergens: canonical.allergens,
|
||||
isVegan: canonical.isVegan,
|
||||
isVegetarian: canonical.isVegetarian,
|
||||
containsGluten: canonical.containsGluten,
|
||||
containsLactose: canonical.containsLactose,
|
||||
isPork: canonical.isPork,
|
||||
isBeef: canonical.isBeef,
|
||||
isAlcohol: canonical.isAlcohol,
|
||||
dataVerified: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Om någon ingrediens saknas → avvisat direkt
|
||||
if (reasons.length > 0) {
|
||||
return {
|
||||
candidate,
|
||||
status: "rejected",
|
||||
reasons,
|
||||
nutritionPerPortion: null,
|
||||
allergens: [],
|
||||
canonicalIngredientIds: canonicalIds,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 2. Allergener (härledda, aldrig AI) ──────────────────────────────────
|
||||
const allergens = deriveRecipeAllergens(canonicalIds, safetyInfos);
|
||||
|
||||
// ── 3. Näring (beräknad via nutrition-engine) ────────────────────────────
|
||||
const calcIngredients: RecipeIngredientForCalc[] = candidate.ingredients.map((ing) => ({
|
||||
canonicalIngredientId: ing.canonicalIngredientId,
|
||||
quantity: ing.quantity,
|
||||
unit: ing.unit as import("@app/shared-types").Unit,
|
||||
optional: ing.optional,
|
||||
}));
|
||||
|
||||
const nutritionResult = computeRecipeNutrition(
|
||||
calcIngredients,
|
||||
candidate.portions,
|
||||
nutritionSources,
|
||||
);
|
||||
|
||||
if (nutritionResult.uncomputableIngredientIds.length > 0) {
|
||||
reasons.push(
|
||||
`Näring kunde inte beräknas för: ${nutritionResult.uncomputableIngredientIds.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. Språk-/kvalitetskontroll ──────────────────────────────────────────
|
||||
if (candidate.prepTimeMinutes > opts.maxPrepTimeMinutes) {
|
||||
reasons.push(
|
||||
`Förberedelsetid ${candidate.prepTimeMinutes} min överstiger max ${opts.maxPrepTimeMinutes} min.`,
|
||||
);
|
||||
}
|
||||
if (candidate.cookTimeMinutes > opts.maxCookTimeMinutes) {
|
||||
reasons.push(
|
||||
`Koktid ${candidate.cookTimeMinutes} min överstiger max ${opts.maxCookTimeMinutes} min.`,
|
||||
);
|
||||
}
|
||||
const totalTime = candidate.prepTimeMinutes + candidate.cookTimeMinutes;
|
||||
if (totalTime > opts.maxTotalTimeMinutes) {
|
||||
reasons.push(`Total tid ${totalTime} min överstiger max ${opts.maxTotalTimeMinutes} min.`);
|
||||
}
|
||||
if (candidate.steps.length < opts.minSteps) {
|
||||
reasons.push(`Endast ${candidate.steps.length} steg — minst ${opts.minSteps} krävs.`);
|
||||
}
|
||||
if (candidate.ingredients.length < opts.minIngredients) {
|
||||
reasons.push(
|
||||
`Endast ${candidate.ingredients.length} ingredienser — minst ${opts.minIngredients} krävs.`,
|
||||
);
|
||||
}
|
||||
if (candidate.totalTimeMinutes !== totalTime) {
|
||||
reasons.push(
|
||||
`totalTimeMinutes (${candidate.totalTimeMinutes}) matchar inte prep+cook (${totalTime}).`,
|
||||
);
|
||||
}
|
||||
|
||||
// Kontrollera att stegen är rimliga
|
||||
for (const step of candidate.steps) {
|
||||
if (!step.instructionSv || step.instructionSv.length < 10) {
|
||||
reasons.push(`Steg ${step.stepNumber} har för kort instruktion.`);
|
||||
}
|
||||
if (step.timerSeconds != null && step.timerSeconds < 0) {
|
||||
reasons.push(`Steg ${step.stepNumber} har negativ timer.`);
|
||||
}
|
||||
if (step.temperatureC != null && (step.temperatureC < 0 || step.temperatureC > 350)) {
|
||||
reasons.push(`Steg ${step.stepNumber} har orimlig temperatur (${step.temperatureC}°C).`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Dedup mot befintliga recept ────────────────────────────────────────
|
||||
if (similarity) {
|
||||
const isDup = await similarity.hasSimilarity(candidate.titleSv);
|
||||
if (isDup) {
|
||||
reasons.push(`Titel "${candidate.titleSv}" flaggad som potentiell dubblett.`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resultat ─────────────────────────────────────────────────────────────
|
||||
const status: VerificationResult["status"] =
|
||||
reasons.length === 0 ? "verified" : "unverified";
|
||||
|
||||
return {
|
||||
candidate,
|
||||
status,
|
||||
reasons,
|
||||
nutritionPerPortion: nutritionResult.perPortion,
|
||||
allergens,
|
||||
canonicalIngredientIds: canonicalIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kör en batch kandidater genom pipelinen.
|
||||
*/
|
||||
export async function verifyBatch(
|
||||
candidates: RecipeCandidate[],
|
||||
ingredients: CanonicalIngredientLookup,
|
||||
similarity: SimilarityLookup | null,
|
||||
options?: VerificationOptions,
|
||||
): Promise<VerificationResult[]> {
|
||||
return Promise.all(candidates.map((c) => verifyCandidate(c, ingredients, similarity, options)));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildGapReport, formatGapReport } from "../src/gap-report.js";
|
||||
import type { AnalyticsEvent, RecipeSignal } from "../src/gap-report.js";
|
||||
|
||||
describe("buildGapReport", () => {
|
||||
it("identifierar topp-saknade ingredienser", () => {
|
||||
const events: AnalyticsEvent[] = [
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } },
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } },
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } },
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "tofu", suggestedIngredientId: "fast_tofu" } },
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "tofu", suggestedIngredientId: "fast_tofu" } },
|
||||
];
|
||||
|
||||
const report = buildGapReport(events, [], []);
|
||||
expect(report.entries).toHaveLength(2);
|
||||
expect(report.topMissingIngredients[0]?.canonicalIngredientId).toBe("laxfile");
|
||||
expect(report.topMissingIngredients[0]?.count).toBe(3);
|
||||
});
|
||||
|
||||
it("filtrerar bort enstaka missar", () => {
|
||||
const events: AnalyticsEvent[] = [
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "enstaka" } },
|
||||
];
|
||||
const report = buildGapReport(events, [], [], { minMissThreshold: 2 });
|
||||
expect(report.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("formaterar rapporten", () => {
|
||||
const events: AnalyticsEvent[] = [
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax" } },
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax" } },
|
||||
];
|
||||
const report = buildGapReport(events, [], [], { minMissThreshold: 1 });
|
||||
const text = formatGapReport(report);
|
||||
expect(text).toContain("Katalog-gaprapport");
|
||||
expect(text).toContain("lax");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { MockAamosClient } from "@app/ai-contracts";
|
||||
import { runPipeline, type PipelineTarget, type PipelineIngredient } from "../src/pipeline.js";
|
||||
import type { CanonicalIngredientLookup, SimilarityLookup } from "../src/verification.js";
|
||||
|
||||
const mockClient = new MockAamosClient();
|
||||
|
||||
const mockIngredients: CanonicalIngredientLookup = {
|
||||
getById(id: string) {
|
||||
const db: Record<string, ReturnType<CanonicalIngredientLookup["getById"]>> = {
|
||||
kycklingfile: {
|
||||
id: "kycklingfile",
|
||||
nutritionPer100: {
|
||||
basis: "per_100_g",
|
||||
values: {
|
||||
kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6,
|
||||
saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1,
|
||||
},
|
||||
},
|
||||
defaultUnit: "GRAM",
|
||||
densityGPerMl: null,
|
||||
gramsPerPiece: null,
|
||||
allergens: [],
|
||||
isVegan: false,
|
||||
isVegetarian: false,
|
||||
containsGluten: false,
|
||||
containsLactose: false,
|
||||
isPork: false,
|
||||
isBeef: false,
|
||||
isAlcohol: false,
|
||||
},
|
||||
};
|
||||
return db[id] ?? undefined;
|
||||
},
|
||||
};
|
||||
|
||||
const noSimilarity: SimilarityLookup = {
|
||||
async hasSimilarity() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
const catalog: PipelineIngredient[] = [
|
||||
{
|
||||
id: "kycklingfile",
|
||||
nameSv: "kycklingfilé",
|
||||
category: "kott_fagel",
|
||||
defaultUnit: "GRAM",
|
||||
isVegan: false,
|
||||
isVegetarian: false,
|
||||
containsGluten: false,
|
||||
containsLactose: false,
|
||||
allergens: [],
|
||||
},
|
||||
];
|
||||
|
||||
const targets: PipelineTarget[] = [
|
||||
{ mealType: "dinner", mainIngredientId: "kycklingfile", dietVariant: "standard", count: 2 },
|
||||
];
|
||||
|
||||
describe("runPipeline", () => {
|
||||
it("kör med mockad klient och returnerar resultat", async () => {
|
||||
const result = await runPipeline(mockClient, targets, catalog, mockIngredients, noSimilarity);
|
||||
expect(result.geminiResult.status).toBe("ok");
|
||||
// MockAamosClient returnerar nu kandidater för GENERATE_RECIPE_CANDIDATES
|
||||
expect(result.candidates.length).toBeGreaterThan(0);
|
||||
expect(result.verifiedCount + result.unverifiedCount + result.rejectedCount).toBe(result.candidates.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { verifyCandidate, type CanonicalIngredientLookup, type SimilarityLookup } from "../src/verification.js";
|
||||
import type { RecipeCandidate } from "../src/types.js";
|
||||
|
||||
const mockIngredients: CanonicalIngredientLookup = {
|
||||
getById(id: string) {
|
||||
const db: Record<string, ReturnType<CanonicalIngredientLookup["getById"]>> = {
|
||||
kycklingfile: {
|
||||
id: "kycklingfile",
|
||||
nutritionPer100: {
|
||||
basis: "per_100_g",
|
||||
values: {
|
||||
kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6,
|
||||
saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1,
|
||||
},
|
||||
},
|
||||
defaultUnit: "GRAM",
|
||||
densityGPerMl: null,
|
||||
gramsPerPiece: null,
|
||||
allergens: [],
|
||||
isVegan: false,
|
||||
isVegetarian: false,
|
||||
containsGluten: false,
|
||||
containsLactose: false,
|
||||
isPork: false,
|
||||
isBeef: false,
|
||||
isAlcohol: false,
|
||||
},
|
||||
ris: {
|
||||
id: "ris",
|
||||
nutritionPer100: {
|
||||
basis: "per_100_g",
|
||||
values: {
|
||||
kcal: 130, proteinG: 2.7, carbsG: 28, fatG: 0.3,
|
||||
saturatedFatG: 0.1, fiberG: 0.4, sugarG: 0.1, saltG: 0,
|
||||
},
|
||||
},
|
||||
defaultUnit: "GRAM",
|
||||
densityGPerMl: null,
|
||||
gramsPerPiece: null,
|
||||
allergens: [],
|
||||
isVegan: true,
|
||||
isVegetarian: true,
|
||||
containsGluten: false,
|
||||
containsLactose: false,
|
||||
isPork: false,
|
||||
isBeef: false,
|
||||
isAlcohol: false,
|
||||
},
|
||||
"krossade_tomater": {
|
||||
id: "krossade_tomater",
|
||||
nutritionPer100: {
|
||||
basis: "per_100_g",
|
||||
values: {
|
||||
kcal: 32, proteinG: 1.6, carbsG: 5.8, fatG: 0.3,
|
||||
saturatedFatG: 0, fiberG: 1.2, sugarG: 4, saltG: 0.1,
|
||||
},
|
||||
},
|
||||
defaultUnit: "GRAM",
|
||||
densityGPerMl: null,
|
||||
gramsPerPiece: null,
|
||||
allergens: [],
|
||||
isVegan: true,
|
||||
isVegetarian: true,
|
||||
containsGluten: false,
|
||||
containsLactose: false,
|
||||
isPork: false,
|
||||
isBeef: false,
|
||||
isAlcohol: false,
|
||||
},
|
||||
};
|
||||
return db[id] ?? undefined;
|
||||
},
|
||||
};
|
||||
|
||||
const noSimilarity: SimilarityLookup = {
|
||||
async hasSimilarity() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
function makeCandidate(overrides?: Partial<RecipeCandidate>): RecipeCandidate {
|
||||
return {
|
||||
titleSv: "Testrecept",
|
||||
descriptionSv: "Ett enkelt testrecept.",
|
||||
cuisine: "swedish",
|
||||
mealTypes: ["dinner"],
|
||||
prepTimeMinutes: 10,
|
||||
cookTimeMinutes: 20,
|
||||
totalTimeMinutes: 30,
|
||||
portions: 4,
|
||||
spiceLevel: 1,
|
||||
ingredients: [
|
||||
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
|
||||
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
|
||||
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
|
||||
],
|
||||
steps: [
|
||||
{ stepNumber: 1, instructionSv: "Stek kycklingen i en panna.", timerSeconds: null, temperatureC: null, tip: null },
|
||||
{ stepNumber: 2, instructionSv: "Tillsätt tomater och ris, låt koka.", timerSeconds: 900, temperatureC: null, tip: null },
|
||||
],
|
||||
storageGuidanceSv: "Förvara i kylskåp upp till 3 dagar.",
|
||||
mealPrepFriendly: false,
|
||||
freezerFriendly: false,
|
||||
sourceType: "ai_generated",
|
||||
confidence: 0.9,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("verifyCandidate", () => {
|
||||
it("passerar en giltig kandidat som verified", async () => {
|
||||
const result = await verifyCandidate(makeCandidate(), mockIngredients, noSimilarity);
|
||||
expect(result.status).toBe("verified");
|
||||
expect(result.reasons).toHaveLength(0);
|
||||
expect(result.allergens).toHaveLength(0);
|
||||
expect(result.nutritionPerPortion).not.toBeNull();
|
||||
expect(result.nutritionPerPortion!.kcal).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("avvisar kandidat med okänd ingrediens", async () => {
|
||||
const c = makeCandidate({
|
||||
ingredients: [
|
||||
{ canonicalIngredientId: "fantasi_gronsak", displayNameSv: "fantasigrönsak", quantity: 100, unit: "GRAM", optional: false, note: null },
|
||||
],
|
||||
});
|
||||
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
|
||||
expect(result.status).toBe("rejected");
|
||||
expect(result.reasons.some((r) => r.includes("fantasi_gronsak"))).toBe(true);
|
||||
});
|
||||
|
||||
it("markerar unverified vid orimlig tid", async () => {
|
||||
const c = makeCandidate({ prepTimeMinutes: 200, cookTimeMinutes: 10, totalTimeMinutes: 210 });
|
||||
const result = await verifyCandidate(c, mockIngredients, noSimilarity, { maxPrepTimeMinutes: 60 });
|
||||
expect(result.status).toBe("unverified");
|
||||
expect(result.reasons.some((r) => r.includes("Förberedelsetid"))).toBe(true);
|
||||
});
|
||||
|
||||
it("markerar unverified vid för få steg", async () => {
|
||||
const c = makeCandidate({ steps: [{ stepNumber: 1, instructionSv: "Gör allt.", timerSeconds: null, temperatureC: null, tip: null }] });
|
||||
const result = await verifyCandidate(c, mockIngredients, noSimilarity, { minSteps: 2 });
|
||||
expect(result.status).toBe("unverified");
|
||||
expect(result.reasons.some((r) => r.includes("steg"))).toBe(true);
|
||||
});
|
||||
|
||||
it("härleder allergener korrekt", async () => {
|
||||
const c = makeCandidate({
|
||||
ingredients: [
|
||||
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
|
||||
],
|
||||
});
|
||||
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
|
||||
expect(result.allergens).toEqual([]);
|
||||
});
|
||||
|
||||
it("detekterar dubblett", async () => {
|
||||
const dupSimilarity: SimilarityLookup = {
|
||||
async hasSimilarity() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const result = await verifyCandidate(makeCandidate(), mockIngredients, dupSimilarity);
|
||||
expect(result.status).toBe("unverified");
|
||||
expect(result.reasons.some((r) => r.includes("dubblett"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"tsBuildInfoFile": "./.tsbuildinfo",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user