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).
This commit is contained in:
@@ -2,13 +2,34 @@ import { createAamosClient } from "@app/ai-contracts";
|
||||
import { SEED_INGREDIENTS } from "@app/database/seed";
|
||||
import { createDatabase, closeDatabase, schema } from "@app/database";
|
||||
import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation";
|
||||
import type { CanonicalIngredientLookup, SimilarityLookup, RecipeCandidate } from "@app/recipe-generation";
|
||||
import type {
|
||||
CanonicalIngredientLookup,
|
||||
SimilarityLookup,
|
||||
RecipeCandidate,
|
||||
} from "@app/recipe-generation";
|
||||
import { BRAND } from "@app/shared-types";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const VALID_CUISINES = new Set([
|
||||
"swedish", "nordic", "italian", "french", "spanish", "greek", "thai",
|
||||
"chinese", "japanese", "korean", "vietnamese", "indian", "mexican",
|
||||
"american", "turkish", "lebanese", "moroccan", "middle_eastern", "international",
|
||||
"swedish",
|
||||
"nordic",
|
||||
"italian",
|
||||
"french",
|
||||
"spanish",
|
||||
"greek",
|
||||
"thai",
|
||||
"chinese",
|
||||
"japanese",
|
||||
"korean",
|
||||
"vietnamese",
|
||||
"indian",
|
||||
"mexican",
|
||||
"american",
|
||||
"turkish",
|
||||
"lebanese",
|
||||
"moroccan",
|
||||
"middle_eastern",
|
||||
"international",
|
||||
]);
|
||||
|
||||
function normalizeCuisine(raw: string | undefined): string {
|
||||
@@ -69,7 +90,9 @@ const targets: PipelineTarget[] = [
|
||||
];
|
||||
|
||||
function inferDietTags(candidate: RecipeCandidate): string[] {
|
||||
const ings = candidate.ingredients.map((i) => ingredientLookup.getById(i.canonicalIngredientId)).filter(Boolean);
|
||||
const ings = candidate.ingredients
|
||||
.map((i) => ingredientLookup.getById(i.canonicalIngredientId))
|
||||
.filter(Boolean);
|
||||
const tags: string[] = [];
|
||||
const allVegan = ings.every((i) => i?.isVegan);
|
||||
const allVegetarian = ings.every((i) => i?.isVegetarian);
|
||||
@@ -89,7 +112,9 @@ async function main() {
|
||||
const client = createAamosClient(process.env);
|
||||
|
||||
const existing = await db.query.recipes.findMany({ columns: { titleSv: true, slug: true } });
|
||||
const knownTitles = new Set(existing.map((r) => r.titleSv.toLowerCase().replace(/[^a-z0-9åäö]/g, " ")));
|
||||
const knownTitles = new Set(
|
||||
existing.map((r) => r.titleSv.toLowerCase().replace(/[^a-z0-9åäö]/g, " ")),
|
||||
);
|
||||
const knownSlugs = new Set(existing.map((r) => r.slug));
|
||||
|
||||
const similarityLookup: SimilarityLookup = {
|
||||
@@ -99,16 +124,15 @@ async function main() {
|
||||
};
|
||||
|
||||
console.error("[scale-smoke-test] starting");
|
||||
const result = await runPipeline(
|
||||
client,
|
||||
targets,
|
||||
catalog,
|
||||
ingredientLookup,
|
||||
similarityLookup,
|
||||
{ maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 },
|
||||
);
|
||||
const result = await runPipeline(client, targets, catalog, ingredientLookup, similarityLookup, {
|
||||
maxPrepTimeMinutes: 30,
|
||||
maxCookTimeMinutes: 45,
|
||||
portions: 4,
|
||||
});
|
||||
|
||||
console.error(`[scale-smoke-test] generated=${result.candidates.length} verified=${result.verifiedCount} unverified=${result.unverifiedCount} rejected=${result.rejectedCount}`);
|
||||
console.error(
|
||||
`[scale-smoke-test] generated=${result.candidates.length} verified=${result.verifiedCount} unverified=${result.unverifiedCount} rejected=${result.rejectedCount}`,
|
||||
);
|
||||
|
||||
let seeded = 0;
|
||||
for (let i = 0; i < result.candidates.length; i++) {
|
||||
@@ -126,7 +150,16 @@ async function main() {
|
||||
if (knownSlugs.has(slug)) slug = `${slug}-${randomUUID().slice(0, 8)}`;
|
||||
knownSlugs.add(slug);
|
||||
|
||||
const nutrition = v.nutritionPerPortion ?? { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0 };
|
||||
const nutrition = v.nutritionPerPortion ?? {
|
||||
kcal: 0,
|
||||
proteinG: 0,
|
||||
carbsG: 0,
|
||||
fatG: 0,
|
||||
saturatedFatG: 0,
|
||||
fiberG: 0,
|
||||
sugarG: 0,
|
||||
saltG: 0,
|
||||
};
|
||||
|
||||
await db.insert(schema.recipes).values({
|
||||
id: randomUUID(),
|
||||
@@ -150,9 +183,29 @@ async function main() {
|
||||
freezerFriendly: candidate.freezerFriendly,
|
||||
dna: {
|
||||
cuisine: normalizeCuisine(candidate.cuisine),
|
||||
protein: candidate.ingredients.find((i) => ["chicken_breast", "chicken_thigh", "minced_beef", "minced_mixed", "salmon", "cod", "shrimp", "tofu", "red_lentils"].includes(i.canonicalIngredientId))?.canonicalIngredientId,
|
||||
carbohydrate: candidate.ingredients.find((i) => ["rice_white", "pasta_dry", "potato"].includes(i.canonicalIngredientId))?.canonicalIngredientId,
|
||||
vegetables: candidate.ingredients.filter((i) => ["tomato", "zucchini", "paprika", "carrot", "spinach", "onion", "garlic"].includes(i.canonicalIngredientId)).map((i) => i.canonicalIngredientId),
|
||||
protein: candidate.ingredients.find((i) =>
|
||||
[
|
||||
"chicken_breast",
|
||||
"chicken_thigh",
|
||||
"minced_beef",
|
||||
"minced_mixed",
|
||||
"salmon",
|
||||
"cod",
|
||||
"shrimp",
|
||||
"tofu",
|
||||
"red_lentils",
|
||||
].includes(i.canonicalIngredientId),
|
||||
)?.canonicalIngredientId,
|
||||
carbohydrate: candidate.ingredients.find((i) =>
|
||||
["rice_white", "pasta_dry", "potato"].includes(i.canonicalIngredientId),
|
||||
)?.canonicalIngredientId,
|
||||
vegetables: candidate.ingredients
|
||||
.filter((i) =>
|
||||
["tomato", "zucchini", "paprika", "carrot", "spinach", "onion", "garlic"].includes(
|
||||
i.canonicalIngredientId,
|
||||
),
|
||||
)
|
||||
.map((i) => i.canonicalIngredientId),
|
||||
flavorProfile: inferDietTags(candidate),
|
||||
spiceLevel: candidate.spiceLevel,
|
||||
method: "stovetop",
|
||||
@@ -164,7 +217,7 @@ async function main() {
|
||||
status: "draft",
|
||||
verificationStatus: "verified",
|
||||
sourceType: "ai_assisted_reviewed",
|
||||
creatorDisplayName: "Cibello AI Smoke",
|
||||
creatorDisplayName: `${BRAND.name} AI Smoke`,
|
||||
});
|
||||
seeded++;
|
||||
}
|
||||
@@ -173,4 +226,7 @@ async function main() {
|
||||
await closeDatabase();
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error(err); process.exit(1); });
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user