diff --git a/klausur-service/backend/cv_vocab_pipeline.py b/klausur-service/backend/cv_vocab_pipeline.py index 1d62319..7c7a53e 100644 --- a/klausur-service/backend/cv_vocab_pipeline.py +++ b/klausur-service/backend/cv_vocab_pipeline.py @@ -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]" _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: """Check if an entry should be sent to the LLM for review. - Skip entries that are empty or contain IPA phonetic transcriptions - (those were already corrected by the word dictionary lookup). + Only sends entries that actually contain OCR digit↔letter confusion + patterns (e.g. "8en" instead of "Ben", "L0ndon" instead of "London"). + This prevents the LLM from touching correct entries. """ en = entry.get("english", "") or "" de = entry.get("german", "") or "" + ex = entry.get("example", "") or "" + # Skip completely empty entries if not en.strip() and not de.strip(): return False - # Skip entries with phonetic/IPA brackets — these are dictionary-corrected - if _HAS_PHONETIC_RE.search(en): + # Skip entries with IPA/phonetic brackets — dictionary-corrected, no OCR digits expected + if _HAS_PHONETIC_RE.search(en) or _HAS_PHONETIC_RE.search(de): 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: """Build the LLM correction prompt for a batch of entries.""" - return f"""Du bist ein Korrekturleser fuer OCR-erkannte Vokabeltabellen (Englisch-Deutsch). -Die Tabelle wurde per OCR aus einem Schulbuch-Scan extrahiert. Korrigiere NUR offensichtliche OCR-Fehler. + return f"""Du bist ein OCR-Zeichenkorrektur-Werkzeug fuer Vokabeltabellen (Englisch-Deutsch). -Haeufige OCR-Fehler die du korrigieren sollst: -- 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 +DEINE EINZIGE AUFGABE: Einzelne Zeichen korrigieren, die vom OCR-Scanner als Ziffer statt als Buchstabe erkannt wurden. -WICHTIG — Aendere NICHTS in diesen Faellen: -- Woerter die korrekt geschrieben sind (auch wenn sie ungewoehnlich aussehen) -- Eigennamen, Laendernamen, Staedtenamen (z.B. China, Japan, London, Africa) -- Abkuerzungen wie sth., sb., etc., e.g., i.e. -- Lautschrift und phonetische Zeichen in eckigen Klammern [...] -- Fachbegriffe und Fremdwoerter die korrekt sind -- Im Zweifel: NICHT aendern! +NUR diese Korrekturen sind erlaubt: +- Ziffer 8 statt B: "8en" → "Ben", "8uch" → "Buch", "8all" → "Ball" +- Ziffer 0 statt O oder o: "L0ndon" → "London", "0ld" → "Old" +- Ziffer 1 statt l oder I: "1ong" → "long", "Ber1in" → "Berlin" +- Ziffer 5 statt S oder s: "5tadt" → "Stadt", "5ee" → "See" +- Ziffer 6 statt G oder g: "6eld" → "Geld" -Antworte NUR mit dem korrigierten JSON-Array. Kein erklaerener Text. -Fuer jeden Eintrag den du aenderst, setze "corrected": true. -Fuer unveraenderte Eintraege setze "corrected": false. -Behalte die exakte Struktur (gleiche Anzahl Eintraege). +ABSOLUT VERBOTEN — aendere NIEMALS: +- Woerter die korrekt geschrieben sind — auch wenn du eine andere Schreibweise kennst +- Uebersetzungen — du uebersetzt NICHTS, weder EN→DE noch DE→EN +- 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 @@ -5440,31 +5455,54 @@ Eingabe: def _is_spurious_change(old_val: str, new_val: str) -> bool: """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: - - Case-only changes (OCR doesn't typically swap case) - - Completely different words (LLM hallucinating a replacement) - - Changes where the old value is a valid proper noun / place name + - Case-only changes + - Changes that don't contain any digit→letter fix + - Completely different words (LLM translating or hallucinating) + - Additions or removals of whole words (count changed) """ if not old_val or not new_val: 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(): return True - # If old value starts with uppercase and new is totally different word, - # it's likely a proper noun the LLM "corrected" + # If the word count changed significantly, the LLM rewrote rather than fixed old_words = old_val.split() new_words = new_val.split() - if len(old_words) == 1 and len(new_words) == 1: - ow, nw = old_words[0], new_words[0] - # Both are single words but share very few characters — likely hallucination - if len(ow) > 2 and len(nw) > 2: - # Levenshtein-like quick check: if < 50% chars overlap, reject - common = sum(1 for c in ow.lower() if c in nw.lower()) - max_len = max(len(ow), len(nw)) - if common / max_len < 0.5: - return True + if abs(len(old_words) - len(new_words)) > 1: + return True + + # Core rule: a legitimate correction replaces a digit with the corresponding + # letter. If the change doesn't include such a substitution, reject it. + # Build a set of (old_char, new_char) pairs that differ between old and new. + # Use character-level diff heuristic: if lengths are close, zip and compare. + _DIGIT_TO_LETTER = { + '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