e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
144 lines
6.0 KiB
Python
144 lines
6.0 KiB
Python
"""The data contract for multiple-choice mastery questions.
|
||
|
||
A choice question crosses four boundaries with different shapes for the same
|
||
data: the model registers option *bodies* through ``mastery_quiz``, the learner
|
||
answers a *label* (``"C"``) on an interactive ``ask_user`` card, deterministic
|
||
grading must compare like with like, and the Question Bank persists the full
|
||
option text. This module owns the translation between those shapes so the tool
|
||
layer (:mod:`deeptutor.capabilities.mastery.tools`) reads as orchestration:
|
||
|
||
* :func:`parse_options` — option strings → a ``{label: body}`` map.
|
||
* :func:`has_option_bodies` — did the model send real bodies, not bare labels?
|
||
* :func:`format_options` — a ``{label: body}`` map → canonical option strings.
|
||
* :func:`resolve_answer` — a model-supplied answer → its stable option label.
|
||
* :func:`recover_options_from_turn` — bodies recovered from a legacy turn's
|
||
``ask_user`` event, for paths registered before the contract was enforced.
|
||
|
||
Everything here is pure except :func:`recover_options_from_turn`, which takes a
|
||
session store by dependency injection rather than importing one, keeping this
|
||
module free of infrastructure wiring.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Matches a labelled option like ``"C: body"`` / ``"C) body"`` / ``"C、body"``,
|
||
# capturing the label letter and the body. Used to recover the label a model
|
||
# embedded in the option text instead of supplying it positionally.
|
||
OPTION_PREFIX_RE = re.compile(r"^\s*([A-Z])\s*[.::、))-]\s*(.+)$", re.IGNORECASE)
|
||
|
||
|
||
def parse_options(options: list[str]) -> dict[str, str]:
|
||
"""Map option strings to ``{label: body}``.
|
||
|
||
``"C: the answer"`` → ``{"C": "the answer"}``; a bare single character
|
||
``"C"`` maps to itself (a label-only registration); anything else gets a
|
||
positional label (A, B, C, … then 27, 28, … past Z).
|
||
"""
|
||
result: dict[str, str] = {}
|
||
for idx, raw in enumerate(options):
|
||
text = str(raw or "").strip()
|
||
if not text:
|
||
continue
|
||
match = OPTION_PREFIX_RE.match(text)
|
||
if match:
|
||
result[match.group(1).upper()] = match.group(2).strip()
|
||
elif len(text) == 1 and text.isalnum():
|
||
result[text.upper()] = text
|
||
else:
|
||
result[chr(ord("A") + idx) if idx < 26 else str(idx + 1)] = text
|
||
return result
|
||
|
||
|
||
def has_option_bodies(options: dict[str, str]) -> bool:
|
||
"""Whether a choice map holds real answer text, not only A/B/C labels."""
|
||
return len(options) >= 2 and all(
|
||
value.strip() and value.strip().upper() != key.upper() for key, value in options.items()
|
||
)
|
||
|
||
|
||
def format_options(options: dict[str, str]) -> list[str]:
|
||
"""Render a ``{label: body}`` map back to canonical ``"label: body"`` strings."""
|
||
return [f"{label}: {body}" for label, body in options.items()]
|
||
|
||
|
||
def resolve_answer(expected_answer: str, options: dict[str, str]) -> str:
|
||
"""Resolve a model-supplied choice answer to its stable option label.
|
||
|
||
Models occasionally send ``"Step 6"`` or the full option text even though
|
||
the interactive card returns ``"C"``. Resolve a unique textual match at
|
||
registration time so deterministic grading compares like with like. Returns
|
||
``""`` when nothing matches or the match is ambiguous.
|
||
"""
|
||
expected = str(expected_answer or "").strip()
|
||
if not expected:
|
||
return ""
|
||
|
||
key = expected.upper()
|
||
if key in options:
|
||
return key
|
||
|
||
prefix_match = OPTION_PREFIX_RE.match(expected)
|
||
if prefix_match and prefix_match.group(1).upper() in options:
|
||
return prefix_match.group(1).upper()
|
||
|
||
needle = expected.casefold()
|
||
exact = [label for label, text in options.items() if text.casefold() == needle]
|
||
if len(exact) == 1:
|
||
return exact[0]
|
||
contained = [label for label, text in options.items() if needle in text.casefold()]
|
||
return contained[0] if len(contained) == 1 else ""
|
||
|
||
|
||
def _normalized_prompt(value: str) -> str:
|
||
"""Alphanumeric-only, case-folded form for tolerant prompt matching."""
|
||
return "".join(char.casefold() for char in str(value or "") if char.isalnum())
|
||
|
||
|
||
async def recover_options_from_turn(store: Any, turn_id: str, question: str) -> dict[str, str]:
|
||
"""Recover choice bodies from the most recent matching ``ask_user`` card.
|
||
|
||
A compatibility fallback for questions registered by older versions, where
|
||
``mastery_quiz`` persisted only ``["A", "B", ...]`` even though the full
|
||
descriptions were present in the turn's ``ask_user`` event. ``store`` is
|
||
injected so this stays decoupled from the session layer.
|
||
"""
|
||
if not turn_id or not hasattr(store, "get_turn_events"):
|
||
return {}
|
||
try:
|
||
events = await store.get_turn_events(turn_id)
|
||
except Exception:
|
||
logger.warning("Failed to load turn events for mastery option recovery", exc_info=True)
|
||
return {}
|
||
|
||
target = _normalized_prompt(question)
|
||
for event in reversed(events):
|
||
if event.get("type") != "tool_call":
|
||
continue
|
||
metadata = event.get("metadata") or {}
|
||
if metadata.get("tool_name") != "ask_user":
|
||
continue
|
||
for item in reversed((metadata.get("args") or {}).get("questions") or []):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
recovered = {
|
||
str(option.get("label") or "").strip().upper(): str(
|
||
option.get("description") or ""
|
||
).strip()
|
||
for option in (item.get("options") or [])
|
||
if isinstance(option, dict)
|
||
and str(option.get("label") or "").strip()
|
||
and str(option.get("description") or "").strip()
|
||
}
|
||
if not has_option_bodies(recovered):
|
||
continue
|
||
prompt = _normalized_prompt(str(item.get("prompt") or ""))
|
||
if prompt == target or prompt.startswith(target) or target.startswith(prompt):
|
||
return recovered
|
||
return {}
|