Files
Cibello-app/packages/ai-contracts/test/gemini.test.ts
T
Claude 5cf9f8ab0d fix(ai): las aldrig gissade utgangsdatum – skarpt prompt + rimlighetssparr
Gemini gissade fel artal pa kryptiska tryckstamplar (t.ex. "2024" pa en farsk
gradde) vilket kan trigga falska "utgangen"-varningar. Tva lager:
- Prompt: modellen far aldrig gissa artal, returnerar null vid tvetydiga/
  kryptiska koder (buildFridgePrompt + buildExpiryPrompt).
- Kod: ny isPlausibleFoodDate() nollar orimligt gamla/framtida datum i
  toDetectedItems. Hellre inget datum an ett felaktigt (spec §13).
promptVersion -> gemini-fridge-v2. Tester 7/7 grona.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WZdew6cWn1MeHqoYWFfxzo
2026-08-18 11:29:11 +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.5-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.5-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);
});
});