Files
Sven (AAMOS AI) f4a603e977 fix(recipe-generation): BRAND.name-interpolation för creatorDisplayName + rubriker/filnamn
feat(recommendation-engine,api): S4 Smak/Hälsa/Lager-vyer för 'Vad ska vi äta?'

- Ersätter hårdkodat 'Cibello' i scale-batch, scale-smoke-test och export-verified.
- DB-backfill: 224 recept hade 'Cibello AI' i creator_display_name (värdena
  motsvarar nuvarande BRAND.name, ingen rad ändrades men kontrollen är gjord).
- Lägger till view-query-param (default|taste|health|pantry) med fördefinierade
  ScoringWeights och samtyckesgrind.
- Unit-tester för vyer; integrationstester för vy-param, validering och
  fallback utan personalization-samtycke.
- brand-guard grön; pnpm typecheck 19/19; pnpm test --force x2 grönt (34 tasks,
  275 tester).
2026-08-10 05:17:43 +07:00

201 lines
7.3 KiB
TypeScript
Raw Permalink 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 { BRAND } from "@app/shared-types";
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, `${BRAND.slug}-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, `${BRAND.slug}-verified-final-coverage-matrix.json`),
JSON.stringify({ exportedAt: new Date().toISOString(), coverageMatrix }, null, 2),
"utf-8",
);
await fs.writeFile(
path.join(publicDir, `${BRAND.slug}-verified-final-dedup-stats.json`),
JSON.stringify({ exportedAt: new Date().toISOString(), ...summary.dedupStats }, null, 2),
"utf-8",
);
await fs.writeFile(
path.join(publicDir, `${BRAND.slug}-verified-final-summary.md`),
`# ${BRAND.name} 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, `${BRAND.slug}-verified-final-result.json`)}`,
);
console.error(
` coverage-matrix -> ${path.join(publicDir, `${BRAND.slug}-verified-final-coverage-matrix.json`)}`,
);
console.error(
` dedup-stats -> ${path.join(publicDir, `${BRAND.slug}-verified-final-dedup-stats.json`)}`,
);
console.error(
` summary.md -> ${path.join(publicDir, `${BRAND.slug}-verified-final-summary.md`)}`,
);
await closeDatabase();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});