fix(ai): implementera TRANSLATE_RECIPE i Gemini-klienten (saknades helt)

I AAMOS_MODE=gemini (direkt Gemini-nyckel) hanterade klienten bara bild-/
skann-uppgifterna; TRANSLATE_RECIPE föll i default-grenen och returnerade
'failed' - därför funkade foto-avläsning men INTE receptöversättning för den
som kör med egen Gemini-nyckel.

Lägger till en text-only översättningsväg (samma mönster som
generateRecipeCandidates): buildTranslatePrompt bevarar tal/stegantal/
stegnummer och kräver JSON enligt translateRecipeOutput; svaret valideras mot
kontraktet. Verifierat med mockad Gemini-respons: status=ok, korrekt titel +
stegantal, kostnad bokförd.

Detta var grundorsaken till 'TRANSLATE_RECIPE misslyckades: failed' i kön.
This commit is contained in:
Claude
2026-08-20 21:25:24 +00:00
parent 5974e8740f
commit 02d5bc4f73
+130
View File
@@ -20,12 +20,29 @@ import {
readNutritionLabelOutput,
readExpiryDateOutput,
readReceiptOutput,
translateRecipeOutput,
} 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";
/** Språknamn (på svenska) som används i receptöversättningsprompten. */
const LANGUAGE_NAMES: Record<string, string> = {
en: "engelska",
da: "danska",
de: "tyska",
es: "spanska",
fi: "finska",
fr: "franska",
it: "italienska",
nb: "norska (bokmål)",
nl: "nederländska",
pl: "polska",
pt: "portugisiska",
sv: "svenska",
};
/** 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);
@@ -382,6 +399,11 @@ export class GeminiAamosClient implements AamosClient {
return this.readReceipt(parsedInput.data as TaskInput<"READ_RECEIPT">, options) as Promise<
AamosResult<T>
>;
case "TRANSLATE_RECIPE":
return this.translateRecipe(
parsedInput.data as TaskInput<"TRANSLATE_RECIPE">,
options,
) as Promise<AamosResult<T>>;
default:
return {
status: "failed",
@@ -802,6 +824,114 @@ export class GeminiAamosClient implements AamosClient {
};
}
private async translateRecipe(
input: TaskInput<"TRANSLATE_RECIPE">,
_options: AamosCallOptions,
): Promise<AamosResult<"TRANSLATE_RECIPE">> {
const started = Date.now();
const approxChars =
input.title.length +
(input.description?.length ?? 0) +
input.steps.reduce((s, st) => s + st.instruction.length + (st.tip?.length ?? 0), 0);
if (await this.isOverBudget((approxChars / 4) * 2 * 1.5e-6)) {
console.warn(
"[gemini] Global dagsbudget överskriden — LARM, blockerar INTE, översättning fortsätter.",
);
}
const prompt = this.buildTranslatePrompt(input);
const payload = {
contents: [{ parts: [{ text: prompt }] }],
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 translateRecipeOutput>;
try {
const json = JSON.parse(text);
const safe = translateRecipeOutput.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);
return {
status: "ok",
output: parsed as TaskOutput<"TRANSLATE_RECIPE">,
modelVersion: this.cfg.model,
promptVersion: "gemini-translate-v1",
latencyMs: Date.now() - started,
costUsd,
inputTokens,
outputTokens,
totalTokens,
};
}
private buildTranslatePrompt(input: TaskInput<"TRANSLATE_RECIPE">): string {
const tag = input.targetLanguageTag.split("-")[0] ?? input.targetLanguageTag;
const langName = LANGUAGE_NAMES[tag] ?? input.targetLanguageTag;
const src = {
title: input.title,
description: input.description,
storageGuidance: input.storageGuidance,
steps: input.steps.map((s) => ({
stepNumber: s.stepNumber,
instruction: s.instruction,
tip: s.tip,
})),
};
return `Du är en professionell kock och översättare. Översätt receptet nedan från svenska till ${langName}.
REGLER (mycket viktigt):
- Behåll ALLA siffror EXAKT (temperaturer, tider, mängder, portioner) — ändra aldrig ett tal.
- Behåll exakt samma antal steg och exakt samma "stepNumber" som källan.
- Översätt "title", "description", "storageGuidance" samt varje stegs "instruction" och "tip".
- Är ett fält null i källan ska det vara null i svaret (även "tip").
- Naturlig, idiomatisk ${langName} — så som ett recept faktiskt skrivs i det landet. Inga kommentarer, bara översättningen.
Svara ENDAST med giltig JSON enligt exakt detta schema:
{
"title": string,
"description": string | null,
"storageGuidance": string | null,
"steps": [ { "stepNumber": number, "instruction": string, "tip": string | null } ],
"confidence": number
}
Källrecept (svenska):
${JSON.stringify(src, null, 2)}`;
}
private buildRecipeGenerationPrompt(
input: TaskInput<"GENERATE_RECIPE_CANDIDATES">,
locale: LocaleContext,