personal memory agent
at main 259 lines 8.6 kB view raw
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""Tests for observe/extract.py frame selection logic.""" 5 6from unittest.mock import patch 7 8from observe.extract import ( 9 DEFAULT_MAX_EXTRACTIONS, 10 _build_extraction_guidance, 11 _fallback_select_frames, 12 select_frames_for_extraction, 13) 14 15 16def _make_frames(count: int, start_id: int = 1) -> list[dict]: 17 """Create test frame data.""" 18 return [ 19 {"frame_id": i, "timestamp": float(i), "analysis": {"primary": "code"}} 20 for i in range(start_id, start_id + count) 21 ] 22 23 24def test_default_max_extractions(): 25 """Test default max extractions value.""" 26 assert DEFAULT_MAX_EXTRACTIONS == 20 27 28 29def test_empty_list(): 30 """Test empty input returns empty output.""" 31 result = select_frames_for_extraction([]) 32 assert result == [] 33 34 35def test_single_frame(): 36 """Test single frame is always selected.""" 37 frames = _make_frames(1) 38 result = select_frames_for_extraction(frames, max_extractions=5) 39 assert result == [1] 40 41 42def test_fewer_than_max_returns_all(): 43 """Test that frames under the limit are all returned.""" 44 frames = _make_frames(5) 45 result = select_frames_for_extraction(frames, max_extractions=10) 46 assert result == [1, 2, 3, 4, 5] 47 48 49def test_exactly_max_returns_all(): 50 """Test that exactly max frames returns all.""" 51 frames = _make_frames(10) 52 result = select_frames_for_extraction(frames, max_extractions=10) 53 assert result == list(range(1, 11)) 54 55 56def test_more_than_max_returns_around_max(): 57 """Test that more than max frames returns approximately max count.""" 58 frames = _make_frames(30) 59 result = select_frames_for_extraction(frames, max_extractions=5) 60 # May be max or max+1 if first frame wasn't in random selection 61 assert 5 <= len(result) <= 6 62 63 64def test_first_frame_always_included(): 65 """Test that first frame is always in selection.""" 66 frames = _make_frames(100) 67 # Run multiple times to account for randomness 68 for _ in range(10): 69 result = select_frames_for_extraction(frames, max_extractions=10) 70 assert 1 in result, "First frame must always be included" 71 72 73def test_results_sorted(): 74 """Test that results are sorted by frame_id.""" 75 frames = _make_frames(50) 76 result = select_frames_for_extraction(frames, max_extractions=10) 77 assert result == sorted(result) 78 79 80def test_fallback_deterministic_for_small_lists(): 81 """Test fallback is deterministic for lists under max.""" 82 frames = _make_frames(5) 83 result1 = _fallback_select_frames(frames, max_extractions=10) 84 result2 = _fallback_select_frames(frames, max_extractions=10) 85 assert result1 == result2 == [1, 2, 3, 4, 5] 86 87 88def test_max_extractions_of_one(): 89 """Test edge case of max_extractions=1.""" 90 frames = _make_frames(10) 91 result = select_frames_for_extraction(frames, max_extractions=1) 92 # First frame always included, plus possibly one random 93 assert 1 in result 94 assert 1 <= len(result) <= 2 95 96 97def test_non_sequential_frame_ids(): 98 """Test with non-sequential frame IDs.""" 99 frames = [ 100 {"frame_id": 5, "timestamp": 1.0, "analysis": {}}, 101 {"frame_id": 10, "timestamp": 2.0, "analysis": {}}, 102 {"frame_id": 15, "timestamp": 3.0, "analysis": {}}, 103 ] 104 result = select_frames_for_extraction(frames, max_extractions=10) 105 assert result == [5, 10, 15] 106 107 108# --- Tests for _build_extraction_guidance --- 109 110 111def test_build_extraction_guidance_empty(): 112 """Test extraction guidance with empty categories.""" 113 result = _build_extraction_guidance({}) 114 assert result == "No category-specific rules." 115 116 117def test_build_extraction_guidance_no_extraction_fields(): 118 """Test extraction guidance when no categories have extraction field.""" 119 categories = { 120 "code": {"description": "Code editors"}, 121 "browsing": {"description": "Web browsing"}, 122 } 123 result = _build_extraction_guidance(categories) 124 assert result == "No category-specific rules." 125 126 127def test_build_extraction_guidance_with_extraction(): 128 """Test extraction guidance with valid extraction fields.""" 129 categories = { 130 "code": { 131 "description": "Code editors", 132 "extraction": "Extract on file changes", 133 }, 134 "browsing": { 135 "description": "Web browsing", 136 "extraction": "Extract on site changes", 137 }, 138 "meeting": {"description": "Video calls"}, # No extraction field 139 } 140 result = _build_extraction_guidance(categories) 141 assert "- browsing: Extract on site changes" in result 142 assert "- code: Extract on file changes" in result 143 assert "meeting" not in result 144 145 146def test_build_extraction_guidance_sorted(): 147 """Test extraction guidance is sorted alphabetically.""" 148 categories = { 149 "zebra": {"extraction": "Z rule"}, 150 "apple": {"extraction": "A rule"}, 151 "mango": {"extraction": "M rule"}, 152 } 153 result = _build_extraction_guidance(categories) 154 lines = result.split("\n") 155 assert lines[0].startswith("- apple:") 156 assert lines[1].startswith("- mango:") 157 assert lines[2].startswith("- zebra:") 158 159 160# --- Tests for AI selection with mocked generate --- 161 162 163def test_ai_selection_with_categories(): 164 """Test that AI selection is used when categories are provided.""" 165 frames = _make_frames(10) 166 categories = {"code": {"description": "Code editors"}} 167 168 # Mock generate to return specific frame IDs 169 with patch("think.models.generate") as mock_generate: 170 mock_generate.return_value = "[1, 3, 5, 7]" 171 result = select_frames_for_extraction( 172 frames, max_extractions=5, categories=categories 173 ) 174 175 assert result == [1, 3, 5, 7] 176 mock_generate.assert_called_once() 177 178 179def test_ai_selection_filters_invalid_ids(): 180 """Test that AI selection filters out invalid frame IDs.""" 181 frames = _make_frames(5) # IDs 1-5 182 categories = {"code": {"description": "Code editors"}} 183 184 # Mock generate to return some invalid IDs 185 with patch("think.models.generate") as mock_generate: 186 mock_generate.return_value = "[1, 3, 99, 100, 5]" # 99, 100 are invalid 187 result = select_frames_for_extraction( 188 frames, max_extractions=10, categories=categories 189 ) 190 191 assert result == [1, 3, 5] 192 assert 99 not in result 193 assert 100 not in result 194 195 196def test_ai_selection_hard_cap(): 197 """Test that AI selection caps at 2x max_extractions.""" 198 frames = _make_frames(50) 199 categories = {"code": {"description": "Code editors"}} 200 201 # Mock generate to return way too many IDs 202 many_ids = list(range(1, 51)) # 50 IDs 203 with patch("think.models.generate") as mock_generate: 204 mock_generate.return_value = str(many_ids) 205 result = select_frames_for_extraction( 206 frames, max_extractions=5, categories=categories 207 ) 208 209 # Hard cap is 2 * 5 = 10, plus first frame guarantee 210 assert len(result) <= 11 211 212 213def test_ai_selection_fallback_on_error(): 214 """Test that fallback is used when AI selection fails.""" 215 frames = _make_frames(10) 216 categories = {"code": {"description": "Code editors"}} 217 218 # Mock generate to raise an exception 219 with patch("think.models.generate") as mock_generate: 220 mock_generate.side_effect = Exception("API error") 221 result = select_frames_for_extraction( 222 frames, max_extractions=5, categories=categories 223 ) 224 225 # Should still get a valid result from fallback 226 assert len(result) >= 5 227 assert 1 in result # First frame always included 228 229 230def test_ai_selection_fallback_on_invalid_json(): 231 """Test that fallback is used when AI returns invalid JSON.""" 232 frames = _make_frames(10) 233 categories = {"code": {"description": "Code editors"}} 234 235 with patch("think.models.generate") as mock_generate: 236 mock_generate.return_value = "not valid json" 237 result = select_frames_for_extraction( 238 frames, max_extractions=5, categories=categories 239 ) 240 241 # Should still get a valid result from fallback 242 assert len(result) >= 5 243 assert 1 in result 244 245 246def test_no_ai_selection_without_categories(): 247 """Test that AI selection is skipped when categories is None.""" 248 frames = _make_frames(10) 249 250 with patch("think.models.generate") as mock_generate: 251 result = select_frames_for_extraction( 252 frames, max_extractions=5, categories=None 253 ) 254 255 # generate should not be called 256 mock_generate.assert_not_called() 257 # Should get fallback result 258 assert len(result) >= 5 259 assert 1 in result