gapfill: generera+verifiera nollceller (export)
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Gap-fill för fyra nollceller (docs/32 Spår B):
|
||||
* fläskytterfilé, banan, naturell yoghurt, jordnötssmör
|
||||
*
|
||||
* - 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[] = [
|
||||
{ mealType: "dinner", mainIngredientId: "pork_loin", dietVariant: "standard", count: 3 },
|
||||
{ mealType: "breakfast", mainIngredientId: "banana", dietVariant: "standard", count: 3 },
|
||||
{ mealType: "breakfast", mainIngredientId: "yoghurt_natural", dietVariant: "standard", count: 3 },
|
||||
{ mealType: "snack", mainIngredientId: "peanut_butter", dietVariant: "standard", count: 3 },
|
||||
];
|
||||
|
||||
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"];
|
||||
if (mainId === "peanut_butter") return ["no_cook"];
|
||||
return ["stovetop"];
|
||||
}
|
||||
|
||||
function deriveEquipment(mainId: string): string[] {
|
||||
if (mainId === "banana" || mainId === "yoghurt_natural" || mainId === "peanut_butter") {
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user