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;