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,
|
type TaskOutput,
|
||||||
detectedItemSchema,
|
detectedItemSchema,
|
||||||
type DetectedItem,
|
type DetectedItem,
|
||||||
|
generateRecipeCandidatesOutput,
|
||||||
} from "./tasks.js";
|
} from "./tasks.js";
|
||||||
import type { AamosCallOptions, AamosClient, AamosResult } from "./client.js";
|
import type { AamosCallOptions, AamosClient, AamosResult } from "./client.js";
|
||||||
import type { LocaleContext } from "@app/shared-types";
|
import type { LocaleContext } from "@app/shared-types";
|
||||||
@@ -193,6 +194,8 @@ export class GeminiAamosClient implements AamosClient {
|
|||||||
case "ANALYZE_FRIDGE_IMAGE":
|
case "ANALYZE_FRIDGE_IMAGE":
|
||||||
case "ANALYZE_PANTRY_IMAGE":
|
case "ANALYZE_PANTRY_IMAGE":
|
||||||
return this.analyzeStorageImage(taskType, parsedInput.data as TaskInput<"ANALYZE_FRIDGE_IMAGE">, options) as Promise<AamosResult<T>>;
|
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:
|
default:
|
||||||
return {
|
return {
|
||||||
status: "failed",
|
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(
|
private buildFridgePrompt(
|
||||||
taskType: "ANALYZE_FRIDGE_IMAGE" | "ANALYZE_PANTRY_IMAGE",
|
taskType: "ANALYZE_FRIDGE_IMAGE" | "ANALYZE_PANTRY_IMAGE",
|
||||||
locale: LocaleContext,
|
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": {
|
case "RANK_RECIPES": {
|
||||||
const ids =
|
const ids =
|
||||||
typeof input === "object" && input !== null
|
typeof input === "object" && input !== null
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const AAMOS_TASK_TYPES = [
|
|||||||
"DEDUPLICATE_INVENTORY",
|
"DEDUPLICATE_INVENTORY",
|
||||||
"STRUCTURE_RECIPE_TEXT",
|
"STRUCTURE_RECIPE_TEXT",
|
||||||
"GENERATE_RECIPE_OPTIONS",
|
"GENERATE_RECIPE_OPTIONS",
|
||||||
|
"GENERATE_RECIPE_CANDIDATES",
|
||||||
"RANK_RECIPES",
|
"RANK_RECIPES",
|
||||||
"PARSE_CRAVING",
|
"PARSE_CRAVING",
|
||||||
"UPDATE_USER_MEMORY",
|
"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({
|
export const rankRecipesInput = z.object({
|
||||||
candidateIds: z.array(z.string()).max(50),
|
candidateIds: z.array(z.string()).max(50),
|
||||||
deterministicScores: z.record(z.string(), z.number()),
|
deterministicScores: z.record(z.string(), z.number()),
|
||||||
@@ -454,6 +531,10 @@ export const TASK_CONTRACTS = {
|
|||||||
input: generateRecipeOptionsInput,
|
input: generateRecipeOptionsInput,
|
||||||
output: generateRecipeOptionsOutput,
|
output: generateRecipeOptionsOutput,
|
||||||
},
|
},
|
||||||
|
GENERATE_RECIPE_CANDIDATES: {
|
||||||
|
input: generateRecipeCandidatesInput,
|
||||||
|
output: generateRecipeCandidatesOutput,
|
||||||
|
},
|
||||||
RANK_RECIPES: { input: rankRecipesInput, output: rankRecipesOutput },
|
RANK_RECIPES: { input: rankRecipesInput, output: rankRecipesOutput },
|
||||||
PARSE_CRAVING: { input: parseCravingInput, output: parseCravingOutput },
|
PARSE_CRAVING: { input: parseCravingInput, output: parseCravingOutput },
|
||||||
UPDATE_USER_MEMORY: { input: updateUserMemoryInput, output: updateUserMemoryOutput },
|
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"]
|
||||||
|
}
|
||||||
Generated
+128
@@ -409,6 +409,40 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../shared-types
|
version: link:../shared-types
|
||||||
|
|
||||||
|
packages/recipe-generation:
|
||||||
|
dependencies:
|
||||||
|
'@app/ai-contracts':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../ai-contracts
|
||||||
|
'@app/database':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../database
|
||||||
|
'@app/nutrition-engine':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../nutrition-engine
|
||||||
|
'@app/recipe-engine':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../recipe-engine
|
||||||
|
'@app/shared-types':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared-types
|
||||||
|
drizzle-orm:
|
||||||
|
specifier: ^0.41.0
|
||||||
|
version: 0.41.0(@types/pg@8.20.3)(pg@8.22.0)
|
||||||
|
zod:
|
||||||
|
specifier: ^4.4.0
|
||||||
|
version: 4.4.3
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.10.0
|
||||||
|
version: 22.20.1
|
||||||
|
typescript:
|
||||||
|
specifier: ~5.9.3
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^4.0.0
|
||||||
|
version: 4.1.10(@types/node@22.20.1)(vite@6.4.3(@types/node@22.20.1)(lightningcss@1.33.0)(terser@5.49.0)(tsx@4.23.4)(yaml@2.9.0))
|
||||||
|
|
||||||
packages/recommendation-engine:
|
packages/recommendation-engine:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@app/recipe-engine':
|
'@app/recipe-engine':
|
||||||
@@ -3279,6 +3313,95 @@ packages:
|
|||||||
resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==}
|
resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
drizzle-orm@0.41.0:
|
||||||
|
resolution: {integrity: sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@aws-sdk/client-rds-data': '>=3'
|
||||||
|
'@cloudflare/workers-types': '>=4'
|
||||||
|
'@electric-sql/pglite': '>=0.2.0'
|
||||||
|
'@libsql/client': '>=0.10.0'
|
||||||
|
'@libsql/client-wasm': '>=0.10.0'
|
||||||
|
'@neondatabase/serverless': '>=0.10.0'
|
||||||
|
'@op-engineering/op-sqlite': '>=2'
|
||||||
|
'@opentelemetry/api': ^1.4.1
|
||||||
|
'@planetscale/database': '>=1'
|
||||||
|
'@prisma/client': '*'
|
||||||
|
'@tidbcloud/serverless': '*'
|
||||||
|
'@types/better-sqlite3': '*'
|
||||||
|
'@types/pg': '*'
|
||||||
|
'@types/sql.js': '*'
|
||||||
|
'@vercel/postgres': '>=0.8.0'
|
||||||
|
'@xata.io/client': '*'
|
||||||
|
better-sqlite3: '>=7'
|
||||||
|
bun-types: '*'
|
||||||
|
expo-sqlite: '>=14.0.0'
|
||||||
|
gel: '>=2'
|
||||||
|
knex: '*'
|
||||||
|
kysely: '*'
|
||||||
|
mysql2: '>=2'
|
||||||
|
pg: '>=8'
|
||||||
|
postgres: '>=3'
|
||||||
|
prisma: '*'
|
||||||
|
sql.js: '>=1'
|
||||||
|
sqlite3: '>=5'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@aws-sdk/client-rds-data':
|
||||||
|
optional: true
|
||||||
|
'@cloudflare/workers-types':
|
||||||
|
optional: true
|
||||||
|
'@electric-sql/pglite':
|
||||||
|
optional: true
|
||||||
|
'@libsql/client':
|
||||||
|
optional: true
|
||||||
|
'@libsql/client-wasm':
|
||||||
|
optional: true
|
||||||
|
'@neondatabase/serverless':
|
||||||
|
optional: true
|
||||||
|
'@op-engineering/op-sqlite':
|
||||||
|
optional: true
|
||||||
|
'@opentelemetry/api':
|
||||||
|
optional: true
|
||||||
|
'@planetscale/database':
|
||||||
|
optional: true
|
||||||
|
'@prisma/client':
|
||||||
|
optional: true
|
||||||
|
'@tidbcloud/serverless':
|
||||||
|
optional: true
|
||||||
|
'@types/better-sqlite3':
|
||||||
|
optional: true
|
||||||
|
'@types/pg':
|
||||||
|
optional: true
|
||||||
|
'@types/sql.js':
|
||||||
|
optional: true
|
||||||
|
'@vercel/postgres':
|
||||||
|
optional: true
|
||||||
|
'@xata.io/client':
|
||||||
|
optional: true
|
||||||
|
better-sqlite3:
|
||||||
|
optional: true
|
||||||
|
bun-types:
|
||||||
|
optional: true
|
||||||
|
expo-sqlite:
|
||||||
|
optional: true
|
||||||
|
gel:
|
||||||
|
optional: true
|
||||||
|
knex:
|
||||||
|
optional: true
|
||||||
|
kysely:
|
||||||
|
optional: true
|
||||||
|
mysql2:
|
||||||
|
optional: true
|
||||||
|
pg:
|
||||||
|
optional: true
|
||||||
|
postgres:
|
||||||
|
optional: true
|
||||||
|
prisma:
|
||||||
|
optional: true
|
||||||
|
sql.js:
|
||||||
|
optional: true
|
||||||
|
sqlite3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
drizzle-orm@0.45.2:
|
drizzle-orm@0.45.2:
|
||||||
resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==}
|
resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -8871,6 +8994,11 @@ snapshots:
|
|||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
tsx: 4.23.4
|
tsx: 4.23.4
|
||||||
|
|
||||||
|
drizzle-orm@0.41.0(@types/pg@8.20.3)(pg@8.22.0):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/pg': 8.20.3
|
||||||
|
pg: 8.22.0
|
||||||
|
|
||||||
drizzle-orm@0.45.2(@types/pg@8.20.3)(pg@8.22.0):
|
drizzle-orm@0.45.2(@types/pg@8.20.3)(pg@8.22.0):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/pg': 8.20.3
|
'@types/pg': 8.20.3
|
||||||
|
|||||||
Reference in New Issue
Block a user