aboutsummaryrefslogtreecommitdiff
path: root/.ai
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-28 13:12:57 -0400
committerCraig Jennings <c@cjennings.net>2026-06-28 13:12:57 -0400
commit6be62aee7437fd8fe8d6eff991869b09529d3924 (patch)
tree0360a199e0152541a27c6db21daab88c1d657b5c /.ai
parent6fb6797fb6dfa2d468f873c000b64eb6ef6accb6 (diff)
downloadrulesets-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 '.ai')
-rw-r--r--.ai/scripts/route_recommend.py136
-rw-r--r--.ai/scripts/tests/test_route_recommend.py124
2 files changed, 260 insertions, 0 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"