Files
Cibello-app/packages/recipe-generation/src/pipeline.ts
T
Sven (AAMOS AI) c32a7e33c7
CI / Typecheck, test & build (push) Failing after 2s
ci: trigga på master + formatfix inför Gitea Actions
2026-08-13 17:25:14 +07:00

175 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Genereringspipeline för receptkandidater (docs/32 §23).
*
* - 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,
};
}