shiftgate 0.1.1__tar.gz → 0.1.2__tar.gz
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.
- {shiftgate-0.1.1 → shiftgate-0.1.2}/PKG-INFO +33 -31
- {shiftgate-0.1.1 → shiftgate-0.1.2}/README.md +32 -30
- {shiftgate-0.1.1 → shiftgate-0.1.2}/pyproject.toml +1 -1
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/cli.py +15 -3
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/registry/schemas.py +19 -1
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/router/matcher.py +41 -49
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/router/router.py +11 -5
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/utils/display.py +41 -14
- {shiftgate-0.1.1 → shiftgate-0.1.2}/tests/test_registry.py +65 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/tests/test_router.py +21 -24
- {shiftgate-0.1.1 → shiftgate-0.1.2}/.github/workflows/release.yml +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/.gitignore +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/data/default_tasks.json +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/__init__.py +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/feedback/__init__.py +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/feedback/loop.py +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/registry/__init__.py +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/registry/adapter_registry.py +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/registry/task_registry.py +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/router/__init__.py +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/router/embedder.py +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/runtime/__init__.py +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/runtime/backend.py +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/shiftgate/utils/__init__.py +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/tests/__init__.py +0 -0
- {shiftgate-0.1.1 → shiftgate-0.1.2}/tests/test_feedback.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shiftgate
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Intelligent routing layer that automatically selects the right LoRA adapter for each task in your local agent loop.
|
|
5
5
|
Project-URL: Homepage, https://github.com/shiftgate-ai/shiftgate
|
|
6
6
|
Project-URL: Repository, https://github.com/shiftgate-ai/shiftgate
|
|
@@ -41,15 +41,13 @@ Your inference backend (Ollama, vLLM) is responsible for loading the weights; sh
|
|
|
41
41
|
|
|
42
42
|
Instead of hardcoding which adapter to use, shiftgate embeds your query and matches it against a catalog of task clusters using cosine similarity — then routes inference to the best-fit LoRA adapter on your running Ollama or vLLM instance.
|
|
43
43
|
|
|
44
|
-
Inspired by the [LORAUTER paper](https://arxiv.org/abs/2406.08213) (EPFL, 2026).
|
|
45
|
-
|
|
46
44
|
---
|
|
47
45
|
|
|
48
46
|
## Quickstart
|
|
49
47
|
|
|
50
48
|
```bash
|
|
51
49
|
# 1. Install (requires Python 3.10+, uv recommended)
|
|
52
|
-
uv
|
|
50
|
+
uv tool install shiftgate (or pip install shiftgate)
|
|
53
51
|
|
|
54
52
|
# 2. Initialise: sets up ~/.shiftgate/ and computes task embeddings
|
|
55
53
|
shiftgate init
|
|
@@ -126,24 +124,26 @@ User query
|
|
|
126
124
|
|
|
127
125
|
## Commands
|
|
128
126
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
|
132
|
-
| `shiftgate
|
|
133
|
-
| `shiftgate route "<query>"
|
|
134
|
-
| `shiftgate
|
|
135
|
-
| `shiftgate
|
|
136
|
-
| `shiftgate adapter add <
|
|
137
|
-
| `shiftgate adapter add <id> --
|
|
138
|
-
| `shiftgate adapter
|
|
139
|
-
| `shiftgate adapter
|
|
140
|
-
| `shiftgate
|
|
141
|
-
| `shiftgate task
|
|
142
|
-
| `shiftgate
|
|
143
|
-
| `shiftgate feedback
|
|
144
|
-
| `shiftgate feedback
|
|
145
|
-
| `shiftgate
|
|
146
|
-
| `shiftgate
|
|
127
|
+
|
|
128
|
+
| Command | Description |
|
|
129
|
+
| -------------------------------------------------------- | --------------------------------------------------------------------- |
|
|
130
|
+
| `shiftgate init` | First-time setup: initialise `~/.shiftgate/`, compute task embeddings |
|
|
131
|
+
| `shiftgate route "<query>"` | Route a query and show the decision — no inference |
|
|
132
|
+
| `shiftgate route "<query>" --explain` | Full decision tree: task scores, candidates, selection reason |
|
|
133
|
+
| `shiftgate run "<query>"` | Route + run via Ollama or vLLM |
|
|
134
|
+
| `shiftgate adapter add <hf_repo> [--tags …] [--base …]` | Register adapter from HuggingFace (metadata only) |
|
|
135
|
+
| `shiftgate adapter add <id> --local <path> [--tags …]` | Register a local adapter path |
|
|
136
|
+
| `shiftgate adapter add <id> --runtime <name> [--tags …]` | Register a backend-loaded adapter by its runtime name |
|
|
137
|
+
| `shiftgate adapter list` | Table of all registered adapters |
|
|
138
|
+
| `shiftgate adapter remove <id>` | Remove an adapter |
|
|
139
|
+
| `shiftgate task list` | Table of all task clusters |
|
|
140
|
+
| `shiftgate task add` | Interactively add a new task cluster |
|
|
141
|
+
| `shiftgate feedback accept` | Mark last routing as good |
|
|
142
|
+
| `shiftgate feedback reject` | Mark last routing as bad |
|
|
143
|
+
| `shiftgate feedback stats` | Adapter acceptance rate table |
|
|
144
|
+
| `shiftgate status` | Backend connectivity + registry summary |
|
|
145
|
+
| `shiftgate demo` | Animated demo with fake routing traces |
|
|
146
|
+
|
|
147
147
|
|
|
148
148
|
---
|
|
149
149
|
|
|
@@ -231,12 +231,14 @@ To add a task cluster that better matches your domain, edit `data/default_tasks.
|
|
|
231
231
|
|
|
232
232
|
## Roadmap
|
|
233
233
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
|
237
|
-
| v0.
|
|
238
|
-
| v0.
|
|
239
|
-
|
|
|
234
|
+
|
|
235
|
+
| Version | Focus |
|
|
236
|
+
| -------- | ------------------------------------------------------------- |
|
|
237
|
+
| **v0.1** | Single base model, multi-adapter routing ← *current* |
|
|
238
|
+
| v0.2 | Feedback loop + adapter scoring (auto-demote bad adapters) |
|
|
239
|
+
| v0.3 | Multi-model routing (route to different base models per task) |
|
|
240
|
+
| v1.0 | Community registry + web UI |
|
|
241
|
+
|
|
240
242
|
|
|
241
243
|
---
|
|
242
244
|
|
|
@@ -270,8 +272,8 @@ The recommended flow:
|
|
|
270
272
|
1. Bump the version in `pyproject.toml` (`version = "x.y.z"`).
|
|
271
273
|
2. Open a PR, get it reviewed and merged.
|
|
272
274
|
3. Tag the commit: `git tag vx.y.z && git push origin vx.y.z`.
|
|
273
|
-
4. The CI workflow builds the wheel with `uv build` and publishes to PyPI
|
|
274
|
-
|
|
275
|
+
4. The CI workflow builds the wheel with `uv build` and publishes to PyPI
|
|
276
|
+
using [Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/)
|
|
275
277
|
— no stored API token needed.
|
|
276
278
|
|
|
277
279
|
For a one-off manual publish (maintainers only):
|
|
@@ -306,4 +308,4 @@ shiftgate/
|
|
|
306
308
|
|
|
307
309
|
## License
|
|
308
310
|
|
|
309
|
-
MIT. See [LICENSE](LICENSE).
|
|
311
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -8,15 +8,13 @@ Your inference backend (Ollama, vLLM) is responsible for loading the weights; sh
|
|
|
8
8
|
|
|
9
9
|
Instead of hardcoding which adapter to use, shiftgate embeds your query and matches it against a catalog of task clusters using cosine similarity — then routes inference to the best-fit LoRA adapter on your running Ollama or vLLM instance.
|
|
10
10
|
|
|
11
|
-
Inspired by the [LORAUTER paper](https://arxiv.org/abs/2406.08213) (EPFL, 2026).
|
|
12
|
-
|
|
13
11
|
---
|
|
14
12
|
|
|
15
13
|
## Quickstart
|
|
16
14
|
|
|
17
15
|
```bash
|
|
18
16
|
# 1. Install (requires Python 3.10+, uv recommended)
|
|
19
|
-
uv
|
|
17
|
+
uv tool install shiftgate (or pip install shiftgate)
|
|
20
18
|
|
|
21
19
|
# 2. Initialise: sets up ~/.shiftgate/ and computes task embeddings
|
|
22
20
|
shiftgate init
|
|
@@ -93,24 +91,26 @@ User query
|
|
|
93
91
|
|
|
94
92
|
## Commands
|
|
95
93
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
|
99
|
-
| `shiftgate
|
|
100
|
-
| `shiftgate route "<query>"
|
|
101
|
-
| `shiftgate
|
|
102
|
-
| `shiftgate
|
|
103
|
-
| `shiftgate adapter add <
|
|
104
|
-
| `shiftgate adapter add <id> --
|
|
105
|
-
| `shiftgate adapter
|
|
106
|
-
| `shiftgate adapter
|
|
107
|
-
| `shiftgate
|
|
108
|
-
| `shiftgate task
|
|
109
|
-
| `shiftgate
|
|
110
|
-
| `shiftgate feedback
|
|
111
|
-
| `shiftgate feedback
|
|
112
|
-
| `shiftgate
|
|
113
|
-
| `shiftgate
|
|
94
|
+
|
|
95
|
+
| Command | Description |
|
|
96
|
+
| -------------------------------------------------------- | --------------------------------------------------------------------- |
|
|
97
|
+
| `shiftgate init` | First-time setup: initialise `~/.shiftgate/`, compute task embeddings |
|
|
98
|
+
| `shiftgate route "<query>"` | Route a query and show the decision — no inference |
|
|
99
|
+
| `shiftgate route "<query>" --explain` | Full decision tree: task scores, candidates, selection reason |
|
|
100
|
+
| `shiftgate run "<query>"` | Route + run via Ollama or vLLM |
|
|
101
|
+
| `shiftgate adapter add <hf_repo> [--tags …] [--base …]` | Register adapter from HuggingFace (metadata only) |
|
|
102
|
+
| `shiftgate adapter add <id> --local <path> [--tags …]` | Register a local adapter path |
|
|
103
|
+
| `shiftgate adapter add <id> --runtime <name> [--tags …]` | Register a backend-loaded adapter by its runtime name |
|
|
104
|
+
| `shiftgate adapter list` | Table of all registered adapters |
|
|
105
|
+
| `shiftgate adapter remove <id>` | Remove an adapter |
|
|
106
|
+
| `shiftgate task list` | Table of all task clusters |
|
|
107
|
+
| `shiftgate task add` | Interactively add a new task cluster |
|
|
108
|
+
| `shiftgate feedback accept` | Mark last routing as good |
|
|
109
|
+
| `shiftgate feedback reject` | Mark last routing as bad |
|
|
110
|
+
| `shiftgate feedback stats` | Adapter acceptance rate table |
|
|
111
|
+
| `shiftgate status` | Backend connectivity + registry summary |
|
|
112
|
+
| `shiftgate demo` | Animated demo with fake routing traces |
|
|
113
|
+
|
|
114
114
|
|
|
115
115
|
---
|
|
116
116
|
|
|
@@ -198,12 +198,14 @@ To add a task cluster that better matches your domain, edit `data/default_tasks.
|
|
|
198
198
|
|
|
199
199
|
## Roadmap
|
|
200
200
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
|
204
|
-
| v0.
|
|
205
|
-
| v0.
|
|
206
|
-
|
|
|
201
|
+
|
|
202
|
+
| Version | Focus |
|
|
203
|
+
| -------- | ------------------------------------------------------------- |
|
|
204
|
+
| **v0.1** | Single base model, multi-adapter routing ← *current* |
|
|
205
|
+
| v0.2 | Feedback loop + adapter scoring (auto-demote bad adapters) |
|
|
206
|
+
| v0.3 | Multi-model routing (route to different base models per task) |
|
|
207
|
+
| v1.0 | Community registry + web UI |
|
|
208
|
+
|
|
207
209
|
|
|
208
210
|
---
|
|
209
211
|
|
|
@@ -237,8 +239,8 @@ The recommended flow:
|
|
|
237
239
|
1. Bump the version in `pyproject.toml` (`version = "x.y.z"`).
|
|
238
240
|
2. Open a PR, get it reviewed and merged.
|
|
239
241
|
3. Tag the commit: `git tag vx.y.z && git push origin vx.y.z`.
|
|
240
|
-
4. The CI workflow builds the wheel with `uv build` and publishes to PyPI
|
|
241
|
-
|
|
242
|
+
4. The CI workflow builds the wheel with `uv build` and publishes to PyPI
|
|
243
|
+
using [Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/)
|
|
242
244
|
— no stored API token needed.
|
|
243
245
|
|
|
244
246
|
For a one-off manual publish (maintainers only):
|
|
@@ -273,4 +275,4 @@ shiftgate/
|
|
|
273
275
|
|
|
274
276
|
## License
|
|
275
277
|
|
|
276
|
-
MIT. See [LICENSE](LICENSE).
|
|
278
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "shiftgate"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Intelligent routing layer that automatically selects the right LoRA adapter for each task in your local agent loop."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -104,11 +104,15 @@ def _finish_adapter_add(adapter: AdapterEntry, task_reg, adapter_reg) -> None:
|
|
|
104
104
|
|
|
105
105
|
linked = _auto_link_adapter(adapter, task_reg)
|
|
106
106
|
if linked:
|
|
107
|
+
# Successfully wired into at least one task cluster → mark routable.
|
|
108
|
+
adapter.status = "linked"
|
|
109
|
+
adapter_reg.save()
|
|
107
110
|
task_reg.save()
|
|
108
|
-
console.print(f" [
|
|
111
|
+
console.print(f" [green]Status: linked[/green] → {', '.join(linked)}")
|
|
109
112
|
else:
|
|
110
113
|
console.print(
|
|
111
|
-
" [
|
|
114
|
+
" [yellow]Status: unassigned[/yellow] — no task clusters matched these tags.\n"
|
|
115
|
+
" [dim]This adapter will NOT be selected by the router until it is linked. "
|
|
112
116
|
"Use `shiftgate task list` to see clusters.[/dim]"
|
|
113
117
|
)
|
|
114
118
|
|
|
@@ -479,7 +483,15 @@ def run(
|
|
|
479
483
|
)
|
|
480
484
|
|
|
481
485
|
if adapter is None:
|
|
482
|
-
|
|
486
|
+
# Either the matched task has no linked adapter, or the linked ID is
|
|
487
|
+
# missing from the registry. In both cases: never guess, never run.
|
|
488
|
+
console.print(
|
|
489
|
+
"[red]No adapter available for this query — not running inference.[/red]\n"
|
|
490
|
+
f" Matched task: [bold]{trace.matched_task_id}[/bold]\n"
|
|
491
|
+
" Add one with: "
|
|
492
|
+
f"[cyan]shiftgate adapter add <hf_repo> --tags {trace.matched_task_id}[/cyan]"
|
|
493
|
+
)
|
|
494
|
+
feedback_loop.record_trace(trace)
|
|
483
495
|
raise typer.Exit(1)
|
|
484
496
|
|
|
485
497
|
if backend_name is None:
|
|
@@ -15,6 +15,8 @@ weights available to the backend (Ollama, vLLM, etc.) before running inference.
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
18
20
|
from pydantic import BaseModel, Field, model_validator
|
|
19
21
|
|
|
20
22
|
|
|
@@ -81,6 +83,16 @@ class AdapterEntry(BaseModel):
|
|
|
81
83
|
description="Optional benchmark score (0–1) reported by the adapter author.",
|
|
82
84
|
)
|
|
83
85
|
|
|
86
|
+
# --- routing wiring status ---
|
|
87
|
+
status: Literal["linked", "unassigned"] = Field(
|
|
88
|
+
default="unassigned",
|
|
89
|
+
description=(
|
|
90
|
+
"Whether this adapter is wired into at least one task cluster's "
|
|
91
|
+
"preferred_adapters list. 'linked' = reachable by the router; "
|
|
92
|
+
"'unassigned' = registered but not yet routable to any task."
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
|
|
84
96
|
@model_validator(mode="after")
|
|
85
97
|
def _at_least_one_source(self) -> "AdapterEntry":
|
|
86
98
|
"""Warn (not error) when no source field is set.
|
|
@@ -155,7 +167,13 @@ class RoutingTrace(BaseModel):
|
|
|
155
167
|
similarity_score: float = Field(
|
|
156
168
|
description="Cosine similarity between the query embedding and the winning centroid (0–1)."
|
|
157
169
|
)
|
|
158
|
-
selected_adapter_id: str = Field(
|
|
170
|
+
selected_adapter_id: str | None = Field(
|
|
171
|
+
default=None,
|
|
172
|
+
description=(
|
|
173
|
+
"ID of the adapter selected for inference, or None when the matched "
|
|
174
|
+
"task has no linked adapter in the registry."
|
|
175
|
+
),
|
|
176
|
+
)
|
|
159
177
|
accepted: bool | None = Field(
|
|
160
178
|
default=None,
|
|
161
179
|
description="User feedback: True = good routing, False = bad routing, None = not yet rated.",
|
|
@@ -43,16 +43,25 @@ class MatchResult:
|
|
|
43
43
|
|
|
44
44
|
Returned by ``select_adapter`` so that both the router and the
|
|
45
45
|
``--explain`` display have access to the complete decision tree.
|
|
46
|
+
|
|
47
|
+
``selected_adapter`` is ``None`` when the matched task has no adapter
|
|
48
|
+
linked in the registry. In that case ``selection_method`` is
|
|
49
|
+
``"no_adapter_for_task"`` and the router must NOT run inference.
|
|
46
50
|
"""
|
|
47
51
|
|
|
48
|
-
selected_adapter: AdapterEntry
|
|
52
|
+
selected_adapter: AdapterEntry | None
|
|
49
53
|
matched_task: TaskCluster
|
|
50
54
|
similarity_score: float
|
|
51
55
|
# All top-K task matches including their candidate adapter lists
|
|
52
56
|
all_task_matches: list[TaskMatch] = field(default_factory=list)
|
|
53
|
-
# How the adapter was
|
|
57
|
+
# How the adapter was found: "preferred", "fallback", or "no_adapter_for_task"
|
|
54
58
|
selection_method: str = "preferred"
|
|
55
59
|
|
|
60
|
+
@property
|
|
61
|
+
def has_adapter(self) -> bool:
|
|
62
|
+
"""True when an adapter was successfully selected."""
|
|
63
|
+
return self.selected_adapter is not None
|
|
64
|
+
|
|
56
65
|
|
|
57
66
|
# ---------------------------------------------------------------------------
|
|
58
67
|
# Core functions
|
|
@@ -109,40 +118,38 @@ def select_adapter(
|
|
|
109
118
|
top_tasks: list[TaskMatch],
|
|
110
119
|
adapter_registry, # AdapterRegistry — avoid circular import with string hint
|
|
111
120
|
) -> MatchResult:
|
|
112
|
-
"""Select the
|
|
121
|
+
"""Select the adapter linked to the best-matching task.
|
|
113
122
|
|
|
114
123
|
Strategy
|
|
115
124
|
--------
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
without a separate linking step.
|
|
129
|
-
|
|
130
|
-
Pass 3 — empty registry
|
|
131
|
-
Raise ``NoAdapterError`` only when there are literally no adapters.
|
|
125
|
+
For each top task (highest score first), walk ``preferred_adapters`` then
|
|
126
|
+
``fallback_adapters`` and collect the adapters that exist in the registry
|
|
127
|
+
(populating ``TaskMatch.candidate_adapters`` for the ``--explain`` view).
|
|
128
|
+
The first such adapter found, on the highest-scoring task, is selected.
|
|
129
|
+
|
|
130
|
+
No silent fallback
|
|
131
|
+
------------------
|
|
132
|
+
If the matched (top) task has **no** linked adapter in the registry, the
|
|
133
|
+
router must NOT substitute an arbitrary adapter — doing so silently routes,
|
|
134
|
+
e.g., a music query to a SQL adapter and destroys trust. Instead this
|
|
135
|
+
returns a ``MatchResult`` with ``selected_adapter=None`` and
|
|
136
|
+
``selection_method="no_adapter_for_task"``.
|
|
132
137
|
|
|
133
138
|
Parameters
|
|
134
139
|
----------
|
|
135
140
|
top_tasks:
|
|
136
|
-
Output of ``top_k_tasks
|
|
141
|
+
Output of ``top_k_tasks`` (sorted by score descending).
|
|
137
142
|
adapter_registry:
|
|
138
143
|
``AdapterRegistry`` instance to look up adapter IDs.
|
|
139
144
|
|
|
140
145
|
Returns
|
|
141
146
|
-------
|
|
142
|
-
``MatchResult``
|
|
143
|
-
|
|
147
|
+
``MatchResult``. ``selected_adapter`` is ``None`` when no adapter is
|
|
148
|
+
linked to any of the ranked tasks. The ``matched_task`` is always the
|
|
149
|
+
top-scoring task so callers can still report what was matched.
|
|
144
150
|
"""
|
|
145
|
-
#
|
|
151
|
+
# Populate candidate lists for every task (for the --explain view) and
|
|
152
|
+
# find the first explicit match in score order.
|
|
146
153
|
explicit_result: MatchResult | None = None
|
|
147
154
|
|
|
148
155
|
for tm in top_tasks:
|
|
@@ -178,40 +185,25 @@ def select_adapter(
|
|
|
178
185
|
)
|
|
179
186
|
return explicit_result
|
|
180
187
|
|
|
181
|
-
#
|
|
182
|
-
|
|
183
|
-
if not all_adapters:
|
|
184
|
-
raise NoAdapterError(
|
|
185
|
-
"No adapters registered. Add one with `shiftgate adapter add`."
|
|
186
|
-
)
|
|
187
|
-
|
|
188
|
+
# No adapter linked to any ranked task — do NOT guess. Report the matched
|
|
189
|
+
# task with no adapter so the caller can prompt the user to add one.
|
|
188
190
|
top_task = top_tasks[0]
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
task_vocab.update(tm.task.id.lower().split("_"))
|
|
192
|
-
|
|
193
|
-
best_adapter = max(
|
|
194
|
-
all_adapters,
|
|
195
|
-
key=lambda a: len({t.lower() for t in a.task_tags} & task_vocab),
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
# Add the fallback adapter as a candidate on the top task for the explain view.
|
|
199
|
-
if best_adapter not in top_task.candidate_adapters:
|
|
200
|
-
top_task.candidate_adapters.append(best_adapter)
|
|
201
|
-
|
|
202
|
-
logger.debug(
|
|
203
|
-
"Tag-overlap fallback selected adapter '%s' for task '%s'",
|
|
204
|
-
best_adapter.id,
|
|
191
|
+
logger.info(
|
|
192
|
+
"No linked adapter for matched task '%s' — refusing to guess.",
|
|
205
193
|
top_task.task.id,
|
|
206
194
|
)
|
|
207
195
|
return MatchResult(
|
|
208
|
-
selected_adapter=
|
|
196
|
+
selected_adapter=None,
|
|
209
197
|
matched_task=top_task.task,
|
|
210
198
|
similarity_score=top_task.score,
|
|
211
199
|
all_task_matches=top_tasks,
|
|
212
|
-
selection_method="
|
|
200
|
+
selection_method="no_adapter_for_task",
|
|
213
201
|
)
|
|
214
202
|
|
|
215
203
|
|
|
216
204
|
class NoAdapterError(RuntimeError):
|
|
217
|
-
"""Raised when the matcher cannot find any registered adapter for a query.
|
|
205
|
+
"""Raised when the matcher cannot find any registered adapter for a query.
|
|
206
|
+
|
|
207
|
+
Retained for backward compatibility; ``select_adapter`` no longer raises it
|
|
208
|
+
(it returns a ``MatchResult`` with ``selected_adapter=None`` instead).
|
|
209
|
+
"""
|
|
@@ -15,7 +15,7 @@ from shiftgate.registry.adapter_registry import AdapterRegistry
|
|
|
15
15
|
from shiftgate.registry.schemas import RoutingTrace
|
|
16
16
|
from shiftgate.registry.task_registry import TaskRegistry
|
|
17
17
|
from shiftgate.router.embedder import Embedder
|
|
18
|
-
from shiftgate.router.matcher import MatchResult,
|
|
18
|
+
from shiftgate.router.matcher import MatchResult, select_adapter, top_k_tasks
|
|
19
19
|
|
|
20
20
|
logger = logging.getLogger(__name__)
|
|
21
21
|
|
|
@@ -54,10 +54,14 @@ def route(
|
|
|
54
54
|
``(RoutingTrace, MatchResult)`` — the trace for persistence/feedback and
|
|
55
55
|
the full match result for detailed display (e.g. ``--explain``).
|
|
56
56
|
|
|
57
|
+
Note
|
|
58
|
+
----
|
|
59
|
+
When the matched task has no linked adapter, ``MatchResult.selected_adapter``
|
|
60
|
+
is ``None`` and the trace's ``selected_adapter_id`` is ``None``. The router
|
|
61
|
+
never substitutes an arbitrary adapter.
|
|
62
|
+
|
|
57
63
|
Raises
|
|
58
64
|
------
|
|
59
|
-
NoAdapterError
|
|
60
|
-
If no registered adapter matches any of the top-K tasks.
|
|
61
65
|
ValueError
|
|
62
66
|
If embeddings have not been computed (missing centroids).
|
|
63
67
|
"""
|
|
@@ -71,12 +75,14 @@ def route(
|
|
|
71
75
|
ranked = top_k_tasks(query_embedding, all_tasks, k=top_k)
|
|
72
76
|
result = select_adapter(ranked, adapter_registry)
|
|
73
77
|
|
|
78
|
+
selected_id = result.selected_adapter.id if result.selected_adapter else None
|
|
79
|
+
|
|
74
80
|
trace = RoutingTrace(
|
|
75
81
|
id=uuid.uuid4().hex,
|
|
76
82
|
query=query,
|
|
77
83
|
matched_task_id=result.matched_task.id,
|
|
78
84
|
similarity_score=result.similarity_score,
|
|
79
|
-
selected_adapter_id=
|
|
85
|
+
selected_adapter_id=selected_id,
|
|
80
86
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
81
87
|
)
|
|
82
88
|
|
|
@@ -85,7 +91,7 @@ def route(
|
|
|
85
91
|
query[:60],
|
|
86
92
|
result.matched_task.id,
|
|
87
93
|
result.similarity_score * 100,
|
|
88
|
-
|
|
94
|
+
selected_id or "<none>",
|
|
89
95
|
result.selection_method,
|
|
90
96
|
)
|
|
91
97
|
return trace, result
|
|
@@ -83,7 +83,10 @@ def show_routing_decision(
|
|
|
83
83
|
backend_name:
|
|
84
84
|
Active backend name ('ollama', 'vllm', or None).
|
|
85
85
|
"""
|
|
86
|
-
|
|
86
|
+
# When no adapter was selected the decision is unactionable — render red
|
|
87
|
+
# regardless of how confident the task match was.
|
|
88
|
+
no_adapter = adapter is None and trace.selected_adapter_id is None
|
|
89
|
+
colour = "red" if no_adapter else _similarity_colour(trace.similarity_score)
|
|
87
90
|
|
|
88
91
|
grid = Table.grid(padding=(0, 2))
|
|
89
92
|
grid.add_column(style="dim", min_width=16)
|
|
@@ -98,7 +101,18 @@ def show_routing_decision(
|
|
|
98
101
|
task_text.append_text(_similarity_bar(trace.similarity_score))
|
|
99
102
|
grid.add_row("Matched Task", task_text)
|
|
100
103
|
|
|
101
|
-
if
|
|
104
|
+
if no_adapter:
|
|
105
|
+
# Never silently substitute an adapter. Tell the user how to fix it.
|
|
106
|
+
adapter_text = Text("No adapter available", style="bold red")
|
|
107
|
+
grid.add_row("Adapter", adapter_text)
|
|
108
|
+
suggestion = Text()
|
|
109
|
+
suggestion.append("Add one with: ", style="dim")
|
|
110
|
+
suggestion.append(
|
|
111
|
+
f"shiftgate adapter add <hf_repo> --tags {trace.matched_task_id}",
|
|
112
|
+
style="cyan",
|
|
113
|
+
)
|
|
114
|
+
grid.add_row("Suggestion", suggestion)
|
|
115
|
+
elif adapter:
|
|
102
116
|
adapter_text = Text()
|
|
103
117
|
adapter_text.append(adapter.name, style="bold magenta")
|
|
104
118
|
adapter_text.append(f" [{adapter.base_model}]", style="dim")
|
|
@@ -107,7 +121,7 @@ def show_routing_decision(
|
|
|
107
121
|
adapter_text.append(f"\n {source}", style="dim blue")
|
|
108
122
|
grid.add_row("Adapter", adapter_text)
|
|
109
123
|
else:
|
|
110
|
-
grid.add_row("Adapter", Text(trace.selected_adapter_id, style="bold magenta"))
|
|
124
|
+
grid.add_row("Adapter", Text(str(trace.selected_adapter_id), style="bold magenta"))
|
|
111
125
|
|
|
112
126
|
backend_text = Text(backend_name or "—", style="green" if backend_name else "dim")
|
|
113
127
|
grid.add_row("Backend", backend_text)
|
|
@@ -115,12 +129,12 @@ def show_routing_decision(
|
|
|
115
129
|
if trace.latency_ms is not None:
|
|
116
130
|
grid.add_row("Latency", Text(f"{trace.latency_ms:.0f} ms", style="dim"))
|
|
117
131
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
expand=False,
|
|
132
|
+
title = (
|
|
133
|
+
"[bold red] no adapter available [/bold red]"
|
|
134
|
+
if no_adapter
|
|
135
|
+
else f"[bold {colour}] shiftgate routing decision [/bold {colour}]"
|
|
123
136
|
)
|
|
137
|
+
panel = Panel(grid, title=title, border_style=colour, expand=False)
|
|
124
138
|
console.print()
|
|
125
139
|
console.print(panel)
|
|
126
140
|
console.print()
|
|
@@ -192,15 +206,23 @@ def show_explain_decision(
|
|
|
192
206
|
method_labels = {
|
|
193
207
|
"preferred": "[green]preferred_adapters list[/green]",
|
|
194
208
|
"fallback": "[yellow]fallback_adapters list[/yellow]",
|
|
195
|
-
"
|
|
209
|
+
"no_adapter_for_task": "[red]no adapter linked to the matched task[/red]",
|
|
196
210
|
}
|
|
197
211
|
method_display = method_labels.get(match_result.selection_method, match_result.selection_method)
|
|
198
212
|
|
|
199
213
|
selected = match_result.selected_adapter
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
214
|
+
if selected is None:
|
|
215
|
+
console.print(" [bold]Selected adapter:[/bold] [bold red]No adapter available[/bold red]")
|
|
216
|
+
console.print(f" [bold]Selection method:[/bold] {method_display}")
|
|
217
|
+
console.print(
|
|
218
|
+
" [dim]Add one with:[/dim] "
|
|
219
|
+
f"[cyan]shiftgate adapter add <hf_repo> --tags {match_result.matched_task.id}[/cyan]"
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
console.print(f" [bold]Selected adapter:[/bold] [bold magenta]{selected.id}[/bold magenta]")
|
|
223
|
+
console.print(f" [bold]Base model:[/bold] {selected.base_model}")
|
|
224
|
+
console.print(f" [bold]Source:[/bold] {_adapter_source_label(selected)}")
|
|
225
|
+
console.print(f" [bold]Selection method:[/bold] {method_display}")
|
|
204
226
|
console.print()
|
|
205
227
|
console.rule()
|
|
206
228
|
console.print()
|
|
@@ -233,13 +255,18 @@ def show_adapter_table(adapters: list[AdapterEntry]) -> None:
|
|
|
233
255
|
table.add_column("Base Model", style="dim")
|
|
234
256
|
table.add_column("Tags", style="green")
|
|
235
257
|
table.add_column("Source", style="blue")
|
|
258
|
+
table.add_column("Status", justify="center")
|
|
236
259
|
table.add_column("Score", justify="right")
|
|
237
260
|
|
|
238
261
|
for a in adapters:
|
|
239
262
|
source = _adapter_source_label(a)
|
|
240
263
|
score = f"{a.benchmark_score:.2f}" if a.benchmark_score is not None else "—"
|
|
241
264
|
tags = ", ".join(a.task_tags) if a.task_tags else "—"
|
|
242
|
-
|
|
265
|
+
if a.status == "linked":
|
|
266
|
+
status = "[green]linked[/green]"
|
|
267
|
+
else:
|
|
268
|
+
status = "[yellow]unassigned[/yellow]"
|
|
269
|
+
table.add_row(a.id, a.name, a.base_model, tags, source, status, score)
|
|
243
270
|
|
|
244
271
|
console.print(table)
|
|
245
272
|
|
|
@@ -381,3 +381,68 @@ class TestAutoLinkAdapter:
|
|
|
381
381
|
|
|
382
382
|
linked = _auto_link_adapter(adapter, task_reg)
|
|
383
383
|
assert linked == []
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# Adapter status field
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
class TestAdapterStatus:
|
|
391
|
+
def test_default_status_is_unassigned(self):
|
|
392
|
+
adapter = AdapterEntry(id="x", name="X", base_model="b")
|
|
393
|
+
assert adapter.status == "unassigned"
|
|
394
|
+
|
|
395
|
+
def test_status_persists_round_trip(self, tmp_shiftgate, monkeypatch):
|
|
396
|
+
import shiftgate.registry.adapter_registry as ar_mod
|
|
397
|
+
|
|
398
|
+
save_path = tmp_shiftgate / "adapters.json"
|
|
399
|
+
monkeypatch.setattr(ar_mod, "_USER_ADAPTERS_PATH", save_path)
|
|
400
|
+
|
|
401
|
+
adapter = AdapterEntry(
|
|
402
|
+
id="sql-lora", name="SQL", base_model="llama3",
|
|
403
|
+
task_tags=["sql"], hf_repo="org/sql-lora", status="linked",
|
|
404
|
+
)
|
|
405
|
+
AdapterRegistry(adapters=[adapter], source_path=save_path).save()
|
|
406
|
+
|
|
407
|
+
reloaded = AdapterRegistry.load().get_adapter("sql-lora")
|
|
408
|
+
assert reloaded.status == "linked"
|
|
409
|
+
|
|
410
|
+
def test_finish_adapter_add_marks_linked(self, tmp_shiftgate, monkeypatch):
|
|
411
|
+
"""_finish_adapter_add should set status='linked' when a task is linked."""
|
|
412
|
+
import shiftgate.cli as cli_mod
|
|
413
|
+
|
|
414
|
+
# Quiet console output during the test.
|
|
415
|
+
monkeypatch.setattr(cli_mod.console, "print", lambda *a, **k: None)
|
|
416
|
+
|
|
417
|
+
task = TaskCluster(
|
|
418
|
+
id="code_sql", name="SQL", description="",
|
|
419
|
+
validation_examples=["x"], preferred_adapters=[],
|
|
420
|
+
)
|
|
421
|
+
task_reg = TaskRegistry(tasks=[task], source_path=tmp_shiftgate / "tasks.json")
|
|
422
|
+
adapter = AdapterEntry(
|
|
423
|
+
id="sql-lora", name="SQL", base_model="llama3",
|
|
424
|
+
task_tags=["sql"], hf_repo="org/sql-lora",
|
|
425
|
+
)
|
|
426
|
+
adapter_reg = AdapterRegistry(adapters=[adapter], source_path=tmp_shiftgate / "adapters.json")
|
|
427
|
+
|
|
428
|
+
cli_mod._finish_adapter_add(adapter, task_reg, adapter_reg)
|
|
429
|
+
assert adapter.status == "linked"
|
|
430
|
+
|
|
431
|
+
def test_finish_adapter_add_stays_unassigned_without_match(self, tmp_shiftgate, monkeypatch):
|
|
432
|
+
import shiftgate.cli as cli_mod
|
|
433
|
+
|
|
434
|
+
monkeypatch.setattr(cli_mod.console, "print", lambda *a, **k: None)
|
|
435
|
+
|
|
436
|
+
task = TaskCluster(
|
|
437
|
+
id="code_sql", name="SQL", description="",
|
|
438
|
+
validation_examples=["x"], preferred_adapters=[],
|
|
439
|
+
)
|
|
440
|
+
task_reg = TaskRegistry(tasks=[task], source_path=tmp_shiftgate / "tasks.json")
|
|
441
|
+
adapter = AdapterEntry(
|
|
442
|
+
id="music-lora", name="Music", base_model="llama3",
|
|
443
|
+
task_tags=["music"], hf_repo="org/music-lora",
|
|
444
|
+
)
|
|
445
|
+
adapter_reg = AdapterRegistry(adapters=[adapter], source_path=tmp_shiftgate / "adapters.json")
|
|
446
|
+
|
|
447
|
+
cli_mod._finish_adapter_add(adapter, task_reg, adapter_reg)
|
|
448
|
+
assert adapter.status == "unassigned"
|
|
@@ -172,17 +172,26 @@ class TestSelectAdapter:
|
|
|
172
172
|
result = select_adapter(ranked, adapter_reg_partial)
|
|
173
173
|
assert result.selected_adapter.id in {"adapter-y", "adapter-z"}
|
|
174
174
|
|
|
175
|
-
def
|
|
175
|
+
def test_no_adapter_when_registry_empty(self, synthetic_tasks, tmp_path):
|
|
176
|
+
"""An empty registry yields a None adapter, never an exception or a guess."""
|
|
176
177
|
empty_reg = AdapterRegistry(adapters=[], source_path=tmp_path / "adapters.json")
|
|
177
178
|
query_emb = np.array([1.0, 0.0, 0.0], dtype=np.float32)
|
|
178
179
|
ranked = top_k_tasks(query_emb, synthetic_tasks, k=3)
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
result = select_adapter(ranked, empty_reg)
|
|
181
|
+
assert result.selected_adapter is None
|
|
182
|
+
assert result.has_adapter is False
|
|
183
|
+
assert result.selection_method == "no_adapter_for_task"
|
|
184
|
+
# The matched task is still reported so the caller can prompt the user.
|
|
185
|
+
assert result.matched_task.id == "task_x"
|
|
186
|
+
|
|
187
|
+
def test_no_silent_fallback_when_task_has_no_linked_adapter(self, tmp_path):
|
|
188
|
+
"""The bug fix: an unrelated adapter must NOT be picked for the matched task.
|
|
181
189
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
190
|
+
A music-style query matches a task that has no linked adapter; even
|
|
191
|
+
though a sql-lora exists in the registry, it must not be selected.
|
|
192
|
+
"""
|
|
193
|
+
task_music = _make_task("audio_music", [0, 1, 0], adapter_ids=[])
|
|
194
|
+
task_py = _make_task("code_python", [1, 0, 0], adapter_ids=[])
|
|
186
195
|
|
|
187
196
|
sql_adapter = AdapterEntry(
|
|
188
197
|
id="sql-lora",
|
|
@@ -192,25 +201,13 @@ class TestSelectAdapter:
|
|
|
192
201
|
)
|
|
193
202
|
reg = AdapterRegistry(adapters=[sql_adapter], source_path=tmp_path / "adapters.json")
|
|
194
203
|
|
|
195
|
-
query_emb = np.array([0.0, 1.0, 0.0], dtype=np.float32)
|
|
196
|
-
ranked = top_k_tasks(query_emb, [
|
|
197
|
-
result = select_adapter(ranked, reg)
|
|
198
|
-
|
|
199
|
-
assert result.selected_adapter.id == "sql-lora"
|
|
200
|
-
assert result.selection_method == "tag_overlap"
|
|
201
|
-
|
|
202
|
-
def test_tag_overlap_selects_best_matching_adapter(self, tmp_path):
|
|
203
|
-
task_sql = _make_task("code_sql", [0, 1, 0], adapter_ids=[])
|
|
204
|
-
|
|
205
|
-
sql_adapter = AdapterEntry(id="sql-lora", name="SQL", base_model="x", task_tags=["sql", "code"])
|
|
206
|
-
py_adapter = AdapterEntry(id="py-lora", name="Py", base_model="x", task_tags=["python"])
|
|
207
|
-
|
|
208
|
-
reg = AdapterRegistry(adapters=[sql_adapter, py_adapter], source_path=tmp_path / "adapters.json")
|
|
209
|
-
query_emb = np.array([0.0, 1.0, 0.0], dtype=np.float32)
|
|
210
|
-
ranked = top_k_tasks(query_emb, [task_sql], k=1)
|
|
204
|
+
query_emb = np.array([0.0, 1.0, 0.0], dtype=np.float32) # matches audio_music
|
|
205
|
+
ranked = top_k_tasks(query_emb, [task_music, task_py], k=2)
|
|
211
206
|
result = select_adapter(ranked, reg)
|
|
212
207
|
|
|
213
|
-
assert result.selected_adapter
|
|
208
|
+
assert result.selected_adapter is None
|
|
209
|
+
assert result.selection_method == "no_adapter_for_task"
|
|
210
|
+
assert result.matched_task.id == "audio_music"
|
|
214
211
|
|
|
215
212
|
def test_fallback_adapters_are_tried(self, tmp_path):
|
|
216
213
|
task_with_fallback = TaskCluster(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|