memorisdk 1.0.1__py3-none-any.whl → 2.0.0__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 +416 -60
- memori/config/memory_manager.py +323 -0
- memori/core/conversation.py +393 -0
- memori/core/database.py +386 -371
- memori/core/memory.py +1676 -534
- 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 +548 -0
- memori/database/sqlalchemy_manager.py +839 -0
- memori/integrations/__init__.py +36 -11
- memori/integrations/litellm_integration.py +340 -6
- memori/integrations/openai_integration.py +506 -240
- 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.1.dist-info → memorisdk-2.0.0.dist-info}/METADATA +144 -34
- memorisdk-2.0.0.dist-info/RECORD +67 -0
- memorisdk-1.0.1.dist-info/RECORD +0 -44
- memorisdk-1.0.1.dist-info/entry_points.txt +0 -2
- {memorisdk-1.0.1.dist-info → memorisdk-2.0.0.dist-info}/WHEEL +0 -0
- {memorisdk-1.0.1.dist-info → memorisdk-2.0.0.dist-info}/licenses/LICENSE +0 -0
- {memorisdk-1.0.1.dist-info → memorisdk-2.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLAlchemy-based database manager for Memori v2.0
|
|
3
|
+
Replaces the existing database.py with cross-database compatibility
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import importlib.util
|
|
7
|
+
import json
|
|
8
|
+
import ssl
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
from urllib.parse import parse_qs, urlparse
|
|
14
|
+
|
|
15
|
+
from loguru import logger
|
|
16
|
+
from sqlalchemy import create_engine, func, text
|
|
17
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
18
|
+
from sqlalchemy.orm import sessionmaker
|
|
19
|
+
|
|
20
|
+
from ..utils.exceptions import DatabaseError
|
|
21
|
+
from ..utils.pydantic_models import (
|
|
22
|
+
ProcessedLongTermMemory,
|
|
23
|
+
)
|
|
24
|
+
from .auto_creator import DatabaseAutoCreator
|
|
25
|
+
from .models import (
|
|
26
|
+
Base,
|
|
27
|
+
ChatHistory,
|
|
28
|
+
LongTermMemory,
|
|
29
|
+
ShortTermMemory,
|
|
30
|
+
)
|
|
31
|
+
from .query_translator import QueryParameterTranslator
|
|
32
|
+
from .search_service import SearchService
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SQLAlchemyDatabaseManager:
|
|
36
|
+
"""SQLAlchemy-based database manager with cross-database support"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self, database_connect: str, template: str = "basic", schema_init: bool = True
|
|
40
|
+
):
|
|
41
|
+
self.database_connect = database_connect
|
|
42
|
+
self.template = template
|
|
43
|
+
self.schema_init = schema_init
|
|
44
|
+
|
|
45
|
+
# Initialize database auto-creator
|
|
46
|
+
self.auto_creator = DatabaseAutoCreator(schema_init)
|
|
47
|
+
|
|
48
|
+
# Ensure database exists (create if necessary)
|
|
49
|
+
self.database_connect = self.auto_creator.ensure_database_exists(
|
|
50
|
+
database_connect
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Parse connection string and create engine
|
|
54
|
+
self.engine = self._create_engine(self.database_connect)
|
|
55
|
+
self.database_type = self.engine.dialect.name
|
|
56
|
+
|
|
57
|
+
# Create session factory
|
|
58
|
+
self.SessionLocal = sessionmaker(bind=self.engine)
|
|
59
|
+
|
|
60
|
+
# Initialize search service
|
|
61
|
+
self._search_service = None
|
|
62
|
+
|
|
63
|
+
# Initialize query parameter translator for cross-database compatibility
|
|
64
|
+
self.query_translator = QueryParameterTranslator(self.database_type)
|
|
65
|
+
|
|
66
|
+
logger.info(f"Initialized SQLAlchemy database manager for {self.database_type}")
|
|
67
|
+
|
|
68
|
+
def _validate_database_dependencies(self, database_connect: str):
|
|
69
|
+
"""Validate that required database drivers are installed"""
|
|
70
|
+
if database_connect.startswith("mysql:") or database_connect.startswith(
|
|
71
|
+
"mysql+"
|
|
72
|
+
):
|
|
73
|
+
# Check for MySQL drivers
|
|
74
|
+
mysql_drivers = []
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
"mysqlconnector" in database_connect
|
|
78
|
+
or "mysql+mysqlconnector" in database_connect
|
|
79
|
+
):
|
|
80
|
+
if importlib.util.find_spec("mysql.connector") is not None:
|
|
81
|
+
mysql_drivers.append("mysql-connector-python")
|
|
82
|
+
|
|
83
|
+
if "pymysql" in database_connect:
|
|
84
|
+
if importlib.util.find_spec("pymysql") is not None:
|
|
85
|
+
mysql_drivers.append("PyMySQL")
|
|
86
|
+
|
|
87
|
+
# If using generic mysql:// try both drivers
|
|
88
|
+
if database_connect.startswith("mysql://"):
|
|
89
|
+
if importlib.util.find_spec("mysql.connector") is not None:
|
|
90
|
+
mysql_drivers.append("mysql-connector-python")
|
|
91
|
+
if importlib.util.find_spec("pymysql") is not None:
|
|
92
|
+
mysql_drivers.append("PyMySQL")
|
|
93
|
+
|
|
94
|
+
if not mysql_drivers:
|
|
95
|
+
error_msg = (
|
|
96
|
+
"❌ No MySQL driver found. Install one of the following:\n\n"
|
|
97
|
+
"Option 1 (Recommended): pip install mysql-connector-python\n"
|
|
98
|
+
"Option 2: pip install PyMySQL\n"
|
|
99
|
+
"Option 3: pip install memorisdk[mysql]\n\n"
|
|
100
|
+
"Then update your connection string:\n"
|
|
101
|
+
"- For mysql-connector-python: mysql+mysqlconnector://user:pass@host:port/db\n"
|
|
102
|
+
"- For PyMySQL: mysql+pymysql://user:pass@host:port/db"
|
|
103
|
+
)
|
|
104
|
+
raise DatabaseError(error_msg)
|
|
105
|
+
|
|
106
|
+
elif database_connect.startswith("postgresql:") or database_connect.startswith(
|
|
107
|
+
"postgresql+"
|
|
108
|
+
):
|
|
109
|
+
# Check for PostgreSQL drivers
|
|
110
|
+
if (
|
|
111
|
+
importlib.util.find_spec("psycopg2") is None
|
|
112
|
+
and importlib.util.find_spec("asyncpg") is None
|
|
113
|
+
):
|
|
114
|
+
error_msg = (
|
|
115
|
+
"❌ No PostgreSQL driver found. Install one of the following:\n\n"
|
|
116
|
+
"Option 1 (Recommended): pip install psycopg2-binary\n"
|
|
117
|
+
"Option 2: pip install memorisdk[postgres]\n\n"
|
|
118
|
+
"Then use connection string: postgresql://user:pass@host:port/db"
|
|
119
|
+
)
|
|
120
|
+
raise DatabaseError(error_msg)
|
|
121
|
+
|
|
122
|
+
def _create_engine(self, database_connect: str):
|
|
123
|
+
"""Create SQLAlchemy engine with appropriate configuration"""
|
|
124
|
+
try:
|
|
125
|
+
# Validate database driver dependencies first
|
|
126
|
+
self._validate_database_dependencies(database_connect)
|
|
127
|
+
# Parse connection string
|
|
128
|
+
if database_connect.startswith("sqlite:"):
|
|
129
|
+
# Ensure directory exists for SQLite
|
|
130
|
+
if ":///" in database_connect:
|
|
131
|
+
db_path = database_connect.replace("sqlite:///", "")
|
|
132
|
+
db_dir = Path(db_path).parent
|
|
133
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
|
|
135
|
+
# SQLite-specific configuration
|
|
136
|
+
engine = create_engine(
|
|
137
|
+
database_connect,
|
|
138
|
+
json_serializer=json.dumps,
|
|
139
|
+
json_deserializer=json.loads,
|
|
140
|
+
echo=False,
|
|
141
|
+
# SQLite-specific options
|
|
142
|
+
connect_args={
|
|
143
|
+
"check_same_thread": False, # Allow multiple threads
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
elif database_connect.startswith("mysql:") or database_connect.startswith(
|
|
148
|
+
"mysql+"
|
|
149
|
+
):
|
|
150
|
+
# MySQL-specific configuration
|
|
151
|
+
connect_args = {"charset": "utf8mb4"}
|
|
152
|
+
|
|
153
|
+
# Parse URL for SSL parameters
|
|
154
|
+
parsed = urlparse(database_connect)
|
|
155
|
+
if parsed.query:
|
|
156
|
+
query_params = parse_qs(parsed.query)
|
|
157
|
+
|
|
158
|
+
# Handle SSL parameters for PyMySQL - enforce secure transport
|
|
159
|
+
if any(key in query_params for key in ["ssl", "ssl_disabled"]):
|
|
160
|
+
if query_params.get("ssl", ["false"])[0].lower() == "true":
|
|
161
|
+
# Enable SSL with secure configuration for required secure transport
|
|
162
|
+
connect_args["ssl"] = {
|
|
163
|
+
"ssl_disabled": False,
|
|
164
|
+
"check_hostname": False,
|
|
165
|
+
"verify_mode": ssl.CERT_NONE,
|
|
166
|
+
}
|
|
167
|
+
# Also add ssl_disabled=False for PyMySQL
|
|
168
|
+
connect_args["ssl_disabled"] = False
|
|
169
|
+
elif (
|
|
170
|
+
query_params.get("ssl_disabled", ["true"])[0].lower()
|
|
171
|
+
== "false"
|
|
172
|
+
):
|
|
173
|
+
# Enable SSL with secure configuration for required secure transport
|
|
174
|
+
connect_args["ssl"] = {
|
|
175
|
+
"ssl_disabled": False,
|
|
176
|
+
"check_hostname": False,
|
|
177
|
+
"verify_mode": ssl.CERT_NONE,
|
|
178
|
+
}
|
|
179
|
+
# Also add ssl_disabled=False for PyMySQL
|
|
180
|
+
connect_args["ssl_disabled"] = False
|
|
181
|
+
|
|
182
|
+
# Different args for different MySQL drivers
|
|
183
|
+
if "pymysql" in database_connect:
|
|
184
|
+
# PyMySQL-specific arguments
|
|
185
|
+
connect_args.update(
|
|
186
|
+
{
|
|
187
|
+
"charset": "utf8mb4",
|
|
188
|
+
"autocommit": False,
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
elif (
|
|
192
|
+
"mysqlconnector" in database_connect
|
|
193
|
+
or "mysql+mysqlconnector" in database_connect
|
|
194
|
+
):
|
|
195
|
+
# MySQL Connector/Python-specific arguments
|
|
196
|
+
connect_args.update(
|
|
197
|
+
{
|
|
198
|
+
"charset": "utf8mb4",
|
|
199
|
+
"use_pure": True,
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
engine = create_engine(
|
|
204
|
+
database_connect,
|
|
205
|
+
json_serializer=json.dumps,
|
|
206
|
+
json_deserializer=json.loads,
|
|
207
|
+
echo=False,
|
|
208
|
+
connect_args=connect_args,
|
|
209
|
+
pool_pre_ping=True, # Validate connections
|
|
210
|
+
pool_recycle=3600, # Recycle connections every hour
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
elif database_connect.startswith(
|
|
214
|
+
"postgresql:"
|
|
215
|
+
) or database_connect.startswith("postgresql+"):
|
|
216
|
+
# PostgreSQL-specific configuration
|
|
217
|
+
engine = create_engine(
|
|
218
|
+
database_connect,
|
|
219
|
+
json_serializer=json.dumps,
|
|
220
|
+
json_deserializer=json.loads,
|
|
221
|
+
echo=False,
|
|
222
|
+
pool_pre_ping=True,
|
|
223
|
+
pool_recycle=3600,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
else:
|
|
227
|
+
raise DatabaseError(f"Unsupported database type: {database_connect}")
|
|
228
|
+
|
|
229
|
+
# Test connection
|
|
230
|
+
with engine.connect() as conn:
|
|
231
|
+
conn.execute(text("SELECT 1"))
|
|
232
|
+
|
|
233
|
+
return engine
|
|
234
|
+
|
|
235
|
+
except DatabaseError:
|
|
236
|
+
# Re-raise our custom database errors with helpful messages
|
|
237
|
+
raise
|
|
238
|
+
except ModuleNotFoundError as e:
|
|
239
|
+
if "mysql" in str(e).lower():
|
|
240
|
+
error_msg = (
|
|
241
|
+
"❌ MySQL driver not found. Install one of the following:\n\n"
|
|
242
|
+
"Option 1 (Recommended): pip install mysql-connector-python\n"
|
|
243
|
+
"Option 2: pip install PyMySQL\n"
|
|
244
|
+
"Option 3: pip install memorisdk[mysql]\n\n"
|
|
245
|
+
f"Original error: {e}"
|
|
246
|
+
)
|
|
247
|
+
raise DatabaseError(error_msg)
|
|
248
|
+
elif "psycopg" in str(e).lower() or "postgresql" in str(e).lower():
|
|
249
|
+
error_msg = (
|
|
250
|
+
"❌ PostgreSQL driver not found. Install one of the following:\n\n"
|
|
251
|
+
"Option 1 (Recommended): pip install psycopg2-binary\n"
|
|
252
|
+
"Option 2: pip install memorisdk[postgres]\n\n"
|
|
253
|
+
f"Original error: {e}"
|
|
254
|
+
)
|
|
255
|
+
raise DatabaseError(error_msg)
|
|
256
|
+
else:
|
|
257
|
+
raise DatabaseError(f"Missing required dependency: {e}")
|
|
258
|
+
except SQLAlchemyError as e:
|
|
259
|
+
error_msg = f"Database connection failed: {e}\n\nCheck your connection string and ensure the database server is running."
|
|
260
|
+
raise DatabaseError(error_msg)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
raise DatabaseError(f"Failed to create database engine: {e}")
|
|
263
|
+
|
|
264
|
+
def initialize_schema(self):
|
|
265
|
+
"""Initialize database schema"""
|
|
266
|
+
try:
|
|
267
|
+
# Create all tables
|
|
268
|
+
Base.metadata.create_all(bind=self.engine)
|
|
269
|
+
|
|
270
|
+
# Setup database-specific features
|
|
271
|
+
self._setup_database_features()
|
|
272
|
+
|
|
273
|
+
logger.info(
|
|
274
|
+
f"Database schema initialized successfully for {self.database_type}"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
logger.error(f"Failed to initialize schema: {e}")
|
|
279
|
+
raise DatabaseError(f"Failed to initialize schema: {e}")
|
|
280
|
+
|
|
281
|
+
def _setup_database_features(self):
|
|
282
|
+
"""Setup database-specific features like full-text search"""
|
|
283
|
+
try:
|
|
284
|
+
with self.engine.connect() as conn:
|
|
285
|
+
if self.database_type == "sqlite":
|
|
286
|
+
self._setup_sqlite_fts(conn)
|
|
287
|
+
elif self.database_type == "mysql":
|
|
288
|
+
self._setup_mysql_fulltext(conn)
|
|
289
|
+
elif self.database_type == "postgresql":
|
|
290
|
+
self._setup_postgresql_fts(conn)
|
|
291
|
+
|
|
292
|
+
conn.commit()
|
|
293
|
+
|
|
294
|
+
except Exception as e:
|
|
295
|
+
logger.warning(f"Failed to setup database-specific features: {e}")
|
|
296
|
+
|
|
297
|
+
def _setup_sqlite_fts(self, conn):
|
|
298
|
+
"""Setup SQLite FTS5"""
|
|
299
|
+
try:
|
|
300
|
+
# Create FTS5 virtual table
|
|
301
|
+
conn.execute(
|
|
302
|
+
text(
|
|
303
|
+
"""
|
|
304
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_search_fts USING fts5(
|
|
305
|
+
memory_id,
|
|
306
|
+
memory_type,
|
|
307
|
+
namespace,
|
|
308
|
+
searchable_content,
|
|
309
|
+
summary,
|
|
310
|
+
category_primary,
|
|
311
|
+
content='',
|
|
312
|
+
contentless_delete=1
|
|
313
|
+
)
|
|
314
|
+
"""
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Create triggers
|
|
319
|
+
conn.execute(
|
|
320
|
+
text(
|
|
321
|
+
"""
|
|
322
|
+
CREATE TRIGGER IF NOT EXISTS short_term_memory_fts_insert AFTER INSERT ON short_term_memory
|
|
323
|
+
BEGIN
|
|
324
|
+
INSERT INTO memory_search_fts(memory_id, memory_type, namespace, searchable_content, summary, category_primary)
|
|
325
|
+
VALUES (NEW.memory_id, 'short_term', NEW.namespace, NEW.searchable_content, NEW.summary, NEW.category_primary);
|
|
326
|
+
END
|
|
327
|
+
"""
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
conn.execute(
|
|
332
|
+
text(
|
|
333
|
+
"""
|
|
334
|
+
CREATE TRIGGER IF NOT EXISTS long_term_memory_fts_insert AFTER INSERT ON long_term_memory
|
|
335
|
+
BEGIN
|
|
336
|
+
INSERT INTO memory_search_fts(memory_id, memory_type, namespace, searchable_content, summary, category_primary)
|
|
337
|
+
VALUES (NEW.memory_id, 'long_term', NEW.namespace, NEW.searchable_content, NEW.summary, NEW.category_primary);
|
|
338
|
+
END
|
|
339
|
+
"""
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
logger.info("SQLite FTS5 setup completed")
|
|
344
|
+
|
|
345
|
+
except Exception as e:
|
|
346
|
+
logger.warning(f"SQLite FTS5 setup failed: {e}")
|
|
347
|
+
|
|
348
|
+
def _setup_mysql_fulltext(self, conn):
|
|
349
|
+
"""Setup MySQL FULLTEXT indexes"""
|
|
350
|
+
try:
|
|
351
|
+
# Create FULLTEXT indexes
|
|
352
|
+
conn.execute(
|
|
353
|
+
text(
|
|
354
|
+
"ALTER TABLE short_term_memory ADD FULLTEXT INDEX ft_short_term_search (searchable_content, summary)"
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
conn.execute(
|
|
358
|
+
text(
|
|
359
|
+
"ALTER TABLE long_term_memory ADD FULLTEXT INDEX ft_long_term_search (searchable_content, summary)"
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
logger.info("MySQL FULLTEXT indexes setup completed")
|
|
364
|
+
|
|
365
|
+
except Exception as e:
|
|
366
|
+
logger.warning(
|
|
367
|
+
f"MySQL FULLTEXT setup failed (indexes may already exist): {e}"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def _setup_postgresql_fts(self, conn):
|
|
371
|
+
"""Setup PostgreSQL full-text search"""
|
|
372
|
+
try:
|
|
373
|
+
# Add tsvector columns
|
|
374
|
+
conn.execute(
|
|
375
|
+
text(
|
|
376
|
+
"ALTER TABLE short_term_memory ADD COLUMN IF NOT EXISTS search_vector tsvector"
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
conn.execute(
|
|
380
|
+
text(
|
|
381
|
+
"ALTER TABLE long_term_memory ADD COLUMN IF NOT EXISTS search_vector tsvector"
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Create GIN indexes
|
|
386
|
+
conn.execute(
|
|
387
|
+
text(
|
|
388
|
+
"CREATE INDEX IF NOT EXISTS idx_short_term_search_vector ON short_term_memory USING GIN(search_vector)"
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
conn.execute(
|
|
392
|
+
text(
|
|
393
|
+
"CREATE INDEX IF NOT EXISTS idx_long_term_search_vector ON long_term_memory USING GIN(search_vector)"
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Create update functions and triggers
|
|
398
|
+
conn.execute(
|
|
399
|
+
text(
|
|
400
|
+
"""
|
|
401
|
+
CREATE OR REPLACE FUNCTION update_short_term_search_vector() RETURNS trigger AS $$
|
|
402
|
+
BEGIN
|
|
403
|
+
NEW.search_vector := to_tsvector('english', COALESCE(NEW.searchable_content, '') || ' ' || COALESCE(NEW.summary, ''));
|
|
404
|
+
RETURN NEW;
|
|
405
|
+
END
|
|
406
|
+
$$ LANGUAGE plpgsql;
|
|
407
|
+
"""
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
conn.execute(
|
|
412
|
+
text(
|
|
413
|
+
"""
|
|
414
|
+
DROP TRIGGER IF EXISTS update_short_term_search_vector_trigger ON short_term_memory;
|
|
415
|
+
CREATE TRIGGER update_short_term_search_vector_trigger
|
|
416
|
+
BEFORE INSERT OR UPDATE ON short_term_memory
|
|
417
|
+
FOR EACH ROW EXECUTE FUNCTION update_short_term_search_vector();
|
|
418
|
+
"""
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
logger.info("PostgreSQL FTS setup completed")
|
|
423
|
+
|
|
424
|
+
except Exception as e:
|
|
425
|
+
logger.warning(f"PostgreSQL FTS setup failed: {e}")
|
|
426
|
+
|
|
427
|
+
def _get_search_service(self) -> SearchService:
|
|
428
|
+
"""Get search service instance with fresh session"""
|
|
429
|
+
# Always create a new session to avoid stale connections
|
|
430
|
+
session = self.SessionLocal()
|
|
431
|
+
return SearchService(session, self.database_type)
|
|
432
|
+
|
|
433
|
+
def store_chat_history(
|
|
434
|
+
self,
|
|
435
|
+
chat_id: str,
|
|
436
|
+
user_input: str,
|
|
437
|
+
ai_output: str,
|
|
438
|
+
model: str,
|
|
439
|
+
timestamp: datetime,
|
|
440
|
+
session_id: str,
|
|
441
|
+
namespace: str = "default",
|
|
442
|
+
tokens_used: int = 0,
|
|
443
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
444
|
+
):
|
|
445
|
+
"""Store chat history"""
|
|
446
|
+
with self.SessionLocal() as session:
|
|
447
|
+
try:
|
|
448
|
+
chat_history = ChatHistory(
|
|
449
|
+
chat_id=chat_id,
|
|
450
|
+
user_input=user_input,
|
|
451
|
+
ai_output=ai_output,
|
|
452
|
+
model=model,
|
|
453
|
+
timestamp=timestamp,
|
|
454
|
+
session_id=session_id,
|
|
455
|
+
namespace=namespace,
|
|
456
|
+
tokens_used=tokens_used,
|
|
457
|
+
metadata_json=metadata or {},
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
session.merge(chat_history) # Use merge for INSERT OR REPLACE behavior
|
|
461
|
+
session.commit()
|
|
462
|
+
|
|
463
|
+
except SQLAlchemyError as e:
|
|
464
|
+
session.rollback()
|
|
465
|
+
raise DatabaseError(f"Failed to store chat history: {e}")
|
|
466
|
+
|
|
467
|
+
def get_chat_history(
|
|
468
|
+
self,
|
|
469
|
+
namespace: str = "default",
|
|
470
|
+
session_id: Optional[str] = None,
|
|
471
|
+
limit: int = 10,
|
|
472
|
+
) -> List[Dict[str, Any]]:
|
|
473
|
+
"""Get chat history with optional session filtering"""
|
|
474
|
+
with self.SessionLocal() as session:
|
|
475
|
+
try:
|
|
476
|
+
query = session.query(ChatHistory).filter(
|
|
477
|
+
ChatHistory.namespace == namespace
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
if session_id:
|
|
481
|
+
query = query.filter(ChatHistory.session_id == session_id)
|
|
482
|
+
|
|
483
|
+
results = (
|
|
484
|
+
query.order_by(ChatHistory.timestamp.desc()).limit(limit).all()
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# Convert to dictionaries
|
|
488
|
+
return [
|
|
489
|
+
{
|
|
490
|
+
"chat_id": result.chat_id,
|
|
491
|
+
"user_input": result.user_input,
|
|
492
|
+
"ai_output": result.ai_output,
|
|
493
|
+
"model": result.model,
|
|
494
|
+
"timestamp": result.timestamp,
|
|
495
|
+
"session_id": result.session_id,
|
|
496
|
+
"namespace": result.namespace,
|
|
497
|
+
"tokens_used": result.tokens_used,
|
|
498
|
+
"metadata": result.metadata_json or {},
|
|
499
|
+
}
|
|
500
|
+
for result in results
|
|
501
|
+
]
|
|
502
|
+
|
|
503
|
+
except SQLAlchemyError as e:
|
|
504
|
+
raise DatabaseError(f"Failed to get chat history: {e}")
|
|
505
|
+
|
|
506
|
+
def store_long_term_memory_enhanced(
|
|
507
|
+
self, memory: ProcessedLongTermMemory, chat_id: str, namespace: str = "default"
|
|
508
|
+
) -> str:
|
|
509
|
+
"""Store a ProcessedLongTermMemory with enhanced schema"""
|
|
510
|
+
memory_id = str(uuid.uuid4())
|
|
511
|
+
|
|
512
|
+
with self.SessionLocal() as session:
|
|
513
|
+
try:
|
|
514
|
+
long_term_memory = LongTermMemory(
|
|
515
|
+
memory_id=memory_id,
|
|
516
|
+
original_chat_id=chat_id,
|
|
517
|
+
processed_data=memory.model_dump(mode="json"),
|
|
518
|
+
importance_score=memory.importance_score,
|
|
519
|
+
category_primary=memory.classification.value,
|
|
520
|
+
retention_type="long_term",
|
|
521
|
+
namespace=namespace,
|
|
522
|
+
created_at=datetime.now(),
|
|
523
|
+
searchable_content=memory.content,
|
|
524
|
+
summary=memory.summary,
|
|
525
|
+
novelty_score=0.5,
|
|
526
|
+
relevance_score=0.5,
|
|
527
|
+
actionability_score=0.5,
|
|
528
|
+
classification=memory.classification.value,
|
|
529
|
+
memory_importance=memory.importance.value,
|
|
530
|
+
topic=memory.topic,
|
|
531
|
+
entities_json=memory.entities,
|
|
532
|
+
keywords_json=memory.keywords,
|
|
533
|
+
is_user_context=memory.is_user_context,
|
|
534
|
+
is_preference=memory.is_preference,
|
|
535
|
+
is_skill_knowledge=memory.is_skill_knowledge,
|
|
536
|
+
is_current_project=memory.is_current_project,
|
|
537
|
+
promotion_eligible=memory.promotion_eligible,
|
|
538
|
+
duplicate_of=memory.duplicate_of,
|
|
539
|
+
supersedes_json=memory.supersedes,
|
|
540
|
+
related_memories_json=memory.related_memories,
|
|
541
|
+
confidence_score=memory.confidence_score,
|
|
542
|
+
extraction_timestamp=memory.extraction_timestamp,
|
|
543
|
+
classification_reason=memory.classification_reason,
|
|
544
|
+
processed_for_duplicates=False,
|
|
545
|
+
conscious_processed=False,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
session.add(long_term_memory)
|
|
549
|
+
session.commit()
|
|
550
|
+
|
|
551
|
+
logger.debug(f"Stored enhanced long-term memory {memory_id}")
|
|
552
|
+
return memory_id
|
|
553
|
+
|
|
554
|
+
except SQLAlchemyError as e:
|
|
555
|
+
session.rollback()
|
|
556
|
+
logger.error(f"Failed to store enhanced long-term memory: {e}")
|
|
557
|
+
raise DatabaseError(f"Failed to store enhanced long-term memory: {e}")
|
|
558
|
+
|
|
559
|
+
def search_memories(
|
|
560
|
+
self,
|
|
561
|
+
query: str,
|
|
562
|
+
namespace: str = "default",
|
|
563
|
+
category_filter: Optional[List[str]] = None,
|
|
564
|
+
limit: int = 10,
|
|
565
|
+
) -> List[Dict[str, Any]]:
|
|
566
|
+
"""Search memories using the cross-database search service"""
|
|
567
|
+
try:
|
|
568
|
+
search_service = self._get_search_service()
|
|
569
|
+
try:
|
|
570
|
+
results = search_service.search_memories(
|
|
571
|
+
query, namespace, category_filter, limit
|
|
572
|
+
)
|
|
573
|
+
logger.debug(f"Search for '{query}' returned {len(results)} results")
|
|
574
|
+
return results
|
|
575
|
+
finally:
|
|
576
|
+
# Ensure session is properly closed
|
|
577
|
+
search_service.session.close()
|
|
578
|
+
|
|
579
|
+
except Exception as e:
|
|
580
|
+
logger.error(f"Memory search failed for query '{query}': {e}")
|
|
581
|
+
# Return empty list instead of raising exception to avoid breaking auto_ingest
|
|
582
|
+
return []
|
|
583
|
+
|
|
584
|
+
def get_memory_stats(self, namespace: str = "default") -> Dict[str, Any]:
|
|
585
|
+
"""Get comprehensive memory statistics"""
|
|
586
|
+
with self.SessionLocal() as session:
|
|
587
|
+
try:
|
|
588
|
+
stats = {}
|
|
589
|
+
|
|
590
|
+
# Basic counts
|
|
591
|
+
stats["chat_history_count"] = (
|
|
592
|
+
session.query(ChatHistory)
|
|
593
|
+
.filter(ChatHistory.namespace == namespace)
|
|
594
|
+
.count()
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
stats["short_term_count"] = (
|
|
598
|
+
session.query(ShortTermMemory)
|
|
599
|
+
.filter(ShortTermMemory.namespace == namespace)
|
|
600
|
+
.count()
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
stats["long_term_count"] = (
|
|
604
|
+
session.query(LongTermMemory)
|
|
605
|
+
.filter(LongTermMemory.namespace == namespace)
|
|
606
|
+
.count()
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
# Category breakdown
|
|
610
|
+
categories = {}
|
|
611
|
+
|
|
612
|
+
# Short-term categories
|
|
613
|
+
short_categories = (
|
|
614
|
+
session.query(
|
|
615
|
+
ShortTermMemory.category_primary,
|
|
616
|
+
func.count(ShortTermMemory.memory_id).label("count"),
|
|
617
|
+
)
|
|
618
|
+
.filter(ShortTermMemory.namespace == namespace)
|
|
619
|
+
.group_by(ShortTermMemory.category_primary)
|
|
620
|
+
.all()
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
for cat, count in short_categories:
|
|
624
|
+
categories[cat] = categories.get(cat, 0) + count
|
|
625
|
+
|
|
626
|
+
# Long-term categories
|
|
627
|
+
long_categories = (
|
|
628
|
+
session.query(
|
|
629
|
+
LongTermMemory.category_primary,
|
|
630
|
+
func.count(LongTermMemory.memory_id).label("count"),
|
|
631
|
+
)
|
|
632
|
+
.filter(LongTermMemory.namespace == namespace)
|
|
633
|
+
.group_by(LongTermMemory.category_primary)
|
|
634
|
+
.all()
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
for cat, count in long_categories:
|
|
638
|
+
categories[cat] = categories.get(cat, 0) + count
|
|
639
|
+
|
|
640
|
+
stats["memories_by_category"] = categories
|
|
641
|
+
|
|
642
|
+
# Average importance
|
|
643
|
+
short_avg = (
|
|
644
|
+
session.query(func.avg(ShortTermMemory.importance_score))
|
|
645
|
+
.filter(ShortTermMemory.namespace == namespace)
|
|
646
|
+
.scalar()
|
|
647
|
+
or 0
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
long_avg = (
|
|
651
|
+
session.query(func.avg(LongTermMemory.importance_score))
|
|
652
|
+
.filter(LongTermMemory.namespace == namespace)
|
|
653
|
+
.scalar()
|
|
654
|
+
or 0
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
total_memories = stats["short_term_count"] + stats["long_term_count"]
|
|
658
|
+
if total_memories > 0:
|
|
659
|
+
# Weight averages by count
|
|
660
|
+
total_avg = (
|
|
661
|
+
(short_avg * stats["short_term_count"])
|
|
662
|
+
+ (long_avg * stats["long_term_count"])
|
|
663
|
+
) / total_memories
|
|
664
|
+
stats["average_importance"] = float(total_avg) if total_avg else 0.0
|
|
665
|
+
else:
|
|
666
|
+
stats["average_importance"] = 0.0
|
|
667
|
+
|
|
668
|
+
# Database info
|
|
669
|
+
stats["database_type"] = self.database_type
|
|
670
|
+
stats["database_url"] = (
|
|
671
|
+
self.database_connect.split("@")[-1]
|
|
672
|
+
if "@" in self.database_connect
|
|
673
|
+
else self.database_connect
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
return stats
|
|
677
|
+
|
|
678
|
+
except SQLAlchemyError as e:
|
|
679
|
+
raise DatabaseError(f"Failed to get memory stats: {e}")
|
|
680
|
+
|
|
681
|
+
def clear_memory(
|
|
682
|
+
self, namespace: str = "default", memory_type: Optional[str] = None
|
|
683
|
+
):
|
|
684
|
+
"""Clear memory data"""
|
|
685
|
+
with self.SessionLocal() as session:
|
|
686
|
+
try:
|
|
687
|
+
if memory_type == "short_term":
|
|
688
|
+
session.query(ShortTermMemory).filter(
|
|
689
|
+
ShortTermMemory.namespace == namespace
|
|
690
|
+
).delete()
|
|
691
|
+
elif memory_type == "long_term":
|
|
692
|
+
session.query(LongTermMemory).filter(
|
|
693
|
+
LongTermMemory.namespace == namespace
|
|
694
|
+
).delete()
|
|
695
|
+
elif memory_type == "chat_history":
|
|
696
|
+
session.query(ChatHistory).filter(
|
|
697
|
+
ChatHistory.namespace == namespace
|
|
698
|
+
).delete()
|
|
699
|
+
else: # Clear all
|
|
700
|
+
session.query(ShortTermMemory).filter(
|
|
701
|
+
ShortTermMemory.namespace == namespace
|
|
702
|
+
).delete()
|
|
703
|
+
session.query(LongTermMemory).filter(
|
|
704
|
+
LongTermMemory.namespace == namespace
|
|
705
|
+
).delete()
|
|
706
|
+
session.query(ChatHistory).filter(
|
|
707
|
+
ChatHistory.namespace == namespace
|
|
708
|
+
).delete()
|
|
709
|
+
|
|
710
|
+
session.commit()
|
|
711
|
+
|
|
712
|
+
except SQLAlchemyError as e:
|
|
713
|
+
session.rollback()
|
|
714
|
+
raise DatabaseError(f"Failed to clear memory: {e}")
|
|
715
|
+
|
|
716
|
+
def execute_with_translation(self, query: str, parameters: Dict[str, Any] = None):
|
|
717
|
+
"""
|
|
718
|
+
Execute a query with automatic parameter translation for cross-database compatibility.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
query: SQL query string
|
|
722
|
+
parameters: Query parameters
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
Query result
|
|
726
|
+
"""
|
|
727
|
+
if parameters:
|
|
728
|
+
translated_params = self.query_translator.translate_parameters(parameters)
|
|
729
|
+
else:
|
|
730
|
+
translated_params = {}
|
|
731
|
+
|
|
732
|
+
with self.engine.connect() as conn:
|
|
733
|
+
result = conn.execute(text(query), translated_params)
|
|
734
|
+
conn.commit()
|
|
735
|
+
return result
|
|
736
|
+
|
|
737
|
+
def _get_connection(self):
|
|
738
|
+
"""
|
|
739
|
+
Compatibility method for legacy code that expects raw database connections.
|
|
740
|
+
|
|
741
|
+
Returns a context manager that provides a SQLAlchemy connection with
|
|
742
|
+
automatic parameter translation support.
|
|
743
|
+
|
|
744
|
+
This is used by memory.py for direct SQL queries.
|
|
745
|
+
"""
|
|
746
|
+
from contextlib import contextmanager
|
|
747
|
+
|
|
748
|
+
@contextmanager
|
|
749
|
+
def connection_context():
|
|
750
|
+
class TranslatingConnection:
|
|
751
|
+
"""Wrapper that adds parameter translation to SQLAlchemy connections"""
|
|
752
|
+
|
|
753
|
+
def __init__(self, conn, translator):
|
|
754
|
+
self._conn = conn
|
|
755
|
+
self._translator = translator
|
|
756
|
+
|
|
757
|
+
def execute(self, query, parameters=None):
|
|
758
|
+
"""Execute query with automatic parameter translation"""
|
|
759
|
+
if parameters:
|
|
760
|
+
# Handle both text() queries and raw strings
|
|
761
|
+
if hasattr(query, "text"):
|
|
762
|
+
# SQLAlchemy text() object
|
|
763
|
+
translated_params = self._translator.translate_parameters(
|
|
764
|
+
parameters
|
|
765
|
+
)
|
|
766
|
+
return self._conn.execute(query, translated_params)
|
|
767
|
+
else:
|
|
768
|
+
# Raw string query
|
|
769
|
+
translated_params = self._translator.translate_parameters(
|
|
770
|
+
parameters
|
|
771
|
+
)
|
|
772
|
+
return self._conn.execute(
|
|
773
|
+
text(str(query)), translated_params
|
|
774
|
+
)
|
|
775
|
+
else:
|
|
776
|
+
return self._conn.execute(query)
|
|
777
|
+
|
|
778
|
+
def commit(self):
|
|
779
|
+
"""Commit transaction"""
|
|
780
|
+
return self._conn.commit()
|
|
781
|
+
|
|
782
|
+
def rollback(self):
|
|
783
|
+
"""Rollback transaction"""
|
|
784
|
+
return self._conn.rollback()
|
|
785
|
+
|
|
786
|
+
def close(self):
|
|
787
|
+
"""Close connection"""
|
|
788
|
+
return self._conn.close()
|
|
789
|
+
|
|
790
|
+
def fetchall(self):
|
|
791
|
+
"""Compatibility method for cursor-like usage"""
|
|
792
|
+
# This is for backwards compatibility with code that expects cursor.fetchall()
|
|
793
|
+
return []
|
|
794
|
+
|
|
795
|
+
def scalar(self):
|
|
796
|
+
"""Compatibility method for cursor-like usage"""
|
|
797
|
+
return None
|
|
798
|
+
|
|
799
|
+
def __getattr__(self, name):
|
|
800
|
+
"""Delegate unknown attributes to the underlying connection"""
|
|
801
|
+
return getattr(self._conn, name)
|
|
802
|
+
|
|
803
|
+
conn = self.engine.connect()
|
|
804
|
+
try:
|
|
805
|
+
yield TranslatingConnection(conn, self.query_translator)
|
|
806
|
+
finally:
|
|
807
|
+
conn.close()
|
|
808
|
+
|
|
809
|
+
return connection_context()
|
|
810
|
+
|
|
811
|
+
def close(self):
|
|
812
|
+
"""Close database connections"""
|
|
813
|
+
if self._search_service and hasattr(self._search_service, "session"):
|
|
814
|
+
self._search_service.session.close()
|
|
815
|
+
|
|
816
|
+
if hasattr(self, "engine"):
|
|
817
|
+
self.engine.dispose()
|
|
818
|
+
|
|
819
|
+
def get_database_info(self) -> Dict[str, Any]:
|
|
820
|
+
"""Get database information and capabilities"""
|
|
821
|
+
base_info = {
|
|
822
|
+
"database_type": self.database_type,
|
|
823
|
+
"database_url": (
|
|
824
|
+
self.database_connect.split("@")[-1]
|
|
825
|
+
if "@" in self.database_connect
|
|
826
|
+
else self.database_connect
|
|
827
|
+
),
|
|
828
|
+
"driver": self.engine.dialect.driver,
|
|
829
|
+
"server_version": getattr(self.engine.dialect, "server_version_info", None),
|
|
830
|
+
"supports_fulltext": True, # Assume true for SQLAlchemy managed connections
|
|
831
|
+
"auto_creation_enabled": self.enable_auto_creation,
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
# Add auto-creation specific information
|
|
835
|
+
if hasattr(self, "auto_creator"):
|
|
836
|
+
creation_info = self.auto_creator.get_database_info(self.database_connect)
|
|
837
|
+
base_info.update(creation_info)
|
|
838
|
+
|
|
839
|
+
return base_info
|