Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion marbletutor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ python -m marbletutor search "stars"
Then learn toward it, by name or `mt_` id:

```bash
python -m marbletutor learn "Star Brightness & Distance" --name Sam
python -m marbletutor learn "Star Brightness & Distance" --name Sam --age 9-10
```

- Type to talk to the tutor.
- `/check` — ask the tutor to evaluate mastery of the current objective.
- `/quit` — save progress and exit (resumes next run). Ctrl-C / Ctrl-D also
save before exiting.
- `--age 9-10` (or `--age 9`) pitches the tutoring at the learner's level.
Prerequisite topics below their band become a brisk show-what-you-know
check-in instead of a from-scratch lesson — the mastery bar itself never
changes. Also works on `search` to filter to age-appropriate topics.

Progress is saved to `.marbletutor-state.json` in the current directory
(gitignored); point `--state` elsewhere to keep separate learners.
Expand Down
40 changes: 35 additions & 5 deletions marbletutor/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,26 @@
_DEPS_JSON = str(_REPO_ROOT / "data" / "dependencies.json")


def _parse_age(text: str) -> tuple[int, int]:
"""argparse type for --age: '9' → (9, 9); '9-10' → (9, 10)."""
parts = text.strip().split("-")
try:
if len(parts) == 1:
lo = hi = int(parts[0])
elif len(parts) == 2:
lo, hi = int(parts[0]), int(parts[1])
else:
raise ValueError
if not (3 <= lo <= hi <= 18):
raise ValueError
except ValueError:
raise argparse.ArgumentTypeError(
f"invalid age {text!r} — use a single age like 9 or a range like 9-10 "
f"(ages 3-18)"
) from None
return lo, hi


class ConsoleIO:
def print(self, msg: str) -> None:
print(msg)
Expand Down Expand Up @@ -101,15 +121,19 @@ def run_session(target: str, graph: TaxonomyGraph, store: MasteryStore,
f"say it again, or /quit.]")


def _run_search(query: str) -> int:
def _run_search(query: str, age: tuple[int, int] | None = None) -> int:
graph = TaxonomyGraph.load(_TOPICS_JSON, _DEPS_JSON)
hits = graph.search(query)
hits = graph.search(query, age=age)
if not hits:
print(f"No topics matching {query!r}.", file=sys.stderr)
qualifier = f" for ages {age[0]}-{age[1]} (drop --age to widen)" if age else ""
print(f"No topics matching {query!r}{qualifier}.", file=sys.stderr)
return 1
for t in hits:
print(f"{t.id} {t.name} [{t.subject} / {t.domain}, ages "
f"{t.age_start}-{t.age_end}]")
if age:
print(f"(showing topics overlapping ages {age[0]}-{age[1]}; "
f"drop --age to see all)", file=sys.stderr)
return 0


Expand All @@ -119,14 +143,19 @@ def main(argv: list[str] | None = None) -> int:
learn = sub.add_parser("learn", help="Tutor toward a target topic")
learn.add_argument("target", help="Target topic name or mt_ id")
learn.add_argument("--name", default="you", help="Learner's name")
learn.add_argument("--age", type=_parse_age, default=None,
help="Learner's age (e.g. 9 or 9-10): pitches the tutoring "
"at their level and fast-tracks below-band prerequisites")
learn.add_argument("--model", default=None, help="Override the claude model")
learn.add_argument("--state", default=DEFAULT_STATE, help="Mastery state file")
search = sub.add_parser("search", help="Find topics by name keyword")
search.add_argument("query", help="Substring or approximate topic name")
search.add_argument("--age", type=_parse_age, default=None,
help="Only show topics overlapping this age (e.g. 9-10)")
args = parser.parse_args(argv)

if args.cmd == "search":
return _run_search(args.query)
return _run_search(args.query, age=args.age)

if not claude_available():
print("Error: the `claude` CLI was not found on your PATH. Install Claude "
Expand All @@ -135,7 +164,8 @@ def main(argv: list[str] | None = None) -> int:

graph = TaxonomyGraph.load(_TOPICS_JSON, _DEPS_JSON)
store = MasteryStore.load(args.state)
tutor = ClaudeTutor(load_system_prompt(), learner_name=args.name, model=args.model)
tutor = ClaudeTutor(load_system_prompt(), learner_name=args.name,
model=args.model, learner_age=args.age)
io = ConsoleIO()
now = lambda: datetime.now(timezone.utc).isoformat()
try:
Expand Down
17 changes: 12 additions & 5 deletions marbletutor/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,26 +88,33 @@ def load(cls, topics_path: str, deps_path: str) -> "TaxonomyGraph":
def topic(self, topic_id: str) -> Topic:
return self._topics[topic_id]

def search(self, query: str, limit: int = 15) -> list[Topic]:
def search(self, query: str, limit: int = 15,
age: tuple[int, int] | None = None) -> list[Topic]:
"""Find topics by name: substring matches first, then fuzzy matches.

Case-insensitive; results are deterministic (name-sorted within each
tier) and capped at `limit`."""
tier) and capped at `limit`. With `age=(lo, hi)`, only topics whose
age band overlaps [lo, hi] are returned."""
q = query.strip().lower()
if not q:
return []

def in_band(t: Topic) -> bool:
return age is None or (t.age_start <= age[1] and t.age_end >= age[0])

hits = sorted(
(t for t in self._topics.values() if q in t.name.lower()),
(t for t in self._topics.values() if q in t.name.lower() and in_band(t)),
key=lambda t: (t.name.lower(), t.id),
)
if len(hits) < limit:
seen = {t.id for t in hits}
names = sorted({t.name for t in self._topics.values()})
for name in difflib.get_close_matches(query, names, n=limit, cutoff=0.5):
for tid in self._by_name.get(name.lower(), []):
if tid not in seen:
t = self._topics[tid]
if tid not in seen and in_band(t):
seen.add(tid)
hits.append(self._topics[tid])
hits.append(t)
return hits[:limit]

def resolve(self, name_or_id: str) -> str:
Expand Down
71 changes: 61 additions & 10 deletions marbletutor/tutor.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,31 +103,78 @@ def _name(topic_text: str, learner_name: str) -> str:
return topic_text.replace("{{name}}", learner_name)


def build_teach_prompt(topic: Topic, learner_msg: str, learner_name: str, opening: bool) -> str:
def _age_lines(topic: Topic, learner_age: tuple[int, int] | None) -> tuple[str, str]:
"""(pitch_line, opening_directive) for the learner's age.

Below-band topics switch the opening from teach-from-scratch to a brisk
check-in — an older learner test-outs of foundational material instead of
sitting through a lesson pitched years beneath them."""
if learner_age is None:
return "", ""
lo, hi = learner_age
span = f"{lo}-{hi}" if lo != hi else str(lo)
pitch = (f"The learner is {span} years old — pitch your language, tone and "
f"examples at that age, regardless of the topic's own age range.\n\n")
if topic.age_end < lo:
directive = (
f"NOTE: this topic is normally taught to younger children (ages "
f"{topic.age_start}-{topic.age_end}); for {learner_name_placeholder} it is "
f"foundational review. Do NOT teach it from scratch — briefly frame the "
f"idea in one sentence, then invite them to show what they already know "
f"about it, so they can clear it quickly and move on."
)
else:
directive = ""
return pitch, directive


# `_age_lines` composes the directive before knowing the learner's name; this
# placeholder is substituted by the caller.
learner_name_placeholder = "{{learner}}"


def build_teach_prompt(topic: Topic, learner_msg: str, learner_name: str,
opening: bool, learner_age: tuple[int, int] | None = None) -> str:
if not opening:
return learner_msg
evidence = "\n".join(f"- {e}" for e in topic.evidence)
pitch, directive = _age_lines(topic, learner_age)
directive = directive.replace(learner_name_placeholder, learner_name)
opening_style = (
directive if directive else
f"Start teaching now, from the very beginning: warmly greet {learner_name}, "
f"introduce the idea in one or two plain sentences with a concrete everyday "
f"example, then end with ONE small, inviting question to get them thinking. "
f"Keep it short — this is the opening of a conversation, not a lecture."
)
return (
f"You are beginning a brand-new objective with {learner_name}. This is the "
f"FIRST turn of this objective and {learner_name} has not said anything yet — "
f"so do not thank them, evaluate them, or refer to any answer, because there is "
f"nothing to react to yet.\n\n"
f"{pitch}"
f"Objective: {topic.name} ({topic.type}, ages {topic.age_start}-{topic.age_end})\n"
f"Description: {topic.description}\n\n"
f"Where this is heading (for your guidance only — do not read this list aloud, "
f"quiz against it, or evaluate it now; you will build toward it over several "
f"turns):\n{evidence}\n\n"
f"Start teaching now, from the very beginning: warmly greet {learner_name}, "
f"introduce the idea in one or two plain sentences with a concrete everyday "
f"example, then end with ONE small, inviting question to get them thinking. "
f"Keep it short — this is the opening of a conversation, not a lecture."
f"{opening_style}"
)


def build_gate_prompt(topic: Topic, learner_name: str, strict: bool = False) -> str:
def build_gate_prompt(topic: Topic, learner_name: str, strict: bool = False,
learner_age: tuple[int, int] | None = None) -> str:
evidence = "\n".join(f"- {e}" for e in topic.evidence)
probe = _name(topic.assessment_prompt, learner_name).strip()
probe_line = f"A useful check to have in mind: {probe}\n\n" if probe else ""
if learner_age:
lo, hi = learner_age
span = f"{lo}-{hi}" if lo != hi else str(lo)
probe_line += (
f"The learner is {span} years old — write the feedback pitched at that "
f"age, but judge the evidence exactly as written: the learner's age "
f"does not change the bar.\n\n"
)
strict_note = (
"\nYour previous reply was not valid JSON. Reply with ONLY the JSON object, "
"nothing else.\n" if strict else ""
Expand Down Expand Up @@ -188,11 +235,13 @@ def gate(self) -> Verdict: ...

class ClaudeTutor:
def __init__(self, system_prompt: str, learner_name: str, model: str | None = None,
invoke: Callable[..., tuple[str, str]] | None = None):
invoke: Callable[..., tuple[str, str]] | None = None,
learner_age: tuple[int, int] | None = None):
self._system = system_prompt
self._name = learner_name
self._model = model
self._invoke = invoke or _default_invoke
self._age = learner_age
self._topic: Topic | None = None
self._session_id: str | None = None
self._opened = False
Expand All @@ -216,16 +265,18 @@ def _require_topic(self) -> Topic:
def teach(self, learner_msg: str) -> str:
topic = self._require_topic()
prompt = build_teach_prompt(topic, learner_msg, self._name,
opening=not self._opened)
opening=not self._opened,
learner_age=self._age)
text = self._run(prompt)
self._opened = True
return text

def gate(self) -> Verdict:
topic = self._require_topic()
text = self._run(build_gate_prompt(topic, self._name))
text = self._run(build_gate_prompt(topic, self._name, learner_age=self._age))
verdict = parse_verdict(text)
if verdict is None:
text = self._run(build_gate_prompt(topic, self._name, strict=True))
text = self._run(build_gate_prompt(topic, self._name, strict=True,
learner_age=self._age))
verdict = parse_verdict(text)
return verdict or Verdict(False, {}, "I couldn't evaluate that yet — let's keep going.")
54 changes: 54 additions & 0 deletions tests/test_cli_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,57 @@ def test_main_search_no_match_returns_1(monkeypatch, capsys):
rc = cli.main(["search", "zzzzqqqq"])
assert rc == 1
assert "no topics" in capsys.readouterr().err.lower()


def test_parse_age_accepts_single_and_range():
from marbletutor.cli import _parse_age
assert _parse_age("9") == (9, 9)
assert _parse_age("9-10") == (9, 10)


def test_parse_age_rejects_garbage():
import argparse
from marbletutor.cli import _parse_age
for bad in ("banana", "10-9", "1-99", "9-10-11", ""):
with pytest.raises(argparse.ArgumentTypeError):
_parse_age(bad)


def test_main_learn_threads_age_to_tutor(tmp_path, monkeypatch):
from marbletutor import cli
from marbletutor.graph import Topic, TaxonomyGraph
topics = {"id1": Topic("id1", "CONCEPTUAL", "S", "D", "Solo", "d", 5, 7, ("e",), "p")}
fake_graph = TaxonomyGraph(topics, {}, {})
captured = {}

class FakeTutor:
def __init__(self, *a, **kw):
captured.update(kw)

class DeclineIO:
def print(self, msg): pass
def input(self, prompt): return "/quit"
def confirm(self, prompt): return False

monkeypatch.setattr(cli, "claude_available", lambda: True)
monkeypatch.setattr(cli.TaxonomyGraph, "load", lambda *a, **k: fake_graph)
monkeypatch.setattr(cli, "ClaudeTutor", FakeTutor)
monkeypatch.setattr(cli, "ConsoleIO", DeclineIO)
rc = cli.main(["learn", "Solo", "--age", "9-10", "--state", str(tmp_path / "s.json")])
assert rc == 0
assert captured.get("learner_age") == (9, 10)


def test_main_search_age_filters(monkeypatch, capsys):
from marbletutor import cli
from marbletutor.graph import Topic, TaxonomyGraph
topics = {
"young": Topic("young", "CONCEPTUAL", "S", "D", "Stars for tots", "d", 5, 7, ("e",), "p"),
"older": Topic("older", "CONCEPTUAL", "S", "D", "Stars for kids", "d", 9, 11, ("e",), "p"),
}
monkeypatch.setattr(cli.TaxonomyGraph, "load",
lambda *a, **k: TaxonomyGraph(topics, {}, {}))
rc = cli.main(["search", "stars", "--age", "9-10"])
out = capsys.readouterr().out
assert rc == 0
assert "older" in out and "young" not in out
12 changes: 12 additions & 0 deletions tests/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,15 @@ def test_search_falls_back_to_fuzzy():

def test_search_empty_query_returns_nothing():
assert graph().search(" ") == []


def test_search_age_filter_keeps_only_overlapping_bands():
g = graph()
hits = g.search("dinosaur", age=(9, 10))
assert hits
assert all(t.age_start <= 10 and t.age_end >= 9 for t in hits)


def test_search_without_age_is_unfiltered():
g = graph()
assert len(g.search("dinosaur")) >= len(g.search("dinosaur", age=(9, 10)))
41 changes: 41 additions & 0 deletions tests/test_tutor_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,44 @@ class P:
text, sid = tutor_mod._default_invoke("hi", "sys", None, None)
assert seen.get("stdin") == sp.DEVNULL
assert (text, sid) == ("ok", "s")


def test_teach_opening_pitches_at_learner_age():
p = build_teach_prompt(TOPIC, "", learner_name="Sam", opening=True,
learner_age=(9, 10))
assert "9-10" in p # learner age stated
assert "foundational" in p.lower() # 4-6 topic is below a 9-10 learner
assert "already know" in p.lower() # test-out invitation


def test_teach_opening_age_matched_topic_has_no_testout():
p = build_teach_prompt(TOPIC, "", learner_name="Sam", opening=True,
learner_age=(4, 6))
assert "4-6" in p
assert "foundational" not in p.lower() # in-band: teach normally


def test_teach_opening_without_age_unchanged():
p = build_teach_prompt(TOPIC, "", learner_name="Sam", opening=True)
assert "foundational" not in p.lower()
assert "first turn" in p.lower() # existing framing intact


def test_gate_prompt_age_never_lowers_the_bar():
p = build_gate_prompt(TOPIC, learner_name="Sam", learner_age=(9, 10))
assert "9-10" in p
assert "does not change the bar" in p.lower()


def test_claude_tutor_threads_age_into_prompts():
seen = []
def fake_invoke(prompt, system, session_id, model):
seen.append(prompt)
return ("ok", "s")
t = ClaudeTutor("SYS", learner_name="Sam", learner_age=(9, 10),
invoke=fake_invoke)
t.start_topic(TOPIC)
t.teach("")
t.gate()
assert "9-10" in seen[0] # teach opening carries the age
assert "9-10" in seen[1] # gate carries the age