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
334 lines
12 KiB
Python
334 lines
12 KiB
Python
"""Tests for the Mastery Path tools — the seam between the chat-loop tutor and
|
|
the engine. They drive the full loop the tutor uses: build a path, read the
|
|
gate, pose + grade questions, assess qualitative objectives, with the active
|
|
path id injected server-side (never by the model)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from deeptutor.learning.storage import LearningStore
|
|
from deeptutor.services.session.sqlite_store import SQLiteSessionStore
|
|
from deeptutor.tools.mastery_tool import (
|
|
MasteryAssessTool,
|
|
MasteryBuildTool,
|
|
MasteryGradeTool,
|
|
MasteryQuizTool,
|
|
MasteryStatusTool,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def path_id(tmp_path, monkeypatch):
|
|
"""Point the LearningStore at a temp workspace and yield a stable path id."""
|
|
monkeypatch.setattr(LearningStore, "__init__", _store_init_factory(tmp_path))
|
|
return "test_path"
|
|
|
|
|
|
@pytest.fixture
|
|
def session_store(tmp_path, monkeypatch):
|
|
store = SQLiteSessionStore(db_path=tmp_path / "chat.db")
|
|
monkeypatch.setattr("deeptutor.services.session.get_sqlite_session_store", lambda: store)
|
|
return store
|
|
|
|
|
|
def _store_init_factory(root):
|
|
def _init(self, root_arg=None): # mirrors LearningStore.__init__ signature
|
|
from pathlib import Path
|
|
|
|
self._root = Path(root) / "learning"
|
|
self._root.mkdir(parents=True, exist_ok=True)
|
|
|
|
return _init
|
|
|
|
|
|
async def _build_basic(path_id):
|
|
build = MasteryBuildTool()
|
|
return await build.execute(
|
|
_mastery_path_id=path_id,
|
|
mode="replace",
|
|
modules=[
|
|
{
|
|
"name": "Module 1",
|
|
"knowledge_points": [
|
|
{"name": "Truth tables", "type": "memory"},
|
|
{"name": "Why XOR matters", "type": "concept"},
|
|
],
|
|
}
|
|
],
|
|
)
|
|
|
|
|
|
# ── build ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_creates_path(path_id):
|
|
result = await _build_basic(path_id)
|
|
assert result.success
|
|
payload = json.loads(result.content)
|
|
assert payload["knowledge_points_added"] == 2
|
|
assert payload["map"]["counts"]["total"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_rejects_empty_modules(path_id):
|
|
result = await MasteryBuildTool().execute(_mastery_path_id=path_id, modules=[])
|
|
assert result.success is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_append_keeps_existing(path_id):
|
|
await _build_basic(path_id)
|
|
result = await MasteryBuildTool().execute(
|
|
_mastery_path_id=path_id,
|
|
mode="append",
|
|
modules=[
|
|
{"name": "Module 2", "knowledge_points": [{"name": "Adders", "type": "procedure"}]}
|
|
],
|
|
)
|
|
payload = json.loads(result.content)
|
|
assert payload["map"]["counts"]["total"] == 3 # 2 existing + 1 appended
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_unknown_type_defaults_to_concept(path_id):
|
|
result = await MasteryBuildTool().execute(
|
|
_mastery_path_id=path_id,
|
|
modules=[{"name": "M", "knowledge_points": [{"name": "Thing", "type": "nonsense"}]}],
|
|
)
|
|
kp = json.loads(result.content)["map"]["modules"][0]["knowledge_points"][0]
|
|
assert kp["type"] == "concept"
|
|
|
|
|
|
# ── status ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_empty_path_asks_for_build(path_id):
|
|
payload = json.loads((await MasteryStatusTool().execute(_mastery_path_id=path_id)).content)
|
|
assert payload["status"] == "empty"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_points_at_first_objective(path_id):
|
|
await _build_basic(path_id)
|
|
payload = json.loads((await MasteryStatusTool().execute(_mastery_path_id=path_id)).content)
|
|
assert payload["status"] == "active"
|
|
assert payload["next"]["action"] == "probe"
|
|
assert payload["next"]["knowledge_point_type"] == "memory"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_path_id_fails_closed():
|
|
result = await MasteryStatusTool().execute(_mastery_path_id="")
|
|
assert result.success is False
|
|
|
|
|
|
# ── quiz + grade: the deterministic objective gate ───────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quiz_then_grade_drives_memory_gate(path_id):
|
|
await _build_basic(path_id)
|
|
status = json.loads((await MasteryStatusTool().execute(_mastery_path_id=path_id)).content)
|
|
kp_id = status["next"]["knowledge_point_id"]
|
|
|
|
quiz, grade = MasteryQuizTool(), MasteryGradeTool()
|
|
mastered = False
|
|
for _ in range(3):
|
|
await quiz.execute(
|
|
_mastery_path_id=path_id,
|
|
knowledge_point_id=kp_id,
|
|
question="2+2?",
|
|
expected_answer="4",
|
|
question_type="short",
|
|
)
|
|
result = json.loads((await grade.execute(_mastery_path_id=path_id, answer="4")).content)
|
|
assert result["is_correct"] is True
|
|
mastered = result["mastered"]
|
|
# 0.5 -> 0.8 -> 1.0 ≥ 0.9: mastered only after the third correct answer.
|
|
assert mastered is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_grade_without_pending_fails(path_id):
|
|
await _build_basic(path_id)
|
|
result = await MasteryGradeTool().execute(_mastery_path_id=path_id, answer="x")
|
|
assert result.success is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quiz_unknown_kp_fails(path_id):
|
|
await _build_basic(path_id)
|
|
result = await MasteryQuizTool().execute(
|
|
_mastery_path_id=path_id,
|
|
knowledge_point_id="nope",
|
|
question="?",
|
|
expected_answer="x",
|
|
)
|
|
assert result.success is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wrong_answer_does_not_master(path_id):
|
|
await _build_basic(path_id)
|
|
status = json.loads((await MasteryStatusTool().execute(_mastery_path_id=path_id)).content)
|
|
kp_id = status["next"]["knowledge_point_id"]
|
|
await MasteryQuizTool().execute(
|
|
_mastery_path_id=path_id, knowledge_point_id=kp_id, question="2+2?", expected_answer="4"
|
|
)
|
|
result = json.loads(
|
|
(await MasteryGradeTool().execute(_mastery_path_id=path_id, answer="5")).content
|
|
)
|
|
assert result["is_correct"] is False
|
|
assert result["mastered"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_grade_syncs_mastery_attempt_to_question_bank(path_id, session_store):
|
|
session = await session_store.create_session(title="Mastery Session")
|
|
await _build_basic(path_id)
|
|
status = json.loads((await MasteryStatusTool().execute(_mastery_path_id=path_id)).content)
|
|
kp_id = status["next"]["knowledge_point_id"]
|
|
await MasteryQuizTool().execute(
|
|
_mastery_path_id=path_id,
|
|
knowledge_point_id=kp_id,
|
|
question="2+2?",
|
|
expected_answer="4",
|
|
question_type="short",
|
|
)
|
|
|
|
result = json.loads(
|
|
(
|
|
await MasteryGradeTool().execute(
|
|
_mastery_path_id=path_id,
|
|
_session_id=session["id"],
|
|
_turn_id="turn_mastery_1",
|
|
answer="5",
|
|
)
|
|
).content
|
|
)
|
|
|
|
assert result["is_correct"] is False
|
|
wrong_entries = await session_store.list_notebook_entries(is_correct=False)
|
|
assert wrong_entries["total"] == 1
|
|
entry = wrong_entries["items"][0]
|
|
assert entry["session_title"] == "Mastery Session"
|
|
assert entry["turn_id"] == "turn_mastery_1"
|
|
assert entry["question"] == "2+2?"
|
|
assert entry["question_type"] == "short_answer"
|
|
assert entry["user_answer"] == "5"
|
|
assert entry["correct_answer"] == "4"
|
|
assert entry["is_correct"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_choice_quiz_rejects_bare_option_labels(path_id):
|
|
await _build_basic(path_id)
|
|
status = json.loads((await MasteryStatusTool().execute(_mastery_path_id=path_id)).content)
|
|
kp_id = status["next"]["knowledge_point_id"]
|
|
|
|
result = await MasteryQuizTool().execute(
|
|
_mastery_path_id=path_id,
|
|
knowledge_point_id=kp_id,
|
|
question="Which order is correct?",
|
|
expected_answer="A",
|
|
question_type="choice",
|
|
options=["A", "B", "C", "D"],
|
|
)
|
|
|
|
assert result.success is False
|
|
assert "full option bodies" in result.content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_choice_quiz_preserves_bodies_and_normalizes_answer(path_id, session_store):
|
|
session = await session_store.create_session(title="Choice Mastery")
|
|
await _build_basic(path_id)
|
|
status = json.loads((await MasteryStatusTool().execute(_mastery_path_id=path_id)).content)
|
|
kp_id = status["next"]["knowledge_point_id"]
|
|
|
|
quiz = await MasteryQuizTool().execute(
|
|
_mastery_path_id=path_id,
|
|
knowledge_point_id=kp_id,
|
|
question="Where is the stop condition added?",
|
|
expected_answer="Step 6",
|
|
question_type="choice",
|
|
options=[
|
|
"A: Step 2 — write the first tool",
|
|
"B: Step 4 — test one call",
|
|
"C: Step 6 — add the stop condition",
|
|
"D: Step 7 — add another tool",
|
|
],
|
|
)
|
|
assert quiz.success is True
|
|
|
|
grade = json.loads(
|
|
(
|
|
await MasteryGradeTool().execute(
|
|
_mastery_path_id=path_id,
|
|
_session_id=session["id"],
|
|
_turn_id="turn_choice_1",
|
|
answer="C",
|
|
)
|
|
).content
|
|
)
|
|
assert grade["is_correct"] is True
|
|
|
|
entries = await session_store.list_notebook_entries()
|
|
entry = entries["items"][0]
|
|
assert entry["options"] == {
|
|
"A": "Step 2 — write the first tool",
|
|
"B": "Step 4 — test one call",
|
|
"C": "Step 6 — add the stop condition",
|
|
"D": "Step 7 — add another tool",
|
|
}
|
|
assert entry["correct_answer"] == "C"
|
|
assert entry["user_answer"] == "C"
|
|
assert entry["is_correct"] is True
|
|
|
|
|
|
# ── assess: the qualitative gate ─────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_assess_passes_concept(path_id):
|
|
await _build_basic(path_id)
|
|
# Drive past the memory objective so status reaches the concept one.
|
|
status = json.loads((await MasteryStatusTool().execute(_mastery_path_id=path_id)).content)
|
|
mem_kp = status["next"]["knowledge_point_id"]
|
|
for _ in range(3):
|
|
await MasteryQuizTool().execute(
|
|
_mastery_path_id=path_id, knowledge_point_id=mem_kp, question="q", expected_answer="a"
|
|
)
|
|
await MasteryGradeTool().execute(_mastery_path_id=path_id, answer="a")
|
|
|
|
status2 = json.loads((await MasteryStatusTool().execute(_mastery_path_id=path_id)).content)
|
|
concept_kp = status2["next"]["knowledge_point_id"]
|
|
assert status2["next"]["action"] == "probe"
|
|
assert status2["next"]["knowledge_point_type"] == "concept"
|
|
|
|
result = json.loads(
|
|
(
|
|
await MasteryAssessTool().execute(
|
|
_mastery_path_id=path_id, knowledge_point_id=concept_kp, passed=True, feedback="ok"
|
|
)
|
|
).content
|
|
)
|
|
assert result["mastered"] is True
|
|
assert result["next"]["action"] == "complete"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_assess_rejects_quantitative_type(path_id):
|
|
await _build_basic(path_id)
|
|
status = json.loads((await MasteryStatusTool().execute(_mastery_path_id=path_id)).content)
|
|
mem_kp = status["next"]["knowledge_point_id"] # a memory objective
|
|
result = await MasteryAssessTool().execute(
|
|
_mastery_path_id=path_id, knowledge_point_id=mem_kp, passed=True
|
|
)
|
|
assert result.success is False
|