Files
Cibello-app/packages/recipe-generation/scripts/gapfill-nullcells.ts
T
Sven (AAMOS AI) c32a7e33c7
CI / Typecheck, test & build (push) Failing after 2s
ci: trigga på master + formatfix inför Gitea Actions
2026-08-13 17:25:14 +07:00

331 lines
10 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Gap-fill för jordnötssmör-cellen (docs/32 Spår B):
* rikare, lagade middagsrätter med jordnötssmör som huvudingrediens.
*
* - Max 3 recept per huvudingrediens (~12 totalt).
* - Använder befintlig runPipeline + verifyCandidate.
* - Dedup mot befintliga 246 recept (titel + slug).
* - Skriver endast sammanfattning till stdout; full export + rejects till filer.
*/
import { createAamosClient } from "@app/ai-contracts";
import { SEED_INGREDIENTS, SEED_RECIPES } from "@app/database/seed";
import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation";
import type { CanonicalIngredientLookup, SimilarityLookup } from "@app/recipe-generation";
import type { SeedRecipe } from "@app/database/seed/data/recipes.js";
import { BRAND } from "@app/shared-types";
import * as fs from "node:fs/promises";
import * as path from "node:path";
const OUTPUT_DIR = path.resolve(import.meta.dirname ?? "..", "..", "output");
const NULLCELL_TARGETS: PipelineTarget[] = [
// Endast jordnötssmör-cellen återstår; rikare, lagade middagsrätter.
{ mealType: "dinner", mainIngredientId: "peanut_butter", dietVariant: "standard", count: 4 },
];
function normalizeForSlug(title: string): string {
return title
.toLowerCase()
.replace(/[åä]/g, "a")
.replace(/ö/g, "o")
.replace(/é/g, "e")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.replace(/-+/g, "-");
}
function makeUniqueSlug(title: string, existingSlugs: Set<string>): string {
let slug = normalizeForSlug(title);
if (!slug) slug = "recept";
let candidate = slug;
let suffix = 2;
while (existingSlugs.has(candidate)) {
candidate = `${slug}-${suffix}`;
suffix++;
}
return candidate;
}
function deriveTags(mealType: string, mainId: string): string[] {
const tags = new Set<string>(["quick"]);
if (mealType === "breakfast" || mealType === "snack") tags.add("kid_friendly");
if (mainId === "pork_loin") tags.add("high_protein");
if (mainId === "peanut_butter") tags.add("high_protein");
return Array.from(tags);
}
function deriveMethods(mainId: string): string[] {
if (mainId === "banana" || mainId === "yoghurt_natural") return ["no_cook"];
return ["stovetop"];
}
function deriveEquipment(mainId: string): string[] {
if (mainId === "banana" || mainId === "yoghurt_natural") {
return [];
}
return ["stove"];
}
function guessDna(mainId: string, ingredientIds: string[]) {
const vegetables = ingredientIds.filter((id) =>
[
"tomato",
"spinach",
"zucchini",
"paprika",
"carrot",
"cucumber",
"lettuce",
"corn",
"onion",
"garlic",
].includes(id),
);
const carbs = ingredientIds.filter((id) =>
["rice_white", "pasta_dry", "potato", "bread", "tortilla", "oats", "quinoa"].includes(id),
);
const proteins = ingredientIds.filter((id) =>
[
"pork_loin",
"chicken_breast",
"chicken_thigh",
"minced_beef",
"salmon",
"tofu",
"egg",
"peanut_butter",
].includes(id),
);
return {
protein: proteins[0] ?? null,
carb: carbs[0] ?? null,
vegetables: vegetables.slice(0, 4),
flavor: ["fresh", "balanced"],
};
}
function toSeedRecipe(
candidate: Awaited<ReturnType<typeof runPipeline>>["verificationResults"][number],
target: PipelineTarget,
slug: string,
): SeedRecipe {
const c = candidate.candidate;
const dna = guessDna(target.mainIngredientId, candidate.canonicalIngredientIds);
return {
slug,
titleSv: c.titleSv,
descriptionSv: c.descriptionSv,
cuisine: (c.cuisine as SeedRecipe["cuisine"]) ?? "international",
mealTypes: (c.mealTypes as SeedRecipe["mealTypes"]) ?? [
target.mealType as SeedRecipe["mealTypes"][number],
],
tags: deriveTags(target.mealType, target.mainIngredientId) as SeedRecipe["tags"],
methods: deriveMethods(target.mainIngredientId) as SeedRecipe["methods"],
equipment: deriveEquipment(target.mainIngredientId) as SeedRecipe["equipment"],
difficulty: "beginner",
prepMin: c.prepTimeMinutes,
cookMin: c.cookTimeMinutes,
portions: c.portions,
spiceLevel: c.spiceLevel,
mealPrepFriendly: c.mealPrepFriendly,
freezerFriendly: c.freezerFriendly,
peakSeasons: [],
holidayTags: [],
variantType: target.dietVariant as SeedRecipe["variantType"],
dnaProtein: dna.protein ?? undefined,
dnaCarb: dna.carb ?? undefined,
dnaVegetables: dna.vegetables,
dnaFlavor: dna.flavor,
verificationStatus: "verified",
storageGuidanceSv: c.storageGuidanceSv ?? undefined,
ingredients: c.ingredients.map((i) => ({
ing: i.canonicalIngredientId,
nameSv: i.displayNameSv,
qty: i.quantity,
unit: i.unit as SeedRecipe["ingredients"][number]["unit"],
note: i.note ?? undefined,
optional: i.optional,
})),
steps: c.steps.map((s) => ({
text: s.instructionSv,
timerSeconds: s.timerSeconds ?? undefined,
temperatureC: s.temperatureC ?? undefined,
tip: s.tip ?? undefined,
})),
};
}
async function main() {
const client = createAamosClient(process.env);
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 existingSlugs = new Set(SEED_RECIPES.map((r) => r.slug));
const existingTitles = new Set(SEED_RECIPES.map((r) => r.titleSv.toLowerCase()));
const usedSlugs = new Set(existingSlugs);
const usedTitles = new Set(existingTitles);
const similarityLookup: SimilarityLookup = {
async hasSimilarity(title: string) {
const lower = title.toLowerCase();
if (usedTitles.has(lower)) return true;
const slug = normalizeForSlug(title);
if (usedSlugs.has(slug)) return true;
return false;
},
};
console.error(
`[gapfill] ${NULLCELL_TARGETS.length} nollceller, max ${NULLCELL_TARGETS.reduce((s, t) => s + t.count, 0)} recept`,
);
const result = await runPipeline(
client,
NULLCELL_TARGETS,
catalog,
ingredientLookup,
similarityLookup,
{ maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 },
);
const verifiedExports: Array<{ target: PipelineTarget; slug: string; recipe: SeedRecipe }> = [];
const rejects: Array<{
target: PipelineTarget;
title: string;
status: string;
reasons: string[];
}> = [];
for (let i = 0; i < result.verificationResults.length; i++) {
const vr = result.verificationResults[i];
const target =
NULLCELL_TARGETS.find((t) => t.mainIngredientId === vr.canonicalIngredientIds[0]) ??
NULLCELL_TARGETS[0];
if (vr.status === "verified") {
const slug = makeUniqueSlug(vr.candidate.titleSv, usedSlugs);
usedSlugs.add(slug);
usedTitles.add(vr.candidate.titleSv.toLowerCase());
verifiedExports.push({ target, slug, recipe: toSeedRecipe(vr, target, slug) });
} else {
rejects.push({
target,
title: vr.candidate.titleSv,
status: vr.status,
reasons: vr.reasons,
});
}
}
await fs.mkdir(OUTPUT_DIR, { recursive: true });
const exportPath = path.join(OUTPUT_DIR, "gapfill-nullcells-export.json");
const rejectsPath = path.join(OUTPUT_DIR, "gapfill-nullcells-rejects.json");
const summaryPath = path.join(OUTPUT_DIR, "gapfill-nullcells-summary.json");
await fs.writeFile(
exportPath,
JSON.stringify(
{
batchId: `gapfill-nullcells-${Date.now()}`,
generatedAt: new Date().toISOString(),
brand: BRAND.name,
targetCount: NULLCELL_TARGETS.reduce((s, t) => s + t.count, 0),
verifiedCount: result.verifiedCount,
unverifiedCount: result.unverifiedCount,
rejectedCount: result.rejectedCount,
geminiStatus: result.geminiResult.status,
geminiCostUsd: result.geminiResult.costUsd ?? 0,
recipes: verifiedExports.map((e) => e.recipe),
},
null,
2,
),
"utf-8",
);
await fs.writeFile(
rejectsPath,
JSON.stringify(
{
batchId: `gapfill-nullcells-${Date.now()}`,
generatedAt: new Date().toISOString(),
rejects: rejects,
},
null,
2,
),
"utf-8",
);
const summary = {
batchId: `gapfill-nullcells-${Date.now()}`,
generatedAt: new Date().toISOString(),
geminiStatus: result.geminiResult.status,
geminiCostUsd: result.geminiResult.costUsd ?? 0,
candidatesGenerated: result.candidates.length,
verified: result.verifiedCount,
unverified: result.unverifiedCount,
rejected: result.rejectedCount,
exportPath,
rejectsPath,
};
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2), "utf-8");
console.error("\n# Gap-fill nollceller — sammanfattning\n");
console.error(`- Genererade kandidater: ${result.candidates.length}`);
console.error(`- Verified/godkända: ${result.verifiedCount}`);
console.error(`- Unverified: ${result.unverifiedCount}`);
console.error(`- Rejected: ${result.rejectedCount}`);
console.error(`- Gemini-kostnad: $${(result.geminiResult.costUsd ?? 0).toFixed(4)}`);
console.error(`- Export: ${exportPath}`);
console.error(`- Rejects: ${rejectsPath}`);
console.error(`- Summary: ${summaryPath}`);
if (result.geminiResult.error) {
console.error(`\n**Fel:** ${result.geminiResult.error}`);
}
}
main().catch((err) => {
console.error("[gapfill] Fatal:", err);
process.exit(1);
});