项目文件夹

文件
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 行
6.0 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""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 {}