feat: Status-Pipeline (reviewed), Audio-Verknüpfung+Coverage, EP-Fortschritt, Wort-Generierung

- reviewed-Status für objects/questions/statements/pairs (Constraints)
- feed: nur fertige Inhalte (published + Bild + Audio-Gate), audio_url
- pairs: Publish-Gating (draft→published = 409)
- audios: source_table/source_id/source_field/language + Unique-Index;
  generate-for, generate-batch, GET /coverage; voices.js (Voice je Sprache)
- auth: POST /auth/progress, /auth/me mit total_ep/streak/level;
  users_public EP-Spalten + user_pair_progress.earned_points
- claude: POST /generate-words; words POST akzeptiert status

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 21:29:48 +02:00
parent 75f05f45f2
commit 9bfd5e8dba
9 changed files with 457 additions and 74 deletions

View File

@@ -68,4 +68,68 @@ router.post('/generate-pairs', async (req, res, next) => {
}
});
// 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);
}
});
module.exports = router;