// Content-Pipeline: Freigeben (release) → Auto-Verarbeitung → Review-Bundle → Bild-Publish. const router = require('express').Router(); const { query } = require('../db'); const { LANGS } = require('../lib/translate'); const { loadPairContext, computeReadiness, loadPairContent } = require('../lib/pairContent'); const { enqueue, loadPairs, collectAudioUnits, generateWithBackoff, translatePair } = require('../lib/pipeline'); const { describeError } = require('./audios'); 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) => { try { const pr = await query(`SELECT id, pipeline_status FROM pictures WHERE id = $1`, [req.params.pictureId]); if (!pr.rows.length) return res.status(404).json({ error: 'Bild nicht gefunden' }); if (['queued', 'running'].includes(pr.rows[0].pipeline_status)) return res.status(409).json({ error: 'Pipeline läuft bereits für dieses Bild' }); const oc = await query( `SELECT count(*) AS c FROM object_pictures WHERE picture_id = $1`, [req.params.pictureId]); if (!parseInt(oc.rows[0].c)) return res.status(400).json({ error: 'Bild hat keine Objekte — erst Objekte anlegen' }); const upd = await query( `UPDATE pictures SET pipeline_status='queued', pipeline_error=NULL, pipeline_step=NULL, pipeline_started_at=NULL, pipeline_finished_at=NULL WHERE id=$1 RETURNING *`, [req.params.pictureId]); enqueue(req.params.pictureId); res.status(202).json(upd.rows[0]); } catch (err) { next(err); } }); // POST /api/pipeline/retry/:pictureId — fehlgeschlagenen Lauf erneut starten router.post('/retry/:pictureId', async (req, res, next) => { try { const upd = await query( `UPDATE pictures SET pipeline_status='queued', pipeline_error=NULL WHERE id=$1 AND pipeline_status='failed' RETURNING *`, [req.params.pictureId]); if (!upd.rows.length) return res.status(409).json({ error: 'Bild ist nicht im Status "failed"' }); enqueue(req.params.pictureId); res.status(202).json(upd.rows[0]); } catch (err) { next(err); } }); // GET /api/pipeline/overview — alle Bilder, die in der Pipeline sind (Polling-Endpoint) router.get('/overview', async (req, res, next) => { try { const r = await query( `SELECT p.id, p.design, p.picture_link, p.blurhash, p.status, p.pipeline_status, p.pipeline_step, p.pipeline_progress, p.pipeline_error, p.pipeline_started_at, p.pipeline_finished_at, (SELECT count(*) FROM object_pictures op WHERE op.picture_id = p.id) AS object_count FROM pictures p WHERE p.pipeline_status NOT IN ('none', 'published') ORDER BY p.pipeline_started_at DESC NULLS LAST, p.updated_at DESC`); res.json(r.rows); } catch (err) { next(err); } }); // GET /api/pipeline/picture/:id/bundle — kompletter Review-Payload für die Veröffentlichen-Seite router.get('/picture/:id/bundle', async (req, res, next) => { try { const pr = await query(`SELECT * FROM pictures WHERE id = $1`, [req.params.id]); if (!pr.rows.length) return res.status(404).json({ error: 'Bild nicht gefunden' }); const picture = pr.rows[0]; const objects = (await query( `SELECT o.id, o.status, o.selections FROM object_pictures op JOIN objects o ON o.id = op.object_id WHERE op.picture_id = $1 AND o.status <> 'blocked' ORDER BY o.created_at`, [req.params.id])).rows; for (const obj of objects) { obj.words = (await query( `SELECT w.id, w.titel_de, w.titel_en, w.titel_sv, w.status FROM object_words ow JOIN words w ON w.id = ow.word_id WHERE ow.object_id = $1`, [obj.id])).rows; const pairs = (await query( `SELECT p.id, p.answer_type, p.status, p.difficulty_level, p.question_id, p.positive_statement_id, p.negative_statement_id FROM object_pairs op JOIN pairs p ON p.id = op.pair_id 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 obj.pairs) { p.content = await loadPairContent(p); p.audio = {}; for (const lang of LANGS) { const ctx = await loadPairContext([p], lang); 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; } } 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/translate-fill — fehlende Übersetzungen dieses Bildes nachholen // (z.B. wenn bei einem früheren Lauf eine Sprache durch einen API-Fehler weggebrochen ist) router.post('/picture/:id/translate-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 result = { translated: 0, failed: 0, errors: [] }; for (const p of pairs) { try { await translatePair(p); result.translated++; } catch (err) { result.failed++; result.errors.push({ pair_id: p.id, error: err.message }); } } res.json(result); } 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: describeError(err) }); } } res.json(result); } catch (err) { next(err); } }); // GET /api/pipeline/settings router.get('/settings', async (req, res, next) => { try { const r = await query(`SELECT value FROM app_settings WHERE key = 'pipeline.pairs_per_object'`); const n = parseInt(r.rows[0]?.value); res.json({ pairs_per_object: isNaN(n) ? 5 : n }); } catch (err) { next(err); } }); // PUT /api/pipeline/settings — { pairs_per_object } router.put('/settings', async (req, res, next) => { try { const n = Math.min(Math.max(parseInt(req.body.pairs_per_object) || 5, 1), 20); await query( `INSERT INTO app_settings (key, value) VALUES ('pipeline.pairs_per_object', $1::jsonb) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, [JSON.stringify(n)]); res.json({ pairs_per_object: n }); } catch (err) { next(err); } }); // POST /api/pipeline/picture/:id/publish — Body { excluded_pair_ids: [] } // Blockt ausgeschlossene Pairs, validiert den Rest (Text + Audio in allen Sprachen) // und veröffentlicht dann kaskadierend: Fragen, Statements, Pairs, Wörter, Objekte, Bild. router.post('/picture/:id/publish', async (req, res, next) => { try { const pictureId = req.params.id; const excluded = Array.isArray(req.body?.excluded_pair_ids) ? req.body.excluded_pair_ids : []; const pr = await query(`SELECT id, status, pipeline_status FROM pictures WHERE id = $1`, [pictureId]); if (!pr.rows.length) return res.status(404).json({ error: 'Bild nicht gefunden' }); // 1) Ausgeschlossene Pairs blocken if (excluded.length) { await query( `UPDATE pairs SET status='blocked', blocked_at=NOW(), blocked_topic='Im Publish-Review ausgeschlossen' WHERE id = ANY($1)`, [excluded]); } // 2) Verbleibende Pairs des Bildes laden const pairs = (await query( `SELECT DISTINCT p.id, p.answer_type, p.status, p.question_id, p.positive_statement_id, p.negative_statement_id FROM object_pairs op JOIN object_pictures pic ON pic.object_id = op.object_id JOIN pairs p ON p.id = op.pair_id WHERE pic.picture_id = $1 AND p.status <> 'blocked'`, [pictureId])).rows; if (!pairs.length) return res.status(400).json({ error: 'Keine veröffentlichbaren Pairs übrig' }); // 3) Readiness pro Pair × Sprache (Bild-/Status-Checks übersprungen — werden hier mitveröffentlicht) const notReady = []; for (const lang of LANGS) { const ctx = await loadPairContext(pairs, lang); for (const p of pairs) { const r = computeReadiness(p, ctx, lang, { skipPicturePublished: true, skipStatusChecks: true }); if (!r.ready) notReady.push({ pair_id: p.id, lang, missing: r.missing }); } } if (notReady.length) return res.status(409).json({ error: 'Noch nicht veröffentlichbar', notReady }); // 4) Kaskadierend veröffentlichen const now = new Date().toISOString(); const questionIds = [...new Set(pairs.map(p => p.question_id).filter(Boolean))]; const stmtIds = [...new Set(pairs.flatMap(p => [p.positive_statement_id, p.negative_statement_id]).filter(Boolean))]; const pairIds = pairs.map(p => p.id); if (questionIds.length) await query(`UPDATE questions SET status='published', published_at=COALESCE(published_at,$2) WHERE id = ANY($1)`, [questionIds, now]); if (stmtIds.length) await query(`UPDATE statements SET status='published', published_at=COALESCE(published_at,$2) WHERE id = ANY($1)`, [stmtIds, now]); await query(`UPDATE pairs SET status='published', published_at=COALESCE(published_at,$2) WHERE id = ANY($1)`, [pairIds, now]); // Verlinkte Wörter: nur 'generated' → 'published' (translated bleibt für die Bild-Generierung // im ServerMonitor-Flow; published würde diesen Schritt überspringen) let publishedWords = 0; if (stmtIds.length) { const w = await query( `UPDATE words SET status='published', published_at=COALESCE(published_at,$2) WHERE status='generated' AND id IN ( SELECT word_id FROM statement_positive_words WHERE statement_id = ANY($1) UNION SELECT word_id FROM statement_negative_words WHERE statement_id = ANY($1) ) RETURNING id`, [stmtIds, now]); publishedWords = w.rows.length; } await query( `UPDATE objects SET status='published', published_at=COALESCE(published_at,$2) WHERE id IN (SELECT object_id FROM object_pictures WHERE picture_id = $1) AND status <> 'blocked'`, [pictureId, now]); await query( `UPDATE pictures SET status='published', published_timestamp=COALESCE(published_timestamp,$2), pipeline_status='published' WHERE id=$1`, [pictureId, now]); res.json({ published_pairs: pairIds.length, blocked_pairs: excluded.length, published_words: publishedWords, picture_id: pictureId, }); } catch (err) { next(err); } }); module.exports = router;