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 /claude-templates | |
| 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.
Diffstat (limited to 'claude-templates')
| -rw-r--r-- | claude-templates/.ai/scripts/route_recommend.py | 136 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_route_recommend.py | 124 |
2 files changed, 260 insertions, 0 deletions
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" |
