fix: move char-confusion fix to correction step, add spell + page-ref corrections
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / test-go-school (push) Successful in 29s
CI / test-go-edu-search (push) Successful in 27s
CI / test-python-klausur (push) Failing after 1m55s
CI / test-python-agent-core (push) Successful in 30s
CI / test-nodejs-website (push) Successful in 20s
CI / nodejs-lint (push) Failing after 10m5s

- Remove _fix_character_confusion() from words endpoint (now only in Phase 0)
- Extend spell checker to find real OCR errors via spell.correction()
- Add field-aware dictionary selection (EN/DE) for spell corrections
- Add _normalize_page_ref() for page_ref column (p-60 → p.60)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-05 00:26:13 +01:00
parent fd99d4f875
commit a58dfca1d8
2 changed files with 83 additions and 33 deletions

View File

@@ -6891,6 +6891,18 @@ except ImportError:
_SPELL_AVAILABLE = False
logger.warning("pyspellchecker not installed — falling back to LLM review")
# ─── Page-Ref Normalization ───────────────────────────────────────────────────
# Normalizes OCR variants like "p-60", "p 61", "p60" → "p.60"
_PAGE_REF_RE = _re.compile(r'\bp[\s\-]?(\d+)', _re.IGNORECASE)
def _normalize_page_ref(text: str) -> str:
"""Normalize page references: 'p-60' / 'p 61' / 'p60''p.60'."""
if not text:
return text
return _PAGE_REF_RE.sub(lambda m: f"p.{m.group(1)}", text)
# Suspicious OCR chars → ordered list of most-likely correct replacements
_SPELL_SUBS: Dict[str, List[str]] = {
'0': ['O', 'o'],
@@ -6914,49 +6926,76 @@ def _spell_dict_knows(word: str) -> bool:
return bool(_en_spell.known([w])) or bool(_de_spell.known([w]))
def _spell_fix_token(token: str) -> Optional[str]:
"""Return corrected form of token, or None if no fix needed/possible."""
if not any(ch in _SPELL_SUSPICIOUS for ch in token):
return None
# Standalone pipe → capital I
if token == '|':
return 'I'
# Original is already a valid word → leave it
def _spell_fix_token(token: str, field: str = "") -> Optional[str]:
"""Return corrected form of token, or None if no fix needed/possible.
*field* is 'english' or 'german' — used to pick the right dictionary
for general spell correction (step 3 below).
"""
has_suspicious = any(ch in _SPELL_SUSPICIOUS for ch in token)
# 1. Already known word → no fix needed
if _spell_dict_knows(token):
return None
# Dictionary-backed single-char substitution
for i, ch in enumerate(token):
if ch not in _SPELL_SUBS:
continue
for replacement in _SPELL_SUBS[ch]:
candidate = token[:i] + replacement + token[i + 1:]
if _spell_dict_knows(candidate):
return candidate
# Structural rule: suspicious char at position 0 + rest is all lowercase letters
# e.g. "8en"→"Ben", "8uch"→"Buch", "5ee"→"See", "6eld"→"Geld"
first = token[0]
if first in _SPELL_SUBS and len(token) >= 2:
rest = token[1:]
if rest.isalpha() and rest.islower():
candidate = _SPELL_SUBS[first][0] + rest
if not candidate[0].isdigit():
return candidate
# 2. Digit/pipe substitution (existing logic)
if has_suspicious:
# Standalone pipe → capital I
if token == '|':
return 'I'
# Dictionary-backed single-char substitution
for i, ch in enumerate(token):
if ch not in _SPELL_SUBS:
continue
for replacement in _SPELL_SUBS[ch]:
candidate = token[:i] + replacement + token[i + 1:]
if _spell_dict_knows(candidate):
return candidate
# Structural rule: suspicious char at position 0 + rest is all lowercase letters
first = token[0]
if first in _SPELL_SUBS and len(token) >= 2:
rest = token[1:]
if rest.isalpha() and rest.islower():
candidate = _SPELL_SUBS[first][0] + rest
if not candidate[0].isdigit():
return candidate
# 3. General spell correction for unknown words (no digits/pipes)
# e.g. "iberqueren" → "ueberqueren", "beautful" → "beautiful"
if not has_suspicious and len(token) >= 3 and token.isalpha():
spell = _en_spell if field == "english" else _de_spell if field == "german" else None
if spell is not None:
correction = spell.correction(token.lower())
if correction and correction != token.lower():
# Preserve original capitalisation pattern
if token[0].isupper():
correction = correction[0].upper() + correction[1:]
if _spell_dict_knows(correction):
return correction
return None
def _spell_fix_field(text: str) -> Tuple[str, bool]:
"""Apply OCR corrections to a text field. Returns (fixed_text, was_changed)."""
if not text or not any(ch in text for ch in _SPELL_SUSPICIOUS):
def _spell_fix_field(text: str, field: str = "") -> Tuple[str, bool]:
"""Apply OCR corrections to a text field. Returns (fixed_text, was_changed).
*field* is 'english' or 'german' — forwarded to _spell_fix_token for
dictionary selection.
"""
if not text:
return text, False
has_suspicious = any(ch in text for ch in _SPELL_SUSPICIOUS)
# If no suspicious chars AND no alpha chars that could be misspelled, skip
if not has_suspicious and not any(c.isalpha() for c in text):
return text, False
# Pattern: | immediately before . or , → numbered list prefix ("|. " → "1. ")
fixed = _re.sub(r'(?<!\w)\|(?=[.,])', '1', text)
fixed = _re.sub(r'(?<!\w)\|(?=[.,])', '1', text) if has_suspicious else text
changed = fixed != text
# Tokenize and fix word by word
parts: List[str] = []
pos = 0
for m in _SPELL_TOKEN_RE.finditer(fixed):
token, sep = m.group(1), m.group(2)
correction = _spell_fix_token(token)
correction = _spell_fix_token(token, field=field)
if correction:
parts.append(correction)
changed = True
@@ -6979,6 +7018,19 @@ def spell_review_entries_sync(entries: List[Dict]) -> Dict:
all_corrected: List[Dict] = []
for i, entry in enumerate(entries):
e = dict(entry)
# Page-ref normalization (always, regardless of review status)
old_ref = (e.get("source_page") or "").strip()
if old_ref:
new_ref = _normalize_page_ref(old_ref)
if new_ref != old_ref:
changes.append({
"row_index": e.get("row_index", i),
"field": "source_page",
"old": old_ref,
"new": new_ref,
})
e["source_page"] = new_ref
e["llm_corrected"] = True
if not _entry_needs_review(e):
all_corrected.append(e)
continue
@@ -6986,7 +7038,7 @@ def spell_review_entries_sync(entries: List[Dict]) -> Dict:
old_val = (e.get(field_name) or "").strip()
if not old_val:
continue
new_val, was_changed = _spell_fix_field(old_val)
new_val, was_changed = _spell_fix_field(old_val, field=field_name)
if was_changed and new_val != old_val:
changes.append({
"row_index": e.get("row_index", i),

View File

@@ -1348,7 +1348,6 @@ async def detect_words(
# No content shuffling — each cell stays at its detected position.
if is_vocab:
entries = _cells_to_vocab_entries(cells, columns_meta)
entries = _fix_character_confusion(entries)
entries = _fix_phonetic_brackets(entries, pronunciation=pronunciation)
word_result["vocab_entries"] = entries
word_result["entries"] = entries
@@ -1487,7 +1486,6 @@ async def _word_batch_stream_generator(
vocab_entries = None
if is_vocab:
entries = _cells_to_vocab_entries(cells, columns_meta)
entries = _fix_character_confusion(entries)
entries = _fix_phonetic_brackets(entries, pronunciation=pronunciation)
word_result["vocab_entries"] = entries
word_result["entries"] = entries