From 02d5bc4f738bd3f4cf3c0b469603b4c61ffe8e37 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 21:25:24 +0000 Subject: [PATCH] fix(ai): implementera TRANSLATE_RECIPE i Gemini-klienten (saknades helt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/ai-contracts/src/gemini.ts | 130 ++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/packages/ai-contracts/src/gemini.ts b/packages/ai-contracts/src/gemini.ts index 14fb369..0cea422 100644 --- a/packages/ai-contracts/src/gemini.ts +++ b/packages/ai-contracts/src/gemini.ts @@ -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 = { + 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 { 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 >; + case "TRANSLATE_RECIPE": + return this.translateRecipe( + parsedInput.data as TaskInput<"TRANSLATE_RECIPE">, + options, + ) as Promise>; default: return { status: "failed", @@ -802,6 +824,114 @@ export class GeminiAamosClient implements AamosClient { }; } + private async translateRecipe( + input: TaskInput<"TRANSLATE_RECIPE">, + _options: AamosCallOptions, + ): Promise> { + 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; + 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,