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.

Files changed (48) hide show
  1. memori/__init__.py +24 -8
  2. memori/agents/conscious_agent.py +252 -414
  3. memori/agents/memory_agent.py +487 -224
  4. memori/agents/retrieval_agent.py +491 -68
  5. memori/config/memory_manager.py +323 -0
  6. memori/core/conversation.py +393 -0
  7. memori/core/database.py +386 -371
  8. memori/core/memory.py +1683 -532
  9. memori/core/providers.py +217 -0
  10. memori/database/adapters/__init__.py +10 -0
  11. memori/database/adapters/mysql_adapter.py +331 -0
  12. memori/database/adapters/postgresql_adapter.py +291 -0
  13. memori/database/adapters/sqlite_adapter.py +229 -0
  14. memori/database/auto_creator.py +320 -0
  15. memori/database/connection_utils.py +207 -0
  16. memori/database/connectors/base_connector.py +283 -0
  17. memori/database/connectors/mysql_connector.py +240 -18
  18. memori/database/connectors/postgres_connector.py +277 -4
  19. memori/database/connectors/sqlite_connector.py +178 -3
  20. memori/database/models.py +400 -0
  21. memori/database/queries/base_queries.py +1 -1
  22. memori/database/queries/memory_queries.py +91 -2
  23. memori/database/query_translator.py +222 -0
  24. memori/database/schema_generators/__init__.py +7 -0
  25. memori/database/schema_generators/mysql_schema_generator.py +215 -0
  26. memori/database/search/__init__.py +8 -0
  27. memori/database/search/mysql_search_adapter.py +255 -0
  28. memori/database/search/sqlite_search_adapter.py +180 -0
  29. memori/database/search_service.py +700 -0
  30. memori/database/sqlalchemy_manager.py +888 -0
  31. memori/integrations/__init__.py +36 -11
  32. memori/integrations/litellm_integration.py +340 -6
  33. memori/integrations/openai_integration.py +506 -240
  34. memori/tools/memory_tool.py +94 -4
  35. memori/utils/input_validator.py +395 -0
  36. memori/utils/pydantic_models.py +138 -36
  37. memori/utils/query_builder.py +530 -0
  38. memori/utils/security_audit.py +594 -0
  39. memori/utils/security_integration.py +339 -0
  40. memori/utils/transaction_manager.py +547 -0
  41. {memorisdk-1.0.2.dist-info → memorisdk-2.0.1.dist-info}/METADATA +56 -23
  42. memorisdk-2.0.1.dist-info/RECORD +66 -0
  43. memori/scripts/llm_text.py +0 -50
  44. memorisdk-1.0.2.dist-info/RECORD +0 -44
  45. memorisdk-1.0.2.dist-info/entry_points.txt +0 -2
  46. {memorisdk-1.0.2.dist-info → memorisdk-2.0.1.dist-info}/WHEEL +0 -0
  47. {memorisdk-1.0.2.dist-info → memorisdk-2.0.1.dist-info}/licenses/LICENSE +0 -0
  48. {memorisdk-1.0.2.dist-info → memorisdk-2.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,217 @@
1
+ """
2
+ Provider configuration for different LLM providers (OpenAI, Azure, custom)
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import Any, Dict, Optional
8
+
9
+ from loguru import logger
10
+
11
+
12
+ class ProviderType(Enum):
13
+ """Supported LLM provider types"""
14
+
15
+ OPENAI = "openai"
16
+ AZURE = "azure"
17
+ CUSTOM = "custom"
18
+ OPENAI_COMPATIBLE = "openai_compatible" # For OpenAI-compatible APIs
19
+
20
+
21
+ @dataclass
22
+ class ProviderConfig:
23
+ """
24
+ Configuration for LLM providers with support for OpenAI, Azure, and custom endpoints.
25
+
26
+ This class provides a unified interface for configuring different LLM providers
27
+ while maintaining backward compatibility with existing OpenAI-only configuration.
28
+ """
29
+
30
+ # Common parameters
31
+ api_key: Optional[str] = None
32
+ api_type: Optional[str] = None # "openai", "azure", or custom
33
+ base_url: Optional[str] = None # Custom endpoint URL
34
+ timeout: Optional[float] = None
35
+ max_retries: Optional[int] = None
36
+
37
+ # Azure-specific parameters
38
+ azure_endpoint: Optional[str] = None
39
+ azure_deployment: Optional[str] = None
40
+ api_version: Optional[str] = None
41
+ azure_ad_token: Optional[str] = None
42
+
43
+ # OpenAI-specific parameters
44
+ organization: Optional[str] = None
45
+ project: Optional[str] = None
46
+
47
+ # Model configuration
48
+ model: Optional[str] = None # User can specify model, defaults to gpt-4o if not set
49
+
50
+ # Additional headers for custom providers
51
+ default_headers: Optional[Dict[str, str]] = None
52
+ default_query: Optional[Dict[str, Any]] = None
53
+
54
+ # HTTP client configuration
55
+ http_client: Optional[Any] = None
56
+
57
+ @classmethod
58
+ def from_openai(
59
+ cls, api_key: Optional[str] = None, model: Optional[str] = None, **kwargs
60
+ ):
61
+ """Create configuration for standard OpenAI"""
62
+ return cls(api_key=api_key, api_type="openai", model=model, **kwargs)
63
+
64
+ @classmethod
65
+ def from_azure(
66
+ cls,
67
+ api_key: Optional[str] = None,
68
+ azure_endpoint: Optional[str] = None,
69
+ azure_deployment: Optional[str] = None,
70
+ api_version: Optional[str] = None,
71
+ model: Optional[str] = None,
72
+ **kwargs,
73
+ ):
74
+ """Create configuration for Azure OpenAI"""
75
+ return cls(
76
+ api_key=api_key,
77
+ api_type="azure",
78
+ azure_endpoint=azure_endpoint,
79
+ azure_deployment=azure_deployment,
80
+ api_version=api_version,
81
+ model=model,
82
+ **kwargs,
83
+ )
84
+
85
+ @classmethod
86
+ def from_custom(
87
+ cls,
88
+ base_url: str,
89
+ api_key: Optional[str] = None,
90
+ model: Optional[str] = None,
91
+ **kwargs,
92
+ ):
93
+ """Create configuration for custom OpenAI-compatible endpoints"""
94
+ return cls(
95
+ api_key=api_key, api_type="custom", base_url=base_url, model=model, **kwargs
96
+ )
97
+
98
+ def get_openai_client_kwargs(self) -> Dict[str, Any]:
99
+ """
100
+ Get kwargs for OpenAI client initialization based on provider type.
101
+
102
+ Returns:
103
+ Dictionary of parameters to pass to OpenAI client constructor
104
+ """
105
+ kwargs = {}
106
+
107
+ # Always include API key if provided
108
+ if self.api_key:
109
+ kwargs["api_key"] = self.api_key
110
+
111
+ if self.api_type == "azure":
112
+ # Azure OpenAI configuration
113
+ if self.azure_endpoint:
114
+ kwargs["azure_endpoint"] = self.azure_endpoint
115
+ if self.azure_deployment:
116
+ kwargs["azure_deployment"] = self.azure_deployment
117
+ if self.api_version:
118
+ kwargs["api_version"] = self.api_version
119
+ if self.azure_ad_token:
120
+ kwargs["azure_ad_token"] = self.azure_ad_token
121
+ # For Azure, we need to use AzureOpenAI client
122
+ kwargs["_use_azure_client"] = True
123
+
124
+ elif self.api_type == "custom" or self.api_type == "openai_compatible":
125
+ # Custom endpoint configuration
126
+ if self.base_url:
127
+ kwargs["base_url"] = self.base_url
128
+
129
+ elif self.api_type == "openai":
130
+ # Standard OpenAI configuration
131
+ if self.organization:
132
+ kwargs["organization"] = self.organization
133
+ if self.project:
134
+ kwargs["project"] = self.project
135
+
136
+ # Common parameters
137
+ if self.timeout:
138
+ kwargs["timeout"] = self.timeout
139
+ if self.max_retries:
140
+ kwargs["max_retries"] = self.max_retries
141
+ if self.default_headers:
142
+ kwargs["default_headers"] = self.default_headers
143
+ if self.default_query:
144
+ kwargs["default_query"] = self.default_query
145
+ if self.http_client:
146
+ kwargs["http_client"] = self.http_client
147
+
148
+ return kwargs
149
+
150
+ def create_client(self):
151
+ """
152
+ Create the appropriate OpenAI client based on configuration.
153
+
154
+ Returns:
155
+ OpenAI or AzureOpenAI client instance
156
+ """
157
+ import openai
158
+
159
+ kwargs = self.get_openai_client_kwargs()
160
+
161
+ # Check if we should use Azure client
162
+ if kwargs.pop("_use_azure_client", False):
163
+ # Use Azure OpenAI client
164
+ from openai import AzureOpenAI
165
+
166
+ return AzureOpenAI(**kwargs)
167
+ else:
168
+ # Use standard OpenAI client (works for OpenAI and custom endpoints)
169
+ return openai.OpenAI(**kwargs)
170
+
171
+ def create_async_client(self):
172
+ """
173
+ Create the appropriate async OpenAI client based on configuration.
174
+
175
+ Returns:
176
+ AsyncOpenAI or AsyncAzureOpenAI client instance
177
+ """
178
+ import openai
179
+
180
+ kwargs = self.get_openai_client_kwargs()
181
+
182
+ # Check if we should use Azure client
183
+ if kwargs.pop("_use_azure_client", False):
184
+ # Use Azure OpenAI async client
185
+ from openai import AsyncAzureOpenAI
186
+
187
+ return AsyncAzureOpenAI(**kwargs)
188
+ else:
189
+ # Use standard async OpenAI client
190
+ return openai.AsyncOpenAI(**kwargs)
191
+
192
+
193
+ def detect_provider_from_env() -> ProviderConfig:
194
+ """
195
+ Create provider configuration from environment variables WITHOUT automatic detection.
196
+
197
+ This function ONLY uses standard OpenAI configuration by default.
198
+ It does NOT automatically detect or prioritize Azure or custom providers.
199
+
200
+ Only use specific providers if explicitly configured via Memori constructor parameters.
201
+
202
+ Returns:
203
+ Standard OpenAI ProviderConfig instance (never auto-detects other providers)
204
+ """
205
+ import os
206
+
207
+ # Get model from environment (optional, defaults to gpt-4o if not set)
208
+ model = os.getenv("OPENAI_MODEL") or os.getenv("LLM_MODEL") or "gpt-4o"
209
+
210
+ # ALWAYS default to standard OpenAI - no automatic detection
211
+ logger.info("Provider configuration: Using standard OpenAI (no auto-detection)")
212
+ return ProviderConfig.from_openai(
213
+ api_key=os.getenv("OPENAI_API_KEY"),
214
+ organization=os.getenv("OPENAI_ORGANIZATION"),
215
+ project=os.getenv("OPENAI_PROJECT"),
216
+ model=model,
217
+ )
@@ -0,0 +1,10 @@
1
+ """
2
+ Database adapters for different database backends
3
+ Provides database-specific implementations with proper security measures
4
+ """
5
+
6
+ from .mysql_adapter import MySQLSearchAdapter
7
+ from .postgresql_adapter import PostgreSQLSearchAdapter
8
+ from .sqlite_adapter import SQLiteSearchAdapter
9
+
10
+ __all__ = ["SQLiteSearchAdapter", "PostgreSQLSearchAdapter", "MySQLSearchAdapter"]
@@ -0,0 +1,331 @@
1
+ """
2
+ MySQL-specific search adapter with FULLTEXT support and proper security
3
+ """
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from loguru import logger
8
+
9
+ from ...utils.exceptions import ValidationError
10
+ from ...utils.input_validator import DatabaseInputValidator
11
+ from ...utils.query_builder import DatabaseDialect, DatabaseQueryExecutor
12
+ from ..connectors.base_connector import BaseDatabaseConnector, BaseSearchAdapter
13
+
14
+
15
+ class MySQLSearchAdapter(BaseSearchAdapter):
16
+ """MySQL-specific search implementation with FULLTEXT and security measures"""
17
+
18
+ def __init__(self, connector: BaseDatabaseConnector):
19
+ super().__init__(connector)
20
+ self.query_executor = DatabaseQueryExecutor(connector, DatabaseDialect.MYSQL)
21
+ self._fts_available = None
22
+ self._mysql_version = None
23
+
24
+ def execute_fulltext_search(
25
+ self,
26
+ query: str,
27
+ namespace: str = "default",
28
+ category_filter: Optional[List[str]] = None,
29
+ limit: int = 10,
30
+ ) -> List[Dict[str, Any]]:
31
+ """Execute MySQL FULLTEXT search with proper validation"""
32
+ try:
33
+ # Validate all parameters
34
+ validated = DatabaseInputValidator.validate_search_params(
35
+ query, namespace, category_filter, limit
36
+ )
37
+
38
+ # Check if FULLTEXT is available
39
+ if not self._check_fts_available():
40
+ logger.debug(
41
+ "MySQL FULLTEXT not available, falling back to LIKE search"
42
+ )
43
+ return self.execute_fallback_search(
44
+ validated["query"],
45
+ validated["namespace"],
46
+ validated["category_filter"],
47
+ validated["limit"],
48
+ )
49
+
50
+ # Execute MySQL FULLTEXT search
51
+ return self.query_executor.execute_search(
52
+ validated["query"],
53
+ validated["namespace"],
54
+ validated["category_filter"],
55
+ validated["limit"],
56
+ use_fts=True,
57
+ )
58
+
59
+ except ValidationError as e:
60
+ logger.error(f"Invalid search parameters: {e}")
61
+ return []
62
+ except Exception as e:
63
+ logger.error(f"MySQL FULLTEXT search failed: {e}")
64
+ return self.execute_fallback_search(
65
+ query, namespace, category_filter, limit
66
+ )
67
+
68
+ def create_search_indexes(self) -> List[str]:
69
+ """Create MySQL-specific search indexes"""
70
+ indexes = []
71
+
72
+ try:
73
+ with self.connector.get_connection() as conn:
74
+ cursor = conn.cursor()
75
+
76
+ # Create FULLTEXT indexes (requires InnoDB in MySQL 5.6+)
77
+ fulltext_indexes = [
78
+ "ALTER TABLE short_term_memory ADD FULLTEXT idx_st_fulltext (searchable_content, summary)",
79
+ "ALTER TABLE long_term_memory ADD FULLTEXT idx_lt_fulltext (searchable_content, summary)",
80
+ ]
81
+
82
+ for index_sql in fulltext_indexes:
83
+ try:
84
+ cursor.execute(index_sql)
85
+ indexes.append("fulltext_index")
86
+ logger.debug("Created FULLTEXT index")
87
+ except Exception as e:
88
+ logger.warning(f"Failed to create FULLTEXT index: {e}")
89
+
90
+ # Create standard indexes for fallback search
91
+ standard_indexes = [
92
+ "CREATE INDEX idx_st_search_mysql ON short_term_memory(namespace, category_primary, importance_score)",
93
+ "CREATE INDEX idx_lt_search_mysql ON long_term_memory(namespace, category_primary, importance_score)",
94
+ "CREATE INDEX idx_st_content_mysql ON short_term_memory(searchable_content(255))", # Prefix index
95
+ "CREATE INDEX idx_lt_content_mysql ON long_term_memory(searchable_content(255))",
96
+ ]
97
+
98
+ for index_sql in standard_indexes:
99
+ try:
100
+ cursor.execute(index_sql)
101
+ indexes.append(index_sql.split()[2]) # Extract index name
102
+ except Exception as e:
103
+ logger.warning(f"Failed to create index: {e}")
104
+
105
+ conn.commit()
106
+ logger.info(f"Created {len(indexes)} MySQL search indexes")
107
+
108
+ except Exception as e:
109
+ logger.error(f"Failed to create MySQL search indexes: {e}")
110
+
111
+ return indexes
112
+
113
+ def translate_search_query(self, query: str) -> str:
114
+ """Translate search query to MySQL FULLTEXT boolean syntax"""
115
+ if not query or not query.strip():
116
+ return ""
117
+
118
+ # Sanitize input for MySQL FULLTEXT
119
+ sanitized = query.strip()
120
+
121
+ # Remove potentially dangerous boolean operators (use phrase search instead)
122
+ dangerous_operators = ["+", "-", "~", "*", '"', "(", ")", "<", ">"]
123
+ for op in dangerous_operators:
124
+ sanitized = sanitized.replace(op, " ")
125
+
126
+ # Split into words and prepare for boolean mode
127
+ words = [
128
+ word.strip()
129
+ for word in sanitized.split()
130
+ if word.strip() and len(word) >= 3
131
+ ] # MySQL ft_min_word_len
132
+
133
+ if not words:
134
+ return ""
135
+
136
+ # Use phrase search for safety (wrap in quotes)
137
+ return f'"{" ".join(words)}"'
138
+
139
+ def _check_fts_available(self) -> bool:
140
+ """Check if MySQL FULLTEXT search is available"""
141
+ if self._fts_available is not None:
142
+ return self._fts_available
143
+
144
+ try:
145
+ with self.connector.get_connection() as conn:
146
+ cursor = conn.cursor(dictionary=True)
147
+
148
+ # Check MySQL version
149
+ cursor.execute("SELECT VERSION() as version")
150
+ version_result = cursor.fetchone()
151
+ version = version_result["version"] if version_result else "0.0.0"
152
+ self._mysql_version = version
153
+
154
+ # Check if InnoDB supports FULLTEXT (MySQL 5.6+)
155
+ version_parts = [int(x.split("-")[0]) for x in version.split(".")[:2]]
156
+ version_ok = version_parts[0] > 5 or (
157
+ version_parts[0] == 5 and version_parts[1] >= 6
158
+ )
159
+
160
+ # Check if we have FULLTEXT indexes
161
+ cursor.execute(
162
+ """
163
+ SELECT COUNT(*) as fulltext_count
164
+ FROM information_schema.STATISTICS
165
+ WHERE TABLE_SCHEMA = DATABASE()
166
+ AND (TABLE_NAME = 'short_term_memory' OR TABLE_NAME = 'long_term_memory')
167
+ AND INDEX_TYPE = 'FULLTEXT'
168
+ """
169
+ )
170
+ fulltext_result = cursor.fetchone()
171
+ has_fulltext = fulltext_result and fulltext_result["fulltext_count"] > 0
172
+
173
+ self._fts_available = version_ok and has_fulltext
174
+
175
+ except Exception as e:
176
+ logger.debug(f"Error checking MySQL FULLTEXT availability: {e}")
177
+ self._fts_available = False
178
+
179
+ return self._fts_available
180
+
181
+ def execute_fallback_search(
182
+ self,
183
+ query: str,
184
+ namespace: str = "default",
185
+ category_filter: Optional[List[str]] = None,
186
+ limit: int = 10,
187
+ ) -> List[Dict[str, Any]]:
188
+ """Execute LIKE-based fallback search for MySQL"""
189
+ try:
190
+ return self.query_executor.execute_search(
191
+ query, namespace, category_filter, limit, use_fts=False
192
+ )
193
+ except Exception as e:
194
+ logger.error(f"MySQL fallback search failed: {e}")
195
+ return []
196
+
197
+ def optimize_database(self):
198
+ """Perform MySQL-specific optimizations"""
199
+ try:
200
+ with self.connector.get_connection() as conn:
201
+ cursor = conn.cursor()
202
+
203
+ # Analyze tables for better query planning
204
+ cursor.execute("ANALYZE TABLE short_term_memory")
205
+ cursor.execute("ANALYZE TABLE long_term_memory")
206
+
207
+ # Optimize FULLTEXT indexes
208
+ try:
209
+ cursor.execute("OPTIMIZE TABLE short_term_memory")
210
+ cursor.execute("OPTIMIZE TABLE long_term_memory")
211
+ logger.debug("Optimized MySQL FULLTEXT indexes")
212
+ except Exception as e:
213
+ logger.warning(f"Table optimization failed: {e}")
214
+
215
+ conn.commit()
216
+ logger.info("MySQL database optimization completed")
217
+
218
+ except Exception as e:
219
+ logger.warning(f"MySQL optimization failed: {e}")
220
+
221
+ def configure_fulltext_settings(self):
222
+ """Configure MySQL FULLTEXT settings for optimal performance"""
223
+ try:
224
+ with self.connector.get_connection() as conn:
225
+ cursor = conn.cursor()
226
+
227
+ # Check current FULLTEXT settings
228
+ cursor.execute("SHOW VARIABLES LIKE 'ft_%'")
229
+ settings = cursor.fetchall()
230
+
231
+ current_settings = {}
232
+ if hasattr(cursor, "description") and cursor.description:
233
+ for row in settings:
234
+ if isinstance(row, (list, tuple)) and len(row) >= 2:
235
+ current_settings[row[0]] = row[1]
236
+
237
+ logger.debug(f"Current MySQL FULLTEXT settings: {current_settings}")
238
+
239
+ # Recommended settings (these require server restart or global privileges)
240
+ recommended_settings = {
241
+ "ft_min_word_len": "3", # Minimum word length
242
+ "ft_max_word_len": "84", # Maximum word length
243
+ "ft_boolean_syntax": '+ -><()~*:""&|', # Boolean operators
244
+ }
245
+
246
+ for setting, value in recommended_settings.items():
247
+ if (
248
+ setting in current_settings
249
+ and current_settings[setting] != value
250
+ ):
251
+ logger.info(
252
+ f"Consider setting {setting} = {value} (current: {current_settings.get(setting)})"
253
+ )
254
+
255
+ except Exception as e:
256
+ logger.debug(f"Could not check MySQL FULLTEXT settings: {e}")
257
+
258
+ def repair_fulltext_indexes(self):
259
+ """Repair FULLTEXT indexes if they become corrupted"""
260
+ try:
261
+ with self.connector.get_connection() as conn:
262
+ cursor = conn.cursor()
263
+
264
+ # Check and repair tables with FULLTEXT indexes
265
+ tables_to_repair = ["short_term_memory", "long_term_memory"]
266
+
267
+ for table in tables_to_repair:
268
+ try:
269
+ cursor.execute(f"CHECK TABLE {table}")
270
+ cursor.execute(f"REPAIR TABLE {table}")
271
+ logger.debug(f"Repaired table {table}")
272
+ except Exception as e:
273
+ logger.warning(f"Could not repair table {table}: {e}")
274
+
275
+ conn.commit()
276
+
277
+ except Exception as e:
278
+ logger.warning(f"FULLTEXT index repair failed: {e}")
279
+
280
+ def get_fulltext_statistics(self) -> Dict[str, Any]:
281
+ """Get statistics about FULLTEXT usage and performance"""
282
+ stats = {}
283
+
284
+ try:
285
+ with self.connector.get_connection() as conn:
286
+ cursor = conn.cursor(dictionary=True)
287
+
288
+ # Get FULLTEXT index information
289
+ cursor.execute(
290
+ """
291
+ SELECT
292
+ TABLE_NAME,
293
+ INDEX_NAME,
294
+ COLUMN_NAME,
295
+ INDEX_TYPE
296
+ FROM information_schema.STATISTICS
297
+ WHERE TABLE_SCHEMA = DATABASE()
298
+ AND INDEX_TYPE = 'FULLTEXT'
299
+ ORDER BY TABLE_NAME, INDEX_NAME
300
+ """
301
+ )
302
+
303
+ fulltext_indexes = cursor.fetchall()
304
+ stats["fulltext_indexes"] = fulltext_indexes
305
+
306
+ # Get table sizes
307
+ cursor.execute(
308
+ """
309
+ SELECT
310
+ TABLE_NAME,
311
+ TABLE_ROWS,
312
+ DATA_LENGTH,
313
+ INDEX_LENGTH
314
+ FROM information_schema.TABLES
315
+ WHERE TABLE_SCHEMA = DATABASE()
316
+ AND TABLE_NAME IN ('short_term_memory', 'long_term_memory')
317
+ """
318
+ )
319
+
320
+ table_stats = cursor.fetchall()
321
+ stats["table_statistics"] = table_stats
322
+
323
+ # MySQL version and FULLTEXT variables
324
+ stats["mysql_version"] = self._mysql_version
325
+ stats["fts_available"] = self._fts_available
326
+
327
+ except Exception as e:
328
+ logger.warning(f"Could not get FULLTEXT statistics: {e}")
329
+ stats["error"] = str(e)
330
+
331
+ return stats