superlocalmemory 2.7.3 → 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.
@@ -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
- cursor.execute('SELECT COUNT(*) FROM ranking_feedback')
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 get_unique_query_count(self) -> int:
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
- cursor.execute(
334
- 'SELECT COUNT(DISTINCT query_hash) FROM ranking_feedback'
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 type."""
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
- ''', (min_confidence, pattern_type))
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
- ''', (min_confidence,))
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 confidence."""
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
- ''', (pattern_type, min_confidence))
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
- ''', (min_confidence,))
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
- cursor.execute('SELECT source_id, quality_score FROM source_quality')
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 test_no_learning_db_returns_baseline(self):
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
- # Force no lazy init
127
- ranker._learning_db = None
128
- assert ranker.get_phase() == "baseline"
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 test_no_learning_db(self, memory_db):
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 test_no_db_returns_none(self, collector_no_db):
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
- assert result is None
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 test_summary_no_db(self, collector_no_db):
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 "error" in summary
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])