From fb93d2296e846410b2c450e2eb9bde6260c48dac Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 10 Jun 2026 21:48:03 +0200 Subject: [PATCH] fix: Readiness pro answer_type + Objekt-Zuweisung & Audio-Nachholen im Publish-Flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - computeReadiness: yes_no braucht keinen Positiv-Satz (nur answer-Flag), word-Pairs prüfen verlinkte Wörter (Titel + Audio) statt Statement-Sätze → behebt 'bei jedem Pair fehlt ein Audio' im Publish-Review - Bundle liefert Placeholder-Kandidaten: Objekt-Wörter, die im deutschen Satz vorkommen (außerhalb bestehender Placeholder, inkl. Flexion) - POST /pipeline/assign-object: Wort in allen 3 Sprachen als {{wort.o:objectId}} markieren (über die Wort-Übersetzungen) - POST /pipeline/picture/:id/audio-fill: fehlende Audios nachgenerieren Co-Authored-By: Claude Fable 5 --- src/lib/pairContent.js | 61 ++++++++++++++----- src/lib/pipeline.js | 2 +- src/routes/pipeline.js | 133 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 178 insertions(+), 18 deletions(-) diff --git a/src/lib/pairContent.js b/src/lib/pairContent.js index 7341dfb..959c509 100644 --- a/src/lib/pairContent.js +++ b/src/lib/pairContent.js @@ -25,6 +25,22 @@ async function loadPairContext(pairs, lang) { FROM statements WHERE id = ANY($1)`, [statementIds]); r.rows.forEach(s => { statementsMap[s.id] = s; }); } + // Wörter pro Statement (für word-Pairs): Titel in der Sprache + Audio-Check + const statementWordsMap = {}; // statementId → [{ id, titel }] + const wordIds = new Set(); + if (statementIds.length) { + for (const link of ['statement_positive_words', 'statement_negative_words']) { + const r = await query( + `SELECT lw.statement_id, w.id, w.titel_${lang} AS titel + FROM ${link} lw JOIN words w ON w.id = lw.word_id + WHERE lw.statement_id = ANY($1)`, [statementIds]); + r.rows.forEach(row => { + (statementWordsMap[row.statement_id] ??= []).push({ id: row.id, titel: row.titel }); + wordIds.add(row.id); + }); + } + } + if (pairIds.length) { const r = await query( `SELECT op.pair_id, @@ -37,7 +53,7 @@ async function loadPairContext(pairs, lang) { GROUP BY op.pair_id`, [pairIds]); r.rows.forEach(row => { pictureMap[row.pair_id] = row; }); - const ids = [...questionIds, ...statementIds]; + const ids = [...questionIds, ...statementIds, ...wordIds]; if (ids.length) { const a = await query( `SELECT source_table, source_id, source_field FROM audios @@ -45,15 +61,19 @@ async function loadPairContext(pairs, lang) { a.rows.forEach(x => { audioMap[`${x.source_table}|${x.source_id}|${x.source_field}`] = true; }); } } - return { questionsMap, statementsMap, pictureMap, audioMap }; + return { questionsMap, statementsMap, pictureMap, audioMap, statementWordsMap }; } -// Berechnet, was einem Pair zur Veröffentlichung (für eine Sprache) noch fehlt. +// Berechnet, was einem Pair zur Veröffentlichung (für eine Sprache) noch fehlt — +// abhängig vom answer_type: yes_no/word-Pairs haben konstruktionsbedingt keine +// Statement-Sätze (nur answer-Flag bzw. verlinkte Wörter) und dürfen dafür +// nicht als unvollständig gelten. // opts.skipPicturePublished: Bild-Publish-Check überspringen (Bundle-Publish veröffentlicht // das Bild im selben Schritt), Bild-Existenz wird weiter geprüft. // opts.skipStatusChecks: Publish-Status von Frage/Statements ignorieren (werden mitveröffentlicht). function computeReadiness(p, ctx, lang, opts = {}) { const missing = []; + const type = p.answer_type; 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; @@ -71,19 +91,32 @@ function computeReadiness(p, ctx, lang, opts = {}) { 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 (!opts.skipStatusChecks && ps.status !== 'published') missing.push('Positiv-Satz nicht freigegeben'); - if (!ctx.audioMap[`statements|${p.positive_statement_id}|positive_sentence`]) missing.push('Audio Positiv fehlt'); + + if (type === 'word') { + // Inhalt sind die verlinkten Wörter: Titel + Audio in der Sprache + for (const [stmtId, label] of [[p.positive_statement_id, 'Positiv'], [p.negative_statement_id, 'Negativ']]) { + if (!stmtId) continue; + for (const w of (ctx.statementWordsMap?.[stmtId] || [])) { + if (!(w.titel || '').trim()) { missing.push(`${label}-Wort (${lang}) fehlt`); continue; } + if (!ctx.audioMap[`words|${w.id}|titel`]) missing.push(`Audio ${label}-Wort „${w.titel}" fehlt`); + } + } + } else if (type !== 'yes_no') { + // text / question: Positiv-Satz Pflicht + if (ps) { + if (!(ps.positive || '').trim()) missing.push(`Positiv-Satz (${lang}) fehlt`); + else { + if (!opts.skipStatusChecks && 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 (!opts.skipStatusChecks && ns.status !== 'published') missing.push('Negativ-Satz nicht freigegeben'); + if (!ctx.audioMap[`statements|${p.negative_statement_id}|negative_sentence`]) missing.push('Audio Negativ fehlt'); } } - // Negativ-Statement (nur wenn Text vorhanden) - if (ns && (ns.negative || '').trim()) { - if (!opts.skipStatusChecks && ns.status !== 'published') missing.push('Negativ-Satz nicht freigegeben'); - if (!ctx.audioMap[`statements|${p.negative_statement_id}|negative_sentence`]) missing.push('Audio Negativ fehlt'); - } + // yes_no: Positiv-Statement trägt nur die Ja/Nein-Antwort — kein Satz, kein Audio nötig. return { missing, missingCount: missing.length, ready: missing.length === 0 }; } diff --git a/src/lib/pipeline.js b/src/lib/pipeline.js index dc6a880..dd28a54 100644 --- a/src/lib/pipeline.js +++ b/src/lib/pipeline.js @@ -334,4 +334,4 @@ async function generateWithBackoff(u) { } } -module.exports = { enqueue, resumePending }; +module.exports = { enqueue, resumePending, loadPairs, collectAudioUnits, generateWithBackoff }; diff --git a/src/routes/pipeline.js b/src/routes/pipeline.js index 40d5d11..9de4225 100644 --- a/src/routes/pipeline.js +++ b/src/routes/pipeline.js @@ -3,7 +3,49 @@ const router = require('express').Router(); const { query } = require('../db'); const { LANGS } = require('../lib/translate'); const { loadPairContext, computeReadiness, loadPairContent } = require('../lib/pairContent'); -const { enqueue } = require('../lib/pipeline'); +const { enqueue, loadPairs, collectAudioUnits, generateWithBackoff } = require('../lib/pipeline'); +const { PLACEHOLDER_RE } = require('../lib/placeholders'); + +// ── Objekt-Wort-Erkennung in Sätzen (für die manuelle Zuweisung beim Review) ── + +// Findet `title` als eigenständiges Wort (inkl. einfacher Flexionsendung) außerhalb +// bestehender Placeholder. Liefert den getroffenen Text oder null. +function findWordInSentence(sentence, title) { + if (!sentence || !(title || '').trim()) return null; + const esc = title.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`(?= 0) { + const actual = t.slice(idx, idx + hit.length); + parts.push(t.slice(0, idx) + `{{${actual}.o:${objectId}}}` + t.slice(idx + hit.length)); + replaced = true; + continue; + } + } + parts.push(t); + } + return replaced ? parts.join('') : null; +} // POST /api/pipeline/release/:pictureId — Bild in die Pipeline geben router.post('/release/:pictureId', async (req, res, next) => { @@ -80,8 +122,17 @@ router.get('/picture/:id/bundle', async (req, res, next) => { WHERE op.object_id = $1 AND p.status <> 'blocked' ORDER BY p.created_at`, [obj.id])).rows; + obj.pairs = pairs; + } + + // Objekt-Wort-Kandidaten: alle Wörter aller Objekte dieses Bildes + const objectWordCandidates = objects.flatMap(o => + o.words.filter(w => (w.titel_de || '').trim()) + .map(w => ({ object_id: o.id, word_id: w.id, titel_de: w.titel_de }))); + + for (const obj of objects) { // Audio-Abdeckung pro Pair × Sprache (für die 🔊-Indikatoren) - for (const p of pairs) { + for (const p of obj.pairs) { p.content = await loadPairContent(p); p.audio = {}; for (const lang of LANGS) { @@ -89,15 +140,91 @@ router.get('/picture/:id/bundle', async (req, res, next) => { const r = computeReadiness(p, ctx, lang, { skipPicturePublished: true, skipStatusChecks: true }); p.audio[lang] = { ready: r.ready, missing: r.missing }; } + + // Placeholder-Kandidaten: Objekt-Wörter, die im deutschen Satz vorkommen, + // aber noch keinem Objekt zugewiesen sind (manuelle Zuweisung im Review). + p.candidates = []; + const fields = [ + ['questions', p.question_id, 'sentence', p.content.question?.sentence_de], + ['statements', p.positive_statement_id, 'positive_sentence', p.content.positive?.sentence?.positive_sentence_de], + ['statements', p.negative_statement_id, 'negative_sentence', p.content.negative?.sentence?.negative_sentence_de], + ]; + for (const [table, id, field, sentence] of fields) { + if (!id || !(sentence || '').trim()) continue; + for (const cand of objectWordCandidates) { + const hit = findWordInSentence(sentence, cand.titel_de); + if (hit) p.candidates.push({ + source_table: table, source_id: id, source_field: field, + object_id: cand.object_id, word_id: cand.word_id, label: hit, + }); + } + } delete p.question_id; delete p.positive_statement_id; delete p.negative_statement_id; } - obj.pairs = pairs; } res.json({ picture, objects }); } catch (err) { next(err); } }); +// POST /api/pipeline/assign-object — markiert ein Wort im Satz als Objekt-Referenz +// Body: { source_table, source_id, source_field, object_id, word_id } +// Ersetzt das Wort in ALLEN Sprachen (über die Wort-Übersetzungen) durch {{wort.o:objectId}}. +router.post('/assign-object', async (req, res, next) => { + try { + const { source_table, source_id, source_field, object_id, word_id } = req.body || {}; + const FIELDS = { questions: ['sentence'], statements: ['positive_sentence', 'negative_sentence'] }; + if (!FIELDS[source_table]?.includes(source_field)) + return res.status(400).json({ error: 'Ungültige source_table/source_field' }); + if (!source_id || !object_id || !word_id) + return res.status(400).json({ error: 'source_id, object_id und word_id sind Pflicht' }); + + const obj = await query(`SELECT id FROM objects WHERE id = $1`, [object_id]); + if (!obj.rows.length) return res.status(404).json({ error: 'Objekt nicht gefunden' }); + const wr = await query(`SELECT titel_de, titel_en, titel_sv FROM words WHERE id = $1`, [word_id]); + if (!wr.rows.length) return res.status(404).json({ error: 'Wort nicht gefunden' }); + const word = wr.rows[0]; + + const cols = LANGS.map(l => `${source_field}_${l}`); + const rr = await query(`SELECT ${cols.join(', ')} FROM ${source_table} WHERE id = $1`, [source_id]); + if (!rr.rows.length) return res.status(404).json({ error: 'Satz-Zeile nicht gefunden' }); + const row = rr.rows[0]; + + const updates = {}, updated = [], skipped = []; + for (const l of LANGS) { + const sentence = row[`${source_field}_${l}`] || ''; + const wrapped = wrapWordAsObject(sentence, word[`titel_${l}`], object_id); + if (wrapped) { updates[`${source_field}_${l}`] = wrapped; updated.push(l); } + else skipped.push(l); + } + if (updated.length) { + const cells = Object.keys(updates); + await query( + `UPDATE ${source_table} SET ${cells.map((c, i) => `${c} = $${i + 1}`).join(', ')} WHERE id = $${cells.length + 1}`, + [...cells.map(c => updates[c]), source_id]); + } + res.json({ updated_langs: updated, skipped_langs: skipped }); + } catch (err) { next(err); } +}); + +// POST /api/pipeline/picture/:id/audio-fill — fehlende Audios dieses Bildes nachgenerieren +router.post('/picture/:id/audio-fill', async (req, res, next) => { + try { + const pairs = await loadPairs(req.params.id); + if (!pairs.length) return res.status(400).json({ error: 'Bild hat keine Pairs' }); + const units = (await collectAudioUnits(req.params.id, pairs)).filter(u => !u.hasAudio); + const result = { generated: 0, failed: 0, errors: [] }; + for (const u of units) { + try { await generateWithBackoff(u); result.generated++; } + catch (err) { + result.failed++; + result.errors.push({ source: `${u.source_table}/${u.source_field}/${u.language}`, error: err.message }); + } + } + res.json(result); + } catch (err) { next(err); } +}); + // GET /api/pipeline/settings router.get('/settings', async (req, res, next) => { try {