planwise 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
planwise/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """PlanWise Router — intelligent tool routing for large tool catalogs."""
2
+
3
+ from .config import RouterConfig
4
+ from .engine.models import RouteResult, ToolCard
5
+ from .engine.router import Router
6
+
7
+ __all__ = ["Router", "RouterConfig", "RouteResult", "ToolCard"]
8
+ __version__ = "0.1.0"
planwise/cli.py ADDED
@@ -0,0 +1,91 @@
1
+ """planwise CLI.
2
+
3
+ planwise route "pull revenue and email the chart" --catalog data/sample_catalog.json
4
+ planwise eval --catalog ... --queries ... [--baseline]
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import Router, RouterConfig
15
+
16
+
17
+ def _load_router(catalog_path: str) -> Router:
18
+ router = Router(RouterConfig())
19
+ catalog = json.loads(Path(catalog_path).read_text())["tools"]
20
+ report = router.load_catalog(catalog)
21
+ if report.rejected:
22
+ for rej in report.rejected:
23
+ print(f"rejected {rej.tool_id}: [{rej.code}] {rej.message}", file=sys.stderr)
24
+ return router
25
+
26
+
27
+ def main() -> None:
28
+ ap = argparse.ArgumentParser(prog="planwise")
29
+ sub = ap.add_subparsers(dest="cmd", required=True)
30
+
31
+ route_p = sub.add_parser("route", help="route one query")
32
+ route_p.add_argument("query")
33
+ route_p.add_argument("--catalog", default="data/sample_catalog.json")
34
+ route_p.add_argument("--format", dest="dialect", default="anthropic",
35
+ choices=["anthropic", "openai", "mcp"])
36
+
37
+ eval_p = sub.add_parser("eval", help="run the eval harness")
38
+ eval_p.add_argument("--catalog", default="data/sample_catalog.json")
39
+ eval_p.add_argument("--queries", default="data/sample_queries.json")
40
+ eval_p.add_argument("--baseline", action="store_true")
41
+
42
+ serve_p = sub.add_parser("serve", help="run the Tier 1 HTTP gateway")
43
+ serve_p.add_argument("--host", default="0.0.0.0")
44
+ serve_p.add_argument("--port", type=int, default=8000)
45
+ serve_p.add_argument("--catalog", default="data/sample_catalog.json")
46
+
47
+ mcp_p = sub.add_parser("mcp", help="run the Tier 3 MCP server (stdio)")
48
+ mcp_p.add_argument("--catalog", default="data/sample_catalog.json")
49
+
50
+ args = ap.parse_args()
51
+
52
+ if args.cmd == "route":
53
+ router = _load_router(args.catalog)
54
+ result = router.route(args.query, dialect=args.dialect)
55
+ print(json.dumps({
56
+ "plan_hint": result.plan_hint,
57
+ "sequence": result.tool_sequence(),
58
+ "confidence": result.confidence,
59
+ "tools": [t.get("name") or t.get("function", {}).get("name") for t in result.tools],
60
+ "tokens_saved_pct": round(result.tokens_saved.saved_pct, 1),
61
+ "unresolved": [s.what for s in result.unresolved],
62
+ "degraded": result.degraded,
63
+ }, indent=2))
64
+ elif args.cmd == "eval":
65
+ from eval.harness import run_naive_baseline, run_planwise
66
+
67
+ router = _load_router(args.catalog)
68
+ queries = json.loads(Path(args.queries).read_text())["queries"]
69
+ print(run_planwise(router, queries).summary())
70
+ if args.baseline:
71
+ print()
72
+ print(run_naive_baseline(router, queries).summary())
73
+
74
+ elif args.cmd == "serve":
75
+ import os
76
+
77
+ import uvicorn
78
+
79
+ os.environ.setdefault("PLANWISE_CATALOG", args.catalog)
80
+ from .server import create_app
81
+
82
+ uvicorn.run(create_app(), host=args.host, port=args.port)
83
+
84
+ elif args.cmd == "mcp":
85
+ from .mcp_server import run_stdio
86
+
87
+ run_stdio(args.catalog)
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
planwise/config.py ADDED
@@ -0,0 +1,126 @@
1
+ """Router configuration.
2
+
3
+ All tunable numbers live here so the eval harness can sweep them and so
4
+ deployments can override any value with a ``PLANWISE_``-prefixed env var
5
+ (e.g. ``PLANWISE_FLOOR=0.4``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Literal
11
+
12
+ from pydantic import BaseModel
13
+ from pydantic_settings import BaseSettings, SettingsConfigDict
14
+
15
+
16
+ class ScoreWeights(BaseModel):
17
+ """Weights of the four scoring signals. Must sum to ~1.0."""
18
+
19
+ semantic: float = 0.40
20
+ bm25: float = 0.25
21
+ param_fit: float = 0.15
22
+ output_fit: float = 0.20
23
+
24
+
25
+ class DupThresholds(BaseModel):
26
+ """Thresholds for deciding two tools are twins (see graphs.py)."""
27
+
28
+ param_jaccard: float = 0.6
29
+ output_sim: float = 0.8
30
+ card_cosine: float = 0.90
31
+
32
+
33
+ class RouterConfig(BaseSettings):
34
+ model_config = SettingsConfigDict(env_prefix="PLANWISE_", env_nested_delimiter="__")
35
+
36
+ weights: ScoreWeights = ScoreWeights()
37
+
38
+ # Gating: a candidate must clear the absolute floor AND stay within
39
+ # rel_margin of the top score. noise_band is the "almost tied" gap.
40
+ floor: float = 0.50
41
+ rel_margin: float = 0.85
42
+ noise_band: float = 0.05
43
+
44
+ k_max_per_step: int = 3
45
+ k_max_total: int = 8
46
+
47
+ # Coarse filter: keep this many clusters per step, plus any cluster
48
+ # whose score is within cluster_margin of the last kept one.
49
+ clusters_kept: int = 2
50
+ cluster_margin: float = 0.05
51
+ n_clusters: int = 10
52
+
53
+ mode: Literal["advisory", "strict"] = "advisory"
54
+ ambiguity_policy: Literal["ship_both", "argmax"] = "argmax"
55
+
56
+ planner: Literal["llm", "rules"] = "llm"
57
+ planner_model: str = "Bedrock-ant-haiku-4-5-20251001-v1-0"
58
+ planner_timeout_s: float = 30.0
59
+
60
+ # Plan-accuracy stack. A structurally invalid plan is retried once with
61
+ # the validator's errors in the prompt, then falls back to rules — so
62
+ # callers never see an invalid plan.
63
+ planner_retries: int = 1
64
+ # >1 = self-consistency: sample N plans (first at temp 0, rest at 0.7)
65
+ # and keep the majority structure. Costs N calls; use where wrong plans
66
+ # are expensive.
67
+ planner_samples: int = 1
68
+ # Second LLM call that critiques the plan against the query; if it flags
69
+ # gaps, the planner retries once with that feedback.
70
+ planner_verify: bool = False
71
+ # Re-plan once with binder feedback when a step matches no tool.
72
+ replan_on_unresolved: bool = True
73
+ # JSONL audit log of every plan + route outcome ("" = disabled).
74
+ plan_log_path: str = ""
75
+
76
+ # LLM gateway (Crest AWS Bedrock proxy, OpenAI-compatible). The key and
77
+ # cert path come from the environment — never hardcode/commit them.
78
+ # If llm_api_key is unset, the planner falls back to the rules planner
79
+ # and enrichment is skipped, so the system still runs fully offline.
80
+ llm_base_url: str = "https://apiproxy.cdsys.local/v1"
81
+ llm_api_key: str = "" # PLANWISE_LLM_API_KEY
82
+ llm_cert: str = "" # PLANWISE_LLM_CERT — path to apiproxy.cdsys.crt
83
+ enrich_model: str = "Bedrock-ant-haiku-4-5-20251001-v1-0"
84
+
85
+ widen_rounds_max: int = 2
86
+
87
+ # "sbert" = sentence-transformers (real semantics, needs the extra).
88
+ # "hashing" = deterministic lexical vectors — CI/tests, no downloads.
89
+ # "auto" = sbert if importable, else hashing (with a warning).
90
+ embed_backend: Literal["auto", "sbert", "hashing"] = "auto"
91
+ embed_model: str = "BAAI/bge-small-en-v1.5"
92
+
93
+ dup_thresholds: DupThresholds = DupThresholds()
94
+
95
+ # Penalty applied in param_fit when a required ID-like param has no
96
+ # candidate entity in the query at all.
97
+ missing_id_penalty: float = 0.3
98
+
99
+ # --- binder: dependency-echo handling ---
100
+ # Mask tokens echoed from a dependency's `produces` / this step's own
101
+ # `needs` out of the step text before semantic+BM25 scoring. Dataflow
102
+ # annotations are not action evidence. (False = legacy behavior.)
103
+ mask_dependency_echo: bool = True
104
+ # Subtracted from output_fit, scaled by similarity(candidate output,
105
+ # step.needs): a tool that OUTPUTS what this step CONSUMES is the
106
+ # producer of the step's input — upstream, not this step. 0 disables.
107
+ needs_output_penalty: float = 0.5
108
+
109
+ # --- assembler ---
110
+ # Collapse consecutive same-tool plan entries only when their steps
111
+ # share a verb; a different-verb collision surfaces as unresolved
112
+ # instead of being silently dropped.
113
+ merge_same_verb_only: bool = True
114
+
115
+ # --- planner validation ---
116
+ # Two steps with the same verb whose `what` word sets overlap at or
117
+ # above this Jaccard are flagged as duplicates that should be one
118
+ # repeated step. Values <= 0 or > 1.0 disable the check.
119
+ planner_dup_step_jaccard: float = 0.5
120
+
121
+ # Cosine floor for mapping an unknown/hallucinated tool name back to a
122
+ # real catalog tool (middleware recovery). Semantic embeddings warrant
123
+ # a high bar; lexical/hashing backends need it lower.
124
+ resolve_threshold: float = 0.75
125
+
126
+ index_dir: str = "index"
File without changes
@@ -0,0 +1,111 @@
1
+ """Format adapters: external tool dialects <-> the canonical ToolCard.
2
+
3
+ Supported dialects:
4
+ - "openai": {"type": "function", "function": {"name", "description", "parameters"}}
5
+ - "anthropic": {"name", "description", "input_schema"}
6
+ - "mcp": {"name", "description", "inputSchema", "outputSchema"?}
7
+
8
+ Output schemas are not part of the OpenAI/Anthropic tool formats, so we
9
+ also accept a sibling "output_schema" / "returns" key on any dialect and
10
+ record a warning when none is present (enrichment may infer one later).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import Any
17
+
18
+ from .models import ParamSpec, ToolCard, VersionInfo
19
+
20
+ _VERSION_RE = re.compile(r"^(?P<family>.+?)_v(?P<num>\d+)$", re.IGNORECASE)
21
+
22
+
23
+ def detect_dialect(raw: dict[str, Any]) -> str:
24
+ if raw.get("type") == "function" and "function" in raw:
25
+ return "openai"
26
+ if "inputSchema" in raw:
27
+ return "mcp"
28
+ return "anthropic"
29
+
30
+
31
+ def _params_from_json_schema(schema: dict[str, Any] | None) -> dict[str, ParamSpec]:
32
+ if not schema:
33
+ return {}
34
+ required = set(schema.get("required", []))
35
+ out: dict[str, ParamSpec] = {}
36
+ for name, spec in (schema.get("properties") or {}).items():
37
+ out[name] = ParamSpec(
38
+ type=str(spec.get("type", "string")),
39
+ description=str(spec.get("description", "")),
40
+ required=name in required,
41
+ )
42
+ return out
43
+
44
+
45
+ def _params_to_json_schema(params: dict[str, ParamSpec]) -> dict[str, Any]:
46
+ return {
47
+ "type": "object",
48
+ "properties": {
49
+ n: {"type": p.type, "description": p.description} for n, p in params.items()
50
+ },
51
+ "required": [n for n, p in params.items() if p.required],
52
+ }
53
+
54
+
55
+ def _parse_version(name: str, raw: dict[str, Any]) -> VersionInfo | None:
56
+ m = _VERSION_RE.match(name)
57
+ deprecated = bool(raw.get("deprecated", False))
58
+ if m:
59
+ return VersionInfo(family=m.group("family"), number=int(m.group("num")), deprecated=deprecated)
60
+ if deprecated:
61
+ return VersionInfo(family=name, number=1, deprecated=True)
62
+ return None
63
+
64
+
65
+ def to_canonical(raw: dict[str, Any], dialect: str | None = None) -> ToolCard:
66
+ dialect = dialect or detect_dialect(raw)
67
+
68
+ if dialect == "openai":
69
+ fn = raw["function"]
70
+ name, description = fn["name"], fn.get("description", "")
71
+ input_schema = fn.get("parameters")
72
+ elif dialect == "mcp":
73
+ name, description = raw["name"], raw.get("description", "")
74
+ input_schema = raw.get("inputSchema")
75
+ else: # anthropic
76
+ name, description = raw["name"], raw.get("description", "")
77
+ input_schema = raw.get("input_schema")
78
+
79
+ output_raw = raw.get("output_schema") or raw.get("returns") or raw.get("outputSchema")
80
+ card = ToolCard(
81
+ tool_id=name,
82
+ description=description,
83
+ input_params=_params_from_json_schema(input_schema),
84
+ output_schema=_params_from_json_schema(output_raw),
85
+ version=_parse_version(name, raw),
86
+ source_dialect=dialect,
87
+ raw=raw,
88
+ )
89
+ if not card.output_schema:
90
+ card.warnings.append("no_output_schema")
91
+ return card
92
+
93
+
94
+ def from_canonical(card: ToolCard, dialect: str = "anthropic") -> dict[str, Any]:
95
+ """Render a card back into the caller's dialect for hand-off."""
96
+ schema = _params_to_json_schema(card.input_params)
97
+ if dialect == "openai":
98
+ return {
99
+ "type": "function",
100
+ "function": {"name": card.tool_id, "description": card.description, "parameters": schema},
101
+ }
102
+ if dialect == "mcp":
103
+ out: dict[str, Any] = {
104
+ "name": card.tool_id,
105
+ "description": card.description,
106
+ "inputSchema": schema,
107
+ }
108
+ if card.output_schema:
109
+ out["outputSchema"] = _params_to_json_schema(card.output_schema)
110
+ return out
111
+ return {"name": card.tool_id, "description": card.description, "input_schema": schema}
@@ -0,0 +1,133 @@
1
+ """Assembly: ordered plan + call counts + tool hand-off (LLD §7).
2
+
3
+ - Order: topological sort of steps by depends_on (a step waits for the
4
+ steps it needs), ties broken by step index.
5
+ - Count: repeat as a number -> that many entries; "per_entity" -> one per
6
+ matched entity, EXCEPT when the tool takes a list for that input (then
7
+ one call handles all items).
8
+ - Emission: schemas sorted in execution order (we use position bias in
9
+ our favor) plus a short plan-hint line for the agent's prompt.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from graphlib import CycleError, TopologicalSorter
15
+
16
+ from .adapters import from_canonical
17
+ from .entities import Entity
18
+ from .index import CatalogIndex
19
+ from .models import Binding, PlanEntry, RouteResult, Step, TokenStats
20
+ from .plan_validator import IRREVERSIBLE_VERBS
21
+
22
+
23
+ def _order_steps(steps: list[Step]) -> list[Step]:
24
+ ts = TopologicalSorter({s.idx: set(s.depends_on) for s in steps})
25
+ try:
26
+ order = list(ts.static_order())
27
+ except CycleError:
28
+ order = [s.idx for s in steps] # fall back to textual order
29
+ by_idx = {s.idx: s for s in steps}
30
+ # static_order is dependency-correct; make ties follow step index.
31
+ return sorted((by_idx[i] for i in order if i in by_idx), key=lambda s: (order.index(s.idx),))
32
+
33
+
34
+ def _expected_calls(step: Step, tool_id: str, index: CatalogIndex, entities: list[Entity]) -> int:
35
+ if isinstance(step.repeat, int):
36
+ return max(1, step.repeat)
37
+ # "per_entity": one call per matched entity unless a list-typed input
38
+ # lets the tool take them all at once.
39
+ card = index.cards[tool_id]
40
+ if any(p.type == "array" for p in card.input_params.values()):
41
+ return 1
42
+ n = sum(1 for e in entities if e.kind in ("email", "id", "quoted"))
43
+ return max(1, n)
44
+
45
+
46
+ def _estimate_tokens(payload: object) -> int:
47
+ """Cheap token estimate (chars/4) — good enough for the savings metric."""
48
+ import json
49
+
50
+ return len(json.dumps(payload, default=str)) // 4
51
+
52
+
53
+ def assemble(
54
+ steps: list[Step],
55
+ bindings: dict[int, Binding],
56
+ index: CatalogIndex,
57
+ entities: list[Entity],
58
+ dialect: str = "anthropic",
59
+ k_max_total: int = 8,
60
+ merge_same_verb_only: bool = True,
61
+ ) -> RouteResult:
62
+ ordered = _order_steps(steps)
63
+
64
+ plan: list[PlanEntry] = []
65
+ tool_order: list[str] = []
66
+ unresolved: list[Step] = []
67
+ confidences: list[float] = []
68
+
69
+ for order_no, step in enumerate(ordered, start=1):
70
+ b = bindings.get(step.idx)
71
+ if b is None or b.status == "unresolved":
72
+ unresolved.append(step)
73
+ continue
74
+ confidences.append(b.confidence)
75
+ if b.status == "agent_native":
76
+ continue
77
+ primary = b.tools[0]
78
+ plan.append(
79
+ PlanEntry(
80
+ order=order_no,
81
+ step_idx=step.idx,
82
+ tool_id=primary,
83
+ expected_calls=_expected_calls(step, primary, index, entities),
84
+ args_hint=", ".join(step.needs),
85
+ irreversible=step.verb in IRREVERSIBLE_VERBS,
86
+ )
87
+ )
88
+ for t in b.tools:
89
+ if t not in tool_order:
90
+ tool_order.append(t)
91
+
92
+ # Merge rule: consecutive entries bound to the same tool are one call —
93
+ # over-splitting by the planner must not inflate the predicted count.
94
+ # Guard: only when the steps share a verb (the same action stated
95
+ # twice). Two DIFFERENT actions colliding on one tool means one of the
96
+ # bindings is wrong — surface the later step as unresolved rather than
97
+ # silently erasing an action.
98
+ by_idx = {s.idx: s for s in steps}
99
+ merged: list[PlanEntry] = []
100
+ for entry in plan:
101
+ if merged and merged[-1].tool_id == entry.tool_id and entry.expected_calls == 1:
102
+ if (not merge_same_verb_only
103
+ or by_idx[entry.step_idx].verb == by_idx[merged[-1].step_idx].verb):
104
+ continue
105
+ unresolved.append(by_idx[entry.step_idx])
106
+ continue
107
+ merged.append(entry)
108
+ plan = merged
109
+
110
+ tool_order = tool_order[:k_max_total]
111
+ schemas = [from_canonical(index.cards[t], dialect) for t in tool_order]
112
+
113
+ hint = "Suggested plan: " + " ".join(
114
+ f"{i}) {e.tool_id}" + (f" x{e.expected_calls}" if e.expected_calls > 1 else "")
115
+ for i, e in enumerate(plan, start=1)
116
+ ) if plan else ""
117
+
118
+ catalog_tokens = sum(
119
+ _estimate_tokens(from_canonical(c, dialect)) for c in index.cards.values()
120
+ )
121
+ return RouteResult(
122
+ tools=schemas,
123
+ plan=plan,
124
+ plan_hint=hint,
125
+ bindings=list(bindings.values()),
126
+ steps=steps,
127
+ confidence=round(min(confidences), 4) if confidences else 0.0,
128
+ unresolved=unresolved,
129
+ tokens_saved=TokenStats(
130
+ catalog_tokens=catalog_tokens,
131
+ injected_tokens=sum(_estimate_tokens(s) for s in schemas),
132
+ ),
133
+ )
@@ -0,0 +1,127 @@
1
+ """Binding: turn a step's scored candidates into a decision (LLD §6).
2
+
3
+ Order of operations matters:
4
+ 1. gate (floor + relative margin, k_max cap)
5
+ 2. collapse twin groups -> siblings to fallback pool
6
+ 3. resolve version families -> losers to fallback pool
7
+ 4. decide: clear winner / near-tie / unresolved
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from ..config import RouterConfig
15
+ from .entities import Entity, can_fill
16
+ from .index import CatalogIndex
17
+ from .models import Binding, Candidate, Step
18
+
19
+ _PIN_OLD_RE = re.compile(r"\b(legacy|old version|v1|deprecated)\b", re.I)
20
+
21
+
22
+ def _resolve_versions(
23
+ survivors: list[Candidate],
24
+ index: CatalogIndex,
25
+ query_text: str,
26
+ entities: list[Entity],
27
+ fallback: list[str],
28
+ ) -> list[Candidate]:
29
+ """Keep one tool per version family: newest non-deprecated wins unless
30
+ the query pins an old version or the newest needs an unfillable input."""
31
+ by_family: dict[str, list[Candidate]] = {}
32
+ keep: list[Candidate] = []
33
+ for cand in survivors:
34
+ v = index.cards[cand.tool_id].version
35
+ if v is None:
36
+ keep.append(cand)
37
+ else:
38
+ by_family.setdefault(v.family, []).append(cand)
39
+
40
+ pin_old = bool(_PIN_OLD_RE.search(query_text))
41
+ for members in by_family.values():
42
+ members.sort(key=lambda c: index.cards[c.tool_id].version.number, reverse=True) # type: ignore[union-attr]
43
+ chosen = members[0]
44
+ if pin_old and len(members) > 1:
45
+ chosen = members[-1] # oldest
46
+ else:
47
+ # Newest needs a required input we can't fill, but an older one can?
48
+ for older in members[1:]:
49
+ newest_ok = _all_required_fillable(chosen.tool_id, index, entities)
50
+ older_ok = _all_required_fillable(older.tool_id, index, entities)
51
+ if not newest_ok and older_ok:
52
+ chosen = older
53
+ break
54
+ keep.append(chosen)
55
+ fallback.extend(c.tool_id for c in members if c is not chosen)
56
+ keep.sort(key=lambda c: c.score, reverse=True)
57
+ return keep
58
+
59
+
60
+ def _all_required_fillable(tool_id: str, index: CatalogIndex, entities: list[Entity]) -> bool:
61
+ card = index.cards[tool_id]
62
+ return all(
63
+ can_fill(n, p.description, entities)
64
+ for n, p in card.input_params.items()
65
+ if p.required
66
+ )
67
+
68
+
69
+ def bind_step(
70
+ step: Step,
71
+ candidates: list[Candidate],
72
+ index: CatalogIndex,
73
+ query_text: str,
74
+ entities: list[Entity],
75
+ cfg: RouterConfig,
76
+ ) -> Binding:
77
+ binding = Binding(step_idx=step.idx, candidates=candidates[:10])
78
+
79
+ if step.agent_native:
80
+ binding.status = "agent_native"
81
+ binding.confidence = 1.0
82
+ return binding
83
+
84
+ # 1. Gate.
85
+ top = candidates[0].score if candidates else 0.0
86
+ gated = [
87
+ c for c in candidates
88
+ if c.score >= cfg.floor and c.score >= cfg.rel_margin * top
89
+ ][: cfg.k_max_per_step + 2] # small headroom before dedup
90
+
91
+ if not gated:
92
+ binding.status = "unresolved"
93
+ return binding
94
+
95
+ fallback: list[str] = []
96
+
97
+ # 2. Twin groups: keep each group's best.
98
+ seen_groups: set[str] = set()
99
+ deduped: list[Candidate] = []
100
+ for cand in gated:
101
+ gid = index.cards[cand.tool_id].dup_group_id
102
+ if gid is None:
103
+ deduped.append(cand)
104
+ elif gid not in seen_groups:
105
+ seen_groups.add(gid)
106
+ deduped.append(cand)
107
+ else:
108
+ fallback.append(cand.tool_id)
109
+
110
+ # 3. Version families.
111
+ resolved = _resolve_versions(deduped, index, query_text, entities, fallback)
112
+
113
+ # 4. Decide.
114
+ best = resolved[0]
115
+ second = resolved[1] if len(resolved) > 1 else None
116
+ gap = (best.score - second.score) / best.score if second and best.score > 0 else 1.0
117
+
118
+ near_tie = second is not None and gap <= cfg.noise_band
119
+ ship_both = near_tie and cfg.mode == "advisory" and cfg.ambiguity_policy == "ship_both"
120
+
121
+ binding.tools = [best.tool_id, second.tool_id] if ship_both else [best.tool_id]
122
+ binding.fallback_pool = fallback + [
123
+ c.tool_id for c in resolved if c.tool_id not in binding.tools
124
+ ]
125
+ binding.status = "bound"
126
+ binding.confidence = round(best.score * (0.5 + 0.5 * min(gap, 1.0)), 4)
127
+ return binding
@@ -0,0 +1,88 @@
1
+ """Embedding backends.
2
+
3
+ Two interchangeable backends behind one tiny interface:
4
+
5
+ - ``SbertBackend`` — sentence-transformers (real semantic vectors). Used in
6
+ the actual product; needs the ``embeddings`` extra installed.
7
+ - ``HashingBackend`` — deterministic bag-of-words vectors via feature
8
+ hashing. No downloads, no randomness: this is what unit tests and CI use,
9
+ and the automatic fallback when sentence-transformers is unavailable.
10
+
11
+ Both return L2-normalized float32 matrices, so cosine similarity is a
12
+ plain dot product everywhere downstream.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import logging
19
+ import re
20
+ from typing import Protocol
21
+
22
+ import numpy as np
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ _TOKEN_RE = re.compile(r"[a-z0-9]+")
27
+
28
+
29
+ def tokenize(text: str) -> list[str]:
30
+ """Lowercase word tokens; snake_case and camelCase are split upstream
31
+ by card_text()."""
32
+ return _TOKEN_RE.findall(text.lower())
33
+
34
+
35
+ class EmbeddingBackend(Protocol):
36
+ dim: int
37
+
38
+ def encode(self, texts: list[str]) -> np.ndarray: ...
39
+
40
+
41
+ class HashingBackend:
42
+ """Deterministic lexical embeddings (feature hashing + L2 norm).
43
+
44
+ Not semantic — two synonyms won't match — but perfectly stable, fast
45
+ and dependency-free. Good enough for tests and as a degraded mode;
46
+ BM25 carries most lexical weight anyway.
47
+ """
48
+
49
+ def __init__(self, dim: int = 512) -> None:
50
+ self.dim = dim
51
+
52
+ def _bucket(self, token: str) -> int:
53
+ return int.from_bytes(hashlib.md5(token.encode()).digest()[:4], "little") % self.dim
54
+
55
+ def encode(self, texts: list[str]) -> np.ndarray:
56
+ out = np.zeros((len(texts), self.dim), dtype=np.float32)
57
+ for i, text in enumerate(texts):
58
+ for tok in tokenize(text):
59
+ out[i, self._bucket(tok)] += 1.0
60
+ norms = np.linalg.norm(out, axis=1, keepdims=True)
61
+ norms[norms == 0] = 1.0
62
+ return out / norms
63
+
64
+
65
+ class SbertBackend:
66
+ def __init__(self, model_name: str) -> None:
67
+ from sentence_transformers import SentenceTransformer # lazy import
68
+
69
+ self._model = SentenceTransformer(model_name)
70
+ self.dim = self._model.get_sentence_embedding_dimension()
71
+
72
+ def encode(self, texts: list[str]) -> np.ndarray:
73
+ vecs = self._model.encode(texts, normalize_embeddings=True, show_progress_bar=False)
74
+ return np.asarray(vecs, dtype=np.float32)
75
+
76
+
77
+ def make_backend(kind: str, model_name: str) -> EmbeddingBackend:
78
+ """Factory honoring RouterConfig.embed_backend ("auto"/"sbert"/"hashing")."""
79
+ if kind == "hashing":
80
+ return HashingBackend()
81
+ if kind == "sbert":
82
+ return SbertBackend(model_name)
83
+ # auto
84
+ try:
85
+ return SbertBackend(model_name)
86
+ except Exception as exc: # ImportError or model download failure
87
+ log.warning("sentence-transformers unavailable (%s); using hashing backend", exc)
88
+ return HashingBackend()