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
+64
View File
@@ -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('');
});
});