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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user