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:
@@ -17,6 +17,7 @@ import {
|
|||||||
rankAll,
|
rankAll,
|
||||||
seasonForDate,
|
seasonForDate,
|
||||||
summarizeContext,
|
summarizeContext,
|
||||||
|
viewWeights,
|
||||||
type CookingAssumption,
|
type CookingAssumption,
|
||||||
type RecommendationCandidate,
|
type RecommendationCandidate,
|
||||||
type RecommendationContext,
|
type RecommendationContext,
|
||||||
@@ -381,11 +382,17 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
|||||||
? Math.floor((today.getTime() - Date.parse(lastDate)) / 86_400_000)
|
? Math.floor((today.getTime() - Date.parse(lastDate)) / 86_400_000)
|
||||||
: null,
|
: null,
|
||||||
householdRating: householdRatingMap.get(recipe.id) ?? null,
|
householdRating: householdRatingMap.get(recipe.id) ?? null,
|
||||||
ingredientIds: recipeIngredients.map((i) => i.canonicalIngredientId).filter((id): id is string => id != null),
|
ingredientIds: recipeIngredients
|
||||||
|
.map((i) => i.canonicalIngredientId)
|
||||||
|
.filter((id): id is string => id != null),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const weights = personalizationEnabled ? undefined : NON_PERSONALIZED_WEIGHTS;
|
const weights = personalizationEnabled
|
||||||
|
? q.view === "default"
|
||||||
|
? undefined
|
||||||
|
: viewWeights(q.view)
|
||||||
|
: NON_PERSONALIZED_WEIGHTS;
|
||||||
let recommendations = rankAll(scoredCandidates, ctx, weights, q.limit);
|
let recommendations = rankAll(scoredCandidates, ctx, weights, q.limit);
|
||||||
|
|
||||||
// --- 9. AAMOS-omrankning bakom feature flag (aldrig obligatorisk) ---
|
// --- 9. AAMOS-omrankning bakom feature flag (aldrig obligatorisk) ---
|
||||||
@@ -416,7 +423,8 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 10. Matlådor först när rimligt (spec §24) ---
|
// --- 10. Matlådor först när rimligt (spec §24) ---
|
||||||
const mealBoxes = q.includeLeftovers && householdId
|
const mealBoxes =
|
||||||
|
q.includeLeftovers && householdId
|
||||||
? await app.db
|
? await app.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.mealBoxes)
|
.from(schema.mealBoxes)
|
||||||
@@ -449,6 +457,7 @@ export async function recommendationRoutes(app: FastifyInstance) {
|
|||||||
remainingKcal: ctx.remainingKcal,
|
remainingKcal: ctx.remainingKcal,
|
||||||
remainingProteinG: ctx.remainingProteinG,
|
remainingProteinG: ctx.remainingProteinG,
|
||||||
craving: craving ?? null,
|
craving: craving ?? null,
|
||||||
|
view: personalizationEnabled ? q.view : "default",
|
||||||
},
|
},
|
||||||
mealBoxSuggestions,
|
mealBoxSuggestions,
|
||||||
recommendations,
|
recommendations,
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { buildServer } from "../src/server.js";
|
|||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
import { createDatabase, closeDatabase, schema } from "@app/database";
|
import { createDatabase, closeDatabase, schema } from "@app/database";
|
||||||
|
|
||||||
|
const userEmail = "what-to-eat-repro@example.invalid";
|
||||||
|
const viewTestEmail = "view-test@example.invalid";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression test: what-to-eat must work for a brand-new user who has not
|
* Regression test: what-to-eat must work for a brand-new user who has not
|
||||||
* created a household yet (empty pantry, single-person context).
|
* created a household yet (empty pantry, single-person context).
|
||||||
@@ -14,16 +17,23 @@ describe("what-to-eat without household", () => {
|
|||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
let app: Awaited<ReturnType<typeof buildServer>>;
|
let app: Awaited<ReturnType<typeof buildServer>>;
|
||||||
let accessToken: string;
|
let accessToken: string;
|
||||||
const userEmail = "what-to-eat-repro@example.invalid";
|
|
||||||
|
|
||||||
async function cleanup() {
|
async function cleanup() {
|
||||||
const emails = [userEmail, "goals-multi@example.invalid", "auto-household@example.invalid"];
|
const emails = [
|
||||||
|
userEmail,
|
||||||
|
"goals-multi@example.invalid",
|
||||||
|
"auto-household@example.invalid",
|
||||||
|
"personalization-gate@example.invalid",
|
||||||
|
viewTestEmail,
|
||||||
|
];
|
||||||
const existing = await testDb.db
|
const existing = await testDb.db
|
||||||
.select({ id: schema.users.id })
|
.select({ id: schema.users.id })
|
||||||
.from(schema.users)
|
.from(schema.users)
|
||||||
.where(inArray(schema.users.email, emails));
|
.where(inArray(schema.users.email, emails));
|
||||||
for (const u of existing) {
|
for (const u of existing) {
|
||||||
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id));
|
await testDb.db
|
||||||
|
.delete(schema.householdMembers)
|
||||||
|
.where(eq(schema.householdMembers.userId, u.id));
|
||||||
const ownedHouseholds = await testDb.db
|
const ownedHouseholds = await testDb.db
|
||||||
.select({ id: schema.households.id })
|
.select({ id: schema.households.id })
|
||||||
.from(schema.households)
|
.from(schema.households)
|
||||||
@@ -31,9 +41,13 @@ describe("what-to-eat without household", () => {
|
|||||||
schema.householdMembers,
|
schema.householdMembers,
|
||||||
eq(schema.householdMembers.householdId, schema.households.id),
|
eq(schema.householdMembers.householdId, schema.households.id),
|
||||||
)
|
)
|
||||||
.where(and(eq(schema.householdMembers.userId, u.id), eq(schema.householdMembers.role, "owner")));
|
.where(
|
||||||
|
and(eq(schema.householdMembers.userId, u.id), eq(schema.householdMembers.role, "owner")),
|
||||||
|
);
|
||||||
for (const h of ownedHouseholds) {
|
for (const h of ownedHouseholds) {
|
||||||
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, h.id));
|
await testDb.db
|
||||||
|
.delete(schema.storageLocations)
|
||||||
|
.where(eq(schema.storageLocations.householdId, h.id));
|
||||||
await testDb.db.delete(schema.households).where(eq(schema.households.id, h.id));
|
await testDb.db.delete(schema.households).where(eq(schema.households.id, h.id));
|
||||||
}
|
}
|
||||||
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
|
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
|
||||||
@@ -56,7 +70,10 @@ describe("what-to-eat without household", () => {
|
|||||||
|
|
||||||
await testDb.db
|
await testDb.db
|
||||||
.insert(schema.userPreferences)
|
.insert(schema.userPreferences)
|
||||||
.values({ userId: (JSON.parse(atob(accessToken.split(".")[1]!)) as { sub: string }).sub, primaryGoal: "cook_more" })
|
.values({
|
||||||
|
userId: (JSON.parse(atob(accessToken.split(".")[1]!)) as { sub: string }).sub,
|
||||||
|
primaryGoal: "cook_more",
|
||||||
|
})
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +107,11 @@ describe("what-to-eat without household", () => {
|
|||||||
const registerRes = await app.inject({
|
const registerRes = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/v1/auth/register",
|
url: "/v1/auth/register",
|
||||||
payload: { email: "goals-multi@example.invalid", password: "Password123!", displayName: "Goals" },
|
payload: {
|
||||||
|
email: "goals-multi@example.invalid",
|
||||||
|
password: "Password123!",
|
||||||
|
displayName: "Goals",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const { accessToken: token } = JSON.parse(registerRes.body) as { accessToken: string };
|
const { accessToken: token } = JSON.parse(registerRes.body) as { accessToken: string };
|
||||||
const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub;
|
const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub;
|
||||||
@@ -117,7 +138,11 @@ describe("what-to-eat without household", () => {
|
|||||||
const registerRes = await app.inject({
|
const registerRes = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/v1/auth/register",
|
url: "/v1/auth/register",
|
||||||
payload: { email: "personalization-gate@example.invalid", password: "Password123!", displayName: "Gate" },
|
payload: {
|
||||||
|
email: "personalization-gate@example.invalid",
|
||||||
|
password: "Password123!",
|
||||||
|
displayName: "Gate",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const { accessToken: token } = JSON.parse(registerRes.body) as { accessToken: string };
|
const { accessToken: token } = JSON.parse(registerRes.body) as { accessToken: string };
|
||||||
const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub;
|
const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub;
|
||||||
@@ -183,7 +208,11 @@ describe("what-to-eat without household", () => {
|
|||||||
const registerRes = await app.inject({
|
const registerRes = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/v1/auth/register",
|
url: "/v1/auth/register",
|
||||||
payload: { email: "auto-household@example.invalid", password: "Password123!", displayName: "Auto" },
|
payload: {
|
||||||
|
email: "auto-household@example.invalid",
|
||||||
|
password: "Password123!",
|
||||||
|
displayName: "Auto",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const { accessToken: token } = JSON.parse(registerRes.body) as { accessToken: string };
|
const { accessToken: token } = JSON.parse(registerRes.body) as { accessToken: string };
|
||||||
const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub;
|
const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub;
|
||||||
@@ -213,3 +242,110 @@ describe("what-to-eat without household", () => {
|
|||||||
expect(locations.map((l) => l.type).sort()).toEqual(["freezer", "fridge", "pantry"]);
|
expect(locations.map((l) => l.type).sort()).toEqual(["freezer", "fridge", "pantry"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("S4 recommendation views", () => {
|
||||||
|
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
|
||||||
|
const config = loadConfig();
|
||||||
|
let app: Awaited<ReturnType<typeof buildServer>>;
|
||||||
|
let accessToken: string;
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await buildServer(config);
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/v1/auth/register",
|
||||||
|
payload: { email: viewTestEmail, password: "Password123!", displayName: "View" },
|
||||||
|
});
|
||||||
|
const body = JSON.parse(res.body) as { accessToken: string };
|
||||||
|
accessToken = body.accessToken;
|
||||||
|
userId = (JSON.parse(atob(accessToken.split(".")[1]!)) as { sub: string }).sub;
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/v1/onboarding/quick-start",
|
||||||
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
|
payload: { goals: ["cook_more"], persons: 2, precisionMode: "simple" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
await testDb.pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported view values", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/recommendations/what-to-eat?view=spicy&limit=1",
|
||||||
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns requested view when personalization consent is granted", async () => {
|
||||||
|
await testDb.db
|
||||||
|
.insert(schema.userConsents)
|
||||||
|
.values({ userId, kind: "personalization", status: "granted" })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [schema.userConsents.userId, schema.userConsents.kind],
|
||||||
|
set: { status: "granted" },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const view of ["taste", "health", "pantry"] as const) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/v1/recommendations/what-to-eat?view=${view}&limit=1`,
|
||||||
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body) as {
|
||||||
|
context: { view: string };
|
||||||
|
recommendations: unknown[];
|
||||||
|
};
|
||||||
|
expect(body.context.view).toBe(view);
|
||||||
|
expect(body.recommendations.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores view and falls back to default without personalization consent", async () => {
|
||||||
|
// Revoke consent.
|
||||||
|
await testDb.db
|
||||||
|
.insert(schema.userConsents)
|
||||||
|
.values({ userId, kind: "personalization", status: "revoked" })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [schema.userConsents.userId, schema.userConsents.kind],
|
||||||
|
set: { status: "revoked" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultRes = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/recommendations/what-to-eat?limit=1",
|
||||||
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
const viewRes = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/recommendations/what-to-eat?view=taste&limit=1",
|
||||||
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(defaultRes.statusCode).toBe(200);
|
||||||
|
expect(viewRes.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const defaultBody = JSON.parse(defaultRes.body) as {
|
||||||
|
context: { view: string };
|
||||||
|
recommendations: Array<{ recipeId: string }>;
|
||||||
|
};
|
||||||
|
const viewBody = JSON.parse(viewRes.body) as {
|
||||||
|
context: { view: string };
|
||||||
|
recommendations: Array<{ recipeId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(viewBody.context.view).toBe("default");
|
||||||
|
expect(viewBody.recommendations.map((r) => r.recipeId)).toEqual(
|
||||||
|
defaultBody.recommendations.map((r) => r.recipeId),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createDatabase, closeDatabase } from "@app/database";
|
import { createDatabase, closeDatabase } from "@app/database";
|
||||||
import { SEED_INGREDIENTS } from "@app/database/seed";
|
import { SEED_INGREDIENTS } from "@app/database/seed";
|
||||||
|
import { BRAND } from "@app/shared-types";
|
||||||
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";
|
||||||
|
|
||||||
@@ -58,7 +59,8 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverageMatrix = mainIds.map((id) => {
|
const coverageMatrix = mainIds
|
||||||
|
.map((id) => {
|
||||||
const ing = SEED_INGREDIENTS.find((i) => i.id === id);
|
const ing = SEED_INGREDIENTS.find((i) => i.id === id);
|
||||||
const cell = matrix.get(id) ?? new Map<string, number>();
|
const cell = matrix.get(id) ?? new Map<string, number>();
|
||||||
return {
|
return {
|
||||||
@@ -68,7 +70,8 @@ async function main() {
|
|||||||
total: Array.from(cell.values()).reduce((s, v) => s + v, 0),
|
total: Array.from(cell.values()).reduce((s, v) => s + v, 0),
|
||||||
byDiet: Object.fromEntries(cell),
|
byDiet: Object.fromEntries(cell),
|
||||||
};
|
};
|
||||||
}).sort((a, b) => a.total - b.total);
|
})
|
||||||
|
.sort((a, b) => a.total - b.total);
|
||||||
|
|
||||||
const thinCells = coverageMatrix.filter((c) => c.total <= 2);
|
const thinCells = coverageMatrix.filter((c) => c.total <= 2);
|
||||||
|
|
||||||
@@ -76,7 +79,11 @@ async function main() {
|
|||||||
const slugs = recipes.map((r) => r.slug);
|
const slugs = recipes.map((r) => r.slug);
|
||||||
const uniqueSlugs = new Set(slugs);
|
const uniqueSlugs = new Set(slugs);
|
||||||
const titleWords = recipes.map((r) =>
|
const titleWords = recipes.map((r) =>
|
||||||
r.titleSv.toLowerCase().replace(/[^\w\s]/g, "").split(/\s+/).filter((w) => w.length > 3),
|
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 titleSignatures = titleWords.map((words) => words.slice(0, 4).sort().join(" "));
|
||||||
const sigCounts = new Map<string, number>();
|
const sigCounts = new Map<string, number>();
|
||||||
@@ -117,26 +124,30 @@ async function main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(publicDir, "cibello-verified-final-result.json"),
|
path.join(publicDir, `${BRAND.slug}-verified-final-result.json`),
|
||||||
JSON.stringify({ exportedAt: new Date().toISOString(), count: enriched.length, recipes: enriched }, null, 2),
|
JSON.stringify(
|
||||||
|
{ exportedAt: new Date().toISOString(), count: enriched.length, recipes: enriched },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(publicDir, "cibello-verified-final-coverage-matrix.json"),
|
path.join(publicDir, `${BRAND.slug}-verified-final-coverage-matrix.json`),
|
||||||
JSON.stringify({ exportedAt: new Date().toISOString(), coverageMatrix }, null, 2),
|
JSON.stringify({ exportedAt: new Date().toISOString(), coverageMatrix }, null, 2),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(publicDir, "cibello-verified-final-dedup-stats.json"),
|
path.join(publicDir, `${BRAND.slug}-verified-final-dedup-stats.json`),
|
||||||
JSON.stringify({ exportedAt: new Date().toISOString(), ...summary.dedupStats }, null, 2),
|
JSON.stringify({ exportedAt: new Date().toISOString(), ...summary.dedupStats }, null, 2),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(publicDir, "cibello-verified-final-summary.md"),
|
path.join(publicDir, `${BRAND.slug}-verified-final-summary.md`),
|
||||||
`# Cibello Receptkatalog – Final Verified Set
|
`# ${BRAND.name} Receptkatalog – Final Verified Set
|
||||||
|
|
||||||
- **Exporterad:** ${summary.exportedAt}
|
- **Exporterad:** ${summary.exportedAt}
|
||||||
- **Totalt godkända recept (verified + editorial):** ${summary.totalVerified}
|
- **Totalt godkända recept (verified + editorial):** ${summary.totalVerified}
|
||||||
@@ -154,7 +165,10 @@ async function main() {
|
|||||||
|
|
||||||
| Huvudingrediens | Kategori | Totalt | Fördelning |
|
| Huvudingrediens | Kategori | Totalt | Fördelning |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
${thinCells.slice(0, 15).map((c) => `| ${c.nameSv} | ${c.category} | ${c.total} | ${JSON.stringify(c.byDiet)} |`).join("\n")}
|
${thinCells
|
||||||
|
.slice(0, 15)
|
||||||
|
.map((c) => `| ${c.nameSv} | ${c.category} | ${c.total} | ${JSON.stringify(c.byDiet)} |`)
|
||||||
|
.join("\n")}
|
||||||
|
|
||||||
## Granskning
|
## Granskning
|
||||||
|
|
||||||
@@ -164,12 +178,23 @@ Setet är redo för allergen- + närings-sanity av granskare.
|
|||||||
);
|
);
|
||||||
|
|
||||||
console.error(`[export-verified] Exported ${recipes.length} verified recipes`);
|
console.error(`[export-verified] Exported ${recipes.length} verified recipes`);
|
||||||
console.error(` result.json -> ${path.join(publicDir, "cibello-verified-final-result.json")}`);
|
console.error(
|
||||||
console.error(` coverage-matrix -> ${path.join(publicDir, "cibello-verified-final-coverage-matrix.json")}`);
|
` result.json -> ${path.join(publicDir, `${BRAND.slug}-verified-final-result.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(
|
||||||
|
` 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();
|
await closeDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => { console.error(err); process.exit(1); });
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { eq, sql, inArray } from "drizzle-orm";
|
|||||||
import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation";
|
import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation";
|
||||||
import type { CanonicalIngredientLookup, SimilarityLookup } from "@app/recipe-generation";
|
import type { CanonicalIngredientLookup, SimilarityLookup } from "@app/recipe-generation";
|
||||||
import type { RecipeCandidate } from "@app/recipe-generation";
|
import type { RecipeCandidate } from "@app/recipe-generation";
|
||||||
|
import { BRAND } from "@app/shared-types";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
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";
|
||||||
@@ -128,7 +129,20 @@ const BASE_TARGETS: PipelineTarget[] = [
|
|||||||
const GROUP_MAIN_IDS: Record<string, string[]> = {
|
const GROUP_MAIN_IDS: Record<string, string[]> = {
|
||||||
"1": ["milk_3", "cream", "creme_fraiche", "chicken_breast", "chicken_thigh"],
|
"1": ["milk_3", "cream", "creme_fraiche", "chicken_breast", "chicken_thigh"],
|
||||||
"2": ["minced_beef", "minced_mixed", "salmon", "cod", "shrimp"],
|
"2": ["minced_beef", "minced_mixed", "salmon", "cod", "shrimp"],
|
||||||
"3": ["pasta_dry", "rice_white", "potato", "tofu", "red_lentils", "chickpeas_canned", "black_beans_canned", "tomato", "zucchini", "paprika", "carrot", "spinach"],
|
"3": [
|
||||||
|
"pasta_dry",
|
||||||
|
"rice_white",
|
||||||
|
"potato",
|
||||||
|
"tofu",
|
||||||
|
"red_lentils",
|
||||||
|
"chickpeas_canned",
|
||||||
|
"black_beans_canned",
|
||||||
|
"tomato",
|
||||||
|
"zucchini",
|
||||||
|
"paprika",
|
||||||
|
"carrot",
|
||||||
|
"spinach",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 2b. RUNDA 2: gap-fokuserade targets (~80 nya mål) ──────────────────────
|
// ── 2b. RUNDA 2: gap-fokuserade targets (~80 nya mål) ──────────────────────
|
||||||
@@ -156,8 +170,18 @@ const RUNDA_2_TARGETS: PipelineTarget[] = [
|
|||||||
|
|
||||||
// Svarta bönor-gap (4 st)
|
// Svarta bönor-gap (4 st)
|
||||||
{ mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegan", count: 4 },
|
{ mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegan", count: 4 },
|
||||||
{ mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "vegetarian", count: 3 },
|
{
|
||||||
{ mealType: "dinner", mainIngredientId: "black_beans_canned", dietVariant: "gluten_free", count: 2 },
|
mealType: "dinner",
|
||||||
|
mainIngredientId: "black_beans_canned",
|
||||||
|
dietVariant: "vegetarian",
|
||||||
|
count: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mealType: "dinner",
|
||||||
|
mainIngredientId: "black_beans_canned",
|
||||||
|
dietVariant: "gluten_free",
|
||||||
|
count: 2,
|
||||||
|
},
|
||||||
|
|
||||||
// Kycklinglår (6 st) – fler varianter
|
// Kycklinglår (6 st) – fler varianter
|
||||||
{ mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 4 },
|
{ mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 4 },
|
||||||
@@ -188,7 +212,8 @@ const RUNDA_2_TARGETS: PipelineTarget[] = [
|
|||||||
const targetGroup = process.env.TARGET_GROUP;
|
const targetGroup = process.env.TARGET_GROUP;
|
||||||
const isRunda2 = process.env.RUNDA === "2";
|
const isRunda2 = process.env.RUNDA === "2";
|
||||||
const SOURCE_TARGETS = isRunda2 ? RUNDA_2_TARGETS : BASE_TARGETS;
|
const SOURCE_TARGETS = isRunda2 ? RUNDA_2_TARGETS : BASE_TARGETS;
|
||||||
const ACTIVE_TARGETS = targetGroup && GROUP_MAIN_IDS[targetGroup]
|
const ACTIVE_TARGETS =
|
||||||
|
targetGroup && GROUP_MAIN_IDS[targetGroup]
|
||||||
? SOURCE_TARGETS.filter((t) => GROUP_MAIN_IDS[targetGroup]!.includes(t.mainIngredientId))
|
? SOURCE_TARGETS.filter((t) => GROUP_MAIN_IDS[targetGroup]!.includes(t.mainIngredientId))
|
||||||
: SOURCE_TARGETS;
|
: SOURCE_TARGETS;
|
||||||
|
|
||||||
@@ -197,9 +222,25 @@ const BATCH_SIZE = 5;
|
|||||||
|
|
||||||
// ── 3. Hjälpfunktioner ─────────────────────────────────────────────────────
|
// ── 3. Hjälpfunktioner ─────────────────────────────────────────────────────
|
||||||
const VALID_CUISINES = new Set([
|
const VALID_CUISINES = new Set([
|
||||||
"swedish", "nordic", "italian", "french", "spanish", "greek", "thai",
|
"swedish",
|
||||||
"chinese", "japanese", "korean", "vietnamese", "indian", "mexican",
|
"nordic",
|
||||||
"american", "turkish", "lebanese", "moroccan", "middle_eastern", "international",
|
"italian",
|
||||||
|
"french",
|
||||||
|
"spanish",
|
||||||
|
"greek",
|
||||||
|
"thai",
|
||||||
|
"chinese",
|
||||||
|
"japanese",
|
||||||
|
"korean",
|
||||||
|
"vietnamese",
|
||||||
|
"indian",
|
||||||
|
"mexican",
|
||||||
|
"american",
|
||||||
|
"turkish",
|
||||||
|
"lebanese",
|
||||||
|
"moroccan",
|
||||||
|
"middle_eastern",
|
||||||
|
"international",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function normalizeCuisine(raw: string | undefined): string {
|
function normalizeCuisine(raw: string | undefined): string {
|
||||||
@@ -245,7 +286,9 @@ function formatRecipeMarkdown(r: SeededRecipe, idx: number): string {
|
|||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`## ${idx + 1}. ${r.titleSv}`);
|
lines.push(`## ${idx + 1}. ${r.titleSv}`);
|
||||||
lines.push(`**Status:** ${r.status} | **Kök:** ${r.cuisine} | **Portioner:** ${r.portions}`);
|
lines.push(`**Status:** ${r.status} | **Kök:** ${r.cuisine} | **Portioner:** ${r.portions}`);
|
||||||
lines.push(`**Tid:** ${r.prepTimeMinutes} min prep + ${r.cookTimeMinutes} min kok = ${r.totalTimeMinutes} min`);
|
lines.push(
|
||||||
|
`**Tid:** ${r.prepTimeMinutes} min prep + ${r.cookTimeMinutes} min kok = ${r.totalTimeMinutes} min`,
|
||||||
|
);
|
||||||
lines.push(`**Allergener:** ${r.allergens.join(", ") || "inget"}`);
|
lines.push(`**Allergener:** ${r.allergens.join(", ") || "inget"}`);
|
||||||
lines.push(`**Näring/portion:** ${r.nutritionText}`);
|
lines.push(`**Näring/portion:** ${r.nutritionText}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
@@ -253,12 +296,16 @@ function formatRecipeMarkdown(r: SeededRecipe, idx: number): string {
|
|||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("### Ingredienser");
|
lines.push("### Ingredienser");
|
||||||
for (const ing of r.ingredients) {
|
for (const ing of r.ingredients) {
|
||||||
lines.push(`- ${ing.displayNameSv}: ${ing.quantity} ${ing.unit}${ing.optional ? " (valfri)" : ""}`);
|
lines.push(
|
||||||
|
`- ${ing.displayNameSv}: ${ing.quantity} ${ing.unit}${ing.optional ? " (valfri)" : ""}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("### Steg");
|
lines.push("### Steg");
|
||||||
for (const s of r.steps) {
|
for (const s of r.steps) {
|
||||||
lines.push(`${s.stepNumber}. ${s.instructionSv}${s.temperatureC ? ` (${s.temperatureC}°C)` : ""}`);
|
lines.push(
|
||||||
|
`${s.stepNumber}. ${s.instructionSv}${s.temperatureC ? ` (${s.temperatureC}°C)` : ""}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (r.flagReasons.length) {
|
if (r.flagReasons.length) {
|
||||||
lines.push("");
|
lines.push("");
|
||||||
@@ -281,7 +328,13 @@ interface SeededRecipe {
|
|||||||
totalTimeMinutes: number;
|
totalTimeMinutes: number;
|
||||||
allergens: string[];
|
allergens: string[];
|
||||||
nutritionText: string;
|
nutritionText: string;
|
||||||
ingredients: { displayNameSv: string; quantity: number; unit: string; optional: boolean; canonicalIngredientId: string }[];
|
ingredients: {
|
||||||
|
displayNameSv: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
optional: boolean;
|
||||||
|
canonicalIngredientId: string;
|
||||||
|
}[];
|
||||||
steps: { stepNumber: number; instructionSv: string; temperatureC: number | null }[];
|
steps: { stepNumber: number; instructionSv: string; temperatureC: number | null }[];
|
||||||
flagReasons: string[];
|
flagReasons: string[];
|
||||||
}
|
}
|
||||||
@@ -359,7 +412,9 @@ async function main() {
|
|||||||
for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
|
for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
|
||||||
const batchTargets = batches[batchIdx]!;
|
const batchTargets = batches[batchIdx]!;
|
||||||
const batchId = `steg2-batch-${batchIdx + 1}-${Date.now()}`;
|
const batchId = `steg2-batch-${batchIdx + 1}-${Date.now()}`;
|
||||||
console.error(`\n[scale-batch] Batch ${batchIdx + 1}/${batches.length} (${batchTargets.reduce((s, t) => s + t.count, 0)} recept)`);
|
console.error(
|
||||||
|
`\n[scale-batch] Batch ${batchIdx + 1}/${batches.length} (${batchTargets.reduce((s, t) => s + t.count, 0)} recept)`,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await runPipeline(
|
const result = await runPipeline(
|
||||||
client,
|
client,
|
||||||
@@ -377,7 +432,9 @@ async function main() {
|
|||||||
totalCost += result.geminiResult.costUsd ?? 0;
|
totalCost += result.geminiResult.costUsd ?? 0;
|
||||||
|
|
||||||
if (result.geminiResult.status !== "ok" || result.candidates.length === 0) {
|
if (result.geminiResult.status !== "ok" || result.candidates.length === 0) {
|
||||||
console.error(`[scale-batch] Batch ${batchIdx + 1} gav inga kandidater: ${result.geminiResult.error ?? "ok utan output"}`);
|
console.error(
|
||||||
|
`[scale-batch] Batch ${batchIdx + 1} gav inga kandidater: ${result.geminiResult.error ?? "ok utan output"}`,
|
||||||
|
);
|
||||||
batchResults.push({
|
batchResults.push({
|
||||||
batchId,
|
batchId,
|
||||||
targets: batchTargets,
|
targets: batchTargets,
|
||||||
@@ -419,8 +476,14 @@ async function main() {
|
|||||||
|
|
||||||
const recipeId = randomUUID();
|
const recipeId = randomUUID();
|
||||||
const nutrition = v.nutritionPerPortion ?? {
|
const nutrition = v.nutritionPerPortion ?? {
|
||||||
kcal: 0, proteinG: 0, carbsG: 0, fatG: 0,
|
kcal: 0,
|
||||||
saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0,
|
proteinG: 0,
|
||||||
|
carbsG: 0,
|
||||||
|
fatG: 0,
|
||||||
|
saturatedFatG: 0,
|
||||||
|
fiberG: 0,
|
||||||
|
sugarG: 0,
|
||||||
|
saltG: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -448,13 +511,45 @@ async function main() {
|
|||||||
cuisine: normalizeCuisine(candidate.cuisine) as "swedish",
|
cuisine: normalizeCuisine(candidate.cuisine) as "swedish",
|
||||||
protein: candidate.ingredients.find((i) => {
|
protein: candidate.ingredients.find((i) => {
|
||||||
const ing = ingredientLookup.getById(i.canonicalIngredientId);
|
const ing = ingredientLookup.getById(i.canonicalIngredientId);
|
||||||
return ing && (ing.isBeef || ing.isPork || ["chicken_breast", "chicken_thigh", "salmon", "cod", "shrimp", "tofu", "red_lentils", "chickpeas_canned", "black_beans_canned", "minced_beef", "minced_mixed"].includes(i.canonicalIngredientId));
|
return (
|
||||||
|
ing &&
|
||||||
|
(ing.isBeef ||
|
||||||
|
ing.isPork ||
|
||||||
|
[
|
||||||
|
"chicken_breast",
|
||||||
|
"chicken_thigh",
|
||||||
|
"salmon",
|
||||||
|
"cod",
|
||||||
|
"shrimp",
|
||||||
|
"tofu",
|
||||||
|
"red_lentils",
|
||||||
|
"chickpeas_canned",
|
||||||
|
"black_beans_canned",
|
||||||
|
"minced_beef",
|
||||||
|
"minced_mixed",
|
||||||
|
].includes(i.canonicalIngredientId))
|
||||||
|
);
|
||||||
})?.canonicalIngredientId,
|
})?.canonicalIngredientId,
|
||||||
carbohydrate: candidate.ingredients.find((i) => ["rice_white", "pasta_dry", "potato"].includes(i.canonicalIngredientId))?.canonicalIngredientId,
|
carbohydrate: candidate.ingredients.find((i) =>
|
||||||
vegetables: candidate.ingredients.filter((i) => {
|
["rice_white", "pasta_dry", "potato"].includes(i.canonicalIngredientId),
|
||||||
|
)?.canonicalIngredientId,
|
||||||
|
vegetables: candidate.ingredients
|
||||||
|
.filter((i) => {
|
||||||
const ing = ingredientLookup.getById(i.canonicalIngredientId);
|
const ing = ingredientLookup.getById(i.canonicalIngredientId);
|
||||||
return ing?.category === "gronsaker" || ["tomato", "zucchini", "paprika", "carrot", "spinach", "onion", "garlic"].includes(i.canonicalIngredientId);
|
return (
|
||||||
}).map((i) => i.canonicalIngredientId),
|
ing?.category === "gronsaker" ||
|
||||||
|
[
|
||||||
|
"tomato",
|
||||||
|
"zucchini",
|
||||||
|
"paprika",
|
||||||
|
"carrot",
|
||||||
|
"spinach",
|
||||||
|
"onion",
|
||||||
|
"garlic",
|
||||||
|
].includes(i.canonicalIngredientId)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((i) => i.canonicalIngredientId),
|
||||||
flavorProfile: inferDietTags(candidate),
|
flavorProfile: inferDietTags(candidate),
|
||||||
spiceLevel: candidate.spiceLevel,
|
spiceLevel: candidate.spiceLevel,
|
||||||
method: "stovetop",
|
method: "stovetop",
|
||||||
@@ -466,7 +561,7 @@ async function main() {
|
|||||||
status: "draft",
|
status: "draft",
|
||||||
verificationStatus: "verified",
|
verificationStatus: "verified",
|
||||||
sourceType: "ai_assisted_reviewed",
|
sourceType: "ai_assisted_reviewed",
|
||||||
creatorDisplayName: "Cibello AI",
|
creatorDisplayName: `${BRAND.name} AI`,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.message?.includes("recipes_slug_unique") || err?.code === "23505") {
|
if (err?.message?.includes("recipes_slug_unique") || err?.code === "23505") {
|
||||||
@@ -559,25 +654,36 @@ async function main() {
|
|||||||
for (let i = 0; i < result.candidates.length; i++) {
|
for (let i = 0; i < result.candidates.length; i++) {
|
||||||
const v = result.verificationResults[i]!;
|
const v = result.verificationResults[i]!;
|
||||||
if (v.status === "unverified" && v.reasons.length > 0) {
|
if (v.status === "unverified" && v.reasons.length > 0) {
|
||||||
console.error(`[scale-batch] unverified: "${result.candidates[i]!.titleSv}" => ${v.reasons.join("; ")}`);
|
console.error(
|
||||||
|
`[scale-batch] unverified: "${result.candidates[i]!.titleSv}" => ${v.reasons.join("; ")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(`[scale-batch] Batch ${batchIdx + 1} klar: gen=${result.candidates.length}, ver=${result.verifiedCount}, unv=${result.unverifiedCount}, rej=${result.rejectedCount}, dup=${batchDuplicate}, seeded=${batchSeeded}, cost=$${(result.geminiResult.costUsd ?? 0).toFixed(4)}`);
|
console.error(
|
||||||
|
`[scale-batch] Batch ${batchIdx + 1} klar: gen=${result.candidates.length}, ver=${result.verifiedCount}, unv=${result.unverifiedCount}, rej=${result.rejectedCount}, dup=${batchDuplicate}, seeded=${batchSeeded}, cost=$${(result.geminiResult.costUsd ?? 0).toFixed(4)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 5. Spara stickprov ────────────────────────────────────────────────────
|
// ── 5. Spara stickprov ────────────────────────────────────────────────────
|
||||||
const publicDir = "/mnt/c/Users/Public";
|
const publicDir = "/mnt/c/Users/Public";
|
||||||
const sampleRecipes = shuffle(allSeeded).slice(0, Math.max(1, Math.round(allSeeded.length * 0.15)));
|
const sampleRecipes = shuffle(allSeeded).slice(
|
||||||
|
0,
|
||||||
|
Math.max(1, Math.round(allSeeded.length * 0.15)),
|
||||||
|
);
|
||||||
const sampleMarkdown = [
|
const sampleMarkdown = [
|
||||||
"# Cibello STEG 2 – Stickprov för smakkoll",
|
`# ${BRAND.name} STEG 2 – Stickprov för smakkoll`,
|
||||||
`Genererad: ${new Date().toISOString()}`,
|
`Genererad: ${new Date().toISOString()}`,
|
||||||
`Totalt seedade: ${allSeeded.length}`,
|
`Totalt seedade: ${allSeeded.length}`,
|
||||||
`Stickprov: ${sampleRecipes.length}`,
|
`Stickprov: ${sampleRecipes.length}`,
|
||||||
"",
|
"",
|
||||||
...sampleRecipes.map((r, i) => formatRecipeMarkdown(r, i)),
|
...sampleRecipes.map((r, i) => formatRecipeMarkdown(r, i)),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
await fs.writeFile(path.join(publicDir, "cibello-steg2-sample.md"), sampleMarkdown, "utf-8");
|
await fs.writeFile(
|
||||||
|
path.join(publicDir, `${BRAND.slug}-steg2-sample.md`),
|
||||||
|
sampleMarkdown,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
// ── 6. Slutrapport ────────────────────────────────────────────────────────
|
// ── 6. Slutrapport ────────────────────────────────────────────────────────
|
||||||
const report = {
|
const report = {
|
||||||
@@ -629,7 +735,9 @@ async function main() {
|
|||||||
console.error(`- Dubbletter: ${totalDuplicate}`);
|
console.error(`- Dubbletter: ${totalDuplicate}`);
|
||||||
console.error(`- Seedade i DB: ${totalSeeded}`);
|
console.error(`- Seedade i DB: ${totalSeeded}`);
|
||||||
console.error(`- Total Gemini-kostnad: $${totalCost.toFixed(4)}`);
|
console.error(`- Total Gemini-kostnad: $${totalCost.toFixed(4)}`);
|
||||||
console.error(`- Stickprov: ${sampleRecipes.length} recept → ${publicDir}\\cibello-steg2-sample.md`);
|
console.error(
|
||||||
|
`- Stickprov: ${sampleRecipes.length} recept → ${publicDir}\\cibello-steg2-sample.md`,
|
||||||
|
);
|
||||||
console.error(`- Rapport: ${publicDir}\\cibello-steg2-report.json`);
|
console.error(`- Rapport: ${publicDir}\\cibello-steg2-report.json`);
|
||||||
|
|
||||||
await closeDatabase();
|
await closeDatabase();
|
||||||
@@ -638,7 +746,9 @@ async function main() {
|
|||||||
function inferDietTags(candidate: RecipeCandidate): string[] {
|
function inferDietTags(candidate: RecipeCandidate): string[] {
|
||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
const ingIds = new Set(candidate.ingredients.map((i) => i.canonicalIngredientId));
|
const ingIds = new Set(candidate.ingredients.map((i) => i.canonicalIngredientId));
|
||||||
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 allVegan = ings.every((i) => i?.isVegan);
|
const allVegan = ings.every((i) => i?.isVegan);
|
||||||
const allVegetarian = ings.every((i) => i?.isVegetarian);
|
const allVegetarian = ings.every((i) => i?.isVegetarian);
|
||||||
const noGluten = ings.every((i) => !i?.containsGluten);
|
const noGluten = ings.every((i) => !i?.containsGluten);
|
||||||
@@ -655,7 +765,16 @@ function flagRecipe(candidate: RecipeCandidate, nutrition: { kcal: number }): st
|
|||||||
if (nutrition.kcal > 1000) reasons.push("hög kalorihalt");
|
if (nutrition.kcal > 1000) reasons.push("hög kalorihalt");
|
||||||
if (nutrition.kcal < 150) reasons.push("låg kalorihalt");
|
if (nutrition.kcal < 150) reasons.push("låg kalorihalt");
|
||||||
if (candidate.ingredients.length < 4) reasons.push("få ingredienser");
|
if (candidate.ingredients.length < 4) reasons.push("få ingredienser");
|
||||||
const riskyAllergens = ["peanuts", "tree_nuts", "shellfish", "crustaceans", "fish", "milk", "gluten", "eggs"];
|
const riskyAllergens = [
|
||||||
|
"peanuts",
|
||||||
|
"tree_nuts",
|
||||||
|
"shellfish",
|
||||||
|
"crustaceans",
|
||||||
|
"fish",
|
||||||
|
"milk",
|
||||||
|
"gluten",
|
||||||
|
"eggs",
|
||||||
|
];
|
||||||
const ingIds = new Set(candidate.ingredients.map((i) => i.canonicalIngredientId));
|
const ingIds = new Set(candidate.ingredients.map((i) => i.canonicalIngredientId));
|
||||||
for (const id of ingIds) {
|
for (const id of ingIds) {
|
||||||
const ing = ingredientLookup.getById(id);
|
const ing = ingredientLookup.getById(id);
|
||||||
@@ -673,11 +792,15 @@ function buildCoverageMatrix(seeded: SeededRecipe[]) {
|
|||||||
|
|
||||||
for (const r of seeded) {
|
for (const r of seeded) {
|
||||||
// Hitta den mest sannolika huvudingrediensen: första ingrediensen som är en target-huvudingrediens
|
// Hitta den mest sannolika huvudingrediensen: första ingrediensen som är en target-huvudingrediens
|
||||||
const mainId = r.ingredients.find((i) => mainIngredientIds.has(i.canonicalIngredientId))?.canonicalIngredientId;
|
const mainId = r.ingredients.find((i) =>
|
||||||
|
mainIngredientIds.has(i.canonicalIngredientId),
|
||||||
|
)?.canonicalIngredientId;
|
||||||
if (!mainId) continue;
|
if (!mainId) continue;
|
||||||
|
|
||||||
// Härled dietvariant från ingredienserna
|
// Härled dietvariant från ingredienserna
|
||||||
const ings = r.ingredients.map((i) => ingredientLookup.getById(i.canonicalIngredientId)).filter(Boolean);
|
const ings = r.ingredients
|
||||||
|
.map((i) => ingredientLookup.getById(i.canonicalIngredientId))
|
||||||
|
.filter(Boolean);
|
||||||
const isVegan = ings.every((i) => i?.isVegan);
|
const isVegan = ings.every((i) => i?.isVegan);
|
||||||
const isVegetarian = ings.every((i) => i?.isVegetarian);
|
const isVegetarian = ings.every((i) => i?.isVegetarian);
|
||||||
const isGlutenFree = ings.every((i) => !i?.containsGluten);
|
const isGlutenFree = ings.every((i) => !i?.containsGluten);
|
||||||
|
|||||||
@@ -2,13 +2,34 @@ import { createAamosClient } from "@app/ai-contracts";
|
|||||||
import { SEED_INGREDIENTS } from "@app/database/seed";
|
import { SEED_INGREDIENTS } from "@app/database/seed";
|
||||||
import { createDatabase, closeDatabase, schema } from "@app/database";
|
import { createDatabase, closeDatabase, schema } from "@app/database";
|
||||||
import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation";
|
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";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
const VALID_CUISINES = new Set([
|
const VALID_CUISINES = new Set([
|
||||||
"swedish", "nordic", "italian", "french", "spanish", "greek", "thai",
|
"swedish",
|
||||||
"chinese", "japanese", "korean", "vietnamese", "indian", "mexican",
|
"nordic",
|
||||||
"american", "turkish", "lebanese", "moroccan", "middle_eastern", "international",
|
"italian",
|
||||||
|
"french",
|
||||||
|
"spanish",
|
||||||
|
"greek",
|
||||||
|
"thai",
|
||||||
|
"chinese",
|
||||||
|
"japanese",
|
||||||
|
"korean",
|
||||||
|
"vietnamese",
|
||||||
|
"indian",
|
||||||
|
"mexican",
|
||||||
|
"american",
|
||||||
|
"turkish",
|
||||||
|
"lebanese",
|
||||||
|
"moroccan",
|
||||||
|
"middle_eastern",
|
||||||
|
"international",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function normalizeCuisine(raw: string | undefined): string {
|
function normalizeCuisine(raw: string | undefined): string {
|
||||||
@@ -69,7 +90,9 @@ const targets: PipelineTarget[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function inferDietTags(candidate: RecipeCandidate): string[] {
|
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 tags: string[] = [];
|
||||||
const allVegan = ings.every((i) => i?.isVegan);
|
const allVegan = ings.every((i) => i?.isVegan);
|
||||||
const allVegetarian = ings.every((i) => i?.isVegetarian);
|
const allVegetarian = ings.every((i) => i?.isVegetarian);
|
||||||
@@ -89,7 +112,9 @@ async function main() {
|
|||||||
const client = createAamosClient(process.env);
|
const client = createAamosClient(process.env);
|
||||||
|
|
||||||
const existing = await db.query.recipes.findMany({ columns: { titleSv: true, slug: true } });
|
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 knownSlugs = new Set(existing.map((r) => r.slug));
|
||||||
|
|
||||||
const similarityLookup: SimilarityLookup = {
|
const similarityLookup: SimilarityLookup = {
|
||||||
@@ -99,16 +124,15 @@ async function main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
console.error("[scale-smoke-test] starting");
|
console.error("[scale-smoke-test] starting");
|
||||||
const result = await runPipeline(
|
const result = await runPipeline(client, targets, catalog, ingredientLookup, similarityLookup, {
|
||||||
client,
|
maxPrepTimeMinutes: 30,
|
||||||
targets,
|
maxCookTimeMinutes: 45,
|
||||||
catalog,
|
portions: 4,
|
||||||
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;
|
let seeded = 0;
|
||||||
for (let i = 0; i < result.candidates.length; i++) {
|
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)}`;
|
if (knownSlugs.has(slug)) slug = `${slug}-${randomUUID().slice(0, 8)}`;
|
||||||
knownSlugs.add(slug);
|
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({
|
await db.insert(schema.recipes).values({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -150,9 +183,29 @@ async function main() {
|
|||||||
freezerFriendly: candidate.freezerFriendly,
|
freezerFriendly: candidate.freezerFriendly,
|
||||||
dna: {
|
dna: {
|
||||||
cuisine: normalizeCuisine(candidate.cuisine),
|
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,
|
protein: candidate.ingredients.find((i) =>
|
||||||
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),
|
"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),
|
flavorProfile: inferDietTags(candidate),
|
||||||
spiceLevel: candidate.spiceLevel,
|
spiceLevel: candidate.spiceLevel,
|
||||||
method: "stovetop",
|
method: "stovetop",
|
||||||
@@ -164,7 +217,7 @@ async function main() {
|
|||||||
status: "draft",
|
status: "draft",
|
||||||
verificationStatus: "verified",
|
verificationStatus: "verified",
|
||||||
sourceType: "ai_assisted_reviewed",
|
sourceType: "ai_assisted_reviewed",
|
||||||
creatorDisplayName: "Cibello AI Smoke",
|
creatorDisplayName: `${BRAND.name} AI Smoke`,
|
||||||
});
|
});
|
||||||
seeded++;
|
seeded++;
|
||||||
}
|
}
|
||||||
@@ -173,4 +226,7 @@ async function main() {
|
|||||||
await closeDatabase();
|
await closeDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => { console.error(err); process.exit(1); });
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -135,3 +135,79 @@ export const NON_PERSONALIZED_WEIGHTS: ScoringWeights = {
|
|||||||
tasteFit: 0,
|
tasteFit: 0,
|
||||||
cookingAssumptionFit: 0,
|
cookingAssumptionFit: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Tillåtna vyer för "Vad ska vi äta?". */
|
||||||
|
export type RecommendationView = "default" | "taste" | "health" | "pantry";
|
||||||
|
|
||||||
|
export const RECOMMENDATION_VIEWS: RecommendationView[] = ["default", "taste", "health", "pantry"];
|
||||||
|
|
||||||
|
/** Fördefinierade vikter för smak-vyn (S4). */
|
||||||
|
export const TASTE_VIEW_WEIGHTS: ScoringWeights = {
|
||||||
|
coverage: 20,
|
||||||
|
expiry: 10,
|
||||||
|
nutritionFit: 5,
|
||||||
|
taste: 25,
|
||||||
|
rating: 10,
|
||||||
|
season: 3,
|
||||||
|
holiday: 3,
|
||||||
|
time: 5,
|
||||||
|
budget: 3,
|
||||||
|
variety: 3,
|
||||||
|
weather: 1,
|
||||||
|
craving: 25,
|
||||||
|
memoryFit: 12,
|
||||||
|
tasteFit: 12,
|
||||||
|
cookingAssumptionFit: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fördefinierade vikter för hälsa-vyn (S4) — stödjande, icke-restriktiv (R7). */
|
||||||
|
export const HEALTH_VIEW_WEIGHTS: ScoringWeights = {
|
||||||
|
coverage: 20,
|
||||||
|
expiry: 12,
|
||||||
|
nutritionFit: 35,
|
||||||
|
taste: 5,
|
||||||
|
rating: 6,
|
||||||
|
season: 4,
|
||||||
|
holiday: 4,
|
||||||
|
time: 6,
|
||||||
|
budget: 3,
|
||||||
|
variety: 4,
|
||||||
|
weather: 2,
|
||||||
|
craving: 5,
|
||||||
|
memoryFit: 3,
|
||||||
|
tasteFit: 3,
|
||||||
|
cookingAssumptionFit: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fördefinierade vikter för lager-vyn (S4). */
|
||||||
|
export const PANTRY_VIEW_WEIGHTS: ScoringWeights = {
|
||||||
|
coverage: 45,
|
||||||
|
expiry: 35,
|
||||||
|
nutritionFit: 5,
|
||||||
|
taste: 4,
|
||||||
|
rating: 4,
|
||||||
|
season: 2,
|
||||||
|
holiday: 2,
|
||||||
|
time: 6,
|
||||||
|
budget: 4,
|
||||||
|
variety: 2,
|
||||||
|
weather: 1,
|
||||||
|
craving: 5,
|
||||||
|
memoryFit: 4,
|
||||||
|
tasteFit: 4,
|
||||||
|
cookingAssumptionFit: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hämta vikter för en specifik vy. */
|
||||||
|
export function viewWeights(view: RecommendationView): ScoringWeights {
|
||||||
|
switch (view) {
|
||||||
|
case "taste":
|
||||||
|
return TASTE_VIEW_WEIGHTS;
|
||||||
|
case "health":
|
||||||
|
return HEALTH_VIEW_WEIGHTS;
|
||||||
|
case "pantry":
|
||||||
|
return PANTRY_VIEW_WEIGHTS;
|
||||||
|
default:
|
||||||
|
return DEFAULT_WEIGHTS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import {
|
|||||||
containsForbiddenCopy,
|
containsForbiddenCopy,
|
||||||
DEFAULT_WEIGHTS,
|
DEFAULT_WEIGHTS,
|
||||||
easterSunday,
|
easterSunday,
|
||||||
|
HEALTH_VIEW_WEIGHTS,
|
||||||
isEventActive,
|
isEventActive,
|
||||||
midsummerEve,
|
midsummerEve,
|
||||||
NON_PERSONALIZED_WEIGHTS,
|
NON_PERSONALIZED_WEIGHTS,
|
||||||
parseCraving,
|
parseCraving,
|
||||||
|
PANTRY_VIEW_WEIGHTS,
|
||||||
rankAll,
|
rankAll,
|
||||||
renderProvenance,
|
renderProvenance,
|
||||||
scoreCandidate,
|
scoreCandidate,
|
||||||
seasonForDate,
|
seasonForDate,
|
||||||
|
TASTE_VIEW_WEIGHTS,
|
||||||
|
viewWeights,
|
||||||
type CookingAssumption,
|
type CookingAssumption,
|
||||||
type RecommendationCandidate,
|
type RecommendationCandidate,
|
||||||
type RecommendationContext,
|
type RecommendationContext,
|
||||||
@@ -329,3 +333,74 @@ describe("S1 personalisering", () => {
|
|||||||
expect(NON_PERSONALIZED_WEIGHTS.coverage).toBe(DEFAULT_WEIGHTS.coverage);
|
expect(NON_PERSONALIZED_WEIGHTS.coverage).toBe(DEFAULT_WEIGHTS.coverage);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("S4 rekommendationsvyer", () => {
|
||||||
|
const viewCtx: RecommendationContext = {
|
||||||
|
...ctx,
|
||||||
|
personalizationEnabled: true,
|
||||||
|
memoryItems: [],
|
||||||
|
tasteSignals: [],
|
||||||
|
cookingAssumptions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const tasty = candidate({
|
||||||
|
recipeId: "tasty",
|
||||||
|
titleSv: "Svensk köttbullsgryta",
|
||||||
|
cuisine: "swedish",
|
||||||
|
coverage: fullCoverage,
|
||||||
|
nutritionPerPortion: { ...nutrition, proteinG: 20 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pantry = candidate({
|
||||||
|
recipeId: "pantry",
|
||||||
|
titleSv: "Italiensk pastarätt",
|
||||||
|
cuisine: "italian",
|
||||||
|
coverage: {
|
||||||
|
...fullCoverage,
|
||||||
|
expiringUsed: [
|
||||||
|
{
|
||||||
|
canonicalIngredientId: "pasta",
|
||||||
|
displayNameSv: "pastan",
|
||||||
|
required: 200,
|
||||||
|
unit: "GRAM",
|
||||||
|
availableInUnit: 250,
|
||||||
|
covered: true,
|
||||||
|
optional: false,
|
||||||
|
mostUrgentDaysLeft: 1,
|
||||||
|
usesExpiringItem: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
nutritionPerPortion: { ...nutrition, proteinG: 15 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const healthy = candidate({
|
||||||
|
recipeId: "healthy",
|
||||||
|
titleSv: "Kyckling och quinoa",
|
||||||
|
cuisine: "greek",
|
||||||
|
coverage: fullCoverage,
|
||||||
|
nutritionPerPortion: { ...nutrition, proteinG: 50 },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("smak-vyn höjer recept i favoritkök", () => {
|
||||||
|
const ranked = rankAll([pantry, healthy, tasty], viewCtx, TASTE_VIEW_WEIGHTS);
|
||||||
|
expect(ranked[0]?.recipeId).toBe("tasty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lager-vyn höjer recept som räddar varor nära utgångsdatum", () => {
|
||||||
|
const ranked = rankAll([tasty, healthy, pantry], viewCtx, PANTRY_VIEW_WEIGHTS);
|
||||||
|
expect(ranked[0]?.recipeId).toBe("pantry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hälsa-vyn höjer recept som matchar näringsmål", () => {
|
||||||
|
const ranked = rankAll([tasty, pantry, healthy], viewCtx, HEALTH_VIEW_WEIGHTS);
|
||||||
|
expect(ranked[0]?.recipeId).toBe("healthy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("viewWeights returnerar rätt vikter för varje vy", () => {
|
||||||
|
expect(viewWeights("default").taste).toBe(DEFAULT_WEIGHTS.taste);
|
||||||
|
expect(viewWeights("taste").taste).toBe(TASTE_VIEW_WEIGHTS.taste);
|
||||||
|
expect(viewWeights("health").nutritionFit).toBe(HEALTH_VIEW_WEIGHTS.nutritionFit);
|
||||||
|
expect(viewWeights("pantry").coverage).toBe(PANTRY_VIEW_WEIGHTS.coverage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ export const whatToEatQuerySchema = z.object({
|
|||||||
craving: z.string().max(300).optional(),
|
craving: z.string().max(300).optional(),
|
||||||
includeLeftovers: z.coerce.boolean().default(true),
|
includeLeftovers: z.coerce.boolean().default(true),
|
||||||
limit: z.coerce.number().int().min(1).max(20).default(5),
|
limit: z.coerce.number().int().min(1).max(20).default(5),
|
||||||
|
/** Personaliseringsvy: smak, hälsa eller lager. Kräver personalization-samtycke. */
|
||||||
|
view: z.enum(["default", "taste", "health", "pantry"]).default("default"),
|
||||||
});
|
});
|
||||||
export type WhatToEatQuery = z.infer<typeof whatToEatQuerySchema>;
|
export type WhatToEatQuery = z.infer<typeof whatToEatQuerySchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user