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

@@ -1,6 +1,6 @@
const router = require('express').Router();
const { query } = require('../db');
const { fillMissingRow } = require('../lib/translate');
const { fillMissingRow, translateWords, maybeAutoTranslated } = require('../lib/translate');
const STATUSES = ['draft', 'reviewed', 'blocked', 'published'];
const ANSWER_TYPES = new Set(['yes_no', 'text', 'question', 'word']);
@@ -243,10 +243,50 @@ async function loadPairContent(p) {
return content;
}
// POST /api/pairs/:id/translate — übersetzt alle noch fehlenden Sätze/Wörter dieses Pairs
// in die fehlenden Sprachen (de/en/sv). Liefert das aktualisierte Inhalts-Bündel fürs Modal.
// Übersetzt die einem Statement zugeordneten Wörter gemeinsam (ein Claude-Call je Zielsprache),
// mit der Frage als Kontext zur Disambiguierung. `questionRow` = { sentence_de/en/sv } | null.
// Gibt die Anzahl tatsächlich aktualisierter Wort-Felder zurück.
async function translateWordGroup(statementId, linkTable, questionRow, overwrite) {
const rows = (await query(
`SELECT w.id, w.titel_de, w.titel_en, w.titel_sv
FROM ${linkTable} lw JOIN words w ON w.id = lw.word_id
WHERE lw.statement_id = $1`, [statementId])).rows;
if (!rows.length) return 0;
// Quellsprache: erste Sprache, in der mind. ein Wort Text hat
let src = null;
for (const l of LANGS) if (rows.some(w => (w[`titel_${l}`] || '').trim())) { src = l; break; }
if (!src) return 0;
const context = questionRow
? (questionRow[`sentence_${src}`] || questionRow.sentence_de || questionRow.sentence_en || questionRow.sentence_sv || '')
: '';
let count = 0;
for (const to of LANGS) {
if (to === src) continue;
const need = rows
.filter(w => (w[`titel_${src}`] || '').trim() && (overwrite || !(w[`titel_${to}`] || '').trim()))
.map(w => ({ id: w.id, text: (w[`titel_${src}`] || '').trim() }));
if (!need.length) continue;
const map = await translateWords({ words: need, from: src, to, context });
for (const w of need) {
const t = map[w.id];
if (!t) continue;
await query(`UPDATE words SET titel_${to} = $1 WHERE id = $2`, [t, w.id]);
await maybeAutoTranslated(w.id);
count++;
}
}
return count;
}
// POST /api/pairs/:id/translate — übersetzt fehlende Sätze/Wörter dieses Pairs in die
// fehlenden Sprachen (de/en/sv). Body `{ overwrite: true }` übersetzt auch bereits gefüllte
// Zielsprachen neu (Quellsprache bleibt unangetastet). Liefert das aktualisierte Inhalts-Bündel.
router.post('/:id/translate', async (req, res, next) => {
try {
const overwrite = req.body?.overwrite === true;
const pr = await query(
`SELECT id, answer_type, question_id, positive_statement_id, negative_statement_id
FROM pairs WHERE id = $1`, [req.params.id]);
@@ -255,38 +295,31 @@ router.post('/:id/translate', async (req, res, next) => {
const result = { translated: 0, failed: 0, errors: [] };
const run = async (label, fn) => {
try {
const { translatedFields } = await fn();
result.translated += translatedFields.length;
} catch (err) {
result.failed++;
result.errors.push({ item: label, error: err.message });
}
try { result.translated += await fn(); }
catch (err) { result.failed++; result.errors.push({ item: label, error: err.message }); }
};
const fields = (fn) => async () => (await fn()).translatedFields.length;
// Frage (yes_no / question / word)
if (p.question_id)
await run('Frage', () => fillMissingRow('questions', p.question_id, ['sentence']));
let questionRow = null;
if (p.question_id) {
questionRow = (await query(
`SELECT sentence_de, sentence_en, sentence_sv FROM questions WHERE id = $1`, [p.question_id])).rows[0] || null;
await run('Frage', fields(() => fillMissingRow('questions', p.question_id, ['sentence'], { overwrite })));
}
// Positiv-Seite
if (p.answer_type === 'word' && p.positive_statement_id) {
const ids = (await query(
`SELECT word_id FROM statement_positive_words WHERE statement_id = $1`, [p.positive_statement_id])).rows;
for (const { word_id } of ids)
await run(`Positiv-Wort ${word_id}`, () => fillMissingRow('words', word_id, ['titel']));
await run('Positiv-Wörter', () => translateWordGroup(p.positive_statement_id, 'statement_positive_words', questionRow, overwrite));
} else if ((p.answer_type === 'text' || p.answer_type === 'question') && p.positive_statement_id) {
await run('Positiv-Satz', () => fillMissingRow('statements', p.positive_statement_id, ['positive_sentence']));
await run('Positiv-Satz', fields(() => fillMissingRow('statements', p.positive_statement_id, ['positive_sentence'], { overwrite })));
}
// Negativ-Seite
if (p.answer_type === 'word' && p.negative_statement_id) {
const ids = (await query(
`SELECT word_id FROM statement_negative_words WHERE statement_id = $1`, [p.negative_statement_id])).rows;
for (const { word_id } of ids)
await run(`Negativ-Wort ${word_id}`, () => fillMissingRow('words', word_id, ['titel']));
await run('Negativ-Wörter', () => translateWordGroup(p.negative_statement_id, 'statement_negative_words', questionRow, overwrite));
} else if (p.answer_type === 'question' && p.negative_statement_id) {
// fillMissingRow überspringt leere Felder ohne Quelle automatisch
await run('Negativ-Satz', () => fillMissingRow('statements', p.negative_statement_id, ['negative_sentence']));
await run('Negativ-Satz', fields(() => fillMissingRow('statements', p.negative_statement_id, ['negative_sentence'], { overwrite })));
}
result.content = await loadPairContent(p);