import re import unicodedata import json def normalize_text(text: str) -> str: if not text: return "" text = text.lower().strip() # Replace German umlauts text = text.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") # Remove accents nfkd = unicodedata.normalize("NFKD", text) text = "".join([c for c in nfkd if not unicodedata.combining(c)]) # Remove special punctuation like quotes, dots, dashes, commas text = re.sub(r"[^\w\s]", "", text) # Collapse whitespace text = re.sub(r"\s+", " ", text).strip() return text def levenshtein_distance(s1: str, s2: str) -> int: if len(s1) < len(s2): return levenshtein_distance(s2, s1) if len(s2) == 0: return len(s1) previous_row = list(range(len(s2) + 1)) for i, c1 in enumerate(s1): current_row = [i + 1] for j, c2 in enumerate(s2): insertions = previous_row[j + 1] + 1 deletions = current_row[j] + 1 substitutions = previous_row[j] + (c1 != c2) current_row.append(min(insertions, deletions, substitutions)) previous_row = current_row return previous_row[-1] WORD_TO_NUM = { "null": "0", "zero": "0", "eins": "1", "ein": "1", "one": "1", "zwei": "2", "two": "2", "drei": "3", "three": "3", "vier": "4", "four": "4", "fuenf": "5", "five": "5", "sechs": "6", "six": "6", "sieben": "7", "seven": "7", "acht": "8", "eight": "8", "neun": "9", "nine": "9", "zehn": "10", "ten": "10", "elf": "11", "eleven": "11", "zwoelf": "12", "twelve": "12", "dreizehn": "13", "thirteen": "13", "vierzehn": "14", "fourteen": "14", "fuenfzehn": "15", "fifteen": "15", "sechzehn": "16", "sixteen": "16", "siebzehn": "17", "seventeen": "17", "achtzehn": "18", "eighteen": "18", "neunzehn": "19", "nineteen": "19", "zwanzig": "20", "twenty": "20", "fuenfzig": "50", "fifty": "50", "hundert": "100", "einhundert": "100", "one hundred": "100", "einhundertfuenfzig": "150", "one hundred fifty": "150", "one hundred and fifty": "150" } NUM_TO_WORDS = {} for w, n in WORD_TO_NUM.items(): NUM_TO_WORDS.setdefault(n, []).append(w) def is_answer_correct(user_input: str, canonical: str, accepted_raw: str | None = None, canonical_en: str | None = None) -> bool: clean_user = normalize_text(user_input) if not clean_user: return False # Also consider digit conversion of user input if a word was typed user_variants = [clean_user] if clean_user in WORD_TO_NUM: user_variants.append(WORD_TO_NUM[clean_user]) targets = [normalize_text(canonical)] if canonical_en: clean_en = normalize_text(canonical_en) if clean_en and clean_en not in targets: targets.append(clean_en) if accepted_raw: try: parsed = json.loads(accepted_raw) if isinstance(parsed, list): targets.extend([normalize_text(str(x)) for x in parsed]) except Exception: # Fallback comma-separated for part in accepted_raw.split(","): part_clean = normalize_text(part) if part_clean: targets.append(part_clean) # Expand any numeric target with corresponding number words and vice-versa expanded_targets = list(targets) for t in targets: if t in NUM_TO_WORDS: expanded_targets.extend(NUM_TO_WORDS[t]) if t in WORD_TO_NUM: expanded_targets.append(WORD_TO_NUM[t]) for target in expanded_targets: if not target: continue for u in user_variants: if u == target: return True # For short answers (1-3 chars, e.g. numbers or abbreviations), require exact match if len(target) <= 3 or len(u) <= 3: continue # For medium words (4-6 chars), allow max 1 typo max_allowed = 1 if len(target) <= 6 else 2 dist = levenshtein_distance(u, target) if dist <= max_allowed: return True # Support compound answers connected by 'und' / 'and' (e.g. "Mantikor und Feuerkrabbe") for sep in (" und ", " and "): if sep in target: parts = [p.strip() for p in target.split(sep) if len(p.strip()) >= 3] if len(parts) == 2: u_words = u.split() matched_both = True for part in parts: if part in u: continue # Check fuzzy match against tokens or reversed order part_matched = any(levenshtein_distance(w, part) <= (1 if len(part) <= 6 else 2) for w in u_words) if not part_matched: matched_both = False break if matched_both: return True return False