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 +8 -0
- planwise/cli.py +91 -0
- planwise/config.py +126 -0
- planwise/engine/__init__.py +0 -0
- planwise/engine/adapters.py +111 -0
- planwise/engine/assembler.py +133 -0
- planwise/engine/binder.py +127 -0
- planwise/engine/embeddings.py +88 -0
- planwise/engine/enrich.py +86 -0
- planwise/engine/entities.py +84 -0
- planwise/engine/graphs.py +124 -0
- planwise/engine/index.py +177 -0
- planwise/engine/intake.py +120 -0
- planwise/engine/llm.py +116 -0
- planwise/engine/models.py +183 -0
- planwise/engine/plan_validator.py +153 -0
- planwise/engine/planlog.py +30 -0
- planwise/engine/planner.py +360 -0
- planwise/engine/router.py +287 -0
- planwise/engine/scorer.py +138 -0
- planwise/mcp_server.py +162 -0
- planwise/middleware/__init__.py +6 -0
- planwise/middleware/interceptors.py +211 -0
- planwise/middleware/widening.py +69 -0
- planwise/middleware/wrapper.py +188 -0
- planwise/server.py +141 -0
- planwise-0.1.0.dist-info/METADATA +100 -0
- planwise-0.1.0.dist-info/RECORD +31 -0
- planwise-0.1.0.dist-info/WHEEL +4 -0
- planwise-0.1.0.dist-info/entry_points.txt +2 -0
- planwise-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|