// 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, };