- Ü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>
293 lines
14 KiB
JavaScript
293 lines
14 KiB
JavaScript
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';
|
||
|
||
|
||
// POST /api/claude/generate-pairs
|
||
// Body: { imageUrl, objects: [{id, words: [{titel_de, titel_en}], selections: [{points:[{x,y}]}]}, ...], selectedObjectId }
|
||
router.post('/generate-pairs', async (req, res, next) => {
|
||
try {
|
||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||
if (!apiKey) return res.status(500).json({ error: 'ANTHROPIC_API_KEY nicht konfiguriert' });
|
||
|
||
const { imageUrl, objects, selectedObjectId } = req.body;
|
||
if (!imageUrl) return res.status(400).json({ error: 'imageUrl fehlt' });
|
||
if (!Array.isArray(objects) || objects.length === 0) return res.status(400).json({ error: 'objects fehlt oder leer' });
|
||
if (!selectedObjectId) return res.status(400).json({ error: 'selectedObjectId fehlt' });
|
||
|
||
const objectsDesc = objects.map((obj, i) => {
|
||
const words = (obj.words || []).map(w => w.titel_de || w.titel_en).filter(Boolean).join(', ');
|
||
const isSelected = obj.id === selectedObjectId;
|
||
let posStr = '';
|
||
if (obj.selections?.[0]?.points?.length) {
|
||
const pts = obj.selections[0].points;
|
||
const cx = (pts.reduce((s, p) => s + p.x, 0) / pts.length * 100).toFixed(0);
|
||
const cy = (pts.reduce((s, p) => s + p.y, 0) / pts.length * 100).toFixed(0);
|
||
posStr = ` (ca. ${cx}% von links, ${cy}% von oben)`;
|
||
}
|
||
return `- Objekt ${i + 1}${isSelected ? ' [DIESES OBJEKT]' : ''}: ${words || '(unbenannt)'}${posStr}`;
|
||
}).join('\n');
|
||
|
||
const userPrompt = `Analysiere das Bild. Folgende Objekte sind markiert:\n${objectsDesc}\n\nErstelle Sprachlernmaterial für das Objekt [DIESES OBJEKT] auf Deutsch — natürliche Sätze wie in einem echten Gespräch.\n\nAntworte NUR mit gültigem JSON ohne Markdown:\n{"pairs":[...]}\n\nJedes Pair braucht: "type" und "difficulty". Feldregeln:\n- type "text": {"type":"text","difficulty":"...","positive":"Aussage."}\n- type "yes_no": {"type":"yes_no","difficulty":"...","question":"Frage?","answer":true}\n- type "question": {"type":"question","difficulty":"...","question":"Frage?","positive":"Positive Aussage.","negative":"Negative Aussage."}\n- type "word": {"type":"word","difficulty":"...","question":"Frage?","positive_words":["Wort1","Wort2"],"negative_words":["Wort3","Wort4","Wort5"]}\n\nErstelle genau 40 Pairs:\n- 10 × type "text": 5 × difficulty "easy" (max 8 Wörter, für Kinder), 5 × "medium" (8–15 Wörter, für Teenager)\n- 10 × type "yes_no": 5 × "easy", 5 × "medium" — mix aus answer:true und answer:false\n- 10 × type "question": 5 × "easy", 5 × "medium"\n- 10 × type "word": 5 × "easy", 5 × "medium" — positive_words: 1–3 passende Einzelwörter, negative_words: genau 3 falsche Einzelwörter\n\nRegeln: Alle Sätze und Wörter auf Deutsch. Sätze müssen natürlich klingen. Keine Wiederholungen. Wörter beim type "word" sind einzelne Nomen/Adjektive/Verben (KEINE Pronomen, Artikel, Präpositionen oder Funktionswörter wie man/der/die/das/ich/wir/ein).`;
|
||
|
||
const anthropicRes = 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: 'claude-haiku-4-5-20251001',
|
||
max_tokens: 8000,
|
||
system: 'Du bist ein Deutsch-Sprachlernassistent. Antworte AUSSCHLIESSLICH mit gültigem JSON, ohne Markdown-Codeblöcke, ohne Erklärungen.',
|
||
messages: [{ role: 'user', content: [
|
||
{ type: 'image', source: { type: 'url', url: imageUrl } },
|
||
{ type: 'text', text: userPrompt },
|
||
]}],
|
||
}),
|
||
});
|
||
|
||
if (!anthropicRes.ok) {
|
||
const err = await anthropicRes.json().catch(() => ({}));
|
||
return res.status(anthropicRes.status).json({ error: err.error?.message || `Claude API Fehler ${anthropicRes.status}` });
|
||
}
|
||
|
||
const data = await anthropicRes.json();
|
||
let rawText = data.content[0].text.trim();
|
||
|
||
// Strip markdown code blocks if present
|
||
const mdMatch = rawText.match(/```(?:json)?\s*([\s\S]+?)\s*```/);
|
||
if (mdMatch) rawText = mdMatch[1];
|
||
|
||
const parsed = JSON.parse(rawText);
|
||
if (!Array.isArray(parsed.pairs)) return res.status(500).json({ error: 'Ungültiges JSON-Format von Claude' });
|
||
|
||
res.json({ pairs: parsed.pairs });
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
// POST /api/claude/generate-words
|
||
// Body: { topic, count=15, difficulty? }
|
||
// Liefert eine Vorschau-Liste neuer Wörter (de/en/sv) — schreibt NICHTS in die DB.
|
||
router.post('/generate-words', async (req, res, next) => {
|
||
try {
|
||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||
if (!apiKey) return res.status(500).json({ error: 'ANTHROPIC_API_KEY nicht konfiguriert' });
|
||
|
||
const topic = (req.body.topic || '').toString().trim();
|
||
if (!topic) return res.status(400).json({ error: 'topic fehlt' });
|
||
const count = Math.min(Math.max(parseInt(req.body.count) || 15, 1), 50);
|
||
const difficulty = req.body.difficulty ? ` Schwierigkeitsgrad: ${req.body.difficulty}.` : '';
|
||
|
||
const userPrompt = `Erstelle ${count} einzelne Vokabeln zum Thema/zur Kategorie: "${topic}".${difficulty}\n\n` +
|
||
`Es sollen lernbare Einzelwörter sein: Nomen, Verben oder Adjektive. ` +
|
||
`KEINE Pronomen, Artikel, Präpositionen oder Funktionswörter (kein der/die/das/ein/ich/wir/man/und/oder). ` +
|
||
`Keine Mehrwortausdrücke, keine Duplikate.\n\n` +
|
||
`Gib für jedes Wort die Übersetzung auf Deutsch, Englisch und Schwedisch an.\n\n` +
|
||
`Antworte NUR mit gültigem JSON ohne Markdown:\n` +
|
||
`{"words":[{"titel_de":"Apfel","titel_en":"apple","titel_sv":"äpple"}, ...]}`;
|
||
|
||
const anthropicRes = 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: 'claude-haiku-4-5-20251001',
|
||
max_tokens: 4000,
|
||
system: 'Du bist ein Vokabel-Assistent. Antworte AUSSCHLIESSLICH mit gültigem JSON, ohne Markdown-Codeblöcke, ohne Erklärungen.',
|
||
messages: [{ role: 'user', content: userPrompt }],
|
||
}),
|
||
});
|
||
|
||
if (!anthropicRes.ok) {
|
||
const err = await anthropicRes.json().catch(() => ({}));
|
||
return res.status(anthropicRes.status).json({ error: err.error?.message || `Claude API Fehler ${anthropicRes.status}` });
|
||
}
|
||
|
||
const data = await anthropicRes.json();
|
||
let rawText = data.content[0].text.trim();
|
||
const mdMatch = rawText.match(/```(?:json)?\s*([\s\S]+?)\s*```/);
|
||
if (mdMatch) rawText = mdMatch[1];
|
||
|
||
const parsed = JSON.parse(rawText);
|
||
if (!Array.isArray(parsed.words)) return res.status(500).json({ error: 'Ungültiges JSON-Format von Claude' });
|
||
|
||
// Nur saubere Einträge mit mindestens einer Übersetzung zurückgeben
|
||
const words = parsed.words
|
||
.map(w => ({
|
||
titel_de: (w.titel_de || '').toString().trim(),
|
||
titel_en: (w.titel_en || '').toString().trim(),
|
||
titel_sv: (w.titel_sv || '').toString().trim(),
|
||
}))
|
||
.filter(w => w.titel_de || w.titel_en || w.titel_sv);
|
||
|
||
res.json({ words });
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
// POST /api/claude/translate-text — generischer Übersetzungs-Primitive
|
||
router.post('/translate-text', async (req, res, next) => {
|
||
try {
|
||
const { text, from, to } = req.body;
|
||
if (!text) return res.status(400).json({ error: 'text fehlt' });
|
||
if (!LANGS.includes(from)) return res.status(400).json({ error: `from muss eine von: ${LANGS.join(', ')} sein` });
|
||
if (!LANGS.includes(to)) return res.status(400).json({ error: `to muss eine von: ${LANGS.join(', ')} sein` });
|
||
if (from === to) return res.status(400).json({ error: 'from und to müssen unterschiedlich sein' });
|
||
|
||
const translated = await translateText({ text, from, to });
|
||
res.json({ translated });
|
||
} catch (err) {
|
||
if (err.status) return res.status(err.status).json({ error: err.message });
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
// POST /api/claude/translate-row — eine konkrete Zeile übersetzen + speichern
|
||
router.post('/translate-row', async (req, res, next) => {
|
||
try {
|
||
const { source_table, source_id, to } = req.body;
|
||
let { from } = req.body;
|
||
const cfg = TRANSLATE_CONFIG[source_table];
|
||
if (!cfg) return res.status(400).json({ error: `source_table muss eine von: ${Object.keys(TRANSLATE_CONFIG).join(', ')} sein` });
|
||
if (!source_id) return res.status(400).json({ error: 'source_id fehlt' });
|
||
if (!LANGS.includes(to)) return res.status(400).json({ error: `to muss eine von: ${LANGS.join(', ')} sein` });
|
||
|
||
// Zeile laden
|
||
const cols = cfg.fields.flatMap(f => LANGS.map(l => `${f}_${l}`));
|
||
const r = await query(`SELECT ${cols.join(', ')} FROM ${source_table} WHERE id = $1`, [source_id]);
|
||
if (!r.rows.length) return res.status(404).json({ error: 'source row not found' });
|
||
const row = r.rows[0];
|
||
|
||
// Quell-Sprache: explizit angegeben, sonst erste gefüllte ≠ to
|
||
if (!from) {
|
||
for (const f of cfg.fields) for (const l of LANGS)
|
||
if (l !== to && (row[`${f}_${l}`] || '').trim()) { from = l; break; }
|
||
if (!from) return res.status(400).json({ error: 'Keine Quellsprache mit Inhalt gefunden' });
|
||
}
|
||
if (from === to) return res.status(400).json({ error: 'from und to müssen unterschiedlich sein' });
|
||
|
||
// Pro Feld übersetzen (überspringt leere Quell-Felder)
|
||
const updates = {};
|
||
for (const field of cfg.fields) {
|
||
const src = (row[`${field}_${from}`] || '').trim();
|
||
if (!src) continue;
|
||
updates[`${field}_${to}`] = await translateText({ text: src, from, to });
|
||
}
|
||
if (!Object.keys(updates).length)
|
||
return res.status(400).json({ error: `Keine Quell-Texte in ${from} vorhanden` });
|
||
|
||
const fields = Object.keys(updates);
|
||
const setClauses = fields.map((f, i) => `${f} = $${i + 1}`).join(', ');
|
||
const values = [...fields.map(f => updates[f]), source_id];
|
||
const upd = await query(
|
||
`UPDATE ${source_table} SET ${setClauses} WHERE id = $${fields.length + 1} RETURNING *`,
|
||
values
|
||
);
|
||
|
||
if (source_table === 'words') await maybeAutoTranslated(source_id);
|
||
|
||
res.json({ ...upd.rows[0], translated_fields: fields });
|
||
} catch (err) {
|
||
if (err.status) return res.status(err.status).json({ error: err.message });
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
// POST /api/claude/translate-missing — alle Zeilen einer Tabelle in `to` übersetzen,
|
||
// wo `to` leer aber mindestens eine andere Sprache gefüllt ist.
|
||
router.post('/translate-missing', async (req, res, next) => {
|
||
try {
|
||
const { source_table, to, from } = req.body;
|
||
const cfg = TRANSLATE_CONFIG[source_table];
|
||
if (!cfg) return res.status(400).json({ error: `source_table muss eine von: ${Object.keys(TRANSLATE_CONFIG).join(', ')} sein` });
|
||
if (!LANGS.includes(to)) return res.status(400).json({ error: `to muss eine von: ${LANGS.join(', ')} sein` });
|
||
|
||
const cols = cfg.fields.flatMap(f => LANGS.map(l => `${f}_${l}`));
|
||
// Zielsprache leer für mindestens ein Feld UND Quell-Sprache(n) gefüllt
|
||
const missingCond = cfg.fields.map(f => `(${f}_${to} IS NULL OR ${f}_${to} = '')`).join(' OR ');
|
||
const sourceCond = cfg.fields.map(f => LANGS.filter(l => l !== to)
|
||
.map(l => `(${f}_${l} IS NOT NULL AND ${f}_${l} <> '')`).join(' OR ')).join(' OR ');
|
||
const rows = (await query(
|
||
`SELECT id, ${cols.join(', ')} FROM ${source_table}
|
||
WHERE (${missingCond}) AND (${sourceCond})
|
||
LIMIT 200`
|
||
)).rows;
|
||
|
||
const results = { translated: 0, failed: 0, errors: [] };
|
||
for (const row of rows) {
|
||
try {
|
||
// Quell-Sprache pro Zeile bestimmen
|
||
let f = from;
|
||
if (!f) {
|
||
for (const lang of LANGS)
|
||
if (lang !== to && cfg.fields.some(field => (row[`${field}_${lang}`] || '').trim())) { f = lang; break; }
|
||
}
|
||
if (!f) { results.failed++; results.errors.push({ id: row.id, error: 'keine Quellsprache' }); continue; }
|
||
|
||
const updates = {};
|
||
for (const field of cfg.fields) {
|
||
const tgt = (row[`${field}_${to}`] || '').trim();
|
||
const src = (row[`${field}_${f}`] || '').trim();
|
||
if (tgt || !src) continue;
|
||
updates[`${field}_${to}`] = await translateText({ text: src, from: f, to });
|
||
}
|
||
if (Object.keys(updates).length) {
|
||
const fields = Object.keys(updates);
|
||
const setClauses = fields.map((c, i) => `${c} = $${i + 1}`).join(', ');
|
||
await query(`UPDATE ${source_table} SET ${setClauses} WHERE id = $${fields.length + 1}`,
|
||
[...fields.map(c => updates[c]), row.id]);
|
||
if (source_table === 'words') await maybeAutoTranslated(row.id);
|
||
results.translated++;
|
||
}
|
||
} catch (err) {
|
||
results.failed++;
|
||
results.errors.push({ id: row.id, error: err.message });
|
||
}
|
||
}
|
||
res.json(results);
|
||
} catch (err) { next(err); }
|
||
});
|
||
|
||
// GET /api/claude/translation-coverage — wie viele Zeilen pro Tabelle×Sprache haben Text
|
||
router.get('/translation-coverage', async (req, res, next) => {
|
||
try {
|
||
const coverage = [];
|
||
for (const [table, cfg] of Object.entries(TRANSLATE_CONFIG)) {
|
||
// Eine Zeile zählt als "in Sprache vorhanden", wenn ALLE konfigurierten Felder gefüllt sind.
|
||
// (für statements: pos+neg — wir nehmen einfach pos als Haupt-Indikator, neg ist optional)
|
||
const mainField = cfg.fields[0];
|
||
const filled = (l) => `(${mainField}_${l} IS NOT NULL AND ${mainField}_${l} <> '')`;
|
||
for (const lang of LANGS) {
|
||
// total = nur Zeilen mit Text in mind. EINER Sprache (übersetzbarer Bestand).
|
||
// Leere Hüllen-Zeilen (in allen Sprachen leer) zählen nicht als "fehlend",
|
||
// weil sie keine Quelle zum Übersetzen haben (sonst zeigt die UI Zeilen an,
|
||
// die translate-missing niemals anrühren kann → "0 übersetzt" bei "viele offen").
|
||
const sourceCond = LANGS.map(filled).join(' OR ');
|
||
const totalRow = (await query(
|
||
`SELECT COUNT(*)::int AS c FROM ${table} WHERE ${sourceCond}`
|
||
)).rows[0];
|
||
const haveRow = (await query(
|
||
`SELECT COUNT(*)::int AS c FROM ${table} WHERE ${filled(lang)}`
|
||
)).rows[0];
|
||
coverage.push({ source_table: table, language: lang, total: totalRow.c, have: haveRow.c, missing: totalRow.c - haveRow.c });
|
||
}
|
||
}
|
||
res.json({ coverage });
|
||
} catch (err) { next(err); }
|
||
});
|
||
|
||
module.exports = router;
|