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:
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ──────────────────
|
||||
|
||||
Reference in New Issue
Block a user