fix(recipe-generation): BRAND.name-interpolation för creatorDisplayName + rubriker/filnamn

feat(recommendation-engine,api): S4 Smak/Hälsa/Lager-vyer för 'Vad ska vi äta?'

- Ersätter hårdkodat 'Cibello' i scale-batch, scale-smoke-test och export-verified.
- DB-backfill: 224 recept hade 'Cibello AI' i creator_display_name (värdena
  motsvarar nuvarande BRAND.name, ingen rad ändrades men kontrollen är gjord).
- Lägger till view-query-param (default|taste|health|pantry) med fördefinierade
  ScoringWeights och samtyckesgrind.
- Unit-tester för vyer; integrationstester för vy-param, validering och
  fallback utan personalization-samtycke.
- brand-guard grön; pnpm typecheck 19/19; pnpm test --force x2 grönt (34 tasks,
  275 tester).
This commit is contained in:
Sven (AAMOS AI)
2026-08-10 05:17:43 +07:00
parent 881c5bb1e3
commit f4a603e977
8 changed files with 605 additions and 103 deletions
+157 -34
View File
@@ -21,6 +21,7 @@ import { eq, sql, inArray } from "drizzle-orm";
import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation";
import type { CanonicalIngredientLookup, SimilarityLookup } from "@app/recipe-generation";
import type { RecipeCandidate } from "@app/recipe-generation";
import { BRAND } from "@app/shared-types";
import { randomUUID } from "node:crypto";
import * as fs from "node:fs/promises";
import * as path from "node:path";
@@ -128,7 +129,20 @@ const BASE_TARGETS: PipelineTarget[] = [
const GROUP_MAIN_IDS: Record<string, string[]> = {
"1": ["milk_3", "cream", "creme_fraiche", "chicken_breast", "chicken_thigh"],
"2": ["minced_beef", "minced_mixed", "salmon", "cod", "shrimp"],
"3": ["pasta_dry", "rice_white", "potato", "tofu", "red_lentils", "chickpeas_canned", "black_beans_canned", "tomato", "zucchini", "paprika", "carrot", "spinach"],
"3": [
"pasta_dry",
"rice_white",
"potato",
"tofu",
"red_lentils",
"chickpeas_canned",
"black_beans_canned",
"tomato",
"zucchini",
"paprika",
"carrot",
"spinach",
],
};
// ── 2b. RUNDA 2: gap-fokuserade targets (~80 nya mål) ──────────────────────
@@ -156,8 +170,18 @@ const RUNDA_2_TARGETS: PipelineTarget[] = [
// Svarta bönor-gap (4 st)
{ mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegan", count: 4 },
{ mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegetarian", count: 3 },
{ mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "gluten_free", count: 2 },
{
mealType: "dinner",
mainIngredientId: "black_beans_canned",
dietVariant: "vegetarian",
count: 3,
},
{
mealType: "dinner",
mainIngredientId: "black_beans_canned",
dietVariant: "gluten_free",
count: 2,
},
// Kycklinglår (6 st) fler varianter
{ mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 4 },
@@ -188,18 +212,35 @@ const RUNDA_2_TARGETS: PipelineTarget[] = [
const targetGroup = process.env.TARGET_GROUP;
const isRunda2 = process.env.RUNDA === "2";
const SOURCE_TARGETS = isRunda2 ? RUNDA_2_TARGETS : BASE_TARGETS;
const ACTIVE_TARGETS = targetGroup && GROUP_MAIN_IDS[targetGroup]
? SOURCE_TARGETS.filter((t) => GROUP_MAIN_IDS[targetGroup]!.includes(t.mainIngredientId))
: SOURCE_TARGETS;
const ACTIVE_TARGETS =
targetGroup && GROUP_MAIN_IDS[targetGroup]
? SOURCE_TARGETS.filter((t) => GROUP_MAIN_IDS[targetGroup]!.includes(t.mainIngredientId))
: SOURCE_TARGETS;
const TOTAL_TARGETS = ACTIVE_TARGETS.reduce((s, t) => s + t.count, 0);
const BATCH_SIZE = 5;
// ── 3. Hjälpfunktioner ─────────────────────────────────────────────────────
const VALID_CUISINES = new Set([
"swedish", "nordic", "italian", "french", "spanish", "greek", "thai",
"chinese", "japanese", "korean", "vietnamese", "indian", "mexican",
"american", "turkish", "lebanese", "moroccan", "middle_eastern", "international",
"swedish",
"nordic",
"italian",
"french",
"spanish",
"greek",
"thai",
"chinese",
"japanese",
"korean",
"vietnamese",
"indian",
"mexican",
"american",
"turkish",
"lebanese",
"moroccan",
"middle_eastern",
"international",
]);
function normalizeCuisine(raw: string | undefined): string {
@@ -245,7 +286,9 @@ function formatRecipeMarkdown(r: SeededRecipe, idx: number): string {
const lines: string[] = [];
lines.push(`## ${idx + 1}. ${r.titleSv}`);
lines.push(`**Status:** ${r.status} | **Kök:** ${r.cuisine} | **Portioner:** ${r.portions}`);
lines.push(`**Tid:** ${r.prepTimeMinutes} min prep + ${r.cookTimeMinutes} min kok = ${r.totalTimeMinutes} min`);
lines.push(
`**Tid:** ${r.prepTimeMinutes} min prep + ${r.cookTimeMinutes} min kok = ${r.totalTimeMinutes} min`,
);
lines.push(`**Allergener:** ${r.allergens.join(", ") || "inget"}`);
lines.push(`**Näring/portion:** ${r.nutritionText}`);
lines.push("");
@@ -253,12 +296,16 @@ function formatRecipeMarkdown(r: SeededRecipe, idx: number): string {
lines.push("");
lines.push("### Ingredienser");
for (const ing of r.ingredients) {
lines.push(`- ${ing.displayNameSv}: ${ing.quantity} ${ing.unit}${ing.optional ? " (valfri)" : ""}`);
lines.push(
`- ${ing.displayNameSv}: ${ing.quantity} ${ing.unit}${ing.optional ? " (valfri)" : ""}`,
);
}
lines.push("");
lines.push("### Steg");
for (const s of r.steps) {
lines.push(`${s.stepNumber}. ${s.instructionSv}${s.temperatureC ? ` (${s.temperatureC}°C)` : ""}`);
lines.push(
`${s.stepNumber}. ${s.instructionSv}${s.temperatureC ? ` (${s.temperatureC}°C)` : ""}`,
);
}
if (r.flagReasons.length) {
lines.push("");
@@ -281,7 +328,13 @@ interface SeededRecipe {
totalTimeMinutes: number;
allergens: string[];
nutritionText: string;
ingredients: { displayNameSv: string; quantity: number; unit: string; optional: boolean; canonicalIngredientId: string }[];
ingredients: {
displayNameSv: string;
quantity: number;
unit: string;
optional: boolean;
canonicalIngredientId: string;
}[];
steps: { stepNumber: number; instructionSv: string; temperatureC: number | null }[];
flagReasons: string[];
}
@@ -359,7 +412,9 @@ async function main() {
for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
const batchTargets = batches[batchIdx]!;
const batchId = `steg2-batch-${batchIdx + 1}-${Date.now()}`;
console.error(`\n[scale-batch] Batch ${batchIdx + 1}/${batches.length} (${batchTargets.reduce((s, t) => s + t.count, 0)} recept)`);
console.error(
`\n[scale-batch] Batch ${batchIdx + 1}/${batches.length} (${batchTargets.reduce((s, t) => s + t.count, 0)} recept)`,
);
const result = await runPipeline(
client,
@@ -377,7 +432,9 @@ async function main() {
totalCost += result.geminiResult.costUsd ?? 0;
if (result.geminiResult.status !== "ok" || result.candidates.length === 0) {
console.error(`[scale-batch] Batch ${batchIdx + 1} gav inga kandidater: ${result.geminiResult.error ?? "ok utan output"}`);
console.error(
`[scale-batch] Batch ${batchIdx + 1} gav inga kandidater: ${result.geminiResult.error ?? "ok utan output"}`,
);
batchResults.push({
batchId,
targets: batchTargets,
@@ -419,8 +476,14 @@ async function main() {
const recipeId = randomUUID();
const nutrition = v.nutritionPerPortion ?? {
kcal: 0, proteinG: 0, carbsG: 0, fatG: 0,
saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0,
kcal: 0,
proteinG: 0,
carbsG: 0,
fatG: 0,
saturatedFatG: 0,
fiberG: 0,
sugarG: 0,
saltG: 0,
};
try {
@@ -448,13 +511,45 @@ async function main() {
cuisine: normalizeCuisine(candidate.cuisine) as "swedish",
protein: candidate.ingredients.find((i) => {
const ing = ingredientLookup.getById(i.canonicalIngredientId);
return ing && (ing.isBeef || ing.isPork || ["chicken_breast", "chicken_thigh", "salmon", "cod", "shrimp", "tofu", "red_lentils", "chickpeas_canned", "black_beans_canned", "minced_beef", "minced_mixed"].includes(i.canonicalIngredientId));
return (
ing &&
(ing.isBeef ||
ing.isPork ||
[
"chicken_breast",
"chicken_thigh",
"salmon",
"cod",
"shrimp",
"tofu",
"red_lentils",
"chickpeas_canned",
"black_beans_canned",
"minced_beef",
"minced_mixed",
].includes(i.canonicalIngredientId))
);
})?.canonicalIngredientId,
carbohydrate: candidate.ingredients.find((i) => ["rice_white", "pasta_dry", "potato"].includes(i.canonicalIngredientId))?.canonicalIngredientId,
vegetables: candidate.ingredients.filter((i) => {
const ing = ingredientLookup.getById(i.canonicalIngredientId);
return ing?.category === "gronsaker" || ["tomato", "zucchini", "paprika", "carrot", "spinach", "onion", "garlic"].includes(i.canonicalIngredientId);
}).map((i) => i.canonicalIngredientId),
carbohydrate: candidate.ingredients.find((i) =>
["rice_white", "pasta_dry", "potato"].includes(i.canonicalIngredientId),
)?.canonicalIngredientId,
vegetables: candidate.ingredients
.filter((i) => {
const ing = ingredientLookup.getById(i.canonicalIngredientId);
return (
ing?.category === "gronsaker" ||
[
"tomato",
"zucchini",
"paprika",
"carrot",
"spinach",
"onion",
"garlic",
].includes(i.canonicalIngredientId)
);
})
.map((i) => i.canonicalIngredientId),
flavorProfile: inferDietTags(candidate),
spiceLevel: candidate.spiceLevel,
method: "stovetop",
@@ -466,7 +561,7 @@ async function main() {
status: "draft",
verificationStatus: "verified",
sourceType: "ai_assisted_reviewed",
creatorDisplayName: "Cibello AI",
creatorDisplayName: `${BRAND.name} AI`,
});
} catch (err: any) {
if (err?.message?.includes("recipes_slug_unique") || err?.code === "23505") {
@@ -559,25 +654,36 @@ async function main() {
for (let i = 0; i < result.candidates.length; i++) {
const v = result.verificationResults[i]!;
if (v.status === "unverified" && v.reasons.length > 0) {
console.error(`[scale-batch] unverified: "${result.candidates[i]!.titleSv}" => ${v.reasons.join("; ")}`);
console.error(
`[scale-batch] unverified: "${result.candidates[i]!.titleSv}" => ${v.reasons.join("; ")}`,
);
}
}
console.error(`[scale-batch] Batch ${batchIdx + 1} klar: gen=${result.candidates.length}, ver=${result.verifiedCount}, unv=${result.unverifiedCount}, rej=${result.rejectedCount}, dup=${batchDuplicate}, seeded=${batchSeeded}, cost=$${(result.geminiResult.costUsd ?? 0).toFixed(4)}`);
console.error(
`[scale-batch] Batch ${batchIdx + 1} klar: gen=${result.candidates.length}, ver=${result.verifiedCount}, unv=${result.unverifiedCount}, rej=${result.rejectedCount}, dup=${batchDuplicate}, seeded=${batchSeeded}, cost=$${(result.geminiResult.costUsd ?? 0).toFixed(4)}`,
);
}
// ── 5. Spara stickprov ────────────────────────────────────────────────────
const publicDir = "/mnt/c/Users/Public";
const sampleRecipes = shuffle(allSeeded).slice(0, Math.max(1, Math.round(allSeeded.length * 0.15)));
const sampleRecipes = shuffle(allSeeded).slice(
0,
Math.max(1, Math.round(allSeeded.length * 0.15)),
);
const sampleMarkdown = [
"# Cibello STEG 2 Stickprov för smakkoll",
`# ${BRAND.name} STEG 2 Stickprov för smakkoll`,
`Genererad: ${new Date().toISOString()}`,
`Totalt seedade: ${allSeeded.length}`,
`Stickprov: ${sampleRecipes.length}`,
"",
...sampleRecipes.map((r, i) => formatRecipeMarkdown(r, i)),
].join("\n");
await fs.writeFile(path.join(publicDir, "cibello-steg2-sample.md"), sampleMarkdown, "utf-8");
await fs.writeFile(
path.join(publicDir, `${BRAND.slug}-steg2-sample.md`),
sampleMarkdown,
"utf-8",
);
// ── 6. Slutrapport ────────────────────────────────────────────────────────
const report = {
@@ -629,7 +735,9 @@ async function main() {
console.error(`- Dubbletter: ${totalDuplicate}`);
console.error(`- Seedade i DB: ${totalSeeded}`);
console.error(`- Total Gemini-kostnad: $${totalCost.toFixed(4)}`);
console.error(`- Stickprov: ${sampleRecipes.length} recept → ${publicDir}\\cibello-steg2-sample.md`);
console.error(
`- Stickprov: ${sampleRecipes.length} recept → ${publicDir}\\cibello-steg2-sample.md`,
);
console.error(`- Rapport: ${publicDir}\\cibello-steg2-report.json`);
await closeDatabase();
@@ -638,7 +746,9 @@ async function main() {
function inferDietTags(candidate: RecipeCandidate): string[] {
const tags: string[] = [];
const ingIds = new Set(candidate.ingredients.map((i) => i.canonicalIngredientId));
const ings = candidate.ingredients.map((i) => ingredientLookup.getById(i.canonicalIngredientId)).filter(Boolean);
const ings = candidate.ingredients
.map((i) => ingredientLookup.getById(i.canonicalIngredientId))
.filter(Boolean);
const allVegan = ings.every((i) => i?.isVegan);
const allVegetarian = ings.every((i) => i?.isVegetarian);
const noGluten = ings.every((i) => !i?.containsGluten);
@@ -655,7 +765,16 @@ function flagRecipe(candidate: RecipeCandidate, nutrition: { kcal: number }): st
if (nutrition.kcal > 1000) reasons.push("hög kalorihalt");
if (nutrition.kcal < 150) reasons.push("låg kalorihalt");
if (candidate.ingredients.length < 4) reasons.push("få ingredienser");
const riskyAllergens = ["peanuts", "tree_nuts", "shellfish", "crustaceans", "fish", "milk", "gluten", "eggs"];
const riskyAllergens = [
"peanuts",
"tree_nuts",
"shellfish",
"crustaceans",
"fish",
"milk",
"gluten",
"eggs",
];
const ingIds = new Set(candidate.ingredients.map((i) => i.canonicalIngredientId));
for (const id of ingIds) {
const ing = ingredientLookup.getById(id);
@@ -673,11 +792,15 @@ function buildCoverageMatrix(seeded: SeededRecipe[]) {
for (const r of seeded) {
// Hitta den mest sannolika huvudingrediensen: första ingrediensen som är en target-huvudingrediens
const mainId = r.ingredients.find((i) => mainIngredientIds.has(i.canonicalIngredientId))?.canonicalIngredientId;
const mainId = r.ingredients.find((i) =>
mainIngredientIds.has(i.canonicalIngredientId),
)?.canonicalIngredientId;
if (!mainId) continue;
// Härled dietvariant från ingredienserna
const ings = r.ingredients.map((i) => ingredientLookup.getById(i.canonicalIngredientId)).filter(Boolean);
const ings = r.ingredients
.map((i) => ingredientLookup.getById(i.canonicalIngredientId))
.filter(Boolean);
const isVegan = ings.every((i) => i?.isVegan);
const isVegetarian = ings.every((i) => i?.isVegetarian);
const isGlutenFree = ings.every((i) => !i?.containsGluten);