Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+353
View File
@@ -0,0 +1,353 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { whatToEatQuerySchema } from "@app/validation";
import {
computeCoverage,
checkRecipeSafety,
isRecipeSafe,
type IngredientSafetyInfo,
type PantryItem,
} from "@app/recipe-engine";
import {
isEventActive,
parseCraving,
rankAll,
seasonForDate,
summarizeContext,
type RecommendationCandidate,
type RecommendationContext,
} from "@app/recommendation-engine";
import { DEFAULT_TARGETS, computeDailyTargets, summarizeDay } from "@app/nutrition-engine";
import { parse } from "../lib/errors.js";
import { requireActiveHousehold, todayIso } from "../lib/helpers.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);
const householdId = await requireActiveHousehold(app.db, req.userId);
const today = new Date();
// --- 1. Kontext: lager ---
const stockRows = 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 = await app.db
.select({ userId: schema.householdMembers.userId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.householdId, householdId));
const memberIds = members.map((m) => m.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) ---
const events = await app.db
.select()
.from(schema.seasonEvents)
.where(and(eq(schema.seasonEvents.active, true), eq(schema.seasonEvents.market, "SE")));
const activeHolidayTags = events
.filter((e) => isEventActive({ dateRule: e.dateRule, leadDays: e.leadDays }, today))
.map((e) => e.slug);
// --- 6. Senast lagat (variation) + hushållsbetyg ---
const cooks = 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. 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,
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,
};
// --- 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,
});
}
let recommendations = rankAll(scoredCandidates, ctx, undefined, 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
? 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: activeHolidayTags,
remainingKcal: ctx.remainingKcal,
remainingProteinG: ctx.remainingProteinG,
craving: craving ?? null,
},
mealBoxSuggestions,
recommendations,
};
});
}