feat: bessere Übersetzungsqualität (Sonnet + Wörter mit Kontext)

- Übersetzungs-Modell auf Sonnet (env TRANSLATE_MODEL, Default claude-sonnet-4-5).
- Neue translateWords(): übersetzt die Wörter eines word-Pairs gemeinsam in
  einem Call, mit der Frage als Kontext → korrekte Bedeutung mehrdeutiger
  Wörter (z.B. 'Ranke' → 'ranka' statt 'klänge'), konsistente Gruppe.
- POST /pairs/:id/translate nutzt translateWordGroup für word-Typ und nimmt
  { overwrite:true } entgegen, um falsche bestehende Zielsprachen neu zu
  übersetzen (Quellsprache bleibt unangetastet); fillMissingRow erhält
  overwrite-Flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 21:29:11 +02:00
parent ccba8902a4
commit 29a260e351
2 changed files with 105 additions and 28 deletions

View File

@@ -3,7 +3,9 @@
const { query } = require('../db');
const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
const ANTHROPIC_MODEL = 'claude-haiku-4-5-20251001';
// Ü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' };
@@ -59,7 +61,7 @@ async function callClaude({ system, user, maxTokens = 2000 }) {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
body: JSON.stringify({
model: ANTHROPIC_MODEL, max_tokens: maxTokens, system,
model: TRANSLATE_MODEL, max_tokens: maxTokens, system,
messages: [{ role: 'user', content: user }],
}),
});
@@ -102,7 +104,7 @@ async function maybeAutoTranslated(wordId) {
// 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 } = {}) {
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;
@@ -123,7 +125,7 @@ async function fillMissingRow(table, id, fields, { from } = {}) {
if (!srcText) continue;
for (const to of LANGS) {
if (to === src) continue;
if ((row[`${field}_${to}`] || '').trim()) continue; // Ziel schon gefüllt
if (!overwrite && (row[`${field}_${to}`] || '').trim()) continue; // Ziel schon gefüllt
updates[`${field}_${to}`] = await translateText({ text: srcText, from: src, to });
}
}
@@ -137,8 +139,50 @@ async function fillMissingRow(table, id, fields, { from } = {}) {
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, maybeAutoTranslated, fillMissingRow,
translateText, translateWords, maybeAutoTranslated, fillMissingRow,
};