roampal 0.1.4__py3-none-any.whl

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.
Files changed (44) hide show
  1. roampal/__init__.py +29 -0
  2. roampal/__main__.py +6 -0
  3. roampal/backend/__init__.py +1 -0
  4. roampal/backend/modules/__init__.py +1 -0
  5. roampal/backend/modules/memory/__init__.py +43 -0
  6. roampal/backend/modules/memory/chromadb_adapter.py +623 -0
  7. roampal/backend/modules/memory/config.py +102 -0
  8. roampal/backend/modules/memory/content_graph.py +543 -0
  9. roampal/backend/modules/memory/context_service.py +455 -0
  10. roampal/backend/modules/memory/embedding_service.py +96 -0
  11. roampal/backend/modules/memory/knowledge_graph_service.py +1052 -0
  12. roampal/backend/modules/memory/memory_bank_service.py +433 -0
  13. roampal/backend/modules/memory/memory_types.py +296 -0
  14. roampal/backend/modules/memory/outcome_service.py +400 -0
  15. roampal/backend/modules/memory/promotion_service.py +473 -0
  16. roampal/backend/modules/memory/routing_service.py +444 -0
  17. roampal/backend/modules/memory/scoring_service.py +324 -0
  18. roampal/backend/modules/memory/search_service.py +646 -0
  19. roampal/backend/modules/memory/tests/__init__.py +1 -0
  20. roampal/backend/modules/memory/tests/conftest.py +12 -0
  21. roampal/backend/modules/memory/tests/unit/__init__.py +1 -0
  22. roampal/backend/modules/memory/tests/unit/conftest.py +7 -0
  23. roampal/backend/modules/memory/tests/unit/test_knowledge_graph_service.py +517 -0
  24. roampal/backend/modules/memory/tests/unit/test_memory_bank_service.py +504 -0
  25. roampal/backend/modules/memory/tests/unit/test_outcome_service.py +485 -0
  26. roampal/backend/modules/memory/tests/unit/test_scoring_service.py +255 -0
  27. roampal/backend/modules/memory/tests/unit/test_search_service.py +413 -0
  28. roampal/backend/modules/memory/tests/unit/test_unified_memory_system.py +418 -0
  29. roampal/backend/modules/memory/unified_memory_system.py +1277 -0
  30. roampal/cli.py +638 -0
  31. roampal/hooks/__init__.py +16 -0
  32. roampal/hooks/session_manager.py +587 -0
  33. roampal/hooks/stop_hook.py +176 -0
  34. roampal/hooks/user_prompt_submit_hook.py +103 -0
  35. roampal/mcp/__init__.py +7 -0
  36. roampal/mcp/server.py +611 -0
  37. roampal/server/__init__.py +7 -0
  38. roampal/server/main.py +744 -0
  39. roampal-0.1.4.dist-info/METADATA +179 -0
  40. roampal-0.1.4.dist-info/RECORD +44 -0
  41. roampal-0.1.4.dist-info/WHEEL +5 -0
  42. roampal-0.1.4.dist-info/entry_points.txt +2 -0
  43. roampal-0.1.4.dist-info/licenses/LICENSE +190 -0
  44. roampal-0.1.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,517 @@
1
+ """
2
+ Unit Tests for KnowledgeGraphService
3
+
4
+ Tests the extracted KG logic including the race condition fix.
5
+ """
6
+
7
+ import sys
8
+ import os
9
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..', '..')))
10
+
11
+ import asyncio
12
+ import json
13
+ import pytest
14
+ import tempfile
15
+ from pathlib import Path
16
+ from unittest.mock import MagicMock, AsyncMock, patch
17
+
18
+ from roampal.backend.modules.memory.knowledge_graph_service import KnowledgeGraphService
19
+ from roampal.backend.modules.memory.config import MemoryConfig
20
+
21
+
22
+ class TestConceptExtraction:
23
+ """Test concept extraction from text."""
24
+
25
+ @pytest.fixture
26
+ def temp_paths(self, tmp_path):
27
+ """Create temporary paths for KG files."""
28
+ return {
29
+ "kg_path": tmp_path / "knowledge_graph.json",
30
+ "content_graph_path": tmp_path / "content_graph.json",
31
+ "relationships_path": tmp_path / "relationships.json",
32
+ }
33
+
34
+ @pytest.fixture
35
+ def service(self, temp_paths):
36
+ """Create KnowledgeGraphService instance."""
37
+ return KnowledgeGraphService(
38
+ kg_path=temp_paths["kg_path"],
39
+ content_graph_path=temp_paths["content_graph_path"],
40
+ relationships_path=temp_paths["relationships_path"],
41
+ )
42
+
43
+ def test_extract_unigrams(self, service):
44
+ """Should extract single words longer than 3 chars."""
45
+ concepts = service.extract_concepts("Python programming language")
46
+ assert "python" in concepts
47
+ assert "programming" in concepts
48
+ assert "language" in concepts
49
+
50
+ def test_extract_bigrams(self, service):
51
+ """Should extract two-word phrases."""
52
+ concepts = service.extract_concepts("Python programming")
53
+ bigrams = [c for c in concepts if "_" in c and c.count("_") == 1]
54
+ assert len(bigrams) > 0
55
+ assert "python_programming" in bigrams
56
+
57
+ def test_extract_trigrams(self, service):
58
+ """Should extract three-word phrases."""
59
+ concepts = service.extract_concepts("Python programming language rocks")
60
+ trigrams = [c for c in concepts if c.count("_") == 2]
61
+ assert len(trigrams) > 0
62
+
63
+ def test_filter_stop_words(self, service):
64
+ """Should filter out stop words."""
65
+ concepts = service.extract_concepts("The quick brown fox is a test")
66
+ assert "the" not in concepts
67
+ assert "is" not in concepts
68
+ assert "quick" in concepts
69
+ assert "brown" in concepts
70
+
71
+ def test_filter_short_words(self, service):
72
+ """Should filter out words with 3 or fewer chars."""
73
+ concepts = service.extract_concepts("Go is a fun language")
74
+ unigrams = [c for c in concepts if "_" not in c]
75
+ # "fun" is exactly 3 chars, should be filtered for unigrams (>3 required)
76
+ assert "fun" not in unigrams
77
+ assert "language" in unigrams
78
+
79
+ def test_filter_tool_blocklist(self, service):
80
+ """Should filter out MCP tool names and internal terms."""
81
+ concepts = service.extract_concepts("Use search_memory to find memory_bank items")
82
+ assert "search_memory" not in concepts
83
+ assert "memory_bank" not in concepts
84
+
85
+ def test_empty_text(self, service):
86
+ """Empty text should return empty list."""
87
+ concepts = service.extract_concepts("")
88
+ assert concepts == []
89
+
90
+ def test_case_insensitive(self, service):
91
+ """Extraction should be case-insensitive."""
92
+ concepts = service.extract_concepts("Python PYTHON python")
93
+ python_concepts = [c for c in concepts if "python" in c.lower()]
94
+ # Should have python unigrams, all normalized to lowercase
95
+ assert "python" in concepts
96
+
97
+
98
+ class TestKGLoading:
99
+ """Test KG loading from disk."""
100
+
101
+ @pytest.fixture
102
+ def temp_paths(self, tmp_path):
103
+ return {
104
+ "kg_path": tmp_path / "knowledge_graph.json",
105
+ "content_graph_path": tmp_path / "content_graph.json",
106
+ "relationships_path": tmp_path / "relationships.json",
107
+ }
108
+
109
+ def test_load_empty_kg(self, temp_paths):
110
+ """Should create default KG when file doesn't exist."""
111
+ service = KnowledgeGraphService(**temp_paths)
112
+ assert "routing_patterns" in service.knowledge_graph
113
+ assert "problem_solutions" in service.knowledge_graph
114
+ assert "context_action_effectiveness" in service.knowledge_graph
115
+
116
+ def test_load_existing_kg(self, temp_paths):
117
+ """Should load existing KG from file."""
118
+ # Create a KG file first
119
+ kg_data = {
120
+ "routing_patterns": {"test_concept": {"best_collection": "history"}},
121
+ "success_rates": {},
122
+ "failure_patterns": {},
123
+ "problem_categories": {},
124
+ "problem_solutions": {},
125
+ "solution_patterns": {},
126
+ "context_action_effectiveness": {},
127
+ }
128
+ temp_paths["kg_path"].parent.mkdir(exist_ok=True, parents=True)
129
+ with open(temp_paths["kg_path"], "w") as f:
130
+ json.dump(kg_data, f)
131
+
132
+ service = KnowledgeGraphService(**temp_paths)
133
+ assert "test_concept" in service.knowledge_graph["routing_patterns"]
134
+
135
+ def test_load_partial_kg_fills_missing_keys(self, temp_paths):
136
+ """Should fill missing keys when loading partial KG."""
137
+ # Create a partial KG file
138
+ kg_data = {"routing_patterns": {}}
139
+ temp_paths["kg_path"].parent.mkdir(exist_ok=True, parents=True)
140
+ with open(temp_paths["kg_path"], "w") as f:
141
+ json.dump(kg_data, f)
142
+
143
+ service = KnowledgeGraphService(**temp_paths)
144
+ # Should have all required keys
145
+ assert "problem_solutions" in service.knowledge_graph
146
+ assert "context_action_effectiveness" in service.knowledge_graph
147
+
148
+
149
+ class TestConceptRelationships:
150
+ """Test building concept relationships."""
151
+
152
+ @pytest.fixture
153
+ def temp_paths(self, tmp_path):
154
+ return {
155
+ "kg_path": tmp_path / "knowledge_graph.json",
156
+ "content_graph_path": tmp_path / "content_graph.json",
157
+ "relationships_path": tmp_path / "relationships.json",
158
+ }
159
+
160
+ @pytest.fixture
161
+ def service(self, temp_paths):
162
+ return KnowledgeGraphService(**temp_paths)
163
+
164
+ def test_build_concept_relationships(self, service):
165
+ """Should create relationships between concept pairs."""
166
+ concepts = ["python", "django", "web"]
167
+ service.build_concept_relationships(concepts)
168
+
169
+ relationships = service.knowledge_graph.get("relationships", {})
170
+ # Should have 3 relationships: python-django, python-web, django-web
171
+ assert len(relationships) == 3
172
+
173
+ # Check relationship keys are sorted
174
+ assert "django|python" in relationships # Sorted alphabetically
175
+ assert "python|web" in relationships
176
+ assert "django|web" in relationships
177
+
178
+ def test_relationship_co_occurrence_increments(self, service):
179
+ """Co-occurrence should increment on repeated builds."""
180
+ concepts = ["python", "django"]
181
+ service.build_concept_relationships(concepts)
182
+ service.build_concept_relationships(concepts)
183
+
184
+ rel_key = "django|python"
185
+ assert service.knowledge_graph["relationships"][rel_key]["co_occurrence"] == 2
186
+
187
+
188
+ class TestKGRouting:
189
+ """Test KG routing pattern updates."""
190
+
191
+ @pytest.fixture
192
+ def temp_paths(self, tmp_path):
193
+ return {
194
+ "kg_path": tmp_path / "knowledge_graph.json",
195
+ "content_graph_path": tmp_path / "content_graph.json",
196
+ "relationships_path": tmp_path / "relationships.json",
197
+ }
198
+
199
+ @pytest.fixture
200
+ def service(self, temp_paths):
201
+ return KnowledgeGraphService(**temp_paths)
202
+
203
+ @pytest.mark.asyncio
204
+ async def test_update_kg_routing_creates_pattern(self, service):
205
+ """Should create routing pattern for new concept."""
206
+ await service.update_kg_routing("Python tutorial", "books", "worked")
207
+
208
+ patterns = service.knowledge_graph["routing_patterns"]
209
+ assert "python" in patterns
210
+ assert patterns["python"]["best_collection"] == "books"
211
+
212
+ @pytest.mark.asyncio
213
+ async def test_update_kg_routing_tracks_outcomes(self, service):
214
+ """Should track success/failure outcomes."""
215
+ await service.update_kg_routing("Python help", "history", "worked")
216
+ await service.update_kg_routing("Python help", "history", "failed")
217
+
218
+ stats = service.knowledge_graph["routing_patterns"]["python"]["collections_used"]["history"]
219
+ assert stats["successes"] == 1
220
+ assert stats["failures"] == 1
221
+ assert stats["total"] == 2
222
+
223
+ @pytest.mark.asyncio
224
+ async def test_update_kg_routing_updates_best_collection(self, service):
225
+ """Should update best collection based on success rate."""
226
+ # Books has 2 successes
227
+ await service.update_kg_routing("Python docs", "books", "worked")
228
+ await service.update_kg_routing("Python docs", "books", "worked")
229
+
230
+ # History has 1 success, 1 failure
231
+ await service.update_kg_routing("Python help", "history", "worked")
232
+ await service.update_kg_routing("Python help", "history", "failed")
233
+
234
+ pattern = service.knowledge_graph["routing_patterns"]["python"]
235
+ assert pattern["best_collection"] == "books" # 100% vs 50%
236
+
237
+
238
+ class TestRaceConditionFix:
239
+ """Test the race condition fix in debounced saves."""
240
+
241
+ @pytest.fixture
242
+ def temp_paths(self, tmp_path):
243
+ return {
244
+ "kg_path": tmp_path / "knowledge_graph.json",
245
+ "content_graph_path": tmp_path / "content_graph.json",
246
+ "relationships_path": tmp_path / "relationships.json",
247
+ }
248
+
249
+ @pytest.fixture
250
+ def service(self, temp_paths):
251
+ # Use short debounce for testing
252
+ config = MemoryConfig(kg_debounce_seconds=0.1)
253
+ return KnowledgeGraphService(**temp_paths, config=config)
254
+
255
+ @pytest.mark.asyncio
256
+ async def test_debounced_save_serializes_access(self, service):
257
+ """Multiple concurrent debounced saves should not race."""
258
+ # Launch multiple concurrent saves
259
+ tasks = [
260
+ asyncio.create_task(service._debounced_save_kg())
261
+ for _ in range(10)
262
+ ]
263
+ await asyncio.gather(*tasks)
264
+
265
+ # Wait for debounce to complete
266
+ await asyncio.sleep(0.2)
267
+
268
+ # Should have completed without errors
269
+ assert not service._kg_save_pending or service._kg_save_task.done()
270
+
271
+ @pytest.mark.asyncio
272
+ async def test_debounced_save_batches_updates(self, service):
273
+ """Multiple updates within debounce window should batch."""
274
+ save_count = 0
275
+ original_save = service._save_kg
276
+
277
+ async def counting_save():
278
+ nonlocal save_count
279
+ save_count += 1
280
+ await original_save()
281
+
282
+ service._save_kg = counting_save
283
+
284
+ # Rapid updates
285
+ for i in range(5):
286
+ await service._debounced_save_kg()
287
+
288
+ # Wait for debounce
289
+ await asyncio.sleep(0.2)
290
+
291
+ # Should only save once due to batching
292
+ assert save_count == 1
293
+
294
+ @pytest.mark.asyncio
295
+ async def test_cleanup_cancels_pending_save(self, service):
296
+ """Cleanup should cancel pending save task."""
297
+ await service._debounced_save_kg()
298
+ assert service._kg_save_task is not None
299
+
300
+ await service.cleanup()
301
+
302
+ # Task should be cancelled or done
303
+ assert service._kg_save_task.done() or service._kg_save_task.cancelled()
304
+
305
+
306
+ class TestProblemSolutionTracking:
307
+ """Test problem-solution pattern tracking."""
308
+
309
+ @pytest.fixture
310
+ def temp_paths(self, tmp_path):
311
+ return {
312
+ "kg_path": tmp_path / "knowledge_graph.json",
313
+ "content_graph_path": tmp_path / "content_graph.json",
314
+ "relationships_path": tmp_path / "relationships.json",
315
+ }
316
+
317
+ @pytest.fixture
318
+ def service(self, temp_paths):
319
+ config = MemoryConfig(kg_debounce_seconds=0) # No debounce for tests
320
+ return KnowledgeGraphService(**temp_paths, config=config)
321
+
322
+ @pytest.mark.asyncio
323
+ async def test_track_problem_solution_creates_entry(self, service):
324
+ """Should create problem-solution mapping."""
325
+ metadata = {
326
+ "original_context": "How do I fix Python import errors?",
327
+ "text": "Use sys.path.insert to add the module path",
328
+ }
329
+ await service.track_problem_solution("doc_123", metadata, None)
330
+
331
+ assert len(service.knowledge_graph["problem_solutions"]) > 0
332
+
333
+ @pytest.mark.asyncio
334
+ async def test_track_problem_solution_increments_on_repeat(self, service):
335
+ """Should increment success_count for existing solutions."""
336
+ metadata = {
337
+ "original_context": "Python import errors fix",
338
+ "text": "Use sys.path.insert",
339
+ }
340
+
341
+ await service.track_problem_solution("doc_123", metadata, None)
342
+ await service.track_problem_solution("doc_123", metadata, None)
343
+
344
+ # Find the entry
345
+ for sig, solutions in service.knowledge_graph["problem_solutions"].items():
346
+ for sol in solutions:
347
+ if sol["doc_id"] == "doc_123":
348
+ assert sol["success_count"] == 2
349
+ return
350
+
351
+ pytest.fail("Solution not found")
352
+
353
+ @pytest.mark.asyncio
354
+ async def test_find_known_solutions_exact_match(self, service):
355
+ """Should find exact problem-solution matches."""
356
+ # First track a solution
357
+ metadata = {
358
+ "original_context": "Python import errors debugging",
359
+ "text": "Use sys.path.insert to fix",
360
+ }
361
+ await service.track_problem_solution("history_doc_1", metadata, None)
362
+
363
+ # Mock get_fragment_fn
364
+ def get_fragment(coll_name, doc_id):
365
+ if doc_id == "history_doc_1":
366
+ return {
367
+ "id": doc_id,
368
+ "content": "Use sys.path.insert to fix",
369
+ "distance": 1.0,
370
+ }
371
+ return None
372
+
373
+ # Search for similar problem
374
+ solutions = await service.find_known_solutions(
375
+ "Python import errors debugging",
376
+ get_fragment
377
+ )
378
+
379
+ # Should find the tracked solution
380
+ assert len(solutions) > 0
381
+ assert solutions[0]["id"] == "history_doc_1"
382
+ assert solutions[0]["is_known_solution"] is True
383
+
384
+
385
+ class TestKGCleanup:
386
+ """Test KG cleanup operations."""
387
+
388
+ @pytest.fixture
389
+ def temp_paths(self, tmp_path):
390
+ return {
391
+ "kg_path": tmp_path / "knowledge_graph.json",
392
+ "content_graph_path": tmp_path / "content_graph.json",
393
+ "relationships_path": tmp_path / "relationships.json",
394
+ }
395
+
396
+ @pytest.fixture
397
+ def service(self, temp_paths):
398
+ return KnowledgeGraphService(**temp_paths)
399
+
400
+ @pytest.mark.asyncio
401
+ async def test_cleanup_dead_references(self, service):
402
+ """Should remove references to non-existent documents."""
403
+ # Add some problem_solutions with doc_ids
404
+ service.knowledge_graph["problem_solutions"] = {
405
+ "test_problem": [
406
+ {"doc_id": "history_valid", "success_count": 1},
407
+ {"doc_id": "history_invalid", "success_count": 1},
408
+ ]
409
+ }
410
+
411
+ # Mock doc_exists - only valid doc exists
412
+ def doc_exists(doc_id):
413
+ return doc_id == "history_valid"
414
+
415
+ cleaned = await service.cleanup_dead_references(doc_exists)
416
+
417
+ # Should have cleaned 1 reference
418
+ assert cleaned == 1
419
+ solutions = service.knowledge_graph["problem_solutions"]["test_problem"]
420
+ assert len(solutions) == 1
421
+ assert solutions[0]["doc_id"] == "history_valid"
422
+
423
+ @pytest.mark.asyncio
424
+ async def test_cleanup_action_kg_for_doc_ids(self, service):
425
+ """Should remove Action KG examples for deleted doc_ids."""
426
+ service.knowledge_graph["context_action_effectiveness"] = {
427
+ "context|action|coll": {
428
+ "successes": 2,
429
+ "failures": 0,
430
+ "examples": [
431
+ {"doc_id": "doc_1", "text": "example 1"},
432
+ {"doc_id": "doc_2", "text": "example 2"},
433
+ {"doc_id": "doc_3", "text": "example 3"},
434
+ ]
435
+ }
436
+ }
437
+
438
+ cleaned = await service.cleanup_action_kg_for_doc_ids(["doc_1", "doc_3"])
439
+
440
+ assert cleaned == 2
441
+ examples = service.knowledge_graph["context_action_effectiveness"]["context|action|coll"]["examples"]
442
+ assert len(examples) == 1
443
+ assert examples[0]["doc_id"] == "doc_2"
444
+
445
+
446
+ class TestKGEntitiesVisualization:
447
+ """Test KG entity retrieval for visualization."""
448
+
449
+ @pytest.fixture
450
+ def temp_paths(self, tmp_path):
451
+ return {
452
+ "kg_path": tmp_path / "knowledge_graph.json",
453
+ "content_graph_path": tmp_path / "content_graph.json",
454
+ "relationships_path": tmp_path / "relationships.json",
455
+ }
456
+
457
+ @pytest.fixture
458
+ def service(self, temp_paths):
459
+ return KnowledgeGraphService(**temp_paths)
460
+
461
+ @pytest.mark.asyncio
462
+ async def test_get_kg_entities_routing(self, service):
463
+ """Should return routing KG entities."""
464
+ service.knowledge_graph["routing_patterns"] = {
465
+ "python": {
466
+ "collections_used": {"history": {"total": 5}},
467
+ "best_collection": "history",
468
+ "success_rate": 0.8,
469
+ }
470
+ }
471
+ # Save to disk so reload_kg finds it (get_kg_entities calls reload_kg)
472
+ service._save_kg_sync()
473
+
474
+ entities = await service.get_kg_entities()
475
+
476
+ assert len(entities) > 0
477
+ python_entity = next((e for e in entities if e["entity"] == "python"), None)
478
+ assert python_entity is not None
479
+ assert python_entity["source"] == "routing"
480
+ assert python_entity["success_rate"] == 0.8
481
+
482
+ @pytest.mark.asyncio
483
+ async def test_get_kg_entities_with_filter(self, service):
484
+ """Should filter entities by text."""
485
+ service.knowledge_graph["routing_patterns"] = {
486
+ "python": {"collections_used": {"history": {"total": 5}}, "best_collection": "history"},
487
+ "javascript": {"collections_used": {"books": {"total": 3}}, "best_collection": "books"},
488
+ }
489
+ # Save to disk so reload_kg finds it
490
+ service._save_kg_sync()
491
+
492
+ entities = await service.get_kg_entities(filter_text="python")
493
+
494
+ assert len(entities) == 1
495
+ assert entities[0]["entity"] == "python"
496
+
497
+ @pytest.mark.asyncio
498
+ async def test_get_kg_relationships(self, service):
499
+ """Should return merged relationships."""
500
+ service.knowledge_graph["relationships"] = {
501
+ "django|python": {
502
+ "co_occurrence": 10,
503
+ "success_together": 5,
504
+ "failure_together": 1,
505
+ }
506
+ }
507
+
508
+ relationships = await service.get_kg_relationships("python")
509
+
510
+ assert len(relationships) > 0
511
+ django_rel = next((r for r in relationships if r["related_entity"] == "django"), None)
512
+ assert django_rel is not None
513
+ assert django_rel["strength"] == 10
514
+
515
+
516
+ if __name__ == "__main__":
517
+ pytest.main([__file__, "-v"])