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
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
"description": "Drizzle-schema, migrationer, seed och databasklient (separat databas, spec §52)",
|
"description": "Drizzle-schema, migrationer, seed och databasklient (separat databas, spec §52)",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
"./client": "./src/client.ts",
|
||||||
"./schema": "./src/schema/index.ts",
|
"./schema": "./src/schema/index.ts",
|
||||||
"./seed": "./src/seed/index.ts"
|
"./seed": "./src/seed/index.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@app/nutrition-engine": "workspace:*",
|
"@app/nutrition-engine": "workspace:*",
|
||||||
"@app/recipe-engine": "workspace:*",
|
"@app/recipe-engine": "workspace:*",
|
||||||
"@app/shared-types": "workspace:*",
|
"@app/shared-types": "workspace:*",
|
||||||
|
"dotenv": "^16.4.0",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.41.0",
|
||||||
"zod": "^4.4.0"
|
"zod": "^4.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,682 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* SCALEBATCH – Receptkatalog Fas A Steg 2 (docs/32).
|
||||||
|
*
|
||||||
|
* Genererar ~200 verified AI-recept i batchar om ~25–30.
|
||||||
|
* 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 0–7 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);
|
||||||
|
});
|
||||||
Generated
+3
@@ -426,6 +426,9 @@ importers:
|
|||||||
'@app/shared-types':
|
'@app/shared-types':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../shared-types
|
version: link:../shared-types
|
||||||
|
dotenv:
|
||||||
|
specifier: ^16.4.0
|
||||||
|
version: 16.6.1
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.41.0
|
specifier: ^0.41.0
|
||||||
version: 0.41.0(@types/pg@8.20.3)(pg@8.22.0)
|
version: 0.41.0(@types/pg@8.20.3)(pg@8.22.0)
|
||||||
|
|||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# scale-batch.sh – wrapper som sätter IPv4-preferens (workaround för Node fetch-häng på WSL+IPv6)
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
export NODE_OPTIONS="${NODE_OPTIONS:---dns-result-order=ipv4first}"
|
||||||
|
export AAMOS_MODE=gemini
|
||||||
|
export GEMINI_MODEL=gemini-2.5-flash
|
||||||
|
export GEMINI_TIMEOUT_MS=180000
|
||||||
|
# GEMINI_API_KEY och GEMINI_DAILY_BUDGET_USD måste sättas utanför
|
||||||
|
pnpm tsx packages/recipe-generation/scripts/scale-batch.ts "$@"
|
||||||
Reference in New Issue
Block a user