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

@@ -132,7 +132,7 @@ async function migrate() {
CREATE TABLE IF NOT EXISTS questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'blocked', 'published')),
CHECK (status IN ('draft', 'reviewed', 'blocked', 'published')),
sentence_de TEXT,
sentence_en TEXT,
sentence_sv TEXT,
@@ -152,7 +152,7 @@ async function migrate() {
for (const col of questionCols)
await query(`ALTER TABLE questions ADD COLUMN IF NOT EXISTS ${col}`).catch(() => {});
await query(`ALTER TABLE questions DROP CONSTRAINT IF EXISTS questions_status_check`).catch(() => {});
await query(`ALTER TABLE questions ADD CONSTRAINT questions_status_check CHECK (status IN ('draft', 'blocked', 'published'))`).catch(() => {});
await query(`ALTER TABLE questions ADD CONSTRAINT questions_status_check CHECK (status IN ('draft', 'reviewed', 'blocked', 'published'))`).catch(() => {});
await query(`
DROP TRIGGER IF EXISTS questions_updated_at ON questions;
@@ -165,7 +165,7 @@ async function migrate() {
CREATE TABLE IF NOT EXISTS statements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'blocked', 'published')),
CHECK (status IN ('draft', 'reviewed', 'blocked', 'published')),
negative_sentence_de TEXT,
negative_sentence_en TEXT,
negative_sentence_sv TEXT,
@@ -189,7 +189,7 @@ async function migrate() {
for (const col of stmtCols)
await query(`ALTER TABLE statements ADD COLUMN IF NOT EXISTS ${col}`).catch(() => {});
await query(`ALTER TABLE statements DROP CONSTRAINT IF EXISTS statements_status_check`).catch(() => {});
await query(`ALTER TABLE statements ADD CONSTRAINT statements_status_check CHECK (status IN ('draft', 'blocked', 'published'))`).catch(() => {});
await query(`ALTER TABLE statements ADD CONSTRAINT statements_status_check CHECK (status IN ('draft', 'reviewed', 'blocked', 'published'))`).catch(() => {});
await query(`
DROP TRIGGER IF EXISTS statements_updated_at ON statements;
@@ -221,7 +221,7 @@ async function migrate() {
CREATE TABLE IF NOT EXISTS pairs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'blocked', 'published')),
CHECK (status IN ('draft', 'reviewed', 'blocked', 'published')),
difficulty_level SMALLINT CHECK (difficulty_level BETWEEN 1 AND 50),
answer_type VARCHAR(20) NOT NULL
CHECK (answer_type IN ('yes_no', 'text', 'word')),
@@ -253,7 +253,7 @@ async function migrate() {
await query(`ALTER TABLE pairs ADD COLUMN IF NOT EXISTS ${col}`).catch(() => {});
}
await query(`ALTER TABLE pairs DROP CONSTRAINT IF EXISTS pairs_status_check`).catch(() => {});
await query(`ALTER TABLE pairs ADD CONSTRAINT pairs_status_check CHECK (status IN ('draft', 'blocked', 'published'))`).catch(() => {});
await query(`ALTER TABLE pairs ADD CONSTRAINT pairs_status_check CHECK (status IN ('draft', 'reviewed', 'blocked', 'published'))`).catch(() => {});
await query(`ALTER TABLE pairs DROP CONSTRAINT IF EXISTS pairs_answer_type_check`).catch(() => {});
await query(`ALTER TABLE pairs ADD CONSTRAINT pairs_answer_type_check CHECK (answer_type IN ('yes_no', 'text', 'word'))`).catch(() => {});
await query(`ALTER TABLE pairs DROP CONSTRAINT IF EXISTS pairs_difficulty_level_check`).catch(() => {});
@@ -274,7 +274,7 @@ async function migrate() {
CREATE TABLE IF NOT EXISTS objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'blocked', 'published')),
CHECK (status IN ('draft', 'reviewed', 'blocked', 'published')),
selections JSONB,
notes TEXT,
blocked_topic TEXT,
@@ -285,6 +285,9 @@ async function migrate() {
)
`);
await query(`ALTER TABLE objects DROP CONSTRAINT IF EXISTS objects_status_check`).catch(() => {});
await query(`ALTER TABLE objects ADD CONSTRAINT objects_status_check CHECK (status IN ('draft', 'reviewed', 'blocked', 'published'))`).catch(() => {});
await query(`
DROP TRIGGER IF EXISTS objects_updated_at ON objects;
CREATE TRIGGER objects_updated_at
@@ -474,6 +477,11 @@ async function migrate() {
await query(`ALTER TABLE users_public ADD CONSTRAINT users_public_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE`).catch(() => {});
await query(`CREATE UNIQUE INDEX IF NOT EXISTS users_public_user_id_idx ON users_public (user_id) WHERE user_id IS NOT NULL`);
// Gamification: EP-Total, Streak, letzter Übungstag
await query(`ALTER TABLE users_public ADD COLUMN IF NOT EXISTS total_ep INTEGER NOT NULL DEFAULT 0`).catch(() => {});
await query(`ALTER TABLE users_public ADD COLUMN IF NOT EXISTS streak_days INTEGER NOT NULL DEFAULT 0`).catch(() => {});
await query(`ALTER TABLE users_public ADD COLUMN IF NOT EXISTS last_practice_at TIMESTAMPTZ`).catch(() => {});
// Seed languages (de exists, add en + sv)
// Full unique constraint (not partial) so ON CONFLICT works cleanly
await query(`CREATE UNIQUE INDEX IF NOT EXISTS languages_short_en_idx ON languages (short_en)`).catch(() => {});
@@ -507,6 +515,8 @@ async function migrate() {
)
`);
await query(`ALTER TABLE user_pair_progress ADD COLUMN IF NOT EXISTS earned_points INTEGER NOT NULL DEFAULT 0`).catch(() => {});
await query(`
CREATE OR REPLACE FUNCTION update_last_seen_at()
RETURNS TRIGGER AS $$
@@ -543,6 +553,18 @@ async function migrate() {
)
`);
// Verknüpfung Audio → Quelle (Wort/Frage/Statement) + Sprache.
// source_field: 'titel' | 'sentence' | 'positive_sentence' | 'negative_sentence'
await query(`ALTER TABLE audios ADD COLUMN IF NOT EXISTS source_table TEXT`).catch(() => {});
await query(`ALTER TABLE audios ADD COLUMN IF NOT EXISTS source_id UUID`).catch(() => {});
await query(`ALTER TABLE audios ADD COLUMN IF NOT EXISTS source_field TEXT`).catch(() => {});
await query(`ALTER TABLE audios ADD COLUMN IF NOT EXISTS language VARCHAR(10)`).catch(() => {});
await query(`
CREATE UNIQUE INDEX IF NOT EXISTS audios_source_uq
ON audios (source_table, source_id, source_field, language)
WHERE source_table IS NOT NULL
`).catch(() => {});
await query(`
DROP TRIGGER IF EXISTS audios_updated_at ON audios;
CREATE TRIGGER audios_updated_at

View File

@@ -2,18 +2,129 @@ const router = require('express').Router();
const { v4: uuidv4 } = require('uuid');
const { query } = require('../db');
const { uploadFile, deleteFile, keyFromUrl } = require('../s3');
const { voiceForLanguage } = require('../voices');
const ELEVENLABS_BASE = 'https://api.elevenlabs.io/v1';
const ALLOWED_STATUSES = ['generated', 'published', 'blocked'];
const LANGS = ['de', 'en', 'sv'];
// Welche Felder pro Quelle Audio brauchen und ab welchem Status sie "reif" sind.
// column = `${field}_${lang}` (z.B. titel_de, sentence_sv, positive_sentence_en)
const SOURCE_CONFIG = {
words: { fields: ['titel'], ready: ['generated', 'published'] },
questions: { fields: ['sentence'], ready: ['reviewed', 'published'] },
statements: { fields: ['positive_sentence', 'negative_sentence'], ready: ['reviewed', 'published'] },
};
// ── ElevenLabs aufrufen + in S3/DB ablegen ──────────────────────────────────
async function generateAndStore({ text, voice_id, language, model_id, speed, stability,
similarity_boost, style, source_table, source_id, source_field }) {
const apiKey = process.env.ELEVENLABS_API_KEY;
if (!apiKey) { const e = new Error('ELEVENLABS_API_KEY not configured'); e.status = 500; throw e; }
const voice = voice_id || voiceForLanguage(language);
const m = model_id || 'eleven_multilingual_v2';
const sp = speed ?? 1.0, st = stability ?? 0.5, sb = similarity_boost ?? 0.75, sy = style ?? 0.0;
const elevenRes = await fetch(
`${ELEVENLABS_BASE}/text-to-speech/${voice}/with-timestamps`,
{
method: 'POST',
headers: { 'xi-api-key': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({
text, model_id: m, speed: sp,
voice_settings: { stability: st, similarity_boost: sb, style: sy, use_speaker_boost: true },
}),
}
);
if (!elevenRes.ok) {
const detail = await elevenRes.text();
const e = new Error('ElevenLabs error'); e.status = elevenRes.status; e.detail = detail; throw e;
}
const { audio_base64, alignment } = await elevenRes.json();
const buffer = Buffer.from(audio_base64, 'base64');
const id = uuidv4();
const key = `audios/${id}/${uuidv4()}.mp3`;
const audio_link = await uploadFile(key, buffer, 'audio/mpeg');
// Bestehendes Audio derselben Quelle ersetzen (idempotent dank unique index)
if (source_table && source_id && source_field && language) {
const old = await query(
`SELECT id, audio_link FROM audios
WHERE source_table = $1 AND source_id = $2 AND source_field = $3 AND language = $4`,
[source_table, source_id, source_field, language]
);
for (const row of old.rows) {
const k = keyFromUrl(row.audio_link);
if (k) await deleteFile(k).catch(() => {});
await query('DELETE FROM audios WHERE id = $1', [row.id]);
}
}
const result = await query(
`INSERT INTO audios
(id, text, audio_link, alignment, voice_id, model_id, speed, stability, similarity_boost, style,
source_table, source_id, source_field, language)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
RETURNING *`,
[id, text, audio_link, JSON.stringify(alignment), voice, m, sp, st, sb, sy,
source_table || null, source_id || null, source_field || null, language || null]
);
return result.rows[0];
}
// ── Fehlende/vorhandene Audio-Einheiten für einen Filter berechnen ───────────
async function computeUnits({ source_table, language }) {
const tables = source_table ? [source_table] : Object.keys(SOURCE_CONFIG);
const langs = language ? [language] : LANGS;
const units = [];
for (const table of tables) {
const cfg = SOURCE_CONFIG[table];
if (!cfg) continue;
const cols = [];
for (const f of cfg.fields) for (const l of langs) cols.push(`${f}_${l}`);
const rows = (await query(
`SELECT id, ${cols.join(', ')} FROM ${table} WHERE status = ANY($1)`,
[cfg.ready]
)).rows;
// Vorhandene Audios dieser Tabelle (für die gefragten Sprachen)
const have = new Set();
const audioRows = (await query(
`SELECT source_id, source_field, language FROM audios
WHERE source_table = $1 AND language = ANY($2) AND source_id IS NOT NULL`,
[table, langs]
)).rows;
audioRows.forEach(a => have.add(`${a.source_id}|${a.source_field}|${a.language}`));
for (const row of rows) {
for (const f of cfg.fields) for (const l of langs) {
const text = row[`${f}_${l}`];
if (!text || !text.trim()) continue;
units.push({
source_table: table, source_id: row.id, source_field: f, language: l,
text: text.trim(),
hasAudio: have.has(`${row.id}|${f}|${l}`),
});
}
}
}
return units;
}
// GET /api/audios
router.get('/', async (req, res, next) => {
try {
const { status, voice_id, limit = 50, offset = 0 } = req.query;
const { status, voice_id, source_table, source_id, language, limit = 50, offset = 0 } = req.query;
const params = [Math.min(parseInt(limit), 500), parseInt(offset)];
const conditions = [];
if (status) { conditions.push(`status = $${params.length + 1}`); params.push(status); }
if (voice_id) { conditions.push(`voice_id = $${params.length + 1}`); params.push(voice_id); }
if (status) { conditions.push(`status = $${params.length + 1}`); params.push(status); }
if (voice_id) { conditions.push(`voice_id = $${params.length + 1}`); params.push(voice_id); }
if (source_table) { conditions.push(`source_table = $${params.length + 1}`); params.push(source_table); }
if (source_id) { conditions.push(`source_id = $${params.length + 1}`); params.push(source_id); }
if (language) { conditions.push(`language = $${params.length + 1}`); params.push(language); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await query(
`SELECT * FROM audios ${where} ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
@@ -23,6 +134,31 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /api/audios/coverage?source_table=questions&language=sv
// Aggregierte Abdeckung + Liste fehlender Einheiten.
router.get('/coverage', async (req, res, next) => {
try {
const { source_table, language } = req.query;
const units = await computeUnits({ source_table, language });
const byGroup = {};
for (const u of units) {
const key = `${u.source_table}|${u.language}`;
byGroup[key] ??= { source_table: u.source_table, language: u.language, total: 0, withAudio: 0 };
byGroup[key].total++;
if (u.hasAudio) byGroup[key].withAudio++;
}
const coverage = Object.values(byGroup).map(g => ({
...g, missing: g.total - g.withAudio,
}));
const missingUnits = units
.filter(u => !u.hasAudio)
.map(({ hasAudio, ...rest }) => rest);
res.json({ coverage, missingUnits });
} catch (err) { next(err); }
});
// GET /api/audios/:id
router.get('/:id', async (req, res, next) => {
try {
@@ -32,72 +168,85 @@ router.get('/:id', async (req, res, next) => {
} catch (err) { next(err); }
});
// POST /api/audios/generate — ElevenLabs TTS → S3 → DB
// POST /api/audios/generate — ElevenLabs TTS → S3 → DB (optional mit Quell-Verknüpfung)
router.post('/generate', async (req, res, next) => {
try {
const {
text,
voice_id,
model_id = 'eleven_multilingual_v2',
speed = 1.0,
stability = 0.5,
similarity_boost = 0.75,
style = 0.0,
} = req.body;
const { text, voice_id, language, source_table, source_id, source_field,
model_id, speed, stability, similarity_boost, style } = req.body;
if (!text) return res.status(400).json({ error: 'text is required' });
if (!voice_id && !language)
return res.status(400).json({ error: 'voice_id or language is required' });
if (!text) return res.status(400).json({ error: 'text is required' });
if (!voice_id) return res.status(400).json({ error: 'voice_id is required' });
const row = await generateAndStore({
text, voice_id, language, model_id, speed, stability, similarity_boost, style,
source_table, source_id, source_field,
});
res.status(201).json(row);
} catch (err) {
if (err.status) return res.status(err.status).json({ error: err.message, detail: err.detail });
next(err);
}
});
const apiKey = process.env.ELEVENLABS_API_KEY;
if (!apiKey) return res.status(500).json({ error: 'ELEVENLABS_API_KEY not configured' });
// POST /api/audios/generate-for — Text aus der Quell-Zeile auflösen, dann generieren
router.post('/generate-for', async (req, res, next) => {
try {
const { source_table, source_id, source_field, language } = req.body;
const cfg = SOURCE_CONFIG[source_table];
if (!cfg) return res.status(400).json({ error: `source_table must be one of: ${Object.keys(SOURCE_CONFIG).join(', ')}` });
if (!cfg.fields.includes(source_field)) return res.status(400).json({ error: `source_field must be one of: ${cfg.fields.join(', ')}` });
if (!LANGS.includes(language)) return res.status(400).json({ error: `language must be one of: ${LANGS.join(', ')}` });
if (!source_id) return res.status(400).json({ error: 'source_id is required' });
const elevenRes = await fetch(
`${ELEVENLABS_BASE}/text-to-speech/${voice_id}/with-timestamps`,
{
method: 'POST',
headers: {
'xi-api-key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text,
model_id,
speed,
voice_settings: { stability, similarity_boost, style, use_speaker_boost: true },
}),
}
);
const col = `${source_field}_${language}`;
const r = await query(`SELECT ${col} AS text FROM ${source_table} WHERE id = $1`, [source_id]);
if (!r.rows.length) return res.status(404).json({ error: 'source row not found' });
const text = (r.rows[0].text || '').trim();
if (!text) return res.status(400).json({ error: `${col} is empty` });
if (!elevenRes.ok) {
const err = await elevenRes.text();
return res.status(elevenRes.status).json({ error: 'ElevenLabs error', detail: err });
const row = await generateAndStore({ text, language, source_table, source_id, source_field });
res.status(201).json(row);
} catch (err) {
if (err.status) return res.status(err.status).json({ error: err.message, detail: err.detail });
next(err);
}
});
// POST /api/audios/generate-batch — alle fehlenden Audios für {source_table, language} generieren
router.post('/generate-batch', async (req, res, next) => {
try {
const { source_table, language, units } = req.body;
let todo;
if (Array.isArray(units) && units.length) {
todo = units;
} else {
todo = (await computeUnits({ source_table, language })).filter(u => !u.hasAudio);
}
const { audio_base64, alignment } = await elevenRes.json();
const buffer = Buffer.from(audio_base64, 'base64');
const id = uuidv4();
const key = `audios/${id}/${uuidv4()}.mp3`;
const audio_link = await uploadFile(key, buffer, 'audio/mpeg');
const result = await query(
`INSERT INTO audios
(id, text, audio_link, alignment, voice_id, model_id, speed, stability, similarity_boost, style)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[id, text, audio_link, JSON.stringify(alignment), voice_id, model_id,
speed, stability, similarity_boost, style]
);
res.status(201).json(result.rows[0]);
const results = { generated: 0, failed: 0, errors: [] };
for (const u of todo) {
try {
await generateAndStore({
text: u.text, language: u.language,
source_table: u.source_table, source_id: u.source_id, source_field: u.source_field,
});
results.generated++;
} catch (err) {
results.failed++;
results.errors.push({ source_id: u.source_id, field: u.source_field, lang: u.language, error: err.message });
}
}
res.json(results);
} catch (err) { next(err); }
});
// PATCH /api/audios/:id — Status ändern
// PATCH /api/audios/:id — Status / Metadaten ändern
router.patch('/:id', async (req, res, next) => {
try {
const allowed = ['status', 'text', 'audio_link', 'alignment', 'voice_id', 'model_id',
'speed', 'stability', 'similarity_boost', 'style', 'published_at', 'blocked_at'];
'speed', 'stability', 'similarity_boost', 'style',
'source_table', 'source_id', 'source_field', 'language',
'published_at', 'blocked_at'];
const fields = Object.keys(req.body).filter(k => allowed.includes(k));
if (!fields.length) return res.status(400).json({ error: 'No valid fields provided' });

View File

@@ -136,6 +136,9 @@ router.get('/me', requireJwt, async (req, res, next) => {
const r = await query(
`SELECT u.id, u.email, u.role,
un.username,
COALESCE(up.total_ep, 0) AS total_ep,
COALESCE(up.streak_days, 0) AS streak_days,
up.last_practice_at,
ln.id AS language_native_id, ln.short_en AS language_native_short, ln.titel_de AS language_native_titel,
lt.id AS language_target_id, lt.short_en AS language_target_short, lt.titel_de AS language_target_titel
FROM users u
@@ -147,7 +150,54 @@ router.get('/me', requireJwt, async (req, res, next) => {
[req.user.userId]
);
if (!r.rows.length) return res.status(404).json({ error: 'User not found' });
res.json(r.rows[0]);
const row = r.rows[0];
row.level = Math.floor((row.total_ep || 0) / 500);
res.json(row);
} catch (err) { next(err); }
});
// POST /auth/progress — eine gelöste Karte verbuchen (EP + Streak + Detail pro Pair)
router.post('/progress', requireJwt, async (req, res, next) => {
try {
const userId = req.user.userId;
const { pair_id, correct, points } = req.body;
if (!pair_id) return res.status(400).json({ error: 'pair_id is required' });
const pts = Math.max(0, parseInt(points) || 0);
const isCorrect = correct === true || correct === 'true';
// Detail pro Pair upserten
await query(
`INSERT INTO user_pair_progress (user_id, pair_id, seen_count, correct_count, wrong_count, earned_points)
VALUES ($1, $2, 1, $3, $4, $5)
ON CONFLICT (user_id, pair_id) DO UPDATE SET
seen_count = user_pair_progress.seen_count + 1,
correct_count = user_pair_progress.correct_count + $3,
wrong_count = user_pair_progress.wrong_count + $4,
earned_points = user_pair_progress.earned_points + $5`,
[userId, pair_id, isCorrect ? 1 : 0, isCorrect ? 0 : 1, pts]
);
// EP + Streak auf users_public; Streak: +1 bei neuem Tag, Reset bei Lücke > 1 Tag
const upd = await query(
`UPDATE users_public SET
total_ep = total_ep + $2,
streak_days = CASE
WHEN last_practice_at IS NULL THEN 1
WHEN last_practice_at::date = CURRENT_DATE THEN streak_days
WHEN last_practice_at::date = CURRENT_DATE - INTERVAL '1 day' THEN streak_days + 1
ELSE 1
END,
last_practice_at = NOW()
WHERE user_id = $1
RETURNING total_ep, streak_days`,
[userId, pts]
);
if (!upd.rows.length)
return res.status(409).json({ error: 'Kein Profil vorhanden. Bitte zuerst Profil anlegen.' });
const { total_ep, streak_days } = upd.rows[0];
res.json({ total_ep, streak_days, level: Math.floor(total_ep / 500) });
} catch (err) { next(err); }
});

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;

View File

@@ -42,11 +42,25 @@ router.get('/', requireJwt, async (req, res, next) => {
const lang = ['de', 'en', 'sv'].includes(req.query.lang) ? req.query.lang : 'de';
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
// 1. Random pairs
// 1. Random pairs — only fully ready content:
// pair published + linked question/statements published + a published picture exists.
// (Audio coverage is additionally enforced in Phase 2.)
const pairsRes = await query(
`SELECT id, answer_type, status, difficulty_level,
question_id, positive_statement_id, negative_statement_id
FROM pairs
`SELECT p.id, p.answer_type, p.status, p.difficulty_level,
p.question_id, p.positive_statement_id, p.negative_statement_id
FROM pairs p
WHERE p.status = 'published'
AND (p.question_id IS NULL OR EXISTS (
SELECT 1 FROM questions q WHERE q.id = p.question_id AND q.status = 'published'))
AND (p.positive_statement_id IS NULL OR EXISTS (
SELECT 1 FROM statements s WHERE s.id = p.positive_statement_id AND s.status = 'published'))
AND (p.negative_statement_id IS NULL OR EXISTS (
SELECT 1 FROM statements s WHERE s.id = p.negative_statement_id AND s.status = 'published'))
AND EXISTS (
SELECT 1 FROM object_pairs op
JOIN object_pictures pic ON pic.object_id = op.object_id
JOIN pictures pp ON pp.id = pic.picture_id
WHERE op.pair_id = p.id AND pp.status = 'published')
ORDER BY random()
LIMIT $1`,
[limit]
@@ -140,6 +154,32 @@ router.get('/', requireJwt, async (req, res, next) => {
negRes.rows.forEach(r => { statWordMap[r.statement_id]?.negative.push({ id: r.id, de: r.de, en: r.en, sv: r.sv }); });
}
// 7b. Fetch audios for the target language (question + statement sentences)
// keyed by `${source_table}|${source_id}|${source_field}`
const audioMap = {};
{
const lookups = [];
questionIds.forEach(id => lookups.push(['questions', id, 'sentence']));
statementIds.forEach(id => {
lookups.push(['statements', id, 'positive_sentence']);
lookups.push(['statements', id, 'negative_sentence']);
});
if (lookups.length) {
const ids = [...new Set(lookups.map(l => l[1]))];
const r = await query(
`SELECT source_table, source_id, source_field, audio_link, alignment
FROM audios
WHERE source_id = ANY($1) AND language = $2 AND status <> 'blocked'`,
[ids, lang]
);
r.rows.forEach(a => {
audioMap[`${a.source_table}|${a.source_id}|${a.source_field}`] =
{ url: a.audio_link, alignment: a.alignment };
});
}
}
const getAudio = (table, id, field) => audioMap[`${table}|${id}|${field}`] || null;
// 8. Fetch pictures via object_pairs → object_pictures → pictures (one per pair)
const pictureMap = {}; // pairId → { url, blurhash }
if (pairIds.length) {
@@ -176,6 +216,8 @@ router.get('/', requireJwt, async (req, res, next) => {
function buildStatement(id) {
if (!id || !statementsMap[id]) return null;
const s = statementsMap[id];
const posAudio = getAudio('statements', id, 'positive_sentence');
const negAudio = getAudio('statements', id, 'negative_sentence');
return {
id,
sentence_de: s.positive_sentence_de,
@@ -187,17 +229,39 @@ router.get('/', requireJwt, async (req, res, next) => {
answer: s.answer,
positive_words: statWordMap[id]?.positive || [],
negative_words: statWordMap[id]?.negative || [],
audio_url: posAudio?.url || null,
audio_alignment: posAudio?.alignment || null,
negative_audio_url: negAudio?.url || null,
};
}
function buildQuestion(id) {
if (!id || !questionsMap[id]) return null;
const q = questionsMap[id];
return { id, sentence_de: q.sentence_de, sentence_en: q.sentence_en, sentence_sv: q.sentence_sv };
const audio = getAudio('questions', id, 'sentence');
return {
id, sentence_de: q.sentence_de, sentence_en: q.sentence_en, sentence_sv: q.sentence_sv,
audio_url: audio?.url || null, audio_alignment: audio?.alignment || null,
};
}
// 10. Assemble
const result = pairs.map(p => ({
// Audio-Gate: jeder nicht-leere Satz, der in der Zielsprache angezeigt wird,
// muss ein Audio haben. Sonst fliegt das Pair aus dem Feed.
function hasRequiredAudio(p) {
const q = questionsMap[p.question_id];
if (q && (q[`sentence_${lang}`] || '').trim() && !getAudio('questions', p.question_id, 'sentence'))
return false;
const ps = statementsMap[p.positive_statement_id];
if (ps && (ps[`positive_sentence_${lang}`] || '').trim() && !getAudio('statements', p.positive_statement_id, 'positive_sentence'))
return false;
const ns = statementsMap[p.negative_statement_id];
if (ns && (ns[`negative_sentence_${lang}`] || '').trim() && !getAudio('statements', p.negative_statement_id, 'negative_sentence'))
return false;
return true;
}
// 10. Assemble (only pairs whose displayed sentences all have audio)
const result = pairs.filter(hasRequiredAudio).map(p => ({
id: p.id,
answer_type: p.answer_type,
status: p.status,

View File

@@ -77,6 +77,16 @@ router.patch('/:id', async (req, res, next) => {
if (atErr) return res.status(400).json({ error: atErr });
}
// Publish-Gating: ein Pair muss erst 'reviewed' sein, bevor es published werden darf.
if (req.body.status === 'published') {
const cur = await query('SELECT status FROM pairs WHERE id = $1', [req.params.id]);
if (!cur.rows.length) return res.status(404).json({ error: 'Not found' });
if (cur.rows[0].status === 'draft')
return res.status(409).json({
error: 'Pair muss erst geprüft werden (Status "reviewed"), bevor es veröffentlicht werden kann.',
});
}
const tsField = STATUS_TIMESTAMP[req.body.status];
if (tsField && !req.body[tsField]) {
fields.push(tsField);

View File

@@ -45,11 +45,13 @@ router.get('/', async (req, res, next) => {
// POST /api/words
router.post('/', async (req, res, next) => {
try {
const { titel_de, titel_en, titel_sv, difficulty_level } = req.body;
const { titel_de, titel_en, titel_sv, difficulty_level, status } = req.body;
if (status && !STATUSES.includes(status))
return res.status(400).json({ error: `status must be one of: ${STATUSES.join(', ')}` });
const result = await query(
`INSERT INTO words (titel_de, titel_en, titel_sv, difficulty_level, requested_at)
VALUES ($1, $2, $3, $4, NOW()) RETURNING *`,
[titel_de || null, titel_en || null, titel_sv || null, difficulty_level || null]
`INSERT INTO words (titel_de, titel_en, titel_sv, difficulty_level, status, requested_at)
VALUES ($1, $2, $3, $4, COALESCE($5, 'requested'), NOW()) RETURNING *`,
[titel_de || null, titel_en || null, titel_sv || null, difficulty_level || null, status || null]
);
res.status(201).json({ ...result.rows[0], picture_ids: [], category_ids: [] });
} catch (err) { next(err); }

17
src/voices.js Normal file
View File

@@ -0,0 +1,17 @@
// Default ElevenLabs voice per language (ISO 639-1 → voice_id).
// Configure via env (ELEVENLABS_VOICE_DE/EN/SV). Falls back to a shared multilingual voice.
const FALLBACK_VOICE = process.env.ELEVENLABS_VOICE_DEFAULT || 'XXCqsM8I9KhqA7jLGj1U';
const VOICES = {
de: process.env.ELEVENLABS_VOICE_DE || FALLBACK_VOICE,
en: process.env.ELEVENLABS_VOICE_EN || FALLBACK_VOICE,
sv: process.env.ELEVENLABS_VOICE_SV || FALLBACK_VOICE,
};
/** Returns the configured voice_id for a language code (default: fallback voice). */
function voiceForLanguage(lang) {
return VOICES[lang] || FALLBACK_VOICE;
}
module.exports = { VOICES, voiceForLanguage, FALLBACK_VOICE };