feat: Objekt-Token-Cleanup + schärferer LLM-Prompt (Kopf-Kompositum vs Bestimmungswort)

Der LLM-Fallback hatte Objektwörter auch dann verlinkt, wenn sie nur
Bestimmungswort eines anderen Dings waren (z.B. "jordgubbsfältet"/Erdbeerfeld
als Erdbeere). Regel jetzt explizit:
- behalten: Wort, Beugung/Mehrzahl/bestimmte Form, Kopf-Kompositum
  ("Landschildkröte"=Schildkröte), Synonym ("Stiefel"/"Lederstiefel"=Schuh)
- entfernen: Objektwort nur als Bestimmungswort ("Erdbeerfeld" != Erdbeere)

- locateSurfaceLLM-Prompt um diese Regel + Beispiele geschärft (verhindert
  künftiges Fehl-Tagging).
- Neuer cleanup-Modus: POST /api/pipeline/retag-objects {"cleanup":true}
  prüft bestehende Objekt-Tokens per LLM und entfernt die falschen. Eindeutig
  gute Formen (exakt/Lemma+Endung) werden ohne LLM behalten.
- Helfer in objectTagging.js: objectTokensInSentence, isSimpleObjectForm, untagToken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 19:56:13 +02:00
parent 434839e1d4
commit e44d896f9e
3 changed files with 140 additions and 12 deletions

View File

@@ -148,4 +148,36 @@ function objectIdsInSentence(sentence) {
return ids;
}
module.exports = { tagObjectWords, wrapSurface, buildLemmas, objectIdsInSentence };
// Alle OBJEKT-Tokens eines Satzes als { full, label, oid }.
const OBJ_TOKEN_RE = /\{\{([^.{}]+)\.o:([0-9a-f-]{36})\}\}/g;
function objectTokensInSentence(sentence) {
const out = [];
for (const m of String(sentence || '').matchAll(OBJ_TOKEN_RE)) out.push({ full: m[0], label: m[1], oid: m[2] });
return out;
}
// Ist `label` eine SICHER gute Form des Objekts `oid` in `lang`? (Exakt oder Lemma+reguläre
// Endung.) Solche Tokens müssen für die Cleanup-Prüfung nicht ans LLM sie sind eindeutig ok.
function isSimpleObjectForm(label, lang, objects, oid) {
const o = (objects || []).find(x => x.id === oid);
if (!o) return false;
const L = (label || '').toLowerCase();
const sfx = SUFFIXES[lang] || [];
for (const w of o.words || []) {
const lemma = (w[`titel_${lang}`] || '').trim().toLowerCase();
if (!lemma) continue;
if (L === lemma) return true;
if (sfx.some(s => L === lemma + s)) return true;
}
return false;
}
// Entfernt ein bestimmtes Objekt-Token (alle Vorkommen) → nur das Label bleibt stehen.
function untagToken(sentence, full, label) {
return String(sentence || '').split(full).join(label);
}
module.exports = {
tagObjectWords, wrapSurface, buildLemmas, objectIdsInSentence,
objectTokensInSentence, isSimpleObjectForm, untagToken,
};

View File

@@ -4,7 +4,8 @@
const { query } = require('../db');
const { LANGS, fillMissingRow, callClaude } = require('./translate');
const { PLACEHOLDER_RE } = require('./placeholders');
const { tagObjectWords, wrapSurface, objectIdsInSentence } = require('./objectTagging');
const { tagObjectWords, wrapSurface, objectIdsInSentence,
objectTokensInSentence, isSimpleObjectForm, untagToken } = require('./objectTagging');
const { translateWordGroup } = require('./pairContent');
const { generatePairsForObject, persistPair } = require('./generatePairs');
const { reviewPicturePairs } = require('./reviewPairs');
@@ -99,13 +100,21 @@ function pairSentenceFields(p) {
}
// LLM-Fallback: exakte (gebeugte) Oberflächenform eines Objektworts in einem Satz finden.
// WICHTIG: nur zurückgeben, wenn das Wort das Objekt SELBST bezeichnet (Wort, Beugung, Mehrzahl,
// Kopf-Kompositum wie „Landschildkröte" für „Schildkröte", oder Synonym wie „Stiefel" für
// „Schuh"). NICHT, wenn das Objektwort nur BESTIMMUNGSWORT eines anderen Dings ist
// (z.B. „Erdbeerfeld"/„Erdbeerpflanze" ≠ Erdbeere).
async function locateSurfaceLLM(sentence, label) {
try {
const data = await callClaude({
system: 'Du findest die exakte Oberflächenform eines Wortes in einem Satz. Antworte AUSSCHLIESSLICH mit gültigem JSON.',
user: `Satz: "${sentence}"\nGesuchtes Wort (Grundform/Bedeutung): "${label}"\n\n` +
`Gib die EXAKTE Zeichenkette zurück, genau so wie das Wort (ggf. gebeugt / bestimmte Form / Plural) ` +
`im Satz vorkommt. Kommt es NICHT vor: null.\nFormat: {"surface":"…"|null}`,
system: 'Du findest die Oberflächenform eines Objektworts in einem Satz. Antworte AUSSCHLIESSLICH mit gültigem JSON.',
user: `Satz: "${sentence}"\nObjekt (Grundform/Bedeutung): "${label}"\n\n` +
`Gib die EXAKTE Zeichenkette zurück, mit der dieses Objekt im Satz benannt ist — als Wort, ` +
`Beugung/Mehrzahl/bestimmte Form, Kopf-Kompositum (Objektwort ist das GRUNDWORT, z.B. ` +
`"Landschildkröte" für "Schildkröte") oder Synonym (z.B. "Stiefel"/"Lederstiefel" für "Schuh").\n` +
`Gib null zurück, wenn das Objekt NICHT vorkommt ODER nur als BESTIMMUNGSWORT eines anderen ` +
`Dings (z.B. "Erdbeerfeld"/"Erdbeerpflanze" bezeichnet Feld/Pflanze, NICHT die Erdbeere).\n` +
`Format: {"surface":"…"|null}`,
maxTokens: 80,
});
const s = data && typeof data.surface === 'string' ? data.surface.trim() : null;
@@ -115,6 +124,26 @@ async function locateSurfaceLLM(sentence, label) {
} catch { return null; }
}
// LLM-Prüfung für den Cleanup: bezeichnet das markierte `label` wirklich das Objekt `objWord`?
// true ⇒ behalten (Wort/Beugung/Kopf-Kompositum/Synonym), false ⇒ Token entfernen
// (Bestimmungswort eines anderen Dings). Bei Fehler/Unklarheit: behalten (konservativ).
async function denotesObjectLLM(sentence, label, objWord) {
try {
const data = await callClaude({
system: 'Du beurteilst, ob ein markiertes Wort wirklich das genannte Objekt bezeichnet. Antworte AUSSCHLIESSLICH mit gültigem JSON.',
user: `Objekt: "${objWord}"\nSatz: "${sentence}"\nMarkiertes Wort: "${label}"\n\n` +
`Bezeichnet "${label}" das Objekt "${objWord}" SELBST? JA bei: dem Wort, einer Beugung/` +
`Mehrzahl/bestimmten Form, einem Kompositum mit "${objWord}" als GRUNDWORT (z.B. ` +
`"Landschildkröte" für "Schildkröte"), oder einem Synonym (z.B. "Stiefel"/"Lederstiefel" ` +
`für "Schuh"). NEIN, wenn "${objWord}" nur BESTIMMUNGSWORT eines ANDEREN Dings ist (z.B. ` +
`"Erdbeerfeld"/"Erdbeerpflanze" ist ein Feld/eine Pflanze, NICHT die Erdbeere).\n` +
`Format: {"denotes": true|false}`,
maxTokens: 40,
});
return data && typeof data.denotes === 'boolean' ? data.denotes : true;
} catch { return true; }
}
// Tokenisiert OBJEKT-Wörter in den Sätzen EINES Pairs nach.
// Deterministisch (tagObjectWords); optional Hybrid-LLM-Fallback für gebeugte Formen, die
// deterministisch nicht erkannt wurden aber NUR für Objekte, die in einer anderen Sprache
@@ -192,12 +221,73 @@ async function retagPair(p, objects, { dryRun = false, useLLM = false } = {}) {
return changes;
}
// Cleanup EINES Pairs: entfernt OBJEKT-Tokens, deren Label das Objekt nicht wirklich bezeichnet
// (Bestimmungswort eines anderen Dings, z.B. „Erdbeerfeld" als Erdbeere). Eindeutig gute Formen
// (exakt / Lemma+Endung) werden ohne LLM behalten; nur die unklaren Tokens gehen ans LLM.
async function cleanPair(p, objects, { dryRun = false } = {}) {
const fields = pairSentenceFields(p);
if (!fields.length) return [];
const byRow = new Map();
for (const f of fields) {
const k = `${f.table}|${f.id}`;
if (!byRow.has(k)) byRow.set(k, { table: f.table, id: f.id, cols: new Set() });
byRow.get(k).cols.add(f.col);
}
const text = {};
for (const { table, id, cols } of byRow.values()) {
const colList = [...cols];
const row = (await query(`SELECT ${colList.join(', ')} FROM ${table} WHERE id = $1`, [id])).rows[0] || {};
for (const col of colList) text[`${table}|${id}|${col}`] = row[col] || '';
}
const key = f => `${f.table}|${f.id}|${f.col}`;
const labelOf = (oid, lang) => {
const o = objects.find(x => x.id === oid);
for (const w of o?.words || []) if ((w[`titel_${lang}`] || '').trim()) return w[`titel_${lang}`].trim();
return null;
};
const cleaned = {};
for (const f of fields) {
let cur = text[key(f)];
cleaned[key(f)] = cur;
if (!cur || !cur.trim()) continue;
for (const tok of objectTokensInSentence(cur)) {
if (isSimpleObjectForm(tok.label, f.lang, objects, tok.oid)) continue; // eindeutig ok
const objWord = labelOf(tok.oid, f.lang);
if (!objWord) continue; // unbekannt → unangetastet lassen
const ok = await denotesObjectLLM(cur, tok.label, objWord);
if (!ok) { cur = untagToken(cur, tok.full, tok.label); cleaned[key(f)] = cur; }
}
}
const changes = [];
for (const { table, id, cols } of byRow.values()) {
const set = {};
for (const col of cols) {
const k = `${table}|${id}|${col}`;
if (cleaned[k] !== text[k]) {
set[col] = cleaned[k];
changes.push({ table, id, col, lang: col.slice(-2), before: text[k], after: cleaned[k] });
}
}
const cells = Object.keys(set);
if (!dryRun && cells.length) {
await query(
`UPDATE ${table} SET ${cells.map((c, i) => `${c} = $${i + 1}`).join(', ')} WHERE id = $${cells.length + 1}`,
[...cells.map(c => set[c]), id]);
}
}
return changes;
}
// Backfill/Retag über ein Bild oder alle Bilder. Gibt eine Zusammenfassung zurück.
async function retagObjects({ pictureId = null, dryRun = false, useLLM = false } = {}) {
// `cleanup:true` ⇒ statt zu taggen werden falsch getokte Objekt-Wörter (Bestimmungswort eines
// anderen Dings) per LLM-Prüfung entfernt.
async function retagObjects({ pictureId = null, dryRun = false, useLLM = false, cleanup = false } = {}) {
const picIds = pictureId
? [pictureId]
: (await query(`SELECT id FROM pictures ORDER BY created_at`)).rows.map(r => r.id);
const report = { pictures: 0, pairs: 0, changedPairs: 0, changedFields: 0, dryRun, useLLM, samples: [] };
const report = { pictures: 0, pairs: 0, changedPairs: 0, changedFields: 0, dryRun, useLLM, cleanup, samples: [] };
for (const pid of picIds) {
const objects = await loadObjects(pid);
if (!objects.length) continue;
@@ -206,8 +296,11 @@ async function retagObjects({ pictureId = null, dryRun = false, useLLM = false }
for (const p of pairs) {
report.pairs++;
let changes = [];
try { changes = await retagPair(p, objects, { dryRun, useLLM }); }
catch (err) { console.error(`Retag-Fehler bei Pair ${p.id}:`, err.message); continue; }
try {
changes = cleanup
? await cleanPair(p, objects, { dryRun })
: await retagPair(p, objects, { dryRun, useLLM });
} catch (err) { console.error(`Retag-Fehler bei Pair ${p.id}:`, err.message); continue; }
if (changes.length) {
report.changedPairs++;
report.changedFields += changes.length;

View File

@@ -312,18 +312,21 @@ router.post('/repair-tokens', async (req, res, next) => {
// POST /api/pipeline/retag-objects — Backfill: Objekt-Wörter in bestehenden Sätzen
// nachtokenisieren (deterministisch + optional Hybrid-LLM-Fallback für gebeugte Formen).
// Body: { picture_id?, dry_run?, use_llm? }. Ohne picture_id über ALLE Bilder.
// Body: { picture_id?, dry_run?, use_llm?, cleanup? }. Ohne picture_id über ALLE Bilder.
// cleanup:true ⇒ statt taggen werden falsch getokte Objekt-Wörter (Objektwort nur als
// Bestimmungswort eines anderen Dings, z.B. „Erdbeerfeld") per LLM-Prüfung wieder entfernt.
// Ändert nur die Satz-Textfelder; Audio/Alignment bleiben gültig (Sprechtext unverändert).
router.post('/retag-objects', async (req, res, next) => {
try {
const pictureId = req.body?.picture_id || null;
const dryRun = !!req.body?.dry_run;
const useLLM = !!req.body?.use_llm;
const cleanup = !!req.body?.cleanup;
if (pictureId) {
const pr = await query(`SELECT id FROM pictures WHERE id = $1`, [pictureId]);
if (!pr.rows.length) return res.status(404).json({ error: 'Bild nicht gefunden' });
}
const report = await retagObjects({ pictureId, dryRun, useLLM });
const report = await retagObjects({ pictureId, dryRun, useLLM, cleanup });
res.json(report);
} catch (err) { next(err); }
});