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:
Sven (AAMOS AI)
2026-08-08 03:23:44 +07:00
parent 7894a1ec06
commit 050c958285
18 changed files with 1055 additions and 86 deletions
+31 -3
View File
@@ -12,7 +12,8 @@ for (const candidate of [".env", "../.env", "../../.env"]) {
}
import { createHmac } from "node:crypto";
import { createDatabase, type Database } from "@app/database";
import { createAamosClient, type AamosClient } from "@app/ai-contracts";
import { createAamosClient, type AamosClient, type BudgetStore } from "@app/ai-contracts";
import type { Redis } from "ioredis";
export interface WorkerContext {
db: Database;
@@ -23,13 +24,40 @@ export interface WorkerContext {
close: () => Promise<void>;
}
export function createContext(): WorkerContext {
class RedisBudgetStore implements BudgetStore {
constructor(
private readonly redis: Redis,
private readonly key: string,
) {}
async getDailySpendUsd(): Promise<number> {
const val = await this.redis.get(this.key);
return val ? Number(val) : 0;
}
async incrementDailySpendUsd(amountUsd: number): Promise<number> {
const newVal = await this.redis.incrbyfloat(this.key, amountUsd);
// Expire at next midnight UTC to keep daily window.
const ttl = this.secondsUntilMidnightUtc();
await this.redis.expire(this.key, ttl);
return Number(newVal);
}
private secondsUntilMidnightUtc(): number {
const now = new Date();
const midnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
return Math.max(1, Math.floor((midnight.getTime() - now.getTime()) / 1000));
}
}
export function createContext(redis?: Redis): WorkerContext {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("Missing DATABASE_URL. Worker must connect explicitly to the app database.");
}
const { db, pool } = createDatabase(databaseUrl);
const aamos = createAamosClient();
const budgetStore = redis ? new RedisBudgetStore(redis, `gemini:daily:budget:${new Date().toISOString().slice(0, 10)}`) : undefined;
const aamos = createAamosClient(undefined, { budgetStore });
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:4000";
const secret = process.env.ENTITLEMENT_SIGNING_SECRET ?? "dev-only-change-me-three";