Files
Cibello-app/packages/ai-contracts/test/gemini.test.ts
T
Claude ee5eb32e09 chore(modell): rikta ALLT mot gemini-3.1-flash-lite + fixa kostnadsberakning
Koden/docs pastod overallt gemini-3.5-flash-lite fast .env kor 3.1. Nu enhetligt
3.1 i .env.example, config.ts-default, gemini.ts-default, tester, eval och docs
(09 + 10). Dessutom en RIKTIG bugg: estimateCostUsd rakna pa 3.5-priser
($0,30/$2,50) medan vi kor 3.1 -> overskattade spend och troffade budgetlarmet
for tidigt. Nu 3.1-priser ($0,25/$1,50). docs/10 helt omraknad pa 3.1 (per
uppgift, per anvandare, skala, 100k-trial-scenariot). Referenslinje kvar som
visar 3.5-priset for att motivera valet.
2026-08-18 20:16:37 +00:00

189 lines
6.6 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 { describe, it, expect } from "vitest";
import { GeminiAamosClient, MemoryBudgetStore, parseDate, isPlausibleFoodDate } from "../src/gemini.js";
import type { AamosRequestEnvelope } from "../src/tasks.js";
const FIXTURE_RESPONSE = {
candidates: [
{
content: {
parts: [
{
text: JSON.stringify({
items: [
{
produkt: "Mellanmjölk 1,5% fett",
varumarke: "Arla",
kvantitet: 1,
enhet: "liter",
kategori: "Mejeri",
bastaFore: null,
sistaForbruk: null,
konfidens: 0.98,
},
{
produkt: "Smör",
varumarke: "Arla",
kvantitet: "500 g",
enhet: "g",
kategori: "Mejeri",
bastaFore: null,
konfidens: 0.95,
},
],
imageQualityIssues: [],
}),
},
],
},
},
],
usageMetadata: { promptTokenCount: 392, candidatesTokenCount: 121 },
};
function makeFetch(imageBytes: Buffer): typeof fetch {
return async (url: string | URL | Request, init?: RequestInit) => {
const urlStr = url.toString();
if (urlStr.includes("/mock-s3/")) {
return new Response(imageBytes, { status: 200, headers: { "content-type": "image/jpeg" } });
}
if (urlStr.includes("/models/") && init?.method === "POST") {
return new Response(JSON.stringify(FIXTURE_RESPONSE), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response("not found", { status: 404 });
};
}
// A tiny 1x1 JPEG.
const JPEG_BYTES = Buffer.from(
"/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAA//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8Af//Z",
"base64",
);
describe("GeminiAamosClient", () => {
it("analyzes fridge image with recorded fixture", async () => {
const budget = new MemoryBudgetStore();
const client = new GeminiAamosClient({
apiKey: "test-key",
model: "gemini-3.1-flash-lite",
fetchImpl: makeFetch(JPEG_BYTES),
budgetStore: budget,
dailyBudgetUsd: 10,
});
const result = await client.runTask("ANALYZE_FRIDGE_IMAGE", {
imageUrls: ["http://localhost/v1/mock-s3/fridge.jpg"],
locationType: "fridge",
marketLocale: "sv-SE",
knownItems: [],
});
expect(result.status).toBe("ok");
expect(result.output).not.toBeNull();
expect(result.modelVersion).toBe("gemini-3.1-flash-lite");
expect(result.costUsd).toBeGreaterThan(0);
expect(result.inputTokens).toBe(392);
expect(result.outputTokens).toBe(121);
expect(result.output).not.toBeNull();
const items = result.output!.items;
expect(items).toHaveLength(2);
expect(items[0]?.detectedName).toBe("Mellanmjölk 1,5% fett");
expect(items[0]?.brand).toBe("Arla");
expect(items[0]?.estimatedQuantity).toBe(1);
expect(items[0]?.unit).toBe("LITER");
expect(items[0]?.confidence).toBe(0.98);
expect(items[1]?.detectedName).toBe("Smör");
expect(items[1]?.estimatedQuantity).toBe(500);
expect(items[1]?.unit).toBe("GRAM");
const spend = await budget.getDailySpendUsd();
expect(spend).toBe(result.costUsd);
});
it("larmar men blockerar INTE när dagsbudgeten överskrids", async () => {
const budget = new MemoryBudgetStore();
const client = new GeminiAamosClient({
apiKey: "test-key",
fetchImpl: makeFetch(JPEG_BYTES),
budgetStore: budget,
dailyBudgetUsd: 0.00001, // i princip noll men budgeten ska ALDRIG stoppa en skanning
});
const result = await client.runTask("ANALYZE_FRIDGE_IMAGE", {
imageUrls: ["http://localhost/v1/mock-s3/fridge.jpg"],
locationType: "fridge",
marketLocale: "sv-SE",
knownItems: [],
});
// Produktbeslut (MIKRO 12): ingen dygnsspärr. Överskriden budget larmar i loggen
// men skanningen fortsätter och lyckas — annars slutar appen fungera för användaren.
expect(result.status).toBe("ok");
expect(result.output).not.toBeNull();
});
it("rejects unsupported task types", async () => {
const client = new GeminiAamosClient({
apiKey: "test-key",
fetchImpl: makeFetch(JPEG_BYTES),
});
const result = await client.runTask("DEDUPLICATE_INVENTORY", { items: [] });
expect(result.status).toBe("failed");
expect(result.error).toContain("DEDUPLICATE_INVENTORY");
});
it("validates input before calling Gemini", async () => {
const client = new GeminiAamosClient({
apiKey: "test-key",
fetchImpl: makeFetch(JPEG_BYTES),
});
const result = await client.runTask("ANALYZE_FRIDGE_IMAGE", {
imageUrls: [],
locationType: "fridge",
marketLocale: "sv-SE",
knownItems: [],
} as unknown as {
imageUrls: string[];
locationType: string;
marketLocale: string;
knownItems: string[];
});
expect(result.status).toBe("failed");
expect(result.error).toContain("Kontraktsfel");
});
});
describe("parseDate (svenska bäst-före-stämplar)", () => {
it("läser 2-siffrigt år och kompakta format", () => {
expect(parseDate("25-08-30")).toBe("2025-08-30"); // YY-MM-DD
expect(parseDate("250830")).toBe("2025-08-30"); // YYMMDD
expect(parseDate("20250830")).toBe("2025-08-30"); // YYYYMMDD
expect(parseDate("2025-08-30")).toBe("2025-08-30"); // ISO
expect(parseDate("30-08-2025")).toBe("2025-08-30"); // DD-MM-YYYY
expect(parseDate("skräp")).toBeNull();
});
});
describe("isPlausibleFoodDate (rimlighetsspärr mot feltolkade årtal)", () => {
const TODAY = new Date("2026-08-18T00:00:00Z");
it("släpper igenom rimliga datum (nära i tid och framåt)", () => {
expect(isPlausibleFoodDate("2026-09-04", TODAY)).toBe(true); // ~2 v framåt
expect(isPlausibleFoodDate("2026-08-01", TODAY)).toBe(true); // nyss passerat
expect(isPlausibleFoodDate("2027-06-01", TODAY)).toBe(true); // torrvara framåt
});
it("kastar orimliga datum grovt feltolkat årtal", () => {
// Grädden i fälttestet: stämpel lästes som 2024 → ~2 år bak = feltolkning, inte utgången vara.
expect(isPlausibleFoodDate("2024-09-26", TODAY)).toBe(false);
expect(isPlausibleFoodDate("2035-01-01", TODAY)).toBe(false); // orimligt långt fram
expect(isPlausibleFoodDate(null, TODAY)).toBe(false);
expect(isPlausibleFoodDate("skräp", TODAY)).toBe(false);
});
});