feat: Pro-Pair-Übersetzung + Review-Kaskade auf Objekt/Bild

- Übersetzungs-Kern (Claude + Platzhalter-Schutz) nach src/lib/translate.js
  ausgelagert; claude.js importiert von dort (Endpoints unverändert).
- Neuer Endpoint POST /pairs/:id/translate: füllt fehlende Sprachen für
  Frage, Statements bzw. (bei word-Typ) verlinkte Wörter und liefert das
  3-sprachige Inhalts-Bündel fürs Review-Modal.
- POST /pairs/:id/review hebt verlinkte Objekte + Bilder zusätzlich auf
  'reviewed' (idempotent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 14:27:52 +02:00
parent 209a765154
commit 8f9a48fa5a
3 changed files with 278 additions and 107 deletions

View File

@@ -1,95 +1,11 @@
const router = require('express').Router();
const { query } = require('../db');
const {
LANGS, TRANSLATE_CONFIG, translateText, maybeAutoTranslated,
} = require('../lib/translate');
const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
const ANTHROPIC_MODEL = 'claude-haiku-4-5-20251001';
const LANGS = ['de', 'en', 'sv'];
const LANG_LABEL = { de: 'Deutsch', en: 'English', sv: 'Svenska' };
// ── Placeholder-Schutz ────────────────────────────────────────────────────────
// Format im Quelltext: {{label.w:uuid}} oder {{label.o:uuid}}
const PLACEHOLDER_RE = /\{\{([^.{}]+)\.(w|o):([0-9a-f-]{36})\}\}/g;
// 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 }) {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) { const e = new Error('ANTHROPIC_API_KEY nicht konfiguriert'); e.status = 500; throw e; }
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: ANTHROPIC_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);
}
// Ü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]);
}
// POST /api/claude/generate-pairs
@@ -222,13 +138,6 @@ router.post('/generate-words', async (req, res, next) => {
}
});
// ── Übersetzungs-Konfiguration pro Tabelle ────────────────────────────────────
const TRANSLATE_CONFIG = {
words: { fields: ['titel'] },
questions: { fields: ['sentence'] },
statements: { fields: ['positive_sentence', 'negative_sentence'] },
};
// POST /api/claude/translate-text — generischer Übersetzungs-Primitive
router.post('/translate-text', async (req, res, next) => {
try {