superlocalmemory 3.8.11 → 3.8.13
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.
- package/CHANGELOG.md +85 -0
- package/README.md +7 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +62 -3
- package/src/superlocalmemory/cli/daemon.py +219 -10
- package/src/superlocalmemory/cli/setup_wizard.py +45 -1
- package/src/superlocalmemory/core/component_registry.py +25 -0
- package/src/superlocalmemory/core/config.py +35 -1
- package/src/superlocalmemory/core/engine_wiring.py +81 -5
- package/src/superlocalmemory/core/recall_pipeline.py +25 -4
- package/src/superlocalmemory/core/reranker_worker.py +23 -4
- package/src/superlocalmemory/infra/daemon_identity.py +16 -0
- package/src/superlocalmemory/infra/process_identity.py +180 -0
- package/src/superlocalmemory/infra/version_integrity.py +229 -0
- package/src/superlocalmemory/learning/feedback.py +288 -29
- package/src/superlocalmemory/learning/legacy_migration.py +45 -4
- package/src/superlocalmemory/mcp/_daemon_proxy.py +23 -1
- package/src/superlocalmemory/mcp/tools_active.py +109 -58
- package/src/superlocalmemory/mcp/tools_core.py +6 -5
- package/src/superlocalmemory/retrieval/remote_reranker.py +636 -0
- package/src/superlocalmemory/server/unified_daemon.py +27 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""SuperLocalMemory V3 — Remote (OpenAI-compatible) cross-encoder reranker.
|
|
6
|
+
|
|
7
|
+
v3.8.12 (issue #105). The mirror image of the remote EMBEDDING endpoint that
|
|
8
|
+
shipped in v3.4.24 (issue #16): when ``retrieval.cross_encoder_backend`` is
|
|
9
|
+
``"openai"`` (or ``"remote"``) and ``retrieval.cross_encoder_endpoint`` is set,
|
|
10
|
+
reranking is an HTTP POST to that endpoint instead of a local subprocess.
|
|
11
|
+
|
|
12
|
+
WHY THIS EXISTS
|
|
13
|
+
The bundled cross-encoder, ``cross-encoder/ms-marco-MiniLM-L-12-v2``, is
|
|
14
|
+
English-only. A Chinese, Japanese, or Arabic corpus was being scored by a
|
|
15
|
+
model that cannot read it — a silent relevance regression with no error to
|
|
16
|
+
look at. Bringing your own multilingual reranker (bge-reranker-v2-m3, a
|
|
17
|
+
Qwen reranker, …) is the same escape hatch embeddings already had.
|
|
18
|
+
|
|
19
|
+
WHY IT LIVES IN THE PARENT PROCESS
|
|
20
|
+
``CrossEncoderReranker`` spawns a subprocess to keep torch/ONNX out of the
|
|
21
|
+
parent. The remote path imports neither, so a subprocess would buy nothing
|
|
22
|
+
and cost a fork, a PID-file singleton, a warmup handshake, and the JSON
|
|
23
|
+
pipe. Issue #103's reporter hit exactly that: a machine-wide worker
|
|
24
|
+
singleton blocking a reranker that was never local to begin with. The
|
|
25
|
+
remote path never spawns, never touches the PID file, and never warms up a
|
|
26
|
+
model. This also mirrors the embedding side, where the OpenAI-compatible
|
|
27
|
+
call lives in ``core/embeddings.py`` (parent), not ``embedding_worker.py``.
|
|
28
|
+
|
|
29
|
+
WIRE PROTOCOL (Cohere-shaped ``/v1/rerank``; llama-server, TEI, Infinity, …)
|
|
30
|
+
Request : {"model": "...", "query": "...", "documents": ["...", ...]}
|
|
31
|
+
Response: {"results": [{"index": 0, "relevance_score": -5.94}, ...]}
|
|
32
|
+
|
|
33
|
+
Bare-list responses (``[{"index": 0, "score": 0.9}, ...]``) are accepted
|
|
34
|
+
too. Anything else is REJECTED with a precise error rather than coerced
|
|
35
|
+
into plausible-looking scores — issue #103 was a lesson in what silent
|
|
36
|
+
degradation costs.
|
|
37
|
+
|
|
38
|
+
FAILURE POLICY
|
|
39
|
+
An unreachable, slow, or malformed endpoint degrades to fusion-score
|
|
40
|
+
ordering and logs an error. It does NOT fall back to the local
|
|
41
|
+
cross-encoder: a user who configured a multilingual reranker asked for it
|
|
42
|
+
precisely because the local English model is wrong for their corpus, and
|
|
43
|
+
quietly substituting it would recreate the bug this feature fixes.
|
|
44
|
+
|
|
45
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import json
|
|
51
|
+
import logging
|
|
52
|
+
import math
|
|
53
|
+
import os
|
|
54
|
+
import threading
|
|
55
|
+
import time
|
|
56
|
+
from typing import Any
|
|
57
|
+
from urllib.parse import urlparse, urlunparse
|
|
58
|
+
|
|
59
|
+
from superlocalmemory.storage.models import AtomicFact
|
|
60
|
+
|
|
61
|
+
logger = logging.getLogger(__name__)
|
|
62
|
+
|
|
63
|
+
# Backend tokens that select the remote path. "openai" is what issue #105
|
|
64
|
+
# asked for and matches ``embedding.provider == "openai"``, the established
|
|
65
|
+
# repo token for "any OpenAI-compatible HTTP endpoint". It is a slight misnomer
|
|
66
|
+
# — OpenAI has no rerank API and these endpoints are usually llama-server or
|
|
67
|
+
# TEI — so "remote" is accepted as a truthful alias.
|
|
68
|
+
REMOTE_CROSS_ENCODER_BACKENDS = ("openai", "remote")
|
|
69
|
+
|
|
70
|
+
# Environment override for the bearer token. Preferred over the config field:
|
|
71
|
+
# ``config.json`` is world-readable in many installs and is copied around.
|
|
72
|
+
CROSS_ENCODER_API_KEY_ENV = "SLM_CROSS_ENCODER_API_KEY"
|
|
73
|
+
|
|
74
|
+
_CONNECT_TIMEOUT_S = 5.0
|
|
75
|
+
_DEFAULT_READ_TIMEOUT_S = 15.0
|
|
76
|
+
|
|
77
|
+
# A rerank response is a small array of floats. Anything past this is either a
|
|
78
|
+
# misconfigured URL pointing at something that is not a reranker, or a hostile
|
|
79
|
+
# endpoint trying to exhaust memory. Bounded read, hard stop.
|
|
80
|
+
_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
81
|
+
|
|
82
|
+
# Candidate pools are 50-200 in practice (semantic_top_k/bm25_top_k are 50).
|
|
83
|
+
# This cap only guards against a pathological pool inflating one HTTP body.
|
|
84
|
+
_MAX_DOCUMENTS = 512
|
|
85
|
+
|
|
86
|
+
# Only transport faults and 5xx are retried, and only once: recall is
|
|
87
|
+
# interactive, so a second failure must surface fast rather than pay a
|
|
88
|
+
# backoff sleep on the user's latency budget.
|
|
89
|
+
_MAX_ATTEMPTS = 2
|
|
90
|
+
|
|
91
|
+
# Consecutive failures re-log at most this often. The first failure always
|
|
92
|
+
# logs; the operator must never have to guess whether reranking is running.
|
|
93
|
+
_FAILURE_RELOG_INTERVAL_S = 60.0
|
|
94
|
+
|
|
95
|
+
_ERROR_BODY_SNIPPET_CHARS = 200
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class RemoteRerankerError(RuntimeError):
|
|
99
|
+
"""A remote rerank request failed (transport, status, or schema)."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class RemoteRerankerConfigError(ValueError):
|
|
103
|
+
"""The remote reranker configuration is unusable as written."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Configuration (pure functions — no I/O, directly testable)
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def is_remote_cross_encoder_backend(backend: str) -> bool:
|
|
111
|
+
"""True when ``backend`` selects the remote reranker."""
|
|
112
|
+
return (backend or "").strip().lower() in REMOTE_CROSS_ENCODER_BACKENDS
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def validate_remote_reranker_config(backend: str, endpoint: str) -> str | None:
|
|
116
|
+
"""Return an actionable error string, or None when the pair is coherent.
|
|
117
|
+
|
|
118
|
+
Covers the issue-#103 leftover directly: an endpoint configured against a
|
|
119
|
+
LOCAL backend used to be dropped on the floor by ``SLMConfig.load``. It now
|
|
120
|
+
produces a named error naming both keys and the exact edit to make.
|
|
121
|
+
"""
|
|
122
|
+
backend = (backend or "").strip()
|
|
123
|
+
endpoint = (endpoint or "").strip()
|
|
124
|
+
remote = is_remote_cross_encoder_backend(backend)
|
|
125
|
+
|
|
126
|
+
if remote and not endpoint:
|
|
127
|
+
return (
|
|
128
|
+
f"retrieval.cross_encoder_backend={backend!r} selects the remote "
|
|
129
|
+
f"reranker but retrieval.cross_encoder_endpoint is empty. Set the "
|
|
130
|
+
f"endpoint (e.g. \"http://127.0.0.1:8041/v1/rerank\"), or set "
|
|
131
|
+
f"cross_encoder_backend to \"\" (PyTorch) / \"onnx\" to rerank "
|
|
132
|
+
f"locally."
|
|
133
|
+
)
|
|
134
|
+
if endpoint and not remote:
|
|
135
|
+
return (
|
|
136
|
+
f"retrieval.cross_encoder_endpoint is set to {endpoint!r} but "
|
|
137
|
+
f"retrieval.cross_encoder_backend={backend!r} is a LOCAL backend, "
|
|
138
|
+
f"so the endpoint would be ignored. Set cross_encoder_backend to "
|
|
139
|
+
f"\"openai\" to use the endpoint, or remove cross_encoder_endpoint "
|
|
140
|
+
f"to rerank locally."
|
|
141
|
+
)
|
|
142
|
+
if not remote:
|
|
143
|
+
return None
|
|
144
|
+
return _validate_endpoint_url(endpoint)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _validate_endpoint_url(endpoint: str) -> str | None:
|
|
148
|
+
"""Scheme/host allow-listing for the operator-supplied rerank URL."""
|
|
149
|
+
try:
|
|
150
|
+
parsed = urlparse(endpoint)
|
|
151
|
+
except ValueError as exc:
|
|
152
|
+
return f"retrieval.cross_encoder_endpoint is not a valid URL: {exc}"
|
|
153
|
+
if parsed.scheme not in ("http", "https"):
|
|
154
|
+
return (
|
|
155
|
+
f"retrieval.cross_encoder_endpoint must use http or https, got "
|
|
156
|
+
f"{parsed.scheme or '(none)'!r}. SuperLocalMemory will not open "
|
|
157
|
+
f"file/ftp/other schemes for reranking."
|
|
158
|
+
)
|
|
159
|
+
if not parsed.hostname:
|
|
160
|
+
return (
|
|
161
|
+
"retrieval.cross_encoder_endpoint has no host; expected something "
|
|
162
|
+
"like \"http://127.0.0.1:8041/v1/rerank\"."
|
|
163
|
+
)
|
|
164
|
+
if parsed.username or parsed.password:
|
|
165
|
+
# httpx logs "HTTP Request: POST <url>" at INFO using str(url), which
|
|
166
|
+
# renders an embedded password in full. This module never logs the raw
|
|
167
|
+
# URL, but it does not own the httpx logger — so credentials are
|
|
168
|
+
# refused at the door instead of being trusted to stay redacted.
|
|
169
|
+
return (
|
|
170
|
+
"retrieval.cross_encoder_endpoint must not embed credentials "
|
|
171
|
+
"(user:password@host) — the HTTP client logs request URLs in "
|
|
172
|
+
"full. Put the token in SLM_CROSS_ENCODER_API_KEY (preferred) or "
|
|
173
|
+
"retrieval.cross_encoder_api_key; it is sent as a Bearer header "
|
|
174
|
+
"and never logged."
|
|
175
|
+
)
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def normalize_rerank_endpoint(endpoint: str) -> str:
|
|
180
|
+
"""Append ``/rerank`` when the URL stops at the API root.
|
|
181
|
+
|
|
182
|
+
Mirrors the embedding path's ``/embeddings`` suffixing so a user can paste
|
|
183
|
+
either ``http://host:8041/v1`` or ``http://host:8041/v1/rerank``.
|
|
184
|
+
"""
|
|
185
|
+
url = (endpoint or "").strip().rstrip("/")
|
|
186
|
+
parsed = urlparse(url)
|
|
187
|
+
if parsed.path.endswith("/rerank"):
|
|
188
|
+
return url
|
|
189
|
+
return f"{url}/rerank"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def redact_endpoint(endpoint: str) -> str:
|
|
193
|
+
"""Drop any ``user:password@`` userinfo before an endpoint reaches a log.
|
|
194
|
+
|
|
195
|
+
Defence in depth. ``_validate_endpoint_url`` already refuses credentialed
|
|
196
|
+
URLs, so this should never have anything to strip in a configured install
|
|
197
|
+
— it exists so that any future caller constructing a reranker directly
|
|
198
|
+
still cannot put a password in the log.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
parsed = urlparse(endpoint)
|
|
202
|
+
except ValueError:
|
|
203
|
+
return "<unparseable endpoint>"
|
|
204
|
+
if not parsed.hostname:
|
|
205
|
+
return endpoint
|
|
206
|
+
netloc = parsed.hostname
|
|
207
|
+
if parsed.port:
|
|
208
|
+
netloc = f"{netloc}:{parsed.port}"
|
|
209
|
+
if parsed.username or parsed.password:
|
|
210
|
+
netloc = f"***@{netloc}"
|
|
211
|
+
return urlunparse(parsed._replace(netloc=netloc))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# Response parsing (pure — the schema gate)
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def parse_rerank_response(payload: Any, expected: int) -> list[float]:
|
|
219
|
+
"""Validate a ``/v1/rerank`` payload and return scores in document order.
|
|
220
|
+
|
|
221
|
+
Raises ``RemoteRerankerError`` on ANY deviation. A rerank response that is
|
|
222
|
+
not understood must abort reranking, never yield partly-invented scores:
|
|
223
|
+
a wrong score silently reorders a user's memory, and nothing downstream
|
|
224
|
+
can tell that apart from a good one.
|
|
225
|
+
"""
|
|
226
|
+
results = _extract_results_array(payload)
|
|
227
|
+
if len(results) != expected:
|
|
228
|
+
raise RemoteRerankerError(
|
|
229
|
+
f"rerank endpoint returned {len(results)} results for "
|
|
230
|
+
f"{expected} documents; refusing to guess the missing scores"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
scores: list[float | None] = [None] * expected
|
|
234
|
+
for position, item in enumerate(results):
|
|
235
|
+
if not isinstance(item, dict):
|
|
236
|
+
raise RemoteRerankerError(
|
|
237
|
+
f"rerank result #{position} is {type(item).__name__}, "
|
|
238
|
+
f"expected an object with 'index' and 'relevance_score'"
|
|
239
|
+
)
|
|
240
|
+
index = _coerce_index(item, position, expected)
|
|
241
|
+
if scores[index] is not None:
|
|
242
|
+
raise RemoteRerankerError(
|
|
243
|
+
f"rerank endpoint returned index {index} more than once"
|
|
244
|
+
)
|
|
245
|
+
scores[index] = _coerce_score(item, index)
|
|
246
|
+
|
|
247
|
+
missing = [i for i, s in enumerate(scores) if s is None]
|
|
248
|
+
if missing:
|
|
249
|
+
raise RemoteRerankerError(
|
|
250
|
+
f"rerank endpoint returned no score for document index(es) "
|
|
251
|
+
f"{missing[:5]}{'…' if len(missing) > 5 else ''}"
|
|
252
|
+
)
|
|
253
|
+
return [float(s) for s in scores] # type: ignore[arg-type]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _extract_results_array(payload: Any) -> list[Any]:
|
|
257
|
+
if isinstance(payload, list):
|
|
258
|
+
return payload # text-embeddings-inference style bare array
|
|
259
|
+
if not isinstance(payload, dict):
|
|
260
|
+
raise RemoteRerankerError(
|
|
261
|
+
f"rerank endpoint returned {type(payload).__name__}, expected a "
|
|
262
|
+
f"JSON object with a 'results' array"
|
|
263
|
+
)
|
|
264
|
+
results = payload.get("results")
|
|
265
|
+
if results is None:
|
|
266
|
+
raise RemoteRerankerError(
|
|
267
|
+
f"rerank response has no 'results' array (keys: "
|
|
268
|
+
f"{sorted(payload)[:8]}). Is cross_encoder_endpoint pointing at a "
|
|
269
|
+
f"rerank route and not, say, /v1/embeddings?"
|
|
270
|
+
)
|
|
271
|
+
if not isinstance(results, list):
|
|
272
|
+
raise RemoteRerankerError(
|
|
273
|
+
f"rerank response 'results' is {type(results).__name__}, "
|
|
274
|
+
f"expected an array"
|
|
275
|
+
)
|
|
276
|
+
return results
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _coerce_index(item: dict, position: int, expected: int) -> int:
|
|
280
|
+
raw = item.get("index", position)
|
|
281
|
+
if isinstance(raw, bool) or not isinstance(raw, int):
|
|
282
|
+
raise RemoteRerankerError(
|
|
283
|
+
f"rerank result #{position} has non-integer index {raw!r}"
|
|
284
|
+
)
|
|
285
|
+
if not 0 <= raw < expected:
|
|
286
|
+
raise RemoteRerankerError(
|
|
287
|
+
f"rerank result #{position} has out-of-range index {raw} "
|
|
288
|
+
f"(sent {expected} documents)"
|
|
289
|
+
)
|
|
290
|
+
return raw
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _coerce_score(item: dict, index: int) -> float:
|
|
294
|
+
for key in ("relevance_score", "score"):
|
|
295
|
+
if key in item:
|
|
296
|
+
raw = item[key]
|
|
297
|
+
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
|
|
298
|
+
raise RemoteRerankerError(
|
|
299
|
+
f"rerank result for index {index} has non-numeric "
|
|
300
|
+
f"{key}={raw!r}"
|
|
301
|
+
)
|
|
302
|
+
value = float(raw)
|
|
303
|
+
if not math.isfinite(value):
|
|
304
|
+
raise RemoteRerankerError(
|
|
305
|
+
f"rerank result for index {index} has non-finite "
|
|
306
|
+
f"{key}={raw!r}"
|
|
307
|
+
)
|
|
308
|
+
return value
|
|
309
|
+
raise RemoteRerankerError(
|
|
310
|
+
f"rerank result for index {index} has neither 'relevance_score' nor "
|
|
311
|
+
f"'score' (keys: {sorted(item)[:8]})"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# The reranker
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
class RemoteReranker:
|
|
320
|
+
"""Rerank candidates via an OpenAI-compatible ``/v1/rerank`` endpoint.
|
|
321
|
+
|
|
322
|
+
Public surface is interchangeable with ``CrossEncoderReranker`` so
|
|
323
|
+
``RetrievalEngine`` never learns which one it holds.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
model_name: Model identifier passed to the endpoint (llama-server
|
|
327
|
+
wants the served path, e.g. ``/root/model/reranker.gguf``).
|
|
328
|
+
endpoint: Base or full rerank URL. ``/rerank`` is appended when absent.
|
|
329
|
+
api_key: Optional bearer token. ``SLM_CROSS_ENCODER_API_KEY`` wins.
|
|
330
|
+
backend: The configured backend token, for validation + logs.
|
|
331
|
+
timeout_seconds: Per-request read budget.
|
|
332
|
+
|
|
333
|
+
Raises:
|
|
334
|
+
RemoteRerankerConfigError: the backend/endpoint pair is unusable.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
def __init__(
|
|
338
|
+
self,
|
|
339
|
+
model_name: str,
|
|
340
|
+
endpoint: str,
|
|
341
|
+
*,
|
|
342
|
+
api_key: str = "",
|
|
343
|
+
backend: str = "openai",
|
|
344
|
+
timeout_seconds: float = _DEFAULT_READ_TIMEOUT_S,
|
|
345
|
+
) -> None:
|
|
346
|
+
error = validate_remote_reranker_config(backend, endpoint)
|
|
347
|
+
if error:
|
|
348
|
+
raise RemoteRerankerConfigError(error)
|
|
349
|
+
|
|
350
|
+
self._model_name = model_name
|
|
351
|
+
self._backend = backend
|
|
352
|
+
self._endpoint = normalize_rerank_endpoint(endpoint)
|
|
353
|
+
self.safe_endpoint = redact_endpoint(self._endpoint)
|
|
354
|
+
self._api_key = os.environ.get(CROSS_ENCODER_API_KEY_ENV, "") or api_key
|
|
355
|
+
try:
|
|
356
|
+
self._read_timeout = max(1.0, float(timeout_seconds))
|
|
357
|
+
except (TypeError, ValueError):
|
|
358
|
+
self._read_timeout = _DEFAULT_READ_TIMEOUT_S
|
|
359
|
+
|
|
360
|
+
self._client: Any = None
|
|
361
|
+
self._client_lock = threading.Lock()
|
|
362
|
+
self._shutdown = threading.Event()
|
|
363
|
+
self._consecutive_failures = 0
|
|
364
|
+
self._last_failure_log = 0.0
|
|
365
|
+
self._probe_ok = False
|
|
366
|
+
|
|
367
|
+
# -- lifecycle ---------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
def warmup_sync(self, timeout: float = _DEFAULT_READ_TIMEOUT_S) -> bool:
|
|
370
|
+
"""Probe the endpoint once so startup states reachability out loud.
|
|
371
|
+
|
|
372
|
+
Diagnostic only: a failed probe never disables reranking, because an
|
|
373
|
+
endpoint that is still booting will serve the next real recall fine.
|
|
374
|
+
"""
|
|
375
|
+
if self._shutdown.is_set():
|
|
376
|
+
return False
|
|
377
|
+
try:
|
|
378
|
+
self._request_scores("ping", ["SuperLocalMemory reranker probe"])
|
|
379
|
+
except RemoteRerankerError as exc:
|
|
380
|
+
self._probe_ok = False
|
|
381
|
+
logger.error(
|
|
382
|
+
"Remote reranker probe failed for %s (model=%s): %s. Recall "
|
|
383
|
+
"will run WITHOUT reranking until the endpoint answers. "
|
|
384
|
+
"Verify retrieval.cross_encoder_endpoint and that the service "
|
|
385
|
+
"is up.",
|
|
386
|
+
self.safe_endpoint, self._model_name, exc,
|
|
387
|
+
)
|
|
388
|
+
return False
|
|
389
|
+
self._probe_ok = True
|
|
390
|
+
logger.info(
|
|
391
|
+
"Remote reranker ready: %s (model=%s, backend=%s)",
|
|
392
|
+
self.safe_endpoint, self._model_name, self._backend,
|
|
393
|
+
)
|
|
394
|
+
return True
|
|
395
|
+
|
|
396
|
+
def unload(self) -> None:
|
|
397
|
+
"""Release the pooled HTTP connections; the object stays usable."""
|
|
398
|
+
self._close_client()
|
|
399
|
+
|
|
400
|
+
def shutdown(self, timeout: float = 3.0) -> None: # noqa: ARG002 - parity
|
|
401
|
+
"""Stop serving and close the HTTP client."""
|
|
402
|
+
self._shutdown.set()
|
|
403
|
+
self._close_client()
|
|
404
|
+
|
|
405
|
+
def __del__(self) -> None:
|
|
406
|
+
try:
|
|
407
|
+
self._close_client()
|
|
408
|
+
except Exception:
|
|
409
|
+
pass
|
|
410
|
+
|
|
411
|
+
@property
|
|
412
|
+
def is_available(self) -> bool:
|
|
413
|
+
"""Whether the endpoint answers a probe right now."""
|
|
414
|
+
if self._shutdown.is_set():
|
|
415
|
+
return False
|
|
416
|
+
try:
|
|
417
|
+
self._request_scores("ping", ["SuperLocalMemory reranker probe"])
|
|
418
|
+
except RemoteRerankerError:
|
|
419
|
+
return False
|
|
420
|
+
return True
|
|
421
|
+
|
|
422
|
+
# -- public reranking --------------------------------------------------
|
|
423
|
+
|
|
424
|
+
def rerank(
|
|
425
|
+
self,
|
|
426
|
+
query: str,
|
|
427
|
+
candidates: list[tuple[AtomicFact, float]],
|
|
428
|
+
top_k: int = 10,
|
|
429
|
+
) -> list[tuple[AtomicFact, float]]:
|
|
430
|
+
"""Rerank ``candidates``; fusion order is returned when the endpoint fails."""
|
|
431
|
+
results, _, _ = self.rerank_with_status(query, candidates, top_k=top_k)
|
|
432
|
+
return results
|
|
433
|
+
|
|
434
|
+
def rerank_with_status(
|
|
435
|
+
self,
|
|
436
|
+
query: str,
|
|
437
|
+
candidates: list[tuple[AtomicFact, float]],
|
|
438
|
+
top_k: int = 10,
|
|
439
|
+
) -> tuple[list[tuple[AtomicFact, float]], bool, str]:
|
|
440
|
+
"""Return results plus whether remote reranking actually ran."""
|
|
441
|
+
if not candidates:
|
|
442
|
+
return [], False, "no_candidates"
|
|
443
|
+
if self._shutdown.is_set():
|
|
444
|
+
return self._fusion_order(candidates)[:top_k], False, "shutdown"
|
|
445
|
+
|
|
446
|
+
ranked = self._fusion_order(candidates)
|
|
447
|
+
if len(ranked) > _MAX_DOCUMENTS:
|
|
448
|
+
# Unreachable with stock config (semantic_top_k/bm25_top_k are 50).
|
|
449
|
+
# RetrievalEngine keeps every fused result and assigns the batch
|
|
450
|
+
# minimum to any fact absent from the rerank map, so the excluded
|
|
451
|
+
# tail — already the lowest-fusion candidates — is demoted, not
|
|
452
|
+
# lost.
|
|
453
|
+
logger.warning(
|
|
454
|
+
"Remote reranker: %d candidates exceeds the %d-document "
|
|
455
|
+
"request cap; reranking the top %d by fusion score and "
|
|
456
|
+
"dropping the rest",
|
|
457
|
+
len(ranked), _MAX_DOCUMENTS, _MAX_DOCUMENTS,
|
|
458
|
+
)
|
|
459
|
+
ranked = ranked[:_MAX_DOCUMENTS]
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
scores = self._request_scores(
|
|
463
|
+
query, [fact.content for fact, _ in ranked],
|
|
464
|
+
)
|
|
465
|
+
except RemoteRerankerError as exc:
|
|
466
|
+
self._note_failure(exc)
|
|
467
|
+
return ranked[:top_k], False, "remote_unavailable"
|
|
468
|
+
|
|
469
|
+
self._note_success()
|
|
470
|
+
scored = [
|
|
471
|
+
(fact, float(score))
|
|
472
|
+
for (fact, _), score in zip(ranked, scores)
|
|
473
|
+
]
|
|
474
|
+
scored.sort(key=lambda pair: pair[1], reverse=True)
|
|
475
|
+
return scored[:top_k], True, "applied"
|
|
476
|
+
|
|
477
|
+
def score_pair(self, query: str, document: str) -> float:
|
|
478
|
+
"""Score one (query, document) pair; 0.0 when the endpoint fails."""
|
|
479
|
+
if self._shutdown.is_set():
|
|
480
|
+
return 0.0
|
|
481
|
+
try:
|
|
482
|
+
return self._request_scores(query, [document])[0]
|
|
483
|
+
except RemoteRerankerError as exc:
|
|
484
|
+
self._note_failure(exc)
|
|
485
|
+
return 0.0
|
|
486
|
+
|
|
487
|
+
# -- HTTP --------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
def _request_scores(self, query: str, documents: list[str]) -> list[float]:
|
|
490
|
+
"""POST one rerank request, retrying only genuinely transient faults."""
|
|
491
|
+
headers = {"Content-Type": "application/json"}
|
|
492
|
+
if self._api_key:
|
|
493
|
+
# Never logged: no error path in this module formats `headers`.
|
|
494
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
495
|
+
body = {
|
|
496
|
+
"model": self._model_name,
|
|
497
|
+
"query": query,
|
|
498
|
+
"documents": documents,
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
last_error: RemoteRerankerError | None = None
|
|
502
|
+
for attempt in range(_MAX_ATTEMPTS):
|
|
503
|
+
try:
|
|
504
|
+
payload = self._post(headers, body)
|
|
505
|
+
except _RetryableRemoteError as exc:
|
|
506
|
+
last_error = RemoteRerankerError(str(exc))
|
|
507
|
+
if attempt < _MAX_ATTEMPTS - 1:
|
|
508
|
+
continue
|
|
509
|
+
break
|
|
510
|
+
return parse_rerank_response(payload, len(documents))
|
|
511
|
+
raise RemoteRerankerError(
|
|
512
|
+
f"remote reranker at {self.safe_endpoint} failed after "
|
|
513
|
+
f"{_MAX_ATTEMPTS} attempts: {last_error}"
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
def _post(self, headers: dict[str, str], body: dict[str, Any]) -> Any:
|
|
517
|
+
"""Send the request and return parsed JSON, with a bounded body read."""
|
|
518
|
+
import httpx
|
|
519
|
+
|
|
520
|
+
client = self._get_client()
|
|
521
|
+
try:
|
|
522
|
+
with client.stream(
|
|
523
|
+
"POST", self._endpoint, headers=headers, json=body,
|
|
524
|
+
) as resp:
|
|
525
|
+
raw = _read_bounded(resp)
|
|
526
|
+
if 300 <= resp.status_code < 400:
|
|
527
|
+
# Redirects are not followed: a rerank endpoint that
|
|
528
|
+
# bounces us elsewhere is either misconfigured or an
|
|
529
|
+
# attempt to pivot this outbound request at a host the
|
|
530
|
+
# operator never approved.
|
|
531
|
+
raise RemoteRerankerError(
|
|
532
|
+
f"rerank endpoint {self.safe_endpoint} replied HTTP "
|
|
533
|
+
f"{resp.status_code} (redirect). Redirects are not "
|
|
534
|
+
f"followed — configure the final URL directly."
|
|
535
|
+
)
|
|
536
|
+
if resp.status_code >= 400:
|
|
537
|
+
snippet = raw[:_ERROR_BODY_SNIPPET_CHARS].decode(
|
|
538
|
+
"utf-8", "replace",
|
|
539
|
+
)
|
|
540
|
+
message = (
|
|
541
|
+
f"HTTP {resp.status_code} from {self.safe_endpoint}: "
|
|
542
|
+
f"{snippet}"
|
|
543
|
+
)
|
|
544
|
+
if resp.status_code >= 500:
|
|
545
|
+
raise _RetryableRemoteError(message)
|
|
546
|
+
raise RemoteRerankerError(message)
|
|
547
|
+
except httpx.TransportError as exc:
|
|
548
|
+
raise _RetryableRemoteError(
|
|
549
|
+
f"cannot reach {self.safe_endpoint}: "
|
|
550
|
+
f"{type(exc).__name__}: {exc}"
|
|
551
|
+
) from exc
|
|
552
|
+
try:
|
|
553
|
+
return json.loads(raw)
|
|
554
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
555
|
+
raise RemoteRerankerError(
|
|
556
|
+
f"rerank endpoint {self.safe_endpoint} returned non-JSON "
|
|
557
|
+
f"({exc})"
|
|
558
|
+
) from exc
|
|
559
|
+
|
|
560
|
+
def _get_client(self) -> Any:
|
|
561
|
+
import httpx
|
|
562
|
+
|
|
563
|
+
with self._client_lock:
|
|
564
|
+
if self._client is None:
|
|
565
|
+
self._client = httpx.Client(
|
|
566
|
+
timeout=httpx.Timeout(
|
|
567
|
+
connect=_CONNECT_TIMEOUT_S,
|
|
568
|
+
read=self._read_timeout,
|
|
569
|
+
write=10.0,
|
|
570
|
+
pool=5.0,
|
|
571
|
+
),
|
|
572
|
+
follow_redirects=False,
|
|
573
|
+
)
|
|
574
|
+
return self._client
|
|
575
|
+
|
|
576
|
+
def _close_client(self) -> None:
|
|
577
|
+
with self._client_lock:
|
|
578
|
+
client, self._client = self._client, None
|
|
579
|
+
if client is not None:
|
|
580
|
+
try:
|
|
581
|
+
client.close()
|
|
582
|
+
except Exception:
|
|
583
|
+
pass
|
|
584
|
+
|
|
585
|
+
# -- failure visibility ------------------------------------------------
|
|
586
|
+
|
|
587
|
+
def _note_failure(self, exc: Exception) -> None:
|
|
588
|
+
"""Make a degraded reranker impossible to miss, without log flooding."""
|
|
589
|
+
self._consecutive_failures += 1
|
|
590
|
+
now = time.time()
|
|
591
|
+
if (
|
|
592
|
+
self._consecutive_failures == 1
|
|
593
|
+
or now - self._last_failure_log >= _FAILURE_RELOG_INTERVAL_S
|
|
594
|
+
):
|
|
595
|
+
logger.error(
|
|
596
|
+
"Remote reranker unavailable (%d consecutive failures): %s. "
|
|
597
|
+
"Recall is returning fusion-ranked results with NO reranking. "
|
|
598
|
+
"SuperLocalMemory will not silently substitute the local "
|
|
599
|
+
"English cross-encoder for your configured model.",
|
|
600
|
+
self._consecutive_failures, exc,
|
|
601
|
+
)
|
|
602
|
+
self._last_failure_log = now
|
|
603
|
+
|
|
604
|
+
def _note_success(self) -> None:
|
|
605
|
+
if self._consecutive_failures:
|
|
606
|
+
logger.info(
|
|
607
|
+
"Remote reranker recovered after %d consecutive failures (%s)",
|
|
608
|
+
self._consecutive_failures, self.safe_endpoint,
|
|
609
|
+
)
|
|
610
|
+
self._consecutive_failures = 0
|
|
611
|
+
|
|
612
|
+
@staticmethod
|
|
613
|
+
def _fusion_order(
|
|
614
|
+
candidates: list[tuple[AtomicFact, float]],
|
|
615
|
+
) -> list[tuple[AtomicFact, float]]:
|
|
616
|
+
return sorted(candidates, key=lambda pair: pair[1], reverse=True)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
class _RetryableRemoteError(RemoteRerankerError):
|
|
620
|
+
"""Internal marker: this failure is worth exactly one more attempt."""
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _read_bounded(resp: Any) -> bytes:
|
|
624
|
+
"""Read a streaming response body, refusing to buffer past the cap."""
|
|
625
|
+
chunks: list[bytes] = []
|
|
626
|
+
total = 0
|
|
627
|
+
for chunk in resp.iter_bytes():
|
|
628
|
+
total += len(chunk)
|
|
629
|
+
if total > _MAX_RESPONSE_BYTES:
|
|
630
|
+
raise RemoteRerankerError(
|
|
631
|
+
f"rerank response exceeded {_MAX_RESPONSE_BYTES} bytes; "
|
|
632
|
+
f"aborting the read. Is cross_encoder_endpoint pointing at a "
|
|
633
|
+
f"rerank route?"
|
|
634
|
+
)
|
|
635
|
+
chunks.append(chunk)
|
|
636
|
+
return b"".join(chunks)
|
|
@@ -3272,6 +3272,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3272
3272
|
"runtime_state": runtime_state,
|
|
3273
3273
|
"active_profile": profile_snapshot.profile_id,
|
|
3274
3274
|
"profile_generation": profile_snapshot.generation,
|
|
3275
|
+
# issue #107: does this daemon's *imported* code still match the
|
|
3276
|
+
# installed distribution? ``version`` above reports what this
|
|
3277
|
+
# process loaded, which is self-consistent and therefore cannot
|
|
3278
|
+
# reveal staleness on its own. Loopback-only, alongside the other
|
|
3279
|
+
# operational metadata.
|
|
3280
|
+
"version_integrity": _version_integrity_payload(),
|
|
3275
3281
|
}
|
|
3276
3282
|
|
|
3277
3283
|
@application.get("/recall")
|
|
@@ -4016,6 +4022,27 @@ class _PendingProfileMismatchError(RuntimeError):
|
|
|
4016
4022
|
"""A legacy pending row no longer matches the admitted profile lease."""
|
|
4017
4023
|
|
|
4018
4024
|
|
|
4025
|
+
def _version_integrity_payload() -> dict:
|
|
4026
|
+
"""Report whether this daemon's imported code matches what is installed.
|
|
4027
|
+
|
|
4028
|
+
Issue #107. ``/health``'s ``version`` field reports the version this
|
|
4029
|
+
process loaded at import, so a stale daemon reports its *own* stale version
|
|
4030
|
+
perfectly happily -- self-consistent and useless as a staleness signal.
|
|
4031
|
+
This compares that against the distribution metadata on disk.
|
|
4032
|
+
|
|
4033
|
+
Fail-open by construction: any error degrades to a ``state`` of
|
|
4034
|
+
``"unknown"`` rather than raising, because ``/health`` is what clients poll
|
|
4035
|
+
to decide whether the daemon is usable and must not start returning 500s
|
|
4036
|
+
over a diagnostic.
|
|
4037
|
+
"""
|
|
4038
|
+
try:
|
|
4039
|
+
from superlocalmemory.infra.version_integrity import check_version_integrity
|
|
4040
|
+
|
|
4041
|
+
return check_version_integrity().as_dict()
|
|
4042
|
+
except Exception as exc: # noqa: BLE001 - health must never fail on this
|
|
4043
|
+
return {"state": "unknown", "detail": f"version check failed: {exc}"}
|
|
4044
|
+
|
|
4045
|
+
|
|
4019
4046
|
def _materializer_actor_id() -> str:
|
|
4020
4047
|
"""Return the process-owned actor identity used by background writes."""
|
|
4021
4048
|
descriptor = _ACTIVE_DAEMON_DESCRIPTOR
|