feat(recipe-generation): STEG 2 RUNDA 2 prep
- 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
This commit is contained in:
@@ -403,6 +403,12 @@ export class GeminiAamosClient implements AamosClient {
|
|||||||
let parsed: z.infer<typeof generateRecipeCandidatesOutput>;
|
let parsed: z.infer<typeof generateRecipeCandidatesOutput>;
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(text);
|
const json = JSON.parse(text);
|
||||||
|
// Defensiv normalisering: Gemini kan returnera rejectedPrompts som objekt istället för strängar.
|
||||||
|
if (Array.isArray(json.rejectedPrompts)) {
|
||||||
|
json.rejectedPrompts = json.rejectedPrompts.map((p: unknown) =>
|
||||||
|
typeof p === "string" ? p : JSON.stringify(p)
|
||||||
|
);
|
||||||
|
}
|
||||||
const safe = generateRecipeCandidatesOutput.safeParse(json);
|
const safe = generateRecipeCandidatesOutput.safeParse(json);
|
||||||
if (!safe.success) {
|
if (!safe.success) {
|
||||||
return { status: "failed", output: null, error: `Gemini-svar matchar inte schema: ${safe.error.message}` };
|
return { status: "failed", output: null, error: `Gemini-svar matchar inte schema: ${safe.error.message}` };
|
||||||
@@ -474,6 +480,7 @@ Begränsningar:
|
|||||||
Regler:
|
Regler:
|
||||||
- Recepten ska vara realistiska vardagsrätter för svenska hushåll.
|
- Recepten ska vara realistiska vardagsrätter för svenska hushåll.
|
||||||
- Stegen ska vara tydliga, med rimliga tider och temperaturer.
|
- Stegen ska vara tydliga, med rimliga tider och temperaturer.
|
||||||
|
- MATSÄKERHET – recept som innehåller rå fågel, rått fläsk/köttfärs, rå fisk/skaldjur eller råa ägg MÅSTE innehålla ett explicit steg som beskriver att proteinet blir genomstekt/genomkokt. Använd formuleringar som: "stek tills kycklingen är genomstekt och inte längre rosa i mitten", "koka tills fisken flagnar", "stek tills köttfärsen är genomstekt", eller ange kärn-/innertemperatur i °C. Det räcker INTE med enbart utseende (t.ex. "gyllenbrun") eller enbart tid.
|
||||||
- Använd ENDAST dessa exakta enhetskoder: GRAM, KILOGRAM, MILLILITER, DECILITER, LITER, TEASPOON, TABLESPOON, CUP_US, COUNT, PORTION, PINCH, SLICE, CLOVE, CAN, PACKAGE. Aldrig "g", "dl", "msk", "tsk", "st".
|
- Använd ENDAST dessa exakta enhetskoder: GRAM, KILOGRAM, MILLILITER, DECILITER, LITER, TEASPOON, TABLESPOON, CUP_US, COUNT, PORTION, PINCH, SLICE, CLOVE, CAN, PACKAGE. Aldrig "g", "dl", "msk", "tsk", "st".
|
||||||
- Formuleringen ska vara mjölkprincips-vänlig: ingen svinnskam, inget "släng".
|
- Formuleringen ska vara mjölkprincips-vänlig: ingen svinnskam, inget "släng".
|
||||||
- Varje ingrediens MÅSTE finnas i katalogen ovan.
|
- Varje ingrediens MÅSTE finnas i katalogen ovan.
|
||||||
|
|||||||
@@ -471,6 +471,7 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
|
|||||||
}),
|
}),
|
||||||
ing("dill", "Dill (färsk)", "Dill", "gronsaker", {
|
ing("dill", "Dill (färsk)", "Dill", "gronsaker", {
|
||||||
...veg,
|
...veg,
|
||||||
|
densityGPerMl: 0.12,
|
||||||
n: { kcal: 40, p: 3.5, k: 5, f: 0.8, fib: 2.5 },
|
n: { kcal: 40, p: 3.5, k: 5, f: 0.8, fib: 2.5 },
|
||||||
shelfLifeGuidance: { fridge: 5 },
|
shelfLifeGuidance: { fridge: 5 },
|
||||||
defaultPriceMinorPerKg: 30000,
|
defaultPriceMinorPerKg: 30000,
|
||||||
@@ -478,12 +479,14 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
|
|||||||
}),
|
}),
|
||||||
ing("parsley", "Persilja (färsk)", "Parsley", "gronsaker", {
|
ing("parsley", "Persilja (färsk)", "Parsley", "gronsaker", {
|
||||||
...veg,
|
...veg,
|
||||||
|
densityGPerMl: 0.12,
|
||||||
n: { kcal: 45, p: 3.7, k: 6, f: 0.9, fib: 3.5 },
|
n: { kcal: 45, p: 3.7, k: 6, f: 0.9, fib: 3.5 },
|
||||||
shelfLifeGuidance: { fridge: 5 },
|
shelfLifeGuidance: { fridge: 5 },
|
||||||
defaultPriceMinorPerKg: 30000,
|
defaultPriceMinorPerKg: 30000,
|
||||||
}),
|
}),
|
||||||
ing("basil", "Basilika (färsk)", "Basil", "gronsaker", {
|
ing("basil", "Basilika (färsk)", "Basil", "gronsaker", {
|
||||||
...veg,
|
...veg,
|
||||||
|
densityGPerMl: 0.12,
|
||||||
n: { kcal: 30, p: 3, k: 2.5, f: 0.6, fib: 1.6 },
|
n: { kcal: 30, p: 3, k: 2.5, f: 0.6, fib: 1.6 },
|
||||||
shelfLifeGuidance: { fridge: 4 },
|
shelfLifeGuidance: { fridge: 4 },
|
||||||
defaultPriceMinorPerKg: 40000,
|
defaultPriceMinorPerKg: 40000,
|
||||||
@@ -491,12 +494,14 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
|
|||||||
}),
|
}),
|
||||||
ing("ginger", "Ingefära (färsk)", "Ginger", "gronsaker", {
|
ing("ginger", "Ingefära (färsk)", "Ginger", "gronsaker", {
|
||||||
...veg,
|
...veg,
|
||||||
|
densityGPerMl: 0.95,
|
||||||
n: { kcal: 80, p: 1.8, k: 16, f: 0.8, fib: 2 },
|
n: { kcal: 80, p: 1.8, k: 16, f: 0.8, fib: 2 },
|
||||||
shelfLifeGuidance: { fridge: 21 },
|
shelfLifeGuidance: { fridge: 21 },
|
||||||
defaultPriceMinorPerKg: 8000,
|
defaultPriceMinorPerKg: 8000,
|
||||||
}),
|
}),
|
||||||
ing("mushroom", "Champinjoner", "Mushrooms", "gronsaker", {
|
ing("mushroom", "Champinjoner", "Mushrooms", "gronsaker", {
|
||||||
...veg,
|
...veg,
|
||||||
|
densityGPerMl: 0.9,
|
||||||
n: { kcal: 25, p: 3, k: 1.5, f: 0.4, fib: 1.5 },
|
n: { kcal: 25, p: 3, k: 1.5, f: 0.4, fib: 1.5 },
|
||||||
shelfLifeGuidance: { fridge: 5 },
|
shelfLifeGuidance: { fridge: 5 },
|
||||||
defaultPriceMinorPerKg: 6000,
|
defaultPriceMinorPerKg: 6000,
|
||||||
@@ -504,18 +509,21 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
|
|||||||
}),
|
}),
|
||||||
ing("frozen_peas", "Gröna ärtor (frysta)", "Frozen peas", "gronsaker", {
|
ing("frozen_peas", "Gröna ärtor (frysta)", "Frozen peas", "gronsaker", {
|
||||||
...veg,
|
...veg,
|
||||||
|
densityGPerMl: 0.8,
|
||||||
n: { kcal: 78, p: 5.5, k: 11, f: 0.5, fib: 5.5, s: 5 },
|
n: { kcal: 78, p: 5.5, k: 11, f: 0.5, fib: 5.5, s: 5 },
|
||||||
shelfLifeGuidance: { freezer: 365 },
|
shelfLifeGuidance: { freezer: 365 },
|
||||||
defaultPriceMinorPerKg: 3000,
|
defaultPriceMinorPerKg: 3000,
|
||||||
}),
|
}),
|
||||||
ing("corn", "Majs (konserverad)", "Sweet corn", "gronsaker", {
|
ing("corn", "Majs (konserverad)", "Sweet corn", "gronsaker", {
|
||||||
...veg,
|
...veg,
|
||||||
|
densityGPerMl: 0.75,
|
||||||
n: { kcal: 90, p: 3, k: 17, f: 1.2, fib: 2.5, s: 5 },
|
n: { kcal: 90, p: 3, k: 17, f: 1.2, fib: 2.5, s: 5 },
|
||||||
shelfLifeGuidance: { pantry: 365, fridge: 4 },
|
shelfLifeGuidance: { pantry: 365, fridge: 4 },
|
||||||
defaultPriceMinorPerKg: 3000,
|
defaultPriceMinorPerKg: 3000,
|
||||||
}),
|
}),
|
||||||
ing("olives", "Oliver", "Olives", "gronsaker", {
|
ing("olives", "Oliver", "Olives", "gronsaker", {
|
||||||
...veg,
|
...veg,
|
||||||
|
densityGPerMl: 0.95,
|
||||||
n: { kcal: 150, p: 1, k: 1, f: 15, mf: 2.2, salt: 3.5 },
|
n: { kcal: 150, p: 1, k: 1, f: 15, mf: 2.2, salt: 3.5 },
|
||||||
shelfLifeGuidance: { fridge: 30 },
|
shelfLifeGuidance: { fridge: 30 },
|
||||||
defaultPriceMinorPerKg: 10000,
|
defaultPriceMinorPerKg: 10000,
|
||||||
@@ -843,6 +851,7 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
|
|||||||
}),
|
}),
|
||||||
ing("salsa", "Salsa (burk)", "Salsa", "konserver", {
|
ing("salsa", "Salsa (burk)", "Salsa", "konserver", {
|
||||||
...veg,
|
...veg,
|
||||||
|
densityGPerMl: 1.0,
|
||||||
n: { kcal: 45, p: 1.5, k: 8, f: 0.3, fib: 1.5, s: 6, salt: 1.5 },
|
n: { kcal: 45, p: 1.5, k: 8, f: 0.3, fib: 1.5, s: 6, salt: 1.5 },
|
||||||
shelfLifeGuidance: { pantry: 365, fridge: 7 },
|
shelfLifeGuidance: { pantry: 365, fridge: 7 },
|
||||||
defaultPriceMinorPerKg: 4500,
|
defaultPriceMinorPerKg: 4500,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createDatabase, closeDatabase } from "@app/database";
|
||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const dbUrl = process.env.DATABASE_URL;
|
||||||
|
if (!dbUrl) throw new Error("DATABASE_URL saknas");
|
||||||
|
const { db } = createDatabase(dbUrl);
|
||||||
|
|
||||||
|
const recipes = await db.query.recipes.findMany({
|
||||||
|
where: (t, { eq }) => eq(t.verificationStatus, "verified"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const enriched = [];
|
||||||
|
for (const r of recipes) {
|
||||||
|
const ingredients = await db.query.recipeIngredients.findMany({
|
||||||
|
where: (t, { eq }) => eq(t.recipeId, r.id),
|
||||||
|
});
|
||||||
|
const steps = await db.query.recipeSteps.findMany({
|
||||||
|
where: (t, { eq }) => eq(t.recipeId, r.id),
|
||||||
|
});
|
||||||
|
enriched.push({ ...r, ingredients, steps });
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicDir = "/mnt/c/Users/Public";
|
||||||
|
const outPath = path.join(publicDir, "cibello-verified-119-result.json");
|
||||||
|
await fs.writeFile(outPath, JSON.stringify({
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
count: enriched.length,
|
||||||
|
recipes: enriched,
|
||||||
|
}, null, 2), "utf-8");
|
||||||
|
|
||||||
|
console.error(`[export-verified] Exported ${recipes.length} verified recipes to ${outPath}`);
|
||||||
|
await closeDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => { console.error(err); process.exit(1); });
|
||||||
@@ -1,70 +1,31 @@
|
|||||||
#!/usr/bin/env tsx
|
#!/usr/bin/env tsx
|
||||||
/**
|
/**
|
||||||
* 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.
|
* - Kör batchvis (~25–30 recept per batch).
|
||||||
* Budgetguard, DB-dedup, stickprov, seed i staging.
|
* - 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:
|
* Körning:
|
||||||
* AAMOS_MODE=gemini GEMINI_API_KEY=... pnpm tsx packages/recipe-generation/scripts/scale-batch.ts --batch=0
|
* GEMINI_API_KEY=$(aws ssm get-parameter --name "/openclaw/GEMINI_API_KEY" ...) \
|
||||||
*
|
* DATABASE_URL=... \
|
||||||
* Kör alla batchar:
|
* pnpm tsx packages/recipe-generation/scripts/scale-batch.ts
|
||||||
* 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 { createAamosClient } from "@app/ai-contracts";
|
||||||
import { SEED_INGREDIENTS } from "@app/database/seed";
|
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 { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation";
|
||||||
import type { CanonicalIngredientLookup, SimilarityLookup, VerificationResult } from "@app/recipe-generation";
|
import type { CanonicalIngredientLookup, SimilarityLookup } from "@app/recipe-generation";
|
||||||
import { sql, eq } from "drizzle-orm";
|
import type { RecipeCandidate } from "@app/recipe-generation";
|
||||||
import { writeFileSync, mkdirSync, existsSync, readFileSync } from "fs";
|
import { randomUUID } from "node:crypto";
|
||||||
import { join } from "path";
|
import * as fs from "node:fs/promises";
|
||||||
import type { RecipeDNA, Allergen } from "@app/shared-types";
|
import * as path from "node:path";
|
||||||
|
|
||||||
// ── 0. ARGS ────────────────────────────────────────────────────────────────
|
// ── 1. Bygg katalog och lookups ────────────────────────────────────────────
|
||||||
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) => ({
|
const catalog: PipelineIngredient[] = SEED_INGREDIENTS.map((i) => ({
|
||||||
id: i.id,
|
id: i.id,
|
||||||
nameSv: i.nameSv,
|
nameSv: i.nameSv,
|
||||||
@@ -99,584 +60,642 @@ const ingredientLookup: CanonicalIngredientLookup = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 3. DB-klient ───────────────────────────────────────────────────────────
|
// ── 2. Täckningsmatris (mål ~220 rå-kandidater → ~200 verified) ─────────────
|
||||||
const { db, pool } = createDatabase();
|
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) ─────────────────
|
// Kyckling
|
||||||
const BATCHES: PipelineTarget[][] = [
|
{ mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "standard", count: 5 },
|
||||||
// Batch 0: Mejeri
|
{ mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 4 },
|
||||||
[
|
{ mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "gluten_free", count: 3 },
|
||||||
{ mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "standard", count: 5 },
|
{ mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "lactose_free", count: 3 },
|
||||||
{ mealType: "dinner", mainIngredientId: "cream", dietVariant: "standard", count: 5 },
|
|
||||||
{ mealType: "dinner", mainIngredientId: "creme_fraiche", dietVariant: "standard", count: 5 },
|
// Köttfärs
|
||||||
{ mealType: "dinner", mainIngredientId: "cooking_cream", dietVariant: "standard", count: 5 },
|
{ mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "standard", count: 5 },
|
||||||
{ mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "lactose_free", count: 3 },
|
{ mealType: "dinner", mainIngredientId: "minced_mixed", dietVariant: "standard", count: 4 },
|
||||||
{ mealType: "dinner", mainIngredientId: "cream", dietVariant: "lactose_free", count: 4 },
|
{ mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "gluten_free", count: 3 },
|
||||||
],
|
{ mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "lactose_free", count: 3 },
|
||||||
// Batch 1: Kyckling
|
|
||||||
[
|
// Fisk
|
||||||
{ mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "standard", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "salmon", dietVariant: "standard", count: 5 },
|
||||||
{ mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "cod", dietVariant: "standard", count: 4 },
|
||||||
{ mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "gluten_free", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "shrimp", dietVariant: "standard", count: 4 },
|
||||||
{ mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "gluten_free", count: 6 },
|
{ mealType: "dinner", mainIngredientId: "salmon", dietVariant: "gluten_free", count: 3 },
|
||||||
],
|
{ mealType: "dinner", mainIngredientId: "salmon", dietVariant: "lactose_free", count: 3 },
|
||||||
// Batch 2: Kött
|
|
||||||
[
|
// Pasta/ris
|
||||||
{ mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "standard", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "pasta_dry", dietVariant: "standard", count: 5 },
|
||||||
{ mealType: "dinner", mainIngredientId: "minced_mixed", dietVariant: "standard", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "rice_white", dietVariant: "standard", count: 5 },
|
||||||
{ mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "gluten_free", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "pasta_dry", dietVariant: "vegetarian", count: 3 },
|
||||||
{ mealType: "dinner", mainIngredientId: "pork_loin", dietVariant: "standard", count: 6 },
|
{ mealType: "dinner", mainIngredientId: "rice_white", dietVariant: "vegan", count: 3 },
|
||||||
],
|
{ mealType: "dinner", mainIngredientId: "rice_white", dietVariant: "gluten_free", count: 3 },
|
||||||
// Batch 3: Fisk
|
|
||||||
[
|
// Potatis
|
||||||
{ mealType: "dinner", mainIngredientId: "salmon", dietVariant: "standard", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "potato", dietVariant: "standard", count: 5 },
|
||||||
{ mealType: "dinner", mainIngredientId: "cod", dietVariant: "standard", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "potato", dietVariant: "vegetarian", count: 3 },
|
||||||
{ mealType: "dinner", mainIngredientId: "shrimp", dietVariant: "standard", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "potato", dietVariant: "vegan", count: 3 },
|
||||||
{ mealType: "dinner", mainIngredientId: "salmon", dietVariant: "gluten_free", count: 6 },
|
{ mealType: "dinner", mainIngredientId: "potato", dietVariant: "gluten_free", count: 3 },
|
||||||
],
|
|
||||||
// Batch 4: Pasta/ris/potatis
|
// Baljväxter
|
||||||
[
|
{ mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegan", count: 4 },
|
||||||
{ mealType: "dinner", mainIngredientId: "pasta_dry", dietVariant: "standard", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegetarian", count: 3 },
|
||||||
{ mealType: "dinner", mainIngredientId: "rice_white", dietVariant: "standard", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "red_lentils", dietVariant: "vegan", count: 4 },
|
||||||
{ mealType: "dinner", mainIngredientId: "potato", dietVariant: "standard", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "red_lentils", dietVariant: "vegetarian", count: 3 },
|
||||||
{ mealType: "dinner", mainIngredientId: "pasta_dry", dietVariant: "vegan", count: 6 },
|
{ mealType: "dinner", mainIngredientId: "chickpeas_canned", dietVariant: "vegan", count: 4 },
|
||||||
],
|
{ mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegan", count: 3 },
|
||||||
// Batch 5: Baljväxter
|
|
||||||
[
|
// Veckans grönsaker
|
||||||
{ mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegan", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "tomato", dietVariant: "standard", count: 3 },
|
||||||
{ mealType: "dinner", mainIngredientId: "red_lentils", dietVariant: "vegetarian", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "tomato", dietVariant: "vegetarian", count: 2 },
|
||||||
{ mealType: "dinner", mainIngredientId: "chickpeas_canned", dietVariant: "vegan", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "tomato", dietVariant: "vegan", count: 2 },
|
||||||
{ mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegan", count: 6 },
|
{ mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "standard", count: 3 },
|
||||||
],
|
{ mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "vegan", count: 2 },
|
||||||
// Batch 6: Grönsaker
|
{ mealType: "dinner", mainIngredientId: "paprika", dietVariant: "standard", count: 3 },
|
||||||
[
|
{ mealType: "dinner", mainIngredientId: "paprika", dietVariant: "vegan", count: 2 },
|
||||||
{ mealType: "dinner", mainIngredientId: "tomato", dietVariant: "standard", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "carrot", dietVariant: "standard", count: 3 },
|
||||||
{ mealType: "dinner", mainIngredientId: "zucchini", dietVariant: "standard", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "carrot", dietVariant: "vegetarian", count: 2 },
|
||||||
{ mealType: "dinner", mainIngredientId: "bell_pepper", dietVariant: "standard", count: 7 },
|
{ mealType: "dinner", mainIngredientId: "spinach", dietVariant: "standard", count: 3 },
|
||||||
{ mealType: "dinner", mainIngredientId: "carrot", dietVariant: "standard", count: 6 },
|
{ mealType: "dinner", mainIngredientId: "spinach", dietVariant: "vegan", count: 2 },
|
||||||
],
|
|
||||||
// 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 ─────────────────────────────────────────────────────
|
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
|
return title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[åä]/g, "a")
|
.normalize("NFD")
|
||||||
.replace(/[ö]/g, "o")
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
.replace(/[^a-z0-9\s-]/g, "")
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
.trim()
|
.replace(/^-+|-+$/g, "");
|
||||||
.replace(/\s+/g, "-")
|
|
||||||
.substring(0, 80);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchExistingTitles(): Promise<Set<string>> {
|
function normalizeTitle(title: string): string {
|
||||||
const rows = await db.select({ titleSv: schema.recipes.titleSv }).from(schema.recipes);
|
return title.toLowerCase().replace(/[^a-z0-9åäö]/g, " ");
|
||||||
return new Set(rows.map((r) => r.titleSv.toLowerCase().trim()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferDna(vr: VerificationResult): RecipeDNA {
|
function shuffle<T>(arr: T[]): T[] {
|
||||||
const c = vr.candidate;
|
const a = [...arr];
|
||||||
const ings = c.ingredients.map((i) => i.canonicalIngredientId);
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
// Protein
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
async hasSimilarity(title: string): Promise<boolean> {
|
function formatRecipeMarkdown(r: SeededRecipe, idx: number): string {
|
||||||
const key = title.toLowerCase().trim();
|
const lines: string[] = [];
|
||||||
if (this.knownTitles.has(key)) return true;
|
lines.push(`## ${idx + 1}. ${r.titleSv}`);
|
||||||
if (this.sessionTitles.has(key)) return true;
|
lines.push(`**Status:** ${r.status} | **Kök:** ${r.cuisine} | **Portioner:** ${r.portions}`);
|
||||||
this.sessionTitles.add(key);
|
lines.push(`**Tid:** ${r.prepTimeMinutes} min prep + ${r.cookTimeMinutes} min kok = ${r.totalTimeMinutes} min`);
|
||||||
return false;
|
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");
|
||||||
// ── 7. Seed-verified-recept till DB ────────────────────────────────────────
|
for (const s of r.steps) {
|
||||||
async function seedVerifiedRecipes(results: VerificationResult[]): Promise<number> {
|
lines.push(`${s.stepNumber}. ${s.instructionSv}${s.temperatureC ? ` (${s.temperatureC}°C)` : ""}`);
|
||||||
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++;
|
|
||||||
}
|
}
|
||||||
|
if (r.flagReasons.length) {
|
||||||
return seeded;
|
lines.push("");
|
||||||
|
lines.push(`**Flaggat:** ${r.flagReasons.join("; ")}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 8. Stickprov ───────────────────────────────────────────────────────────
|
interface SeededRecipe {
|
||||||
function saveSamples(results: VerificationResult[], batchIdx: number) {
|
id: string;
|
||||||
const verified = results.filter((r) => r.status === "verified");
|
titleSv: string;
|
||||||
const sampleCount = Math.max(1, Math.round(verified.length * 0.15));
|
slug: string;
|
||||||
const shuffled = [...verified].sort(() => Math.random() - 0.5);
|
descriptionSv: string;
|
||||||
const samples = shuffled.slice(0, sampleCount);
|
cuisine: string;
|
||||||
|
status: string;
|
||||||
const flagged: Array<{ title: string; reason: string }> = [];
|
portions: number;
|
||||||
|
prepTimeMinutes: number;
|
||||||
const markdown = samples
|
cookTimeMinutes: number;
|
||||||
.map((vr, idx) => {
|
totalTimeMinutes: number;
|
||||||
const c = vr.candidate;
|
allergens: string[];
|
||||||
const nut = vr.nutritionPerPortion;
|
nutritionText: string;
|
||||||
const flags: string[] = [];
|
ingredients: { displayNameSv: string; quantity: number; unit: string; optional: boolean; canonicalIngredientId: string }[];
|
||||||
if (nut && nut.kcal > 900) flags.push("MYCKET HÖG kcal (>900)");
|
steps: { stepNumber: number; instructionSv: string; temperatureC: number | null }[];
|
||||||
if (nut && nut.kcal < 150) flags.push("MYCKET LÅG kcal (<150)");
|
flagReasons: string[];
|
||||||
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 ────────────────────────────────────────────────────────
|
interface BatchResult {
|
||||||
async function runBatch(batchIdx: number): Promise<{
|
|
||||||
batchId: string;
|
batchId: string;
|
||||||
candidates: number;
|
targets: PipelineTarget[];
|
||||||
|
generated: number;
|
||||||
verified: number;
|
verified: number;
|
||||||
unverified: number;
|
unverified: number;
|
||||||
rejected: number;
|
rejected: number;
|
||||||
deduped: number;
|
duplicate: number;
|
||||||
seeded: number;
|
seeded: number;
|
||||||
costUsd: number;
|
costUsd: number;
|
||||||
}> {
|
sample: SeededRecipe[];
|
||||||
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 ──────────────────────────────────────────────────────────
|
// ── 4. Huvudflöde ──────────────────────────────────────────────────────────
|
||||||
async function main() {
|
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<{
|
const { db, pool } = createDatabase(dbUrl);
|
||||||
batch: number;
|
const client = createAamosClient(process.env);
|
||||||
candidates: number;
|
|
||||||
verified: number;
|
|
||||||
unverified: number;
|
|
||||||
rejected: number;
|
|
||||||
deduped: number;
|
|
||||||
seeded: number;
|
|
||||||
costUsd: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const idx of indicesToRun) {
|
console.error(`[scale-batch] Startar STEG 2: ${TOTAL_TARGETS} mål, batchar om ~${BATCH_SIZE}`);
|
||||||
try {
|
console.error(`[scale-batch] Katalog: ${catalog.length} ingredienser`);
|
||||||
const report = await runBatch(idx);
|
|
||||||
summary.push({ batch: idx, ...report });
|
// Ladda befintliga titlar för dedup
|
||||||
} catch (err) {
|
const existingRecipes = await db.query.recipes.findMany({
|
||||||
console.error(`[scale-batch] Batch ${idx} misslyckades:`, err);
|
columns: { id: true, titleSv: true },
|
||||||
summary.push({
|
});
|
||||||
batch: idx,
|
const knownTitles = new Set(existingRecipes.map((r) => normalizeTitle(r.titleSv)));
|
||||||
candidates: 0,
|
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,
|
verified: 0,
|
||||||
unverified: 0,
|
unverified: 0,
|
||||||
rejected: 0,
|
rejected: 0,
|
||||||
deduped: 0,
|
duplicate: 0,
|
||||||
seeded: 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
|
// ── 5. Spara stickprov ────────────────────────────────────────────────────
|
||||||
const totalCost = summary.reduce((s, r) => s + r.costUsd, 0);
|
const publicDir = "/mnt/c/Users/Public";
|
||||||
const totalCandidates = summary.reduce((s, r) => s + r.candidates, 0);
|
const sampleRecipes = shuffle(allSeeded).slice(0, Math.max(1, Math.round(allSeeded.length * 0.15)));
|
||||||
const totalVerified = summary.reduce((s, r) => s + r.verified, 0);
|
const sampleMarkdown = [
|
||||||
const totalUnverified = summary.reduce((s, r) => s + r.unverified, 0);
|
"# Cibello STEG 2 – Stickprov för smakkoll",
|
||||||
const totalRejected = summary.reduce((s, r) => s + r.rejected, 0);
|
`Genererad: ${new Date().toISOString()}`,
|
||||||
const totalDeduped = summary.reduce((s, r) => s + r.deduped, 0);
|
`Totalt seedade: ${allSeeded.length}`,
|
||||||
const totalSeeded = summary.reduce((s, r) => s + r.seeded, 0);
|
`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 = {
|
// ── 6. Slutrapport ────────────────────────────────────────────────────────
|
||||||
runId: `scale-run-${Date.now()}`,
|
const report = {
|
||||||
|
runId: `steg2-${Date.now()}`,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
dryRun,
|
totalTargets: TOTAL_TARGETS,
|
||||||
budgetLimitUsd: BUDGET_USD,
|
batches: batchResults.length,
|
||||||
|
totalGenerated,
|
||||||
|
totalVerified,
|
||||||
|
totalUnverified,
|
||||||
|
totalRejected,
|
||||||
|
totalDuplicate,
|
||||||
|
totalSeeded,
|
||||||
totalCostUsd: totalCost,
|
totalCostUsd: totalCost,
|
||||||
summary: {
|
batchSummary: batchResults.map((b) => ({
|
||||||
batchesRun: summary.length,
|
batchId: b.batchId,
|
||||||
totalCandidates,
|
targets: b.targets.reduce((s, t) => s + t.count, 0),
|
||||||
totalVerified,
|
generated: b.generated,
|
||||||
totalUnverified,
|
verified: b.verified,
|
||||||
totalRejected,
|
unverified: b.unverified,
|
||||||
totalDeduped,
|
rejected: b.rejected,
|
||||||
totalSeeded,
|
duplicate: b.duplicate,
|
||||||
},
|
seeded: b.seeded,
|
||||||
perBatch: summary,
|
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("\n# STEG 2 – Slutrapport\n");
|
||||||
console.error(`- Batchar körda: ${summary.length}`);
|
console.error(`- Batchar: ${batchResults.length}`);
|
||||||
console.error(`- Kandidater genererade: ${totalCandidates}`);
|
console.error(`- Genererade kandidater: ${totalGenerated}`);
|
||||||
console.error(`- Verified: ${totalVerified}`);
|
console.error(`- Verified: ${totalVerified}`);
|
||||||
console.error(`- Unverified: ${totalUnverified}`);
|
console.error(`- Unverified: ${totalUnverified}`);
|
||||||
console.error(`- Rejected: ${totalRejected}`);
|
console.error(`- Rejected: ${totalRejected}`);
|
||||||
console.error(`- Deduped: ${totalDeduped}`);
|
console.error(`- Dubbletter: ${totalDuplicate}`);
|
||||||
console.error(`- Seedade till DB: ${totalSeeded}`);
|
console.error(`- Seedade i DB: ${totalSeeded}`);
|
||||||
console.error(`- Total Gemini-kostnad: $${totalCost.toFixed(4)}`);
|
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) => {
|
main().catch((err) => {
|
||||||
console.error("[scale-batch] Fatal:", err);
|
console.error("[scale-batch] Fatal:", err);
|
||||||
pool.end().catch(() => {});
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { createAamosClient } from "@app/ai-contracts";
|
||||||
|
import { SEED_INGREDIENTS } from "@app/database/seed";
|
||||||
|
import { createDatabase, closeDatabase, schema } from "@app/database";
|
||||||
|
import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation";
|
||||||
|
import type { CanonicalIngredientLookup, SimilarityLookup, RecipeCandidate } from "@app/recipe-generation";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const targets: PipelineTarget[] = [
|
||||||
|
{ mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "standard", count: 2 },
|
||||||
|
{ mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "standard", count: 2 },
|
||||||
|
{ mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegan", count: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function inferDietTags(candidate: RecipeCandidate): string[] {
|
||||||
|
const ings = candidate.ingredients.map((i) => ingredientLookup.getById(i.canonicalIngredientId)).filter(Boolean);
|
||||||
|
const tags: string[] = [];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const dbUrl = process.env.DATABASE_URL;
|
||||||
|
if (!dbUrl) throw new Error("DATABASE_URL saknas");
|
||||||
|
const { db } = createDatabase(dbUrl);
|
||||||
|
const client = createAamosClient(process.env);
|
||||||
|
|
||||||
|
const existing = await db.query.recipes.findMany({ columns: { titleSv: true, slug: true } });
|
||||||
|
const knownTitles = new Set(existing.map((r) => r.titleSv.toLowerCase().replace(/[^a-z0-9åäö]/g, " ")));
|
||||||
|
const knownSlugs = new Set(existing.map((r) => r.slug));
|
||||||
|
|
||||||
|
const similarityLookup: SimilarityLookup = {
|
||||||
|
async hasSimilarity(title: string) {
|
||||||
|
return knownTitles.has(title.toLowerCase().replace(/[^a-z0-9åäö]/g, " "));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error("[scale-smoke-test] starting");
|
||||||
|
const result = await runPipeline(
|
||||||
|
client,
|
||||||
|
targets,
|
||||||
|
catalog,
|
||||||
|
ingredientLookup,
|
||||||
|
similarityLookup,
|
||||||
|
{ maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.error(`[scale-smoke-test] generated=${result.candidates.length} verified=${result.verifiedCount} unverified=${result.unverifiedCount} rejected=${result.rejectedCount}`);
|
||||||
|
|
||||||
|
let seeded = 0;
|
||||||
|
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 slugBase = candidate.titleSv
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
let slug = slugBase;
|
||||||
|
if (knownSlugs.has(slug)) slug = `${slug}-${randomUUID().slice(0, 8)}`;
|
||||||
|
knownSlugs.add(slug);
|
||||||
|
|
||||||
|
const nutrition = v.nutritionPerPortion ?? { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0 };
|
||||||
|
|
||||||
|
await db.insert(schema.recipes).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
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),
|
||||||
|
protein: candidate.ingredients.find((i) => ["chicken_breast", "chicken_thigh", "minced_beef", "minced_mixed", "salmon", "cod", "shrimp", "tofu", "red_lentils"].includes(i.canonicalIngredientId))?.canonicalIngredientId,
|
||||||
|
carbohydrate: candidate.ingredients.find((i) => ["rice_white", "pasta_dry", "potato"].includes(i.canonicalIngredientId))?.canonicalIngredientId,
|
||||||
|
vegetables: candidate.ingredients.filter((i) => ["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 Smoke",
|
||||||
|
});
|
||||||
|
seeded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[scale-smoke-test] seeded=${seeded}`);
|
||||||
|
await closeDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => { console.error(err); process.exit(1); });
|
||||||
@@ -93,13 +93,20 @@ const RAW_PROTEIN_REQUIRING_SAFE_COOKING = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const SAFE_COOKING_KEYWORDS_SV = [
|
const SAFE_COOKING_KEYWORDS_SV = [
|
||||||
/\btemperatur\b/i,
|
|
||||||
/\b°\s*c\b/i,
|
|
||||||
/\bgrader\b/i,
|
|
||||||
/\bgenomstekt\b/i,
|
/\bgenomstekt\b/i,
|
||||||
/\bgenomkokt\b/i,
|
/\bgenomkokt\b/i,
|
||||||
/\bgenomgrillad\b/i,
|
/\bgenomgrillad\b/i,
|
||||||
/\bgenomv\w+\b/i,
|
/\bgenomv\w+\b/i,
|
||||||
|
/\binte längre rosa\b/i,
|
||||||
|
/\binte rosa\b/i,
|
||||||
|
/\bfiber\w*\b/i,
|
||||||
|
/\bflagnar\b/i,
|
||||||
|
/\bgenomkokt\b/i,
|
||||||
|
/\bkärntemperatur\b/i,
|
||||||
|
/\binnertemperatur\b/i,
|
||||||
|
/\btemperatur\b/i,
|
||||||
|
/\b°\s*c\b/i,
|
||||||
|
/\bgrader\b/i,
|
||||||
/\btill(?:s)? den är klar\b/i,
|
/\btill(?:s)? den är klar\b/i,
|
||||||
/\btill(?:s)? köttet släpper vätskan\b/i,
|
/\btill(?:s)? köttet släpper vätskan\b/i,
|
||||||
/\b72\s*c?\b/i,
|
/\b72\s*c?\b/i,
|
||||||
@@ -110,14 +117,31 @@ const SAFE_COOKING_KEYWORDS_SV = [
|
|||||||
/\b70\s*c?\b/i,
|
/\b70\s*c?\b/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Fraser som INTE räcker som säkerhetsbevis – de beskriver bara utseende/tid. */
|
||||||
|
const UNSAFE_APPEARANCE_ONLY_SV = [
|
||||||
|
/\bgyllenbrun\b/i,
|
||||||
|
/\bgyllene\b/i,
|
||||||
|
/\bkrispig\b/i,
|
||||||
|
/\bkrispiga\b/i,
|
||||||
|
/\bfint färg\b/i,
|
||||||
|
/\bfint färgade\b/i,
|
||||||
|
/\bfärgad\b/i,
|
||||||
|
/\bfräsch\b/i,
|
||||||
|
/\bfräscha\b/i,
|
||||||
|
];
|
||||||
|
|
||||||
function requiresSafeCookingStep(ingredientIds: string[]): boolean {
|
function requiresSafeCookingStep(ingredientIds: string[]): boolean {
|
||||||
return ingredientIds.some((id) => RAW_PROTEIN_REQUIRING_SAFE_COOKING.has(id));
|
return ingredientIds.some((id) => RAW_PROTEIN_REQUIRING_SAFE_COOKING.has(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasSafeCookingStep(steps: RecipeCandidate["steps"]): boolean {
|
function hasSafeCookingStep(steps: RecipeCandidate["steps"]): boolean {
|
||||||
return steps.some((s) =>
|
return steps.some((s) => {
|
||||||
SAFE_COOKING_KEYWORDS_SV.some((re) => re.test(s.instructionSv)),
|
const instruction = s.instructionSv;
|
||||||
);
|
const hasPositive = SAFE_COOKING_KEYWORDS_SV.some((re) => re.test(instruction));
|
||||||
|
const onlyAppearance = UNSAFE_APPEARANCE_ONLY_SV.some((re) => re.test(instruction)) &&
|
||||||
|
!SAFE_COOKING_KEYWORDS_SV.some((re) => re.test(instruction));
|
||||||
|
return hasPositive && !onlyAppearance;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -250,6 +250,22 @@ describe("verifyCandidate", () => {
|
|||||||
expect(result.reasons).not.toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
|
expect(result.reasons).not.toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("markerar unverified när rå fågel bara beskrivs som gyllenbrun (utseende räcker inte)", async () => {
|
||||||
|
const c = makeCandidate({
|
||||||
|
ingredients: [
|
||||||
|
{ canonicalIngredientId: "chicken_breast", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
|
||||||
|
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ stepNumber: 1, instructionSv: "Stek kycklingen tills den är gyllenbrun.", timerSeconds: null, temperatureC: null, tip: null },
|
||||||
|
{ stepNumber: 2, instructionSv: "Tillsätt ris och koka klart.", timerSeconds: null, temperatureC: null, tip: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
|
||||||
|
expect(result.status).toBe("unverified");
|
||||||
|
expect(result.reasons).toContain("Receptet innehåller rått kött/fågel/ägg/fisk men saknar steg för genomstekning/temperatur.");
|
||||||
|
});
|
||||||
|
|
||||||
it("markerar unverified när rå fågel saknar genomstekningssteg (negativt)", async () => {
|
it("markerar unverified när rå fågel saknar genomstekningssteg (negativt)", async () => {
|
||||||
const c = makeCandidate({
|
const c = makeCandidate({
|
||||||
ingredients: [
|
ingredients: [
|
||||||
|
|||||||
Reference in New Issue
Block a user