Files
Cibello-app/packages/ai-contracts/src/tasks.ts
T
Claude d75500bebd fix(streckkod+kvitto): sparad-bekraftelse, fettvarden, kvitto-antal
Streckkod / produktbidrag:
- Tydlig bekraftelse 'Sparad i delade katalogen - tack!' visas nu efter att en
  fotad produkt lasts av, sa man ser att bidraget gick fram (forut bara
  'Skanna igen' utan aterkoppling).
- Naringsetiketter laser nu ocksa enkelomattat och fleromattat fett (t.ex.
  olivolja visar tre fettrader). Nya valfria falt monounsaturatedFatG/
  polyunsaturatedFatG i NutritionValues + READ_NUTRITION_LABEL + prompt +
  produktpanelen. Obligatoriska EU-falt oforandrade.

Kvitto:
- Rader som ar samma vara (samma normaliserade namn + enhet) slas nu ihop till
  EN vara med ratt antal - '2 x mjolk' pa kvittot blir en vara a 2 st i stallet
  for tva rader a 1 st. Priser summeras, rabatter dras som forut. En kvittorad
  raknas som minst 1 exemplar aven om antal saknas.

Verifierat: typecheck hela repot gront, nutrition-engine 19/19, ai-contracts
24/24, worker 23/23, api 104/104, samt repro mot testdatabas (2 mjolk-rader ->
1 vara a 2; olivolja sparar fett/mattat/enkel-/fleromattat).
2026-08-20 14:37:45 +00:00

706 lines
22 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",
"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<string>(UNITS);
const UNIT_ALIASES: Record<string, string> = {
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<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: 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 §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,
});
// ---------------------------------------------------------------------------
// 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<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(),
}),
/** Extra systeminstruktion som AAMOS ska lägga till i prompten. */
systemInstruction: z.string().optional(),
}),
});
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>;