331 lines
10 KiB
TypeScript
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);
|
|
});
|