Files
Cibello-app/packages/recipe-generation/scripts/scale-batch.ts
T

825 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env tsx
/**
* STEG 2 Skala receptkatalogen till ~200 verified AI-recept.
*
* - Kör batchvis (~2530 recept per batch).
* - Dedup mot hela växande banken (recipes + recipe_similarities).
* - Seed verified kandidater i staging DB.
* - Dra ~15% slumpat stickprov per batch till C:\Users\Public.
* - Rapporterar yield, täckningsmatris, dedup-statistik, kostnad.
*
* Körning:
* GEMINI_API_KEY=$(aws ssm get-parameter --name "/cibello/prod/gemini-api-key" --with-decryption --query Parameter.Value --output text) \
* DATABASE_URL=... \
* pnpm tsx packages/recipe-generation/scripts/scale-batch.ts
*/
import { createAamosClient } from "@app/ai-contracts";
import { SEED_INGREDIENTS } from "@app/database/seed";
import { createDatabase, closeDatabase, schema } from "@app/database";
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";
// ── 1. Bygg katalog och lookups ────────────────────────────────────────────
const catalog: PipelineIngredient[] = SEED_INGREDIENTS.map((i) => ({
id: i.id,
nameSv: i.nameSv,
category: i.category,
defaultUnit: i.defaultUnit,
isVegan: i.isVegan,
isVegetarian: i.isVegetarian,
containsGluten: i.containsGluten,
containsLactose: i.containsLactose,
allergens: i.allergens,
}));
const ingredientLookup: CanonicalIngredientLookup = {
getById(id: string) {
const ing = SEED_INGREDIENTS.find((i) => i.id === id);
if (!ing) return undefined;
return {
id: ing.id,
nutritionPer100: ing.nutritionPer100,
defaultUnit: ing.defaultUnit,
densityGPerMl: ing.densityGPerMl ?? null,
gramsPerPiece: ing.gramsPerPiece ?? null,
allergens: ing.allergens,
isVegan: ing.isVegan,
isVegetarian: ing.isVegetarian,
containsGluten: ing.containsGluten,
containsLactose: ing.containsLactose,
isPork: ing.isPork,
isBeef: ing.isBeef,
isAlcohol: ing.isAlcohol,
};
},
};
// ── 2. Täckningsmatris (mål ~220 rå-kandidater → ~200 verified) ─────────────
const BASE_TARGETS: PipelineTarget[] = [
// Mejeri
{ mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "standard", count: 3 },
{ mealType: "dinner", mainIngredientId: "cream", dietVariant: "standard", count: 3 },
{ mealType: "dinner", mainIngredientId: "creme_fraiche", dietVariant: "standard", count: 3 },
{ mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "vegetarian", count: 2 },
{ mealType: "dinner", mainIngredientId: "cream", dietVariant: "vegetarian", count: 2 },
{ mealType: "dinner", mainIngredientId: "creme_fraiche", dietVariant: "gluten_free", count: 2 },
{ mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "lactose_free", count: 2 },
// Kyckling
{ mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "standard", count: 5 },
{ mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 4 },
{ mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "gluten_free", count: 3 },
{ mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "lactose_free", count: 3 },
// Köttfärs
{ mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "standard", count: 5 },
{ mealType: "dinner", mainIngredientId: "minced_mixed", dietVariant: "standard", count: 4 },
{ mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "gluten_free", count: 3 },
{ mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "lactose_free", count: 3 },
// Fisk
{ mealType: "dinner", mainIngredientId: "salmon", dietVariant: "standard", count: 5 },
{ mealType: "dinner", mainIngredientId: "cod", dietVariant: "standard", count: 4 },
{ mealType: "dinner", mainIngredientId: "shrimp", dietVariant: "standard", count: 4 },
{ mealType: "dinner", mainIngredientId: "salmon", dietVariant: "gluten_free", count: 3 },
{ mealType: "dinner", mainIngredientId: "salmon", dietVariant: "lactose_free", count: 3 },
// Pasta/ris
{ mealType: "dinner", mainIngredientId: "pasta_dry", dietVariant: "standard", count: 5 },
{ mealType: "dinner", mainIngredientId: "rice_white", dietVariant: "standard", count: 5 },
{ mealType: "dinner", mainIngredientId: "pasta_dry", dietVariant: "vegetarian", count: 3 },
{ mealType: "dinner", mainIngredientId: "rice_white", dietVariant: "vegan", count: 3 },
{ mealType: "dinner", mainIngredientId: "rice_white", dietVariant: "gluten_free", count: 3 },
// Potatis
{ mealType: "dinner", mainIngredientId: "potato", dietVariant: "standard", count: 5 },
{ mealType: "dinner", mainIngredientId: "potato", dietVariant: "vegetarian", count: 3 },
{ mealType: "dinner", mainIngredientId: "potato", dietVariant: "vegan", count: 3 },
{ mealType: "dinner", mainIngredientId: "potato", dietVariant: "gluten_free", count: 3 },
// Baljväxter
{ mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegan", count: 4 },
{ mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegetarian", count: 3 },
{ mealType: "dinner", mainIngredientId: "red_lentils", dietVariant: "vegan", count: 4 },
{ mealType: "dinner", mainIngredientId: "red_lentils", dietVariant: "vegetarian", count: 3 },
{ mealType: "dinner", mainIngredientId: "chickpeas_canned", dietVariant: "vegan", count: 4 },
{ mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegan", count: 3 },
// Veckans grönsaker
{ mealType: "dinner", mainIngredientId: "tomato", dietVariant: "standard", count: 3 },
{ mealType: "dinner", mainIngredientId: "tomato", dietVariant: "vegetarian", count: 2 },
{ mealType: "dinner", mainIngredientId: "tomato", dietVariant: "vegan", count: 2 },
{ mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "standard", count: 3 },
{ mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "vegan", count: 2 },
{ mealType: "dinner", mainIngredientId: "paprika", dietVariant: "standard", count: 3 },
{ mealType: "dinner", mainIngredientId: "paprika", dietVariant: "vegan", count: 2 },
{ mealType: "dinner", mainIngredientId: "carrot", dietVariant: "standard", count: 3 },
{ mealType: "dinner", mainIngredientId: "carrot", dietVariant: "vegetarian", count: 2 },
{ mealType: "dinner", mainIngredientId: "spinach", dietVariant: "standard", count: 3 },
{ mealType: "dinner", mainIngredientId: "spinach", dietVariant: "vegan", count: 2 },
];
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",
],
};
// ── 2b. RUNDA 2: gap-fokuserade targets (~80 nya mål) ──────────────────────
const RUNDA_2_TARGETS: PipelineTarget[] = [
// Paprika-gap (0 st) variera kök
{ mealType: "dinner", mainIngredientId: "paprika", dietVariant: "standard", count: 4 },
{ mealType: "dinner", mainIngredientId: "paprika", dietVariant: "vegetarian", count: 3 },
{ mealType: "dinner", mainIngredientId: "paprika", dietVariant: "vegan", count: 3 },
{ mealType: "dinner", mainIngredientId: "paprika", dietVariant: "gluten_free", count: 2 },
// Blandfärs-gap (2 st)
{ mealType: "dinner", mainIngredientId: "minced_mixed", dietVariant: "standard", count: 5 },
{ mealType: "dinner", mainIngredientId: "minced_mixed", dietVariant: "gluten_free", count: 3 },
{ mealType: "dinner", mainIngredientId: "minced_mixed", dietVariant: "lactose_free", count: 3 },
// Räkor-gap (3 st)
{ mealType: "dinner", mainIngredientId: "shrimp", dietVariant: "standard", count: 5 },
{ mealType: "dinner", mainIngredientId: "shrimp", dietVariant: "gluten_free", count: 3 },
{ mealType: "dinner", mainIngredientId: "shrimp", dietVariant: "lactose_free", count: 3 },
// Torsk-gap (4 st)
{ mealType: "dinner", mainIngredientId: "cod", dietVariant: "standard", count: 5 },
{ mealType: "dinner", mainIngredientId: "cod", dietVariant: "gluten_free", count: 3 },
{ mealType: "dinner", mainIngredientId: "cod", dietVariant: "lactose_free", count: 3 },
// 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,
},
// Kycklinglår (6 st) fler varianter
{ mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 4 },
{ mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "gluten_free", count: 3 },
{ mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "lactose_free", count: 3 },
// Tomat/zucchini (6 st vardera) fler veg-varianter
{ mealType: "dinner", mainIngredientId: "tomato", dietVariant: "vegan", count: 4 },
{ mealType: "dinner", mainIngredientId: "tomato", dietVariant: "vegetarian", count: 3 },
{ mealType: "dinner", mainIngredientId: "tomato", dietVariant: "standard", count: 3 },
{ mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "vegan", count: 4 },
{ mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "vegetarian", count: 3 },
{ mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "standard", count: 3 },
// Tofu (8 st) fler asiatiska varianter
{ mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegan", count: 4 },
{ mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegetarian", count: 3 },
{ mealType: "dinner", mainIngredientId: "tofu", dietVariant: "gluten_free", count: 2 },
// Morot/spenat lågt täckta
{ mealType: "dinner", mainIngredientId: "carrot", dietVariant: "vegetarian", count: 3 },
{ mealType: "dinner", mainIngredientId: "carrot", dietVariant: "vegan", count: 3 },
{ mealType: "dinner", mainIngredientId: "spinach", dietVariant: "standard", count: 3 },
{ mealType: "dinner", mainIngredientId: "spinach", dietVariant: "vegetarian", count: 3 },
{ mealType: "dinner", mainIngredientId: "spinach", dietVariant: "vegan", count: 2 },
];
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 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",
]);
function normalizeCuisine(raw: string | undefined): string {
const c = (raw ?? "swedish").toLowerCase().replace(/[^a-z]/g, "_");
if (VALID_CUISINES.has(c)) return c;
if (c === "asian") return "international";
if (c.includes("swedish") || c.includes("nordic")) return "swedish";
if (c.includes("italian")) return "italian";
if (c.includes("asian")) return "international";
if (c.includes("japan")) return "japanese";
if (c.includes("china")) return "chinese";
if (c.includes("thai")) return "thai";
if (c.includes("indian")) return "indian";
if (c.includes("mexican")) return "mexican";
if (c.includes("american")) return "american";
if (c.includes("mediterranean")) return "greek";
return "international";
}
function toSlug(title: string): string {
return title
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function normalizeTitle(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9åäö]/g, " ");
}
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
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(`**Allergener:** ${r.allergens.join(", ") || "inget"}`);
lines.push(`**Näring/portion:** ${r.nutritionText}`);
lines.push("");
lines.push(r.descriptionSv);
lines.push("");
lines.push("### Ingredienser");
for (const ing of r.ingredients) {
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)` : ""}`,
);
}
if (r.flagReasons.length) {
lines.push("");
lines.push(`**Flaggat:** ${r.flagReasons.join("; ")}`);
}
lines.push("");
return lines.join("\n");
}
interface SeededRecipe {
id: string;
titleSv: string;
slug: string;
descriptionSv: string;
cuisine: string;
status: string;
portions: number;
prepTimeMinutes: number;
cookTimeMinutes: number;
totalTimeMinutes: number;
allergens: string[];
nutritionText: string;
ingredients: {
displayNameSv: string;
quantity: number;
unit: string;
optional: boolean;
canonicalIngredientId: string;
}[];
steps: { stepNumber: number; instructionSv: string; temperatureC: number | null }[];
flagReasons: string[];
}
interface BatchResult {
batchId: string;
targets: PipelineTarget[];
generated: number;
verified: number;
unverified: number;
rejected: number;
duplicate: number;
seeded: number;
costUsd: number;
sample: SeededRecipe[];
}
// ── 4. Huvudflöde ──────────────────────────────────────────────────────────
async function main() {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error("[scale-batch] DATABASE_URL saknas");
process.exit(1);
}
const { db, pool } = createDatabase(dbUrl);
const client = createAamosClient(process.env);
console.error(`[scale-batch] Startar STEG 2: ${TOTAL_TARGETS} mål, batchar om ~${BATCH_SIZE}`);
console.error(`[scale-batch] Katalog: ${catalog.length} ingredienser`);
// Ladda befintliga titlar för dedup
const existingRecipes = await db.query.recipes.findMany({
columns: { id: true, titleSv: true },
});
const knownTitles = new Set(existingRecipes.map((r) => normalizeTitle(r.titleSv)));
const knownSlugs = new Set(existingRecipes.map((r) => r.slug));
console.error(`[scale-batch] Befintliga recept i DB: ${existingRecipes.length}`);
// Ladda befintliga similarities (vi kommer bara skriva nya, men bra att ha om vi vill kolla)
// För närvarande räcker titel-dedup för STEG 2.
const similarityLookup: SimilarityLookup = {
async hasSimilarity(title: string) {
return knownTitles.has(normalizeTitle(title));
},
};
// Dela upp targets i batchar
const batches: PipelineTarget[][] = [];
let currentBatch: PipelineTarget[] = [];
let currentCount = 0;
for (const t of ACTIVE_TARGETS) {
if (currentCount + t.count > BATCH_SIZE && currentBatch.length > 0) {
batches.push(currentBatch);
currentBatch = [];
currentCount = 0;
}
currentBatch.push(t);
currentCount += t.count;
}
if (currentBatch.length) batches.push(currentBatch);
console.error(`[scale-batch] ${batches.length} batchar planerade`);
const batchResults: BatchResult[] = [];
const allSeeded: SeededRecipe[] = [];
let totalCost = 0;
let totalGenerated = 0;
let totalVerified = 0;
let totalUnverified = 0;
let totalRejected = 0;
let totalDuplicate = 0;
let totalSeeded = 0;
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)`,
);
const result = await runPipeline(
client,
batchTargets,
catalog,
ingredientLookup,
similarityLookup,
{ maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 },
);
totalGenerated += result.candidates.length;
totalVerified += result.verifiedCount;
totalUnverified += result.unverifiedCount;
totalRejected += result.rejectedCount;
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"}`,
);
batchResults.push({
batchId,
targets: batchTargets,
generated: 0,
verified: 0,
unverified: 0,
rejected: 0,
duplicate: 0,
seeded: 0,
costUsd: result.geminiResult.costUsd ?? 0,
sample: [],
});
continue;
}
// Seed verified kandidater som inte är dubbletter
let batchDuplicate = 0;
let batchSeeded = 0;
const batchSeededRecipes: SeededRecipe[] = [];
for (let i = 0; i < result.candidates.length; i++) {
const candidate = result.candidates[i]!;
const v = result.verificationResults[i]!;
if (v.status !== "verified") continue;
const titleNorm = normalizeTitle(candidate.titleSv);
if (knownTitles.has(titleNorm)) {
batchDuplicate++;
continue;
}
let slug = toSlug(candidate.titleSv);
if (knownSlugs.has(slug)) {
slug = `${slug}-${randomUUID().slice(0, 8)}`;
}
knownSlugs.add(slug);
knownTitles.add(titleNorm);
const recipeId = randomUUID();
const nutrition = v.nutritionPerPortion ?? {
kcal: 0,
proteinG: 0,
carbsG: 0,
fatG: 0,
saturatedFatG: 0,
fiberG: 0,
sugarG: 0,
saltG: 0,
};
try {
await db.insert(schema.recipes).values({
id: recipeId,
slug,
titleSv: candidate.titleSv,
descriptionSv: candidate.descriptionSv,
cuisine: normalizeCuisine(candidate.cuisine),
mealTypes: candidate.mealTypes,
tags: [],
methods: [],
equipment: [],
prepTimeMinutes: candidate.prepTimeMinutes,
cookTimeMinutes: candidate.cookTimeMinutes,
totalTimeMinutes: candidate.totalTimeMinutes,
portions: candidate.portions,
nutritionPerPortion: nutrition,
allergens: v.allergens,
spiceLevel: candidate.spiceLevel,
storageGuidanceSv: candidate.storageGuidanceSv ?? null,
mealPrepFriendly: candidate.mealPrepFriendly,
freezerFriendly: candidate.freezerFriendly,
dna: {
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))
);
})?.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",
timeMinutes: candidate.totalTimeMinutes,
calories: Math.round(nutrition.kcal),
proteinGrams: Math.round(nutrition.proteinG),
},
variantType: "standard",
status: "draft",
verificationStatus: "verified",
sourceType: "ai_assisted_reviewed",
creatorDisplayName: `${BRAND.name} AI`,
});
} catch (err: any) {
if (err?.message?.includes("recipes_slug_unique") || err?.code === "23505") {
batchDuplicate++;
continue;
}
throw err;
}
for (let ingIdx = 0; ingIdx < candidate.ingredients.length; ingIdx++) {
const ing = candidate.ingredients[ingIdx]!;
await db.insert(schema.recipeIngredients).values({
recipeId,
canonicalIngredientId: ing.canonicalIngredientId,
displayNameSv: ing.displayNameSv,
quantity: ing.quantity,
unit: ing.unit,
note: ing.note ?? null,
optional: ing.optional,
sortOrder: ingIdx,
});
}
for (let stepIdx = 0; stepIdx < candidate.steps.length; stepIdx++) {
const step = candidate.steps[stepIdx]!;
await db.insert(schema.recipeSteps).values({
recipeId,
stepNumber: step.stepNumber,
instructionSv: step.instructionSv,
timerSeconds: step.timerSeconds,
temperatureC: step.temperatureC,
tip: step.tip ?? null,
});
}
const flagReasons = flagRecipe(candidate, nutrition);
const seededRecipe: SeededRecipe = {
id: recipeId,
titleSv: candidate.titleSv,
slug,
descriptionSv: candidate.descriptionSv,
cuisine: normalizeCuisine(candidate.cuisine),
status: "verified",
portions: candidate.portions,
prepTimeMinutes: candidate.prepTimeMinutes,
cookTimeMinutes: candidate.cookTimeMinutes,
totalTimeMinutes: candidate.totalTimeMinutes,
allergens: v.allergens,
nutritionText: `${Math.round(nutrition.kcal)} kcal, P ${nutrition.proteinG.toFixed(1)}g, K ${nutrition.carbsG.toFixed(1)}g, F ${nutrition.fatG.toFixed(1)}g`,
ingredients: candidate.ingredients.map((ing) => ({
displayNameSv: ing.displayNameSv,
quantity: ing.quantity,
unit: ing.unit,
optional: ing.optional,
canonicalIngredientId: ing.canonicalIngredientId,
})),
steps: candidate.steps.map((s) => ({
stepNumber: s.stepNumber,
instructionSv: s.instructionSv,
temperatureC: s.temperatureC,
})),
flagReasons,
};
batchSeededRecipes.push(seededRecipe);
allSeeded.push(seededRecipe);
batchSeeded++;
}
totalDuplicate += batchDuplicate;
totalSeeded += batchSeeded;
// Stickprov: ~15% slumpat av denna batchs seeded recept
const sampleSize = Math.max(1, Math.round(batchSeededRecipes.length * 0.15));
const sample = shuffle(batchSeededRecipes).slice(0, sampleSize);
batchResults.push({
batchId,
targets: batchTargets,
generated: result.candidates.length,
verified: result.verifiedCount,
unverified: result.unverifiedCount,
rejected: result.rejectedCount,
duplicate: batchDuplicate,
seeded: batchSeeded,
costUsd: result.geminiResult.costUsd ?? 0,
sample,
});
// Logga varför unverified recept misslyckades (för debugging / prompt-förbättring)
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] 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 sampleMarkdown = [
`# ${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, `${BRAND.slug}-steg2-sample.md`),
sampleMarkdown,
"utf-8",
);
// ── 6. Slutrapport ────────────────────────────────────────────────────────
const report = {
runId: `steg2-${Date.now()}`,
generatedAt: new Date().toISOString(),
totalTargets: TOTAL_TARGETS,
batches: batchResults.length,
totalGenerated,
totalVerified,
totalUnverified,
totalRejected,
totalDuplicate,
totalSeeded,
totalCostUsd: totalCost,
batchSummary: batchResults.map((b) => ({
batchId: b.batchId,
targets: b.targets.reduce((s, t) => s + t.count, 0),
generated: b.generated,
verified: b.verified,
unverified: b.unverified,
rejected: b.rejected,
duplicate: b.duplicate,
seeded: b.seeded,
costUsd: b.costUsd,
sampleSize: b.sample.length,
})),
coverageMatrix: buildCoverageMatrix(allSeeded),
};
await fs.writeFile(
path.join(publicDir, "cibello-steg2-report.json"),
JSON.stringify(report, null, 2),
"utf-8",
);
// Skriv också till workspace för enkel åtkomst
await fs.writeFile(
"/home/dator_ubuntujpb/.openclaw/workspace/cibello-steg2-report.json",
JSON.stringify(report, null, 2),
"utf-8",
);
console.error("\n# STEG 2 Slutrapport\n");
console.error(`- Batchar: ${batchResults.length}`);
console.error(`- Genererade kandidater: ${totalGenerated}`);
console.error(`- Verified: ${totalVerified}`);
console.error(`- Unverified: ${totalUnverified}`);
console.error(`- Rejected: ${totalRejected}`);
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(`- Rapport: ${publicDir}\\cibello-steg2-report.json`);
await closeDatabase();
}
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 allVegan = ings.every((i) => i?.isVegan);
const allVegetarian = ings.every((i) => i?.isVegetarian);
const noGluten = ings.every((i) => !i?.containsGluten);
const noLactose = ings.every((i) => !i?.containsLactose);
if (allVegan) tags.push("vegan");
else if (allVegetarian) tags.push("vegetarian");
if (noGluten) tags.push("gluten_free");
if (noLactose) tags.push("lactose_free");
return tags;
}
function flagRecipe(candidate: RecipeCandidate, nutrition: { kcal: number }): string[] {
const reasons: string[] = [];
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 ingIds = new Set(candidate.ingredients.map((i) => i.canonicalIngredientId));
for (const id of ingIds) {
const ing = ingredientLookup.getById(id);
if (ing?.allergens.some((a) => riskyAllergens.includes(a))) {
reasons.push("kant-allergener");
break;
}
}
return reasons;
}
function buildCoverageMatrix(seeded: SeededRecipe[]) {
const mainIngredientIds = new Set(ACTIVE_TARGETS.map((t) => t.mainIngredientId));
const matrix: Record<string, Record<string, number>> = {};
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;
if (!mainId) continue;
// Härled dietvariant från ingredienserna
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);
const isLactoseFree = ings.every((i) => !i?.containsLactose);
let diet = "standard";
if (isVegan) diet = "vegan";
else if (isVegetarian) diet = "vegetarian";
if (isGlutenFree && !isVegan && !isVegetarian) diet = "gluten_free";
if (isLactoseFree && !isVegan && !isVegetarian && !isGlutenFree) diet = "lactose_free";
matrix[mainId] ??= {};
matrix[mainId]![diet] = (matrix[mainId]![diet] ?? 0) + 1;
}
return matrix;
}
main().catch((err) => {
console.error("[scale-batch] Fatal:", err);
process.exit(1);
});