Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

144 lines
6.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 {}