bacpilot-backend / app /services /math_answer_extractor.py
debpc
Use deterministic scanner for solution extraction
49da08e
Raw
History Blame Contribute Delete
7.21 kB
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)