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
This commit is contained in:
Sven (AAMOS AI)
2026-08-10 00:40:01 +07:00
parent fc3d9daac8
commit 2fda1a5785
@@ -1,15 +1,34 @@
import { createDatabase, closeDatabase } from "@app/database"; import { createDatabase, closeDatabase } from "@app/database";
import { SEED_INGREDIENTS } from "@app/database/seed";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as path from "node:path"; 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() { async function main() {
const dbUrl = process.env.DATABASE_URL; const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) throw new Error("DATABASE_URL saknas"); if (!dbUrl) throw new Error("DATABASE_URL saknas");
const { db } = createDatabase(dbUrl); const { db } = createDatabase(dbUrl);
const recipes = await db.query.recipes.findMany({ const recipes = (await db.query.recipes.findMany({
where: (t, { eq }) => eq(t.verificationStatus, "verified"), where: (t, { inArray }) => inArray(t.verificationStatus, ["verified", "editorial"]),
}); })) as RecipeWithIngredients[];
const enriched = []; const enriched = [];
for (const r of recipes) { for (const r of recipes) {
@@ -22,15 +41,134 @@ async function main() {
enriched.push({ ...r, ingredients, steps }); enriched.push({ ...r, ingredients, steps });
} }
const publicDir = "/mnt/c/Users/Public"; // ── Coverage matrix ──────────────────────────────────────────────────────
const outPath = path.join(publicDir, "cibello-verified-119-result.json"); const mainIds = Array.from(new Set(SEED_INGREDIENTS.map((i) => i.id)));
await fs.writeFile(outPath, JSON.stringify({ const matrix = new Map<string, Map<string, number>>();
exportedAt: new Date().toISOString(),
count: enriched.length, for (const r of recipes) {
recipes: enriched, const ingredients = await db.query.recipeIngredients.findMany({
}, null, 2), "utf-8"); 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")}`);
console.error(`[export-verified] Exported ${recipes.length} verified recipes to ${outPath}`);
await closeDatabase(); await closeDatabase();
} }