feat: TTS-Settings je Sprache, Audio-Coverage entkoppelt, Veröffentlichen-Workflow

- tts_settings (voice/model/speed/... pro Sprache) + Seed de/en/sv; Route /api/tts-settings
- audios: Stimme/Parameter aus tts_settings; Coverage zählt jetzt auch draft/translated
- pairs: GET /publishability (Readiness, sortierbar nach 'am wenigsten fehlt'),
  POST /:id/publish (kaskadiert question/statements→published, validiert Bild+Audio je Sprache)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 22:02:07 +02:00
parent 9bfd5e8dba
commit 6c74aabc3f
5 changed files with 255 additions and 7 deletions

View File

@@ -8,23 +8,43 @@ 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.
// Welche Felder pro Quelle Audio brauchen und ab welchem Status sie vertonbar sind.
// Audio darf früh erzeugt werden (sobald Text existiert, nur 'blocked' ausgenommen) —
// das ist entkoppelt vom App-Gate (published).
// 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'] },
words: { fields: ['titel'], ready: ['translated', 'generated', 'published'] },
questions: { fields: ['sentence'], ready: ['draft', 'reviewed', 'published'] },
statements: { fields: ['positive_sentence', 'negative_sentence'], ready: ['draft', 'reviewed', 'published'] },
};
// Stimme + Parameter pro Sprache aus tts_settings (Fallback: env-Voice aus voices.js)
async function getTtsSettings(language) {
const r = await query('SELECT * FROM tts_settings WHERE language = $1', [language]);
if (r.rows.length) {
const s = r.rows[0];
return {
voice_id: s.voice_id,
model_id: s.model_id || 'eleven_multilingual_v2',
speed: Number(s.speed), stability: Number(s.stability),
similarity_boost: Number(s.similarity_boost), style: Number(s.style),
};
}
return { voice_id: voiceForLanguage(language), model_id: 'eleven_multilingual_v2',
speed: 1.0, stability: 0.5, similarity_boost: 0.75, style: 0.0 };
}
// ── 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 cfg = await getTtsSettings(language);
const voice = voice_id || cfg.voice_id;
const m = model_id || cfg.model_id;
const sp = speed ?? cfg.speed, st = stability ?? cfg.stability,
sb = similarity_boost ?? cfg.similarity_boost, sy = style ?? cfg.style;
const elevenRes = await fetch(
`${ELEVENLABS_BASE}/text-to-speech/${voice}/with-timestamps`,

View File

@@ -15,6 +15,116 @@ const STATUS_TIMESTAMP = {
blocked: 'blocked_at',
};
const LANGS = ['de', 'en', 'sv'];
// Sammelt für eine Menge Pairs den Kontext (Fragen, Statements, Bilder, Audios) für eine Sprache.
async function loadPairContext(pairs, lang) {
const questionIds = [...new Set(pairs.map(p => p.question_id).filter(Boolean))];
const statementIds = [...new Set([
...pairs.map(p => p.positive_statement_id),
...pairs.map(p => p.negative_statement_id),
].filter(Boolean))];
const pairIds = pairs.map(p => p.id);
const questionsMap = {}, statementsMap = {}, pictureMap = {}, audioMap = {};
if (questionIds.length) {
const r = await query(
`SELECT id, status, sentence_${lang} AS sentence FROM questions WHERE id = ANY($1)`, [questionIds]);
r.rows.forEach(q => { questionsMap[q.id] = q; });
}
if (statementIds.length) {
const r = await query(
`SELECT id, status, positive_sentence_${lang} AS positive, negative_sentence_${lang} AS negative
FROM statements WHERE id = ANY($1)`, [statementIds]);
r.rows.forEach(s => { statementsMap[s.id] = s; });
}
if (pairIds.length) {
const r = await query(
`SELECT op.pair_id,
bool_or(true) AS has_picture,
bool_or(pp.status = 'published') AS has_published_picture
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 = ANY($1)
GROUP BY op.pair_id`, [pairIds]);
r.rows.forEach(row => { pictureMap[row.pair_id] = row; });
const ids = [...questionIds, ...statementIds];
if (ids.length) {
const a = await query(
`SELECT source_table, source_id, source_field FROM audios
WHERE source_id = ANY($1) AND language = $2 AND status <> 'blocked'`, [ids, lang]);
a.rows.forEach(x => { audioMap[`${x.source_table}|${x.source_id}|${x.source_field}`] = true; });
}
}
return { questionsMap, statementsMap, pictureMap, audioMap };
}
// Berechnet, was einem Pair zur Veröffentlichung (für eine Sprache) noch fehlt.
function computeReadiness(p, ctx, lang) {
const missing = [];
const q = p.question_id ? ctx.questionsMap[p.question_id] : null;
const ps = p.positive_statement_id ? ctx.statementsMap[p.positive_statement_id] : null;
const ns = p.negative_statement_id ? ctx.statementsMap[p.negative_statement_id] : null;
const pic = ctx.pictureMap[p.id];
// Bild
if (!pic || !pic.has_picture) missing.push('Bild fehlt');
else if (!pic.has_published_picture) missing.push('Bild nicht veröffentlicht');
// Frage
if (q) {
if (!(q.sentence || '').trim()) missing.push(`Frage-Text (${lang}) fehlt`);
else {
if (q.status !== 'published') missing.push('Frage nicht freigegeben');
if (!ctx.audioMap[`questions|${p.question_id}|sentence`]) missing.push('Audio Frage fehlt');
}
}
// Positiv-Statement
if (ps) {
if (!(ps.positive || '').trim()) missing.push(`Positiv-Satz (${lang}) fehlt`);
else {
if (ps.status !== 'published') missing.push('Positiv-Satz nicht freigegeben');
if (!ctx.audioMap[`statements|${p.positive_statement_id}|positive_sentence`]) missing.push('Audio Positiv fehlt');
}
}
// Negativ-Statement (nur wenn Text vorhanden)
if (ns && (ns.negative || '').trim()) {
if (ns.status !== 'published') missing.push('Negativ-Satz nicht freigegeben');
if (!ctx.audioMap[`statements|${p.negative_statement_id}|negative_sentence`]) missing.push('Audio Negativ fehlt');
}
return { missing, missingCount: missing.length, ready: missing.length === 0 };
}
// GET /api/pairs/publishability?lang=sv — Pairs mit Readiness, sortierbar nach "am wenigsten fehlt"
router.get('/publishability', async (req, res, next) => {
try {
const lang = LANGS.includes(req.query.lang) ? req.query.lang : 'de';
const includePublished = req.query.includePublished === 'true';
const statusFilter = includePublished ? `status <> 'blocked'` : `status IN ('draft','reviewed')`;
const pairsRes = await query(
`SELECT id, answer_type, status, question_id, positive_statement_id, negative_statement_id
FROM pairs WHERE ${statusFilter} ORDER BY created_at DESC LIMIT 300`);
const pairs = pairsRes.rows;
if (!pairs.length) return res.json({ lang, pairs: [] });
const ctx = await loadPairContext(pairs, lang);
const result = pairs.map(p => {
const r = computeReadiness(p, ctx, lang);
const q = p.question_id ? ctx.questionsMap[p.question_id] : null;
const ps = p.positive_statement_id ? ctx.statementsMap[p.positive_statement_id] : null;
const preview = (q?.sentence || ps?.positive || '').slice(0, 80);
return { id: p.id, answer_type: p.answer_type, status: p.status, preview,
missing: r.missing, missingCount: r.missingCount, ready: r.ready };
}).sort((a, b) => a.missingCount - b.missingCount);
res.json({ lang, pairs: result });
} catch (err) { next(err); }
});
// GET /api/pairs
router.get('/', async (req, res, next) => {
try {
@@ -104,6 +214,37 @@ router.patch('/:id', async (req, res, next) => {
} catch (err) { next(err); }
});
// POST /api/pairs/:id/publish?lang=sv — validiert Readiness und veröffentlicht kaskadierend
router.post('/:id/publish', async (req, res, next) => {
try {
const lang = LANGS.includes(req.query.lang || req.body.lang) ? (req.query.lang || req.body.lang) : 'de';
const pr = await query(
`SELECT id, answer_type, status, question_id, positive_statement_id, negative_statement_id
FROM pairs WHERE id = $1`, [req.params.id]);
if (!pr.rows.length) return res.status(404).json({ error: 'Not found' });
const p = pr.rows[0];
const ctx = await loadPairContext([p], lang);
const r = computeReadiness(p, ctx, lang);
if (!r.ready)
return res.status(409).json({ error: 'Noch nicht veröffentlichbar', missing: r.missing, lang });
// Kaskadierend veröffentlichen
const now = new Date().toISOString();
const ids = [p.question_id, p.positive_statement_id, p.negative_statement_id].filter(Boolean);
if (p.question_id)
await query(`UPDATE questions SET status='published', published_at=COALESCE(published_at,$2) WHERE id=$1`, [p.question_id, now]);
const stmtIds = [p.positive_statement_id, p.negative_statement_id].filter(Boolean);
if (stmtIds.length)
await query(`UPDATE statements SET status='published', published_at=COALESCE(published_at,$2) WHERE id = ANY($1)`, [stmtIds, now]);
const upd = await query(
`UPDATE pairs SET status='published', published_at=COALESCE(published_at,$2) WHERE id=$1 RETURNING *`,
[p.id, now]);
res.json({ ...upd.rows[0], published_languages: [lang] });
} catch (err) { next(err); }
});
// DELETE /api/pairs/:id
router.delete('/:id', async (req, res, next) => {
try {

View File

@@ -0,0 +1,58 @@
const router = require('express').Router();
const { query } = require('../db');
const EDITABLE = ['voice_id', 'model_id', 'speed', 'stability', 'similarity_boost', 'style'];
// GET /api/tts-settings
router.get('/', async (req, res, next) => {
try {
const r = await query('SELECT * FROM tts_settings ORDER BY language');
res.json(r.rows);
} catch (err) { next(err); }
});
// GET /api/tts-settings/:language
router.get('/:language', async (req, res, next) => {
try {
const r = await query('SELECT * FROM tts_settings WHERE language = $1', [req.params.language]);
if (!r.rows.length) return res.status(404).json({ error: 'Not found' });
res.json(r.rows[0]);
} catch (err) { next(err); }
});
// PUT /api/tts-settings/:language — anlegen oder aktualisieren
router.put('/:language', async (req, res, next) => {
try {
const { voice_id } = req.body;
if (!voice_id) return res.status(400).json({ error: 'voice_id is required' });
const r = await query(
`INSERT INTO tts_settings (language, voice_id, model_id, speed, stability, similarity_boost, style)
VALUES ($1, $2, COALESCE($3,'eleven_multilingual_v2'), COALESCE($4,1.0), COALESCE($5,0.5), COALESCE($6,0.75), COALESCE($7,0.0))
ON CONFLICT (language) DO UPDATE SET
voice_id = EXCLUDED.voice_id, model_id = EXCLUDED.model_id, speed = EXCLUDED.speed,
stability = EXCLUDED.stability, similarity_boost = EXCLUDED.similarity_boost, style = EXCLUDED.style
RETURNING *`,
[req.params.language, voice_id, req.body.model_id, req.body.speed,
req.body.stability, req.body.similarity_boost, req.body.style]
);
res.json(r.rows[0]);
} catch (err) { next(err); }
});
// PATCH /api/tts-settings/:language
router.patch('/:language', async (req, res, next) => {
try {
const fields = Object.keys(req.body).filter(k => EDITABLE.includes(k));
if (!fields.length) return res.status(400).json({ error: 'No valid fields provided' });
const setClauses = fields.map((f, i) => `${f} = $${i + 1}`).join(', ');
const values = [...fields.map(f => req.body[f]), req.params.language];
const r = await query(
`UPDATE tts_settings SET ${setClauses} WHERE language = $${fields.length + 1} RETURNING *`,
values
);
if (!r.rows.length) return res.status(404).json({ error: 'Not found' });
res.json(r.rows[0]);
} catch (err) { next(err); }
});
module.exports = router;