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
This commit is contained in:
2026-09-20 20:13:28 +02:00
co-authored by Claude Opus 5
parent c430205bf2
commit f68ce1c3a5
5 changed files with 567 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
/**
* The translation boundary.
*
* Everything above this interface deals in "French in, English out". The
* adapter below it deals with whatever shape the service of the day happens to
* have — which matters here, because api.loxi.ch is neither Anthropic- nor
* OpenAI-compatible and a future move to either should not reach the callers.
*/
export type TranslationRequest = {
text: string;
/** Times and numbers must survive unchanged, so the prompt says so. */
signal?: AbortSignal;
};
export type TranslationOutcome =
| { ok: true; text: string; model: string }
| { ok: false; error: string };
export interface TranslationProvider {
/** A stable name, stored alongside the cached result. */
readonly model: string;
translate(request: TranslationRequest): Promise<TranslationOutcome>;
}
/**
* The instruction sent with every message.
*
* Written as one block because the loxi endpoint takes a single prompt string
* rather than a system/user pair. The constraints are the ones that matter on
* an e-ink panel 800 pixels wide: no added quotes, no commentary, no growth.
*/
export function buildPrompt(text: string): string {
return [
"Traduis en anglais le message d'affichage suivant, destiné à la vitrine d'une boutique de tricot.",
'',
'Contraintes impératives :',
"- Réponds UNIQUEMENT avec la traduction, sans guillemets ajoutés, sans préambule, sans commentaire.",
'- Ton sobre et commercial, pas de familiarité ajoutée.',
'- Conserve à l’identique les horaires, les dates et les nombres.',
`- La traduction ne doit pas dépasser ${Math.ceil(text.length * 1.2)} caractères.`,
'',
'Message à traduire :',
text,
].join('\n');
}
/**
* Cleans what a model returns.
*
* Models wrap answers in quotes and prefix them with "Translation:" often
* enough that stripping it here is cheaper than re-prompting, and a stray
* quotation mark on the shop window looks like a mistake.
*/
export function cleanTranslation(raw: string): string {
let text = raw.trim();
text = text.replace(/^(?:translation|traduction)\s*:\s*/i, '').trim();
const pairs: [string, string][] = [
['"', '"'],
['«', '»'],
['“', '”'],
["'", "'"],
];
for (const [open, close] of pairs) {
if (text.startsWith(open) && text.endsWith(close) && text.length > open.length + close.length) {
text = text.slice(open.length, text.length - close.length).trim();
break;
}
}
// A multi-line answer means the model explained itself; keep the first line.
const [firstLine] = text.split('\n');
return (firstLine ?? '').trim();
}