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:
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildGapReport, formatGapReport } from "../src/gap-report.js";
|
||||
import type { AnalyticsEvent, RecipeSignal } from "../src/gap-report.js";
|
||||
|
||||
describe("buildGapReport", () => {
|
||||
it("identifierar topp-saknade ingredienser", () => {
|
||||
const events: AnalyticsEvent[] = [
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } },
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } },
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax", suggestedIngredientId: "laxfile" } },
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "tofu", suggestedIngredientId: "fast_tofu" } },
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "tofu", suggestedIngredientId: "fast_tofu" } },
|
||||
];
|
||||
|
||||
const report = buildGapReport(events, [], []);
|
||||
expect(report.entries).toHaveLength(2);
|
||||
expect(report.topMissingIngredients[0]?.canonicalIngredientId).toBe("laxfile");
|
||||
expect(report.topMissingIngredients[0]?.count).toBe(3);
|
||||
});
|
||||
|
||||
it("filtrerar bort enstaka missar", () => {
|
||||
const events: AnalyticsEvent[] = [
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "enstaka" } },
|
||||
];
|
||||
const report = buildGapReport(events, [], [], { minMissThreshold: 2 });
|
||||
expect(report.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("formaterar rapporten", () => {
|
||||
const events: AnalyticsEvent[] = [
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax" } },
|
||||
{ eventName: "recipe_search_zero_results", occurredAt: new Date(), properties: { query: "lax" } },
|
||||
];
|
||||
const report = buildGapReport(events, [], [], { minMissThreshold: 1 });
|
||||
const text = formatGapReport(report);
|
||||
expect(text).toContain("Katalog-gaprapport");
|
||||
expect(text).toContain("lax");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { MockAamosClient } from "@app/ai-contracts";
|
||||
import { runPipeline, type PipelineTarget, type PipelineIngredient } from "../src/pipeline.js";
|
||||
import type { CanonicalIngredientLookup, SimilarityLookup } from "../src/verification.js";
|
||||
|
||||
const mockClient = new MockAamosClient();
|
||||
|
||||
const mockIngredients: CanonicalIngredientLookup = {
|
||||
getById(id: string) {
|
||||
const db: Record<string, ReturnType<CanonicalIngredientLookup["getById"]>> = {
|
||||
kycklingfile: {
|
||||
id: "kycklingfile",
|
||||
nutritionPer100: {
|
||||
basis: "per_100_g",
|
||||
values: {
|
||||
kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6,
|
||||
saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1,
|
||||
},
|
||||
},
|
||||
defaultUnit: "GRAM",
|
||||
densityGPerMl: null,
|
||||
gramsPerPiece: null,
|
||||
allergens: [],
|
||||
isVegan: false,
|
||||
isVegetarian: false,
|
||||
containsGluten: false,
|
||||
containsLactose: false,
|
||||
isPork: false,
|
||||
isBeef: false,
|
||||
isAlcohol: false,
|
||||
},
|
||||
};
|
||||
return db[id] ?? undefined;
|
||||
},
|
||||
};
|
||||
|
||||
const noSimilarity: SimilarityLookup = {
|
||||
async hasSimilarity() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
const catalog: PipelineIngredient[] = [
|
||||
{
|
||||
id: "kycklingfile",
|
||||
nameSv: "kycklingfilé",
|
||||
category: "kott_fagel",
|
||||
defaultUnit: "GRAM",
|
||||
isVegan: false,
|
||||
isVegetarian: false,
|
||||
containsGluten: false,
|
||||
containsLactose: false,
|
||||
allergens: [],
|
||||
},
|
||||
];
|
||||
|
||||
const targets: PipelineTarget[] = [
|
||||
{ mealType: "dinner", mainIngredientId: "kycklingfile", dietVariant: "standard", count: 2 },
|
||||
];
|
||||
|
||||
describe("runPipeline", () => {
|
||||
it("kör med mockad klient och returnerar resultat", async () => {
|
||||
const result = await runPipeline(mockClient, targets, catalog, mockIngredients, noSimilarity);
|
||||
expect(result.geminiResult.status).toBe("ok");
|
||||
// MockAamosClient returnerar nu kandidater för GENERATE_RECIPE_CANDIDATES
|
||||
expect(result.candidates.length).toBeGreaterThan(0);
|
||||
expect(result.verifiedCount + result.unverifiedCount + result.rejectedCount).toBe(result.candidates.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { verifyCandidate, type CanonicalIngredientLookup, type SimilarityLookup } from "../src/verification.js";
|
||||
import type { RecipeCandidate } from "../src/types.js";
|
||||
|
||||
const mockIngredients: CanonicalIngredientLookup = {
|
||||
getById(id: string) {
|
||||
const db: Record<string, ReturnType<CanonicalIngredientLookup["getById"]>> = {
|
||||
kycklingfile: {
|
||||
id: "kycklingfile",
|
||||
nutritionPer100: {
|
||||
basis: "per_100_g",
|
||||
values: {
|
||||
kcal: 165, proteinG: 31, carbsG: 0, fatG: 3.6,
|
||||
saturatedFatG: 1, fiberG: 0, sugarG: 0, saltG: 0.1,
|
||||
},
|
||||
},
|
||||
defaultUnit: "GRAM",
|
||||
densityGPerMl: null,
|
||||
gramsPerPiece: null,
|
||||
allergens: [],
|
||||
isVegan: false,
|
||||
isVegetarian: false,
|
||||
containsGluten: false,
|
||||
containsLactose: false,
|
||||
isPork: false,
|
||||
isBeef: false,
|
||||
isAlcohol: false,
|
||||
},
|
||||
ris: {
|
||||
id: "ris",
|
||||
nutritionPer100: {
|
||||
basis: "per_100_g",
|
||||
values: {
|
||||
kcal: 130, proteinG: 2.7, carbsG: 28, fatG: 0.3,
|
||||
saturatedFatG: 0.1, fiberG: 0.4, sugarG: 0.1, saltG: 0,
|
||||
},
|
||||
},
|
||||
defaultUnit: "GRAM",
|
||||
densityGPerMl: null,
|
||||
gramsPerPiece: null,
|
||||
allergens: [],
|
||||
isVegan: true,
|
||||
isVegetarian: true,
|
||||
containsGluten: false,
|
||||
containsLactose: false,
|
||||
isPork: false,
|
||||
isBeef: false,
|
||||
isAlcohol: false,
|
||||
},
|
||||
"krossade_tomater": {
|
||||
id: "krossade_tomater",
|
||||
nutritionPer100: {
|
||||
basis: "per_100_g",
|
||||
values: {
|
||||
kcal: 32, proteinG: 1.6, carbsG: 5.8, fatG: 0.3,
|
||||
saturatedFatG: 0, fiberG: 1.2, sugarG: 4, saltG: 0.1,
|
||||
},
|
||||
},
|
||||
defaultUnit: "GRAM",
|
||||
densityGPerMl: null,
|
||||
gramsPerPiece: null,
|
||||
allergens: [],
|
||||
isVegan: true,
|
||||
isVegetarian: true,
|
||||
containsGluten: false,
|
||||
containsLactose: false,
|
||||
isPork: false,
|
||||
isBeef: false,
|
||||
isAlcohol: false,
|
||||
},
|
||||
};
|
||||
return db[id] ?? undefined;
|
||||
},
|
||||
};
|
||||
|
||||
const noSimilarity: SimilarityLookup = {
|
||||
async hasSimilarity() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
function makeCandidate(overrides?: Partial<RecipeCandidate>): RecipeCandidate {
|
||||
return {
|
||||
titleSv: "Testrecept",
|
||||
descriptionSv: "Ett enkelt testrecept.",
|
||||
cuisine: "swedish",
|
||||
mealTypes: ["dinner"],
|
||||
prepTimeMinutes: 10,
|
||||
cookTimeMinutes: 20,
|
||||
totalTimeMinutes: 30,
|
||||
portions: 4,
|
||||
spiceLevel: 1,
|
||||
ingredients: [
|
||||
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
|
||||
{ canonicalIngredientId: "ris", displayNameSv: "ris", quantity: 300, unit: "GRAM", optional: false, note: null },
|
||||
{ canonicalIngredientId: "krossade_tomater", displayNameSv: "krossade tomater", quantity: 400, unit: "GRAM", optional: false, note: null },
|
||||
],
|
||||
steps: [
|
||||
{ stepNumber: 1, instructionSv: "Stek kycklingen i en panna.", timerSeconds: null, temperatureC: null, tip: null },
|
||||
{ stepNumber: 2, instructionSv: "Tillsätt tomater och ris, låt koka.", timerSeconds: 900, temperatureC: null, tip: null },
|
||||
],
|
||||
storageGuidanceSv: "Förvara i kylskåp upp till 3 dagar.",
|
||||
mealPrepFriendly: false,
|
||||
freezerFriendly: false,
|
||||
sourceType: "ai_generated",
|
||||
confidence: 0.9,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("verifyCandidate", () => {
|
||||
it("passerar en giltig kandidat som verified", async () => {
|
||||
const result = await verifyCandidate(makeCandidate(), mockIngredients, noSimilarity);
|
||||
expect(result.status).toBe("verified");
|
||||
expect(result.reasons).toHaveLength(0);
|
||||
expect(result.allergens).toHaveLength(0);
|
||||
expect(result.nutritionPerPortion).not.toBeNull();
|
||||
expect(result.nutritionPerPortion!.kcal).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("avvisar kandidat med okänd ingrediens", async () => {
|
||||
const c = makeCandidate({
|
||||
ingredients: [
|
||||
{ canonicalIngredientId: "fantasi_gronsak", displayNameSv: "fantasigrönsak", quantity: 100, unit: "GRAM", optional: false, note: null },
|
||||
],
|
||||
});
|
||||
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
|
||||
expect(result.status).toBe("rejected");
|
||||
expect(result.reasons.some((r) => r.includes("fantasi_gronsak"))).toBe(true);
|
||||
});
|
||||
|
||||
it("markerar unverified vid orimlig tid", async () => {
|
||||
const c = makeCandidate({ prepTimeMinutes: 200, cookTimeMinutes: 10, totalTimeMinutes: 210 });
|
||||
const result = await verifyCandidate(c, mockIngredients, noSimilarity, { maxPrepTimeMinutes: 60 });
|
||||
expect(result.status).toBe("unverified");
|
||||
expect(result.reasons.some((r) => r.includes("Förberedelsetid"))).toBe(true);
|
||||
});
|
||||
|
||||
it("markerar unverified vid för få steg", async () => {
|
||||
const c = makeCandidate({ steps: [{ stepNumber: 1, instructionSv: "Gör allt.", timerSeconds: null, temperatureC: null, tip: null }] });
|
||||
const result = await verifyCandidate(c, mockIngredients, noSimilarity, { minSteps: 2 });
|
||||
expect(result.status).toBe("unverified");
|
||||
expect(result.reasons.some((r) => r.includes("steg"))).toBe(true);
|
||||
});
|
||||
|
||||
it("härleder allergener korrekt", async () => {
|
||||
const c = makeCandidate({
|
||||
ingredients: [
|
||||
{ canonicalIngredientId: "kycklingfile", displayNameSv: "kycklingfilé", quantity: 500, unit: "GRAM", optional: false, note: null },
|
||||
],
|
||||
});
|
||||
const result = await verifyCandidate(c, mockIngredients, noSimilarity);
|
||||
expect(result.allergens).toEqual([]);
|
||||
});
|
||||
|
||||
it("detekterar dubblett", async () => {
|
||||
const dupSimilarity: SimilarityLookup = {
|
||||
async hasSimilarity() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const result = await verifyCandidate(makeCandidate(), mockIngredients, dupSimilarity);
|
||||
expect(result.status).toBe("unverified");
|
||||
expect(result.reasons.some((r) => r.includes("dubblett"))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user