feat(recipe-generation): Cibello Receptkatalog Fas A – STEG 1

- Nytt paket @app/recipe-generation med pipeline, verifieringsgrind,
  gaprapport och pilotbatch-script.
- Ny AAMOS-task GENERATE_RECIPE_CANDIDATES i @app/ai-contracts.
- GeminiAamosClient utökat med receptgenereringsstöd.
- MockAamosClient uppdaterad med deterministiska kandidater.
- Verifieringspipeline: ingrediensmappning (hård spärr), härledda allergener,
  beräknad näring via nutrition-engine, språk-/kvalitetskontroll, dedup.
- Hermetiska tester: 10/10 gröna.
- typecheck + test + build: gröna för hela workspace.

Refs: docs/32-receptkatalog-buildout.md
This commit is contained in:
Sven (AAMOS AI)
2026-08-09 19:47:03 +07:00
parent 9197d93a92
commit 4b7dec2759
15 changed files with 1554 additions and 0 deletions
@@ -0,0 +1,145 @@
#!/usr/bin/env tsx
/**
* PILOTBATCH Receptkatalog Fas A (docs/32).
*
* Genererar ~2030 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: "ground_beef", dietVariant: "standard", count: 3 },
{ mealType: "dinner", mainIngredientId: "ground_pork", dietVariant: "standard", count: 2 },
// Veg-varianter
{ mealType: "dinner", mainIngredientId: "tofu_firm", dietVariant: "vegan", count: 3 },
{ mealType: "dinner", mainIngredientId: "lentils_red", dietVariant: "vegetarian", count: 2 },
{ mealType: "dinner", mainIngredientId: "chickpeas", 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,
status: r.status,
reasons: r.reasons,
allergens: r.allergens,
nutritionPerPortion: r.nutritionPerPortion,
ingredients: r.candidate.ingredients.map((i) => i.canonicalIngredientId),
})),
};
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);
});