diff --git a/lib/translation/loxi.ts b/lib/translation/loxi.ts new file mode 100644 index 0000000..0cef56d --- /dev/null +++ b/lib/translation/loxi.ts @@ -0,0 +1,134 @@ +/** + * Adapter for api.loxi.ch. + * + * The service runs the Claude Code CLI server-side and returns its output, so + * three things differ from a normal completions API and each has bitten + * somebody before: + * + * - the model is chosen by integer id, not by name, so the id is resolved + * once from /api/models rather than hard-coded into the environment; + * - a CLI failure still answers HTTP 200, so `exit_code` is what decides, + * not the status line; + * - it really does start a process, so the timeout is seconds, not + * milliseconds. + */ + +import { buildPrompt, cleanTranslation, type TranslationOutcome, type TranslationProvider, type TranslationRequest } from './provider'; + +type ModelRow = { id: number; provider: string; model_name: string; is_active: boolean }; + +export type LoxiConfig = { + baseUrl: string; + apiKey: string; + modelName: string; + timeoutMs: number; +}; + +export function loxiConfigFromEnv(): LoxiConfig | null { + const baseUrl = process.env.TRANSLATION_API_URL?.replace(/\/$/, ''); + const apiKey = process.env.TRANSLATION_API_KEY; + if (!baseUrl || !apiKey) { + return null; + } + return { + baseUrl, + apiKey, + modelName: process.env.TRANSLATION_MODEL_NAME?.trim() || 'haiku', + timeoutMs: Number(process.env.TRANSLATION_TIMEOUT_MS ?? 30_000), + }; +} + +export class LoxiTranslationProvider implements TranslationProvider { + private modelId: number | null = null; + + constructor(private readonly config: LoxiConfig) {} + + get model(): string { + return `loxi:${this.config.modelName}`; + } + + async translate({ text, signal }: TranslationRequest): Promise { + try { + const modelId = await this.resolveModelId(signal); + if (modelId === null) { + return { + ok: false, + error: `Aucun modèle actif nommé « ${this.config.modelName} » sur ${this.config.baseUrl}.`, + }; + } + + const response = await this.fetchJson('/api/generate', signal, { + method: 'POST', + body: JSON.stringify({ model_id: modelId, prompt: buildPrompt(text) }), + }); + + if (!response.ok) { + return { ok: false, error: `Le service de traduction a répondu ${response.status}.` }; + } + + const body = (await response.json()) as { + stdout?: string; + stderr?: string; + exit_code?: number; + }; + + // A failed CLI still answers 200. Trusting the status line here would + // store an empty translation and call it a success. + if (body.exit_code !== 0) { + const detail = (body.stderr ?? '').trim().slice(0, 200); + return { + ok: false, + error: `Le modèle a échoué (code ${body.exit_code ?? 'inconnu'})${detail ? ` : ${detail}` : ''}.`, + }; + } + + const translated = cleanTranslation(body.stdout ?? ''); + if (translated.length === 0) { + return { ok: false, error: 'Le modèle a renvoyé une réponse vide.' }; + } + + return { ok: true, text: translated, model: this.model }; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { ok: false, error: 'Le service de traduction n’a pas répondu à temps.' }; + } + return { ok: false, error: `Le service de traduction est injoignable (${describe(error)}).` }; + } + } + + /** Resolved once per process: the id is stable while the model list is. */ + private async resolveModelId(signal: AbortSignal | undefined): Promise { + if (this.modelId !== null) { + return this.modelId; + } + + const response = await this.fetchJson('/api/models?provider=claude&active=true', signal); + if (!response.ok) { + throw new Error(`liste des modèles indisponible (${response.status})`); + } + + const rows = (await response.json()) as ModelRow[]; + const match = rows.find( + (row) => row.is_active && row.model_name.toLowerCase() === this.config.modelName.toLowerCase(), + ); + + this.modelId = match?.id ?? null; + return this.modelId; + } + + private fetchJson(path: string, signal: AbortSignal | undefined, init: RequestInit = {}) { + return fetch(`${this.config.baseUrl}${path}`, { + ...init, + signal: signal ?? AbortSignal.timeout(this.config.timeoutMs), + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + 'Content-Type': 'application/json', + ...(init.headers ?? {}), + }, + }); + } +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/lib/translation/provider.test.ts b/lib/translation/provider.test.ts new file mode 100644 index 0000000..02ade5d --- /dev/null +++ b/lib/translation/provider.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { buildPrompt, cleanTranslation } from './provider'; + +describe('buildPrompt', () => { + const prompt = buildPrompt('Fermeture exceptionnelle jeudi après-midi'); + + it('carries the message to translate', () => { + expect(prompt).toContain('Fermeture exceptionnelle jeudi après-midi'); + }); + + it('states the constraints that matter on a shop window', () => { + expect(prompt).toMatch(/UNIQUEMENT avec la traduction/); + expect(prompt).toMatch(/sans guillemets ajoutés/); + expect(prompt).toMatch(/horaires, les dates et les nombres/); + }); + + it('caps the length at the source plus a fifth', () => { + // 40 characters in, 48 allowed out. + expect(buildPrompt('a'.repeat(40))).toContain('48 caractères'); + }); +}); + +describe('cleanTranslation', () => { + it('returns a plain answer untouched', () => { + expect(cleanTranslation('Exceptionally closed on Thursday afternoon')).toBe( + 'Exceptionally closed on Thursday afternoon', + ); + }); + + it('trims surrounding whitespace', () => { + expect(cleanTranslation(' Closed today \n')).toBe('Closed today'); + }); + + it('strips the quotes models like to add', () => { + // A stray quotation mark on the shop window reads as a mistake. + expect(cleanTranslation('"Closed today"')).toBe('Closed today'); + expect(cleanTranslation('« Closed today »')).toBe('Closed today'); + expect(cleanTranslation('“Closed today”')).toBe('Closed today'); + }); + + it('keeps an apostrophe inside the text', () => { + expect(cleanTranslation("Today's opening hours")).toBe("Today's opening hours"); + }); + + it('strips a label the model prefixed', () => { + expect(cleanTranslation('Translation: Closed today')).toBe('Closed today'); + expect(cleanTranslation('traduction : Closed today')).toBe('Closed today'); + }); + + it('keeps only the first line when the model explains itself', () => { + expect(cleanTranslation('Closed today\n\nNote: I kept the time unchanged.')).toBe( + 'Closed today', + ); + }); + + it('does not strip an unmatched quote', () => { + expect(cleanTranslation('"Closed today')).toBe('"Closed today'); + }); + + it('survives an empty answer', () => { + expect(cleanTranslation(' ')).toBe(''); + }); +}); diff --git a/lib/translation/provider.ts b/lib/translation/provider.ts new file mode 100644 index 0000000..48e6a29 --- /dev/null +++ b/lib/translation/provider.ts @@ -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; +} + +/** + * 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(); +} diff --git a/lib/translation/service.ts b/lib/translation/service.ts new file mode 100644 index 0000000..60a22ee --- /dev/null +++ b/lib/translation/service.ts @@ -0,0 +1,98 @@ +/** + * 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 { + 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 }; +} diff --git a/tests/integration/translation.test.ts b/tests/integration/translation.test.ts new file mode 100644 index 0000000..cc2ff08 --- /dev/null +++ b/tests/integration/translation.test.ts @@ -0,0 +1,195 @@ +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { prisma } from '@/lib/db'; +import { LoxiTranslationProvider } from '@/lib/translation/loxi'; +import { translateToEnglish } from '@/lib/translation/service'; + +import { hasDatabase, resetDatabase } from './helpers'; + +const BASE = 'https://api.loxi.test'; + +const MODELS = [ + { id: 5, provider: 'claude', model_name: 'haiku', is_active: true }, + { id: 2, provider: 'claude', model_name: 'opus', is_active: true }, +]; + +let generateCalls = 0; +let modelCalls = 0; + +const server = setupServer(); + +function provider(overrides: Partial[0]> = {}) { + return new LoxiTranslationProvider({ + baseUrl: BASE, + apiKey: 'llk_test', + modelName: 'haiku', + timeoutMs: 2000, + ...overrides, + }); +} + +function happyPath(stdout = 'Exceptionally closed on Thursday afternoon') { + server.use( + http.get(`${BASE}/api/models`, ({ request }) => { + modelCalls += 1; + // The adapter must ask for active Claude models only. + const url = new URL(request.url); + expect(url.searchParams.get('provider')).toBe('claude'); + expect(request.headers.get('authorization')).toBe('Bearer llk_test'); + return HttpResponse.json(MODELS); + }), + http.post(`${BASE}/api/generate`, async ({ request }) => { + generateCalls += 1; + const body = (await request.json()) as { model_id: number; prompt: string }; + // Resolved by name, not hard-coded. + expect(body.model_id).toBe(5); + expect(body.prompt).toContain('Fermeture exceptionnelle jeudi après-midi'); + return HttpResponse.json({ stdout, stderr: '', exit_code: 0, duration_ms: 2774 }); + }), + ); +} + +beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe.skipIf(!hasDatabase)('translation', () => { + beforeEach(async () => { + generateCalls = 0; + modelCalls = 0; + await resetDatabase(); + await prisma.translationCache.deleteMany(); + }); + + it('translates and stores the result', async () => { + happyPath(); + const result = await translateToEnglish('Fermeture exceptionnelle jeudi après-midi', provider()); + + expect(result).toMatchObject({ + ok: true, + text: 'Exceptionally closed on Thursday afternoon', + cached: false, + }); + expect(await prisma.translationCache.count()).toBe(1); + }); + + it('cleans the quotes a model adds', async () => { + happyPath('"Exceptionally closed on Thursday afternoon"'); + const result = await translateToEnglish('Fermeture exceptionnelle jeudi après-midi', provider()); + expect(result).toMatchObject({ ok: true, text: 'Exceptionally closed on Thursday afternoon' }); + }); + + it('asks the service once for the same text', async () => { + happyPath(); + const shared = provider(); + await translateToEnglish('Fermeture exceptionnelle jeudi après-midi', shared); + const second = await translateToEnglish('Fermeture exceptionnelle jeudi après-midi', shared); + + expect(second).toMatchObject({ ok: true, cached: true }); + // Translation runs a CLI on the other side; twice is measured in seconds. + expect(generateCalls).toBe(1); + }); + + it('ignores surrounding whitespace when matching the cache', async () => { + happyPath(); + const shared = provider(); + await translateToEnglish('Fermeture exceptionnelle jeudi après-midi', shared); + const second = await translateToEnglish(' Fermeture exceptionnelle jeudi après-midi ', shared); + + expect(second).toMatchObject({ cached: true }); + expect(generateCalls).toBe(1); + }); + + it('resolves the model id only once per provider', async () => { + happyPath(); + const shared = provider(); + await translateToEnglish('Premier message', shared); + await translateToEnglish('Second message', shared); + expect(modelCalls).toBe(1); + }); + + it('treats a non-zero exit code as a failure despite the 200', async () => { + server.use( + http.get(`${BASE}/api/models`, () => HttpResponse.json(MODELS)), + http.post(`${BASE}/api/generate`, () => + // This is the trap: the CLI failed, the HTTP call did not. + HttpResponse.json({ stdout: '', stderr: 'quota exceeded', exit_code: 1 }), + ), + ); + + const result = await translateToEnglish('Bonjour', provider()); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('quota exceeded'); + } + expect(await prisma.translationCache.count()).toBe(0); + }); + + it('treats an empty answer as a failure', async () => { + server.use( + http.get(`${BASE}/api/models`, () => HttpResponse.json(MODELS)), + http.post(`${BASE}/api/generate`, () => HttpResponse.json({ stdout: ' ', exit_code: 0 })), + ); + + expect((await translateToEnglish('Bonjour', provider())).ok).toBe(false); + expect(await prisma.translationCache.count()).toBe(0); + }); + + it('reports a server error', async () => { + server.use( + http.get(`${BASE}/api/models`, () => HttpResponse.json(MODELS)), + http.post(`${BASE}/api/generate`, () => new HttpResponse(null, { status: 500 })), + ); + + const result = await translateToEnglish('Bonjour', provider()); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('500'); + } + }); + + it('reports an unreachable service', async () => { + server.use(http.get(`${BASE}/api/models`, () => HttpResponse.error())); + expect((await translateToEnglish('Bonjour', provider())).ok).toBe(false); + }); + + it('reports a timeout without throwing', async () => { + server.use( + http.get(`${BASE}/api/models`, () => HttpResponse.json(MODELS)), + http.post(`${BASE}/api/generate`, async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + return HttpResponse.json({ stdout: 'late', exit_code: 0 }); + }), + ); + + const result = await translateToEnglish('Bonjour', provider({ timeoutMs: 20 })); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/temps|injoignable/); + } + }); + + it('says so when the configured model does not exist', async () => { + server.use(http.get(`${BASE}/api/models`, () => HttpResponse.json(MODELS))); + + const result = await translateToEnglish('Bonjour', provider({ modelName: 'sonnet-42' })); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('sonnet-42'); + } + expect(generateCalls).toBe(0); + }); + + it('does nothing for empty text', async () => { + const result = await translateToEnglish(' ', provider()); + expect(result).toMatchObject({ ok: true, text: '' }); + expect(modelCalls).toBe(0); + }); + + it('reports a service that is not configured', async () => { + const result = await translateToEnglish('Bonjour', null); + expect(result).toMatchObject({ ok: false }); + }); +});