Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@app/worker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Worker – asynkrona jobb: bildanalys via AAMOS, kvitton, veckoplan, minne, notiser, subscriptions (spec §54)",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"build": "tsup src/index.ts --format esm --target node22 --sourcemap --clean",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"eval": "tsx src/eval/run.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@app/ai-contracts": "workspace:*",
|
||||
"@app/database": "workspace:*",
|
||||
"@app/events": "workspace:*",
|
||||
"@app/inventory-engine": "workspace:*",
|
||||
"@app/memory-client": "workspace:*",
|
||||
"@app/nutrition-engine": "workspace:*",
|
||||
"@app/recipe-engine": "workspace:*",
|
||||
"@app/recommendation-engine": "workspace:*",
|
||||
"@app/shared-types": "workspace:*",
|
||||
"@app/subscriptions": "workspace:*",
|
||||
"bullmq": "^6.0.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"drizzle-orm": "^0.45.0",
|
||||
"ioredis": "^6.0.0",
|
||||
"pg": "^8.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.11.0",
|
||||
"tsup": "^8.3.0"
|
||||
},
|
||||
"tsup": {
|
||||
"noExternal": [
|
||||
"@app/ai-contracts",
|
||||
"@app/database",
|
||||
"@app/events",
|
||||
"@app/inventory-engine",
|
||||
"@app/memory-client",
|
||||
"@app/nutrition-engine",
|
||||
"@app/recipe-engine",
|
||||
"@app/recommendation-engine",
|
||||
"@app/shared-types",
|
||||
"@app/subscriptions"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// Ladda .env från paketet ELLER monorepo-roten (pnpm --filter sätter cwd till paketet).
|
||||
for (const candidate of [".env", "../.env", "../../.env"]) {
|
||||
const p = path.resolve(process.cwd(), candidate);
|
||||
if (existsSync(p)) {
|
||||
loadDotenv({ path: p });
|
||||
break;
|
||||
}
|
||||
}
|
||||
import { createHmac } from "node:crypto";
|
||||
import { createDatabase, type Database } from "@app/database";
|
||||
import { createAamosClient, type AamosClient } from "@app/ai-contracts";
|
||||
|
||||
export interface WorkerContext {
|
||||
db: Database;
|
||||
aamos: AamosClient;
|
||||
/** Bygger läs-URL för lagrade bilder (samma signaturlogik som API:ts mock-S3). */
|
||||
readUrl: (key: string) => string;
|
||||
apiBaseUrl: string;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createContext(): WorkerContext {
|
||||
const { db, pool } = createDatabase();
|
||||
const aamos = createAamosClient();
|
||||
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:4000";
|
||||
const secret = process.env.ENTITLEMENT_SIGNING_SECRET ?? "dev-only-change-me-three";
|
||||
|
||||
return {
|
||||
db,
|
||||
aamos,
|
||||
apiBaseUrl,
|
||||
readUrl: (key: string) => {
|
||||
const sig = createHmac("sha256", secret).update(key).digest("hex").slice(0, 32);
|
||||
return `${apiBaseUrl}/v1/mock-s3/${encodeURIComponent(key)}?sig=${sig}`;
|
||||
},
|
||||
close: async () => {
|
||||
await pool.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { AamosTaskType, TaskInput, TaskOutput } from "@app/ai-contracts";
|
||||
import { numbersPreserved } from "../processors/translation.js";
|
||||
|
||||
/**
|
||||
* Utvärderingssvit för AAMOS (spec §40 – "ingen modell-/promptändring i
|
||||
* produktion utan godkänd utvärdering").
|
||||
*
|
||||
* Golden cases med SEMANTISKA kontroller (inte exakt-matchning): riktiga
|
||||
* modellsvar varierar, men egenskaperna nedan måste alltid hålla. Samma svit
|
||||
* körs mot mocken i dev/CI och mot riktiga AAMOS före varje produktionsbyte –
|
||||
* bara AAMOS_MODE skiljer.
|
||||
*/
|
||||
|
||||
export interface EvalCheck {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export interface EvalCase<T extends AamosTaskType = AamosTaskType> {
|
||||
id: string;
|
||||
taskType: T;
|
||||
descriptionSv: string;
|
||||
input: TaskInput<T>;
|
||||
/** Returnerar kontrollresultat – varje kontroll är en produktegenskap. */
|
||||
verify(output: TaskOutput<T>): EvalCheck[];
|
||||
}
|
||||
|
||||
const check = (name: string, passed: boolean): EvalCheck => ({ name, passed });
|
||||
|
||||
function confidenceInRange(values: number[]): boolean {
|
||||
return values.every((c) => c >= 0 && c <= 1);
|
||||
}
|
||||
|
||||
export const EVAL_CASES: EvalCase[] = [
|
||||
{
|
||||
id: "fridge-basic",
|
||||
taskType: "ANALYZE_FRIDGE_IMAGE",
|
||||
descriptionSv: "Kylskåpsbild: objekt hittas, osäkerhet uttrycks ärligt, enheter är koder",
|
||||
input: {
|
||||
imageUrls: ["https://example.com/eval/fridge-1.jpg"],
|
||||
locationType: "fridge",
|
||||
marketLocale: "sv-SE",
|
||||
knownItems: [],
|
||||
},
|
||||
verify(output) {
|
||||
const o = output as TaskOutput<"ANALYZE_FRIDGE_IMAGE">;
|
||||
return [
|
||||
check("minst ett objekt detekteras", o.items.length >= 1),
|
||||
check("alla confidence i [0,1]", confidenceInRange(o.items.map((i) => i.confidence))),
|
||||
check(
|
||||
"osäkra objekt kräver bekräftelse (spec §61.4)",
|
||||
o.items.every((i) => i.confidence >= 0.7 || i.requiresConfirmation),
|
||||
),
|
||||
check(
|
||||
"okänd vara tillåts vara okänd – aldrig påhittat id med hög confidence",
|
||||
o.items.every((i) => i.canonicalIngredientId !== null || i.confidence <= 0.9),
|
||||
),
|
||||
check(
|
||||
"kvantiteter är positiva när de anges",
|
||||
o.items.every((i) => i.estimatedQuantity == null || i.estimatedQuantity > 0),
|
||||
),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "receipt-consistency",
|
||||
taskType: "READ_RECEIPT",
|
||||
descriptionSv: "Kvitto: belopp i minor units, radsumma stämmer med totalen",
|
||||
input: { imageUrls: ["https://example.com/eval/receipt-1.jpg"], marketLocale: "sv-SE" },
|
||||
verify(output) {
|
||||
const o = output as TaskOutput<"READ_RECEIPT">;
|
||||
const lineSum = o.lines
|
||||
.filter((l) => !l.isDiscount)
|
||||
.reduce((s, l) => s + (l.totalPriceMinor ?? 0), 0);
|
||||
const total = o.totalMinor ?? 0;
|
||||
return [
|
||||
check("minst en kvittorad", o.lines.length >= 1),
|
||||
check(
|
||||
"alla belopp är heltal (minor units, i18n §20)",
|
||||
o.lines.every(
|
||||
(l) =>
|
||||
(l.unitPriceMinor == null || Number.isInteger(l.unitPriceMinor)) &&
|
||||
(l.totalPriceMinor == null || Number.isInteger(l.totalPriceMinor)),
|
||||
) && Number.isInteger(total),
|
||||
),
|
||||
check(
|
||||
"radsumma ≈ total (±5 % för avrundning/pant/rabatter)",
|
||||
total === 0 || Math.abs(lineSum - (o.discountTotalMinor ?? 0) - total) / total <= 0.05,
|
||||
),
|
||||
check("rad-confidence i [0,1]", confidenceInRange(o.lines.map((l) => l.confidence))),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "craving-swedish",
|
||||
taskType: "PARSE_CRAVING",
|
||||
descriptionSv: "Fritext-sug på svenska blir strukturerade filter",
|
||||
input: { text: "något krämigt med kyckling under 500 kcal", marketLocale: "sv-SE" },
|
||||
verify(output) {
|
||||
const o = output as TaskOutput<"PARSE_CRAVING">;
|
||||
return [
|
||||
check(
|
||||
"tolkningen är inte tom (taggar/kök/kcal)",
|
||||
o.tags.length > 0 || o.cuisine != null || o.maxKcal != null,
|
||||
),
|
||||
check("maxKcal rimlig när satt", o.maxKcal == null || (o.maxKcal > 0 && o.maxKcal <= 3000)),
|
||||
check("confidence i [0,1]", confidenceInRange([o.confidence])),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "translate-preserves-structure",
|
||||
taskType: "TRANSLATE_RECIPE",
|
||||
descriptionSv: "Översättning: stegantal och alla tal bevaras (M3-verifieringen)",
|
||||
input: {
|
||||
sourceLanguageTag: "sv",
|
||||
targetLanguageTag: "en",
|
||||
title: "Ugnspannkaka med 225 g fläsk",
|
||||
description: "Klassiker i 200 grader.",
|
||||
storageGuidance: null,
|
||||
steps: [
|
||||
{ stepNumber: 1, instruction: "Sätt ugnen på 200 grader.", tip: null },
|
||||
{ stepNumber: 2, instruction: "Vispa 4 ägg med 6 dl mjölk i 2 minuter.", tip: null },
|
||||
],
|
||||
},
|
||||
verify(output) {
|
||||
const o = output as TaskOutput<"TRANSLATE_RECIPE">;
|
||||
const sourceTexts = [
|
||||
"Ugnspannkaka med 225 g fläsk",
|
||||
"Klassiker i 200 grader.",
|
||||
"Sätt ugnen på 200 grader.",
|
||||
"Vispa 4 ägg med 6 dl mjölk i 2 minuter.",
|
||||
];
|
||||
const targetTexts = [o.title, o.description ?? "", ...o.steps.map((s) => s.instruction)];
|
||||
return [
|
||||
check("stegantal bevarat", o.steps.length === 2),
|
||||
check("stegnummer bevarade", o.steps.map((s) => s.stepNumber).join(",") === "1,2"),
|
||||
check(
|
||||
"alla tal bevarade (aldrig konverterade)",
|
||||
numbersPreserved(sourceTexts, targetTexts),
|
||||
),
|
||||
check("titel ej tom", o.title.trim().length > 0),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nutrition-label",
|
||||
taskType: "READ_NUTRITION_LABEL",
|
||||
descriptionSv: "Näringsetikett: värden per 100 med rimliga intervall",
|
||||
input: { imageUrls: ["https://example.com/eval/label-1.jpg"], marketLocale: "sv-SE" },
|
||||
verify(output) {
|
||||
const o = output as TaskOutput<"READ_NUTRITION_LABEL">;
|
||||
const n = o.values ?? { kcal: null, proteinG: null, carbsG: null, fatG: null };
|
||||
return [
|
||||
check("basis identifierad", o.basis != null),
|
||||
check("kcal rimlig när satt (0–900/100)", n.kcal == null || (n.kcal >= 0 && n.kcal <= 900)),
|
||||
check(
|
||||
"makron icke-negativa när satta",
|
||||
[n.proteinG, n.carbsG, n.fatG].every((v) => v == null || v >= 0),
|
||||
),
|
||||
check(
|
||||
"makron ryms i 100 g när alla satta",
|
||||
n.proteinG == null || n.carbsG == null || n.fatG == null
|
||||
? true
|
||||
: n.proteinG + n.carbsG + n.fatG <= 110,
|
||||
),
|
||||
check("confidence i [0,1]", confidenceInRange([o.confidence])),
|
||||
];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "moderation-safe-recipe",
|
||||
taskType: "MODERATE_RECIPE",
|
||||
descriptionSv: "Ofarligt recept flaggas inte som reject",
|
||||
input: {
|
||||
titleSv: "Havregrynsgröt",
|
||||
descriptionSv: "Enkel frukostgröt.",
|
||||
ingredients: ["1 DECILITER havregryn", "2 DECILITER vatten", "1 PINCH salt"],
|
||||
steps: ["Koka upp vatten.", "Rör ner gryn och sjud 3 minuter."],
|
||||
},
|
||||
verify(output) {
|
||||
const o = output as TaskOutput<"MODERATE_RECIPE">;
|
||||
return [
|
||||
check("rekommendationen är inte reject", o.recommendation !== "reject"),
|
||||
check(
|
||||
"inga reject-flaggor",
|
||||
o.flags.every((f) => f.severity !== "reject"),
|
||||
),
|
||||
check("confidence i [0,1]", confidenceInRange([o.confidence])),
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,93 @@
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
for (const candidate of [".env", "../.env", "../../.env"]) {
|
||||
const p = path.resolve(process.cwd(), candidate);
|
||||
if (existsSync(p)) {
|
||||
loadDotenv({ path: p });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
import { schema } from "@app/database";
|
||||
import { createContext } from "../context.js";
|
||||
import { EVAL_CASES } from "./cases.js";
|
||||
|
||||
/**
|
||||
* Kör AAMOS-utvärderingssviten (spec §40).
|
||||
*
|
||||
* pnpm --filter @app/worker eval # mot AAMOS_MODE i .env (mock i dev)
|
||||
* AAMOS_MODE=http AAMOS_API_URL=… pnpm --filter @app/worker eval # mot riktig AAMOS
|
||||
*
|
||||
* Resultatet skrivs till ai_eval_runs (syns i adminpanelen) och processen
|
||||
* avslutas med exitkod 1 om någon kontroll faller – körbar som CI-grind
|
||||
* inför varje modell-/promptbyte i AAMOS.
|
||||
*/
|
||||
async function main() {
|
||||
const ctx = createContext();
|
||||
const mode = process.env.AAMOS_MODE ?? "mock";
|
||||
console.log(`[eval] AAMOS-utvärdering – läge: ${mode}, ${EVAL_CASES.length} fall\n`);
|
||||
|
||||
let totalChecks = 0;
|
||||
let failedChecks = 0;
|
||||
|
||||
for (const evalCase of EVAL_CASES) {
|
||||
const started = Date.now();
|
||||
let checks: { name: string; passed: boolean }[] = [];
|
||||
let error: string | null = null;
|
||||
try {
|
||||
const result = await ctx.aamos.runTask(evalCase.taskType, evalCase.input as never);
|
||||
if (result.status !== "ok" || !result.output) {
|
||||
error = `status=${result.status}`;
|
||||
checks = [{ name: "AAMOS svarade ok", passed: false }];
|
||||
} else {
|
||||
checks = [
|
||||
{ name: "AAMOS svarade ok", passed: true },
|
||||
...evalCase.verify(result.output as never),
|
||||
];
|
||||
}
|
||||
} catch (err) {
|
||||
error = (err as Error).message;
|
||||
checks = [{ name: "AAMOS svarade ok", passed: false }];
|
||||
}
|
||||
const ms = Date.now() - started;
|
||||
const passed = checks.every((c) => c.passed);
|
||||
totalChecks += checks.length;
|
||||
failedChecks += checks.filter((c) => !c.passed).length;
|
||||
|
||||
console.log(`${passed ? "✓" : "✗"} ${evalCase.id} (${evalCase.taskType}, ${ms} ms)`);
|
||||
for (const c of checks) if (!c.passed) console.log(` ✗ ${c.name}`);
|
||||
if (error) console.log(` fel: ${error}`);
|
||||
|
||||
await ctx.db.insert(schema.aiEvalRuns).values({
|
||||
taskType: evalCase.taskType,
|
||||
modelVersion: `aamos-${mode}`,
|
||||
promptVersion: "eval-suite-v1",
|
||||
metrics: {
|
||||
caseId: evalCase.id,
|
||||
durationMs: ms,
|
||||
checks,
|
||||
...(error ? { error } : {}),
|
||||
},
|
||||
passed,
|
||||
notes: evalCase.descriptionSv,
|
||||
});
|
||||
}
|
||||
|
||||
const failedCases = failedChecks > 0;
|
||||
console.log(
|
||||
`\n[eval] ${EVAL_CASES.length} fall, ${totalChecks} kontroller, ${failedChecks} fallerade.`,
|
||||
);
|
||||
console.log(
|
||||
failedCases
|
||||
? "[eval] UNDERKÄND – åtgärda innan modell-/promptbytet tas i produktion (spec §40)."
|
||||
: "[eval] GODKÄND – resultatet är loggat i ai_eval_runs.",
|
||||
);
|
||||
await ctx.close();
|
||||
process.exit(failedCases ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[eval] KRASCH:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import "dotenv/config";
|
||||
import { Queue, Worker, type Job } from "bullmq";
|
||||
import IORedis from "ioredis";
|
||||
import { createContext } from "./context.js";
|
||||
import { processScanJob } from "./processors/scans.js";
|
||||
import { processModerateRecipe } from "./processors/moderation.js";
|
||||
import { processTranslateRecipe } from "./processors/translation.js";
|
||||
import { processGenerateWeekPlan } from "./processors/weekplan.js";
|
||||
import {
|
||||
processExpiryNotifications,
|
||||
processMealBoxReminders,
|
||||
processMemorySync,
|
||||
processOutbox,
|
||||
processRetention,
|
||||
processStoreNotification,
|
||||
processSubscriptionSweep,
|
||||
processTrainingExport,
|
||||
} from "./processors/maintenance.js";
|
||||
|
||||
/**
|
||||
* workern. Konsumerar kön "<slug>-jobs" (spec §50):
|
||||
* App → signed S3 upload → API job → worker → AAMOS → result → app
|
||||
*
|
||||
* Jobbnamn = jobbtyp (spec §54). Deterministiska jobb rör aldrig AAMOS;
|
||||
* AI-jobb går alltid via de typade kontrakten i @app/ai-contracts.
|
||||
*/
|
||||
|
||||
import { JOB_QUEUE_NAME as QUEUE_NAME } from "@app/shared-types";
|
||||
const connection = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
const ctx = createContext();
|
||||
|
||||
const worker = new Worker(
|
||||
QUEUE_NAME,
|
||||
async (job: Job) => {
|
||||
const data = job.data as Record<string, unknown>;
|
||||
const jobType = (data.jobType as string) ?? job.name;
|
||||
log(`▶ ${jobType} (${job.id})`);
|
||||
|
||||
switch (jobType) {
|
||||
case "ANALYZE_FRIDGE_IMAGE":
|
||||
case "ANALYZE_PANTRY_IMAGE":
|
||||
case "ANALYZE_MEAL_IMAGE":
|
||||
case "READ_RECEIPT":
|
||||
case "READ_NUTRITION_LABEL":
|
||||
case "READ_EXPIRY_DATE":
|
||||
return processScanJob(ctx, String(data.scanJobId));
|
||||
|
||||
case "MODERATE_RECIPE":
|
||||
return processModerateRecipe(ctx, String(data.recipeId));
|
||||
|
||||
case "TRANSLATE_RECIPE":
|
||||
return processTranslateRecipe(ctx, {
|
||||
recipeId: String(data.recipeId),
|
||||
targetLanguageTag: String(data.targetLanguageTag),
|
||||
});
|
||||
|
||||
case "GENERATE_WEEK_PLAN":
|
||||
return processGenerateWeekPlan(ctx, data as never);
|
||||
|
||||
case "PROCESS_STORE_NOTIFICATION":
|
||||
return processStoreNotification(ctx, String(data.notificationId));
|
||||
|
||||
case "SEND_EXPIRY_NOTIFICATION": {
|
||||
const expiry = await processExpiryNotifications(ctx);
|
||||
const boxes = await processMealBoxReminders(ctx);
|
||||
log(`Notiser: ${expiry} bäst före, ${boxes} matlådor`);
|
||||
return;
|
||||
}
|
||||
|
||||
case "UPDATE_USER_MEMORY": {
|
||||
const updates = await processMemorySync(ctx);
|
||||
log(`Minnesuppdateringar: ${updates}`);
|
||||
return;
|
||||
}
|
||||
|
||||
case "VERIFY_SUBSCRIPTION": {
|
||||
const expired = await processSubscriptionSweep(ctx);
|
||||
log(`Prenumerationssvep: ${expired} markerade som utgångna`);
|
||||
return;
|
||||
}
|
||||
|
||||
case "BUILD_TRAINING_SAMPLE": {
|
||||
const exported = await processTrainingExport(ctx);
|
||||
log(`Träningsexport: ${exported} korrigeringar (med samtycke)`);
|
||||
return;
|
||||
}
|
||||
|
||||
case "RUN_RETENTION": {
|
||||
const removed = await processRetention(ctx);
|
||||
log(`Retention: ${JSON.stringify(removed)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
case "PUBLISH_OUTBOX": {
|
||||
const published = await processOutbox(ctx);
|
||||
if (published > 0) log(`Outbox: ${published} events publicerade`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deterministiska/planerade jobb som inte kräver egen processor ännu
|
||||
case "NORMALIZE_PRODUCTS":
|
||||
case "DEDUPLICATE_INVENTORY":
|
||||
case "CALCULATE_NUTRITION":
|
||||
case "GENERATE_RECIPE_OPTIONS":
|
||||
case "RANK_RECIPES":
|
||||
case "RUN_AI_EVALUATION":
|
||||
log(`${jobType}: hanteras synkront i API:t eller aktiveras i senare fas`);
|
||||
return;
|
||||
|
||||
default:
|
||||
throw new Error(`Okänd jobbtyp: ${jobType}`);
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: Number(process.env.WORKER_CONCURRENCY ?? 5),
|
||||
},
|
||||
);
|
||||
|
||||
worker.on("completed", (job) => log(`✓ ${job.name} (${job.id})`));
|
||||
worker.on("failed", (job, err) => {
|
||||
console.error(`✗ ${job?.name} (${job?.id}): ${err.message}`);
|
||||
void alertWebhook(`Worker-jobb misslyckades: ${job?.name} (${job?.id}): ${err.message}`);
|
||||
});
|
||||
|
||||
/** Larm till valfri webhook (Slack/Discord/Teams …) – fire-and-forget. */
|
||||
async function alertWebhook(text: string): Promise<void> {
|
||||
const url = process.env.ERROR_WEBHOOK_URL;
|
||||
if (!url) return;
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch {
|
||||
// Larmet får aldrig fälla arbetsflödet.
|
||||
}
|
||||
}
|
||||
|
||||
// --- Återkommande jobb via BullMQ Job Schedulers (spec §54) ---
|
||||
const queue = new Queue(QUEUE_NAME, { connection });
|
||||
async function registerRepeatableJobs() {
|
||||
await queue.upsertJobScheduler(
|
||||
"scheduler-outbox",
|
||||
{ every: 30_000 },
|
||||
{ name: "PUBLISH_OUTBOX", data: { jobType: "PUBLISH_OUTBOX" } },
|
||||
);
|
||||
await queue.upsertJobScheduler(
|
||||
"scheduler-expiry",
|
||||
{ pattern: "0 7 * * *", tz: "Europe/Stockholm" },
|
||||
{ name: "SEND_EXPIRY_NOTIFICATION", data: { jobType: "SEND_EXPIRY_NOTIFICATION" } },
|
||||
);
|
||||
await queue.upsertJobScheduler(
|
||||
"scheduler-memory",
|
||||
{ pattern: "30 3 * * *", tz: "Europe/Stockholm" },
|
||||
{ name: "UPDATE_USER_MEMORY", data: { jobType: "UPDATE_USER_MEMORY" } },
|
||||
);
|
||||
await queue.upsertJobScheduler(
|
||||
"scheduler-subs",
|
||||
{ pattern: "15 4 * * *", tz: "Europe/Stockholm" },
|
||||
{ name: "VERIFY_SUBSCRIPTION", data: { jobType: "VERIFY_SUBSCRIPTION" } },
|
||||
);
|
||||
await queue.upsertJobScheduler(
|
||||
"scheduler-retention",
|
||||
{ pattern: "45 4 * * *", tz: "Europe/Stockholm" },
|
||||
{ name: "RUN_RETENTION", data: { jobType: "RUN_RETENTION" } },
|
||||
);
|
||||
await queue.upsertJobScheduler(
|
||||
"scheduler-training",
|
||||
{ pattern: "45 2 * * 0", tz: "Europe/Stockholm" },
|
||||
{ name: "BUILD_TRAINING_SAMPLE", data: { jobType: "BUILD_TRAINING_SAMPLE" } },
|
||||
);
|
||||
}
|
||||
|
||||
registerRepeatableJobs()
|
||||
.then(() => log("Worker igång. Väntar på jobb …"))
|
||||
.catch((err) => {
|
||||
console.error("Kunde inte registrera återkommande jobb:", err);
|
||||
});
|
||||
|
||||
const shutdown = async () => {
|
||||
log("Stänger ner …");
|
||||
await worker.close();
|
||||
await queue.close();
|
||||
connection.disconnect();
|
||||
await ctx.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGTERM", () => void shutdown());
|
||||
process.on("SIGINT", () => void shutdown());
|
||||
|
||||
function log(msg: string) {
|
||||
console.log(`[worker ${new Date().toISOString()}] ${msg}`);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { schema } from "@app/database";
|
||||
import { DEFAULT_LOCALE_PREFERENCES, toLocaleContext, type LocaleContext } from "@app/shared-types";
|
||||
import type { WorkerContext } from "./context.js";
|
||||
|
||||
/** LocaleContext för AAMOS-anrop i workern (i18n-spec §22). SE-defaults som fallback. */
|
||||
export async function getLocaleContext(
|
||||
ctx: WorkerContext,
|
||||
userId: string | null | undefined,
|
||||
): Promise<LocaleContext> {
|
||||
if (!userId) return toLocaleContext(DEFAULT_LOCALE_PREFERENCES);
|
||||
const [row] = await ctx.db
|
||||
.select()
|
||||
.from(schema.userLocalePreferences)
|
||||
.where(eq(schema.userLocalePreferences.userId, userId))
|
||||
.limit(1);
|
||||
if (!row) return toLocaleContext(DEFAULT_LOCALE_PREFERENCES);
|
||||
return {
|
||||
languageTag: row.languageTag,
|
||||
regionCode: row.regionCode,
|
||||
timeZone: row.timeZone,
|
||||
measurementSystem: row.measurementSystem,
|
||||
temperatureUnit: row.temperatureUnit,
|
||||
currencyCode: row.currencyCode,
|
||||
};
|
||||
}
|
||||
@@ -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 §32–33): 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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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 §13–14, 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;
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user