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
@@ -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)));
}