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` +
|
`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. ` +
|
`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") — ` +
|
`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, {
|
const res = await fetch(ANTHROPIC_API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -64,7 +69,59 @@ async function generatePairsForObject({ imageUrl, objects, selectedObjectId, cou
|
|||||||
if (md) raw = md[1];
|
if (md) raw = md[1];
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (!Array.isArray(parsed.pairs)) throw new Error('Ungültiges JSON-Format von Claude (pairs fehlt)');
|
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.
|
// 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) {
|
function cleanWordList(list) {
|
||||||
if (!Array.isArray(list)) return [];
|
if (!Array.isArray(list)) return [];
|
||||||
const out = [];
|
const out = [];
|
||||||
|
const unmark = s => s.replace(NOUN_MARKUP_RE, (_, surface) => surface.trim());
|
||||||
for (const item of list) {
|
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') {
|
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();
|
const pos = (item.pos || '').toString().toLowerCase();
|
||||||
if (t && (!pos || pos === 'noun' || pos === 'adjective')) out.push(t);
|
if (t && (!pos || pos === 'noun' || pos === 'adjective')) out.push(t);
|
||||||
}
|
}
|
||||||
@@ -176,4 +234,4 @@ async function persistPair(p, objectId) {
|
|||||||
return pair.id;
|
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.
|
// d.h. ein Resume nach Crash/Redeploy überspringt bereits Erledigtes.
|
||||||
const { query } = require('../db');
|
const { query } = require('../db');
|
||||||
const { LANGS, fillMissingRow } = require('./translate');
|
const { LANGS, fillMissingRow } = require('./translate');
|
||||||
|
const { PLACEHOLDER_RE } = require('./placeholders');
|
||||||
const { translateWordGroup } = require('./pairContent');
|
const { translateWordGroup } = require('./pairContent');
|
||||||
const { generatePairsForObject, persistPair } = require('./generatePairs');
|
const { generatePairsForObject, persistPair } = require('./generatePairs');
|
||||||
const { reviewPicturePairs } = require('./reviewPairs');
|
const { reviewPicturePairs } = require('./reviewPairs');
|
||||||
@@ -86,6 +87,31 @@ async function loadPairs(pictureId) {
|
|||||||
ORDER BY p.id`, [pictureId])).rows;
|
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) {
|
async function runPicture(pictureId) {
|
||||||
// Claim — nur Bilder, die in der Pipeline sind
|
// Claim — nur Bilder, die in der Pipeline sind
|
||||||
const claim = await query(
|
const claim = await query(
|
||||||
@@ -153,6 +179,13 @@ async function runPicture(pictureId) {
|
|||||||
progress.translatedPairs++;
|
progress.translatedPairs++;
|
||||||
await setStep(pictureId, 'translate', progress);
|
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 ────
|
// ── Step 2.5: KI-Review — alle Pairs + Bild an Sonnet zum Korrekturlesen ────
|
||||||
// (Rechtschreibung, Übersetzungs-Konsistenz, Plausibilität zum Bild). Korrekturen
|
// (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
|
JOIN object_pictures op ON op.object_id = ow.object_id
|
||||||
WHERE op.picture_id = $1`, [pictureId]);
|
WHERE op.picture_id = $1`, [pictureId]);
|
||||||
ow.rows.forEach(x => wordIds.add(x.word_id));
|
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 = [];
|
const sources = [];
|
||||||
if (questionIds.length) {
|
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.
|
// Legacy-Form ohne Label: {{uuid}} — sollte migriert sein, defensiv trotzdem entfernen.
|
||||||
const LEGACY_PLACEHOLDER_RE = /\{\{\s*[0-9a-f-]{36}\s*\}\}/g;
|
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).
|
// Macht aus "Ist das ein {{Apfel.w:1234-…}}?" → "Ist das ein Apfel?" (für TTS/Anzeige).
|
||||||
function resolvePlaceholdersToLabels(text) {
|
function resolvePlaceholdersToLabels(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
return String(text)
|
return String(text)
|
||||||
.replace(PLACEHOLDER_RE, (_, label) => label)
|
.replace(PLACEHOLDER_RE, (_, label) => label)
|
||||||
.replace(LEGACY_PLACEHOLDER_RE, '')
|
.replace(LEGACY_PLACEHOLDER_RE, '')
|
||||||
|
.replace(TOKEN_RE, (_, _key, label) => label.trim())
|
||||||
.replace(/\s{2,}/g, ' ')
|
.replace(/\s{2,}/g, ' ')
|
||||||
.trim();
|
.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.
|
// damit Step 3 sie mit dem neuen Text neu erzeugt.
|
||||||
const { query } = require('../db');
|
const { query } = require('../db');
|
||||||
const { callClaude, tokenize, LANGS } = require('./translate');
|
const { callClaude, tokenize, LANGS } = require('./translate');
|
||||||
|
const { TOKEN_RE, stripLeakedTokens } = require('./placeholders');
|
||||||
const { deleteFile, keyFromUrl } = require('../s3');
|
const { deleteFile, keyFromUrl } = require('../s3');
|
||||||
|
|
||||||
const REVIEW_MODEL = process.env.REVIEW_MODEL || process.env.TRANSLATE_MODEL || 'claude-sonnet-4-5';
|
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 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.
|
// Refs der Form "q:<uuid>:sentence_de" — kompakt im Prompt, eindeutig in der itemMap.
|
||||||
const TABLE_PREFIX = { questions: 'q', statements: 's', words: 'w' };
|
const TABLE_PREFIX = { questions: 'q', statements: 's', words: 'w' };
|
||||||
|
|
||||||
function makeItem(table, id, field, lang, text) {
|
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 {
|
return {
|
||||||
ref: `${TABLE_PREFIX[table]}:${id}:${field}_${lang}`,
|
ref: `${TABLE_PREFIX[table]}:${id}:${field}_${lang}`,
|
||||||
table, id, column: `${field}_${lang}`, field, lang,
|
table, id, column: `${field}_${lang}`, field, lang,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const TRANSLATE_CONFIG = {
|
|||||||
|
|
||||||
// ── Placeholder-Schutz ────────────────────────────────────────────────────────
|
// ── Placeholder-Schutz ────────────────────────────────────────────────────────
|
||||||
// Format im Quelltext: {{label.w:uuid}} oder {{label.o:uuid}}
|
// 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.
|
// Sätze für Claude vorbereiten: jedes Placeholder durch ⟦PHn:label⟧-Token ersetzen.
|
||||||
// Token-Format ist absichtlich exotisch, damit Claude es nicht versehentlich ändert.
|
// 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 '';
|
if (!text || !text.trim()) return '';
|
||||||
const { tokenized, tokens } = tokenize(text);
|
const { tokenized, tokens } = tokenize(text);
|
||||||
const system = 'Du bist ein professioneller Übersetzer. Antworte AUSSCHLIESSLICH mit gültigem JSON, ohne Markdown, ohne Erklärungen.';
|
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` +
|
// 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, ` +
|
`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 ` +
|
`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` +
|
`(Mehrzahl/Kasus). Die Token-Reihenfolge im Satz darfst du frei wählen wie es natürlich klingt.\n\n` +
|
||||||
`Quelltext:\n${tokenized}\n\n` +
|
`Quelltext:\n${tokenized}\n\n` +
|
||||||
`Antwort-Format:\n{"translated":"...","labels":{${tokens.map(t => `"${t.key}":"<übersetztes Wort>"`).join(',')}}}`;
|
`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 });
|
const data = await callClaude({ system, user });
|
||||||
if (typeof data.translated !== 'string') throw new Error('Ungültiges JSON: translated fehlt');
|
if (typeof data.translated !== 'string') throw new Error('Ungültiges JSON: translated fehlt');
|
||||||
const { text: detok } = detokenize(data.translated, tokens, data.labels || {});
|
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) ──────────────────
|
// ── Auto-Status für Wörter (Spiegel zum Trigger in words.js) ──────────────────
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ const { LANGS } = require('../lib/translate');
|
|||||||
const { loadPairContext, computeReadiness, loadPairContent } = require('../lib/pairContent');
|
const { loadPairContext, computeReadiness, loadPairContent } = require('../lib/pairContent');
|
||||||
const { enqueue, loadPairs, collectAudioUnits, generateWithBackoff, translatePair } = require('../lib/pipeline');
|
const { enqueue, loadPairs, collectAudioUnits, generateWithBackoff, translatePair } = require('../lib/pipeline');
|
||||||
const { describeError } = require('./audios');
|
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) ──
|
// ── 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); }
|
} 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
|
// 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