ci: trigga på master + formatfix inför Gitea Actions
CI / Typecheck, test & build (push) Failing after 2s
CI / Typecheck, test & build (push) Failing after 2s
This commit is contained in:
@@ -121,9 +121,7 @@ export class HttpAamosClient implements AamosClient {
|
||||
anonymizedImprovement: false,
|
||||
imageTraining: false,
|
||||
},
|
||||
...(options.systemInstruction
|
||||
? { systemInstruction: options.systemInstruction }
|
||||
: {}),
|
||||
...(options.systemInstruction ? { systemInstruction: options.systemInstruction } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -31,70 +31,86 @@ function customNodeFetch(input: string | URL | Request, init?: RequestInit): Pro
|
||||
return requestOnce(input, init, 5);
|
||||
}
|
||||
|
||||
function requestOnce(input: string | URL | Request, init: RequestInit | undefined, redirectsLeft: number): Promise<Response> {
|
||||
function requestOnce(
|
||||
input: string | URL | Request,
|
||||
init: RequestInit | undefined,
|
||||
redirectsLeft: number,
|
||||
): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const u = new URL(url);
|
||||
const isHttps = u.protocol === "https:";
|
||||
const mod = isHttps ? import("node:https") : import("node:http");
|
||||
mod.then((httpMod) => {
|
||||
const postData = init?.body ? String(init.body) : undefined;
|
||||
const headers: Record<string, string> = {};
|
||||
if (init?.headers) {
|
||||
if (init.headers instanceof Headers) {
|
||||
init.headers.forEach((v, k) => { headers[k] = v; });
|
||||
} else if (Array.isArray(init.headers)) {
|
||||
(init.headers as [string, string][]).forEach(([k, v]) => { if (k != null) headers[k] = v; });
|
||||
} else {
|
||||
Object.assign(headers, init.headers as Record<string, string>);
|
||||
}
|
||||
}
|
||||
if (postData && !headers["content-length"]) {
|
||||
headers["content-length"] = String(Buffer.byteLength(postData));
|
||||
}
|
||||
if (!headers["user-agent"]) {
|
||||
headers["user-agent"] = "Mozilla/5.0 (compatible; AAMOS-Gateway/1.0)";
|
||||
}
|
||||
|
||||
const req = httpMod.request(
|
||||
{
|
||||
hostname: u.hostname,
|
||||
port: u.port || (isHttps ? 443 : 80),
|
||||
path: u.pathname + u.search,
|
||||
method: init?.method || "GET",
|
||||
headers,
|
||||
timeout: 120_000,
|
||||
},
|
||||
(res) => {
|
||||
const status = res.statusCode ?? 200;
|
||||
const location = res.headers.location;
|
||||
if (status >= 300 && status < 400 && location && redirectsLeft > 0) {
|
||||
const nextUrl = new URL(location, url).toString();
|
||||
requestOnce(nextUrl, { ...init, method: "GET", body: undefined }, redirectsLeft - 1)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
mod
|
||||
.then((httpMod) => {
|
||||
const postData = init?.body ? String(init.body) : undefined;
|
||||
const headers: Record<string, string> = {};
|
||||
if (init?.headers) {
|
||||
if (init.headers instanceof Headers) {
|
||||
init.headers.forEach((v, k) => {
|
||||
headers[k] = v;
|
||||
});
|
||||
} else if (Array.isArray(init.headers)) {
|
||||
(init.headers as [string, string][]).forEach(([k, v]) => {
|
||||
if (k != null) headers[k] = v;
|
||||
});
|
||||
} else {
|
||||
Object.assign(headers, init.headers as Record<string, string>);
|
||||
}
|
||||
}
|
||||
if (postData && !headers["content-length"]) {
|
||||
headers["content-length"] = String(Buffer.byteLength(postData));
|
||||
}
|
||||
if (!headers["user-agent"]) {
|
||||
headers["user-agent"] = "Mozilla/5.0 (compatible; AAMOS-Gateway/1.0)";
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => { chunks.push(chunk); });
|
||||
res.on("end", () => {
|
||||
const body = Buffer.concat(chunks);
|
||||
resolve(
|
||||
new Response(body, {
|
||||
status,
|
||||
statusText: res.statusMessage ?? "OK",
|
||||
headers: new Headers(Object.entries(res.headers).map(([k, v]) => [k, String(v)])),
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", (err) => reject(err));
|
||||
req.on("timeout", () => { req.destroy(); reject(new Error("Request timeout")); });
|
||||
if (postData) req.write(postData);
|
||||
req.end();
|
||||
}).catch(reject);
|
||||
const req = httpMod.request(
|
||||
{
|
||||
hostname: u.hostname,
|
||||
port: u.port || (isHttps ? 443 : 80),
|
||||
path: u.pathname + u.search,
|
||||
method: init?.method || "GET",
|
||||
headers,
|
||||
timeout: 120_000,
|
||||
},
|
||||
(res) => {
|
||||
const status = res.statusCode ?? 200;
|
||||
const location = res.headers.location;
|
||||
if (status >= 300 && status < 400 && location && redirectsLeft > 0) {
|
||||
const nextUrl = new URL(location, url).toString();
|
||||
requestOnce(nextUrl, { ...init, method: "GET", body: undefined }, redirectsLeft - 1)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on("end", () => {
|
||||
const body = Buffer.concat(chunks);
|
||||
resolve(
|
||||
new Response(body, {
|
||||
status,
|
||||
statusText: res.statusMessage ?? "OK",
|
||||
headers: new Headers(Object.entries(res.headers).map(([k, v]) => [k, String(v)])),
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", (err) => reject(err));
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Request timeout"));
|
||||
});
|
||||
if (postData) req.write(postData);
|
||||
req.end();
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -163,9 +179,7 @@ const geminiFridgeResponseSchema = z.object({
|
||||
konfidens: z.number().min(0).max(1),
|
||||
}),
|
||||
),
|
||||
imageQualityIssues: z
|
||||
.array(z.enum(["dark", "blurry", "occlusion", "too_far"]))
|
||||
.default([]),
|
||||
imageQualityIssues: z.array(z.enum(["dark", "blurry", "occlusion", "too_far"])).default([]),
|
||||
});
|
||||
|
||||
function parseDate(value: string | null | undefined): string | null {
|
||||
@@ -184,14 +198,19 @@ function parseDate(value: string | null | undefined): string | null {
|
||||
const c = parseInt(cStr, 10);
|
||||
if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c)) return null;
|
||||
// YYYY-MM-DD
|
||||
if (a > 2000 && b <= 12 && c <= 31) return `${a}-${String(b).padStart(2, "0")}-${String(c).padStart(2, "0")}`;
|
||||
if (a > 2000 && b <= 12 && c <= 31)
|
||||
return `${a}-${String(b).padStart(2, "0")}-${String(c).padStart(2, "0")}`;
|
||||
// DD-MM-YYYY
|
||||
if (c > 2000 && b <= 12 && a <= 31) return `${c}-${String(b).padStart(2, "0")}-${String(a).padStart(2, "0")}`;
|
||||
if (c > 2000 && b <= 12 && a <= 31)
|
||||
return `${c}-${String(b).padStart(2, "0")}-${String(a).padStart(2, "0")}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseQuantity(value: string | number | null | undefined): { quantity: number | null; unit: string | null } {
|
||||
function parseQuantity(value: string | number | null | undefined): {
|
||||
quantity: number | null;
|
||||
unit: string | null;
|
||||
} {
|
||||
if (value == null) return { quantity: null, unit: null };
|
||||
if (typeof value === "number") return { quantity: value, unit: null };
|
||||
const text = String(value).trim().replace(/,/g, ".");
|
||||
@@ -284,17 +303,35 @@ export class GeminiAamosClient implements AamosClient {
|
||||
switch (taskType) {
|
||||
case "ANALYZE_FRIDGE_IMAGE":
|
||||
case "ANALYZE_PANTRY_IMAGE":
|
||||
return this.analyzeStorageImage(taskType, parsedInput.data as TaskInput<"ANALYZE_FRIDGE_IMAGE">, options) as Promise<AamosResult<T>>;
|
||||
return this.analyzeStorageImage(
|
||||
taskType,
|
||||
parsedInput.data as TaskInput<"ANALYZE_FRIDGE_IMAGE">,
|
||||
options,
|
||||
) as Promise<AamosResult<T>>;
|
||||
case "GENERATE_RECIPE_CANDIDATES":
|
||||
return this.generateRecipeCandidates(parsedInput.data as TaskInput<"GENERATE_RECIPE_CANDIDATES">, options) as Promise<AamosResult<T>>;
|
||||
return this.generateRecipeCandidates(
|
||||
parsedInput.data as TaskInput<"GENERATE_RECIPE_CANDIDATES">,
|
||||
options,
|
||||
) as Promise<AamosResult<T>>;
|
||||
case "ANALYZE_MEAL_IMAGE":
|
||||
return this.analyzeMealImage(parsedInput.data as TaskInput<"ANALYZE_MEAL_IMAGE">, options) as Promise<AamosResult<T>>;
|
||||
return this.analyzeMealImage(
|
||||
parsedInput.data as TaskInput<"ANALYZE_MEAL_IMAGE">,
|
||||
options,
|
||||
) as Promise<AamosResult<T>>;
|
||||
case "READ_NUTRITION_LABEL":
|
||||
return this.readNutritionLabel(parsedInput.data as TaskInput<"READ_NUTRITION_LABEL">, options) as Promise<AamosResult<T>>;
|
||||
return this.readNutritionLabel(
|
||||
parsedInput.data as TaskInput<"READ_NUTRITION_LABEL">,
|
||||
options,
|
||||
) as Promise<AamosResult<T>>;
|
||||
case "READ_EXPIRY_DATE":
|
||||
return this.readExpiryDate(parsedInput.data as TaskInput<"READ_EXPIRY_DATE">, options) as Promise<AamosResult<T>>;
|
||||
return this.readExpiryDate(
|
||||
parsedInput.data as TaskInput<"READ_EXPIRY_DATE">,
|
||||
options,
|
||||
) as Promise<AamosResult<T>>;
|
||||
case "READ_RECEIPT":
|
||||
return this.readReceipt(parsedInput.data as TaskInput<"READ_RECEIPT">, options) as Promise<AamosResult<T>>;
|
||||
return this.readReceipt(parsedInput.data as TaskInput<"READ_RECEIPT">, options) as Promise<
|
||||
AamosResult<T>
|
||||
>;
|
||||
default:
|
||||
return {
|
||||
status: "failed",
|
||||
@@ -304,7 +341,10 @@ export class GeminiAamosClient implements AamosClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchOneImage(url: string, timeoutMs: number): Promise<{ inline_data: { mime_type: string; data: string } }> {
|
||||
private async fetchOneImage(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<{ inline_data: { mime_type: string; data: string } }> {
|
||||
if (url.startsWith("data:")) {
|
||||
const match = url.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (match && match[1] && match[2]) {
|
||||
@@ -329,7 +369,9 @@ export class GeminiAamosClient implements AamosClient {
|
||||
const imageCount = Math.min(input.imageUrls.length, 6);
|
||||
const estimatedCostUsd = imageCount * COST_ESTIMATE_PER_IMAGE_USD;
|
||||
if (await this.isOverBudget(estimatedCostUsd)) {
|
||||
console.warn("[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.");
|
||||
console.warn(
|
||||
"[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.",
|
||||
);
|
||||
}
|
||||
|
||||
const imageParts = await Promise.all(
|
||||
@@ -372,7 +414,11 @@ export class GeminiAamosClient implements AamosClient {
|
||||
|
||||
const geminiBody = (await res.json()) as {
|
||||
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
|
||||
usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number };
|
||||
usageMetadata?: {
|
||||
promptTokenCount?: number;
|
||||
candidatesTokenCount?: number;
|
||||
totalTokenCount?: number;
|
||||
};
|
||||
};
|
||||
const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
|
||||
|
||||
@@ -381,7 +427,11 @@ export class GeminiAamosClient implements AamosClient {
|
||||
const json = JSON.parse(text);
|
||||
const safe = geminiFridgeResponseSchema.safeParse(json);
|
||||
if (!safe.success) {
|
||||
return { status: "failed", output: null, error: `Gemini-svar matchar inte schema: ${safe.error.message}` };
|
||||
return {
|
||||
status: "failed",
|
||||
output: null,
|
||||
error: `Gemini-svar matchar inte schema: ${safe.error.message}`,
|
||||
};
|
||||
}
|
||||
parsed = safe.data;
|
||||
} catch {
|
||||
@@ -399,7 +449,10 @@ export class GeminiAamosClient implements AamosClient {
|
||||
|
||||
return {
|
||||
status: items.length > 0 ? "ok" : "uncertain",
|
||||
output: { items, imageQualityIssues: parsed.imageQualityIssues } as TaskOutput<"ANALYZE_FRIDGE_IMAGE">,
|
||||
output: {
|
||||
items,
|
||||
imageQualityIssues: parsed.imageQualityIssues,
|
||||
} as TaskOutput<"ANALYZE_FRIDGE_IMAGE">,
|
||||
modelVersion: this.cfg.model,
|
||||
promptVersion: this.cfg.promptVersion,
|
||||
latencyMs,
|
||||
@@ -426,7 +479,9 @@ export class GeminiAamosClient implements AamosClient {
|
||||
const imageCount = Math.min(imageUrls.length, 6);
|
||||
const estimatedCostUsd = imageCount * costEstimatePerImage;
|
||||
if (await this.isOverBudget(estimatedCostUsd)) {
|
||||
console.warn("[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.");
|
||||
console.warn(
|
||||
"[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.",
|
||||
);
|
||||
}
|
||||
|
||||
const imageParts = await this.fetchImageParts(imageUrls, this.cfg.timeoutMs);
|
||||
@@ -465,7 +520,11 @@ export class GeminiAamosClient implements AamosClient {
|
||||
|
||||
const geminiBody = (await res.json()) as {
|
||||
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
|
||||
usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number };
|
||||
usageMetadata?: {
|
||||
promptTokenCount?: number;
|
||||
candidatesTokenCount?: number;
|
||||
totalTokenCount?: number;
|
||||
};
|
||||
};
|
||||
const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
|
||||
|
||||
@@ -587,9 +646,7 @@ export class GeminiAamosClient implements AamosClient {
|
||||
imageUrls: string[],
|
||||
timeoutMs: number,
|
||||
): Promise<Array<{ inline_data: { mime_type: string; data: string } }>> {
|
||||
return Promise.all(
|
||||
imageUrls.slice(0, 6).map((url) => this.fetchOneImage(url, timeoutMs)),
|
||||
);
|
||||
return Promise.all(imageUrls.slice(0, 6).map((url) => this.fetchOneImage(url, timeoutMs)));
|
||||
}
|
||||
|
||||
private async generateRecipeCandidates(
|
||||
@@ -603,7 +660,9 @@ export class GeminiAamosClient implements AamosClient {
|
||||
const totalCandidates = input.targetMatrix.reduce((sum, t) => sum + t.count, 0);
|
||||
const estimatedCostUsd = totalCandidates * 0.003;
|
||||
if (await this.isOverBudget(estimatedCostUsd)) {
|
||||
console.warn("[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.");
|
||||
console.warn(
|
||||
"[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.",
|
||||
);
|
||||
}
|
||||
|
||||
const prompt = this.buildRecipeGenerationPrompt(input, locale);
|
||||
@@ -634,7 +693,11 @@ export class GeminiAamosClient implements AamosClient {
|
||||
|
||||
const geminiBody = (await res.json()) as {
|
||||
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
|
||||
usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number };
|
||||
usageMetadata?: {
|
||||
promptTokenCount?: number;
|
||||
candidatesTokenCount?: number;
|
||||
totalTokenCount?: number;
|
||||
};
|
||||
};
|
||||
const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
|
||||
|
||||
@@ -644,12 +707,16 @@ export class GeminiAamosClient implements AamosClient {
|
||||
// Defensiv normalisering: Gemini kan returnera rejectedPrompts som objekt istället för strängar.
|
||||
if (Array.isArray(json.rejectedPrompts)) {
|
||||
json.rejectedPrompts = json.rejectedPrompts.map((p: unknown) =>
|
||||
typeof p === "string" ? p : JSON.stringify(p)
|
||||
typeof p === "string" ? p : JSON.stringify(p),
|
||||
);
|
||||
}
|
||||
const safe = generateRecipeCandidatesOutput.safeParse(json);
|
||||
if (!safe.success) {
|
||||
return { status: "failed", output: null, error: `Gemini-svar matchar inte schema: ${safe.error.message}` };
|
||||
return {
|
||||
status: "failed",
|
||||
output: null,
|
||||
error: `Gemini-svar matchar inte schema: ${safe.error.message}`,
|
||||
};
|
||||
}
|
||||
parsed = safe.data;
|
||||
} catch {
|
||||
@@ -690,13 +757,16 @@ export class GeminiAamosClient implements AamosClient {
|
||||
locale: LocaleContext,
|
||||
): string {
|
||||
const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
|
||||
const catalog = input.canonicalIngredientsCatalog.map((i) =>
|
||||
`- ${i.id} (${i.nameSv}, ${i.category}, enhet: ${i.defaultUnit}, vegan: ${i.isVegan}, veg: ${i.isVegetarian}, gluten: ${i.containsGluten}, laktos: ${i.containsLactose})`
|
||||
).join("\n");
|
||||
const catalog = input.canonicalIngredientsCatalog
|
||||
.map(
|
||||
(i) =>
|
||||
`- ${i.id} (${i.nameSv}, ${i.category}, enhet: ${i.defaultUnit}, vegan: ${i.isVegan}, veg: ${i.isVegetarian}, gluten: ${i.containsGluten}, laktos: ${i.containsLactose})`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const targets = input.targetMatrix.map((t) =>
|
||||
`- ${t.mealType} × ${t.mainIngredientId} × ${t.dietVariant}: ${t.count} st`
|
||||
).join("\n");
|
||||
const targets = input.targetMatrix
|
||||
.map((t) => `- ${t.mealType} × ${t.mainIngredientId} × ${t.dietVariant}: ${t.count} st`)
|
||||
.join("\n");
|
||||
|
||||
const constraints = input.constraints;
|
||||
|
||||
@@ -939,7 +1009,7 @@ Språk: ${lang}.`;
|
||||
|
||||
private estimateCostUsd(inputTokens: number, outputTokens: number): number {
|
||||
// Gemini 2.5 Flash pricing (Aug 2025): ~$0.075/1M input, $0.30/1M output.
|
||||
return inputTokens * 0.075e-6 + outputTokens * 0.30e-6;
|
||||
return inputTokens * 0.075e-6 + outputTokens * 0.3e-6;
|
||||
}
|
||||
|
||||
private async isOverBudget(estimateUsd: number): Promise<boolean> {
|
||||
|
||||
@@ -220,8 +220,18 @@ export function mockOutputFor(taskType: AamosTaskType, input: unknown): unknown
|
||||
case "GENERATE_RECIPE_CANDIDATES": {
|
||||
// Deterministisk mock: returnerar en enkel kandidat per target-cell
|
||||
const inp = input as {
|
||||
targetMatrix?: Array<{ mealType: string; mainIngredientId: string; dietVariant: string; count: number }>;
|
||||
canonicalIngredientsCatalog?: Array<{ id: string; nameSv: string; category: string; defaultUnit: string }>;
|
||||
targetMatrix?: Array<{
|
||||
mealType: string;
|
||||
mainIngredientId: string;
|
||||
dietVariant: string;
|
||||
count: number;
|
||||
}>;
|
||||
canonicalIngredientsCatalog?: Array<{
|
||||
id: string;
|
||||
nameSv: string;
|
||||
category: string;
|
||||
defaultUnit: string;
|
||||
}>;
|
||||
};
|
||||
const targets = inp.targetMatrix ?? [];
|
||||
const catalog = inp.canonicalIngredientsCatalog ?? [];
|
||||
@@ -265,8 +275,18 @@ export function mockOutputFor(taskType: AamosTaskType, input: unknown): unknown
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{ instructionSv: "Förbered ingredienserna.", timerSeconds: null, temperatureC: null, tip: null },
|
||||
{ instructionSv: "Stek och låt koka klart.", timerSeconds: 900, temperatureC: null, tip: null },
|
||||
{
|
||||
instructionSv: "Förbered ingredienserna.",
|
||||
timerSeconds: null,
|
||||
temperatureC: null,
|
||||
tip: null,
|
||||
},
|
||||
{
|
||||
instructionSv: "Stek och låt koka klart.",
|
||||
timerSeconds: 900,
|
||||
temperatureC: null,
|
||||
tip: null,
|
||||
},
|
||||
],
|
||||
storageGuidanceSv: "Förvara i kylskåp upp till 3 dagar.",
|
||||
mealPrepFriendly: false,
|
||||
|
||||
@@ -282,34 +282,47 @@ export const generateRecipeOptionsOutput = z.object({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: [] })),
|
||||
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"),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user