Files
vliaudatandClaude Opus 5 ed89d08172 fix: show one clock convention in English on the panel
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
2026-09-20 22:37:59 +02:00

107 lines
3.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}