mem-llm 1.0.2__py3-none-any.whl → 2.1.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 mem-llm might be problematic. Click here for more details.

Files changed (41) hide show
  1. mem_llm/__init__.py +71 -8
  2. mem_llm/api_server.py +595 -0
  3. mem_llm/base_llm_client.py +201 -0
  4. mem_llm/builtin_tools.py +311 -0
  5. mem_llm/builtin_tools_async.py +170 -0
  6. mem_llm/cli.py +254 -0
  7. mem_llm/clients/__init__.py +22 -0
  8. mem_llm/clients/lmstudio_client.py +393 -0
  9. mem_llm/clients/ollama_client.py +354 -0
  10. mem_llm/config.yaml.example +1 -1
  11. mem_llm/config_from_docs.py +1 -1
  12. mem_llm/config_manager.py +5 -3
  13. mem_llm/conversation_summarizer.py +372 -0
  14. mem_llm/data_export_import.py +640 -0
  15. mem_llm/dynamic_prompt.py +298 -0
  16. mem_llm/llm_client.py +77 -14
  17. mem_llm/llm_client_factory.py +260 -0
  18. mem_llm/logger.py +129 -0
  19. mem_llm/mem_agent.py +1178 -87
  20. mem_llm/memory_db.py +290 -59
  21. mem_llm/memory_manager.py +60 -1
  22. mem_llm/prompt_security.py +304 -0
  23. mem_llm/response_metrics.py +221 -0
  24. mem_llm/retry_handler.py +193 -0
  25. mem_llm/thread_safe_db.py +301 -0
  26. mem_llm/tool_system.py +537 -0
  27. mem_llm/vector_store.py +278 -0
  28. mem_llm/web_launcher.py +129 -0
  29. mem_llm/web_ui/README.md +44 -0
  30. mem_llm/web_ui/__init__.py +7 -0
  31. mem_llm/web_ui/index.html +641 -0
  32. mem_llm/web_ui/memory.html +569 -0
  33. mem_llm/web_ui/metrics.html +75 -0
  34. mem_llm-2.1.0.dist-info/METADATA +753 -0
  35. mem_llm-2.1.0.dist-info/RECORD +40 -0
  36. {mem_llm-1.0.2.dist-info → mem_llm-2.1.0.dist-info}/WHEEL +1 -1
  37. mem_llm-2.1.0.dist-info/entry_points.txt +3 -0
  38. mem_llm/prompt_templates.py +0 -244
  39. mem_llm-1.0.2.dist-info/METADATA +0 -382
  40. mem_llm-1.0.2.dist-info/RECORD +0 -15
  41. {mem_llm-1.0.2.dist-info → mem_llm-2.1.0.dist-info}/top_level.txt +0 -0
mem_llm/mem_agent.py CHANGED
@@ -29,23 +29,28 @@ agent = MemAgent(
29
29
  ```
30
30
  """
31
31
 
32
- from typing import Optional, Dict, List, Any, Union
32
+ from typing import Optional, Dict, List, Any, Union, Iterator
33
33
  from datetime import datetime
34
34
  import logging
35
35
  import json
36
36
  import os
37
+ import time
37
38
 
38
39
  # Core dependencies
39
40
  from .memory_manager import MemoryManager
40
- from .llm_client import OllamaClient
41
+ from .llm_client import OllamaClient # Backward compatibility
42
+ from .llm_client_factory import LLMClientFactory
43
+ from .base_llm_client import BaseLLMClient
44
+ from .response_metrics import ChatResponse, ResponseMetricsAnalyzer, calculate_confidence
45
+ from .tool_system import ToolRegistry, ToolCallParser, format_tools_for_prompt
41
46
 
42
47
  # Advanced features (optional)
43
48
  try:
44
49
  from .memory_db import SQLMemoryManager
45
- from .prompt_templates import prompt_manager
46
50
  from .knowledge_loader import KnowledgeLoader
47
51
  from .config_manager import get_config
48
52
  from .memory_tools import ToolExecutor, MemoryTools
53
+ from .dynamic_prompt import dynamic_prompt_builder
49
54
  ADVANCED_AVAILABLE = True
50
55
  except ImportError:
51
56
  ADVANCED_AVAILABLE = False
@@ -60,21 +65,69 @@ class MemAgent:
60
65
  """
61
66
 
62
67
  def __init__(self,
63
- model: str = "granite4:tiny-h",
68
+ model: str = "granite4:3b",
69
+ backend: str = "ollama",
64
70
  config_file: Optional[str] = None,
65
71
  use_sql: bool = True,
66
72
  memory_dir: Optional[str] = None,
73
+ db_path: Optional[str] = None,
67
74
  load_knowledge_base: bool = True,
68
- ollama_url: str = "http://localhost:11434"):
75
+ ollama_url: str = "http://localhost:11434",
76
+ base_url: Optional[str] = None,
77
+ api_key: Optional[str] = None,
78
+ auto_detect_backend: bool = False,
79
+ check_connection: bool = False,
80
+ enable_security: bool = False,
81
+ enable_vector_search: bool = False,
82
+ embedding_model: str = "all-MiniLM-L6-v2",
83
+ enable_tools: bool = False,
84
+ tools: Optional[List] = None,
85
+ **llm_kwargs):
69
86
  """
70
87
  Args:
71
88
  model: LLM model to use
89
+ backend: LLM backend ('ollama', 'lmstudio') - NEW in v1.3.0
72
90
  config_file: Configuration file (optional)
73
91
  use_sql: Use SQL database (True) or JSON (False)
74
- memory_dir: Memory directory
92
+ memory_dir: Memory directory (for JSON mode or if db_path not specified)
93
+ db_path: SQLite database path (for SQL mode, e.g., ":memory:" or "path/to/db.db")
75
94
  load_knowledge_base: Automatically load knowledge base
76
- ollama_url: Ollama API URL
95
+ ollama_url: Ollama API URL (backward compatibility, use base_url instead)
96
+ base_url: Backend API URL (for local backends) - NEW in v1.3.0
97
+ auto_detect_backend: Auto-detect available LLM backend - NEW in v1.3.0
98
+ check_connection: Verify LLM connection on startup (default: False)
99
+ enable_security: Enable prompt injection protection (v1.1.0+, default: False for backward compatibility)
100
+ enable_vector_search: Enable semantic/vector search for KB (v1.3.2+, requires chromadb) - NEW
101
+ embedding_model: Embedding model for vector search (default: "all-MiniLM-L6-v2") - NEW
102
+ **llm_kwargs: Additional backend-specific parameters
103
+
104
+ Examples:
105
+ # Default Ollama
106
+ agent = MemAgent()
107
+
108
+ # LM Studio
109
+ agent = MemAgent(backend='lmstudio', model='llama-3-8b')
110
+
111
+ # Auto-detect
112
+ agent = MemAgent(auto_detect_backend=True)
77
113
  """
114
+
115
+ # Setup logging first
116
+ self._setup_logging()
117
+
118
+ # Security features (v1.1.0+)
119
+ self.enable_security = enable_security
120
+ self.security_detector = None
121
+ self.security_sanitizer = None
122
+
123
+ if enable_security:
124
+ try:
125
+ from .prompt_security import PromptInjectionDetector, InputSanitizer
126
+ self.security_detector = PromptInjectionDetector()
127
+ self.security_sanitizer = InputSanitizer()
128
+ self.logger.info("🔒 Security features enabled (prompt injection protection)")
129
+ except ImportError:
130
+ self.logger.warning("⚠️ Security features requested but not available")
78
131
 
79
132
  # Load configuration
80
133
  self.config = None
@@ -95,39 +148,182 @@ class MemAgent:
95
148
  # No config file
96
149
  self.usage_mode = "personal"
97
150
 
98
- # Setup logging
99
- self._setup_logging()
100
-
101
- # Memory system selection
151
+ # Initialize flags first
152
+ self.has_knowledge_base: bool = False # Track KB status
153
+ self.has_tools: bool = False # Track tools status (v1.3.x)
154
+
155
+ # Tool system (v2.0.0+)
156
+ self.enable_tools = enable_tools
157
+ self.tool_registry = None
158
+ if enable_tools:
159
+ self.tool_registry = ToolRegistry()
160
+ self.has_tools = True
161
+
162
+ # Register custom tools if provided
163
+ if tools:
164
+ for tool in tools:
165
+ self.tool_registry.register_function(tool)
166
+ self.logger.info(f"🔧 Registered {len(tools)} custom tools")
167
+
168
+ builtin_count = len(self.tool_registry.tools)
169
+ self.logger.info(f"🛠️ Tool system enabled ({builtin_count} tools available)")
170
+
171
+ # Memory system
102
172
  if use_sql and ADVANCED_AVAILABLE:
103
173
  # SQL memory (advanced)
104
- db_path = memory_dir or self.config.get("memory.db_path", "memories.db") if self.config else "memories.db"
105
- self.memory = SQLMemoryManager(db_path)
106
- self.logger.info(f"SQL memory system active: {db_path}")
174
+ # Determine database path
175
+ if db_path:
176
+ # Use provided db_path (can be ":memory:" for in-memory DB)
177
+ final_db_path = db_path
178
+ elif memory_dir:
179
+ final_db_path = memory_dir
180
+ elif self.config:
181
+ final_db_path = self.config.get("memory.db_path", "memories/memories.db")
182
+ else:
183
+ final_db_path = "memories/memories.db"
184
+
185
+ # Get vector search settings from config or parameters
186
+ vector_search_enabled = enable_vector_search
187
+ vector_model = embedding_model
188
+
189
+ if self.config:
190
+ vector_search_enabled = self.config.get("knowledge_base.enable_vector_search", vector_search_enabled)
191
+ vector_model = self.config.get("knowledge_base.embedding_model", vector_model)
192
+
193
+ # Ensure memories directory exists (skip for :memory:)
194
+ import os
195
+ if final_db_path != ":memory:":
196
+ db_dir = os.path.dirname(final_db_path)
197
+ if db_dir and not os.path.exists(db_dir):
198
+ os.makedirs(db_dir, exist_ok=True)
199
+
200
+ self.memory = SQLMemoryManager(
201
+ final_db_path,
202
+ enable_vector_search=vector_search_enabled,
203
+ embedding_model=vector_model
204
+ )
205
+ self.logger.info(f"SQL memory system active: {final_db_path}")
206
+ if vector_search_enabled:
207
+ self.logger.info(f"🔍 Vector search enabled (model: {vector_model})")
107
208
  else:
108
209
  # JSON memory (simple)
109
210
  json_dir = memory_dir or self.config.get("memory.json_dir", "memories") if self.config else "memories"
110
211
  self.memory = MemoryManager(json_dir)
111
212
  self.logger.info(f"JSON memory system active: {json_dir}")
112
213
 
214
+ # Active user and system prompt
215
+ self.current_user: Optional[str] = None
216
+ self.current_system_prompt: Optional[str] = None
217
+
113
218
  # LLM client
114
219
  self.model = model # Store model name
220
+ self.backend = backend # Store backend name
115
221
  self.use_sql = use_sql # Store SQL usage flag
116
- self.llm = OllamaClient(model, ollama_url)
117
- self.logger.info(f"LLM client ready: {model}")
222
+
223
+ # Initialize LLM client (v1.3.0: Multi-backend support)
224
+ # Prepare backend configuration
225
+ llm_config = llm_kwargs.copy()
226
+
227
+ # Handle backward compatibility: ollama_url -> base_url
228
+ if base_url is None and backend == "ollama":
229
+ base_url = ollama_url
230
+
231
+ # Add base_url for local backends
232
+ if base_url and backend in ['ollama', 'lmstudio']:
233
+ llm_config['base_url'] = base_url
234
+
235
+ # Add api_key for cloud backends
236
+ # Auto-detect backend if requested
237
+ if auto_detect_backend:
238
+ self.logger.info("🔍 Auto-detecting available LLM backend...")
239
+ self.llm = LLMClientFactory.auto_detect()
240
+ if self.llm:
241
+ detected_backend = self.llm.__class__.__name__
242
+ self.logger.info(f"✅ Detected and using: {detected_backend}")
243
+ else:
244
+ self.logger.error("❌ No LLM backend available.")
245
+ raise RuntimeError(
246
+ "No LLM backend detected. Please start a local LLM service (Ollama or LM Studio)."
247
+ )
248
+ else:
249
+ # Create client using factory
250
+ try:
251
+ self.llm = LLMClientFactory.create(
252
+ backend=backend,
253
+ model=model,
254
+ **llm_config
255
+ )
256
+ self.logger.info(f"✅ Initialized {backend} backend with model: {model}")
257
+ except Exception as e:
258
+ self.logger.error(f"❌ Failed to initialize {backend} backend: {e}")
259
+ raise
260
+
261
+ # Optional connection check on startup
262
+ if check_connection:
263
+ backend_name = backend if not auto_detect_backend else "LLM service"
264
+ self.logger.info(f"Checking {backend_name} connection...")
265
+ if not self.llm.check_connection():
266
+ error_msg = f"❌ ERROR: Cannot connect to {backend_name}!\n"
267
+
268
+ if backend == "ollama":
269
+ error_msg += (
270
+ " \n"
271
+ " Solutions:\n"
272
+ " 1. Start Ollama: ollama serve\n"
273
+ " 2. Check if Ollama is running: http://localhost:11434\n"
274
+ " 3. Verify base_url parameter is correct\n"
275
+ )
276
+ elif backend == "lmstudio":
277
+ error_msg += (
278
+ " \n"
279
+ " Solutions:\n"
280
+ " 1. Start LM Studio\n"
281
+ " 2. Load a model in LM Studio\n"
282
+ " 3. Start local server (default: http://localhost:1234)\n"
283
+ " 4. Verify base_url parameter is correct\n"
284
+ )
285
+
286
+ error_msg += " \n To skip this check, use: MemAgent(check_connection=False)"
287
+ self.logger.error(error_msg)
288
+ raise ConnectionError(f"{backend_name} not available")
289
+
290
+ # Check if model exists (for backends that support listing)
291
+ try:
292
+ available_models = self.llm.list_models()
293
+ if available_models and model not in available_models:
294
+ error_msg = (
295
+ f"❌ ERROR: Model '{model}' not found in {backend}!\n"
296
+ f" \n"
297
+ f" Available models: {', '.join(available_models[:5])}\n"
298
+ f" Total: {len(available_models)} models available\n"
299
+ f" \n"
300
+ f" To skip this check, use: MemAgent(check_connection=False)"
301
+ )
302
+ self.logger.error(error_msg)
303
+ raise ValueError(f"Model '{model}' not available")
304
+ except:
305
+ # Some backends may not support list_models, skip check
306
+ pass
307
+
308
+ self.logger.info(f"✅ {backend_name} connection verified, model '{model}' ready")
309
+
310
+ self.logger.info(f"LLM client ready: {model} on {backend}")
311
+
118
312
 
119
313
  # Advanced features (if available)
120
314
  if ADVANCED_AVAILABLE:
121
315
  self._setup_advanced_features(load_knowledge_base)
122
316
  else:
123
317
  print("⚠️ Load additional packages for advanced features")
124
-
125
- # Active user and system prompt
126
- self.current_user: Optional[str] = None
127
- self.current_system_prompt: Optional[str] = None
318
+ # Build basic prompt even without advanced features
319
+ self._build_dynamic_system_prompt()
128
320
 
129
321
  # Tool system (always available)
130
322
  self.tool_executor = ToolExecutor(self.memory)
323
+
324
+ # Metrics tracking system (v1.3.1+)
325
+ self.metrics_analyzer = ResponseMetricsAnalyzer()
326
+ self.track_metrics = True # Can be disabled if needed
131
327
 
132
328
  self.logger.info("MemAgent successfully initialized")
133
329
 
@@ -139,17 +335,23 @@ class MemAgent:
139
335
  if ADVANCED_AVAILABLE and hasattr(self, 'config') and self.config:
140
336
  log_config = self.config.get("logging", {})
141
337
 
338
+ # Default to WARNING level to keep console clean (users can override in config)
339
+ default_level = "WARNING"
340
+
142
341
  if log_config.get("enabled", True):
342
+ # Only console logging (no file) - keep workspace clean
143
343
  logging.basicConfig(
144
- level=getattr(logging, log_config.get("level", "INFO")),
344
+ level=getattr(logging, log_config.get("level", default_level)),
145
345
  format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
146
346
  handlers=[
147
- logging.FileHandler(log_config.get("file", "mem_agent.log")),
148
- logging.StreamHandler()
347
+ logging.StreamHandler() # Console only
149
348
  ]
150
349
  )
151
350
 
152
351
  self.logger = logging.getLogger("MemAgent")
352
+
353
+ # Set default level for mem_llm loggers
354
+ logging.getLogger("mem_llm").setLevel(getattr(logging, log_config.get("level", default_level)))
153
355
 
154
356
  def _setup_advanced_features(self, load_knowledge_base: bool) -> None:
155
357
  """Setup advanced features"""
@@ -171,60 +373,79 @@ class MemAgent:
171
373
  if default_kb == "ecommerce":
172
374
  count = kb_loader.load_default_ecommerce_kb()
173
375
  self.logger.info(f"E-commerce knowledge base loaded: {count} records")
376
+ self.has_knowledge_base = True # KB loaded!
174
377
  elif default_kb == "tech_support":
175
378
  count = kb_loader.load_default_tech_support_kb()
176
379
  self.logger.info(f"Technical support knowledge base loaded: {count} records")
380
+ self.has_knowledge_base = True # KB loaded!
177
381
  elif default_kb == "business_tech_support":
178
382
  count = kb_loader.load_default_tech_support_kb()
179
383
  self.logger.info(f"Corporate technical support knowledge base loaded: {count} records")
384
+ self.has_knowledge_base = True # KB loaded!
180
385
  elif default_kb == "personal_learning":
181
386
  # Simple KB for personal learning
182
387
  count = kb_loader.load_default_ecommerce_kb() # Temporarily use the same KB
183
388
  self.logger.info(f"Personal learning knowledge base loaded: {count} records")
389
+ self.has_knowledge_base = True # KB loaded!
184
390
  except Exception as e:
185
391
  self.logger.error(f"Knowledge base loading error: {e}")
186
-
187
- # Load system prompt (according to usage mode)
392
+ self.has_knowledge_base = False
393
+
394
+ # Build dynamic system prompt based on active features
395
+ self._build_dynamic_system_prompt()
396
+
397
+ def _build_dynamic_system_prompt(self) -> None:
398
+ """Build dynamic system prompt based on active features"""
399
+ if not ADVANCED_AVAILABLE:
400
+ # Fallback simple prompt
401
+ self.current_system_prompt = "You are a helpful AI assistant."
402
+ return
403
+
404
+ # Get config data
405
+ business_config = None
406
+ personal_config = None
407
+
188
408
  if hasattr(self, 'config') and self.config:
189
- prompt_config = self.config.get("prompt", {})
190
-
191
- # Select default template according to usage mode
192
- if self.usage_mode == "business":
193
- default_template = "business_customer_service"
194
- else: # personal
195
- default_template = "personal_assistant"
196
-
197
- template_name = prompt_config.get("template", default_template)
198
- variables = prompt_config.get("variables", {})
199
-
200
- # Additional variables for business mode
201
409
  if self.usage_mode == "business":
202
410
  business_config = self.config.get("business", {})
203
- variables.update({
204
- "company_name": business_config.get("company_name", "Our Company"),
205
- "founded_year": business_config.get("founded_year", "2010"),
206
- "employee_count": business_config.get("employee_count", "100+"),
207
- "industry": business_config.get("industry", "Teknoloji")
208
- })
209
- else: # personal
411
+ else:
210
412
  personal_config = self.config.get("personal", {})
211
- variables.update({
212
- "user_name": personal_config.get("user_name", "User"),
213
- "timezone": personal_config.get("timezone", "Europe/London")
214
- })
215
-
216
- try:
217
- variables['current_date'] = datetime.now().strftime("%Y-%m-%d")
218
- self.current_system_prompt = prompt_manager.render_prompt(template_name, **variables)
219
- self.logger.info(f"Prompt template loaded: {template_name} (Mode: {self.usage_mode})")
220
- except Exception as e:
221
- self.logger.error(f"Prompt template loading error: {e}")
222
- # Simple, short and effective default prompt
223
- self.current_system_prompt = """You are a helpful AI assistant. Be concise and direct.
224
- - Give short, clear answers (2-3 sentences max)
225
- - Only use information from conversation history
226
- - If you don't know something, say so
227
- - Don't make assumptions or hallucinate"""
413
+
414
+ # Check if tools are enabled (future feature)
415
+ # For now, tools are always available but not advertised in prompt
416
+ # self.has_tools = False # Will be enabled when tool system is ready
417
+
418
+ # Build prompt using dynamic builder
419
+ try:
420
+ self.current_system_prompt = dynamic_prompt_builder.build_prompt(
421
+ usage_mode=self.usage_mode,
422
+ has_knowledge_base=self.has_knowledge_base,
423
+ has_tools=self.enable_tools, # Now advertised when enabled (v2.0+)
424
+ is_multi_user=False, # Always False for now, per-session state
425
+ business_config=business_config,
426
+ personal_config=personal_config,
427
+ memory_type="sql" if self.use_sql else "json"
428
+ )
429
+
430
+ # Add tool information to prompt if tools are enabled (v2.0+)
431
+ if self.enable_tools and self.tool_registry:
432
+ tools_list = self.tool_registry.list_tools()
433
+ tools_prompt = format_tools_for_prompt(tools_list)
434
+ self.current_system_prompt += f"\n\n{tools_prompt}"
435
+
436
+ # Log feature summary
437
+ feature_summary = dynamic_prompt_builder.get_feature_summary(
438
+ has_knowledge_base=self.has_knowledge_base,
439
+ has_tools=self.enable_tools,
440
+ is_multi_user=False,
441
+ memory_type="sql" if self.use_sql else "json"
442
+ )
443
+ self.logger.info(f"Dynamic prompt built: {feature_summary}")
444
+
445
+ except Exception as e:
446
+ self.logger.error(f"Dynamic prompt building error: {e}")
447
+ # Fallback
448
+ self.current_system_prompt = "You are a helpful AI assistant."
228
449
 
229
450
  def check_setup(self) -> Dict[str, Any]:
230
451
  """Check system setup"""
@@ -282,9 +503,130 @@ class MemAgent:
282
503
  self.memory.update_user_profile(user_id, {"name": name})
283
504
 
284
505
  self.logger.debug(f"Active user set: {user_id}")
506
+
507
+ def _execute_tool_calls(self, response_text: str, max_iterations: int = 3) -> str:
508
+ """
509
+ Execute tool calls found in LLM response and get results.
510
+
511
+ Args:
512
+ response_text: LLM response that may contain tool calls
513
+ max_iterations: Maximum number of tool execution iterations
514
+
515
+ Returns:
516
+ Final response after all tool executions
517
+ """
518
+ iteration = 0
519
+ current_text = response_text
520
+
521
+ while iteration < max_iterations:
522
+ # Check if response contains tool calls
523
+ if not ToolCallParser.has_tool_call(current_text):
524
+ break
525
+
526
+ # Parse tool calls
527
+ tool_calls = ToolCallParser.parse(current_text)
528
+ if not tool_calls:
529
+ break
530
+
531
+ self.logger.info(f"🔧 Detected {len(tool_calls)} tool call(s)")
532
+
533
+ # Execute each tool
534
+ tool_results = []
535
+ for call in tool_calls:
536
+ tool_name = call["tool"]
537
+ arguments = call["arguments"]
538
+
539
+ self.logger.info(f" Executing: {tool_name}({arguments})")
540
+
541
+ # Execute tool
542
+ result = self.tool_registry.execute(tool_name, **arguments)
543
+
544
+ # Handle memory-specific tools
545
+ if result.status.value == "success" and isinstance(result.result, str):
546
+ if result.result.startswith("MEMORY_SEARCH:"):
547
+ keyword = result.result.split(":", 1)[1]
548
+ try:
549
+ search_results = self.memory_manager.search_conversations(keyword)
550
+ if search_results:
551
+ formatted = f"Found {len(search_results)} results for '{keyword}':\n"
552
+ for idx, conv in enumerate(search_results[:5], 1):
553
+ formatted += f"{idx}. {conv.get('user', 'N/A')}: {conv.get('message', 'N/A')[:100]}...\n"
554
+ result.result = formatted
555
+ else:
556
+ result.result = f"No conversations found containing '{keyword}'"
557
+ except Exception as e:
558
+ result.result = f"Memory search error: {e}"
559
+
560
+ elif result.result == "MEMORY_USER_INFO":
561
+ try:
562
+ user_info = f"Current user: {self.current_user or 'Not set'}"
563
+ if self.current_user:
564
+ conv_count = len(self.memory_manager.get_conversation_history(self.current_user))
565
+ user_info += f"\nTotal conversations: {conv_count}"
566
+ result.result = user_info
567
+ except Exception as e:
568
+ result.result = f"User info error: {e}"
569
+
570
+ elif result.result.startswith("MEMORY_LIST_CONVERSATIONS:"):
571
+ try:
572
+ limit = int(result.result.split(":", 1)[1])
573
+ history = self.memory_manager.get_conversation_history(self.current_user or "default", limit=limit)
574
+ if history:
575
+ formatted = f"Last {len(history)} conversations:\n"
576
+ for idx, conv in enumerate(history, 1):
577
+ role = conv.get('role', 'unknown')
578
+ msg = conv.get('content', '')[:80]
579
+ formatted += f"{idx}. [{role}] {msg}...\n"
580
+ result.result = formatted
581
+ else:
582
+ result.result = "No conversation history found"
583
+ except Exception as e:
584
+ result.result = f"Conversation list error: {e}"
585
+
586
+ if result.status.value == "success": # Compare with enum value
587
+ self.logger.info(f" ✅ Success: {result.result}")
588
+ tool_results.append(f"Tool '{tool_name}' returned: {result.result}")
589
+ else:
590
+ self.logger.warning(f" ❌ Error: {result.error}")
591
+ tool_results.append(f"Tool '{tool_name}' failed with error: {result.error}")
592
+
593
+ # Remove tool call syntax from response
594
+ clean_text = ToolCallParser.remove_tool_calls(current_text)
595
+
596
+ # If we have tool results, ask LLM to continue with the results
597
+ if tool_results:
598
+ results_text = "\n".join(tool_results)
599
+
600
+ # Build follow-up message for LLM
601
+ follow_up = f"{clean_text}\n\nTool Results:\n{results_text}\n\nPlease provide the final answer to the user based on these results."
602
+
603
+ # Get LLM response with tool results
604
+ try:
605
+ messages = [
606
+ {"role": "system", "content": "You are a helpful assistant. Use the tool results to answer the user's question."},
607
+ {"role": "user", "content": follow_up}
608
+ ]
609
+
610
+ llm_response = self.llm.chat(
611
+ messages=messages,
612
+ temperature=0.7,
613
+ max_tokens=500
614
+ )
615
+
616
+ current_text = llm_response
617
+ iteration += 1
618
+ except Exception as e:
619
+ self.logger.error(f"Error getting follow-up response: {e}")
620
+ # Return what we have
621
+ return f"{clean_text}\n\n{results_text}"
622
+ else:
623
+ # No tool results, return clean text
624
+ return clean_text
625
+
626
+ return current_text
285
627
 
286
628
  def chat(self, message: str, user_id: Optional[str] = None,
287
- metadata: Optional[Dict] = None) -> str:
629
+ metadata: Optional[Dict] = None, return_metrics: bool = False) -> Union[str, ChatResponse]:
288
630
  """
289
631
  Chat with user
290
632
 
@@ -292,37 +634,103 @@ class MemAgent:
292
634
  message: User's message
293
635
  user_id: User ID (optional)
294
636
  metadata: Additional information
637
+ return_metrics: If True, returns ChatResponse with metrics; if False, returns only text (default)
295
638
 
296
639
  Returns:
297
- Bot's response
640
+ Bot's response (str) or ChatResponse object with metrics
298
641
  """
642
+ # Start timing
643
+ start_time = time.time()
299
644
  # Determine user
300
645
  if user_id:
301
646
  self.set_user(user_id)
302
647
  elif not self.current_user:
303
- return "Error: User ID not specified."
648
+ error_response = "Error: User ID not specified."
649
+ if return_metrics:
650
+ return ChatResponse(
651
+ text=error_response,
652
+ confidence=1.0,
653
+ source="tool",
654
+ latency=(time.time() - start_time) * 1000,
655
+ timestamp=datetime.now(),
656
+ kb_results_count=0,
657
+ metadata={"error": True}
658
+ )
659
+ return error_response
304
660
 
305
661
  user_id = self.current_user
662
+
663
+ # Initialize tracking variables
664
+ kb_results_count = 0
665
+ used_kb = False
666
+ used_memory = False
667
+ response_source = "model" # Default source
668
+
669
+ # Security check (v1.1.0+) - opt-in
670
+ security_info = {}
671
+ if self.enable_security and self.security_detector and self.security_sanitizer:
672
+ # Detect injection attempts
673
+ risk_level = self.security_detector.get_risk_level(message)
674
+ is_suspicious, patterns = self.security_detector.detect(message)
675
+
676
+ if risk_level in ["high", "critical"]:
677
+ self.logger.warning(f"🚨 Blocked {risk_level} risk input from {user_id}: {len(patterns)} patterns detected")
678
+ return f"⚠️ Your message was blocked due to security concerns. Please rephrase your request."
679
+
680
+ if is_suspicious:
681
+ self.logger.info(f"⚠️ Suspicious input from {user_id} (risk: {risk_level}): {len(patterns)} patterns")
682
+
683
+ # Sanitize input
684
+ original_message = message
685
+ message = self.security_sanitizer.sanitize(message, aggressive=(risk_level == "medium"))
686
+
687
+ if message != original_message:
688
+ self.logger.debug(f"Input sanitized for {user_id}")
689
+
690
+ security_info = {
691
+ "risk_level": risk_level,
692
+ "sanitized": message != original_message,
693
+ "patterns_detected": len(patterns)
694
+ }
306
695
 
307
696
  # Check tool commands first
308
697
  tool_result = self.tool_executor.execute_user_command(message, user_id)
309
698
  if tool_result:
699
+ latency = (time.time() - start_time) * 1000
700
+ if return_metrics:
701
+ return ChatResponse(
702
+ text=tool_result,
703
+ confidence=0.95, # Tools are deterministic
704
+ source="tool",
705
+ latency=latency,
706
+ timestamp=datetime.now(),
707
+ kb_results_count=0,
708
+ metadata={"tool_command": True}
709
+ )
310
710
  return tool_result
311
711
 
312
712
  # Knowledge base search (if using SQL)
313
713
  kb_context = ""
314
- if ADVANCED_AVAILABLE and isinstance(self.memory, SQLMemoryManager) and hasattr(self, 'config') and self.config:
315
- if self.config.get("response.use_knowledge_base", True):
714
+ if ADVANCED_AVAILABLE and isinstance(self.memory, SQLMemoryManager):
715
+ # Check config only if it exists, otherwise always use KB
716
+ use_kb = True
717
+ kb_limit = 5
718
+
719
+ if hasattr(self, 'config') and self.config:
720
+ use_kb = self.config.get("response.use_knowledge_base", True)
721
+ kb_limit = self.config.get("knowledge_base.search_limit", 5)
722
+
723
+ if use_kb:
316
724
  try:
317
- kb_results = self.memory.search_knowledge(
318
- query=message,
319
- limit=self.config.get("knowledge_base.search_limit", 5)
320
- )
725
+ kb_results = self.memory.search_knowledge(query=message, limit=kb_limit)
321
726
 
322
727
  if kb_results:
323
- kb_context = "\n\nRelevant Information:\n"
728
+ kb_results_count = len(kb_results)
729
+ used_kb = True
730
+ kb_context = "\n\n📚 RELEVANT KNOWLEDGE BASE:\n"
324
731
  for i, result in enumerate(kb_results, 1):
325
- kb_context += f"{i}. S: {result['question']}\n C: {result['answer']}\n"
732
+ kb_context += f"{i}. Q: {result['question']}\n A: {result['answer']}\n"
733
+ kb_context += "\n⚠️ USE THIS INFORMATION TO ANSWER! Be brief but accurate.\n"
326
734
  except Exception as e:
327
735
  self.logger.error(f"Knowledge base search error: {e}")
328
736
 
@@ -337,6 +745,9 @@ class MemAgent:
337
745
  recent_limit = self.config.get("response.recent_conversations_limit", 5) if hasattr(self, 'config') and self.config else 5
338
746
  recent_convs = self.memory.get_recent_conversations(user_id, recent_limit)
339
747
 
748
+ if recent_convs:
749
+ used_memory = True
750
+
340
751
  # Add conversations in chronological order (oldest first)
341
752
  for conv in recent_convs:
342
753
  messages.append({"role": "user", "content": conv.get('user_message', '')})
@@ -344,27 +755,86 @@ class MemAgent:
344
755
  except Exception as e:
345
756
  self.logger.error(f"Memory history loading error: {e}")
346
757
 
347
- # Add knowledge base context
758
+ # Add current message WITH knowledge base context (if available)
759
+ final_message = message
348
760
  if kb_context:
349
- messages.append({
350
- "role": "system",
351
- "content": f"You can use this information when answering the user's question:{kb_context}"
352
- })
353
-
354
- # Add current message
355
- messages.append({"role": "user", "content": message})
761
+ # Inject KB directly into user message for maximum visibility
762
+ final_message = f"{kb_context}\n\nUser Question: {message}"
763
+
764
+ messages.append({"role": "user", "content": final_message})
356
765
 
357
766
  # Get response from LLM
767
+ temperature = self.config.get("llm.temperature", 0.2) if hasattr(self, 'config') and self.config else 0.2
358
768
  try:
359
769
  response = self.llm.chat(
360
770
  messages=messages,
361
- temperature=self.config.get("llm.temperature", 0.3) if hasattr(self, 'config') and self.config else 0.3, # Lower = more focused
362
- max_tokens=self.config.get("llm.max_tokens", 300) if hasattr(self, 'config') and self.config else 300 # Shorter responses
771
+ temperature=temperature,
772
+ max_tokens=self.config.get("llm.max_tokens", 2000) if hasattr(self, 'config') and self.config else 2000 # Enough tokens for thinking models
363
773
  )
774
+
775
+ # Fallback: If response is empty (can happen with thinking models)
776
+ if not response or response.strip() == "":
777
+ self.logger.warning(f"Empty response from model {self.llm.model}, retrying with simpler prompt...")
778
+
779
+ # Retry with just the current message, no history
780
+ simple_messages = [
781
+ {"role": "system", "content": "You are a helpful assistant. Respond directly and concisely."},
782
+ {"role": "user", "content": message}
783
+ ]
784
+ response = self.llm.chat(simple_messages, temperature=0.7, max_tokens=2000)
785
+
786
+ # If still empty, provide fallback
787
+ if not response or response.strip() == "":
788
+ response = "I'm having trouble responding right now. Could you rephrase your question?"
789
+ self.logger.error(f"Model {self.llm.model} returned empty response even after retry")
790
+
364
791
  except Exception as e:
365
792
  self.logger.error(f"LLM response error: {e}")
366
793
  response = "Sorry, I cannot respond right now. Please try again later."
367
-
794
+
795
+ # Execute tool calls if tools are enabled (v2.0+)
796
+ if self.enable_tools and self.tool_registry and response:
797
+ try:
798
+ response = self._execute_tool_calls(response)
799
+ except Exception as e:
800
+ self.logger.error(f"Tool execution error: {e}")
801
+ # Continue with original response
802
+
803
+ # Calculate latency
804
+ latency = (time.time() - start_time) * 1000
805
+
806
+ # Determine response source
807
+ if used_kb and used_memory:
808
+ response_source = "hybrid"
809
+ elif used_kb:
810
+ response_source = "knowledge_base"
811
+ else:
812
+ response_source = "model"
813
+
814
+ # Calculate confidence score
815
+ confidence = calculate_confidence(
816
+ kb_results_count=kb_results_count,
817
+ temperature=temperature,
818
+ used_memory=used_memory,
819
+ response_length=len(response)
820
+ )
821
+
822
+ # Build enriched metadata with response metrics
823
+ enriched_metadata = {}
824
+ if metadata:
825
+ enriched_metadata.update(metadata)
826
+ enriched_metadata.update({
827
+ "confidence": round(confidence, 3),
828
+ "source": response_source,
829
+ "latency_ms": round(latency, 1),
830
+ "kb_results_count": kb_results_count,
831
+ "used_memory": used_memory,
832
+ "used_kb": used_kb,
833
+ "response_length": len(response),
834
+ "model": self.model,
835
+ "temperature": temperature
836
+ })
837
+
368
838
  # Save interaction
369
839
  try:
370
840
  if hasattr(self.memory, 'add_interaction'):
@@ -372,13 +842,532 @@ class MemAgent:
372
842
  user_id=user_id,
373
843
  user_message=message,
374
844
  bot_response=response,
375
- metadata=metadata
845
+ metadata=enriched_metadata
376
846
  )
847
+
848
+ # Extract and save user info to profile
849
+ self._update_user_profile(user_id, message, response)
850
+
851
+ # Always update summary after each conversation (JSON mode)
852
+ if not self.use_sql and hasattr(self.memory, 'conversations'):
853
+ self._update_conversation_summary(user_id)
854
+ # Save summary update
855
+ if user_id in self.memory.user_profiles:
856
+ self.memory.save_memory(user_id)
377
857
  except Exception as e:
378
858
  self.logger.error(f"Interaction saving error: {e}")
379
-
380
- return response
381
-
859
+
860
+ # Create response metrics object
861
+ chat_response = ChatResponse(
862
+ text=response,
863
+ confidence=confidence,
864
+ source=response_source,
865
+ latency=latency,
866
+ timestamp=datetime.now(),
867
+ kb_results_count=kb_results_count,
868
+ metadata={
869
+ "model": self.model,
870
+ "temperature": temperature,
871
+ "used_memory": used_memory,
872
+ "used_kb": used_kb,
873
+ "user_id": user_id
874
+ }
875
+ )
876
+
877
+ # Track metrics if enabled
878
+ if self.track_metrics:
879
+ self.metrics_analyzer.add_metric(chat_response)
880
+
881
+ # Return based on user preference
882
+ if return_metrics:
883
+ return chat_response
884
+ else:
885
+ return response
886
+
887
+ def chat_stream(self, message: str, user_id: Optional[str] = None, metadata: Optional[Dict] = None) -> Iterator[str]:
888
+ """
889
+ Chat with user using streaming response (real-time)
890
+
891
+ This method streams the response as it's generated, providing a better UX
892
+ for longer responses (like ChatGPT's typing effect).
893
+
894
+ Args:
895
+ message: User's message
896
+ user_id: User ID (optional)
897
+ metadata: Additional information
898
+
899
+ Yields:
900
+ Response text chunks as they arrive from the LLM
901
+
902
+ Example:
903
+ >>> agent = MemAgent()
904
+ >>> agent.set_user("alice")
905
+ >>> for chunk in agent.chat_stream("Python nedir?"):
906
+ ... print(chunk, end='', flush=True)
907
+ Python bir programlama dilidir...
908
+ """
909
+ # Start timing
910
+ start_time = time.time()
911
+
912
+ # Determine user
913
+ if user_id:
914
+ self.set_user(user_id)
915
+ elif not self.current_user:
916
+ yield "Error: User ID not specified."
917
+ return
918
+
919
+ user_id = self.current_user
920
+
921
+ # Initialize tracking variables
922
+ kb_results_count = 0
923
+ used_kb = False
924
+ used_memory = False
925
+
926
+ # Security check (v1.1.0+) - opt-in
927
+ if self.enable_security and self.security_detector and self.security_sanitizer:
928
+ risk_level = self.security_detector.get_risk_level(message)
929
+ is_suspicious, patterns = self.security_detector.detect(message)
930
+
931
+ if risk_level in ["high", "critical"]:
932
+ self.logger.warning(f"🚨 Blocked {risk_level} risk input from {user_id}")
933
+ yield f"⚠️ Your message was blocked due to security concerns. Please rephrase your request."
934
+ return
935
+
936
+ # Sanitize input
937
+ message = self.security_sanitizer.sanitize(message, aggressive=(risk_level == "medium"))
938
+
939
+ # Check tool commands first
940
+ tool_result = self.tool_executor.execute_user_command(message, user_id)
941
+ if tool_result:
942
+ yield tool_result
943
+ return
944
+
945
+ # Knowledge base search (if using SQL)
946
+ kb_context = ""
947
+ if ADVANCED_AVAILABLE and isinstance(self.memory, SQLMemoryManager):
948
+ use_kb = True
949
+ kb_limit = 5
950
+
951
+ if hasattr(self, 'config') and self.config:
952
+ use_kb = self.config.get("response.use_knowledge_base", True)
953
+ kb_limit = self.config.get("knowledge_base.search_limit", 5)
954
+
955
+ if use_kb:
956
+ try:
957
+ kb_results = self.memory.search_knowledge(query=message, limit=kb_limit)
958
+
959
+ if kb_results:
960
+ kb_results_count = len(kb_results)
961
+ used_kb = True
962
+ kb_context = "\n\n📚 RELEVANT KNOWLEDGE BASE:\n"
963
+ for i, result in enumerate(kb_results, 1):
964
+ kb_context += f"{i}. Q: {result['question']}\n A: {result['answer']}\n"
965
+ kb_context += "\n⚠️ USE THIS INFORMATION TO ANSWER! Be brief but accurate.\n"
966
+ except Exception as e:
967
+ self.logger.error(f"Knowledge base search error: {e}")
968
+
969
+ # Get conversation history
970
+ messages = []
971
+ if self.current_system_prompt:
972
+ messages.append({"role": "system", "content": self.current_system_prompt})
973
+
974
+ # Add memory history
975
+ try:
976
+ if hasattr(self.memory, 'get_recent_conversations'):
977
+ recent_limit = self.config.get("response.recent_conversations_limit", 5) if hasattr(self, 'config') and self.config else 5
978
+ recent_convs = self.memory.get_recent_conversations(user_id, recent_limit)
979
+
980
+ if recent_convs:
981
+ used_memory = True
982
+
983
+ # Add conversations in chronological order
984
+ for conv in recent_convs:
985
+ messages.append({"role": "user", "content": conv.get('user_message', '')})
986
+ messages.append({"role": "assistant", "content": conv.get('bot_response', '')})
987
+ except Exception as e:
988
+ self.logger.error(f"Memory history loading error: {e}")
989
+
990
+ # Add current message WITH knowledge base context (if available)
991
+ final_message = message
992
+ if kb_context:
993
+ final_message = f"{kb_context}\n\nUser Question: {message}"
994
+
995
+ messages.append({"role": "user", "content": final_message})
996
+
997
+ # Get streaming response from LLM
998
+ temperature = self.config.get("llm.temperature", 0.2) if hasattr(self, 'config') and self.config else 0.2
999
+ max_tokens = self.config.get("llm.max_tokens", 2000) if hasattr(self, 'config') and self.config else 2000
1000
+
1001
+ # Collect full response for saving
1002
+ full_response = ""
1003
+
1004
+ try:
1005
+ # Stream chunks from LLM
1006
+ for chunk in self.llm.chat_stream(
1007
+ messages=messages,
1008
+ temperature=temperature,
1009
+ max_tokens=max_tokens
1010
+ ):
1011
+ full_response += chunk
1012
+ yield chunk
1013
+
1014
+ except Exception as e:
1015
+ error_msg = f"Streaming error: {str(e)}"
1016
+ self.logger.error(error_msg)
1017
+ yield f"\n\n⚠️ {error_msg}"
1018
+ return
1019
+
1020
+ # Calculate latency
1021
+ latency = (time.time() - start_time) * 1000
1022
+
1023
+ # Determine response source
1024
+ response_source = "model"
1025
+ if used_memory and used_kb:
1026
+ response_source = "hybrid"
1027
+ elif used_kb:
1028
+ response_source = "knowledge_base"
1029
+
1030
+ # Calculate confidence
1031
+ confidence = calculate_confidence(
1032
+ kb_results_count=kb_results_count,
1033
+ temperature=temperature,
1034
+ used_memory=used_memory,
1035
+ response_length=len(full_response)
1036
+ )
1037
+
1038
+ # Build enriched metadata
1039
+ enriched_metadata = {}
1040
+ if metadata:
1041
+ enriched_metadata.update(metadata)
1042
+ enriched_metadata.update({
1043
+ "confidence": round(confidence, 3),
1044
+ "source": response_source,
1045
+ "latency_ms": round(latency, 1),
1046
+ "kb_results_count": kb_results_count,
1047
+ "used_memory": used_memory,
1048
+ "used_kb": used_kb,
1049
+ "response_length": len(full_response),
1050
+ "model": self.model,
1051
+ "temperature": temperature,
1052
+ "streaming": True
1053
+ })
1054
+
1055
+ # Save interaction
1056
+ try:
1057
+ if hasattr(self.memory, 'add_interaction'):
1058
+ self.memory.add_interaction(
1059
+ user_id=user_id,
1060
+ user_message=message,
1061
+ bot_response=full_response,
1062
+ metadata=enriched_metadata
1063
+ )
1064
+
1065
+ # Extract and save user info to profile
1066
+ self._update_user_profile(user_id, message, full_response)
1067
+
1068
+ # Update summary (JSON mode)
1069
+ if not self.use_sql and hasattr(self.memory, 'conversations'):
1070
+ self._update_conversation_summary(user_id)
1071
+ if user_id in self.memory.user_profiles:
1072
+ self.memory.save_memory(user_id)
1073
+ except Exception as e:
1074
+ self.logger.error(f"Interaction saving error: {e}")
1075
+
1076
+ # Track metrics if enabled
1077
+ if self.track_metrics:
1078
+ chat_response = ChatResponse(
1079
+ text=full_response,
1080
+ confidence=confidence,
1081
+ source=response_source,
1082
+ latency=latency,
1083
+ timestamp=datetime.now(),
1084
+ kb_results_count=kb_results_count,
1085
+ metadata={
1086
+ "model": self.model,
1087
+ "temperature": temperature,
1088
+ "used_memory": used_memory,
1089
+ "used_kb": used_kb,
1090
+ "user_id": user_id,
1091
+ "streaming": True
1092
+ }
1093
+ )
1094
+ self.metrics_analyzer.add_metric(chat_response)
1095
+
1096
+ def _update_user_profile(self, user_id: str, message: str, response: str):
1097
+ """Extract user info from conversation and update profile"""
1098
+ msg_lower = message.lower()
1099
+
1100
+ # Extract information
1101
+ extracted = {}
1102
+
1103
+ # Extract name
1104
+ if "my name is" in msg_lower or "i am" in msg_lower or "i'm" in msg_lower or "adım" in msg_lower or "ismim" in msg_lower:
1105
+ for phrase in ["my name is ", "i am ", "i'm ", "adım ", "ismim ", "benim adım "]:
1106
+ if phrase in msg_lower:
1107
+ name_part = message[msg_lower.index(phrase) + len(phrase):].strip()
1108
+ name = name_part.split()[0] if name_part else None
1109
+ if name and len(name) > 1:
1110
+ extracted['name'] = name.strip('.,!?')
1111
+ break
1112
+
1113
+ # Extract favorite food
1114
+ if "favorite food" in msg_lower or "favourite food" in msg_lower or "sevdiğim yemek" in msg_lower or "en sevdiğim" in msg_lower:
1115
+ if "is" in msg_lower or ":" in msg_lower:
1116
+ food = msg_lower.split("is")[-1].strip() if "is" in msg_lower else msg_lower.split(":")[-1].strip()
1117
+ food = food.strip('.,!?')
1118
+ if food and len(food) < 50:
1119
+ extracted['favorite_food'] = food
1120
+
1121
+ # Extract location
1122
+ if "i live in" in msg_lower or "i'm from" in msg_lower or "yaşıyorum" in msg_lower or "yaşadığım" in msg_lower:
1123
+ for phrase in ["i live in ", "i'm from ", "from ", "yaşıyorum", "yaşadığım yer", "yaşadığım şehir"]:
1124
+ if phrase in msg_lower:
1125
+ loc = message[msg_lower.index(phrase) + len(phrase):].strip()
1126
+ location = loc.split()[0] if loc else None
1127
+ if location and len(location) > 2:
1128
+ extracted['location'] = location.strip('.,!?')
1129
+ break
1130
+
1131
+ # Save updates
1132
+ if extracted:
1133
+ try:
1134
+ # SQL memory - store in preferences JSON
1135
+ if hasattr(self.memory, 'update_user_profile'):
1136
+ # Get current profile
1137
+ profile = self.memory.get_user_profile(user_id) or {}
1138
+
1139
+ # Update name directly if extracted
1140
+ updates = {}
1141
+ if 'name' in extracted:
1142
+ updates['name'] = extracted.pop('name')
1143
+
1144
+ # Store other info in preferences
1145
+ if extracted:
1146
+ current_prefs = profile.get('preferences')
1147
+ if current_prefs:
1148
+ try:
1149
+ prefs = json.loads(current_prefs) if isinstance(current_prefs, str) else current_prefs
1150
+ except:
1151
+ prefs = {}
1152
+ else:
1153
+ prefs = {}
1154
+
1155
+ prefs.update(extracted)
1156
+ updates['preferences'] = json.dumps(prefs)
1157
+
1158
+ if updates:
1159
+ self.memory.update_user_profile(user_id, updates)
1160
+ self.logger.debug(f"Profile updated for {user_id}: {extracted}")
1161
+
1162
+ # JSON memory - direct update
1163
+ elif hasattr(self.memory, 'update_profile'):
1164
+ # Load memory if not already loaded
1165
+ if user_id not in self.memory.user_profiles:
1166
+ self.memory.load_memory(user_id)
1167
+
1168
+ # For JSON memory, merge into preferences
1169
+ current_profile = self.memory.user_profiles.get(user_id, {})
1170
+ current_prefs = current_profile.get('preferences', {})
1171
+
1172
+ # Handle case where preferences might be a JSON string
1173
+ if isinstance(current_prefs, str):
1174
+ try:
1175
+ current_prefs = json.loads(current_prefs)
1176
+ except:
1177
+ current_prefs = {}
1178
+
1179
+ # Update preferences
1180
+ if extracted:
1181
+ current_prefs.update(extracted)
1182
+ self.memory.user_profiles[user_id]['preferences'] = current_prefs
1183
+
1184
+ # Update name if extracted
1185
+ if 'name' in extracted:
1186
+ self.memory.user_profiles[user_id]['name'] = extracted['name']
1187
+
1188
+ # Auto-generate summary from conversation history
1189
+ self._update_conversation_summary(user_id)
1190
+
1191
+ # Save to disk
1192
+ self.memory.save_memory(user_id)
1193
+ self.logger.debug(f"Profile updated for {user_id}: {extracted}")
1194
+ except Exception as e:
1195
+ self.logger.error(f"Error updating profile: {e}")
1196
+
1197
+ def _update_conversation_summary(self, user_id: str) -> None:
1198
+ """
1199
+ Auto-generate conversation summary for user profile
1200
+
1201
+ Args:
1202
+ user_id: User ID
1203
+ """
1204
+ try:
1205
+ if not hasattr(self.memory, 'conversations'):
1206
+ return
1207
+
1208
+ # Ensure memory is loaded
1209
+ if user_id not in self.memory.conversations:
1210
+ self.memory.load_memory(user_id)
1211
+
1212
+ conversations = self.memory.conversations.get(user_id, [])
1213
+ if not conversations:
1214
+ return
1215
+
1216
+ # Get recent conversations for summary
1217
+ recent_convs = conversations[-10:] # Last 10 conversations
1218
+
1219
+ # Extract topics/interests
1220
+ all_messages = " ".join([c.get('user_message', '') for c in recent_convs])
1221
+ topics = self._extract_topics(all_messages)
1222
+
1223
+ # Calculate engagement stats
1224
+ total_interactions = len(conversations)
1225
+ avg_response_length = sum(len(c.get('bot_response', '')) for c in recent_convs) / len(recent_convs) if recent_convs else 0
1226
+
1227
+ # Build summary
1228
+ summary = {
1229
+ "total_interactions": total_interactions,
1230
+ "topics_of_interest": topics[:5] if topics else [], # Top 5 topics
1231
+ "avg_response_length": round(avg_response_length, 0),
1232
+ "last_active": recent_convs[-1].get('timestamp') if recent_convs else None,
1233
+ "engagement_level": "high" if total_interactions > 20 else ("medium" if total_interactions > 5 else "low")
1234
+ }
1235
+
1236
+ # Update profile summary (JSON mode)
1237
+ if user_id in self.memory.user_profiles:
1238
+ self.memory.user_profiles[user_id]['summary'] = summary
1239
+
1240
+ except Exception as e:
1241
+ self.logger.debug(f"Summary generation skipped: {e}")
1242
+
1243
+ def _extract_topics(self, text: str) -> List[str]:
1244
+ """
1245
+ Extract key topics/interests from conversation text
1246
+
1247
+ Args:
1248
+ text: Combined conversation text
1249
+
1250
+ Returns:
1251
+ List of extracted topics
1252
+ """
1253
+ # Simple keyword extraction (can be enhanced with NLP)
1254
+ keywords_map = {
1255
+ "python": "Python Programming",
1256
+ "javascript": "JavaScript",
1257
+ "coding": "Programming",
1258
+ "weather": "Weather",
1259
+ "food": "Food & Dining",
1260
+ "music": "Music",
1261
+ "sport": "Sports",
1262
+ "travel": "Travel",
1263
+ "work": "Work",
1264
+ "help": "Support",
1265
+ "problem": "Problem Solving",
1266
+ "question": "Questions",
1267
+ "chat": "Chatting"
1268
+ }
1269
+
1270
+ text_lower = text.lower()
1271
+ found_topics = []
1272
+
1273
+ for keyword, topic in keywords_map.items():
1274
+ if keyword in text_lower:
1275
+ found_topics.append(topic)
1276
+
1277
+ # Remove duplicates while preserving order
1278
+ seen = set()
1279
+ unique_topics = []
1280
+ for topic in found_topics:
1281
+ if topic not in seen:
1282
+ seen.add(topic)
1283
+ unique_topics.append(topic)
1284
+
1285
+ return unique_topics
1286
+
1287
+ def get_user_profile(self, user_id: Optional[str] = None) -> Dict:
1288
+ """
1289
+ Get user's profile info
1290
+
1291
+ Args:
1292
+ user_id: User ID (uses current_user if not specified)
1293
+
1294
+ Returns:
1295
+ User profile dictionary with all info (name, favorite_food, location, etc.)
1296
+ """
1297
+ uid = user_id or self.current_user
1298
+ if not uid:
1299
+ return {}
1300
+
1301
+ try:
1302
+ # Check if SQL or JSON memory - SQL has SQLMemoryManager type
1303
+ if ADVANCED_AVAILABLE and isinstance(self.memory, SQLMemoryManager):
1304
+ # SQL memory - merge preferences into main dict
1305
+ profile = self.memory.get_user_profile(uid)
1306
+ if not profile:
1307
+ return {}
1308
+
1309
+ # Parse preferences JSON if exists
1310
+ result = {
1311
+ 'user_id': profile.get('user_id'),
1312
+ 'name': profile.get('name'),
1313
+ 'first_seen': profile.get('first_seen'),
1314
+ 'last_interaction': profile.get('last_interaction'),
1315
+ }
1316
+
1317
+ # Merge preferences
1318
+ prefs_str = profile.get('preferences')
1319
+ if prefs_str:
1320
+ try:
1321
+ prefs = json.loads(prefs_str) if isinstance(prefs_str, str) else prefs_str
1322
+ result.update(prefs) # Add favorite_food, location, etc.
1323
+ except:
1324
+ pass
1325
+
1326
+ return result
1327
+ else:
1328
+ # JSON memory - reload from disk to get latest data
1329
+ memory_data = self.memory.load_memory(uid)
1330
+ profile = memory_data.get('profile', {}).copy() # Make a copy to avoid modifying cached data
1331
+
1332
+ # Parse preferences if it's a JSON string
1333
+ if isinstance(profile.get('preferences'), str):
1334
+ try:
1335
+ profile['preferences'] = json.loads(profile['preferences'])
1336
+ except:
1337
+ profile['preferences'] = {}
1338
+
1339
+ # Return profile as-is (summary should already be there if it was generated)
1340
+ # Only regenerate if truly missing
1341
+ summary_value = profile.get('summary')
1342
+ summary_is_empty = (not summary_value or
1343
+ (isinstance(summary_value, dict) and len(summary_value) == 0))
1344
+
1345
+ if summary_is_empty:
1346
+ # Try to regenerate summary if missing (for old users)
1347
+ # Ensure conversations are loaded
1348
+ if uid not in self.memory.conversations:
1349
+ self.memory.load_memory(uid)
1350
+
1351
+ if uid in self.memory.conversations and len(self.memory.conversations[uid]) > 0:
1352
+ self._update_conversation_summary(uid)
1353
+ # Save the updated summary
1354
+ if uid in self.memory.user_profiles:
1355
+ self.memory.save_memory(uid)
1356
+ # Reload to get updated summary
1357
+ memory_data = self.memory.load_memory(uid)
1358
+ profile = memory_data.get('profile', {}).copy()
1359
+ # Parse preferences again after reload
1360
+ if isinstance(profile.get('preferences'), str):
1361
+ try:
1362
+ profile['preferences'] = json.loads(profile['preferences'])
1363
+ except:
1364
+ profile['preferences'] = {}
1365
+
1366
+ return profile
1367
+ except Exception as e:
1368
+ self.logger.error(f"Error getting user profile: {e}")
1369
+ return {}
1370
+
382
1371
  def add_knowledge(self, category: str, question: str, answer: str,
383
1372
  keywords: Optional[List[str]] = None, priority: int = 0) -> int:
384
1373
  """Add new record to knowledge base"""
@@ -505,6 +1494,108 @@ class MemAgent:
505
1494
  return self.tool_executor.memory_tools.list_available_tools()
506
1495
  else:
507
1496
  return "Tool system not available."
1497
+
1498
+ # === METRICS & ANALYTICS METHODS (v1.3.1+) ===
1499
+
1500
+ def get_response_metrics(self, last_n: Optional[int] = None) -> Dict[str, Any]:
1501
+ """
1502
+ Get response quality metrics summary
1503
+
1504
+ Args:
1505
+ last_n: Analyze only last N responses (None = all)
1506
+
1507
+ Returns:
1508
+ Metrics summary dictionary
1509
+
1510
+ Example:
1511
+ >>> agent.get_response_metrics(last_n=10)
1512
+ {
1513
+ 'total_responses': 10,
1514
+ 'avg_latency_ms': 245.3,
1515
+ 'avg_confidence': 0.82,
1516
+ 'kb_usage_rate': 0.6,
1517
+ 'source_distribution': {'knowledge_base': 6, 'model': 4},
1518
+ 'fast_response_rate': 0.9
1519
+ }
1520
+ """
1521
+ return self.metrics_analyzer.get_summary(last_n)
1522
+
1523
+ def get_latest_response_metric(self) -> Optional[ChatResponse]:
1524
+ """
1525
+ Get the most recent response metric
1526
+
1527
+ Returns:
1528
+ Latest ChatResponse object or None if no metrics
1529
+ """
1530
+ if not self.metrics_analyzer.metrics_history:
1531
+ return None
1532
+ return self.metrics_analyzer.metrics_history[-1]
1533
+
1534
+ def get_average_confidence(self, last_n: Optional[int] = None) -> float:
1535
+ """
1536
+ Get average confidence score
1537
+
1538
+ Args:
1539
+ last_n: Analyze only last N responses (None = all)
1540
+
1541
+ Returns:
1542
+ Average confidence (0.0-1.0)
1543
+ """
1544
+ return self.metrics_analyzer.get_average_confidence(last_n)
1545
+
1546
+ def get_kb_usage_rate(self, last_n: Optional[int] = None) -> float:
1547
+ """
1548
+ Get knowledge base usage rate
1549
+
1550
+ Args:
1551
+ last_n: Analyze only last N responses (None = all)
1552
+
1553
+ Returns:
1554
+ KB usage rate (0.0-1.0)
1555
+ """
1556
+ return self.metrics_analyzer.get_kb_usage_rate(last_n)
1557
+
1558
+ def clear_metrics(self) -> None:
1559
+ """Clear all metrics history"""
1560
+ self.metrics_analyzer.clear_history()
1561
+ self.logger.info("Metrics history cleared")
1562
+
1563
+ def export_metrics(self, format: str = "json") -> str:
1564
+ """
1565
+ Export metrics data
1566
+
1567
+ Args:
1568
+ format: Export format ('json' or 'summary')
1569
+
1570
+ Returns:
1571
+ Formatted metrics data
1572
+ """
1573
+ summary = self.get_response_metrics()
1574
+
1575
+ if format == "json":
1576
+ return json.dumps(summary, ensure_ascii=False, indent=2)
1577
+ elif format == "summary":
1578
+ lines = [
1579
+ "📊 RESPONSE METRICS SUMMARY",
1580
+ "=" * 60,
1581
+ f"Total Responses: {summary['total_responses']}",
1582
+ f"Avg Latency: {summary['avg_latency_ms']:.1f} ms",
1583
+ f"Avg Confidence: {summary['avg_confidence']:.2%}",
1584
+ f"KB Usage Rate: {summary['kb_usage_rate']:.2%}",
1585
+ f"Fast Response Rate: {summary['fast_response_rate']:.2%}",
1586
+ "",
1587
+ "Source Distribution:",
1588
+ ]
1589
+ for source, count in summary['source_distribution'].items():
1590
+ lines.append(f" - {source:20s}: {count}")
1591
+
1592
+ lines.extend(["", "Quality Distribution:"])
1593
+ for quality, count in summary.get('quality_distribution', {}).items():
1594
+ lines.append(f" - {quality:20s}: {count}")
1595
+
1596
+ return "\n".join(lines)
1597
+ else:
1598
+ return "Unsupported format. Use 'json' or 'summary'."
508
1599
 
509
1600
  def close(self) -> None:
510
1601
  """Clean up resources"""