feat: Placeholder in der Auto-Generierung + Token-Leak-Fix

- Pair-Generierung markiert Nomen per [surface|lemma]-Markup und löst sie zu
  {{label.o:objectId}} / {{label.w:wordId}} auf (Words werden auto-erstellt)
- Pipeline übersetzt + vertont Placeholder-Wörter aus den Sätzen mit
- translateText halluziniert keine ⟦PHn⟧-Tokens mehr (kein Token-Prompt ohne
  Tokens, defensives Strippen); TTS/Review lösen geleakte Tokens auf
- POST /api/pipeline/repair-tokens repariert bestehende Sätze + Audios

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 22:43:39 +02:00
parent 25d1e89446
commit 895d7c56a1
6 changed files with 199 additions and 18 deletions

View File

@@ -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 13 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 };

View File

@@ -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) {

View File

@@ -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 };

View File

@@ -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:<uuid>: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,

View File

@@ -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) ──────────────────

View File

@@ -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 {