superlocalmemory 2.7.2 → 2.7.4
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 +30 -1
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +8 -8
- package/docs/COMPRESSION-README.md +1 -1
- package/docs/SEARCH-ENGINE-V2.2.0.md +1 -0
- package/hooks/post-recall-hook.js +53 -0
- package/mcp_server.py +425 -17
- package/package.json +1 -1
- package/skills/slm-recall/SKILL.md +1 -0
- package/src/agent_registry.py +3 -3
- package/src/auto_backup.py +64 -31
- package/src/graph_engine.py +15 -11
- package/src/learning/adaptive_ranker.py +70 -1
- package/src/learning/feature_extractor.py +131 -16
- package/src/learning/feedback_collector.py +114 -0
- package/src/learning/learning_db.py +158 -34
- package/src/learning/tests/test_adaptive_ranker.py +5 -4
- package/src/learning/tests/test_aggregator.py +4 -3
- package/src/learning/tests/test_feedback_collector.py +7 -4
- package/src/learning/tests/test_signal_inference.py +399 -0
- package/src/learning/tests/test_synthetic_bootstrap.py +1 -1
- package/src/trust_scorer.py +288 -74
- package/ui/app.js +4 -4
- package/ui/index.html +38 -0
- package/ui/js/agents.js +4 -4
- package/ui/js/feedback.js +333 -0
- package/ui/js/learning.js +117 -0
- package/ui/js/modal.js +22 -1
- package/ui/js/profiles.js +8 -0
- package/ui/js/settings.js +58 -1
|
@@ -87,6 +87,19 @@ class LearningDB:
|
|
|
87
87
|
self._write_lock = threading.Lock()
|
|
88
88
|
self._ensure_directory()
|
|
89
89
|
self._init_schema()
|
|
90
|
+
|
|
91
|
+
def _get_active_profile(self) -> str:
|
|
92
|
+
"""Get the active profile name from profiles.json. Returns 'default' if unavailable."""
|
|
93
|
+
try:
|
|
94
|
+
import json
|
|
95
|
+
profiles_path = self.db_path.parent / "profiles.json"
|
|
96
|
+
if profiles_path.exists():
|
|
97
|
+
with open(profiles_path, 'r') as f:
|
|
98
|
+
config = json.load(f)
|
|
99
|
+
return config.get('active_profile', 'default')
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
return "default"
|
|
90
103
|
logger.info("LearningDB initialized: %s", self.db_path)
|
|
91
104
|
|
|
92
105
|
def _ensure_directory(self):
|
|
@@ -214,6 +227,25 @@ class LearningDB:
|
|
|
214
227
|
# ------------------------------------------------------------------
|
|
215
228
|
# Indexes for performance
|
|
216
229
|
# ------------------------------------------------------------------
|
|
230
|
+
# v2.7.4: Add profile columns for per-profile learning
|
|
231
|
+
for table in ['ranking_feedback', 'transferable_patterns', 'workflow_patterns', 'source_quality']:
|
|
232
|
+
try:
|
|
233
|
+
cursor.execute(f'ALTER TABLE {table} ADD COLUMN profile TEXT DEFAULT "default"')
|
|
234
|
+
except Exception:
|
|
235
|
+
pass # Column already exists
|
|
236
|
+
|
|
237
|
+
cursor.execute(
|
|
238
|
+
'CREATE INDEX IF NOT EXISTS idx_feedback_profile '
|
|
239
|
+
'ON ranking_feedback(profile)'
|
|
240
|
+
)
|
|
241
|
+
cursor.execute(
|
|
242
|
+
'CREATE INDEX IF NOT EXISTS idx_patterns_profile '
|
|
243
|
+
'ON transferable_patterns(profile)'
|
|
244
|
+
)
|
|
245
|
+
cursor.execute(
|
|
246
|
+
'CREATE INDEX IF NOT EXISTS idx_workflow_profile '
|
|
247
|
+
'ON workflow_patterns(profile)'
|
|
248
|
+
)
|
|
217
249
|
cursor.execute(
|
|
218
250
|
'CREATE INDEX IF NOT EXISTS idx_feedback_query '
|
|
219
251
|
'ON ranking_feedback(query_hash)'
|
|
@@ -268,6 +300,7 @@ class LearningDB:
|
|
|
268
300
|
rank_position: Optional[int] = None,
|
|
269
301
|
source_tool: Optional[str] = None,
|
|
270
302
|
dwell_time: Optional[float] = None,
|
|
303
|
+
profile: Optional[str] = None,
|
|
271
304
|
) -> int:
|
|
272
305
|
"""
|
|
273
306
|
Store a ranking feedback signal.
|
|
@@ -282,10 +315,15 @@ class LearningDB:
|
|
|
282
315
|
rank_position: Where it appeared in results (1-50)
|
|
283
316
|
source_tool: Tool that originated the query (e.g., 'claude-desktop')
|
|
284
317
|
dwell_time: Seconds spent viewing (dashboard only)
|
|
318
|
+
profile: Active profile name (v2.7.4 — per-profile learning)
|
|
285
319
|
|
|
286
320
|
Returns:
|
|
287
321
|
Row ID of the inserted feedback record.
|
|
288
322
|
"""
|
|
323
|
+
# v2.7.4: Detect active profile if not provided
|
|
324
|
+
if not profile:
|
|
325
|
+
profile = self._get_active_profile()
|
|
326
|
+
|
|
289
327
|
with self._write_lock:
|
|
290
328
|
conn = self._get_connection()
|
|
291
329
|
try:
|
|
@@ -294,12 +332,12 @@ class LearningDB:
|
|
|
294
332
|
INSERT INTO ranking_feedback
|
|
295
333
|
(query_hash, memory_id, signal_type, signal_value,
|
|
296
334
|
channel, query_keywords, rank_position, source_tool,
|
|
297
|
-
dwell_time)
|
|
298
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
335
|
+
dwell_time, profile)
|
|
336
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
299
337
|
''', (
|
|
300
338
|
query_hash, memory_id, signal_type, signal_value,
|
|
301
339
|
channel, query_keywords, rank_position, source_tool,
|
|
302
|
-
dwell_time,
|
|
340
|
+
dwell_time, profile,
|
|
303
341
|
))
|
|
304
342
|
conn.commit()
|
|
305
343
|
row_id = cursor.lastrowid
|
|
@@ -315,24 +353,85 @@ class LearningDB:
|
|
|
315
353
|
finally:
|
|
316
354
|
conn.close()
|
|
317
355
|
|
|
318
|
-
def get_feedback_count(self) -> int:
|
|
319
|
-
"""Get total number of feedback signals.
|
|
356
|
+
def get_feedback_count(self, profile_scoped: bool = False) -> int:
|
|
357
|
+
"""Get total number of feedback signals.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
profile_scoped: If True, count only signals for the active profile.
|
|
361
|
+
"""
|
|
320
362
|
conn = self._get_connection()
|
|
321
363
|
try:
|
|
322
364
|
cursor = conn.cursor()
|
|
323
|
-
|
|
365
|
+
if profile_scoped:
|
|
366
|
+
profile = self._get_active_profile()
|
|
367
|
+
cursor.execute(
|
|
368
|
+
'SELECT COUNT(*) FROM ranking_feedback WHERE profile = ?',
|
|
369
|
+
(profile,)
|
|
370
|
+
)
|
|
371
|
+
else:
|
|
372
|
+
cursor.execute('SELECT COUNT(*) FROM ranking_feedback')
|
|
324
373
|
return cursor.fetchone()[0]
|
|
325
374
|
finally:
|
|
326
375
|
conn.close()
|
|
327
376
|
|
|
328
|
-
def
|
|
377
|
+
def get_signal_stats_for_memories(self, memory_ids: Optional[List[int]] = None) -> Dict[str, Dict[str, float]]:
|
|
378
|
+
"""
|
|
379
|
+
Get aggregate feedback signal stats per memory (v2.7.4).
|
|
380
|
+
|
|
381
|
+
Returns a dict mapping str(memory_id) to {count, avg_value}.
|
|
382
|
+
Used by FeatureExtractor for features [10] and [11].
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
memory_ids: If provided, only fetch stats for these IDs.
|
|
386
|
+
If None, fetch stats for all memories with signals.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
{'42': {'count': 5, 'avg_value': 0.72}, ...}
|
|
390
|
+
"""
|
|
391
|
+
conn = self._get_connection()
|
|
392
|
+
try:
|
|
393
|
+
cursor = conn.cursor()
|
|
394
|
+
if memory_ids:
|
|
395
|
+
placeholders = ','.join('?' for _ in memory_ids)
|
|
396
|
+
cursor.execute(
|
|
397
|
+
f'SELECT memory_id, COUNT(*) as cnt, AVG(signal_value) as avg_val '
|
|
398
|
+
f'FROM ranking_feedback WHERE memory_id IN ({placeholders}) '
|
|
399
|
+
f'GROUP BY memory_id',
|
|
400
|
+
tuple(memory_ids),
|
|
401
|
+
)
|
|
402
|
+
else:
|
|
403
|
+
cursor.execute(
|
|
404
|
+
'SELECT memory_id, COUNT(*) as cnt, AVG(signal_value) as avg_val '
|
|
405
|
+
'FROM ranking_feedback GROUP BY memory_id'
|
|
406
|
+
)
|
|
407
|
+
result = {}
|
|
408
|
+
for row in cursor.fetchall():
|
|
409
|
+
result[str(row['memory_id'])] = {
|
|
410
|
+
'count': row['cnt'],
|
|
411
|
+
'avg_value': round(float(row['avg_val']), 3),
|
|
412
|
+
}
|
|
413
|
+
return result
|
|
414
|
+
except Exception as e:
|
|
415
|
+
logger.error("Failed to get signal stats: %s", e)
|
|
416
|
+
return {}
|
|
417
|
+
finally:
|
|
418
|
+
conn.close()
|
|
419
|
+
|
|
420
|
+
def get_unique_query_count(self, profile_scoped: bool = False) -> int:
|
|
329
421
|
"""Get number of unique queries with feedback."""
|
|
330
422
|
conn = self._get_connection()
|
|
331
423
|
try:
|
|
332
424
|
cursor = conn.cursor()
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
425
|
+
if profile_scoped:
|
|
426
|
+
profile = self._get_active_profile()
|
|
427
|
+
cursor.execute(
|
|
428
|
+
'SELECT COUNT(DISTINCT query_hash) FROM ranking_feedback WHERE profile = ?',
|
|
429
|
+
(profile,)
|
|
430
|
+
)
|
|
431
|
+
else:
|
|
432
|
+
cursor.execute(
|
|
433
|
+
'SELECT COUNT(DISTINCT query_hash) FROM ranking_feedback'
|
|
434
|
+
)
|
|
336
435
|
return cursor.fetchone()[0]
|
|
337
436
|
finally:
|
|
338
437
|
conn.close()
|
|
@@ -434,23 +533,32 @@ class LearningDB:
|
|
|
434
533
|
self,
|
|
435
534
|
min_confidence: float = 0.0,
|
|
436
535
|
pattern_type: Optional[str] = None,
|
|
536
|
+
profile_scoped: bool = False,
|
|
437
537
|
) -> List[Dict[str, Any]]:
|
|
438
|
-
"""Get transferable patterns filtered by confidence and
|
|
538
|
+
"""Get transferable patterns filtered by confidence, type, and profile."""
|
|
439
539
|
conn = self._get_connection()
|
|
440
540
|
try:
|
|
441
541
|
cursor = conn.cursor()
|
|
542
|
+
profile_filter = ""
|
|
543
|
+
params = [min_confidence]
|
|
544
|
+
if profile_scoped:
|
|
545
|
+
profile = self._get_active_profile()
|
|
546
|
+
profile_filter = " AND profile = ?"
|
|
547
|
+
params.append(profile)
|
|
442
548
|
if pattern_type:
|
|
443
|
-
cursor.execute(
|
|
444
|
-
SELECT * FROM transferable_patterns
|
|
445
|
-
WHERE confidence >= ? AND pattern_type = ?
|
|
446
|
-
ORDER BY confidence DESC
|
|
447
|
-
|
|
549
|
+
cursor.execute(
|
|
550
|
+
'SELECT * FROM transferable_patterns '
|
|
551
|
+
'WHERE confidence >= ? AND pattern_type = ?' + profile_filter +
|
|
552
|
+
' ORDER BY confidence DESC',
|
|
553
|
+
tuple(params[:1]) + (pattern_type,) + tuple(params[1:])
|
|
554
|
+
)
|
|
448
555
|
else:
|
|
449
|
-
cursor.execute(
|
|
450
|
-
SELECT * FROM transferable_patterns
|
|
451
|
-
WHERE confidence >= ?
|
|
452
|
-
ORDER BY confidence DESC
|
|
453
|
-
|
|
556
|
+
cursor.execute(
|
|
557
|
+
'SELECT * FROM transferable_patterns '
|
|
558
|
+
'WHERE confidence >= ?' + profile_filter +
|
|
559
|
+
' ORDER BY confidence DESC',
|
|
560
|
+
tuple(params)
|
|
561
|
+
)
|
|
454
562
|
return [dict(row) for row in cursor.fetchall()]
|
|
455
563
|
finally:
|
|
456
564
|
conn.close()
|
|
@@ -497,23 +605,32 @@ class LearningDB:
|
|
|
497
605
|
self,
|
|
498
606
|
pattern_type: Optional[str] = None,
|
|
499
607
|
min_confidence: float = 0.0,
|
|
608
|
+
profile_scoped: bool = False,
|
|
500
609
|
) -> List[Dict[str, Any]]:
|
|
501
|
-
"""Get workflow patterns filtered by type and
|
|
610
|
+
"""Get workflow patterns filtered by type, confidence, and profile."""
|
|
502
611
|
conn = self._get_connection()
|
|
503
612
|
try:
|
|
504
613
|
cursor = conn.cursor()
|
|
614
|
+
profile_filter = ""
|
|
615
|
+
extra_params = []
|
|
616
|
+
if profile_scoped:
|
|
617
|
+
profile = self._get_active_profile()
|
|
618
|
+
profile_filter = " AND profile = ?"
|
|
619
|
+
extra_params.append(profile)
|
|
505
620
|
if pattern_type:
|
|
506
|
-
cursor.execute(
|
|
507
|
-
SELECT * FROM workflow_patterns
|
|
508
|
-
WHERE pattern_type = ? AND confidence >= ?
|
|
509
|
-
ORDER BY confidence DESC
|
|
510
|
-
|
|
621
|
+
cursor.execute(
|
|
622
|
+
'SELECT * FROM workflow_patterns '
|
|
623
|
+
'WHERE pattern_type = ? AND confidence >= ?' + profile_filter +
|
|
624
|
+
' ORDER BY confidence DESC',
|
|
625
|
+
(pattern_type, min_confidence) + tuple(extra_params)
|
|
626
|
+
)
|
|
511
627
|
else:
|
|
512
|
-
cursor.execute(
|
|
513
|
-
SELECT * FROM workflow_patterns
|
|
514
|
-
WHERE confidence >= ?
|
|
515
|
-
ORDER BY confidence DESC
|
|
516
|
-
|
|
628
|
+
cursor.execute(
|
|
629
|
+
'SELECT * FROM workflow_patterns '
|
|
630
|
+
'WHERE confidence >= ?' + profile_filter +
|
|
631
|
+
' ORDER BY confidence DESC',
|
|
632
|
+
(min_confidence,) + tuple(extra_params)
|
|
633
|
+
)
|
|
517
634
|
return [dict(row) for row in cursor.fetchall()]
|
|
518
635
|
finally:
|
|
519
636
|
conn.close()
|
|
@@ -579,12 +696,19 @@ class LearningDB:
|
|
|
579
696
|
finally:
|
|
580
697
|
conn.close()
|
|
581
698
|
|
|
582
|
-
def get_source_scores(self) -> Dict[str, float]:
|
|
699
|
+
def get_source_scores(self, profile_scoped: bool = False) -> Dict[str, float]:
|
|
583
700
|
"""Get quality scores for all known sources."""
|
|
584
701
|
conn = self._get_connection()
|
|
585
702
|
try:
|
|
586
703
|
cursor = conn.cursor()
|
|
587
|
-
|
|
704
|
+
if profile_scoped:
|
|
705
|
+
profile = self._get_active_profile()
|
|
706
|
+
cursor.execute(
|
|
707
|
+
'SELECT source_id, quality_score FROM source_quality WHERE profile = ?',
|
|
708
|
+
(profile,)
|
|
709
|
+
)
|
|
710
|
+
else:
|
|
711
|
+
cursor.execute('SELECT source_id, quality_score FROM source_quality')
|
|
588
712
|
return {row['source_id']: row['quality_score'] for row in cursor.fetchall()}
|
|
589
713
|
finally:
|
|
590
714
|
conn.close()
|
|
@@ -120,12 +120,13 @@ class TestGetPhase:
|
|
|
120
120
|
if HAS_LIGHTGBM and HAS_NUMPY:
|
|
121
121
|
assert phase == "rule_based" # 10 < 50 unique queries
|
|
122
122
|
|
|
123
|
-
def
|
|
123
|
+
def test_no_learning_db_auto_creates(self):
|
|
124
|
+
"""v2.7.2+: AdaptiveRanker auto-creates LearningDB. Phase depends on existing data."""
|
|
124
125
|
from src.learning.adaptive_ranker import AdaptiveRanker
|
|
125
126
|
ranker = AdaptiveRanker(learning_db=None)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
assert
|
|
127
|
+
phase = ranker.get_phase()
|
|
128
|
+
# Phase is valid (auto-created DB may have data from other tests)
|
|
129
|
+
assert phase in ("baseline", "rule_based", "ml_model")
|
|
129
130
|
|
|
130
131
|
|
|
131
132
|
# ---------------------------------------------------------------------------
|
|
@@ -245,15 +245,16 @@ class TestGetTechPreferences:
|
|
|
245
245
|
assert "high" in prefs
|
|
246
246
|
assert "low" not in prefs
|
|
247
247
|
|
|
248
|
-
def
|
|
248
|
+
def test_no_learning_db_auto_creates(self, memory_db):
|
|
249
|
+
"""v2.7.2+: Aggregator auto-creates LearningDB. Should not crash."""
|
|
249
250
|
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
250
251
|
aggregator = CrossProjectAggregator(
|
|
251
252
|
memory_db_path=memory_db,
|
|
252
253
|
learning_db=None,
|
|
253
254
|
)
|
|
254
|
-
# Should not crash
|
|
255
|
+
# Should not crash — may return data from auto-created DB
|
|
255
256
|
prefs = aggregator.get_tech_preferences()
|
|
256
|
-
assert prefs
|
|
257
|
+
assert isinstance(prefs, dict)
|
|
257
258
|
|
|
258
259
|
|
|
259
260
|
# ---------------------------------------------------------------------------
|
|
@@ -87,9 +87,11 @@ class TestRecordMemoryUsed:
|
|
|
87
87
|
assert rows[0]["source_tool"] == "claude-desktop"
|
|
88
88
|
assert rows[0]["rank_position"] == 2
|
|
89
89
|
|
|
90
|
-
def
|
|
90
|
+
def test_no_db_auto_creates(self, collector_no_db):
|
|
91
|
+
"""v2.7.2+: FeedbackCollector auto-creates LearningDB when None passed."""
|
|
91
92
|
result = collector_no_db.record_memory_used(42, "test query")
|
|
92
|
-
|
|
93
|
+
# Auto-created DB means this succeeds (returns row ID)
|
|
94
|
+
assert result is not None
|
|
93
95
|
|
|
94
96
|
|
|
95
97
|
# ---------------------------------------------------------------------------
|
|
@@ -283,9 +285,10 @@ class TestFeedbackSummary:
|
|
|
283
285
|
assert summary["total_signals"] == 0
|
|
284
286
|
assert summary["unique_queries"] == 0
|
|
285
287
|
|
|
286
|
-
def
|
|
288
|
+
def test_summary_no_db_auto_creates(self, collector_no_db):
|
|
289
|
+
"""v2.7.2+: Auto-created DB returns valid summary, not error."""
|
|
287
290
|
summary = collector_no_db.get_feedback_summary()
|
|
288
|
-
assert "
|
|
291
|
+
assert "total_signals" in summary
|
|
289
292
|
|
|
290
293
|
def test_summary_buffer_stats(self, collector):
|
|
291
294
|
collector.record_recall_results("q1", [1, 2, 3])
|