The labels this application writes itself already read "6:30 pm", while a translated notice kept whatever the French said, so the screen could show "Closes at 6:30 pm" above "Delayed opening at 14:00". Two conventions side by side on one panel. The conversion is done in code, not by the prompt. The model is still told to leave times alone — reading a clock and rewriting it is deterministic work, and asking a language model to do it introduces a failure mode for no benefit. Only HH:mm is touched, never a time that already carries am or pm, so the pass is idempotent and years, dates written 20/07 and percentages are left alone. Hand-written translations are not rewritten: that wording was somebody's explicit choice. Verified against the live service: "Ouvert 10:00 – 13:00 puis 14:00 – 18:30" now comes back as "Open 10:00 am – 1:00 pm then 2:00 pm – 6:30 pm". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
107 lines
3.3 KiB
TypeScript
107 lines
3.3 KiB
TypeScript
/**
|
||
* 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<TranslationResult> {
|
||
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 };
|
||
}
|