from __future__ import annotations import re import unicodedata from app.schemas.cross_validation import ( AnswerKind, StudentMathAnswerCandidate, ValidationConfidence, ) _NUMBER_PATTERN = r"[-+]?\d+(?:[.,]\d+)?(?:/\d+)?" def normalize_math_text(value: str) -> str: """Normalise une réponse mathématique courte sans décider si elle est correcte.""" text = value.strip().lower() text = unicodedata.normalize("NFKD", text) text = "".join(char for char in text if not unicodedata.combining(char)) replacements = { "ℝ": "r", "−": "-", "–": "-", "—": "-", "\\mathbb{r}": "r", "\\mathbb{R}": "r", "\\infty": "infinity", "∞": "infinity", "donc": " ", "alors": " ", "d'ou": " ", "d’où": " ", " ": "", } for old, new in replacements.items(): text = text.replace(old, new) text = re.sub(r"\s+", "", text) return text def _extract_x_equals_value(normalized_answer: str) -> str | None: match = re.search(rf"x=({_NUMBER_PATTERN}|[a-z])", normalized_answer) if not match: return None return match.group(1).replace(",", ".") def _extract_solution_set_value(normalized_answer: str) -> str | None: match = re.search(rf"s=\{{?({_NUMBER_PATTERN}|[a-z])\}}?", normalized_answer) if not match: return None return match.group(1).replace(",", ".") def _extract_last_equality_value(normalized_answer: str) -> str | None: if "=" not in normalized_answer: return None right_side = normalized_answer.rsplit("=", 1)[-1] match = re.fullmatch(rf"({_NUMBER_PATTERN}|[a-z])", right_side) if not match: return None return match.group(1).replace(",", ".") def _extract_domain(normalized_answer: str) -> str | None: if "df=" in normalized_answer: return normalized_answer.split("df=", 1)[-1] if "d_f=" in normalized_answer: return normalized_answer.split("d_f=", 1)[-1] if normalized_answer.startswith("r\\") or normalized_answer.startswith("r-"): return normalized_answer return None def _extract_boolean_property(normalized_answer: str) -> str | None: if "impaire" in normalized_answer: return "impaire" if "paire" in normalized_answer: return "paire" return None def _infer_answer_kind( normalized_answer: str, expected_answer_kind: AnswerKind | None, ) -> AnswerKind: if expected_answer_kind is not None and expected_answer_kind != AnswerKind.UNKNOWN: return expected_answer_kind if _extract_domain(normalized_answer): return AnswerKind.DOMAIN if _extract_solution_set_value(normalized_answer) or _extract_x_equals_value(normalized_answer): return AnswerKind.SOLUTION_SET if _extract_boolean_property(normalized_answer): return AnswerKind.BOOLEAN_PROPERTY if _extract_last_equality_value(normalized_answer): return AnswerKind.VALUE if normalized_answer: return AnswerKind.EXPRESSION return AnswerKind.UNKNOWN def _extract_value_for_kind(normalized_answer: str, answer_kind: AnswerKind) -> str | None: if answer_kind == AnswerKind.DOMAIN: return _extract_domain(normalized_answer) if answer_kind == AnswerKind.SOLUTION_SET: return ( _extract_solution_set_value(normalized_answer) or _extract_x_equals_value(normalized_answer) or _extract_last_equality_value(normalized_answer) ) if answer_kind == AnswerKind.VALUE: return _extract_last_equality_value(normalized_answer) or _extract_x_equals_value( normalized_answer ) if answer_kind == AnswerKind.BOOLEAN_PROPERTY: return _extract_boolean_property(normalized_answer) if answer_kind == AnswerKind.EXPRESSION: return normalized_answer or None return None def _extract_student_math_answer_legacy( answer_text: str, expected_answer_kind: AnswerKind | None = None, ) -> StudentMathAnswerCandidate: """Extrait une réponse élève exploitable pour comparaison future. Cette fonction ne note pas. Cette fonction ne valide pas la correction. Elle prépare seulement une représentation normalisée. """ raw_answer = answer_text.strip() normalized_answer = normalize_math_text(raw_answer) answer_kind = _infer_answer_kind(normalized_answer, expected_answer_kind) extracted_value = _extract_value_for_kind(normalized_answer, answer_kind) if extracted_value: confidence = ValidationConfidence.CERTAIN needs_human_review = False reason = "student_math_answer_extracted" elif normalized_answer: confidence = ValidationConfidence.PROBABLE needs_human_review = False reason = "student_math_answer_normalized_without_specific_value" else: confidence = ValidationConfidence.UNCERTAIN needs_human_review = True reason = "student_math_answer_empty_after_normalization" return StudentMathAnswerCandidate( raw_answer=raw_answer, normalized_answer=normalized_answer, answer_kind=answer_kind, extracted_value=extracted_value, confidence=confidence, needs_human_review=needs_human_review, reason=reason, ) from app.schemas.cross_validation import AnswerKind, StudentMathAnswerCandidate, ValidationConfidence from app.services.math_structure_scanner import MathStructureKind, scan_math_structures def _scanner_solution_value(m): e=m.children.get("elements", []) if m.kind == MathStructureKind.ASSIGN_SET: return e[0] if len(e)==1 else m.children.get("value") if m.kind == MathStructureKind.ASSIGN_NUM: return m.children.get("value") if m.kind == MathStructureKind.SET_LITERAL: return e[0] if len(e)==1 else "{"+",".join(e)+"}" return m.normalized_text def _extract_solution_set_with_scanner(answer_text: str): ms=scan_math_structures(answer_text) for m in ms: if m.kind in {MathStructureKind.ASSIGN_SET, MathStructureKind.ASSIGN_NUM, MathStructureKind.SET_LITERAL}: return StudentMathAnswerCandidate(raw_answer=answer_text, normalized_answer=normalize_math_text(answer_text), answer_kind=AnswerKind.SOLUTION_SET, extracted_value=_scanner_solution_value(m), confidence=ValidationConfidence.CERTAIN, needs_human_review=False, reason="deterministic_structure_scanner") for m in ms: if m.kind == MathStructureKind.BARE_NUMBER: return StudentMathAnswerCandidate(raw_answer=answer_text, normalized_answer=normalize_math_text(answer_text), answer_kind=AnswerKind.SOLUTION_SET, extracted_value=m.normalized_text, confidence=ValidationConfidence.PROBABLE, needs_human_review=True, reason="bare_number_requires_semantic_confirmation") return None def extract_student_math_answer(answer_text: str, expected_answer_kind: AnswerKind | None = None) -> StudentMathAnswerCandidate: if expected_answer_kind == AnswerKind.SOLUTION_SET: r=_extract_solution_set_with_scanner(answer_text) if r is not None: return r return _extract_student_math_answer_legacy(answer_text, expected_answer_kind)