576c451cfe
- Food-safety prompt + lint: doneness krävs, gyllenbrun räcker inte - DensityGPerMl för ginger, örter, mushroom, salsa, corn, olives, peas - RUNDA_2_TARGETS fokuserade på gap-celler (paprika, blandfärs, räkor, torsk, svarta bönor) - Export-verified script + scale-smoke-test - Temp-skript borttagna; inga nycklar kvar i kod
702 lines
30 KiB
TypeScript
702 lines
30 KiB
TypeScript
#!/usr/bin/env tsx
|
||
/**
|
||
* STEG 2 – Skala receptkatalogen till ~200 verified AI-recept.
|
||
*
|
||
* - Kör batchvis (~25–30 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 "/openclaw/GEMINI_API_KEY" ...) \
|
||
* 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 { 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: "Cibello 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 = [
|
||
"# Cibello 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");
|
||
|
||
// ── 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);
|
||
});
|