From e44d896f9e4507d480c505e19150672a6bbda0f7 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 13 Jun 2026 19:56:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Objekt-Token-Cleanup=20+=20sch=C3=A4rfe?= =?UTF-8?q?rer=20LLM-Prompt=20(Kopf-Kompositum=20vs=20Bestimmungswort)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lib/objectTagging.js | 34 +++++++++++- src/lib/pipeline.js | 111 +++++++++++++++++++++++++++++++++++---- src/routes/pipeline.js | 7 ++- 3 files changed, 140 insertions(+), 12 deletions(-) diff --git a/src/lib/objectTagging.js b/src/lib/objectTagging.js index 6981db3..8d9dc6c 100644 --- a/src/lib/objectTagging.js +++ b/src/lib/objectTagging.js @@ -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, +}; diff --git a/src/lib/pipeline.js b/src/lib/pipeline.js index 1a63d7b..3b19930 100644 --- a/src/lib/pipeline.js +++ b/src/lib/pipeline.js @@ -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; diff --git a/src/routes/pipeline.js b/src/routes/pipeline.js index c585fdd..4376a7d 100644 --- a/src/routes/pipeline.js +++ b/src/routes/pipeline.js @@ -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); } });