feat(recipe-generation): Cibello Receptkatalog Fas A – STEG 1

- Nytt paket @app/recipe-generation med pipeline, verifieringsgrind,
  gaprapport och pilotbatch-script.
- Ny AAMOS-task GENERATE_RECIPE_CANDIDATES i @app/ai-contracts.
- GeminiAamosClient utökat med receptgenereringsstöd.
- MockAamosClient uppdaterad med deterministiska kandidater.
- Verifieringspipeline: ingrediensmappning (hård spärr), härledda allergener,
  beräknad näring via nutrition-engine, språk-/kvalitetskontroll, dedup.
- Hermetiska tester: 10/10 gröna.
- typecheck + test + build: gröna för hela workspace.

Refs: docs/32-receptkatalog-buildout.md
This commit is contained in:
Sven (AAMOS AI)
2026-08-09 19:47:03 +07:00
parent 9197d93a92
commit 4b7dec2759
15 changed files with 1554 additions and 0 deletions
+165
View File
@@ -16,6 +16,7 @@ import {
type TaskOutput,
detectedItemSchema,
type DetectedItem,
generateRecipeCandidatesOutput,
} from "./tasks.js";
import type { AamosCallOptions, AamosClient, AamosResult } from "./client.js";
import type { LocaleContext } from "@app/shared-types";
@@ -193,6 +194,8 @@ export class GeminiAamosClient implements AamosClient {
case "ANALYZE_FRIDGE_IMAGE":
case "ANALYZE_PANTRY_IMAGE":
return this.analyzeStorageImage(taskType, parsedInput.data as TaskInput<"ANALYZE_FRIDGE_IMAGE">, options) as Promise<AamosResult<T>>;
case "GENERATE_RECIPE_CANDIDATES":
return this.generateRecipeCandidates(parsedInput.data as TaskInput<"GENERATE_RECIPE_CANDIDATES">, options) as Promise<AamosResult<T>>;
default:
return {
status: "failed",
@@ -292,6 +295,168 @@ export class GeminiAamosClient implements AamosClient {
};
}
private async generateRecipeCandidates(
input: TaskInput<"GENERATE_RECIPE_CANDIDATES">,
options: AamosCallOptions,
): Promise<AamosResult<"GENERATE_RECIPE_CANDIDATES">> {
const locale = options.localeContext ?? this.defaultLocale();
const started = Date.now();
// Estimate cost: ~0.003 USD per candidate (text-only, pessimistic)
const totalCandidates = input.targetMatrix.reduce((sum, t) => sum + t.count, 0);
const estimatedCostUsd = totalCandidates * 0.003;
if (await this.isOverBudget(estimatedCostUsd)) {
return {
status: "failed",
output: null,
error: "Global Gemini-dagsbudget är förbrukad.",
};
}
const prompt = this.buildRecipeGenerationPrompt(input, locale);
const payload = {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
responseMimeType: "application/json",
temperature: 0.3,
},
};
const url = `${GEMINI_API_BASE}/models/${this.cfg.model}:generateContent?key=${this.cfg.apiKey}`;
const res = await this.cfg.fetchImpl(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(this.cfg.timeoutMs),
});
if (!res.ok) {
const body = await res.text();
return {
status: "failed",
output: null,
error: `Gemini ${res.status}: ${body.slice(0, 500)}`,
};
}
const geminiBody = (await res.json()) as {
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number };
};
const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
let parsed: z.infer<typeof generateRecipeCandidatesOutput>;
try {
const json = JSON.parse(text);
const safe = generateRecipeCandidatesOutput.safeParse(json);
if (!safe.success) {
return { status: "failed", output: null, error: `Gemini-svar matchar inte schema: ${safe.error.message}` };
}
parsed = safe.data;
} catch {
return { status: "failed", output: null, error: "Gemini-svar var inte giltig JSON." };
}
const inputTokens = geminiBody.usageMetadata?.promptTokenCount ?? 0;
const outputTokens = geminiBody.usageMetadata?.candidatesTokenCount ?? 0;
const costUsd = this.estimateCostUsd(inputTokens, outputTokens);
await this.recordSpend(costUsd);
const latencyMs = Date.now() - started;
return {
status: parsed.candidates.length > 0 ? "ok" : "uncertain",
output: parsed as TaskOutput<"GENERATE_RECIPE_CANDIDATES">,
modelVersion: this.cfg.model,
promptVersion: "gemini-recipe-gen-v1",
latencyMs,
costUsd,
inputTokens,
outputTokens,
};
}
private buildRecipeGenerationPrompt(
input: TaskInput<"GENERATE_RECIPE_CANDIDATES">,
locale: LocaleContext,
): string {
const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
const catalog = input.canonicalIngredientsCatalog.map((i) =>
`- ${i.id} (${i.nameSv}, ${i.category}, enhet: ${i.defaultUnit}, vegan: ${i.isVegan}, veg: ${i.isVegetarian}, gluten: ${i.containsGluten}, laktos: ${i.containsLactose})`
).join("\n");
const targets = input.targetMatrix.map((t) =>
`- ${t.mealType} × ${t.mainIngredientId} × ${t.dietVariant}: ${t.count} st`
).join("\n");
const constraints = input.constraints;
return `Du är en erfaren svensk matskribent som skriver vardagsrecept för svenska hushåll.
VIKTIGAST: Använd ENDAST ingredienser från katalogen nedan. Hitta ALDRIG på ingredienser som inte finns i listan. Om ett recept behöver något som saknas, hoppa över det receptet och lägg till en notering i rejectedPrompts.
Tillåtna ingredienser (canonical_ingredients):
${catalog}
Önskade recept:
${targets}
Begränsningar:
- max förberedelsetid: ${constraints.maxPrepTimeMinutes ?? 60} min
- max koktid: ${constraints.maxCookTimeMinutes ?? 45} min
- portioner: ${constraints.portions ?? 4}
- max kryddnivå: ${constraints.spiceLevelMax ?? 3}
- undvik: ${constraints.avoidIngredients.join(", ") || "(ingen)"}
Regler:
- Recepten ska vara realistiska vardagsrätter för svenska hushåll.
- Stegen ska vara tydliga, med rimliga tider och temperaturer.
- Använd svenska mått (g, dl, msk, tsk, st).
- Formuleringen ska vara mjölkprincips-vänlig: ingen svinnskam, inget "släng".
- Varje ingrediens MÅSTE finnas i katalogen ovan.
- Svara ENDAST med giltig JSON i exakt detta format:
{
"candidates": [
{
"titleSv": "...",
"descriptionSv": "...",
"cuisine": "swedish",
"mealTypes": ["dinner"],
"prepTimeMinutes": 15,
"cookTimeMinutes": 30,
"portions": 4,
"spiceLevel": 1,
"ingredients": [
{
"canonicalIngredientId": "kycklingfile",
"displayNameSv": "kycklingfilé",
"quantity": 500,
"unit": "GRAM",
"optional": false,
"note": null
}
],
"steps": [
{
"instructionSv": "...",
"timerSeconds": null,
"temperatureC": null,
"tip": null
}
],
"storageGuidanceSv": "...",
"mealPrepFriendly": false,
"freezerFriendly": false,
"confidence": 0.95
}
],
"rejectedPrompts": []
}
Språk: ${lang}.`;
}
private buildFridgePrompt(
taskType: "ANALYZE_FRIDGE_IMAGE" | "ANALYZE_PANTRY_IMAGE",
locale: LocaleContext,
+53
View File
@@ -217,6 +217,59 @@ export function mockOutputFor(taskType: AamosTaskType, input: unknown): unknown
],
};
case "GENERATE_RECIPE_CANDIDATES": {
// Deterministisk mock: returnerar en enkel kandidat per target-cell
const inp = input as {
targetMatrix?: Array<{ mealType: string; mainIngredientId: string; dietVariant: string; count: number }>;
canonicalIngredientsCatalog?: Array<{ id: string; nameSv: string; category: string; defaultUnit: string }>;
};
const targets = inp.targetMatrix ?? [];
const catalog = inp.canonicalIngredientsCatalog ?? [];
const candidates = [];
for (const t of targets) {
const mainIng = catalog.find((c) => c.id === t.mainIngredientId);
for (let i = 0; i < Math.min(t.count, 2); i++) {
candidates.push({
titleSv: `Mock-${mainIng?.nameSv ?? t.mainIngredientId} ${t.dietVariant} ${i + 1}`,
descriptionSv: `Ett enkelt vardagsrecept med ${mainIng?.nameSv ?? t.mainIngredientId}.`,
cuisine: "swedish",
mealTypes: [t.mealType],
prepTimeMinutes: 10,
cookTimeMinutes: 20,
portions: 4,
spiceLevel: 1,
ingredients: [
{
canonicalIngredientId: t.mainIngredientId,
displayNameSv: mainIng?.nameSv ?? t.mainIngredientId,
quantity: 500,
unit: mainIng?.defaultUnit ?? "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "onion_yellow",
displayNameSv: "gul lök",
quantity: 2,
unit: "COUNT",
optional: false,
note: null,
},
],
steps: [
{ instructionSv: "Förbered ingredienserna.", timerSeconds: null, temperatureC: null, tip: null },
{ instructionSv: "Stek och låt koka klart.", timerSeconds: 900, temperatureC: null, tip: null },
],
storageGuidanceSv: "Förvara i kylskåp upp till 3 dagar.",
mealPrepFriendly: false,
freezerFriendly: false,
confidence: 0.85,
});
}
}
return { candidates, rejectedPrompts: [] };
}
case "RANK_RECIPES": {
const ids =
typeof input === "object" && input !== null
+81
View File
@@ -18,6 +18,7 @@ export const AAMOS_TASK_TYPES = [
"DEDUPLICATE_INVENTORY",
"STRUCTURE_RECIPE_TEXT",
"GENERATE_RECIPE_OPTIONS",
"GENERATE_RECIPE_CANDIDATES",
"RANK_RECIPES",
"PARSE_CRAVING",
"UPDATE_USER_MEMORY",
@@ -274,6 +275,82 @@ export const generateRecipeOptionsOutput = z.object({
),
});
// ---------------------------------------------------------------------------
// GENERATE_RECIPE_CANDIDATES (docs/32): AI-genererade receptkandidater
// konstruerade mot BEFINTLIGA canonical_ingredients. Ingen påhittad
// ingrediens. Näring räknas ALDRIG här det gör nutrition-engine.
// ---------------------------------------------------------------------------
export const generateRecipeCandidatesInput = z.object({
targetMatrix: z.array(
z.object({
mealType: z.string(),
mainIngredientId: z.string(),
dietVariant: z.enum(["standard", "vegetarian", "vegan", "gluten_free", "lactose_free"]),
count: z.number().int().min(1).max(10),
}),
).min(1).max(20),
canonicalIngredientsCatalog: z.array(
z.object({
id: z.string(),
nameSv: z.string(),
category: z.string(),
defaultUnit: unitSchema,
isVegan: z.boolean(),
isVegetarian: z.boolean(),
containsGluten: z.boolean(),
containsLactose: z.boolean(),
allergens: z.array(z.string()),
}),
).min(1),
constraints: z.object({
maxPrepTimeMinutes: z.number().int().nullable().default(60),
maxCookTimeMinutes: z.number().int().nullable().default(45),
portions: z.number().int().default(4),
spiceLevelMax: z.number().int().nullable().default(3),
avoidIngredients: z.array(z.string()).default([]),
}).default(() => ({ maxPrepTimeMinutes: 60, maxCookTimeMinutes: 45, portions: 4, spiceLevelMax: 3, avoidIngredients: [] })),
marketLocale: z.string().default("sv-SE"),
});
export const generateRecipeCandidatesOutput = z.object({
candidates: z.array(
z.object({
titleSv: z.string(),
descriptionSv: z.string(),
cuisine: z.string().nullable(),
mealTypes: z.array(z.string()),
prepTimeMinutes: z.number().int(),
cookTimeMinutes: z.number().int(),
portions: z.number().int(),
spiceLevel: z.number().int(),
ingredients: z.array(
z.object({
canonicalIngredientId: z.string(),
displayNameSv: z.string(),
quantity: z.number(),
unit: unitSchema,
optional: z.boolean().default(false),
note: z.string().nullable(),
}),
),
steps: z.array(
z.object({
instructionSv: z.string(),
timerSeconds: z.number().nullable(),
temperatureC: z.number().nullable(),
tip: z.string().nullable(),
}),
),
storageGuidanceSv: z.string().nullable(),
mealPrepFriendly: z.boolean().default(false),
freezerFriendly: z.boolean().default(false),
confidence: z.number().min(0).max(1),
}),
),
rejectedPrompts: z.array(z.string()).default([]),
});
export const rankRecipesInput = z.object({
candidateIds: z.array(z.string()).max(50),
deterministicScores: z.record(z.string(), z.number()),
@@ -454,6 +531,10 @@ export const TASK_CONTRACTS = {
input: generateRecipeOptionsInput,
output: generateRecipeOptionsOutput,
},
GENERATE_RECIPE_CANDIDATES: {
input: generateRecipeCandidatesInput,
output: generateRecipeCandidatesOutput,
},
RANK_RECIPES: { input: rankRecipesInput, output: rankRecipesOutput },
PARSE_CRAVING: { input: parseCravingInput, output: parseCravingOutput },
UPDATE_USER_MEMORY: { input: updateUserMemoryInput, output: updateUserMemoryOutput },
+32
View File
@@ -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 ~2030 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");
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./types.js";
export * from "./verification.js";
export * from "./pipeline.js";
export * from "./gap-report.js";
+173
View File
@@ -0,0 +1,173 @@
/**
* Genereringspipeline för receptkandidater (docs/32 §23).
*
* - 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,
};
}
+81
View File
@@ -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);
});
});
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": ".",
"tsBuildInfoFile": "./.tsbuildinfo",
"types": ["node"]
},
"include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules", "dist"]
}
+128
View File
@@ -409,6 +409,40 @@ importers:
specifier: workspace:*
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:
dependencies:
'@app/recipe-engine':
@@ -3279,6 +3313,95 @@ packages:
resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==}
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:
resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==}
peerDependencies:
@@ -8871,6 +8994,11 @@ snapshots:
esbuild: 0.25.12
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):
optionalDependencies:
'@types/pg': 8.20.3