feat(gemini): Skiva 1 – Gemini 2.5 Flash som lärar-tier för kylskåpsskanning
- Gemini-adapter bakom AamosClient-interface (AAMOS_MODE=gemini) - Serversida/worker: hämtar bild, anropar Gemini, mappar mot canonical_ingredients - Kostnad/tokens bokförs i ai_usage_counters; global dagsbudget via BudgetStore - Redis-backed budget i worker, in-memory i tester - Migration 0019: ai_cost_usd_microcents - Hermetiska tester med inspelad fixture; separat pnpm eval:scan - docs/09 uppdaterad ärligt: AAMOS-status, Gemini-flöde, säkerhet/kostnad - REQUIRE_REAL=1 stödjer AAMOS_MODE=gemini; deploy-grind uppdaterad
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { GeminiAamosClient, MemoryBudgetStore } 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-2.5-flash",
|
||||
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-2.5-flash");
|
||||
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("blocks call when daily budget exceeded", async () => {
|
||||
const budget = new MemoryBudgetStore();
|
||||
const client = new GeminiAamosClient({
|
||||
apiKey: "test-key",
|
||||
fetchImpl: makeFetch(JPEG_BYTES),
|
||||
budgetStore: budget,
|
||||
dailyBudgetUsd: 0.00001, // essentially zero
|
||||
});
|
||||
|
||||
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("failed");
|
||||
expect(result.error).toContain("budget");
|
||||
});
|
||||
|
||||
it("rejects unsupported task types", async () => {
|
||||
const client = new GeminiAamosClient({
|
||||
apiKey: "test-key",
|
||||
fetchImpl: makeFetch(JPEG_BYTES),
|
||||
});
|
||||
|
||||
const result = await client.runTask(
|
||||
"READ_RECEIPT",
|
||||
{ imageUrls: ["http://localhost/v1/mock-s3/receipt.jpg"], marketLocale: "sv-SE" },
|
||||
);
|
||||
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.error).toContain("READ_RECEIPT");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user