Files
Cibello-app/packages/ai-contracts/src/tasks.ts
T
2026-08-05 19:21:11 +07:00

496 lines
16 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.
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 §1314): 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>;