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
+397
View File
@@ -0,0 +1,397 @@
import { and, eq, gt, inArray, isNull, lte, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { classifyExpiry } from "@app/inventory-engine";
import { deriveMemoryUpdates } from "@app/memory-client";
import { getLocaleContext } from "../locale.js";
import type { WorkerContext } from "../context.js";
/**
* Återkommande underhållsjobb: outbox-publicering, bäst före-notiser,
* minnessynk (UPDATE_USER_MEMORY) och matlåde-påminnelser (spec §40, §32).
*/
/** Outbox: markera events publicerade (konsumenter läser via DB/analytics). */
export async function processOutbox(ctx: WorkerContext): Promise<number> {
const pending = await ctx.db
.select()
.from(schema.domainEvents)
.where(isNull(schema.domainEvents.publishedAt))
.orderBy(schema.domainEvents.occurredAt)
.limit(200);
if (pending.length === 0) return 0;
await ctx.db
.update(schema.domainEvents)
.set({ publishedAt: new Date() })
.where(
inArray(
schema.domainEvents.id,
pending.map((e) => e.id),
),
);
return pending.length;
}
/** SEND_EXPIRY_NOTIFICATION (spec §54): skapa notiser för varor som snart går ut. */
export async function processExpiryNotifications(ctx: WorkerContext): Promise<number> {
const households = await ctx.db.select({ id: schema.households.id }).from(schema.households);
let created = 0;
for (const household of households) {
const rows = await ctx.db
.select({
item: schema.inventoryItems,
locationType: schema.storageLocations.type,
shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
})
.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, household.id),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
),
);
const urgent = rows
.map((r) => ({
item: r.item,
expiry: classifyExpiry({
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,
}),
}))
.filter((r) => r.expiry.status === "expiring" && (r.expiry.daysLeft ?? 99) <= 2);
if (urgent.length === 0) continue;
const members = await ctx.db
.select({ userId: schema.householdMembers.userId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.householdId, household.id));
const names = urgent
.slice(0, 3)
.map((u) => u.item.displayName)
.join(", ");
const title =
urgent.length === 1
? "En vara bör användas snart"
: `${urgent.length} varor bör användas snart`;
const body = `${names}${urgent.length > 3 ? " med flera" : ""} går snart ut. Tryck för receptförslag som räddar dem.`;
for (const member of members) {
// Max en expiry-notis per användare och dygn.
const [recent] = await ctx.db
.select({ id: schema.notifications.id })
.from(schema.notifications)
.where(
and(
eq(schema.notifications.userId, member.userId),
eq(schema.notifications.type, "expiry_warning"),
gt(schema.notifications.createdAt, new Date(Date.now() - 20 * 3600_000)),
),
)
.limit(1);
if (recent) continue;
await ctx.db.insert(schema.notifications).values({
userId: member.userId,
type: "expiry_warning",
titleSv: title,
bodySv: body,
data: { itemIds: urgent.map((u) => u.item.id) },
// i18n-spec §26: mall + variabler; texten ovan är renderad sv-cache.
templateKey: "notification.expiry_warning",
variables: { count: urgent.length, names, itemIds: urgent.map((u) => u.item.id) },
locale: "sv-SE",
});
created++;
}
}
return created;
}
/** Matlåde-påminnelser (spec §40). */
export async function processMealBoxReminders(ctx: WorkerContext): Promise<number> {
const soon = new Date(Date.now() + 2 * 86_400_000).toISOString().slice(0, 10);
const boxes = await ctx.db
.select()
.from(schema.mealBoxes)
.where(
and(eq(schema.mealBoxes.status, "available"), lte(schema.mealBoxes.recommendedUseBy, soon)),
);
let created = 0;
for (const box of boxes) {
const members = await ctx.db
.select({ userId: schema.householdMembers.userId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.householdId, box.householdId));
for (const member of members) {
if (box.reservedForUserId && box.reservedForUserId !== member.userId) continue;
const [recent] = await ctx.db
.select({ id: schema.notifications.id })
.from(schema.notifications)
.where(
and(
eq(schema.notifications.userId, member.userId),
eq(schema.notifications.type, "meal_box_reminder"),
gt(schema.notifications.createdAt, new Date(Date.now() - 20 * 3600_000)),
),
)
.limit(1);
if (recent) continue;
await ctx.db.insert(schema.notifications).values({
userId: member.userId,
type: "meal_box_reminder",
titleSv: "Matlåda väntar",
bodySv: `${box.titleSv} bör ätas senast ${box.recommendedUseBy}.`,
data: { mealBoxId: box.id },
templateKey: "notification.meal_box_reminder",
variables: { mealBoxId: box.id, title: box.titleSv, useBy: box.recommendedUseBy },
locale: "sv-SE",
});
created++;
}
}
return created;
}
/**
* UPDATE_USER_MEMORY (spec §3233): sammanfatta senaste events till
* minnesförslag via AAMOS. Kör ENDAST med personaliseringssamtycke;
* förslag skrivs till memory_items där användaren äger dem.
*/
export async function processMemorySync(ctx: WorkerContext): Promise<number> {
const users = await ctx.db
.select({ userId: schema.userConsents.userId })
.from(schema.userConsents)
.where(
and(
eq(schema.userConsents.kind, "personalization"),
eq(schema.userConsents.status, "granted"),
),
);
let updates = 0;
for (const { userId } of users) {
const events = await ctx.db
.select()
.from(schema.domainEvents)
.where(
and(
eq(schema.domainEvents.userId, userId),
gt(schema.domainEvents.occurredAt, new Date(Date.now() - 7 * 86_400_000)),
inArray(schema.domainEvents.type, [
"RECIPE_COOKED",
"RECIPE_RATED",
"MEAL_LOGGED",
"PRODUCT_DISCARDED",
]),
),
)
.limit(100);
if (events.length < 3) continue;
const existing = await ctx.db
.select({ key: schema.memoryItems.key })
.from(schema.memoryItems)
.where(eq(schema.memoryItems.userId, userId));
const consents = await ctx.db
.select()
.from(schema.userConsents)
.where(eq(schema.userConsents.userId, userId));
const has = (kind: string) => consents.find((c) => c.kind === kind)?.status === "granted";
const localeContext = await getLocaleContext(ctx, userId);
const proposals = await deriveMemoryUpdates(ctx.aamos, {
scope: "user",
scopeId: userId,
localeContext,
events: events.map((e) => ({
type: e.type,
occurredAt: e.occurredAt.toISOString(),
payload: e.payload,
})),
existingMemoryKeys: existing.map((e) => e.key),
consentFlags: {
personalization: true,
anonymizedImprovement: has("anonymized_improvement"),
imageTraining: has("image_training"),
},
});
for (const proposal of proposals) {
// Skriv aldrig över användarverifierade poster (spec §30: user_stated vinner).
const [current] = await ctx.db
.select()
.from(schema.memoryItems)
.where(and(eq(schema.memoryItems.userId, userId), eq(schema.memoryItems.key, proposal.key)))
.limit(1);
if (current?.verifiedByUser || current?.paused) continue;
if (current) {
await ctx.db
.update(schema.memoryItems)
.set({
summarySv: proposal.summarySv,
value: proposal.value,
origin: proposal.origin,
confidence: proposal.confidence,
expiresAt: proposal.expiresAt ? new Date(proposal.expiresAt) : null,
updatedAt: new Date(),
})
.where(eq(schema.memoryItems.id, current.id));
} else {
await ctx.db.insert(schema.memoryItems).values({
userId,
kind: proposal.kind,
key: proposal.key,
summarySv: proposal.summarySv,
value: proposal.value,
origin: proposal.origin,
confidence: proposal.confidence,
expiresAt: proposal.expiresAt ? new Date(proposal.expiresAt) : null,
});
}
updates++;
}
}
return updates;
}
/** BUILD_TRAINING_SAMPLE (spec §33): exportera korrigeringar MED samtycke. */
export async function processTrainingExport(ctx: WorkerContext): Promise<number> {
const corrections = await ctx.db
.select()
.from(schema.aiCorrections)
.where(isNull(schema.aiCorrections.exportedToTraining))
.limit(100);
let exported = 0;
for (const correction of corrections) {
const snapshot = correction.consentSnapshot as Record<string, string>;
// Endast korrigeringar där anonymiserad förbättring var beviljad vid tillfället.
if (snapshot.anonymized_improvement !== "granted") continue;
// Här skulle exporten till AAMOS training-pipeline ske (avidentifierad).
await ctx.db
.update(schema.aiCorrections)
.set({ exportedToTraining: new Date() })
.where(eq(schema.aiCorrections.id, correction.id));
exported++;
}
return exported;
}
/** VERIFY_SUBSCRIPTION: flagga prenumerationer som passerat expiry. */
export async function processSubscriptionSweep(ctx: WorkerContext): Promise<number> {
const result = await ctx.db
.update(schema.subscriptions)
.set({ status: "expired", updatedAt: new Date() })
.where(
and(
inArray(schema.subscriptions.status, ["active", "in_grace", "trial"]),
sql`${schema.subscriptions.expiresAt} IS NOT NULL AND ${schema.subscriptions.expiresAt} < now()`,
sql`(${schema.subscriptions.gracePeriodExpiresAt} IS NULL OR ${schema.subscriptions.gracePeriodExpiresAt} < now())`,
),
)
.returning({ id: schema.subscriptions.id });
return result.length;
}
/** PROCESS_STORE_NOTIFICATION (spec §47): tolka och applicera store-notiser. */
export async function processStoreNotification(
ctx: WorkerContext,
notificationId: string,
): Promise<void> {
const [notification] = await ctx.db
.select()
.from(schema.storeNotifications)
.where(eq(schema.storeNotifications.id, notificationId))
.limit(1);
if (!notification || notification.processed) return;
// Produktionsimplementation (fas 7): verifiera JWS (Apple) / Pub/Sub-token
// (Google), slå upp originalTransactionId och uppdatera subscriptions.
// Tills butiksnycklar finns markeras notisen som mottagen men overifierad.
await ctx.db
.update(schema.storeNotifications)
.set({
processed: true,
processedAt: new Date(),
error: notification.signatureVerified
? null
: "Signaturverifiering väntar på butiksnycklar (se docs/subscriptions.md).",
})
.where(eq(schema.storeNotifications.id, notificationId));
}
/**
* Dataretention (GDPR, spec §56; hardening-checklistans automatiseringskrav).
* Körs dagligen. Konservativa fönster rådata som användaren äger rörs aldrig,
* endast förbrukade säkerhetstokens, gamla jobb och lästa notiser.
*/
export async function processRetention(ctx: WorkerContext): Promise<Record<string, number>> {
const now = Date.now();
const days = (n: number) => new Date(now - n * 86_400_000);
const removed: Record<string, number> = {};
// Förbrukade/utgångna säkerhetstokens äldre än 7 dagar.
removed.passwordResetTokens = (
await ctx.db
.delete(schema.passwordResetTokens)
.where(
sql`(${schema.passwordResetTokens.usedAt} IS NOT NULL OR ${schema.passwordResetTokens.expiresAt} < now()) AND ${schema.passwordResetTokens.createdAt} < ${days(7)}`,
)
.returning({ id: schema.passwordResetTokens.id })
).length;
removed.emailVerificationTokens = (
await ctx.db
.delete(schema.emailVerificationTokens)
.where(
sql`(${schema.emailVerificationTokens.usedAt} IS NOT NULL OR ${schema.emailVerificationTokens.expiresAt} < now()) AND ${schema.emailVerificationTokens.createdAt} < ${days(7)}`,
)
.returning({ id: schema.emailVerificationTokens.id })
).length;
// Återkallade/utgångna refresh-tokens äldre än 30 dagar.
removed.refreshTokens = (
await ctx.db
.delete(schema.refreshTokens)
.where(
sql`(${schema.refreshTokens.revokedAt} IS NOT NULL OR ${schema.refreshTokens.expiresAt} < now()) AND ${schema.refreshTokens.createdAt} < ${days(30)}`,
)
.returning({ id: schema.refreshTokens.id })
).length;
// Skanningsjobb äldre än 90 dagar (spec §53: scans/ 90 dagar).
removed.scanJobs = (
await ctx.db
.delete(schema.scanJobs)
.where(sql`${schema.scanJobs.createdAt} < ${days(90)}`)
.returning({ id: schema.scanJobs.id })
).length;
// Notiser äldre än 90 dagar.
removed.notifications = (
await ctx.db
.delete(schema.notifications)
.where(sql`${schema.notifications.createdAt} < ${days(90)}`)
.returning({ id: schema.notifications.id })
).length;
return removed;
}
+101
View File
@@ -0,0 +1,101 @@
import { and, eq, ne, sql } from "drizzle-orm";
import { schema } from "@app/database";
import type { WorkerContext } from "../context.js";
/**
* Publiceringsflöde för användarrecept (spec §35):
* submitted → AI-kontroll (AAMOS MODERATE_RECIPE) → dubblettkontroll (spec §36)
* → in_moderation (mänsklig granskning i admin) eller direkt reject.
*/
export async function processModerateRecipe(ctx: WorkerContext, recipeId: string): Promise<void> {
const [recipe] = await ctx.db
.select()
.from(schema.recipes)
.where(eq(schema.recipes.id, recipeId))
.limit(1);
if (!recipe || recipe.status !== "submitted") return;
const ingredients = await ctx.db
.select()
.from(schema.recipeIngredients)
.where(eq(schema.recipeIngredients.recipeId, recipeId));
const steps = await ctx.db
.select()
.from(schema.recipeSteps)
.where(eq(schema.recipeSteps.recipeId, recipeId))
.orderBy(schema.recipeSteps.stepNumber);
// 1. AI-kontroll (spec §35 steg 2)
const result = await ctx.aamos.runTask("MODERATE_RECIPE", {
titleSv: recipe.titleSv,
descriptionSv: recipe.descriptionSv,
ingredients: ingredients.map((i) => `${i.quantity} ${i.unit} ${i.displayNameSv}`),
steps: steps.map((s) => s.instructionSv),
});
if (result.status === "ok" && result.output?.recommendation === "reject") {
await ctx.db
.update(schema.recipes)
.set({
status: "rejected",
moderationNote: result.output.flags.map((f) => f.messageSv).join(" "),
updatedAt: new Date(),
})
.where(eq(schema.recipes.id, recipeId));
return;
}
// 2. Dubblettkontroll (spec §36): jämför ingrediensuppsättning + DNA
const candidates = await ctx.db
.select({ id: schema.recipes.id, dna: schema.recipes.dna, titleSv: schema.recipes.titleSv })
.from(schema.recipes)
.where(and(eq(schema.recipes.status, "published"), ne(schema.recipes.id, recipeId)))
.limit(500);
const mySet = new Set(ingredients.map((i) => i.canonicalIngredientId));
for (const candidate of candidates) {
const otherIngredients = await ctx.db
.select({ canonicalIngredientId: schema.recipeIngredients.canonicalIngredientId })
.from(schema.recipeIngredients)
.where(eq(schema.recipeIngredients.recipeId, candidate.id));
const otherSet = new Set(otherIngredients.map((i) => i.canonicalIngredientId));
const intersection = [...mySet].filter((id) => otherSet.has(id)).length;
const union = new Set([...mySet, ...otherSet]).size;
const jaccard = union > 0 ? intersection / union : 0;
if (jaccard >= 0.6) {
const classification =
jaccard >= 0.9 ? "duplicate" : jaccard >= 0.75 ? "variant" : "inspired";
await ctx.db
.insert(schema.recipeSimilarities)
.values({
recipeAId: recipeId,
recipeBId: candidate.id,
similarityScore: Math.round(jaccard * 100) / 100,
classification,
details: { method: "ingredient_jaccard", intersection, union },
})
.onConflictDoNothing();
}
}
// 3. Till mänsklig moderering (spec §35 steg 4). Vanliga rätter får ha
// legitima varianter dubbletter avgörs av människa, inte automatik.
const flagsNote =
result.status === "ok" && result.output
? result.output.flags.map((f) => `[${f.severity}] ${f.messageSv}`).join(" ")
: "AI-kontrollen kunde inte köras manuell granskning krävs.";
const dupCount = await ctx.db
.select({ count: sql<number>`count(*)` })
.from(schema.recipeSimilarities)
.where(eq(schema.recipeSimilarities.recipeAId, recipeId));
await ctx.db
.update(schema.recipes)
.set({
status: "in_moderation",
moderationNote: `${flagsNote} Dubblettkandidater: ${Number(dupCount[0]?.count ?? 0)}.`.trim(),
updatedAt: new Date(),
})
.where(eq(schema.recipes.id, recipeId));
}
+178
View File
@@ -0,0 +1,178 @@
import { eq } from "drizzle-orm";
import { schema } from "@app/database";
import type { AamosTaskType } from "@app/ai-contracts";
import type { WorkerContext } from "../context.js";
import { getLocaleContext } from "../locale.js";
import type { LocaleContext } from "@app/shared-types";
/**
* Bild-/OCR-jobb (spec §54): hämtar scan_job, anropar AAMOS med kontraktvaliderad
* input/output, sparar resultatet och sätter awaiting_confirmation.
* Användaren bekräftar ALLTID innan lagret röres (spec §61.5).
*/
export async function processScanJob(ctx: WorkerContext, scanJobId: string): Promise<void> {
const [job] = await ctx.db
.select()
.from(schema.scanJobs)
.where(eq(schema.scanJobs.id, scanJobId))
.limit(1);
if (!job) throw new Error(`scan_job ${scanJobId} finns inte`);
if (job.status === "completed" || job.status === "awaiting_confirmation") return; // idempotent
await ctx.db
.update(schema.scanJobs)
.set({ status: "running", attempts: job.attempts + 1, updatedAt: new Date() })
.where(eq(schema.scanJobs.id, scanJobId));
const imageUrls = job.s3Keys.map((k) => ctx.readUrl(k));
const localeContext = await getLocaleContext(ctx, job.userId);
const consents = await loadConsentFlags(ctx, job.userId);
const started = Date.now();
const result = await runAamosForJob(
ctx,
job.jobType as AamosTaskType,
job.scanType,
imageUrls,
job.context,
localeContext,
);
if (result.status === "failed" || result.output == null) {
await ctx.db
.update(schema.scanJobs)
.set({
status: "failed",
error: result.error ?? "AI-analysen misslyckades. Försök igen eller registrera manuellt.",
latencyMs: Date.now() - started,
updatedAt: new Date(),
})
.where(eq(schema.scanJobs.id, scanJobId));
return;
}
await ctx.db
.update(schema.scanJobs)
.set({
status: "awaiting_confirmation",
result: result.output as Record<string, unknown>,
modelVersion: result.modelVersion ?? null,
promptVersion: result.promptVersion ?? null,
latencyMs: result.latencyMs ?? Date.now() - started,
costUsd: result.costUsd ?? null,
updatedAt: new Date(),
})
.where(eq(schema.scanJobs.id, scanJobId));
// MEAL_PHOTO_ANALYZED-event för tallriksfoton (spec §55)
if (job.jobType === "ANALYZE_MEAL_IMAGE") {
const output = result.output as {
kcalRange?: { mostLikely: number } | null;
matchesRecipeContext?: boolean | null;
};
await ctx.db.insert(schema.domainEvents).values({
type: "MEAL_PHOTO_ANALYZED",
userId: job.userId,
householdId: job.householdId,
payload: {
scanJobId,
matched: output.matchesRecipeContext ?? false,
kcalMostLikely: output.kcalRange?.mostLikely ?? null,
},
});
}
void consents;
}
async function runAamosForJob(
ctx: WorkerContext,
jobType: AamosTaskType,
scanType: string,
imageUrls: string[],
context: unknown,
localeContext: LocaleContext,
) {
switch (jobType) {
case "ANALYZE_FRIDGE_IMAGE":
case "ANALYZE_PANTRY_IMAGE":
return ctx.aamos.runTask(
jobType,
{
imageUrls,
locationType: scanType,
marketLocale: localeContext.languageTag,
knownItems: [],
},
{ localeContext },
);
case "ANALYZE_MEAL_IMAGE": {
const recipeContext = await buildRecipeContext(ctx, context);
return ctx.aamos.runTask(
"ANALYZE_MEAL_IMAGE",
{ imageUrls, recipeContext, marketLocale: localeContext.languageTag },
{ localeContext },
);
}
case "READ_RECEIPT":
return ctx.aamos.runTask(
"READ_RECEIPT",
{ imageUrls, marketLocale: localeContext.languageTag },
{ localeContext },
);
case "READ_NUTRITION_LABEL":
return ctx.aamos.runTask(
"READ_NUTRITION_LABEL",
{ imageUrls, marketLocale: localeContext.languageTag },
{ localeContext },
);
case "READ_EXPIRY_DATE":
return ctx.aamos.runTask(
"READ_EXPIRY_DATE",
{ imageUrls: imageUrls.slice(0, 2) },
{ localeContext },
);
default:
throw new Error(`Jobbtypen ${jobType} hanteras inte av scan-processorn`);
}
}
async function buildRecipeContext(ctx: WorkerContext, context: unknown) {
if (
typeof context !== "object" ||
context === null ||
typeof (context as { recipeId?: unknown }).recipeId !== "string"
) {
return null;
}
const recipeId = (context as { recipeId: string }).recipeId;
const [recipe] = await ctx.db
.select({
id: schema.recipes.id,
titleSv: schema.recipes.titleSv,
nutritionPerPortion: schema.recipes.nutritionPerPortion,
portions: schema.recipes.portions,
})
.from(schema.recipes)
.where(eq(schema.recipes.id, recipeId))
.limit(1);
if (!recipe) return null;
return {
recipeId: recipe.id,
titleSv: recipe.titleSv,
nutritionPerPortion: recipe.nutritionPerPortion as unknown as Record<string, number>,
portions: recipe.portions,
};
}
async function loadConsentFlags(ctx: WorkerContext, userId: string) {
const consents = await ctx.db
.select()
.from(schema.userConsents)
.where(eq(schema.userConsents.userId, userId));
const get = (kind: string) => consents.find((c) => c.kind === kind)?.status === "granted";
return {
personalization: get("personalization"),
anonymizedImprovement: get("anonymized_improvement"),
imageTraining: get("image_training"),
};
}
+142
View File
@@ -0,0 +1,142 @@
import { and, eq } from "drizzle-orm";
import { schema } from "@app/database";
import type { WorkerContext } from "../context.js";
/**
* Receptöversättning (i18n-spec §1314, M3):
* TRANSLATE_RECIPE-jobb → AAMOS översätter TEXT → deterministisk verifiering
* → sparas som draft_ai → människa publicerar i admin.
*
* AI kan aldrig ändra mängder, ingredient-IDs, tider eller allergener
* de bor i strukturerade fält utanför översättningen. Verifieringen här
* kontrollerar det som ÄNDÅ kan gå fel i text: stegantal, bevarade tal
* (temperaturer/mängder inbakade i löptext) och tomma fält.
*/
export async function processTranslateRecipe(
ctx: WorkerContext,
data: { recipeId: string; targetLanguageTag: string },
): Promise<void> {
const { recipeId, targetLanguageTag } = data;
const [recipe] = await ctx.db
.select()
.from(schema.recipes)
.where(eq(schema.recipes.id, recipeId))
.limit(1);
if (!recipe) return;
const steps = await ctx.db
.select()
.from(schema.recipeSteps)
.where(eq(schema.recipeSteps.recipeId, recipeId))
.orderBy(schema.recipeSteps.stepNumber);
const input = {
sourceLanguageTag: "sv",
targetLanguageTag,
title: recipe.titleSv,
description: recipe.descriptionSv ?? null,
storageGuidance: recipe.storageGuidanceSv ?? null,
steps: steps.map((s) => ({
stepNumber: s.stepNumber,
instruction: s.instructionSv,
tip: s.tip ?? null,
})),
};
const result = await ctx.aamos.runTask("TRANSLATE_RECIPE", input);
if (result.status !== "ok" || !result.output) {
throw new Error(`TRANSLATE_RECIPE misslyckades: ${result.status}`);
}
const out = result.output;
// --- Deterministisk verifiering (spec §61.1: AI:s svar litas aldrig på rakt av) ---
const checks: Record<string, boolean> = {
stepCountMatches: out.steps.length === input.steps.length,
stepNumbersMatch: out.steps.every((s, i) => s.stepNumber === input.steps[i]?.stepNumber),
titleNonEmpty: out.title.trim().length > 0,
numbersPreserved: numbersPreserved(
[input.title, input.description ?? "", ...input.steps.map((s) => s.instruction)],
[out.title, out.description ?? "", ...out.steps.map((s) => s.instruction)],
),
confidenceAcceptable: out.confidence >= 0.5,
};
const ok = Object.values(checks).every(Boolean);
const notes = Object.entries(checks)
.filter(([, v]) => !v)
.map(([k]) => `Verifiering föll: ${k}`);
// --- Spara utkast (upsert per språk) ---
const [existing] = await ctx.db
.select({ id: schema.recipeTranslations.id })
.from(schema.recipeTranslations)
.where(
and(
eq(schema.recipeTranslations.recipeId, recipeId),
eq(schema.recipeTranslations.languageTag, targetLanguageTag),
),
)
.limit(1);
const row = {
title: out.title,
description: out.description,
storageGuidance: out.storageGuidance,
status: "draft_ai" as const,
source: "ai" as const,
verification: { ok, checks, ...(notes.length ? { notes } : {}) },
updatedAt: new Date(),
};
if (existing) {
await ctx.db
.update(schema.recipeTranslations)
.set(row)
.where(eq(schema.recipeTranslations.id, existing.id));
} else {
await ctx.db
.insert(schema.recipeTranslations)
.values({ recipeId, languageTag: targetLanguageTag, ...row });
}
// Stegtexter: ersätt hela uppsättningen för språket (idempotent).
await ctx.db
.delete(schema.recipeStepTranslations)
.where(
and(
eq(schema.recipeStepTranslations.recipeId, recipeId),
eq(schema.recipeStepTranslations.languageTag, targetLanguageTag),
),
);
if (out.steps.length > 0) {
await ctx.db.insert(schema.recipeStepTranslations).values(
out.steps.map((s) => ({
recipeId,
languageTag: targetLanguageTag,
stepNumber: s.stepNumber,
instruction: s.instruction,
tip: s.tip,
})),
);
}
}
/**
* Alla tal i källtexten ska finnas kvar i måltexten (multiset-jämförelse).
* Fångar när en modell "översätter om" 225°C till 437°F eller tappar en mängd
* enhetskonvertering är visningslagrets jobb, aldrig översättningens.
*/
export function numbersPreserved(sourceTexts: string[], targetTexts: string[]): boolean {
const extract = (texts: string[]) => {
const counts = new Map<string, number>();
for (const m of texts.join(" ").matchAll(/\d+(?:[.,]\d+)?/g)) {
const key = m[0].replace(",", ".");
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
};
const src = extract(sourceTexts);
const tgt = extract(targetTexts);
for (const [num, count] of src) {
if ((tgt.get(num) ?? 0) < count) return false;
}
return true;
}
+304
View File
@@ -0,0 +1,304 @@
import { and, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { schema } from "@app/database";
import {
computeCoverage,
checkRecipeSafety,
isRecipeSafe,
type IngredientSafetyInfo,
type PantryItem,
} from "@app/recipe-engine";
import type { MealType } from "@app/shared-types";
import type { WorkerContext } from "../context.js";
interface WeekPlanJobInput {
weekPlanId: string;
householdId: string;
userId: string;
input: {
weekStartDate: string;
daysToPlann?: number;
mealTypes: MealType[];
portionsPerMeal?: number;
budgetMinorTotal?: number;
preferLeftoversFirst: boolean;
varietyLevel: "low" | "medium" | "high";
};
}
/**
* Veckoplansgenerering (spec §25). Deterministisk kärna:
* 1. Matlådor planeras först (spec §24) när preferLeftoversFirst.
* 2. Recept väljs på täckning + utgångsdatum + variation + budget.
* 3. Samma recept upprepas inte inom planen (styrs av varietyLevel).
* AAMOS (GENERATE_WEEK_PLAN) kan förfina ordningen men aldrig bryta reglerna.
*/
export async function processGenerateWeekPlan(
ctx: WorkerContext,
data: WeekPlanJobInput,
): Promise<void> {
const { weekPlanId, householdId } = data;
const [plan] = await ctx.db
.select()
.from(schema.weekPlans)
.where(eq(schema.weekPlans.id, weekPlanId))
.limit(1);
if (!plan) return;
const days = data.input.daysToPlann ?? 7;
const mealTypes = data.input.mealTypes;
const portions = data.input.portionsPerMeal ?? (await defaultPortions(ctx, householdId));
// --- Lager & säkerhet ---
const stockRows = await ctx.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 },
]),
);
const members = await ctx.db
.select({ userId: schema.householdMembers.userId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.householdId, householdId));
const prefs = await ctx.db
.select()
.from(schema.userPreferences)
.where(
inArray(
schema.userPreferences.userId,
members.map((m) => m.userId),
),
);
const combinedAllergens = [...new Set(prefs.flatMap((p) => p.allergens))];
const combinedAvoid = [...new Set(prefs.flatMap((p) => p.avoidIngredientIds))];
const strictestSpice = prefs.length > 0 ? Math.min(...prefs.map((p) => p.spiceLevelMax)) : 5;
// --- Kandidater ---
const candidates = await ctx.db
.select()
.from(schema.recipes)
.where(and(eq(schema.recipes.status, "published")))
.limit(300);
const allIngredients = await ctx.db
.select()
.from(schema.recipeIngredients)
.where(
inArray(
schema.recipeIngredients.recipeId,
candidates.map((c) => c.id),
),
);
const safetyRows = await ctx.db
.select()
.from(schema.canonicalIngredients)
.where(
inArray(schema.canonicalIngredients.id, [
...new Set(allIngredients.map((i) => i.canonicalIngredientId)),
]),
);
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 });
}
const scored = candidates
.filter((recipe) => {
const ings = allIngredients.filter((i) => i.recipeId === recipe.id);
const violations = checkRecipeSafety(
{
ingredients: ings.map((i) => ({
canonicalIngredientId: i.canonicalIngredientId,
optional: i.optional,
})),
spiceLevel: recipe.spiceLevel,
},
{
allergens: combinedAllergens,
avoidIngredientIds: combinedAvoid,
spiceLevelMax: strictestSpice,
},
safetyMap,
);
return isRecipeSafe(violations);
})
.map((recipe) => {
const ings = 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 coverage = computeCoverage(ings, pantry, unitInfo);
const expiryBoost = coverage.expiringUsed.length > 0 ? 0.3 : 0;
const budgetOk =
data.input.budgetMinorTotal == null ||
recipe.estimatedCostMinorPerPortion == null ||
recipe.estimatedCostMinorPerPortion * portions <=
(data.input.budgetMinorTotal / (days * mealTypes.length)) * 1.5;
return {
recipe,
coverage,
score: coverage.coverage + expiryBoost + (budgetOk ? 0 : -0.5),
};
})
.sort((a, b) => b.score - a.score);
// --- Matlådor först (spec §24) ---
const mealBoxes = data.input.preferLeftoversFirst
? await ctx.db
.select()
.from(schema.mealBoxes)
.where(
and(
eq(schema.mealBoxes.householdId, householdId),
eq(schema.mealBoxes.status, "available"),
),
)
.orderBy(schema.mealBoxes.recommendedUseBy)
: [];
// --- Bygg planen ---
const usedRecipeIds = new Set<string>();
const repeatLimit = data.input.varietyLevel === "low" ? 2 : 1;
const recipeUseCount = new Map<string, number>();
let boxIndex = 0;
let candidateIndex = 0;
const entries: Array<typeof schema.weekPlanEntries.$inferInsert> = [];
for (let day = 0; day < days; day++) {
const date = new Date(Date.parse(data.input.weekStartDate) + day * 86_400_000)
.toISOString()
.slice(0, 10);
for (const mealType of mealTypes) {
// Matlåda om det finns och portionerna räcker
const box = mealBoxes[boxIndex];
if (box && box.portionsRemaining >= Math.min(portions, 2)) {
entries.push({
weekPlanId,
date,
mealType,
mealBoxId: box.id,
titleSv: `${box.titleSv} (matlåda)`,
portions: Math.min(box.portionsRemaining, portions),
status: "planned",
rescheduleReasonSv: `Matlådan bör användas senast ${box.recommendedUseBy}.`,
sortOrder: entries.length,
});
boxIndex++;
continue;
}
// Nästa bästa recept som passar måltidstyp och variationsregeln
let chosen = null;
for (let i = 0; i < scored.length; i++) {
const idx = (candidateIndex + i) % scored.length;
const candidate = scored[idx]!;
if (!candidate.recipe.mealTypes.includes(mealType)) continue;
const used = recipeUseCount.get(candidate.recipe.id) ?? 0;
if (used >= repeatLimit) continue;
chosen = candidate;
candidateIndex = idx + 1;
break;
}
if (!chosen) continue;
recipeUseCount.set(chosen.recipe.id, (recipeUseCount.get(chosen.recipe.id) ?? 0) + 1);
usedRecipeIds.add(chosen.recipe.id);
const expiring = chosen.coverage.expiringUsed[0];
entries.push({
weekPlanId,
date,
mealType,
recipeId: chosen.recipe.id,
titleSv: chosen.recipe.titleSv,
portions,
status: "planned",
rescheduleReasonSv: expiring
? `Använder ${expiring.displayNameSv.toLowerCase()} som bör ätas snart.`
: null,
sortOrder: entries.length,
});
}
}
await ctx.db
.delete(schema.weekPlanEntries)
.where(eq(schema.weekPlanEntries.weekPlanId, weekPlanId));
if (entries.length > 0) await ctx.db.insert(schema.weekPlanEntries).values(entries);
await ctx.db
.update(schema.weekPlans)
.set({ status: "draft", updatedAt: new Date() })
.where(eq(schema.weekPlans.id, weekPlanId));
await ctx.db.insert(schema.domainEvents).values({
type: "WEEK_PLAN_UPDATED",
userId: data.userId,
householdId,
payload: { weekPlanId, reason: "generated" },
});
}
async function defaultPortions(ctx: WorkerContext, householdId: string): Promise<number> {
const [row] = await ctx.db
.select({ total: sql<number>`coalesce(sum(${schema.householdMembers.portionFactor}), 2)` })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.householdId, householdId));
return Math.max(1, Math.round(Number(row?.total ?? 2)));
}