Alle Pairs eines Bildes (de/en/sv) gehen zusammen mit dem Bild an Sonnet zur Prüfung von Rechtschreibung, Übersetzungs-Konsistenz und Plausibilität. Korrekturen werden vor der Audio-Erzeugung angewendet; vorhandene Audios korrigierter Zellen werden invalidiert. Review-Fehler sind nicht fatal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
209 lines
9.7 KiB
JavaScript
209 lines
9.7 KiB
JavaScript
// Gemeinsamer Übersetzungs-Kern (Claude + Platzhalter-Schutz).
|
|
// Wird von routes/claude.js (Endpoints) und routes/pairs.js (Pro-Pair-Übersetzung) genutzt.
|
|
const { query } = require('../db');
|
|
|
|
const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
|
|
// Übersetzungen laufen auf Sonnet (bessere Qualität, v.a. Schwedisch/Mehrdeutigkeit).
|
|
// Per Env überschreibbar, falls ein anderer Modell-Snapshot gewünscht ist.
|
|
const TRANSLATE_MODEL = process.env.TRANSLATE_MODEL || 'claude-sonnet-4-5';
|
|
const LANGS = ['de', 'en', 'sv'];
|
|
const LANG_LABEL = { de: 'Deutsch', en: 'English', sv: 'Svenska' };
|
|
|
|
// Welche Felder pro Tabelle übersetzbar sind (Spalten heißen `${field}_${lang}`).
|
|
const TRANSLATE_CONFIG = {
|
|
words: { fields: ['titel'] },
|
|
questions: { fields: ['sentence'] },
|
|
statements: { fields: ['positive_sentence', 'negative_sentence'] },
|
|
};
|
|
|
|
// ── Placeholder-Schutz ────────────────────────────────────────────────────────
|
|
// Format im Quelltext: {{label.w:uuid}} oder {{label.o:uuid}}
|
|
const { PLACEHOLDER_RE } = require('./placeholders');
|
|
|
|
// Sätze für Claude vorbereiten: jedes Placeholder durch ⟦PHn:label⟧-Token ersetzen.
|
|
// Token-Format ist absichtlich exotisch, damit Claude es nicht versehentlich ändert.
|
|
function tokenize(text) {
|
|
const tokens = [];
|
|
let i = 0;
|
|
const tokenized = text.replace(PLACEHOLDER_RE, (_, label, type, uuid) => {
|
|
const safeLabel = String(label).replace(/[⟦⟧:]/g, ' ').trim();
|
|
const key = `PH${i++}`;
|
|
tokens.push({ key, uuid, type, sourceLabel: label });
|
|
return `⟦${key}:${safeLabel}⟧`;
|
|
});
|
|
return { tokenized, tokens };
|
|
}
|
|
|
|
// Rückbau: aus Claude-Antwort wieder {{label.type:uuid}} machen.
|
|
// Erwartet `labels: { PH0: 'apple', ... }` aus dem JSON-Response.
|
|
function detokenize(translated, tokens, labelsFromClaude) {
|
|
let out = translated;
|
|
const seen = new Set();
|
|
for (const t of tokens) {
|
|
const label = (labelsFromClaude && labelsFromClaude[t.key]) || t.sourceLabel;
|
|
// Token-Form im Text kann ⟦PH0:irgendwas⟧ sein — wir matchen über die Key
|
|
const re = new RegExp(`⟦${t.key}:[^⟧]*⟧`, 'g');
|
|
let replaced = false;
|
|
out = out.replace(re, () => { replaced = true; seen.add(t.key); return `{{${label}.${t.type}:${t.uuid}}}`; });
|
|
if (!replaced) {
|
|
// Notfall: Token nicht zurückgekommen → an Ende hängen, damit nichts verloren geht
|
|
out += ` {{${label}.${t.type}:${t.uuid}}}`;
|
|
seen.add(t.key);
|
|
}
|
|
}
|
|
return { text: out, missingTokens: tokens.filter(t => !seen.has(t.key)).map(t => t.key) };
|
|
}
|
|
|
|
async function callClaude({ system, user, maxTokens = 2000, model = TRANSLATE_MODEL }) {
|
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
if (!apiKey) { const e = new Error('ANTHROPIC_API_KEY nicht konfiguriert'); e.status = 500; throw e; }
|
|
|
|
// Retry bei Überlast/Rate-Limit/Netzfehler — der große Pipeline-Durchlauf macht
|
|
// viele Calls hintereinander; ohne Retry brach früher eine Sprache komplett weg.
|
|
const RETRYABLE = new Set([429, 500, 503, 529]);
|
|
const delays = [1000, 4000, 12000];
|
|
let lastErr;
|
|
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
|
try {
|
|
const res = await fetch(ANTHROPIC_API_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
|
body: JSON.stringify({
|
|
model, max_tokens: maxTokens, system,
|
|
messages: [{ role: 'user', content: user }],
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
const e = new Error(err.error?.message || `Claude API ${res.status}`); e.status = res.status; throw e;
|
|
}
|
|
const data = await res.json();
|
|
let raw = data.content[0].text.trim();
|
|
const md = raw.match(/```(?:json)?\s*([\s\S]+?)\s*```/);
|
|
if (md) raw = md[1];
|
|
return JSON.parse(raw);
|
|
} catch (err) {
|
|
lastErr = err;
|
|
// Netzfehler (kein status) oder retrybare HTTP-Codes → erneut versuchen
|
|
const retryable = err.status == null || RETRYABLE.has(err.status);
|
|
if (!retryable || attempt === delays.length) throw err;
|
|
await new Promise(r => setTimeout(r, delays[attempt]));
|
|
}
|
|
}
|
|
throw lastErr;
|
|
}
|
|
|
|
// Übersetzt einen Text inkl. Placeholder-Schutz.
|
|
async function translateText({ text, from, to }) {
|
|
if (!text || !text.trim()) return '';
|
|
const { tokenized, tokens } = tokenize(text);
|
|
const system = 'Du bist ein professioneller Übersetzer. Antworte AUSSCHLIESSLICH mit gültigem JSON, ohne Markdown, ohne Erklärungen.';
|
|
const user = `Übersetze diesen Text von ${LANG_LABEL[from] || from} nach ${LANG_LABEL[to] || to}.\n\n` +
|
|
`WICHTIG: Tokens der Form ⟦PHn:wort⟧ sind Platzhalter. Übersetze NUR das Wort innerhalb des Tokens, ` +
|
|
`behalte das Token-Format exakt bei (⟦PHn:übersetztesWort⟧). Passe die Beugung des Wortes an den umgebenden Satz an ` +
|
|
`(Mehrzahl/Kasus). Die Token-Reihenfolge im Satz darfst du frei wählen wie es natürlich klingt.\n\n` +
|
|
`Quelltext:\n${tokenized}\n\n` +
|
|
`Antwort-Format:\n{"translated":"...","labels":{${tokens.map(t => `"${t.key}":"<übersetztes Wort>"`).join(',')}}}`;
|
|
|
|
const data = await callClaude({ system, user });
|
|
if (typeof data.translated !== 'string') throw new Error('Ungültiges JSON: translated fehlt');
|
|
const { text: detok } = detokenize(data.translated, tokens, data.labels || {});
|
|
return detok;
|
|
}
|
|
|
|
// ── Auto-Status für Wörter (Spiegel zum Trigger in words.js) ──────────────────
|
|
async function maybeAutoTranslated(wordId) {
|
|
const r = await query(`SELECT titel_de, titel_en, titel_sv, status FROM words WHERE id = $1`, [wordId]);
|
|
const w = r.rows[0];
|
|
if (!w) return;
|
|
if (w.titel_de && w.titel_en && w.titel_sv && w.status === 'requested')
|
|
await query(`UPDATE words SET status='translated' WHERE id=$1`, [wordId]);
|
|
}
|
|
|
|
// Füllt für eine Zeile alle leeren Zielsprachen der angegebenen Felder auf.
|
|
// Quellsprache wird pro Feld automatisch gewählt (erste gefüllte ≠ Ziel),
|
|
// sofern `from` nicht explizit übergeben wird.
|
|
// Gibt { translatedFields: [...] } zurück; leere Hüllen-Zeilen ⇒ [].
|
|
async function fillMissingRow(table, id, fields, { from, overwrite = false } = {}) {
|
|
const cfg = TRANSLATE_CONFIG[table];
|
|
if (!cfg) throw new Error(`Unbekannte Tabelle: ${table}`);
|
|
const useFields = fields && fields.length ? fields : cfg.fields;
|
|
const cols = useFields.flatMap(f => LANGS.map(l => `${f}_${l}`));
|
|
const r = await query(`SELECT ${cols.join(', ')} FROM ${table} WHERE id = $1`, [id]);
|
|
if (!r.rows.length) return { translatedFields: [] };
|
|
const row = r.rows[0];
|
|
|
|
const updates = {};
|
|
for (const field of useFields) {
|
|
// Quellsprache für dieses Feld bestimmen
|
|
let src = from;
|
|
if (!src) {
|
|
for (const l of LANGS) if ((row[`${field}_${l}`] || '').trim()) { src = l; break; }
|
|
}
|
|
if (!src) continue; // kein Quelltext in irgendeiner Sprache → nichts zu tun
|
|
const srcText = (row[`${field}_${src}`] || '').trim();
|
|
if (!srcText) continue;
|
|
for (const to of LANGS) {
|
|
if (to === src) continue;
|
|
if (!overwrite && (row[`${field}_${to}`] || '').trim()) continue; // Ziel schon gefüllt
|
|
updates[`${field}_${to}`] = await translateText({ text: srcText, from: src, to });
|
|
}
|
|
}
|
|
|
|
const cells = Object.keys(updates);
|
|
if (!cells.length) return { translatedFields: [] };
|
|
const setClauses = cells.map((c, i) => `${c} = $${i + 1}`).join(', ');
|
|
await query(`UPDATE ${table} SET ${setClauses} WHERE id = $${cells.length + 1}`,
|
|
[...cells.map(c => updates[c]), id]);
|
|
if (table === 'words') await maybeAutoTranslated(id);
|
|
return { translatedFields: cells };
|
|
}
|
|
|
|
// Übersetzt mehrere Wörter in EINEM Claude-Call, optional mit Kontext (z.B. der Frage).
|
|
// Gemeinsame Übersetzung sorgt für Konsistenz und richtige Bedeutung mehrdeutiger Wörter.
|
|
// `words`: [{ id, text }]. Rückgabe: Map id → übersetztes Wort (nur erfolgreich übersetzte).
|
|
async function translateWords({ words, from, to, context }) {
|
|
const items = (words || []).filter(w => (w.text || '').trim());
|
|
if (!items.length) return {};
|
|
|
|
const ctxLine = (context || '').trim()
|
|
? `Kontext: Diese Wörter sind Antwortoptionen auf die Frage „${context.trim()}". Übersetze sie fachlich korrekt und passend zu diesem Kontext.\n\n`
|
|
: '';
|
|
const system = 'Du bist ein professioneller Übersetzer. Antworte AUSSCHLIESSLICH mit gültigem JSON, ohne Markdown, ohne Erklärungen.';
|
|
const user =
|
|
`Übersetze die folgenden Wörter von ${LANG_LABEL[from] || from} nach ${LANG_LABEL[to] || to}.\n\n` +
|
|
ctxLine +
|
|
`Wähle bei mehrdeutigen Wörtern die im Kontext fachlich korrekte Bedeutung.\n\n` +
|
|
`Wörter (JSON-Array):\n${JSON.stringify(items.map(w => w.text))}\n\n` +
|
|
`Antwort-Format: ein JSON-Array mit den Übersetzungen in EXAKT gleicher Reihenfolge und Länge:\n` +
|
|
`{"translations":[${items.map(() => '"…"').join(',')}]}`;
|
|
|
|
let arr;
|
|
try {
|
|
const data = await callClaude({ system, user });
|
|
arr = Array.isArray(data.translations) ? data.translations : null;
|
|
} catch { arr = null; }
|
|
|
|
// Fallback: kein/ungültiges Array oder Längen-Mismatch → Wort für Wort einzeln.
|
|
if (!arr || arr.length !== items.length) {
|
|
const out = {};
|
|
for (const w of items) {
|
|
try { out[w.id] = await translateText({ text: w.text, from, to }); } catch { /* skip */ }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const out = {};
|
|
items.forEach((w, i) => {
|
|
const t = (arr[i] || '').toString().trim();
|
|
if (t) out[w.id] = t;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
module.exports = {
|
|
LANGS, LANG_LABEL, TRANSLATE_CONFIG,
|
|
tokenize, detokenize, callClaude,
|
|
translateText, translateWords, maybeAutoTranslated, fillMissingRow,
|
|
};
|