diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-28 13:12:57 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-28 13:12:57 -0400 |
| commit | 6be62aee7437fd8fe8d6eff991869b09529d3924 (patch) | |
| tree | 0360a199e0152541a27c6db21daab88c1d657b5c | |
| parent | 6fb6797fb6dfa2d468f873c000b64eb6ef6accb6 (diff) | |
| download | rulesets-6be62aee7437fd8fe8d6eff991869b09529d3924.tar.gz rulesets-6be62aee7437fd8fe8d6eff991869b09529d3924.zip | |
feat(scripts): add wrap-up routing recommendation engine
I added route_recommend.py, a pure recommend(item, projects) → (destination, confidence). It has strong, weak, and none tiers, word-boundary literal matching that also handles dot-stripped name aliases, and a deterministic tie-break that downgrades an ambiguous top-tier tie to weak. An empty candidate list yields none. The CLI reuses inbox-send's discover_projects, so the candidate set is the same project universe inbox-send already knows. This covers Phases 1 and 3 of the wrap-up routing spec. The marker and router sub-tasks call it next.
| -rw-r--r-- | .ai/scripts/route_recommend.py | 136 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_route_recommend.py | 124 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/route_recommend.py | 136 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_route_recommend.py | 124 | ||||
| -rw-r--r-- | todo.org | 4 |
5 files changed, 522 insertions, 2 deletions
diff --git a/.ai/scripts/route_recommend.py b/.ai/scripts/route_recommend.py new file mode 100644 index 0000000..7b36405 --- /dev/null +++ b/.ai/scripts/route_recommend.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Wrap-up routing recommendation engine. + +Given an inbox keeper's text and a list of candidate project names, infer which +project the item belongs to, with a confidence tier: + + strong a project's name (or its dot-stripped form, or a path containing it) + appears literally in the item + weak a distinctive name token overlaps, but the full name doesn't + none no overlap; the item stays put + +A multi-way tie at the top tier is ambiguous, so it downgrades to weak with a +deterministic pick (most token overlap, then alphabetical). An empty candidate +list yields none. + +The pure core is `recommend(item, projects) -> (destination, confidence)` — the +shape the wrap-up router (Phase 4) and the process-inbox marker (Phase 2) both +call. The CLI wires it to inbox-send.py's `discover_projects` so the candidate +set is the same project universe inbox-send already knows. + +CLI: + route_recommend.py --item "<text>" [--exclude <current-project>] +prints "<destination>\\t<confidence>" on a match, or "none". +""" + +import argparse +import importlib.util +import re +import sys +from pathlib import Path + +# A distinctive-enough token for weak matching; shorter tokens (of, to, id) are +# too noisy to route on. +MIN_WEAK_TOKEN = 4 + +_TOKEN_RE = re.compile(r"[a-z0-9]+") + + +def _tokens(text: str) -> set[str]: + return set(_TOKEN_RE.findall(text.lower())) + + +def _name_variants(name: str) -> set[str]: + """A project name and its dot-stripped alias (.emacs.d -> emacsd).""" + return {v for v in (name.lower(), name.replace(".", "").lower()) if v} + + +def _literal_present(name: str, item_lower: str) -> bool: + """True if a name variant appears in the item on word-ish boundaries. + + Boundaries keep 'home' from matching inside 'homeowner' while still + matching it inside a path ('~/code/home/...') or a hyphenated name. + """ + for variant in _name_variants(name): + if re.search(r"(?<![a-z0-9])" + re.escape(variant) + r"(?![a-z0-9])", item_lower): + return True + return False + + +def _tiebreak(candidates: list[str], item_tokens: set[str]) -> str: + """Most token overlap first, then alphabetical — deterministic.""" + return sorted(candidates, key=lambda p: (-len(_tokens(p) & item_tokens), p))[0] + + +def recommend(item: str, projects: list[str]) -> tuple[str | None, str]: + """Infer the destination project for `item` from `projects`. + + Returns (destination, confidence). confidence is "strong" / "weak" / "none"; + destination is None exactly when confidence is "none". + """ + if not projects: + return (None, "none") + + item_lower = item.lower() + item_tokens = _tokens(item) + + strong: list[str] = [] + weak: list[str] = [] + for project in projects: + if _literal_present(project, item_lower): + strong.append(project) + continue + name_tokens = {t for t in _tokens(project) if len(t) >= MIN_WEAK_TOKEN} + if name_tokens & item_tokens: + weak.append(project) + + if len(strong) == 1: + return (strong[0], "strong") + if len(strong) > 1: + return (_tiebreak(strong, item_tokens), "weak") + if len(weak) == 1: + return (weak[0], "weak") + if len(weak) > 1: + return (_tiebreak(weak, item_tokens), "weak") + return (None, "none") + + +def _load_inbox_send(): + """Load the sibling kebab-named inbox-send.py as a module for its discovery.""" + path = Path(__file__).with_name("inbox-send.py") + spec = importlib.util.spec_from_file_location("inbox_send", path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def discover_destination_names(exclude: str | None = None) -> list[str]: + """The candidate project names, reusing inbox-send's discovery. + + `exclude` drops the current project (matched by exact name or dot-stripped + alias) so the engine never recommends routing an item to where it already is. + """ + mod = _load_inbox_send() + names = [p.name for p in mod.discover_projects(mod.resolve_roots())] + if exclude: + drop = _name_variants(exclude) + names = [n for n in names if not (_name_variants(n) & drop)] + return names + + +def main() -> int: + parser = argparse.ArgumentParser(description="Recommend a routing destination for an inbox keeper.") + parser.add_argument("--item", required=True, help="the keeper's text") + parser.add_argument("--exclude", help="current project to exclude from candidates") + args = parser.parse_args() + + projects = discover_destination_names(exclude=args.exclude) + destination, confidence = recommend(args.item, projects) + print("none" if destination is None else f"{destination}\t{confidence}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.ai/scripts/tests/test_route_recommend.py b/.ai/scripts/tests/test_route_recommend.py new file mode 100644 index 0000000..acc4755 --- /dev/null +++ b/.ai/scripts/tests/test_route_recommend.py @@ -0,0 +1,124 @@ +"""Tests for route_recommend.py — the wrap-up routing recommendation engine. + +The core is a pure function recommend(item, projects) -> (destination, confidence): +- strong: a project's name (or its dot-stripped form) appears literally in the item +- weak: a distinctive name token overlaps, but the full name doesn't +- none: no overlap; the item stays put (destination is None) + +A multi-way tie at the top tier downgrades to weak with a deterministic pick. +An empty project list yields none. + +The CLI wires this to inbox-send.py's discover_projects (sandboxed here via the +INBOX_SEND_ROOTS env var, the same hook inbox-send's own tests use). +""" + +import subprocess +import sys +from pathlib import Path + +SCRIPTS = Path(__file__).parent.parent +SCRIPT = SCRIPTS / "route_recommend.py" +sys.path.insert(0, str(SCRIPTS)) + +import route_recommend as rr # noqa: E402 + + +# --- pure function: the five spec'd cases ----------------------------------- + +def test_strong_match_named_literally(): + dest, conf = rr.recommend("fix the rulesets refactor command", ["rulesets", "home", "work"]) + assert (dest, conf) == ("rulesets", "strong") + + +def test_strong_match_via_dot_stripped_name(): + # ".emacs.d" addressed as "emacsd" in the item is still a literal hit. + dest, conf = rr.recommend("update the emacsd ai-term module", [".emacs.d", "rulesets"]) + assert (dest, conf) == (".emacs.d", "strong") + + +def test_strong_match_dotted_name_verbatim(): + dest, conf = rr.recommend("patch .emacs.d startup", [".emacs.d", "rulesets"]) + assert (dest, conf) == (".emacs.d", "strong") + + +def test_weak_match_topic_token_only(): + # "wttrin" is a token of "emacs-wttrin" but the full name isn't present. + dest, conf = rr.recommend("the wttrin weather bug", ["emacs-wttrin", "rulesets"]) + assert (dest, conf) == ("emacs-wttrin", "weak") + + +def test_no_match_stays_put(): + dest, conf = rr.recommend("calibrate the telescope mount", ["rulesets", "deepsat"]) + assert dest is None + assert conf == "none" + + +def test_two_project_strong_tie_downgrades_to_weak(): + # Both named literally → ambiguous → weak, deterministic tie-break (alphabetical). + dest, conf = rr.recommend("sync rulesets and home configs", ["rulesets", "home", "work"]) + assert conf == "weak" + assert dest == "home" # tie-break: most-overlap then alphabetical + + +def test_empty_project_list_is_none(): + assert rr.recommend("anything at all", []) == (None, "none") + + +# --- boundary / robustness -------------------------------------------------- + +def test_literal_name_requires_word_boundary(): + # "home" must not match inside "homeowner". + dest, conf = rr.recommend("the homeowner association meeting", ["home", "rulesets"]) + assert dest is None and conf == "none" + + +def test_path_mention_counts_as_literal(): + dest, conf = rr.recommend("edit ~/code/rulesets/Makefile", ["rulesets", "home"]) + assert (dest, conf) == ("rulesets", "strong") + + +def test_strong_beats_weak_when_both_present(): + # "rulesets" named literally (strong) outranks an emacs-wttrin token hit (weak). + dest, conf = rr.recommend("the wttrin fix belongs in rulesets", ["rulesets", "emacs-wttrin"]) + assert (dest, conf) == ("rulesets", "strong") + + +# --- CLI + discovery reuse (sandboxed roots) -------------------------------- + +def _run(args, roots, item): + import os + env = {"PATH": os.environ.get("PATH", ""), "HOME": os.environ.get("HOME", "/tmp"), + "INBOX_SEND_ROOTS": ":".join(str(r) for r in roots)} + return subprocess.run([sys.executable, str(SCRIPT), "--item", item, *args], + capture_output=True, text=True, env=env) + + +def _mk_project(tmp_path, name): + proj = tmp_path / "projects" / name + (proj / ".ai").mkdir(parents=True, exist_ok=True) + (proj / "inbox").mkdir(exist_ok=True) + return proj + + +def test_cli_discovers_and_recommends(tmp_path): + _mk_project(tmp_path, "foo") + _mk_project(tmp_path, "bar") + r = _run([], roots=[tmp_path / "projects"], item="fix the foo widget") + assert r.returncode == 0 + assert r.stdout.strip() == "foo\tstrong" + + +def test_cli_no_match_prints_none(tmp_path): + _mk_project(tmp_path, "foo") + r = _run([], roots=[tmp_path / "projects"], item="unrelated grocery list") + assert r.returncode == 0 + assert r.stdout.strip() == "none" + + +def test_cli_exclude_drops_current_project(tmp_path): + _mk_project(tmp_path, "foo") + _mk_project(tmp_path, "bar") + # Item names foo, but foo is excluded as the current project → no other match. + r = _run(["--exclude", "foo"], roots=[tmp_path / "projects"], item="fix the foo widget") + assert r.returncode == 0 + assert r.stdout.strip() == "none" diff --git a/claude-templates/.ai/scripts/route_recommend.py b/claude-templates/.ai/scripts/route_recommend.py new file mode 100644 index 0000000..7b36405 --- /dev/null +++ b/claude-templates/.ai/scripts/route_recommend.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Wrap-up routing recommendation engine. + +Given an inbox keeper's text and a list of candidate project names, infer which +project the item belongs to, with a confidence tier: + + strong a project's name (or its dot-stripped form, or a path containing it) + appears literally in the item + weak a distinctive name token overlaps, but the full name doesn't + none no overlap; the item stays put + +A multi-way tie at the top tier is ambiguous, so it downgrades to weak with a +deterministic pick (most token overlap, then alphabetical). An empty candidate +list yields none. + +The pure core is `recommend(item, projects) -> (destination, confidence)` — the +shape the wrap-up router (Phase 4) and the process-inbox marker (Phase 2) both +call. The CLI wires it to inbox-send.py's `discover_projects` so the candidate +set is the same project universe inbox-send already knows. + +CLI: + route_recommend.py --item "<text>" [--exclude <current-project>] +prints "<destination>\\t<confidence>" on a match, or "none". +""" + +import argparse +import importlib.util +import re +import sys +from pathlib import Path + +# A distinctive-enough token for weak matching; shorter tokens (of, to, id) are +# too noisy to route on. +MIN_WEAK_TOKEN = 4 + +_TOKEN_RE = re.compile(r"[a-z0-9]+") + + +def _tokens(text: str) -> set[str]: + return set(_TOKEN_RE.findall(text.lower())) + + +def _name_variants(name: str) -> set[str]: + """A project name and its dot-stripped alias (.emacs.d -> emacsd).""" + return {v for v in (name.lower(), name.replace(".", "").lower()) if v} + + +def _literal_present(name: str, item_lower: str) -> bool: + """True if a name variant appears in the item on word-ish boundaries. + + Boundaries keep 'home' from matching inside 'homeowner' while still + matching it inside a path ('~/code/home/...') or a hyphenated name. + """ + for variant in _name_variants(name): + if re.search(r"(?<![a-z0-9])" + re.escape(variant) + r"(?![a-z0-9])", item_lower): + return True + return False + + +def _tiebreak(candidates: list[str], item_tokens: set[str]) -> str: + """Most token overlap first, then alphabetical — deterministic.""" + return sorted(candidates, key=lambda p: (-len(_tokens(p) & item_tokens), p))[0] + + +def recommend(item: str, projects: list[str]) -> tuple[str | None, str]: + """Infer the destination project for `item` from `projects`. + + Returns (destination, confidence). confidence is "strong" / "weak" / "none"; + destination is None exactly when confidence is "none". + """ + if not projects: + return (None, "none") + + item_lower = item.lower() + item_tokens = _tokens(item) + + strong: list[str] = [] + weak: list[str] = [] + for project in projects: + if _literal_present(project, item_lower): + strong.append(project) + continue + name_tokens = {t for t in _tokens(project) if len(t) >= MIN_WEAK_TOKEN} + if name_tokens & item_tokens: + weak.append(project) + + if len(strong) == 1: + return (strong[0], "strong") + if len(strong) > 1: + return (_tiebreak(strong, item_tokens), "weak") + if len(weak) == 1: + return (weak[0], "weak") + if len(weak) > 1: + return (_tiebreak(weak, item_tokens), "weak") + return (None, "none") + + +def _load_inbox_send(): + """Load the sibling kebab-named inbox-send.py as a module for its discovery.""" + path = Path(__file__).with_name("inbox-send.py") + spec = importlib.util.spec_from_file_location("inbox_send", path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def discover_destination_names(exclude: str | None = None) -> list[str]: + """The candidate project names, reusing inbox-send's discovery. + + `exclude` drops the current project (matched by exact name or dot-stripped + alias) so the engine never recommends routing an item to where it already is. + """ + mod = _load_inbox_send() + names = [p.name for p in mod.discover_projects(mod.resolve_roots())] + if exclude: + drop = _name_variants(exclude) + names = [n for n in names if not (_name_variants(n) & drop)] + return names + + +def main() -> int: + parser = argparse.ArgumentParser(description="Recommend a routing destination for an inbox keeper.") + parser.add_argument("--item", required=True, help="the keeper's text") + parser.add_argument("--exclude", help="current project to exclude from candidates") + args = parser.parse_args() + + projects = discover_destination_names(exclude=args.exclude) + destination, confidence = recommend(args.item, projects) + print("none" if destination is None else f"{destination}\t{confidence}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/claude-templates/.ai/scripts/tests/test_route_recommend.py b/claude-templates/.ai/scripts/tests/test_route_recommend.py new file mode 100644 index 0000000..acc4755 --- /dev/null +++ b/claude-templates/.ai/scripts/tests/test_route_recommend.py @@ -0,0 +1,124 @@ +"""Tests for route_recommend.py — the wrap-up routing recommendation engine. + +The core is a pure function recommend(item, projects) -> (destination, confidence): +- strong: a project's name (or its dot-stripped form) appears literally in the item +- weak: a distinctive name token overlaps, but the full name doesn't +- none: no overlap; the item stays put (destination is None) + +A multi-way tie at the top tier downgrades to weak with a deterministic pick. +An empty project list yields none. + +The CLI wires this to inbox-send.py's discover_projects (sandboxed here via the +INBOX_SEND_ROOTS env var, the same hook inbox-send's own tests use). +""" + +import subprocess +import sys +from pathlib import Path + +SCRIPTS = Path(__file__).parent.parent +SCRIPT = SCRIPTS / "route_recommend.py" +sys.path.insert(0, str(SCRIPTS)) + +import route_recommend as rr # noqa: E402 + + +# --- pure function: the five spec'd cases ----------------------------------- + +def test_strong_match_named_literally(): + dest, conf = rr.recommend("fix the rulesets refactor command", ["rulesets", "home", "work"]) + assert (dest, conf) == ("rulesets", "strong") + + +def test_strong_match_via_dot_stripped_name(): + # ".emacs.d" addressed as "emacsd" in the item is still a literal hit. + dest, conf = rr.recommend("update the emacsd ai-term module", [".emacs.d", "rulesets"]) + assert (dest, conf) == (".emacs.d", "strong") + + +def test_strong_match_dotted_name_verbatim(): + dest, conf = rr.recommend("patch .emacs.d startup", [".emacs.d", "rulesets"]) + assert (dest, conf) == (".emacs.d", "strong") + + +def test_weak_match_topic_token_only(): + # "wttrin" is a token of "emacs-wttrin" but the full name isn't present. + dest, conf = rr.recommend("the wttrin weather bug", ["emacs-wttrin", "rulesets"]) + assert (dest, conf) == ("emacs-wttrin", "weak") + + +def test_no_match_stays_put(): + dest, conf = rr.recommend("calibrate the telescope mount", ["rulesets", "deepsat"]) + assert dest is None + assert conf == "none" + + +def test_two_project_strong_tie_downgrades_to_weak(): + # Both named literally → ambiguous → weak, deterministic tie-break (alphabetical). + dest, conf = rr.recommend("sync rulesets and home configs", ["rulesets", "home", "work"]) + assert conf == "weak" + assert dest == "home" # tie-break: most-overlap then alphabetical + + +def test_empty_project_list_is_none(): + assert rr.recommend("anything at all", []) == (None, "none") + + +# --- boundary / robustness -------------------------------------------------- + +def test_literal_name_requires_word_boundary(): + # "home" must not match inside "homeowner". + dest, conf = rr.recommend("the homeowner association meeting", ["home", "rulesets"]) + assert dest is None and conf == "none" + + +def test_path_mention_counts_as_literal(): + dest, conf = rr.recommend("edit ~/code/rulesets/Makefile", ["rulesets", "home"]) + assert (dest, conf) == ("rulesets", "strong") + + +def test_strong_beats_weak_when_both_present(): + # "rulesets" named literally (strong) outranks an emacs-wttrin token hit (weak). + dest, conf = rr.recommend("the wttrin fix belongs in rulesets", ["rulesets", "emacs-wttrin"]) + assert (dest, conf) == ("rulesets", "strong") + + +# --- CLI + discovery reuse (sandboxed roots) -------------------------------- + +def _run(args, roots, item): + import os + env = {"PATH": os.environ.get("PATH", ""), "HOME": os.environ.get("HOME", "/tmp"), + "INBOX_SEND_ROOTS": ":".join(str(r) for r in roots)} + return subprocess.run([sys.executable, str(SCRIPT), "--item", item, *args], + capture_output=True, text=True, env=env) + + +def _mk_project(tmp_path, name): + proj = tmp_path / "projects" / name + (proj / ".ai").mkdir(parents=True, exist_ok=True) + (proj / "inbox").mkdir(exist_ok=True) + return proj + + +def test_cli_discovers_and_recommends(tmp_path): + _mk_project(tmp_path, "foo") + _mk_project(tmp_path, "bar") + r = _run([], roots=[tmp_path / "projects"], item="fix the foo widget") + assert r.returncode == 0 + assert r.stdout.strip() == "foo\tstrong" + + +def test_cli_no_match_prints_none(tmp_path): + _mk_project(tmp_path, "foo") + r = _run([], roots=[tmp_path / "projects"], item="unrelated grocery list") + assert r.returncode == 0 + assert r.stdout.strip() == "none" + + +def test_cli_exclude_drops_current_project(tmp_path): + _mk_project(tmp_path, "foo") + _mk_project(tmp_path, "bar") + # Item names foo, but foo is excluded as the current project → no other match. + r = _run(["--exclude", "foo"], roots=[tmp_path / "projects"], item="fix the foo widget") + assert r.returncode == 0 + assert r.stdout.strip() == "none" @@ -143,8 +143,8 @@ Craig's review challenge reshaped the design from a direct cross-repo =todo.org= *** 2026-06-24 Wed @ 00:21:20 -0400 Reconcile — marker sub-task repointed at inbox.org The 2026-06-23 inbox consolidation (24ca58d) merged =process-inbox= + =monitor-inbox= + =inbox-zero= into one =inbox.org= engine (process/monitor/roam modes) and deleted the three old files. The =:ROUTE_CANDIDATE:= marker sub-task targeted =process-inbox.org='s Phase D — repointed it to =inbox.org= process mode (core §3 "File as TODO"). No build has started, so this is a target-rename only; the spec design is unaffected. -*** TODO [#B] Recommendation engine + destination discovery :feature:solo: -Pure function =(item, project-list) → (destination, confidence)= reusing =inbox-send.py='s =discover_projects= for the project list. Confidence tiers: strong (destination name/path literal in the item), weak (topic-word overlap only — still routed, visibly labeled), none (stays put, never surfaced). Unit-tested directly: strong/weak/none, two-project tie, empty project list. Covers spec Phases 1 + 3. Spec: [[file:docs/design/wrapup-routing-spec.org]]. +*** 2026-06-28 Sun @ 13:02:42 -0400 Built the recommendation engine + destination discovery +Added =.ai/scripts/route_recommend.py= (canonical + mirror): pure =recommend(item, projects) → (destination, confidence)= with strong (name/path literal, word-boundary matched, dot-stripped alias aware), weak (distinctive name-token overlap), and none tiers; a multi-way top-tier tie downgrades to weak with a deterministic pick (most overlap, then alphabetical); empty list → none. The CLI (=--item=, =--exclude=) reuses =inbox-send.py='s =discover_projects= via importlib so the candidate set matches inbox-send's project universe. 13 tests (the five spec'd cases + boundary/path/strong-beats-weak + 3 sandboxed CLI integration tests), full =make test= green. Covers spec Phases 1 + 3. Next sub-tasks (=:ROUTE_CANDIDATE:= marker, wrap-up router) call this engine. *** TODO [#B] =:ROUTE_CANDIDATE:= marker in inbox process mode :feature:solo: Extend =inbox.org= process mode's "File as TODO" disposition (core §3 / Phase D) to stamp =:ROUTE_CANDIDATE: <inferred-project>= on any keeper whose inferred home differs from the current project (uses the engine above). Edit the canonical, sync the =.ai/= mirror, verify sync-check clean. Spec Phase 2 / D8. Spec: [[file:docs/design/wrapup-routing-spec.org]]. (Originally targeted =process-inbox.org=, merged into =inbox.org= by the 2026-06-23 consolidation.) |
