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

1"""Lenient answer/choice text matching (EI-840). 

2 

3Developer: Allan Ninal 

4""" 

5 

6import html 

7import re 

8 

9_TAG_RE = re.compile(r"<[^>]*>") 

10_WS_RE = re.compile(r"\s+") 

11 

12 

13def normalize_answer_text(value) -> str: 

14 """Reduce rich-text answer/choice text to a comparable form. 

15 

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() 

26 

27 

28def first_unmatched_answer(answers, choices): 

29 """Return the first answer matching no choice's text, or None if all match. 

30 

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