Coverage for server / utilities / answer_match.py: 100%
22 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""Lenient answer/choice text matching (EI-840).
3Developer: Allan Ninal
4"""
6import html
7import re
9_TAG_RE = re.compile(r"<[^>]*>")
10_WS_RE = re.compile(r"\s+")
13def normalize_answer_text(value) -> str:
14 """Reduce rich-text answer/choice text to a comparable form.
16 Rules, in order: strip HTML tags, unescape HTML entities, collapse every
17 whitespace run (including non-breaking space) to one space, trim, casefold.
18 Non-string input is converted with ``str()``; ``None`` becomes "".
19 """
20 if value is None:
21 return ""
22 text = _TAG_RE.sub("", str(value))
23 text = html.unescape(text)
24 text = _WS_RE.sub(" ", text).strip()
25 return text.casefold()
28def first_unmatched_answer(answers, choices):
29 """Return the first answer matching no choice's text, or None if all match.
31 ``choices`` is a list of ``{"id", "text"}`` dicts (bare strings tolerated).
32 Comparison uses :func:`normalize_answer_text` on both sides. If any choice
33 text or answer is not a string (e.g. an erudition-math document object) the
34 texts cannot be compared reliably, so nothing is reported (None).
35 """
36 for c in choices or []:
37 if not isinstance(c.get("text") if isinstance(c, dict) else c, str):
38 return None
39 if any(not isinstance(a, str) for a in answers or []):
40 return None
41 choice_texts = {
42 normalize_answer_text(c.get("text") if isinstance(c, dict) else c)
43 for c in (choices or [])
44 }
45 for answer in answers or []:
46 if normalize_answer_text(answer) not in choice_texts:
47 return answer
48 return None