158 lines
6.5 KiB
TypeScript
158 lines
6.5 KiB
TypeScript
#!/usr/bin/env tsx
|
||
/**
|
||
* PILOTBATCH – Receptkatalog Fas A (docs/32).
|
||
*
|
||
* Genererar ~20–30 recept över en skiva av matrisen:
|
||
* middag × mejeri/kyckling/köttfärs × allätare+veg
|
||
*
|
||
* Använder Gemini i staging/dev (AAMOS_MODE=gemini) eller mock (AAMOS_MODE=mock).
|
||
* Ingen påfyllning i prod. Resultatet skrivs till stdout som JSON + markdown.
|
||
*
|
||
* Körning:
|
||
* AAMOS_MODE=mock pnpm tsx packages/recipe-generation/scripts/pilot-batch.ts
|
||
* AAMOS_MODE=gemini GEMINI_API_KEY=... pnpm tsx packages/recipe-generation/scripts/pilot-batch.ts
|
||
*/
|
||
|
||
import { createAamosClient } from "@app/ai-contracts";
|
||
import { SEED_INGREDIENTS } from "@app/database/seed";
|
||
import { runPipeline, type PipelineTarget, type PipelineIngredient } from "@app/recipe-generation";
|
||
import type { CanonicalIngredientLookup, SimilarityLookup } from "@app/recipe-generation";
|
||
|
||
// ── 1. Bygg katalog från seed-data ─────────────────────────────────────────
|
||
const catalog: PipelineIngredient[] = SEED_INGREDIENTS.map((i) => ({
|
||
id: i.id,
|
||
nameSv: i.nameSv,
|
||
category: i.category,
|
||
defaultUnit: i.defaultUnit,
|
||
isVegan: i.isVegan,
|
||
isVegetarian: i.isVegetarian,
|
||
containsGluten: i.containsGluten,
|
||
containsLactose: i.containsLactose,
|
||
allergens: i.allergens,
|
||
}));
|
||
|
||
// ── 2. Bygg lookup för verifiering ─────────────────────────────────────────
|
||
const ingredientLookup: CanonicalIngredientLookup = {
|
||
getById(id: string) {
|
||
const ing = SEED_INGREDIENTS.find((i) => i.id === id);
|
||
if (!ing) return undefined;
|
||
return {
|
||
id: ing.id,
|
||
nutritionPer100: ing.nutritionPer100,
|
||
defaultUnit: ing.defaultUnit,
|
||
densityGPerMl: ing.densityGPerMl ?? null,
|
||
gramsPerPiece: ing.gramsPerPiece ?? null,
|
||
allergens: ing.allergens,
|
||
isVegan: ing.isVegan,
|
||
isVegetarian: ing.isVegetarian,
|
||
containsGluten: ing.containsGluten,
|
||
containsLactose: ing.containsLactose,
|
||
isPork: ing.isPork,
|
||
isBeef: ing.isBeef,
|
||
isAlcohol: ing.isAlcohol,
|
||
};
|
||
},
|
||
};
|
||
|
||
// ── 3. Dedup-lookup (enkel, baserad på titel) ──────────────────────────────
|
||
const knownTitles = new Set<string>();
|
||
const similarityLookup: SimilarityLookup = {
|
||
async hasSimilarity(title: string) {
|
||
if (knownTitles.has(title.toLowerCase())) return true;
|
||
knownTitles.add(title.toLowerCase());
|
||
return false;
|
||
},
|
||
};
|
||
|
||
// ── 4. Definiera pilot-matris ──────────────────────────────────────────────
|
||
const targets: PipelineTarget[] = [
|
||
// Mejeri × allätare
|
||
{ mealType: "dinner", mainIngredientId: "milk_3", dietVariant: "standard", count: 3 },
|
||
{ mealType: "dinner", mainIngredientId: "cream", dietVariant: "standard", count: 2 },
|
||
// Kyckling × allätare
|
||
{ mealType: "dinner", mainIngredientId: "chicken_breast", dietVariant: "standard", count: 4 },
|
||
{ mealType: "dinner", mainIngredientId: "chicken_thigh", dietVariant: "standard", count: 2 },
|
||
// Köttfärs × allätare
|
||
{ mealType: "dinner", mainIngredientId: "minced_beef", dietVariant: "standard", count: 3 },
|
||
{ mealType: "dinner", mainIngredientId: "minced_mixed", dietVariant: "standard", count: 2 },
|
||
// Veg-varianter
|
||
{ mealType: "dinner", mainIngredientId: "tofu", dietVariant: "vegan", count: 3 },
|
||
{ mealType: "dinner", mainIngredientId: "red_lentils", dietVariant: "vegetarian", count: 2 },
|
||
{ mealType: "dinner", mainIngredientId: "chickpeas_canned", dietVariant: "vegan", count: 2 },
|
||
];
|
||
|
||
// ── 5. Kör pipelinen ───────────────────────────────────────────────────────
|
||
async function main() {
|
||
const client = createAamosClient(process.env);
|
||
console.error("[pilot-batch] Startar generering...");
|
||
console.error(`[pilot-batch] Katalog: ${catalog.length} ingredienser`);
|
||
console.error(`[pilot-batch] Mål: ${targets.length} matris-celler, ~${targets.reduce((s, t) => s + t.count, 0)} recept`);
|
||
|
||
const result = await runPipeline(
|
||
client,
|
||
targets,
|
||
catalog,
|
||
ingredientLookup,
|
||
similarityLookup,
|
||
{ maxPrepTimeMinutes: 30, maxCookTimeMinutes: 45, portions: 4 },
|
||
);
|
||
|
||
// ── 6. Rapportera ────────────────────────────────────────────────────────
|
||
const report = {
|
||
batchId: `pilot-${Date.now()}`,
|
||
generatedAt: new Date().toISOString(),
|
||
targetMatrix: targets,
|
||
geminiStatus: result.geminiResult.status,
|
||
geminiCostUsd: result.geminiResult.costUsd ?? 0,
|
||
candidatesGenerated: result.candidates.length,
|
||
verifiedCount: result.verifiedCount,
|
||
unverifiedCount: result.unverifiedCount,
|
||
rejectedCount: result.rejectedCount,
|
||
candidates: result.verificationResults.map((r) => ({
|
||
title: r.candidate.titleSv,
|
||
descriptionSv: r.candidate.descriptionSv,
|
||
status: r.status,
|
||
reasons: r.reasons,
|
||
allergens: r.allergens,
|
||
nutritionPerPortion: r.nutritionPerPortion,
|
||
ingredients: r.candidate.ingredients.map((i) => ({
|
||
id: i.canonicalIngredientId,
|
||
name: i.displayNameSv,
|
||
quantity: i.quantity,
|
||
unit: i.unit,
|
||
optional: i.optional,
|
||
})),
|
||
steps: r.candidate.steps.map((s, idx) => ({
|
||
number: idx + 1,
|
||
instruction: s.instructionSv,
|
||
durationMinutes: s.durationMinutes ?? null,
|
||
})),
|
||
})),
|
||
};
|
||
|
||
console.log(JSON.stringify(report, null, 2));
|
||
|
||
// Markdown-sammanfattning till stderr
|
||
console.error("\n# Pilotbatch-sammanfattning\n");
|
||
console.error(`- Genererade kandidater: ${result.candidates.length}`);
|
||
console.error(`- Verified: ${result.verifiedCount}`);
|
||
console.error(`- Unverified: ${result.unverifiedCount}`);
|
||
console.error(`- Rejected: ${result.rejectedCount}`);
|
||
console.error(`- Gemini-kostnad: $${(result.geminiResult.costUsd ?? 0).toFixed(4)}`);
|
||
|
||
if (result.geminiResult.error) {
|
||
console.error(`\n**Fel:** ${result.geminiResult.error}`);
|
||
}
|
||
|
||
if (result.candidates.length === 0 && result.geminiResult.status === "ok") {
|
||
console.error("\n**Notering:** Gemini returnerade ok men inga kandidater. " +
|
||
"Mock-klienten stödjer inte GENERATE_RECIPE_CANDIDATES ännu — " +
|
||
"kör med AAMOS_MODE=gemini för live-generering.");
|
||
}
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error("[pilot-batch] Fatal:", err);
|
||
process.exit(1);
|
||
});
|