ci: trigga på master + formatfix inför Gitea Actions
CI / Typecheck, test & build (push) Failing after 2s

This commit is contained in:
Sven (AAMOS AI)
2026-08-13 17:25:14 +07:00
parent 61d60931ad
commit c32a7e33c7
141 changed files with 40555 additions and 45210 deletions
+1 -3
View File
@@ -121,9 +121,7 @@ export class HttpAamosClient implements AamosClient {
anonymizedImprovement: false,
imageTraining: false,
},
...(options.systemInstruction
? { systemInstruction: options.systemInstruction }
: {}),
...(options.systemInstruction ? { systemInstruction: options.systemInstruction } : {}),
},
};
+161 -91
View File
@@ -31,70 +31,86 @@ function customNodeFetch(input: string | URL | Request, init?: RequestInit): Pro
return requestOnce(input, init, 5);
}
function requestOnce(input: string | URL | Request, init: RequestInit | undefined, redirectsLeft: number): Promise<Response> {
function requestOnce(
input: string | URL | Request,
init: RequestInit | undefined,
redirectsLeft: number,
): Promise<Response> {
return new Promise((resolve, reject) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const u = new URL(url);
const isHttps = u.protocol === "https:";
const mod = isHttps ? import("node:https") : import("node:http");
mod.then((httpMod) => {
const postData = init?.body ? String(init.body) : undefined;
const headers: Record<string, string> = {};
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((v, k) => { headers[k] = v; });
} else if (Array.isArray(init.headers)) {
(init.headers as [string, string][]).forEach(([k, v]) => { if (k != null) headers[k] = v; });
} else {
Object.assign(headers, init.headers as Record<string, string>);
}
}
if (postData && !headers["content-length"]) {
headers["content-length"] = String(Buffer.byteLength(postData));
}
if (!headers["user-agent"]) {
headers["user-agent"] = "Mozilla/5.0 (compatible; AAMOS-Gateway/1.0)";
}
const req = httpMod.request(
{
hostname: u.hostname,
port: u.port || (isHttps ? 443 : 80),
path: u.pathname + u.search,
method: init?.method || "GET",
headers,
timeout: 120_000,
},
(res) => {
const status = res.statusCode ?? 200;
const location = res.headers.location;
if (status >= 300 && status < 400 && location && redirectsLeft > 0) {
const nextUrl = new URL(location, url).toString();
requestOnce(nextUrl, { ...init, method: "GET", body: undefined }, redirectsLeft - 1)
.then(resolve)
.catch(reject);
return;
mod
.then((httpMod) => {
const postData = init?.body ? String(init.body) : undefined;
const headers: Record<string, string> = {};
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((v, k) => {
headers[k] = v;
});
} else if (Array.isArray(init.headers)) {
(init.headers as [string, string][]).forEach(([k, v]) => {
if (k != null) headers[k] = v;
});
} else {
Object.assign(headers, init.headers as Record<string, string>);
}
}
if (postData && !headers["content-length"]) {
headers["content-length"] = String(Buffer.byteLength(postData));
}
if (!headers["user-agent"]) {
headers["user-agent"] = "Mozilla/5.0 (compatible; AAMOS-Gateway/1.0)";
}
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => { chunks.push(chunk); });
res.on("end", () => {
const body = Buffer.concat(chunks);
resolve(
new Response(body, {
status,
statusText: res.statusMessage ?? "OK",
headers: new Headers(Object.entries(res.headers).map(([k, v]) => [k, String(v)])),
}),
);
});
},
);
req.on("error", (err) => reject(err));
req.on("timeout", () => { req.destroy(); reject(new Error("Request timeout")); });
if (postData) req.write(postData);
req.end();
}).catch(reject);
const req = httpMod.request(
{
hostname: u.hostname,
port: u.port || (isHttps ? 443 : 80),
path: u.pathname + u.search,
method: init?.method || "GET",
headers,
timeout: 120_000,
},
(res) => {
const status = res.statusCode ?? 200;
const location = res.headers.location;
if (status >= 300 && status < 400 && location && redirectsLeft > 0) {
const nextUrl = new URL(location, url).toString();
requestOnce(nextUrl, { ...init, method: "GET", body: undefined }, redirectsLeft - 1)
.then(resolve)
.catch(reject);
return;
}
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
res.on("end", () => {
const body = Buffer.concat(chunks);
resolve(
new Response(body, {
status,
statusText: res.statusMessage ?? "OK",
headers: new Headers(Object.entries(res.headers).map(([k, v]) => [k, String(v)])),
}),
);
});
},
);
req.on("error", (err) => reject(err));
req.on("timeout", () => {
req.destroy();
reject(new Error("Request timeout"));
});
if (postData) req.write(postData);
req.end();
})
.catch(reject);
});
}
@@ -163,9 +179,7 @@ const geminiFridgeResponseSchema = z.object({
konfidens: z.number().min(0).max(1),
}),
),
imageQualityIssues: z
.array(z.enum(["dark", "blurry", "occlusion", "too_far"]))
.default([]),
imageQualityIssues: z.array(z.enum(["dark", "blurry", "occlusion", "too_far"])).default([]),
});
function parseDate(value: string | null | undefined): string | null {
@@ -184,14 +198,19 @@ function parseDate(value: string | null | undefined): string | null {
const c = parseInt(cStr, 10);
if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c)) return null;
// YYYY-MM-DD
if (a > 2000 && b <= 12 && c <= 31) return `${a}-${String(b).padStart(2, "0")}-${String(c).padStart(2, "0")}`;
if (a > 2000 && b <= 12 && c <= 31)
return `${a}-${String(b).padStart(2, "0")}-${String(c).padStart(2, "0")}`;
// DD-MM-YYYY
if (c > 2000 && b <= 12 && a <= 31) return `${c}-${String(b).padStart(2, "0")}-${String(a).padStart(2, "0")}`;
if (c > 2000 && b <= 12 && a <= 31)
return `${c}-${String(b).padStart(2, "0")}-${String(a).padStart(2, "0")}`;
}
return null;
}
function parseQuantity(value: string | number | null | undefined): { quantity: number | null; unit: string | null } {
function parseQuantity(value: string | number | null | undefined): {
quantity: number | null;
unit: string | null;
} {
if (value == null) return { quantity: null, unit: null };
if (typeof value === "number") return { quantity: value, unit: null };
const text = String(value).trim().replace(/,/g, ".");
@@ -284,17 +303,35 @@ export class GeminiAamosClient implements AamosClient {
switch (taskType) {
case "ANALYZE_FRIDGE_IMAGE":
case "ANALYZE_PANTRY_IMAGE":
return this.analyzeStorageImage(taskType, parsedInput.data as TaskInput<"ANALYZE_FRIDGE_IMAGE">, options) as Promise<AamosResult<T>>;
return this.analyzeStorageImage(
taskType,
parsedInput.data as TaskInput<"ANALYZE_FRIDGE_IMAGE">,
options,
) as Promise<AamosResult<T>>;
case "GENERATE_RECIPE_CANDIDATES":
return this.generateRecipeCandidates(parsedInput.data as TaskInput<"GENERATE_RECIPE_CANDIDATES">, options) as Promise<AamosResult<T>>;
return this.generateRecipeCandidates(
parsedInput.data as TaskInput<"GENERATE_RECIPE_CANDIDATES">,
options,
) as Promise<AamosResult<T>>;
case "ANALYZE_MEAL_IMAGE":
return this.analyzeMealImage(parsedInput.data as TaskInput<"ANALYZE_MEAL_IMAGE">, options) as Promise<AamosResult<T>>;
return this.analyzeMealImage(
parsedInput.data as TaskInput<"ANALYZE_MEAL_IMAGE">,
options,
) as Promise<AamosResult<T>>;
case "READ_NUTRITION_LABEL":
return this.readNutritionLabel(parsedInput.data as TaskInput<"READ_NUTRITION_LABEL">, options) as Promise<AamosResult<T>>;
return this.readNutritionLabel(
parsedInput.data as TaskInput<"READ_NUTRITION_LABEL">,
options,
) as Promise<AamosResult<T>>;
case "READ_EXPIRY_DATE":
return this.readExpiryDate(parsedInput.data as TaskInput<"READ_EXPIRY_DATE">, options) as Promise<AamosResult<T>>;
return this.readExpiryDate(
parsedInput.data as TaskInput<"READ_EXPIRY_DATE">,
options,
) as Promise<AamosResult<T>>;
case "READ_RECEIPT":
return this.readReceipt(parsedInput.data as TaskInput<"READ_RECEIPT">, options) as Promise<AamosResult<T>>;
return this.readReceipt(parsedInput.data as TaskInput<"READ_RECEIPT">, options) as Promise<
AamosResult<T>
>;
default:
return {
status: "failed",
@@ -304,7 +341,10 @@ export class GeminiAamosClient implements AamosClient {
}
}
private async fetchOneImage(url: string, timeoutMs: number): Promise<{ inline_data: { mime_type: string; data: string } }> {
private async fetchOneImage(
url: string,
timeoutMs: number,
): Promise<{ inline_data: { mime_type: string; data: string } }> {
if (url.startsWith("data:")) {
const match = url.match(/^data:([^;]+);base64,(.+)$/);
if (match && match[1] && match[2]) {
@@ -329,7 +369,9 @@ export class GeminiAamosClient implements AamosClient {
const imageCount = Math.min(input.imageUrls.length, 6);
const estimatedCostUsd = imageCount * COST_ESTIMATE_PER_IMAGE_USD;
if (await this.isOverBudget(estimatedCostUsd)) {
console.warn("[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.");
console.warn(
"[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.",
);
}
const imageParts = await Promise.all(
@@ -372,7 +414,11 @@ export class GeminiAamosClient implements AamosClient {
const geminiBody = (await res.json()) as {
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number };
usageMetadata?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
};
};
const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
@@ -381,7 +427,11 @@ export class GeminiAamosClient implements AamosClient {
const json = JSON.parse(text);
const safe = geminiFridgeResponseSchema.safeParse(json);
if (!safe.success) {
return { status: "failed", output: null, error: `Gemini-svar matchar inte schema: ${safe.error.message}` };
return {
status: "failed",
output: null,
error: `Gemini-svar matchar inte schema: ${safe.error.message}`,
};
}
parsed = safe.data;
} catch {
@@ -399,7 +449,10 @@ export class GeminiAamosClient implements AamosClient {
return {
status: items.length > 0 ? "ok" : "uncertain",
output: { items, imageQualityIssues: parsed.imageQualityIssues } as TaskOutput<"ANALYZE_FRIDGE_IMAGE">,
output: {
items,
imageQualityIssues: parsed.imageQualityIssues,
} as TaskOutput<"ANALYZE_FRIDGE_IMAGE">,
modelVersion: this.cfg.model,
promptVersion: this.cfg.promptVersion,
latencyMs,
@@ -426,7 +479,9 @@ export class GeminiAamosClient implements AamosClient {
const imageCount = Math.min(imageUrls.length, 6);
const estimatedCostUsd = imageCount * costEstimatePerImage;
if (await this.isOverBudget(estimatedCostUsd)) {
console.warn("[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.");
console.warn(
"[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.",
);
}
const imageParts = await this.fetchImageParts(imageUrls, this.cfg.timeoutMs);
@@ -465,7 +520,11 @@ export class GeminiAamosClient implements AamosClient {
const geminiBody = (await res.json()) as {
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number };
usageMetadata?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
};
};
const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
@@ -587,9 +646,7 @@ export class GeminiAamosClient implements AamosClient {
imageUrls: string[],
timeoutMs: number,
): Promise<Array<{ inline_data: { mime_type: string; data: string } }>> {
return Promise.all(
imageUrls.slice(0, 6).map((url) => this.fetchOneImage(url, timeoutMs)),
);
return Promise.all(imageUrls.slice(0, 6).map((url) => this.fetchOneImage(url, timeoutMs)));
}
private async generateRecipeCandidates(
@@ -603,7 +660,9 @@ export class GeminiAamosClient implements AamosClient {
const totalCandidates = input.targetMatrix.reduce((sum, t) => sum + t.count, 0);
const estimatedCostUsd = totalCandidates * 0.003;
if (await this.isOverBudget(estimatedCostUsd)) {
console.warn("[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.");
console.warn(
"[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.",
);
}
const prompt = this.buildRecipeGenerationPrompt(input, locale);
@@ -634,7 +693,11 @@ export class GeminiAamosClient implements AamosClient {
const geminiBody = (await res.json()) as {
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number };
usageMetadata?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
};
};
const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
@@ -644,12 +707,16 @@ export class GeminiAamosClient implements AamosClient {
// Defensiv normalisering: Gemini kan returnera rejectedPrompts som objekt istället för strängar.
if (Array.isArray(json.rejectedPrompts)) {
json.rejectedPrompts = json.rejectedPrompts.map((p: unknown) =>
typeof p === "string" ? p : JSON.stringify(p)
typeof p === "string" ? p : JSON.stringify(p),
);
}
const safe = generateRecipeCandidatesOutput.safeParse(json);
if (!safe.success) {
return { status: "failed", output: null, error: `Gemini-svar matchar inte schema: ${safe.error.message}` };
return {
status: "failed",
output: null,
error: `Gemini-svar matchar inte schema: ${safe.error.message}`,
};
}
parsed = safe.data;
} catch {
@@ -690,13 +757,16 @@ export class GeminiAamosClient implements AamosClient {
locale: LocaleContext,
): string {
const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
const catalog = input.canonicalIngredientsCatalog.map((i) =>
`- ${i.id} (${i.nameSv}, ${i.category}, enhet: ${i.defaultUnit}, vegan: ${i.isVegan}, veg: ${i.isVegetarian}, gluten: ${i.containsGluten}, laktos: ${i.containsLactose})`
).join("\n");
const catalog = input.canonicalIngredientsCatalog
.map(
(i) =>
`- ${i.id} (${i.nameSv}, ${i.category}, enhet: ${i.defaultUnit}, vegan: ${i.isVegan}, veg: ${i.isVegetarian}, gluten: ${i.containsGluten}, laktos: ${i.containsLactose})`,
)
.join("\n");
const targets = input.targetMatrix.map((t) =>
`- ${t.mealType} × ${t.mainIngredientId} × ${t.dietVariant}: ${t.count} st`
).join("\n");
const targets = input.targetMatrix
.map((t) => `- ${t.mealType} × ${t.mainIngredientId} × ${t.dietVariant}: ${t.count} st`)
.join("\n");
const constraints = input.constraints;
@@ -939,7 +1009,7 @@ Språk: ${lang}.`;
private estimateCostUsd(inputTokens: number, outputTokens: number): number {
// Gemini 2.5 Flash pricing (Aug 2025): ~$0.075/1M input, $0.30/1M output.
return inputTokens * 0.075e-6 + outputTokens * 0.30e-6;
return inputTokens * 0.075e-6 + outputTokens * 0.3e-6;
}
private async isOverBudget(estimateUsd: number): Promise<boolean> {
+24 -4
View File
@@ -220,8 +220,18 @@ export function mockOutputFor(taskType: AamosTaskType, input: unknown): unknown
case "GENERATE_RECIPE_CANDIDATES": {
// Deterministisk mock: returnerar en enkel kandidat per target-cell
const inp = input as {
targetMatrix?: Array<{ mealType: string; mainIngredientId: string; dietVariant: string; count: number }>;
canonicalIngredientsCatalog?: Array<{ id: string; nameSv: string; category: string; defaultUnit: string }>;
targetMatrix?: Array<{
mealType: string;
mainIngredientId: string;
dietVariant: string;
count: number;
}>;
canonicalIngredientsCatalog?: Array<{
id: string;
nameSv: string;
category: string;
defaultUnit: string;
}>;
};
const targets = inp.targetMatrix ?? [];
const catalog = inp.canonicalIngredientsCatalog ?? [];
@@ -265,8 +275,18 @@ export function mockOutputFor(taskType: AamosTaskType, input: unknown): unknown
},
],
steps: [
{ instructionSv: "Förbered ingredienserna.", timerSeconds: null, temperatureC: null, tip: null },
{ instructionSv: "Stek och låt koka klart.", timerSeconds: 900, temperatureC: null, tip: null },
{
instructionSv: "Förbered ingredienserna.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
instructionSv: "Stek och låt koka klart.",
timerSeconds: 900,
temperatureC: null,
tip: null,
},
],
storageGuidanceSv: "Förvara i kylskåp upp till 3 dagar.",
mealPrepFriendly: false,
+41 -28
View File
@@ -282,34 +282,47 @@ export const generateRecipeOptionsOutput = z.object({
// ---------------------------------------------------------------------------
export const generateRecipeCandidatesInput = z.object({
targetMatrix: z.array(
z.object({
mealType: z.string(),
mainIngredientId: z.string(),
dietVariant: z.enum(["standard", "vegetarian", "vegan", "gluten_free", "lactose_free"]),
count: z.number().int().min(1).max(10),
}),
).min(1).max(20),
canonicalIngredientsCatalog: z.array(
z.object({
id: z.string(),
nameSv: z.string(),
category: z.string(),
defaultUnit: unitSchema,
isVegan: z.boolean(),
isVegetarian: z.boolean(),
containsGluten: z.boolean(),
containsLactose: z.boolean(),
allergens: z.array(z.string()),
}),
).min(1),
constraints: z.object({
maxPrepTimeMinutes: z.number().int().nullable().default(60),
maxCookTimeMinutes: z.number().int().nullable().default(45),
portions: z.number().int().default(4),
spiceLevelMax: z.number().int().nullable().default(3),
avoidIngredients: z.array(z.string()).default([]),
}).default(() => ({ maxPrepTimeMinutes: 60, maxCookTimeMinutes: 45, portions: 4, spiceLevelMax: 3, avoidIngredients: [] })),
targetMatrix: z
.array(
z.object({
mealType: z.string(),
mainIngredientId: z.string(),
dietVariant: z.enum(["standard", "vegetarian", "vegan", "gluten_free", "lactose_free"]),
count: z.number().int().min(1).max(10),
}),
)
.min(1)
.max(20),
canonicalIngredientsCatalog: z
.array(
z.object({
id: z.string(),
nameSv: z.string(),
category: z.string(),
defaultUnit: unitSchema,
isVegan: z.boolean(),
isVegetarian: z.boolean(),
containsGluten: z.boolean(),
containsLactose: z.boolean(),
allergens: z.array(z.string()),
}),
)
.min(1),
constraints: z
.object({
maxPrepTimeMinutes: z.number().int().nullable().default(60),
maxCookTimeMinutes: z.number().int().nullable().default(45),
portions: z.number().int().default(4),
spiceLevelMax: z.number().int().nullable().default(3),
avoidIngredients: z.array(z.string()).default([]),
})
.default(() => ({
maxPrepTimeMinutes: 60,
maxCookTimeMinutes: 45,
portions: 4,
spiceLevelMax: 3,
avoidIngredients: [],
})),
marketLocale: z.string().default("sv-SE"),
});
+11 -6
View File
@@ -47,7 +47,10 @@ function makeFetch(imageBytes: Buffer): typeof fetch {
return new Response(imageBytes, { status: 200, headers: { "content-type": "image/jpeg" } });
}
if (urlStr.includes("/models/") && init?.method === "POST") {
return new Response(JSON.stringify(FIXTURE_RESPONSE), { status: 200, headers: { "content-type": "application/json" } });
return new Response(JSON.stringify(FIXTURE_RESPONSE), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response("not found", { status: 404 });
};
@@ -126,10 +129,7 @@ describe("GeminiAamosClient", () => {
fetchImpl: makeFetch(JPEG_BYTES),
});
const result = await client.runTask(
"DEDUPLICATE_INVENTORY",
{ items: [] },
);
const result = await client.runTask("DEDUPLICATE_INVENTORY", { items: [] });
expect(result.status).toBe("failed");
expect(result.error).toContain("DEDUPLICATE_INVENTORY");
@@ -146,7 +146,12 @@ describe("GeminiAamosClient", () => {
locationType: "fridge",
marketLocale: "sv-SE",
knownItems: [],
} as unknown as { imageUrls: string[]; locationType: string; marketLocale: string; knownItems: string[] });
} as unknown as {
imageUrls: string[];
locationType: string;
marketLocale: string;
knownItems: string[];
});
expect(result.status).toBe("failed");
expect(result.error).toContain("Kontraktsfel");
+3 -1
View File
@@ -51,7 +51,9 @@ export function createTracker(config: TrackerConfig, send: SendBatch): Tracker {
// server asked us to retry (e.g. rate limit). For validation errors
// we drop them to avoid an infinite retry loop.
const retryable = batch.filter((_, i) =>
result.errors?.some((e) => e.index === i && e.message.toLowerCase().includes("rate limit")),
result.errors?.some(
(e) => e.index === i && e.message.toLowerCase().includes("rate limit"),
),
);
queue = [...retryable, ...queue];
}
+16 -12
View File
@@ -84,8 +84,10 @@ export async function markMilestone(
const isValueConfirmedNow =
isActivatedNow &&
(before.firstCookingSessionCompletedAt != null || milestone === "firstCookingSessionCompletedAt") &&
(before.inventoryUpdatedAfterCookingAt != null || milestone === "inventoryUpdatedAfterCookingAt");
(before.firstCookingSessionCompletedAt != null ||
milestone === "firstCookingSessionCompletedAt") &&
(before.inventoryUpdatedAfterCookingAt != null ||
milestone === "inventoryUpdatedAfterCookingAt");
const valueConfirmedNow = !wasValueConfirmed && isValueConfirmedNow;
@@ -121,14 +123,16 @@ export async function getActivationState(db: Db, householdId: string): Promise<A
.from(schema.households)
.where(eq(schema.households.id, householdId));
return row ?? {
firstScanCompletedAt: null,
fifthItemConfirmedAt: null,
firstRecipeRecommendationViewedAt: null,
firstRecipeSavedOrStartedAt: null,
activatedAt: null,
firstCookingSessionCompletedAt: null,
inventoryUpdatedAfterCookingAt: null,
valueConfirmedAt: null,
};
return (
row ?? {
firstScanCompletedAt: null,
fifthItemConfirmedAt: null,
firstRecipeRecommendationViewedAt: null,
firstRecipeSavedOrStartedAt: null,
activatedAt: null,
firstCookingSessionCompletedAt: null,
inventoryUpdatedAfterCookingAt: null,
valueConfirmedAt: null,
}
);
}
+5 -2
View File
@@ -35,7 +35,8 @@ function stripSslmode(url: string): string {
*/
export function createDatabase(connectionString?: string) {
const isTest = process.env.NODE_ENV === "test" || process.env.VITEST !== undefined;
const rawUrl = connectionString ?? (isTest ? process.env.TEST_DATABASE_URL : process.env.DATABASE_URL);
const rawUrl =
connectionString ?? (isTest ? process.env.TEST_DATABASE_URL : process.env.DATABASE_URL);
if (!rawUrl) {
if (isTest) {
@@ -43,7 +44,9 @@ export function createDatabase(connectionString?: string) {
"Sätt TEST_DATABASE_URL till en dedikerad testdatabas. Tester får aldrig använda DATABASE_URL.",
);
}
throw new Error("Missing DATABASE_URL. Set it in your .env or environment before starting the app.");
throw new Error(
"Missing DATABASE_URL. Set it in your .env or environment before starting the app.",
);
}
const url = stripSslmode(rawUrl);
+217 -61
View File
@@ -37,7 +37,10 @@ export async function buildErasurePlan(db: Database, userId: string): Promise<Er
const bankImages = await db
.select({ key: schema.aiTrainingBank.imageS3Key })
.from(schema.aiTrainingBank)
.innerJoin(schema.aiCorrections, eq(schema.aiTrainingBank.correctionId, schema.aiCorrections.id))
.innerJoin(
schema.aiCorrections,
eq(schema.aiTrainingBank.correctionId, schema.aiCorrections.id),
)
.where(eq(schema.aiCorrections.userId, userId));
for (const row of bankImages) {
if (row.key) imageReferences.push(row.key);
@@ -101,12 +104,7 @@ export async function buildErasurePlan(db: Database, userId: string): Promise<Er
const draftRecipeImages = await db
.select({ urls: schema.recipes.imageUrls })
.from(schema.recipes)
.where(
and(
eq(schema.recipes.creatorUserId, userId),
ne(schema.recipes.status, "published"),
),
);
.where(and(eq(schema.recipes.creatorUserId, userId), ne(schema.recipes.status, "published")));
for (const row of draftRecipeImages) {
for (const url of row.urls ?? []) {
if (url) imageReferences.push(url);
@@ -128,167 +126,261 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.delete(schema.userCredentials)
.where(eq(schema.userCredentials.userId, userId))
.returning({ userId: schema.userCredentials.userId });
if (credDel.length) log.push({ table: getTableName(schema.userCredentials), action: "delete", count: credDel.length });
if (credDel.length)
log.push({
table: getTableName(schema.userCredentials),
action: "delete",
count: credDel.length,
});
const adminTotpDel = await db
.delete(schema.adminTotp)
.where(eq(schema.adminTotp.userId, userId))
.returning({ userId: schema.adminTotp.userId });
if (adminTotpDel.length) log.push({ table: getTableName(schema.adminTotp), action: "delete", count: adminTotpDel.length });
if (adminTotpDel.length)
log.push({
table: getTableName(schema.adminTotp),
action: "delete",
count: adminTotpDel.length,
});
const emailTokensDel = await db
.delete(schema.emailVerificationTokens)
.where(eq(schema.emailVerificationTokens.userId, userId))
.returning({ id: schema.emailVerificationTokens.id });
if (emailTokensDel.length) log.push({ table: getTableName(schema.emailVerificationTokens), action: "delete", count: emailTokensDel.length });
if (emailTokensDel.length)
log.push({
table: getTableName(schema.emailVerificationTokens),
action: "delete",
count: emailTokensDel.length,
});
const pwTokensDel = await db
.delete(schema.passwordResetTokens)
.where(eq(schema.passwordResetTokens.userId, userId))
.returning({ id: schema.passwordResetTokens.id });
if (pwTokensDel.length) log.push({ table: getTableName(schema.passwordResetTokens), action: "delete", count: pwTokensDel.length });
if (pwTokensDel.length)
log.push({
table: getTableName(schema.passwordResetTokens),
action: "delete",
count: pwTokensDel.length,
});
const refreshDel = await db
.delete(schema.refreshTokens)
.where(eq(schema.refreshTokens.userId, userId))
.returning({ id: schema.refreshTokens.id });
if (refreshDel.length) log.push({ table: getTableName(schema.refreshTokens), action: "delete", count: refreshDel.length });
if (refreshDel.length)
log.push({
table: getTableName(schema.refreshTokens),
action: "delete",
count: refreshDel.length,
});
const consentsDel = await db
.delete(schema.userConsents)
.where(eq(schema.userConsents.userId, userId))
.returning({ userId: schema.userConsents.userId });
if (consentsDel.length) log.push({ table: getTableName(schema.userConsents), action: "delete", count: consentsDel.length });
if (consentsDel.length)
log.push({
table: getTableName(schema.userConsents),
action: "delete",
count: consentsDel.length,
});
const idemDel = await db
.delete(schema.idempotencyKeys)
.where(eq(schema.idempotencyKeys.userId, userId))
.returning({ key: schema.idempotencyKeys.key });
if (idemDel.length) log.push({ table: getTableName(schema.idempotencyKeys), action: "delete", count: idemDel.length });
if (idemDel.length)
log.push({
table: getTableName(schema.idempotencyKeys),
action: "delete",
count: idemDel.length,
});
const pushDel = await db
.delete(schema.pushTokens)
.where(eq(schema.pushTokens.userId, userId))
.returning({ token: schema.pushTokens.token });
if (pushDel.length) log.push({ table: getTableName(schema.pushTokens), action: "delete", count: pushDel.length });
if (pushDel.length)
log.push({ table: getTableName(schema.pushTokens), action: "delete", count: pushDel.length });
const notifDel = await db
.delete(schema.notifications)
.where(eq(schema.notifications.userId, userId))
.returning({ id: schema.notifications.id });
if (notifDel.length) log.push({ table: getTableName(schema.notifications), action: "delete", count: notifDel.length });
if (notifDel.length)
log.push({
table: getTableName(schema.notifications),
action: "delete",
count: notifDel.length,
});
const feedbackDel = await db
.delete(schema.feedback)
.where(eq(schema.feedback.userId, userId))
.returning({ id: schema.feedback.id });
if (feedbackDel.length) log.push({ table: getTableName(schema.feedback), action: "delete", count: feedbackDel.length });
if (feedbackDel.length)
log.push({ table: getTableName(schema.feedback), action: "delete", count: feedbackDel.length });
// ---- 2. Recipes ----
const deletedRecipes = await db
.delete(schema.recipes)
.where(
and(
eq(schema.recipes.creatorUserId, userId),
ne(schema.recipes.status, "published"),
),
)
.where(and(eq(schema.recipes.creatorUserId, userId), ne(schema.recipes.status, "published")))
.returning({ id: schema.recipes.id });
if (deletedRecipes.length) log.push({ table: getTableName(schema.recipes), action: "delete", count: deletedRecipes.length });
if (deletedRecipes.length)
log.push({
table: getTableName(schema.recipes),
action: "delete",
count: deletedRecipes.length,
});
const anonymizedRecipes = await db
.update(schema.recipes)
.set({ creatorUserId: null, creatorDisplayName: null, updatedAt: new Date() })
.where(
and(
eq(schema.recipes.creatorUserId, userId),
eq(schema.recipes.status, "published"),
),
)
.where(and(eq(schema.recipes.creatorUserId, userId), eq(schema.recipes.status, "published")))
.returning({ id: schema.recipes.id });
if (anonymizedRecipes.length) log.push({ table: getTableName(schema.recipes), action: "anonymize", count: anonymizedRecipes.length });
if (anonymizedRecipes.length)
log.push({
table: getTableName(schema.recipes),
action: "anonymize",
count: anonymizedRecipes.length,
});
const ratingsDel = await db
.delete(schema.recipeRatings)
.where(eq(schema.recipeRatings.userId, userId))
.returning({ id: schema.recipeRatings.id });
if (ratingsDel.length) log.push({ table: getTableName(schema.recipeRatings), action: "delete", count: ratingsDel.length });
if (ratingsDel.length)
log.push({
table: getTableName(schema.recipeRatings),
action: "delete",
count: ratingsDel.length,
});
const favoritesDel = await db
.delete(schema.recipeFavorites)
.where(eq(schema.recipeFavorites.userId, userId))
.returning({ recipeId: schema.recipeFavorites.recipeId });
if (favoritesDel.length) log.push({ table: getTableName(schema.recipeFavorites), action: "delete", count: favoritesDel.length });
if (favoritesDel.length)
log.push({
table: getTableName(schema.recipeFavorites),
action: "delete",
count: favoritesDel.length,
});
const cooksDel = await db
.delete(schema.recipeCooks)
.where(eq(schema.recipeCooks.userId, userId))
.returning({ id: schema.recipeCooks.id });
if (cooksDel.length) log.push({ table: getTableName(schema.recipeCooks), action: "delete", count: cooksDel.length });
if (cooksDel.length)
log.push({ table: getTableName(schema.recipeCooks), action: "delete", count: cooksDel.length });
const creatorStatsDel = await db
.delete(schema.creatorStats)
.where(eq(schema.creatorStats.userId, userId))
.returning({ userId: schema.creatorStats.userId });
if (creatorStatsDel.length) log.push({ table: getTableName(schema.creatorStats), action: "delete", count: creatorStatsDel.length });
if (creatorStatsDel.length)
log.push({
table: getTableName(schema.creatorStats),
action: "delete",
count: creatorStatsDel.length,
});
const followsDel = await db
.delete(schema.creatorFollows)
.where(
or(eq(schema.creatorFollows.followerUserId, userId), eq(schema.creatorFollows.creatorUserId, userId)),
or(
eq(schema.creatorFollows.followerUserId, userId),
eq(schema.creatorFollows.creatorUserId, userId),
),
)
.returning({ followerUserId: schema.creatorFollows.followerUserId });
if (followsDel.length) log.push({ table: getTableName(schema.creatorFollows), action: "delete", count: followsDel.length });
if (followsDel.length)
log.push({
table: getTableName(schema.creatorFollows),
action: "delete",
count: followsDel.length,
});
// ---- 3. Personal content ----
const mealsDel = await db
.delete(schema.meals)
.where(eq(schema.meals.userId, userId))
.returning({ id: schema.meals.id });
if (mealsDel.length) log.push({ table: getTableName(schema.meals), action: "delete", count: mealsDel.length });
if (mealsDel.length)
log.push({ table: getTableName(schema.meals), action: "delete", count: mealsDel.length });
const foodMemDel = await db
.delete(schema.foodMemories)
.where(eq(schema.foodMemories.userId, userId))
.returning({ id: schema.foodMemories.id });
if (foodMemDel.length) log.push({ table: getTableName(schema.foodMemories), action: "delete", count: foodMemDel.length });
if (foodMemDel.length)
log.push({
table: getTableName(schema.foodMemories),
action: "delete",
count: foodMemDel.length,
});
const tasteDel = await db
.delete(schema.tasteSignals)
.where(eq(schema.tasteSignals.userId, userId))
.returning({ id: schema.tasteSignals.id });
if (tasteDel.length) log.push({ table: getTableName(schema.tasteSignals), action: "delete", count: tasteDel.length });
if (tasteDel.length)
log.push({
table: getTableName(schema.tasteSignals),
action: "delete",
count: tasteDel.length,
});
const memoryDel = await db
.delete(schema.memoryItems)
.where(eq(schema.memoryItems.userId, userId))
.returning({ id: schema.memoryItems.id });
if (memoryDel.length) log.push({ table: getTableName(schema.memoryItems), action: "delete", count: memoryDel.length });
if (memoryDel.length)
log.push({
table: getTableName(schema.memoryItems),
action: "delete",
count: memoryDel.length,
});
// ---- 4. AI / scans / training ----
const aiCorrectionsDel = await db
.delete(schema.aiCorrections)
.where(eq(schema.aiCorrections.userId, userId))
.returning({ id: schema.aiCorrections.id });
if (aiCorrectionsDel.length) log.push({ table: getTableName(schema.aiCorrections), action: "delete", count: aiCorrectionsDel.length });
if (aiCorrectionsDel.length)
log.push({
table: getTableName(schema.aiCorrections),
action: "delete",
count: aiCorrectionsDel.length,
});
const scanJobsDel = await db
.delete(schema.scanJobs)
.where(eq(schema.scanJobs.userId, userId))
.returning({ id: schema.scanJobs.id });
if (scanJobsDel.length) log.push({ table: getTableName(schema.scanJobs), action: "delete", count: scanJobsDel.length });
if (scanJobsDel.length)
log.push({ table: getTableName(schema.scanJobs), action: "delete", count: scanJobsDel.length });
const aiUsageDel = await db
.delete(schema.aiUsageCounters)
.where(eq(schema.aiUsageCounters.userId, userId))
.returning({ userId: schema.aiUsageCounters.userId });
if (aiUsageDel.length) log.push({ table: getTableName(schema.aiUsageCounters), action: "delete", count: aiUsageDel.length });
if (aiUsageDel.length)
log.push({
table: getTableName(schema.aiUsageCounters),
action: "delete",
count: aiUsageDel.length,
});
const trialsDel = await db
.delete(schema.trials)
.where(eq(schema.trials.userId, userId))
.returning({ userId: schema.trials.userId });
if (trialsDel.length) log.push({ table: getTableName(schema.trials), action: "delete", count: trialsDel.length });
if (trialsDel.length)
log.push({ table: getTableName(schema.trials), action: "delete", count: trialsDel.length });
// ---- 5. Financial / subscriptions (pseudonymize) ----
const subCountRow = await db
@@ -299,7 +391,8 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.select({ count: sql<number>`count(*)::int` })
.from(schema.storeTransactions)
.where(eq(schema.storeTransactions.userId, userId));
const hasFinancialRecords = Number(subCountRow[0]?.count ?? 0) > 0 || Number(txCountRow[0]?.count ?? 0) > 0;
const hasFinancialRecords =
Number(subCountRow[0]?.count ?? 0) > 0 || Number(txCountRow[0]?.count ?? 0) > 0;
if (hasFinancialRecords) {
const pseudonym = randomUUID();
@@ -317,7 +410,12 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.set({ userId: null, pseudonym, householdId: null, updatedAt: new Date() })
.where(eq(schema.subscriptions.userId, userId))
.returning({ id: schema.subscriptions.id });
if (subPseud.length) log.push({ table: getTableName(schema.subscriptions), action: "pseudonymize", count: subPseud.length });
if (subPseud.length)
log.push({
table: getTableName(schema.subscriptions),
action: "pseudonymize",
count: subPseud.length,
});
const storePseud = await db
.update(schema.storeTransactions)
@@ -328,14 +426,24 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
})
.where(eq(schema.storeTransactions.userId, userId))
.returning({ id: schema.storeTransactions.id });
if (storePseud.length) log.push({ table: getTableName(schema.storeTransactions), action: "pseudonymize", count: storePseud.length });
if (storePseud.length)
log.push({
table: getTableName(schema.storeTransactions),
action: "pseudonymize",
count: storePseud.length,
});
const eventPseud = await db
.update(schema.subscriptionEvents)
.set({ userId: null, pseudonym, payload: sql`jsonb_build_object('anonymized', true)` })
.where(eq(schema.subscriptionEvents.userId, userId))
.returning({ id: schema.subscriptionEvents.id });
if (eventPseud.length) log.push({ table: getTableName(schema.subscriptionEvents), action: "pseudonymize", count: eventPseud.length });
if (eventPseud.length)
log.push({
table: getTableName(schema.subscriptionEvents),
action: "pseudonymize",
count: eventPseud.length,
});
}
// ---- 6. Audit logs (anonymize actor, keep for retention) ----
@@ -344,7 +452,12 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.set({ actorUserId: null, ip: null, metadata: sql`jsonb_build_object('anonymized', true)` })
.where(eq(schema.auditLogs.actorUserId, userId))
.returning({ id: schema.auditLogs.id });
if (auditAnon.length) log.push({ table: getTableName(schema.auditLogs), action: "anonymize", count: auditAnon.length });
if (auditAnon.length)
log.push({
table: getTableName(schema.auditLogs),
action: "anonymize",
count: auditAnon.length,
});
// ---- 7. Domain events (anonymize) ----
const domainAnon = await db
@@ -352,7 +465,12 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.set({ userId: null, payload: sql`jsonb_build_object('anonymized', true)` })
.where(eq(schema.domainEvents.userId, userId))
.returning({ id: schema.domainEvents.id });
if (domainAnon.length) log.push({ table: getTableName(schema.domainEvents), action: "anonymize", count: domainAnon.length });
if (domainAnon.length)
log.push({
table: getTableName(schema.domainEvents),
action: "anonymize",
count: domainAnon.length,
});
// ---- 8. Households ----
// Remove memberships first; remember surviving households for receipt anonymization.
@@ -366,7 +484,8 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.delete(schema.households)
.where(inArray(schema.households.id, plan.householdsToDelete))
.returning({ id: schema.households.id });
if (hhDel.length) log.push({ table: getTableName(schema.households), action: "delete", count: hhDel.length });
if (hhDel.length)
log.push({ table: getTableName(schema.households), action: "delete", count: hhDel.length });
}
const survivingHouseholdIds = survivingHouseholds
@@ -380,11 +499,20 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.where(inArray(schema.receipts.householdId, survivingHouseholdIds))
.returning({ id: schema.receipts.id });
if (receiptAnon.length) {
log.push({ table: getTableName(schema.receipts), action: "anonymize", count: receiptAnon.length });
log.push({
table: getTableName(schema.receipts),
action: "anonymize",
count: receiptAnon.length,
});
await db
.update(schema.receiptLines)
.set({ rawText: "" })
.where(inArray(schema.receiptLines.receiptId, receiptAnon.map((r) => r.id)));
.where(
inArray(
schema.receiptLines.receiptId,
receiptAnon.map((r) => r.id),
),
);
}
// Anonymize cooking sessions started by user in surviving households.
@@ -393,7 +521,12 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.set({ startedByUserId: null })
.where(eq(schema.cookingSessions.startedByUserId, userId))
.returning({ id: schema.cookingSessions.id });
if (cookingAnon.length) log.push({ table: getTableName(schema.cookingSessions), action: "anonymize", count: cookingAnon.length });
if (cookingAnon.length)
log.push({
table: getTableName(schema.cookingSessions),
action: "anonymize",
count: cookingAnon.length,
});
// Anonymize household-level records that still reference the user.
const conflictAnon = await db
@@ -401,38 +534,61 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.set({ resolvedByUserId: null })
.where(eq(schema.inventoryConflicts.resolvedByUserId, userId))
.returning({ id: schema.inventoryConflicts.id });
if (conflictAnon.length) log.push({ table: getTableName(schema.inventoryConflicts), action: "anonymize", count: conflictAnon.length });
if (conflictAnon.length)
log.push({
table: getTableName(schema.inventoryConflicts),
action: "anonymize",
count: conflictAnon.length,
});
const transactionAnon = await db
.update(schema.inventoryTransactions)
.set({ actorUserId: null })
.where(eq(schema.inventoryTransactions.actorUserId, userId))
.returning({ id: schema.inventoryTransactions.id });
if (transactionAnon.length) log.push({ table: getTableName(schema.inventoryTransactions), action: "anonymize", count: transactionAnon.length });
if (transactionAnon.length)
log.push({
table: getTableName(schema.inventoryTransactions),
action: "anonymize",
count: transactionAnon.length,
});
const mealBoxAnon = await db
.update(schema.mealBoxes)
.set({ reservedForUserId: null })
.where(eq(schema.mealBoxes.reservedForUserId, userId))
.returning({ id: schema.mealBoxes.id });
if (mealBoxAnon.length) log.push({ table: getTableName(schema.mealBoxes), action: "anonymize", count: mealBoxAnon.length });
if (mealBoxAnon.length)
log.push({
table: getTableName(schema.mealBoxes),
action: "anonymize",
count: mealBoxAnon.length,
});
const shoppingAnon = await db
.update(schema.shoppingListItems)
.set({ addedByUserId: null })
.where(eq(schema.shoppingListItems.addedByUserId, userId))
.returning({ id: schema.shoppingListItems.id });
if (shoppingAnon.length) log.push({ table: getTableName(schema.shoppingListItems), action: "anonymize", count: shoppingAnon.length });
if (shoppingAnon.length)
log.push({
table: getTableName(schema.shoppingListItems),
action: "anonymize",
count: shoppingAnon.length,
});
}
// ---- 9. Analytics ----
const analyticsDel = await deleteUserAnalyticsEvents(db, userId);
if (analyticsDel > 0) log.push({ table: "analytics_events", action: "delete", count: analyticsDel });
if (analyticsDel > 0)
log.push({ table: "analytics_events", action: "delete", count: analyticsDel });
// ---- 10. Health/preferences/locale (delete) ----
await db.delete(schema.userHealthProfiles).where(eq(schema.userHealthProfiles.userId, userId));
await db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, userId));
await db.delete(schema.userLocalePreferences).where(eq(schema.userLocalePreferences.userId, userId));
await db
.delete(schema.userLocalePreferences)
.where(eq(schema.userLocalePreferences.userId, userId));
return log;
}
+68 -47
View File
@@ -111,8 +111,7 @@ async function aiScanBlock(
AND properties->>'latencyMs' IS NOT NULL
`);
const latencyRow = (Array.isArray(latency) ? latency[0] : latency.rows[0]) as
| { p50: string | number | null; p95: string | number | null }
| undefined;
{ p50: string | number | null; p95: string | number | null } | undefined;
const latestErrors = await db.execute(sql`
SELECT properties->>'errorCode' AS code, occurred_at AS tid
@@ -122,7 +121,9 @@ async function aiScanBlock(
ORDER BY occurred_at DESC
LIMIT 5
`);
const latestErrorsRows = (Array.isArray(latestErrors) ? latestErrors : latestErrors.rows) as Array<{
const latestErrorsRows = (
Array.isArray(latestErrors) ? latestErrors : latestErrors.rows
) as Array<{
code: string | null;
tid: string | Date;
}>;
@@ -132,8 +133,10 @@ async function aiScanBlock(
FROM ai_usage_counters
WHERE month = to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM')
`);
const monthRow = (Array.isArray(month) ? month[0] : month.rows[0]) as { total: bigint | number } | undefined;
const monthlyCostMicrocents = typeof monthRow?.total === "bigint" ? Number(monthRow.total) : asNumber(monthRow?.total);
const monthRow = (Array.isArray(month) ? month[0] : month.rows[0]) as
{ total: bigint | number } | undefined;
const monthlyCostMicrocents =
typeof monthRow?.total === "bigint" ? Number(monthRow.total) : asNumber(monthRow?.total);
const exact24h = await db.execute(sql`
SELECT COALESCE(sum(cost_usd), 0)::float AS total
@@ -142,7 +145,8 @@ async function aiScanBlock(
AND updated_at >= now() - interval '24 hours'
AND status IN ('awaiting_confirmation', 'completed')
`);
const exact24hRow = (Array.isArray(exact24h) ? exact24h[0] : exact24h.rows[0]) as { total: number } | undefined;
const exact24hRow = (Array.isArray(exact24h) ? exact24h[0] : exact24h.rows[0]) as
{ total: number } | undefined;
const exactCostUsd = asNumber(exact24hRow?.total);
let cost24hMicrocents: number | null = null;
@@ -209,8 +213,7 @@ async function conversionWithin(
(SELECT count(*) FROM converted) AS converted
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
| { total: number; converted: number }
| undefined;
{ total: number; converted: number } | undefined;
if (!row || asNumber(row.total) === 0) return null;
return asNumber(row.converted) / asNumber(row.total);
}
@@ -242,8 +245,7 @@ async function householdConversionWithin(
(SELECT count(*) FROM converted) AS converted
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
| { total: number; converted: number }
| undefined;
{ total: number; converted: number } | undefined;
if (!row || asNumber(row.total) === 0) return null;
return asNumber(row.converted) / asNumber(row.total);
}
@@ -269,8 +271,7 @@ async function cohortRetention(db: Database, day: number): Promise<number | null
(SELECT count(*) FROM active) AS active
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
| { total: number; active: number }
| undefined;
{ total: number; active: number } | undefined;
if (!row || asNumber(row.total) === 0) return null;
return asNumber(row.active) / asNumber(row.total);
}
@@ -305,8 +306,7 @@ async function engagementBlock(db: Database): Promise<OpsEngagementBlock> {
FROM product_analytics_events
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
| { c24: number; c7: number; opened: number; viewed: number }
| undefined;
{ c24: number; c7: number; opened: number; viewed: number } | undefined;
const tips = await db.execute(sql`
SELECT count(*)::int AS n
@@ -333,8 +333,7 @@ async function paymentBlock(db: Database): Promise<OpsPaymentBlock> {
FROM subscriptions
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
| { failed: number; grace: number }
| undefined;
{ failed: number; grace: number } | undefined;
const trials = await db.execute(sql`
SELECT count(*)::int AS n
@@ -342,7 +341,8 @@ async function paymentBlock(db: Database): Promise<OpsPaymentBlock> {
WHERE ends_at >= now()
AND ends_at <= now() + interval '48 hours'
`);
const trialsRow = (Array.isArray(trials) ? trials[0] : trials.rows[0]) as { n: number } | undefined;
const trialsRow = (Array.isArray(trials) ? trials[0] : trials.rows[0]) as
{ n: number } | undefined;
const store = await db.execute(sql`
SELECT
@@ -352,8 +352,7 @@ async function paymentBlock(db: Database): Promise<OpsPaymentBlock> {
WHERE created_at >= now() - interval '7 days'
`);
const storeRow = (Array.isArray(store) ? store[0] : store.rows[0]) as
| { refunds: number; chargebacks: number }
| undefined;
{ refunds: number; chargebacks: number } | undefined;
return {
failed_nu: asNumber(row?.failed),
@@ -547,8 +546,12 @@ async function subscriptionsBlock(db: Database): Promise<OpsSubscriptionsBlock>
return {
trial_aktiva: trialAktiva,
trial_konverterade_24h: asNumber((Array.isArray(trialConv24h) ? trialConv24h[0] : trialConv24h.rows[0]).n),
trial_konverterade_7d: asNumber((Array.isArray(trialConv7d) ? trialConv7d[0] : trialConv7d.rows[0]).n),
trial_konverterade_24h: asNumber(
(Array.isArray(trialConv24h) ? trialConv24h[0] : trialConv24h.rows[0]).n,
),
trial_konverterade_7d: asNumber(
(Array.isArray(trialConv7d) ? trialConv7d[0] : trialConv7d.rows[0]).n,
),
konverteringsgrad_30d: roundRate(konv30d),
betalande,
avslutade_24h: asNumber((Array.isArray(avslutade) ? avslutade[0] : avslutade.rows[0]).n),
@@ -561,17 +564,22 @@ function storeBlock(): OpsStoreBlock {
export function buildWall(summary: OpsSummary): OpsWallBlock {
const budgetUsd = Number(process.env.GEMINI_DAILY_BUDGET_USD ?? 0);
const dailySpendUsd = summary.ai_scan.budget_andel != null && budgetUsd > 0
? summary.ai_scan.budget_andel * budgetUsd
: null;
const budgetLabel = dailySpendUsd != null && budgetUsd > 0
? `${dailySpendUsd.toFixed(2)} / ${budgetUsd} USD`
: "—";
const dailySpendUsd =
summary.ai_scan.budget_andel != null && budgetUsd > 0
? summary.ai_scan.budget_andel * budgetUsd
: null;
const budgetLabel =
dailySpendUsd != null && budgetUsd > 0 ? `${dailySpendUsd.toFixed(2)} / ${budgetUsd} USD` : "—";
const budgetTone: OpsWallTile["tone"] =
budgetUsd <= 0 ? "ok" :
summary.ai_scan.budget_andel == null ? "warn" :
summary.ai_scan.budget_andel >= 1 ? "crit" :
summary.ai_scan.budget_andel >= 0.8 ? "warn" : "ok";
budgetUsd <= 0
? "ok"
: summary.ai_scan.budget_andel == null
? "warn"
: summary.ai_scan.budget_andel >= 1
? "crit"
: summary.ai_scan.budget_andel >= 0.8
? "warn"
: "ok";
const tiles: OpsWallTile[] = [
{
@@ -722,22 +730,35 @@ function alarmBlock(budgetUsd: number, dailySpendUsd: number | null | undefined)
export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Promise<OpsSummary> {
const { db, budgetUsd, dailySpendUsd, queueSummary, planPrices: planPricesOverride } = options;
const planPrices = { ...getPlanPrices(), ...(planPricesOverride ?? {}) };
const [app, economy, users, subscriptions, butik, feedbackData, ai, activation, engagement, payment, safety, larm, gemini] =
await Promise.all([
Promise.resolve(appBlock()),
economyBlock(db, planPrices),
usersBlock(db),
subscriptionsBlock(db),
Promise.resolve(storeBlock()),
feedbackBlock(db),
aiScanBlock(db, budgetUsd, dailySpendUsd),
activationBlock(db),
engagementBlock(db),
paymentBlock(db),
safetyBlock(db),
Promise.resolve(alarmBlock(budgetUsd, dailySpendUsd)),
geminiBlock(db),
]);
const [
app,
economy,
users,
subscriptions,
butik,
feedbackData,
ai,
activation,
engagement,
payment,
safety,
larm,
gemini,
] = await Promise.all([
Promise.resolve(appBlock()),
economyBlock(db, planPrices),
usersBlock(db),
subscriptionsBlock(db),
Promise.resolve(storeBlock()),
feedbackBlock(db),
aiScanBlock(db, budgetUsd, dailySpendUsd),
activationBlock(db),
engagementBlock(db),
paymentBlock(db),
safetyBlock(db),
Promise.resolve(alarmBlock(budgetUsd, dailySpendUsd)),
geminiBlock(db),
]);
const base: OpsSummary = {
app,
+58 -37
View File
@@ -220,16 +220,21 @@ async function userConversion(
(SELECT count(*) FROM base) AS total,
(SELECT count(*) FROM converted) AS converted
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
total: number;
converted: number;
} | undefined;
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as
| {
total: number;
converted: number;
}
| undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.converted) / Number(row.total);
}
function inClause(values: string[]) {
return sql`(${sql.join(values.map((v) => sql`${v}`), sql`, `)})`;
return sql`(${sql.join(
values.map((v) => sql`${v}`),
sql`, `,
)})`;
}
async function sameDayConversion(
@@ -260,10 +265,12 @@ async function sameDayConversion(
(SELECT count(DISTINCT user_id) FROM base) AS total,
(SELECT count(*) FROM converted) AS converted
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
total: number;
converted: number;
} | undefined;
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as
| {
total: number;
converted: number;
}
| undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.converted) / Number(row.total);
}
@@ -298,10 +305,12 @@ async function repeatedEventRate(
(SELECT count(*) FROM base) AS total,
(SELECT count(*) FROM event_counts) AS converted
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
total: number;
converted: number;
} | undefined;
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as
| {
total: number;
converted: number;
}
| undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.converted) / Number(row.total);
}
@@ -332,10 +341,12 @@ async function householdCollaborationRate(
(SELECT count(*) FROM base) AS total,
(SELECT count(*) FROM collaborative) AS converted
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
total: number;
converted: number;
} | undefined;
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as
| {
total: number;
converted: number;
}
| undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.converted) / Number(row.total);
}
@@ -369,10 +380,12 @@ async function weeklyTrustedMealWeek2(
(SELECT count(*) FROM activated) AS total,
(SELECT count(*) FROM week2_cooks) AS converted
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
total: number;
converted: number;
} | undefined;
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as
| {
total: number;
converted: number;
}
| undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.converted) / Number(row.total);
}
@@ -391,10 +404,12 @@ async function correctionRate(
AND occurred_at >= ${startIso}
AND occurred_at < ${endIso}
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
corrected: number;
total: number;
} | undefined;
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as
| {
corrected: number;
total: number;
}
| undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.corrected) / Number(row.total);
}
@@ -413,10 +428,12 @@ async function conflictResolutionRate(
AND occurred_at >= ${startIso}
AND occurred_at < ${endIso}
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
resolved: number;
total: number;
} | undefined;
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as
| {
resolved: number;
total: number;
}
| undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.resolved) / Number(row.total);
}
@@ -446,10 +463,12 @@ async function cohortRetention(
(SELECT count(DISTINCT user_id) FROM cohort) AS total,
(SELECT count(*) FROM active) AS active
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
total: number;
active: number;
} | undefined;
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as
| {
total: number;
active: number;
}
| undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.active) / Number(row.total);
}
@@ -481,10 +500,12 @@ async function householdWeekRetention(
(SELECT count(DISTINCT household_id) FROM cohort) AS total,
(SELECT count(*) FROM active) AS active
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
total: number;
active: number;
} | undefined;
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as
| {
total: number;
active: number;
}
| undefined;
if (!row || Number(row.total) === 0) return null;
return Number(row.active) / Number(row.total);
}
+10 -1
View File
@@ -2,7 +2,16 @@
* Product analytics schema (spec §8).
* Pseudonymous events stored in Postgres, separated from AAMOS memory.
*/
import { index, integer, jsonb, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
import {
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { createdAt } from "./_shared.js";
import { households } from "./households.js";
import { users } from "./users.js";
+9 -1
View File
@@ -1,4 +1,12 @@
import { boolean, doublePrecision, integer, pgTable, text, uuid, varchar } from "drizzle-orm/pg-core";
import {
boolean,
doublePrecision,
integer,
pgTable,
text,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { createdAt, updatedAt } from "./_shared.js";
import { inventoryItems } from "./inventory.js";
+3 -4
View File
@@ -24,10 +24,9 @@ export const households = pgTable(
/** Fas 1b: aktiveringsmått (spec §4.2) */
firstScanCompletedAt: timestamp("first_scan_completed_at", { withTimezone: true }),
fifthItemConfirmedAt: timestamp("fifth_item_confirmed_at", { withTimezone: true }),
firstRecipeRecommendationViewedAt: timestamp(
"first_recipe_recommendation_viewed_at",
{ withTimezone: true },
),
firstRecipeRecommendationViewedAt: timestamp("first_recipe_recommendation_viewed_at", {
withTimezone: true,
}),
firstRecipeSavedOrStartedAt: timestamp("first_recipe_saved_or_started_at", {
withTimezone: true,
}),
+6 -2
View File
@@ -121,7 +121,9 @@ export const inventoryConflicts = pgTable(
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
inventoryItemId: uuid("inventory_item_id").references(() => inventoryItems.id, { onDelete: "cascade" }),
inventoryItemId: uuid("inventory_item_id").references(() => inventoryItems.id, {
onDelete: "cascade",
}),
scanJobId: uuid("scan_job_id"),
/** Beskrivande typ: receipt_vs_image, member_vs_member, offline_vs_server, model_disagreement. */
conflictType: text("conflict_type").notNull(),
@@ -137,7 +139,9 @@ export const inventoryConflicts = pgTable(
proposedResolution: jsonb("proposed_resolution").$type<Record<string, unknown>>(),
status: text("status").notNull().default("open"),
/** Användaren som löste konflikten, eller NULL om system-förslag. */
resolvedByUserId: uuid("resolved_by_user_id").references(() => users.id, { onDelete: "set null" }),
resolvedByUserId: uuid("resolved_by_user_id").references(() => users.id, {
onDelete: "set null",
}),
resolution: jsonb("resolution").$type<Record<string, unknown>>(),
/** Modell- och promptversion som användes när konflikten upptäcktes. */
modelVersion: text("model_version"),
+12 -9
View File
@@ -217,14 +217,16 @@ export const cookingSessions = pgTable(
/** Måltidsdatum (UTC) som rester/matlådor knyts till. */
mealDate: date("meal_date"),
/** Vilka meal_boxes som påverkades av sessionen och med hur mycket. */
mealBoxMutations: jsonb("meal_box_mutations").$type<
Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>
>(),
mealBoxMutations:
jsonb("meal_box_mutations").$type<
Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>
>(),
leftoverNote: text("leftover_note"),
/** Ursprungligt FEFO-förslag, sparas för ev. undo/omräkning. */
plannedDeductions: jsonb("planned_deductions").$type<
Array<{ itemId: string; quantity: number; unit: string; name: string }>
>(),
plannedDeductions:
jsonb("planned_deductions").$type<
Array<{ itemId: string; quantity: number; unit: string; name: string }>
>(),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
@@ -283,9 +285,10 @@ export const cookingAssumptionProfiles = pgTable(
/** Hur många observationer profilen bygger på. */
observationCount: integer("observation_count").notNull().default(0),
/** Senaste rådata för felsökning/återspelning. */
lastSessionAnswers: jsonb("last_session_answers").$type<
Array<{ sessionId: string; eaten: number; leftovers: number; date: string }>
>(),
lastSessionAnswers:
jsonb("last_session_answers").$type<
Array<{ sessionId: string; eaten: number; leftovers: number; date: string }>
>(),
updatedAt: updatedAt(),
},
(t) => [
+13 -5
View File
@@ -2,7 +2,18 @@
* Release gates schema (spec §17).
* Configurable go/no-go criteria evaluated against analytics events.
*/
import { boolean, doublePrecision, index, integer, pgEnum, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
import {
boolean,
doublePrecision,
index,
integer,
pgEnum,
pgTable,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { createdAt, updatedAt } from "./_shared.js";
import {
RELEASE_GATE_CATEGORIES,
@@ -19,10 +30,7 @@ export const releaseGateComparisonEnum = pgEnum(
"release_gate_comparison",
tuple(RELEASE_GATE_COMPARISONS),
);
export const releaseGateStatusEnum = pgEnum(
"release_gate_status",
tuple(RELEASE_GATE_STATUSES),
);
export const releaseGateStatusEnum = pgEnum("release_gate_status", tuple(RELEASE_GATE_STATUSES));
export const releaseGates = pgTable(
"release_gates",
File diff suppressed because it is too large Load Diff
+18 -7
View File
@@ -1,6 +1,12 @@
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { eq, inArray } from "drizzle-orm";
import { createDatabase, closeDatabase, markMilestone, getActivationState, schema } from "@app/database";
import {
createDatabase,
closeDatabase,
markMilestone,
getActivationState,
schema,
} from "@app/database";
describe("activation", () => {
const { db } = createDatabase();
@@ -8,12 +14,17 @@ describe("activation", () => {
const inviteCodes = ["ACT123", "ACT223"];
async function cleanup() {
await db.delete(schema.householdMembers).where(
inArray(
schema.householdMembers.householdId,
db.select({ id: schema.households.id }).from(schema.households).where(inArray(schema.households.inviteCode, inviteCodes)),
),
);
await db
.delete(schema.householdMembers)
.where(
inArray(
schema.householdMembers.householdId,
db
.select({ id: schema.households.id })
.from(schema.households)
.where(inArray(schema.households.inviteCode, inviteCodes)),
),
);
await db.delete(schema.households).where(inArray(schema.households.inviteCode, inviteCodes));
await db.delete(schema.users).where(inArray(schema.users.email, emails));
}
+15 -3
View File
@@ -4,7 +4,13 @@
*/
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { eq } from "drizzle-orm";
import { createDatabase, closeDatabase, schema, isAnalyticsOptedIn, deleteUserAnalyticsEvents } from "@app/database";
import {
createDatabase,
closeDatabase,
schema,
isAnalyticsOptedIn,
deleteUserAnalyticsEvents,
} from "@app/database";
const TEST_USER_EMAIL = "analytics-gdpr-test@example.invalid";
@@ -76,7 +82,11 @@ describe("analytics GDPR helpers", () => {
// Insert a dummy event for the test user and another user.
const [otherUser] = await db
.insert(schema.users)
.values({ email: "other-analytics-test@example.invalid", displayName: "Other", locale: "sv-SE" })
.values({
email: "other-analytics-test@example.invalid",
displayName: "Other",
locale: "sv-SE",
})
.returning({ id: schema.users.id });
await db.insert(schema.productAnalyticsEvents).values({
@@ -99,7 +109,9 @@ describe("analytics GDPR helpers", () => {
.where(eq(schema.productAnalyticsEvents.userId, otherUser!.id));
expect(remaining).toHaveLength(1);
await db.delete(schema.productAnalyticsEvents).where(eq(schema.productAnalyticsEvents.userId, otherUser!.id));
await db
.delete(schema.productAnalyticsEvents)
.where(eq(schema.productAnalyticsEvents.userId, otherUser!.id));
await db.delete(schema.users).where(eq(schema.users.id, otherUser!.id));
});
});
+12 -6
View File
@@ -120,15 +120,17 @@ describe("buildWall", () => {
expect(ok.value).toBe("OK");
expect(ok.tone).toBe("ok");
const nere = buildWall(baseSummary({ jobb: { ...baseSummary().jobb, workers_ok: false } })).boards[0]!.tiles.find(
(t) => t.label === "Workers",
)!;
const nere = buildWall(
baseSummary({ jobb: { ...baseSummary().jobb, workers_ok: false } }),
).boards[0]!.tiles.find((t) => t.label === "Workers")!;
expect(nere.value).toBe("NERE");
expect(nere.tone).toBe("warn");
});
it("allergen-brott 0 är ok; >0 varnar", () => {
const zero = buildWall(baseSummary()).boards[0]!.tiles.find((t) => t.label === "Allergen-brott")!;
const zero = buildWall(baseSummary()).boards[0]!.tiles.find(
(t) => t.label === "Allergen-brott",
)!;
expect(zero.value).toBe("0");
expect(zero.tone).toBe("ok");
@@ -153,8 +155,12 @@ describe("buildWall", () => {
});
it("ändrat summary-värde ändrar tile (inga hårdkodade tal)", () => {
const first = buildWall(baseSummary({ anvandare: { ...baseSummary().anvandare, aktiva_nu: 1 } }));
const second = buildWall(baseSummary({ anvandare: { ...baseSummary().anvandare, aktiva_nu: 2 } }));
const first = buildWall(
baseSummary({ anvandare: { ...baseSummary().anvandare, aktiva_nu: 1 } }),
);
const second = buildWall(
baseSummary({ anvandare: { ...baseSummary().anvandare, aktiva_nu: 2 } }),
);
expect(first.boards[0]!.tiles.find((t) => t.label === "Aktiva nu")!.value).toBe("1");
expect(second.boards[0]!.tiles.find((t) => t.label === "Aktiva nu")!.value).toBe("2");
});
+45 -7
View File
@@ -1,7 +1,12 @@
import { describe, expect, it, beforeEach, afterAll } from "vitest";
import { eq, sql } from "drizzle-orm";
import { createDatabase, closeDatabase } from "../src/client.js";
import { schema, seedReleaseGates, evaluateReleaseGates, summarizeReleaseGates } from "../src/index.js";
import {
schema,
seedReleaseGates,
evaluateReleaseGates,
summarizeReleaseGates,
} from "../src/index.js";
import { BUILT_IN_RELEASE_GATES } from "@app/shared-types";
const { db, pool } = createDatabase();
@@ -53,9 +58,27 @@ describe("release gates", () => {
const userB = await createTestUser("22222222-2222-2222-2222-222222222222");
const now = new Date();
await db.insert(schema.productAnalyticsEvents).values([
{ eventName: "account_created", userId: userA, occurredAt: now, receivedAt: now, properties: {} },
{ eventName: "account_created", userId: userB, occurredAt: now, receivedAt: now, properties: {} },
{ eventName: "scan_completed", userId: userA, occurredAt: now, receivedAt: now, properties: {} },
{
eventName: "account_created",
userId: userA,
occurredAt: now,
receivedAt: now,
properties: {},
},
{
eventName: "account_created",
userId: userB,
occurredAt: now,
receivedAt: now,
properties: {},
},
{
eventName: "scan_completed",
userId: userA,
occurredAt: now,
receivedAt: now,
properties: {},
},
]);
const results = await evaluateReleaseGates(db);
@@ -80,12 +103,27 @@ describe("release gates", () => {
const user = await createTestUser("33333333-3333-3333-3333-333333333333");
const now = new Date();
await db.insert(schema.productAnalyticsEvents).values([
{ eventName: "account_created", userId: user, occurredAt: now, receivedAt: now, properties: {} },
{ eventName: "scan_completed", userId: user, occurredAt: now, receivedAt: now, properties: {} },
{
eventName: "account_created",
userId: user,
occurredAt: now,
receivedAt: now,
properties: {},
},
{
eventName: "scan_completed",
userId: user,
occurredAt: now,
receivedAt: now,
properties: {},
},
]);
await evaluateReleaseGates(db);
const [gate] = await db.select().from(schema.releaseGates).where(eq(schema.releaseGates.gateKey, "first_scan_rate"));
const [gate] = await db
.select()
.from(schema.releaseGates)
.where(eq(schema.releaseGates.gateKey, "first_scan_rate"));
expect(gate?.lastValue).toBeCloseTo(1);
expect(gate?.status).toBe("passed");
expect(gate?.lastEvaluatedAt).not.toBeNull();
@@ -18,7 +18,12 @@ export interface ExistingProfile {
averageEatenPortions: number | null;
averageLeftoverPortions: number | null;
observationCount: number;
lastSessionAnswers?: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }> | null;
lastSessionAnswers?: Array<{
sessionId: string;
eaten: number;
leftovers: number;
date: string;
}> | null;
}
/**
@@ -48,7 +48,9 @@ const MS_PER_DAY = 86_400_000;
* Deterministisk, UTC, alltid samma svar för samma input.
*/
export function buildReconciliationCandidates(
items: Array<InventoryItemLike & { id: string; displayName: string; unit: string; locationName: string }>,
items: Array<
InventoryItemLike & { id: string; displayName: string; unit: string; locationName: string }
>,
context: ReconciliationContext,
now: Date = new Date(),
maxItems = 15,
+1 -6
View File
@@ -19,12 +19,7 @@ export interface NewScanObservation {
observationConfidence: number;
}
export type ScanDiffRowKind =
| "new_item"
| "moved"
| "quantity_changed"
| "vanished"
| "unchanged";
export type ScanDiffRowKind = "new_item" | "moved" | "quantity_changed" | "vanished" | "unchanged";
export interface ScanDiffRow {
kind: ScanDiffRowKind;
+4 -5
View File
@@ -175,7 +175,8 @@ export function householdTrustScore(
// 3. Andel uppskattade mängder (0100, 100 = alla har confidence 1)
const confidenceScore =
items.reduce((sum, i) => sum + Math.min(1, Math.max(0, i.confidence)), 0) / items.length * 100;
(items.reduce((sum, i) => sum + Math.min(1, Math.max(0, i.confidence)), 0) / items.length) *
100;
// 4. Poster som borde vara slut (0100, 100 = inga låga kvantiteter)
const depletionScore =
@@ -189,15 +190,13 @@ export function householdTrustScore(
// 5. Korrigeringsfrekvens (0100, 100 = inga korrigeringar)
const correctionRatio =
input.transactionCount30d > 0
? input.correctionCount30d / input.transactionCount30d
: 0;
input.transactionCount30d > 0 ? input.correctionCount30d / input.transactionCount30d : 0;
const correctionScore = Math.max(0, 100 - correctionRatio * 200);
const score = Math.round(
verifiedScore * 0.25 +
ageScore * 0.25 +
confidenceScore * 0.20 +
confidenceScore * 0.2 +
depletionScore * 0.15 +
correctionScore * 0.15,
);
@@ -44,8 +44,22 @@ describe("updateCookingAssumptionProfile", () => {
});
it("keeps a rolling history of last 10 sessions", () => {
type Profile = { averageEatenPortions: number | null; averageLeftoverPortions: number | null; observationCount: number; lastSessionAnswers?: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }> | null };
let existing: Profile = { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 };
type Profile = {
averageEatenPortions: number | null;
averageLeftoverPortions: number | null;
observationCount: number;
lastSessionAnswers?: Array<{
sessionId: string;
eaten: number;
leftovers: number;
date: string;
}> | null;
};
let existing: Profile = {
averageEatenPortions: null,
averageLeftoverPortions: null,
observationCount: 0,
};
for (let i = 0; i < 12; i++) {
existing = updateCookingAssumptionProfile(
{
@@ -91,7 +105,12 @@ describe("rollbackCookingAssumptionProfile", () => {
averageEatenPortions: number | null;
averageLeftoverPortions: number | null;
observationCount: number;
lastSessionAnswers?: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }> | null;
lastSessionAnswers?: Array<{
sessionId: string;
eaten: number;
leftovers: number;
date: string;
}> | null;
} = { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 };
// Three chronological observations.
@@ -134,7 +153,12 @@ describe("rollbackCookingAssumptionProfile", () => {
averageEatenPortions: number | null;
averageLeftoverPortions: number | null;
observationCount: number;
lastSessionAnswers?: Array<{ sessionId: string; eaten: number; leftovers: number; date: string }> | null;
lastSessionAnswers?: Array<{
sessionId: string;
eaten: number;
leftovers: number;
date: string;
}> | null;
} = { averageEatenPortions: null, averageLeftoverPortions: null, observationCount: 0 };
existing = updateCookingAssumptionProfile(
{
@@ -3,7 +3,9 @@ import { buildReconciliationCandidates } from "../src/reconciliation.js";
const PINNED = new Date("2026-08-07T00:00:00.000Z");
function baseItem(overrides: Partial<Parameters<typeof buildReconciliationCandidates>[0][number]> = {}) {
function baseItem(
overrides: Partial<Parameters<typeof buildReconciliationCandidates>[0][number]> = {},
) {
return {
id: "itm-1",
displayName: "Mjölk",
@@ -22,7 +24,12 @@ describe("buildReconciliationCandidates", () => {
it("returns empty when no items match", () => {
const result = buildReconciliationCandidates(
[baseItem({ quantity: 0, depletedAt: PINNED })],
{ plannedRecipeIngredientIds: new Map(), daysLeftByItemId: new Map(), dailyConsumptionRate: new Map(), priceMinorByItemId: new Map() },
{
plannedRecipeIngredientIds: new Map(),
daysLeftByItemId: new Map(),
dailyConsumptionRate: new Map(),
priceMinorByItemId: new Map(),
},
PINNED,
);
expect(result).toEqual([]);
@@ -69,7 +76,12 @@ describe("buildReconciliationCandidates", () => {
const item = baseItem({ confidence: 0.3, verifiedByUser: false });
const result = buildReconciliationCandidates(
[item],
{ plannedRecipeIngredientIds: new Map(), daysLeftByItemId: new Map(), dailyConsumptionRate: new Map(), priceMinorByItemId: new Map() },
{
plannedRecipeIngredientIds: new Map(),
daysLeftByItemId: new Map(),
dailyConsumptionRate: new Map(),
priceMinorByItemId: new Map(),
},
PINNED,
);
expect(result[0]?.reasons).toContainEqual({ kind: "low_confidence", confidence: 0.3 });
@@ -92,11 +104,20 @@ describe("buildReconciliationCandidates", () => {
it("caps results at maxItems", () => {
const items = Array.from({ length: 20 }, (_, i) =>
baseItem({ id: `itm-${i}`, displayName: `Vara ${String(i).padStart(2, "0")}`, confidence: 0.1 }),
baseItem({
id: `itm-${i}`,
displayName: `Vara ${String(i).padStart(2, "0")}`,
confidence: 0.1,
}),
);
const result = buildReconciliationCandidates(
items,
{ plannedRecipeIngredientIds: new Map(), daysLeftByItemId: new Map(), dailyConsumptionRate: new Map(), priceMinorByItemId: new Map() },
{
plannedRecipeIngredientIds: new Map(),
daysLeftByItemId: new Map(),
dailyConsumptionRate: new Map(),
priceMinorByItemId: new Map(),
},
PINNED,
5,
);
+39 -6
View File
@@ -98,7 +98,10 @@ describe("computeTrust", () => {
describe("householdTrustScore", () => {
it("returns 100 / up_to_date for empty inventory", () => {
const result = householdTrustScore({ items: [], correctionCount30d: 0, transactionCount30d: 0 }, PINNED);
const result = householdTrustScore(
{ items: [], correctionCount30d: 0, transactionCount30d: 0 },
PINNED,
);
expect(result.score).toBe(100);
expect(result.status).toBe("up_to_date");
});
@@ -107,8 +110,20 @@ describe("householdTrustScore", () => {
const result = householdTrustScore(
{
items: [
{ confidence: 1, verifiedByUser: true, lastVerifiedAt: PINNED, quantity: 2, updatedAt: PINNED },
{ confidence: 1, verifiedByUser: true, lastVerifiedAt: PINNED, quantity: 3, updatedAt: PINNED },
{
confidence: 1,
verifiedByUser: true,
lastVerifiedAt: PINNED,
quantity: 2,
updatedAt: PINNED,
},
{
confidence: 1,
verifiedByUser: true,
lastVerifiedAt: PINNED,
quantity: 3,
updatedAt: PINNED,
},
],
correctionCount30d: 0,
transactionCount30d: 0,
@@ -124,7 +139,13 @@ describe("householdTrustScore", () => {
const result = householdTrustScore(
{
items: [
{ confidence: 0.3, verifiedByUser: false, lastVerifiedAt: null, quantity: 0.1, updatedAt: old },
{
confidence: 0.3,
verifiedByUser: false,
lastVerifiedAt: null,
quantity: 0.1,
updatedAt: old,
},
],
correctionCount30d: 5,
transactionCount30d: 10,
@@ -140,8 +161,20 @@ describe("householdTrustScore", () => {
const result = householdTrustScore(
{
items: [
{ confidence: 0.8, verifiedByUser: true, lastVerifiedAt: weekAgo, quantity: 1, updatedAt: weekAgo },
{ confidence: 0.5, verifiedByUser: false, lastVerifiedAt: null, quantity: 1, updatedAt: weekAgo },
{
confidence: 0.8,
verifiedByUser: true,
lastVerifiedAt: weekAgo,
quantity: 1,
updatedAt: weekAgo,
},
{
confidence: 0.5,
verifiedByUser: false,
lastVerifiedAt: null,
quantity: 1,
updatedAt: weekAgo,
},
],
correctionCount30d: 1,
transactionCount30d: 5,
+83 -209
View File
@@ -99,10 +99,13 @@ export async function deriveMemoryUpdates(
const validEventIds = new Set(input.events.map((e) => e.id));
const groundedProposals = parsed.data.memoryUpdates.filter((u) => {
const grounded = u.sourceEventIds.length > 0 && u.sourceEventIds.every((id) => validEventIds.has(id));
const grounded =
u.sourceEventIds.length > 0 && u.sourceEventIds.every((id) => validEventIds.has(id));
if (!grounded) {
// eslint-disable-next-line no-console
console.error(`UPDATE_USER_MEMORY avvisade fabricerat minne: key=${u.key}, sourceEventIds=[${u.sourceEventIds.join(", ")}]`);
console.error(
`UPDATE_USER_MEMORY avvisade fabricerat minne: key=${u.key}, sourceEventIds=[${u.sourceEventIds.join(", ")}]`,
);
}
return grounded;
});
@@ -264,12 +267,20 @@ const UI_LABELS: Record<
guess: "Questa è un'ipotesi: confermala o modificala se è corretta.",
},
de: {
origin: { user_stated: "Du hast gesagt", observed: "Wir haben bemerkt", ai_inferred: "Vermutung" },
origin: {
user_stated: "Du hast gesagt",
observed: "Wir haben bemerkt",
ai_inferred: "Vermutung",
},
paused: "Pausiert",
guess: "Dies ist eine Vermutung: bestätige oder ändere sie, wenn sie stimmt.",
},
fr: {
origin: { user_stated: "Vous avez dit", observed: "Nous avons remarqué", ai_inferred: "Hypothèse" },
origin: {
user_stated: "Vous avez dit",
observed: "Nous avons remarqué",
ai_inferred: "Hypothèse",
},
paused: "En pause",
guess: "Ceci est une hypothèse : confirmez ou modifiez si cela vous convient.",
},
@@ -307,37 +318,22 @@ const UI_LABELS: Record<
type MemoryValueRenderer = (value: Record<string, unknown>) => string | null;
const MEMORY_SUMMARY_TEMPLATES: Record<
string,
Record<string, MemoryValueRenderer>
> = {
const MEMORY_SUMMARY_TEMPLATES: Record<string, Record<string, MemoryValueRenderer>> = {
sv: {
favoriteCuisine: (v) =>
typeof v.favoriteCuisine === "string"
? `Favoritkök: ${String(v.favoriteCuisine)}`
: null,
typeof v.favoriteCuisine === "string" ? `Favoritkök: ${String(v.favoriteCuisine)}` : null,
avoidIngredient: (v) =>
typeof v.avoidIngredientId === "string"
? `Undviker ingrediens: ${String(v.avoidIngredientId)}`
: null,
goal: (v) =>
typeof v.goal === "string" ? `Mål: ${String(v.goal)}` : null,
goal: (v) => (typeof v.goal === "string" ? `Mål: ${String(v.goal)}` : null),
primaryGoal: (v) =>
typeof v.primaryGoal === "string"
? `Primärt mål: ${String(v.primaryGoal)}`
: null,
allergen: (v) =>
typeof v.allergen === "string"
? `Allergi: ${String(v.allergen)}`
: null,
typeof v.primaryGoal === "string" ? `Primärt mål: ${String(v.primaryGoal)}` : null,
allergen: (v) => (typeof v.allergen === "string" ? `Allergi: ${String(v.allergen)}` : null),
spiceLevelMax: (v) =>
typeof v.spiceLevelMax === "number"
? `Max styrka: ${v.spiceLevelMax}`
: null,
typeof v.spiceLevelMax === "number" ? `Max styrka: ${v.spiceLevelMax}` : null,
dislikedIngredient: (v) =>
typeof v.dislikedIngredient === "string"
? `Undviker: ${String(v.dislikedIngredient)}`
: null,
typeof v.dislikedIngredient === "string" ? `Undviker: ${String(v.dislikedIngredient)}` : null,
typicalPortions: (v) =>
typeof v.typicalPortions === "number"
? `Laggar vanligen ${v.typicalPortions} portioner`
@@ -356,28 +352,16 @@ const MEMORY_SUMMARY_TEMPLATES: Record<
typeof v.avoidIngredientId === "string"
? `Avoids ingredient: ${String(v.avoidIngredientId)}`
: null,
goal: (v) =>
typeof v.goal === "string" ? `Goal: ${String(v.goal)}` : null,
goal: (v) => (typeof v.goal === "string" ? `Goal: ${String(v.goal)}` : null),
primaryGoal: (v) =>
typeof v.primaryGoal === "string"
? `Primary goal: ${String(v.primaryGoal)}`
: null,
allergen: (v) =>
typeof v.allergen === "string"
? `Allergy: ${String(v.allergen)}`
: null,
typeof v.primaryGoal === "string" ? `Primary goal: ${String(v.primaryGoal)}` : null,
allergen: (v) => (typeof v.allergen === "string" ? `Allergy: ${String(v.allergen)}` : null),
spiceLevelMax: (v) =>
typeof v.spiceLevelMax === "number"
? `Max spice level: ${v.spiceLevelMax}`
: null,
typeof v.spiceLevelMax === "number" ? `Max spice level: ${v.spiceLevelMax}` : null,
dislikedIngredient: (v) =>
typeof v.dislikedIngredient === "string"
? `Avoids: ${String(v.dislikedIngredient)}`
: null,
typeof v.dislikedIngredient === "string" ? `Avoids: ${String(v.dislikedIngredient)}` : null,
typicalPortions: (v) =>
typeof v.typicalPortions === "number"
? `Usually cooks ${v.typicalPortions} servings`
: null,
typeof v.typicalPortions === "number" ? `Usually cooks ${v.typicalPortions} servings` : null,
spicePreference: (v) =>
typeof v.spicePreference === "string"
? `Spice preference: ${String(v.spicePreference)}`
@@ -392,28 +376,16 @@ const MEMORY_SUMMARY_TEMPLATES: Record<
typeof v.avoidIngredientId === "string"
? `Evita ingrediente: ${String(v.avoidIngredientId)}`
: null,
goal: (v) =>
typeof v.goal === "string" ? `Objetivo: ${String(v.goal)}` : null,
goal: (v) => (typeof v.goal === "string" ? `Objetivo: ${String(v.goal)}` : null),
primaryGoal: (v) =>
typeof v.primaryGoal === "string"
? `Objetivo principal: ${String(v.primaryGoal)}`
: null,
allergen: (v) =>
typeof v.allergen === "string"
? `Alergia: ${String(v.allergen)}`
: null,
typeof v.primaryGoal === "string" ? `Objetivo principal: ${String(v.primaryGoal)}` : null,
allergen: (v) => (typeof v.allergen === "string" ? `Alergia: ${String(v.allergen)}` : null),
spiceLevelMax: (v) =>
typeof v.spiceLevelMax === "number"
? `Nivel máximo de picante: ${v.spiceLevelMax}`
: null,
typeof v.spiceLevelMax === "number" ? `Nivel máximo de picante: ${v.spiceLevelMax}` : null,
dislikedIngredient: (v) =>
typeof v.dislikedIngredient === "string"
? `Evita: ${String(v.dislikedIngredient)}`
: null,
typeof v.dislikedIngredient === "string" ? `Evita: ${String(v.dislikedIngredient)}` : null,
typicalPortions: (v) =>
typeof v.typicalPortions === "number"
? `Suele cocinar ${v.typicalPortions} raciones`
: null,
typeof v.typicalPortions === "number" ? `Suele cocinar ${v.typicalPortions} raciones` : null,
spicePreference: (v) =>
typeof v.spicePreference === "string"
? `Preferencia de picante: ${String(v.spicePreference)}`
@@ -428,24 +400,14 @@ const MEMORY_SUMMARY_TEMPLATES: Record<
typeof v.avoidIngredientId === "string"
? `Evita ingrediente: ${String(v.avoidIngredientId)}`
: null,
goal: (v) =>
typeof v.goal === "string" ? `Obiettivo: ${String(v.goal)}` : null,
goal: (v) => (typeof v.goal === "string" ? `Obiettivo: ${String(v.goal)}` : null),
primaryGoal: (v) =>
typeof v.primaryGoal === "string"
? `Obiettivo principale: ${String(v.primaryGoal)}`
: null,
allergen: (v) =>
typeof v.allergen === "string"
? `Allergia: ${String(v.allergen)}`
: null,
typeof v.primaryGoal === "string" ? `Obiettivo principale: ${String(v.primaryGoal)}` : null,
allergen: (v) => (typeof v.allergen === "string" ? `Allergia: ${String(v.allergen)}` : null),
spiceLevelMax: (v) =>
typeof v.spiceLevelMax === "number"
? `Livello piccante max: ${v.spiceLevelMax}`
: null,
typeof v.spiceLevelMax === "number" ? `Livello piccante max: ${v.spiceLevelMax}` : null,
dislikedIngredient: (v) =>
typeof v.dislikedIngredient === "string"
? `Evita: ${String(v.dislikedIngredient)}`
: null,
typeof v.dislikedIngredient === "string" ? `Evita: ${String(v.dislikedIngredient)}` : null,
typicalPortions: (v) =>
typeof v.typicalPortions === "number"
? `Di solito cucina ${v.typicalPortions} porzioni`
@@ -457,35 +419,23 @@ const MEMORY_SUMMARY_TEMPLATES: Record<
},
de: {
favoriteCuisine: (v) =>
typeof v.favoriteCuisine === "string"
? `Lieblingsküche: ${String(v.favoriteCuisine)}`
: null,
typeof v.favoriteCuisine === "string" ? `Lieblingsküche: ${String(v.favoriteCuisine)}` : null,
avoidIngredient: (v) =>
typeof v.avoidIngredientId === "string"
? `Vermeidet Zutat: ${String(v.avoidIngredientId)}`
: null,
goal: (v) =>
typeof v.goal === "string" ? `Ziel: ${String(v.goal)}` : null,
goal: (v) => (typeof v.goal === "string" ? `Ziel: ${String(v.goal)}` : null),
primaryGoal: (v) =>
typeof v.primaryGoal === "string"
? `Hauptziel: ${String(v.primaryGoal)}`
: null,
allergen: (v) =>
typeof v.allergen === "string"
? `Allergie: ${String(v.allergen)}`
: null,
typeof v.primaryGoal === "string" ? `Hauptziel: ${String(v.primaryGoal)}` : null,
allergen: (v) => (typeof v.allergen === "string" ? `Allergie: ${String(v.allergen)}` : null),
spiceLevelMax: (v) =>
typeof v.spiceLevelMax === "number"
? `Max. Schärfe: ${v.spiceLevelMax}`
: null,
typeof v.spiceLevelMax === "number" ? `Max. Schärfe: ${v.spiceLevelMax}` : null,
dislikedIngredient: (v) =>
typeof v.dislikedIngredient === "string"
? `Vermeidet: ${String(v.dislikedIngredient)}`
: null,
typicalPortions: (v) =>
typeof v.typicalPortions === "number"
? `Kocht meist ${v.typicalPortions} Portionen`
: null,
typeof v.typicalPortions === "number" ? `Kocht meist ${v.typicalPortions} Portionen` : null,
spicePreference: (v) =>
typeof v.spicePreference === "string"
? `Schärfepräferenz: ${String(v.spicePreference)}`
@@ -500,24 +450,14 @@ const MEMORY_SUMMARY_TEMPLATES: Record<
typeof v.avoidIngredientId === "string"
? `Évite l'ingrédient: ${String(v.avoidIngredientId)}`
: null,
goal: (v) =>
typeof v.goal === "string" ? `Objectif: ${String(v.goal)}` : null,
goal: (v) => (typeof v.goal === "string" ? `Objectif: ${String(v.goal)}` : null),
primaryGoal: (v) =>
typeof v.primaryGoal === "string"
? `Objectif principal: ${String(v.primaryGoal)}`
: null,
allergen: (v) =>
typeof v.allergen === "string"
? `Allergie: ${String(v.allergen)}`
: null,
typeof v.primaryGoal === "string" ? `Objectif principal: ${String(v.primaryGoal)}` : null,
allergen: (v) => (typeof v.allergen === "string" ? `Allergie: ${String(v.allergen)}` : null),
spiceLevelMax: (v) =>
typeof v.spiceLevelMax === "number"
? `Niveau épicé max: ${v.spiceLevelMax}`
: null,
typeof v.spiceLevelMax === "number" ? `Niveau épicé max: ${v.spiceLevelMax}` : null,
dislikedIngredient: (v) =>
typeof v.dislikedIngredient === "string"
? `Évite: ${String(v.dislikedIngredient)}`
: null,
typeof v.dislikedIngredient === "string" ? `Évite: ${String(v.dislikedIngredient)}` : null,
typicalPortions: (v) =>
typeof v.typicalPortions === "number"
? `Cuisine habituellement ${v.typicalPortions} portions`
@@ -529,31 +469,19 @@ const MEMORY_SUMMARY_TEMPLATES: Record<
},
da: {
favoriteCuisine: (v) =>
typeof v.favoriteCuisine === "string"
? `Yndlingskøkken: ${String(v.favoriteCuisine)}`
: null,
typeof v.favoriteCuisine === "string" ? `Yndlingskøkken: ${String(v.favoriteCuisine)}` : null,
avoidIngredient: (v) =>
typeof v.avoidIngredientId === "string"
? `Undgår ingrediens: ${String(v.avoidIngredientId)}`
: null,
goal: (v) =>
typeof v.goal === "string" ? `Mål: ${String(v.goal)}` : null,
goal: (v) => (typeof v.goal === "string" ? `Mål: ${String(v.goal)}` : null),
primaryGoal: (v) =>
typeof v.primaryGoal === "string"
? `Primært mål: ${String(v.primaryGoal)}`
: null,
allergen: (v) =>
typeof v.allergen === "string"
? `Allergi: ${String(v.allergen)}`
: null,
typeof v.primaryGoal === "string" ? `Primært mål: ${String(v.primaryGoal)}` : null,
allergen: (v) => (typeof v.allergen === "string" ? `Allergi: ${String(v.allergen)}` : null),
spiceLevelMax: (v) =>
typeof v.spiceLevelMax === "number"
? `Max styrke: ${v.spiceLevelMax}`
: null,
typeof v.spiceLevelMax === "number" ? `Max styrke: ${v.spiceLevelMax}` : null,
dislikedIngredient: (v) =>
typeof v.dislikedIngredient === "string"
? `Undgår: ${String(v.dislikedIngredient)}`
: null,
typeof v.dislikedIngredient === "string" ? `Undgår: ${String(v.dislikedIngredient)}` : null,
typicalPortions: (v) =>
typeof v.typicalPortions === "number"
? `Tilbereder som regel ${v.typicalPortions} portioner`
@@ -572,24 +500,14 @@ const MEMORY_SUMMARY_TEMPLATES: Record<
typeof v.avoidIngredientId === "string"
? `Unngår ingrediens: ${String(v.avoidIngredientId)}`
: null,
goal: (v) =>
typeof v.goal === "string" ? `Mål: ${String(v.goal)}` : null,
goal: (v) => (typeof v.goal === "string" ? `Mål: ${String(v.goal)}` : null),
primaryGoal: (v) =>
typeof v.primaryGoal === "string"
? `Primært mål: ${String(v.primaryGoal)}`
: null,
allergen: (v) =>
typeof v.allergen === "string"
? `Allergi: ${String(v.allergen)}`
: null,
typeof v.primaryGoal === "string" ? `Primært mål: ${String(v.primaryGoal)}` : null,
allergen: (v) => (typeof v.allergen === "string" ? `Allergi: ${String(v.allergen)}` : null),
spiceLevelMax: (v) =>
typeof v.spiceLevelMax === "number"
? `Maks styrke: ${v.spiceLevelMax}`
: null,
typeof v.spiceLevelMax === "number" ? `Maks styrke: ${v.spiceLevelMax}` : null,
dislikedIngredient: (v) =>
typeof v.dislikedIngredient === "string"
? `Unngår: ${String(v.dislikedIngredient)}`
: null,
typeof v.dislikedIngredient === "string" ? `Unngår: ${String(v.dislikedIngredient)}` : null,
typicalPortions: (v) =>
typeof v.typicalPortions === "number"
? `Lager vanligvis ${v.typicalPortions} porsjoner`
@@ -608,24 +526,14 @@ const MEMORY_SUMMARY_TEMPLATES: Record<
typeof v.avoidIngredientId === "string"
? `Vältettävä ainesosa: ${String(v.avoidIngredientId)}`
: null,
goal: (v) =>
typeof v.goal === "string" ? `Tavoite: ${String(v.goal)}` : null,
goal: (v) => (typeof v.goal === "string" ? `Tavoite: ${String(v.goal)}` : null),
primaryGoal: (v) =>
typeof v.primaryGoal === "string"
? `Päätavoite: ${String(v.primaryGoal)}`
: null,
allergen: (v) =>
typeof v.allergen === "string"
? `Allergia: ${String(v.allergen)}`
: null,
typeof v.primaryGoal === "string" ? `Päätavoite: ${String(v.primaryGoal)}` : null,
allergen: (v) => (typeof v.allergen === "string" ? `Allergia: ${String(v.allergen)}` : null),
spiceLevelMax: (v) =>
typeof v.spiceLevelMax === "number"
? `Max tulisuus: ${v.spiceLevelMax}`
: null,
typeof v.spiceLevelMax === "number" ? `Max tulisuus: ${v.spiceLevelMax}` : null,
dislikedIngredient: (v) =>
typeof v.dislikedIngredient === "string"
? `Vältää: ${String(v.dislikedIngredient)}`
: null,
typeof v.dislikedIngredient === "string" ? `Vältää: ${String(v.dislikedIngredient)}` : null,
typicalPortions: (v) =>
typeof v.typicalPortions === "number"
? `Valmistaa yleensä ${v.typicalPortions} annosta`
@@ -644,28 +552,16 @@ const MEMORY_SUMMARY_TEMPLATES: Record<
typeof v.avoidIngredientId === "string"
? `Vermijdt ingrediënt: ${String(v.avoidIngredientId)}`
: null,
goal: (v) =>
typeof v.goal === "string" ? `Doel: ${String(v.goal)}` : null,
goal: (v) => (typeof v.goal === "string" ? `Doel: ${String(v.goal)}` : null),
primaryGoal: (v) =>
typeof v.primaryGoal === "string"
? `Primair doel: ${String(v.primaryGoal)}`
: null,
allergen: (v) =>
typeof v.allergen === "string"
? `Allergie: ${String(v.allergen)}`
: null,
typeof v.primaryGoal === "string" ? `Primair doel: ${String(v.primaryGoal)}` : null,
allergen: (v) => (typeof v.allergen === "string" ? `Allergie: ${String(v.allergen)}` : null),
spiceLevelMax: (v) =>
typeof v.spiceLevelMax === "number"
? `Max pittigheid: ${v.spiceLevelMax}`
: null,
typeof v.spiceLevelMax === "number" ? `Max pittigheid: ${v.spiceLevelMax}` : null,
dislikedIngredient: (v) =>
typeof v.dislikedIngredient === "string"
? `Vermijdt: ${String(v.dislikedIngredient)}`
: null,
typeof v.dislikedIngredient === "string" ? `Vermijdt: ${String(v.dislikedIngredient)}` : null,
typicalPortions: (v) =>
typeof v.typicalPortions === "number"
? `Kookt meestal ${v.typicalPortions} porties`
: null,
typeof v.typicalPortions === "number" ? `Kookt meestal ${v.typicalPortions} porties` : null,
spicePreference: (v) =>
typeof v.spicePreference === "string"
? `Pittigheidspreferentie: ${String(v.spicePreference)}`
@@ -680,28 +576,16 @@ const MEMORY_SUMMARY_TEMPLATES: Record<
typeof v.avoidIngredientId === "string"
? `Unika składnika: ${String(v.avoidIngredientId)}`
: null,
goal: (v) =>
typeof v.goal === "string" ? `Cel: ${String(v.goal)}` : null,
goal: (v) => (typeof v.goal === "string" ? `Cel: ${String(v.goal)}` : null),
primaryGoal: (v) =>
typeof v.primaryGoal === "string"
? `Cel główny: ${String(v.primaryGoal)}`
: null,
allergen: (v) =>
typeof v.allergen === "string"
? `Alergia: ${String(v.allergen)}`
: null,
typeof v.primaryGoal === "string" ? `Cel główny: ${String(v.primaryGoal)}` : null,
allergen: (v) => (typeof v.allergen === "string" ? `Alergia: ${String(v.allergen)}` : null),
spiceLevelMax: (v) =>
typeof v.spiceLevelMax === "number"
? `Maks. ostrość: ${v.spiceLevelMax}`
: null,
typeof v.spiceLevelMax === "number" ? `Maks. ostrość: ${v.spiceLevelMax}` : null,
dislikedIngredient: (v) =>
typeof v.dislikedIngredient === "string"
? `Unika: ${String(v.dislikedIngredient)}`
: null,
typeof v.dislikedIngredient === "string" ? `Unika: ${String(v.dislikedIngredient)}` : null,
typicalPortions: (v) =>
typeof v.typicalPortions === "number"
? `Zwykle gotuje ${v.typicalPortions} porcje`
: null,
typeof v.typicalPortions === "number" ? `Zwykle gotuje ${v.typicalPortions} porcje` : null,
spicePreference: (v) =>
typeof v.spicePreference === "string"
? `Preferencja ostrości: ${String(v.spicePreference)}`
@@ -716,24 +600,14 @@ const MEMORY_SUMMARY_TEMPLATES: Record<
typeof v.avoidIngredientId === "string"
? `Evita ingrediente: ${String(v.avoidIngredientId)}`
: null,
goal: (v) =>
typeof v.goal === "string" ? `Objetivo: ${String(v.goal)}` : null,
goal: (v) => (typeof v.goal === "string" ? `Objetivo: ${String(v.goal)}` : null),
primaryGoal: (v) =>
typeof v.primaryGoal === "string"
? `Objetivo principal: ${String(v.primaryGoal)}`
: null,
allergen: (v) =>
typeof v.allergen === "string"
? `Alergia: ${String(v.allergen)}`
: null,
typeof v.primaryGoal === "string" ? `Objetivo principal: ${String(v.primaryGoal)}` : null,
allergen: (v) => (typeof v.allergen === "string" ? `Alergia: ${String(v.allergen)}` : null),
spiceLevelMax: (v) =>
typeof v.spiceLevelMax === "number"
? `Nível picante máx.: ${v.spiceLevelMax}`
: null,
typeof v.spiceLevelMax === "number" ? `Nível picante máx.: ${v.spiceLevelMax}` : null,
dislikedIngredient: (v) =>
typeof v.dislikedIngredient === "string"
? `Evita: ${String(v.dislikedIngredient)}`
: null,
typeof v.dislikedIngredient === "string" ? `Evita: ${String(v.dislikedIngredient)}` : null,
typicalPortions: (v) =>
typeof v.typicalPortions === "number"
? `Costuma cozinhar ${v.typicalPortions} porções`
+25 -11
View File
@@ -38,9 +38,7 @@ describe("buildMemoryOverview", () => {
});
it("visar origin och confidence tydligt", () => {
const items: MemoryItem[] = [
makeItem({ origin: "observed", confidence: 0.75 }),
];
const items: MemoryItem[] = [makeItem({ origin: "observed", confidence: 0.75 })];
const overview = buildMemoryOverview(items, "sv-SE");
const item = overview.sections[0]!.items[0]!;
expect(item.originLabel).toBe("Vi har sett");
@@ -83,12 +81,24 @@ describe("buildMemoryOverview", () => {
});
it("renderMemorySummary stödjer S5-value-former", () => {
expect(renderMemorySummary({ summarySv: "", value: { favoriteCuisine: "thai" } }, "en-US")).toBe("Favorite cuisine: thai");
expect(renderMemorySummary({ summarySv: "", value: { avoidIngredientId: "broccoli" } }, "en-US")).toBe("Avoids ingredient: broccoli");
expect(renderMemorySummary({ summarySv: "", value: { goal: "less_waste" } }, "en-US")).toBe("Goal: less_waste");
expect(renderMemorySummary({ summarySv: "", value: { primaryGoal: "less_waste" } }, "en-US")).toBe("Primary goal: less_waste");
expect(renderMemorySummary({ summarySv: "", value: { allergen: "gluten" } }, "en-US")).toBe("Allergy: gluten");
expect(renderMemorySummary({ summarySv: "", value: { spiceLevelMax: 2 } }, "en-US")).toBe("Max spice level: 2");
expect(
renderMemorySummary({ summarySv: "", value: { favoriteCuisine: "thai" } }, "en-US"),
).toBe("Favorite cuisine: thai");
expect(
renderMemorySummary({ summarySv: "", value: { avoidIngredientId: "broccoli" } }, "en-US"),
).toBe("Avoids ingredient: broccoli");
expect(renderMemorySummary({ summarySv: "", value: { goal: "less_waste" } }, "en-US")).toBe(
"Goal: less_waste",
);
expect(
renderMemorySummary({ summarySv: "", value: { primaryGoal: "less_waste" } }, "en-US"),
).toBe("Primary goal: less_waste");
expect(renderMemorySummary({ summarySv: "", value: { allergen: "gluten" } }, "en-US")).toBe(
"Allergy: gluten",
);
expect(renderMemorySummary({ summarySv: "", value: { spiceLevelMax: 2 } }, "en-US")).toBe(
"Max spice level: 2",
);
});
});
@@ -131,7 +141,9 @@ describe("deriveMemoryUpdates", () => {
const result = await deriveMemoryUpdates(aamos, {
scope: "user",
scopeId: "u1",
events: [{ id: "evt-real", type: "recipe_cooked", occurredAt: "2026-08-01T12:00:00Z", payload: {} }],
events: [
{ id: "evt-real", type: "recipe_cooked", occurredAt: "2026-08-01T12:00:00Z", payload: {} },
],
existingMemoryKeys: [],
consentFlags: { personalization: true, anonymizedImprovement: false, imageTraining: false },
});
@@ -156,7 +168,9 @@ describe("deriveMemoryUpdates", () => {
const result = await deriveMemoryUpdates(aamos, {
scope: "user",
scopeId: "u1",
events: [{ id: "evt-real", type: "recipe_cooked", occurredAt: "2026-08-01T12:00:00Z", payload: {} }],
events: [
{ id: "evt-real", type: "recipe_cooked", occurredAt: "2026-08-01T12:00:00Z", payload: {} },
],
existingMemoryKeys: [],
consentFlags: { personalization: true, anonymizedImprovement: false, imageTraining: false },
});
@@ -14,19 +14,10 @@
"titleSv": "Krämig Kyckling med Jordnötssås och Ris",
"descriptionSv": "En enkel och smakrik vardagsrätt med saftig kyckling i en krämig jordnötssås, serverad med fluffigt ris och krispiga grönsaker. Perfekt för en snabb middag som hela familjen gillar.",
"cuisine": "swedish",
"mealTypes": [
"dinner"
],
"tags": [
"quick",
"high_protein"
],
"methods": [
"stovetop"
],
"equipment": [
"stove"
],
"mealTypes": ["dinner"],
"tags": ["quick", "high_protein"],
"methods": ["stovetop"],
"equipment": ["stove"],
"difficulty": "beginner",
"prepMin": 20,
"cookMin": 25,
@@ -39,14 +30,8 @@
"variantType": "standard",
"dnaProtein": "chicken_breast",
"dnaCarb": "rice_white",
"dnaVegetables": [
"garlic",
"onion"
],
"dnaFlavor": [
"fresh",
"balanced"
],
"dnaVegetables": ["garlic", "onion"],
"dnaFlavor": ["fresh", "balanced"],
"verificationStatus": "verified",
"storageGuidanceSv": "Förvara eventuella rester i en lufttät behållare i kylskåp i upp till 2-3 dagar.",
"ingredients": [
@@ -190,19 +175,10 @@
"titleSv": "Vegetarisk Jordnötsgryta med Linser och Potatis",
"descriptionSv": "En värmande och mättande vegetarisk gryta med röda linser, potatis och en krämig jordnötssås. En perfekt vardagsrätt som är full av smak och näring.",
"cuisine": "swedish",
"mealTypes": [
"dinner"
],
"tags": [
"quick",
"high_protein"
],
"methods": [
"stovetop"
],
"equipment": [
"stove"
],
"mealTypes": ["dinner"],
"tags": ["quick", "high_protein"],
"methods": ["stovetop"],
"equipment": ["stove"],
"difficulty": "beginner",
"prepMin": 20,
"cookMin": 35,
@@ -215,16 +191,8 @@
"variantType": "standard",
"dnaProtein": "peanut_butter",
"dnaCarb": "potato",
"dnaVegetables": [
"onion",
"garlic",
"carrot",
"spinach"
],
"dnaFlavor": [
"fresh",
"balanced"
],
"dnaVegetables": ["onion", "garlic", "carrot", "spinach"],
"dnaFlavor": ["fresh", "balanced"],
"verificationStatus": "verified",
"storageGuidanceSv": "Förvara rester i en lufttät behållare i kylskåp i upp till 3-4 dagar. Grytan går utmärkt att frysa in.",
"ingredients": [
@@ -367,19 +335,10 @@
"titleSv": "Nudelwok med Tofu och Jordnötssås",
"descriptionSv": "En snabb och smakrik nudelwok med krispiga grönsaker, stekt tofu och en ljuvlig jordnötssås. Perfekt för en vardag när du vill ha något gott och enkelt.",
"cuisine": "swedish",
"mealTypes": [
"dinner"
],
"tags": [
"quick",
"high_protein"
],
"methods": [
"stovetop"
],
"equipment": [
"stove"
],
"mealTypes": ["dinner"],
"tags": ["quick", "high_protein"],
"methods": ["stovetop"],
"equipment": ["stove"],
"difficulty": "beginner",
"prepMin": 20,
"cookMin": 20,
@@ -391,15 +350,8 @@
"holidayTags": [],
"variantType": "standard",
"dnaProtein": "tofu",
"dnaVegetables": [
"garlic",
"onion",
"carrot"
],
"dnaFlavor": [
"fresh",
"balanced"
],
"dnaVegetables": ["garlic", "onion", "carrot"],
"dnaFlavor": ["fresh", "balanced"],
"verificationStatus": "verified",
"storageGuidanceSv": "Förvara eventuella rester i en lufttät behållare i kylskåp i upp till 2-3 dagar. Ej lämplig för infrysning på grund av nudlarna.",
"ingredients": [
@@ -562,19 +514,10 @@
"titleSv": "Köttfärsbiffar med Jordnötssmak och Ugnsrostad Potatis",
"descriptionSv": "Saftiga köttfärsbiffar med en oväntad touch av jordnötssmör, serverade med krispig ugnsrostad potatis och en fräsch yoghurtsås. En spännande twist på en klassisk vardagsrätt.",
"cuisine": "swedish",
"mealTypes": [
"dinner"
],
"tags": [
"quick",
"high_protein"
],
"methods": [
"stovetop"
],
"equipment": [
"stove"
],
"mealTypes": ["dinner"],
"tags": ["quick", "high_protein"],
"methods": ["stovetop"],
"equipment": ["stove"],
"difficulty": "beginner",
"prepMin": 25,
"cookMin": 40,
@@ -587,15 +530,8 @@
"variantType": "standard",
"dnaProtein": "minced_beef",
"dnaCarb": "potato",
"dnaVegetables": [
"onion",
"garlic",
"cucumber"
],
"dnaFlavor": [
"fresh",
"balanced"
],
"dnaVegetables": ["onion", "garlic", "cucumber"],
"dnaFlavor": ["fresh", "balanced"],
"verificationStatus": "verified",
"storageGuidanceSv": "Förvara eventuella rester i en lufttät behållare i kylskåp i upp till 2-3 dagar. Biffarna går bra att frysa in.",
"ingredients": [
@@ -734,4 +670,4 @@
]
}
]
}
}
@@ -2,4 +2,4 @@
"batchId": "gapfill-nullcells-1786450738528",
"generatedAt": "2026-08-11T12:18:58.528Z",
"rejects": []
}
}
@@ -9,4 +9,4 @@
"rejected": 0,
"exportPath": "/home/dator_ubuntujpb/.openclaw/workspace/cibello-work/cibello/packages/recipe-generation/output/gapfill-nullcells-export.json",
"rejectsPath": "/home/dator_ubuntujpb/.openclaw/workspace/cibello-work/cibello/packages/recipe-generation/output/gapfill-nullcells-rejects.json"
}
}
@@ -18,11 +18,7 @@ import { BRAND } from "@app/shared-types";
import * as fs from "node:fs/promises";
import * as path from "node:path";
const OUTPUT_DIR = path.resolve(
import.meta.dirname ?? "..",
"..",
"output",
);
const OUTPUT_DIR = path.resolve(import.meta.dirname ?? "..", "..", "output");
const NULLCELL_TARGETS: PipelineTarget[] = [
// Endast jordnötssmör-cellen återstår; rikare, lagade middagsrätter.
@@ -74,13 +70,33 @@ function deriveEquipment(mainId: string): string[] {
function guessDna(mainId: string, ingredientIds: string[]) {
const vegetables = ingredientIds.filter((id) =>
["tomato", "spinach", "zucchini", "paprika", "carrot", "cucumber", "lettuce", "corn", "onion", "garlic"].includes(id),
[
"tomato",
"spinach",
"zucchini",
"paprika",
"carrot",
"cucumber",
"lettuce",
"corn",
"onion",
"garlic",
].includes(id),
);
const carbs = ingredientIds.filter((id) =>
["rice_white", "pasta_dry", "potato", "bread", "tortilla", "oats", "quinoa"].includes(id),
);
const proteins = ingredientIds.filter((id) =>
["pork_loin", "chicken_breast", "chicken_thigh", "minced_beef", "salmon", "tofu", "egg", "peanut_butter"].includes(id),
[
"pork_loin",
"chicken_breast",
"chicken_thigh",
"minced_beef",
"salmon",
"tofu",
"egg",
"peanut_butter",
].includes(id),
);
return {
protein: proteins[0] ?? null,
@@ -103,7 +119,9 @@ function toSeedRecipe(
titleSv: c.titleSv,
descriptionSv: c.descriptionSv,
cuisine: (c.cuisine as SeedRecipe["cuisine"]) ?? "international",
mealTypes: (c.mealTypes as SeedRecipe["mealTypes"]) ?? [target.mealType as SeedRecipe["mealTypes"][number]],
mealTypes: (c.mealTypes as SeedRecipe["mealTypes"]) ?? [
target.mealType as SeedRecipe["mealTypes"][number],
],
tags: deriveTags(target.mealType, target.mainIngredientId) as SeedRecipe["tags"],
methods: deriveMethods(target.mainIngredientId) as SeedRecipe["methods"],
equipment: deriveEquipment(target.mainIngredientId) as SeedRecipe["equipment"],
@@ -193,7 +211,9 @@ async function main() {
},
};
console.error(`[gapfill] ${NULLCELL_TARGETS.length} nollceller, max ${NULLCELL_TARGETS.reduce((s, t) => s + t.count, 0)} recept`);
console.error(
`[gapfill] ${NULLCELL_TARGETS.length} nollceller, max ${NULLCELL_TARGETS.reduce((s, t) => s + t.count, 0)} recept`,
);
const result = await runPipeline(
client,
@@ -205,11 +225,18 @@ async function main() {
);
const verifiedExports: Array<{ target: PipelineTarget; slug: string; recipe: SeedRecipe }> = [];
const rejects: Array<{ target: PipelineTarget; title: string; status: string; reasons: string[] }> = [];
const rejects: Array<{
target: PipelineTarget;
title: string;
status: string;
reasons: string[];
}> = [];
for (let i = 0; i < result.verificationResults.length; i++) {
const vr = result.verificationResults[i];
const target = NULLCELL_TARGETS.find((t) => t.mainIngredientId === vr.canonicalIngredientIds[0]) ?? NULLCELL_TARGETS[0];
const target =
NULLCELL_TARGETS.find((t) => t.mainIngredientId === vr.canonicalIngredientIds[0]) ??
NULLCELL_TARGETS[0];
if (vr.status === "verified") {
const slug = makeUniqueSlug(vr.candidate.titleSv, usedSlugs);
@@ -87,17 +87,16 @@ async function main() {
const client = createAamosClient(process.env);
console.error("[pilot-batch] Startar generering...");
console.error(`[pilot-batch] Katalog: ${catalog.length} ingredienser`);
console.error(`[pilot-batch] Mål: ${targets.length} matris-celler, ~${targets.reduce((s, t) => s + t.count, 0)} recept`);
const result = await runPipeline(
client,
targets,
catalog,
ingredientLookup,
similarityLookup,
{ maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 },
console.error(
`[pilot-batch] Mål: ${targets.length} matris-celler, ~${targets.reduce((s, t) => s + t.count, 0)} recept`,
);
const result = await runPipeline(client, targets, catalog, ingredientLookup, similarityLookup, {
maxPrepTimeMinutes: 30,
maxCookTimeMinutes: 45,
portions: 4,
});
// ── 6. Rapportera ────────────────────────────────────────────────────────
const report = {
batchId: `pilot-${Date.now()}`,
@@ -146,9 +145,11 @@ async function main() {
}
if (result.candidates.length === 0 && result.geminiResult.status === "ok") {
console.error("\n**Notering:** Gemini returnerade ok men inga kandidater. " +
"Mock-klienten stödjer inte GENERATE_RECIPE_CANDIDATES ännu — " +
"kör med AAMOS_MODE=gemini för live-generering.");
console.error(
"\n**Notering:** Gemini returnerade ok men inga kandidater. " +
"Mock-klienten stödjer inte GENERATE_RECIPE_CANDIDATES ännu — " +
"kör med AAMOS_MODE=gemini för live-generering.",
);
}
}
+12 -6
View File
@@ -62,15 +62,15 @@ export function buildGapReport(
for (const ev of searchMissEvents) {
if (ev.occurredAt < cutoff) continue;
const query = String(ev.properties?.query ?? "").toLowerCase().trim();
const query = String(ev.properties?.query ?? "")
.toLowerCase()
.trim();
if (!query) continue;
const ingredientId = ev.properties?.suggestedIngredientId
? String(ev.properties.suggestedIngredientId)
: null;
const mealType = ev.properties?.mealType
? String(ev.properties.mealType)
: null;
const mealType = ev.properties?.mealType ? String(ev.properties.mealType) : null;
const key = `${query}::${ingredientId ?? "_"}::${mealType ?? "_"}`;
const existing = missMap.get(key);
@@ -123,8 +123,14 @@ export function buildGapReport(
const mealTypeCounts = new Map<string | null, number>();
for (const e of limitedEntries) {
ingredientCounts.set(e.suggestedIngredientId, (ingredientCounts.get(e.suggestedIngredientId) ?? 0) + e.missCount);
mealTypeCounts.set(e.suggestedMealType, (mealTypeCounts.get(e.suggestedMealType) ?? 0) + e.missCount);
ingredientCounts.set(
e.suggestedIngredientId,
(ingredientCounts.get(e.suggestedIngredientId) ?? 0) + e.missCount,
);
mealTypeCounts.set(
e.suggestedMealType,
(mealTypeCounts.get(e.suggestedMealType) ?? 0) + e.missCount,
);
}
const topMissingIngredients = [...ingredientCounts.entries()]
+6 -5
View File
@@ -9,12 +9,13 @@
*/
import type { AamosClient, AamosResult } from "@app/ai-contracts";
import type {
TaskInput,
TaskOutput,
} from "@app/ai-contracts";
import type { TaskInput, TaskOutput } from "@app/ai-contracts";
import type { RecipeCandidate, VerificationResult } from "./types.js";
import { verifyCandidate, type CanonicalIngredientLookup, type SimilarityLookup } from "./verification.js";
import {
verifyCandidate,
type CanonicalIngredientLookup,
type SimilarityLookup,
} from "./verification.js";
export interface PipelineIngredient {
id: string;
@@ -16,10 +16,7 @@ import {
type RecipeIngredientForCalc,
type IngredientNutritionSource,
} from "@app/nutrition-engine";
import {
deriveRecipeAllergens,
type IngredientSafetyInfo,
} from "@app/recipe-engine";
import { deriveRecipeAllergens, type IngredientSafetyInfo } from "@app/recipe-engine";
import type { NutritionValues, Allergen } from "@app/shared-types";
import type { RecipeCandidate, VerificationResult } from "./types.js";
@@ -137,7 +134,8 @@ function hasSafeCookingStep(steps: RecipeCandidate["steps"]): boolean {
return steps.some((s) => {
const instruction = s.instructionSv;
const hasPositive = SAFE_COOKING_KEYWORDS_SV.some((re) => re.test(instruction));
const onlyAppearance = UNSAFE_APPEARANCE_ONLY_SV.some((re) => re.test(instruction)) &&
const onlyAppearance =
UNSAFE_APPEARANCE_ONLY_SV.some((re) => re.test(instruction)) &&
!SAFE_COOKING_KEYWORDS_SV.some((re) => re.test(instruction));
return hasPositive && !onlyAppearance;
});
@@ -170,7 +168,11 @@ export async function verifyCandidate(
if (candidate.portions == null || candidate.portions <= 0) {
reasons.push("Receptet saknar giltigt antal portioner.");
}
if (candidate.prepTimeMinutes == null || candidate.cookTimeMinutes == null || candidate.totalTimeMinutes == null) {
if (
candidate.prepTimeMinutes == null ||
candidate.cookTimeMinutes == null ||
candidate.totalTimeMinutes == null
) {
reasons.push("Receptet saknar tidsangivelser.");
}
@@ -308,8 +310,7 @@ export async function verifyCandidate(
}
// ── Resultat ─────────────────────────────────────────────────────────────
const status: VerificationResult["status"] =
reasons.length === 0 ? "verified" : "unverified";
const status: VerificationResult["status"] = reasons.length === 0 ? "verified" : "unverified";
return {
candidate,
@@ -81,7 +81,9 @@ describe("allergen invariant", () => {
const stored = [...(recipe.allergens ?? [])].sort();
if (JSON.stringify(derived) !== JSON.stringify(stored)) {
mismatches.push(`${recipe.titleSv}: stored=${JSON.stringify(stored)} derived=${JSON.stringify(derived)}`);
mismatches.push(
`${recipe.titleSv}: stored=${JSON.stringify(stored)} derived=${JSON.stringify(derived)}`,
);
}
}
@@ -5,11 +5,31 @@ import type { AnalyticsEvent, RecipeSignal } from "../src/gap-report.js";
describe("buildGapReport", () => {
it("identifierar topp-saknade ingredienser", () => {
const events: AnalyticsEvent[] = [
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } },
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } },
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } },
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "tofu", suggestedIngredientId: "fast_tofu" } },
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "tofu", suggestedIngredientId: "fast_tofu" } },
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "lax", suggestedIngredientId: "laxfile" },
},
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "lax", suggestedIngredientId: "laxfile" },
},
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "lax", suggestedIngredientId: "laxfile" },
},
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "tofu", suggestedIngredientId: "fast_tofu" },
},
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "tofu", suggestedIngredientId: "fast_tofu" },
},
];
const report = buildGapReport(events, [], []);
@@ -20,7 +40,11 @@ describe("buildGapReport", () => {
it("filtrerar bort enstaka missar", () => {
const events: AnalyticsEvent[] = [
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "enstaka" } },
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "enstaka" },
},
];
const report = buildGapReport(events, [], [], { minMissThreshold: 2 });
expect(report.entries).toHaveLength(0);
@@ -28,8 +52,16 @@ describe("buildGapReport", () => {
it("formaterar rapporten", () => {
const events: AnalyticsEvent[] = [
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax" } },
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax" } },
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "lax" },
},
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "lax" },
},
];
const report = buildGapReport(events, [], [], { minMissThreshold: 1 });
const text = formatGapReport(report);
@@ -13,8 +13,14 @@ const mockIngredients: CanonicalIngredientLookup = {
nutritionPer100: {
basis: "per_100_g",
values: {
kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6,
saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1,
kcal: 165,
proteinG: 31,
carbsG: 0,
fatG: 3.6,
saturatedFatG: 1,
fiberG: 0,
sugarG: 0,
saltG: 0.1,
},
},
defaultUnit: "GRAM",
@@ -64,6 +70,8 @@ describe("runPipeline", () => {
expect(result.geminiResult.status).toBe("ok");
// MockAamosClient returnerar nu kandidater för GENERATE_RECIPE_CANDIDATES
expect(result.candidates.length).toBeGreaterThan(0);
expect(result.verifiedCount + result.unverifiedCount + result.rejectedCount).toBe(result.candidates.length);
expect(result.verifiedCount + result.unverifiedCount + result.rejectedCount).toBe(
result.candidates.length,
);
});
});
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
import { verifyCandidate, type CanonicalIngredientLookup, type SimilarityLookup } from "../src/verification.js";
import {
verifyCandidate,
type CanonicalIngredientLookup,
type SimilarityLookup,
} from "../src/verification.js";
import type { RecipeCandidate } from "../src/types.js";
const mockIngredients: CanonicalIngredientLookup = {
@@ -10,8 +14,14 @@ const mockIngredients: CanonicalIngredientLookup = {
nutritionPer100: {
basis: "per_100_g",
values: {
kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6,
saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1,
kcal: 165,
proteinG: 31,
carbsG: 0,
fatG: 3.6,
saturatedFatG: 1,
fiberG: 0,
sugarG: 0,
saltG: 0.1,
},
},
defaultUnit: "GRAM",
@@ -31,8 +41,14 @@ const mockIngredients: CanonicalIngredientLookup = {
nutritionPer100: {
basis: "per_100_g",
values: {
kcal: 130, proteinG: 2.7, carbsG: 28, fatG: 0.3,
saturatedFatG: 0.1, fiberG: 0.4, sugarG: 0.1, saltG: 0,
kcal: 130,
proteinG: 2.7,
carbsG: 28,
fatG: 0.3,
saturatedFatG: 0.1,
fiberG: 0.4,
sugarG: 0.1,
saltG: 0,
},
},
defaultUnit: "GRAM",
@@ -47,13 +63,19 @@ const mockIngredients: CanonicalIngredientLookup = {
isBeef: false,
isAlcohol: false,
},
"krossade_tomater": {
krossade_tomater: {
id: "krossade_tomater",
nutritionPer100: {
basis: "per_100_g",
values: {
kcal: 32, proteinG: 1.6, carbsG: 5.8, fatG: 0.3,
saturatedFatG: 0, fiberG: 1.2, sugarG: 4, saltG: 0.1,
kcal: 32,
proteinG: 1.6,
carbsG: 5.8,
fatG: 0.3,
saturatedFatG: 0,
fiberG: 1.2,
sugarG: 4,
saltG: 0.1,
},
},
defaultUnit: "GRAM",
@@ -73,8 +95,14 @@ const mockIngredients: CanonicalIngredientLookup = {
nutritionPer100: {
basis: "per_100_g",
values: {
kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6,
saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1,
kcal: 165,
proteinG: 31,
carbsG: 0,
fatG: 3.6,
saturatedFatG: 1,
fiberG: 0,
sugarG: 0,
saltG: 0.1,
},
},
defaultUnit: "GRAM",
@@ -93,7 +121,16 @@ const mockIngredients: CanonicalIngredientLookup = {
id: "sesame_seeds",
nutritionPer100: {
basis: "per_100_g",
values: { kcal: 600, proteinG: 18, carbsG: 12, fatG: 50, saturatedFatG: 7, fiberG: 12, sugarG: 0, saltG: 0 },
values: {
kcal: 600,
proteinG: 18,
carbsG: 12,
fatG: 50,
saturatedFatG: 7,
fiberG: 12,
sugarG: 0,
saltG: 0,
},
},
defaultUnit: "GRAM",
densityGPerMl: 0.6,
@@ -111,7 +148,16 @@ const mockIngredients: CanonicalIngredientLookup = {
id: "milk_3",
nutritionPer100: {
basis: "per_100_g",
values: { kcal: 60, proteinG: 3.4, carbsG: 4.7, fatG: 3, saturatedFatG: 1.9, fiberG: 0, sugarG: 4.7, saltG: 0.1 },
values: {
kcal: 60,
proteinG: 3.4,
carbsG: 4.7,
fatG: 3,
saturatedFatG: 1.9,
fiberG: 0,
sugarG: 4.7,
saltG: 0.1,
},
},
defaultUnit: "DECILITER",
densityGPerMl: 1.03,
@@ -148,13 +194,46 @@ function makeCandidate(overrides?: Partial<RecipeCandidate>): RecipeCandidate {
portions: 4,
spiceLevel: 1,
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
{
canonicalIngredientId: "kycklingfile",
displayNameSv: "kycklingfilé",
quantity: 500,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "krossade_tomater",
displayNameSv: "krossade tomater",
quantity: 400,
unit: "GRAM",
optional: false,
note: null,
},
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen i en panna.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt tomater och ris, låt koka.", timerSeconds: 900, temperatureC: null, tip: null },
{
stepNumber: 1,
instructionSv: "Stek kycklingen i en panna.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
stepNumber: 2,
instructionSv: "Tillsätt tomater och ris, låt koka.",
timerSeconds: 900,
temperatureC: null,
tip: null,
},
],
storageGuidanceSv: "Förvara i kylskåp upp till 3 dagar.",
mealPrepFriendly: false,
@@ -178,7 +257,14 @@ describe("verifyCandidate", () => {
it("avvisar kandidat med okänd ingrediens", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "fantasi_gronsak", displayNameSv: "fantasigrönsak", quantity: 100, unit: "GRAM", optional: false, note: null },
{
canonicalIngredientId: "fantasi_gronsak",
displayNameSv: "fantasigrönsak",
quantity: 100,
unit: "GRAM",
optional: false,
note: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
@@ -188,13 +274,25 @@ describe("verifyCandidate", () => {
it("markerar unverified vid orimlig tid", async () => {
const c = makeCandidate({ prepTimeMinutes: 200, cookTimeMinutes: 10, totalTimeMinutes: 210 });
const result = await verifyCandidate(c, mockIngredients, noSimilarity, { maxPrepTimeMinutes: 60 });
const result = await verifyCandidate(c, mockIngredients, noSimilarity, {
maxPrepTimeMinutes: 60,
});
expect(result.status).toBe("unverified");
expect(result.reasons.some((r) => r.includes("Förberedelsetid"))).toBe(true);
});
it("markerar unverified vid för få steg", async () => {
const c = makeCandidate({ steps: [{ stepNumber: 1, instructionSv: "Gör allt.", timerSeconds: null, temperatureC: null, tip: null }] });
const c = makeCandidate({
steps: [
{
stepNumber: 1,
instructionSv: "Gör allt.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity, { minSteps: 2 });
expect(result.status).toBe("unverified");
expect(result.reasons.some((r) => r.includes("steg"))).toBe(true);
@@ -203,7 +301,14 @@ describe("verifyCandidate", () => {
it("härleder allergener korrekt", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{
canonicalIngredientId: "kycklingfile",
displayNameSv: "kycklingfilé",
quantity: 500,
unit: "GRAM",
optional: false,
note: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
@@ -215,7 +320,16 @@ describe("verifyCandidate", () => {
id: "milk_3",
nutritionPer100: {
basis: "per_100_g",
values: { kcal: 60, proteinG: 3.4, carbsG: 4.7, fatG: 3, saturatedFatG: 1.9, fiberG: 0, sugarG: 4.7, saltG: 0.1 },
values: {
kcal: 60,
proteinG: 3.4,
carbsG: 4.7,
fatG: 3,
saturatedFatG: 1.9,
fiberG: 0,
sugarG: 4.7,
saltG: 0.1,
},
},
defaultUnit: "DECILITER",
densityGPerMl: 1.03,
@@ -237,7 +351,14 @@ describe("verifyCandidate", () => {
const c = makeCandidate({
aiClaimedAllergens: [],
ingredients: [
{ canonicalIngredientId: "milk_3", displayNameSv: "mjölk", quantity: 5, unit: "DECILITER", optional: false, note: null },
{
canonicalIngredientId: "milk_3",
displayNameSv: "mjölk",
quantity: 5,
unit: "DECILITER",
optional: false,
note: null,
},
],
});
const result = await verifyCandidate(c, lookup, noSimilarity);
@@ -248,9 +369,30 @@ describe("verifyCandidate", () => {
const c = makeCandidate({
aiClaimedAllergens: ["gluten"],
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
{
canonicalIngredientId: "kycklingfile",
displayNameSv: "kycklingfilé",
quantity: 500,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "krossade_tomater",
displayNameSv: "krossade tomater",
quantity: 400,
unit: "GRAM",
optional: false,
note: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
@@ -272,63 +414,194 @@ describe("verifyCandidate", () => {
it("kräver genomstekningssteg för rå fågel (positivt)", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
{
canonicalIngredientId: "chicken_breast",
displayNameSv: "kycklingfilé",
quantity: 500,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "krossade_tomater",
displayNameSv: "krossade tomater",
quantity: 400,
unit: "GRAM",
optional: false,
note: null,
},
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen tills den är genomstekt och innertemperaturen är 72°C.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och tomater, koka klart.", timerSeconds: null, temperatureC: null, tip: null },
{
stepNumber: 1,
instructionSv: "Stek kycklingen tills den är genomstekt och innertemperaturen är 72°C.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
stepNumber: 2,
instructionSv: "Tillsätt ris och tomater, koka klart.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.status).toBe("verified");
expect(result.reasons).not.toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
expect(result.reasons).not.toContain(
"Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.",
);
});
it("markerar unverified när rå fågel bara beskrivs som gyllenbrun (utseende räcker inte)", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{
canonicalIngredientId: "chicken_breast",
displayNameSv: "kycklingfilé",
quantity: 500,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen tills den är gyllenbrun.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och koka klart.", timerSeconds: null, temperatureC: null, tip: null },
{
stepNumber: 1,
instructionSv: "Stek kycklingen tills den är gyllenbrun.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
stepNumber: 2,
instructionSv: "Tillsätt ris och koka klart.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.status).toBe("unverified");
expect(result.reasons).toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
expect(result.reasons).toContain(
"Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.",
);
});
it("markerar unverified när rå fågel saknar genomstekningssteg (negativt)", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
{
canonicalIngredientId: "chicken_breast",
displayNameSv: "kycklingfilé",
quantity: 500,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "krossade_tomater",
displayNameSv: "krossade tomater",
quantity: 400,
unit: "GRAM",
optional: false,
note: null,
},
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen i pannan.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och tomater, koka klart.", timerSeconds: null, temperatureC: null, tip: null },
{
stepNumber: 1,
instructionSv: "Stek kycklingen i pannan.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
stepNumber: 2,
instructionSv: "Tillsätt ris och tomater, koka klart.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.status).toBe("unverified");
expect(result.reasons).toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
expect(result.reasons).toContain(
"Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.",
);
});
it("härleder allergen även från valfri ingrediens (optional sesame)", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "sesame_seeds", displayNameSv: "sesamfrön till garnering", quantity: 10, unit: "GRAM", optional: true, note: null },
{
canonicalIngredientId: "kycklingfile",
displayNameSv: "kycklingfilé",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "sesame_seeds",
displayNameSv: "sesamfrön till garnering",
quantity: 10,
unit: "GRAM",
optional: true,
note: null,
},
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen genomstekt.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och sesamefrön.", timerSeconds: null, temperatureC: null, tip: null },
{
stepNumber: 1,
instructionSv: "Stek kycklingen genomstekt.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
stepNumber: 2,
instructionSv: "Tillsätt ris och sesamefrön.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
@@ -338,30 +611,98 @@ describe("verifyCandidate", () => {
it("kräver genomstekningssteg även för optional rå fågel", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 300, unit: "GRAM", optional: true, note: null },
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "krossade_tomater",
displayNameSv: "krossade tomater",
quantity: 400,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "chicken_breast",
displayNameSv: "kycklingfilé",
quantity: 300,
unit: "GRAM",
optional: true,
note: null,
},
],
steps: [
{ stepNumber: 1, instructionSv: "Koka riset enligt anvisningen.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt tomater och kyckling.", timerSeconds: null, temperatureC: null, tip: null },
{
stepNumber: 1,
instructionSv: "Koka riset enligt anvisningen.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
stepNumber: 2,
instructionSv: "Tillsätt tomater och kyckling.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.status).toBe("unverified");
expect(result.reasons).toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
expect(result.reasons).toContain(
"Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.",
);
});
it("härleder mjölk-allergen från optional mjölk", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "milk_3", displayNameSv: "mjölk till servering", quantity: 2, unit: "DECILITER", optional: true, note: null },
{
canonicalIngredientId: "kycklingfile",
displayNameSv: "kycklingfilé",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "milk_3",
displayNameSv: "mjölk till servering",
quantity: 2,
unit: "DECILITER",
optional: true,
note: null,
},
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen genomstekt.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och mjölk.", timerSeconds: null, temperatureC: null, tip: null },
{
stepNumber: 1,
instructionSv: "Stek kycklingen genomstekt.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
stepNumber: 2,
instructionSv: "Tillsätt ris och mjölk.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
@@ -1,8 +1,4 @@
import type {
ProvenanceEntry,
RecommendationCandidate,
RecommendationContext,
} from "./types.js";
import type { ProvenanceEntry, RecommendationCandidate, RecommendationContext } from "./types.js";
import { renderProvenance } from "./provenance-templates.js";
import type { LanguageTag } from "./provenance-templates.js";
@@ -182,7 +178,8 @@ const WHY_TEMPLATES: Record<LanguageTag, WhyTemplates> = {
nl: {
coverage100: "Jullie hebben alle ingrediënten thuis.",
coveragePct: "Jullie hebben {{pct}} % van de ingrediënten thuis.",
coverageLow: "Jullie hebben {{pct}} % van de ingrediënten; de rest komt op het boodschappenlijstje.",
coverageLow:
"Jullie hebben {{pct}} % van de ingrediënten; de rest komt op het boodschappenlijstje.",
expiringToday: "{{ingredient}} moet vandaag gebruikt worden.",
expiringTomorrow: "{{ingredient}} moet uiterlijk morgen gebruikt worden.",
expiringDays: "{{ingredient}} moet binnen {{days}} dagen gebruikt worden.",
@@ -7,23 +7,9 @@ import type { ProvenanceEntry } from "./types.js";
*/
export type LanguageTag =
| "sv"
| "en"
| "es"
| "it"
| "de"
| "fr"
| "da"
| "nb"
| "fi"
| "nl"
| "pl"
| "pt";
"sv" | "en" | "es" | "it" | "de" | "fr" | "da" | "nb" | "fi" | "nl" | "pl" | "pt";
const TEMPLATES: Record<
string,
Record<LanguageTag, string>
> = {
const TEMPLATES: Record<string, Record<LanguageTag, string>> = {
favoriteCuisine: {
sv: "För att du har berättat att du gillar {{cuisine}} mat.",
en: "Because you said you like {{cuisine}} food.",
@@ -124,10 +110,7 @@ const TEMPLATES: Record<
},
};
export function renderProvenance(
entries: ProvenanceEntry[],
languageTag: string,
): string {
export function renderProvenance(entries: ProvenanceEntry[], languageTag: string): string {
const lang = (languageTag.split("-")[0] ?? "sv") as LanguageTag;
const parts: string[] = [];
for (const entry of entries) {
@@ -142,10 +125,7 @@ export function renderProvenance(
return parts.join(" ");
}
export function renderProvenanceList(
entries: ProvenanceEntry[],
languageTag: string,
): string[] {
export function renderProvenanceList(entries: ProvenanceEntry[], languageTag: string): string[] {
const lang = (languageTag.split("-")[0] ?? "sv") as LanguageTag;
return entries
.map((entry) => {
@@ -230,7 +230,12 @@ function memoryFit(candidate: RecommendationCandidate, ctx: RecommendationContex
// Favoritkök
if (memory.kind === "structured_fact" && value.favoriteCuisine === candidate.cuisine) {
const weight = memory.verifiedByUser || memory.origin === "user_stated" ? 1 : memory.origin === "observed" ? 0.7 : 0.4;
const weight =
memory.verifiedByUser || memory.origin === "user_stated"
? 1
: memory.origin === "observed"
? 0.7
: 0.4;
score = Math.max(score, weight);
if (weight >= 0.7) {
provenance.push({
@@ -454,13 +454,29 @@ describe("S4 rekommendationsvyer", () => {
});
// default utan samtycke = depersonalize(DEFAULT_WEIGHTS) == NON_PERSONALIZED_WEIGHTS
const defaultRanked = rankAll([pantry, healthy, tasty], nonPersonalCtx, depersonalize(viewWeights("default")));
const nonPersonalDefaultRanked = rankAll([pantry, healthy, tasty], nonPersonalCtx, NON_PERSONALIZED_WEIGHTS);
expect(defaultRanked.map((r) => r.recipeId)).toEqual(nonPersonalDefaultRanked.map((r) => r.recipeId));
const defaultRanked = rankAll(
[pantry, healthy, tasty],
nonPersonalCtx,
depersonalize(viewWeights("default")),
);
const nonPersonalDefaultRanked = rankAll(
[pantry, healthy, tasty],
nonPersonalCtx,
NON_PERSONALIZED_WEIGHTS,
);
expect(defaultRanked.map((r) => r.recipeId)).toEqual(
nonPersonalDefaultRanked.map((r) => r.recipeId),
);
// vyerna är fortfarande skilda från default även utan samtycke
expect(rankAll([tasty, healthy, pantry], nonPersonalCtx, depersonalize(viewWeights("pantry")))[0]?.recipeId).toBe("pantry");
expect(rankAll([tasty, pantry, healthy], nonPersonalCtx, depersonalize(viewWeights("health")))[0]?.recipeId).toBe("healthy");
expect(
rankAll([tasty, healthy, pantry], nonPersonalCtx, depersonalize(viewWeights("pantry")))[0]
?.recipeId,
).toBe("pantry");
expect(
rankAll([tasty, pantry, healthy], nonPersonalCtx, depersonalize(viewWeights("health")))[0]
?.recipeId,
).toBe("healthy");
// taste-vyn honoreras men personliga axlar är nollade
const tasteWeights = depersonalize(viewWeights("taste"));
@@ -8,20 +8,7 @@ import {
type ProvenanceEntry,
} from "../src/index.js";
const EXPECTED_LANGS = [
"sv",
"en",
"es",
"it",
"de",
"fr",
"da",
"nb",
"fi",
"nl",
"pl",
"pt",
];
const EXPECTED_LANGS = ["sv", "en", "es", "it", "de", "fr", "da", "nb", "fi", "nl", "pl", "pt"];
const fullCoverage = { coverage: 1, matches: [], missing: [], expiringUsed: [] };
const nutrition = {
@@ -71,7 +58,9 @@ describe("S6 i18n-paritet", () => {
const langsByTemplate = provenanceTemplateLanguages();
expect(Object.keys(langsByTemplate).length).toBeGreaterThan(0);
for (const [key, langs] of Object.entries(langsByTemplate)) {
expect(langs.length, `mall ${key} har dubbletter eller saknar språk`).toBe(EXPECTED_LANGS.length);
expect(langs.length, `mall ${key} har dubbletter eller saknar språk`).toBe(
EXPECTED_LANGS.length,
);
expect(new Set(langs).size, `mall ${key} har dubbletter`).toBe(EXPECTED_LANGS.length);
for (const lang of EXPECTED_LANGS) {
expect(langs).toContain(lang);
@@ -89,11 +78,31 @@ describe("S6 i18n-paritet", () => {
});
it("buildWhy renderar på rätt språk för varje supporterat språk", () => {
const provenance: ProvenanceEntry[] = [
{ key: "favoriteCuisine", args: { cuisine: "svensk" } },
];
const provenance: ProvenanceEntry[] = [{ key: "favoriteCuisine", args: { cuisine: "svensk" } }];
for (const lang of EXPECTED_LANGS) {
const why = buildWhy(candidate(), { ...ctx, personalizationEnabled: true }, { coverage: 1, expiry: 0, nutritionFit: 0.9, taste: 0.5, rating: 0.5, season: 0, holiday: 0, time: 0, budget: 0, variety: 0.5, weather: 0.5, craving: 0.5, memoryFit: 1, tasteFit: 0, cookingAssumptionFit: 0 }, provenance, `${lang}-XX`);
const why = buildWhy(
candidate(),
{ ...ctx, personalizationEnabled: true },
{
coverage: 1,
expiry: 0,
nutritionFit: 0.9,
taste: 0.5,
rating: 0.5,
season: 0,
holiday: 0,
time: 0,
budget: 0,
variety: 0.5,
weather: 0.5,
craving: 0.5,
memoryFit: 1,
tasteFit: 0,
cookingAssumptionFit: 0,
},
provenance,
`${lang}-XX`,
);
expect(why.length, `tom why för ${lang}`).toBeGreaterThan(0);
// Ingen why får falla tillbaka på svenska fallback om språket finns.
if (lang !== "sv") {
+1 -6
View File
@@ -589,12 +589,7 @@ export const VERIFICATION_STATUSES = [
export type VerificationStatus = (typeof VERIFICATION_STATUSES)[number];
/** Inventory Trust Engine (Fas 2): computed trust state per item. */
export const TRUST_STATES = [
"unverified",
"trusted",
"decaying",
"stale",
] as const;
export const TRUST_STATES = ["unverified", "trusted", "decaying", "stale"] as const;
export type TrustState = (typeof TRUST_STATES)[number];
// ---------------------------------------------------------------------------
+5 -8
View File
@@ -2,12 +2,7 @@
* Release gate definitions and go/no-go taxonomy (spec §17).
*/
export const RELEASE_GATE_CATEGORIES = [
"product",
"quality",
"retention",
"economy",
] as const;
export const RELEASE_GATE_CATEGORIES = ["product", "quality", "retention", "economy"] as const;
export type ReleaseGateCategory = (typeof RELEASE_GATE_CATEGORIES)[number];
@@ -71,7 +66,8 @@ export const BUILT_IN_RELEASE_GATES: Array<{
category: "product",
nameSv: "Recept sparat eller tillagat dag 0",
nameEn: "Recipe saved or cooked on day 0",
descriptionSv: "Andel användare som sparar eller startar ett recept samma dag som registrering.",
descriptionSv:
"Andel användare som sparar eller startar ett recept samma dag som registrering.",
descriptionEn: "Share of users who save or start cooking a recipe on registration day.",
targetValue: 0.35,
comparison: "gte",
@@ -83,7 +79,8 @@ export const BUILT_IN_RELEASE_GATES: Array<{
category: "product",
nameSv: "Andra lagerhändelsen inom 7 dagar",
nameEn: "Second inventory event within 7 days",
descriptionSv: "Andel användare som lägger till, konsumerar eller kasserar en vara inom en vecka.",
descriptionSv:
"Andel användare som lägger till, konsumerar eller kasserar en vara inom en vecka.",
descriptionEn: "Share of users who add, consume, or discard an item within one week.",
targetValue: 0.25,
comparison: "gte",
+4 -1
View File
@@ -8,7 +8,10 @@ const eventNameSchema = z.enum(PRODUCT_ANALYTICS_EVENT_NAMES);
const analyticsEventSchema = z.object({
name: eventNameSchema,
occurredAt: z.string().refine((v) => !Number.isNaN(Date.parse(v))).optional(),
occurredAt: z
.string()
.refine((v) => !Number.isNaN(Date.parse(v)))
.optional(),
anonymousId: z.string().max(64).optional(),
sessionId: z.string().max(64).optional(),
householdId: z.string().uuid().optional(),