ci: trigga på master + formatfix inför Gitea Actions
CI / Typecheck, test & build (push) Failing after 2s
CI / Typecheck, test & build (push) Failing after 2s
This commit is contained in:
@@ -121,9 +121,7 @@ export class HttpAamosClient implements AamosClient {
|
||||
anonymizedImprovement: false,
|
||||
imageTraining: false,
|
||||
},
|
||||
...(options.systemInstruction
|
||||
? { systemInstruction: options.systemInstruction }
|
||||
: {}),
|
||||
...(options.systemInstruction ? { systemInstruction: options.systemInstruction } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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",
|
||||
|
||||
+26236
-28036
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -175,7 +175,8 @@ export function householdTrustScore(
|
||||
|
||||
// 3. Andel uppskattade mängder (0–100, 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 (0–100, 100 = inga låga kvantiteter)
|
||||
const depletionScore =
|
||||
@@ -189,15 +190,13 @@ export function householdTrustScore(
|
||||
|
||||
// 5. Korrigeringsfrekvens (0–100, 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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user