Files
Cibello-app/packages/recipe-generation/scripts/scale-batch.ts
T
Sven (AAMOS AI) 84f8ff3c45 feat(recipe-generation): scale-batch.ts för Fas A Steg 2 (~200 verified)
- 8 batchar om ~27 recept = ~216 mål
- Budgetguard med pause vid 90 %
- DB-dedup mot recipes.titleSv + session-dedup
- Seed verified till staging DB (recipes, recipeIngredients, recipeSteps)
- Stickprov ~15 % per batch med flaggning
- scale-batch.sh wrapper för Node IPv4-workaround
- Lade till ./client export i @app/database
- Lade till dotenv i @app/recipe-generation deps

Relaterat till docs/32-receptkatalog-buildout.md
2026-08-09 22:03:55 +07:00

683 lines
26 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
/**
* SCALEBATCH Receptkatalog Fas A Steg 2 (docs/32).
*
* Genererar ~200 verified AI-recept i batchar om ~2530.
* Budgetguard, DB-dedup, stickprov, seed i staging.
*
* Körning per batch:
* AAMOS_MODE=gemini GEMINI_API_KEY=... pnpm tsx packages/recipe-generation/scripts/scale-batch.ts --batch=0
*
* Kör alla batchar:
* AAMOS_MODE=gemini GEMINI_API_KEY=... pnpm tsx packages/recipe-generation/scripts/scale-batch.ts --all
*/
import { config as loadDotenv } from "dotenv";
import { existsSync as fsExistsSync } from "fs";
import { resolve } from "path";
for (const candidate of [".env", "../.env", "../../.env"]) {
const p = resolve(process.cwd(), candidate);
if (fsExistsSync(p)) { loadDotenv({ path: p }); break; }
}
import { createAamosClient } from "@app/ai-contracts";
import { SEED_INGREDIENTS } from "@app/database/seed";
import { createDatabase, schema } from "@app/database";
import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation";
import type { CanonicalIngredientLookup, SimilarityLookup, VerificationResult } from "@app/recipe-generation";
import { sql, eq } from "drizzle-orm";
import { writeFileSync, mkdirSync, existsSync, readFileSync } from "fs";
import { join } from "path";
import type { RecipeDNA, Allergen } from "@app/shared-types";
// ── 0. ARGS ────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const batchArg = args.find((a) => a.startsWith("--batch="));
const batchIndex = batchArg ? parseInt(batchArg.split("=")[1], 10) : null;
const runAll = args.includes("--all");
const dryRun = args.includes("--dry-run");
if (batchIndex === null && !runAll) {
console.error("Användning: pnpm tsx scale-batch.ts --batch=N | --all [--dry-run]");
console.error("Batchar 07 finns. --all kör alla sekventiellt med budgetcheck.");
process.exit(1);
}
// ── 1. Konfiguration ───────────────────────────────────────────────────────
const REPORT_DIR = "/mnt/c/Users/Public/cibello-scale-batch";
const BUDGET_FILE = join(REPORT_DIR, "budget-state.json");
const BUDGET_USD = Number(process.env.GEMINI_DAILY_BUDGET_USD ?? 10);
const PAUSE_THRESHOLD_USD = BUDGET_USD * 0.9; // Pausa vid 90 %
mkdirSync(REPORT_DIR, { recursive: true });
function getBudgetState(): { spentUsd: number; lastReset: string } {
if (!existsSync(BUDGET_FILE)) return { spentUsd: 0, lastReset: new Date().toISOString() };
try {
return JSON.parse(readFileSync(BUDGET_FILE, "utf-8"));
} catch {
return { spentUsd: 0, lastReset: new Date().toISOString() };
}
}
function saveBudgetState(state: { spentUsd: number; lastReset: string }) {
writeFileSync(BUDGET_FILE, JSON.stringify(state, null, 2));
}
// ── 2. Katalog från seed ───────────────────────────────────────────────────
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,
};
},
};
// ── 3. DB-klient ───────────────────────────────────────────────────────────
const { db, pool } = createDatabase();
// ── 4. Batch-matriser (8 batchar × ~27 recept = ~216 mål) ─────────────────
const BATCHES: PipelineTarget[][] = [
// Batch 0: Mejeri
[
{ mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "standard", count: 5 },
{ mealType: "dinner", mainIngredientId: "cream", dietVariant: "standard", count: 5 },
{ mealType: "dinner", mainIngredientId: "creme_fraiche", dietVariant: "standard", count: 5 },
{ mealType: "dinner", mainIngredientId: "cooking_cream", dietVariant: "standard", count: 5 },
{ mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "lactose_free", count: 3 },
{ mealType: "dinner", mainIngredientId: "cream", dietVariant: "lactose_free", count: 4 },
],
// Batch 1: Kyckling
[
{ mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "standard", count: 7 },
{ mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 7 },
{ mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "gluten_free", count: 7 },
{ mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "gluten_free", count: 6 },
],
// Batch 2: Kött
[
{ mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "standard", count: 7 },
{ mealType: "dinner", mainIngredientId: "minced_mixed", dietVariant: "standard", count: 7 },
{ mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "gluten_free", count: 7 },
{ mealType: "dinner", mainIngredientId: "pork_loin", dietVariant: "standard", count: 6 },
],
// Batch 3: Fisk
[
{ mealType: "dinner", mainIngredientId: "salmon", dietVariant: "standard", count: 7 },
{ mealType: "dinner", mainIngredientId: "cod", dietVariant: "standard", count: 7 },
{ mealType: "dinner", mainIngredientId: "shrimp", dietVariant: "standard", count: 7 },
{ mealType: "dinner", mainIngredientId: "salmon", dietVariant: "gluten_free", count: 6 },
],
// Batch 4: Pasta/ris/potatis
[
{ mealType: "dinner", mainIngredientId: "pasta_dry", dietVariant: "standard", count: 7 },
{ mealType: "dinner", mainIngredientId: "rice_white", dietVariant: "standard", count: 7 },
{ mealType: "dinner", mainIngredientId: "potato", dietVariant: "standard", count: 7 },
{ mealType: "dinner", mainIngredientId: "pasta_dry", dietVariant: "vegan", count: 6 },
],
// Batch 5: Baljväxter
[
{ mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegan", count: 7 },
{ mealType: "dinner", mainIngredientId: "red_lentils", dietVariant: "vegetarian", count: 7 },
{ mealType: "dinner", mainIngredientId: "chickpeas_canned", dietVariant: "vegan", count: 7 },
{ mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegan", count: 6 },
],
// Batch 6: Grönsaker
[
{ mealType: "dinner", mainIngredientId: "tomato", dietVariant: "standard", count: 7 },
{ mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "standard", count: 7 },
{ mealType: "dinner", mainIngredientId: "bell_pepper", dietVariant: "standard", count: 7 },
{ mealType: "dinner", mainIngredientId: "carrot", dietVariant: "standard", count: 6 },
],
// Batch 7: Mixed dietvarianter
[
{ mealType: "dinner", mainIngredientId: "rice_white", dietVariant: "vegan", count: 7 },
{ mealType: "dinner", mainIngredientId: "tofu", dietVariant: "standard", count: 7 },
{ mealType: "dinner", mainIngredientId: "cod", dietVariant: "gluten_free", count: 7 },
{ mealType: "dinner", mainIngredientId: "shrimp", dietVariant: "gluten_free", count: 6 },
],
];
// ── 5. Hjälpfunktioner ─────────────────────────────────────────────────────
function slugify(title: string): string {
return title
.toLowerCase()
.replace(/[åä]/g, "a")
.replace(/[ö]/g, "o")
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.substring(0, 80);
}
async function fetchExistingTitles(): Promise<Set<string>> {
const rows = await db.select({ titleSv: schema.recipes.titleSv }).from(schema.recipes);
return new Set(rows.map((r) => r.titleSv.toLowerCase().trim()));
}
function inferDna(vr: VerificationResult): RecipeDNA {
const c = vr.candidate;
const ings = c.ingredients.map((i) => i.canonicalIngredientId);
// Protein
const proteinId = ings.find(
(id) =>
id.includes("chicken") ||
id.includes("beef") ||
id.includes("pork") ||
id.includes("salmon") ||
id.includes("cod") ||
id.includes("shrimp") ||
id.includes("tofu") ||
id.includes("lentil") ||
id.includes("chickpea") ||
id.includes("bean") ||
id.includes("egg") ||
id.includes("meatball"),
);
// Kolhydrat
const carbId = ings.find(
(id) =>
id.includes("pasta") ||
id.includes("rice") ||
id.includes("potato") ||
id.includes("noodle") ||
id.includes("bread") ||
id.includes("tortilla") ||
id.includes("bun"),
);
// Grönsaker
const vegIds = ings.filter(
(id) =>
id.includes("tomato") ||
id.includes("zucchini") ||
id.includes("pepper") ||
id.includes("carrot") ||
id.includes("onion") ||
id.includes("spinach") ||
id.includes("broccoli") ||
id.includes("leek") ||
id.includes("mushroom") ||
id.includes("cucumber") ||
id.includes("lettuce") ||
id.includes("garlic") ||
id.includes("dill") ||
id.includes("parsley") ||
id.includes("basil") ||
id.includes("ginger") ||
id.includes("peas") ||
id.includes("corn") ||
id.includes("olives") ||
id.includes("avocado") ||
id.includes("cabbage") ||
id.includes("cauliflower") ||
id.includes("celeriac") ||
id.includes("beetroot") ||
id.includes("parsnip") ||
id.includes("swede") ||
id.includes("kale") ||
id.includes("asparagus") ||
id.includes("green_bean") ||
id.includes("sugar_snap") ||
id.includes("radish") ||
id.includes("fennel") ||
id.includes("eggplant") ||
id.includes("pumpkin") ||
id.includes("squash") ||
id.includes("sweet_potato"),
);
// Smakprofil
const flavorTokens: string[] = [];
if (ings.some((id) => id.includes("curry"))) flavorTokens.push("curry");
if (ings.some((id) => id.includes("tomato") || id.includes("paste"))) flavorTokens.push("tomato");
if (ings.some((id) => id.includes("garlic") || id.includes("onion"))) flavorTokens.push("aromatic");
if (ings.some((id) => id.includes("lemon") || id.includes("lime"))) flavorTokens.push("citrus");
if (ings.some((id) => id.includes("dill") || id.includes("parsley") || id.includes("basil")))
flavorTokens.push("herb");
if (ings.some((id) => id.includes("ginger") || id.includes("soy"))) flavorTokens.push("asian");
if (ings.some((id) => id.includes("coconut"))) flavorTokens.push("coconut");
if (ings.some((id) => id.includes("cheese") || id.includes("feta") || id.includes("halloumi")))
flavorTokens.push("cheese");
if (flavorTokens.length === 0) flavorTokens.push("savory");
// Metod
const firstStep = c.steps[0]?.instructionSv.toLowerCase() ?? "";
let method = "stovetop";
if (firstStep.includes("ugn") || firstStep.includes("baka") || firstStep.includes("gratin"))
method = "oven";
else if (firstStep.includes("wok") || firstStep.includes("woka")) method = "wok";
else if (firstStep.includes("grill")) method = "grill";
else if (firstStep.includes("koka") && firstStep.includes("soppa")) method = "simmer";
else if (firstStep.includes("ånga")) method = "steam";
return {
cuisine: c.cuisine ?? "swedish",
...(proteinId ? { protein: proteinId.replace(/_/g, " ") } : {}),
...(carbId ? { carbohydrate: carbId.replace(/_/g, " ") } : {}),
vegetables: vegIds.map((id) => id.replace(/_/g, " ")),
flavorProfile: flavorTokens.join(", "),
spiceLevel: c.spiceLevel,
method,
timeMinutes: c.prepTimeMinutes + c.cookTimeMinutes,
calories: vr.nutritionPerPortion?.kcal ?? 0,
proteinGrams: Math.round(vr.nutritionPerPortion?.proteinG ?? 0),
};
}
function estimateCostUsd(targets: PipelineTarget[]): number {
const total = targets.reduce((s, t) => s + t.count, 0);
return total * 0.003; // pessimistisk uppskattning per kandidat
}
// ── 6. DB-dedup-lookup ─────────────────────────────────────────────────────
class DbSimilarityLookup implements SimilarityLookup {
private knownTitles: Set<string>;
private sessionTitles: Set<string> = new Set();
constructor(existingTitles: Set<string>) {
this.knownTitles = existingTitles;
}
async hasSimilarity(title: string): Promise<boolean> {
const key = title.toLowerCase().trim();
if (this.knownTitles.has(key)) return true;
if (this.sessionTitles.has(key)) return true;
this.sessionTitles.add(key);
return false;
}
}
// ── 7. Seed-verified-recept till DB ────────────────────────────────────────
async function seedVerifiedRecipes(results: VerificationResult[]): Promise<number> {
let seeded = 0;
// Hämta eller skapa source registry för AI
const [registry] = await db
.insert(schema.recipeSourceRegistry)
.values({
sourceName: "Cibello AI",
license: "proprietary",
rightToStore: true,
rightToModify: true,
rightToDisplay: true,
attributionRequired: false,
commercialUse: true,
notes: "AI-genererade recept via Gemini. Genomgår verifieringspipeline.",
})
.onConflictDoUpdate({
target: schema.recipeSourceRegistry.sourceName,
set: { updatedAt: new Date() },
})
.returning();
const sourceRegistryId = registry?.id ?? null;
for (const vr of results) {
if (vr.status !== "verified" || !vr.nutritionPerPortion) continue;
const c = vr.candidate;
const slug = slugify(c.titleSv);
// Kolla om slug redan finns
const existing = await db
.select({ id: schema.recipes.id })
.from(schema.recipes)
.where(eq(schema.recipes.slug, slug))
.limit(1);
if (existing.length > 0) {
console.error(`[seed] SKIP: slug ${slug} finns redan i DB`);
continue;
}
const dna = inferDna(vr);
const [row] = await db
.insert(schema.recipes)
.values({
slug,
titleSv: c.titleSv,
descriptionSv: c.descriptionSv,
cuisine: (c.cuisine as any) ?? "swedish",
mealTypes: c.mealTypes as any,
tags: [],
methods: [dna.method],
equipment: [],
difficulty: "easy",
prepTimeMinutes: c.prepTimeMinutes,
cookTimeMinutes: c.cookTimeMinutes,
totalTimeMinutes: c.prepTimeMinutes + c.cookTimeMinutes,
portions: c.portions,
nutritionPerPortion: vr.nutritionPerPortion,
allergens: vr.allergens as Allergen[],
spiceLevel: c.spiceLevel,
storageGuidanceSv: c.storageGuidanceSv,
mealPrepFriendly: c.mealPrepFriendly,
freezerFriendly: c.freezerFriendly,
dna,
variantType: "standard",
status: "draft",
verificationStatus: "verified",
sourceType: "ai_generated" as any,
sourceRegistryId,
creatorDisplayName: "Cibello AI",
imageUrls: [],
})
.returning();
const recipeId = row!.id;
await db.insert(schema.recipeIngredients).values(
c.ingredients.map((ing, idx) => ({
recipeId,
canonicalIngredientId: ing.canonicalIngredientId,
displayNameSv: ing.displayNameSv,
quantity: ing.quantity,
unit: ing.unit as any,
note: ing.note,
optional: ing.optional,
sortOrder: idx,
})),
);
await db.insert(schema.recipeSteps).values(
c.steps.map((step, idx) => ({
recipeId,
stepNumber: idx + 1,
instructionSv: step.instructionSv,
timerSeconds: step.timerSeconds,
temperatureC: step.temperatureC,
tip: step.tip,
})),
);
seeded++;
}
return seeded;
}
// ── 8. Stickprov ───────────────────────────────────────────────────────────
function saveSamples(results: VerificationResult[], batchIdx: number) {
const verified = results.filter((r) => r.status === "verified");
const sampleCount = Math.max(1, Math.round(verified.length * 0.15));
const shuffled = [...verified].sort(() => Math.random() - 0.5);
const samples = shuffled.slice(0, sampleCount);
const flagged: Array<{ title: string; reason: string }> = [];
const markdown = samples
.map((vr, idx) => {
const c = vr.candidate;
const nut = vr.nutritionPerPortion;
const flags: string[] = [];
if (nut && nut.kcal > 900) flags.push("MYCKET HÖG kcal (>900)");
if (nut && nut.kcal < 150) flags.push("MYCKET LÅG kcal (<150)");
if (c.ingredients.length < 5) flags.push("FÅ ingredienser (<5)");
const edgeAllergens = vr.allergens.filter(
(a) => ["peanuts", "tree_nuts", "crustaceans", "molluscs", "sesame", "lupin"].includes(a),
);
if (edgeAllergens.length > 0) flags.push(`Kant-allergener: ${edgeAllergens.join(", ")}`);
if (flags.length > 0) {
flagged.push({ title: c.titleSv, reason: flags.join("; ") });
}
return [
`## ${idx + 1}. ${c.titleSv}`,
`**Beskrivning:** ${c.descriptionSv}`,
`**Tid:** ${c.prepTimeMinutes} min prep + ${c.cookTimeMinutes} min kok = ${c.totalTimeMinutes} min`,
`**Portioner:** ${c.portions} | **Krydda:** ${c.spiceLevel}/3`,
`**Näring/portion:** ${nut ? `${nut.kcal} kcal, P ${Math.round(nut.proteinG)}g, K ${Math.round(nut.carbsG)}g, F ${Math.round(nut.fatG)}g` : "OKÄND"}`,
`**Allergener:** ${vr.allergens.join(", ") || "-"}`,
`**Flaggor:** ${flags.join("; ") || "-"}`,
`**Ingredienser:**`,
...c.ingredients.map((i) => `- ${i.displayNameSv}: ${i.quantity} ${i.unit}${i.optional ? " (valfri)" : ""}`),
`**Steg:**`,
...c.steps.map((s) => `${s.stepNumber}. ${s.instructionSv}${s.temperatureC ? ` (${s.temperatureC}°C)` : ""}${s.timerSeconds ? ` [${Math.round(s.timerSeconds / 60)} min]` : ""}`),
`---`,
].join("\n");
})
.join("\n\n");
const header = `# Stickprov Batch ${batchIdx}\n\n*${new Date().toISOString()}* | ${samples.length} av ${verified.length} verifierade recept\n\n`;
const flaggedSection =
flagged.length > 0
? `## ⚠️ Flagga-recept\n\n${flagged.map((f) => `- **${f.title}:** ${f.reason}`).join("\n")}\n\n`
: "## Inga avvikelser flaggade\n\n";
writeFileSync(join(REPORT_DIR, `stickprov-batch-${batchIdx}.md`), header + flaggedSection + markdown);
return { sampleCount, flaggedCount: flagged.length };
}
// ── 9. Kör en batch ────────────────────────────────────────────────────────
async function runBatch(batchIdx: number): Promise<{
batchId: string;
candidates: number;
verified: number;
unverified: number;
rejected: number;
deduped: number;
seeded: number;
costUsd: number;
}> {
const targets = BATCHES[batchIdx];
if (!targets) throw new Error(`Batch ${batchIdx} finns inte (0${BATCHES.length - 1})`);
const budgetState = getBudgetState();
const estimatedCost = estimateCostUsd(targets);
console.error(`[scale-batch] === BATCH ${batchIdx} ===`);
console.error(`[scale-batch] Mål: ${targets.length} celler, ~${targets.reduce((s, t) => s + t.count, 0)} recept`);
console.error(`[scale-batch] Uppskattad kostnad: $${estimatedCost.toFixed(4)}`);
console.error(`[scale-batch] Budget kvar: $${(BUDGET_USD - budgetState.spentUsd).toFixed(4)} / $${BUDGET_USD}`);
if (budgetState.spentUsd + estimatedCost > PAUSE_THRESHOLD_USD) {
console.error(`[scale-batch] BUDGET-PAUS: skulle överskrida 90 % av dagsbudget.`);
console.error(`[scale-batch] Avbryter. Återställ budget imorgon eller höj GEMINI_DAILY_BUDGET_USD.`);
process.exit(0);
}
const client = createAamosClient(process.env);
const existingTitles = await fetchExistingTitles();
const similarity = new DbSimilarityLookup(existingTitles);
console.error(`[scale-batch] Kända titlar i DB: ${existingTitles.size}`);
const result = await runPipeline(
client,
targets,
catalog,
ingredientLookup,
similarity,
{ maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 },
);
const costUsd = result.geminiResult.costUsd ?? 0;
budgetState.spentUsd += costUsd;
saveBudgetState(budgetState);
// Räkna dedup
const deduped = result.verificationResults.filter((r) =>
r.reasons.some((reason) => reason.includes("dubblett")),
).length;
const verified = result.verificationResults.filter((r) => r.status === "verified");
console.error(`[scale-batch] Gemini: ${result.geminiResult.status}, kostnad: $${costUsd.toFixed(4)}`);
console.error(`[scale-batch] Kandidater: ${result.candidates.length}, Verified: ${result.verifiedCount}, Unverified: ${result.unverifiedCount}, Rejected: ${result.rejectedCount}, Deduped: ${deduped}`);
if (result.geminiResult.error) {
console.error(`[scale-batch] Gemini-fel: ${result.geminiResult.error}`);
}
// Seed verified till DB
let seeded = 0;
if (!dryRun && verified.length > 0) {
seeded = await seedVerifiedRecipes(verified);
console.error(`[scale-batch] Seedade ${seeded} recept till DB`);
} else if (dryRun) {
console.error(`[scale-batch] DRY RUN inget seedas`);
}
// Stickprov
const sampleInfo = saveSamples(result.verificationResults, batchIdx);
console.error(`[scale-batch] Stickprov: ${sampleInfo.sampleCount} recept, ${sampleInfo.flaggedCount} flaggade`);
// Spara batch-rapport
const batchReport = {
batchId: `scale-${Date.now()}-b${batchIdx}`,
batchIndex: batchIdx,
generatedAt: new Date().toISOString(),
dryRun,
targets,
geminiStatus: result.geminiResult.status,
geminiCostUsd: costUsd,
candidatesGenerated: result.candidates.length,
verifiedCount: result.verifiedCount,
unverifiedCount: result.unverifiedCount,
rejectedCount: result.rejectedCount,
dedupedCount: deduped,
seededCount: seeded,
sampleCount: sampleInfo.sampleCount,
flaggedCount: sampleInfo.flaggedCount,
candidates: result.verificationResults.map((r) => ({
title: r.candidate.titleSv,
status: r.status,
reasons: r.reasons,
allergens: r.allergens,
nutritionPerPortion: r.nutritionPerPortion,
})),
};
writeFileSync(
join(REPORT_DIR, `batch-${batchIdx}-report.json`),
JSON.stringify(batchReport, null, 2),
);
return {
batchId: batchReport.batchId,
candidates: result.candidates.length,
verified: result.verifiedCount,
unverified: result.unverifiedCount,
rejected: result.rejectedCount,
deduped,
seeded,
costUsd,
};
}
// ── 10. Huvudloop ──────────────────────────────────────────────────────────
async function main() {
const indicesToRun = runAll ? BATCHES.map((_, i) => i) : [batchIndex!];
const summary: Array<{
batch: number;
candidates: number;
verified: number;
unverified: number;
rejected: number;
deduped: number;
seeded: number;
costUsd: number;
}> = [];
for (const idx of indicesToRun) {
try {
const report = await runBatch(idx);
summary.push({ batch: idx, ...report });
} catch (err) {
console.error(`[scale-batch] Batch ${idx} misslyckades:`, err);
summary.push({
batch: idx,
candidates: 0,
verified: 0,
unverified: 0,
rejected: 0,
deduped: 0,
seeded: 0,
costUsd: 0,
});
}
}
// Spara slutlig sammanfattning
const totalCost = summary.reduce((s, r) => s + r.costUsd, 0);
const totalCandidates = summary.reduce((s, r) => s + r.candidates, 0);
const totalVerified = summary.reduce((s, r) => s + r.verified, 0);
const totalUnverified = summary.reduce((s, r) => s + r.unverified, 0);
const totalRejected = summary.reduce((s, r) => s + r.rejected, 0);
const totalDeduped = summary.reduce((s, r) => s + r.deduped, 0);
const totalSeeded = summary.reduce((s, r) => s + r.seeded, 0);
const finalReport = {
runId: `scale-run-${Date.now()}`,
generatedAt: new Date().toISOString(),
dryRun,
budgetLimitUsd: BUDGET_USD,
totalCostUsd: totalCost,
summary: {
batchesRun: summary.length,
totalCandidates,
totalVerified,
totalUnverified,
totalRejected,
totalDeduped,
totalSeeded,
},
perBatch: summary,
};
writeFileSync(join(REPORT_DIR, "final-report.json"), JSON.stringify(finalReport, null, 2));
console.log(JSON.stringify(finalReport, null, 2));
console.error("\n# Scale-batch sammanfattning\n");
console.error(`- Batchar körda: ${summary.length}`);
console.error(`- Kandidater genererade: ${totalCandidates}`);
console.error(`- Verified: ${totalVerified}`);
console.error(`- Unverified: ${totalUnverified}`);
console.error(`- Rejected: ${totalRejected}`);
console.error(`- Deduped: ${totalDeduped}`);
console.error(`- Seedade till DB: ${totalSeeded}`);
console.error(`- Total Gemini-kostnad: $${totalCost.toFixed(4)}`);
console.error(`- Budget kvar: $${(BUDGET_USD - totalCost).toFixed(4)} / $${BUDGET_USD}`);
await pool.end();
}
main().catch((err) => {
console.error("[scale-batch] Fatal:", err);
pool.end().catch(() => {});
process.exit(1);
});