Files
snakkimo-API/src/routes/claude.js
admin 8f9a48fa5a 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>
2026-06-05 14:27:52 +02:00

293 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" (815 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: 13 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;