ciyidogan commited on
Commit
f91801d
·
verified ·
1 Parent(s): f8b2916

Upload 4 files

Browse files
Files changed (4) hide show
  1. config-provider.py +582 -0
  2. exceptions.py +176 -0
  3. logger.py +186 -0
  4. session.py +285 -141
config-provider.py ADDED
@@ -0,0 +1,582 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Thread-Safe Configuration Provider for Flare Platform
3
+ """
4
+ import threading
5
+ import os
6
+ import json
7
+ import commentjson
8
+ from typing import Optional, Dict, Any, List
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ import tempfile
12
+ import shutil
13
+
14
+ from config_models import (
15
+ ServiceConfig, GlobalConfig, ProjectConfig, VersionConfig,
16
+ IntentConfig, APIConfig, ActivityLogEntry
17
+ )
18
+ from logger import log_info, log_error, log_warning, log_debug, LogTimer
19
+ from exceptions import (
20
+ RaceConditionError, ConfigurationError, ResourceNotFoundError,
21
+ DuplicateResourceError, ValidationError
22
+ )
23
+ from encryption_utils import encrypt, decrypt
24
+
25
+ class ConfigProvider:
26
+ """Thread-safe singleton configuration provider"""
27
+
28
+ _instance: Optional[ServiceConfig] = None
29
+ _lock = threading.RLock() # Reentrant lock for nested calls
30
+ _file_lock = threading.Lock() # Separate lock for file operations
31
+ _CONFIG_PATH = Path(__file__).parent / "service_config.jsonc"
32
+
33
+ @classmethod
34
+ def get(cls) -> ServiceConfig:
35
+ """Get cached configuration - thread-safe"""
36
+ if cls._instance is None:
37
+ with cls._lock:
38
+ # Double-checked locking pattern
39
+ if cls._instance is None:
40
+ with LogTimer("config_load"):
41
+ cls._instance = cls._load()
42
+ cls._instance.build_index()
43
+ log_info("Configuration loaded successfully")
44
+ return cls._instance
45
+
46
+ @classmethod
47
+ def reload(cls) -> ServiceConfig:
48
+ """Force reload configuration from file"""
49
+ with cls._lock:
50
+ log_info("Reloading configuration...")
51
+ cls._instance = None
52
+ return cls.get()
53
+
54
+ @classmethod
55
+ def _load(cls) -> ServiceConfig:
56
+ """Load configuration from file"""
57
+ try:
58
+ if not cls._CONFIG_PATH.exists():
59
+ raise ConfigurationError(
60
+ f"Config file not found: {cls._CONFIG_PATH}",
61
+ config_key="service_config.jsonc"
62
+ )
63
+
64
+ with open(cls._CONFIG_PATH, 'r', encoding='utf-8') as f:
65
+ config_data = commentjson.load(f)
66
+
67
+ # Ensure required fields
68
+ if 'config' not in config_data:
69
+ config_data['config'] = {}
70
+
71
+ # Parse API configs (handle JSON strings)
72
+ if 'apis' in config_data:
73
+ cls._parse_api_configs(config_data['apis'])
74
+
75
+ # Validate and create model
76
+ cfg = ServiceConfig.model_validate(config_data)
77
+
78
+ log_debug(
79
+ "Configuration loaded",
80
+ projects=len(cfg.projects),
81
+ apis=len(cfg.apis),
82
+ users=len(cfg.global_config.users)
83
+ )
84
+
85
+ return cfg
86
+
87
+ except Exception as e:
88
+ log_error(f"Error loading config", error=str(e), path=str(cls._CONFIG_PATH))
89
+ raise ConfigurationError(f"Failed to load configuration: {e}")
90
+
91
+ @classmethod
92
+ def _parse_api_configs(cls, apis: List[Dict[str, Any]]) -> None:
93
+ """Parse JSON string fields in API configs"""
94
+ for api in apis:
95
+ # Parse headers
96
+ if 'headers' in api and isinstance(api['headers'], str):
97
+ try:
98
+ api['headers'] = json.loads(api['headers'])
99
+ except json.JSONDecodeError:
100
+ api['headers'] = {}
101
+
102
+ # Parse body_template
103
+ if 'body_template' in api and isinstance(api['body_template'], str):
104
+ try:
105
+ api['body_template'] = json.loads(api['body_template'])
106
+ except json.JSONDecodeError:
107
+ api['body_template'] = {}
108
+
109
+ # Parse auth configs
110
+ if 'auth' in api and api['auth']:
111
+ cls._parse_auth_config(api['auth'])
112
+
113
+ @classmethod
114
+ def _parse_auth_config(cls, auth: Dict[str, Any]) -> None:
115
+ """Parse auth configuration"""
116
+ # Parse token_request_body
117
+ if 'token_request_body' in auth and isinstance(auth['token_request_body'], str):
118
+ try:
119
+ auth['token_request_body'] = json.loads(auth['token_request_body'])
120
+ except json.JSONDecodeError:
121
+ auth['token_request_body'] = {}
122
+
123
+ # Parse token_refresh_body
124
+ if 'token_refresh_body' in auth and isinstance(auth['token_refresh_body'], str):
125
+ try:
126
+ auth['token_refresh_body'] = json.loads(auth['token_refresh_body'])
127
+ except json.JSONDecodeError:
128
+ auth['token_refresh_body'] = {}
129
+
130
+ @classmethod
131
+ def save(cls, config: ServiceConfig, username: str) -> None:
132
+ """Thread-safe configuration save with optimistic locking"""
133
+ with cls._file_lock:
134
+ try:
135
+ # Load current config for race condition check
136
+ current_config = cls._load()
137
+
138
+ # Check for race condition
139
+ if config.last_update_date and current_config.last_update_date:
140
+ if config.last_update_date != current_config.last_update_date:
141
+ raise RaceConditionError(
142
+ "Configuration was modified by another user",
143
+ current_user=username,
144
+ last_update_user=current_config.last_update_user,
145
+ last_update_date=current_config.last_update_date,
146
+ entity_type="configuration"
147
+ )
148
+
149
+ # Update metadata
150
+ config.last_update_date = datetime.utcnow().isoformat()
151
+ config.last_update_user = username
152
+
153
+ # Convert to JSON
154
+ data = config.to_jsonc_dict()
155
+ json_str = json.dumps(data, ensure_ascii=False, indent=2)
156
+
157
+ # Atomic write using temp file
158
+ with tempfile.NamedTemporaryFile(
159
+ mode='w',
160
+ encoding='utf-8',
161
+ dir=cls._CONFIG_PATH.parent,
162
+ delete=False
163
+ ) as tmp_file:
164
+ tmp_file.write(json_str)
165
+ temp_path = Path(tmp_file.name)
166
+
167
+ # Atomic rename
168
+ temp_path.replace(cls._CONFIG_PATH)
169
+
170
+ # Update cache
171
+ with cls._lock:
172
+ cls._instance = config
173
+ cls._instance.build_index()
174
+
175
+ log_info(
176
+ "Configuration saved",
177
+ user=username,
178
+ size=len(json_str)
179
+ )
180
+
181
+ except Exception as e:
182
+ log_error("Failed to save configuration", error=str(e), user=username)
183
+ raise
184
+
185
+ # ===================== Environment Methods =====================
186
+
187
+ @classmethod
188
+ def update_environment(cls, update_data: dict, username: str) -> None:
189
+ """Update environment configuration"""
190
+ with cls._lock:
191
+ config = cls.get()
192
+
193
+ # Update providers
194
+ if 'llm_provider' in update_data:
195
+ config.global_config.llm_provider = update_data['llm_provider']
196
+
197
+ if 'tts_provider' in update_data:
198
+ config.global_config.tts_provider = update_data['tts_provider']
199
+
200
+ if 'stt_provider' in update_data:
201
+ config.global_config.stt_provider = update_data['stt_provider']
202
+
203
+ # Log activity
204
+ cls._add_activity(
205
+ config, username, "UPDATE_ENVIRONMENT",
206
+ "environment", None, None,
207
+ f"Updated providers"
208
+ )
209
+
210
+ # Save
211
+ cls.save(config, username)
212
+
213
+ # ===================== Project Methods =====================
214
+
215
+ @classmethod
216
+ def get_project(cls, project_id: int) -> Optional[ProjectConfig]:
217
+ """Get project by ID"""
218
+ config = cls.get()
219
+ return next((p for p in config.projects if p.id == project_id), None)
220
+
221
+ @classmethod
222
+ def create_project(cls, project_data: dict, username: str) -> ProjectConfig:
223
+ """Create new project with thread safety"""
224
+ with cls._lock:
225
+ config = cls.get()
226
+
227
+ # Check for duplicate name
228
+ if any(p.name == project_data['name'] for p in config.projects):
229
+ raise DuplicateResourceError("project", project_data['name'])
230
+
231
+ # Create project
232
+ project = ProjectConfig(
233
+ id=config.project_id_counter,
234
+ created_date=datetime.utcnow().isoformat(),
235
+ created_by=username,
236
+ **project_data
237
+ )
238
+
239
+ # Update config
240
+ config.projects.append(project)
241
+ config.project_id_counter += 1
242
+
243
+ # Log activity
244
+ cls._add_activity(
245
+ config, username, "CREATE_PROJECT",
246
+ "project", project.id, project.name
247
+ )
248
+
249
+ # Save
250
+ cls.save(config, username)
251
+
252
+ log_info(
253
+ "Project created",
254
+ project_id=project.id,
255
+ name=project.name,
256
+ user=username
257
+ )
258
+
259
+ return project
260
+
261
+ @classmethod
262
+ def update_project(cls, project_id: int, update_data: dict, username: str) -> ProjectConfig:
263
+ """Update project with optimistic locking"""
264
+ with cls._lock:
265
+ config = cls.get()
266
+ project = cls.get_project(project_id)
267
+
268
+ if not project:
269
+ raise ResourceNotFoundError("project", project_id)
270
+
271
+ # Check race condition
272
+ if 'last_update_date' in update_data:
273
+ if project.last_update_date != update_data['last_update_date']:
274
+ raise RaceConditionError(
275
+ f"Project '{project.name}' was modified by another user",
276
+ current_user=username,
277
+ last_update_user=project.last_update_user,
278
+ last_update_date=project.last_update_date,
279
+ entity_type="project",
280
+ entity_id=project_id
281
+ )
282
+
283
+ # Update fields
284
+ for key, value in update_data.items():
285
+ if hasattr(project, key) and key not in ['id', 'created_date', 'created_by']:
286
+ setattr(project, key, value)
287
+
288
+ project.last_update_date = datetime.utcnow().isoformat()
289
+ project.last_update_user = username
290
+
291
+ # Log activity
292
+ cls._add_activity(
293
+ config, username, "UPDATE_PROJECT",
294
+ "project", project.id, project.name
295
+ )
296
+
297
+ # Save
298
+ cls.save(config, username)
299
+
300
+ log_info(
301
+ "Project updated",
302
+ project_id=project.id,
303
+ user=username
304
+ )
305
+
306
+ return project
307
+
308
+ @classmethod
309
+ def delete_project(cls, project_id: int, username: str) -> None:
310
+ """Soft delete project"""
311
+ with cls._lock:
312
+ config = cls.get()
313
+ project = cls.get_project(project_id)
314
+
315
+ if not project:
316
+ raise ResourceNotFoundError("project", project_id)
317
+
318
+ project.deleted = True
319
+ project.last_update_date = datetime.utcnow().isoformat()
320
+ project.last_update_user = username
321
+
322
+ # Log activity
323
+ cls._add_activity(
324
+ config, username, "DELETE_PROJECT",
325
+ "project", project.id, project.name
326
+ )
327
+
328
+ # Save
329
+ cls.save(config, username)
330
+
331
+ log_info(
332
+ "Project deleted",
333
+ project_id=project.id,
334
+ user=username
335
+ )
336
+
337
+ # ===================== Version Methods =====================
338
+
339
+ @classmethod
340
+ def create_version(cls, project_id: int, version_data: dict, username: str) -> VersionConfig:
341
+ """Create new version"""
342
+ with cls._lock:
343
+ config = cls.get()
344
+ project = cls.get_project(project_id)
345
+
346
+ if not project:
347
+ raise ResourceNotFoundError("project", project_id)
348
+
349
+ # Create version
350
+ version = VersionConfig(
351
+ id=project.version_id_counter,
352
+ no=project.version_id_counter,
353
+ created_date=datetime.utcnow().isoformat(),
354
+ created_by=username,
355
+ **version_data
356
+ )
357
+
358
+ # Update project
359
+ project.versions.append(version)
360
+ project.version_id_counter += 1
361
+ project.last_update_date = datetime.utcnow().isoformat()
362
+ project.last_update_user = username
363
+
364
+ # Log activity
365
+ cls._add_activity(
366
+ config, username, "CREATE_VERSION",
367
+ "version", version.id, f"{project.name} v{version.no}",
368
+ f"Project: {project.name}"
369
+ )
370
+
371
+ # Save
372
+ cls.save(config, username)
373
+
374
+ log_info(
375
+ "Version created",
376
+ project_id=project.id,
377
+ version_id=version.id,
378
+ user=username
379
+ )
380
+
381
+ return version
382
+
383
+ @classmethod
384
+ def publish_version(cls, project_id: int, version_id: int, username: str) -> VersionConfig:
385
+ """Publish a version"""
386
+ with cls._lock:
387
+ config = cls.get()
388
+ project = cls.get_project(project_id)
389
+
390
+ if not project:
391
+ raise ResourceNotFoundError("project", project_id)
392
+
393
+ version = next((v for v in project.versions if v.id == version_id), None)
394
+ if not version:
395
+ raise ResourceNotFoundError("version", version_id)
396
+
397
+ # Unpublish other versions
398
+ for v in project.versions:
399
+ if v.published and v.id != version_id:
400
+ v.published = False
401
+
402
+ # Publish this version
403
+ version.published = True
404
+ version.publish_date = datetime.utcnow().isoformat()
405
+ version.published_by = username
406
+
407
+ # Update project
408
+ project.last_update_date = datetime.utcnow().isoformat()
409
+ project.last_update_user = username
410
+
411
+ # Log activity
412
+ cls._add_activity(
413
+ config, username, "PUBLISH_VERSION",
414
+ "version", version.id, f"{project.name} v{version.no}",
415
+ f"Published version {version.no}"
416
+ )
417
+
418
+ # Save
419
+ cls.save(config, username)
420
+
421
+ log_info(
422
+ "Version published",
423
+ project_id=project.id,
424
+ version_id=version.id,
425
+ user=username
426
+ )
427
+
428
+ return version
429
+
430
+ # ===================== API Methods =====================
431
+
432
+ @classmethod
433
+ def create_api(cls, api_data: dict, username: str) -> APIConfig:
434
+ """Create new API"""
435
+ with cls._lock:
436
+ config = cls.get()
437
+
438
+ # Check for duplicate name
439
+ if any(a.name == api_data['name'] for a in config.apis):
440
+ raise DuplicateResourceError("api", api_data['name'])
441
+
442
+ # Create API
443
+ api = APIConfig(
444
+ created_date=datetime.utcnow().isoformat(),
445
+ created_by=username,
446
+ **api_data
447
+ )
448
+
449
+ # Add to config
450
+ config.apis.append(api)
451
+
452
+ # Rebuild index
453
+ config.build_index()
454
+
455
+ # Log activity
456
+ cls._add_activity(
457
+ config, username, "CREATE_API",
458
+ "api", api.name, api.name
459
+ )
460
+
461
+ # Save
462
+ cls.save(config, username)
463
+
464
+ log_info(
465
+ "API created",
466
+ api_name=api.name,
467
+ user=username
468
+ )
469
+
470
+ return api
471
+
472
+ @classmethod
473
+ def update_api(cls, api_name: str, update_data: dict, username: str) -> APIConfig:
474
+ """Update API configuration"""
475
+ with cls._lock:
476
+ config = cls.get()
477
+ api = config.get_api(api_name)
478
+
479
+ if not api:
480
+ raise ResourceNotFoundError("api", api_name)
481
+
482
+ # Check race condition
483
+ if 'last_update_date' in update_data:
484
+ if api.last_update_date != update_data['last_update_date']:
485
+ raise RaceConditionError(
486
+ f"API '{api_name}' was modified by another user",
487
+ current_user=username,
488
+ last_update_user=api.last_update_user,
489
+ last_update_date=api.last_update_date,
490
+ entity_type="api",
491
+ entity_id=api_name
492
+ )
493
+
494
+ # Update fields
495
+ for key, value in update_data.items():
496
+ if hasattr(api, key) and key not in ['name', 'created_date', 'created_by']:
497
+ setattr(api, key, value)
498
+
499
+ api.last_update_date = datetime.utcnow().isoformat()
500
+ api.last_update_user = username
501
+
502
+ # Rebuild index
503
+ config.build_index()
504
+
505
+ # Log activity
506
+ cls._add_activity(
507
+ config, username, "UPDATE_API",
508
+ "api", api.name, api.name
509
+ )
510
+
511
+ # Save
512
+ cls.save(config, username)
513
+
514
+ log_info(
515
+ "API updated",
516
+ api_name=api.name,
517
+ user=username
518
+ )
519
+
520
+ return api
521
+
522
+ @classmethod
523
+ def delete_api(cls, api_name: str, username: str) -> None:
524
+ """Soft delete API"""
525
+ with cls._lock:
526
+ config = cls.get()
527
+ api = config.get_api(api_name)
528
+
529
+ if not api:
530
+ raise ResourceNotFoundError("api", api_name)
531
+
532
+ api.deleted = True
533
+ api.last_update_date = datetime.utcnow().isoformat()
534
+ api.last_update_user = username
535
+
536
+ # Rebuild index
537
+ config.build_index()
538
+
539
+ # Log activity
540
+ cls._add_activity(
541
+ config, username, "DELETE_API",
542
+ "api", api.name, api.name
543
+ )
544
+
545
+ # Save
546
+ cls.save(config, username)
547
+
548
+ log_info(
549
+ "API deleted",
550
+ api_name=api.name,
551
+ user=username
552
+ )
553
+
554
+ # ===================== Helper Methods =====================
555
+
556
+ @classmethod
557
+ def _add_activity(
558
+ cls,
559
+ config: ServiceConfig,
560
+ username: str,
561
+ action: str,
562
+ entity_type: str,
563
+ entity_id: Any,
564
+ entity_name: Optional[str] = None,
565
+ details: Optional[str] = None
566
+ ) -> None:
567
+ """Add activity log entry"""
568
+ activity = ActivityLogEntry(
569
+ timestamp=datetime.utcnow().isoformat(),
570
+ username=username,
571
+ action=action,
572
+ entity_type=entity_type,
573
+ entity_id=str(entity_id) if entity_id else None,
574
+ entity_name=entity_name,
575
+ details=details
576
+ )
577
+
578
+ config.activity_log.append(activity)
579
+
580
+ # Keep only last 1000 entries
581
+ if len(config.activity_log) > 1000:
582
+ config.activity_log = config.activity_log[-1000:]
exceptions.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Custom Exception Classes for Flare Platform
3
+ """
4
+ from typing import Optional, Dict, Any
5
+ from datetime import datetime
6
+
7
+ class FlareException(Exception):
8
+ """Base exception for all Flare errors"""
9
+ def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
10
+ super().__init__(message)
11
+ self.message = message
12
+ self.details = details or {}
13
+ self.timestamp = datetime.utcnow().isoformat()
14
+
15
+ def to_dict(self) -> Dict[str, Any]:
16
+ """Convert exception to dictionary for API responses"""
17
+ return {
18
+ "error": self.__class__.__name__,
19
+ "message": self.message,
20
+ "details": self.details,
21
+ "timestamp": self.timestamp
22
+ }
23
+
24
+ class RaceConditionError(FlareException):
25
+ """Raised when a race condition is detected during concurrent updates"""
26
+ def __init__(
27
+ self,
28
+ message: str,
29
+ current_user: Optional[str] = None,
30
+ last_update_user: Optional[str] = None,
31
+ last_update_date: Optional[str] = None,
32
+ entity_type: Optional[str] = None,
33
+ entity_id: Optional[Any] = None
34
+ ):
35
+ details = {
36
+ "current_user": current_user,
37
+ "last_update_user": last_update_user,
38
+ "last_update_date": last_update_date,
39
+ "entity_type": entity_type,
40
+ "entity_id": entity_id,
41
+ "action": "Please reload the data and try again"
42
+ }
43
+ super().__init__(message, details)
44
+ self.current_user = current_user
45
+ self.last_update_user = last_update_user
46
+ self.last_update_date = last_update_date
47
+
48
+ class ConfigurationError(FlareException):
49
+ """Raised when there's a configuration issue"""
50
+ def __init__(self, message: str, config_key: Optional[str] = None):
51
+ details = {"config_key": config_key} if config_key else {}
52
+ super().__init__(message, details)
53
+
54
+ class ValidationError(FlareException):
55
+ """Raised when validation fails"""
56
+ def __init__(self, message: str, field: Optional[str] = None, value: Any = None):
57
+ details = {}
58
+ if field:
59
+ details["field"] = field
60
+ if value is not None:
61
+ details["value"] = str(value)
62
+ super().__init__(message, details)
63
+
64
+ class AuthenticationError(FlareException):
65
+ """Raised when authentication fails"""
66
+ def __init__(self, message: str = "Authentication failed"):
67
+ super().__init__(message)
68
+
69
+ class AuthorizationError(FlareException):
70
+ """Raised when authorization fails"""
71
+ def __init__(self, message: str = "Insufficient permissions", required_permission: Optional[str] = None):
72
+ details = {"required_permission": required_permission} if required_permission else {}
73
+ super().__init__(message, details)
74
+
75
+ class SessionError(FlareException):
76
+ """Raised when there's a session-related error"""
77
+ def __init__(self, message: str, session_id: Optional[str] = None):
78
+ details = {"session_id": session_id} if session_id else {}
79
+ super().__init__(message, details)
80
+
81
+ class ProviderError(FlareException):
82
+ """Raised when a provider (LLM, TTS, STT) fails"""
83
+ def __init__(self, message: str, provider_type: str, provider_name: str, original_error: Optional[str] = None):
84
+ details = {
85
+ "provider_type": provider_type,
86
+ "provider_name": provider_name,
87
+ "original_error": original_error
88
+ }
89
+ super().__init__(message, details)
90
+
91
+ class APICallError(FlareException):
92
+ """Raised when an external API call fails"""
93
+ def __init__(
94
+ self,
95
+ message: str,
96
+ api_name: str,
97
+ status_code: Optional[int] = None,
98
+ response_body: Optional[str] = None
99
+ ):
100
+ details = {
101
+ "api_name": api_name,
102
+ "status_code": status_code,
103
+ "response_body": response_body
104
+ }
105
+ super().__init__(message, details)
106
+
107
+ class WebSocketError(FlareException):
108
+ """Raised when WebSocket operations fail"""
109
+ def __init__(self, message: str, session_id: Optional[str] = None, state: Optional[str] = None):
110
+ details = {
111
+ "session_id": session_id,
112
+ "state": state
113
+ }
114
+ super().__init__(message, details)
115
+
116
+ class ResourceNotFoundError(FlareException):
117
+ """Raised when a requested resource is not found"""
118
+ def __init__(self, resource_type: str, resource_id: Any):
119
+ message = f"{resource_type} not found: {resource_id}"
120
+ details = {
121
+ "resource_type": resource_type,
122
+ "resource_id": str(resource_id)
123
+ }
124
+ super().__init__(message, details)
125
+
126
+ class DuplicateResourceError(FlareException):
127
+ """Raised when attempting to create a duplicate resource"""
128
+ def __init__(self, resource_type: str, identifier: str):
129
+ message = f"{resource_type} already exists: {identifier}"
130
+ details = {
131
+ "resource_type": resource_type,
132
+ "identifier": identifier
133
+ }
134
+ super().__init__(message, details)
135
+
136
+ # Error response formatters
137
+ def format_error_response(error: Exception, request_id: Optional[str] = None) -> Dict[str, Any]:
138
+ """Format any exception into a standardized error response"""
139
+ if isinstance(error, FlareException):
140
+ response = error.to_dict()
141
+ else:
142
+ # Generic error
143
+ response = {
144
+ "error": error.__class__.__name__,
145
+ "message": str(error),
146
+ "details": {},
147
+ "timestamp": datetime.utcnow().isoformat()
148
+ }
149
+
150
+ if request_id:
151
+ response["request_id"] = request_id
152
+
153
+ return response
154
+
155
+ def get_http_status_code(error: Exception) -> int:
156
+ """Get appropriate HTTP status code for an exception"""
157
+ status_map = {
158
+ ValidationError: 422,
159
+ AuthenticationError: 401,
160
+ AuthorizationError: 403,
161
+ ResourceNotFoundError: 404,
162
+ DuplicateResourceError: 409,
163
+ RaceConditionError: 409,
164
+ ConfigurationError: 500,
165
+ ProviderError: 503,
166
+ APICallError: 502,
167
+ WebSocketError: 500,
168
+ SessionError: 400
169
+ }
170
+
171
+ for error_class, status_code in status_map.items():
172
+ if isinstance(error, error_class):
173
+ return status_code
174
+
175
+ # Default to 500 for unknown errors
176
+ return 500
logger.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Centralized Logging System for Flare Platform
3
+ """
4
+ import sys
5
+ import logging
6
+ import json
7
+ import os
8
+ import threading
9
+ from datetime import datetime
10
+ from enum import Enum
11
+ from typing import Optional, Dict, Any
12
+ from pathlib import Path
13
+
14
+ class LogLevel(Enum):
15
+ DEBUG = "DEBUG"
16
+ INFO = "INFO"
17
+ WARNING = "WARNING"
18
+ ERROR = "ERROR"
19
+ CRITICAL = "CRITICAL"
20
+
21
+ class FlareLogger:
22
+ _instance = None
23
+ _lock = threading.Lock()
24
+
25
+ def __new__(cls):
26
+ if cls._instance is None:
27
+ with cls._lock:
28
+ if cls._instance is None:
29
+ cls._instance = super().__new__(cls)
30
+ cls._instance._initialized = False
31
+ return cls._instance
32
+
33
+ def __init__(self):
34
+ if self._initialized:
35
+ return
36
+
37
+ self._initialized = True
38
+
39
+ # Log level from environment
40
+ self.log_level = LogLevel[os.getenv('LOG_LEVEL', 'INFO')]
41
+
42
+ # Configure Python logging
43
+ self.logger = logging.getLogger('flare')
44
+ self.logger.setLevel(self.log_level.value)
45
+
46
+ # Remove default handlers
47
+ self.logger.handlers = []
48
+
49
+ # Console handler with custom format
50
+ console_handler = logging.StreamHandler(sys.stdout)
51
+ console_handler.setFormatter(self._get_formatter())
52
+ self.logger.addHandler(console_handler)
53
+
54
+ # File handler for production
55
+ if os.getenv('LOG_TO_FILE', 'false').lower() == 'true':
56
+ log_dir = Path('logs')
57
+ log_dir.mkdir(exist_ok=True)
58
+ file_handler = logging.FileHandler(
59
+ log_dir / f"flare_{datetime.now().strftime('%Y%m%d')}.log"
60
+ )
61
+ file_handler.setFormatter(self._get_formatter())
62
+ self.logger.addHandler(file_handler)
63
+
64
+ # Future: Add ElasticSearch handler here
65
+ # if os.getenv('ELASTICSEARCH_URL'):
66
+ # from elasticsearch_handler import ElasticsearchHandler
67
+ # es_handler = ElasticsearchHandler(
68
+ # hosts=[os.getenv('ELASTICSEARCH_URL')],
69
+ # index='flare-logs'
70
+ # )
71
+ # self.logger.addHandler(es_handler)
72
+
73
+ def _get_formatter(self):
74
+ return logging.Formatter(
75
+ '[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s',
76
+ datefmt='%H:%M:%S'
77
+ )
78
+
79
+ def log(self, level: LogLevel, message: str, **kwargs):
80
+ """Central logging method with structured data"""
81
+ # Add context data
82
+ extra_data = {
83
+ 'timestamp': datetime.utcnow().isoformat(),
84
+ 'service': 'flare',
85
+ 'thread_id': threading.get_ident(),
86
+ **kwargs
87
+ }
88
+
89
+ # Log with structured data
90
+ log_message = message
91
+ if kwargs:
92
+ # Format kwargs for readability
93
+ kwargs_str = json.dumps(kwargs, ensure_ascii=False, default=str)
94
+ log_message = f"{message} | {kwargs_str}"
95
+
96
+ getattr(self.logger, level.value.lower())(log_message, extra={'data': extra_data})
97
+
98
+ # Always flush for real-time debugging
99
+ sys.stdout.flush()
100
+
101
+ def debug(self, message: str, **kwargs):
102
+ """Log debug message"""
103
+ self.log(LogLevel.DEBUG, message, **kwargs)
104
+
105
+ def info(self, message: str, **kwargs):
106
+ """Log info message"""
107
+ self.log(LogLevel.INFO, message, **kwargs)
108
+
109
+ def warning(self, message: str, **kwargs):
110
+ """Log warning message"""
111
+ self.log(LogLevel.WARNING, message, **kwargs)
112
+
113
+ def error(self, message: str, **kwargs):
114
+ """Log error message"""
115
+ self.log(LogLevel.ERROR, message, **kwargs)
116
+
117
+ def critical(self, message: str, **kwargs):
118
+ """Log critical message"""
119
+ self.log(LogLevel.CRITICAL, message, **kwargs)
120
+
121
+ def set_level(self, level: str):
122
+ """Dynamically change log level"""
123
+ try:
124
+ self.log_level = LogLevel[level.upper()]
125
+ self.logger.setLevel(self.log_level.value)
126
+ self.info(f"Log level changed to {level}")
127
+ except KeyError:
128
+ self.warning(f"Invalid log level: {level}")
129
+
130
+ # Global logger instance
131
+ logger = FlareLogger()
132
+
133
+ # Convenience functions
134
+ def log_debug(message: str, **kwargs):
135
+ """Log debug message"""
136
+ logger.debug(message, **kwargs)
137
+
138
+ def log_info(message: str, **kwargs):
139
+ """Log info message"""
140
+ logger.info(message, **kwargs)
141
+
142
+ def log_warning(message: str, **kwargs):
143
+ """Log warning message"""
144
+ logger.warning(message, **kwargs)
145
+
146
+ def log_error(message: str, **kwargs):
147
+ """Log error message"""
148
+ logger.error(message, **kwargs)
149
+
150
+ def log_critical(message: str, **kwargs):
151
+ """Log critical message"""
152
+ logger.critical(message, **kwargs)
153
+
154
+ # Backward compatibility
155
+ def log(message: str, level: str = "INFO", **kwargs):
156
+ """Legacy log function for compatibility"""
157
+ getattr(logger, level.lower())(message, **kwargs)
158
+
159
+ # Performance logging helpers
160
+ class LogTimer:
161
+ """Context manager for timing operations"""
162
+ def __init__(self, operation_name: str, **extra_kwargs):
163
+ self.operation_name = operation_name
164
+ self.extra_kwargs = extra_kwargs
165
+ self.start_time = None
166
+
167
+ def __enter__(self):
168
+ self.start_time = datetime.now()
169
+ log_debug(f"Starting {self.operation_name}", **self.extra_kwargs)
170
+ return self
171
+
172
+ def __exit__(self, exc_type, exc_val, exc_tb):
173
+ duration_ms = (datetime.now() - self.start_time).total_seconds() * 1000
174
+ if exc_type:
175
+ log_error(
176
+ f"{self.operation_name} failed after {duration_ms:.2f}ms",
177
+ error=str(exc_val),
178
+ duration_ms=duration_ms,
179
+ **self.extra_kwargs
180
+ )
181
+ else:
182
+ log_info(
183
+ f"{self.operation_name} completed in {duration_ms:.2f}ms",
184
+ duration_ms=duration_ms,
185
+ **self.extra_kwargs
186
+ )
session.py CHANGED
@@ -1,169 +1,313 @@
1
  """
2
- Flare Session Management
3
- ~~~~~~~~~~~~~~~~~~~~~~~~~~
4
- • thread-safe SessionStore
5
- • state-machine alanları
6
  """
 
 
 
 
 
 
 
7
 
8
- from __future__ import annotations
9
- import threading, uuid, time
10
- from datetime import datetime, timedelta
11
- from typing import Dict, List
12
- from utils import log
13
 
 
14
  class Session:
15
- """Single chat session."""
16
-
17
- def __init__(self, project_name: str, version_config=None):
18
- self.session_id: str = str(uuid.uuid4())
19
- self.project_name: str = project_name
20
- self.version_number: int = version_config.id if version_config else None
21
- self.version_config = version_config # Version config'i sakla
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
- # flow state
24
- self.state: str = "idle" # idle | await_param | call_api | humanize
25
- self.last_intent: str | None = None
26
- self.awaiting_parameters: List[str] = []
27
- self.missing_ask_count: int = 0
28
-
29
- # data
30
- self.variables: Dict[str, str] = {}
31
- self.auth_tokens: Dict[str, Dict] = {} # api_name -> {token, expiry}
32
-
33
- # history
34
- self.chat_history: List[Dict[str, str]] = []
35
-
36
- # smart parameter collection tracking
37
- self.asked_parameters: Dict[str, int] = {} # parametre_adı -> kaç_kez_soruldu
38
- self.unanswered_parameters: List[str] = [] # sorulduğu halde cevaplanmayan parametreler
39
- self.parameter_ask_rounds: int = 0 # toplam kaç tur soru soruldu
40
-
41
- self.created_at: datetime = datetime.now()
42
- self.last_activity: datetime = datetime.now()
43
-
44
- # Real-time conversation tracking
45
- self.is_realtime_session: bool = False
46
- self.active_websocket = None
47
- self.current_audio_buffer = []
48
- self.last_audio_timestamp: Optional[datetime] = None
49
-
50
- def mark_as_realtime(self):
51
- """Mark session as real-time (WebSocket-based)"""
52
- self.is_realtime_session = True
53
- self.last_audio_timestamp = datetime.now()
54
 
55
- def update_audio_timestamp(self):
56
- """Update last audio activity timestamp"""
57
- self.last_audio_timestamp = datetime.now()
58
- self.update_activity()
59
 
60
- def get_version_config(self):
61
- """Get stored version config"""
62
- return self.version_config
63
 
64
- def update_activity(self):
65
- """Update last activity timestamp"""
66
- self.last_activity = datetime.now()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
- # -------- helper ----------
69
- def add_turn(self, role: str, content: str):
70
- self.chat_history.append({"role": role, "content": content})
71
- self.update_activity() # Activity güncellemesi eklendi
72
- if len(self.chat_history) > 20:
73
- self.chat_history.pop(0)
74
 
75
- # -------- reset flow ------
76
- def reset_flow(self):
77
- self.state = "idle"
78
- self.last_intent = None
79
- self.awaiting_parameters.clear()
80
- self.missing_ask_count = 0
81
- # Smart collection tracking'i reset etme,
82
- # çünkü aynı session'da başka intent için kullanılabilir
 
 
 
 
 
83
 
84
- # -------- smart parameter collection methods ------
85
- def record_parameter_question(self, param_names: List[str]):
86
- """Sorulan parametreleri kaydet"""
87
- self.parameter_ask_rounds += 1
88
- log(f"📝 Recording parameter question round {self.parameter_ask_rounds} for: {param_names}")
89
-
90
- for param in param_names:
91
- self.asked_parameters[param] = self.asked_parameters.get(param, 0) + 1
92
- log(f" - {param} asked {self.asked_parameters[param]} time(s)")
93
-
94
- def mark_parameters_unanswered(self, param_names: List[str]):
95
- """Cevaplanmayan parametreleri işaretle"""
96
- for param in param_names:
97
- if param not in self.unanswered_parameters:
98
- self.unanswered_parameters.append(param)
99
- log(f"❓ Parameter marked as unanswered: {param}")
100
-
101
- def mark_parameter_answered(self, param_name: str):
102
- """Parametre cevaplandığında işareti kaldır"""
103
- if param_name in self.unanswered_parameters:
104
- self.unanswered_parameters.remove(param_name)
105
- log(f"✅ Parameter marked as answered: {param_name}")
106
-
107
- def get_parameter_ask_count(self, param_name: str) -> int:
108
- """Bir parametrenin kaç kez sorulduğunu döndür"""
109
- return self.asked_parameters.get(param_name, 0)
110
-
111
- def reset_parameter_tracking(self):
112
- """Parametre takibini sıfırla (yeni intent için)"""
113
- self.asked_parameters.clear()
114
- self.unanswered_parameters.clear()
115
- self.parameter_ask_rounds = 0
116
- log("🔄 Parameter tracking reset for new intent")
117
 
118
  class SessionStore:
119
- """Thread-safe global store."""
120
-
121
  def __init__(self):
122
- self._lock = threading.Lock()
123
  self._sessions: Dict[str, Session] = {}
124
-
125
- def create_session(self, project_name: str, version_config=None) -> Session:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  with self._lock:
127
- s = Session(project_name, version_config)
128
- self._sessions[s.session_id] = s
129
- log(f"🆕 Session created {s.session_id} with project {project_name} version {s.version_number}")
130
- return s
131
-
132
- def get_session(self, sid: str) -> Session | None:
 
 
 
 
 
 
 
 
133
  with self._lock:
134
- return self._sessions.get(sid)
135
-
136
- def __contains__(self, sid: str) -> bool:
137
- return self.get_session(sid) is not None
138
-
139
- def cleanup_expired_sessions(self, timeout_minutes: int = 30):
140
- """Remove expired sessions"""
 
 
 
 
 
 
 
 
 
 
 
141
  with self._lock:
142
- now = datetime.now()
143
- expired = []
144
- for sid, session in self._sessions.items():
145
- if (now - session.last_activity).total_seconds() > timeout_minutes * 60:
146
- expired.append(sid)
 
 
 
 
 
 
 
 
147
 
148
- for sid in expired:
149
- del self._sessions[sid]
150
- log(f"🗑️ Expired session removed: {sid[:8]}...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
- if expired:
153
- log(f"📊 Active sessions: {len(self._sessions)}")
 
 
 
 
154
 
 
 
155
  session_store = SessionStore()
156
 
157
- # Cleanup thread başlat
158
- def start_session_cleanup():
159
- def cleanup_loop():
 
 
 
160
  while True:
161
  try:
162
- session_store.cleanup_expired_sessions()
 
 
163
  except Exception as e:
164
- log(f"Session cleanup error: {e}")
165
- time.sleep(300) # 5 dakikada bir
 
166
 
167
- thread = threading.Thread(target=cleanup_loop, daemon=True)
168
- thread.start()
169
- log("🧹 Session cleanup thread started")
 
1
  """
2
+ Optimized Session Management for Flare Platform
 
 
 
3
  """
4
+ from dataclasses import dataclass, field
5
+ from typing import Dict, List, Optional, Any
6
+ from datetime import datetime
7
+ import json
8
+ import secrets
9
+ import hashlib
10
+ import time
11
 
12
+ from config_models import VersionConfig, IntentConfig
13
+ from logger import log_debug, log_info
 
 
 
14
 
15
+ @dataclass
16
  class Session:
17
+ """Optimized session for future Redis storage"""
18
+
19
+ # Core identifiers
20
+ session_id: str
21
+ project_name: str
22
+ version_number: int
23
+
24
+ # State management - string for better debugging
25
+ state: str = "idle" # idle | collect_params | call_api | humanize
26
+
27
+ # Minimal stored data
28
+ current_intent: Optional[str] = None
29
+ variables: Dict[str, str] = field(default_factory=dict)
30
+ project_id: Optional[int] = None
31
+ version_id: Optional[int] = None
32
+
33
+ # Chat history - limited to recent messages
34
+ chat_history: List[Dict[str, str]] = field(default_factory=list)
35
+
36
+ # Metadata
37
+ created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
38
+ last_activity: str = field(default_factory=lambda: datetime.utcnow().isoformat())
39
+
40
+ # Parameter collection state
41
+ awaiting_parameters: List[str] = field(default_factory=list)
42
+ asked_parameters: Dict[str, int] = field(default_factory=dict)
43
+ unanswered_parameters: List[str] = field(default_factory=list)
44
+ parameter_ask_rounds: int = 0
45
+
46
+ # Real-time support
47
+ is_realtime_session: bool = False
48
+
49
+ # Transient data (not serialized to Redis)
50
+ _version_config: Optional[VersionConfig] = field(default=None, init=False, repr=False)
51
+ _intent_config: Optional[IntentConfig] = field(default=None, init=False, repr=False)
52
+ _auth_tokens: Dict[str, Dict] = field(default_factory=dict, init=False, repr=False)
53
+
54
+ # Constants
55
+ MAX_CHAT_HISTORY: int = field(default=20, init=False, repr=False)
56
+
57
+ def add_message(self, role: str, content: str) -> None:
58
+ """Add message to chat history with size limit"""
59
+ message = {
60
+ "role": role,
61
+ "content": content,
62
+ "timestamp": datetime.utcnow().isoformat()
63
+ }
64
 
65
+ self.chat_history.append(message)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
+ # Keep only recent messages
68
+ if len(self.chat_history) > self.MAX_CHAT_HISTORY:
69
+ self.chat_history = self.chat_history[-self.MAX_CHAT_HISTORY:]
 
70
 
71
+ # Update activity
72
+ self.last_activity = datetime.utcnow().isoformat()
 
73
 
74
+ log_debug(
75
+ f"Message added to session",
76
+ session_id=self.session_id,
77
+ role=role,
78
+ history_size=len(self.chat_history)
79
+ )
80
+
81
+ def add_turn(self, role: str, content: str) -> None:
82
+ """Alias for add_message for compatibility"""
83
+ self.add_message(role, content)
84
+
85
+ def set_version_config(self, config: VersionConfig) -> None:
86
+ """Set transient version config"""
87
+ self._version_config = config
88
+ self.version_id = config.id if config else None
89
+
90
+ def get_version_config(self) -> Optional[VersionConfig]:
91
+ """Get transient version config"""
92
+ return self._version_config
93
+
94
+ def set_intent_config(self, config: IntentConfig) -> None:
95
+ """Set current intent config"""
96
+ self._intent_config = config
97
+ self.current_intent = config.name if config else None
98
+
99
+ def get_intent_config(self) -> Optional[IntentConfig]:
100
+ """Get current intent config"""
101
+ return self._intent_config
102
+
103
+ def reset_flow(self) -> None:
104
+ """Reset conversation flow to idle"""
105
+ self.state = "idle"
106
+ self.current_intent = None
107
+ self._intent_config = None
108
+ self.awaiting_parameters = []
109
+ self.asked_parameters = {}
110
+ self.unanswered_parameters = []
111
+ self.parameter_ask_rounds = 0
112
+
113
+ log_debug(
114
+ f"Session flow reset",
115
+ session_id=self.session_id
116
+ )
117
+
118
+ def to_redis(self) -> str:
119
+ """Serialize for Redis storage"""
120
+ data = {
121
+ 'session_id': self.session_id,
122
+ 'project_name': self.project_name,
123
+ 'version_number': self.version_number,
124
+ 'state': self.state,
125
+ 'current_intent': self.current_intent,
126
+ 'variables': self.variables,
127
+ 'project_id': self.project_id,
128
+ 'version_id': self.version_id,
129
+ 'chat_history': self.chat_history[-self.MAX_CHAT_HISTORY:],
130
+ 'created_at': self.created_at,
131
+ 'last_activity': self.last_activity,
132
+ 'awaiting_parameters': self.awaiting_parameters,
133
+ 'asked_parameters': self.asked_parameters,
134
+ 'unanswered_parameters': self.unanswered_parameters,
135
+ 'parameter_ask_rounds': self.parameter_ask_rounds,
136
+ 'is_realtime_session': self.is_realtime_session
137
+ }
138
+ return json.dumps(data, ensure_ascii=False)
139
+
140
+ @classmethod
141
+ def from_redis(cls, data: str) -> 'Session':
142
+ """Deserialize from Redis"""
143
+ obj = json.loads(data)
144
+ return cls(**obj)
145
+
146
+ def get_state_info(self) -> dict:
147
+ """Get debug info about current state"""
148
+ return {
149
+ 'state': self.state,
150
+ 'intent': self.current_intent,
151
+ 'variables': list(self.variables.keys()),
152
+ 'history_length': len(self.chat_history),
153
+ 'awaiting_params': self.awaiting_parameters,
154
+ 'last_activity': self.last_activity
155
+ }
156
+
157
+ def get_auth_token(self, api_name: str) -> Optional[Dict]:
158
+ """Get cached auth token for API"""
159
+ return self._auth_tokens.get(api_name)
160
+
161
+ def set_auth_token(self, api_name: str, token_data: Dict) -> None:
162
+ """Cache auth token for API"""
163
+ self._auth_tokens[api_name] = token_data
164
+
165
+ def is_expired(self, timeout_minutes: int = 30) -> bool:
166
+ """Check if session is expired"""
167
+ last_activity_time = datetime.fromisoformat(self.last_activity.replace('Z', '+00:00'))
168
+ current_time = datetime.utcnow()
169
+ elapsed_minutes = (current_time - last_activity_time).total_seconds() / 60
170
+ return elapsed_minutes > timeout_minutes
171
 
 
 
 
 
 
 
172
 
173
+ def generate_secure_session_id() -> str:
174
+ """Generate cryptographically secure session ID"""
175
+ # Use secrets for secure random generation
176
+ random_bytes = secrets.token_bytes(32)
177
+
178
+ # Add timestamp for uniqueness
179
+ timestamp = str(int(time.time() * 1000000))
180
+
181
+ # Combine and hash
182
+ combined = random_bytes + timestamp.encode()
183
+ session_id = hashlib.sha256(combined).hexdigest()
184
+
185
+ return f"session_{session_id[:32]}"
186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
  class SessionStore:
189
+ """In-memory session store (to be replaced with Redis)"""
190
+
191
  def __init__(self):
 
192
  self._sessions: Dict[str, Session] = {}
193
+ self._lock = threading.Lock()
194
+
195
+ def create_session(
196
+ self,
197
+ project_name: str,
198
+ version_number: int,
199
+ is_realtime: bool = False
200
+ ) -> Session:
201
+ """Create new session"""
202
+ session_id = generate_secure_session_id()
203
+
204
+ session = Session(
205
+ session_id=session_id,
206
+ project_name=project_name,
207
+ version_number=version_number,
208
+ is_realtime_session=is_realtime
209
+ )
210
+
211
  with self._lock:
212
+ self._sessions[session_id] = session
213
+
214
+ log_info(
215
+ "Session created",
216
+ session_id=session_id,
217
+ project=project_name,
218
+ version=version_number,
219
+ is_realtime=is_realtime
220
+ )
221
+
222
+ return session
223
+
224
+ def get_session(self, session_id: str) -> Optional[Session]:
225
+ """Get session by ID"""
226
  with self._lock:
227
+ session = self._sessions.get(session_id)
228
+
229
+ if session and session.is_expired():
230
+ log_info(f"Session expired", session_id=session_id)
231
+ self.delete_session(session_id)
232
+ return None
233
+
234
+ return session
235
+
236
+ def update_session(self, session: Session) -> None:
237
+ """Update session in store"""
238
+ session.last_activity = datetime.utcnow().isoformat()
239
+
240
+ with self._lock:
241
+ self._sessions[session.session_id] = session
242
+
243
+ def delete_session(self, session_id: str) -> None:
244
+ """Delete session"""
245
  with self._lock:
246
+ if session_id in self._sessions:
247
+ del self._sessions[session_id]
248
+ log_info(f"Session deleted", session_id=session_id)
249
+
250
+ def cleanup_expired_sessions(self, timeout_minutes: int = 30) -> int:
251
+ """Clean up expired sessions"""
252
+ expired_count = 0
253
+
254
+ with self._lock:
255
+ expired_ids = [
256
+ sid for sid, session in self._sessions.items()
257
+ if session.is_expired(timeout_minutes)
258
+ ]
259
 
260
+ for session_id in expired_ids:
261
+ del self._sessions[session_id]
262
+ expired_count += 1
263
+
264
+ if expired_count > 0:
265
+ log_info(
266
+ f"Cleaned up expired sessions",
267
+ count=expired_count
268
+ )
269
+
270
+ return expired_count
271
+
272
+ def get_active_session_count(self) -> int:
273
+ """Get count of active sessions"""
274
+ with self._lock:
275
+ return len(self._sessions)
276
+
277
+ def get_session_stats(self) -> Dict[str, Any]:
278
+ """Get session statistics"""
279
+ with self._lock:
280
+ realtime_count = sum(
281
+ 1 for s in self._sessions.values()
282
+ if s.is_realtime_session
283
+ )
284
 
285
+ return {
286
+ 'total_sessions': len(self._sessions),
287
+ 'realtime_sessions': realtime_count,
288
+ 'regular_sessions': len(self._sessions) - realtime_count
289
+ }
290
+
291
 
292
+ # Global session store instance
293
+ import threading
294
  session_store = SessionStore()
295
 
296
+ # Session cleanup task
297
+ def start_session_cleanup(interval_minutes: int = 5, timeout_minutes: int = 30):
298
+ """Start background task to clean up expired sessions"""
299
+ import asyncio
300
+
301
+ async def cleanup_task():
302
  while True:
303
  try:
304
+ expired = session_store.cleanup_expired_sessions(timeout_minutes)
305
+ if expired > 0:
306
+ log_info(f"Session cleanup completed", expired=expired)
307
  except Exception as e:
308
+ log_error(f"Session cleanup error", error=str(e))
309
+
310
+ await asyncio.sleep(interval_minutes * 60)
311
 
312
+ # Run in background
313
+ asyncio.create_task(cleanup_task())