/** * Translation with a cache in front of it. * * The cache is keyed on the hash of the French text, so the same notice is * never paid for twice — and because translation runs a CLI on the other side, * "twice" is measured in seconds, not milliseconds. */ import { createHash } from 'node:crypto'; import { prisma } from '@/lib/db'; import { toEnglishClock } from '@/lib/schedule/format'; import { LoxiTranslationProvider, loxiConfigFromEnv } from './loxi'; import type { TranslationOutcome, TranslationProvider } from './provider'; export const TARGET_LANG = 'en'; export function hashSource(text: string): string { return createHash('sha256').update(text.trim(), 'utf8').digest('hex'); } let cachedProvider: TranslationProvider | null | undefined; /** `null` when the service is not configured, which is not an error. */ export function defaultProvider(): TranslationProvider | null { if (cachedProvider !== undefined) { return cachedProvider; } const flavour = process.env.TRANSLATION_API_FLAVOR?.trim() || 'loxi'; const config = loxiConfigFromEnv(); // Only the loxi adapter exists, because only loxi is in use. The interface // is the seam; writing two more adapters nobody calls would be inventory. cachedProvider = flavour === 'loxi' && config ? new LoxiTranslationProvider(config) : null; return cachedProvider; } /** Test seam. */ export function resetProvider(): void { cachedProvider = undefined; } export type TranslationResult = TranslationOutcome & { cached?: boolean }; export async function translateToEnglish( text: string, provider: TranslationProvider | null = defaultProvider(), ): Promise { const source = text.trim(); if (source.length === 0) { return { ok: true, text: '', model: 'none' }; } if (!provider) { return { ok: false, error: 'Le service de traduction n’est pas configuré.' }; } const sourceHash = hashSource(source); const hit = await prisma.translationCache.findUnique({ where: { sourceHash_targetLang_model: { sourceHash, targetLang: TARGET_LANG, model: provider.model, }, }, }); if (hit) { return { ok: true, text: hit.translatedText, model: provider.model, cached: true }; } const raw = await provider.translate({ text: source }); if (!raw.ok) { return raw; } // The prompt tells the model to leave times untouched, because converting a // clock is not a job for a language model. It is done here instead, so a // translated notice and the labels this application writes itself never show // two different conventions on the same panel. A hand-written translation is // not touched: that wording was somebody's explicit choice. const outcome = { ...raw, text: toEnglishClock(raw.text) }; // Two concurrent saves of the same text would race here; the upsert makes // the second a no-op instead of a crash. await prisma.translationCache.upsert({ where: { sourceHash_targetLang_model: { sourceHash, targetLang: TARGET_LANG, model: provider.model, }, }, update: {}, create: { sourceHash, targetLang: TARGET_LANG, sourceText: source, translatedText: outcome.text, model: provider.model, }, }); return { ...outcome, cached: false }; }