diff --git a/apps/web/src/app/[slug]/read/page.jsx b/apps/web/src/app/[slug]/read/page.jsx
index fda8a828..6e46f45c 100644
--- a/apps/web/src/app/[slug]/read/page.jsx
+++ b/apps/web/src/app/[slug]/read/page.jsx
@@ -363,6 +363,18 @@ export default async function ReaderPage({ params, searchParams }) {
)}
+ {/*
+ * The translation is running in the background (c0mpute takes minutes
+ * under load). The original shows meanwhile; reloading later is a
+ * cache hit and costs the reader nothing more.
+ */}
+ {'pending' in attempt && attempt.pending && (
+
+ Translating this post — it takes a few minutes. Showing the original until it is
+ ready; reload this page later to read it in your language.
+
+ )}
+
=18.0.0" }, "optionalPeers": ["react"] }, "sha512-hXzHgCFj5PAHsov6bnIai++cRaHJsoo8OsSPj1b41qDAP3WfABE87O3A7vlQ44MoRPkqV7pEk5JaSgSAGmB9Lw=="],
diff --git a/packages/translate/index.js b/packages/translate/index.js
index 23db3655..274c9407 100644
--- a/packages/translate/index.js
+++ b/packages/translate/index.js
@@ -14,13 +14,41 @@ export {
} from './src/languages.js';
export {
MODEL,
+ MAX_PEER_ARTICLE_CHARS,
MAX_SOURCE_CHARS,
MAX_ARTICLE_CHARS,
MIN_ARTICLE_CHARS,
translatePost,
translateArticle,
+ readTagged,
} from './src/translate.js';
+/**
+ * How long a request waits for a fresh translation before answering "pending".
+ *
+ * c0mpute, which answers first, takes minutes under load (a peer node writes a
+ * few tokens a second, after a queue). That cannot sit inside a page render or
+ * behind the proxy's timeout, so the translation runs in the background and the
+ * request only waits this long for it. A fast answer still lands on the first
+ * click; a slow one lands in the cache, and the next view of the post is a hit.
+ */
+export const DEFAULT_WAIT_MS = 8_000;
+
+/**
+ * Background translations one server process runs at once. Past this a cache
+ * miss shows the original and is not charged, rather than queueing work that
+ * would finish long after anybody cared.
+ */
+export const MAX_PENDING = 24;
+
+/** @type {Map>} */
+const pending = new Map();
+
+/** For tests: the background translations still running. */
+export function pendingTranslations() {
+ return [...pending.values()];
+}
+
/**
* Paid translations one reader may trigger in a UTC day.
*
@@ -60,13 +88,15 @@ export const DEFAULT_DAILY_TOTAL = 1000;
* hurry. A value that is not a positive number falls back to the default rather
* than to "no limit" — a typo in a Railway variable must not uncap spending.
*
- * @returns {{ perUser: number, total: number }}
+ * @returns {{ perUser: number, total: number, waitMs: number }}
*/
export function limits() {
const env = process.env;
+ const wait = Number(env['TRANSLATE_WAIT_MS']);
return {
perUser: positive(env['TRANSLATE_DAILY_PER_USER'], DEFAULT_DAILY_PER_USER),
total: positive(env['TRANSLATE_DAILY_TOTAL'], DEFAULT_DAILY_TOTAL),
+ waitMs: Number.isFinite(wait) && wait >= 0 ? wait : DEFAULT_WAIT_MS,
};
}
@@ -108,6 +138,7 @@ function positive(raw, fallback) {
* sourceLang?: string|null,
* userId?: string|null,
* now?: Date,
+ * ai?: import('@profullstack/ai-providers').AiRouter,
* }} input
* @returns {Promise<{
* translation: {
@@ -115,6 +146,7 @@ function positive(raw, fallback) {
* truncated: boolean, sourceLang: string|null, cached: boolean
* }|null,
* limited: boolean,
+ * pending?: boolean,
* }>}
*/
export async function ensureTranslation(db, input) {
@@ -151,8 +183,14 @@ export async function ensureTranslation(db, input) {
const userId = input.userId ? String(input.userId) : null;
if (!userId) return none();
+ // Already being translated (by this process): say so, and do not charge the
+ // reader again for asking twice.
+ const key = `${input.itemId}\u0000${target}`;
+ if (pending.has(key)) return { translation: null, limited: false, pending: true };
+ if (pending.size >= MAX_PENDING) return none();
+
const day = translations.usageDay(input.now);
- const { perUser, total } = limits();
+ const { perUser, total, waitMs } = limits();
const [mine, everyone] = await Promise.all([
translations.usageForUser(db, userId, day),
@@ -174,58 +212,80 @@ export async function ensureTranslation(db, input) {
const body = String(input.contentHtml ?? '');
const wholeArticle = textLength(body) >= MIN_ARTICLE_CHARS;
- let fresh;
- try {
- fresh = wholeArticle
- ? await translateArticle({
- title: input.title,
- summary: input.summary,
- contentHtml: body,
- targetLang: target,
- sourceLang: source,
- })
- : await translatePost({
- title: input.title,
- summary: input.summary,
- targetLang: target,
- sourceLang: source,
- });
- } catch {
- // Rate limits, timeouts, a revoked key: all of them mean "show the
- // original", and none of them should reach the reader as an error page.
- return none();
- }
+ const job = (async () => {
+ let fresh;
+ try {
+ fresh = wholeArticle
+ ? await translateArticle({
+ title: input.title,
+ summary: input.summary,
+ contentHtml: body,
+ targetLang: target,
+ sourceLang: source,
+ ai: input.ai,
+ })
+ : await translatePost({
+ title: input.title,
+ summary: input.summary,
+ targetLang: target,
+ sourceLang: source,
+ ai: input.ai,
+ });
+ } catch {
+ // Rate limits, timeouts, a revoked key: all of them mean "show the
+ // original", and none of them should reach the reader as an error page.
+ return null;
+ }
- if (!fresh) return none();
-
- // Sanitized before it is stored as well as before it is rendered. The model
- // is not a trusted author — a post can ask it for a script tag — and a
- // sanitizer that only runs at render time is one forgotten call site away
- // from serving whatever came back.
- const translatedHtml = fresh.contentHtml ? sanitizeHtml(fresh.contentHtml) : null;
-
- await translations.saveTranslation(db, {
- itemId: input.itemId,
- lang: target,
- title: fresh.title,
- summary: fresh.summary,
- contentHtml: translatedHtml,
- truncated: Boolean(fresh.truncated),
- model: fresh.model,
- sourceLang: fresh.sourceLang ?? source,
- });
+ if (!fresh) return null;
- return {
- translation: {
+ // Sanitized before it is stored as well as before it is rendered. The model
+ // is not a trusted author — a post can ask it for a script tag — and a
+ // sanitizer that only runs at render time is one forgotten call site away
+ // from serving whatever came back.
+ const translatedHtml = fresh.contentHtml ? sanitizeHtml(fresh.contentHtml) : null;
+
+ await translations.saveTranslation(db, {
+ itemId: input.itemId,
+ lang: target,
+ title: fresh.title,
+ summary: fresh.summary,
+ contentHtml: translatedHtml,
+ truncated: Boolean(fresh.truncated),
+ model: fresh.model,
+ sourceLang: fresh.sourceLang ?? source,
+ });
+
+ return {
title: fresh.title,
summary: fresh.summary,
contentHtml: translatedHtml,
truncated: Boolean(fresh.truncated),
sourceLang: fresh.sourceLang ?? source,
cached: false,
- },
- limited: false,
- };
+ };
+ })()
+ .catch((err) => {
+ console.warn(`[translate] ${input.itemId} -> ${target}: ${err?.message ?? err}`);
+ return null;
+ })
+ .finally(() => pending.delete(key));
+ pending.set(key, job);
+
+ // Wait a little: a quick answer is shown now, a slow one finishes in the
+ // background and is a cache hit on the next view.
+ let timer;
+ const waited = await Promise.race([
+ job.then((translation) => ({ translation })),
+ new Promise((resolve) => {
+ timer = setTimeout(() => resolve(null), waitMs);
+ }),
+ ]);
+ clearTimeout(timer);
+
+ if (!waited) return { translation: null, limited: false, pending: true };
+ if (!waited.translation) return none();
+ return { translation: waited.translation, limited: false };
}
/**
diff --git a/packages/translate/package.json b/packages/translate/package.json
index 08848a81..0c00e98f 100644
--- a/packages/translate/package.json
+++ b/packages/translate/package.json
@@ -13,6 +13,7 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^1.3.0",
+ "@profullstack/ai-providers": "0.2.0",
"@rssamplifier/db": "workspace:*",
"@rssamplifier/feed": "workspace:*"
}
diff --git a/packages/translate/src/translate.js b/packages/translate/src/translate.js
index 294fa406..0fe55e65 100644
--- a/packages/translate/src/translate.js
+++ b/packages/translate/src/translate.js
@@ -1,4 +1,5 @@
import Anthropic from '@anthropic-ai/sdk';
+import { AllProvidersFailedError, ProviderError, getAi } from '@profullstack/ai-providers';
/**
* Machine translation of one post's title and summary.
@@ -7,11 +8,32 @@ import Anthropic from '@anthropic-ai/sdk';
* task where a small model loses almost nothing — there is no reasoning to do,
* the source text says exactly what the output should say — and the directory
* holds 47k feeds, so the per-post cost is the whole design constraint.
+ *
+ * c0mpute (the peer network, through @profullstack/ai-providers) answers
+ * first. The Anthropic call below, with its schema-constrained output, is the
+ * paid fallback and runs only when c0mpute fails and a key is set. Any other
+ * paid provider the router has (OpenAI, when OPENAI_API_KEY is set) gets the
+ * same tagged-text prompt c0mpute does.
+ *
+ * Everything translated here is a public feed post, which is why it may go to
+ * peer nodes at all.
*/
-/** @see https://platform.claude.com/docs/en/about-claude/models/overview */
+/** The paid fallback's model. @see https://platform.claude.com/docs/en/about-claude/models/overview */
export const MODEL = 'claude-haiku-4-5';
+/**
+ * Longest article body sent to c0mpute, in characters of HTML.
+ *
+ * A peer node writes a few tokens a second under load, so 60k characters would
+ * be hours. Past this the article is translated down to here and marked
+ * truncated, exactly as the paid path does at MAX_ARTICLE_CHARS.
+ */
+export const MAX_PEER_ARTICLE_CHARS = 8_000;
+
+/** Whole-call ceiling for a c0mpute translation; they run off the request path. */
+const PEER_TIMEOUT_MS = 30 * 60_000;
+
/**
* Longest source text sent for translation.
*
@@ -65,6 +87,7 @@ const SYSTEM = [
* targetLang: string,
* sourceLang?: string|null,
* client?: Anthropic,
+ * ai?: import('@profullstack/ai-providers').AiRouter,
* }} input
* @returns {Promise<{ title: string, summary: string|null, sourceLang: string|null, model: string }|null>}
*/
@@ -72,9 +95,6 @@ export async function translatePost(input) {
const title = String(input.title ?? '').trim();
if (!title) return null;
- const client = input.client ?? anthropic();
- if (!client) return null;
-
const summary = String(input.summary ?? '')
.trim()
.slice(0, MAX_SOURCE_CHARS);
@@ -83,6 +103,38 @@ export async function translatePost(input) {
? `The source is believed to be in "${input.sourceLang}", but trust the text over that label.`
: 'The source language is not recorded; work it out from the text.';
+ const user = [
+ `Target language: ${input.targetLang}`,
+ known,
+ '',
+ `\n${title}\n`,
+ `\n${summary}\n`,
+ ].join('\n');
+
+ return route(input, {
+ feature: 'translate-post',
+ paid: (client) => postViaAnthropic(client, user),
+ request: {
+ system: `${SYSTEM}\n\n${TAGGED_ANSWER}`,
+ user,
+ maxTokens: 2000,
+ temperature: 0.2,
+ timeoutMs: PEER_TIMEOUT_MS,
+ },
+ read: (text) => {
+ const parsed = readTagged(text, ['title', 'summary', 'source_language']);
+ return parsed && finishPost(parsed);
+ },
+ });
+}
+
+/**
+ * The paid tier: Anthropic with structured outputs.
+ *
+ * @param {Anthropic} client
+ * @param {string} user
+ */
+async function postViaAnthropic(client, user) {
const response = await client.messages.create({
model: MODEL,
// Title plus a capped summary — a few thousand characters of output at
@@ -90,18 +142,7 @@ export async function translatePost(input) {
max_tokens: 4000,
system: SYSTEM,
output_config: { format: { type: 'json_schema', schema: OUTPUT_SCHEMA } },
- messages: [
- {
- role: 'user',
- content: [
- `Target language: ${input.targetLang}`,
- known,
- '',
- `\n${title}\n`,
- `\n${summary}\n`,
- ].join('\n'),
- },
- ],
+ messages: [{ role: 'user', content: user }],
});
// A refusal and a truncation both come back as an ordinary 200 with unusable
@@ -119,6 +160,14 @@ export async function translatePost(input) {
return null;
}
+ const result = finishPost(parsed);
+ return result && { ...result, model: MODEL };
+}
+
+/**
+ * @param {{ title?: unknown, summary?: unknown, source_language?: unknown }} parsed
+ */
+function finishPost(parsed) {
const translatedTitle = String(parsed.title ?? '').trim();
if (!translatedTitle) return null;
@@ -127,8 +176,7 @@ export async function translatePost(input) {
return {
title: translatedTitle,
summary: translatedSummary || null,
- sourceLang: parsed.source_language ? String(parsed.source_language) : null,
- model: MODEL,
+ sourceLang: parsed.source_language ? String(parsed.source_language).trim() : null,
};
}
@@ -210,6 +258,7 @@ const ARTICLE_SYSTEM = [
* targetLang: string,
* sourceLang?: string|null,
* client?: Anthropic,
+ * ai?: import('@profullstack/ai-providers').AiRouter,
* }} input
* @returns {Promise<{
* title: string, summary: string|null, contentHtml: string,
@@ -221,9 +270,6 @@ export async function translateArticle(input) {
const body = String(input.contentHtml ?? '').trim();
if (!title || !body) return null;
- const client = input.client ?? anthropic();
- if (!client) return null;
-
const truncated = body.length > MAX_ARTICLE_CHARS;
const source = truncated ? body.slice(0, MAX_ARTICLE_CHARS) : body;
@@ -235,27 +281,59 @@ export async function translateArticle(input) {
? `The source is believed to be in "${input.sourceLang}", but trust the text over that label.`
: 'The source language is not recorded; work it out from the text.';
+ const prompt = (/** @type {string} */ body, /** @type {boolean} */ cut) =>
+ [
+ `Target language: ${input.targetLang}`,
+ known,
+ cut ? 'This is the beginning of a longer post; translate what is here and stop.' : '',
+ '',
+ `\n${title}\n`,
+ `\n${summary}\n`,
+ `\n${body}\n`,
+ ].join('\n');
+
+ // The peer network gets less of a long post than the paid model would: it is
+ // a few tokens a second, and a translation nobody waits for is no use.
+ const peerCut = body.length > MAX_PEER_ARTICLE_CHARS;
+ const peerSource = peerCut ? cutHtml(body, MAX_PEER_ARTICLE_CHARS) : body;
+
+ return route(input, {
+ feature: 'translate-article',
+ paid: (client) => articleViaAnthropic(client, prompt(source, truncated), truncated),
+ request: {
+ system: `${ARTICLE_SYSTEM}\n\n${TAGGED_ARTICLE_ANSWER}`,
+ user: prompt(peerSource, peerCut),
+ maxTokens: 8000,
+ temperature: 0.2,
+ timeoutMs: PEER_TIMEOUT_MS,
+ },
+ read: (text) => {
+ const parsed = readTagged(text, ['title', 'summary', 'source_language', 'body']);
+ if (!parsed) return null;
+ const result = finishArticle({ ...parsed, content_html: parsed.body });
+ return result && { ...result, truncated: peerCut };
+ },
+ });
+}
+
+/**
+ * The paid tier: Anthropic, streamed, with structured outputs.
+ *
+ * Streamed rather than awaited as one response. A full article at this token
+ * ceiling is minutes of generation, and a non-streaming request that long hits
+ * the SDK's HTTP timeout and throws away work that was nearly finished.
+ *
+ * @param {Anthropic} client
+ * @param {string} user
+ * @param {boolean} truncated
+ */
+async function articleViaAnthropic(client, user, truncated) {
const stream = client.messages.stream({
model: MODEL,
max_tokens: ARTICLE_MAX_TOKENS,
system: ARTICLE_SYSTEM,
output_config: { format: { type: 'json_schema', schema: ARTICLE_SCHEMA } },
- messages: [
- {
- role: 'user',
- content: [
- `Target language: ${input.targetLang}`,
- known,
- truncated
- ? 'This is the beginning of a longer post; translate what is here and stop.'
- : '',
- '',
- `\n${title}\n`,
- `\n${summary}\n`,
- `\n${source}\n`,
- ].join('\n'),
- },
- ],
+ messages: [{ role: 'user', content: user }],
});
const response = await stream.finalMessage();
@@ -277,6 +355,14 @@ export async function translateArticle(input) {
return null;
}
+ const result = finishArticle(parsed);
+ return result && { ...result, model: MODEL, truncated };
+}
+
+/**
+ * @param {{ title?: unknown, summary?: unknown, content_html?: unknown, source_language?: unknown }} parsed
+ */
+function finishArticle(parsed) {
const translatedTitle = String(parsed.title ?? '').trim();
const translatedBody = String(parsed.content_html ?? '').trim();
if (!translatedTitle || !translatedBody) return null;
@@ -287,12 +373,126 @@ export async function translateArticle(input) {
title: translatedTitle,
summary: translatedSummary || null,
contentHtml: translatedBody,
- sourceLang: parsed.source_language ? String(parsed.source_language) : null,
- model: MODEL,
- truncated,
+ sourceLang: parsed.source_language ? String(parsed.source_language).trim() : null,
};
}
+/**
+ * The answer format for c0mpute and any non-Anthropic fallback. Tags rather
+ * than JSON: a 9B model escapes a page of HTML into a JSON string badly, and
+ * it copies the tags the input already arrives in very reliably.
+ */
+const TAGGED_ANSWER = [
+ 'Answer in exactly this format and nothing else:',
+ 'the translated title',
+ 'the translated summary',
+ 'ISO-639-1 code of the source text',
+].join('\n');
+
+const TAGGED_ARTICLE_ANSWER = [
+ TAGGED_ANSWER,
+ 'the translated body HTML',
+].join('\n');
+
+/**
+ * Reads `...` fields out of a model answer. `body` is read last and
+ * greedily, so markup inside it cannot end it early; the short fields are read
+ * from the text before it. Null if any field is missing.
+ *
+ * @param {string} text
+ * @param {string[]} fields
+ * @returns {Record|null}
+ */
+export function readTagged(text, fields) {
+ let rest = String(text ?? '').replace(/[\s\S]*?<\/think>/gi, '');
+ /** @type {Record} */
+ const out = {};
+ if (fields.includes('body')) {
+ const open = rest.indexOf('');
+ const close = rest.lastIndexOf('');
+ if (open < 0 || close < open) return null;
+ out.body = rest.slice(open + 6, close).trim();
+ rest = rest.slice(0, open);
+ }
+ for (const field of fields) {
+ if (field === 'body') continue;
+ const m = new RegExp(`<${field}>([\\s\\S]*?)${field}>`, 'i').exec(rest);
+ if (!m) return null;
+ out[field] = m[1].trim();
+ }
+ return out;
+}
+
+/**
+ * Cuts HTML at a paragraph-ish boundary at or before `max` characters, so the
+ * model is not handed half a tag.
+ *
+ * @param {string} html
+ * @param {number} max
+ */
+function cutHtml(html, max) {
+ const head = html.slice(0, max);
+ const at = Math.max(head.lastIndexOf(''), head.lastIndexOf(''), head.lastIndexOf(''));
+ if (at > max / 2) return head.slice(0, head.indexOf('>', at) + 1);
+ const tag = head.lastIndexOf('<');
+ return tag > head.lastIndexOf('>') ? head.slice(0, tag) : head;
+}
+
+/**
+ * Runs one translation through the provider chain: c0mpute first (streamed,
+ * over the package's provider.stream), then the paid fallbacks. The Anthropic
+ * slot runs this module's own SDK call.
+ *
+ * A test (or a caller) that passes `client` without `ai` gets that client and
+ * nothing else, which is how the request shape is unit-tested.
+ *
+ * Null when every provider failed or answered unusably: a translation is an
+ * enhancement and its absence is never an error page.
+ *
+ * @template T
+ * @param {{ client?: any, ai?: any }} input
+ * @param {{
+ * feature: string,
+ * paid: (client: Anthropic) => Promise,
+ * request: object,
+ * read: (text: string) => T|null,
+ * }} job
+ * @returns {Promise<(T & { model: string })|null>}
+ */
+async function route(input, job) {
+ if (input.client && !input.ai) return /** @type {any} */ (job.paid(input.client));
+
+ const router = input.ai ?? getAi();
+ try {
+ const { value } = await router.run(
+ async (/** @type {any} */ provider) => {
+ if (provider.name === 'anthropic') {
+ const client = input.client ?? anthropic();
+ if (!client) throw new ProviderError('anthropic', 'no API key', { kind: 'billing' });
+ const answer = await job.paid(client);
+ if (!answer) throw new ProviderError('anthropic', 'refused or unusable answer', { kind: 'output' });
+ return answer;
+ }
+ const out =
+ typeof provider.stream === 'function'
+ ? await provider.stream(job.request, () => {})
+ : await provider.generate(job.request);
+ const answer = job.read(out.text);
+ if (!answer) throw new ProviderError(provider.name, 'answer was not in the tagged format', { kind: 'output' });
+ return { ...answer, model: `${provider.name}:${out.model}` };
+ },
+ { feature: job.feature },
+ );
+ return value;
+ } catch (err) {
+ if (err instanceof AllProvidersFailedError) {
+ console.warn(`[translate] ${job.feature}: ${err.message}`);
+ return null;
+ }
+ throw err;
+ }
+}
+
/**
* A client, or null when the deployment has no key.
*
diff --git a/packages/translate/test/ensure.test.js b/packages/translate/test/ensure.test.js
index ef059e71..35d7b31f 100644
--- a/packages/translate/test/ensure.test.js
+++ b/packages/translate/test/ensure.test.js
@@ -3,7 +3,19 @@ import { test, before, after, beforeEach } from 'node:test';
import { q, translations, accounts } from '@rssamplifier/db';
import { connectTest } from '@rssamplifier/db/src/testdb.js';
-import { DEFAULT_DAILY_PER_USER, DEFAULT_DAILY_TOTAL, ensureTranslation, limits } from '../index.js';
+import { AiRouter } from '@profullstack/ai-providers';
+
+import {
+ DEFAULT_DAILY_PER_USER,
+ DEFAULT_DAILY_TOTAL,
+ DEFAULT_WAIT_MS,
+ ensureTranslation,
+ limits,
+ pendingTranslations,
+} from '../index.js';
+
+// No provider at all unless a test hands one in: c0mpute off, no paid keys.
+process.env['C0MPUTE_DISABLED'] = '1';
/**
* The branches of ensureTranslation that must never reach the API: a cache hit,
@@ -44,6 +56,7 @@ beforeEach(async () => {
await db.execute('delete from translation_usage');
delete process.env['TRANSLATE_DAILY_PER_USER'];
delete process.env['TRANSLATE_DAILY_TOTAL'];
+ delete process.env['TRANSLATE_WAIT_MS'];
});
after(async () => {
@@ -199,7 +212,11 @@ test("yesterday's spending does not count against today", async () => {
});
test('limits fall back to the defaults rather than to no limit', async () => {
- assert.deepEqual(limits(), { perUser: DEFAULT_DAILY_PER_USER, total: DEFAULT_DAILY_TOTAL });
+ assert.deepEqual(limits(), {
+ perUser: DEFAULT_DAILY_PER_USER,
+ total: DEFAULT_DAILY_TOTAL,
+ waitMs: DEFAULT_WAIT_MS,
+ });
// A typo in a Railway variable must not uncap spending.
for (const bad of ['', 'lots', '0', '-5', 'NaN']) {
@@ -210,3 +227,78 @@ test('limits fall back to the defaults rather than to no limit', async () => {
process.env['TRANSLATE_DAILY_TOTAL'] = '250';
assert.equal(limits().total, 250);
});
+
+/**
+ * A c0mpute stand-in that answers in the tagged format after `ms`.
+ *
+ * @param {number} ms
+ */
+function peer(ms) {
+ const provider = {
+ name: 'c0mpute',
+ calls: 0,
+ /** @param {any} req */
+ async generate(req) {
+ provider.calls += 1;
+ provider.last = req;
+ await new Promise((r) => setTimeout(r, ms));
+ return {
+ text: 'Configurer le BIOS UEFI de Proxmox\nBonjour le forum !\nde',
+ model: 'qwen-9b',
+ usage: { inputTokens: 1, outputTokens: 1, estimated: true },
+ };
+ },
+ last: /** @type {any} */ (null),
+ };
+ return provider;
+}
+
+test('c0mpute answers first, and a quick answer is shown and cached on the first click', async () => {
+ await db.execute('delete from item_translations');
+ const provider = peer(5);
+ const { translation, limited } = await ask({ ai: new AiRouter({ primary: [provider] }) });
+
+ assert.equal(limited, false);
+ assert.equal(translation.title, 'Configurer le BIOS UEFI de Proxmox');
+ assert.equal(translation.summary, 'Bonjour le forum !');
+ assert.match(provider.last.system, //);
+
+ const row = await translations.translationFor(db, itemId, 'fr');
+ assert.equal(row.model, 'c0mpute:qwen-9b');
+});
+
+test('a slow translation runs in the background: pending now, a cache hit later, charged once', async () => {
+ await db.execute('delete from item_translations');
+ process.env['TRANSLATE_WAIT_MS'] = '10';
+ const provider = peer(150);
+ const ai = new AiRouter({ primary: [provider] });
+
+ const first = await ask({ ai });
+ assert.equal(first.translation, null);
+ assert.equal(first.pending, true);
+
+ // Asking again while it runs is not a second call and not a second charge.
+ const second = await ask({ ai });
+ assert.equal(second.pending, true);
+ assert.equal(provider.calls, 1);
+ assert.equal(await translations.usageForUser(db, userId, translations.usageDay()), 1);
+
+ await Promise.all(pendingTranslations());
+ const third = await ask({ ai });
+ assert.equal(third.translation.cached, true);
+ assert.equal(third.translation.title, 'Configurer le BIOS UEFI de Proxmox');
+});
+
+test('an answer that is not in the tagged format shows the original', async () => {
+ await db.execute('delete from item_translations');
+ const junk = {
+ name: 'c0mpute',
+ async generate() {
+ return { text: 'Sure! Here is your translation: ...', model: 'm', usage: { inputTokens: 0, outputTokens: 0, estimated: true } };
+ },
+ };
+ const { translation, pending } = await ask({ ai: new AiRouter({ primary: [junk] }) });
+ assert.equal(translation, null);
+ assert.equal(pending, undefined);
+ assert.equal(await translations.translationFor(db, itemId, 'fr'), null);
+});
diff --git a/packages/translate/test/translate.test.js b/packages/translate/test/translate.test.js
index 0336ae5e..bf5d5f49 100644
--- a/packages/translate/test/translate.test.js
+++ b/packages/translate/test/translate.test.js
@@ -182,3 +182,22 @@ test('a post with no title never reaches the API', async () => {
);
assert.equal(client.calls.length, 0);
});
+
+test('readTagged reads the short fields and a body full of markup', async () => {
+ const { readTagged } = await import('../src/translate.js');
+ const text = [
+ 'scratch',
+ 'Titre',
+ '',
+ 'en',
+ 'Un texte
</p>
',
+ ].join('\n');
+ assert.deepEqual(readTagged(text, ['title', 'summary', 'source_language', 'body']), {
+ title: 'Titre',
+ summary: '',
+ source_language: 'en',
+ body: 'Un texte
</p>
',
+ });
+ assert.equal(readTagged('x', ['title', 'summary']), null);
+ assert.equal(readTagged('xcut off', ['title', 'body']), null);
+});