ci: trigga på master + formatfix inför Gitea Actions
CI / Typecheck, test & build (push) Failing after 2s

This commit is contained in:
Sven (AAMOS AI)
2026-08-13 17:25:14 +07:00
parent 61d60931ad
commit c32a7e33c7
141 changed files with 40555 additions and 45210 deletions
+1 -3
View File
@@ -121,9 +121,7 @@ export class HttpAamosClient implements AamosClient {
anonymizedImprovement: false,
imageTraining: false,
},
...(options.systemInstruction
? { systemInstruction: options.systemInstruction }
: {}),
...(options.systemInstruction ? { systemInstruction: options.systemInstruction } : {}),
},
};
+161 -91
View File
@@ -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> {
+24 -4
View File
@@ -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,
+41 -28
View File
@@ -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"),
});