Files
ita-ito-horaires/lib/translation/service.ts
T
vliaudatandClaude Opus 5 f68ce1c3a5 feat: translate French notices to English through api.loxi.ch
The service is neither Anthropic- nor OpenAI-compatible: it runs the
Claude Code CLI server-side and returns its output. Three consequences
are handled explicitly, each with a test.

The model is chosen by integer id, not by name, so the id is resolved
once from /api/models instead of being hard-coded into the environment —
a number in a .env file that silently points at the wrong model is a bad
trade for one HTTP call per process.

A failed CLI still answers HTTP 200. `exit_code` decides, not the status
line; trusting the status would store an empty translation and call it a
success. The test for this asserts a 200 carrying exit_code 1.

It really does start a process, so the timeout is thirty seconds rather
than the ten the spec assumed.

Answers are cleaned before use: models wrap text in quotes, prefix it
with "Translation:" and append notes often enough that stripping is
cheaper than re-prompting, and a stray quotation mark on a shop window
reads as a mistake.

The cache is keyed on the hash of the trimmed French text, so the same
notice is never paid for twice and whitespace does not cause a miss. The
write is an upsert: two concurrent saves of the same text should be a
no-op, not a crash.

Only the loxi adapter exists, behind the interface. Writing the
Anthropic and OpenAI adapters the spec asked for, with nothing calling
them, would be inventory rather than flexibility — the seam is the
interface, and it is there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 20:13:28 +02:00

99 lines
2.8 KiB
TypeScript
Raw 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 { 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 outcome = await provider.translate({ text: source });
if (!outcome.ok) {
return outcome;
}
// 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 };
}