python-rerouting-library 0.2.1__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.
- python_rerouting_library/__init__.py +25 -0
- python_rerouting_library/backends/__init__.py +30 -0
- python_rerouting_library/backends/cloud.py +58 -0
- python_rerouting_library/backends/local_llama.py +74 -0
- python_rerouting_library/config.py +128 -0
- python_rerouting_library/dispatcher.py +200 -0
- python_rerouting_library/exceptions.py +18 -0
- python_rerouting_library/privacy.py +171 -0
- python_rerouting_library/router.py +227 -0
- python_rerouting_library/training.py +177 -0
- python_rerouting_library-0.2.1.dist-info/METADATA +624 -0
- python_rerouting_library-0.2.1.dist-info/RECORD +15 -0
- python_rerouting_library-0.2.1.dist-info/WHEEL +5 -0
- python_rerouting_library-0.2.1.dist-info/licenses/LICENSE +21 -0
- python_rerouting_library-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from time import perf_counter
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import joblib
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_EMBEDDING_MODEL = (
|
|
12
|
+
"sentence-transformers/all-MiniLM-L6-v2"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class RouteDecision:
|
|
18
|
+
label: str
|
|
19
|
+
confidence: float | None
|
|
20
|
+
complex_probability: float | None
|
|
21
|
+
latency_ms: float
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Router:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
classifier_path: str | Path,
|
|
28
|
+
*,
|
|
29
|
+
embedding_model_name: str | None = None,
|
|
30
|
+
simple_threshold: float = 0.40,
|
|
31
|
+
complex_threshold: float = 0.60,
|
|
32
|
+
device: str = "cpu",
|
|
33
|
+
) -> None:
|
|
34
|
+
self.classifier_path = Path(
|
|
35
|
+
classifier_path
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if not self.classifier_path.is_file():
|
|
39
|
+
raise FileNotFoundError(
|
|
40
|
+
"Router classifier not found: "
|
|
41
|
+
f"{self.classifier_path}. "
|
|
42
|
+
"python-rerouting-library does not ship "
|
|
43
|
+
"with a default complexity classifier. "
|
|
44
|
+
"Train one with "
|
|
45
|
+
"'python -m python_rerouting_library.training' "
|
|
46
|
+
"or provide a compatible classifier_path."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if not (
|
|
50
|
+
0.0
|
|
51
|
+
< simple_threshold
|
|
52
|
+
< complex_threshold
|
|
53
|
+
< 1.0
|
|
54
|
+
):
|
|
55
|
+
raise ValueError(
|
|
56
|
+
"Thresholds must satisfy: "
|
|
57
|
+
"0 < simple_threshold "
|
|
58
|
+
"< complex_threshold < 1"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
self.simple_threshold = float(
|
|
62
|
+
simple_threshold
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self.complex_threshold = float(
|
|
66
|
+
complex_threshold
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
self.device = device
|
|
70
|
+
|
|
71
|
+
artifact = joblib.load(
|
|
72
|
+
self.classifier_path
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if isinstance(artifact, dict):
|
|
76
|
+
if "classifier" not in artifact:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"Router artifact does not "
|
|
79
|
+
"contain a 'classifier'."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
self.classifier = artifact[
|
|
83
|
+
"classifier"
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
artifact_model_name = artifact.get(
|
|
87
|
+
"embedding_model_name",
|
|
88
|
+
DEFAULT_EMBEDDING_MODEL,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
else:
|
|
92
|
+
self.classifier = artifact
|
|
93
|
+
|
|
94
|
+
artifact_model_name = (
|
|
95
|
+
DEFAULT_EMBEDDING_MODEL
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
self.embedding_model_name = (
|
|
99
|
+
embedding_model_name
|
|
100
|
+
or artifact_model_name
|
|
101
|
+
or DEFAULT_EMBEDDING_MODEL
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
self._embedding_model: Any | None = None
|
|
105
|
+
|
|
106
|
+
def _get_embedding_model(
|
|
107
|
+
self,
|
|
108
|
+
) -> Any:
|
|
109
|
+
if self._embedding_model is None:
|
|
110
|
+
try:
|
|
111
|
+
from sentence_transformers import (
|
|
112
|
+
SentenceTransformer,
|
|
113
|
+
)
|
|
114
|
+
except ImportError as exc:
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
"Sentence Transformers could not be "
|
|
117
|
+
"loaded. Semantic routing requires the "
|
|
118
|
+
"'sentence-transformers' dependency and "
|
|
119
|
+
"a compatible Python environment."
|
|
120
|
+
) from exc
|
|
121
|
+
|
|
122
|
+
self._embedding_model = (
|
|
123
|
+
SentenceTransformer(
|
|
124
|
+
self.embedding_model_name,
|
|
125
|
+
device=self.device,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return self._embedding_model
|
|
130
|
+
|
|
131
|
+
def warmup(self) -> None:
|
|
132
|
+
model = self._get_embedding_model()
|
|
133
|
+
|
|
134
|
+
model.encode(
|
|
135
|
+
["router warmup"],
|
|
136
|
+
normalize_embeddings=True,
|
|
137
|
+
show_progress_bar=False,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _get_complex_probability(
|
|
141
|
+
self,
|
|
142
|
+
embedding,
|
|
143
|
+
) -> float:
|
|
144
|
+
probabilities = (
|
|
145
|
+
self.classifier.predict_proba(
|
|
146
|
+
embedding
|
|
147
|
+
)[0]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
classes = list(
|
|
151
|
+
self.classifier.classes_
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if "complex" not in classes:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
"Classifier does not contain "
|
|
157
|
+
"the 'complex' class."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
complex_index = classes.index(
|
|
161
|
+
"complex"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return float(
|
|
165
|
+
probabilities[complex_index]
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def route(
|
|
169
|
+
self,
|
|
170
|
+
query: str,
|
|
171
|
+
) -> RouteDecision:
|
|
172
|
+
if not isinstance(query, str):
|
|
173
|
+
raise TypeError(
|
|
174
|
+
"Query must be a string."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
query = query.strip()
|
|
178
|
+
|
|
179
|
+
if not query:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
"Query must not be empty."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
start = perf_counter()
|
|
185
|
+
|
|
186
|
+
model = self._get_embedding_model()
|
|
187
|
+
|
|
188
|
+
embedding = model.encode(
|
|
189
|
+
[query],
|
|
190
|
+
normalize_embeddings=True,
|
|
191
|
+
show_progress_bar=False,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
probability = (
|
|
195
|
+
self._get_complex_probability(
|
|
196
|
+
embedding
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if probability < self.simple_threshold:
|
|
201
|
+
label = "simple"
|
|
202
|
+
confidence = (
|
|
203
|
+
1.0 - probability
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
elif probability > self.complex_threshold:
|
|
207
|
+
label = "complex"
|
|
208
|
+
confidence = probability
|
|
209
|
+
|
|
210
|
+
else:
|
|
211
|
+
label = "uncertain"
|
|
212
|
+
|
|
213
|
+
confidence = (
|
|
214
|
+
abs(probability - 0.5)
|
|
215
|
+
* 2.0
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
latency_ms = (
|
|
219
|
+
perf_counter() - start
|
|
220
|
+
) * 1000.0
|
|
221
|
+
|
|
222
|
+
return RouteDecision(
|
|
223
|
+
label=label,
|
|
224
|
+
confidence=confidence,
|
|
225
|
+
complex_probability=probability,
|
|
226
|
+
latency_ms=latency_ms,
|
|
227
|
+
)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import csv
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import joblib
|
|
8
|
+
from sentence_transformers import SentenceTransformer
|
|
9
|
+
from sklearn.linear_model import LogisticRegression
|
|
10
|
+
|
|
11
|
+
from .router import DEFAULT_EMBEDDING_MODEL
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_training_data(
|
|
15
|
+
csv_path: Path,
|
|
16
|
+
) -> tuple[list[str], list[str]]:
|
|
17
|
+
queries: list[str] = []
|
|
18
|
+
labels: list[str] = []
|
|
19
|
+
|
|
20
|
+
with csv_path.open(
|
|
21
|
+
"r",
|
|
22
|
+
encoding="utf-8",
|
|
23
|
+
newline="",
|
|
24
|
+
) as f:
|
|
25
|
+
reader = csv.DictReader(f)
|
|
26
|
+
|
|
27
|
+
required = {"label", "query"}
|
|
28
|
+
|
|
29
|
+
if not required.issubset(
|
|
30
|
+
set(reader.fieldnames or [])
|
|
31
|
+
):
|
|
32
|
+
raise ValueError(
|
|
33
|
+
"CSV must contain 'label' and 'query' columns."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
for row in reader:
|
|
37
|
+
label = row["label"].strip().lower()
|
|
38
|
+
query = row["query"].strip()
|
|
39
|
+
|
|
40
|
+
if label not in {"simple", "complex"}:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"Invalid label: {label!r}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if not query:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"Encountered an empty query."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
labels.append(label)
|
|
51
|
+
queries.append(query)
|
|
52
|
+
|
|
53
|
+
if not queries:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"Training CSV is empty."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return queries, labels
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def train_router(
|
|
62
|
+
csv_path: str | Path,
|
|
63
|
+
output_path: str | Path,
|
|
64
|
+
*,
|
|
65
|
+
embedding_model_name: str = DEFAULT_EMBEDDING_MODEL,
|
|
66
|
+
) -> Path:
|
|
67
|
+
csv_path = Path(csv_path)
|
|
68
|
+
output_path = Path(output_path)
|
|
69
|
+
|
|
70
|
+
queries, labels = load_training_data(
|
|
71
|
+
csv_path
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
print(
|
|
75
|
+
f"Loaded {len(queries)} training examples: "
|
|
76
|
+
f"{labels.count('simple')} simple / "
|
|
77
|
+
f"{labels.count('complex')} complex"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
print(
|
|
81
|
+
f"Loading embedding model: "
|
|
82
|
+
f"{embedding_model_name}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
encoder = SentenceTransformer(
|
|
86
|
+
embedding_model_name,
|
|
87
|
+
device="cpu",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
print("Generating embeddings...")
|
|
91
|
+
|
|
92
|
+
embeddings = encoder.encode(
|
|
93
|
+
queries,
|
|
94
|
+
convert_to_numpy=True,
|
|
95
|
+
normalize_embeddings=True,
|
|
96
|
+
show_progress_bar=True,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
print(
|
|
100
|
+
"Training Logistic Regression classifier..."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
classifier = LogisticRegression(
|
|
104
|
+
max_iter=1000,
|
|
105
|
+
class_weight="balanced",
|
|
106
|
+
solver="liblinear",
|
|
107
|
+
random_state=42,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
classifier.fit(
|
|
111
|
+
embeddings,
|
|
112
|
+
labels,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
artifact = {
|
|
116
|
+
"classifier": classifier,
|
|
117
|
+
"embedding_model_name": embedding_model_name,
|
|
118
|
+
"training_examples": len(queries),
|
|
119
|
+
"classes": list(classifier.classes_),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
output_path.parent.mkdir(
|
|
123
|
+
parents=True,
|
|
124
|
+
exist_ok=True,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
joblib.dump(
|
|
128
|
+
artifact,
|
|
129
|
+
output_path,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
print(
|
|
133
|
+
f"Saved router classifier to: "
|
|
134
|
+
f"{output_path.resolve()}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return output_path
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main() -> None:
|
|
141
|
+
parser = argparse.ArgumentParser(
|
|
142
|
+
description=(
|
|
143
|
+
"Train the MiniLM + Logistic Regression "
|
|
144
|
+
"simple/complex router."
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
parser.add_argument(
|
|
149
|
+
"--csv",
|
|
150
|
+
type=Path,
|
|
151
|
+
required=True,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
parser.add_argument(
|
|
155
|
+
"--output",
|
|
156
|
+
type=Path,
|
|
157
|
+
default=Path(
|
|
158
|
+
"artifacts/router_classifier.joblib"
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
parser.add_argument(
|
|
163
|
+
"--model",
|
|
164
|
+
default=DEFAULT_EMBEDDING_MODEL,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
args = parser.parse_args()
|
|
168
|
+
|
|
169
|
+
train_router(
|
|
170
|
+
csv_path=args.csv,
|
|
171
|
+
output_path=args.output,
|
|
172
|
+
embedding_model_name=args.model,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
main()
|