"""Mastery Path tools — the seam between the chat-loop tutor and the pure mastery engine (:mod:`deeptutor.learning`). These five tools are auto-mounted only when a mastery path is active on the turn (via the chat loop mastery capability). The chat agent loop IS the tutor; these tools let it read the gate and record outcomes, while the pedagogy — what to teach, how to question, when to explain — stays the model's job. The arithmetic (mastery, gate, spaced repetition) stays in the engine. The active path id is injected server-side by the pipeline as ``_mastery_path_id``; the model never supplies it. Each call constructs a fresh store + service (matching the REST router) so concurrent turns can't race on a shared object. """ from __future__ import annotations import json import logging from typing import TYPE_CHECKING, Any import uuid from deeptutor.capabilities.mastery.choices import ( format_options, has_option_bodies, parse_options, recover_options_from_turn, resolve_answer, ) from deeptutor.core.tool_protocol import BaseTool, ToolDefinition, ToolParameter, ToolResult # ``learning.models`` and ``learning.policy`` only depend on pydantic — safe to # import at module load. ``learning.service`` / ``storage`` / ``scheduler`` # reach the path service (and so the runtime + tool registry), so importing # them here would close an import cycle through the built-in registry. They # are imported lazily inside the call paths instead (same pattern as the other # builtin tools). from deeptutor.learning.models import ( KnowledgePoint, KnowledgeType, LearningModule, PendingQuestion, ) from deeptutor.learning.policy import ( QUALITATIVE_TYPES, display_mastery, find_knowledge_point, gate_threshold, is_mastered, map_summary, next_objective, ) if TYPE_CHECKING: from deeptutor.learning.service import LearningService # Tool names the pipeline mounts together when a mastery path is active. Kept # here so the mount policy and the registration list can't disagree. MASTERY_TOOL_NAMES: tuple[str, ...] = ( "mastery_status", "mastery_quiz", "mastery_grade", "mastery_assess", "mastery_build", ) _QUESTION_TYPES = ("choice", "short", "open") _ALLOWED_KP_TYPES = {t.value for t in KnowledgeType} logger = logging.getLogger(__name__) def _new_service() -> LearningService: from deeptutor.learning.service import LearningService from deeptutor.learning.storage import LearningStore return LearningService(LearningStore()) def _resolve_path_id(kwargs: dict[str, Any]) -> str: return str(kwargs.get("_mastery_path_id") or "").strip() def _resolve_session_id(kwargs: dict[str, Any]) -> str: return str(kwargs.get("_session_id") or "").strip() def _resolve_turn_id(kwargs: dict[str, Any]) -> str: return str(kwargs.get("_turn_id") or "").strip() def _question_bank_type(question_type: str) -> str: qtype = str(question_type or "").strip().lower() if qtype == "choice": return "choice" if qtype == "open": return "written" return "short_answer" async def _resolve_pending_choice( pending: PendingQuestion, turn_id: str ) -> tuple[dict[str, str], str]: """Resolve a pending choice question's ``({label: body}, expected_label)``. Re-parses the bodies stored at registration; for legacy paths that stored only ``["A", "B", ...]`` it recovers the real bodies from the turn's ``ask_user`` event. The expected answer is normalised to a stable label when it resolves, else left as registered. """ options = parse_options(list(pending.options or [])) if not has_option_bodies(options): try: from deeptutor.services.session import get_sqlite_session_store options = await recover_options_from_turn( get_sqlite_session_store(), turn_id, pending.prompt ) except Exception: logger.warning("Failed to recover legacy mastery choice options", exc_info=True) options = {} return options, resolve_answer(pending.expected_answer, options) or pending.expected_answer async def _sync_mastery_attempt_to_question_bank( *, session_id: str, turn_id: str, pending: PendingQuestion, user_answer: str, is_correct: bool, choice_options: dict[str, str] | None = None, correct_answer: str | None = None, ) -> None: if not session_id: return item = { "turn_id": turn_id, "question_id": pending.question_id, "question": pending.prompt, "question_type": _question_bank_type(pending.question_type), "options": choice_options or parse_options(list(pending.options or [])), "correct_answer": correct_answer or pending.expected_answer, "explanation": "", "difficulty": "", "user_answer": user_answer, "is_correct": is_correct, } try: from deeptutor.services.session import get_sqlite_session_store await get_sqlite_session_store().upsert_notebook_entries(session_id, [item]) except Exception: logger.warning( "Failed to sync mastery question %s to question bank for session %s", pending.question_id, session_id, exc_info=True, ) def _json_result(payload: dict[str, Any], *, meta_key: str, success: bool = True) -> ToolResult: return ToolResult( content=json.dumps(payload, ensure_ascii=False), success=success, metadata={meta_key: payload}, ) def _no_path_result() -> ToolResult: return ToolResult( content="No mastery path is active on this turn; mastery tools are unavailable.", success=False, ) class MasteryStatusTool(BaseTool): """Read the current objective + map snapshot. Call FIRST every turn.""" def get_definition(self) -> ToolDefinition: return ToolDefinition( name="mastery_status", description=( "Read the learner's mastery path: the next objective to work on " "(decided by a hard mastery gate), any question awaiting an " "answer, due reviews, and a map of every objective's status " "(new / learning / mastered). Call this FIRST on every mastery " "turn — it tells you what to do; never guess the next objective." ), parameters=[], ) async def execute(self, **kwargs: Any) -> ToolResult: path_id = _resolve_path_id(kwargs) if not path_id: return _no_path_result() service = _new_service() progress = service.get_or_create(path_id) if not any(module.knowledge_points for module in progress.modules): return _json_result( { "status": "empty", "message": ( "No mastery path has been built yet. Design one from the " "learner's materials and call mastery_build." ), }, meta_key="mastery_status", ) payload = { "status": "active", "next": next_objective(progress).to_dict(), "map": map_summary(progress), } return _json_result(payload, meta_key="mastery_status") class MasteryQuizTool(BaseTool): """Register an objective-type question; the engine holds the answer.""" def get_definition(self) -> ToolDefinition: return ToolDefinition( name="mastery_quiz", description=( "Pose a question for a MEMORY or PROCEDURE objective and register " "its expected answer with the engine (so grading is deterministic " "and you never re-state the answer later). After calling this, " "present the question with the ask_user tool so the learner answers " "on an interactive card (for choices, give ask_user options short " "labels like A/B/C, pass every full option body here, and set the " "correct label as expected_answer); " "then call mastery_grade with their answer. For CONCEPT / DESIGN " "objectives use mastery_assess instead." ), parameters=[ ToolParameter( name="knowledge_point_id", type="string", description="Objective id from mastery_status (verbatim).", ), ToolParameter( name="question", type="string", description="The question text shown to the learner.", ), ToolParameter( name="expected_answer", type="string", description="The correct answer, used only server-side for grading.", ), ToolParameter( name="question_type", type="string", description=( "'choice' (exact match), 'short' (exact / fuzzy for ≤30 " "chars), or 'open' (keyword overlap). Default 'short'." ), required=False, default="short", enum=list(_QUESTION_TYPES), ), ToolParameter( name="options", type="array", description=( "For question_type='choice', every full option in label order, " "for example ['A: first answer', 'B: second answer']. Never " "pass bare labels such as ['A', 'B', 'C', 'D']. Use the same " "bodies as the ask_user option descriptions." ), required=False, items={"type": "string"}, ), ], ) async def execute(self, **kwargs: Any) -> ToolResult: path_id = _resolve_path_id(kwargs) if not path_id: return _no_path_result() kp_id = str(kwargs.get("knowledge_point_id") or "").strip() question = str(kwargs.get("question") or "").strip() expected = str(kwargs.get("expected_answer") or "").strip() if not kp_id or not question or not expected: return ToolResult( content="mastery_quiz needs knowledge_point_id, question, and expected_answer.", success=False, ) q_type = str(kwargs.get("question_type") or "short").strip().lower() if q_type not in _QUESTION_TYPES: q_type = "short" options = [str(o) for o in (kwargs.get("options") or []) if str(o).strip()] if q_type == "choice": choice_options = parse_options(options) if not has_option_bodies(choice_options): return ToolResult( content=( "Choice questions need full option bodies in mastery_quiz.options " "(for example ['A: first answer', 'B: second answer']), not only " "the labels A/B/C/D. Retry mastery_quiz with the exact option " "descriptions you will show through ask_user." ), success=False, ) resolved_expected = resolve_answer(expected, choice_options) if not resolved_expected: return ToolResult( content=( "Choice expected_answer must be an option label such as A/B/C/D, " "or uniquely match one full option body. Retry mastery_quiz with " "the correct label." ), success=False, ) expected = resolved_expected options = format_options(choice_options) service = _new_service() progress = service.get_or_create(path_id) kp, module_id, _ = find_knowledge_point(progress, kp_id) if kp is None: return ToolResult( content=f"Unknown objective {kp_id!r}; call mastery_status for valid ids.", success=False, ) pending = PendingQuestion( question_id=uuid.uuid4().hex, knowledge_point_id=kp_id, module_id=module_id, prompt=question, question_type=q_type, expected_answer=expected, options=options, ) service.set_pending_question(progress, pending) return _json_result( { "status": "registered", "knowledge_point_id": kp_id, "question": question, "options": options, "instruction": ( "Present this question with the ask_user tool (use its options " "for multiple choice; the option labels must match the " "expected_answer you registered), then call mastery_grade with " "the learner's answer." ), }, meta_key="mastery_quiz", ) class MasteryGradeTool(BaseTool): """Grade the learner's answer to the pending question (deterministic).""" def get_definition(self) -> ToolDefinition: return ToolDefinition( name="mastery_grade", description=( "Grade the learner's answer to the question you registered with " "mastery_quiz. Grading is deterministic against the stored " "expected answer; this updates mastery, advances spaced " "repetition, and tells you whether the objective's gate is now " "cleared. Then give the learner feedback." ), parameters=[ ToolParameter( name="answer", type="string", description="The learner's answer, verbatim.", ), ], ) async def execute(self, **kwargs: Any) -> ToolResult: path_id = _resolve_path_id(kwargs) if not path_id: return _no_path_result() from deeptutor.learning.scheduler import SpacedRepetitionScheduler answer = str(kwargs.get("answer") or "") service = _new_service() scheduler = SpacedRepetitionScheduler() progress = service.get_or_create(path_id) pending = progress.pending_question if pending is None: return ToolResult( content="No question is awaiting an answer. Pose one with mastery_quiz first.", success=False, ) choice_options: dict[str, str] = {} expected_answer = pending.expected_answer if pending.question_type == "choice": choice_options, expected_answer = await _resolve_pending_choice( pending, _resolve_turn_id(kwargs) ) is_correct = service.grade_and_record( progress, question_id=pending.question_id, knowledge_point_id=pending.knowledge_point_id, module_id=pending.module_id, user_answer=answer, expected_answer=expected_answer, question_type=pending.question_type, scheduler=scheduler, ) await _sync_mastery_attempt_to_question_bank( session_id=_resolve_session_id(kwargs), turn_id=_resolve_turn_id(kwargs), pending=pending, user_answer=answer, is_correct=is_correct, choice_options=choice_options, correct_answer=expected_answer, ) service.clear_pending_question(progress) kp, _, _ = find_knowledge_point(progress, pending.knowledge_point_id) mastered = bool(kp and is_mastered(progress, kp)) payload = { "is_correct": is_correct, "knowledge_point_id": pending.knowledge_point_id, "mastery": round(display_mastery(progress, kp), 3) if kp else 0.0, "threshold": round(gate_threshold(kp.type), 3) if kp else 0.0, "mastered": mastered, "next": next_objective(progress).to_dict(), } return _json_result(payload, meta_key="mastery_grade") class MasteryAssessTool(BaseTool): """Record the qualitative (CONCEPT / DESIGN) gate from a Feynman check.""" def get_definition(self) -> ToolDefinition: return ToolDefinition( name="mastery_assess", description=( "Record your judgement of a CONCEPT or DESIGN objective after the " "learner explains it in their own words (a Feynman-style check). " "Pass passed=true only when the explanation is correct and " "complete enough to count as mastery — this is the gate for these " "objective types. For MEMORY / PROCEDURE objectives use " "mastery_quiz + mastery_grade instead." ), parameters=[ ToolParameter( name="knowledge_point_id", type="string", description="Objective id from mastery_status (verbatim).", ), ToolParameter( name="passed", type="boolean", description="True if the explanation demonstrates mastery.", ), ToolParameter( name="feedback", type="string", description="Short note on what was strong or missing (stored as evidence).", required=False, ), ], ) async def execute(self, **kwargs: Any) -> ToolResult: path_id = _resolve_path_id(kwargs) if not path_id: return _no_path_result() kp_id = str(kwargs.get("knowledge_point_id") or "").strip() if not kp_id: return ToolResult(content="mastery_assess needs a knowledge_point_id.", success=False) passed = bool(kwargs.get("passed")) feedback = str(kwargs.get("feedback") or "").strip() service = _new_service() progress = service.get_or_create(path_id) kp, _, _ = find_knowledge_point(progress, kp_id) if kp is None: return ToolResult( content=f"Unknown objective {kp_id!r}; call mastery_status for valid ids.", success=False, ) if kp.type not in QUALITATIVE_TYPES: return ToolResult( content=( f"Objective {kp.name!r} is a {kp.type.value} type — gate it with " "mastery_quiz + mastery_grade, not mastery_assess." ), success=False, ) service.record_qualitative(progress, kp_id, passed=passed, evidence=feedback) payload = { "knowledge_point_id": kp_id, "passed": passed, "mastered": is_mastered(progress, kp), "mastery": round(display_mastery(progress, kp), 3), "next": next_objective(progress).to_dict(), } return _json_result(payload, meta_key="mastery_assess") class MasteryBuildTool(BaseTool): """Create / extend the skill map from objectives the tutor designed.""" def get_definition(self) -> ToolDefinition: return ToolDefinition( name="mastery_build", description=( "Create or extend the learner's mastery path. Design modules and " "their knowledge points from the learner's materials (use rag / " "read_source first when materials are attached) and pass them " "here. Each knowledge point needs a 'type': memory (facts), " "procedure (step-by-step skills), concept (ideas to understand), " "or design (open-ended judgement). Use mode='replace' to start " "fresh or 'append' to add to an existing path." ), parameters=[ ToolParameter( name="modules", type="array", description=( "Ordered modules: each {name, knowledge_points: [{name, " "type}]}. type is one of memory/procedure/concept/design." ), items={ "type": "object", "properties": { "name": {"type": "string"}, "knowledge_points": { "type": "array", "items": { "type": "object", "properties": { "name": {"type": "string"}, "type": { "type": "string", "enum": sorted(_ALLOWED_KP_TYPES), }, }, "required": ["name"], }, }, }, "required": ["name", "knowledge_points"], }, ), ToolParameter( name="mode", type="string", description="'replace' (default) starts fresh; 'append' adds modules.", required=False, default="replace", enum=["replace", "append"], ), ], ) async def execute(self, **kwargs: Any) -> ToolResult: path_id = _resolve_path_id(kwargs) if not path_id: return _no_path_result() mode = str(kwargs.get("mode") or "replace").strip().lower() if mode not in {"replace", "append"}: mode = "replace" service = _new_service() progress = service.get_or_create(path_id) offset = len(progress.modules) if mode == "append" else 0 new_modules, error = _parse_modules(kwargs.get("modules"), path_id, offset) if error: return ToolResult(content=error, success=False) combined = (list(progress.modules) + new_modules) if mode == "append" else new_modules service.replace_modules(progress, combined) progress.pending_question = None # a rebuilt map invalidates any open question if combined: progress.current_module_id = combined[0].id progress.current_kp_index = 0 service.save(progress) kp_count = sum(len(m.knowledge_points) for m in new_modules) return _json_result( { "status": "built", "mode": mode, "modules_added": len(new_modules), "knowledge_points_added": kp_count, "map": map_summary(progress), }, meta_key="mastery_build", ) def _parse_modules( raw_modules: Any, path_id: str, offset: int ) -> tuple[list[LearningModule], str | None]: """Validate the model-designed module tree into engine models. Ids are generated server-side (``_m_kp``) so the model never controls storage keys; unknown knowledge types fall back to 'concept'. """ if not isinstance(raw_modules, list) or not raw_modules: return [], "mastery_build needs a non-empty 'modules' array." modules: list[LearningModule] = [] for i, raw in enumerate(raw_modules): if not isinstance(raw, dict): continue index = offset + i name = str(raw.get("name") or "").strip()[:200] if not name: continue module_id = f"{path_id}_m{index}" kps: list[KnowledgePoint] = [] for j, raw_kp in enumerate(raw.get("knowledge_points") or []): if not isinstance(raw_kp, dict): continue kp_name = str(raw_kp.get("name") or "").strip()[:200] if len(kp_name) < 2: continue kp_type = str(raw_kp.get("type") or "concept").strip().lower() if kp_type not in _ALLOWED_KP_TYPES: kp_type = "concept" kps.append( KnowledgePoint( id=f"{module_id}_kp{j}", name=kp_name, type=KnowledgeType(kp_type), module_id=module_id, ) ) if not kps: continue modules.append(LearningModule(id=module_id, name=name, order=index, knowledge_points=kps)) if not modules: return [], "No valid modules: each module needs a name and at least one knowledge point." return modules, None MASTERY_TOOL_TYPES: tuple[type[BaseTool], ...] = ( MasteryStatusTool, MasteryQuizTool, MasteryGradeTool, MasteryAssessTool, MasteryBuildTool, ) __all__ = [ "MASTERY_TOOL_NAMES", "MASTERY_TOOL_TYPES", "MasteryStatusTool", "MasteryQuizTool", "MasteryGradeTool", "MasteryAssessTool", "MasteryBuildTool", ]