superlocalmemory 3.8.10 → 3.8.11
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 +36 -0
- package/README.md +3 -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 +13 -3
- package/src/superlocalmemory/core/reranker_worker.py +59 -17
- package/src/superlocalmemory/learning/feedback.py +46 -4
- package/src/superlocalmemory/learning/pattern_miner.py +31 -11
- package/src/superlocalmemory/mcp/tools_active.py +115 -4
- package/src/superlocalmemory/retrieval/reranker.py +52 -5
- package/src/superlocalmemory/server/unified_daemon.py +4 -0
- package/src/superlocalmemory/storage/migration_runner.py +9 -0
- package/src/superlocalmemory/storage/migrations/M033_learning_feedback_channel.py +77 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,42 @@ All notable changes to SuperLocalMemory V3 will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.8.11] - 2026-08-02 — Learning-signal integrity and honest reranker diagnostics
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Explicit feedback reported through `report_feedback` now writes to the
|
|
12
|
+
canonical learning store (`learning.db`), which every learning consumer
|
|
13
|
+
reads: the adaptive-ranking phase gate, `pattern_miner`, and the dashboard
|
|
14
|
+
Living Brain. Previously it wrote only to a table nothing else read, so
|
|
15
|
+
feedback returned success and a rising counter while the ranker never
|
|
16
|
+
advanced past Phase 1 (#102).
|
|
17
|
+
- `learning_feedback` now has the `channel` column `pattern_miner` has always
|
|
18
|
+
queried but no schema ever defined. Every fresh database raised
|
|
19
|
+
`no such column: channel` on the first mining pass — caught, logged at
|
|
20
|
+
debug, and silently disabled both channel-performance mining and the
|
|
21
|
+
co-retrieval mining that shared its error handler. Migration `M033`
|
|
22
|
+
backfills existing databases without touching existing rows (#102).
|
|
23
|
+
- The cross-encoder reranker now reports the real reason a model load
|
|
24
|
+
failed instead of a generic timeout message, and no longer retries a
|
|
25
|
+
configuration error five times (~7.5 minutes) before giving up. An
|
|
26
|
+
unrecognized `cross_encoder_backend` value is now rejected by name;
|
|
27
|
+
SuperLocalMemory has no remote/OpenAI-compatible reranker backend, so a
|
|
28
|
+
`cross_encoder_endpoint` config key was previously accepted and silently
|
|
29
|
+
ignored (#103).
|
|
30
|
+
- `slm recall` no longer crashes when a daemon response's
|
|
31
|
+
`retrieval_time_ms` or a result's `score` is present but `null` — the
|
|
32
|
+
keyword-fallback recall path now includes `retrieval_time_ms` in every
|
|
33
|
+
response, matching every other recall path's contract.
|
|
34
|
+
- The MagicMock artifact guard (`.gitignore` and its CI test) now also
|
|
35
|
+
catches the directory-shaped leak (`MagicMock/mock/<id>/`) produced when
|
|
36
|
+
a mock-derived path reaches `mkdir()`, not just the file-shaped leak
|
|
37
|
+
(`<MagicMock id='...'>`) produced by `os.open()`.
|
|
38
|
+
|
|
39
|
+
### Documentation
|
|
40
|
+
- `docs/auto-memory.md` no longer references `slm patterns` / `slm useful`,
|
|
41
|
+
which do not exist in V3; documents the `report_feedback` MCP tool as the
|
|
42
|
+
supported path instead.
|
|
43
|
+
|
|
8
44
|
## [3.8.10] - 2026-07-29 — Reliable startup and MCP writes
|
|
9
45
|
|
|
10
46
|
### Fixed
|
package/README.md
CHANGED
|
@@ -5,15 +5,15 @@
|
|
|
5
5
|
</picture>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
|
-
<h1 align="center">SuperLocalMemory V3.8.
|
|
8
|
+
<h1 align="center">SuperLocalMemory V3.8.11</h1>
|
|
9
9
|
<p align="center"><strong>Enterprise-grade, local-first memory for AI agents and teams.</strong><br/>
|
|
10
10
|
<em>A persistent, auditable long-term brain for your agents that runs on your own infrastructure — with multi-workspace isolation, role-based access, and GDPR + EU AI Act governance controls built in.</em></p>
|
|
11
|
-
<p align="center"><code>v3.8.
|
|
11
|
+
<p align="center"><code>v3.8.11</code> — one control plane: auditable retrieval · multi-scope memory (personal / shared / global) · Cache · Compress · trusted-peer Mesh · bounded loops — across CLI, MCP, dashboard, the <strong>Claude plugin</strong>, the <strong>Codex add-on</strong>, and documented IDE integrations.<br/>
|
|
12
12
|
Proxy: <code>slm wrap claude</code> · MCP: add <code>slm_compress</code> to your config · Skill: zero-config</p>
|
|
13
13
|
<p align="center"><strong>3 public research preprints</strong> (arXiv + Zenodo archives) · <a href="https://arxiv.org/abs/2603.02240">arXiv:2603.02240</a> · <a href="https://arxiv.org/abs/2603.14588">arXiv:2603.14588</a> · <a href="https://arxiv.org/abs/2604.04514">arXiv:2604.04514</a></p>
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
16
|
-
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/v3.8.
|
|
16
|
+
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/v3.8.11-Current_Release-2ea44f?style=for-the-badge&logo=checkmarx&logoColor=white" alt="v3.8.11 — Current Release"/></a>
|
|
17
17
|
<a href="https://arxiv.org/abs/2603.14588"><img src="https://img.shields.io/badge/arXiv-2603.14588-b31b1b?style=for-the-badge&logo=arxiv&logoColor=white" alt="arXiv Paper"/></a>
|
|
18
18
|
<a href="#three-surfaces-proxy--mcp-tools--skill"><img src="https://img.shields.io/badge/Proxy_|_MCP_|_Skill-22c55e?style=for-the-badge" alt="Three Surfaces: Proxy, MCP Tools, Skill"/></a>
|
|
19
19
|
<a href="https://pypi.org/project/superlocalmemory/"><img src="https://img.shields.io/pypi/v/superlocalmemory?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"/></a>
|
package/package.json
CHANGED
package/plugin/CLAUDE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- BEGIN SuperLocalMemory v3.8.
|
|
1
|
+
<!-- BEGIN SuperLocalMemory v3.8.11 -->
|
|
2
2
|
|
|
3
3
|
## SuperLocalMemory (SLM) — Agent Rules
|
|
4
4
|
|
|
@@ -39,6 +39,6 @@ slm-recall · slm-remember · slm-session · slm-status · slm-cache · slm-comp
|
|
|
39
39
|
### Subagents
|
|
40
40
|
slm-memory-advisor (memory decisions, session hygiene, scope/profile guidance) · slm-optimize-advisor (context compression + KV cache) · slm-governance-advisor (scope/roles/compliance/GDPR)
|
|
41
41
|
|
|
42
|
-
<!-- END SuperLocalMemory v3.8.
|
|
42
|
+
<!-- END SuperLocalMemory v3.8.11 -->
|
|
43
43
|
|
|
44
|
-
SuperLocalMemory v3.8.
|
|
44
|
+
SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
|
|
@@ -77,4 +77,4 @@ slm-scope · slm-governance · slm-profile · slm-remember · slm-recall
|
|
|
77
77
|
# What NOT to do
|
|
78
78
|
Never session_init twice; never forget without dry-run preview; never store secrets; never bypass role checks; never claim an erasure succeeded without verifying via recall.
|
|
79
79
|
|
|
80
|
-
SuperLocalMemory v3.8.
|
|
80
|
+
SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
|
|
@@ -46,4 +46,4 @@ slm-recall · slm-remember · slm-session · slm-scope · slm-profile · slm-gov
|
|
|
46
46
|
# What NOT to do
|
|
47
47
|
Never session_init twice; never forget dry_run=False without reporting preview; never dump a whole file into remember; never invent a memory; never claim "saved" without success:true / clean CLI exit; never bypass scope or governance restrictions.
|
|
48
48
|
|
|
49
|
-
SuperLocalMemory v3.8.
|
|
49
|
+
SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
|
|
@@ -41,4 +41,4 @@ slm-compress · slm-cache · slm-status · slm-profile
|
|
|
41
41
|
# What NOT to do
|
|
42
42
|
Never compress code-for-edit/JSON-to-parse/<500 chars; never store secrets/ccr_ids; never let optimize failure block/alter the task; never claim a specific savings %; never carry ccr_ids across profile switches.
|
|
43
43
|
|
|
44
|
-
SuperLocalMemory v3.8.
|
|
44
|
+
SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
|
package/plugin/requirements.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
superlocalmemory==3.8.
|
|
1
|
+
superlocalmemory==3.8.11
|
|
@@ -128,4 +128,4 @@ When the SLM MCP server is unavailable, use these CLI equivalents:
|
|
|
128
128
|
- **slm-optimize-advisor** — context compression and KV cache
|
|
129
129
|
- **slm-governance-advisor** — scope/role compliance, retention policies, GDPR
|
|
130
130
|
|
|
131
|
-
SuperLocalMemory v3.8.
|
|
131
|
+
SuperLocalMemory v3.8.11 · Qualixar · AGPL-3.0-or-later
|
package/pyproject.toml
CHANGED
|
@@ -32,7 +32,7 @@ if "OMP_NUM_THREADS" not in os.environ:
|
|
|
32
32
|
os.environ["OMP_NUM_THREADS"] = "2"
|
|
33
33
|
# ---------------------------------------------------------------------------
|
|
34
34
|
|
|
35
|
-
__version__ = "3.8.
|
|
35
|
+
__version__ = "3.8.11"
|
|
36
36
|
|
|
37
37
|
_REQUIRED_VERSIONS = {
|
|
38
38
|
"sentence_transformers": "5.3.0",
|
|
@@ -1459,10 +1459,20 @@ def cmd_recall(args: Namespace) -> None:
|
|
|
1459
1459
|
if result.get("no_confident_match")
|
|
1460
1460
|
else "No matching memories found.")
|
|
1461
1461
|
return
|
|
1462
|
-
# Text output
|
|
1463
|
-
|
|
1462
|
+
# Text output.
|
|
1463
|
+
# PR #101: ``dict.get(k, 0)`` returns the DEFAULT only when the
|
|
1464
|
+
# key is ABSENT — a present-but-null value still reaches the
|
|
1465
|
+
# format spec and raises "unsupported format string passed to
|
|
1466
|
+
# NoneType.__format__". ``or 0`` covers both. Same for score,
|
|
1467
|
+
# which the keyword-fallback path returns as None.
|
|
1468
|
+
elapsed_ms = result.get('retrieval_time_ms') or 0
|
|
1469
|
+
print(
|
|
1470
|
+
"SpreadingActivation.search completed via daemon "
|
|
1471
|
+
f"({elapsed_ms:.0f}ms)"
|
|
1472
|
+
)
|
|
1464
1473
|
for i, r in enumerate(result["results"], 1):
|
|
1465
|
-
|
|
1474
|
+
score = r.get('score') or 0
|
|
1475
|
+
print(f" {i}. [{score:.2f}] {r['content']}")
|
|
1466
1476
|
return
|
|
1467
1477
|
except Exception as _exc: # noqa: BLE001
|
|
1468
1478
|
logger.warning(
|
|
@@ -119,7 +119,9 @@ def _worker_main() -> None:
|
|
|
119
119
|
if cmd == "load":
|
|
120
120
|
name = req.get("model_name", "cross-encoder/ms-marco-MiniLM-L-12-v2")
|
|
121
121
|
backend = req.get("backend", "onnx")
|
|
122
|
-
model, active_backend, model_name = _load_model(
|
|
122
|
+
model, active_backend, model_name, load_error = _load_model(
|
|
123
|
+
name, backend,
|
|
124
|
+
)
|
|
123
125
|
# V3.3.16: Run real inference to trigger ONNX CoreML JIT compilation.
|
|
124
126
|
# Without this, first real rerank call triggers 30-60s compilation
|
|
125
127
|
# that exceeds the caller's timeout, killing the worker.
|
|
@@ -147,6 +149,9 @@ def _worker_main() -> None:
|
|
|
147
149
|
"backend": active_backend,
|
|
148
150
|
"model": model_name,
|
|
149
151
|
"warmup_inference": warmup_ok,
|
|
152
|
+
# Carries the real reason to the parent so the warmup log can
|
|
153
|
+
# print it instead of a generic timeout message (issue #103).
|
|
154
|
+
"error": load_error,
|
|
150
155
|
})
|
|
151
156
|
continue
|
|
152
157
|
|
|
@@ -160,9 +165,14 @@ def _worker_main() -> None:
|
|
|
160
165
|
# Auto-load with defaults
|
|
161
166
|
name = req.get("model_name", "cross-encoder/ms-marco-MiniLM-L-12-v2")
|
|
162
167
|
backend = req.get("backend", "onnx")
|
|
163
|
-
model, active_backend, model_name = _load_model(
|
|
168
|
+
model, active_backend, model_name, load_error = _load_model(
|
|
169
|
+
name, backend,
|
|
170
|
+
)
|
|
164
171
|
if model is None:
|
|
165
|
-
_respond({
|
|
172
|
+
_respond({
|
|
173
|
+
"ok": False,
|
|
174
|
+
"error": load_error or "Model load failed",
|
|
175
|
+
})
|
|
166
176
|
continue
|
|
167
177
|
try:
|
|
168
178
|
pairs = [(query, doc) for doc in documents]
|
|
@@ -195,9 +205,14 @@ def _worker_main() -> None:
|
|
|
195
205
|
if model is None:
|
|
196
206
|
name = req.get("model_name", "cross-encoder/ms-marco-MiniLM-L-12-v2")
|
|
197
207
|
backend = req.get("backend", "onnx")
|
|
198
|
-
model, active_backend, model_name = _load_model(
|
|
208
|
+
model, active_backend, model_name, load_error = _load_model(
|
|
209
|
+
name, backend,
|
|
210
|
+
)
|
|
199
211
|
if model is None:
|
|
200
|
-
_respond({
|
|
212
|
+
_respond({
|
|
213
|
+
"ok": False,
|
|
214
|
+
"error": load_error or "Model load failed",
|
|
215
|
+
})
|
|
201
216
|
continue
|
|
202
217
|
try:
|
|
203
218
|
try:
|
|
@@ -214,10 +229,13 @@ def _worker_main() -> None:
|
|
|
214
229
|
_respond({"ok": False, "error": f"Unknown command: {cmd}"})
|
|
215
230
|
|
|
216
231
|
|
|
232
|
+
_KNOWN_BACKENDS = ("onnx", "", "pytorch", "torch")
|
|
233
|
+
|
|
234
|
+
|
|
217
235
|
def _load_model(
|
|
218
236
|
name: str, backend: str,
|
|
219
237
|
) -> tuple:
|
|
220
|
-
"""Load cross-encoder model. Returns (model, backend_name, model_name).
|
|
238
|
+
"""Load cross-encoder model. Returns (model, backend_name, model_name, error).
|
|
221
239
|
|
|
222
240
|
V3.3.13: sentence-transformers 5.x+ supports backend='onnx' for
|
|
223
241
|
CrossEncoder. We use a 3-tier fallback chain:
|
|
@@ -231,6 +249,19 @@ def _load_model(
|
|
|
231
249
|
x86_64 → model_quint8_avx2.onnx
|
|
232
250
|
Fallback → model.onnx (generic)
|
|
233
251
|
"""
|
|
252
|
+
# v3.8.11 (issue #103): an unrecognised backend used to fall through to
|
|
253
|
+
# the PyTorch tier and fail there with a confusing model-load error. A
|
|
254
|
+
# user who set backend="openai" expecting a remote reranker got five
|
|
255
|
+
# silent failures and no hint that the value meant nothing. Name it.
|
|
256
|
+
if backend not in _KNOWN_BACKENDS:
|
|
257
|
+
return None, "", "", (
|
|
258
|
+
f"unknown backend {backend!r}; supported values are "
|
|
259
|
+
f"'onnx' or '' (PyTorch). SuperLocalMemory has no remote/"
|
|
260
|
+
f"OpenAI-compatible reranker backend — the cross-encoder always "
|
|
261
|
+
f"runs locally, so 'cross_encoder_endpoint' has no effect."
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
tier_errors: list[str] = []
|
|
234
265
|
try:
|
|
235
266
|
from sentence_transformers import CrossEncoder
|
|
236
267
|
|
|
@@ -242,24 +273,35 @@ def _load_model(
|
|
|
242
273
|
name, backend="onnx",
|
|
243
274
|
model_kwargs={"file_name": onnx_file},
|
|
244
275
|
)
|
|
245
|
-
return m, f"onnx-quantized({onnx_file})", name
|
|
246
|
-
except Exception:
|
|
247
|
-
|
|
276
|
+
return m, f"onnx-quantized({onnx_file})", name, ""
|
|
277
|
+
except Exception as exc:
|
|
278
|
+
tier_errors.append(f"onnx-quantized: {exc}")
|
|
248
279
|
|
|
249
280
|
# Tier 2: Generic ONNX (auto-exported by optimum)
|
|
250
281
|
try:
|
|
251
282
|
m = CrossEncoder(name, backend="onnx")
|
|
252
|
-
return m, "onnx", name
|
|
253
|
-
except Exception:
|
|
254
|
-
|
|
283
|
+
return m, "onnx", name, ""
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
tier_errors.append(f"onnx: {exc}")
|
|
255
286
|
|
|
256
287
|
# Tier 3: PyTorch (always works, no ONNX dependency needed)
|
|
257
288
|
m = CrossEncoder(name)
|
|
258
|
-
return m, "pytorch", name
|
|
259
|
-
except ImportError:
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
289
|
+
return m, "pytorch", name, ""
|
|
290
|
+
except ImportError as exc:
|
|
291
|
+
# Previously indistinguishable from a bad model name.
|
|
292
|
+
return None, "", "", (
|
|
293
|
+
f"sentence-transformers is not installed ({exc}); "
|
|
294
|
+
f"install it or set retrieval.use_cross_encoder=false"
|
|
295
|
+
)
|
|
296
|
+
except Exception as exc:
|
|
297
|
+
tier_errors.append(f"pytorch: {exc}")
|
|
298
|
+
# Every tier's real error, propagated instead of discarded. Before
|
|
299
|
+
# 3.8.11 this returned (None, "", "") and the operator saw only a
|
|
300
|
+
# generic "did not confirm ready" line from the parent process.
|
|
301
|
+
return None, "", "", (
|
|
302
|
+
f"could not load cross-encoder model {name!r} "
|
|
303
|
+
f"(backend={backend or 'pytorch'}): " + "; ".join(tier_errors)
|
|
304
|
+
)
|
|
263
305
|
|
|
264
306
|
|
|
265
307
|
def _respond(data: dict) -> None:
|
|
@@ -53,6 +53,13 @@ _DASHBOARD_SIGNAL_MAP: Dict[str, tuple[str, float]] = {
|
|
|
53
53
|
"dwell_negative": ("dwell_negative", 0.2),
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
# ``channel`` records WHICH retrieval channel surfaced the fact (semantic,
|
|
57
|
+
# bm25, entity_graph, temporal, ...). ``pattern_miner._mine_channel_and_
|
|
58
|
+
# coretrieval`` groups on it to mine ``channel_performance`` patterns. It was
|
|
59
|
+
# read by the miner but never defined here, so every fresh database raised
|
|
60
|
+
# "no such column: channel" — swallowed at debug level, which silently killed
|
|
61
|
+
# BOTH channel mining and the co-retrieval mining that followed it in the same
|
|
62
|
+
# try block. Defined here for new databases; M033 back-fills existing ones.
|
|
56
63
|
_CREATE_TABLE = """
|
|
57
64
|
CREATE TABLE IF NOT EXISTS learning_feedback (
|
|
58
65
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -62,7 +69,8 @@ CREATE TABLE IF NOT EXISTS learning_feedback (
|
|
|
62
69
|
signal_value REAL NOT NULL,
|
|
63
70
|
query_hash TEXT,
|
|
64
71
|
created_at TEXT NOT NULL,
|
|
65
|
-
metadata TEXT
|
|
72
|
+
metadata TEXT,
|
|
73
|
+
channel TEXT DEFAULT 'unknown'
|
|
66
74
|
)
|
|
67
75
|
"""
|
|
68
76
|
|
|
@@ -71,6 +79,11 @@ CREATE INDEX IF NOT EXISTS idx_feedback_profile
|
|
|
71
79
|
ON learning_feedback (profile_id, created_at DESC)
|
|
72
80
|
"""
|
|
73
81
|
|
|
82
|
+
_CREATE_CHANNEL_INDEX = """
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_channel
|
|
84
|
+
ON learning_feedback (profile_id, channel)
|
|
85
|
+
"""
|
|
86
|
+
|
|
74
87
|
|
|
75
88
|
def _utcnow_iso() -> str:
|
|
76
89
|
"""Return current UTC time as ISO-8601 string."""
|
|
@@ -108,6 +121,20 @@ class FeedbackCollector:
|
|
|
108
121
|
try:
|
|
109
122
|
conn.execute(_CREATE_TABLE)
|
|
110
123
|
conn.execute(_CREATE_INDEX)
|
|
124
|
+
# Pre-3.8.11 databases created ``learning_feedback`` without the
|
|
125
|
+
# ``channel`` column. M033 covers migrated installs; this ADD keeps
|
|
126
|
+
# a collector pointed at a legacy file self-healing rather than
|
|
127
|
+
# failing every channel query for the life of the process.
|
|
128
|
+
existing = {
|
|
129
|
+
row[1] for row in
|
|
130
|
+
conn.execute("PRAGMA table_info(learning_feedback)")
|
|
131
|
+
}
|
|
132
|
+
if "channel" not in existing:
|
|
133
|
+
conn.execute(
|
|
134
|
+
"ALTER TABLE learning_feedback "
|
|
135
|
+
"ADD COLUMN channel TEXT DEFAULT 'unknown'"
|
|
136
|
+
)
|
|
137
|
+
conn.execute(_CREATE_CHANNEL_INDEX)
|
|
111
138
|
conn.commit()
|
|
112
139
|
finally:
|
|
113
140
|
conn.close()
|
|
@@ -196,16 +223,29 @@ class FeedbackCollector:
|
|
|
196
223
|
fact_id: str,
|
|
197
224
|
signal_type: str,
|
|
198
225
|
value: float,
|
|
226
|
+
query: str = "",
|
|
227
|
+
channel: str = "unknown",
|
|
199
228
|
) -> Optional[int]:
|
|
200
229
|
"""
|
|
201
230
|
Record explicit user feedback on a specific fact.
|
|
202
231
|
|
|
232
|
+
This is the canonical durable write for the learning system. Recall
|
|
233
|
+
itself is deliberately read-only (it must never open a writer — see
|
|
234
|
+
``test_readonly_bandit_uses_uri_read_connection_and_never_records_play``),
|
|
235
|
+
so explicit feedback is the ONLY path that grows ``learning_feedback``.
|
|
236
|
+
Every downstream consumer reads this table: the phase gate
|
|
237
|
+
(``_ReadOnlyLearningView.count_feedback`` unlocks adaptive ranking at
|
|
238
|
+
50 rows), ``pattern_miner`` (channel_performance), and the dashboard.
|
|
239
|
+
|
|
203
240
|
Args:
|
|
204
241
|
profile_id: Profile providing feedback.
|
|
205
242
|
fact_id: The fact being rated.
|
|
206
243
|
signal_type: One of ``user_positive``, ``user_negative``,
|
|
207
244
|
``user_correction``, or any custom type.
|
|
208
245
|
value: Numeric signal value (0.0 to 1.0).
|
|
246
|
+
query: Originating query. Stored only as a SHA-256[:16]
|
|
247
|
+
hash — full text is never persisted.
|
|
248
|
+
channel: Retrieval channel that surfaced the fact.
|
|
209
249
|
|
|
210
250
|
Returns:
|
|
211
251
|
Row ID of the inserted record, or None on error.
|
|
@@ -215,6 +255,7 @@ class FeedbackCollector:
|
|
|
215
255
|
|
|
216
256
|
clamped = max(0.0, min(1.0, float(value)))
|
|
217
257
|
now = _utcnow_iso()
|
|
258
|
+
query_hash = _hash_query(query) if query else None
|
|
218
259
|
|
|
219
260
|
with self._lock:
|
|
220
261
|
conn = self._connect()
|
|
@@ -222,9 +263,10 @@ class FeedbackCollector:
|
|
|
222
263
|
cursor = conn.execute(
|
|
223
264
|
"INSERT INTO learning_feedback "
|
|
224
265
|
"(profile_id, fact_id, signal_type, signal_value, "
|
|
225
|
-
"query_hash, created_at, metadata) "
|
|
226
|
-
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
227
|
-
(profile_id, fact_id, signal_type, clamped,
|
|
266
|
+
"query_hash, created_at, metadata, channel) "
|
|
267
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
268
|
+
(profile_id, fact_id, signal_type, clamped, query_hash,
|
|
269
|
+
now, None, channel or "unknown"),
|
|
228
270
|
)
|
|
229
271
|
conn.commit()
|
|
230
272
|
return cursor.lastrowid
|
|
@@ -325,14 +325,28 @@ def _mine_channel_and_coretrieval(
|
|
|
325
325
|
learn_conn = sqlite3.connect(learning_db, timeout=10)
|
|
326
326
|
learn_conn.row_factory = sqlite3.Row
|
|
327
327
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
328
|
+
# Isolated from the co-retrieval block below. Until 3.8.11
|
|
329
|
+
# ``learning_feedback`` had no ``channel`` column, so this query
|
|
330
|
+
# raised and — sharing one try with co-retrieval — took that mining
|
|
331
|
+
# down with it. Two pattern types died from one missing column, and
|
|
332
|
+
# the only trace was a DEBUG line. Each miner now fails alone, loudly.
|
|
333
|
+
channel_rows = []
|
|
334
|
+
try:
|
|
335
|
+
channel_rows = learn_conn.execute(
|
|
336
|
+
"SELECT channel, COUNT(*) AS cnt, "
|
|
337
|
+
"AVG(signal_value) AS avg_signal "
|
|
338
|
+
"FROM learning_feedback "
|
|
339
|
+
"WHERE profile_id = ? "
|
|
340
|
+
"GROUP BY channel ORDER BY cnt DESC",
|
|
341
|
+
(profile_id,),
|
|
342
|
+
).fetchall()
|
|
343
|
+
except sqlite3.Error as exc:
|
|
344
|
+
logger.warning(
|
|
345
|
+
"Channel pattern mining skipped — learning_feedback query "
|
|
346
|
+
"failed (%s). Run 'slm db migrate' to apply M033 if this "
|
|
347
|
+
"reports a missing 'channel' column. Co-retrieval mining "
|
|
348
|
+
"continues.", exc,
|
|
349
|
+
)
|
|
336
350
|
|
|
337
351
|
for row in channel_rows:
|
|
338
352
|
d = dict(row)
|
|
@@ -376,12 +390,18 @@ def _mine_channel_and_coretrieval(
|
|
|
376
390
|
confidence=min(1.0, len(coret_rows) / 10),
|
|
377
391
|
)
|
|
378
392
|
gen += 1
|
|
379
|
-
except
|
|
380
|
-
|
|
393
|
+
except sqlite3.Error as exc:
|
|
394
|
+
logger.warning(
|
|
395
|
+
"Co-retrieval pattern mining skipped — co_retrieval_edges "
|
|
396
|
+
"query failed: %s", exc,
|
|
397
|
+
)
|
|
381
398
|
|
|
382
399
|
learn_conn.close()
|
|
383
400
|
except Exception as exc:
|
|
384
|
-
|
|
401
|
+
# Was DEBUG. A learning subsystem that mines nothing must say so at a
|
|
402
|
+
# level operators actually see; issue #102 went undiagnosed for weeks
|
|
403
|
+
# because the only evidence was invisible by default.
|
|
404
|
+
logger.warning("Signal pattern mining failed: %s", exc)
|
|
385
405
|
return gen
|
|
386
406
|
|
|
387
407
|
|
|
@@ -140,6 +140,78 @@ def _emit_event(event_type: str, payload: dict | None = None,
|
|
|
140
140
|
logger.warning("event emit failed: type=%s err=%s", event_type, exc)
|
|
141
141
|
|
|
142
142
|
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Canonical learning-store feedback (issue #102)
|
|
145
|
+
#
|
|
146
|
+
# learning.db is the single store every learning consumer reads: the phase
|
|
147
|
+
# gate (recall_pipeline._ReadOnlyLearningView.count_feedback), pattern_miner,
|
|
148
|
+
# the ranker retrainers, and the dashboard. Recall itself is deliberately
|
|
149
|
+
# read-only and must never open a writer, so an explicit feedback command is
|
|
150
|
+
# the only durable writer in the design. These helpers are that writer.
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
_FEEDBACK_SIGNAL_MAP: dict[str, tuple[str, float]] = {
|
|
154
|
+
"relevant": ("user_positive", 1.0),
|
|
155
|
+
"irrelevant": ("user_negative", 0.0),
|
|
156
|
+
"partial": ("user_correction", 0.5),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _learning_db_path():
|
|
161
|
+
"""Resolve the canonical learning.db path."""
|
|
162
|
+
return state_path("learning.db")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _record_canonical_feedback(
|
|
166
|
+
*, profile_id: str, fact_id: str, feedback: str, query: str = "",
|
|
167
|
+
channel: str = "explicit",
|
|
168
|
+
) -> bool:
|
|
169
|
+
"""Write explicit feedback to learning.db. Returns True on success.
|
|
170
|
+
|
|
171
|
+
Best-effort by design — a learning write must never fail the user's
|
|
172
|
+
feedback call — but the outcome is RETURNED rather than swallowed, so the
|
|
173
|
+
caller can tell the user the truth about whether the write was durable.
|
|
174
|
+
"""
|
|
175
|
+
signal_type, value = _FEEDBACK_SIGNAL_MAP.get(
|
|
176
|
+
feedback, ("user_correction", 0.5),
|
|
177
|
+
)
|
|
178
|
+
try:
|
|
179
|
+
from superlocalmemory.learning.feedback import FeedbackCollector
|
|
180
|
+
|
|
181
|
+
collector = FeedbackCollector(_learning_db_path())
|
|
182
|
+
row_id = collector.record_explicit(
|
|
183
|
+
profile_id=profile_id,
|
|
184
|
+
fact_id=fact_id,
|
|
185
|
+
signal_type=signal_type,
|
|
186
|
+
value=value,
|
|
187
|
+
query=query,
|
|
188
|
+
channel=channel,
|
|
189
|
+
)
|
|
190
|
+
return row_id is not None
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
logger.warning(
|
|
193
|
+
"canonical feedback write failed (fact_id=%s): %s", fact_id, exc,
|
|
194
|
+
)
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _canonical_feedback_count(profile_id: str) -> int | None:
|
|
199
|
+
"""Count rows in the store that gates the adaptive phases.
|
|
200
|
+
|
|
201
|
+
Returns None when the store cannot be read, so the caller can fall back
|
|
202
|
+
rather than report a misleading zero.
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
from superlocalmemory.learning.feedback import FeedbackCollector
|
|
206
|
+
|
|
207
|
+
return FeedbackCollector(
|
|
208
|
+
_learning_db_path(),
|
|
209
|
+
).get_feedback_count(profile_id)
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
logger.warning("canonical feedback count failed: %s", exc)
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
143
215
|
def register_active_tools(server, get_engine: Callable) -> None:
|
|
144
216
|
"""Register 3 active memory tools on *server*."""
|
|
145
217
|
|
|
@@ -551,25 +623,64 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
551
623
|
profile_id=pid,
|
|
552
624
|
)
|
|
553
625
|
|
|
554
|
-
|
|
626
|
+
# v3.8.11 (issue #102): the AdaptiveLearner write above lands in
|
|
627
|
+
# ``feedback_records`` in memory.db — a table whose only readers
|
|
628
|
+
# are AdaptiveLearner's own count and its train(), which nothing
|
|
629
|
+
# in the running system calls. Reported feedback therefore
|
|
630
|
+
# returned success and an incrementing counter while every actual
|
|
631
|
+
# consumer saw nothing.
|
|
632
|
+
#
|
|
633
|
+
# The canonical learning store is learning.db. Writing here is
|
|
634
|
+
# what makes feedback do work: the phase gate
|
|
635
|
+
# (_ReadOnlyLearningView.count_feedback) unlocks adaptive ranking
|
|
636
|
+
# at 50 rows, pattern_miner mines channel_performance from it, and
|
|
637
|
+
# the dashboard Living Brain reads it. Recall stays read-only by
|
|
638
|
+
# design, so this explicit path is the ONLY durable writer.
|
|
639
|
+
#
|
|
640
|
+
# Kept alongside (not replacing) the AdaptiveLearner write so
|
|
641
|
+
# existing feedback_records data and GDPR erasure stay intact.
|
|
642
|
+
canonical_recorded = _record_canonical_feedback(
|
|
643
|
+
profile_id=pid,
|
|
644
|
+
fact_id=fact_id,
|
|
645
|
+
feedback=feedback,
|
|
646
|
+
query=query,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
# Report the count from the store that ACTUALLY gates the phases.
|
|
650
|
+
# Pre-3.8.11 this returned the feedback_records count, so the
|
|
651
|
+
# caller watched a number climb toward 50 while the gate — which
|
|
652
|
+
# reads learning_feedback — never moved.
|
|
653
|
+
count = _canonical_feedback_count(pid)
|
|
654
|
+
if count is None:
|
|
655
|
+
count = engine._adaptive_learner.get_feedback_count(pid)
|
|
555
656
|
authorization.complete()
|
|
556
657
|
|
|
658
|
+
phase = 1 if count < 50 else (2 if count < 200 else 3)
|
|
557
659
|
_emit_event("pattern.learned", {
|
|
558
660
|
"fact_id": fact_id,
|
|
559
661
|
"feedback": feedback,
|
|
560
662
|
"total_signals": count,
|
|
561
|
-
"phase":
|
|
663
|
+
"phase": phase,
|
|
562
664
|
})
|
|
563
665
|
|
|
564
|
-
|
|
666
|
+
result = {
|
|
565
667
|
"success": True,
|
|
566
668
|
"feedback_id": record.feedback_id,
|
|
567
669
|
"total_signals": count,
|
|
568
|
-
"phase":
|
|
670
|
+
"phase": phase,
|
|
569
671
|
"message": f"Feedback recorded. {count} total signals."
|
|
570
672
|
+ (" Phase 2 unlocked!" if count == 50 else "")
|
|
571
673
|
+ (" Phase 3 (ML) unlocked!" if count == 200 else ""),
|
|
572
674
|
}
|
|
675
|
+
if not canonical_recorded:
|
|
676
|
+
# Never claim a durable learning write that did not happen.
|
|
677
|
+
result["durable"] = False
|
|
678
|
+
result["warning"] = (
|
|
679
|
+
"Feedback was accepted but could not be written to the "
|
|
680
|
+
"canonical learning store; it will not influence ranking. "
|
|
681
|
+
"Run 'slm doctor' to diagnose learning.db."
|
|
682
|
+
)
|
|
683
|
+
return result
|
|
573
684
|
except Exception as exc:
|
|
574
685
|
logger.exception("report_feedback failed")
|
|
575
686
|
return {"success": False, "error": str(exc)}
|
|
@@ -84,6 +84,21 @@ _WARMUP_MAX_ATTEMPTS = int(os.environ.get("SLM_RERANKER_WARMUP_ATTEMPTS", "5"))
|
|
|
84
84
|
_WARMUP_RETRY_BACKOFF_S = float(os.environ.get("SLM_RERANKER_WARMUP_BACKOFF", "3"))
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
# Substrings that mark a load failure as a configuration problem rather than a
|
|
88
|
+
# transient one. Retrying these can never succeed, so the warmup aborts on the
|
|
89
|
+
# first occurrence instead of spending _WARMUP_MAX_ATTEMPTS × backoff on them.
|
|
90
|
+
_PERMANENT_LOAD_ERROR_MARKERS = (
|
|
91
|
+
"unknown backend",
|
|
92
|
+
"sentence-transformers is not installed",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_permanent_load_error(error: str) -> bool:
|
|
97
|
+
"""True when a worker load error cannot be fixed by retrying."""
|
|
98
|
+
lowered = (error or "").lower()
|
|
99
|
+
return any(m in lowered for m in _PERMANENT_LOAD_ERROR_MARKERS)
|
|
100
|
+
|
|
101
|
+
|
|
87
102
|
class CrossEncoderReranker:
|
|
88
103
|
"""Rerank candidate facts using a local cross-encoder model.
|
|
89
104
|
|
|
@@ -201,11 +216,43 @@ class CrossEncoderReranker:
|
|
|
201
216
|
resp.get("warmup_inference", False),
|
|
202
217
|
)
|
|
203
218
|
return
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
219
|
+
# v3.8.11 (issue #103): this used to report
|
|
220
|
+
# "(timeout=90s)" for EVERY failure, including loads
|
|
221
|
+
# that failed instantly. A user whose retries were 3s
|
|
222
|
+
# apart was told each one timed out after 90s, and the
|
|
223
|
+
# worker's actual error was never printed at all.
|
|
224
|
+
# Distinguish the two cases and surface the real cause.
|
|
225
|
+
if resp is None:
|
|
226
|
+
logger.warning(
|
|
227
|
+
"Reranker warmup attempt %d/%d: no response "
|
|
228
|
+
"from worker within %ds; retrying",
|
|
229
|
+
attempt, _WARMUP_MAX_ATTEMPTS,
|
|
230
|
+
_WARMUP_LOAD_TIMEOUT,
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
load_error = (
|
|
234
|
+
resp.get("error")
|
|
235
|
+
or "worker reported not-ready without an error"
|
|
236
|
+
)
|
|
237
|
+
# A misconfiguration cannot fix itself. Retrying a
|
|
238
|
+
# bad backend name or a missing dependency four
|
|
239
|
+
# more times burns ~7.5 minutes of daemon startup
|
|
240
|
+
# to reach the same answer (issue #103). Fail fast
|
|
241
|
+
# and say exactly what to change.
|
|
242
|
+
if _is_permanent_load_error(load_error):
|
|
243
|
+
logger.error(
|
|
244
|
+
"Reranker disabled — configuration error: "
|
|
245
|
+
"%s. Not retrying. Fix the config or set "
|
|
246
|
+
"retrieval.use_cross_encoder=false; recall "
|
|
247
|
+
"continues with fusion scores.",
|
|
248
|
+
load_error,
|
|
249
|
+
)
|
|
250
|
+
return
|
|
251
|
+
logger.warning(
|
|
252
|
+
"Reranker warmup attempt %d/%d failed: %s; "
|
|
253
|
+
"retrying",
|
|
254
|
+
attempt, _WARMUP_MAX_ATTEMPTS, load_error,
|
|
255
|
+
)
|
|
209
256
|
|
|
210
257
|
if attempt < _WARMUP_MAX_ATTEMPTS and not self._model_loaded:
|
|
211
258
|
if self._shutdown_event.wait(
|
|
@@ -534,6 +534,10 @@ def _recall_keyword_fallback(engine, query: str, limit: int) -> dict:
|
|
|
534
534
|
"results": results,
|
|
535
535
|
"count": len(results),
|
|
536
536
|
"no_confident_match": True,
|
|
537
|
+
# PR #101: every other recall path returns this key, so clients format
|
|
538
|
+
# it unconditionally. Omitting it here made the degraded path — the one
|
|
539
|
+
# that fires when recall is ALREADY struggling — crash the CLI.
|
|
540
|
+
"retrieval_time_ms": 0,
|
|
537
541
|
}
|
|
538
542
|
|
|
539
543
|
# v3.4.52: Embedding model warm state. Set to True by the async pre-warm
|
|
@@ -128,6 +128,9 @@ from superlocalmemory.storage.migrations import (
|
|
|
128
128
|
from superlocalmemory.storage.migrations import (
|
|
129
129
|
M032_write_coordinator_admission as _M032,
|
|
130
130
|
)
|
|
131
|
+
from superlocalmemory.storage.migrations import (
|
|
132
|
+
M033_learning_feedback_channel as _M033,
|
|
133
|
+
)
|
|
131
134
|
|
|
132
135
|
# Map migration name → module (used for the optional ``verify(conn)`` hook
|
|
133
136
|
# that lets the runner detect "already applied" state when an idempotent
|
|
@@ -164,6 +167,7 @@ _MODULES = {
|
|
|
164
167
|
_M030.NAME: _M030,
|
|
165
168
|
_M031.NAME: _M031,
|
|
166
169
|
_M032.NAME: _M032,
|
|
170
|
+
_M033.NAME: _M033,
|
|
167
171
|
}
|
|
168
172
|
|
|
169
173
|
logger = logging.getLogger(__name__)
|
|
@@ -220,6 +224,11 @@ MIGRATIONS: list[Migration] = [
|
|
|
220
224
|
# observations for ShadowTest persistence across daemon restart.
|
|
221
225
|
Migration(name=_M012.NAME, db_target="learning", ddl=_M012.DDL,
|
|
222
226
|
dependencies=(_M003.NAME,)),
|
|
227
|
+
# M033 adds learning_feedback.channel, which pattern_miner has always
|
|
228
|
+
# queried but which no schema ever defined. Its DDL creates the table
|
|
229
|
+
# when absent, so it needs no dependency beyond the migration log.
|
|
230
|
+
Migration(name=_M033.NAME, db_target="learning", ddl=_M033.DDL,
|
|
231
|
+
dependencies=(_M003.NAME,)),
|
|
223
232
|
Migration(name=_M004.NAME, db_target="memory", ddl=_M004.DDL),
|
|
224
233
|
# M007 creates pending_outcomes (memory.db, LLD-00 §1.2).
|
|
225
234
|
Migration(name=_M007.NAME, db_target="memory", ddl=_M007.DDL),
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory v3.8.11
|
|
4
|
+
|
|
5
|
+
"""M033 — add the ``channel`` column to ``learning_feedback``.
|
|
6
|
+
|
|
7
|
+
``pattern_miner._mine_channel_and_coretrieval`` has always executed::
|
|
8
|
+
|
|
9
|
+
SELECT channel, COUNT(*) AS cnt, AVG(signal_value) AS avg_signal
|
|
10
|
+
FROM learning_feedback GROUP BY channel
|
|
11
|
+
|
|
12
|
+
but ``channel`` was never defined on the table. Every database therefore
|
|
13
|
+
raised ``sqlite3.OperationalError: no such column: channel``. That error was
|
|
14
|
+
caught by the miner's outer ``except Exception`` and logged at DEBUG, so the
|
|
15
|
+
failure was invisible — and because the channel query runs FIRST inside that
|
|
16
|
+
try block, it also aborted the co-retrieval mining below it. One missing
|
|
17
|
+
column silently disabled two pattern types (issue #102, "Patterns learned
|
|
18
|
+
remains 0" despite a restored backup: restoring rows cannot fix a schema gap).
|
|
19
|
+
|
|
20
|
+
Additive only — ``ALTER TABLE ADD COLUMN`` with a default. No data loss and no
|
|
21
|
+
type changes; existing rows get ``'unknown'``, which groups cleanly rather than
|
|
22
|
+
being dropped by the miner's ``GROUP BY``.
|
|
23
|
+
|
|
24
|
+
``learning_feedback`` is bootstrapped at runtime by
|
|
25
|
+
``learning.feedback.FeedbackCollector._ensure_schema`` rather than by a
|
|
26
|
+
migration, so the DDL below CREATEs it (without ``channel``) when absent
|
|
27
|
+
before the ALTER. That keeps all three states correct:
|
|
28
|
+
|
|
29
|
+
- table missing -> CREATE, then ALTER adds ``channel``
|
|
30
|
+
- table present, no column -> CREATE is a no-op, ALTER adds ``channel``
|
|
31
|
+
- table already migrated -> ``verify()`` returns True, runner skips entirely
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import sqlite3
|
|
37
|
+
|
|
38
|
+
NAME = "M033_learning_feedback_channel"
|
|
39
|
+
DB_TARGET = "learning"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def verify(conn: sqlite3.Connection) -> bool:
|
|
43
|
+
"""Return True if ``learning_feedback.channel`` already exists."""
|
|
44
|
+
try:
|
|
45
|
+
cols = {
|
|
46
|
+
row[1] for row in
|
|
47
|
+
conn.execute("PRAGMA table_info(learning_feedback)").fetchall()
|
|
48
|
+
}
|
|
49
|
+
except sqlite3.Error:
|
|
50
|
+
return False
|
|
51
|
+
# An empty set means the table does not exist yet — not migrated.
|
|
52
|
+
return bool(cols) and "channel" in cols
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
DDL = """
|
|
56
|
+
BEGIN IMMEDIATE;
|
|
57
|
+
|
|
58
|
+
CREATE TABLE IF NOT EXISTS learning_feedback (
|
|
59
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
|
+
profile_id TEXT NOT NULL,
|
|
61
|
+
fact_id TEXT NOT NULL,
|
|
62
|
+
signal_type TEXT NOT NULL,
|
|
63
|
+
signal_value REAL NOT NULL,
|
|
64
|
+
query_hash TEXT,
|
|
65
|
+
created_at TEXT NOT NULL,
|
|
66
|
+
metadata TEXT
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
ALTER TABLE learning_feedback ADD COLUMN channel TEXT DEFAULT 'unknown';
|
|
70
|
+
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_profile
|
|
72
|
+
ON learning_feedback (profile_id, created_at DESC);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_feedback_channel
|
|
74
|
+
ON learning_feedback (profile_id, channel);
|
|
75
|
+
|
|
76
|
+
COMMIT;
|
|
77
|
+
"""
|
|
@@ -28,6 +28,7 @@ from . import (
|
|
|
28
28
|
M020_model_state_integrity,
|
|
29
29
|
M029_behavioral_history_indexes,
|
|
30
30
|
M030_entity_explorer_indexes,
|
|
31
|
+
M033_learning_feedback_channel,
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
# ---------------------------------------------------------------------------
|
|
@@ -81,6 +82,7 @@ __all__ = (
|
|
|
81
82
|
"M020_model_state_integrity",
|
|
82
83
|
"M029_behavioral_history_indexes",
|
|
83
84
|
"M030_entity_explorer_indexes",
|
|
85
|
+
"M033_learning_feedback_channel",
|
|
84
86
|
# Legacy re-exports (backward compat):
|
|
85
87
|
"CURRENT_SCHEMA_VERSION",
|
|
86
88
|
"get_schema_version",
|