Files
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

77 lines
2.6 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.
/**
* 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();
}