Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@app/ai-contracts",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Typade kontrakt + HTTP-klient mot AAMOS. Food API får stabila kontrakt oavsett modell (spec §31)",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@app/shared-types": "workspace:*",
|
||||
"zod": "^4.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
TASK_CONTRACTS,
|
||||
aamosResponseEnvelopeSchema,
|
||||
type AamosRequestEnvelope,
|
||||
type AamosTaskType,
|
||||
type TaskInput,
|
||||
type TaskOutput,
|
||||
} from "./tasks.js";
|
||||
import { mockOutputFor } from "./mock.js";
|
||||
|
||||
import type { LocaleContext } from "@app/shared-types";
|
||||
|
||||
export interface AamosCallOptions {
|
||||
correlationId?: string;
|
||||
localeContext?: LocaleContext;
|
||||
subjectRef?: string | null;
|
||||
priority?: "low" | "normal" | "high";
|
||||
consentFlags?: {
|
||||
personalization: boolean;
|
||||
anonymizedImprovement: boolean;
|
||||
imageTraining: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AamosResult<T extends AamosTaskType> {
|
||||
status: "ok" | "uncertain" | "failed";
|
||||
output: TaskOutput<T> | null;
|
||||
error?: string | undefined;
|
||||
modelVersion?: string | undefined;
|
||||
promptVersion?: string | undefined;
|
||||
latencyMs?: number | undefined;
|
||||
costUsd?: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Klientinterface mot AAMOS. Mobilappen anropar ALDRIG detta – bara
|
||||
* Food API/worker (spec §31, §61.13).
|
||||
*/
|
||||
export interface AamosClient {
|
||||
runTask<T extends AamosTaskType>(
|
||||
taskType: T,
|
||||
input: TaskInput<T>,
|
||||
options?: AamosCallOptions,
|
||||
): Promise<AamosResult<T>>;
|
||||
healthCheck(): Promise<{ ok: boolean; detail?: string }>;
|
||||
}
|
||||
|
||||
export interface HttpAamosClientConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
timeoutMs?: number;
|
||||
maxRetries?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-klient mot den befintliga AAMOS-plattformen.
|
||||
*
|
||||
* Antaget API (justeras mot AAMOS faktiska dokumentation – endast denna fil
|
||||
* och endpointkonstanterna behöver ändras, inga kontrakt läcker vidare):
|
||||
* POST {baseUrl}/v1/tasks body: AamosRequestEnvelope → AamosResponseEnvelope
|
||||
* GET {baseUrl}/v1/health
|
||||
* Auth: Authorization: Bearer <AAMOS_API_KEY>
|
||||
*/
|
||||
export class HttpAamosClient implements AamosClient {
|
||||
private readonly cfg: Required<Omit<HttpAamosClientConfig, "fetchImpl">>;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(config: HttpAamosClientConfig) {
|
||||
this.cfg = {
|
||||
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
||||
apiKey: config.apiKey,
|
||||
timeoutMs: config.timeoutMs ?? 60_000,
|
||||
maxRetries: config.maxRetries ?? 2,
|
||||
};
|
||||
this.fetchImpl = config.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
async runTask<T extends AamosTaskType>(
|
||||
taskType: T,
|
||||
input: TaskInput<T>,
|
||||
options: AamosCallOptions = {},
|
||||
): Promise<AamosResult<T>> {
|
||||
// Validera input mot kontraktet INNAN nätverksanropet – fail fast.
|
||||
const contract = TASK_CONTRACTS[taskType];
|
||||
const parsedInput = contract.input.safeParse(input);
|
||||
if (!parsedInput.success) {
|
||||
return {
|
||||
status: "failed",
|
||||
output: null,
|
||||
error: `Kontraktsfel (input) för ${taskType}: ${parsedInput.error.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
const envelope: AamosRequestEnvelope = {
|
||||
taskId: randomUUID(),
|
||||
taskType,
|
||||
contractVersion: "1.0.0",
|
||||
input: parsedInput.data,
|
||||
metadata: {
|
||||
correlationId: options.correlationId ?? randomUUID(),
|
||||
localeContext: options.localeContext ?? {
|
||||
languageTag: "sv-SE",
|
||||
regionCode: "SE",
|
||||
timeZone: "Europe/Stockholm",
|
||||
measurementSystem: "METRIC" as const,
|
||||
temperatureUnit: "CELSIUS" as const,
|
||||
currencyCode: "SEK",
|
||||
},
|
||||
subjectRef: options.subjectRef ?? null,
|
||||
priority: options.priority ?? "normal",
|
||||
consentFlags: options.consentFlags ?? {
|
||||
personalization: true,
|
||||
anonymizedImprovement: false,
|
||||
imageTraining: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let lastError = "";
|
||||
for (let attempt = 0; attempt <= this.cfg.maxRetries; attempt++) {
|
||||
if (attempt > 0) await sleep(500 * 2 ** attempt);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.cfg.timeoutMs);
|
||||
const res = await this.fetchImpl(`${this.cfg.baseUrl}/v1/tasks`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${this.cfg.apiKey}`,
|
||||
"x-correlation-id": envelope.metadata.correlationId,
|
||||
"x-contract-version": envelope.contractVersion,
|
||||
},
|
||||
body: JSON.stringify(envelope),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (res.status === 429 || res.status >= 500) {
|
||||
lastError = `AAMOS svarade ${res.status}`;
|
||||
continue; // retry
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await safeText(res);
|
||||
return { status: "failed", output: null, error: `AAMOS ${res.status}: ${body}` };
|
||||
}
|
||||
|
||||
const json: unknown = await res.json();
|
||||
const parsed = aamosResponseEnvelopeSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
status: "failed",
|
||||
output: null,
|
||||
error: `Ogiltigt svarskuvert från AAMOS: ${parsed.error.message}`,
|
||||
};
|
||||
}
|
||||
const env = parsed.data;
|
||||
if (env.status === "failed" || env.output == null) {
|
||||
return {
|
||||
status: env.status === "failed" ? "failed" : "uncertain",
|
||||
output: null,
|
||||
error: env.error ?? undefined,
|
||||
modelVersion: env.modelVersion ?? undefined,
|
||||
promptVersion: env.promptVersion ?? undefined,
|
||||
latencyMs: env.latencyMs ?? undefined,
|
||||
costUsd: env.costUsd ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Validera output mot kontraktet – ett brutet kontrakt är ett fel,
|
||||
// aldrig något som tyst släpps vidare till användardata.
|
||||
const parsedOutput = contract.output.safeParse(env.output);
|
||||
if (!parsedOutput.success) {
|
||||
return {
|
||||
status: "failed",
|
||||
output: null,
|
||||
error: `Kontraktsfel (output) för ${taskType}: ${parsedOutput.error.message}`,
|
||||
modelVersion: env.modelVersion ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: env.status,
|
||||
output: parsedOutput.data as TaskOutput<T>,
|
||||
modelVersion: env.modelVersion ?? undefined,
|
||||
promptVersion: env.promptVersion ?? undefined,
|
||||
latencyMs: env.latencyMs ?? undefined,
|
||||
costUsd: env.costUsd ?? undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
return { status: "failed", output: null, error: `AAMOS onåbart: ${lastError}` };
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
const res = await this.fetchImpl(`${this.cfg.baseUrl}/v1/health`, {
|
||||
headers: { authorization: `Bearer ${this.cfg.apiKey}` },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
return { ok: res.ok, detail: `status ${res.status}` };
|
||||
} catch (err) {
|
||||
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministisk mock för lokal utveckling och tester (AAMOS_MODE=mock).
|
||||
* Produktionsmiljöer ska ALLTID köra AAMOS_MODE=http.
|
||||
*/
|
||||
export class MockAamosClient implements AamosClient {
|
||||
async runTask<T extends AamosTaskType>(
|
||||
taskType: T,
|
||||
input: TaskInput<T>,
|
||||
): Promise<AamosResult<T>> {
|
||||
const contract = TASK_CONTRACTS[taskType];
|
||||
const parsedInput = contract.input.safeParse(input);
|
||||
if (!parsedInput.success) {
|
||||
return {
|
||||
status: "failed",
|
||||
output: null,
|
||||
error: `Kontraktsfel (input) för ${taskType}: ${parsedInput.error.message}`,
|
||||
};
|
||||
}
|
||||
const output = mockOutputFor(taskType, parsedInput.data);
|
||||
const validated = contract.output.parse(output) as TaskOutput<T>;
|
||||
return {
|
||||
status: "ok",
|
||||
output: validated,
|
||||
modelVersion: "mock-1.0",
|
||||
promptVersion: "mock",
|
||||
latencyMs: 42,
|
||||
costUsd: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<{ ok: boolean; detail?: string }> {
|
||||
return { ok: true, detail: "mock" };
|
||||
}
|
||||
}
|
||||
|
||||
export interface AamosEnv {
|
||||
AAMOS_MODE?: string | undefined;
|
||||
AAMOS_API_URL?: string | undefined;
|
||||
AAMOS_API_KEY?: string | undefined;
|
||||
AAMOS_TIMEOUT_MS?: string | undefined;
|
||||
}
|
||||
|
||||
/** Fabrik: http mot riktiga AAMOS (default), mock för dev/test. */
|
||||
export function createAamosClient(env: AamosEnv = process.env): AamosClient {
|
||||
const mode = env.AAMOS_MODE ?? "http";
|
||||
if (mode === "mock") return new MockAamosClient();
|
||||
const baseUrl = env.AAMOS_API_URL;
|
||||
const apiKey = env.AAMOS_API_KEY;
|
||||
if (!baseUrl || !apiKey) {
|
||||
throw new Error(
|
||||
"AAMOS_MODE=http kräver AAMOS_API_URL och AAMOS_API_KEY. " +
|
||||
"Sätt AAMOS_MODE=mock för lokal utveckling utan AAMOS-åtkomst.",
|
||||
);
|
||||
}
|
||||
return new HttpAamosClient({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
timeoutMs: env.AAMOS_TIMEOUT_MS ? Number(env.AAMOS_TIMEOUT_MS) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
async function safeText(res: Response): Promise<string> {
|
||||
try {
|
||||
return (await res.text()).slice(0, 500);
|
||||
} catch {
|
||||
return "<no body>";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./tasks.js";
|
||||
export * from "./client.js";
|
||||
export { mockOutputFor } from "./mock.js";
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { AamosTaskType } from "./tasks.js";
|
||||
|
||||
/**
|
||||
* Deterministiska mock-svar per uppgiftstyp. Realistiska nog för att bygga
|
||||
* UI-flöden och tester mot – inklusive osäkerhetsfall (requiresConfirmation,
|
||||
* lågt confidence) så att korrigerings-UX:et alltid övas (spec §61.4–5).
|
||||
*/
|
||||
export function mockOutputFor(taskType: AamosTaskType, input: unknown): unknown {
|
||||
switch (taskType) {
|
||||
case "ANALYZE_FRIDGE_IMAGE":
|
||||
case "ANALYZE_PANTRY_IMAGE":
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
detectedName: "mjölk",
|
||||
canonicalIngredientId: "milk_3",
|
||||
brand: "Arla",
|
||||
estimatedQuantity: 0.7,
|
||||
unit: "LITER",
|
||||
bestBeforeDate: null,
|
||||
confidence: 0.84,
|
||||
requiresConfirmation: true,
|
||||
boundingBox: null,
|
||||
},
|
||||
{
|
||||
detectedName: "crème fraîche",
|
||||
canonicalIngredientId: "creme_fraiche",
|
||||
brand: null,
|
||||
estimatedQuantity: 1,
|
||||
unit: "COUNT",
|
||||
bestBeforeDate: null,
|
||||
confidence: 0.61,
|
||||
requiresConfirmation: true,
|
||||
boundingBox: null,
|
||||
},
|
||||
{
|
||||
detectedName: "okänd burk",
|
||||
canonicalIngredientId: null,
|
||||
brand: null,
|
||||
estimatedQuantity: null,
|
||||
unit: null,
|
||||
bestBeforeDate: null,
|
||||
confidence: 0.2,
|
||||
requiresConfirmation: true,
|
||||
boundingBox: null,
|
||||
},
|
||||
],
|
||||
imageQualityIssues: [],
|
||||
};
|
||||
|
||||
case "ANALYZE_MEAL_IMAGE": {
|
||||
const hasContext =
|
||||
typeof input === "object" &&
|
||||
input !== null &&
|
||||
(input as { recipeContext?: unknown }).recipeContext != null;
|
||||
return {
|
||||
matchesRecipeContext: hasContext ? true : null,
|
||||
portionFractionEstimate: 1,
|
||||
kcalRange: { min: 650, max: 750, mostLikely: 700 },
|
||||
components: [
|
||||
{
|
||||
name: "kycklingfilé",
|
||||
canonicalIngredientId: "chicken_breast",
|
||||
estimatedGrams: 150,
|
||||
confidence: 0.8,
|
||||
},
|
||||
{
|
||||
name: "ris",
|
||||
canonicalIngredientId: "rice_white",
|
||||
estimatedGrams: 180,
|
||||
confidence: 0.75,
|
||||
},
|
||||
],
|
||||
confidence: 0.72,
|
||||
};
|
||||
}
|
||||
|
||||
case "READ_RECEIPT":
|
||||
return {
|
||||
storeName: "ICA Supermarket",
|
||||
purchaseDate: "2026-08-01",
|
||||
lines: [
|
||||
{
|
||||
rawText: "KYCKL FILE 925G",
|
||||
normalizedName: "Kycklingfilé 925 g",
|
||||
canonicalIngredientId: "chicken_breast",
|
||||
quantity: 925,
|
||||
unit: "GRAM",
|
||||
unitPriceMinor: 11990,
|
||||
totalPriceMinor: 11990,
|
||||
isDiscount: false,
|
||||
confidence: 0.9,
|
||||
},
|
||||
{
|
||||
rawText: "MELLANMJ 1.5L",
|
||||
normalizedName: "Mellanmjölk 1,5 l",
|
||||
canonicalIngredientId: "milk_1_5",
|
||||
quantity: 1.5,
|
||||
unit: "LITER",
|
||||
unitPriceMinor: 1890,
|
||||
totalPriceMinor: 1890,
|
||||
isDiscount: false,
|
||||
confidence: 0.87,
|
||||
},
|
||||
],
|
||||
totalMinor: 13880,
|
||||
discountTotalMinor: 0,
|
||||
confidence: 0.85,
|
||||
};
|
||||
|
||||
case "READ_NUTRITION_LABEL":
|
||||
return {
|
||||
basis: "per_100_g",
|
||||
values: {
|
||||
kcal: 106,
|
||||
proteinG: 22,
|
||||
carbsG: 0,
|
||||
fatG: 2,
|
||||
saturatedFatG: 0.6,
|
||||
fiberG: 0,
|
||||
sugarG: 0,
|
||||
saltG: 0.2,
|
||||
},
|
||||
ingredientsText: "Kycklingfilé (100 %)",
|
||||
allergensDeclared: [],
|
||||
gtin: null,
|
||||
productName: "Kycklingfilé",
|
||||
brand: null,
|
||||
confidence: 0.88,
|
||||
};
|
||||
|
||||
case "READ_EXPIRY_DATE":
|
||||
return { date: "2026-08-06", dateKind: "best_before", confidence: 0.9 };
|
||||
|
||||
case "NORMALIZE_PRODUCTS": {
|
||||
const rawNames =
|
||||
typeof input === "object" && input !== null
|
||||
? ((input as { rawNames?: string[] }).rawNames ?? [])
|
||||
: [];
|
||||
return {
|
||||
matches: rawNames.map((raw) => ({
|
||||
raw,
|
||||
canonicalIngredientId: null,
|
||||
normalizedName: raw.toLowerCase(),
|
||||
confidence: 0.4,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
case "DEDUPLICATE_INVENTORY":
|
||||
return { duplicateGroups: [] };
|
||||
|
||||
case "STRUCTURE_RECIPE_TEXT":
|
||||
return {
|
||||
titleSv: "Snabb kycklingpasta",
|
||||
descriptionSv: "Krämig vardagspasta med kyckling och spenat.",
|
||||
ingredients: [
|
||||
{
|
||||
rawText: "400 g kycklingfilé",
|
||||
canonicalIngredientId: "chicken_breast",
|
||||
displayNameSv: "Kycklingfilé",
|
||||
quantity: 400,
|
||||
unit: "GRAM",
|
||||
optional: false,
|
||||
confidence: 0.9,
|
||||
},
|
||||
{
|
||||
rawText: "300 g pasta",
|
||||
canonicalIngredientId: "pasta_dry",
|
||||
displayNameSv: "Pasta",
|
||||
quantity: 300,
|
||||
unit: "GRAM",
|
||||
optional: false,
|
||||
confidence: 0.9,
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{ instructionSv: "Koka pastan enligt anvisning.", timerSeconds: 600, temperatureC: null },
|
||||
{
|
||||
instructionSv: "Stek kycklingen tills genomstekt.",
|
||||
timerSeconds: null,
|
||||
temperatureC: null,
|
||||
},
|
||||
],
|
||||
prepTimeMinutes: 10,
|
||||
cookTimeMinutes: 15,
|
||||
portions: 4,
|
||||
suggestedCuisine: "italian",
|
||||
suggestedMealTypes: ["dinner"],
|
||||
confidence: 0.82,
|
||||
};
|
||||
|
||||
case "GENERATE_RECIPE_OPTIONS":
|
||||
return {
|
||||
suggestions: [
|
||||
{
|
||||
titleSv: "Krämig kycklingwok med grönsaker",
|
||||
descriptionSv: "Snabb wok på det som finns hemma.",
|
||||
ingredients: [
|
||||
{
|
||||
canonicalIngredientId: "chicken_breast",
|
||||
displayNameSv: "Kycklingfilé",
|
||||
quantity: 400,
|
||||
unit: "GRAM",
|
||||
},
|
||||
{
|
||||
canonicalIngredientId: "rice_white",
|
||||
displayNameSv: "Ris",
|
||||
quantity: 3,
|
||||
unit: "DECILITER",
|
||||
},
|
||||
],
|
||||
steps: ["Koka riset.", "Woka kycklingen.", "Blanda och servera."],
|
||||
estimatedTimeMinutes: 25,
|
||||
confidence: 0.7,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
case "RANK_RECIPES": {
|
||||
const ids =
|
||||
typeof input === "object" && input !== null
|
||||
? ((input as { candidateIds?: string[] }).candidateIds ?? [])
|
||||
: [];
|
||||
return { rankedIds: ids, rationaleSv: null };
|
||||
}
|
||||
|
||||
case "PARSE_CRAVING":
|
||||
return { tags: ["creamy"], cuisine: null, maxKcal: null, confidence: 0.6 };
|
||||
|
||||
case "UPDATE_USER_MEMORY":
|
||||
return { memoryUpdates: [] };
|
||||
|
||||
case "GENERATE_WEEK_PLAN":
|
||||
return { entries: [], confidence: 0.5 };
|
||||
|
||||
case "MODERATE_RECIPE":
|
||||
return { flags: [], recommendation: "approve", confidence: 0.9 };
|
||||
|
||||
case "TRANSLATE_RECIPE": {
|
||||
// Deterministisk pseudo-översättning: bevarar alla tal och stegstruktur,
|
||||
// markerar texten med målspråket så flödet är testbart utan riktig AI.
|
||||
const inp = input as {
|
||||
targetLanguageTag: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
storageGuidance: string | null;
|
||||
steps: { stepNumber: number; instruction: string; tip: string | null }[];
|
||||
};
|
||||
const tag = `[${inp.targetLanguageTag}]`;
|
||||
return {
|
||||
title: `${tag} ${inp.title}`,
|
||||
description: inp.description ? `${tag} ${inp.description}` : null,
|
||||
storageGuidance: inp.storageGuidance ? `${tag} ${inp.storageGuidance}` : null,
|
||||
steps: inp.steps.map((s) => ({
|
||||
stepNumber: s.stepNumber,
|
||||
instruction: `${tag} ${s.instruction}`,
|
||||
tip: s.tip ? `${tag} ${s.tip}` : null,
|
||||
})),
|
||||
confidence: 0.85,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
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>;
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "test"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user