ciyidogan commited on
Commit
9db24d7
·
verified ·
1 Parent(s): 69ba733

Update config_provider.py

Browse files
Files changed (1) hide show
  1. config_provider.py +175 -926
config_provider.py CHANGED
@@ -1,175 +1,104 @@
1
  """
2
- Flare – ConfigProvider (TTS/STT support)
3
  """
4
 
5
  from __future__ import annotations
6
- import json, os
7
- import threading
8
  from pathlib import Path
9
- from typing import Any, Dict, List, Optional, Union
10
- from datetime import datetime
11
  import commentjson
 
 
 
12
  from utils import log
13
- from pydantic import BaseModel, Field, HttpUrl, ValidationError, field_validator, validator
14
  from encryption_utils import decrypt
15
 
16
- # ---------------- Parameter Collection Config ---------
17
- class ParameterCollectionConfig(BaseModel):
18
- """Configuration for smart parameter collection"""
19
- max_params_per_question: int = Field(default=2, ge=1, le=5)
20
- smart_grouping: bool = Field(default=True)
21
- retry_unanswered: bool = Field(default=True)
22
- collection_prompt: str = Field(default="""
23
- You are a helpful assistant collecting information from the user.
24
-
25
- Conversation context:
26
- {{conversation_history}}
27
-
28
- Intent: {{intent_name}} - {{intent_caption}}
29
-
30
- Already collected:
31
- {{collected_params}}
32
-
33
- Still needed:
34
- {{missing_params}}
35
-
36
- Previously asked but not answered:
37
- {{unanswered_params}}
38
-
39
- Rules:
40
- 1. Ask for maximum {{max_params}} parameters in one question
41
- 2. Group parameters that naturally go together (like from/to cities, dates)
42
- 3. If some parameters were asked before but not answered, include them again
43
- 4. Be natural and conversational in {{project_language}}
44
- 5. Use context from the conversation to make the question flow naturally
45
-
46
- Generate ONLY the question, nothing else.""")
47
 
 
 
 
 
 
 
 
48
  class Config:
49
  extra = "allow"
50
 
 
51
  class GlobalConfig(BaseModel):
52
- work_mode: str = Field("hfcloud", pattern=r"^(hfcloud|cloud|on-premise|gpt4o|gpt4o-mini)$")
53
- cloud_token: Optional[str] = None
54
- spark_endpoint: HttpUrl
55
- internal_prompt: Optional[str] = None
 
 
 
 
 
56
  users: List["UserConfig"] = []
57
 
58
- # TTS configurations
59
- tts_engine: str = Field("no_tts", pattern=r"^(no_tts|elevenlabs|blaze)$")
60
- tts_engine_api_key: Optional[str] = None
61
- tts_settings: Optional[Dict[str, Any]] = Field(default_factory=lambda: {
62
- "use_ssml": False
63
- })
64
-
65
- # STT configurations
66
- stt_engine: str = Field("no_stt", pattern=r"^(no_stt|google|azure|amazon|gpt4o_realtime|flicker)$")
67
- stt_engine_api_key: Optional[str] = None
68
- stt_settings: Optional[Dict[str, Any]] = Field(default_factory=lambda: {
69
- "speech_timeout_ms": 2000,
70
- "noise_reduction_level": 2,
71
- "vad_sensitivity": 0.5,
72
- "language": "tr-TR",
73
- "model": "latest_long",
74
- "use_enhanced": True,
75
- "enable_punctuation": True,
76
- "interim_results": True
77
- })
78
-
79
- parameter_collection_config: ParameterCollectionConfig = Field(default_factory=ParameterCollectionConfig)
80
-
81
- def get_plain_token(self) -> Optional[str]:
82
- if self.cloud_token:
83
- # Lazy import to avoid circular dependency
84
- from encryption_utils import decrypt
85
- return decrypt(self.cloud_token) if self.cloud_token else None
86
- return None
87
 
88
- def get_tts_api_key(self) -> Optional[str]:
89
- """Get decrypted TTS API key"""
90
- raw_key = self.tts_engine_api_key
91
-
92
- if raw_key and raw_key.startswith("enc:"):
93
- from encryption_utils import decrypt
94
- decrypted = decrypt(raw_key)
95
- log(f"🔓 TTS key decrypted: {'***' + decrypted[-4:] if decrypted else 'None'}")
96
- return decrypted
97
-
98
- log(f"🔑 TTS key not encrypted: {'***' + raw_key[-4:] if raw_key else 'None'}")
99
- return raw_key
100
-
101
- def get_tts_settings(self) -> Dict[str, Any]:
102
- """Get TTS settings with defaults"""
103
- return self.tts_settings or {
104
- "use_ssml": False
105
- }
106
-
107
- def get_stt_api_key(self) -> Optional[str]:
108
- """Get decrypted STT API key or credentials path"""
109
- raw_key = self.stt_engine_api_key
110
-
111
- if raw_key and raw_key.startswith("enc:"):
112
- from encryption_utils import decrypt
113
  decrypted = decrypt(raw_key)
114
- log(f"🔓 STT key decrypted: {'***' + decrypted[-4:] if decrypted else 'None'}")
115
  return decrypted
116
 
117
- log(f"🔑 STT key/path: {'***' + raw_key[-4:] if raw_key else 'None'}")
118
  return raw_key
119
 
120
- def get_stt_settings(self) -> Dict[str, Any]:
121
- """Get STT settings with defaults"""
122
- return self.stt_settings or {
123
- "speech_timeout_ms": 2000,
124
- "noise_reduction_level": 2,
125
- "vad_sensitivity": 0.5,
126
- "language": "tr-TR",
127
- "model": "latest_long",
128
- "use_enhanced": True,
129
- "enable_punctuation": True,
130
- "interim_results": True
131
- }
132
-
133
- def is_cloud_mode(self) -> bool:
134
- """Check if running in cloud mode (anything except on-premise)"""
135
- return self.work_mode != "on-premise"
136
-
137
- def is_on_premise(self) -> bool:
138
- """Check if running in on-premise mode"""
139
- return self.work_mode == "on-premise"
140
-
141
- def is_gpt_mode(self) -> bool:
142
- """Check if running in GPT mode (any variant)"""
143
- return self.work_mode in ("gpt4o", "gpt4o-mini")
144
-
145
- def get_gpt_model(self) -> str:
146
- """Get the GPT model name for OpenAI API"""
147
- if self.work_mode == "gpt4o":
148
- return "gpt-4o"
149
- elif self.work_mode == "gpt4o-mini":
150
- return "gpt-4o-mini"
151
- return None
152
 
153
- # ---------------- Global -----------------
154
  class UserConfig(BaseModel):
155
  username: str
156
  password_hash: str
157
  salt: str
158
 
159
-
160
- # ---------------- Retry / Proxy ----------
161
  class RetryConfig(BaseModel):
162
  retry_count: int = Field(3, alias="max_attempts")
163
  backoff_seconds: int = 2
164
  strategy: str = Field("static", pattern=r"^(static|exponential)$")
165
 
166
-
167
  class ProxyConfig(BaseModel):
168
  enabled: bool = True
169
  url: HttpUrl
170
 
171
-
172
- # ---------------- API & Auth -------------
173
  class APIAuthConfig(BaseModel):
174
  enabled: bool = False
175
  token_endpoint: Optional[HttpUrl] = None
@@ -182,7 +111,6 @@ class APIAuthConfig(BaseModel):
182
  extra = "allow"
183
  populate_by_name = True
184
 
185
-
186
  class APIConfig(BaseModel):
187
  name: str
188
  url: HttpUrl
@@ -199,18 +127,41 @@ class APIConfig(BaseModel):
199
  extra = "allow"
200
  populate_by_name = True
201
 
 
 
 
 
202
 
203
- # ---------------- Intent / Param ---------
 
 
 
 
204
  class ParameterConfig(BaseModel):
205
  name: str
206
- caption: Optional[str] = ""
207
- type: str = Field(..., pattern=r"^(int|float|bool|str|string|date)$") # Added 'date'
208
  required: bool = True
209
  variable_name: str
210
  extraction_prompt: Optional[str] = None
211
  validation_regex: Optional[str] = None
212
  invalid_prompt: Optional[str] = None
213
  type_error_prompt: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
  def canonical_type(self) -> str:
216
  if self.type == "string":
@@ -219,78 +170,61 @@ class ParameterConfig(BaseModel):
219
  return "str" # Store dates as strings in ISO format
220
  return self.type
221
 
222
-
223
  class IntentConfig(BaseModel):
224
  name: str
225
  caption: Optional[str] = ""
226
- locale: str = "tr-TR"
227
  dependencies: List[str] = []
228
- examples: List[str] = []
229
  detection_prompt: Optional[str] = None
230
  parameters: List[ParameterConfig] = []
231
  action: str
232
  fallback_timeout_prompt: Optional[str] = None
233
  fallback_error_prompt: Optional[str] = None
 
 
 
 
234
 
235
  class Config:
236
  extra = "allow"
237
 
238
-
239
- # ---------------- Version / Project ------
240
  class LLMConfig(BaseModel):
241
  repo_id: str
242
  generation_config: Dict[str, Any] = {}
243
  use_fine_tune: bool = False
244
  fine_tune_zip: str = ""
245
 
246
-
247
  class VersionConfig(BaseModel):
248
  id: int = Field(..., alias="version_number")
249
  caption: Optional[str] = ""
250
  published: bool = False
251
  general_prompt: str
252
- llm: "LLMConfig"
253
  intents: List["IntentConfig"]
254
 
255
  class Config:
256
  extra = "allow"
257
  populate_by_name = True
258
 
259
-
260
  class ProjectConfig(BaseModel):
261
  id: Optional[int] = None
262
  name: str
263
  caption: Optional[str] = ""
264
  enabled: bool = True
 
 
265
  last_version_number: Optional[int] = None
266
  versions: List[VersionConfig]
267
 
268
  class Config:
269
  extra = "allow"
270
 
271
-
272
- # ---------------- Activity Log -----------
273
- class ActivityLogEntry(BaseModel):
274
- timestamp: str
275
- username: str
276
- action: str
277
- entity_type: str
278
- entity_id: Optional[int] = None
279
- entity_name: Optional[str] = None
280
- details: Optional[str] = None
281
-
282
-
283
- # ---------------- Service Config ---------
284
  class ServiceConfig(BaseModel):
285
  global_config: GlobalConfig = Field(..., alias="config")
286
  projects: List[ProjectConfig]
287
  apis: List[APIConfig]
288
- activity_log: List[ActivityLogEntry] = []
289
-
290
- # Config level fields
291
- project_id_counter: int = 1
292
- last_update_date: Optional[str] = None
293
- last_update_user: Optional[str] = None
294
 
295
  # runtime helpers (skip validation)
296
  _api_by_name: Dict[str, APIConfig] = {}
@@ -298,47 +232,14 @@ class ServiceConfig(BaseModel):
298
  def build_index(self):
299
  self._api_by_name = {a.name: a for a in self.apis}
300
 
301
- def get_api(self, name: str) -> Optional[APIConfig]:
302
  return self._api_by_name.get(name)
303
-
304
- def to_jsonc_dict(self) -> dict:
305
- """Convert to dict for saving to JSONC file"""
306
- data = self.model_dump(by_alias=True, exclude={'_api_by_name'})
307
-
308
- # Convert API configs
309
- for api in data.get('apis', []):
310
- # Convert headers and body_template to JSON strings
311
- if 'headers' in api and isinstance(api['headers'], dict):
312
- api['headers'] = json.dumps(api['headers'], ensure_ascii=False)
313
- if 'body_template' in api and isinstance(api['body_template'], dict):
314
- api['body_template'] = json.dumps(api['body_template'], ensure_ascii=False)
315
-
316
- # Convert auth configs
317
- if 'auth' in api and api['auth']:
318
- if 'token_request_body' in api['auth'] and isinstance(api['auth']['token_request_body'], dict):
319
- api['auth']['token_request_body'] = json.dumps(api['auth']['token_request_body'], ensure_ascii=False)
320
- if 'token_refresh_body' in api['auth'] and isinstance(api['auth']['token_refresh_body'], dict):
321
- api['auth']['token_refresh_body'] = json.dumps(api['auth']['token_refresh_body'], ensure_ascii=False)
322
-
323
- return data
324
-
325
- def save(self):
326
- """Save configuration to file"""
327
- config_path = Path(__file__).parent / "service_config.jsonc"
328
- data = self.to_jsonc_dict()
329
-
330
- with open(config_path, 'w', encoding='utf-8') as f:
331
- json.dump(data, f, ensure_ascii=False, indent=2)
332
-
333
- log("✅ Configuration saved to service_config.jsonc")
334
 
335
-
336
- # ---------------- Provider Singleton -----
337
  class ConfigProvider:
338
  _instance: Optional[ServiceConfig] = None
339
  _CONFIG_PATH = Path(__file__).parent / "service_config.jsonc"
340
- _lock = threading.Lock() # Thread-safe access için lock
341
- _environment_checked = False # Environment kontrolü için flag
342
 
343
  @classmethod
344
  def get(cls) -> ServiceConfig:
@@ -349,10 +250,6 @@ class ConfigProvider:
349
  if cls._instance is None:
350
  cls._instance = cls._load()
351
  cls._instance.build_index()
352
- # Environment kontrolünü sadece ilk yüklemede yap
353
- if not cls._environment_checked:
354
- cls._check_environment_setup()
355
- cls._environment_checked = True
356
  return cls._instance
357
 
358
  @classmethod
@@ -361,7 +258,6 @@ class ConfigProvider:
361
  with cls._lock:
362
  log("🔄 Reloading configuration...")
363
  cls._instance = None
364
- # reload'da environment kontrolünü tekrar yapmıyoruz, flag'i reset etmiyoruz
365
  return cls.get()
366
 
367
  @classmethod
@@ -397,31 +293,33 @@ class ConfigProvider:
397
 
398
  config_section = config_data['config']
399
 
400
- # Set defaults for missing fields
401
- config_section.setdefault('work_mode', 'hfcloud')
402
- config_section.setdefault('spark_endpoint', 'http://localhost:7861')
403
- config_section.setdefault('cloud_token', None)
404
- config_section.setdefault('internal_prompt', None)
405
- config_section.setdefault('tts_engine', 'no_tts')
406
- config_section.setdefault('tts_engine_api_key', None)
407
- config_section.setdefault('tts_settings', {'use_ssml': False})
408
- config_section.setdefault('stt_engine', 'no_stt')
409
- config_section.setdefault('stt_engine_api_key', None)
410
- config_section.setdefault('stt_settings', {
411
- 'speech_timeout_ms': 2000,
412
- 'noise_reduction_level': 2,
413
- 'vad_sensitivity': 0.5,
414
- 'language': 'tr-TR',
415
- 'model': 'latest_long',
416
- 'use_enhanced': True,
417
- 'enable_punctuation': True,
418
- 'interim_results': True
419
- })
420
-
421
- pcc = config_data['config'].get('parameter_collection_config')
422
- if pcc is None:
423
- # Yoksa default değerlerle ekle
424
- config_data['config']['parameter_collection_config'] = ParameterCollectionConfig().model_dump()
 
 
425
 
426
  config_section.setdefault('users', [])
427
 
@@ -453,723 +351,74 @@ class ConfigProvider:
453
  except:
454
  auth['token_refresh_body'] = {}
455
 
456
- # Fix activity_log entries if needed
457
- if 'activity_log' in config_data:
458
- for entry in config_data['activity_log']:
459
- # Add missing username field
460
- if 'username' not in entry:
461
- entry['username'] = entry.get('user', 'system')
462
- # Ensure all required fields exist
463
- entry.setdefault('action', 'UNKNOWN')
464
- entry.setdefault('entity_type', 'unknown')
465
- entry.setdefault('timestamp', datetime.now().isoformat())
466
-
467
- # Create ServiceConfig instance
468
- service_config = ServiceConfig(**config_data)
469
 
470
  log("✅ Configuration loaded successfully")
471
- return service_config
472
 
473
- except FileNotFoundError as e:
474
- log(f"❌ Config file not found: {e}")
475
- raise
476
- except json.JSONDecodeError as e:
477
- log(f"❌ Invalid JSON in config file: {e}")
478
  raise
479
- except ValidationError as e:
480
- log(f"❌ Config validation error: {e}")
481
  raise
482
  except Exception as e:
483
  log(f"❌ Unexpected error loading config: {e}")
484
  raise
485
-
486
- @classmethod
487
- def _check_environment_setup(cls):
488
- """Check if environment is properly configured based on work_mode"""
489
- config = cls._instance.global_config
490
-
491
- if config.is_cloud_mode():
492
- # Cloud mode - check for HuggingFace Secrets
493
- missing_secrets = []
494
-
495
- if not os.getenv("JWT_SECRET"):
496
- missing_secrets.append("JWT_SECRET")
497
- if not os.getenv("FLARE_TOKEN_KEY"):
498
- missing_secrets.append("FLARE_TOKEN_KEY")
499
- if not os.getenv("SPARK_TOKEN"):
500
- missing_secrets.append("SPARK_TOKEN")
501
-
502
- if missing_secrets:
503
- log(f"⚠️ Running in {config.work_mode} mode. Missing secrets: {', '.join(missing_secrets)}")
504
- log("Please set these as HuggingFace Space Secrets for cloud deployment.")
505
- else:
506
- # On-premise mode - check for .env file
507
- env_path = Path(__file__).parent / ".env"
508
- if not env_path.exists():
509
- log("⚠️ Running in on-premise mode but .env file not found")
510
- # Docker ortamında yazma izni olmayabilir, sadece uyarı ver
511
- log("⚠️ Cannot create .env file in Docker environment. Using default values.")
512
-
513
- # Set default environment variables if not already set
514
- if not os.getenv("JWT_SECRET"):
515
- os.environ["JWT_SECRET"] = "flare-admin-secret-key-change-in-production"
516
- if not os.getenv("JWT_ALGORITHM"):
517
- os.environ["JWT_ALGORITHM"] = "HS256"
518
- if not os.getenv("JWT_EXPIRATION_HOURS"):
519
- os.environ["JWT_EXPIRATION_HOURS"] = "24"
520
- if not os.getenv("FLARE_TOKEN_KEY"):
521
- os.environ["FLARE_TOKEN_KEY"] = "flare-token-encryption-key"
522
- if not os.getenv("SPARK_TOKEN"):
523
- os.environ["SPARK_TOKEN"] = "your-spark-token-here"
524
-
525
- log("✅ Default environment variables set.")
526
-
527
- @classmethod
528
- def update_user_password(cls, username: str, new_hash: str, new_salt: str) -> None:
529
- """Update user password"""
530
- if cls._instance is None:
531
- cls.get()
532
-
533
- user = next((u for u in cls._instance.global_config.users if u.username == username), None)
534
- if user:
535
- user.password_hash = new_hash
536
- user.salt = new_salt
537
- cls._instance.save()
538
- cls.add_activity_log(username, "CHANGE_PASSWORD", "user", None, username)
539
 
 
540
  @classmethod
541
- def update_environment(cls, update_data: dict, username: str) -> None:
542
  """Update environment configuration"""
543
  if cls._instance is None:
544
  cls.get()
545
 
546
- config = cls._instance.global_config
547
-
548
- # Update fields
549
- if 'work_mode' in update_data:
550
- config.work_mode = update_data['work_mode']
551
- if 'cloud_token' in update_data:
552
- from encryption_utils import encrypt
553
- config.cloud_token = encrypt(update_data['cloud_token']) if update_data['cloud_token'] else ""
554
- if 'spark_endpoint' in update_data:
555
- config.spark_endpoint = update_data['spark_endpoint']
556
- if 'internal_prompt' in update_data:
557
- config.internal_prompt = update_data['internal_prompt']
558
-
559
- # TTS/STT settings
560
- if 'tts_engine' in update_data:
561
- config.tts_engine = update_data['tts_engine']
562
- if 'tts_engine_api_key' in update_data and update_data['tts_engine_api_key'] != "***":
563
- from encryption_utils import encrypt
564
- config.tts_engine_api_key = encrypt(update_data['tts_engine_api_key']) if update_data['tts_engine_api_key'] else ""
565
- if 'tts_settings' in update_data:
566
- config.tts_settings = update_data['tts_settings']
567
-
568
- if 'stt_engine' in update_data:
569
- config.stt_engine = update_data['stt_engine']
570
- if 'stt_engine_api_key' in update_data:
571
- from encryption_utils import encrypt
572
- config.stt_engine_api_key = encrypt(update_data['stt_engine_api_key']) if update_data['stt_engine_api_key'] else ""
573
- if 'stt_settings' in update_data:
574
- config.stt_settings = update_data['stt_settings']
575
-
576
- if 'parameter_collection_config' in update_data:
577
- config.parameter_collection_config = ParameterCollectionConfig(**update_data['parameter_collection_config'])
578
-
579
- # Update metadata
580
- cls._instance.last_update_date = datetime.now().isoformat()
581
- cls._instance.last_update_user = username
582
-
583
- # Add activity log
584
- cls.add_activity_log(username, "UPDATE_ENVIRONMENT", "config", None, "environment",
585
- f"Changed to {config.work_mode}, TTS: {config.tts_engine}, STT: {config.stt_engine}")
586
 
587
- # Save to file
588
- cls._instance.save()
589
-
590
- # Reload to ensure consistency
591
- cls.reload()
592
-
593
- @classmethod
594
- def add_activity_log(cls, username: str, action: str, entity_type: str,
595
- entity_id: Any = None, entity_name: str = None, details: str = "") -> None:
596
- """Add activity log entry"""
597
- if cls._instance is None:
598
- cls.get()
599
-
600
- entry = ActivityLogEntry(
601
- timestamp=datetime.now().isoformat() + "Z",
602
- username=username,
603
- action=action,
604
- entity_type=entity_type,
605
- entity_id=entity_id,
606
- entity_name=entity_name,
607
- details=details
608
- )
609
-
610
- cls._instance.activity_log.append(entry)
611
-
612
- # Keep only last 1000 entries
613
- if len(cls._instance.activity_log) > 1000:
614
- cls._instance.activity_log = cls._instance.activity_log[-1000:]
615
-
616
- @classmethod
617
- def get_project(cls, project_id: int) -> Optional[ProjectConfig]:
618
- """Get project by ID"""
619
- if cls._instance is None:
620
- cls.get()
621
- return next((p for p in cls._instance.projects if p.id == project_id), None)
622
-
623
- @classmethod
624
- def get_project_by_name(cls, name: str) -> Optional[ProjectConfig]:
625
- """Get project by name"""
626
- if cls._instance is None:
627
- cls.get()
628
- return next((p for p in cls._instance.projects if p.name == name), None)
629
-
630
- @classmethod
631
- def create_project(cls, project_data: dict, username: str) -> ProjectConfig:
632
- """Create new project"""
633
- if cls._instance is None:
634
- cls.get()
635
-
636
- # Check duplicate name
637
- existing = [p.name for p in cls._instance.projects if not getattr(p, 'deleted', False)]
638
- if project_data['name'] in existing:
639
- raise ValueError(f"Project name '{project_data['name']}' already exists")
640
-
641
- # Get new project ID
642
- project_id = cls._instance.project_id_counter
643
- cls._instance.project_id_counter += 1
644
-
645
- # Create project
646
- new_project = ProjectConfig(
647
- id=project_id,
648
- name=project_data['name'],
649
- caption=project_data.get('caption', ''),
650
- icon=project_data.get('icon', 'folder'),
651
- description=project_data.get('description', ''),
652
- default_language=project_data.get('default_language', 'Türkçe'),
653
- supported_languages=project_data.get('supported_languages', ['tr-TR']),
654
- timezone=project_data.get('timezone', 'Europe/Istanbul'),
655
- region=project_data.get('region', 'tr-TR'),
656
- enabled=True,
657
- deleted=False,
658
- version_id_counter=2, # Start from 2 since we create version 1
659
- last_version_number=1,
660
- created_date=datetime.now().isoformat() + "Z",
661
- created_by=username,
662
- last_update_date=datetime.now().isoformat() + "Z",
663
- last_update_user=username,
664
- versions=[
665
- VersionConfig(
666
- id=1,
667
- version_number=1,
668
- no=1,
669
- caption="Version 1",
670
- published=False,
671
- created_date=datetime.now().isoformat() + "Z",
672
- created_by=username,
673
- last_update_date=datetime.now().isoformat() + "Z",
674
- last_update_user=username,
675
- general_prompt="",
676
- llm=LLMConfig(
677
- repo_id="Qwen/Qwen2.5-72B-Instruct",
678
- generation_config={
679
- "temperature": 0.5,
680
- "max_tokens": 2048,
681
- "top_p": 0.7,
682
- "repetition_penalty": 1.1
683
- },
684
- use_fine_tune=False,
685
- fine_tune_zip=""
686
- ),
687
- intents=[]
688
- )
689
- ]
690
- )
691
-
692
- cls._instance.projects.append(new_project)
693
-
694
- # Add activity log
695
- cls.add_activity_log(username, "CREATE_PROJECT", "project", project_id, project_data['name'])
696
 
697
  # Save
698
- cls._instance.save()
699
 
700
- return new_project
 
701
 
 
702
  @classmethod
703
- def update_project(cls, project_id: int, update_data: dict, username: str) -> ProjectConfig:
704
- """Update project"""
705
  if cls._instance is None:
706
- cls.get()
707
-
708
- project = cls.get_project(project_id)
709
- if not project:
710
- raise ValueError(f"Project {project_id} not found")
711
-
712
- # Check race condition
713
- if project.last_update_date != update_data.get('last_update_date'):
714
- raise ValueError("Project was modified by another user")
715
 
716
- # Update fields
717
- project.caption = update_data['caption']
718
- project.icon = update_data.get('icon', project.icon)
719
- project.description = update_data.get('description', project.description)
720
- project.default_language = update_data.get('default_language', project.default_language)
721
- project.supported_languages = update_data.get('supported_languages', project.supported_languages)
722
- project.timezone = update_data.get('timezone', project.timezone)
723
- project.region = update_data.get('region', project.region)
724
- project.last_update_date = datetime.now().isoformat() + "Z"
725
- project.last_update_user = username
726
-
727
- # Add activity log
728
- cls.add_activity_log(username, "UPDATE_PROJECT", "project", project_id, project.name)
729
-
730
- # Save
731
- cls._instance.save()
732
-
733
- return project
734
-
735
- @classmethod
736
- def delete_project(cls, project_id: int, username: str) -> None:
737
- """Delete project (soft delete)"""
738
- if cls._instance is None:
739
- cls.get()
740
-
741
- project = cls.get_project(project_id)
742
- if not project:
743
- raise ValueError(f"Project {project_id} not found")
744
-
745
- project.deleted = True
746
- project.last_update_date = datetime.now().isoformat() + "Z"
747
- project.last_update_user = username
748
-
749
- # Add activity log
750
- cls.add_activity_log(username, "DELETE_PROJECT", "project", project_id, project.name)
751
-
752
- # Save
753
- cls._instance.save()
754
-
755
- @classmethod
756
- def toggle_project(cls, project_id: int, username: str) -> bool:
757
- """Toggle project enabled status"""
758
- if cls._instance is None:
759
- cls.get()
760
-
761
- project = cls.get_project(project_id)
762
- if not project:
763
- raise ValueError(f"Project {project_id} not found")
764
-
765
- project.enabled = not project.enabled
766
- project.last_update_date = datetime.now().isoformat() + "Z"
767
- project.last_update_user = username
768
-
769
- # Add activity log
770
- action = "ENABLE_PROJECT" if project.enabled else "DISABLE_PROJECT"
771
- cls.add_activity_log(username, action, "project", project_id, project.name)
772
-
773
- # Save
774
- cls._instance.save()
775
-
776
- return project.enabled
777
-
778
- @classmethod
779
- def create_version(cls, project_id: int, version_data: dict, username: str) -> VersionConfig:
780
- """Create new version"""
781
- if cls._instance is None:
782
- cls.get()
783
-
784
- project = cls.get_project(project_id)
785
- if not project:
786
- raise ValueError(f"Project {project_id} not found")
787
-
788
- # Get next version ID
789
- version_id = project.version_id_counter
790
- project.version_id_counter += 1
791
-
792
- # Get next version number
793
- existing_versions = [v for v in project.versions if not getattr(v, 'deleted', False)]
794
- version_no = max([v.no for v in existing_versions], default=0) + 1
795
-
796
- # Create base version
797
- new_version = VersionConfig(
798
- id=version_id,
799
- version_number=version_id,
800
- no=version_no,
801
- caption=version_data['caption'],
802
- description=f"Version {version_no}",
803
- published=False,
804
- deleted=False,
805
- created_date=datetime.now().isoformat() + "Z",
806
- created_by=username,
807
- last_update_date=datetime.now().isoformat() + "Z",
808
- last_update_user=username,
809
- general_prompt="",
810
- llm=LLMConfig(
811
- repo_id="",
812
- generation_config={
813
- "max_new_tokens": 512,
814
- "temperature": 0.7,
815
- "top_p": 0.95,
816
- "top_k": 50,
817
- "repetition_penalty": 1.1
818
- },
819
- use_fine_tune=False,
820
- fine_tune_zip=""
821
- ),
822
- intents=[]
823
- )
824
-
825
- # Copy from source version if specified
826
- if version_data.get('source_version_id'):
827
- source_version = next(
828
- (v for v in project.versions if v.id == version_data['source_version_id']),
829
- None
830
- )
831
- if source_version:
832
- new_version.general_prompt = source_version.general_prompt
833
- new_version.llm = source_version.llm.model_copy(deep=True)
834
- new_version.intents = [i.model_copy(deep=True) for i in source_version.intents]
835
-
836
- project.versions.append(new_version)
837
- project.last_update_date = datetime.now().isoformat() + "Z"
838
- project.last_update_user = username
839
-
840
- # Add activity log
841
- cls.add_activity_log(username, "CREATE_VERSION", "version", version_id,
842
- f"{project.name} v{version_no}")
843
-
844
- # Save
845
- cls._instance.save()
846
-
847
- return new_version
848
-
849
- @classmethod
850
- def update_version(cls, project_id: int, version_id: int, update_data: dict, username: str) -> VersionConfig:
851
- """Update version"""
852
- if cls._instance is None:
853
- cls.get()
854
-
855
- project = cls.get_project(project_id)
856
- if not project:
857
- raise ValueError(f"Project {project_id} not found")
858
-
859
- version = next((v for v in project.versions if v.id == version_id), None)
860
- if not version:
861
- raise ValueError(f"Version {version_id} not found")
862
-
863
- # Check race condition
864
- if version.last_update_date != update_data.get('last_update_date'):
865
- raise ValueError("Version was modified by another user")
866
-
867
- # Cannot update published version
868
- if version.published:
869
- raise ValueError("Cannot modify published version")
870
-
871
- # Update version
872
- version.caption = update_data['caption']
873
- version.general_prompt = update_data['general_prompt']
874
- version.llm = LLMConfig(**update_data['llm'])
875
- version.intents = [IntentConfig(**i) for i in update_data['intents']]
876
- version.last_update_date = datetime.now().isoformat() + "Z"
877
- version.last_update_user = username
878
-
879
- # Update project timestamp
880
- project.last_update_date = datetime.now().isoformat() + "Z"
881
- project.last_update_user = username
882
-
883
- # Add activity log
884
- cls.add_activity_log(username, "UPDATE_VERSION", "version", version_id,
885
- f"{project.name} v{version.no}")
886
-
887
- # Save
888
- cls._instance.save()
889
-
890
- return version
891
-
892
- @classmethod
893
- def publish_version(cls, project_id: int, version_id: int, username: str) -> tuple[ProjectConfig, VersionConfig]:
894
- """Publish version"""
895
- if cls._instance is None:
896
- cls.get()
897
-
898
- project = cls.get_project(project_id)
899
- if not project:
900
- raise ValueError(f"Project {project_id} not found")
901
-
902
- version = next((v for v in project.versions if v.id == version_id), None)
903
- if not version:
904
- raise ValueError(f"Version {version_id} not found")
905
-
906
- # Unpublish all other versions
907
- for v in project.versions:
908
- if v.id != version_id:
909
- v.published = False
910
-
911
- # Publish this version
912
- version.published = True
913
- version.publish_date = datetime.now().isoformat() + "Z"
914
- version.published_by = username
915
- version.last_update_date = datetime.now().isoformat() + "Z"
916
- version.last_update_user = username
917
-
918
- # Update project timestamp
919
- project.last_update_date = datetime.now().isoformat() + "Z"
920
- project.last_update_user = username
921
-
922
- # Add activity log
923
- cls.add_activity_log(username, "PUBLISH_VERSION", "version", version_id,
924
- f"{project.name} v{version.no}")
925
-
926
- # Save
927
- cls._instance.save()
928
-
929
- return project, version
930
-
931
- @classmethod
932
- def delete_version(cls, project_id: int, version_id: int, username: str) -> None:
933
- """Delete version (soft delete)"""
934
- if cls._instance is None:
935
- cls.get()
936
-
937
- project = cls.get_project(project_id)
938
- if not project:
939
- raise ValueError(f"Project {project_id} not found")
940
-
941
- version = next((v for v in project.versions if v.id == version_id), None)
942
- if not version:
943
- raise ValueError(f"Version {version_id} not found")
944
-
945
- # Cannot delete published version
946
- if version.published:
947
- raise ValueError("Cannot delete published version")
948
-
949
- version.deleted = True
950
- version.last_update_date = datetime.now().isoformat() + "Z"
951
- version.last_update_user = username
952
-
953
- project.last_update_date = datetime.now().isoformat() + "Z"
954
- project.last_update_user = username
955
-
956
- # Add activity log
957
- cls.add_activity_log(username, "DELETE_VERSION", "version", version_id,
958
- f"{project.name} v{version.no}")
959
-
960
- # Save
961
- cls._instance.save()
962
-
963
- @classmethod
964
- def create_api(cls, api_data: dict, username: str) -> APIConfig:
965
- """Create new API"""
966
- if cls._instance is None:
967
- cls.get()
968
-
969
- # Check duplicate name
970
- existing = [a.name for a in cls._instance.apis if not getattr(a, 'deleted', False)]
971
- if api_data['name'] in existing:
972
- raise ValueError(f"API name '{api_data['name']}' already exists")
973
-
974
- # Create API
975
- new_api = APIConfig(
976
- **api_data,
977
- deleted=False,
978
- created_date=datetime.now().isoformat() + "Z",
979
- created_by=username,
980
- last_update_date=datetime.now().isoformat() + "Z",
981
- last_update_user=username
982
- )
983
-
984
- cls._instance.apis.append(new_api)
985
- cls._instance.build_index() # Rebuild index
986
-
987
- # Add activity log
988
- cls.add_activity_log(username, "CREATE_API", "api", None, api_data['name'])
989
-
990
- # Save
991
- cls._instance.save()
992
-
993
- return new_api
994
-
995
- @classmethod
996
- def update_api(cls, api_name: str, update_data: dict, username: str) -> APIConfig:
997
- """Update API"""
998
- if cls._instance is None:
999
- cls.get()
1000
 
1001
- api = cls._instance.get_api(api_name)
1002
- if not api:
1003
- raise ValueError(f"API '{api_name}' not found")
1004
-
1005
- # Check race condition
1006
- if api.last_update_date != update_data.get('last_update_date'):
1007
- raise ValueError("API was modified by another user")
1008
-
1009
- # Check if API is in use in published versions
1010
- for project in cls._instance.projects:
1011
- for version in project.versions:
1012
- if version.published:
1013
- for intent in version.intents:
1014
- if intent.action == api_name:
1015
- raise ValueError(f"API is used in published version of project '{project.name}'")
1016
-
1017
- # Update API
1018
- for key, value in update_data.items():
1019
- if key != 'last_update_date' and hasattr(api, key):
1020
- setattr(api, key, value)
1021
-
1022
- api.last_update_date = datetime.now().isoformat() + "Z"
1023
- api.last_update_user = username
1024
-
1025
- # Add activity log
1026
- cls.add_activity_log(username, "UPDATE_API", "api", None, api_name)
1027
-
1028
- # Save
1029
- cls._instance.save()
1030
-
1031
- return api
1032
-
1033
- @classmethod
1034
- def delete_api(cls, api_name: str, username: str) -> None:
1035
- """Delete API (soft delete)"""
1036
- if cls._instance is None:
1037
- cls.get()
1038
 
1039
- api = cls._instance.get_api(api_name)
1040
- if not api:
1041
- raise ValueError(f"API '{api_name}' not found")
1042
-
1043
- # Check if API is in use
1044
- for project in cls._instance.projects:
1045
- if getattr(project, 'deleted', False):
1046
- continue
1047
-
1048
- for version in project.versions:
1049
- if getattr(version, 'deleted', False):
1050
- continue
1051
-
1052
- for intent in version.intents:
1053
- if intent.action == api_name:
1054
- raise ValueError(f"API is used in intent '{intent.name}' in project '{project.name}' version {version.no}")
1055
-
1056
- api.deleted = True
1057
- api.last_update_date = datetime.now().isoformat() + "Z"
1058
- api.last_update_user = username
1059
-
1060
- # Add activity log
1061
- cls.add_activity_log(username, "DELETE_API", "api", None, api_name)
1062
-
1063
- # Save
1064
- cls._instance.save()
1065
 
 
1066
  @classmethod
1067
- def import_project(cls, project_data: dict, username: str) -> ProjectConfig:
1068
- """Import project from JSON"""
1069
- if cls._instance is None:
1070
- cls.get()
1071
-
1072
- # Validate structure
1073
- if "name" not in project_data:
1074
- raise ValueError("Invalid project data")
1075
-
1076
- # Create new project with imported data
1077
- imported_data = {
1078
- "name": project_data["name"],
1079
- "caption": project_data.get("caption", ""),
1080
- "icon": project_data.get("icon", "folder"),
1081
- "description": project_data.get("description", "")
1082
- }
1083
-
1084
- # Create project
1085
- new_project = cls.create_project(imported_data, username)
1086
-
1087
- # Clear default version
1088
- new_project.versions = []
1089
-
1090
- # Import versions
1091
- for idx, version_data in enumerate(project_data.get("versions", [])):
1092
- new_version = VersionConfig(
1093
- id=idx + 1,
1094
- version_number=idx + 1,
1095
- no=idx + 1,
1096
- caption=version_data.get("caption", f"Version {idx + 1}"),
1097
- description=version_data.get("description", ""),
1098
- published=False,
1099
- deleted=False,
1100
- created_date=datetime.now().isoformat() + "Z",
1101
- created_by=username,
1102
- last_update_date=datetime.now().isoformat() + "Z",
1103
- last_update_user=username,
1104
- general_prompt=version_data.get("general_prompt", ""),
1105
- llm=LLMConfig(**version_data.get("llm", {})) if version_data.get("llm") else LLMConfig(
1106
- repo_id="",
1107
- generation_config={},
1108
- use_fine_tune=False,
1109
- fine_tune_zip=""
1110
- ),
1111
- intents=[IntentConfig(**i) for i in version_data.get("intents", [])]
1112
- )
1113
- new_project.versions.append(new_version)
1114
- new_project.version_id_counter = idx + 2
1115
-
1116
- # Add activity log (already added in create_project)
1117
- cls.add_activity_log(username, "IMPORT_PROJECT", "project", new_project.id, new_project.name)
1118
-
1119
- # Save
1120
- cls._instance.save()
1121
-
1122
- return new_project
1123
-
1124
- @classmethod
1125
- def export_project(cls, project_id: int, username: str) -> dict:
1126
- """Export project as JSON"""
1127
- if cls._instance is None:
1128
- cls.get()
1129
-
1130
- project = cls.get_project(project_id)
1131
- if not project:
1132
- raise ValueError(f"Project {project_id} not found")
1133
-
1134
- # Create export data
1135
- export_data = {
1136
- "name": project.name,
1137
- "caption": project.caption,
1138
- "icon": project.icon,
1139
- "description": project.description,
1140
- "versions": []
1141
- }
1142
-
1143
- # Export versions
1144
- for version in project.versions:
1145
- if not getattr(version, 'deleted', False):
1146
- export_version = {
1147
- "caption": version.caption,
1148
- "description": getattr(version, 'description', ''),
1149
- "general_prompt": version.general_prompt,
1150
- "llm": version.llm.model_dump(),
1151
- "intents": [i.model_dump() for i in version.intents]
1152
- }
1153
- export_data["versions"].append(export_version)
1154
-
1155
- # Add activity log
1156
- cls.add_activity_log(username, "EXPORT_PROJECT", "project", project_id, project.name)
1157
- cls._instance.save()
1158
-
1159
- return export_data
1160
-
1161
- @classmethod
1162
- def cleanup_activity_logs(cls, keep_count: int = 1000) -> None:
1163
- """Cleanup old activity logs"""
1164
- if cls._instance is None:
1165
- cls.get()
1166
-
1167
- if len(cls._instance.activity_log) > keep_count:
1168
- # Keep only last entries
1169
- cls._instance.activity_log = cls._instance.activity_log[-keep_count:]
1170
- cls._instance.save()
1171
-
1172
- # Forward references
1173
- GlobalConfig.model_rebuild()
1174
- VersionConfig.model_rebuild()
1175
- ServiceConfig.model_rebuild()
 
1
  """
2
+ Flare – ConfigProvider
3
  """
4
 
5
  from __future__ import annotations
6
+ import json, os, threading
 
7
  from pathlib import Path
8
+ from typing import Any, Dict, List, Optional
 
9
  import commentjson
10
+ from datetime import datetime, timezone
11
+
12
+ from pydantic import BaseModel, Field, HttpUrl, ValidationError
13
  from utils import log
 
14
  from encryption_utils import decrypt
15
 
16
+ # ============== Provider Configs ==============
17
+ class ProviderConfig(BaseModel):
18
+ """Configuration for available providers (LLM, TTS, STT)"""
19
+ type: str = Field(..., pattern=r"^(llm|tts|stt)$")
20
+ name: str
21
+ display_name: str
22
+ requires_endpoint: bool = False
23
+ requires_api_key: bool = True
24
+ requires_repo_info: bool = False
25
+ description: Optional[str] = None
26
+
27
+ class ProviderSettings(BaseModel):
28
+ """Provider-specific settings"""
29
+ name: str
30
+ api_key: Optional[str] = None
31
+ endpoint: Optional[str] = None
32
+ settings: Dict[str, Any] = Field(default_factory=dict)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
+ # ============== Parameter Collection ==============
35
+ class ParameterCollectionConfig(BaseModel):
36
+ """Configuration for parameter collection behavior"""
37
+ max_params_per_question: int = Field(2, ge=1, le=5)
38
+ retry_unanswered: bool = True
39
+ collection_prompt: str = ""
40
+
41
  class Config:
42
  extra = "allow"
43
 
44
+ # ============== Global Config ==============
45
  class GlobalConfig(BaseModel):
46
+ # Provider configurations
47
+ llm_provider: ProviderSettings
48
+ tts_provider: ProviderSettings
49
+ stt_provider: ProviderSettings
50
+
51
+ # Available providers
52
+ providers: List[ProviderConfig] = Field(default_factory=list)
53
+
54
+ # Users
55
  users: List["UserConfig"] = []
56
 
57
+ def get_provider_config(self, provider_type: str, provider_name: str) -> Optional[ProviderConfig]:
58
+ """Get configuration for a specific provider"""
59
+ return next((p for p in self.providers if p.type == provider_type and p.name == provider_name), None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
+ def get_providers_by_type(self, provider_type: str) -> List[ProviderConfig]:
62
+ """Get all providers of a specific type"""
63
+ return [p for p in self.providers if p.type == provider_type]
64
+
65
+ def get_plain_api_key(self, provider_type: str) -> Optional[str]:
66
+ """Get decrypted API key for a provider type"""
67
+ provider = getattr(self, f"{provider_type}_provider", None)
68
+ if not provider or not provider.api_key:
69
+ return None
70
+
71
+ raw_key = provider.api_key
72
+ # If it starts with enc:, decrypt it
73
+ if raw_key.startswith("enc:"):
74
+ log(f"🔓 Decrypting {provider_type} API key...")
 
 
 
 
 
 
 
 
 
 
 
75
  decrypted = decrypt(raw_key)
76
+ log(f"🔓 {provider_type} key decrypted: {'***' + decrypted[-4:] if decrypted else 'None'}")
77
  return decrypted
78
 
79
+ log(f"🔑 {provider_type} key/path: {'***' + raw_key[-4:] if raw_key else 'None'}")
80
  return raw_key
81
 
82
+ class Config:
83
+ extra = "allow"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
+ # ============== User Config ==============
86
  class UserConfig(BaseModel):
87
  username: str
88
  password_hash: str
89
  salt: str
90
 
91
+ # ============== Retry / Proxy ==============
 
92
  class RetryConfig(BaseModel):
93
  retry_count: int = Field(3, alias="max_attempts")
94
  backoff_seconds: int = 2
95
  strategy: str = Field("static", pattern=r"^(static|exponential)$")
96
 
 
97
  class ProxyConfig(BaseModel):
98
  enabled: bool = True
99
  url: HttpUrl
100
 
101
+ # ============== API & Auth ==============
 
102
  class APIAuthConfig(BaseModel):
103
  enabled: bool = False
104
  token_endpoint: Optional[HttpUrl] = None
 
111
  extra = "allow"
112
  populate_by_name = True
113
 
 
114
  class APIConfig(BaseModel):
115
  name: str
116
  url: HttpUrl
 
127
  extra = "allow"
128
  populate_by_name = True
129
 
130
+ # ============== Localized Content ==============
131
+ class LocalizedCaption(BaseModel):
132
+ locale_code: str
133
+ caption: str
134
 
135
+ class LocalizedExample(BaseModel):
136
+ locale_code: str
137
+ example: str
138
+
139
+ # ============== Intent / Param ==============
140
  class ParameterConfig(BaseModel):
141
  name: str
142
+ caption: List[LocalizedCaption] # Multi-language captions
143
+ type: str = Field(..., pattern=r"^(int|float|bool|str|string|date)$")
144
  required: bool = True
145
  variable_name: str
146
  extraction_prompt: Optional[str] = None
147
  validation_regex: Optional[str] = None
148
  invalid_prompt: Optional[str] = None
149
  type_error_prompt: Optional[str] = None
150
+
151
+ def get_caption_for_locale(self, locale_code: str, default_locale: str = "tr") -> str:
152
+ """Get caption for specific locale with fallback"""
153
+ # Try exact match
154
+ caption = next((c.caption for c in self.caption if c.locale_code == locale_code), None)
155
+ if caption:
156
+ return caption
157
+
158
+ # Try default locale
159
+ caption = next((c.caption for c in self.caption if c.locale_code == default_locale), None)
160
+ if caption:
161
+ return caption
162
+
163
+ # Return first available or parameter name
164
+ return self.caption[0].caption if self.caption else self.name
165
 
166
  def canonical_type(self) -> str:
167
  if self.type == "string":
 
170
  return "str" # Store dates as strings in ISO format
171
  return self.type
172
 
 
173
  class IntentConfig(BaseModel):
174
  name: str
175
  caption: Optional[str] = ""
 
176
  dependencies: List[str] = []
177
+ examples: List[LocalizedExample] = [] # Multi-language examples
178
  detection_prompt: Optional[str] = None
179
  parameters: List[ParameterConfig] = []
180
  action: str
181
  fallback_timeout_prompt: Optional[str] = None
182
  fallback_error_prompt: Optional[str] = None
183
+
184
+ def get_examples_for_locale(self, locale_code: str) -> List[str]:
185
+ """Get examples for specific locale"""
186
+ return [ex.example for ex in self.examples if ex.locale_code == locale_code]
187
 
188
  class Config:
189
  extra = "allow"
190
 
191
+ # ============== Version / Project ==============
 
192
  class LLMConfig(BaseModel):
193
  repo_id: str
194
  generation_config: Dict[str, Any] = {}
195
  use_fine_tune: bool = False
196
  fine_tune_zip: str = ""
197
 
 
198
  class VersionConfig(BaseModel):
199
  id: int = Field(..., alias="version_number")
200
  caption: Optional[str] = ""
201
  published: bool = False
202
  general_prompt: str
203
+ llm: Optional["LLMConfig"] = None # Optional based on provider
204
  intents: List["IntentConfig"]
205
 
206
  class Config:
207
  extra = "allow"
208
  populate_by_name = True
209
 
 
210
  class ProjectConfig(BaseModel):
211
  id: Optional[int] = None
212
  name: str
213
  caption: Optional[str] = ""
214
  enabled: bool = True
215
+ default_locale: str = "tr" # Changed from default_language
216
+ supported_locales: List[str] = ["tr"] # Changed from supported_languages
217
  last_version_number: Optional[int] = None
218
  versions: List[VersionConfig]
219
 
220
  class Config:
221
  extra = "allow"
222
 
223
+ # ============== Service Config ==============
 
 
 
 
 
 
 
 
 
 
 
 
224
  class ServiceConfig(BaseModel):
225
  global_config: GlobalConfig = Field(..., alias="config")
226
  projects: List[ProjectConfig]
227
  apis: List[APIConfig]
 
 
 
 
 
 
228
 
229
  # runtime helpers (skip validation)
230
  _api_by_name: Dict[str, APIConfig] = {}
 
232
  def build_index(self):
233
  self._api_by_name = {a.name: a for a in self.apis}
234
 
235
+ def get_api(self, name: str) -> APIConfig | None:
236
  return self._api_by_name.get(name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
+ # ============== Provider Singleton ==============
 
239
  class ConfigProvider:
240
  _instance: Optional[ServiceConfig] = None
241
  _CONFIG_PATH = Path(__file__).parent / "service_config.jsonc"
242
+ _lock = threading.Lock()
 
243
 
244
  @classmethod
245
  def get(cls) -> ServiceConfig:
 
250
  if cls._instance is None:
251
  cls._instance = cls._load()
252
  cls._instance.build_index()
 
 
 
 
253
  return cls._instance
254
 
255
  @classmethod
 
258
  with cls._lock:
259
  log("🔄 Reloading configuration...")
260
  cls._instance = None
 
261
  return cls.get()
262
 
263
  @classmethod
 
293
 
294
  config_section = config_data['config']
295
 
296
+ # Set defaults for missing fields if needed
297
+ if 'llm_provider' not in config_section:
298
+ config_section['llm_provider'] = {
299
+ "name": "spark",
300
+ "api_key": None,
301
+ "endpoint": "http://localhost:7861",
302
+ "settings": {}
303
+ }
304
+
305
+ if 'tts_provider' not in config_section:
306
+ config_section['tts_provider'] = {
307
+ "name": "no_tts",
308
+ "api_key": None,
309
+ "endpoint": None,
310
+ "settings": {}
311
+ }
312
+
313
+ if 'stt_provider' not in config_section:
314
+ config_section['stt_provider'] = {
315
+ "name": "no_stt",
316
+ "api_key": None,
317
+ "endpoint": None,
318
+ "settings": {}
319
+ }
320
+
321
+ if 'providers' not in config_section:
322
+ config_section['providers'] = []
323
 
324
  config_section.setdefault('users', [])
325
 
 
351
  except:
352
  auth['token_refresh_body'] = {}
353
 
354
+ # Parse and validate
355
+ cfg = ServiceConfig.model_validate(config_data)
 
 
 
 
 
 
 
 
 
 
 
356
 
357
  log("✅ Configuration loaded successfully")
358
+ return cfg
359
 
360
+ except FileNotFoundError:
361
+ log(f"❌ Config file not found: {cls._CONFIG_PATH}")
 
 
 
362
  raise
363
+ except (commentjson.JSONLibraryException, ValidationError) as e:
364
+ log(f"❌ Config parsing/validation error: {e}")
365
  raise
366
  except Exception as e:
367
  log(f"❌ Unexpected error loading config: {e}")
368
  raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
 
370
+ # ============== Environment Methods ==============
371
  @classmethod
372
+ def update_environment(cls, env_data: dict, username: str):
373
  """Update environment configuration"""
374
  if cls._instance is None:
375
  cls.get()
376
 
377
+ cfg = cls._instance
378
+ timestamp = _get_iso_timestamp()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
 
380
+ # Update provider configurations
381
+ if 'llm_provider' in env_data:
382
+ cfg.global_config.llm_provider = ProviderSettings(**env_data['llm_provider'])
383
+ if 'tts_provider' in env_data:
384
+ cfg.global_config.tts_provider = ProviderSettings(**env_data['tts_provider'])
385
+ if 'stt_provider' in env_data:
386
+ cfg.global_config.stt_provider = ProviderSettings(**env_data['stt_provider'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
 
388
  # Save
389
+ cls._save()
390
 
391
+ # Log activity
392
+ cls._log_activity("UPDATE_ENVIRONMENT", "environment", username=username)
393
 
394
+ # ============== Save Method ==============
395
  @classmethod
396
+ def _save(cls):
397
+ """Save current config to file"""
398
  if cls._instance is None:
399
+ return
 
 
 
 
 
 
 
 
400
 
401
+ with cls._lock:
402
+ config_dict = {
403
+ "config": cls._instance.global_config.model_dump(exclude_none=True),
404
+ "projects": [p.model_dump(by_alias=True) for p in cls._instance.projects],
405
+ "apis": [a.model_dump(by_alias=True) for a in cls._instance.apis]
406
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
 
408
+ # Write to file
409
+ with open(cls._CONFIG_PATH, 'w', encoding='utf-8') as f:
410
+ f.write(commentjson.dumps(config_dict, indent=2, ensure_ascii=False))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
 
412
+ log("💾 Configuration saved")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
+ # ============== Activity Logging ==============
415
  @classmethod
416
+ def _log_activity(cls, action: str, entity_type: str, entity_id: Any = None,
417
+ entity_name: str = None, username: str = "system", details: str = None):
418
+ """Log activity - placeholder for future implementation"""
419
+ log(f"📝 Activity: {action} on {entity_type} by {username}")
420
+
421
+ # ============== Helper Functions ==============
422
+ def _get_iso_timestamp() -> str:
423
+ """Get current timestamp in ISO format"""
424
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"