File size: 9,385 Bytes
a7ffb22 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 | """The drafting tool, with a fake AI client: nothing here calls the network or spends money."""
import copy
import json
import anthropic
import httpx
import pytest
from mathly2.bank import BANK_PATH, check_bank, load_bank
from mathly2.scripts import draft_examples as tool
@pytest.fixture(autouse=True)
def the_real_bank_is_never_touched():
"""A test that writes to the real bank would add fake examples to the app's content: fail loudly."""
before = BANK_PATH.read_bytes()
yield
assert BANK_PATH.read_bytes() == before, "a test changed the real exercise bank"
@pytest.fixture
def data():
return copy.deepcopy(load_bank().data)
def good_model_answer():
return {"worked_example": {"question": "$x虏 + 5x + 6$", "answer": "$(x + 2)(x + 3)$", "steps": [
{"do": "诪讜爪讗讬诐 2 讜志3", "why": "讻讬 讛诪讻驻诇讛 6 讜讛住讻讜诐 5"},
{"do": "讻讜转讘讬诐 $(x + 2)(x + 3)$", "why": "讻讱 诪驻专拽讬诐 讟专讬谞讜诐 诇讙讜专诪讬诐"}]}}
class Block:
type = "text"
def __init__(self, text):
self.text = text
class Response:
def __init__(self, text, stop="end_turn"):
self.content, self.stop_reason = [Block(text)], stop
class FakeClient:
def __init__(self, *answers):
self.answers, self.requests = list(answers), []
self.messages = self
def create(self, **kwargs):
self.requests.append(kwargs)
answer = self.answers.pop(0)
if isinstance(answer, Exception):
raise answer
return answer if isinstance(answer, Response) else Response(json.dumps(answer, ensure_ascii=False))
def first_without_example(data, lesson="t"):
return tool.skills_without_examples(data, lesson)[0]
# --- draft ---
def test_a_good_draft_is_in_the_bank_shape_and_has_no_problems(data):
skill = first_without_example(data)
client = FakeClient(good_model_answer())
review = tool.draft_lesson(client, "m", data, "t", only=[skill["id"]])
entry = review["drafts"][skill["id"]]
assert entry["problems"] == [] and entry["approved"] is False and entry["reviewed_by"] == ""
assert set(entry["example"]) == {"worked_example"} and entry["example"]["worked_example"]["steps"][0]["why"] == "讻讬 讛诪讻驻诇讛 6 讜讛住讻讜诐 5"
assert client.requests[0]["system"] == tool.SYSTEM
def test_the_schema_asks_for_one_plain_text_example_and_no_faded_ones():
schema = tool.EXAMPLE_SCHEMA
assert schema["required"] == ["worked_example"]
step = schema["properties"]["worked_example"]["properties"]["steps"]["items"]["properties"]
assert step["why"] == {"type": "string"} and "faded_examples" not in json.dumps(schema)
def test_the_request_shows_the_skill_and_its_common_mistakes(data):
skill = first_without_example(data)
client = FakeClient(good_model_answer())
tool.draft_lesson(client, "m", data, "t", only=[skill["id"]])
sent = json.loads(client.requests[0]["messages"][0]["content"])
assert sent["skill"] == skill["name"] and 0 < len(sent["practice_questions"]) <= tool.SAMPLE_QUESTIONS
assert sent["common_mistakes"] and all({"name", "tip"} <= set(m) for m in sent["common_mistakes"])
def test_a_draft_that_breaks_the_bank_rules_is_flagged(data):
skill = first_without_example(data)
answer = good_model_answer()
answer["worked_example"]["steps"][0]["why"] = ""
answer["worked_example"]["answer"] = ""
entry = tool.draft_lesson(FakeClient(answer), "m", data, "t", only=[skill["id"]])["drafts"][skill["id"]]
assert any("needs a question, steps and an answer" in p for p in entry["problems"])
assert any("step 1: 'why' must be text" in p for p in entry["problems"])
def test_a_failed_request_becomes_a_problem_not_a_crash(data):
skills = tool.skills_without_examples(data, "t")[:3]
error = anthropic.APIConnectionError(request=httpx.Request("POST", "https://api.test"))
client = FakeClient(error, Response("{}", stop="refusal"), Response("not json"))
review = tool.draft_lesson(client, "m", data, "t", only=[s["id"] for s in skills])
problems = [review["drafts"][s["id"]]["problems"][0] for s in skills]
assert all(p.startswith("no draft:") for p in problems)
assert "stop_reason=refusal" in problems[1] and "not the expected JSON" in problems[2]
assert all(review["drafts"][s["id"]]["example"] is None for s in skills)
def test_limit_and_only_choose_the_skills(data):
ids = [s["id"] for s in tool.skills_without_examples(data, "t")]
assert len(tool.draft_lesson(FakeClient(*[good_model_answer()] * 2), "m", data, "t", limit=2)["drafts"]) == 2
only = tool.draft_lesson(FakeClient(good_model_answer()), "m", data, "t", only=[ids[1]])
assert list(only["drafts"]) == [ids[1]]
def test_a_skill_that_already_has_a_solved_example_is_not_drafted(data):
skill = first_without_example(data)
skill["worked_example"] = good_model_answer()["worked_example"]
assert skill["id"] not in [s["id"] for s in tool.skills_without_examples(data, "t")]
# --- merge ---
@pytest.fixture
def bank_copy(tmp_path, monkeypatch):
path = tmp_path / "bank.json"
path.write_text(BANK_PATH.read_text(encoding="utf-8"), encoding="utf-8")
monkeypatch.setattr(tool, "BANK_PATH", path)
return path
def three(data):
return [s["id"] for s in tool.skills_without_examples(data, "t")[:3]]
def review_file(tmp_path, data, approve, reviewer="诪讜专讛"):
drafts = {sid: {"approved": sid in approve, "reviewed_by": reviewer if sid in approve else "",
"example": tool.clean(good_model_answer()), "problems": []} for sid in three(data)}
path = tmp_path / "review.json"
path.write_text(json.dumps({"drafts": drafts}, ensure_ascii=False), encoding="utf-8")
return path
def test_merge_adds_only_the_approved_drafts_and_the_bank_still_loads(tmp_path, data, bank_copy, capsys):
ids = three(data)
assert tool.main(["merge", str(review_file(tmp_path, data, {ids[0]}))]) == 0
merged = json.loads(bank_copy.read_text(encoding="utf-8"))
by_id = {s["id"]: s for s in merged["skills"]}
assert by_id[ids[0]]["worked_example"]["steps"][0]["why"] == "讻讬 讛诪讻驻诇讛 6 讜讛住讻讜诐 5"
assert "worked_example" not in by_id[ids[1]] and "worked_example" not in by_id[ids[2]]
assert check_bank(merged) == [] and load_bank(bank_copy)
out = capsys.readouterr().out
assert f"added solved examples to bank.json: {ids[0]}" in out and f"{ids[1]}: not approved" in out
def test_merge_leaves_out_an_approval_without_a_reviewer_name(tmp_path, data, bank_copy, capsys):
before = bank_copy.read_text(encoding="utf-8")
assert tool.main(["merge", str(review_file(tmp_path, data, {three(data)[0]}, reviewer=" "))]) == 1
assert "approved without the reviewer's name" in capsys.readouterr().out
assert bank_copy.read_text(encoding="utf-8") == before
def test_merge_refuses_a_draft_that_breaks_the_rules_even_if_approved(tmp_path, data, bank_copy, capsys):
ids = three(data)
path = review_file(tmp_path, data, {ids[0]})
review = json.loads(path.read_text(encoding="utf-8"))
review["drafts"][ids[0]]["example"]["worked_example"]["steps"][0]["why"] = ""
path.write_text(json.dumps(review, ensure_ascii=False), encoding="utf-8")
before = bank_copy.read_text(encoding="utf-8")
assert tool.main(["merge", str(path)]) == 1
assert "'why' must be text" in capsys.readouterr().out
assert bank_copy.read_text(encoding="utf-8") == before
def test_merge_never_overwrites_a_skill_that_already_has_a_solved_example(tmp_path, data, bank_copy, capsys):
path = review_file(tmp_path, data, {three(data)[0]})
assert tool.main(["merge", str(path)]) == 0
assert tool.main(["merge", str(path)]) == 1 # the same review again
assert "already has a solved example" in capsys.readouterr().out
def test_writing_the_bank_keeps_the_files_own_format():
assert BANK_PATH.read_text(encoding="utf-8") == json.dumps(load_bank().data, ensure_ascii=False, indent=1) + "\n"
# --- the command line ---
def test_dry_run_sends_nothing_and_needs_no_key(monkeypatch, capsys):
def boom(*args, **kwargs):
raise AssertionError("the dry run must not create a client")
monkeypatch.setattr(anthropic, "Anthropic", boom)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_API_KEY", raising=False)
before = BANK_PATH.read_bytes()
assert tool.main(["draft", "--lesson", "t", "--dry-run"]) == 0
out = capsys.readouterr().out
assert "5 skills without a solved example" in out and "nothing was sent" in out and "system prompt" in out
assert BANK_PATH.read_bytes() == before
def test_draft_without_a_key_or_with_a_bad_lesson_or_over_a_review_stops(monkeypatch, capsys, tmp_path):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_API_KEY", raising=False)
assert tool.main(["draft", "--lesson", "nope"]) == 2
assert tool.main(["draft", "--lesson", "t"]) == 2
assert "ANTHROPIC_API_KEY" in capsys.readouterr().err
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
existing = tmp_path / "review.json"
existing.write_text("{}", encoding="utf-8")
assert tool.main(["draft", "--lesson", "t", "--out", str(existing)]) == 2 # may hold a teacher's review
assert existing.read_text(encoding="utf-8") == "{}"
|