175 lines
5.2 KiB
TypeScript
175 lines
5.2 KiB
TypeScript
/**
|
||
* Genereringspipeline för receptkandidater (docs/32 §2–3).
|
||
*
|
||
* - Gemini genererar svenska vardagsrecept KONSTRUERADE mot BEFINTLIGA
|
||
* canonical_ingredients.
|
||
* - Hitta aldrig på ingredienser utanför katalogen.
|
||
* - Varje kandidat passerar verifieringspipelinen.
|
||
* - Ingen kandidat blir verified utan att passera alla grindar.
|
||
*/
|
||
|
||
import type { AamosClient, AamosResult } from "@app/ai-contracts";
|
||
import type { TaskInput, TaskOutput } from "@app/ai-contracts";
|
||
import type { RecipeCandidate, VerificationResult } from "./types.js";
|
||
import {
|
||
verifyCandidate,
|
||
type CanonicalIngredientLookup,
|
||
type SimilarityLookup,
|
||
} from "./verification.js";
|
||
|
||
export interface PipelineIngredient {
|
||
id: string;
|
||
nameSv: string;
|
||
category: string;
|
||
defaultUnit: string;
|
||
isVegan: boolean;
|
||
isVegetarian: boolean;
|
||
containsGluten: boolean;
|
||
containsLactose: boolean;
|
||
allergens: string[];
|
||
}
|
||
|
||
export interface PipelineTarget {
|
||
mealType: string;
|
||
mainIngredientId: string;
|
||
dietVariant: "standard" | "vegetarian" | "vegan" | "gluten_free" | "lactose_free";
|
||
count: number;
|
||
}
|
||
|
||
export interface PipelineOptions {
|
||
/** Max förberedelsetid i minuter. */
|
||
maxPrepTimeMinutes?: number;
|
||
/** Max koktid i minuter. */
|
||
maxCookTimeMinutes?: number;
|
||
/** Antal portioner. */
|
||
portions?: number;
|
||
/** Max kryddnivå. */
|
||
spiceLevelMax?: number;
|
||
/** Ingredienser att undvika. */
|
||
avoidIngredients?: string[];
|
||
/** Locale, default sv-SE. */
|
||
marketLocale?: string;
|
||
}
|
||
|
||
export interface PipelineResult {
|
||
candidates: RecipeCandidate[];
|
||
verificationResults: VerificationResult[];
|
||
verifiedCount: number;
|
||
unverifiedCount: number;
|
||
rejectedCount: number;
|
||
geminiResult: AamosResult<"GENERATE_RECIPE_CANDIDATES">;
|
||
}
|
||
|
||
/**
|
||
* Kör genereringspipelinen: Gemini → verifiering.
|
||
*
|
||
* @param client AamosClient (Gemini, mock, eller HTTP)
|
||
* @param targets Vad som ska genereras
|
||
* @param catalog Tillgängliga canonical_ingredients
|
||
* @param ingredients Lookup för verifiering
|
||
* @param similarity Lookup för dedup
|
||
* @param options Begränsningar
|
||
*/
|
||
export async function runPipeline(
|
||
client: AamosClient,
|
||
targets: PipelineTarget[],
|
||
catalog: PipelineIngredient[],
|
||
ingredients: CanonicalIngredientLookup,
|
||
similarity: SimilarityLookup | null,
|
||
options: PipelineOptions = {},
|
||
): Promise<PipelineResult> {
|
||
const input: TaskInput<"GENERATE_RECIPE_CANDIDATES"> = {
|
||
targetMatrix: targets.map((t) => ({
|
||
mealType: t.mealType,
|
||
mainIngredientId: t.mainIngredientId,
|
||
dietVariant: t.dietVariant,
|
||
count: t.count,
|
||
})),
|
||
canonicalIngredientsCatalog: catalog.map((i) => ({
|
||
id: i.id,
|
||
nameSv: i.nameSv,
|
||
category: i.category,
|
||
defaultUnit: i.defaultUnit as import("@app/shared-types").Unit,
|
||
isVegan: i.isVegan,
|
||
isVegetarian: i.isVegetarian,
|
||
containsGluten: i.containsGluten,
|
||
containsLactose: i.containsLactose,
|
||
allergens: i.allergens,
|
||
})),
|
||
constraints: {
|
||
maxPrepTimeMinutes: options.maxPrepTimeMinutes ?? 60,
|
||
maxCookTimeMinutes: options.maxCookTimeMinutes ?? 45,
|
||
portions: options.portions ?? 4,
|
||
spiceLevelMax: options.spiceLevelMax ?? 3,
|
||
avoidIngredients: options.avoidIngredients ?? [],
|
||
},
|
||
marketLocale: options.marketLocale ?? "sv-SE",
|
||
};
|
||
|
||
const geminiResult = await client.runTask("GENERATE_RECIPE_CANDIDATES", input);
|
||
|
||
if (geminiResult.status !== "ok" || !geminiResult.output) {
|
||
return {
|
||
candidates: [],
|
||
verificationResults: [],
|
||
verifiedCount: 0,
|
||
unverifiedCount: 0,
|
||
rejectedCount: 0,
|
||
geminiResult,
|
||
};
|
||
}
|
||
|
||
const output = geminiResult.output as TaskOutput<"GENERATE_RECIPE_CANDIDATES">;
|
||
|
||
// Mappa Gemini-output till interna typer
|
||
const candidates: RecipeCandidate[] = output.candidates.map((c, idx) => ({
|
||
titleSv: c.titleSv,
|
||
descriptionSv: c.descriptionSv,
|
||
cuisine: c.cuisine,
|
||
mealTypes: c.mealTypes,
|
||
prepTimeMinutes: c.prepTimeMinutes,
|
||
cookTimeMinutes: c.cookTimeMinutes,
|
||
totalTimeMinutes: c.prepTimeMinutes + c.cookTimeMinutes,
|
||
portions: c.portions,
|
||
spiceLevel: c.spiceLevel,
|
||
ingredients: c.ingredients.map((ing) => ({
|
||
canonicalIngredientId: ing.canonicalIngredientId,
|
||
displayNameSv: ing.displayNameSv,
|
||
quantity: ing.quantity,
|
||
unit: ing.unit,
|
||
optional: ing.optional,
|
||
note: ing.note,
|
||
})),
|
||
steps: c.steps.map((s, sIdx) => ({
|
||
stepNumber: sIdx + 1,
|
||
instructionSv: s.instructionSv,
|
||
timerSeconds: s.timerSeconds,
|
||
temperatureC: s.temperatureC,
|
||
tip: s.tip,
|
||
})),
|
||
storageGuidanceSv: c.storageGuidanceSv,
|
||
mealPrepFriendly: c.mealPrepFriendly,
|
||
freezerFriendly: c.freezerFriendly,
|
||
sourceType: "ai_generated" as const,
|
||
confidence: c.confidence,
|
||
}));
|
||
|
||
// Kör verifieringspipelinen
|
||
const verificationResults = await Promise.all(
|
||
candidates.map((c) => verifyCandidate(c, ingredients, similarity)),
|
||
);
|
||
|
||
const verifiedCount = verificationResults.filter((r) => r.status === "verified").length;
|
||
const unverifiedCount = verificationResults.filter((r) => r.status === "unverified").length;
|
||
const rejectedCount = verificationResults.filter((r) => r.status === "rejected").length;
|
||
|
||
return {
|
||
candidates,
|
||
verificationResults,
|
||
verifiedCount,
|
||
unverifiedCount,
|
||
rejectedCount,
|
||
geminiResult,
|
||
};
|
||
}
|