memorisdk 1.0.2__py3-none-any.whl → 2.0.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.
Potentially problematic release.
This version of memorisdk might be problematic. Click here for more details.
- memori/__init__.py +24 -8
- memori/agents/conscious_agent.py +252 -414
- memori/agents/memory_agent.py +487 -224
- memori/agents/retrieval_agent.py +491 -68
- memori/config/memory_manager.py +323 -0
- memori/core/conversation.py +393 -0
- memori/core/database.py +386 -371
- memori/core/memory.py +1683 -532
- memori/core/providers.py +217 -0
- memori/database/adapters/__init__.py +10 -0
- memori/database/adapters/mysql_adapter.py +331 -0
- memori/database/adapters/postgresql_adapter.py +291 -0
- memori/database/adapters/sqlite_adapter.py +229 -0
- memori/database/auto_creator.py +320 -0
- memori/database/connection_utils.py +207 -0
- memori/database/connectors/base_connector.py +283 -0
- memori/database/connectors/mysql_connector.py +240 -18
- memori/database/connectors/postgres_connector.py +277 -4
- memori/database/connectors/sqlite_connector.py +178 -3
- memori/database/models.py +400 -0
- memori/database/queries/base_queries.py +1 -1
- memori/database/queries/memory_queries.py +91 -2
- memori/database/query_translator.py +222 -0
- memori/database/schema_generators/__init__.py +7 -0
- memori/database/schema_generators/mysql_schema_generator.py +215 -0
- memori/database/search/__init__.py +8 -0
- memori/database/search/mysql_search_adapter.py +255 -0
- memori/database/search/sqlite_search_adapter.py +180 -0
- memori/database/search_service.py +700 -0
- memori/database/sqlalchemy_manager.py +888 -0
- memori/integrations/__init__.py +36 -11
- memori/integrations/litellm_integration.py +340 -6
- memori/integrations/openai_integration.py +506 -240
- memori/tools/memory_tool.py +94 -4
- memori/utils/input_validator.py +395 -0
- memori/utils/pydantic_models.py +138 -36
- memori/utils/query_builder.py +530 -0
- memori/utils/security_audit.py +594 -0
- memori/utils/security_integration.py +339 -0
- memori/utils/transaction_manager.py +547 -0
- {memorisdk-1.0.2.dist-info → memorisdk-2.0.1.dist-info}/METADATA +56 -23
- memorisdk-2.0.1.dist-info/RECORD +66 -0
- memori/scripts/llm_text.py +0 -50
- memorisdk-1.0.2.dist-info/RECORD +0 -44
- memorisdk-1.0.2.dist-info/entry_points.txt +0 -2
- {memorisdk-1.0.2.dist-info → memorisdk-2.0.1.dist-info}/WHEEL +0 -0
- {memorisdk-1.0.2.dist-info → memorisdk-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {memorisdk-1.0.2.dist-info → memorisdk-2.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLAlchemy-based search service for Memori v2.0
|
|
3
|
+
Provides cross-database full-text search capabilities
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from sqlalchemy import and_, desc, or_, text
|
|
11
|
+
from sqlalchemy.orm import Session
|
|
12
|
+
|
|
13
|
+
from .models import LongTermMemory, ShortTermMemory
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SearchService:
|
|
17
|
+
"""Cross-database search service using SQLAlchemy"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, session: Session, database_type: str):
|
|
20
|
+
self.session = session
|
|
21
|
+
self.database_type = database_type
|
|
22
|
+
|
|
23
|
+
def search_memories(
|
|
24
|
+
self,
|
|
25
|
+
query: str,
|
|
26
|
+
namespace: str = "default",
|
|
27
|
+
category_filter: Optional[List[str]] = None,
|
|
28
|
+
limit: int = 10,
|
|
29
|
+
memory_types: Optional[List[str]] = None,
|
|
30
|
+
) -> List[Dict[str, Any]]:
|
|
31
|
+
"""
|
|
32
|
+
Search memories across different database backends
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
query: Search query string
|
|
36
|
+
namespace: Memory namespace
|
|
37
|
+
category_filter: List of categories to filter by
|
|
38
|
+
limit: Maximum number of results
|
|
39
|
+
memory_types: Types of memory to search ('short_term', 'long_term', or both)
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
List of memory dictionaries with search metadata
|
|
43
|
+
"""
|
|
44
|
+
logger.debug(
|
|
45
|
+
f"SearchService.search_memories called - query: '{query}', namespace: '{namespace}', database: {self.database_type}, limit: {limit}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if not query or not query.strip():
|
|
49
|
+
logger.debug("Empty query provided, returning recent memories")
|
|
50
|
+
return self._get_recent_memories(
|
|
51
|
+
namespace, category_filter, limit, memory_types
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
results = []
|
|
55
|
+
|
|
56
|
+
# Determine which memory types to search
|
|
57
|
+
search_short_term = not memory_types or "short_term" in memory_types
|
|
58
|
+
search_long_term = not memory_types or "long_term" in memory_types
|
|
59
|
+
|
|
60
|
+
logger.debug(
|
|
61
|
+
f"Memory types to search - short_term: {search_short_term}, long_term: {search_long_term}, categories: {category_filter}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
# Try database-specific full-text search first
|
|
66
|
+
if self.database_type == "sqlite":
|
|
67
|
+
logger.debug("Using SQLite FTS5 search strategy")
|
|
68
|
+
results = self._search_sqlite_fts(
|
|
69
|
+
query,
|
|
70
|
+
namespace,
|
|
71
|
+
category_filter,
|
|
72
|
+
limit,
|
|
73
|
+
search_short_term,
|
|
74
|
+
search_long_term,
|
|
75
|
+
)
|
|
76
|
+
elif self.database_type == "mysql":
|
|
77
|
+
logger.debug("Using MySQL FULLTEXT search strategy")
|
|
78
|
+
results = self._search_mysql_fulltext(
|
|
79
|
+
query,
|
|
80
|
+
namespace,
|
|
81
|
+
category_filter,
|
|
82
|
+
limit,
|
|
83
|
+
search_short_term,
|
|
84
|
+
search_long_term,
|
|
85
|
+
)
|
|
86
|
+
elif self.database_type == "postgresql":
|
|
87
|
+
logger.debug("Using PostgreSQL FTS search strategy")
|
|
88
|
+
results = self._search_postgresql_fts(
|
|
89
|
+
query,
|
|
90
|
+
namespace,
|
|
91
|
+
category_filter,
|
|
92
|
+
limit,
|
|
93
|
+
search_short_term,
|
|
94
|
+
search_long_term,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
logger.debug(f"Primary search strategy returned {len(results)} results")
|
|
98
|
+
|
|
99
|
+
# If no results or full-text search failed, fall back to LIKE search
|
|
100
|
+
if not results:
|
|
101
|
+
logger.debug(
|
|
102
|
+
"Primary search returned no results, falling back to LIKE search"
|
|
103
|
+
)
|
|
104
|
+
results = self._search_like_fallback(
|
|
105
|
+
query,
|
|
106
|
+
namespace,
|
|
107
|
+
category_filter,
|
|
108
|
+
limit,
|
|
109
|
+
search_short_term,
|
|
110
|
+
search_long_term,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.error(
|
|
115
|
+
f"Full-text search failed for query '{query}' in namespace '{namespace}': {e}"
|
|
116
|
+
)
|
|
117
|
+
logger.debug(
|
|
118
|
+
f"Full-text search error details: {type(e).__name__}: {str(e)}",
|
|
119
|
+
exc_info=True,
|
|
120
|
+
)
|
|
121
|
+
logger.warning(f"Falling back to LIKE search for query '{query}'")
|
|
122
|
+
try:
|
|
123
|
+
results = self._search_like_fallback(
|
|
124
|
+
query,
|
|
125
|
+
namespace,
|
|
126
|
+
category_filter,
|
|
127
|
+
limit,
|
|
128
|
+
search_short_term,
|
|
129
|
+
search_long_term,
|
|
130
|
+
)
|
|
131
|
+
logger.debug(f"LIKE fallback search returned {len(results)} results")
|
|
132
|
+
except Exception as fallback_e:
|
|
133
|
+
logger.error(
|
|
134
|
+
f"LIKE fallback search also failed for query '{query}': {fallback_e}"
|
|
135
|
+
)
|
|
136
|
+
results = []
|
|
137
|
+
|
|
138
|
+
final_results = self._rank_and_limit_results(results, limit)
|
|
139
|
+
logger.debug(
|
|
140
|
+
f"SearchService completed - returning {len(final_results)} final results after ranking and limiting"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if final_results:
|
|
144
|
+
logger.debug(
|
|
145
|
+
f"Top result: memory_id={final_results[0].get('memory_id')}, score={final_results[0].get('composite_score', 0):.3f}, strategy={final_results[0].get('search_strategy')}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return final_results
|
|
149
|
+
|
|
150
|
+
def _search_sqlite_fts(
|
|
151
|
+
self,
|
|
152
|
+
query: str,
|
|
153
|
+
namespace: str,
|
|
154
|
+
category_filter: Optional[List[str]],
|
|
155
|
+
limit: int,
|
|
156
|
+
search_short_term: bool,
|
|
157
|
+
search_long_term: bool,
|
|
158
|
+
) -> List[Dict[str, Any]]:
|
|
159
|
+
"""Search using SQLite FTS5"""
|
|
160
|
+
try:
|
|
161
|
+
logger.debug(
|
|
162
|
+
f"SQLite FTS search starting for query: '{query}' in namespace: '{namespace}'"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Use parameters to validate search scope
|
|
166
|
+
if not search_short_term and not search_long_term:
|
|
167
|
+
logger.debug("No memory types specified for search, defaulting to both")
|
|
168
|
+
search_short_term = search_long_term = True
|
|
169
|
+
|
|
170
|
+
logger.debug(
|
|
171
|
+
f"Search scope - short_term: {search_short_term}, long_term: {search_long_term}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Build FTS query
|
|
175
|
+
fts_query = f'"{query.strip()}"'
|
|
176
|
+
logger.debug(f"FTS query built: {fts_query}")
|
|
177
|
+
|
|
178
|
+
# Build category filter
|
|
179
|
+
category_clause = ""
|
|
180
|
+
params = {"fts_query": fts_query, "namespace": namespace}
|
|
181
|
+
|
|
182
|
+
if category_filter:
|
|
183
|
+
category_placeholders = ",".join(
|
|
184
|
+
[f":cat_{i}" for i in range(len(category_filter))]
|
|
185
|
+
)
|
|
186
|
+
category_clause = (
|
|
187
|
+
f"AND fts.category_primary IN ({category_placeholders})"
|
|
188
|
+
)
|
|
189
|
+
for i, cat in enumerate(category_filter):
|
|
190
|
+
params[f"cat_{i}"] = cat
|
|
191
|
+
logger.debug(f"Category filter applied: {category_filter}")
|
|
192
|
+
|
|
193
|
+
# SQLite FTS5 search query with COALESCE to handle NULL values
|
|
194
|
+
sql_query = f"""
|
|
195
|
+
SELECT
|
|
196
|
+
fts.memory_id,
|
|
197
|
+
fts.memory_type,
|
|
198
|
+
fts.category_primary,
|
|
199
|
+
COALESCE(
|
|
200
|
+
CASE
|
|
201
|
+
WHEN fts.memory_type = 'short_term' THEN st.processed_data
|
|
202
|
+
WHEN fts.memory_type = 'long_term' THEN lt.processed_data
|
|
203
|
+
END,
|
|
204
|
+
'{{}}'
|
|
205
|
+
) as processed_data,
|
|
206
|
+
COALESCE(
|
|
207
|
+
CASE
|
|
208
|
+
WHEN fts.memory_type = 'short_term' THEN st.importance_score
|
|
209
|
+
WHEN fts.memory_type = 'long_term' THEN lt.importance_score
|
|
210
|
+
ELSE 0.5
|
|
211
|
+
END,
|
|
212
|
+
0.5
|
|
213
|
+
) as importance_score,
|
|
214
|
+
COALESCE(
|
|
215
|
+
CASE
|
|
216
|
+
WHEN fts.memory_type = 'short_term' THEN st.created_at
|
|
217
|
+
WHEN fts.memory_type = 'long_term' THEN lt.created_at
|
|
218
|
+
END,
|
|
219
|
+
datetime('now')
|
|
220
|
+
) as created_at,
|
|
221
|
+
COALESCE(fts.summary, '') as summary,
|
|
222
|
+
COALESCE(rank, 0.0) as search_score,
|
|
223
|
+
'sqlite_fts5' as search_strategy
|
|
224
|
+
FROM memory_search_fts fts
|
|
225
|
+
LEFT JOIN short_term_memory st ON fts.memory_id = st.memory_id AND fts.memory_type = 'short_term'
|
|
226
|
+
LEFT JOIN long_term_memory lt ON fts.memory_id = lt.memory_id AND fts.memory_type = 'long_term'
|
|
227
|
+
WHERE memory_search_fts MATCH :fts_query AND fts.namespace = :namespace
|
|
228
|
+
{category_clause}
|
|
229
|
+
ORDER BY search_score, importance_score DESC
|
|
230
|
+
LIMIT {limit}
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
logger.debug(f"Executing SQLite FTS query with params: {params}")
|
|
234
|
+
result = self.session.execute(text(sql_query), params)
|
|
235
|
+
rows = [dict(row) for row in result]
|
|
236
|
+
logger.debug(f"SQLite FTS search returned {len(rows)} results")
|
|
237
|
+
|
|
238
|
+
# Log details of first result for debugging
|
|
239
|
+
if rows:
|
|
240
|
+
logger.debug(
|
|
241
|
+
f"Sample result: memory_id={rows[0].get('memory_id')}, type={rows[0].get('memory_type')}, score={rows[0].get('search_score')}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return rows
|
|
245
|
+
|
|
246
|
+
except Exception as e:
|
|
247
|
+
logger.error(
|
|
248
|
+
f"SQLite FTS5 search failed for query '{query}' in namespace '{namespace}': {e}"
|
|
249
|
+
)
|
|
250
|
+
logger.debug(
|
|
251
|
+
f"SQLite FTS5 error details: {type(e).__name__}: {str(e)}",
|
|
252
|
+
exc_info=True,
|
|
253
|
+
)
|
|
254
|
+
# Roll back the transaction to recover from error state
|
|
255
|
+
self.session.rollback()
|
|
256
|
+
return []
|
|
257
|
+
|
|
258
|
+
def _search_mysql_fulltext(
|
|
259
|
+
self,
|
|
260
|
+
query: str,
|
|
261
|
+
namespace: str,
|
|
262
|
+
category_filter: Optional[List[str]],
|
|
263
|
+
limit: int,
|
|
264
|
+
search_short_term: bool,
|
|
265
|
+
search_long_term: bool,
|
|
266
|
+
) -> List[Dict[str, Any]]:
|
|
267
|
+
"""Search using MySQL FULLTEXT"""
|
|
268
|
+
results = []
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
# Apply limit proportionally between memory types
|
|
272
|
+
short_limit = (
|
|
273
|
+
limit // 2 if search_short_term and search_long_term else limit
|
|
274
|
+
)
|
|
275
|
+
long_limit = (
|
|
276
|
+
limit - short_limit if search_short_term and search_long_term else limit
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Search short-term memory if requested
|
|
280
|
+
if search_short_term:
|
|
281
|
+
short_query = self.session.query(ShortTermMemory).filter(
|
|
282
|
+
ShortTermMemory.namespace == namespace
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Add FULLTEXT search
|
|
286
|
+
fulltext_condition = text(
|
|
287
|
+
"MATCH(searchable_content, summary) AGAINST(:query IN NATURAL LANGUAGE MODE)"
|
|
288
|
+
).params(query=query)
|
|
289
|
+
short_query = short_query.filter(fulltext_condition)
|
|
290
|
+
|
|
291
|
+
# Add category filter
|
|
292
|
+
if category_filter:
|
|
293
|
+
short_query = short_query.filter(
|
|
294
|
+
ShortTermMemory.category_primary.in_(category_filter)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Add relevance score and limit
|
|
298
|
+
short_results = self.session.execute(
|
|
299
|
+
short_query.statement.add_columns(
|
|
300
|
+
text(
|
|
301
|
+
"MATCH(searchable_content, summary) AGAINST(:query IN NATURAL LANGUAGE MODE) as search_score"
|
|
302
|
+
).params(query=query),
|
|
303
|
+
text("'short_term' as memory_type"),
|
|
304
|
+
text("'mysql_fulltext' as search_strategy"),
|
|
305
|
+
).limit(short_limit)
|
|
306
|
+
).fetchall()
|
|
307
|
+
|
|
308
|
+
results.extend([dict(row) for row in short_results])
|
|
309
|
+
|
|
310
|
+
# Search long-term memory if requested
|
|
311
|
+
if search_long_term:
|
|
312
|
+
long_query = self.session.query(LongTermMemory).filter(
|
|
313
|
+
LongTermMemory.namespace == namespace
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Add FULLTEXT search
|
|
317
|
+
fulltext_condition = text(
|
|
318
|
+
"MATCH(searchable_content, summary) AGAINST(:query IN NATURAL LANGUAGE MODE)"
|
|
319
|
+
).params(query=query)
|
|
320
|
+
long_query = long_query.filter(fulltext_condition)
|
|
321
|
+
|
|
322
|
+
# Add category filter
|
|
323
|
+
if category_filter:
|
|
324
|
+
long_query = long_query.filter(
|
|
325
|
+
LongTermMemory.category_primary.in_(category_filter)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Add relevance score and limit
|
|
329
|
+
long_results = self.session.execute(
|
|
330
|
+
long_query.statement.add_columns(
|
|
331
|
+
text(
|
|
332
|
+
"MATCH(searchable_content, summary) AGAINST(:query IN NATURAL LANGUAGE MODE) as search_score"
|
|
333
|
+
).params(query=query),
|
|
334
|
+
text("'long_term' as memory_type"),
|
|
335
|
+
text("'mysql_fulltext' as search_strategy"),
|
|
336
|
+
).limit(long_limit)
|
|
337
|
+
).fetchall()
|
|
338
|
+
|
|
339
|
+
results.extend([dict(row) for row in long_results])
|
|
340
|
+
|
|
341
|
+
return results
|
|
342
|
+
|
|
343
|
+
except Exception as e:
|
|
344
|
+
logger.error(
|
|
345
|
+
f"MySQL FULLTEXT search failed for query '{query}' in namespace '{namespace}': {e}"
|
|
346
|
+
)
|
|
347
|
+
logger.debug(
|
|
348
|
+
f"MySQL FULLTEXT error details: {type(e).__name__}: {str(e)}",
|
|
349
|
+
exc_info=True,
|
|
350
|
+
)
|
|
351
|
+
# Roll back the transaction to recover from error state
|
|
352
|
+
self.session.rollback()
|
|
353
|
+
return []
|
|
354
|
+
|
|
355
|
+
def _search_postgresql_fts(
|
|
356
|
+
self,
|
|
357
|
+
query: str,
|
|
358
|
+
namespace: str,
|
|
359
|
+
category_filter: Optional[List[str]],
|
|
360
|
+
limit: int,
|
|
361
|
+
search_short_term: bool,
|
|
362
|
+
search_long_term: bool,
|
|
363
|
+
) -> List[Dict[str, Any]]:
|
|
364
|
+
"""Search using PostgreSQL tsvector"""
|
|
365
|
+
results = []
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
# Apply limit proportionally between memory types
|
|
369
|
+
short_limit = (
|
|
370
|
+
limit // 2 if search_short_term and search_long_term else limit
|
|
371
|
+
)
|
|
372
|
+
long_limit = (
|
|
373
|
+
limit - short_limit if search_short_term and search_long_term else limit
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# Prepare query for tsquery - handle spaces and special characters
|
|
377
|
+
# Convert simple query to tsquery format (join words with &)
|
|
378
|
+
tsquery_text = " & ".join(query.split())
|
|
379
|
+
|
|
380
|
+
# Search short-term memory if requested
|
|
381
|
+
if search_short_term:
|
|
382
|
+
short_query = self.session.query(ShortTermMemory).filter(
|
|
383
|
+
ShortTermMemory.namespace == namespace
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Add tsvector search
|
|
387
|
+
ts_query = text(
|
|
388
|
+
"search_vector @@ to_tsquery('english', :query)"
|
|
389
|
+
).params(query=tsquery_text)
|
|
390
|
+
short_query = short_query.filter(ts_query)
|
|
391
|
+
|
|
392
|
+
# Add category filter
|
|
393
|
+
if category_filter:
|
|
394
|
+
short_query = short_query.filter(
|
|
395
|
+
ShortTermMemory.category_primary.in_(category_filter)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Add relevance score and limit
|
|
399
|
+
short_results = self.session.execute(
|
|
400
|
+
short_query.statement.add_columns(
|
|
401
|
+
text(
|
|
402
|
+
"ts_rank(search_vector, to_tsquery('english', :query)) as search_score"
|
|
403
|
+
).params(query=tsquery_text),
|
|
404
|
+
text("'short_term' as memory_type"),
|
|
405
|
+
text("'postgresql_fts' as search_strategy"),
|
|
406
|
+
)
|
|
407
|
+
.order_by(text("search_score DESC"))
|
|
408
|
+
.limit(short_limit)
|
|
409
|
+
).fetchall()
|
|
410
|
+
|
|
411
|
+
results.extend([dict(row) for row in short_results])
|
|
412
|
+
|
|
413
|
+
# Search long-term memory if requested
|
|
414
|
+
if search_long_term:
|
|
415
|
+
long_query = self.session.query(LongTermMemory).filter(
|
|
416
|
+
LongTermMemory.namespace == namespace
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Add tsvector search
|
|
420
|
+
ts_query = text(
|
|
421
|
+
"search_vector @@ to_tsquery('english', :query)"
|
|
422
|
+
).params(query=tsquery_text)
|
|
423
|
+
long_query = long_query.filter(ts_query)
|
|
424
|
+
|
|
425
|
+
# Add category filter
|
|
426
|
+
if category_filter:
|
|
427
|
+
long_query = long_query.filter(
|
|
428
|
+
LongTermMemory.category_primary.in_(category_filter)
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Add relevance score and limit
|
|
432
|
+
long_results = self.session.execute(
|
|
433
|
+
long_query.statement.add_columns(
|
|
434
|
+
text(
|
|
435
|
+
"ts_rank(search_vector, to_tsquery('english', :query)) as search_score"
|
|
436
|
+
).params(query=tsquery_text),
|
|
437
|
+
text("'long_term' as memory_type"),
|
|
438
|
+
text("'postgresql_fts' as search_strategy"),
|
|
439
|
+
)
|
|
440
|
+
.order_by(text("search_score DESC"))
|
|
441
|
+
.limit(long_limit)
|
|
442
|
+
).fetchall()
|
|
443
|
+
|
|
444
|
+
results.extend([dict(row) for row in long_results])
|
|
445
|
+
|
|
446
|
+
return results
|
|
447
|
+
|
|
448
|
+
except Exception as e:
|
|
449
|
+
logger.error(
|
|
450
|
+
f"PostgreSQL FTS search failed for query '{query}' in namespace '{namespace}': {e}"
|
|
451
|
+
)
|
|
452
|
+
logger.debug(
|
|
453
|
+
f"PostgreSQL FTS error details: {type(e).__name__}: {str(e)}",
|
|
454
|
+
exc_info=True,
|
|
455
|
+
)
|
|
456
|
+
# Roll back the transaction to recover from error state
|
|
457
|
+
self.session.rollback()
|
|
458
|
+
return []
|
|
459
|
+
|
|
460
|
+
def _search_like_fallback(
|
|
461
|
+
self,
|
|
462
|
+
query: str,
|
|
463
|
+
namespace: str,
|
|
464
|
+
category_filter: Optional[List[str]],
|
|
465
|
+
limit: int,
|
|
466
|
+
search_short_term: bool,
|
|
467
|
+
search_long_term: bool,
|
|
468
|
+
) -> List[Dict[str, Any]]:
|
|
469
|
+
"""Fallback LIKE-based search with improved flexibility"""
|
|
470
|
+
logger.debug(
|
|
471
|
+
f"Starting LIKE fallback search for query: '{query}' in namespace: '{namespace}'"
|
|
472
|
+
)
|
|
473
|
+
results = []
|
|
474
|
+
|
|
475
|
+
# Create multiple search patterns for better matching
|
|
476
|
+
search_patterns = [
|
|
477
|
+
f"%{query}%", # Original full query
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
# Add individual word patterns for better matching
|
|
481
|
+
words = query.strip().split()
|
|
482
|
+
if len(words) > 1:
|
|
483
|
+
for word in words:
|
|
484
|
+
if len(word) > 2: # Skip very short words
|
|
485
|
+
search_patterns.append(f"%{word}%")
|
|
486
|
+
|
|
487
|
+
logger.debug(f"LIKE search patterns: {search_patterns}")
|
|
488
|
+
|
|
489
|
+
# Search short-term memory
|
|
490
|
+
if search_short_term:
|
|
491
|
+
# Build OR conditions for all search patterns
|
|
492
|
+
search_conditions = []
|
|
493
|
+
for pattern in search_patterns:
|
|
494
|
+
search_conditions.extend(
|
|
495
|
+
[
|
|
496
|
+
ShortTermMemory.searchable_content.like(pattern),
|
|
497
|
+
ShortTermMemory.summary.like(pattern),
|
|
498
|
+
]
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
short_query = self.session.query(ShortTermMemory).filter(
|
|
502
|
+
and_(
|
|
503
|
+
ShortTermMemory.namespace == namespace,
|
|
504
|
+
or_(*search_conditions),
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
if category_filter:
|
|
509
|
+
short_query = short_query.filter(
|
|
510
|
+
ShortTermMemory.category_primary.in_(category_filter)
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
short_results = (
|
|
514
|
+
short_query.order_by(
|
|
515
|
+
desc(ShortTermMemory.importance_score),
|
|
516
|
+
desc(ShortTermMemory.created_at),
|
|
517
|
+
)
|
|
518
|
+
.limit(limit)
|
|
519
|
+
.all()
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
logger.debug(f"LIKE fallback found {len(short_results)} short-term results")
|
|
523
|
+
|
|
524
|
+
for result in short_results:
|
|
525
|
+
memory_dict = {
|
|
526
|
+
"memory_id": result.memory_id,
|
|
527
|
+
"memory_type": "short_term",
|
|
528
|
+
"processed_data": result.processed_data,
|
|
529
|
+
"importance_score": result.importance_score,
|
|
530
|
+
"created_at": result.created_at,
|
|
531
|
+
"summary": result.summary,
|
|
532
|
+
"category_primary": result.category_primary,
|
|
533
|
+
"search_score": 0.4, # Fixed score for LIKE search
|
|
534
|
+
"search_strategy": f"{self.database_type}_like_fallback",
|
|
535
|
+
}
|
|
536
|
+
results.append(memory_dict)
|
|
537
|
+
|
|
538
|
+
# Search long-term memory
|
|
539
|
+
if search_long_term:
|
|
540
|
+
# Build OR conditions for all search patterns
|
|
541
|
+
search_conditions = []
|
|
542
|
+
for pattern in search_patterns:
|
|
543
|
+
search_conditions.extend(
|
|
544
|
+
[
|
|
545
|
+
LongTermMemory.searchable_content.like(pattern),
|
|
546
|
+
LongTermMemory.summary.like(pattern),
|
|
547
|
+
]
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
long_query = self.session.query(LongTermMemory).filter(
|
|
551
|
+
and_(
|
|
552
|
+
LongTermMemory.namespace == namespace,
|
|
553
|
+
or_(*search_conditions),
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
if category_filter:
|
|
558
|
+
long_query = long_query.filter(
|
|
559
|
+
LongTermMemory.category_primary.in_(category_filter)
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
long_results = (
|
|
563
|
+
long_query.order_by(
|
|
564
|
+
desc(LongTermMemory.importance_score),
|
|
565
|
+
desc(LongTermMemory.created_at),
|
|
566
|
+
)
|
|
567
|
+
.limit(limit)
|
|
568
|
+
.all()
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
logger.debug(f"LIKE fallback found {len(long_results)} long-term results")
|
|
572
|
+
|
|
573
|
+
for result in long_results:
|
|
574
|
+
memory_dict = {
|
|
575
|
+
"memory_id": result.memory_id,
|
|
576
|
+
"memory_type": "long_term",
|
|
577
|
+
"processed_data": result.processed_data,
|
|
578
|
+
"importance_score": result.importance_score,
|
|
579
|
+
"created_at": result.created_at,
|
|
580
|
+
"summary": result.summary,
|
|
581
|
+
"category_primary": result.category_primary,
|
|
582
|
+
"search_score": 0.4, # Fixed score for LIKE search
|
|
583
|
+
"search_strategy": f"{self.database_type}_like_fallback",
|
|
584
|
+
}
|
|
585
|
+
results.append(memory_dict)
|
|
586
|
+
|
|
587
|
+
logger.debug(
|
|
588
|
+
f"LIKE fallback search completed, returning {len(results)} total results"
|
|
589
|
+
)
|
|
590
|
+
return results
|
|
591
|
+
|
|
592
|
+
def _get_recent_memories(
|
|
593
|
+
self,
|
|
594
|
+
namespace: str,
|
|
595
|
+
category_filter: Optional[List[str]],
|
|
596
|
+
limit: int,
|
|
597
|
+
memory_types: Optional[List[str]],
|
|
598
|
+
) -> List[Dict[str, Any]]:
|
|
599
|
+
"""Get recent memories when no search query is provided"""
|
|
600
|
+
results = []
|
|
601
|
+
|
|
602
|
+
search_short_term = not memory_types or "short_term" in memory_types
|
|
603
|
+
search_long_term = not memory_types or "long_term" in memory_types
|
|
604
|
+
|
|
605
|
+
# Get recent short-term memories
|
|
606
|
+
if search_short_term:
|
|
607
|
+
short_query = self.session.query(ShortTermMemory).filter(
|
|
608
|
+
ShortTermMemory.namespace == namespace
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
if category_filter:
|
|
612
|
+
short_query = short_query.filter(
|
|
613
|
+
ShortTermMemory.category_primary.in_(category_filter)
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
short_results = (
|
|
617
|
+
short_query.order_by(desc(ShortTermMemory.created_at))
|
|
618
|
+
.limit(limit // 2)
|
|
619
|
+
.all()
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
for result in short_results:
|
|
623
|
+
memory_dict = {
|
|
624
|
+
"memory_id": result.memory_id,
|
|
625
|
+
"memory_type": "short_term",
|
|
626
|
+
"processed_data": result.processed_data,
|
|
627
|
+
"importance_score": result.importance_score,
|
|
628
|
+
"created_at": result.created_at,
|
|
629
|
+
"summary": result.summary,
|
|
630
|
+
"category_primary": result.category_primary,
|
|
631
|
+
"search_score": 1.0,
|
|
632
|
+
"search_strategy": "recent_memories",
|
|
633
|
+
}
|
|
634
|
+
results.append(memory_dict)
|
|
635
|
+
|
|
636
|
+
# Get recent long-term memories
|
|
637
|
+
if search_long_term:
|
|
638
|
+
long_query = self.session.query(LongTermMemory).filter(
|
|
639
|
+
LongTermMemory.namespace == namespace
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
if category_filter:
|
|
643
|
+
long_query = long_query.filter(
|
|
644
|
+
LongTermMemory.category_primary.in_(category_filter)
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
long_results = (
|
|
648
|
+
long_query.order_by(desc(LongTermMemory.created_at))
|
|
649
|
+
.limit(limit // 2)
|
|
650
|
+
.all()
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
for result in long_results:
|
|
654
|
+
memory_dict = {
|
|
655
|
+
"memory_id": result.memory_id,
|
|
656
|
+
"memory_type": "long_term",
|
|
657
|
+
"processed_data": result.processed_data,
|
|
658
|
+
"importance_score": result.importance_score,
|
|
659
|
+
"created_at": result.created_at,
|
|
660
|
+
"summary": result.summary,
|
|
661
|
+
"category_primary": result.category_primary,
|
|
662
|
+
"search_score": 1.0,
|
|
663
|
+
"search_strategy": "recent_memories",
|
|
664
|
+
}
|
|
665
|
+
results.append(memory_dict)
|
|
666
|
+
|
|
667
|
+
return results
|
|
668
|
+
|
|
669
|
+
def _rank_and_limit_results(
|
|
670
|
+
self, results: List[Dict[str, Any]], limit: int
|
|
671
|
+
) -> List[Dict[str, Any]]:
|
|
672
|
+
"""Rank and limit search results"""
|
|
673
|
+
# Calculate composite score
|
|
674
|
+
for result in results:
|
|
675
|
+
search_score = result.get("search_score", 0.4)
|
|
676
|
+
importance_score = result.get("importance_score", 0.5)
|
|
677
|
+
recency_score = self._calculate_recency_score(result.get("created_at"))
|
|
678
|
+
|
|
679
|
+
# Weighted composite score
|
|
680
|
+
result["composite_score"] = (
|
|
681
|
+
search_score * 0.5 + importance_score * 0.3 + recency_score * 0.2
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
# Sort by composite score and limit
|
|
685
|
+
results.sort(key=lambda x: x.get("composite_score", 0), reverse=True)
|
|
686
|
+
return results[:limit]
|
|
687
|
+
|
|
688
|
+
def _calculate_recency_score(self, created_at) -> float:
|
|
689
|
+
"""Calculate recency score (0-1, newer = higher)"""
|
|
690
|
+
try:
|
|
691
|
+
if not created_at:
|
|
692
|
+
return 0.0
|
|
693
|
+
|
|
694
|
+
if isinstance(created_at, str):
|
|
695
|
+
created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
|
696
|
+
|
|
697
|
+
days_old = (datetime.now() - created_at).days
|
|
698
|
+
return max(0, 1 - (days_old / 30)) # Full score for recent, 0 after 30 days
|
|
699
|
+
except:
|
|
700
|
+
return 0.0
|