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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user