Files
Cibello-app/apps/api/src/routes/recommendations.ts
T
Sven (AAMOS AI) 9c8ffbe2ab
CI / Typecheck, test & build (push) Failing after 43s
feat(reco): högtids-sök – expandera till eventets foodTags + holidayTag (MIKRO E1)
2026-08-14 22:45:44 +07:00

506 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
import { schema } from "@app/database";
import type { MemoryItem, TasteSignal } from "@app/shared-types";
import { whatToEatQuerySchema } from "@app/validation";
import {
computeCoverage,
checkRecipeSafety,
isRecipeSafe,
type IngredientSafetyInfo,
type PantryItem,
} from "@app/recipe-engine";
import {
depersonalize,
isEventActive,
parseCraving,
rankAll,
seasonForDate,
summarizeContext,
viewWeights,
type CookingAssumption,
type RecommendationCandidate,
type RecommendationContext,
} from "@app/recommendation-engine";
import { DEFAULT_TARGETS, computeDailyTargets, summarizeDay } from "@app/nutrition-engine";
import { parse } from "../lib/errors.js";
import { getActiveHouseholdId, todayIso } from "../lib/helpers.js";
import { loadLocalePreferences } from "../lib/localeContext.js";
/**
* "Vad ska vi äta?" (spec §18) appens viktigaste endpoint.
*
* Pipeline:
* 1. Hämta hushållets lager, medlemmarnas SAMLADE kostbegränsningar och kontext.
* 2. Deterministisk säkerhetsfiltrering (spec §61.2) blockers försvinner.
* 3. Täckningsberäkning mot lagret + poängsättning med förklaringar.
* 4. Matlådor rekommenderas före ny matlagning när rimligt (spec §24).
* 5. (Bakom flagga) AAMOS får omranka topplistan aldrig lägga till recept.
*/
export async function recommendationRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/recommendations/what-to-eat", auth, async (req) => {
const q = parse(whatToEatQuerySchema, req.query);
// Graceful for users without a household yet (e.g. after step A onboarding):
// treat it as an empty pantry / single-person context instead of failing.
const householdId = await getActiveHouseholdId(app.db, req.userId);
const today = new Date();
// --- 1. Kontext: lager ---
const stockRows = householdId
? await app.db
.select({
item: schema.inventoryItems,
locationType: schema.storageLocations.type,
shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
density: schema.canonicalIngredients.densityGPerMl,
gramsPerPiece: schema.canonicalIngredients.gramsPerPiece,
})
.from(schema.inventoryItems)
.innerJoin(
schema.storageLocations,
eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id),
)
.leftJoin(
schema.canonicalIngredients,
eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id),
)
.where(
and(
eq(schema.inventoryItems.householdId, householdId),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
),
)
: [];
const pantry: PantryItem[] = stockRows.map((r) => ({
id: r.item.id,
canonicalIngredientId: r.item.canonicalIngredientId,
quantity: r.item.quantity,
unit: r.item.unit,
bestBeforeDate: r.item.bestBeforeDate,
useByDate: r.item.useByDate,
openedAt: r.item.openedAt,
frozenAt: r.item.frozenAt,
thawedAt: r.item.thawedAt,
purchasedAt: r.item.purchasedAt,
storageLocationType: r.locationType,
shelfLifeGuidance: r.shelfLife,
}));
const unitInfo = new Map(
stockRows
.filter((r) => r.item.canonicalIngredientId)
.map((r) => [
r.item.canonicalIngredientId!,
{ densityGPerMl: r.density, gramsPerPiece: r.gramsPerPiece },
]),
);
// --- 2. Hushållets samlade begränsningar (spec §7: strängaste gäller) ---
const members = householdId
? await app.db
.select({ userId: schema.householdMembers.userId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.householdId, householdId))
: [];
const memberIds = members.length ? members.map((m) => m.userId) : [req.userId];
const allPrefs = await app.db
.select()
.from(schema.userPreferences)
.where(inArray(schema.userPreferences.userId, memberIds));
const combinedAllergens = [...new Set(allPrefs.flatMap((p) => p.allergens))];
const combinedAvoid = [...new Set(allPrefs.flatMap((p) => p.avoidIngredientIds))];
const strictestSpice = Math.min(...allPrefs.map((p) => p.spiceLevelMax), 5);
const myPrefs = allPrefs.find((p) => p.userId === req.userId);
// --- 3. Kandidater: publicerade recept för måltidstypen ---
const candidates = await app.db
.select()
.from(schema.recipes)
.where(
and(
eq(schema.recipes.status, "published"),
sql`${q.mealType} = ANY(${schema.recipes.mealTypes})`,
),
)
.limit(200);
const allIngredients = await app.db
.select()
.from(schema.recipeIngredients)
.where(
inArray(
schema.recipeIngredients.recipeId,
candidates.map((c) => c.id),
),
);
const ingredientIds = [...new Set(allIngredients.map((i) => i.canonicalIngredientId))];
const safetyRows = await app.db
.select()
.from(schema.canonicalIngredients)
.where(inArray(schema.canonicalIngredients.id, ingredientIds));
const safetyMap = new Map<string, IngredientSafetyInfo>(
safetyRows.map((r) => [
r.id,
{
id: r.id,
allergens: r.allergens,
isVegan: r.isVegan,
isVegetarian: r.isVegetarian,
containsGluten: r.containsGluten,
containsLactose: r.containsLactose,
isPork: r.isPork,
isBeef: r.isBeef,
isAlcohol: r.isAlcohol,
},
]),
);
for (const r of safetyRows) {
if (!unitInfo.has(r.id)) {
unitInfo.set(r.id, { densityGPerMl: r.densityGPerMl, gramsPerPiece: r.gramsPerPiece });
}
}
// --- 4. Näringskontext: vad återstår av dagen? ---
const [profile] = await app.db
.select()
.from(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, req.userId))
.limit(1);
const targets =
profile?.weightKg && profile.heightCm && profile.birthYear
? computeDailyTargets({
sex: profile.sex ?? "unspecified",
age: today.getUTCFullYear() - profile.birthYear,
heightCm: profile.heightCm,
weightKg: profile.weightKg,
activityLevel: profile.activityLevel,
primaryGoal: myPrefs?.primaryGoal ?? undefined,
}).targets
: DEFAULT_TARGETS;
const todaysMeals = await app.db
.select({ nutrition: schema.meals.nutrition })
.from(schema.meals)
.where(and(eq(schema.meals.userId, req.userId), eq(schema.meals.date, todayIso())));
const daySummary = summarizeDay(
todaysMeals.map((m) => m.nutrition),
targets,
);
// --- 5. Säsong & högtid (spec §28) marknadsstyrt via användarens region ---
const localePrefs = await loadLocalePreferences(app.db, req.userId);
const market = localePrefs.regionCode;
const events = await app.db
.select()
.from(schema.seasonEvents)
.where(and(eq(schema.seasonEvents.active, true), eq(schema.seasonEvents.market, market)));
const activeEvents = events.filter((e) =>
isEventActive({ dateRule: e.dateRule, leadDays: e.leadDays }, today),
);
const activeHolidayTags = activeEvents.map((e) => e.slug); // slugs → matchar recept-holidayTags (scoring)
const activeHolidayNames = activeEvents.map((e) => e.nameLocalized || e.nameSv); // lokaliserat namn, fallback nameSv
// --- 6. Senast lagat (variation) + hushållsbetyg ---
const cooks = householdId
? await app.db
.select({
recipeId: schema.recipeCooks.recipeId,
last: sql<string>`max(${schema.recipeCooks.cookedAt})`,
})
.from(schema.recipeCooks)
.where(eq(schema.recipeCooks.householdId, householdId))
.groupBy(schema.recipeCooks.recipeId)
: [];
const lastCooked = new Map(cooks.map((c) => [c.recipeId, c.last]));
const householdRatings = await app.db
.select({
recipeId: schema.recipeRatings.recipeId,
avg: sql<number>`avg(${schema.recipeRatings.stars})`,
})
.from(schema.recipeRatings)
.where(inArray(schema.recipeRatings.userId, memberIds))
.groupBy(schema.recipeRatings.recipeId);
const householdRatingMap = new Map(householdRatings.map((r) => [r.recipeId, Number(r.avg)]));
// --- 7. Personaliseringssamtycke — HÅRD GRIND (S1) ---
const [personalizationConsent] = await app.db
.select()
.from(schema.userConsents)
.where(
and(
eq(schema.userConsents.userId, req.userId),
eq(schema.userConsents.kind, "personalization"),
),
)
.limit(1);
const personalizationEnabled = personalizationConsent?.status === "granted";
let memoryItems: MemoryItem[] = [];
let tasteSignals: TasteSignal[] = [];
let cookingAssumptions: CookingAssumption[] = [];
if (personalizationEnabled && householdId) {
const memoryRows = await app.db
.select({
id: schema.memoryItems.id,
userId: schema.memoryItems.userId,
householdId: schema.memoryItems.householdId,
kind: schema.memoryItems.kind,
key: schema.memoryItems.key,
summarySv: schema.memoryItems.summarySv,
value: schema.memoryItems.value,
origin: schema.memoryItems.origin,
confidence: schema.memoryItems.confidence,
verifiedByUser: schema.memoryItems.verifiedByUser,
paused: schema.memoryItems.paused,
createdAt: schema.memoryItems.createdAt,
updatedAt: schema.memoryItems.updatedAt,
})
.from(schema.memoryItems)
.where(
or(
eq(schema.memoryItems.userId, req.userId),
eq(schema.memoryItems.householdId, householdId),
),
);
memoryItems = memoryRows.map((m) => ({
...m,
userId: m.userId ?? undefined,
householdId: m.householdId ?? undefined,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
lastUsedAt: undefined,
expiresAt: undefined,
}));
const tasteRows = await app.db
.select({
id: schema.tasteSignals.id,
userId: schema.tasteSignals.userId,
axis: schema.tasteSignals.axis,
target: schema.tasteSignals.target,
direction: schema.tasteSignals.direction,
strength: schema.tasteSignals.strength,
origin: schema.tasteSignals.origin,
refRecipeId: schema.tasteSignals.refRecipeId,
createdAt: schema.tasteSignals.createdAt,
})
.from(schema.tasteSignals)
.where(eq(schema.tasteSignals.userId, req.userId));
tasteSignals = tasteRows.map((t) => ({
...t,
target: t.target ?? undefined,
refRecipeId: t.refRecipeId ?? undefined,
createdAt: t.createdAt.toISOString(),
}));
const assumptions = await app.db
.select({
canonicalIngredientId: schema.cookingAssumptionProfiles.canonicalIngredientId,
averageEatenPortions: schema.cookingAssumptionProfiles.averageEatenPortions,
averageLeftoverPortions: schema.cookingAssumptionProfiles.averageLeftoverPortions,
observationCount: schema.cookingAssumptionProfiles.observationCount,
})
.from(schema.cookingAssumptionProfiles)
.where(eq(schema.cookingAssumptionProfiles.householdId, householdId));
cookingAssumptions = assumptions.map((a) => ({
canonicalIngredientId: a.canonicalIngredientId,
averageEatenPortions: a.averageEatenPortions,
averageLeftoverPortions: a.averageLeftoverPortions,
observationCount: a.observationCount,
}));
}
// --- 8. Tolka "jag är sugen på" (spec §19) ---
const craving = q.craving ? parseCraving(q.craving) : null;
const ctx: RecommendationContext = {
mealType: q.mealType,
persons: q.persons ?? (members.length ? members.length : 1),
maxMinutes: q.maxMinutes ?? myPrefs?.maxCookingMinutesWeekday ?? undefined,
maxCostMinorPerPortion: q.maxCostMinorPerPortion,
remainingProteinG: Math.max(0, daySummary.remaining.proteinG),
remainingKcal: Math.max(0, daySummary.remaining.kcal),
currentSeason: seasonForDate(today),
activeHolidayTags,
isWeekday: today.getUTCDay() >= 1 && today.getUTCDay() <= 4,
favoriteCuisines: myPrefs?.favoriteCuisines ?? [],
cravingTags: craving?.tags,
cravingCuisine: craving?.cuisine,
cravingMaxKcal: craving?.maxKcal,
personalizationEnabled,
memoryItems: personalizationEnabled ? memoryItems : undefined,
tasteSignals: personalizationEnabled ? tasteSignals : undefined,
cookingAssumptions: personalizationEnabled ? cookingAssumptions : undefined,
};
// --- 8. Filtrera säkert + beräkna täckning + poängsätt ---
const scoredCandidates: RecommendationCandidate[] = [];
for (const recipe of candidates) {
const recipeIngredients = allIngredients
.filter((i) => i.recipeId === recipe.id)
.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
displayNameSv: i.displayNameSv,
quantity: i.quantity,
unit: i.unit,
optional: i.optional,
}));
const violations = checkRecipeSafety(
{
ingredients: recipeIngredients.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
optional: i.optional,
})),
spiceLevel: recipe.spiceLevel,
},
{
allergens: combinedAllergens,
dietPattern: myPrefs?.dietPattern,
religiousRule: myPrefs?.religiousRule,
avoidIngredientIds: combinedAvoid,
spiceLevelMax: strictestSpice,
},
safetyMap,
);
if (!isRecipeSafe(violations)) continue;
const coverage = computeCoverage(recipeIngredients, pantry, unitInfo, today);
const lastDate = lastCooked.get(recipe.id);
scoredCandidates.push({
recipeId: recipe.id,
titleSv: recipe.titleSv,
cuisine: recipe.cuisine,
tags: recipe.tags as never,
totalTimeMinutes: recipe.totalTimeMinutes,
nutritionPerPortion: recipe.nutritionPerPortion,
estimatedCostMinorPerPortion: recipe.estimatedCostMinorPerPortion,
ratingAverage: recipe.ratingAverage,
ratingCount: recipe.ratingCount,
peakSeasons: recipe.peakSeasons,
holidayTags: recipe.holidayTags,
spiceLevel: recipe.spiceLevel,
coverage,
daysSinceLastCooked: lastDate
? Math.floor((today.getTime() - Date.parse(lastDate)) / 86_400_000)
: null,
householdRating: householdRatingMap.get(recipe.id) ?? null,
ingredientIds: recipeIngredients
.map((i) => i.canonicalIngredientId)
.filter((id): id is string => id != null),
});
}
const weights = personalizationEnabled
? viewWeights(q.view)
: depersonalize(viewWeights(q.view));
// Högtids-sök: matchar sökningen ett event för marknaden (året runt) → expandera till dess foodTags + slug.
const holidayFoodTags: string[] = [];
const holidaySlugs: string[] = [];
if (q.craving) {
const qc = q.craving.toLowerCase();
for (const e of events) {
if (
qc.includes(e.slug) ||
qc.includes(e.nameSv.toLowerCase()) ||
(e.nameLocalized ? qc.includes(e.nameLocalized.toLowerCase()) : false)
) {
holidayFoodTags.push(...e.foodTags);
holidaySlugs.push(e.slug);
}
}
}
// Sökte användaren specifikt (råvara "kyckling" ELLER högtid "midsommar")? Visa matchande först.
let pool = scoredCandidates;
if (craving && (craving.keywords.length > 0 || craving.cuisine || holidaySlugs.length > 0)) {
const matches = scoredCandidates.filter((c) => {
const title = c.titleSv.toLowerCase();
const tagSet = new Set<string>(c.tags as unknown as string[]);
return (
(!!craving.cuisine && c.cuisine === craving.cuisine) ||
craving.keywords.some((k) => title.includes(k) || tagSet.has(k)) ||
holidaySlugs.some((s) => c.holidayTags.includes(s)) ||
holidayFoodTags.some((ft) => title.includes(ft) || tagSet.has(ft))
);
});
if (matches.length > 0) pool = matches; // annars behåll alla (aldrig tomt)
}
let recommendations = rankAll(pool, ctx, weights, q.limit);
// --- 9. AAMOS-omrankning bakom feature flag (aldrig obligatorisk) ---
if (await app.flags.isEnabled("ai_rerank", req.userId)) {
const result = await app.aamos.runTask(
"RANK_RECIPES",
{
candidateIds: recommendations.map((r) => r.recipeId),
deterministicScores: Object.fromEntries(
recommendations.map((r) => [r.recipeId, r.score]),
),
contextSummary: summarizeContext(ctx),
},
{
correlationId: req.correlationId,
subjectRef: null,
localeContext: await (
await import("../lib/localeContext.js")
).getLocaleContext(app.db, req.userId),
},
);
if (result.status === "ok" && result.output) {
const order = new Map(result.output.rankedIds.map((id, i) => [id, i]));
recommendations = [...recommendations].sort(
(a, b) => (order.get(a.recipeId) ?? 99) - (order.get(b.recipeId) ?? 99),
);
}
}
// --- 10. Matlådor först när rimligt (spec §24) ---
const mealBoxes =
q.includeLeftovers && householdId
? await app.db
.select()
.from(schema.mealBoxes)
.where(
and(
eq(schema.mealBoxes.householdId, householdId),
eq(schema.mealBoxes.status, "available"),
),
)
.orderBy(schema.mealBoxes.recommendedUseBy)
.limit(5)
: [];
const mealBoxSuggestions = mealBoxes.map((box) => ({
mealBoxId: box.id,
titleSv: box.titleSv,
portionsRemaining: box.portionsRemaining,
recommendedUseBy: box.recommendedUseBy,
whySv:
Date.parse(box.recommendedUseBy) <= today.getTime() + 2 * 86_400_000
? `Matlådan bör ätas senast ${box.recommendedUseBy}. Noll matlagning, noll svinn.`
: "Färdig mat som väntar snabbaste middagen i huset.",
}));
return {
mealType: q.mealType,
context: {
persons: ctx.persons,
season: ctx.currentSeason,
activeHolidays: activeHolidayNames,
remainingKcal: ctx.remainingKcal,
remainingProteinG: ctx.remainingProteinG,
craving: craving ?? null,
view: q.view,
},
mealBoxSuggestions,
recommendations,
};
});
}