659d0f9290
- config.ts: tillåt AAMOS_MODE=http|gemini i prod; mock förbjudet - first-deploy.sh: Grind 1 accepterar gemini (kräv GEMINI_API_KEY); Grind 5 kör eval:scan mot gemini; Grind 8 behandlar gemini som riktig AI - gemini.ts: stöd för data-URLer (inline base64-bilder) i fetchOneImage - eval-fixtures: 4 lokala bilder (fridge-1, fridge-2, label-1, receipt-1) - scan-eval.ts: läser lokala fixtures, inga externa hämtningar - capture-eval.ts: använder lokal fixture som data-URL
171 lines
5.6 KiB
TypeScript
171 lines
5.6 KiB
TypeScript
import { config as loadDotenv } from "dotenv";
|
||
import { existsSync, readFileSync } from "node:fs";
|
||
import path from "node:path";
|
||
for (const candidate of [".env", "../.env", "../../.env"]) {
|
||
const p = path.resolve(process.cwd(), candidate);
|
||
if (existsSync(p)) {
|
||
loadDotenv({ path: p });
|
||
break;
|
||
}
|
||
}
|
||
|
||
import { fileURLToPath } from "node:url";
|
||
import { GeminiAamosClient, type BudgetStore } from "@app/ai-contracts";
|
||
|
||
/**
|
||
* Live-evaluering av Gemini-lärar-tier för kylskåps-/skafferiskanning.
|
||
*
|
||
* pnpm --filter @app/worker eval:scan
|
||
*
|
||
* Kräver AAMOS_MODE=gemini + GEMINI_API_KEY. Använder lokala fixture-bilder
|
||
* (committade i repot) – inga externa hämtningar, inga 429.
|
||
*/
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const FIXTURES = path.resolve(path.dirname(__filename), "fixtures");
|
||
|
||
function fileToDataUrl(filePath: string): string {
|
||
const buf = readFileSync(filePath);
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
const mime = ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg";
|
||
return `data:${mime};base64,${buf.toString("base64")}`;
|
||
}
|
||
|
||
interface ScanEvalCase {
|
||
id: string;
|
||
imageUrls: string[];
|
||
locationType: "fridge" | "pantry";
|
||
marketLocale: string;
|
||
checks: (items: Array<{
|
||
detectedName: string;
|
||
brand: string | null;
|
||
estimatedQuantity: number | null;
|
||
unit: string | null;
|
||
confidence: number;
|
||
requiresConfirmation: boolean;
|
||
}>) => { name: string; passed: boolean }[];
|
||
}
|
||
|
||
const CASES: ScanEvalCase[] = [
|
||
{
|
||
id: "fridge-1",
|
||
imageUrls: [fileToDataUrl(path.join(FIXTURES, "fridge-1.jpg"))],
|
||
locationType: "fridge",
|
||
marketLocale: "sv-SE",
|
||
checks: (items) => {
|
||
const milk = items.find((i) => /mjölk|milk|grädde|cream/i.test(i.detectedName));
|
||
return [
|
||
{ name: "hittade mjölkprodukt", passed: !!milk },
|
||
{ name: "konfidens > 0.5", passed: !!milk && milk.confidence > 0.5 },
|
||
{ name: "kräver bekräftelse om låg konfidens", passed: !!milk && (milk.confidence >= 0.92 || milk.requiresConfirmation) },
|
||
];
|
||
},
|
||
},
|
||
{
|
||
id: "fridge-2",
|
||
imageUrls: [fileToDataUrl(path.join(FIXTURES, "fridge-2.jpg"))],
|
||
locationType: "fridge",
|
||
marketLocale: "sv-SE",
|
||
checks: (items) => {
|
||
const found = items.find((i) => /gurka|inlagd|konserverad|burk|pickle/i.test(i.detectedName));
|
||
return [
|
||
{ name: "hittade konserverad produkt", passed: !!found },
|
||
{ name: "konfidens > 0.5", passed: !!found && found.confidence > 0.5 },
|
||
{ name: "minst ett item med confidence > 0.7", passed: items.some((i) => i.confidence > 0.7) },
|
||
];
|
||
},
|
||
},
|
||
];
|
||
|
||
class SilentBudgetStore implements BudgetStore {
|
||
private spend = 0;
|
||
async getDailySpendUsd(): Promise<number> {
|
||
return this.spend;
|
||
}
|
||
async incrementDailySpendUsd(amountUsd: number): Promise<number> {
|
||
this.spend += amountUsd;
|
||
return this.spend;
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
const mode = process.env.AAMOS_MODE ?? "mock";
|
||
const apiKey = process.env.GEMINI_API_KEY;
|
||
|
||
if (mode !== "gemini" || !apiKey) {
|
||
console.error("[eval:scan] KRÄVER AAMOS_MODE=gemini + GEMINI_API_KEY.");
|
||
process.exit(1);
|
||
}
|
||
|
||
const client = new GeminiAamosClient({
|
||
apiKey,
|
||
model: process.env.GEMINI_MODEL,
|
||
timeoutMs: process.env.GEMINI_TIMEOUT_MS ? Number(process.env.GEMINI_TIMEOUT_MS) : 60_000,
|
||
dailyBudgetUsd: process.env.GEMINI_DAILY_BUDGET_USD ? Number(process.env.GEMINI_DAILY_BUDGET_USD) : 10,
|
||
budgetStore: new SilentBudgetStore(),
|
||
});
|
||
|
||
console.log(`[eval:scan] Gemini-lärar-tier live-evaluering – ${CASES.length} fall\n`);
|
||
|
||
let totalChecks = 0;
|
||
let failedChecks = 0;
|
||
let totalCostUsd = 0;
|
||
let totalLatencyMs = 0;
|
||
|
||
for (const evalCase of CASES) {
|
||
const started = Date.now();
|
||
let checks: { name: string; passed: boolean }[] = [];
|
||
let error: string | null = null;
|
||
|
||
try {
|
||
const result = await client.runTask(
|
||
evalCase.locationType === "fridge" ? "ANALYZE_FRIDGE_IMAGE" : "ANALYZE_PANTRY_IMAGE",
|
||
{
|
||
imageUrls: evalCase.imageUrls,
|
||
locationType: evalCase.locationType,
|
||
marketLocale: evalCase.marketLocale,
|
||
knownItems: [],
|
||
},
|
||
);
|
||
|
||
if (result.status !== "ok" || !result.output) {
|
||
error = `status=${result.status}: ${result.error ?? "okänt fel"}`;
|
||
checks = [{ name: "Gemini svarade ok", passed: false }];
|
||
} else {
|
||
const items = result.output.items;
|
||
checks = [{ name: "Gemini svarade ok", passed: true }, ...evalCase.checks(items)];
|
||
}
|
||
|
||
if (result.costUsd) totalCostUsd += result.costUsd;
|
||
if (result.latencyMs) totalLatencyMs += result.latencyMs;
|
||
} catch (err) {
|
||
error = (err as Error).message;
|
||
checks = [{ name: "Gemini svarade ok", passed: false }];
|
||
}
|
||
|
||
const ms = Date.now() - started;
|
||
const passed = checks.every((c) => c.passed);
|
||
totalChecks += checks.length;
|
||
failedChecks += checks.filter((c) => !c.passed).length;
|
||
|
||
console.log(`${passed ? "✓" : "✗"} ${evalCase.id} (${ms} ms)`);
|
||
for (const c of checks) if (!c.passed) console.log(` ✗ ${c.name}`);
|
||
if (error) console.log(` fel: ${error}`);
|
||
}
|
||
|
||
console.log(`\n[eval:scan] ${CASES.length} fall, ${totalChecks} kontroller, ${failedChecks} fallerade.`);
|
||
console.log(`[eval:scan] Total latens: ${totalLatencyMs} ms, total kostnad: ~$${totalCostUsd.toFixed(6)}`);
|
||
|
||
if (failedChecks > 0) {
|
||
console.log("[eval:scan] UNDERKÄND – åtgärda innan Skiva 1 går till fälttest.");
|
||
process.exit(1);
|
||
}
|
||
console.log("[eval:scan] GODKÄND – fälttest kan börja.");
|
||
process.exit(0);
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error("[eval:scan] KRASCH:", err);
|
||
process.exit(1);
|
||
});
|