spatial-memory-mcp 1.9.1__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.
- spatial_memory/__init__.py +97 -0
- spatial_memory/__main__.py +271 -0
- spatial_memory/adapters/__init__.py +7 -0
- spatial_memory/adapters/lancedb_repository.py +880 -0
- spatial_memory/config.py +769 -0
- spatial_memory/core/__init__.py +118 -0
- spatial_memory/core/cache.py +317 -0
- spatial_memory/core/circuit_breaker.py +297 -0
- spatial_memory/core/connection_pool.py +220 -0
- spatial_memory/core/consolidation_strategies.py +401 -0
- spatial_memory/core/database.py +3072 -0
- spatial_memory/core/db_idempotency.py +242 -0
- spatial_memory/core/db_indexes.py +576 -0
- spatial_memory/core/db_migrations.py +588 -0
- spatial_memory/core/db_search.py +512 -0
- spatial_memory/core/db_versioning.py +178 -0
- spatial_memory/core/embeddings.py +558 -0
- spatial_memory/core/errors.py +317 -0
- spatial_memory/core/file_security.py +701 -0
- spatial_memory/core/filesystem.py +178 -0
- spatial_memory/core/health.py +289 -0
- spatial_memory/core/helpers.py +79 -0
- spatial_memory/core/import_security.py +433 -0
- spatial_memory/core/lifecycle_ops.py +1067 -0
- spatial_memory/core/logging.py +194 -0
- spatial_memory/core/metrics.py +192 -0
- spatial_memory/core/models.py +660 -0
- spatial_memory/core/rate_limiter.py +326 -0
- spatial_memory/core/response_types.py +500 -0
- spatial_memory/core/security.py +588 -0
- spatial_memory/core/spatial_ops.py +430 -0
- spatial_memory/core/tracing.py +300 -0
- spatial_memory/core/utils.py +110 -0
- spatial_memory/core/validation.py +406 -0
- spatial_memory/factory.py +444 -0
- spatial_memory/migrations/__init__.py +40 -0
- spatial_memory/ports/__init__.py +11 -0
- spatial_memory/ports/repositories.py +630 -0
- spatial_memory/py.typed +0 -0
- spatial_memory/server.py +1214 -0
- spatial_memory/services/__init__.py +70 -0
- spatial_memory/services/decay_manager.py +411 -0
- spatial_memory/services/export_import.py +1031 -0
- spatial_memory/services/lifecycle.py +1139 -0
- spatial_memory/services/memory.py +412 -0
- spatial_memory/services/spatial.py +1152 -0
- spatial_memory/services/utility.py +429 -0
- spatial_memory/tools/__init__.py +5 -0
- spatial_memory/tools/definitions.py +695 -0
- spatial_memory/verify.py +140 -0
- spatial_memory_mcp-1.9.1.dist-info/METADATA +509 -0
- spatial_memory_mcp-1.9.1.dist-info/RECORD +55 -0
- spatial_memory_mcp-1.9.1.dist-info/WHEEL +4 -0
- spatial_memory_mcp-1.9.1.dist-info/entry_points.txt +2 -0
- spatial_memory_mcp-1.9.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1139 @@
|
|
|
1
|
+
"""Lifecycle service for memory management operations.
|
|
2
|
+
|
|
3
|
+
This service provides the application layer for memory lifecycle operations:
|
|
4
|
+
- decay: Apply time/access-based importance reduction
|
|
5
|
+
- reinforce: Boost memory importance
|
|
6
|
+
- extract: Auto-extract memories from text
|
|
7
|
+
- consolidate: Merge similar/duplicate memories
|
|
8
|
+
|
|
9
|
+
These operations address the "Context Window Pollution" problem by providing
|
|
10
|
+
utility-based memory management with cognitive-like dynamics.
|
|
11
|
+
|
|
12
|
+
The service uses dependency injection for repository and embedding services.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from spatial_memory.core.consolidation_strategies import (
|
|
24
|
+
ConsolidationAction,
|
|
25
|
+
get_strategy,
|
|
26
|
+
)
|
|
27
|
+
from spatial_memory.core.errors import (
|
|
28
|
+
ConsolidationError,
|
|
29
|
+
DecayError,
|
|
30
|
+
ExtractionError,
|
|
31
|
+
ReinforcementError,
|
|
32
|
+
ValidationError,
|
|
33
|
+
)
|
|
34
|
+
from spatial_memory.core.lifecycle_ops import (
|
|
35
|
+
apply_decay,
|
|
36
|
+
calculate_decay_factor,
|
|
37
|
+
calculate_reinforcement,
|
|
38
|
+
combined_similarity,
|
|
39
|
+
extract_candidates,
|
|
40
|
+
find_duplicate_groups,
|
|
41
|
+
jaccard_similarity,
|
|
42
|
+
)
|
|
43
|
+
from spatial_memory.core.models import (
|
|
44
|
+
ConsolidateResult,
|
|
45
|
+
ConsolidationGroup,
|
|
46
|
+
DecayedMemory,
|
|
47
|
+
DecayResult,
|
|
48
|
+
ExtractedMemory,
|
|
49
|
+
ExtractResult,
|
|
50
|
+
Memory,
|
|
51
|
+
MemorySource,
|
|
52
|
+
ReinforcedMemory,
|
|
53
|
+
ReinforceResult,
|
|
54
|
+
)
|
|
55
|
+
from spatial_memory.core.utils import to_naive_utc, utc_now, utc_now_naive
|
|
56
|
+
from spatial_memory.core.validation import validate_namespace
|
|
57
|
+
|
|
58
|
+
# Alias for backward compatibility
|
|
59
|
+
ConsolidationGroupResult = ConsolidationGroup
|
|
60
|
+
|
|
61
|
+
logger = logging.getLogger(__name__)
|
|
62
|
+
|
|
63
|
+
# Explicit exports for mypy
|
|
64
|
+
__all__ = [
|
|
65
|
+
# Result types (re-exported from models)
|
|
66
|
+
"ConsolidateResult",
|
|
67
|
+
"ConsolidationGroupResult",
|
|
68
|
+
"DecayedMemory",
|
|
69
|
+
"DecayResult",
|
|
70
|
+
"ExtractedMemory",
|
|
71
|
+
"ExtractResult",
|
|
72
|
+
"ReinforcedMemory",
|
|
73
|
+
"ReinforceResult",
|
|
74
|
+
# Config and service
|
|
75
|
+
"LifecycleConfig",
|
|
76
|
+
"LifecycleService",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
if TYPE_CHECKING:
|
|
80
|
+
from spatial_memory.ports.repositories import (
|
|
81
|
+
EmbeddingServiceProtocol,
|
|
82
|
+
MemoryRepositoryProtocol,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# =============================================================================
|
|
87
|
+
# Configuration
|
|
88
|
+
# =============================================================================
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class LifecycleConfig:
|
|
93
|
+
"""Configuration for lifecycle operations.
|
|
94
|
+
|
|
95
|
+
Attributes:
|
|
96
|
+
decay_default_half_life_days: Default half-life for exponential decay.
|
|
97
|
+
decay_default_function: Default decay function type.
|
|
98
|
+
decay_min_importance_floor: Minimum importance after decay.
|
|
99
|
+
decay_batch_size: Batch size for decay updates.
|
|
100
|
+
reinforce_default_boost: Default boost amount.
|
|
101
|
+
reinforce_max_importance: Maximum importance after reinforcement.
|
|
102
|
+
extract_max_text_length: Maximum text length for extraction.
|
|
103
|
+
extract_max_candidates: Maximum candidates per extraction.
|
|
104
|
+
extract_default_importance: Default importance for extracted memories.
|
|
105
|
+
extract_default_namespace: Default namespace for extracted memories.
|
|
106
|
+
consolidate_min_threshold: Minimum similarity threshold.
|
|
107
|
+
consolidate_content_weight: Weight of content overlap vs vector similarity.
|
|
108
|
+
consolidate_max_batch: Maximum memories per consolidation pass.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
# Decay settings
|
|
112
|
+
decay_default_half_life_days: float = 30.0
|
|
113
|
+
decay_default_function: str = "exponential" # exponential, linear, step
|
|
114
|
+
decay_min_importance_floor: float = 0.1
|
|
115
|
+
decay_batch_size: int = 500
|
|
116
|
+
|
|
117
|
+
# Reinforce settings
|
|
118
|
+
reinforce_default_boost: float = 0.1
|
|
119
|
+
reinforce_max_importance: float = 1.0
|
|
120
|
+
|
|
121
|
+
# Extract settings
|
|
122
|
+
extract_max_text_length: int = 50000
|
|
123
|
+
extract_max_candidates: int = 20
|
|
124
|
+
extract_default_importance: float = 0.4
|
|
125
|
+
extract_default_namespace: str = "extracted"
|
|
126
|
+
|
|
127
|
+
# Consolidate settings
|
|
128
|
+
consolidate_min_threshold: float = 0.7
|
|
129
|
+
consolidate_content_weight: float = 0.3
|
|
130
|
+
consolidate_max_batch: int = 1000
|
|
131
|
+
consolidate_chunk_size: int = 200 # Process in smaller chunks for memory efficiency
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# =============================================================================
|
|
135
|
+
# Service Implementation
|
|
136
|
+
# =============================================================================
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class LifecycleService:
|
|
140
|
+
"""Service for memory lifecycle management.
|
|
141
|
+
|
|
142
|
+
Uses Clean Architecture - depends on protocol interfaces, not implementations.
|
|
143
|
+
Implements cognitive-like memory dynamics: decay, reinforcement, extraction,
|
|
144
|
+
and consolidation.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
repository: MemoryRepositoryProtocol,
|
|
150
|
+
embeddings: EmbeddingServiceProtocol,
|
|
151
|
+
config: LifecycleConfig | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Initialize the lifecycle service.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
repository: Repository for memory storage.
|
|
157
|
+
embeddings: Service for generating embeddings.
|
|
158
|
+
config: Optional configuration (uses defaults if not provided).
|
|
159
|
+
"""
|
|
160
|
+
self._repo = repository
|
|
161
|
+
self._embeddings = embeddings
|
|
162
|
+
self._config = config or LifecycleConfig()
|
|
163
|
+
|
|
164
|
+
def decay(
|
|
165
|
+
self,
|
|
166
|
+
namespace: str | None = None,
|
|
167
|
+
decay_function: Literal["exponential", "linear", "step"] = "exponential",
|
|
168
|
+
half_life_days: float | None = None,
|
|
169
|
+
min_importance: float | None = None,
|
|
170
|
+
access_weight: float = 0.3,
|
|
171
|
+
dry_run: bool = True,
|
|
172
|
+
) -> DecayResult:
|
|
173
|
+
"""Apply time and access-based decay to memory importance scores.
|
|
174
|
+
|
|
175
|
+
Implements the "forgetting curve" - memories not accessed become less
|
|
176
|
+
important over time. More accesses and higher base importance slow decay.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
namespace: Namespace to decay (all if not specified).
|
|
180
|
+
decay_function: Decay curve shape ("exponential", "linear", "step").
|
|
181
|
+
half_life_days: Days until importance halves (default from config).
|
|
182
|
+
min_importance: Minimum importance floor (default from config).
|
|
183
|
+
access_weight: Weight of access count in decay calculation (0-1).
|
|
184
|
+
dry_run: Preview changes without applying (default True).
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
DecayResult with decay statistics and affected memories.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
DecayError: If decay calculation or application fails.
|
|
191
|
+
ValidationError: If input validation fails.
|
|
192
|
+
"""
|
|
193
|
+
# Validate inputs
|
|
194
|
+
if namespace is not None:
|
|
195
|
+
namespace = validate_namespace(namespace)
|
|
196
|
+
|
|
197
|
+
if decay_function not in ("exponential", "linear", "step"):
|
|
198
|
+
raise ValidationError(
|
|
199
|
+
f"Invalid decay function: {decay_function}. "
|
|
200
|
+
"Must be 'exponential', 'linear', or 'step'."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if not 0.0 <= access_weight <= 1.0:
|
|
204
|
+
raise ValidationError("access_weight must be between 0.0 and 1.0")
|
|
205
|
+
|
|
206
|
+
# Use config defaults
|
|
207
|
+
effective_half_life = (
|
|
208
|
+
half_life_days
|
|
209
|
+
if half_life_days is not None
|
|
210
|
+
else self._config.decay_default_half_life_days
|
|
211
|
+
)
|
|
212
|
+
effective_min_importance = (
|
|
213
|
+
min_importance
|
|
214
|
+
if min_importance is not None
|
|
215
|
+
else self._config.decay_min_importance_floor
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if effective_half_life < 1.0:
|
|
219
|
+
raise ValidationError("half_life_days must be at least 1.0")
|
|
220
|
+
if not 0.0 <= effective_min_importance <= 0.5:
|
|
221
|
+
raise ValidationError("min_importance must be between 0.0 and 0.5")
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
# Fetch all memories for decay calculation
|
|
225
|
+
all_memories = self._repo.get_all(
|
|
226
|
+
namespace=namespace,
|
|
227
|
+
limit=self._config.decay_batch_size * 10, # Allow multiple batches
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if not all_memories:
|
|
231
|
+
logger.info("No memories found for decay")
|
|
232
|
+
return DecayResult(
|
|
233
|
+
memories_analyzed=0,
|
|
234
|
+
memories_decayed=0,
|
|
235
|
+
avg_decay_factor=1.0,
|
|
236
|
+
decayed_memories=[],
|
|
237
|
+
dry_run=dry_run,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Use naive UTC to match LanceDB storage format (avoids timezone mismatch errors)
|
|
241
|
+
now = utc_now_naive()
|
|
242
|
+
decayed_memories: list[DecayedMemory] = []
|
|
243
|
+
total_decay_factor = 0.0
|
|
244
|
+
memories_to_update: list[tuple[str, float]] = []
|
|
245
|
+
|
|
246
|
+
for memory, _ in all_memories:
|
|
247
|
+
# Normalize last_accessed to naive UTC (handle both aware and naive timestamps)
|
|
248
|
+
last_accessed = to_naive_utc(memory.last_accessed)
|
|
249
|
+
|
|
250
|
+
# Calculate days since last access
|
|
251
|
+
days_since_access = (now - last_accessed).total_seconds() / 86400
|
|
252
|
+
|
|
253
|
+
# Calculate decay factor
|
|
254
|
+
decay_factor = calculate_decay_factor(
|
|
255
|
+
days_since_access=days_since_access,
|
|
256
|
+
access_count=memory.access_count,
|
|
257
|
+
base_importance=memory.importance,
|
|
258
|
+
decay_function=decay_function,
|
|
259
|
+
half_life_days=effective_half_life,
|
|
260
|
+
access_weight=access_weight,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Apply decay to get new importance
|
|
264
|
+
new_importance = apply_decay(
|
|
265
|
+
current_importance=memory.importance,
|
|
266
|
+
decay_factor=decay_factor,
|
|
267
|
+
min_importance=effective_min_importance,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Track if importance actually changed
|
|
271
|
+
if abs(new_importance - memory.importance) > 0.001:
|
|
272
|
+
decayed_memories.append(
|
|
273
|
+
DecayedMemory(
|
|
274
|
+
id=memory.id,
|
|
275
|
+
content_preview=memory.content[:100] + "..."
|
|
276
|
+
if len(memory.content) > 100
|
|
277
|
+
else memory.content,
|
|
278
|
+
old_importance=memory.importance,
|
|
279
|
+
new_importance=new_importance,
|
|
280
|
+
decay_factor=decay_factor,
|
|
281
|
+
days_since_access=int(days_since_access),
|
|
282
|
+
access_count=memory.access_count,
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
memories_to_update.append((memory.id, new_importance))
|
|
286
|
+
|
|
287
|
+
total_decay_factor += decay_factor
|
|
288
|
+
|
|
289
|
+
avg_decay = (
|
|
290
|
+
total_decay_factor / len(all_memories) if all_memories else 1.0
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Apply updates if not dry run - use batch update for efficiency
|
|
294
|
+
failed_updates: list[str] = []
|
|
295
|
+
if not dry_run and memories_to_update:
|
|
296
|
+
logger.info(f"Applying decay to {len(memories_to_update)} memories")
|
|
297
|
+
# Convert to batch update format: list of (memory_id, updates_dict)
|
|
298
|
+
batch_updates = [
|
|
299
|
+
(memory_id, {"importance": new_importance})
|
|
300
|
+
for memory_id, new_importance in memories_to_update
|
|
301
|
+
]
|
|
302
|
+
try:
|
|
303
|
+
success_count, failed_ids = self._repo.update_batch(batch_updates)
|
|
304
|
+
failed_updates = failed_ids
|
|
305
|
+
logger.debug(
|
|
306
|
+
f"Batch decay update: {success_count} succeeded, "
|
|
307
|
+
f"{len(failed_ids)} failed"
|
|
308
|
+
)
|
|
309
|
+
except Exception as e:
|
|
310
|
+
logger.warning(f"Batch decay update failed: {e}")
|
|
311
|
+
# Fall back to individual updates on batch failure
|
|
312
|
+
for memory_id, new_importance in memories_to_update:
|
|
313
|
+
try:
|
|
314
|
+
self._repo.update(memory_id, {"importance": new_importance})
|
|
315
|
+
except Exception as update_err:
|
|
316
|
+
logger.warning(f"Failed to update {memory_id}: {update_err}")
|
|
317
|
+
failed_updates.append(memory_id)
|
|
318
|
+
|
|
319
|
+
return DecayResult(
|
|
320
|
+
memories_analyzed=len(all_memories),
|
|
321
|
+
memories_decayed=len(decayed_memories),
|
|
322
|
+
avg_decay_factor=avg_decay,
|
|
323
|
+
decayed_memories=decayed_memories,
|
|
324
|
+
dry_run=dry_run,
|
|
325
|
+
failed_updates=failed_updates,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
except (ValidationError, DecayError):
|
|
329
|
+
raise
|
|
330
|
+
except Exception as e:
|
|
331
|
+
raise DecayError(f"Decay operation failed: {e}") from e
|
|
332
|
+
|
|
333
|
+
def reinforce(
|
|
334
|
+
self,
|
|
335
|
+
memory_ids: list[str],
|
|
336
|
+
boost_type: Literal["additive", "multiplicative", "set_value"] = "additive",
|
|
337
|
+
boost_amount: float | None = None,
|
|
338
|
+
update_access: bool = True,
|
|
339
|
+
) -> ReinforceResult:
|
|
340
|
+
"""Boost memory importance based on usage or explicit feedback.
|
|
341
|
+
|
|
342
|
+
Reinforcement increases importance and can reset decay timer by
|
|
343
|
+
updating the access timestamp.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
memory_ids: Memory IDs to reinforce.
|
|
347
|
+
boost_type: Type of boost ("additive", "multiplicative", "set_value").
|
|
348
|
+
boost_amount: Amount for boost (default from config).
|
|
349
|
+
update_access: Also update last_accessed timestamp (default True).
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
ReinforceResult with reinforcement statistics.
|
|
353
|
+
|
|
354
|
+
Raises:
|
|
355
|
+
ReinforcementError: If reinforcement fails.
|
|
356
|
+
ValidationError: If input validation fails.
|
|
357
|
+
"""
|
|
358
|
+
if not memory_ids:
|
|
359
|
+
raise ValidationError("memory_ids cannot be empty")
|
|
360
|
+
|
|
361
|
+
if boost_type not in ("additive", "multiplicative", "set_value"):
|
|
362
|
+
raise ValidationError(
|
|
363
|
+
f"Invalid boost_type: {boost_type}. "
|
|
364
|
+
"Must be 'additive', 'multiplicative', or 'set_value'."
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
effective_boost = (
|
|
368
|
+
boost_amount
|
|
369
|
+
if boost_amount is not None
|
|
370
|
+
else self._config.reinforce_default_boost
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if effective_boost < 0.0 or effective_boost > 1.0:
|
|
374
|
+
raise ValidationError("boost_amount must be between 0.0 and 1.0")
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
reinforced: list[ReinforcedMemory] = []
|
|
378
|
+
not_found: list[str] = []
|
|
379
|
+
failed_updates: list[str] = []
|
|
380
|
+
total_boost = 0.0
|
|
381
|
+
|
|
382
|
+
# Batch fetch all memories in a single query
|
|
383
|
+
memory_map = self._repo.get_batch(memory_ids)
|
|
384
|
+
|
|
385
|
+
# Track which memories were not found
|
|
386
|
+
for memory_id in memory_ids:
|
|
387
|
+
if memory_id not in memory_map:
|
|
388
|
+
not_found.append(memory_id)
|
|
389
|
+
logger.warning(f"Memory not found for reinforcement: {memory_id}")
|
|
390
|
+
|
|
391
|
+
if not memory_map:
|
|
392
|
+
return ReinforceResult(
|
|
393
|
+
memories_reinforced=0,
|
|
394
|
+
avg_boost=0.0,
|
|
395
|
+
reinforced=[],
|
|
396
|
+
not_found=not_found,
|
|
397
|
+
failed_updates=[],
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# Calculate reinforcement for all found memories
|
|
401
|
+
now = utc_now()
|
|
402
|
+
batch_updates: list[tuple[str, dict[str, Any]]] = []
|
|
403
|
+
# Tuple: (id, memory, new_importance, boost_applied)
|
|
404
|
+
reinforcement_info: list[tuple[str, Memory, float, float]] = []
|
|
405
|
+
|
|
406
|
+
for memory_id, memory in memory_map.items():
|
|
407
|
+
# Calculate new importance
|
|
408
|
+
new_importance, actual_boost = calculate_reinforcement(
|
|
409
|
+
current_importance=memory.importance,
|
|
410
|
+
boost_type=boost_type,
|
|
411
|
+
boost_amount=effective_boost,
|
|
412
|
+
max_importance=self._config.reinforce_max_importance,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Prepare update
|
|
416
|
+
updates: dict[str, Any] = {"importance": new_importance}
|
|
417
|
+
if update_access:
|
|
418
|
+
updates["last_accessed"] = now
|
|
419
|
+
updates["access_count"] = memory.access_count + 1
|
|
420
|
+
|
|
421
|
+
batch_updates.append((memory_id, updates))
|
|
422
|
+
reinforcement_info.append((memory_id, memory, new_importance, actual_boost))
|
|
423
|
+
|
|
424
|
+
# Apply all updates in a single batch operation
|
|
425
|
+
try:
|
|
426
|
+
success_count, batch_failed_ids = self._repo.update_batch(batch_updates)
|
|
427
|
+
failed_updates = batch_failed_ids
|
|
428
|
+
logger.debug(
|
|
429
|
+
f"Batch reinforce update: {success_count} succeeded, "
|
|
430
|
+
f"{len(batch_failed_ids)} failed"
|
|
431
|
+
)
|
|
432
|
+
except Exception as e:
|
|
433
|
+
logger.warning(
|
|
434
|
+
f"Batch reinforce update failed: {e}, falling back to individual updates"
|
|
435
|
+
)
|
|
436
|
+
# Fall back to individual updates on batch failure
|
|
437
|
+
batch_failed_ids = []
|
|
438
|
+
for memory_id, updates in batch_updates:
|
|
439
|
+
try:
|
|
440
|
+
self._repo.update(memory_id, updates)
|
|
441
|
+
except Exception as update_err:
|
|
442
|
+
logger.warning(f"Failed to reinforce {memory_id}: {update_err}")
|
|
443
|
+
batch_failed_ids.append(memory_id)
|
|
444
|
+
failed_updates = batch_failed_ids
|
|
445
|
+
|
|
446
|
+
# Build reinforced results for successful updates
|
|
447
|
+
failed_set = set(failed_updates)
|
|
448
|
+
for memory_id, memory, new_importance, actual_boost in reinforcement_info:
|
|
449
|
+
if memory_id not in failed_set:
|
|
450
|
+
reinforced.append(
|
|
451
|
+
ReinforcedMemory(
|
|
452
|
+
id=memory_id,
|
|
453
|
+
content_preview=memory.content[:100] + "..."
|
|
454
|
+
if len(memory.content) > 100
|
|
455
|
+
else memory.content,
|
|
456
|
+
old_importance=memory.importance,
|
|
457
|
+
new_importance=new_importance,
|
|
458
|
+
boost_applied=actual_boost,
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
total_boost += actual_boost
|
|
462
|
+
|
|
463
|
+
avg_boost = total_boost / len(reinforced) if reinforced else 0.0
|
|
464
|
+
|
|
465
|
+
return ReinforceResult(
|
|
466
|
+
memories_reinforced=len(reinforced),
|
|
467
|
+
avg_boost=avg_boost,
|
|
468
|
+
reinforced=reinforced,
|
|
469
|
+
not_found=not_found,
|
|
470
|
+
failed_updates=failed_updates,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
except (ValidationError, ReinforcementError):
|
|
474
|
+
raise
|
|
475
|
+
except Exception as e:
|
|
476
|
+
raise ReinforcementError(f"Reinforcement operation failed: {e}") from e
|
|
477
|
+
|
|
478
|
+
def extract(
|
|
479
|
+
self,
|
|
480
|
+
text: str,
|
|
481
|
+
namespace: str | None = None,
|
|
482
|
+
min_confidence: float = 0.5,
|
|
483
|
+
deduplicate: bool = True,
|
|
484
|
+
dedup_threshold: float = 0.9,
|
|
485
|
+
) -> ExtractResult:
|
|
486
|
+
"""Automatically extract memories from conversation text.
|
|
487
|
+
|
|
488
|
+
Uses pattern matching to identify facts, decisions, and key information
|
|
489
|
+
from unstructured text.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
text: Text to extract memories from.
|
|
493
|
+
namespace: Namespace for extracted memories (default from config).
|
|
494
|
+
min_confidence: Minimum confidence to extract (0-1).
|
|
495
|
+
deduplicate: Skip if similar memory exists (default True).
|
|
496
|
+
dedup_threshold: Similarity threshold for deduplication (0.7-0.99).
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
ExtractResult with extraction statistics and created memories.
|
|
500
|
+
|
|
501
|
+
Raises:
|
|
502
|
+
ExtractionError: If extraction fails.
|
|
503
|
+
ValidationError: If input validation fails.
|
|
504
|
+
"""
|
|
505
|
+
if not text or not text.strip():
|
|
506
|
+
raise ValidationError("Text cannot be empty")
|
|
507
|
+
|
|
508
|
+
if len(text) > self._config.extract_max_text_length:
|
|
509
|
+
raise ValidationError(
|
|
510
|
+
f"Text exceeds maximum length of {self._config.extract_max_text_length}"
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
if not 0.0 <= min_confidence <= 1.0:
|
|
514
|
+
raise ValidationError("min_confidence must be between 0.0 and 1.0")
|
|
515
|
+
|
|
516
|
+
if not 0.7 <= dedup_threshold <= 0.99:
|
|
517
|
+
raise ValidationError("dedup_threshold must be between 0.7 and 0.99")
|
|
518
|
+
|
|
519
|
+
effective_namespace = namespace or self._config.extract_default_namespace
|
|
520
|
+
effective_namespace = validate_namespace(effective_namespace)
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
# Extract candidates using pattern matching
|
|
524
|
+
candidates = extract_candidates(
|
|
525
|
+
text=text,
|
|
526
|
+
min_confidence=min_confidence,
|
|
527
|
+
max_candidates=self._config.extract_max_candidates,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
if not candidates:
|
|
531
|
+
logger.info("No extraction candidates found")
|
|
532
|
+
return ExtractResult(
|
|
533
|
+
candidates_found=0,
|
|
534
|
+
memories_created=0,
|
|
535
|
+
deduplicated_count=0,
|
|
536
|
+
extractions=[],
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
# Generate embeddings for all candidates in a single batch
|
|
540
|
+
# This is much more efficient than generating one at a time
|
|
541
|
+
candidate_texts = [c.content for c in candidates]
|
|
542
|
+
candidate_vectors = self._embeddings.embed_batch(candidate_texts)
|
|
543
|
+
logger.debug(f"Generated {len(candidate_vectors)} embeddings in batch")
|
|
544
|
+
|
|
545
|
+
extractions: list[ExtractedMemory] = []
|
|
546
|
+
memories_created = 0
|
|
547
|
+
deduplicated_count = 0
|
|
548
|
+
|
|
549
|
+
for candidate, vector in zip(candidates, candidate_vectors):
|
|
550
|
+
extraction = ExtractedMemory(
|
|
551
|
+
content=candidate.content,
|
|
552
|
+
confidence=candidate.confidence,
|
|
553
|
+
pattern_matched=candidate.pattern_type,
|
|
554
|
+
start_pos=candidate.start_pos,
|
|
555
|
+
end_pos=candidate.end_pos,
|
|
556
|
+
stored=False,
|
|
557
|
+
memory_id=None,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
# Check for duplicates if requested (use pre-computed vector)
|
|
561
|
+
if deduplicate:
|
|
562
|
+
is_duplicate = self._check_duplicate_with_vector(
|
|
563
|
+
content=candidate.content,
|
|
564
|
+
vector=vector,
|
|
565
|
+
namespace=effective_namespace,
|
|
566
|
+
threshold=dedup_threshold,
|
|
567
|
+
)
|
|
568
|
+
if is_duplicate:
|
|
569
|
+
deduplicated_count += 1
|
|
570
|
+
extractions.append(extraction)
|
|
571
|
+
continue
|
|
572
|
+
|
|
573
|
+
# Store the extracted memory (use pre-computed vector)
|
|
574
|
+
try:
|
|
575
|
+
memory_id = self._store_extracted_memory_with_vector(
|
|
576
|
+
content=candidate.content,
|
|
577
|
+
vector=vector,
|
|
578
|
+
namespace=effective_namespace,
|
|
579
|
+
confidence=candidate.confidence,
|
|
580
|
+
pattern_type=candidate.pattern_type,
|
|
581
|
+
)
|
|
582
|
+
extraction.stored = True
|
|
583
|
+
extraction.memory_id = memory_id
|
|
584
|
+
memories_created += 1
|
|
585
|
+
except Exception as e:
|
|
586
|
+
logger.warning(f"Failed to store extraction: {e}")
|
|
587
|
+
|
|
588
|
+
extractions.append(extraction)
|
|
589
|
+
|
|
590
|
+
return ExtractResult(
|
|
591
|
+
candidates_found=len(candidates),
|
|
592
|
+
memories_created=memories_created,
|
|
593
|
+
deduplicated_count=deduplicated_count,
|
|
594
|
+
extractions=extractions,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
except (ValidationError, ExtractionError):
|
|
598
|
+
raise
|
|
599
|
+
except Exception as e:
|
|
600
|
+
raise ExtractionError(f"Extraction operation failed: {e}") from e
|
|
601
|
+
|
|
602
|
+
def consolidate(
|
|
603
|
+
self,
|
|
604
|
+
namespace: str,
|
|
605
|
+
similarity_threshold: float = 0.85,
|
|
606
|
+
strategy: Literal[
|
|
607
|
+
"keep_newest", "keep_oldest", "keep_highest_importance", "merge_content"
|
|
608
|
+
] = "keep_highest_importance",
|
|
609
|
+
dry_run: bool = True,
|
|
610
|
+
max_groups: int = 50,
|
|
611
|
+
) -> ConsolidateResult:
|
|
612
|
+
"""Merge similar or duplicate memories to reduce redundancy.
|
|
613
|
+
|
|
614
|
+
Finds memories above similarity threshold and merges them according
|
|
615
|
+
to the specified strategy.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
namespace: Namespace to consolidate (required).
|
|
619
|
+
similarity_threshold: Minimum similarity for duplicates (0.7-0.99).
|
|
620
|
+
strategy: How to handle duplicates:
|
|
621
|
+
- "keep_newest": Keep most recent memory
|
|
622
|
+
- "keep_oldest": Keep oldest memory
|
|
623
|
+
- "keep_highest_importance": Keep highest importance
|
|
624
|
+
- "merge_content": Combine content and re-embed
|
|
625
|
+
dry_run: Preview without changes (default True).
|
|
626
|
+
max_groups: Maximum groups to process.
|
|
627
|
+
|
|
628
|
+
Returns:
|
|
629
|
+
ConsolidateResult with consolidation statistics.
|
|
630
|
+
|
|
631
|
+
Raises:
|
|
632
|
+
ConsolidationError: If consolidation fails.
|
|
633
|
+
ValidationError: If input validation fails.
|
|
634
|
+
"""
|
|
635
|
+
namespace = validate_namespace(namespace)
|
|
636
|
+
|
|
637
|
+
if not 0.7 <= similarity_threshold <= 0.99:
|
|
638
|
+
raise ValidationError(
|
|
639
|
+
"similarity_threshold must be between 0.7 and 0.99"
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
# Validate strategy using the strategy registry
|
|
643
|
+
try:
|
|
644
|
+
strategy_impl = get_strategy(strategy)
|
|
645
|
+
except ValueError as e:
|
|
646
|
+
raise ValidationError(str(e)) from e
|
|
647
|
+
|
|
648
|
+
if max_groups < 1:
|
|
649
|
+
raise ValidationError("max_groups must be at least 1")
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
# Get total count to decide processing strategy
|
|
653
|
+
total_count = self._repo.count(namespace=namespace)
|
|
654
|
+
|
|
655
|
+
if total_count < 2:
|
|
656
|
+
logger.info("Not enough memories for consolidation")
|
|
657
|
+
return ConsolidateResult(
|
|
658
|
+
groups_found=0,
|
|
659
|
+
memories_merged=0,
|
|
660
|
+
memories_deleted=0,
|
|
661
|
+
groups=[],
|
|
662
|
+
dry_run=dry_run,
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
# Use chunked processing for large namespaces to reduce memory usage
|
|
666
|
+
chunk_size = min(
|
|
667
|
+
self._config.consolidate_chunk_size,
|
|
668
|
+
self._config.consolidate_max_batch,
|
|
669
|
+
)
|
|
670
|
+
use_chunked = total_count > chunk_size
|
|
671
|
+
|
|
672
|
+
if use_chunked:
|
|
673
|
+
logger.info(
|
|
674
|
+
f"Using chunked consolidation: {total_count} memories in "
|
|
675
|
+
f"chunks of {chunk_size}"
|
|
676
|
+
)
|
|
677
|
+
return self._consolidate_chunked(
|
|
678
|
+
namespace=namespace,
|
|
679
|
+
similarity_threshold=similarity_threshold,
|
|
680
|
+
strategy=strategy,
|
|
681
|
+
dry_run=dry_run,
|
|
682
|
+
max_groups=max_groups,
|
|
683
|
+
chunk_size=chunk_size,
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
# Standard single-pass processing for smaller namespaces
|
|
687
|
+
all_memories = self._repo.get_all(
|
|
688
|
+
namespace=namespace,
|
|
689
|
+
limit=self._config.consolidate_max_batch,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
# Build lookup structures
|
|
693
|
+
memories = [m for m, _ in all_memories]
|
|
694
|
+
vectors_list = [v for _, v in all_memories]
|
|
695
|
+
vectors_array = np.array(vectors_list, dtype=np.float32)
|
|
696
|
+
memory_ids = [m.id for m in memories]
|
|
697
|
+
contents = [m.content for m in memories]
|
|
698
|
+
memory_dicts: list[dict[str, Any]] = [
|
|
699
|
+
{
|
|
700
|
+
"id": m.id,
|
|
701
|
+
"content": m.content,
|
|
702
|
+
"created_at": m.created_at,
|
|
703
|
+
"last_accessed": m.last_accessed,
|
|
704
|
+
"access_count": m.access_count,
|
|
705
|
+
"importance": m.importance,
|
|
706
|
+
"tags": list(m.tags),
|
|
707
|
+
}
|
|
708
|
+
for m in memories
|
|
709
|
+
]
|
|
710
|
+
|
|
711
|
+
# Find duplicate groups using array-based API
|
|
712
|
+
group_indices = find_duplicate_groups(
|
|
713
|
+
memory_ids=memory_ids,
|
|
714
|
+
vectors=vectors_array,
|
|
715
|
+
contents=contents,
|
|
716
|
+
threshold=similarity_threshold,
|
|
717
|
+
content_weight=self._config.consolidate_content_weight,
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
# Limit groups
|
|
721
|
+
group_indices = group_indices[:max_groups]
|
|
722
|
+
|
|
723
|
+
if not group_indices:
|
|
724
|
+
logger.info("No duplicate groups found")
|
|
725
|
+
return ConsolidateResult(
|
|
726
|
+
groups_found=0,
|
|
727
|
+
memories_merged=0,
|
|
728
|
+
memories_deleted=0,
|
|
729
|
+
groups=[],
|
|
730
|
+
dry_run=dry_run,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
result_groups: list[ConsolidationGroupResult] = []
|
|
734
|
+
memories_merged = 0
|
|
735
|
+
memories_deleted = 0
|
|
736
|
+
|
|
737
|
+
for member_indices in group_indices:
|
|
738
|
+
group_member_dicts = [memory_dicts[i] for i in member_indices]
|
|
739
|
+
group_member_ids = [str(d["id"]) for d in group_member_dicts]
|
|
740
|
+
|
|
741
|
+
# Calculate average similarity for the group
|
|
742
|
+
total_sim = 0.0
|
|
743
|
+
pair_count = 0
|
|
744
|
+
for i_idx, i in enumerate(member_indices):
|
|
745
|
+
for j in member_indices[i_idx + 1 :]:
|
|
746
|
+
# Vector similarity
|
|
747
|
+
v1, v2 = vectors_array[i], vectors_array[j]
|
|
748
|
+
dot = float(np.dot(v1, v2))
|
|
749
|
+
norm1 = float(np.linalg.norm(v1))
|
|
750
|
+
norm2 = float(np.linalg.norm(v2))
|
|
751
|
+
if norm1 > 1e-10 and norm2 > 1e-10:
|
|
752
|
+
v_sim = dot / (norm1 * norm2)
|
|
753
|
+
else:
|
|
754
|
+
v_sim = 0.0
|
|
755
|
+
# Content similarity
|
|
756
|
+
c_sim = jaccard_similarity(contents[i], contents[j])
|
|
757
|
+
combined = combined_similarity(
|
|
758
|
+
v_sim, c_sim, self._config.consolidate_content_weight
|
|
759
|
+
)
|
|
760
|
+
total_sim += combined
|
|
761
|
+
pair_count += 1
|
|
762
|
+
avg_similarity = total_sim / pair_count if pair_count > 0 else 0.0
|
|
763
|
+
|
|
764
|
+
# Apply consolidation strategy
|
|
765
|
+
try:
|
|
766
|
+
action_result: ConsolidationAction = strategy_impl.apply(
|
|
767
|
+
members=group_member_dicts,
|
|
768
|
+
member_ids=group_member_ids,
|
|
769
|
+
namespace=namespace,
|
|
770
|
+
repository=self._repo,
|
|
771
|
+
embeddings=self._embeddings,
|
|
772
|
+
dry_run=dry_run,
|
|
773
|
+
)
|
|
774
|
+
memories_merged += action_result.memories_merged
|
|
775
|
+
memories_deleted += action_result.memories_deleted
|
|
776
|
+
except Exception as e:
|
|
777
|
+
logger.warning(f"Failed to consolidate group: {e}")
|
|
778
|
+
action_result = ConsolidationAction(
|
|
779
|
+
representative_id=group_member_ids[0],
|
|
780
|
+
deleted_ids=[],
|
|
781
|
+
action="failed",
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
result_groups.append(
|
|
785
|
+
ConsolidationGroupResult(
|
|
786
|
+
representative_id=action_result.representative_id,
|
|
787
|
+
member_ids=group_member_ids,
|
|
788
|
+
avg_similarity=avg_similarity,
|
|
789
|
+
action_taken=action_result.action,
|
|
790
|
+
)
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
return ConsolidateResult(
|
|
794
|
+
groups_found=len(group_indices),
|
|
795
|
+
memories_merged=memories_merged,
|
|
796
|
+
memories_deleted=memories_deleted,
|
|
797
|
+
groups=result_groups,
|
|
798
|
+
dry_run=dry_run,
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
except (ValidationError, ConsolidationError):
|
|
802
|
+
raise
|
|
803
|
+
except Exception as e:
|
|
804
|
+
raise ConsolidationError(f"Consolidation operation failed: {e}") from e
|
|
805
|
+
|
|
806
|
+
# =========================================================================
|
|
807
|
+
# Helper Methods
|
|
808
|
+
# =========================================================================
|
|
809
|
+
|
|
810
|
+
def _consolidate_chunked(
|
|
811
|
+
self,
|
|
812
|
+
namespace: str,
|
|
813
|
+
similarity_threshold: float,
|
|
814
|
+
strategy: Literal[
|
|
815
|
+
"keep_newest", "keep_oldest", "keep_highest_importance", "merge_content"
|
|
816
|
+
],
|
|
817
|
+
dry_run: bool,
|
|
818
|
+
max_groups: int,
|
|
819
|
+
chunk_size: int,
|
|
820
|
+
) -> ConsolidateResult:
|
|
821
|
+
"""Process consolidation in memory-efficient chunks.
|
|
822
|
+
|
|
823
|
+
Processes memories in smaller chunks to reduce peak memory usage.
|
|
824
|
+
Note: This may miss duplicates that span chunk boundaries.
|
|
825
|
+
|
|
826
|
+
Args:
|
|
827
|
+
namespace: Namespace to consolidate.
|
|
828
|
+
similarity_threshold: Minimum similarity for duplicates.
|
|
829
|
+
strategy: How to handle duplicates.
|
|
830
|
+
dry_run: Preview without changes.
|
|
831
|
+
max_groups: Maximum groups to process total.
|
|
832
|
+
chunk_size: Memories per chunk.
|
|
833
|
+
|
|
834
|
+
Returns:
|
|
835
|
+
Aggregated ConsolidateResult from all chunks.
|
|
836
|
+
"""
|
|
837
|
+
all_groups: list[ConsolidationGroupResult] = []
|
|
838
|
+
total_merged = 0
|
|
839
|
+
total_deleted = 0
|
|
840
|
+
offset = 0
|
|
841
|
+
groups_remaining = max_groups
|
|
842
|
+
|
|
843
|
+
while groups_remaining > 0:
|
|
844
|
+
# Fetch chunk of memories
|
|
845
|
+
chunk_memories = self._repo.get_all(
|
|
846
|
+
namespace=namespace,
|
|
847
|
+
limit=chunk_size,
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
# Skip already processed memories by filtering by offset
|
|
851
|
+
# Note: This is a simplified approach - in production, you'd want
|
|
852
|
+
# to track processed IDs or use cursor-based pagination
|
|
853
|
+
if offset > 0:
|
|
854
|
+
# Re-fetch with offset simulation (get more and skip)
|
|
855
|
+
all_chunk = self._repo.get_all(
|
|
856
|
+
namespace=namespace,
|
|
857
|
+
limit=offset + chunk_size,
|
|
858
|
+
)
|
|
859
|
+
if len(all_chunk) <= offset:
|
|
860
|
+
# No more memories to process
|
|
861
|
+
break
|
|
862
|
+
chunk_memories = all_chunk[offset:offset + chunk_size]
|
|
863
|
+
|
|
864
|
+
if len(chunk_memories) < 2:
|
|
865
|
+
break
|
|
866
|
+
|
|
867
|
+
# Build lookup structures for this chunk
|
|
868
|
+
memories = [m for m, _ in chunk_memories]
|
|
869
|
+
vectors_list = [v for _, v in chunk_memories]
|
|
870
|
+
vectors_array = np.array(vectors_list, dtype=np.float32)
|
|
871
|
+
memory_ids = [m.id for m in memories]
|
|
872
|
+
contents = [m.content for m in memories]
|
|
873
|
+
memory_dicts: list[dict[str, Any]] = [
|
|
874
|
+
{
|
|
875
|
+
"id": m.id,
|
|
876
|
+
"content": m.content,
|
|
877
|
+
"created_at": m.created_at,
|
|
878
|
+
"last_accessed": m.last_accessed,
|
|
879
|
+
"access_count": m.access_count,
|
|
880
|
+
"importance": m.importance,
|
|
881
|
+
"tags": list(m.tags),
|
|
882
|
+
}
|
|
883
|
+
for m in memories
|
|
884
|
+
]
|
|
885
|
+
|
|
886
|
+
# Find duplicate groups in this chunk
|
|
887
|
+
group_indices = find_duplicate_groups(
|
|
888
|
+
memory_ids=memory_ids,
|
|
889
|
+
vectors=vectors_array,
|
|
890
|
+
contents=contents,
|
|
891
|
+
threshold=similarity_threshold,
|
|
892
|
+
content_weight=self._config.consolidate_content_weight,
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
# Limit groups for this chunk
|
|
896
|
+
group_indices = group_indices[:groups_remaining]
|
|
897
|
+
|
|
898
|
+
if not group_indices:
|
|
899
|
+
offset += len(chunk_memories)
|
|
900
|
+
continue
|
|
901
|
+
|
|
902
|
+
# Get strategy implementation
|
|
903
|
+
strategy_impl = get_strategy(strategy)
|
|
904
|
+
|
|
905
|
+
# Process groups in this chunk
|
|
906
|
+
for member_indices in group_indices:
|
|
907
|
+
group_member_dicts = [memory_dicts[i] for i in member_indices]
|
|
908
|
+
group_member_ids = [str(d["id"]) for d in group_member_dicts]
|
|
909
|
+
|
|
910
|
+
# Calculate average similarity
|
|
911
|
+
total_sim = 0.0
|
|
912
|
+
pair_count = 0
|
|
913
|
+
for i_idx, i in enumerate(member_indices):
|
|
914
|
+
for j in member_indices[i_idx + 1:]:
|
|
915
|
+
v1, v2 = vectors_array[i], vectors_array[j]
|
|
916
|
+
dot = float(np.dot(v1, v2))
|
|
917
|
+
norm1 = float(np.linalg.norm(v1))
|
|
918
|
+
norm2 = float(np.linalg.norm(v2))
|
|
919
|
+
if norm1 > 1e-10 and norm2 > 1e-10:
|
|
920
|
+
v_sim = dot / (norm1 * norm2)
|
|
921
|
+
else:
|
|
922
|
+
v_sim = 0.0
|
|
923
|
+
c_sim = jaccard_similarity(contents[i], contents[j])
|
|
924
|
+
combined = combined_similarity(
|
|
925
|
+
v_sim, c_sim, self._config.consolidate_content_weight
|
|
926
|
+
)
|
|
927
|
+
total_sim += combined
|
|
928
|
+
pair_count += 1
|
|
929
|
+
avg_similarity = total_sim / pair_count if pair_count > 0 else 0.0
|
|
930
|
+
|
|
931
|
+
# Apply consolidation strategy
|
|
932
|
+
try:
|
|
933
|
+
action_result: ConsolidationAction = strategy_impl.apply(
|
|
934
|
+
members=group_member_dicts,
|
|
935
|
+
member_ids=group_member_ids,
|
|
936
|
+
namespace=namespace,
|
|
937
|
+
repository=self._repo,
|
|
938
|
+
embeddings=self._embeddings,
|
|
939
|
+
dry_run=dry_run,
|
|
940
|
+
)
|
|
941
|
+
total_merged += action_result.memories_merged
|
|
942
|
+
total_deleted += action_result.memories_deleted
|
|
943
|
+
except Exception as e:
|
|
944
|
+
logger.warning(f"Failed to consolidate group: {e}")
|
|
945
|
+
action_result = ConsolidationAction(
|
|
946
|
+
representative_id=group_member_ids[0],
|
|
947
|
+
deleted_ids=[],
|
|
948
|
+
action="failed",
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
all_groups.append(
|
|
952
|
+
ConsolidationGroupResult(
|
|
953
|
+
representative_id=action_result.representative_id,
|
|
954
|
+
member_ids=group_member_ids,
|
|
955
|
+
avg_similarity=avg_similarity,
|
|
956
|
+
action_taken=action_result.action,
|
|
957
|
+
)
|
|
958
|
+
)
|
|
959
|
+
groups_remaining -= 1
|
|
960
|
+
|
|
961
|
+
offset += len(chunk_memories)
|
|
962
|
+
logger.debug(
|
|
963
|
+
f"Processed chunk at offset {offset - len(chunk_memories)}, "
|
|
964
|
+
f"found {len(group_indices)} groups"
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
return ConsolidateResult(
|
|
968
|
+
groups_found=len(all_groups),
|
|
969
|
+
memories_merged=total_merged,
|
|
970
|
+
memories_deleted=total_deleted,
|
|
971
|
+
groups=all_groups,
|
|
972
|
+
dry_run=dry_run,
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
def _check_duplicate(
|
|
976
|
+
self,
|
|
977
|
+
content: str,
|
|
978
|
+
namespace: str,
|
|
979
|
+
threshold: float,
|
|
980
|
+
) -> bool:
|
|
981
|
+
"""Check if similar content already exists.
|
|
982
|
+
|
|
983
|
+
Args:
|
|
984
|
+
content: Content to check.
|
|
985
|
+
namespace: Namespace to search.
|
|
986
|
+
threshold: Similarity threshold.
|
|
987
|
+
|
|
988
|
+
Returns:
|
|
989
|
+
True if a similar memory exists.
|
|
990
|
+
"""
|
|
991
|
+
try:
|
|
992
|
+
# Generate embedding for content
|
|
993
|
+
vector = self._embeddings.embed(content)
|
|
994
|
+
|
|
995
|
+
# Search for similar memories
|
|
996
|
+
results = self._repo.search(vector, limit=5, namespace=namespace)
|
|
997
|
+
|
|
998
|
+
for result in results:
|
|
999
|
+
# Check vector similarity
|
|
1000
|
+
if result.similarity >= threshold:
|
|
1001
|
+
return True
|
|
1002
|
+
|
|
1003
|
+
# Also check content overlap
|
|
1004
|
+
content_sim = jaccard_similarity(content, result.content)
|
|
1005
|
+
combined = combined_similarity(
|
|
1006
|
+
result.similarity,
|
|
1007
|
+
content_sim,
|
|
1008
|
+
self._config.consolidate_content_weight,
|
|
1009
|
+
)
|
|
1010
|
+
if combined >= threshold:
|
|
1011
|
+
return True
|
|
1012
|
+
|
|
1013
|
+
return False
|
|
1014
|
+
|
|
1015
|
+
except Exception as e:
|
|
1016
|
+
logger.warning(f"Duplicate check failed: {e}")
|
|
1017
|
+
return False
|
|
1018
|
+
|
|
1019
|
+
def _store_extracted_memory(
|
|
1020
|
+
self,
|
|
1021
|
+
content: str,
|
|
1022
|
+
namespace: str,
|
|
1023
|
+
confidence: float,
|
|
1024
|
+
pattern_type: str,
|
|
1025
|
+
) -> str:
|
|
1026
|
+
"""Store an extracted memory.
|
|
1027
|
+
|
|
1028
|
+
Args:
|
|
1029
|
+
content: Memory content.
|
|
1030
|
+
namespace: Target namespace.
|
|
1031
|
+
confidence: Extraction confidence.
|
|
1032
|
+
pattern_type: Type of pattern matched.
|
|
1033
|
+
|
|
1034
|
+
Returns:
|
|
1035
|
+
The new memory's ID.
|
|
1036
|
+
"""
|
|
1037
|
+
# Generate embedding
|
|
1038
|
+
vector = self._embeddings.embed(content)
|
|
1039
|
+
|
|
1040
|
+
# Scale importance by confidence but keep lower than manual memories
|
|
1041
|
+
importance = self._config.extract_default_importance * confidence
|
|
1042
|
+
|
|
1043
|
+
# Create memory
|
|
1044
|
+
memory = Memory(
|
|
1045
|
+
id="", # Will be assigned
|
|
1046
|
+
content=content,
|
|
1047
|
+
namespace=namespace,
|
|
1048
|
+
tags=[f"extracted-{pattern_type}"],
|
|
1049
|
+
importance=importance,
|
|
1050
|
+
source=MemorySource.EXTRACTED,
|
|
1051
|
+
metadata={
|
|
1052
|
+
"extraction_confidence": confidence,
|
|
1053
|
+
"extraction_pattern": pattern_type,
|
|
1054
|
+
},
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
return self._repo.add(memory, vector)
|
|
1058
|
+
|
|
1059
|
+
def _check_duplicate_with_vector(
|
|
1060
|
+
self,
|
|
1061
|
+
content: str,
|
|
1062
|
+
vector: np.ndarray,
|
|
1063
|
+
namespace: str,
|
|
1064
|
+
threshold: float,
|
|
1065
|
+
) -> bool:
|
|
1066
|
+
"""Check if similar content already exists using pre-computed vector.
|
|
1067
|
+
|
|
1068
|
+
Args:
|
|
1069
|
+
content: Content to check.
|
|
1070
|
+
vector: Pre-computed embedding vector.
|
|
1071
|
+
namespace: Namespace to search.
|
|
1072
|
+
threshold: Similarity threshold.
|
|
1073
|
+
|
|
1074
|
+
Returns:
|
|
1075
|
+
True if a similar memory exists.
|
|
1076
|
+
"""
|
|
1077
|
+
try:
|
|
1078
|
+
# Search for similar memories using pre-computed vector
|
|
1079
|
+
results = self._repo.search(vector, limit=5, namespace=namespace)
|
|
1080
|
+
|
|
1081
|
+
for result in results:
|
|
1082
|
+
# Check vector similarity
|
|
1083
|
+
if result.similarity >= threshold:
|
|
1084
|
+
return True
|
|
1085
|
+
|
|
1086
|
+
# Also check content overlap
|
|
1087
|
+
content_sim = jaccard_similarity(content, result.content)
|
|
1088
|
+
combined = combined_similarity(
|
|
1089
|
+
result.similarity,
|
|
1090
|
+
content_sim,
|
|
1091
|
+
self._config.consolidate_content_weight,
|
|
1092
|
+
)
|
|
1093
|
+
if combined >= threshold:
|
|
1094
|
+
return True
|
|
1095
|
+
|
|
1096
|
+
return False
|
|
1097
|
+
|
|
1098
|
+
except Exception as e:
|
|
1099
|
+
logger.warning(f"Duplicate check failed: {e}")
|
|
1100
|
+
return False
|
|
1101
|
+
|
|
1102
|
+
def _store_extracted_memory_with_vector(
|
|
1103
|
+
self,
|
|
1104
|
+
content: str,
|
|
1105
|
+
vector: np.ndarray,
|
|
1106
|
+
namespace: str,
|
|
1107
|
+
confidence: float,
|
|
1108
|
+
pattern_type: str,
|
|
1109
|
+
) -> str:
|
|
1110
|
+
"""Store an extracted memory using pre-computed vector.
|
|
1111
|
+
|
|
1112
|
+
Args:
|
|
1113
|
+
content: Memory content.
|
|
1114
|
+
vector: Pre-computed embedding vector.
|
|
1115
|
+
namespace: Target namespace.
|
|
1116
|
+
confidence: Extraction confidence.
|
|
1117
|
+
pattern_type: Type of pattern matched.
|
|
1118
|
+
|
|
1119
|
+
Returns:
|
|
1120
|
+
The new memory's ID.
|
|
1121
|
+
"""
|
|
1122
|
+
# Scale importance by confidence but keep lower than manual memories
|
|
1123
|
+
importance = self._config.extract_default_importance * confidence
|
|
1124
|
+
|
|
1125
|
+
# Create memory
|
|
1126
|
+
memory = Memory(
|
|
1127
|
+
id="", # Will be assigned
|
|
1128
|
+
content=content,
|
|
1129
|
+
namespace=namespace,
|
|
1130
|
+
tags=[f"extracted-{pattern_type}"],
|
|
1131
|
+
importance=importance,
|
|
1132
|
+
source=MemorySource.EXTRACTED,
|
|
1133
|
+
metadata={
|
|
1134
|
+
"extraction_confidence": confidence,
|
|
1135
|
+
"extraction_pattern": pattern_type,
|
|
1136
|
+
},
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
return self._repo.add(memory, vector)
|