cacheverifier 0.5.0__tar.gz → 0.7.0__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.
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/CHANGELOG.md +28 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/PKG-INFO +59 -3
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/README.md +53 -1
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/__init__.py +11 -2
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/_healthcheck/_finetune.py +6 -35
- cacheverifier-0.7.0/cacheverifier/_inference.py +162 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/client.py +72 -5
- cacheverifier-0.7.0/cacheverifier/local.py +504 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/pyproject.toml +8 -2
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/tests/test_client.py +24 -0
- cacheverifier-0.7.0/tests/test_local.py +251 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/.github/workflows/ci.yml +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/.github/workflows/publish.yml +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/.gitignore +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/LICENSE +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/__main__.py +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/_healthcheck/__init__.py +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/_healthcheck/_base_models.py +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/_healthcheck/cli.py +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/integrations/__init__.py +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/integrations/gptcache.py +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/integrations/langchain.py +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/cacheverifier/py.typed +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/examples/gptcache_example.py +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/examples/quickstart.py +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/tests/test_gptcache_integration.py +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/tests/test_healthcheck.py +0 -0
- {cacheverifier-0.5.0 → cacheverifier-0.7.0}/tests/test_langchain_integration.py +0 -0
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
`entries_to_refresh()` lists the cache entries your feedback reported stale,
|
|
6
|
+
or wrong more often than right, and `resolve_entry(key)` marks one refreshed
|
|
7
|
+
(`GET /v1/monitor/entries-to-refresh`, every plan). `feedback()` (on both
|
|
8
|
+
`CacheVerifier` and `LocalVerifier`) takes `cache_entry_id`, your own key for
|
|
9
|
+
the entry, which the list hands back so you can refresh or drop it directly.
|
|
10
|
+
|
|
11
|
+
## 0.6.0
|
|
12
|
+
|
|
13
|
+
New `LocalVerifier` (`pip install "cacheverifier[local]"`, Managed plans):
|
|
14
|
+
scores gray-zone hits on your own machines with the model CacheVerifier
|
|
15
|
+
fine-tuned for you, deciding exactly as the hosted `/v1/verify` would. It
|
|
16
|
+
downloads the live model, checks it against reference verdicts from the
|
|
17
|
+
service before using it, switches to newer models as they go live, and, while
|
|
18
|
+
auditing is on, uploads each decision without the texts so audits and risk
|
|
19
|
+
monitoring keep working. Falls back to the hosted API until a model is ready.
|
|
20
|
+
|
|
21
|
+
`CacheVerifier.verify()` takes `similarity_score`, `cached_query` and
|
|
22
|
+
`runner_up_similarity_score` (all optional), which a fine-tuned model's joint
|
|
23
|
+
rules use; before this the client couldn't send them. `feedback()` takes
|
|
24
|
+
`cached_query` and `verification_id`. `VerifyResult` gains `decision_rule`,
|
|
25
|
+
`audit` and `verification_id`, which the API already returned.
|
|
26
|
+
|
|
27
|
+
The offline Health Check now truncates long inputs exactly as the service
|
|
28
|
+
does since its verifier-core 0.7.0: a long query next to a short answer keeps
|
|
29
|
+
the room the answer doesn't need, instead of being cut to half the budget.
|
|
30
|
+
|
|
3
31
|
## 0.5.0
|
|
4
32
|
|
|
5
33
|
New LangChain integration: `cacheverifier.integrations.langchain.VerifiedCache`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cacheverifier
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Python client for the
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: Python client for the CacheVerifier semantic-cache verification API, hosted or scored locally
|
|
5
5
|
Project-URL: Homepage, https://www.cacheverifier.com
|
|
6
6
|
Project-URL: Documentation, https://www.cacheverifier.com/docs
|
|
7
7
|
Project-URL: Source, https://github.com/imxinchengyou/cacheverifier-python
|
|
@@ -31,6 +31,10 @@ Requires-Dist: sentence-transformers<4,>=3.0; extra == 'healthcheck'
|
|
|
31
31
|
Requires-Dist: torch>=2.2; extra == 'healthcheck'
|
|
32
32
|
Provides-Extra: langchain
|
|
33
33
|
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
34
|
+
Provides-Extra: local
|
|
35
|
+
Requires-Dist: numpy<2,>=1.26; extra == 'local'
|
|
36
|
+
Requires-Dist: sentence-transformers<4,>=3.0; extra == 'local'
|
|
37
|
+
Requires-Dist: torch>=2.2; extra == 'local'
|
|
34
38
|
Description-Content-Type: text/markdown
|
|
35
39
|
|
|
36
40
|
# cacheverifier
|
|
@@ -60,6 +64,8 @@ pip install cacheverifier
|
|
|
60
64
|
pip install "cacheverifier[gptcache]"
|
|
61
65
|
# with the offline Health Check (adds torch + sentence-transformers):
|
|
62
66
|
pip install "cacheverifier[healthcheck]"
|
|
67
|
+
# to score with your fine-tuned model on your own machines (same deps):
|
|
68
|
+
pip install "cacheverifier[local]"
|
|
63
69
|
```
|
|
64
70
|
|
|
65
71
|
Requires Python 3.9+. The only runtime dependency is `httpx` — the extras above
|
|
@@ -179,6 +185,19 @@ set_llm_cache(
|
|
|
179
185
|
)
|
|
180
186
|
```
|
|
181
187
|
|
|
188
|
+
## Entries to refresh
|
|
189
|
+
|
|
190
|
+
Send your cache's own key with feedback, and the service tells you which
|
|
191
|
+
entries to fix: ones reported stale, or wrong more often than right.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
cv.feedback(query, cached_answer, was_correct=False, stale=True, cache_entry_id="faq:shipping")
|
|
195
|
+
|
|
196
|
+
for e in cv.entries_to_refresh():
|
|
197
|
+
my_cache.delete(e["cache_entry_id"]) # or regenerate the answer
|
|
198
|
+
cv.resolve_entry(e["key"])
|
|
199
|
+
```
|
|
200
|
+
|
|
182
201
|
## Fine-tuning
|
|
183
202
|
|
|
184
203
|
Once you have ~20+ feedback rows (the service found fine-tuning is often a net negative
|
|
@@ -198,6 +217,40 @@ if job.get("result_model_version"):
|
|
|
198
217
|
`cv.dry_run([...])` reports the same baseline-vs-tuned AUC on examples you pass directly,
|
|
199
218
|
without writing anything or deploying a model.
|
|
200
219
|
|
|
220
|
+
## Local scoring (Managed plans)
|
|
221
|
+
|
|
222
|
+
`LocalVerifier` runs the model CacheVerifier fine-tuned for you on your own
|
|
223
|
+
machines: no network round trip on your request path, and your query and
|
|
224
|
+
answer texts stay with you. Fine-tuning, drift monitoring, audits and
|
|
225
|
+
certification keep running on the service.
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from cacheverifier import LocalVerifier
|
|
229
|
+
|
|
230
|
+
cv = LocalVerifier(api_key="cv_...") # downloads your live model in the background
|
|
231
|
+
r = cv.verify(query, cached_answer, similarity_score=0.87, cached_query=cached_question)
|
|
232
|
+
if r.approved:
|
|
233
|
+
... # serve from cache
|
|
234
|
+
else:
|
|
235
|
+
answer = call_llm(query)
|
|
236
|
+
if r.audit: # a randomized audit: report whether the cached answer was right
|
|
237
|
+
cv.feedback(query, cached_answer, was_correct=same_answer(answer, cached_answer), result=r)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
- It decides exactly as the hosted `/v1/verify` would with that model: every download
|
|
241
|
+
carries reference verdicts from the service, and a model that doesn't reproduce them
|
|
242
|
+
locally is not used.
|
|
243
|
+
- When a newer model goes live (after a fine-tune or a scheduled recalibration), it is
|
|
244
|
+
downloaded, checked, and switched to without a restart.
|
|
245
|
+
- While auditing is on, each decision is uploaded as scores, verdicts and a keyed hash of
|
|
246
|
+
the pair, never the texts, so audits and `/v1/monitor/risk` work as with the hosted API.
|
|
247
|
+
- Until a model is ready, calls go to the hosted API (`fallback="hosted"`, the default)
|
|
248
|
+
or are rejected (`fallback="reject"`). Pass `wait=True` to block until the first
|
|
249
|
+
download finishes (a model is ~330MB).
|
|
250
|
+
- The audit draw happens in this client. The risk bound assumes it is independent of
|
|
251
|
+
whether the answer is right, which the client guarantees unless it is modified.
|
|
252
|
+
- Not supported yet: the online ACI threshold (local scoring uses the model's fixed one).
|
|
253
|
+
|
|
201
254
|
## Local Health Check (offline)
|
|
202
255
|
|
|
203
256
|
`cv.dry_run()` still uploads your examples to the API. If that's a blocker — a
|
|
@@ -259,15 +312,18 @@ verdict
|
|
|
259
312
|
|
|
260
313
|
| method | endpoint |
|
|
261
314
|
|---|---|
|
|
262
|
-
| `verify(query, candidate_answer)` | `POST /v1/verify` |
|
|
315
|
+
| `verify(query, candidate_answer, similarity_score=..., cached_query=...)` | `POST /v1/verify` |
|
|
263
316
|
| `verify_batch(pairs)` | `POST /v1/verify/batch` |
|
|
264
317
|
| `feedback(...)` / `feedback_batch(items)` | `POST /v1/feedback` / `/batch` |
|
|
265
318
|
| `finetune(...)` / `dry_run(examples, ...)` | `POST /v1/finetune/jobs` / `/dry-run` |
|
|
266
319
|
| `get_finetune_job(id)` / `list_finetune_jobs()` | `GET /v1/finetune/jobs[/id]` |
|
|
267
320
|
| `activate_model_version(id)` | `POST /v1/finetune/model-versions/{id}/activate` |
|
|
268
321
|
| `drift_status()` | `GET /v1/monitor/drift-status` |
|
|
322
|
+
| `entries_to_refresh()` / `resolve_entry(key)` | `GET /v1/monitor/entries-to-refresh` / `POST .../{key}/resolve` |
|
|
269
323
|
| `gray_zone_threshold()` | `GET /v1/monitor/gray-zone-threshold` |
|
|
270
324
|
| `usage()` / `savings()` | `GET /v1/usage/status` / `/savings` |
|
|
325
|
+
| `LocalVerifier.verify(...)` / `verify_batch(...)` | local; decisions to `POST /v1/verify/decisions` |
|
|
326
|
+
| `LocalVerifier.refresh()` | `GET /v1/models/active` + `/v1/models/{id}/files/{name}` |
|
|
271
327
|
|
|
272
328
|
Non-2xx responses raise `CacheVerifierError` (`.status_code`, `.detail`).
|
|
273
329
|
|
|
@@ -25,6 +25,8 @@ pip install cacheverifier
|
|
|
25
25
|
pip install "cacheverifier[gptcache]"
|
|
26
26
|
# with the offline Health Check (adds torch + sentence-transformers):
|
|
27
27
|
pip install "cacheverifier[healthcheck]"
|
|
28
|
+
# to score with your fine-tuned model on your own machines (same deps):
|
|
29
|
+
pip install "cacheverifier[local]"
|
|
28
30
|
```
|
|
29
31
|
|
|
30
32
|
Requires Python 3.9+. The only runtime dependency is `httpx` — the extras above
|
|
@@ -144,6 +146,19 @@ set_llm_cache(
|
|
|
144
146
|
)
|
|
145
147
|
```
|
|
146
148
|
|
|
149
|
+
## Entries to refresh
|
|
150
|
+
|
|
151
|
+
Send your cache's own key with feedback, and the service tells you which
|
|
152
|
+
entries to fix: ones reported stale, or wrong more often than right.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
cv.feedback(query, cached_answer, was_correct=False, stale=True, cache_entry_id="faq:shipping")
|
|
156
|
+
|
|
157
|
+
for e in cv.entries_to_refresh():
|
|
158
|
+
my_cache.delete(e["cache_entry_id"]) # or regenerate the answer
|
|
159
|
+
cv.resolve_entry(e["key"])
|
|
160
|
+
```
|
|
161
|
+
|
|
147
162
|
## Fine-tuning
|
|
148
163
|
|
|
149
164
|
Once you have ~20+ feedback rows (the service found fine-tuning is often a net negative
|
|
@@ -163,6 +178,40 @@ if job.get("result_model_version"):
|
|
|
163
178
|
`cv.dry_run([...])` reports the same baseline-vs-tuned AUC on examples you pass directly,
|
|
164
179
|
without writing anything or deploying a model.
|
|
165
180
|
|
|
181
|
+
## Local scoring (Managed plans)
|
|
182
|
+
|
|
183
|
+
`LocalVerifier` runs the model CacheVerifier fine-tuned for you on your own
|
|
184
|
+
machines: no network round trip on your request path, and your query and
|
|
185
|
+
answer texts stay with you. Fine-tuning, drift monitoring, audits and
|
|
186
|
+
certification keep running on the service.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from cacheverifier import LocalVerifier
|
|
190
|
+
|
|
191
|
+
cv = LocalVerifier(api_key="cv_...") # downloads your live model in the background
|
|
192
|
+
r = cv.verify(query, cached_answer, similarity_score=0.87, cached_query=cached_question)
|
|
193
|
+
if r.approved:
|
|
194
|
+
... # serve from cache
|
|
195
|
+
else:
|
|
196
|
+
answer = call_llm(query)
|
|
197
|
+
if r.audit: # a randomized audit: report whether the cached answer was right
|
|
198
|
+
cv.feedback(query, cached_answer, was_correct=same_answer(answer, cached_answer), result=r)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- It decides exactly as the hosted `/v1/verify` would with that model: every download
|
|
202
|
+
carries reference verdicts from the service, and a model that doesn't reproduce them
|
|
203
|
+
locally is not used.
|
|
204
|
+
- When a newer model goes live (after a fine-tune or a scheduled recalibration), it is
|
|
205
|
+
downloaded, checked, and switched to without a restart.
|
|
206
|
+
- While auditing is on, each decision is uploaded as scores, verdicts and a keyed hash of
|
|
207
|
+
the pair, never the texts, so audits and `/v1/monitor/risk` work as with the hosted API.
|
|
208
|
+
- Until a model is ready, calls go to the hosted API (`fallback="hosted"`, the default)
|
|
209
|
+
or are rejected (`fallback="reject"`). Pass `wait=True` to block until the first
|
|
210
|
+
download finishes (a model is ~330MB).
|
|
211
|
+
- The audit draw happens in this client. The risk bound assumes it is independent of
|
|
212
|
+
whether the answer is right, which the client guarantees unless it is modified.
|
|
213
|
+
- Not supported yet: the online ACI threshold (local scoring uses the model's fixed one).
|
|
214
|
+
|
|
166
215
|
## Local Health Check (offline)
|
|
167
216
|
|
|
168
217
|
`cv.dry_run()` still uploads your examples to the API. If that's a blocker — a
|
|
@@ -224,15 +273,18 @@ verdict
|
|
|
224
273
|
|
|
225
274
|
| method | endpoint |
|
|
226
275
|
|---|---|
|
|
227
|
-
| `verify(query, candidate_answer)` | `POST /v1/verify` |
|
|
276
|
+
| `verify(query, candidate_answer, similarity_score=..., cached_query=...)` | `POST /v1/verify` |
|
|
228
277
|
| `verify_batch(pairs)` | `POST /v1/verify/batch` |
|
|
229
278
|
| `feedback(...)` / `feedback_batch(items)` | `POST /v1/feedback` / `/batch` |
|
|
230
279
|
| `finetune(...)` / `dry_run(examples, ...)` | `POST /v1/finetune/jobs` / `/dry-run` |
|
|
231
280
|
| `get_finetune_job(id)` / `list_finetune_jobs()` | `GET /v1/finetune/jobs[/id]` |
|
|
232
281
|
| `activate_model_version(id)` | `POST /v1/finetune/model-versions/{id}/activate` |
|
|
233
282
|
| `drift_status()` | `GET /v1/monitor/drift-status` |
|
|
283
|
+
| `entries_to_refresh()` / `resolve_entry(key)` | `GET /v1/monitor/entries-to-refresh` / `POST .../{key}/resolve` |
|
|
234
284
|
| `gray_zone_threshold()` | `GET /v1/monitor/gray-zone-threshold` |
|
|
235
285
|
| `usage()` / `savings()` | `GET /v1/usage/status` / `/savings` |
|
|
286
|
+
| `LocalVerifier.verify(...)` / `verify_batch(...)` | local; decisions to `POST /v1/verify/decisions` |
|
|
287
|
+
| `LocalVerifier.refresh()` | `GET /v1/models/active` + `/v1/models/{id}/files/{name}` |
|
|
236
288
|
|
|
237
289
|
Non-2xx responses raise `CacheVerifierError` (`.status_code`, `.detail`).
|
|
238
290
|
|
|
@@ -17,6 +17,15 @@ Research behind it: https://github.com/imxinchengyou/CacheVerifier
|
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
19
|
from cacheverifier.client import CacheVerifier, CacheVerifierError, ShadowVerdict, VerifyResult
|
|
20
|
+
from cacheverifier.local import LocalVerifier, ModelCheckFailed
|
|
20
21
|
|
|
21
|
-
__version__ = "0.
|
|
22
|
-
__all__ = [
|
|
22
|
+
__version__ = "0.7.0"
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CacheVerifier",
|
|
25
|
+
"CacheVerifierError",
|
|
26
|
+
"LocalVerifier",
|
|
27
|
+
"ModelCheckFailed",
|
|
28
|
+
"ShadowVerdict",
|
|
29
|
+
"VerifyResult",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
|
@@ -25,6 +25,7 @@ from sentence_transformers import CrossEncoder, InputExample
|
|
|
25
25
|
from torch.utils.data import DataLoader
|
|
26
26
|
|
|
27
27
|
from cacheverifier._healthcheck._base_models import DEFAULT_BASE_MODEL
|
|
28
|
+
from cacheverifier._inference import MAX_SEQUENCE_LENGTH, smart_truncate_pair
|
|
28
29
|
|
|
29
30
|
# The four third-party imports above are from the `healthcheck` extra. This module is
|
|
30
31
|
# only ever imported from inside `cacheverifier._healthcheck.cli.run()`,
|
|
@@ -36,13 +37,11 @@ NLI_LABEL_ORDER = ("contradiction", "entailment", "neutral")
|
|
|
36
37
|
"""`cross-encoder/nli-*` models' class order, used when the loaded model
|
|
37
38
|
doesn't expose an `id2label` naming them."""
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
_CONTENT_TOKEN_BUDGET = MAX_SEQUENCE_LENGTH - 3 # [CLS] + 2x [SEP]
|
|
45
|
-
_MIN_QUERY_TOKENS = 32
|
|
40
|
+
# Token cap and truncation shared with local scoring, so a Health Check
|
|
41
|
+
# trains with exactly what the service and `LocalVerifier` score with. Before
|
|
42
|
+
# 0.6.0 this module kept its own copy of the truncation, which had fallen
|
|
43
|
+
# behind the service's 0.7.0 change (a long query next to a short answer
|
|
44
|
+
# was cut to half the budget).
|
|
46
45
|
|
|
47
46
|
TRAIN_BATCH_SIZE = 4
|
|
48
47
|
TRAIN_EPOCHS = 3
|
|
@@ -102,34 +101,6 @@ class HealthCheckResult:
|
|
|
102
101
|
return self.auc_tuned - self.auc_baseline
|
|
103
102
|
|
|
104
103
|
|
|
105
|
-
def _head_tail_truncate(ids: list[int], budget: int, head_ratio: float = 0.6) -> list[int]:
|
|
106
|
-
if len(ids) <= budget:
|
|
107
|
-
return ids
|
|
108
|
-
head_n = int(budget * head_ratio)
|
|
109
|
-
tail_n = budget - head_n
|
|
110
|
-
return ids[:head_n] + (ids[-tail_n:] if tail_n > 0 else [])
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def smart_truncate_pair(query: str, candidate_answer: str, tokenizer: Any) -> tuple[str, str]:
|
|
114
|
-
"""Tokenizer-aware head+tail truncation, applied identically at train
|
|
115
|
-
and score time (a model trained on one truncation policy and scored
|
|
116
|
-
with another sees a different token window than it was optimized
|
|
117
|
-
against). A no-op whenever the pair already fits."""
|
|
118
|
-
query_ids = tokenizer.encode(query, add_special_tokens=False)
|
|
119
|
-
answer_ids = tokenizer.encode(candidate_answer, add_special_tokens=False)
|
|
120
|
-
if len(query_ids) + len(answer_ids) <= _CONTENT_TOKEN_BUDGET:
|
|
121
|
-
return query, candidate_answer
|
|
122
|
-
|
|
123
|
-
query_budget = min(len(query_ids), max(_MIN_QUERY_TOKENS, _CONTENT_TOKEN_BUDGET // 2))
|
|
124
|
-
answer_budget = _CONTENT_TOKEN_BUDGET - query_budget
|
|
125
|
-
query_ids = _head_tail_truncate(query_ids, query_budget)
|
|
126
|
-
answer_ids = _head_tail_truncate(answer_ids, answer_budget)
|
|
127
|
-
return (
|
|
128
|
-
tokenizer.decode(query_ids, skip_special_tokens=True),
|
|
129
|
-
tokenizer.decode(answer_ids, skip_special_tokens=True),
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
|
|
133
104
|
def roc_auc(scores: np.ndarray, labels: np.ndarray) -> float:
|
|
134
105
|
"""Rank-based AUC, no sklearn dependency. NaN if either class is absent."""
|
|
135
106
|
order = np.argsort(scores)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""The scoring and decision steps the hosted service applies to one
|
|
2
|
+
(query, candidate_answer) pair, for running a downloaded model locally.
|
|
3
|
+
|
|
4
|
+
A port of the hosted service's inference path: input composition with the
|
|
5
|
+
`cached_query` diff, tokenizer-aware truncation, and the threshold / joint
|
|
6
|
+
decision rules. Training, certification and monitoring stay on the service.
|
|
7
|
+
Pure Python (the tokenizer is passed in), so it imports without the `local`
|
|
8
|
+
extra.
|
|
9
|
+
|
|
10
|
+
Kept in step with the service by two checks: the service's own test suite
|
|
11
|
+
compares these functions with its implementation, and every model download
|
|
12
|
+
carries reference verdicts that `LocalVerifier` reproduces before it decides
|
|
13
|
+
anything (see `cacheverifier.local`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import difflib
|
|
19
|
+
import math
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
MAX_SEQUENCE_LENGTH = 128
|
|
24
|
+
"""Token cap applied at train and score time, as on the service."""
|
|
25
|
+
|
|
26
|
+
CONTENT_TOKEN_BUDGET = MAX_SEQUENCE_LENGTH - 3 # [CLS] + 2x [SEP]
|
|
27
|
+
MIN_QUERY_TOKENS = 32
|
|
28
|
+
QUERY_DIFF_MAX_WORDS = 12
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def query_diff_summary(current_query: str, cached_query: str) -> str:
|
|
32
|
+
"""Word-level diff of the cache entry's own query against the request's,
|
|
33
|
+
e.g. `removed: pause | added: cancel`."""
|
|
34
|
+
old, new = cached_query.split(), current_query.split()
|
|
35
|
+
sm = difflib.SequenceMatcher(a=old, b=new, autojunk=False)
|
|
36
|
+
removed: list[str] = []
|
|
37
|
+
added: list[str] = []
|
|
38
|
+
for tag, i1, i2, j1, j2 in sm.get_opcodes():
|
|
39
|
+
if tag in ("replace", "delete"):
|
|
40
|
+
removed.extend(old[i1:i2])
|
|
41
|
+
if tag in ("replace", "insert"):
|
|
42
|
+
added.extend(new[j1:j2])
|
|
43
|
+
parts = []
|
|
44
|
+
if removed:
|
|
45
|
+
parts.append("removed: " + " ".join(removed[:QUERY_DIFF_MAX_WORDS]))
|
|
46
|
+
if added:
|
|
47
|
+
parts.append("added: " + " ".join(added[:QUERY_DIFF_MAX_WORDS]))
|
|
48
|
+
return " | ".join(parts) if parts else "no change"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def compose_query_input(current_query: str, cached_query: str | None) -> str:
|
|
52
|
+
if not cached_query:
|
|
53
|
+
return current_query
|
|
54
|
+
return f"{current_query} [qdiff] {query_diff_summary(current_query, cached_query)}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _head_tail_truncate(ids: list[int], budget: int, head_ratio: float = 0.6) -> list[int]:
|
|
58
|
+
if len(ids) <= budget:
|
|
59
|
+
return ids
|
|
60
|
+
head_n = int(budget * head_ratio)
|
|
61
|
+
tail_n = budget - head_n
|
|
62
|
+
return ids[:head_n] + (ids[-tail_n:] if tail_n > 0 else [])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def smart_truncate_pair(query: str, candidate_answer: str, tokenizer: Any) -> tuple[str, str]:
|
|
66
|
+
"""Tokenizer-aware head+tail truncation, a no-op whenever the pair fits.
|
|
67
|
+
The answer gets at most half the budget while the query wants more; the
|
|
68
|
+
query gets whatever the answer doesn't need."""
|
|
69
|
+
query_ids = tokenizer.encode(query, add_special_tokens=False)
|
|
70
|
+
answer_ids = tokenizer.encode(candidate_answer, add_special_tokens=False)
|
|
71
|
+
if len(query_ids) + len(answer_ids) <= CONTENT_TOKEN_BUDGET:
|
|
72
|
+
return query, candidate_answer
|
|
73
|
+
half = CONTENT_TOKEN_BUDGET // 2
|
|
74
|
+
query_budget = min(len(query_ids), max(MIN_QUERY_TOKENS, half, CONTENT_TOKEN_BUDGET - len(answer_ids)))
|
|
75
|
+
answer_budget = CONTENT_TOKEN_BUDGET - query_budget
|
|
76
|
+
query_ids = _head_tail_truncate(query_ids, query_budget)
|
|
77
|
+
answer_ids = _head_tail_truncate(answer_ids, answer_budget)
|
|
78
|
+
return (
|
|
79
|
+
tokenizer.decode(query_ids, skip_special_tokens=True),
|
|
80
|
+
tokenizer.decode(answer_ids, skip_special_tokens=True),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _sigmoid(x: float) -> float:
|
|
85
|
+
if x >= 0.0:
|
|
86
|
+
return 1.0 / (1.0 + math.exp(-x))
|
|
87
|
+
e = math.exp(x)
|
|
88
|
+
return e / (1.0 + e)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class JointRule:
|
|
93
|
+
"""A fitted joint decision rule, as the service stores it: approve iff
|
|
94
|
+
the verifier score reaches `verifier_floor` (when set) and the fused
|
|
95
|
+
probability over the standardized similarity, verifier score (and, with
|
|
96
|
+
`uses_gap`, the gap to the runner-up match) reaches `threshold`."""
|
|
97
|
+
|
|
98
|
+
coef_similarity: float
|
|
99
|
+
coef_verifier: float
|
|
100
|
+
intercept: float
|
|
101
|
+
similarity_mean: float
|
|
102
|
+
similarity_std: float
|
|
103
|
+
verifier_mean: float
|
|
104
|
+
verifier_std: float
|
|
105
|
+
threshold: float
|
|
106
|
+
coef_gap: float = 0.0
|
|
107
|
+
gap_mean: float = 0.0
|
|
108
|
+
gap_std: float = 1.0
|
|
109
|
+
uses_gap: bool = False
|
|
110
|
+
verifier_floor: float | None = None
|
|
111
|
+
|
|
112
|
+
def approves(
|
|
113
|
+
self, verifier_score: float, similarity_score: float, runner_up_similarity_score: float | None = None
|
|
114
|
+
) -> bool:
|
|
115
|
+
if self.verifier_floor is not None and verifier_score < self.verifier_floor:
|
|
116
|
+
return False
|
|
117
|
+
z_sim = (similarity_score - self.similarity_mean) / self.similarity_std
|
|
118
|
+
z_ver = (verifier_score - self.verifier_mean) / self.verifier_std
|
|
119
|
+
logit = self.coef_similarity * z_sim + self.coef_verifier * z_ver + self.intercept
|
|
120
|
+
if self.uses_gap:
|
|
121
|
+
if runner_up_similarity_score is None:
|
|
122
|
+
raise ValueError("this joint rule uses the similarity gap; runner_up_similarity_score is required")
|
|
123
|
+
z_gap = ((similarity_score - runner_up_similarity_score) - self.gap_mean) / self.gap_std
|
|
124
|
+
logit += self.coef_gap * z_gap
|
|
125
|
+
return _sigmoid(logit) >= self.threshold
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_dict(cls, d: dict[str, Any]) -> JointRule:
|
|
129
|
+
floor = d.get("verifier_floor")
|
|
130
|
+
return cls(
|
|
131
|
+
coef_similarity=float(d["coef_similarity"]),
|
|
132
|
+
coef_verifier=float(d["coef_verifier"]),
|
|
133
|
+
intercept=float(d["intercept"]),
|
|
134
|
+
similarity_mean=float(d["similarity_mean"]),
|
|
135
|
+
similarity_std=float(d["similarity_std"]),
|
|
136
|
+
verifier_mean=float(d["verifier_mean"]),
|
|
137
|
+
verifier_std=float(d["verifier_std"]),
|
|
138
|
+
threshold=float(d["threshold"]),
|
|
139
|
+
coef_gap=float(d.get("coef_gap", 0.0)),
|
|
140
|
+
gap_mean=float(d.get("gap_mean", 0.0)),
|
|
141
|
+
gap_std=float(d.get("gap_std", 1.0)),
|
|
142
|
+
uses_gap=bool(d.get("uses_gap", False)),
|
|
143
|
+
verifier_floor=float(floor) if floor is not None else None,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def decide(
|
|
148
|
+
score: float,
|
|
149
|
+
threshold: float,
|
|
150
|
+
joint_rule: JointRule | None,
|
|
151
|
+
joint_rule_gap: JointRule | None,
|
|
152
|
+
similarity_score: float | None,
|
|
153
|
+
runner_up_similarity_score: float | None,
|
|
154
|
+
) -> tuple[bool, str]:
|
|
155
|
+
"""(approved, decision_rule), with the service's precedence: the gap
|
|
156
|
+
rule when both similarity scores are given, then the 2-feature rule
|
|
157
|
+
when the similarity is, else the plain threshold."""
|
|
158
|
+
if joint_rule_gap is not None and similarity_score is not None and runner_up_similarity_score is not None:
|
|
159
|
+
return joint_rule_gap.approves(score, similarity_score, runner_up_similarity_score), "joint_gap"
|
|
160
|
+
if joint_rule is not None and similarity_score is not None:
|
|
161
|
+
return joint_rule.approves(score, similarity_score), "joint"
|
|
162
|
+
return score >= threshold, "threshold"
|
|
@@ -105,6 +105,14 @@ class VerifyResult:
|
|
|
105
105
|
- `shadow`: on a cold-start call, what the shared stock model would have
|
|
106
106
|
decided (a `ShadowVerdict`); None otherwise, and on servers that don't
|
|
107
107
|
send it.
|
|
108
|
+
- `decision_rule`: `"threshold"`, `"joint"` / `"joint_gap"` (a fitted rule
|
|
109
|
+
that also used your similarity scores), `"aci"`, or `"cold_start"`.
|
|
110
|
+
- `audit`: this approval was drawn for a randomized audit, so `approved`
|
|
111
|
+
is False: call your LLM, serve its answer, and report whether the
|
|
112
|
+
cached answer matched with `feedback(..., verification_id=...)`.
|
|
113
|
+
- `verification_id`: set while auditing is on; pass it to `feedback()`.
|
|
114
|
+
From `LocalVerifier` it is None until the decision has been uploaded:
|
|
115
|
+
pass the result itself as `feedback(..., result=r)` instead.
|
|
108
116
|
"""
|
|
109
117
|
|
|
110
118
|
approved: bool
|
|
@@ -115,6 +123,11 @@ class VerifyResult:
|
|
|
115
123
|
degraded: bool = False
|
|
116
124
|
notice: str | None = None
|
|
117
125
|
shadow: ShadowVerdict | None = None
|
|
126
|
+
decision_rule: str | None = None
|
|
127
|
+
audit: bool = False
|
|
128
|
+
verification_id: int | None = None
|
|
129
|
+
local_decision: str | None = None
|
|
130
|
+
"""`LocalVerifier` only: its handle for this decision until upload."""
|
|
118
131
|
|
|
119
132
|
@classmethod
|
|
120
133
|
def _from_json(cls, d: dict[str, Any]) -> VerifyResult:
|
|
@@ -127,6 +140,9 @@ class VerifyResult:
|
|
|
127
140
|
latency_ms=float(d["latency_ms"]),
|
|
128
141
|
notice=d.get("notice"),
|
|
129
142
|
shadow=ShadowVerdict._from_json(shadow) if shadow else None,
|
|
143
|
+
decision_rule=d.get("decision_rule"),
|
|
144
|
+
audit=bool(d.get("audit", False)),
|
|
145
|
+
verification_id=d.get("verification_id"),
|
|
130
146
|
)
|
|
131
147
|
|
|
132
148
|
|
|
@@ -194,15 +210,36 @@ class CacheVerifier:
|
|
|
194
210
|
|
|
195
211
|
# -- core: verify ----------------------------------------------------
|
|
196
212
|
|
|
197
|
-
def verify(
|
|
213
|
+
def verify(
|
|
214
|
+
self,
|
|
215
|
+
query: str,
|
|
216
|
+
candidate_answer: str,
|
|
217
|
+
*,
|
|
218
|
+
similarity_score: float | None = None,
|
|
219
|
+
cached_query: str | None = None,
|
|
220
|
+
runner_up_similarity_score: float | None = None,
|
|
221
|
+
) -> VerifyResult:
|
|
198
222
|
"""Approve or reject one gray-zone cache hit. `POST /v1/verify`.
|
|
199
223
|
|
|
224
|
+
The optional arguments are what your cache lookup already knows:
|
|
225
|
+
the hit's similarity score, the cached entry's own original query,
|
|
226
|
+
and the second-best match's similarity. A fine-tuned model uses them
|
|
227
|
+
when it learned a rule from them; they never hurt otherwise.
|
|
228
|
+
|
|
200
229
|
On a timeout / connection error / 5xx / rate-limited 429 this returns a synthetic
|
|
201
230
|
`VerifyResult` (`degraded=True`, `approved` per `fail_open`) instead of
|
|
202
231
|
raising -- the verifier being unreachable should degrade to a normal
|
|
203
232
|
cache miss, not an exception on your request path.
|
|
204
233
|
"""
|
|
205
|
-
|
|
234
|
+
payload = {"query": query, "candidate_answer": candidate_answer}
|
|
235
|
+
payload.update(
|
|
236
|
+
_drop_none(
|
|
237
|
+
similarity_score=similarity_score,
|
|
238
|
+
cached_query=cached_query,
|
|
239
|
+
runner_up_similarity_score=runner_up_similarity_score,
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
data = self._verify_call("/v1/verify", payload)
|
|
206
243
|
if data is None:
|
|
207
244
|
return self._degraded_result()
|
|
208
245
|
return VerifyResult._from_json(data)
|
|
@@ -273,6 +310,9 @@ class CacheVerifier:
|
|
|
273
310
|
was_correct: bool,
|
|
274
311
|
*,
|
|
275
312
|
similarity_score: float | None = None,
|
|
313
|
+
cached_query: str | None = None,
|
|
314
|
+
verification_id: int | None = None,
|
|
315
|
+
cache_entry_id: str | None = None,
|
|
276
316
|
stale: bool = False,
|
|
277
317
|
idempotency_key: str | None = None,
|
|
278
318
|
) -> int:
|
|
@@ -286,6 +326,11 @@ class CacheVerifier:
|
|
|
286
326
|
- `similarity_score`: your cache backend's own score for this
|
|
287
327
|
candidate, if you still have it. Only used by
|
|
288
328
|
`gray_zone_threshold()`; safe to omit.
|
|
329
|
+
- `cached_query`: the cached entry's own original query, if you have it.
|
|
330
|
+
- `verification_id`: from the `VerifyResult` this feedback is about
|
|
331
|
+
(always for an `audit` result) -- it ties the label to that verdict.
|
|
332
|
+
- `cache_entry_id`: your own key for the cache entry the answer came
|
|
333
|
+
from; `entries_to_refresh()` hands it back.
|
|
289
334
|
- `stale`: set instead of a bare `was_correct=False` when the answer
|
|
290
335
|
is wrong ONLY because a fact changed (price, date, ...), not a
|
|
291
336
|
semantic mismatch. Stale rows are excluded from training and drift.
|
|
@@ -297,15 +342,22 @@ class CacheVerifier:
|
|
|
297
342
|
"was_correct": was_correct,
|
|
298
343
|
"stale": stale,
|
|
299
344
|
}
|
|
300
|
-
|
|
301
|
-
|
|
345
|
+
payload.update(
|
|
346
|
+
_drop_none(
|
|
347
|
+
similarity_score=similarity_score,
|
|
348
|
+
cached_query=cached_query,
|
|
349
|
+
verification_id=verification_id,
|
|
350
|
+
cache_entry_id=cache_entry_id,
|
|
351
|
+
)
|
|
352
|
+
)
|
|
302
353
|
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
303
354
|
return int(self._post("/v1/feedback", json=payload, headers=headers)["id"])
|
|
304
355
|
|
|
305
356
|
def feedback_batch(self, items: Iterable[dict[str, Any]]) -> list[int]:
|
|
306
357
|
"""Bulk-upload feedback rows (max 500). Each item is a dict with
|
|
307
358
|
`query`, `candidate_answer`, `was_correct`, and optionally
|
|
308
|
-
`similarity_score` / `
|
|
359
|
+
`similarity_score` / `cached_query` / `cache_entry_id` / `stale`.
|
|
360
|
+
Returns the created row ids.
|
|
309
361
|
`POST /v1/feedback/batch`.
|
|
310
362
|
"""
|
|
311
363
|
data = self._post("/v1/feedback/batch", json={"items": list(items)})
|
|
@@ -369,6 +421,21 @@ class CacheVerifier:
|
|
|
369
421
|
"""
|
|
370
422
|
return self._get("/v1/monitor/gray-zone-threshold")
|
|
371
423
|
|
|
424
|
+
def entries_to_refresh(self) -> list[dict[str, Any]]:
|
|
425
|
+
"""Cache entries your feedback reported stale, or wrong more often
|
|
426
|
+
than right -- refresh or drop them, then `resolve_entry(e["key"])`.
|
|
427
|
+
Each has `key`, `cache_entry_id` (if you sent one), `reason`
|
|
428
|
+
(`"stale"` / `"often_wrong"`), the answer, report counts and sample
|
|
429
|
+
queries. `GET /v1/monitor/entries-to-refresh`.
|
|
430
|
+
"""
|
|
431
|
+
return list(self._get("/v1/monitor/entries-to-refresh")["entries"])
|
|
432
|
+
|
|
433
|
+
def resolve_entry(self, key: str) -> dict[str, Any]:
|
|
434
|
+
"""Mark an entry refreshed: earlier feedback stops counting toward it.
|
|
435
|
+
`POST /v1/monitor/entries-to-refresh/{key}/resolve`.
|
|
436
|
+
"""
|
|
437
|
+
return self._post(f"/v1/monitor/entries-to-refresh/{key}/resolve")
|
|
438
|
+
|
|
372
439
|
def usage(self) -> dict[str, Any]:
|
|
373
440
|
"""`GET /v1/usage/status`."""
|
|
374
441
|
return self._get("/v1/usage/status")
|