fix: Readiness pro answer_type + Objekt-Zuweisung & Audio-Nachholen im Publish-Flow
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,22 @@ async function loadPairContext(pairs, lang) {
|
|||||||
FROM statements WHERE id = ANY($1)`, [statementIds]);
|
FROM statements WHERE id = ANY($1)`, [statementIds]);
|
||||||
r.rows.forEach(s => { statementsMap[s.id] = s; });
|
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) {
|
if (pairIds.length) {
|
||||||
const r = await query(
|
const r = await query(
|
||||||
`SELECT op.pair_id,
|
`SELECT op.pair_id,
|
||||||
@@ -37,7 +53,7 @@ async function loadPairContext(pairs, lang) {
|
|||||||
GROUP BY op.pair_id`, [pairIds]);
|
GROUP BY op.pair_id`, [pairIds]);
|
||||||
r.rows.forEach(row => { pictureMap[row.pair_id] = row; });
|
r.rows.forEach(row => { pictureMap[row.pair_id] = row; });
|
||||||
|
|
||||||
const ids = [...questionIds, ...statementIds];
|
const ids = [...questionIds, ...statementIds, ...wordIds];
|
||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
const a = await query(
|
const a = await query(
|
||||||
`SELECT source_table, source_id, source_field FROM audios
|
`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; });
|
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
|
// opts.skipPicturePublished: Bild-Publish-Check überspringen (Bundle-Publish veröffentlicht
|
||||||
// das Bild im selben Schritt), Bild-Existenz wird weiter geprüft.
|
// das Bild im selben Schritt), Bild-Existenz wird weiter geprüft.
|
||||||
// opts.skipStatusChecks: Publish-Status von Frage/Statements ignorieren (werden mitveröffentlicht).
|
// opts.skipStatusChecks: Publish-Status von Frage/Statements ignorieren (werden mitveröffentlicht).
|
||||||
function computeReadiness(p, ctx, lang, opts = {}) {
|
function computeReadiness(p, ctx, lang, opts = {}) {
|
||||||
const missing = [];
|
const missing = [];
|
||||||
|
const type = p.answer_type;
|
||||||
const q = p.question_id ? ctx.questionsMap[p.question_id] : null;
|
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 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 ns = p.negative_statement_id ? ctx.statementsMap[p.negative_statement_id] : null;
|
||||||
@@ -71,7 +91,18 @@ function computeReadiness(p, ctx, lang, opts = {}) {
|
|||||||
if (!ctx.audioMap[`questions|${p.question_id}|sentence`]) missing.push('Audio Frage fehlt');
|
if (!ctx.audioMap[`questions|${p.question_id}|sentence`]) missing.push('Audio Frage fehlt');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Positiv-Statement
|
|
||||||
|
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) {
|
||||||
if (!(ps.positive || '').trim()) missing.push(`Positiv-Satz (${lang}) fehlt`);
|
if (!(ps.positive || '').trim()) missing.push(`Positiv-Satz (${lang}) fehlt`);
|
||||||
else {
|
else {
|
||||||
@@ -84,6 +115,8 @@ function computeReadiness(p, ctx, lang, opts = {}) {
|
|||||||
if (!opts.skipStatusChecks && ns.status !== 'published') missing.push('Negativ-Satz nicht freigegeben');
|
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');
|
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 };
|
return { missing, missingCount: missing.length, ready: missing.length === 0 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,4 +334,4 @@ async function generateWithBackoff(u) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { enqueue, resumePending };
|
module.exports = { enqueue, resumePending, loadPairs, collectAudioUnits, generateWithBackoff };
|
||||||
|
|||||||
@@ -3,7 +3,49 @@ const router = require('express').Router();
|
|||||||
const { query } = require('../db');
|
const { query } = require('../db');
|
||||||
const { LANGS } = require('../lib/translate');
|
const { LANGS } = require('../lib/translate');
|
||||||
const { loadPairContext, computeReadiness, loadPairContent } = require('../lib/pairContent');
|
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(`(?<![A-Za-zÄÖÜäöüßÅÄÖåäö])(${esc}[a-zäöüßåö]{0,2})(?![A-Za-zÄÖÜäöüßÅÄÖåäö])`, 'iu');
|
||||||
|
// Nur außerhalb von {{…}} suchen
|
||||||
|
const segments = sentence.split(PLACEHOLDER_RE);
|
||||||
|
// split mit Capture-Groups liefert [text, label, type, uuid, text, …] → nur die Text-Segmente (Schrittweite 4)
|
||||||
|
for (let i = 0; i < segments.length; i += 4) {
|
||||||
|
const m = (segments[i] || '').match(re);
|
||||||
|
if (m) return m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ersetzt das erste Vorkommen von `title` (außerhalb von Placeholdern) durch {{match.o:objectId}}.
|
||||||
|
function wrapWordAsObject(sentence, title, objectId) {
|
||||||
|
const hit = findWordInSentence(sentence, title);
|
||||||
|
if (!hit) return null;
|
||||||
|
// Sicher ersetzen: Satz an Placeholdern entlang neu zusammensetzen
|
||||||
|
const parts = [];
|
||||||
|
let replaced = false;
|
||||||
|
const tokens = sentence.split(/(\{\{[^}]+\}\})/g);
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (!replaced && !t.startsWith('{{')) {
|
||||||
|
const idx = t.toLowerCase().indexOf(hit.toLowerCase());
|
||||||
|
if (idx >= 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
|
// POST /api/pipeline/release/:pictureId — Bild in die Pipeline geben
|
||||||
router.post('/release/:pictureId', async (req, res, next) => {
|
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'
|
WHERE op.object_id = $1 AND p.status <> 'blocked'
|
||||||
ORDER BY p.created_at`, [obj.id])).rows;
|
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)
|
// 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.content = await loadPairContent(p);
|
||||||
p.audio = {};
|
p.audio = {};
|
||||||
for (const lang of LANGS) {
|
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 });
|
const r = computeReadiness(p, ctx, lang, { skipPicturePublished: true, skipStatusChecks: true });
|
||||||
p.audio[lang] = { ready: r.ready, missing: r.missing };
|
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;
|
delete p.question_id; delete p.positive_statement_id; delete p.negative_statement_id;
|
||||||
}
|
}
|
||||||
obj.pairs = pairs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ picture, objects });
|
res.json({ picture, objects });
|
||||||
} catch (err) { next(err); }
|
} 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
|
// GET /api/pipeline/settings
|
||||||
router.get('/settings', async (req, res, next) => {
|
router.get('/settings', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user