diff --git a/packages/recipe-generation/scripts/export-verified.ts b/packages/recipe-generation/scripts/export-verified.ts index 37f7432..84a5742 100644 --- a/packages/recipe-generation/scripts/export-verified.ts +++ b/packages/recipe-generation/scripts/export-verified.ts @@ -1,15 +1,34 @@ import { createDatabase, closeDatabase } from "@app/database"; +import { SEED_INGREDIENTS } from "@app/database/seed"; import * as fs from "node:fs/promises"; import * as path from "node:path"; +const publicDir = "/mnt/c/Users/Public"; + +interface RecipeWithIngredients { + id: string; + slug: string; + titleSv: string; + titleEn: string | null; + cuisine: string | null; + mealType: string | null; + dietVariant: string | null; + sourceType: string; + verificationStatus: string; +} + +function normalizeDietVariant(raw: string | null): string { + return raw ?? "standard"; +} + 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 recipes = (await db.query.recipes.findMany({ + where: (t, { inArray }) => inArray(t.verificationStatus, ["verified", "editorial"]), + })) as RecipeWithIngredients[]; const enriched = []; for (const r of recipes) { @@ -22,15 +41,134 @@ async function main() { 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"); + // ── Coverage matrix ────────────────────────────────────────────────────── + const mainIds = Array.from(new Set(SEED_INGREDIENTS.map((i) => i.id))); + const matrix = new Map>(); + + for (const r of recipes) { + const ingredients = await db.query.recipeIngredients.findMany({ + where: (t, { eq }) => eq(t.recipeId, r.id), + }); + const mainIngs = ingredients.filter((ing) => mainIds.includes(ing.canonicalIngredientId)); + const diet = normalizeDietVariant(r.dietVariant); + for (const ing of mainIngs) { + if (!matrix.has(ing.canonicalIngredientId)) matrix.set(ing.canonicalIngredientId, new Map()); + const cell = matrix.get(ing.canonicalIngredientId)!; + cell.set(diet, (cell.get(diet) ?? 0) + 1); + } + } + + const coverageMatrix = mainIds.map((id) => { + const ing = SEED_INGREDIENTS.find((i) => i.id === id); + const cell = matrix.get(id) ?? new Map(); + return { + ingredientId: id, + nameSv: ing?.nameSv ?? id, + category: ing?.category ?? "unknown", + total: Array.from(cell.values()).reduce((s, v) => s + v, 0), + byDiet: Object.fromEntries(cell), + }; + }).sort((a, b) => a.total - b.total); + + const thinCells = coverageMatrix.filter((c) => c.total <= 2); + + // ── Dedup / similarity stats ───────────────────────────────────────────── + const slugs = recipes.map((r) => r.slug); + const uniqueSlugs = new Set(slugs); + const titleWords = recipes.map((r) => + r.titleSv.toLowerCase().replace(/[^\w\s]/g, "").split(/\s+/).filter((w) => w.length > 3), + ); + const titleSignatures = titleWords.map((words) => words.slice(0, 4).sort().join(" ")); + const sigCounts = new Map(); + for (const sig of titleSignatures) sigCounts.set(sig, (sigCounts.get(sig) ?? 0) + 1); + const suspectedDupes = Array.from(sigCounts.entries()).filter(([, n]) => n > 1); + + // ── Cost summary ───────────────────────────────────────────────────────── + // Round 1 from earlier log summary; round 2 from the combined logs below. + const round1CostUsd = 0.0612; + const round2CostUsd = 0.0546; // g1 0.0040 + g2 0.0169 + g3 0.0337 + const totalCostUsd = round1CostUsd + round2CostUsd; + + const summary = { + exportedAt: new Date().toISOString(), + totalVerified: recipes.length, + editorialRecipes: recipes.filter((r) => r.sourceType === "own_editorial").length, + aiAssistedRecipes: recipes.filter((r) => r.sourceType === "ai_assisted_reviewed").length, + editorialRecipesByStatus: recipes.filter((r) => r.verificationStatus === "editorial").length, + verifiedRecipesByStatus: recipes.filter((r) => r.verificationStatus === "verified").length, + dedupStats: { + totalSlugs: slugs.length, + uniqueSlugs: uniqueSlugs.size, + duplicateSlugs: slugs.length - uniqueSlugs.size, + suspectedTitleSignatureDuplicates: suspectedDupes.length, + suspectedTitleSignatures: suspectedDupes.map(([sig, n]) => ({ signature: sig, count: n })), + }, + coverageStats: { + totalCanonicalIngredients: mainIds.length, + ingredientsWithVerifiedRecipes: coverageMatrix.filter((c) => c.total > 0).length, + thinCellsCount: thinCells.length, + thinnestCells: thinCells.slice(0, 10), + }, + costUsd: { + round1: round1CostUsd, + round2: round2CostUsd, + total: totalCostUsd, + }, + }; + + await fs.writeFile( + path.join(publicDir, "cibello-verified-final-result.json"), + JSON.stringify({ exportedAt: new Date().toISOString(), count: enriched.length, recipes: enriched }, null, 2), + "utf-8", + ); + + await fs.writeFile( + path.join(publicDir, "cibello-verified-final-coverage-matrix.json"), + JSON.stringify({ exportedAt: new Date().toISOString(), coverageMatrix }, null, 2), + "utf-8", + ); + + await fs.writeFile( + path.join(publicDir, "cibello-verified-final-dedup-stats.json"), + JSON.stringify({ exportedAt: new Date().toISOString(), ...summary.dedupStats }, null, 2), + "utf-8", + ); + + await fs.writeFile( + path.join(publicDir, "cibello-verified-final-summary.md"), + `# Cibello Receptkatalog – Final Verified Set + +- **Exporterad:** ${summary.exportedAt} +- **Totalt godkända recept (verified + editorial):** ${summary.totalVerified} + - Editorial: ${summary.editorialRecipes} (status=editorial) + - AI-assisted & reviewed: ${summary.aiAssistedRecipes} (status=verified) +- **Kostnad:** $${summary.costUsd.total.toFixed(4)} (runda 1: $${summary.costUsd.round1.toFixed(4)}, runda 2: $${summary.costUsd.round2.toFixed(4)}) + +## Dedup-statistik + +- Unika slugs: ${summary.dedupStats.uniqueSlugs} / ${summary.dedupStats.totalSlugs} +- Dublett-slugs: ${summary.dedupStats.duplicateSlugs} +- Misstänkta titel-signatur-dubbletter: ${summary.dedupStats.suspectedTitleSignatureDuplicates} + +## Täckningsmatris (tunnaste cellerna) + +| Huvudingrediens | Kategori | Totalt | Fördelning | +|---|---|---:|---| +${thinCells.slice(0, 15).map((c) => `| ${c.nameSv} | ${c.category} | ${c.total} | ${JSON.stringify(c.byDiet)} |`).join("\n")} + +## Granskning + +Setet är redo för allergen- + närings-sanity av granskare. +`, + "utf-8", + ); + + console.error(`[export-verified] Exported ${recipes.length} verified recipes`); + console.error(` result.json -> ${path.join(publicDir, "cibello-verified-final-result.json")}`); + console.error(` coverage-matrix -> ${path.join(publicDir, "cibello-verified-final-coverage-matrix.json")}`); + console.error(` dedup-stats -> ${path.join(publicDir, "cibello-verified-final-dedup-stats.json")}`); + console.error(` summary.md -> ${path.join(publicDir, "cibello-verified-final-summary.md")}`); - console.error(`[export-verified] Exported ${recipes.length} verified recipes to ${outPath}`); await closeDatabase(); }