/** * 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; } /** * 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(); }