diff --git a/src/lib/generatePairs.js b/src/lib/generatePairs.js index 401485f..38f0962 100644 --- a/src/lib/generatePairs.js +++ b/src/lib/generatePairs.js @@ -39,7 +39,12 @@ async function generatePairsForObject({ imageUrl, objects, selectedObjectId, cou `Bei yes_no: mix aus answer:true und answer:false. Bei word: positive_words 1–3 passende Wörter, negative_words genau 3 falsche Wörter.\n\n` + `Regeln: Alle Sätze und Wörter auf Deutsch. Sätze müssen natürlich klingen. Keine Wiederholungen. ` + `Wörter beim type "word" sind AUSSCHLIESSLICH Nomen ("pos":"noun") oder Adjektive ("pos":"adjective") — ` + - `KEINE Verben, Pronomen, Artikel, Präpositionen oder Funktionswörter. Gib für jedes Wort das "pos"-Feld an.`; + `KEINE Verben, Pronomen, Artikel, Präpositionen oder Funktionswörter. Gib für jedes Wort das "pos"-Feld an.\n\n` + + `NOMEN-MARKUP: Markiere in ALLEN Sätzen (question, positive, negative) jedes Nomen mit ` + + `[Oberflächenform|Grundform] — die Oberflächenform ist das Wort exakt wie es im Satz steht (Beugung/Mehrzahl), ` + + `die Grundform ist Nominativ Singular ohne Artikel. Beispiel: "Die [Wolken|Wolke] schweben am [Himmel|Himmel]." ` + + `Markiere NUR Nomen — keine Verben, Adjektive, Pronomen oder Funktionswörter. ` + + `Die Wörter in positive_words/negative_words bekommen KEIN Markup.`; const res = await fetch(ANTHROPIC_API_URL, { method: 'POST', @@ -64,7 +69,59 @@ async function generatePairsForObject({ imageUrl, objects, selectedObjectId, cou if (md) raw = md[1]; const parsed = JSON.parse(raw); if (!Array.isArray(parsed.pairs)) throw new Error('Ungültiges JSON-Format von Claude (pairs fehlt)'); - return parsed.pairs.map(normalizePair).filter(Boolean); + const pairs = parsed.pairs.map(normalizePair).filter(Boolean); + for (const p of pairs) { + for (const f of ['question', 'positive', 'negative']) { + if (p[f]) p[f] = await resolveNounMarkup(p[f], objects, selectedObjectId); + } + } + return pairs; +} + +// ── Nomen-Markup → Placeholder ─────────────────────────────────────────────── +// Claude markiert Nomen als [Oberflächenform|Grundform]. Hier wird daraus: +// - {{surface.o:objectId}} wenn die Grundform ein Objekt-Wort des Bildes ist +// (Zielobjekt hat Vorrang), +// - sonst {{surface.w:wordId}} mit find-or-create des Wortes (status 'requested'). +const NOUN_MARKUP_RE = /\[([^\[\]|]+)(?:\|([^\[\]|]*))?\]/g; + +async function resolveNounMarkup(text, objects, selectedObjectId) { + // Objekt-Wort-Lookup: lemma (lowercase) → objectId, Zielobjekt zuerst + const objectByLemma = new Map(); + const ordered = [...(objects || [])].sort((a, b) => + (a.id === selectedObjectId ? -1 : 0) - (b.id === selectedObjectId ? -1 : 0)); + for (const obj of ordered) { + for (const w of obj.words || []) { + for (const t of [w.titel_de, w.titel_en]) { + const key = (t || '').trim().toLowerCase(); + if (key && !objectByLemma.has(key)) objectByLemma.set(key, obj.id); + } + } + } + + // Erst alle Markups einsammeln (Word-Erstellung ist async, replace nicht) + const matches = [...text.matchAll(NOUN_MARKUP_RE)]; + const replacements = new Map(); + for (const m of matches) { + if (replacements.has(m[0])) continue; + const surface = m[1].trim(); + const lemma = (m[2] || '').trim() || surface; + if (!surface) { replacements.set(m[0], lemma); continue; } + const objectId = objectByLemma.get(lemma.toLowerCase()) || objectByLemma.get(surface.toLowerCase()); + if (objectId) { + replacements.set(m[0], `{{${surface}.o:${objectId}}}`); + } else { + try { + const wordId = await findOrCreateWord(lemma); + replacements.set(m[0], `{{${surface}.w:${wordId}}}`); + } catch { + replacements.set(m[0], surface); // DB-Fehler → Wort unmarkiert lassen + } + } + } + let out = text; + for (const [from, to] of replacements) out = out.split(from).join(to); + return out; } // Word-Einträge können {"w":"...","pos":"..."} oder plain Strings sein. @@ -72,10 +129,11 @@ async function generatePairsForObject({ imageUrl, objects, selectedObjectId, cou function cleanWordList(list) { if (!Array.isArray(list)) return []; const out = []; + const unmark = s => s.replace(NOUN_MARKUP_RE, (_, surface) => surface.trim()); for (const item of list) { - if (typeof item === 'string') { const t = item.trim(); if (t) out.push(t); continue; } + if (typeof item === 'string') { const t = unmark(item).trim(); if (t) out.push(t); continue; } if (item && typeof item === 'object') { - const t = (item.w || item.word || item.text || '').toString().trim(); + const t = unmark((item.w || item.word || item.text || '').toString()).trim(); const pos = (item.pos || '').toString().toLowerCase(); if (t && (!pos || pos === 'noun' || pos === 'adjective')) out.push(t); } @@ -176,4 +234,4 @@ async function persistPair(p, objectId) { return pair.id; } -module.exports = { generatePairsForObject, persistPair, findOrCreateWord }; +module.exports = { generatePairsForObject, persistPair, findOrCreateWord, resolveNounMarkup }; diff --git a/src/lib/pipeline.js b/src/lib/pipeline.js index 32cf987..7b019d5 100644 --- a/src/lib/pipeline.js +++ b/src/lib/pipeline.js @@ -3,6 +3,7 @@ // d.h. ein Resume nach Crash/Redeploy überspringt bereits Erledigtes. const { query } = require('../db'); const { LANGS, fillMissingRow } = require('./translate'); +const { PLACEHOLDER_RE } = require('./placeholders'); const { translateWordGroup } = require('./pairContent'); const { generatePairsForObject, persistPair } = require('./generatePairs'); const { reviewPicturePairs } = require('./reviewPairs'); @@ -86,6 +87,31 @@ async function loadPairs(pictureId) { ORDER BY p.id`, [pictureId])).rows; } +// Word-IDs aller {{label.w:uuid}}-Placeholder in den Sätzen der Pairs. +// Diese Wörter entstehen bei der Generierung (Nomen im Satz) und hängen nicht an +// statement_words/object_words — für Übersetzung + Audio müssen sie mitgenommen werden. +async function collectPlaceholderWordIds(pairs) { + const ids = new Set(); + const scan = text => { + for (const m of String(text || '').matchAll(PLACEHOLDER_RE)) if (m[2] === 'w') ids.add(m[3]); + }; + 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))]; + if (questionIds.length) { + const r = await query( + `SELECT sentence_de, sentence_en, sentence_sv FROM questions WHERE id = ANY($1)`, [questionIds]); + r.rows.forEach(row => Object.values(row).forEach(scan)); + } + if (stmtIds.length) { + const r = await query( + `SELECT positive_sentence_de, positive_sentence_en, positive_sentence_sv, + negative_sentence_de, negative_sentence_en, negative_sentence_sv + FROM statements WHERE id = ANY($1)`, [stmtIds]); + r.rows.forEach(row => Object.values(row).forEach(scan)); + } + return ids; +} + async function runPicture(pictureId) { // Claim — nur Bilder, die in der Pipeline sind const claim = await query( @@ -153,6 +179,13 @@ async function runPicture(pictureId) { progress.translatedPairs++; await setStep(pictureId, 'translate', progress); } + // Nomen-Wörter aus Satz-Placeholdern ({{label.w:id}}) mitübersetzen + try { + for (const wid of await collectPlaceholderWordIds(pairs)) { + try { await fillMissingRow('words', wid, ['titel']); } + catch (err) { progress.translateFailures++; console.error(`Translate-Fehler bei Wort ${wid}:`, err.message); } + } + } catch (err) { console.error(`Placeholder-Wörter sammeln fehlgeschlagen:`, err.message); } // ── Step 2.5: KI-Review — alle Pairs + Bild an Sonnet zum Korrekturlesen ──── // (Rechtschreibung, Übersetzungs-Konsistenz, Plausibilität zum Bild). Korrekturen @@ -297,6 +330,8 @@ async function collectAudioUnits(pictureId, pairs) { JOIN object_pictures op ON op.object_id = ow.object_id WHERE op.picture_id = $1`, [pictureId]); ow.rows.forEach(x => wordIds.add(x.word_id)); + // + Nomen-Wörter aus Satz-Placeholdern ({{label.w:id}}) + (await collectPlaceholderWordIds(pairs)).forEach(id => wordIds.add(id)); const sources = []; if (questionIds.length) { diff --git a/src/lib/placeholders.js b/src/lib/placeholders.js index 9ff0e8b..6448b70 100644 --- a/src/lib/placeholders.js +++ b/src/lib/placeholders.js @@ -5,14 +5,25 @@ const PLACEHOLDER_RE = /\{\{([^.{}]+)\.(w|o):([0-9a-f-]{36})\}\}/g; // Legacy-Form ohne Label: {{uuid}} — sollte migriert sein, defensiv trotzdem entfernen. const LEGACY_PLACEHOLDER_RE = /\{\{\s*[0-9a-f-]{36}\s*\}\}/g; +// Schutz-Token während Übersetzung/Review: ⟦PHn:label⟧. Darf nie in der DB landen — +// falls doch (Claude-Halluzination), wird er überall defensiv zum Label aufgelöst. +const TOKEN_RE = /⟦(PH\d+):([^⟧]*)⟧/g; + +// Entfernt geleakte ⟦PHn:label⟧-Tokens aus einem Text → nur das Label bleibt. +function stripLeakedTokens(text) { + if (!text) return text; + return String(text).replace(TOKEN_RE, (_, _key, label) => label.trim()); +} + // Macht aus "Ist das ein {{Apfel.w:1234-…}}?" → "Ist das ein Apfel?" (für TTS/Anzeige). function resolvePlaceholdersToLabels(text) { if (!text) return ''; return String(text) .replace(PLACEHOLDER_RE, (_, label) => label) .replace(LEGACY_PLACEHOLDER_RE, '') + .replace(TOKEN_RE, (_, _key, label) => label.trim()) .replace(/\s{2,}/g, ' ') .trim(); } -module.exports = { PLACEHOLDER_RE, resolvePlaceholdersToLabels }; +module.exports = { PLACEHOLDER_RE, TOKEN_RE, stripLeakedTokens, resolvePlaceholdersToLabels }; diff --git a/src/lib/reviewPairs.js b/src/lib/reviewPairs.js index 3edfbc6..b12ae9e 100644 --- a/src/lib/reviewPairs.js +++ b/src/lib/reviewPairs.js @@ -5,18 +5,19 @@ // damit Step 3 sie mit dem neuen Text neu erzeugt. const { query } = require('../db'); const { callClaude, tokenize, LANGS } = require('./translate'); +const { TOKEN_RE, stripLeakedTokens } = require('./placeholders'); const { deleteFile, keyFromUrl } = require('../s3'); const REVIEW_MODEL = process.env.REVIEW_MODEL || process.env.TRANSLATE_MODEL || 'claude-sonnet-4-5'; const BATCH_SIZE = 15; // Pairs pro Claude-Call (Bild wird je Batch mitgeschickt) -const TOKEN_RE = /⟦(PH\d+):([^⟧]*)⟧/g; - // Refs der Form "q::sentence_de" — kompakt im Prompt, eindeutig in der itemMap. const TABLE_PREFIX = { questions: 'q', statements: 's', words: 'w' }; function makeItem(table, id, field, lang, text) { - const { tokenized, tokens } = tokenize(text); + // Geleakte ⟦PHn:…⟧-Reste im Quelltext zuerst auflösen — sonst sieht Claude sie als + // echte Tokens und die Token-Count-Validierung verhindert jede Korrektur der Zeile. + const { tokenized, tokens } = tokenize(stripLeakedTokens(text)); return { ref: `${TABLE_PREFIX[table]}:${id}:${field}_${lang}`, table, id, column: `${field}_${lang}`, field, lang, diff --git a/src/lib/translate.js b/src/lib/translate.js index fabc510..b2e7af8 100644 --- a/src/lib/translate.js +++ b/src/lib/translate.js @@ -18,7 +18,7 @@ const TRANSLATE_CONFIG = { // ── Placeholder-Schutz ──────────────────────────────────────────────────────── // Format im Quelltext: {{label.w:uuid}} oder {{label.o:uuid}} -const { PLACEHOLDER_RE } = require('./placeholders'); +const { PLACEHOLDER_RE, stripLeakedTokens } = require('./placeholders'); // Sätze für Claude vorbereiten: jedes Placeholder durch ⟦PHn:label⟧-Token ersetzen. // Token-Format ist absichtlich exotisch, damit Claude es nicht versehentlich ändert. @@ -98,17 +98,24 @@ async function translateText({ text, from, to }) { if (!text || !text.trim()) return ''; const { tokenized, tokens } = tokenize(text); const system = 'Du bist ein professioneller Übersetzer. Antworte AUSSCHLIESSLICH mit gültigem JSON, ohne Markdown, ohne Erklärungen.'; - const user = `Übersetze diesen Text von ${LANG_LABEL[from] || from} nach ${LANG_LABEL[to] || to}.\n\n` + - `WICHTIG: Tokens der Form ⟦PHn:wort⟧ sind Platzhalter. Übersetze NUR das Wort innerhalb des Tokens, ` + - `behalte das Token-Format exakt bei (⟦PHn:übersetztesWort⟧). Passe die Beugung des Wortes an den umgebenden Satz an ` + - `(Mehrzahl/Kasus). Die Token-Reihenfolge im Satz darfst du frei wählen wie es natürlich klingt.\n\n` + - `Quelltext:\n${tokenized}\n\n` + - `Antwort-Format:\n{"translated":"...","labels":{${tokens.map(t => `"${t.key}":"<übersetztes Wort>"`).join(',')}}}`; + // Token-Erklärung NUR wenn der Text wirklich Tokens enthält — sonst halluziniert + // Claude gelegentlich ⟦PHn:…⟧-Tokens in die Übersetzung hinein. + const user = tokens.length + ? `Übersetze diesen Text von ${LANG_LABEL[from] || from} nach ${LANG_LABEL[to] || to}.\n\n` + + `WICHTIG: Tokens der Form ⟦PHn:wort⟧ sind Platzhalter. Übersetze NUR das Wort innerhalb des Tokens, ` + + `behalte das Token-Format exakt bei (⟦PHn:übersetztesWort⟧). Passe die Beugung des Wortes an den umgebenden Satz an ` + + `(Mehrzahl/Kasus). Die Token-Reihenfolge im Satz darfst du frei wählen wie es natürlich klingt.\n\n` + + `Quelltext:\n${tokenized}\n\n` + + `Antwort-Format:\n{"translated":"...","labels":{${tokens.map(t => `"${t.key}":"<übersetztes Wort>"`).join(',')}}}` + : `Übersetze diesen Text von ${LANG_LABEL[from] || from} nach ${LANG_LABEL[to] || to}.\n\n` + + `Quelltext:\n${tokenized}\n\n` + + `Antwort-Format:\n{"translated":"..."}`; const data = await callClaude({ system, user }); if (typeof data.translated !== 'string') throw new Error('Ungültiges JSON: translated fehlt'); const { text: detok } = detokenize(data.translated, tokens, data.labels || {}); - return detok; + // Defensiv: von Claude erfundene/umnummerierte Tokens dürfen nie in die DB + return stripLeakedTokens(detok); } // ── Auto-Status für Wörter (Spiegel zum Trigger in words.js) ────────────────── diff --git a/src/routes/pipeline.js b/src/routes/pipeline.js index a2aba70..771c6b2 100644 --- a/src/routes/pipeline.js +++ b/src/routes/pipeline.js @@ -5,7 +5,8 @@ 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'); +const { PLACEHOLDER_RE, TOKEN_RE, stripLeakedTokens } = require('../lib/placeholders'); +const { invalidateAudio } = require('../lib/reviewPairs'); // ── Objekt-Wort-Erkennung in Sätzen (für die manuelle Zuweisung beim Review) ── @@ -241,6 +242,74 @@ router.post('/picture/:id/audio-fill', async (req, res, next) => { } catch (err) { next(err); } }); +// POST /api/pipeline/repair-tokens — Datenreparatur: geleakte ⟦PHn:…⟧-Tokens +// (Claude-Halluzination beim Übersetzen, vor dem Fix) aus allen Sätzen entfernen. +// Betroffene Audios werden gelöscht und direkt mit dem reparierten Text neu erzeugt. +router.post('/repair-tokens', async (req, res, next) => { + try { + const hasToken = v => { TOKEN_RE.lastIndex = 0; return TOKEN_RE.test(v || ''); }; + const result = { cells_fixed: 0, audios_regenerated: 0, audios_failed: 0, details: [] }; + const targets = [ + { table: 'questions', fields: ['sentence'] }, + { table: 'statements', fields: ['positive_sentence', 'negative_sentence'] }, + { table: 'words', fields: ['titel'] }, + ]; + + // 1) Textzellen säubern + zugehörige Audios löschen & neu generieren + for (const t of targets) { + const cols = t.fields.flatMap(f => LANGS.map(l => `${f}_${l}`)); + const r = await query( + `SELECT id, ${cols.join(', ')} FROM ${t.table} + WHERE ${cols.map(c => `${c} LIKE '%⟦PH%'`).join(' OR ')}`); + for (const row of r.rows) { + for (const f of t.fields) { + for (const l of LANGS) { + const col = `${f}_${l}`; + if (!hasToken(row[col])) continue; + const fixed = stripLeakedTokens(row[col]).replace(/\s{2,}/g, ' ').trim(); + await query(`UPDATE ${t.table} SET ${col} = $1 WHERE id = $2`, [fixed, row.id]); + await invalidateAudio(t.table, row.id, f, l); + result.cells_fixed++; + const detail = { table: t.table, id: row.id, column: col, fixed }; + try { + await generateWithBackoff({ text: fixed, language: l, source_table: t.table, source_id: row.id, source_field: f }); + result.audios_regenerated++; + } catch (err) { + result.audios_failed++; + detail.audio_error = describeError(err); + } + result.details.push(detail); + } + } + } + } + + // 2) Audios, deren vertonter Text noch Tokens enthält (Zelle ggf. schon anderweitig + // korrigiert) → löschen und mit dem aktuellen Zellen-Text neu erzeugen + const audios = await query( + `SELECT id, source_table, source_id, source_field, language FROM audios WHERE text LIKE '%⟦PH%'`); + for (const a of audios.rows) { + const r = await query( + `SELECT ${a.source_field}_${a.language} AS text FROM ${a.source_table} WHERE id = $1`, [a.source_id]); + const text = (r.rows[0]?.text || '').trim(); + await invalidateAudio(a.source_table, a.source_id, a.source_field, a.language); + const detail = { table: 'audios', id: a.id, column: `${a.source_field}_${a.language}` }; + if (text) { + try { + await generateWithBackoff({ text, language: a.language, source_table: a.source_table, source_id: a.source_id, source_field: a.source_field }); + result.audios_regenerated++; + } catch (err) { + result.audios_failed++; + detail.audio_error = describeError(err); + } + } + result.details.push(detail); + } + + res.json(result); + } catch (err) { next(err); } +}); + // GET /api/pipeline/settings router.get('/settings', async (req, res, next) => { try {