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:
@@ -14,3 +14,8 @@ ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# ElevenLabs (für TTS-Generierung)
|
||||
ELEVENLABS_API_KEY=sk_...
|
||||
# Default-Stimme pro Sprache (ElevenLabs voice_id). Fällt auf ELEVENLABS_VOICE_DEFAULT zurück.
|
||||
ELEVENLABS_VOICE_DEFAULT=XXCqsM8I9KhqA7jLGj1U
|
||||
ELEVENLABS_VOICE_DE=
|
||||
ELEVENLABS_VOICE_EN=
|
||||
ELEVENLABS_VOICE_SV=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (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) return res.status(400).json({ error: 'voice_id is required' });
|
||||
if (!voice_id && !language)
|
||||
return res.status(400).json({ error: 'voice_id or language is required' });
|
||||
|
||||
const apiKey = process.env.ELEVENLABS_API_KEY;
|
||||
if (!apiKey) return res.status(500).json({ error: 'ELEVENLABS_API_KEY not configured' });
|
||||
|
||||
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 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);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!elevenRes.ok) {
|
||||
const err = await elevenRes.text();
|
||||
return res.status(elevenRes.status).json({ error: 'ElevenLabs error', detail: err });
|
||||
// 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 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` });
|
||||
|
||||
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' });
|
||||
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
17
src/voices.js
Normal 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 };
|
||||
Reference in New Issue
Block a user