fix(llm-review): LLM übersetzt nicht mehr — nur noch OCR-Ziffernfehler
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 25s
CI / test-go-edu-search (push) Successful in 27s
CI / test-python-klausur (push) Failing after 1m52s
CI / test-python-agent-core (push) Successful in 15s
CI / test-nodejs-website (push) Successful in 16s

## Problem
qwen3:0.6b interpretierte den Prompt zu weit und versuchte:
- Englische Wörter zu übersetzen (EN-Spalte umschreiben)
- Korrekte deutsche Wörter neu zu übersetzen
- IPA-Einträge in Klammern zu 'korrigieren'

## Fixes

### 1. Strengerer Pre-Filter (entry_needs_review)
Sendet jetzt NUR Einträge ans LLM, die tatsächlich ein
Ziffer-in-Wort-Muster haben (0158 zwischen Buchstaben).
→ Korrekte Einträge werden gar nicht erst gesendet.

### 2. Viel restriktiverer Prompt
- Explizites Verbot: "du übersetzt NICHTS, weder EN→DE noch DE→EN"
- Nur die 5 Ziffer→Buchstaben-Fälle sind erlaubt
- Konkrete Beispiele für erlaubte Korrekturen
- Kein vager "Im Zweifel nicht ändern" — sondern explizites VERBOTEN

### 3. Stärkerer Spurious-Change-Filter
Verwirft LLM-Änderungen, die keine Ziffer→Buchstabe-Substitution sind.
Verhindert Übersetzungen und Neuformulierungen auch wenn der Prompt
sie nicht vollständig unterdrückt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-03 13:48:54 +01:00
parent 7eb03ca8d1
commit 2fce92d7b1

View File

@@ -5390,46 +5390,61 @@ logger.info("LLM review model: %s (batch=%d)", OLLAMA_REVIEW_MODEL, _REVIEW_BATC
# Regex: entry contains IPA phonetic brackets like "dance [dɑːns]" # Regex: entry contains IPA phonetic brackets like "dance [dɑːns]"
_HAS_PHONETIC_RE = _re.compile(r'\[.*?[ˈˌːʃʒθðŋɑɒɔəɜɪʊʌæ].*?\]') _HAS_PHONETIC_RE = _re.compile(r'\[.*?[ˈˌːʃʒθðŋɑɒɔəɜɪʊʌæ].*?\]')
# Regex: digit adjacent to a letter — the hallmark of OCR digit↔letter confusion.
# Matches digits 0,1,5,6,8 (common OCR confusions: 0→O, 1→l/I, 5→S, 6→G, 8→B)
# when they appear inside or next to a word character.
_OCR_DIGIT_IN_WORD_RE = _re.compile(r'(?<=[A-Za-zÄÖÜäöüß])[01568]|[01568](?=[A-Za-zÄÖÜäöüß])')
def _entry_needs_review(entry: Dict) -> bool: def _entry_needs_review(entry: Dict) -> bool:
"""Check if an entry should be sent to the LLM for review. """Check if an entry should be sent to the LLM for review.
Skip entries that are empty or contain IPA phonetic transcriptions Only sends entries that actually contain OCR digit↔letter confusion
(those were already corrected by the word dictionary lookup). patterns (e.g. "8en" instead of "Ben", "L0ndon" instead of "London").
This prevents the LLM from touching correct entries.
""" """
en = entry.get("english", "") or "" en = entry.get("english", "") or ""
de = entry.get("german", "") or "" de = entry.get("german", "") or ""
ex = entry.get("example", "") or ""
# Skip completely empty entries # Skip completely empty entries
if not en.strip() and not de.strip(): if not en.strip() and not de.strip():
return False return False
# Skip entries with phonetic/IPA brackets — these are dictionary-corrected # Skip entries with IPA/phonetic brackets — dictionary-corrected, no OCR digits expected
if _HAS_PHONETIC_RE.search(en): if _HAS_PHONETIC_RE.search(en) or _HAS_PHONETIC_RE.search(de):
return False return False
return True # Only review if at least one field has a digit-in-word pattern
combined = f"{en} {de} {ex}"
return bool(_OCR_DIGIT_IN_WORD_RE.search(combined))
def _build_llm_prompt(table_lines: List[Dict]) -> str: def _build_llm_prompt(table_lines: List[Dict]) -> str:
"""Build the LLM correction prompt for a batch of entries.""" """Build the LLM correction prompt for a batch of entries."""
return f"""Du bist ein Korrekturleser fuer OCR-erkannte Vokabeltabellen (Englisch-Deutsch). return f"""Du bist ein OCR-Zeichenkorrektur-Werkzeug fuer Vokabeltabellen (Englisch-Deutsch).
Die Tabelle wurde per OCR aus einem Schulbuch-Scan extrahiert. Korrigiere NUR offensichtliche OCR-Fehler.
Haeufige OCR-Fehler die du korrigieren sollst: DEINE EINZIGE AUFGABE: Einzelne Zeichen korrigieren, die vom OCR-Scanner als Ziffer statt als Buchstabe erkannt wurden.
- Ziffern statt Buchstaben: 8→B, 0→O, 1→l/I, 5→S, 6→G
- Fehlende oder falsche Satzzeichen
- Offensichtliche Tippfehler die durch OCR entstanden sind
WICHTIG — Aendere NICHTS in diesen Faellen: NUR diese Korrekturen sind erlaubt:
- Woerter die korrekt geschrieben sind (auch wenn sie ungewoehnlich aussehen) - Ziffer 8 statt B: "8en""Ben", "8uch""Buch", "8all""Ball"
- Eigennamen, Laendernamen, Staedtenamen (z.B. China, Japan, London, Africa) - Ziffer 0 statt O oder o: "L0ndon""London", "0ld""Old"
- Abkuerzungen wie sth., sb., etc., e.g., i.e. - Ziffer 1 statt l oder I: "1ong""long", "Ber1in""Berlin"
- Lautschrift und phonetische Zeichen in eckigen Klammern [...] - Ziffer 5 statt S oder s: "5tadt""Stadt", "5ee""See"
- Fachbegriffe und Fremdwoerter die korrekt sind - Ziffer 6 statt G oder g: "6eld""Geld"
- Im Zweifel: NICHT aendern!
Antworte NUR mit dem korrigierten JSON-Array. Kein erklaerener Text. ABSOLUT VERBOTEN — aendere NIEMALS:
Fuer jeden Eintrag den du aenderst, setze "corrected": true. - Woerter die korrekt geschrieben sind — auch wenn du eine andere Schreibweise kennst
Fuer unveraenderte Eintraege setze "corrected": false. - Uebersetzungen — du uebersetzt NICHTS, weder EN→DE noch DE→EN
Behalte die exakte Struktur (gleiche Anzahl Eintraege). - Korrekte englische Woerter (en-Spalte) — auch wenn du eine Bedeutung kennst
- Korrekte deutsche Woerter (de-Spalte) — auch wenn du sie anders sagen wuerdest
- Eigennamen: Ben, London, China, Africa, Shakespeare usw.
- Abkuerzungen: sth., sb., etc., e.g., i.e., v.t., smb. usw.
- Lautschrift in eckigen Klammern [...] — diese NIEMALS beruehren
- Beispielsaetze in der ex-Spalte — NIEMALS aendern
Wenn ein Wort keinen Ziffer-Buchstaben-Fehler enthaelt: gib es UNVERAENDERT zurueck und setze "corrected": false.
Antworte NUR mit dem JSON-Array. Kein Text davor oder danach.
Behalte die exakte Struktur (gleiche Anzahl Eintraege, gleiche Reihenfolge).
/no_think /no_think
@@ -5440,31 +5455,54 @@ Eingabe:
def _is_spurious_change(old_val: str, new_val: str) -> bool: def _is_spurious_change(old_val: str, new_val: str) -> bool:
"""Detect LLM changes that are likely wrong and should be discarded. """Detect LLM changes that are likely wrong and should be discarded.
Only digit↔letter substitutions (0→O, 1→l, 5→S, 6→G, 8→B) are
legitimate OCR corrections. Everything else is rejected.
Filters out: Filters out:
- Case-only changes (OCR doesn't typically swap case) - Case-only changes
- Completely different words (LLM hallucinating a replacement) - Changes that don't contain any digit→letter fix
- Changes where the old value is a valid proper noun / place name - Completely different words (LLM translating or hallucinating)
- Additions or removals of whole words (count changed)
""" """
if not old_val or not new_val: if not old_val or not new_val:
return False return False
# Case-only change — almost never a real OCR error # Case-only change — never a real OCR error
if old_val.lower() == new_val.lower(): if old_val.lower() == new_val.lower():
return True return True
# If old value starts with uppercase and new is totally different word, # If the word count changed significantly, the LLM rewrote rather than fixed
# it's likely a proper noun the LLM "corrected"
old_words = old_val.split() old_words = old_val.split()
new_words = new_val.split() new_words = new_val.split()
if len(old_words) == 1 and len(new_words) == 1: if abs(len(old_words) - len(new_words)) > 1:
ow, nw = old_words[0], new_words[0] return True
# Both are single words but share very few characters — likely hallucination
if len(ow) > 2 and len(nw) > 2: # Core rule: a legitimate correction replaces a digit with the corresponding
# Levenshtein-like quick check: if < 50% chars overlap, reject # letter. If the change doesn't include such a substitution, reject it.
common = sum(1 for c in ow.lower() if c in nw.lower()) # Build a set of (old_char, new_char) pairs that differ between old and new.
max_len = max(len(ow), len(nw)) # Use character-level diff heuristic: if lengths are close, zip and compare.
if common / max_len < 0.5: _DIGIT_TO_LETTER = {
return True '0': set('oOgG'),
'1': set('lLiI'),
'5': set('sS'),
'6': set('gG'),
'8': set('bB'),
}
has_valid_digit_fix = False
if len(old_val) == len(new_val):
for oc, nc in zip(old_val, new_val):
if oc != nc:
if oc in _DIGIT_TO_LETTER and nc in _DIGIT_TO_LETTER[oc]:
has_valid_digit_fix = True
# Any other single-char change is suspicious (could be translation)
else:
# Length changed: only accept if the difference is one char and
# the old contained a digit where new has a letter
if abs(len(old_val) - len(new_val)) <= 1 and _OCR_DIGIT_IN_WORD_RE.search(old_val):
has_valid_digit_fix = True
if not has_valid_digit_fix:
return True # Reject — looks like translation or hallucination
return False return False