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:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user