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", "RANK_RECIPES", "PARSE_CRAVING", "UPDATE_USER_MEMORY", "GENERATE_WEEK_PLAN", "MODERATE_RECIPE", "TRANSLATE_RECIPE", ] as const; export type AamosTaskType = (typeof AAMOS_TASK_TYPES)[number]; const unitSchema = z.enum(UNITS); 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: unitSchema.nullable(), 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: unitSchema.nullable(), 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(), 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: unitSchema.nullable(), 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, }), ), }); 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({ 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), }), ), }); 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, }); // --------------------------------------------------------------------------- // 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, }, 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 }, } 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(), }), }), }); 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;