Files
Cibello-app/packages/ai-contracts/src/gemini.ts
T

1036 lines
36 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.
/**
* Gemini-lärar-tier för AAMOS-task-typer (SKIVA 1).
*
* - Implementerar samma AamosClient-interface som HttpAamosClient/MockAamosClient.
* - App + Food API ser ingen skillnad; workern byter bara adapter.
* - Nycklar finns serversidan; klienten exponeras aldrig i appen.
* - Kostnad/tokens rapporteras tillbaka så workern kan bokföra i ai_usage_counters.
* - Global dagsbudget kan sättas via GEMINI_DAILY_BUDGET_USD.
*/
import { z } from "zod";
import {
TASK_CONTRACTS,
type AamosTaskType,
type TaskInput,
type TaskOutput,
detectedItemSchema,
type DetectedItem,
generateRecipeCandidatesOutput,
analyzeMealImageOutput,
readNutritionLabelOutput,
readExpiryDateOutput,
readReceiptOutput,
} from "./tasks.js";
import type { AamosCallOptions, AamosClient, AamosResult } from "./client.js";
import type { LocaleContext } from "@app/shared-types";
const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta";
/** Custom fetch using Node https module — works around IPv6 hangs on WSL. Follows redirects. */
function customNodeFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
return requestOnce(input, init, 5);
}
function requestOnce(
input: string | URL | Request,
init: RequestInit | undefined,
redirectsLeft: number,
): Promise<Response> {
return new Promise((resolve, reject) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const u = new URL(url);
const isHttps = u.protocol === "https:";
const mod = isHttps ? import("node:https") : import("node:http");
mod
.then((httpMod) => {
const postData = init?.body ? String(init.body) : undefined;
const headers: Record<string, string> = {};
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((v, k) => {
headers[k] = v;
});
} else if (Array.isArray(init.headers)) {
(init.headers as [string, string][]).forEach(([k, v]) => {
if (k != null) headers[k] = v;
});
} else {
Object.assign(headers, init.headers as Record<string, string>);
}
}
if (postData && !headers["content-length"]) {
headers["content-length"] = String(Buffer.byteLength(postData));
}
if (!headers["user-agent"]) {
headers["user-agent"] = "Mozilla/5.0 (compatible; AAMOS-Gateway/1.0)";
}
const req = httpMod.request(
{
hostname: u.hostname,
port: u.port || (isHttps ? 443 : 80),
path: u.pathname + u.search,
method: init?.method || "GET",
headers,
timeout: 120_000,
},
(res) => {
const status = res.statusCode ?? 200;
const location = res.headers.location;
if (status >= 300 && status < 400 && location && redirectsLeft > 0) {
const nextUrl = new URL(location, url).toString();
requestOnce(nextUrl, { ...init, method: "GET", body: undefined }, redirectsLeft - 1)
.then(resolve)
.catch(reject);
return;
}
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
res.on("end", () => {
const body = Buffer.concat(chunks);
resolve(
new Response(body, {
status,
statusText: res.statusMessage ?? "OK",
headers: new Headers(Object.entries(res.headers).map(([k, v]) => [k, String(v)])),
}),
);
});
},
);
req.on("error", (err) => reject(err));
req.on("timeout", () => {
req.destroy();
reject(new Error("Request timeout"));
});
if (postData) req.write(postData);
req.end();
})
.catch(reject);
});
}
/** Estimated max cost per image call in USD (pessimistic). */
const COST_ESTIMATE_PER_IMAGE_USD = 0.0015;
/** Estimated max cost per simple OCR image call in USD. */
const COST_ESTIMATE_PER_OCR_IMAGE_USD = 0.001;
export interface BudgetStore {
/** Return current daily spend in USD. */
getDailySpendUsd(): Promise<number>;
/** Increment daily spend by amountUsd; return new total. */
incrementDailySpendUsd(amountUsd: number): Promise<number>;
/**
* Optional: called when the daily budget is exceeded. Implementations can
* raise an alert (e.g. write to Redis / DB) so ops-summary/EOC can surface it.
*/
raiseBudgetAlarm?(): Promise<void>;
}
/** In-memory budget store for tests / single-process dev. */
export class MemoryBudgetStore implements BudgetStore {
private spend = 0;
private alarmRaised = false;
async getDailySpendUsd(): Promise<number> {
return this.spend;
}
async incrementDailySpendUsd(amountUsd: number): Promise<number> {
this.spend += amountUsd;
return this.spend;
}
async raiseBudgetAlarm(): Promise<void> {
this.alarmRaised = true;
}
alarmWasRaised(): boolean {
return this.alarmRaised;
}
reset() {
this.spend = 0;
this.alarmRaised = false;
}
}
export interface GeminiAamosClientConfig {
apiKey: string;
model?: string;
timeoutMs?: number;
dailyBudgetUsd?: number;
budgetStore?: BudgetStore;
fetchImpl?: typeof fetch;
promptVersion?: string;
}
const geminiFridgeResponseSchema = z.object({
items: z.array(
z.object({
produkt: z.string(),
varumarke: z.string().nullable(),
kvantitet: z.union([z.number(), z.string()]).nullable(),
enhet: z.string().nullable(),
kategori: z.string().nullable(),
bastaFore: z.string().nullable().optional(),
sistaForbruk: z.string().nullable().optional(),
beskrivning: z.string().nullable().optional(),
konfidens: z.number().min(0).max(1),
}),
),
imageQualityIssues: z.array(z.enum(["dark", "blurry", "occlusion", "too_far"])).default([]),
});
function parseDate(value: string | null | undefined): string | null {
if (!value) return null;
const normalized = value.trim();
if (!normalized) return null;
// Accept ISO dates (YYYY-MM-DD) only.
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return normalized;
// Try common Swedish/EU formats.
const parts = normalized.split(/[-/.]/);
if (parts.length === 3) {
const [aStr, bStr, cStr] = parts;
if (!aStr || !bStr || !cStr) return null;
const a = parseInt(aStr, 10);
const b = parseInt(bStr, 10);
const c = parseInt(cStr, 10);
if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c)) return null;
// YYYY-MM-DD
if (a > 2000 && b <= 12 && c <= 31)
return `${a}-${String(b).padStart(2, "0")}-${String(c).padStart(2, "0")}`;
// DD-MM-YYYY
if (c > 2000 && b <= 12 && a <= 31)
return `${c}-${String(b).padStart(2, "0")}-${String(a).padStart(2, "0")}`;
}
return null;
}
function parseQuantity(value: string | number | null | undefined): {
quantity: number | null;
unit: string | null;
} {
if (value == null) return { quantity: null, unit: null };
if (typeof value === "number") return { quantity: value, unit: null };
const text = String(value).trim().replace(/,/g, ".");
if (!text) return { quantity: null, unit: null };
const match = text.match(/^(\d+(?:\.\d+)?)\s*(.*)$/);
if (match && match[1] != null && match[2] != null) {
const qty = parseFloat(match[1]);
const unit = match[2].trim();
return { quantity: Number.isFinite(qty) ? qty : null, unit: unit || null };
}
return { quantity: null, unit: text };
}
function normalizeUnit(unit: string | null | undefined): string | null {
if (unit == null) return null;
const u = unit.toLowerCase().trim();
if (u === "g" || u === "gram") return "GRAM";
if (u === "kg" || u === "kilo" || u === "kilogram") return "KILOGRAM";
if (u === "ml" || u === "milliliter") return "MILLILITER";
if (u === "dl" || u === "deciliter") return "DECILITER";
if (u === "l" || u === "liter") return "LITER";
if (u === "st" || u === "styck" || u === "piece" || u === "pieces") return "COUNT";
if (u === "förpackning" || u === "paket" || u === "package") return "PACKAGE";
if (u === "portion" || u === "portioner") return "PORTION";
if (u === "klyfta" || u === "klyftor") return "CLOVE";
if (u === "skiva" || u === "skivor") return "SLICE";
return null;
}
function toDetectedItems(parsed: z.infer<typeof geminiFridgeResponseSchema>): DetectedItem[] {
return parsed.items.map((item) => {
const { quantity, unit } = parseQuantity(item.kvantitet);
return detectedItemSchema.parse({
detectedName: item.produkt,
canonicalIngredientId: null,
brand: item.varumarke,
estimatedQuantity: quantity,
unit: normalizeUnit(unit ?? item.enhet),
bestBeforeDate: parseDate(item.bastaFore ?? item.sistaForbruk),
confidence: item.konfidens,
requiresConfirmation: item.konfidens < 0.92,
boundingBox: null,
});
});
}
export class GeminiAamosClient implements AamosClient {
private readonly cfg: Required<Omit<GeminiAamosClientConfig, "budgetStore" | "fetchImpl">> & {
fetchImpl: typeof fetch;
budgetStore?: BudgetStore;
};
constructor(config: GeminiAamosClientConfig) {
this.cfg = {
apiKey: config.apiKey,
model: config.model ?? "gemini-3.5-flash-lite",
timeoutMs: config.timeoutMs ?? 60_000,
dailyBudgetUsd: config.dailyBudgetUsd ?? 0,
promptVersion: config.promptVersion ?? "gemini-fridge-v1",
fetchImpl: config.fetchImpl ?? customNodeFetch,
budgetStore: config.budgetStore,
};
}
async healthCheck(): Promise<{ ok: boolean; detail?: string }> {
try {
const url = `${GEMINI_API_BASE}/models?key=${encodeURIComponent(this.cfg.apiKey)}`;
const res = await this.cfg.fetchImpl(url, { signal: AbortSignal.timeout(5_000) });
return { ok: res.ok, detail: `status ${res.status}` };
} catch (err) {
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
}
}
async runTask<T extends AamosTaskType>(
taskType: T,
input: TaskInput<T>,
options: AamosCallOptions = {},
): Promise<AamosResult<T>> {
const contract = TASK_CONTRACTS[taskType];
const parsedInput = contract.input.safeParse(input);
if (!parsedInput.success) {
return {
status: "failed",
output: null,
error: `Kontraktsfel (input) för ${taskType}: ${parsedInput.error.message}`,
};
}
switch (taskType) {
case "ANALYZE_FRIDGE_IMAGE":
case "ANALYZE_PANTRY_IMAGE":
return this.analyzeStorageImage(
taskType,
parsedInput.data as TaskInput<"ANALYZE_FRIDGE_IMAGE">,
options,
) as Promise<AamosResult<T>>;
case "GENERATE_RECIPE_CANDIDATES":
return this.generateRecipeCandidates(
parsedInput.data as TaskInput<"GENERATE_RECIPE_CANDIDATES">,
options,
) as Promise<AamosResult<T>>;
case "ANALYZE_MEAL_IMAGE":
return this.analyzeMealImage(
parsedInput.data as TaskInput<"ANALYZE_MEAL_IMAGE">,
options,
) as Promise<AamosResult<T>>;
case "READ_NUTRITION_LABEL":
return this.readNutritionLabel(
parsedInput.data as TaskInput<"READ_NUTRITION_LABEL">,
options,
) as Promise<AamosResult<T>>;
case "READ_EXPIRY_DATE":
return this.readExpiryDate(
parsedInput.data as TaskInput<"READ_EXPIRY_DATE">,
options,
) as Promise<AamosResult<T>>;
case "READ_RECEIPT":
return this.readReceipt(parsedInput.data as TaskInput<"READ_RECEIPT">, options) as Promise<
AamosResult<T>
>;
default:
return {
status: "failed",
output: null,
error: `${taskType} stöds inte av Gemini-lärar-tier i SKIVA 1.`,
} as AamosResult<T>;
}
}
private async fetchOneImage(
url: string,
timeoutMs: number,
): Promise<{ inline_data: { mime_type: string; data: string } }> {
if (url.startsWith("data:")) {
const match = url.match(/^data:([^;]+);base64,(.+)$/);
if (match && match[1] && match[2]) {
return { inline_data: { mime_type: match[1], data: match[2] } };
}
throw new Error(`Ogiltig data-URL: ${url.slice(0, 50)}`);
}
const res = await this.cfg.fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) throw new Error(`Kunde inte hämta bild: ${res.status} ${url}`);
const buf = Buffer.from(await res.arrayBuffer());
return { inline_data: { mime_type: this.mimeType(buf), data: buf.toString("base64") } };
}
private async analyzeStorageImage(
taskType: "ANALYZE_FRIDGE_IMAGE" | "ANALYZE_PANTRY_IMAGE",
input: TaskInput<"ANALYZE_FRIDGE_IMAGE">,
options: AamosCallOptions,
): Promise<AamosResult<"ANALYZE_FRIDGE_IMAGE">> {
const locale = options.localeContext ?? this.defaultLocale();
const started = Date.now();
const imageCount = Math.min(input.imageUrls.length, 6);
const estimatedCostUsd = imageCount * COST_ESTIMATE_PER_IMAGE_USD;
if (await this.isOverBudget(estimatedCostUsd)) {
console.warn(
"[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.",
);
}
const imageParts = await Promise.all(
input.imageUrls.slice(0, 6).map((url) => this.fetchOneImage(url, this.cfg.timeoutMs)),
);
if (imageParts.length === 0) {
return {
status: "failed",
output: null,
error: "Global Gemini-dagsbudget är förbrukad.",
};
}
const prompt = this.buildFridgePrompt(taskType, locale, input.locationType);
const payload = {
contents: [{ parts: [{ text: prompt }, ...imageParts] }],
generationConfig: {
responseMimeType: "application/json",
temperature: 0.2,
},
};
const url = `${GEMINI_API_BASE}/models/${this.cfg.model}:generateContent?key=${encodeURIComponent(this.cfg.apiKey)}`;
const res = await this.cfg.fetchImpl(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(this.cfg.timeoutMs),
});
if (!res.ok) {
const body = await res.text();
return {
status: "failed",
output: null,
error: `Gemini ${res.status}: ${body.slice(0, 500)}`,
};
}
const geminiBody = (await res.json()) as {
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
usageMetadata?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
};
};
const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
let parsed: z.infer<typeof geminiFridgeResponseSchema>;
try {
const json = JSON.parse(text);
const safe = geminiFridgeResponseSchema.safeParse(json);
if (!safe.success) {
return {
status: "failed",
output: null,
error: `Gemini-svar matchar inte schema: ${safe.error.message}`,
};
}
parsed = safe.data;
} catch {
return { status: "failed", output: null, error: "Gemini-svar var inte giltig JSON." };
}
const inputTokens = geminiBody.usageMetadata?.promptTokenCount ?? 0;
const outputTokens = geminiBody.usageMetadata?.candidatesTokenCount ?? 0;
const totalTokens = geminiBody.usageMetadata?.totalTokenCount ?? 0;
const costUsd = this.estimateCostUsd(inputTokens, outputTokens);
await this.recordSpend(costUsd);
const latencyMs = Date.now() - started;
const items = toDetectedItems(parsed);
return {
status: items.length > 0 ? "ok" : "uncertain",
output: {
items,
imageQualityIssues: parsed.imageQualityIssues,
} as TaskOutput<"ANALYZE_FRIDGE_IMAGE">,
modelVersion: this.cfg.model,
promptVersion: this.cfg.promptVersion,
latencyMs,
costUsd,
inputTokens,
outputTokens,
totalTokens,
};
}
private async runVisionTask<T extends AamosTaskType>(
taskType: T,
imageUrls: string[],
prompt: string,
outputSchema: z.ZodSchema<unknown>,
mapOutput: (parsed: unknown) => TaskOutput<T>,
options: AamosCallOptions,
promptVersion: string,
costEstimatePerImage: number,
): Promise<AamosResult<T>> {
const locale = options.localeContext ?? this.defaultLocale();
const started = Date.now();
const imageCount = Math.min(imageUrls.length, 6);
const estimatedCostUsd = imageCount * costEstimatePerImage;
if (await this.isOverBudget(estimatedCostUsd)) {
console.warn(
"[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.",
);
}
const imageParts = await this.fetchImageParts(imageUrls, this.cfg.timeoutMs);
if (imageParts.length === 0) {
return {
status: "failed",
output: null,
error: "Global Gemini-dagsbudget är förbrukad.",
} as AamosResult<T>;
}
const payload = {
contents: [{ parts: [{ text: prompt }, ...imageParts] }],
generationConfig: {
responseMimeType: "application/json",
temperature: 0.2,
},
};
const url = `${GEMINI_API_BASE}/models/${this.cfg.model}:generateContent?key=${encodeURIComponent(this.cfg.apiKey)}`;
const res = await this.cfg.fetchImpl(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(this.cfg.timeoutMs),
});
if (!res.ok) {
const body = await res.text();
return {
status: "failed",
output: null,
error: `Gemini ${res.status}: ${body.slice(0, 500)}`,
} as AamosResult<T>;
}
const geminiBody = (await res.json()) as {
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
usageMetadata?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
};
};
const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
let parsed: unknown;
try {
const json = JSON.parse(text);
const safe = outputSchema.safeParse(json);
if (!safe.success) {
return {
status: "failed",
output: null,
error: `Gemini-svar matchar inte schema: ${safe.error.message}`,
} as AamosResult<T>;
}
parsed = safe.data;
} catch {
return {
status: "failed",
output: null,
error: "Gemini-svar var inte giltig JSON.",
} as AamosResult<T>;
}
const inputTokens = geminiBody.usageMetadata?.promptTokenCount ?? 0;
const outputTokens = geminiBody.usageMetadata?.candidatesTokenCount ?? 0;
const totalTokens = geminiBody.usageMetadata?.totalTokenCount ?? 0;
const costUsd = this.estimateCostUsd(inputTokens, outputTokens);
await this.recordSpend(costUsd);
const latencyMs = Date.now() - started;
const output = mapOutput(parsed) as TaskOutput<T>;
return {
status: "ok",
output,
modelVersion: this.cfg.model,
promptVersion,
latencyMs,
costUsd,
inputTokens,
outputTokens,
totalTokens,
};
}
private async analyzeMealImage(
input: TaskInput<"ANALYZE_MEAL_IMAGE">,
options: AamosCallOptions,
): Promise<AamosResult<"ANALYZE_MEAL_IMAGE">> {
const locale = options.localeContext ?? this.defaultLocale();
const prompt = this.buildMealPrompt(locale, input.recipeContext);
return this.runVisionTask(
"ANALYZE_MEAL_IMAGE",
input.imageUrls,
prompt,
analyzeMealImageOutput,
(parsed) => parsed as TaskOutput<"ANALYZE_MEAL_IMAGE">,
options,
"gemini-meal-v1",
COST_ESTIMATE_PER_IMAGE_USD,
);
}
private async readNutritionLabel(
input: TaskInput<"READ_NUTRITION_LABEL">,
options: AamosCallOptions,
): Promise<AamosResult<"READ_NUTRITION_LABEL">> {
const locale = options.localeContext ?? this.defaultLocale();
const prompt = this.buildNutritionPrompt(locale);
return this.runVisionTask(
"READ_NUTRITION_LABEL",
input.imageUrls,
prompt,
readNutritionLabelOutput,
(parsed) => parsed as TaskOutput<"READ_NUTRITION_LABEL">,
options,
"gemini-nutrition-v1",
COST_ESTIMATE_PER_OCR_IMAGE_USD,
);
}
private async readExpiryDate(
input: TaskInput<"READ_EXPIRY_DATE">,
options: AamosCallOptions,
): Promise<AamosResult<"READ_EXPIRY_DATE">> {
const locale = options.localeContext ?? this.defaultLocale();
const prompt = this.buildExpiryPrompt(locale);
return this.runVisionTask(
"READ_EXPIRY_DATE",
input.imageUrls,
prompt,
readExpiryDateOutput,
(parsed) => parsed as TaskOutput<"READ_EXPIRY_DATE">,
options,
"gemini-expiry-v1",
COST_ESTIMATE_PER_OCR_IMAGE_USD,
);
}
private async readReceipt(
input: TaskInput<"READ_RECEIPT">,
options: AamosCallOptions,
): Promise<AamosResult<"READ_RECEIPT">> {
const locale = options.localeContext ?? this.defaultLocale();
const prompt = this.buildReceiptPrompt(locale);
return this.runVisionTask(
"READ_RECEIPT",
input.imageUrls,
prompt,
readReceiptOutput,
(parsed) => parsed as TaskOutput<"READ_RECEIPT">,
options,
"gemini-receipt-v1",
COST_ESTIMATE_PER_IMAGE_USD,
);
}
private async fetchImageParts(
imageUrls: string[],
timeoutMs: number,
): Promise<Array<{ inline_data: { mime_type: string; data: string } }>> {
return Promise.all(imageUrls.slice(0, 6).map((url) => this.fetchOneImage(url, timeoutMs)));
}
private async generateRecipeCandidates(
input: TaskInput<"GENERATE_RECIPE_CANDIDATES">,
options: AamosCallOptions,
): Promise<AamosResult<"GENERATE_RECIPE_CANDIDATES">> {
const locale = options.localeContext ?? this.defaultLocale();
const started = Date.now();
// Estimate cost: ~0.003 USD per candidate (text-only, pessimistic)
const totalCandidates = input.targetMatrix.reduce((sum, t) => sum + t.count, 0);
const estimatedCostUsd = totalCandidates * 0.003;
if (await this.isOverBudget(estimatedCostUsd)) {
console.warn(
"[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, skanning fortsätter.",
);
}
const prompt = this.buildRecipeGenerationPrompt(input, locale);
const payload = {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
responseMimeType: "application/json",
temperature: 0.3,
},
};
const url = `${GEMINI_API_BASE}/models/${this.cfg.model}:generateContent?key=${encodeURIComponent(this.cfg.apiKey)}`;
const res = await this.cfg.fetchImpl(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(this.cfg.timeoutMs),
});
if (!res.ok) {
const body = await res.text();
return {
status: "failed",
output: null,
error: `Gemini ${res.status}: ${body.slice(0, 500)}`,
};
}
const geminiBody = (await res.json()) as {
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
usageMetadata?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
};
};
const text = geminiBody.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
let parsed: z.infer<typeof generateRecipeCandidatesOutput>;
try {
const json = JSON.parse(text);
// Defensiv normalisering: Gemini kan returnera rejectedPrompts som objekt istället för strängar.
if (Array.isArray(json.rejectedPrompts)) {
json.rejectedPrompts = json.rejectedPrompts.map((p: unknown) =>
typeof p === "string" ? p : JSON.stringify(p),
);
}
const safe = generateRecipeCandidatesOutput.safeParse(json);
if (!safe.success) {
return {
status: "failed",
output: null,
error: `Gemini-svar matchar inte schema: ${safe.error.message}`,
};
}
parsed = safe.data;
} catch {
return { status: "failed", output: null, error: "Gemini-svar var inte giltig JSON." };
}
const inputTokens = geminiBody.usageMetadata?.promptTokenCount ?? 0;
const outputTokens = geminiBody.usageMetadata?.candidatesTokenCount ?? 0;
const totalTokens = geminiBody.usageMetadata?.totalTokenCount ?? 0;
const costUsd = this.estimateCostUsd(inputTokens, outputTokens);
await this.recordSpend(costUsd);
// Normalisera enheter defensivt: Gemini kan ibland returnera "dl"/"msk"/"st".
for (const c of parsed.candidates) {
for (const ing of c.ingredients) {
const normalized = normalizeUnit(String(ing.unit));
if (normalized) ing.unit = normalized as typeof ing.unit;
}
}
const latencyMs = Date.now() - started;
return {
status: parsed.candidates.length > 0 ? "ok" : "uncertain",
output: parsed as TaskOutput<"GENERATE_RECIPE_CANDIDATES">,
modelVersion: this.cfg.model,
promptVersion: "gemini-recipe-gen-v1",
latencyMs,
costUsd,
inputTokens,
outputTokens,
totalTokens,
};
}
private buildRecipeGenerationPrompt(
input: TaskInput<"GENERATE_RECIPE_CANDIDATES">,
locale: LocaleContext,
): string {
const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
const catalog = input.canonicalIngredientsCatalog
.map(
(i) =>
`- ${i.id} (${i.nameSv}, ${i.category}, enhet: ${i.defaultUnit}, vegan: ${i.isVegan}, veg: ${i.isVegetarian}, gluten: ${i.containsGluten}, laktos: ${i.containsLactose})`,
)
.join("\n");
const targets = input.targetMatrix
.map((t) => `- ${t.mealType} × ${t.mainIngredientId} × ${t.dietVariant}: ${t.count} st`)
.join("\n");
const constraints = input.constraints;
return `Du är en erfaren svensk matskribent som skriver vardagsrecept för svenska hushåll.
VIKTIGAST: Använd ENDAST ingredienser från katalogen nedan. Hitta ALDRIG på ingredienser som inte finns i listan. Om ett recept behöver något som saknas, hoppa över det receptet och lägg till en notering i rejectedPrompts.
Tillåtna ingredienser (canonical_ingredients):
${catalog}
Önskade recept:
${targets}
Begränsningar:
- max förberedelsetid: ${constraints.maxPrepTimeMinutes ?? 60} min
- max koktid: ${constraints.maxCookTimeMinutes ?? 45} min
- portioner: ${constraints.portions ?? 4}
- max kryddnivå: ${constraints.spiceLevelMax ?? 3}
- undvik: ${constraints.avoidIngredients.join(", ") || "(ingen)"}
Regler:
- Recepten ska vara realistiska vardagsrätter för svenska hushåll.
- Stegen ska vara tydliga, med rimliga tider och temperaturer.
- MATSÄKERHET recept som innehåller rå fågel, rått fläsk/köttfärs, rå fisk/skaldjur eller råa ägg MÅSTE innehålla ett explicit steg som beskriver att proteinet blir genomstekt/genomkokt. Använd formuleringar som: "stek tills kycklingen är genomstekt och inte längre rosa i mitten", "koka tills fisken flagnar", "stek tills köttfärsen är genomstekt", eller ange kärn-/innertemperatur i °C. Det räcker INTE med enbart utseende (t.ex. "gyllenbrun") eller enbart tid.
- Använd ENDAST dessa exakta enhetskoder: GRAM, KILOGRAM, MILLILITER, DECILITER, LITER, TEASPOON, TABLESPOON, CUP_US, COUNT, PORTION, PINCH, SLICE, CLOVE, CAN, PACKAGE. Aldrig "g", "dl", "msk", "tsk", "st".
- Formuleringen ska vara mjölkprincips-vänlig: ingen svinnskam, inget "släng".
- Varje ingrediens MÅSTE finnas i katalogen ovan.
- Svara ENDAST med giltig JSON i exakt detta format:
{
"candidates": [
{
"titleSv": "...",
"descriptionSv": "...",
"cuisine": "swedish",
"mealTypes": ["dinner"],
"prepTimeMinutes": 15,
"cookTimeMinutes": 30,
"portions": 4,
"spiceLevel": 1,
"ingredients": [
{
"canonicalIngredientId": "kycklingfile",
"displayNameSv": "kycklingfilé",
"quantity": 500,
"unit": "GRAM",
"optional": false,
"note": null
}
],
"steps": [
{
"instructionSv": "...",
"timerSeconds": null,
"temperatureC": null,
"tip": null
}
],
"storageGuidanceSv": "...",
"mealPrepFriendly": false,
"freezerFriendly": false,
"confidence": 0.95
}
],
"rejectedPrompts": []
}
Språk: ${lang}.`;
}
private buildFridgePrompt(
taskType: "ANALYZE_FRIDGE_IMAGE" | "ANALYZE_PANTRY_IMAGE",
locale: LocaleContext,
locationType: string,
): string {
const place = taskType === "ANALYZE_FRIDGE_IMAGE" ? "kylskåpet" : "skafferiet";
const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
return `Du är en noggrann livsmedelsassistent för svenska hushåll. Användaren har fotat ${place} (${locationType}).
Identifiera varje livsmedelsprodukt du ser. För varje produkt, svara med:
- produkt: produktens namn på svenska (t.ex. "Mellanmjölk", "Smör", "Vispgrädde")
- varumarke: varumärke om det syns tydligt, annars null
- kvantitet: siffra (t.ex. 1, 500, 1.5)
- enhet: förkortning som l, g, ml, st, förpackning
- kategori: t.ex. mejeri, frukt, grönt, kött, skafferi
- bastaFore: bäst-före-datum om det syns (YYYY-MM-DD), annars null
- sistaForbruk: sista förbrukningsdatum om det syns (YYYY-MM-DD), annars null
- konfidens: 0.01.0
Viktigt:
- Kvantitet: uppskatta den FAKTISKA synliga/kvarvarande mängden, inte tryckta förpackningsantal. Ett tomt eller öppnat paket ska ha låg kvantitet (nära 0), även om lådan säger t.ex. "24-pack". Är du osäker, gissa lågt och sänk konfidensen.
- Svenska mjölk- och mejerifärger: röd förpackning = standardmjölk 3%, grön = mellanmjölk 1,5%, blå = lättmjölk 0,5%. Använd färgen för att avgöra fetthalt.
- Hellre lägre konfidens än en exakt gissning när du är osäker.
Svara ENDAST med giltig JSON i exakt detta format:
{
"items": [...],
"imageQualityIssues": []
}
imageQualityIssues kan innehålla någon av: "dark", "blurry", "occlusion", "too_far".
Språk: ${lang}.`;
}
private buildMealPrompt(locale: LocaleContext, recipeContext: unknown): string {
const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
const ctx = recipeContext as {
recipeId?: string;
titleSv?: string;
nutritionPerPortion?: Record<string, number>;
portions?: number;
} | null;
let contextLine = "";
if (ctx?.titleSv) {
contextLine = `Användaren säger att måltiden ska matcha receptet "${ctx.titleSv}" (${ctx.portions ?? "?"} portioner). `;
}
return `Du är en svensk näringsassistent. ${contextLine}Bilden visar en serverad måltid.
Uppskatta:
- matchesRecipeContext: true/false/null matchar bilden receptet användaren nämnde?
- portionFractionEstimate: hur stor del av en normalportion ser det ut att vara? 0.05.0, eller null.
- kcalRange: { min, max, mostLikely } för hela måltiden, eller null.
- components: lista med uppskattade komponenter (namn, ev. canonicalIngredientId som null, estimatedGrams, confidence).
- confidence: 0.01.0 för hela tolkningen.
Svara ENDAST med giltig JSON som matchar detta schema:
{
"matchesRecipeContext": boolean | null,
"portionFractionEstimate": number | null,
"kcalRange": { "min": number, "max": number, "mostLikely": number } | null,
"components": [
{ "name": string, "canonicalIngredientId": string | null, "estimatedGrams": number | null, "confidence": number }
],
"confidence": number
}
Språk: ${lang}.`;
}
private buildNutritionPrompt(locale: LocaleContext): string {
const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
return `Du är en OCR-assistent för svenska livsmedelsförpackningar. Bilden visar en näringsdeklaration/ingredienslista.
Läs av exakt det som står på etiketten. Gissa aldrig värden som inte syns. Returnera:
- basis: "per_100_g", "per_100_ml" eller "per_portion" vad gäller tabellen?
- values: { kcal, proteinG, carbsG, fatG, saturatedFatG, fiberG, sugarG, saltG } null om ej synligt.
- ingredientsText: rå text med ingredienser, eller null.
- allergensDeclared: lista med allergener som explicit nämns (t.ex. ["gluten", "mjölk", "nötter"]).
- gtin: streckkod/EAN om synlig, annars null.
- productName: produktnamn om synligt, annars null.
- brand: varumärke om synligt, annars null.
- confidence: 0.01.0.
Svara ENDAST med giltig JSON som matchar detta schema:
{
"basis": "per_100_g" | "per_100_ml" | "per_portion" | null,
"values": { "kcal": number|null, "proteinG": number|null, "carbsG": number|null, "fatG": number|null, "saturatedFatG": number|null, "fiberG": number|null, "sugarG": number|null, "saltG": number|null } | null,
"ingredientsText": string | null,
"allergensDeclared": string[],
"gtin": string | null,
"productName": string | null,
"brand": string | null,
"confidence": number
}
Språk: ${lang}.`;
}
private buildExpiryPrompt(locale: LocaleContext): string {
const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
return `Du är en OCR-assistent för svenska livsmedelsförpackningar. Bilden visar ett bäst-före-datum eller sista förbrukningsdatum.
Returnera:
- date: datum i format YYYY-MM-DD, eller null om ej läsbart.
- dateKind: "best_before" för "bäst före", "use_by" för "sista förbrukningsdag", annars null.
- confidence: 0.01.0.
Svara ENDAST med giltig JSON:
{
"date": "YYYY-MM-DD" | null,
"dateKind": "best_before" | "use_by" | null,
"confidence": number
}
Språk: ${lang}.`;
}
private buildReceiptPrompt(locale: LocaleContext): string {
const lang = locale.languageTag.startsWith("en") ? "English" : "Swedish";
return `Du är en OCR-assistent för svenska kvitton. Bilden visar ett butikskvitto.
Läs av:
- storeName: butikens namn, eller null.
- purchaseDate: datum i format YYYY-MM-DD, eller null.
- lines: varje rad med { rawText (exakt text), normalizedName (förenklat livsmedelsnamn eller null), canonicalIngredientId (alltid null), quantity (antal/enhet om går att utläsa, annars null), unit (enhetskod eller null), unitPriceMinor (ören per styck om synligt, annars null), totalPriceMinor (radens totalpris i ören om synligt, annars null), isDiscount (true om det är en rabatt/avdrag-rad), confidence (0.01.0) }.
- totalMinor: kvittots totalsumma i ören, eller null.
- discountTotalMinor: total rabatt i ören, eller null.
- confidence: 0.01.0 för hela tolkningen.
Svara ENDAST med giltig JSON som matchar detta schema:
{
"storeName": string | null,
"purchaseDate": "YYYY-MM-DD" | null,
"lines": [
{
"rawText": string,
"normalizedName": string | null,
"canonicalIngredientId": null,
"quantity": number | null,
"unit": string | null,
"unitPriceMinor": number | null,
"totalPriceMinor": number | null,
"isDiscount": boolean,
"confidence": number
}
],
"totalMinor": number | null,
"discountTotalMinor": number | null,
"confidence": number
}
Använd endast de enhetskoder du kan utläsa (GRAM, KILOGRAM, LITER, etc.), annars null. quantity är alltid ett decimaltal.
Språk: ${lang}.`;
}
private defaultLocale(): LocaleContext {
return {
languageTag: "sv-SE",
regionCode: "SE",
timeZone: "Europe/Stockholm",
measurementSystem: "METRIC",
temperatureUnit: "CELSIUS",
currencyCode: "SEK",
};
}
private mimeType(buf: Buffer): string {
if (buf[0] === 0xff && buf[1] === 0xd8) return "image/jpeg";
if (buf.slice(0, 8).toString("hex") === "89504e470d0a1a0a") return "image/png";
if (buf.slice(0, 4).toString("ascii") === "RIFF") return "image/webp";
return "image/jpeg";
}
private estimateCostUsd(inputTokens: number, outputTokens: number): number {
// Gemini 2.5 Flash pricing (Aug 2025): ~$0.075/1M input, $0.30/1M output.
return inputTokens * 0.075e-6 + outputTokens * 0.3e-6;
}
private async isOverBudget(estimateUsd: number): Promise<boolean> {
if (this.cfg.dailyBudgetUsd <= 0 || !this.cfg.budgetStore) return false;
const current = await this.cfg.budgetStore.getDailySpendUsd();
const over = current + estimateUsd > this.cfg.dailyBudgetUsd;
if (over && this.cfg.budgetStore.raiseBudgetAlarm) {
await this.cfg.budgetStore.raiseBudgetAlarm();
}
return over;
}
private async recordSpend(costUsd: number): Promise<void> {
if (this.cfg.budgetStore && costUsd > 0) {
await this.cfg.budgetStore.incrementDailySpendUsd(costUsd);
}
}
}