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;
|
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 { query } = require('../db');
|
||||||
const { LANGS, fillMissingRow, callClaude } = require('./translate');
|
const { LANGS, fillMissingRow, callClaude } = require('./translate');
|
||||||
const { PLACEHOLDER_RE } = require('./placeholders');
|
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 { translateWordGroup } = require('./pairContent');
|
||||||
const { generatePairsForObject, persistPair } = require('./generatePairs');
|
const { generatePairsForObject, persistPair } = require('./generatePairs');
|
||||||
const { reviewPicturePairs } = require('./reviewPairs');
|
const { reviewPicturePairs } = require('./reviewPairs');
|
||||||
@@ -99,13 +100,21 @@ function pairSentenceFields(p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LLM-Fallback: exakte (gebeugte) Oberflächenform eines Objektworts in einem Satz finden.
|
// 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) {
|
async function locateSurfaceLLM(sentence, label) {
|
||||||
try {
|
try {
|
||||||
const data = await callClaude({
|
const data = await callClaude({
|
||||||
system: 'Du findest die exakte Oberflächenform eines Wortes in einem Satz. Antworte AUSSCHLIESSLICH mit gültigem JSON.',
|
system: 'Du findest die Oberflächenform eines Objektworts in einem Satz. Antworte AUSSCHLIESSLICH mit gültigem JSON.',
|
||||||
user: `Satz: "${sentence}"\nGesuchtes Wort (Grundform/Bedeutung): "${label}"\n\n` +
|
user: `Satz: "${sentence}"\nObjekt (Grundform/Bedeutung): "${label}"\n\n` +
|
||||||
`Gib die EXAKTE Zeichenkette zurück, genau so wie das Wort (ggf. gebeugt / bestimmte Form / Plural) ` +
|
`Gib die EXAKTE Zeichenkette zurück, mit der dieses Objekt im Satz benannt ist — als Wort, ` +
|
||||||
`im Satz vorkommt. Kommt es NICHT vor: null.\nFormat: {"surface":"…"|null}`,
|
`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,
|
maxTokens: 80,
|
||||||
});
|
});
|
||||||
const s = data && typeof data.surface === 'string' ? data.surface.trim() : null;
|
const s = data && typeof data.surface === 'string' ? data.surface.trim() : null;
|
||||||
@@ -115,6 +124,26 @@ async function locateSurfaceLLM(sentence, label) {
|
|||||||
} catch { return null; }
|
} 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.
|
// Tokenisiert OBJEKT-Wörter in den Sätzen EINES Pairs nach.
|
||||||
// Deterministisch (tagObjectWords); optional Hybrid-LLM-Fallback für gebeugte Formen, die
|
// 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
|
// 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;
|
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.
|
// 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
|
const picIds = pictureId
|
||||||
? [pictureId]
|
? [pictureId]
|
||||||
: (await query(`SELECT id FROM pictures ORDER BY created_at`)).rows.map(r => r.id);
|
: (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) {
|
for (const pid of picIds) {
|
||||||
const objects = await loadObjects(pid);
|
const objects = await loadObjects(pid);
|
||||||
if (!objects.length) continue;
|
if (!objects.length) continue;
|
||||||
@@ -206,8 +296,11 @@ async function retagObjects({ pictureId = null, dryRun = false, useLLM = false }
|
|||||||
for (const p of pairs) {
|
for (const p of pairs) {
|
||||||
report.pairs++;
|
report.pairs++;
|
||||||
let changes = [];
|
let changes = [];
|
||||||
try { changes = await retagPair(p, objects, { dryRun, useLLM }); }
|
try {
|
||||||
catch (err) { console.error(`Retag-Fehler bei Pair ${p.id}:`, err.message); continue; }
|
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) {
|
if (changes.length) {
|
||||||
report.changedPairs++;
|
report.changedPairs++;
|
||||||
report.changedFields += changes.length;
|
report.changedFields += changes.length;
|
||||||
|
|||||||
@@ -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
|
// POST /api/pipeline/retag-objects — Backfill: Objekt-Wörter in bestehenden Sätzen
|
||||||
// nachtokenisieren (deterministisch + optional Hybrid-LLM-Fallback für gebeugte Formen).
|
// 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).
|
// Ändert nur die Satz-Textfelder; Audio/Alignment bleiben gültig (Sprechtext unverändert).
|
||||||
router.post('/retag-objects', async (req, res, next) => {
|
router.post('/retag-objects', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const pictureId = req.body?.picture_id || null;
|
const pictureId = req.body?.picture_id || null;
|
||||||
const dryRun = !!req.body?.dry_run;
|
const dryRun = !!req.body?.dry_run;
|
||||||
const useLLM = !!req.body?.use_llm;
|
const useLLM = !!req.body?.use_llm;
|
||||||
|
const cleanup = !!req.body?.cleanup;
|
||||||
if (pictureId) {
|
if (pictureId) {
|
||||||
const pr = await query(`SELECT id FROM pictures WHERE id = $1`, [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' });
|
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);
|
res.json(report);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user