diff --git a/marbletutor/README.md b/marbletutor/README.md index 6a0b05b..991f353 100644 --- a/marbletutor/README.md +++ b/marbletutor/README.md @@ -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. diff --git a/marbletutor/cli.py b/marbletutor/cli.py index 5312396..527be5a 100644 --- a/marbletutor/cli.py +++ b/marbletutor/cli.py @@ -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) @@ -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 @@ -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 " @@ -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: diff --git a/marbletutor/graph.py b/marbletutor/graph.py index f09359b..1910a4e 100644 --- a/marbletutor/graph.py +++ b/marbletutor/graph.py @@ -88,16 +88,22 @@ 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: @@ -105,9 +111,10 @@ def search(self, query: str, limit: int = 15) -> list[Topic]: 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: diff --git a/marbletutor/tutor.py b/marbletutor/tutor.py index 5c5be47..28412cc 100644 --- a/marbletutor/tutor.py +++ b/marbletutor/tutor.py @@ -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 "" @@ -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 @@ -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.") diff --git a/tests/test_cli_loop.py b/tests/test_cli_loop.py index 5ded014..97e0ba4 100644 --- a/tests/test_cli_loop.py +++ b/tests/test_cli_loop.py @@ -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 diff --git a/tests/test_graph.py b/tests/test_graph.py index 778ffd9..d72cd8d 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -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))) diff --git a/tests/test_tutor_driver.py b/tests/test_tutor_driver.py index 2259459..6476047 100644 --- a/tests/test_tutor_driver.py +++ b/tests/test_tutor_driver.py @@ -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