@@ -1,70 +1,31 @@
# ! / u s r / b i n / e n v t s x
/**
* SCALEBATCH – Receptkatalog Fas A Steg 2 (docs/32) .
* STEG 2 – Skala receptkatalogen till ~200 verified AI-recept .
*
* Genererar ~200 verified AI- recept i batchar om ~25– 30 .
* Budgetguard, DB-dedup, stickprov, seed i staging .
* - 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 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
* 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 { 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 { 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 , 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 ";
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 ";
// ── 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 ───────────────────────────────────────────────────
// ── 1 . Bygg katalog och lookups ────────────────────────────────────────────
const catalog : PipelineIngredient [ ] = SEED_INGREDIENTS . map ( ( i ) = > ( {
id : i.id ,
nameSv : i.nameSv ,
@@ -99,584 +60,642 @@ const ingredientLookup: CanonicalIngredientLookup = {
} ,
} ;
// ── 3 . DB-klient ────────────────────────────────────────────── ─────────────
const { db , pool } = createDatabase ( ) ;
// ── 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 } ,
// ── 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 } ,
] ,
// 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 } ,
] ;
// ── 5. Hjälpfunktioner ─────────────────────────────────────────────────────
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" ] ,
} ;
function slugify ( title : string ) : string {
// ── 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 ( )
. replace ( /[åä]/g , "a ")
. replace ( /[ö ]/g , "o " )
. replace ( /[^a-z0-9\s- ]/g , "" )
. trim ( )
. replace ( /\s+/g , "-" )
. substring ( 0 , 80 ) ;
. normalize ( "NFD ")
. replace ( /[\u0300-\u036f ]/g , "" )
. replace ( /[^a-z0-9]+ /g , "- " )
. replace ( /^-+|-+$/g , "" ) ;
}
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 normalizeTitle ( title : string ) : string {
return title . toLowerCase ( ) . replace ( /[^a-z0-9åäö]/g , " " ) ;
}
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 ;
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 ;
}
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 ;
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)" : "" } ` ) ;
}
}
// ── 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 ++ ;
lines . push ( "" ) ;
lines . push ( "### Steg" ) ;
for ( const s of r . steps ) {
lines . push ( ` ${ s . stepNumber } . ${ s . instructionSv } ${ s . temperatureC ? ` ( ${ s . temperatureC } °C) ` : "" } ` ) ;
}
return seeded ;
if ( r . flagReasons . length ) {
lines . push ( "" ) ;
lines . push ( ` **Flaggat:** ${ r . flagReasons . join ( "; " ) } ` ) ;
}
lines . push ( "" ) ;
return lines . join ( "\n" ) ;
}
// ── 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 } ;
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 [ ] ;
}
// ── 9. Kör en batch ────────────────────────────────────────────────────────
async function runBatch ( batchIdx : number ) : Promise < {
interface BatchResult {
batchId : string ;
candidates : number ;
targets : PipelineTarget [ ] ;
generated : number ;
verified : number ;
unverified : number ;
rejected : number ;
deduped : number ;
duplicate : 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 ,
} ;
sample : SeededRecipe [ ] ;
}
// ── 10 . Huvudloop ──────────────────────────────────────────────────────────
// ── 4 . Huvudflöde ──────────────────────────────────────────────────────────
async function main() {
const indicesToRun = runAll ? BATCHES . map ( ( _ , i ) = > i ) : [ batchIndex ! ] ;
const dbUrl = process . env . DATABASE_URL ;
if ( ! dbUrl ) {
console . error ( "[scale-batch] DATABASE_URL saknas" ) ;
process . exit ( 1 ) ;
}
const summary : Array < {
batch : number ;
candidates : number ;
verified : number ;
unverified : number ;
rejected : number ;
deduped : number ;
seeded : number ;
costUsd : number ;
} > = [ ] ;
const { db , pool } = createDatabase ( dbUrl ) ;
const client = createAamosClient ( process . env ) ;
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 ,
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 ,
deduped : 0 ,
duplicate : 0 ,
seeded : 0 ,
costUsd : 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 ) } ` ) ;
}
// 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 ) ;
// ── 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" ) ;
const finalReport = {
runId : ` scale-run- ${ Date . now ( ) } ` ,
// ── 6. Slutrapport ────────────────────────────────────────────────────────
const report = {
runId : ` steg2- ${ Date . now ( ) } ` ,
generatedAt : new Date ( ) . toISOString ( ) ,
dryRun ,
budgetLimitUsd : BUDGET_USD ,
totalTargets : TOTAL_TARGETS ,
batches : batchResults.length ,
totalGenerated ,
totalVerified ,
totalUnverified ,
totalRejected ,
totalDuplicate ,
totalSeeded ,
totalCostUsd : totalCost ,
summary : {
batchesRun : summary.length ,
totalCandidates ,
totalVerified ,
totalUnverified ,
totalRejected ,
totalDeduped ,
totalSeeded ,
} ,
perBatch : summary ,
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 ) ,
} ;
writeFileSync ( join ( REPORT_DIR , "final-report.json" ) , JSON . stringify ( finalReport , null , 2 ) ) ;
await fs . writeFile (
path . join ( publicDir , "cibello-steg2-report.json" ) ,
JSON . stringify ( report , null , 2 ) ,
"utf-8" ,
) ;
console . log ( JSON . stringify ( finalReport , null , 2 ) ) ;
// 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# Scale-batch sammanfattning \n" ) ;
console . error ( ` - Batchar körda : ${ summary . length } ` ) ;
console . error ( ` - Kandidater genererade : ${ totalCandidates } ` ) ;
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 ( ` - Deduped : ${ totalDeduped } ` ) ;
console . error ( ` - Seedade till DB: ${ totalSeeded } ` ) ;
console . error ( ` - Dubbletter : ${ totalDuplicate } ` ) ;
console . error ( ` - Seedade i DB: ${ totalSeeded } ` ) ;
console . error ( ` - Total Gemini-kostnad: $ ${ totalCost . toFixed ( 4 ) } ` ) ;
console . error ( ` - Budget kvar: $ ${ ( BUDGET_USD - totalCost ) . toFixed ( 4 ) } / $ ${ BUDGET_USD } ` ) ;
console . error ( ` - Stickprov: ${ sampleRecipes . length } recept → ${ publicDir } \\ cibello-steg2-sample.md ` ) ;
console . error ( ` - Rapport: ${ publicDir } \\ cibello-steg2-report.json ` ) ;
await pool . end ( ) ;
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 ) ;
pool . end ( ) . catch ( ( ) = > { } ) ;
process . exit ( 1 ) ;
} ) ;