496 lines
16 KiB
TypeScript
496 lines
16 KiB
TypeScript
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<typeof detectedItemSchema>;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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<AamosTaskType, { input: z.ZodType; output: z.ZodType }>;
|
||
|
||
export type TaskInput<T extends AamosTaskType> = z.infer<(typeof TASK_CONTRACTS)[T]["input"]>;
|
||
export type TaskOutput<T extends AamosTaskType> = 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<typeof aamosRequestEnvelopeSchema>;
|
||
|
||
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<typeof aamosResponseEnvelopeSchema>;
|