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

This commit is contained in:
Sven (AAMOS AI)
2026-08-13 17:25:14 +07:00
parent 61d60931ad
commit c32a7e33c7
141 changed files with 40555 additions and 45210 deletions
@@ -14,19 +14,10 @@
"titleSv": "Krämig Kyckling med Jordnötssås och Ris",
"descriptionSv": "En enkel och smakrik vardagsrätt med saftig kyckling i en krämig jordnötssås, serverad med fluffigt ris och krispiga grönsaker. Perfekt för en snabb middag som hela familjen gillar.",
"cuisine": "swedish",
"mealTypes": [
"dinner"
],
"tags": [
"quick",
"high_protein"
],
"methods": [
"stovetop"
],
"equipment": [
"stove"
],
"mealTypes": ["dinner"],
"tags": ["quick", "high_protein"],
"methods": ["stovetop"],
"equipment": ["stove"],
"difficulty": "beginner",
"prepMin": 20,
"cookMin": 25,
@@ -39,14 +30,8 @@
"variantType": "standard",
"dnaProtein": "chicken_breast",
"dnaCarb": "rice_white",
"dnaVegetables": [
"garlic",
"onion"
],
"dnaFlavor": [
"fresh",
"balanced"
],
"dnaVegetables": ["garlic", "onion"],
"dnaFlavor": ["fresh", "balanced"],
"verificationStatus": "verified",
"storageGuidanceSv": "Förvara eventuella rester i en lufttät behållare i kylskåp i upp till 2-3 dagar.",
"ingredients": [
@@ -190,19 +175,10 @@
"titleSv": "Vegetarisk Jordnötsgryta med Linser och Potatis",
"descriptionSv": "En värmande och mättande vegetarisk gryta med röda linser, potatis och en krämig jordnötssås. En perfekt vardagsrätt som är full av smak och näring.",
"cuisine": "swedish",
"mealTypes": [
"dinner"
],
"tags": [
"quick",
"high_protein"
],
"methods": [
"stovetop"
],
"equipment": [
"stove"
],
"mealTypes": ["dinner"],
"tags": ["quick", "high_protein"],
"methods": ["stovetop"],
"equipment": ["stove"],
"difficulty": "beginner",
"prepMin": 20,
"cookMin": 35,
@@ -215,16 +191,8 @@
"variantType": "standard",
"dnaProtein": "peanut_butter",
"dnaCarb": "potato",
"dnaVegetables": [
"onion",
"garlic",
"carrot",
"spinach"
],
"dnaFlavor": [
"fresh",
"balanced"
],
"dnaVegetables": ["onion", "garlic", "carrot", "spinach"],
"dnaFlavor": ["fresh", "balanced"],
"verificationStatus": "verified",
"storageGuidanceSv": "Förvara rester i en lufttät behållare i kylskåp i upp till 3-4 dagar. Grytan går utmärkt att frysa in.",
"ingredients": [
@@ -367,19 +335,10 @@
"titleSv": "Nudelwok med Tofu och Jordnötssås",
"descriptionSv": "En snabb och smakrik nudelwok med krispiga grönsaker, stekt tofu och en ljuvlig jordnötssås. Perfekt för en vardag när du vill ha något gott och enkelt.",
"cuisine": "swedish",
"mealTypes": [
"dinner"
],
"tags": [
"quick",
"high_protein"
],
"methods": [
"stovetop"
],
"equipment": [
"stove"
],
"mealTypes": ["dinner"],
"tags": ["quick", "high_protein"],
"methods": ["stovetop"],
"equipment": ["stove"],
"difficulty": "beginner",
"prepMin": 20,
"cookMin": 20,
@@ -391,15 +350,8 @@
"holidayTags": [],
"variantType": "standard",
"dnaProtein": "tofu",
"dnaVegetables": [
"garlic",
"onion",
"carrot"
],
"dnaFlavor": [
"fresh",
"balanced"
],
"dnaVegetables": ["garlic", "onion", "carrot"],
"dnaFlavor": ["fresh", "balanced"],
"verificationStatus": "verified",
"storageGuidanceSv": "Förvara eventuella rester i en lufttät behållare i kylskåp i upp till 2-3 dagar. Ej lämplig för infrysning på grund av nudlarna.",
"ingredients": [
@@ -562,19 +514,10 @@
"titleSv": "Köttfärsbiffar med Jordnötssmak och Ugnsrostad Potatis",
"descriptionSv": "Saftiga köttfärsbiffar med en oväntad touch av jordnötssmör, serverade med krispig ugnsrostad potatis och en fräsch yoghurtsås. En spännande twist på en klassisk vardagsrätt.",
"cuisine": "swedish",
"mealTypes": [
"dinner"
],
"tags": [
"quick",
"high_protein"
],
"methods": [
"stovetop"
],
"equipment": [
"stove"
],
"mealTypes": ["dinner"],
"tags": ["quick", "high_protein"],
"methods": ["stovetop"],
"equipment": ["stove"],
"difficulty": "beginner",
"prepMin": 25,
"cookMin": 40,
@@ -587,15 +530,8 @@
"variantType": "standard",
"dnaProtein": "minced_beef",
"dnaCarb": "potato",
"dnaVegetables": [
"onion",
"garlic",
"cucumber"
],
"dnaFlavor": [
"fresh",
"balanced"
],
"dnaVegetables": ["onion", "garlic", "cucumber"],
"dnaFlavor": ["fresh", "balanced"],
"verificationStatus": "verified",
"storageGuidanceSv": "Förvara eventuella rester i en lufttät behållare i kylskåp i upp till 2-3 dagar. Biffarna går bra att frysa in.",
"ingredients": [
@@ -734,4 +670,4 @@
]
}
]
}
}
@@ -2,4 +2,4 @@
"batchId": "gapfill-nullcells-1786450738528",
"generatedAt": "2026-08-11T12:18:58.528Z",
"rejects": []
}
}
@@ -9,4 +9,4 @@
"rejected": 0,
"exportPath": "/home/dator_ubuntujpb/.openclaw/workspace/cibello-work/cibello/packages/recipe-generation/output/gapfill-nullcells-export.json",
"rejectsPath": "/home/dator_ubuntujpb/.openclaw/workspace/cibello-work/cibello/packages/recipe-generation/output/gapfill-nullcells-rejects.json"
}
}
@@ -18,11 +18,7 @@ import { BRAND } from "@app/shared-types";
import * as fs from "node:fs/promises";
import * as path from "node:path";
const OUTPUT_DIR = path.resolve(
import.meta.dirname ?? "..",
"..",
"output",
);
const OUTPUT_DIR = path.resolve(import.meta.dirname ?? "..", "..", "output");
const NULLCELL_TARGETS: PipelineTarget[] = [
// Endast jordnötssmör-cellen återstår; rikare, lagade middagsrätter.
@@ -74,13 +70,33 @@ function deriveEquipment(mainId: string): string[] {
function guessDna(mainId: string, ingredientIds: string[]) {
const vegetables = ingredientIds.filter((id) =>
["tomato", "spinach", "zucchini", "paprika", "carrot", "cucumber", "lettuce", "corn", "onion", "garlic"].includes(id),
[
"tomato",
"spinach",
"zucchini",
"paprika",
"carrot",
"cucumber",
"lettuce",
"corn",
"onion",
"garlic",
].includes(id),
);
const carbs = ingredientIds.filter((id) =>
["rice_white", "pasta_dry", "potato", "bread", "tortilla", "oats", "quinoa"].includes(id),
);
const proteins = ingredientIds.filter((id) =>
["pork_loin", "chicken_breast", "chicken_thigh", "minced_beef", "salmon", "tofu", "egg", "peanut_butter"].includes(id),
[
"pork_loin",
"chicken_breast",
"chicken_thigh",
"minced_beef",
"salmon",
"tofu",
"egg",
"peanut_butter",
].includes(id),
);
return {
protein: proteins[0] ?? null,
@@ -103,7 +119,9 @@ function toSeedRecipe(
titleSv: c.titleSv,
descriptionSv: c.descriptionSv,
cuisine: (c.cuisine as SeedRecipe["cuisine"]) ?? "international",
mealTypes: (c.mealTypes as SeedRecipe["mealTypes"]) ?? [target.mealType as SeedRecipe["mealTypes"][number]],
mealTypes: (c.mealTypes as SeedRecipe["mealTypes"]) ?? [
target.mealType as SeedRecipe["mealTypes"][number],
],
tags: deriveTags(target.mealType, target.mainIngredientId) as SeedRecipe["tags"],
methods: deriveMethods(target.mainIngredientId) as SeedRecipe["methods"],
equipment: deriveEquipment(target.mainIngredientId) as SeedRecipe["equipment"],
@@ -193,7 +211,9 @@ async function main() {
},
};
console.error(`[gapfill] ${NULLCELL_TARGETS.length} nollceller, max ${NULLCELL_TARGETS.reduce((s, t) => s + t.count, 0)} recept`);
console.error(
`[gapfill] ${NULLCELL_TARGETS.length} nollceller, max ${NULLCELL_TARGETS.reduce((s, t) => s + t.count, 0)} recept`,
);
const result = await runPipeline(
client,
@@ -205,11 +225,18 @@ async function main() {
);
const verifiedExports: Array<{ target: PipelineTarget; slug: string; recipe: SeedRecipe }> = [];
const rejects: Array<{ target: PipelineTarget; title: string; status: string; reasons: string[] }> = [];
const rejects: Array<{
target: PipelineTarget;
title: string;
status: string;
reasons: string[];
}> = [];
for (let i = 0; i < result.verificationResults.length; i++) {
const vr = result.verificationResults[i];
const target = NULLCELL_TARGETS.find((t) => t.mainIngredientId === vr.canonicalIngredientIds[0]) ?? NULLCELL_TARGETS[0];
const target =
NULLCELL_TARGETS.find((t) => t.mainIngredientId === vr.canonicalIngredientIds[0]) ??
NULLCELL_TARGETS[0];
if (vr.status === "verified") {
const slug = makeUniqueSlug(vr.candidate.titleSv, usedSlugs);
@@ -87,17 +87,16 @@ async function main() {
const client = createAamosClient(process.env);
console.error("[pilot-batch] Startar generering...");
console.error(`[pilot-batch] Katalog: ${catalog.length} ingredienser`);
console.error(`[pilot-batch] Mål: ${targets.length} matris-celler, ~${targets.reduce((s, t) => s + t.count, 0)} recept`);
const result = await runPipeline(
client,
targets,
catalog,
ingredientLookup,
similarityLookup,
{ maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 },
console.error(
`[pilot-batch] Mål: ${targets.length} matris-celler, ~${targets.reduce((s, t) => s + t.count, 0)} recept`,
);
const result = await runPipeline(client, targets, catalog, ingredientLookup, similarityLookup, {
maxPrepTimeMinutes: 30,
maxCookTimeMinutes: 45,
portions: 4,
});
// ── 6. Rapportera ────────────────────────────────────────────────────────
const report = {
batchId: `pilot-${Date.now()}`,
@@ -146,9 +145,11 @@ async function main() {
}
if (result.candidates.length === 0 && result.geminiResult.status === "ok") {
console.error("\n**Notering:** Gemini returnerade ok men inga kandidater. " +
"Mock-klienten stödjer inte GENERATE_RECIPE_CANDIDATES ännu — " +
"kör med AAMOS_MODE=gemini för live-generering.");
console.error(
"\n**Notering:** Gemini returnerade ok men inga kandidater. " +
"Mock-klienten stödjer inte GENERATE_RECIPE_CANDIDATES ännu — " +
"kör med AAMOS_MODE=gemini för live-generering.",
);
}
}
+12 -6
View File
@@ -62,15 +62,15 @@ export function buildGapReport(
for (const ev of searchMissEvents) {
if (ev.occurredAt < cutoff) continue;
const query = String(ev.properties?.query ?? "").toLowerCase().trim();
const query = String(ev.properties?.query ?? "")
.toLowerCase()
.trim();
if (!query) continue;
const ingredientId = ev.properties?.suggestedIngredientId
? String(ev.properties.suggestedIngredientId)
: null;
const mealType = ev.properties?.mealType
? String(ev.properties.mealType)
: null;
const mealType = ev.properties?.mealType ? String(ev.properties.mealType) : null;
const key = `${query}::${ingredientId ?? "_"}::${mealType ?? "_"}`;
const existing = missMap.get(key);
@@ -123,8 +123,14 @@ export function buildGapReport(
const mealTypeCounts = new Map<string | null, number>();
for (const e of limitedEntries) {
ingredientCounts.set(e.suggestedIngredientId, (ingredientCounts.get(e.suggestedIngredientId) ?? 0) + e.missCount);
mealTypeCounts.set(e.suggestedMealType, (mealTypeCounts.get(e.suggestedMealType) ?? 0) + e.missCount);
ingredientCounts.set(
e.suggestedIngredientId,
(ingredientCounts.get(e.suggestedIngredientId) ?? 0) + e.missCount,
);
mealTypeCounts.set(
e.suggestedMealType,
(mealTypeCounts.get(e.suggestedMealType) ?? 0) + e.missCount,
);
}
const topMissingIngredients = [...ingredientCounts.entries()]
+6 -5
View File
@@ -9,12 +9,13 @@
*/
import type { AamosClient, AamosResult } from "@app/ai-contracts";
import type {
TaskInput,
TaskOutput,
} from "@app/ai-contracts";
import type { TaskInput, TaskOutput } from "@app/ai-contracts";
import type { RecipeCandidate, VerificationResult } from "./types.js";
import { verifyCandidate, type CanonicalIngredientLookup, type SimilarityLookup } from "./verification.js";
import {
verifyCandidate,
type CanonicalIngredientLookup,
type SimilarityLookup,
} from "./verification.js";
export interface PipelineIngredient {
id: string;
@@ -16,10 +16,7 @@ import {
type RecipeIngredientForCalc,
type IngredientNutritionSource,
} from "@app/nutrition-engine";
import {
deriveRecipeAllergens,
type IngredientSafetyInfo,
} from "@app/recipe-engine";
import { deriveRecipeAllergens, type IngredientSafetyInfo } from "@app/recipe-engine";
import type { NutritionValues, Allergen } from "@app/shared-types";
import type { RecipeCandidate, VerificationResult } from "./types.js";
@@ -137,7 +134,8 @@ function hasSafeCookingStep(steps: RecipeCandidate["steps"]): boolean {
return steps.some((s) => {
const instruction = s.instructionSv;
const hasPositive = SAFE_COOKING_KEYWORDS_SV.some((re) => re.test(instruction));
const onlyAppearance = UNSAFE_APPEARANCE_ONLY_SV.some((re) => re.test(instruction)) &&
const onlyAppearance =
UNSAFE_APPEARANCE_ONLY_SV.some((re) => re.test(instruction)) &&
!SAFE_COOKING_KEYWORDS_SV.some((re) => re.test(instruction));
return hasPositive && !onlyAppearance;
});
@@ -170,7 +168,11 @@ export async function verifyCandidate(
if (candidate.portions == null || candidate.portions <= 0) {
reasons.push("Receptet saknar giltigt antal portioner.");
}
if (candidate.prepTimeMinutes == null || candidate.cookTimeMinutes == null || candidate.totalTimeMinutes == null) {
if (
candidate.prepTimeMinutes == null ||
candidate.cookTimeMinutes == null ||
candidate.totalTimeMinutes == null
) {
reasons.push("Receptet saknar tidsangivelser.");
}
@@ -308,8 +310,7 @@ export async function verifyCandidate(
}
// ── Resultat ─────────────────────────────────────────────────────────────
const status: VerificationResult["status"] =
reasons.length === 0 ? "verified" : "unverified";
const status: VerificationResult["status"] = reasons.length === 0 ? "verified" : "unverified";
return {
candidate,
@@ -81,7 +81,9 @@ describe("allergen invariant", () => {
const stored = [...(recipe.allergens ?? [])].sort();
if (JSON.stringify(derived) !== JSON.stringify(stored)) {
mismatches.push(`${recipe.titleSv}: stored=${JSON.stringify(stored)} derived=${JSON.stringify(derived)}`);
mismatches.push(
`${recipe.titleSv}: stored=${JSON.stringify(stored)} derived=${JSON.stringify(derived)}`,
);
}
}
@@ -5,11 +5,31 @@ import type { AnalyticsEvent, RecipeSignal } from "../src/gap-report.js";
describe("buildGapReport", () => {
it("identifierar topp-saknade ingredienser", () => {
const events: AnalyticsEvent[] = [
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } },
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } },
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } },
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "tofu", suggestedIngredientId: "fast_tofu" } },
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "tofu", suggestedIngredientId: "fast_tofu" } },
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "lax", suggestedIngredientId: "laxfile" },
},
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "lax", suggestedIngredientId: "laxfile" },
},
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "lax", suggestedIngredientId: "laxfile" },
},
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "tofu", suggestedIngredientId: "fast_tofu" },
},
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "tofu", suggestedIngredientId: "fast_tofu" },
},
];
const report = buildGapReport(events, [], []);
@@ -20,7 +40,11 @@ describe("buildGapReport", () => {
it("filtrerar bort enstaka missar", () => {
const events: AnalyticsEvent[] = [
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "enstaka" } },
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "enstaka" },
},
];
const report = buildGapReport(events, [], [], { minMissThreshold: 2 });
expect(report.entries).toHaveLength(0);
@@ -28,8 +52,16 @@ describe("buildGapReport", () => {
it("formaterar rapporten", () => {
const events: AnalyticsEvent[] = [
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax" } },
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax" } },
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "lax" },
},
{
eventName: "recipe_search_zero_results",
occurredAt: new Date(),
properties: { query: "lax" },
},
];
const report = buildGapReport(events, [], [], { minMissThreshold: 1 });
const text = formatGapReport(report);
@@ -13,8 +13,14 @@ const mockIngredients: CanonicalIngredientLookup = {
nutritionPer100: {
basis: "per_100_g",
values: {
kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6,
saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1,
kcal: 165,
proteinG: 31,
carbsG: 0,
fatG: 3.6,
saturatedFatG: 1,
fiberG: 0,
sugarG: 0,
saltG: 0.1,
},
},
defaultUnit: "GRAM",
@@ -64,6 +70,8 @@ describe("runPipeline", () => {
expect(result.geminiResult.status).toBe("ok");
// MockAamosClient returnerar nu kandidater för GENERATE_RECIPE_CANDIDATES
expect(result.candidates.length).toBeGreaterThan(0);
expect(result.verifiedCount + result.unverifiedCount + result.rejectedCount).toBe(result.candidates.length);
expect(result.verifiedCount + result.unverifiedCount + result.rejectedCount).toBe(
result.candidates.length,
);
});
});
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
import { verifyCandidate, type CanonicalIngredientLookup, type SimilarityLookup } from "../src/verification.js";
import {
verifyCandidate,
type CanonicalIngredientLookup,
type SimilarityLookup,
} from "../src/verification.js";
import type { RecipeCandidate } from "../src/types.js";
const mockIngredients: CanonicalIngredientLookup = {
@@ -10,8 +14,14 @@ const mockIngredients: CanonicalIngredientLookup = {
nutritionPer100: {
basis: "per_100_g",
values: {
kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6,
saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1,
kcal: 165,
proteinG: 31,
carbsG: 0,
fatG: 3.6,
saturatedFatG: 1,
fiberG: 0,
sugarG: 0,
saltG: 0.1,
},
},
defaultUnit: "GRAM",
@@ -31,8 +41,14 @@ const mockIngredients: CanonicalIngredientLookup = {
nutritionPer100: {
basis: "per_100_g",
values: {
kcal: 130, proteinG: 2.7, carbsG: 28, fatG: 0.3,
saturatedFatG: 0.1, fiberG: 0.4, sugarG: 0.1, saltG: 0,
kcal: 130,
proteinG: 2.7,
carbsG: 28,
fatG: 0.3,
saturatedFatG: 0.1,
fiberG: 0.4,
sugarG: 0.1,
saltG: 0,
},
},
defaultUnit: "GRAM",
@@ -47,13 +63,19 @@ const mockIngredients: CanonicalIngredientLookup = {
isBeef: false,
isAlcohol: false,
},
"krossade_tomater": {
krossade_tomater: {
id: "krossade_tomater",
nutritionPer100: {
basis: "per_100_g",
values: {
kcal: 32, proteinG: 1.6, carbsG: 5.8, fatG: 0.3,
saturatedFatG: 0, fiberG: 1.2, sugarG: 4, saltG: 0.1,
kcal: 32,
proteinG: 1.6,
carbsG: 5.8,
fatG: 0.3,
saturatedFatG: 0,
fiberG: 1.2,
sugarG: 4,
saltG: 0.1,
},
},
defaultUnit: "GRAM",
@@ -73,8 +95,14 @@ const mockIngredients: CanonicalIngredientLookup = {
nutritionPer100: {
basis: "per_100_g",
values: {
kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6,
saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1,
kcal: 165,
proteinG: 31,
carbsG: 0,
fatG: 3.6,
saturatedFatG: 1,
fiberG: 0,
sugarG: 0,
saltG: 0.1,
},
},
defaultUnit: "GRAM",
@@ -93,7 +121,16 @@ const mockIngredients: CanonicalIngredientLookup = {
id: "sesame_seeds",
nutritionPer100: {
basis: "per_100_g",
values: { kcal: 600, proteinG: 18, carbsG: 12, fatG: 50, saturatedFatG: 7, fiberG: 12, sugarG: 0, saltG: 0 },
values: {
kcal: 600,
proteinG: 18,
carbsG: 12,
fatG: 50,
saturatedFatG: 7,
fiberG: 12,
sugarG: 0,
saltG: 0,
},
},
defaultUnit: "GRAM",
densityGPerMl: 0.6,
@@ -111,7 +148,16 @@ const mockIngredients: CanonicalIngredientLookup = {
id: "milk_3",
nutritionPer100: {
basis: "per_100_g",
values: { kcal: 60, proteinG: 3.4, carbsG: 4.7, fatG: 3, saturatedFatG: 1.9, fiberG: 0, sugarG: 4.7, saltG: 0.1 },
values: {
kcal: 60,
proteinG: 3.4,
carbsG: 4.7,
fatG: 3,
saturatedFatG: 1.9,
fiberG: 0,
sugarG: 4.7,
saltG: 0.1,
},
},
defaultUnit: "DECILITER",
densityGPerMl: 1.03,
@@ -148,13 +194,46 @@ function makeCandidate(overrides?: Partial<RecipeCandidate>): RecipeCandidate {
portions: 4,
spiceLevel: 1,
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
{
canonicalIngredientId: "kycklingfile",
displayNameSv: "kycklingfilé",
quantity: 500,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "krossade_tomater",
displayNameSv: "krossade tomater",
quantity: 400,
unit: "GRAM",
optional: false,
note: null,
},
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen i en panna.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt tomater och ris, låt koka.", timerSeconds: 900, temperatureC: null, tip: null },
{
stepNumber: 1,
instructionSv: "Stek kycklingen i en panna.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
stepNumber: 2,
instructionSv: "Tillsätt tomater och ris, låt koka.",
timerSeconds: 900,
temperatureC: null,
tip: null,
},
],
storageGuidanceSv: "Förvara i kylskåp upp till 3 dagar.",
mealPrepFriendly: false,
@@ -178,7 +257,14 @@ describe("verifyCandidate", () => {
it("avvisar kandidat med okänd ingrediens", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "fantasi_gronsak", displayNameSv: "fantasigrönsak", quantity: 100, unit: "GRAM", optional: false, note: null },
{
canonicalIngredientId: "fantasi_gronsak",
displayNameSv: "fantasigrönsak",
quantity: 100,
unit: "GRAM",
optional: false,
note: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
@@ -188,13 +274,25 @@ describe("verifyCandidate", () => {
it("markerar unverified vid orimlig tid", async () => {
const c = makeCandidate({ prepTimeMinutes: 200, cookTimeMinutes: 10, totalTimeMinutes: 210 });
const result = await verifyCandidate(c, mockIngredients, noSimilarity, { maxPrepTimeMinutes: 60 });
const result = await verifyCandidate(c, mockIngredients, noSimilarity, {
maxPrepTimeMinutes: 60,
});
expect(result.status).toBe("unverified");
expect(result.reasons.some((r) => r.includes("Förberedelsetid"))).toBe(true);
});
it("markerar unverified vid för få steg", async () => {
const c = makeCandidate({ steps: [{ stepNumber: 1, instructionSv: "Gör allt.", timerSeconds: null, temperatureC: null, tip: null }] });
const c = makeCandidate({
steps: [
{
stepNumber: 1,
instructionSv: "Gör allt.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity, { minSteps: 2 });
expect(result.status).toBe("unverified");
expect(result.reasons.some((r) => r.includes("steg"))).toBe(true);
@@ -203,7 +301,14 @@ describe("verifyCandidate", () => {
it("härleder allergener korrekt", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{
canonicalIngredientId: "kycklingfile",
displayNameSv: "kycklingfilé",
quantity: 500,
unit: "GRAM",
optional: false,
note: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
@@ -215,7 +320,16 @@ describe("verifyCandidate", () => {
id: "milk_3",
nutritionPer100: {
basis: "per_100_g",
values: { kcal: 60, proteinG: 3.4, carbsG: 4.7, fatG: 3, saturatedFatG: 1.9, fiberG: 0, sugarG: 4.7, saltG: 0.1 },
values: {
kcal: 60,
proteinG: 3.4,
carbsG: 4.7,
fatG: 3,
saturatedFatG: 1.9,
fiberG: 0,
sugarG: 4.7,
saltG: 0.1,
},
},
defaultUnit: "DECILITER",
densityGPerMl: 1.03,
@@ -237,7 +351,14 @@ describe("verifyCandidate", () => {
const c = makeCandidate({
aiClaimedAllergens: [],
ingredients: [
{ canonicalIngredientId: "milk_3", displayNameSv: "mjölk", quantity: 5, unit: "DECILITER", optional: false, note: null },
{
canonicalIngredientId: "milk_3",
displayNameSv: "mjölk",
quantity: 5,
unit: "DECILITER",
optional: false,
note: null,
},
],
});
const result = await verifyCandidate(c, lookup, noSimilarity);
@@ -248,9 +369,30 @@ describe("verifyCandidate", () => {
const c = makeCandidate({
aiClaimedAllergens: ["gluten"],
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
{
canonicalIngredientId: "kycklingfile",
displayNameSv: "kycklingfilé",
quantity: 500,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "krossade_tomater",
displayNameSv: "krossade tomater",
quantity: 400,
unit: "GRAM",
optional: false,
note: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
@@ -272,63 +414,194 @@ describe("verifyCandidate", () => {
it("kräver genomstekningssteg för rå fågel (positivt)", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
{
canonicalIngredientId: "chicken_breast",
displayNameSv: "kycklingfilé",
quantity: 500,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "krossade_tomater",
displayNameSv: "krossade tomater",
quantity: 400,
unit: "GRAM",
optional: false,
note: null,
},
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen tills den är genomstekt och innertemperaturen är 72°C.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och tomater, koka klart.", timerSeconds: null, temperatureC: null, tip: null },
{
stepNumber: 1,
instructionSv: "Stek kycklingen tills den är genomstekt och innertemperaturen är 72°C.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
stepNumber: 2,
instructionSv: "Tillsätt ris och tomater, koka klart.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.status).toBe("verified");
expect(result.reasons).not.toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
expect(result.reasons).not.toContain(
"Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.",
);
});
it("markerar unverified när rå fågel bara beskrivs som gyllenbrun (utseende räcker inte)", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{
canonicalIngredientId: "chicken_breast",
displayNameSv: "kycklingfilé",
quantity: 500,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen tills den är gyllenbrun.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och koka klart.", timerSeconds: null, temperatureC: null, tip: null },
{
stepNumber: 1,
instructionSv: "Stek kycklingen tills den är gyllenbrun.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
stepNumber: 2,
instructionSv: "Tillsätt ris och koka klart.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.status).toBe("unverified");
expect(result.reasons).toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
expect(result.reasons).toContain(
"Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.",
);
});
it("markerar unverified när rå fågel saknar genomstekningssteg (negativt)", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
{
canonicalIngredientId: "chicken_breast",
displayNameSv: "kycklingfilé",
quantity: 500,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "krossade_tomater",
displayNameSv: "krossade tomater",
quantity: 400,
unit: "GRAM",
optional: false,
note: null,
},
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen i pannan.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och tomater, koka klart.", timerSeconds: null, temperatureC: null, tip: null },
{
stepNumber: 1,
instructionSv: "Stek kycklingen i pannan.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
stepNumber: 2,
instructionSv: "Tillsätt ris och tomater, koka klart.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.status).toBe("unverified");
expect(result.reasons).toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
expect(result.reasons).toContain(
"Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.",
);
});
it("härleder allergen även från valfri ingrediens (optional sesame)", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "sesame_seeds", displayNameSv: "sesamfrön till garnering", quantity: 10, unit: "GRAM", optional: true, note: null },
{
canonicalIngredientId: "kycklingfile",
displayNameSv: "kycklingfilé",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "sesame_seeds",
displayNameSv: "sesamfrön till garnering",
quantity: 10,
unit: "GRAM",
optional: true,
note: null,
},
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen genomstekt.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och sesamefrön.", timerSeconds: null, temperatureC: null, tip: null },
{
stepNumber: 1,
instructionSv: "Stek kycklingen genomstekt.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
stepNumber: 2,
instructionSv: "Tillsätt ris och sesamefrön.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
@@ -338,30 +611,98 @@ describe("verifyCandidate", () => {
it("kräver genomstekningssteg även för optional rå fågel", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 300, unit: "GRAM", optional: true, note: null },
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "krossade_tomater",
displayNameSv: "krossade tomater",
quantity: 400,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "chicken_breast",
displayNameSv: "kycklingfilé",
quantity: 300,
unit: "GRAM",
optional: true,
note: null,
},
],
steps: [
{ stepNumber: 1, instructionSv: "Koka riset enligt anvisningen.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt tomater och kyckling.", timerSeconds: null, temperatureC: null, tip: null },
{
stepNumber: 1,
instructionSv: "Koka riset enligt anvisningen.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
stepNumber: 2,
instructionSv: "Tillsätt tomater och kyckling.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
expect(result.status).toBe("unverified");
expect(result.reasons).toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
expect(result.reasons).toContain(
"Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.",
);
});
it("härleder mjölk-allergen från optional mjölk", async () => {
const c = makeCandidate({
ingredients: [
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
{ canonicalIngredientId: "milk_3", displayNameSv: "mjölk till servering", quantity: 2, unit: "DECILITER", optional: true, note: null },
{
canonicalIngredientId: "kycklingfile",
displayNameSv: "kycklingfilé",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "ris",
displayNameSv: "ris",
quantity: 300,
unit: "GRAM",
optional: false,
note: null,
},
{
canonicalIngredientId: "milk_3",
displayNameSv: "mjölk till servering",
quantity: 2,
unit: "DECILITER",
optional: true,
note: null,
},
],
steps: [
{ stepNumber: 1, instructionSv: "Stek kycklingen genomstekt.", timerSeconds: null, temperatureC: null, tip: null },
{ stepNumber: 2, instructionSv: "Tillsätt ris och mjölk.", timerSeconds: null, temperatureC: null, tip: null },
{
stepNumber: 1,
instructionSv: "Stek kycklingen genomstekt.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
{
stepNumber: 2,
instructionSv: "Tillsätt ris och mjölk.",
timerSeconds: null,
temperatureC: null,
tip: null,
},
],
});
const result = await verifyCandidate(c, mockIngredients, noSimilarity);