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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user