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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user