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