Files
Cibello-app/packages/recipe-generation/scripts/export-verified.ts
T
Sven (AAMOS AI) 2fda1a5785 feat(recipe-generation): final export script med coverage + dedup + kostnad
- export-verified.ts exporterar nu även editorial (status=editorial)
- Lägger till coverage-matrix, dedup-stats och summary.md
- Inkluderar total kostnad för runda 1 + runda 2
2026-08-10 00:40:01 +07:00

176 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, { inArray }) => inArray(t.verificationStatus, ["verified", "editorial"]),
})) as RecipeWithIngredients[];
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 });
}
// ── Coverage matrix ──────────────────────────────────────────────────────
const mainIds = Array.from(new Set(SEED_INGREDIENTS.map((i) => i.id)));
const matrix = new Map<string, Map<string, number>>();
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<string, number>();
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<string, number>();
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")}`);
await closeDatabase();
}
main().catch((err) => { console.error(err); process.exit(1); });