superlocalmemory 2.6.5 → 2.7.1
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 +57 -0
- package/README.md +96 -13
- package/bin/slm +179 -3
- package/bin/superlocalmemoryv2:learning +4 -0
- package/bin/superlocalmemoryv2:patterns +4 -0
- package/docs/ARCHITECTURE.md +12 -6
- package/docs/MCP-MANUAL-SETUP.md +14 -4
- package/install.sh +125 -3
- package/mcp_server.py +291 -1
- package/package.json +2 -1
- package/requirements-learning.txt +12 -0
- package/scripts/verify-v27.sh +233 -0
- package/skills/slm-show-patterns/SKILL.md +224 -0
- package/src/learning/synthetic_bootstrap.py +1047 -0
- package/src/learning/tests/__init__.py +0 -0
- package/src/learning/tests/test_adaptive_ranker.py +328 -0
- package/src/learning/tests/test_aggregator.py +309 -0
- package/src/learning/tests/test_feedback_collector.py +295 -0
- package/src/learning/tests/test_learning_db.py +606 -0
- package/src/learning/tests/test_project_context.py +296 -0
- package/src/learning/tests/test_source_quality.py +355 -0
- package/src/learning/tests/test_synthetic_bootstrap.py +433 -0
- package/src/learning/tests/test_workflow_miner.py +322 -0
- package/ui/index.html +144 -0
- package/ui/js/init.js +4 -0
- package/ui/js/learning.js +318 -0
- package/ui_server.py +9 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
SuperLocalMemory V2 - Tests for FeedbackCollector (v2.7)
|
|
4
|
+
Copyright (c) 2026 Varun Pratap Bhardwaj
|
|
5
|
+
Licensed under MIT License
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Fixtures
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(autouse=True)
|
|
19
|
+
def reset_singleton():
|
|
20
|
+
from src.learning.learning_db import LearningDB
|
|
21
|
+
LearningDB.reset_instance()
|
|
22
|
+
yield
|
|
23
|
+
LearningDB.reset_instance()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def learning_db(tmp_path):
|
|
28
|
+
from src.learning.learning_db import LearningDB
|
|
29
|
+
db_path = tmp_path / "learning.db"
|
|
30
|
+
return LearningDB(db_path=db_path)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def collector(learning_db):
|
|
35
|
+
from src.learning.feedback_collector import FeedbackCollector
|
|
36
|
+
return FeedbackCollector(learning_db=learning_db)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def collector_no_db():
|
|
41
|
+
"""Collector with no database — tests graceful degradation."""
|
|
42
|
+
from src.learning.feedback_collector import FeedbackCollector
|
|
43
|
+
return FeedbackCollector(learning_db=None)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Channel 1: MCP memory_used
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
class TestRecordMemoryUsed:
|
|
51
|
+
def test_high_usefulness(self, collector, learning_db):
|
|
52
|
+
row_id = collector.record_memory_used(42, "deploy fastapi", usefulness="high")
|
|
53
|
+
assert row_id is not None
|
|
54
|
+
|
|
55
|
+
rows = learning_db.get_feedback_for_training()
|
|
56
|
+
assert len(rows) == 1
|
|
57
|
+
assert rows[0]["signal_type"] == "mcp_used_high"
|
|
58
|
+
assert rows[0]["signal_value"] == 1.0
|
|
59
|
+
assert rows[0]["channel"] == "mcp"
|
|
60
|
+
|
|
61
|
+
def test_medium_usefulness(self, collector, learning_db):
|
|
62
|
+
collector.record_memory_used(42, "deploy fastapi", usefulness="medium")
|
|
63
|
+
rows = learning_db.get_feedback_for_training()
|
|
64
|
+
assert rows[0]["signal_type"] == "mcp_used_medium"
|
|
65
|
+
assert rows[0]["signal_value"] == 0.7
|
|
66
|
+
|
|
67
|
+
def test_low_usefulness(self, collector, learning_db):
|
|
68
|
+
collector.record_memory_used(42, "deploy fastapi", usefulness="low")
|
|
69
|
+
rows = learning_db.get_feedback_for_training()
|
|
70
|
+
assert rows[0]["signal_type"] == "mcp_used_low"
|
|
71
|
+
assert rows[0]["signal_value"] == 0.4
|
|
72
|
+
|
|
73
|
+
def test_invalid_usefulness_defaults_to_high(self, collector, learning_db):
|
|
74
|
+
collector.record_memory_used(42, "test query", usefulness="INVALID")
|
|
75
|
+
rows = learning_db.get_feedback_for_training()
|
|
76
|
+
assert rows[0]["signal_type"] == "mcp_used_high"
|
|
77
|
+
|
|
78
|
+
def test_empty_query_returns_none(self, collector):
|
|
79
|
+
result = collector.record_memory_used(42, "")
|
|
80
|
+
assert result is None
|
|
81
|
+
|
|
82
|
+
def test_source_tool_recorded(self, collector, learning_db):
|
|
83
|
+
collector.record_memory_used(
|
|
84
|
+
42, "test query", source_tool="claude-desktop", rank_position=2,
|
|
85
|
+
)
|
|
86
|
+
rows = learning_db.get_feedback_for_training()
|
|
87
|
+
assert rows[0]["source_tool"] == "claude-desktop"
|
|
88
|
+
assert rows[0]["rank_position"] == 2
|
|
89
|
+
|
|
90
|
+
def test_no_db_returns_none(self, collector_no_db):
|
|
91
|
+
result = collector_no_db.record_memory_used(42, "test query")
|
|
92
|
+
assert result is None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Channel 2: CLI slm useful
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
class TestRecordCliUseful:
|
|
100
|
+
def test_batch_ids(self, collector, learning_db):
|
|
101
|
+
row_ids = collector.record_cli_useful([10, 20, 30], "deploy fastapi")
|
|
102
|
+
assert len(row_ids) == 3
|
|
103
|
+
assert all(rid is not None for rid in row_ids)
|
|
104
|
+
assert learning_db.get_feedback_count() == 3
|
|
105
|
+
|
|
106
|
+
def test_signal_value(self, collector, learning_db):
|
|
107
|
+
collector.record_cli_useful([42], "query")
|
|
108
|
+
rows = learning_db.get_feedback_for_training()
|
|
109
|
+
assert rows[0]["signal_value"] == 0.9
|
|
110
|
+
assert rows[0]["signal_type"] == "cli_useful"
|
|
111
|
+
assert rows[0]["channel"] == "cli"
|
|
112
|
+
|
|
113
|
+
def test_all_share_same_query_hash(self, collector, learning_db):
|
|
114
|
+
collector.record_cli_useful([1, 2, 3], "same query")
|
|
115
|
+
rows = learning_db.get_feedback_for_training()
|
|
116
|
+
hashes = {r["query_hash"] for r in rows}
|
|
117
|
+
assert len(hashes) == 1
|
|
118
|
+
|
|
119
|
+
def test_empty_query(self, collector):
|
|
120
|
+
result = collector.record_cli_useful([1, 2], "")
|
|
121
|
+
assert result == [None, None]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Channel 3: Dashboard click
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
class TestRecordDashboardClick:
|
|
129
|
+
def test_basic_click(self, collector, learning_db):
|
|
130
|
+
row_id = collector.record_dashboard_click(42, "test query")
|
|
131
|
+
assert row_id is not None
|
|
132
|
+
rows = learning_db.get_feedback_for_training()
|
|
133
|
+
assert rows[0]["signal_type"] == "dashboard_click"
|
|
134
|
+
assert rows[0]["signal_value"] == 0.8
|
|
135
|
+
assert rows[0]["channel"] == "dashboard"
|
|
136
|
+
|
|
137
|
+
def test_with_dwell_time(self, collector, learning_db):
|
|
138
|
+
collector.record_dashboard_click(42, "test query", dwell_time=15.3)
|
|
139
|
+
# dwell_time is stored in ranking_feedback but not in training export
|
|
140
|
+
# Verify via direct DB query
|
|
141
|
+
conn = learning_db._get_connection()
|
|
142
|
+
cursor = conn.cursor()
|
|
143
|
+
cursor.execute("SELECT dwell_time FROM ranking_feedback WHERE memory_id = 42")
|
|
144
|
+
row = cursor.fetchone()
|
|
145
|
+
conn.close()
|
|
146
|
+
assert row is not None
|
|
147
|
+
assert abs(row[0] - 15.3) < 0.01
|
|
148
|
+
|
|
149
|
+
def test_empty_query_returns_none(self, collector):
|
|
150
|
+
result = collector.record_dashboard_click(42, "")
|
|
151
|
+
assert result is None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Query Hashing
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
class TestHashQuery:
|
|
159
|
+
def test_deterministic(self, collector):
|
|
160
|
+
h1 = collector._hash_query("deploy fastapi")
|
|
161
|
+
h2 = collector._hash_query("deploy fastapi")
|
|
162
|
+
assert h1 == h2
|
|
163
|
+
|
|
164
|
+
def test_sha256_first_16(self, collector):
|
|
165
|
+
query = "deploy fastapi"
|
|
166
|
+
expected = hashlib.sha256(query.encode("utf-8")).hexdigest()[:16]
|
|
167
|
+
assert collector._hash_query(query) == expected
|
|
168
|
+
|
|
169
|
+
def test_length_is_16(self, collector):
|
|
170
|
+
result = collector._hash_query("any string")
|
|
171
|
+
assert len(result) == 16
|
|
172
|
+
|
|
173
|
+
def test_different_queries_different_hashes(self, collector):
|
|
174
|
+
h1 = collector._hash_query("query one")
|
|
175
|
+
h2 = collector._hash_query("query two")
|
|
176
|
+
assert h1 != h2
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Keyword Extraction
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
class TestExtractKeywords:
|
|
184
|
+
def test_stopword_removal(self, collector):
|
|
185
|
+
kw = collector._extract_keywords("how to deploy the app")
|
|
186
|
+
assert "how" not in kw
|
|
187
|
+
assert "to" not in kw
|
|
188
|
+
assert "the" not in kw
|
|
189
|
+
assert "deploy" in kw
|
|
190
|
+
|
|
191
|
+
def test_top_n_limit(self, collector):
|
|
192
|
+
kw = collector._extract_keywords(
|
|
193
|
+
"python fastapi docker kubernetes deployment pipeline"
|
|
194
|
+
)
|
|
195
|
+
assert len(kw.split(",")) <= 3
|
|
196
|
+
|
|
197
|
+
def test_empty_query(self, collector):
|
|
198
|
+
assert collector._extract_keywords("") == ""
|
|
199
|
+
|
|
200
|
+
def test_only_stopwords(self, collector):
|
|
201
|
+
assert collector._extract_keywords("the and or but") == ""
|
|
202
|
+
|
|
203
|
+
def test_comma_separated_output(self, collector):
|
|
204
|
+
kw = collector._extract_keywords("deploy fastapi docker")
|
|
205
|
+
assert "," in kw or len(kw.split(",")) == 1 # single keyword = no comma
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# Passive Decay
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
class TestPassiveDecay:
|
|
213
|
+
def test_record_recall_results(self, collector):
|
|
214
|
+
collector.record_recall_results("test query", [1, 2, 3])
|
|
215
|
+
assert collector._recall_count == 1
|
|
216
|
+
|
|
217
|
+
def test_compute_passive_decay_below_threshold(self, collector):
|
|
218
|
+
"""Should return 0 if below threshold."""
|
|
219
|
+
collector.record_recall_results("test query", [1, 2, 3])
|
|
220
|
+
result = collector.compute_passive_decay(threshold=10)
|
|
221
|
+
assert result == 0
|
|
222
|
+
|
|
223
|
+
def test_compute_passive_decay_with_candidates(self, collector, learning_db):
|
|
224
|
+
"""Memory appearing in 5+ distinct queries should get decay signal."""
|
|
225
|
+
# Create 10+ recall operations with memory_id=99 appearing in 6 distinct queries
|
|
226
|
+
for i in range(10):
|
|
227
|
+
collector.record_recall_results(f"query_{i}", [99, 100 + i])
|
|
228
|
+
|
|
229
|
+
decay_count = collector.compute_passive_decay(threshold=10)
|
|
230
|
+
# memory 99 appeared in 10 distinct queries, no positive feedback
|
|
231
|
+
assert decay_count >= 1
|
|
232
|
+
|
|
233
|
+
def test_no_decay_for_positively_rated(self, collector, learning_db):
|
|
234
|
+
"""Memories with positive feedback should NOT get passive decay."""
|
|
235
|
+
# Give memory 99 positive feedback first
|
|
236
|
+
learning_db.store_feedback(
|
|
237
|
+
query_hash="q", memory_id=99, signal_type="mcp_used",
|
|
238
|
+
signal_value=1.0, channel="mcp",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Record 10+ recall operations with memory 99
|
|
242
|
+
for i in range(12):
|
|
243
|
+
collector.record_recall_results(f"query_{i}", [99])
|
|
244
|
+
|
|
245
|
+
decay_count = collector.compute_passive_decay(threshold=10)
|
|
246
|
+
assert decay_count == 0
|
|
247
|
+
|
|
248
|
+
def test_buffer_cleared_after_decay(self, collector):
|
|
249
|
+
"""Recall buffer should be cleared after computing decay."""
|
|
250
|
+
for i in range(10):
|
|
251
|
+
collector.record_recall_results(f"q{i}", [1])
|
|
252
|
+
collector.compute_passive_decay(threshold=10)
|
|
253
|
+
assert collector._recall_count == 0
|
|
254
|
+
|
|
255
|
+
def test_empty_query_ignored(self, collector):
|
|
256
|
+
collector.record_recall_results("", [1, 2, 3])
|
|
257
|
+
assert collector._recall_count == 0
|
|
258
|
+
|
|
259
|
+
def test_empty_ids_ignored(self, collector):
|
|
260
|
+
collector.record_recall_results("test query", [])
|
|
261
|
+
assert collector._recall_count == 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# Summary
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
class TestFeedbackSummary:
|
|
269
|
+
def test_summary_with_data(self, collector, learning_db):
|
|
270
|
+
collector.record_memory_used(1, "q1", usefulness="high")
|
|
271
|
+
collector.record_cli_useful([2], "q2")
|
|
272
|
+
collector.record_dashboard_click(3, "q3")
|
|
273
|
+
|
|
274
|
+
summary = collector.get_feedback_summary()
|
|
275
|
+
assert summary["total_signals"] == 3
|
|
276
|
+
assert summary["unique_queries"] == 3
|
|
277
|
+
assert "mcp" in summary["by_channel"]
|
|
278
|
+
assert "cli" in summary["by_channel"]
|
|
279
|
+
assert "dashboard" in summary["by_channel"]
|
|
280
|
+
|
|
281
|
+
def test_summary_empty_db(self, collector):
|
|
282
|
+
summary = collector.get_feedback_summary()
|
|
283
|
+
assert summary["total_signals"] == 0
|
|
284
|
+
assert summary["unique_queries"] == 0
|
|
285
|
+
|
|
286
|
+
def test_summary_no_db(self, collector_no_db):
|
|
287
|
+
summary = collector_no_db.get_feedback_summary()
|
|
288
|
+
assert "error" in summary
|
|
289
|
+
|
|
290
|
+
def test_summary_buffer_stats(self, collector):
|
|
291
|
+
collector.record_recall_results("q1", [1, 2, 3])
|
|
292
|
+
collector.record_recall_results("q2", [1, 4, 5])
|
|
293
|
+
|
|
294
|
+
summary = collector.get_feedback_summary()
|
|
295
|
+
assert summary["recall_buffer_size"] == 2
|