import { z } from "zod"; import { AI_CONTRACT_VERSION, UNITS } from "@app/shared-types"; /** * AAMOS-uppgiftstyper. Superset av köns jobbtyper (spec §54): vissa jobb i * plattformen-workern är rena AAMOS-anrop, andra är deterministiska och anropar * aldrig AI. Kontraktet här är versionerat – AAMOS kan byta modeller fritt * bakom det (spec §31: "Food API ska få stabila kontrakt oavsett modell"). */ export const AAMOS_TASK_TYPES = [ "ANALYZE_FRIDGE_IMAGE", "ANALYZE_PANTRY_IMAGE", "ANALYZE_MEAL_IMAGE", "READ_RECEIPT", "READ_NUTRITION_LABEL", "READ_EXPIRY_DATE", "NORMALIZE_PRODUCTS", "DEDUPLICATE_INVENTORY", "STRUCTURE_RECIPE_TEXT", "GENERATE_RECIPE_OPTIONS", "GENERATE_RECIPE_CANDIDATES", "RANK_RECIPES", "PARSE_CRAVING", "UPDATE_USER_MEMORY", "GENERATE_WEEK_PLAN", "MODERATE_RECIPE", "TRANSLATE_RECIPE", "EXPORT_TRAINING_SAMPLE", ] as const; export type AamosTaskType = (typeof AAMOS_TASK_TYPES)[number]; const unitSchema = z.enum(UNITS); /** * Robust enhetstolkning för AI-skanning (kvitto/kylskåp/måltid). Modellen läser * enheter från kvitton/förpackningar som "st", "kg", "förp", "påse" – dessa * MÅSTE normaliseras till våra enhetskoder, annars kraschar hela skanningen på * schemavalidering (rapporterad bugg: "svar matchar inte schema … invalid value"). * Okända enheter blir null (raden behålls ändå) i stället för att fälla svaret. */ const UNIT_CODES = new Set(UNITS); const UNIT_ALIASES: Record = { G: "GRAM", GR: "GRAM", GRAM: "GRAM", GRAMS: "GRAM", KG: "KILOGRAM", KILO: "KILOGRAM", KILOGRAM: "KILOGRAM", HG: "GRAM", // hekto ~ hanteras som gram-nivå; kvantiteten justeras ej men enheten blir giltig ML: "MILLILITER", MILLILITER: "MILLILITER", DL: "DECILITER", DECILITER: "DECILITER", L: "LITER", LT: "LITER", LTR: "LITER", LITER: "LITER", LITRE: "LITER", ST: "COUNT", STK: "COUNT", STY: "COUNT", STYCK: "COUNT", STYCKEN: "COUNT", PCS: "COUNT", PC: "COUNT", EA: "COUNT", FÖRP: "PACKAGE", FORP: "PACKAGE", FRP: "PACKAGE", PKT: "PACKAGE", PKG: "PACKAGE", PAKET: "PACKAGE", PÅSE: "PACKAGE", PASE: "PACKAGE", PACK: "PACKAGE", PACKAGE: "PACKAGE", KART: "PACKAGE", KARTONG: "PACKAGE", BURK: "CAN", CAN: "CAN", KONSERV: "CAN", KLYFTA: "CLOVE", KLYFTOR: "CLOVE", CLOVE: "CLOVE", SKIVA: "SLICE", SKIVOR: "SLICE", SLICE: "SLICE", PORT: "PORTION", PORTION: "PORTION", PORTIONER: "PORTION", KRM: "PINCH", PINCH: "PINCH", MSK: "TABLESPOON", TABLESPOON: "TABLESPOON", TBSP: "TABLESPOON", TSK: "TEASPOON", TEASPOON: "TEASPOON", TSP: "TEASPOON", }; function toUnitCode(value: unknown): string | null { if (value == null) return null; const u = String(value) .trim() .toUpperCase() .replace(/[.\s]+$/g, ""); if (u === "") return null; if (UNIT_CODES.has(u)) return u; return UNIT_ALIASES[u] ?? null; } /** Enhet som alltid normaliseras till en giltig kod eller null (kraschar aldrig). */ const lenientUnit = z.preprocess(toUnitCode, unitSchema.nullable()); const confidence = z.number().min(0).max(1); const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); /** * Ett detekterat objekt från bildanalys (spec §10). AI får alltid säga * "osäker/okänd": canonicalIngredientId=null + lågt confidence. */ export const detectedItemSchema = z.object({ detectedName: z.string(), canonicalIngredientId: z.string().nullable(), brand: z.string().nullable().default(null), estimatedQuantity: z.number().nullable(), unit: lenientUnit, bestBeforeDate: isoDate.nullable().default(null), confidence, requiresConfirmation: z.boolean(), boundingBox: z .object({ x: z.number(), y: z.number(), w: z.number(), h: z.number() }) .nullable() .default(null), }); export type DetectedItem = z.infer; // --------------------------------------------------------------------------- // Input/Output per uppgiftstyp // --------------------------------------------------------------------------- export const analyzeStorageImageInput = z.object({ imageUrls: z.array(z.url()).min(1).max(6), locationType: z.string(), marketLocale: z.string().default("sv-SE"), knownItems: z.array(z.string()).default([]), }); export const analyzeStorageImageOutput = z.object({ items: z.array(detectedItemSchema), imageQualityIssues: z.array(z.enum(["dark", "blurry", "occlusion", "too_far"])).default([]), }); export const analyzeMealImageInput = z.object({ imageUrls: z.array(z.url()).min(1).max(3), recipeContext: z .object({ recipeId: z.string(), titleSv: z.string(), nutritionPerPortion: z.record(z.string(), z.number()), portions: z.number(), }) .nullable() .default(null), marketLocale: z.string().default("sv-SE"), }); /** * Tallriksfoto (spec §22): intervall + mest sannolikt, aldrig exakt påstående. * Kalorierna här är UPPSKATTNINGAR som användaren måste kunna korrigera. */ export const analyzeMealImageOutput = z.object({ matchesRecipeContext: z.boolean().nullable(), portionFractionEstimate: z.number().min(0).max(5).nullable(), kcalRange: z.object({ min: z.number(), max: z.number(), mostLikely: z.number() }).nullable(), components: z .array( z.object({ name: z.string(), canonicalIngredientId: z.string().nullable(), estimatedGrams: z.number().nullable(), confidence, }), ) .default([]), confidence, }); export const readReceiptInput = z.object({ imageUrls: z.array(z.url()).min(1).max(4), marketLocale: z.string().default("sv-SE"), }); export const readReceiptOutput = z.object({ storeName: z.string().nullable(), purchaseDate: isoDate.nullable(), lines: z.array( z.object({ rawText: z.string(), normalizedName: z.string().nullable(), canonicalIngredientId: z.string().nullable(), quantity: z.number().nullable(), unit: lenientUnit, unitPriceMinor: z.number().int().nullable(), totalPriceMinor: z.number().int().nullable(), isDiscount: z.boolean().default(false), confidence, }), ), totalMinor: z.number().int().nullable(), discountTotalMinor: z.number().int().nullable(), confidence, }); export const readNutritionLabelInput = z.object({ imageUrls: z.array(z.url()).min(1).max(3), marketLocale: z.string().default("sv-SE"), }); /** OCR av näringsdeklaration – värden kommer från ETIKETTEN, aldrig från modellens gissning. */ export const readNutritionLabelOutput = z.object({ basis: z.enum(["per_100_g", "per_100_ml", "per_portion"]).nullable(), values: z .object({ kcal: z.number().nullable(), proteinG: z.number().nullable(), carbsG: z.number().nullable(), fatG: z.number().nullable(), saturatedFatG: z.number().nullable(), monounsaturatedFatG: z.number().nullable().default(null), polyunsaturatedFatG: z.number().nullable().default(null), fiberG: z.number().nullable(), sugarG: z.number().nullable(), saltG: z.number().nullable(), }) .nullable(), ingredientsText: z.string().nullable(), allergensDeclared: z.array(z.string()).default([]), gtin: z.string().nullable().default(null), productName: z.string().nullable().default(null), brand: z.string().nullable().default(null), confidence, }); export const readExpiryDateInput = z.object({ imageUrls: z.array(z.url()).min(1).max(2), }); export const readExpiryDateOutput = z.object({ date: isoDate.nullable(), dateKind: z.enum(["best_before", "use_by"]).nullable(), confidence, }); export const normalizeProductsInput = z.object({ rawNames: z.array(z.string()).min(1).max(100), marketLocale: z.string().default("sv-SE"), }); export const normalizeProductsOutput = z.object({ matches: z.array( z.object({ raw: z.string(), canonicalIngredientId: z.string().nullable(), normalizedName: z.string().nullable(), confidence, }), ), }); export const deduplicateInventoryInput = z.object({ items: z.array( z.object({ id: z.string(), displayName: z.string(), canonicalIngredientId: z.string().nullable(), quantity: z.number(), unit: unitSchema, source: z.string(), createdAt: z.string(), }), ), }); export const deduplicateInventoryOutput = z.object({ duplicateGroups: z.array( z.object({ itemIds: z.array(z.string()).min(2), confidence, suggestedAction: z.enum(["merge", "review"]), reasonSv: z.string(), }), ), }); /** Fritextrecept → struktur (spec §35). Näring räknas ALDRIG här – det gör nutrition-engine. */ export const structureRecipeTextInput = z.object({ text: z.string().min(20).max(8000), marketLocale: z.string().default("sv-SE"), }); export const structureRecipeTextOutput = z.object({ titleSv: z.string().nullable(), descriptionSv: z.string().nullable(), ingredients: z.array( z.object({ rawText: z.string(), canonicalIngredientId: z.string().nullable(), displayNameSv: z.string(), quantity: z.number().nullable(), unit: lenientUnit, optional: z.boolean().default(false), confidence, }), ), steps: z.array( z.object({ instructionSv: z.string(), timerSeconds: z.number().nullable().default(null), temperatureC: z.number().nullable().default(null), }), ), prepTimeMinutes: z.number().nullable(), cookTimeMinutes: z.number().nullable(), portions: z.number().nullable(), suggestedCuisine: z.string().nullable(), suggestedMealTypes: z.array(z.string()).default([]), confidence, }); export const generateRecipeOptionsInput = z.object({ cravingText: z.string().nullable(), pantrySummary: z.array( z.object({ canonicalIngredientId: z.string(), quantity: z.number(), unit: unitSchema }), ), constraints: z.object({ allergens: z.array(z.string()), dietPattern: z.string().nullable(), maxMinutes: z.number().nullable(), portions: z.number(), spiceLevelMax: z.number().nullable(), }), marketLocale: z.string().default("sv-SE"), }); /** * AI-assisterade originalförslag (spec §15) – går ALLTID via redaktionell * granskning innan de blir permanenta recept. Allergensäkerhet verifieras * deterministiskt av recipe-engine innan visning. */ export const generateRecipeOptionsOutput = z.object({ suggestions: z.array( z.object({ titleSv: z.string(), descriptionSv: z.string(), ingredients: z.array( z.object({ canonicalIngredientId: z.string().nullable(), displayNameSv: z.string(), quantity: z.number(), unit: unitSchema, }), ), steps: z.array(z.string()), estimatedTimeMinutes: z.number(), confidence, }), ), }); // --------------------------------------------------------------------------- // GENERATE_RECIPE_CANDIDATES (docs/32): AI-genererade receptkandidater // konstruerade mot BEFINTLIGA canonical_ingredients. Ingen påhittad // ingrediens. Näring räknas ALDRIG här – det gör nutrition-engine. // --------------------------------------------------------------------------- export const generateRecipeCandidatesInput = z.object({ targetMatrix: z .array( z.object({ mealType: z.string(), mainIngredientId: z.string(), dietVariant: z.enum(["standard", "vegetarian", "vegan", "gluten_free", "lactose_free"]), count: z.number().int().min(1).max(10), }), ) .min(1) .max(20), canonicalIngredientsCatalog: z .array( z.object({ id: z.string(), nameSv: z.string(), category: z.string(), defaultUnit: unitSchema, isVegan: z.boolean(), isVegetarian: z.boolean(), containsGluten: z.boolean(), containsLactose: z.boolean(), allergens: z.array(z.string()), }), ) .min(1), constraints: z .object({ maxPrepTimeMinutes: z.number().int().nullable().default(60), maxCookTimeMinutes: z.number().int().nullable().default(45), portions: z.number().int().default(4), spiceLevelMax: z.number().int().nullable().default(3), avoidIngredients: z.array(z.string()).default([]), }) .default(() => ({ maxPrepTimeMinutes: 60, maxCookTimeMinutes: 45, portions: 4, spiceLevelMax: 3, avoidIngredients: [], })), marketLocale: z.string().default("sv-SE"), }); export const generateRecipeCandidatesOutput = z.object({ candidates: z.array( z.object({ titleSv: z.string(), descriptionSv: z.string(), cuisine: z.string().nullable(), mealTypes: z.array(z.string()), prepTimeMinutes: z.number().int(), cookTimeMinutes: z.number().int(), portions: z.number().int(), spiceLevel: z.number().int(), ingredients: z.array( z.object({ canonicalIngredientId: z.string(), displayNameSv: z.string(), quantity: z.number(), unit: unitSchema, optional: z.boolean().default(false), note: z.string().nullable(), }), ), steps: z.array( z.object({ instructionSv: z.string(), timerSeconds: z.number().nullable(), temperatureC: z.number().nullable(), tip: z.string().nullable(), }), ), storageGuidanceSv: z.string().nullable(), mealPrepFriendly: z.boolean().default(false), freezerFriendly: z.boolean().default(false), confidence: z.number().min(0).max(1), }), ), rejectedPrompts: z.array(z.string()).default([]), }); export const rankRecipesInput = z.object({ candidateIds: z.array(z.string()).max(50), deterministicScores: z.record(z.string(), z.number()), contextSummary: z.string(), }); export const rankRecipesOutput = z.object({ rankedIds: z.array(z.string()), rationaleSv: z.string().nullable(), }); export const parseCravingInput = z.object({ text: z.string().max(500), marketLocale: z.string().default("sv-SE"), }); export const parseCravingOutput = z.object({ tags: z.array(z.string()), cuisine: z.string().nullable(), maxKcal: z.number().nullable(), confidence, }); export const updateUserMemoryInput = z.object({ scope: z.enum(["user", "household"]), scopeId: z.string(), events: z.array( z.object({ id: z.string(), type: z.string(), occurredAt: z.string(), payload: z.unknown(), }), ), existingMemoryKeys: z.array(z.string()).default([]), }); export const updateUserMemoryOutput = z.object({ memoryUpdates: z.array( z.object({ key: z.string(), kind: z.enum(["structured_fact", "event", "semantic", "profile_summary", "recipe_memory"]), summarySv: z.string(), value: z.unknown(), origin: z.enum(["observed", "ai_inferred"]), confidence, expiresAt: z.string().nullable().default(null), /** Händelse-ID:n som stödjer förslaget – obligatoriskt för grundningskoll (R1). */ sourceEventIds: z.array(z.string()).min(1), }), ), }); export const generateWeekPlanInput = z.object({ days: z.array(z.object({ date: isoDate, mealTypes: z.array(z.string()) })), pantrySummary: z.array( z.object({ canonicalIngredientId: z.string(), quantity: z.number(), unit: unitSchema, daysToExpiry: z.number().nullable(), }), ), candidateRecipes: z.array( z.object({ id: z.string(), titleSv: z.string(), tags: z.array(z.string()) }), ), constraints: z.object({ budgetMinorTotal: z.number().int().nullable(), portionsPerMeal: z.number(), varietyLevel: z.enum(["low", "medium", "high"]), noteSv: z.string().nullable(), }), }); export const generateWeekPlanOutput = z.object({ entries: z.array( z.object({ date: isoDate, mealType: z.string(), recipeId: z.string().nullable(), useMealBox: z.boolean().default(false), motivationSv: z.string().nullable(), }), ), confidence, }); export const moderateRecipeInput = z.object({ titleSv: z.string(), descriptionSv: z.string(), ingredients: z.array(z.string()), steps: z.array(z.string()), }); export const moderateRecipeOutput = z.object({ flags: z.array( z.object({ code: z.enum([ "food_safety", "unrealistic_quantities", "inappropriate_content", "spam", "copyright_suspect", "other", ]), severity: z.enum(["info", "warning", "reject"]), messageSv: z.string(), }), ), recommendation: z.enum(["approve", "review", "reject"]), confidence, }); // --------------------------------------------------------------------------- // TRANSLATE_RECIPE (i18n-spec §13–14): AI översätter TEXT, aldrig struktur. // Mängder, ingredient-IDs, tider och allergener bor utanför översättningen och // kan därför inte ändras av modellen. Deterministisk verifiering sker i worker. // --------------------------------------------------------------------------- export const translateRecipeInput = z.object({ sourceLanguageTag: z.string().default("sv"), targetLanguageTag: z.string(), title: z.string(), description: z.string().nullable(), storageGuidance: z.string().nullable(), steps: z.array( z.object({ stepNumber: z.number().int().min(1), instruction: z.string(), tip: z.string().nullable(), }), ), }); export const translateRecipeOutput = z.object({ title: z.string().min(1), description: z.string().nullable(), storageGuidance: z.string().nullable(), steps: z.array( z.object({ stepNumber: z.number().int().min(1), instruction: z.string().min(1), tip: z.string().nullable(), }), ), confidence, }); // --------------------------------------------------------------------------- // EXPORT_TRAINING_SAMPLE: anonymiserade korrigeringar för AAMOS training-pipeline // Ingen PII, inga bild-URL:er, inga användar-ID:n. // --------------------------------------------------------------------------- export const exportTrainingSampleInput = z.object({ marketLocale: z.string().default("sv-SE"), samples: z.array( z.object({ taskType: z.string(), aiOutput: z.record(z.string(), z.unknown()), userCorrection: z.record(z.string(), z.unknown()), modelVersion: z.string().nullable(), promptVersion: z.string().nullable(), }), ), }); export const exportTrainingSampleOutput = z.object({ batchId: z.string(), accepted: z.number().int().min(0), rejected: z.number().int().min(0), }); // --------------------------------------------------------------------------- // Kontraktsregister // --------------------------------------------------------------------------- export const TASK_CONTRACTS = { ANALYZE_FRIDGE_IMAGE: { input: analyzeStorageImageInput, output: analyzeStorageImageOutput }, ANALYZE_PANTRY_IMAGE: { input: analyzeStorageImageInput, output: analyzeStorageImageOutput }, ANALYZE_MEAL_IMAGE: { input: analyzeMealImageInput, output: analyzeMealImageOutput }, READ_RECEIPT: { input: readReceiptInput, output: readReceiptOutput }, READ_NUTRITION_LABEL: { input: readNutritionLabelInput, output: readNutritionLabelOutput }, READ_EXPIRY_DATE: { input: readExpiryDateInput, output: readExpiryDateOutput }, NORMALIZE_PRODUCTS: { input: normalizeProductsInput, output: normalizeProductsOutput }, DEDUPLICATE_INVENTORY: { input: deduplicateInventoryInput, output: deduplicateInventoryOutput }, STRUCTURE_RECIPE_TEXT: { input: structureRecipeTextInput, output: structureRecipeTextOutput }, GENERATE_RECIPE_OPTIONS: { input: generateRecipeOptionsInput, output: generateRecipeOptionsOutput, }, GENERATE_RECIPE_CANDIDATES: { input: generateRecipeCandidatesInput, output: generateRecipeCandidatesOutput, }, RANK_RECIPES: { input: rankRecipesInput, output: rankRecipesOutput }, PARSE_CRAVING: { input: parseCravingInput, output: parseCravingOutput }, UPDATE_USER_MEMORY: { input: updateUserMemoryInput, output: updateUserMemoryOutput }, GENERATE_WEEK_PLAN: { input: generateWeekPlanInput, output: generateWeekPlanOutput }, MODERATE_RECIPE: { input: moderateRecipeInput, output: moderateRecipeOutput }, TRANSLATE_RECIPE: { input: translateRecipeInput, output: translateRecipeOutput }, EXPORT_TRAINING_SAMPLE: { input: exportTrainingSampleInput, output: exportTrainingSampleOutput, }, } as const satisfies Record; export type TaskInput = z.infer<(typeof TASK_CONTRACTS)[T]["input"]>; export type TaskOutput = z.infer<(typeof TASK_CONTRACTS)[T]["output"]>; /** Kuvert för anrop mot AAMOS. */ export const aamosRequestEnvelopeSchema = z.object({ taskId: z.string(), taskType: z.enum(AAMOS_TASK_TYPES), contractVersion: z.string().default(AI_CONTRACT_VERSION), input: z.unknown(), metadata: z.object({ correlationId: z.string(), /** Lokaliseringskontext (i18n-spec §22): AAMOS svarar på rätt språk och * respekterar region/måttsystem – canonical data förblir språkneutral. */ localeContext: z .object({ languageTag: z.string(), regionCode: z.string(), timeZone: z.string(), measurementSystem: z.enum(["METRIC", "US_CUSTOMARY", "MIXED"]), temperatureUnit: z.enum(["CELSIUS", "FAHRENHEIT"]), currencyCode: z.string(), }) .default({ languageTag: "sv-SE", regionCode: "SE", timeZone: "Europe/Stockholm", measurementSystem: "METRIC", temperatureUnit: "CELSIUS", currencyCode: "SEK", }), /** Pseudonymiserat – aldrig e-post eller namn till AAMOS (spec §56). */ subjectRef: z.string().nullable(), priority: z.enum(["low", "normal", "high"]).default("normal"), consentFlags: z.object({ personalization: z.boolean(), anonymizedImprovement: z.boolean(), imageTraining: z.boolean(), }), /** Extra systeminstruktion som AAMOS ska lägga till i prompten. */ systemInstruction: z.string().optional(), }), }); export type AamosRequestEnvelope = z.infer; export const aamosResponseEnvelopeSchema = z.object({ taskId: z.string(), taskType: z.enum(AAMOS_TASK_TYPES), contractVersion: z.string(), status: z.enum(["ok", "uncertain", "failed"]), output: z.unknown().nullable(), error: z.string().nullable().default(null), modelVersion: z.string().nullable().default(null), promptVersion: z.string().nullable().default(null), latencyMs: z.number().nullable().default(null), costUsd: z.number().nullable().default(null), }); export type AamosResponseEnvelope = z.infer;