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:
@@ -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)));
|
||||
}
|
||||
Reference in New Issue
Block a user