ciyidogan commited on
Commit
9326c5f
·
verified ·
1 Parent(s): 524896a

Upload 3 files

Browse files
routes/admin_routes.py ADDED
@@ -0,0 +1,1049 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin API endpoints for Flare (Refactored)
2
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3
+ Provides authentication, project, version, and API management endpoints with provider support.
4
+ """
5
+
6
+ import os
7
+ import time
8
+ import threading
9
+ import hashlib
10
+ import bcrypt
11
+ from typing import Optional, Dict, List, Any
12
+ from datetime import datetime, timedelta, timezone
13
+ from fastapi import APIRouter, HTTPException, Depends, Query, Response, Body
14
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
15
+ from pydantic import BaseModel, Field
16
+ import httpx
17
+ from functools import wraps
18
+
19
+ from utils import verify_token, create_token, get_current_timestamp
20
+ from config_provider import ConfigProvider
21
+ from logger import log_info, log_error, log_warning, log_debug
22
+ from exceptions import (
23
+ FlareException,
24
+ RaceConditionError,
25
+ ValidationError,
26
+ ResourceNotFoundError,
27
+ AuthenticationError,
28
+ AuthorizationError,
29
+ DuplicateResourceError
30
+ )
31
+ from config_models import VersionConfig, IntentConfig, LLMConfiguration
32
+
33
+ # ===================== Constants & Config =====================
34
+ security = HTTPBearer()
35
+ router = APIRouter(tags=["admin"])
36
+
37
+ # ===================== Decorators =====================
38
+ def handle_exceptions(func):
39
+ """Decorator to handle exceptions consistently"""
40
+ @wraps(func)
41
+ async def wrapper(*args, **kwargs):
42
+ try:
43
+ return await func(*args, **kwargs)
44
+ except HTTPException:
45
+ # HTTPException'ları olduğu gibi geçir
46
+ raise
47
+ except FlareException:
48
+ # Let global handlers deal with our custom exceptions
49
+ raise
50
+ except Exception as e:
51
+ # Log and convert unexpected exceptions to HTTP 500
52
+ log_error(f"❌ Unexpected error in {func.__name__}", e)
53
+ raise HTTPException(status_code=500, detail=str(e))
54
+ return wrapper
55
+
56
+ # ===================== Models =====================
57
+ class LoginRequest(BaseModel):
58
+ username: str
59
+ password: str
60
+
61
+ class LoginResponse(BaseModel):
62
+ token: str
63
+ username: str
64
+
65
+ class ChangePasswordRequest(BaseModel):
66
+ current_password: str
67
+ new_password: str
68
+
69
+ class ProviderSettingsUpdate(BaseModel):
70
+ name: str
71
+ api_key: Optional[str] = None
72
+ endpoint: Optional[str] = None
73
+ settings: Dict[str, Any] = Field(default_factory=dict)
74
+
75
+ class EnvironmentUpdate(BaseModel):
76
+ llm_provider: ProviderSettingsUpdate
77
+ tts_provider: ProviderSettingsUpdate
78
+ stt_provider: ProviderSettingsUpdate
79
+ parameter_collection_config: Optional[Dict[str, Any]] = None
80
+
81
+ class ProjectCreate(BaseModel):
82
+ name: str
83
+ caption: Optional[str] = ""
84
+ icon: Optional[str] = "folder"
85
+ description: Optional[str] = ""
86
+ default_locale: str = "tr"
87
+ supported_locales: List[str] = Field(default_factory=lambda: ["tr"])
88
+ timezone: str = "Europe/Istanbul"
89
+ region: str = "tr-TR"
90
+
91
+ class ProjectUpdate(BaseModel):
92
+ caption: str
93
+ icon: Optional[str] = "folder"
94
+ description: Optional[str] = ""
95
+ default_locale: str = "tr"
96
+ supported_locales: List[str] = Field(default_factory=lambda: ["tr"])
97
+ timezone: str = "Europe/Istanbul"
98
+ region: str = "tr-TR"
99
+ last_update_date: str
100
+
101
+ class VersionCreate(BaseModel):
102
+ caption: str
103
+ source_version_no: int | None = None
104
+
105
+ class IntentModel(BaseModel):
106
+ name: str
107
+ caption: Optional[str] = ""
108
+ detection_prompt: str
109
+ examples: List[Dict[str, str]] = [] # LocalizedExample format
110
+ parameters: List[Dict[str, Any]] = []
111
+ action: str
112
+ fallback_timeout_prompt: Optional[str] = None
113
+ fallback_error_prompt: Optional[str] = None
114
+
115
+ class VersionUpdate(BaseModel):
116
+ caption: str
117
+ general_prompt: str
118
+ llm: Dict[str, Any]
119
+ intents: List[IntentModel]
120
+ last_update_date: str
121
+
122
+ class APICreate(BaseModel):
123
+ name: str
124
+ url: str
125
+ method: str = "POST"
126
+ headers: Dict[str, str] = {}
127
+ body_template: Dict[str, Any] = {}
128
+ timeout_seconds: int = 10
129
+ retry: Dict[str, Any] = Field(default_factory=lambda: {"retry_count": 3, "backoff_seconds": 2, "strategy": "static"})
130
+ proxy: Optional[str] = None
131
+ auth: Optional[Dict[str, Any]] = None
132
+ response_prompt: Optional[str] = None
133
+ response_mappings: List[Dict[str, Any]] = []
134
+
135
+ class APIUpdate(BaseModel):
136
+ url: str
137
+ method: str
138
+ headers: Dict[str, str]
139
+ body_template: Dict[str, Any]
140
+ timeout_seconds: int
141
+ retry: Dict[str, Any]
142
+ proxy: Optional[str]
143
+ auth: Optional[Dict[str, Any]]
144
+ response_prompt: Optional[str]
145
+ response_mappings: List[Dict[str, Any]] = []
146
+ last_update_date: str
147
+
148
+ class TestRequest(BaseModel):
149
+ test_type: str # "all", "ui", "backend", "integration", "spark"
150
+
151
+ # ===================== Auth Endpoints =====================
152
+ @router.post("/login", response_model=LoginResponse)
153
+ @handle_exceptions
154
+ async def login(request: LoginRequest):
155
+ """User login endpoint"""
156
+ cfg = ConfigProvider.get()
157
+
158
+ # Find user
159
+ user = next((u for u in cfg.global_config.users if u.username == request.username), None)
160
+ if not user:
161
+ raise HTTPException(status_code=401, detail="Invalid credentials")
162
+
163
+ # Verify password - Try both bcrypt and SHA256 for backward compatibility
164
+ password_valid = False
165
+
166
+ # First try bcrypt (new format)
167
+ try:
168
+ if user.password_hash.startswith("$2b$") or user.password_hash.startswith("$2a$"):
169
+ password_valid = bcrypt.checkpw(request.password.encode('utf-8'), user.password_hash.encode('utf-8'))
170
+ except:
171
+ pass
172
+
173
+ # If not valid, try SHA256 (old format)
174
+ if not password_valid:
175
+ sha256_hash = hashlib.sha256(request.password.encode('utf-8')).hexdigest()
176
+ password_valid = (user.password_hash == sha256_hash)
177
+
178
+ if not password_valid:
179
+ raise HTTPException(status_code=401, detail="Invalid credentials")
180
+
181
+ # Create token
182
+ token = create_token(request.username)
183
+
184
+ log_info(f"✅ User '{request.username}' logged in successfully")
185
+ return LoginResponse(token=token, username=request.username)
186
+
187
+ @router.post("/change-password")
188
+ @handle_exceptions
189
+ async def change_password(
190
+ request: ChangePasswordRequest,
191
+ username: str = Depends(verify_token)
192
+ ):
193
+ """Change user password"""
194
+ cfg = ConfigProvider.get()
195
+
196
+ # Find user
197
+ user = next((u for u in cfg.global_config.users if u.username == username), None)
198
+ if not user:
199
+ raise HTTPException(status_code=404, detail="User not found")
200
+
201
+ # Verify current password - Try both bcrypt and SHA256 for backward compatibility
202
+ password_valid = False
203
+
204
+ # First try bcrypt (new format)
205
+ try:
206
+ if user.password_hash.startswith("$2b$") or user.password_hash.startswith("$2a$"):
207
+ password_valid = bcrypt.checkpw(request.current_password.encode('utf-8'), user.password_hash.encode('utf-8'))
208
+ except:
209
+ pass
210
+
211
+ # If not valid, try SHA256 (old format)
212
+ if not password_valid:
213
+ sha256_hash = hashlib.sha256(request.current_password.encode('utf-8')).hexdigest()
214
+ password_valid = (user.password_hash == sha256_hash)
215
+
216
+ if not password_valid:
217
+ raise HTTPException(status_code=401, detail="Current password is incorrect")
218
+
219
+ # Generate new password hash (always use bcrypt for new passwords)
220
+ salt = bcrypt.gensalt()
221
+ new_hash = bcrypt.hashpw(request.new_password.encode('utf-8'), salt)
222
+
223
+ # Update user
224
+ user.password_hash = new_hash.decode('utf-8')
225
+ user.salt = salt.decode('utf-8')
226
+
227
+ # Save configuration via ConfigProvider
228
+ ConfigProvider.save(cfg, username)
229
+
230
+ log_info(f"✅ Password changed for user '{username}'")
231
+ return {"success": True}
232
+
233
+ # ===================== Locales Endpoints =====================
234
+ @router.get("/locales")
235
+ @handle_exceptions
236
+ async def get_available_locales(username: str = Depends(verify_token)):
237
+ """Get all system-supported locales"""
238
+ from locale_manager import LocaleManager
239
+
240
+ locales = LocaleManager.get_available_locales_with_names()
241
+
242
+ return {
243
+ "locales": locales,
244
+ "default": LocaleManager.get_default_locale()
245
+ }
246
+
247
+ @router.get("/locales/{locale_code}")
248
+ @handle_exceptions
249
+ async def get_locale_details(
250
+ locale_code: str,
251
+ username: str = Depends(verify_token)
252
+ ):
253
+ """Get detailed information for a specific locale"""
254
+ from locale_manager import LocaleManager
255
+
256
+ locale_info = LocaleManager.get_locale_details(locale_code)
257
+
258
+ if not locale_info:
259
+ raise HTTPException(status_code=404, detail=f"Locale '{locale_code}' not found")
260
+
261
+ return locale_info
262
+
263
+ # ===================== Environment Endpoints =====================
264
+ @router.get("/environment")
265
+ @handle_exceptions
266
+ async def get_environment(username: str = Depends(verify_token)):
267
+ """Get environment configuration with provider info"""
268
+ cfg = ConfigProvider.get()
269
+ env_config = cfg.global_config
270
+
271
+ # Provider tabanlı yeni yapıyı destekle
272
+ response = {}
273
+
274
+ # LLM Provider
275
+ if hasattr(env_config, 'llm_provider'):
276
+ response["llm_provider"] = env_config.llm_provider
277
+
278
+ # TTS Provider
279
+ if hasattr(env_config, 'tts_provider'):
280
+ response["tts_provider"] = env_config.tts_provider
281
+
282
+ # STT Provider
283
+ if hasattr(env_config, 'stt_provider'):
284
+ response["stt_provider"] = env_config.stt_provider
285
+ else:
286
+ response["stt_provider"] = {
287
+ "name": getattr(env_config, 'stt_engine', 'no_stt'),
288
+ "api_key": getattr(env_config, 'stt_engine_api_key', None) or "",
289
+ "endpoint": None,
290
+ "settings": getattr(env_config, 'stt_settings', {})
291
+ }
292
+
293
+ # Provider listesi
294
+ if hasattr(env_config, 'providers'):
295
+ providers_list = []
296
+ for provider in env_config.providers:
297
+ providers_list.append(provider)
298
+ response["providers"] = providers_list
299
+ else:
300
+ # Varsayılan provider listesi
301
+ response["providers"] = [
302
+ {
303
+ "type": "llm",
304
+ "name": "spark_cloud",
305
+ "display_name": "Spark LLM (Cloud)",
306
+ "requires_endpoint": True,
307
+ "requires_api_key": True,
308
+ "requires_repo_info": False
309
+ },
310
+ {
311
+ "type": "llm",
312
+ "name": "gpt-4o",
313
+ "display_name": "GPT-4o",
314
+ "requires_endpoint": True,
315
+ "requires_api_key": True,
316
+ "requires_repo_info": False
317
+ },
318
+ {
319
+ "type": "llm",
320
+ "name": "gpt-4o-mini",
321
+ "display_name": "GPT-4o Mini",
322
+ "requires_endpoint": True,
323
+ "requires_api_key": True,
324
+ "requires_repo_info": False
325
+ },
326
+ {
327
+ "type": "tts",
328
+ "name": "no_tts",
329
+ "display_name": "No TTS",
330
+ "requires_endpoint": False,
331
+ "requires_api_key": False,
332
+ "requires_repo_info": False
333
+ },
334
+ {
335
+ "type": "tts",
336
+ "name": "elevenlabs",
337
+ "display_name": "ElevenLabs",
338
+ "requires_endpoint": False,
339
+ "requires_api_key": True,
340
+ "requires_repo_info": False
341
+ },
342
+ {
343
+ "type": "stt",
344
+ "name": "no_stt",
345
+ "display_name": "No STT",
346
+ "requires_endpoint": False,
347
+ "requires_api_key": False,
348
+ "requires_repo_info": False
349
+ },
350
+ {
351
+ "type": "stt",
352
+ "name": "google",
353
+ "display_name": "Google Cloud STT",
354
+ "requires_endpoint": False,
355
+ "requires_api_key": True,
356
+ "requires_repo_info": False
357
+ }
358
+ ]
359
+
360
+ # Parameter collection config
361
+ if hasattr(env_config, 'parameter_collection_config'):
362
+ response["parameter_collection_config"] = env_config.parameter_collection_config
363
+ else:
364
+ # Varsayılan değerler
365
+ response["parameter_collection_config"] = {
366
+ "max_params_per_question": 2,
367
+ "retry_unanswered": True,
368
+ "smart_grouping": True,
369
+ "collection_prompt": "You are a helpful assistant collecting information from the user..."
370
+ }
371
+
372
+ return response
373
+
374
+ @router.put("/environment")
375
+ @handle_exceptions
376
+ async def update_environment(
377
+ update: EnvironmentUpdate,
378
+ username: str = Depends(verify_token)
379
+ ):
380
+ """Update environment configuration with provider validation"""
381
+ log_info(f"📝 Updating environment config by {username}")
382
+
383
+ cfg = ConfigProvider.get()
384
+
385
+ # Validate LLM provider
386
+ llm_provider_def = cfg.global_config.get_provider_config("llm", update.llm_provider.name)
387
+ if not llm_provider_def:
388
+ raise HTTPException(status_code=400, detail=f"Unknown LLM provider: {update.llm_provider.name}")
389
+
390
+ if llm_provider_def.requires_api_key and not update.llm_provider.api_key:
391
+ raise HTTPException(status_code=400, detail=f"{llm_provider_def.display_name} requires API key")
392
+
393
+ if llm_provider_def.requires_endpoint and not update.llm_provider.endpoint:
394
+ raise HTTPException(status_code=400, detail=f"{llm_provider_def.display_name} requires endpoint")
395
+
396
+ # Validate TTS provider
397
+ tts_provider_def = cfg.global_config.get_provider_config("tts", update.tts_provider.name)
398
+ if not tts_provider_def:
399
+ raise HTTPException(status_code=400, detail=f"Unknown TTS provider: {update.tts_provider.name}")
400
+
401
+ if tts_provider_def.requires_api_key and not update.tts_provider.api_key:
402
+ raise HTTPException(status_code=400, detail=f"{tts_provider_def.display_name} requires API key")
403
+
404
+ # Validate STT provider
405
+ stt_provider_def = cfg.global_config.get_provider_config("stt", update.stt_provider.name)
406
+ if not stt_provider_def:
407
+ raise HTTPException(status_code=400, detail=f"Unknown STT provider: {update.stt_provider.name}")
408
+
409
+ if stt_provider_def.requires_api_key and not update.stt_provider.api_key:
410
+ raise HTTPException(status_code=400, detail=f"{stt_provider_def.display_name} requires API key")
411
+
412
+ # Update via ConfigProvider
413
+ ConfigProvider.update_environment(update.model_dump(), username)
414
+
415
+ log_info(f"✅ Environment updated to LLM: {update.llm_provider.name}, TTS: {update.tts_provider.name}, STT: {update.stt_provider.name} by {username}")
416
+ return {"success": True}
417
+
418
+ # ===================== Project Endpoints =====================
419
+ @router.get("/projects/names")
420
+ @handle_exceptions
421
+ async def list_enabled_projects():
422
+ """Get list of enabled project names for chat"""
423
+ cfg = ConfigProvider.get()
424
+ return [p.name for p in cfg.projects if p.enabled and not getattr(p, 'deleted', False)]
425
+
426
+ @router.get("/projects")
427
+ @handle_exceptions
428
+ async def list_projects(
429
+ include_deleted: bool = False,
430
+ username: str = Depends(verify_token)
431
+ ):
432
+ """List all projects"""
433
+ cfg = ConfigProvider.get()
434
+ projects = cfg.projects
435
+
436
+ # Filter deleted if needed
437
+ if not include_deleted:
438
+ projects = [p for p in projects if not getattr(p, 'deleted', False)]
439
+
440
+ return [p.model_dump() for p in projects]
441
+
442
+ @router.get("/projects/{project_id}")
443
+ @handle_exceptions
444
+ async def get_project(
445
+ project_id: int,
446
+ username: str = Depends(verify_token)
447
+ ):
448
+ """Get single project by ID"""
449
+ project = ConfigProvider.get_project(project_id)
450
+ if not project or getattr(project, 'deleted', False):
451
+ raise HTTPException(status_code=404, detail="Project not found")
452
+
453
+ return project.model_dump()
454
+
455
+ @router.post("/projects")
456
+ @handle_exceptions
457
+ async def create_project(
458
+ project: ProjectCreate,
459
+ username: str = Depends(verify_token)
460
+ ):
461
+ """Create new project with initial version"""
462
+ # Validate supported locales
463
+ from locale_manager import LocaleManager
464
+
465
+ invalid_locales = LocaleManager.validate_project_languages(project.supported_locales)
466
+ if invalid_locales:
467
+ available_locales = LocaleManager.get_available_locales_with_names()
468
+ available_codes = [locale['code'] for locale in available_locales]
469
+ raise HTTPException(
470
+ status_code=400,
471
+ detail=f"Unsupported locales: {', '.join(invalid_locales)}. Available locales: {', '.join(available_codes)}"
472
+ )
473
+
474
+ # Check if default locale is in supported locales
475
+ if project.default_locale not in project.supported_locales:
476
+ raise HTTPException(
477
+ status_code=400,
478
+ detail="Default locale must be one of the supported locales"
479
+ )
480
+
481
+ # Debug log for project creation
482
+ log_debug(f"🔍 Creating project '{project.name}' with default_locale: {project.default_locale}")
483
+
484
+ new_project = ConfigProvider.create_project(project.model_dump(), username)
485
+
486
+ # Debug log for initial version
487
+ if new_project.versions:
488
+ initial_version = new_project.versions[0]
489
+ log_debug(f"🔍 Initial version created - no: {initial_version.no}, published: {initial_version.published}, type: {type(initial_version.published)}")
490
+
491
+ log_info(f"✅ Project '{project.name}' created by {username}")
492
+ return new_project.model_dump()
493
+
494
+ @router.put("/projects/{project_id}")
495
+ @handle_exceptions
496
+ async def update_project(
497
+ project_id: int,
498
+ update: ProjectUpdate,
499
+ username: str = Depends(verify_token)
500
+ ):
501
+ """Update existing project with race condition handling"""
502
+ log_info(f"🔍 Update request for project {project_id} by {username}")
503
+ log_info(f"🔍 Received last_update_date: {update.last_update_date}")
504
+
505
+ # Mevcut project'i al ve durumunu logla
506
+ current_project = ConfigProvider.get_project(project_id)
507
+ if current_project:
508
+ log_info(f"🔍 Current project last_update_date: {current_project.last_update_date}")
509
+ log_info(f"🔍 Current project last_update_user: {current_project.last_update_user}")
510
+
511
+ # Optimistic locking kontrolü
512
+ result = ConfigProvider.update_project(
513
+ project_id,
514
+ update.model_dump(),
515
+ username,
516
+ expected_last_update=update.last_update_date
517
+ )
518
+
519
+ log_info(f"✅ Project {project_id} updated by {username}")
520
+ return result
521
+
522
+ @router.delete("/projects/{project_id}")
523
+ @handle_exceptions
524
+ async def delete_project(project_id: int, username: str = Depends(verify_token)):
525
+ """Delete project (soft delete)"""
526
+ ConfigProvider.delete_project(project_id, username)
527
+
528
+ log_info(f"✅ Project deleted by {username}")
529
+ return {"success": True}
530
+
531
+ @router.patch("/projects/{project_id}/toggle")
532
+ async def toggle_project(project_id: int, username: str = Depends(verify_token)):
533
+ """Toggle project enabled status"""
534
+ enabled = ConfigProvider.toggle_project(project_id, username)
535
+
536
+ log_info(f"✅ Project {'enabled' if enabled else 'disabled'} by {username}")
537
+ return {"enabled": enabled}
538
+
539
+ # ===================== Import/Export Endpoints =====================
540
+ @router.get("/projects/{project_id}/export")
541
+ @handle_exceptions
542
+ async def export_project(
543
+ project_id: int,
544
+ username: str = Depends(verify_token)
545
+ ):
546
+ """Export project as JSON"""
547
+ project = ConfigProvider.get_project(project_id)
548
+ if not project:
549
+ raise HTTPException(status_code=404, detail="Project not found")
550
+
551
+ # Prepare export data
552
+ export_data = {
553
+ "name": project.name,
554
+ "caption": project.caption,
555
+ "icon": project.icon,
556
+ "description": project.description,
557
+ "default_locale": project.default_locale,
558
+ "supported_locales": project.supported_locales,
559
+ "timezone": project.timezone,
560
+ "region": project.region,
561
+ "versions": []
562
+ }
563
+
564
+ # Add versions (only non-deleted)
565
+ for version in project.versions:
566
+ if not getattr(version, 'deleted', False):
567
+ version_data = {
568
+ "caption": version.caption,
569
+ "description": getattr(version, 'description', ''),
570
+ "general_prompt": version.general_prompt,
571
+ "welcome_prompt": getattr(version, 'welcome_prompt', None),
572
+ "llm": version.llm.model_dump() if version.llm else {},
573
+ "intents": [intent.model_dump() for intent in version.intents]
574
+ }
575
+ export_data["versions"].append(version_data)
576
+
577
+ log_info(f"✅ Project '{project.name}' exported by {username}")
578
+
579
+ return export_data
580
+
581
+ @router.post("/projects/import")
582
+ @handle_exceptions
583
+ async def import_project(
584
+ project_data: dict = Body(...),
585
+ username: str = Depends(verify_token)
586
+ ):
587
+ """Import project from JSON"""
588
+ # Validate required fields
589
+ if not project_data.get('name'):
590
+ raise HTTPException(status_code=400, detail="Project name is required")
591
+
592
+ # Check for duplicate name
593
+ cfg = ConfigProvider.get()
594
+ if any(p.name == project_data['name'] for p in cfg.projects if not p.deleted):
595
+ raise HTTPException(
596
+ status_code=409,
597
+ detail=f"Project with name '{project_data['name']}' already exists"
598
+ )
599
+
600
+ # Create project
601
+ new_project_data = {
602
+ "name": project_data['name'],
603
+ "caption": project_data.get('caption', project_data['name']),
604
+ "icon": project_data.get('icon', 'folder'),
605
+ "description": project_data.get('description', ''),
606
+ "default_locale": project_data.get('default_locale', 'tr'),
607
+ "supported_locales": project_data.get('supported_locales', ['tr']),
608
+ "timezone": project_data.get('timezone', 'Europe/Istanbul'),
609
+ "region": project_data.get('region', 'tr-TR')
610
+ }
611
+
612
+ # Create project
613
+ new_project = ConfigProvider.create_project(new_project_data, username)
614
+
615
+ # Import versions
616
+ if 'versions' in project_data and project_data['versions']:
617
+ # Remove the initial version that was auto-created
618
+ if new_project.versions:
619
+ new_project.versions.clear()
620
+
621
+ # Add imported versions
622
+ for idx, version_data in enumerate(project_data['versions']):
623
+ version = VersionConfig(
624
+ no=idx + 1,
625
+ caption=version_data.get('caption', f'Version {idx + 1}'),
626
+ description=version_data.get('description', ''),
627
+ published=False, # Imported versions are unpublished
628
+ deleted=False,
629
+ general_prompt=version_data.get('general_prompt', ''),
630
+ welcome_prompt=version_data.get('welcome_prompt'),
631
+ llm=LLMConfiguration(**version_data.get('llm', {
632
+ 'repo_id': '',
633
+ 'generation_config': {
634
+ 'max_new_tokens': 512,
635
+ 'temperature': 0.7,
636
+ 'top_p': 0.9
637
+ },
638
+ 'use_fine_tune': False,
639
+ 'fine_tune_zip': ''
640
+ })),
641
+ intents=[IntentConfig(**intent) for intent in version_data.get('intents', [])],
642
+ created_date=get_current_timestamp(),
643
+ created_by=username
644
+ )
645
+ new_project.versions.append(version)
646
+
647
+ # Update version counter
648
+ new_project.version_id_counter = len(new_project.versions) + 1
649
+
650
+ # Save updated project
651
+ ConfigProvider.save(cfg, username)
652
+
653
+ log_info(f"✅ Project '{new_project.name}' imported by {username}")
654
+
655
+ return {"success": True, "project_id": new_project.id, "project_name": new_project.name}
656
+
657
+ # ===================== Version Endpoints =====================
658
+ @router.get("/projects/{project_id}/versions")
659
+ @handle_exceptions
660
+ async def list_versions(
661
+ project_id: int,
662
+ include_deleted: bool = False,
663
+ username: str = Depends(verify_token)
664
+ ):
665
+ """List project versions"""
666
+ project = ConfigProvider.get_project(project_id)
667
+ if not project:
668
+ raise HTTPException(status_code=404, detail="Project not found")
669
+
670
+ versions = project.versions
671
+
672
+ # Filter deleted if needed
673
+ if not include_deleted:
674
+ versions = [v for v in versions if not getattr(v, 'deleted', False)]
675
+
676
+ return [v.model_dump() for v in versions]
677
+
678
+ @router.post("/projects/{project_id}/versions")
679
+ @handle_exceptions
680
+ async def create_version(
681
+ project_id: int,
682
+ version_data: VersionCreate,
683
+ username: str = Depends(verify_token)
684
+ ):
685
+ """Create new version"""
686
+ new_version = ConfigProvider.create_version(project_id, version_data.model_dump(), username)
687
+
688
+ log_info(f"✅ Version created for project {project_id} by {username}")
689
+ return new_version.model_dump()
690
+
691
+ @router.put("/projects/{project_id}/versions/{version_no}")
692
+ @handle_exceptions
693
+ async def update_version(
694
+ project_id: int,
695
+ version_no: int,
696
+ update: VersionUpdate,
697
+ force: bool = Query(default=False, description="Force update despite conflicts"),
698
+ username: str = Depends(verify_token)
699
+ ):
700
+ """Update version with race condition handling"""
701
+ log_debug(f"🔍 Version update request - project: {project_id}, version: {version_no}, user: {username}")
702
+
703
+ # Force parametresi kontrolü
704
+ if force:
705
+ log_warning(f"⚠️ Force update requested for version {version_no} by {username}")
706
+
707
+ result = ConfigProvider.update_version(
708
+ project_id,
709
+ version_no,
710
+ update.model_dump(),
711
+ username,
712
+ expected_last_update=update.last_update_date if not force else None
713
+ )
714
+
715
+ log_info(f"✅ Version {version_no} updated by {username}")
716
+ return result
717
+
718
+ @router.post("/projects/{project_id}/versions/{version_no}/publish")
719
+ @handle_exceptions
720
+ async def publish_version(
721
+ project_id: int,
722
+ version_no: int,
723
+ username: str = Depends(verify_token)
724
+ ):
725
+ """Publish version"""
726
+ project, version = ConfigProvider.publish_version(project_id, version_no, username)
727
+
728
+ log_info(f"✅ Version {version_no} published for project '{project.name}' by {username}")
729
+
730
+ # Notify LLM provider if project is enabled and provider requires repo info
731
+ cfg = ConfigProvider.get()
732
+ llm_provider_def = cfg.global_config.get_provider_config("llm", cfg.global_config.llm_provider.name)
733
+
734
+ if project.enabled and llm_provider_def and llm_provider_def.requires_repo_info:
735
+ try:
736
+ await notify_llm_startup(project, version)
737
+ except Exception as e:
738
+ log_error(f"⚠️ Failed to notify LLM provider", e)
739
+ # Don't fail the publish
740
+
741
+ return {"success": True}
742
+
743
+ @router.delete("/projects/{project_id}/versions/{version_no}")
744
+ @handle_exceptions
745
+ async def delete_version(
746
+ project_id: int,
747
+ version_no: int,
748
+ username: str = Depends(verify_token)
749
+ ):
750
+ """Delete version (soft delete)"""
751
+ ConfigProvider.delete_version(project_id, version_no, username)
752
+
753
+ log_info(f"✅ Version {version_no} deleted for project {project_id} by {username}")
754
+ return {"success": True}
755
+
756
+ @router.get("/projects/{project_name}/versions")
757
+ @handle_exceptions
758
+ async def get_project_versions(
759
+ project_name: str,
760
+ username: str = Depends(verify_token)
761
+ ):
762
+ """Get all versions of a project for testing"""
763
+ cfg = ConfigProvider.get()
764
+
765
+ # Find project
766
+ project = next((p for p in cfg.projects if p.name == project_name), None)
767
+ if not project:
768
+ raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
769
+
770
+ # Return versions with their status
771
+ versions = []
772
+ for v in project.versions:
773
+ if not getattr(v, 'deleted', False):
774
+ versions.append({
775
+ "version_number": v.no,
776
+ "caption": v.caption,
777
+ "published": v.published,
778
+ "description": getattr(v, 'description', ''),
779
+ "intent_count": len(v.intents),
780
+ "created_date": getattr(v, 'created_date', None),
781
+ "is_current": v.published # Published version is current
782
+ })
783
+
784
+ return {
785
+ "project_name": project_name,
786
+ "project_caption": project.caption,
787
+ "versions": versions
788
+ }
789
+
790
+ @router.get("/projects/{project_id}/versions/{version1_id}/compare/{version2_id}")
791
+ @handle_exceptions
792
+ async def compare_versions(
793
+ project_id: int,
794
+ version1_no: int,
795
+ version2_no: int,
796
+ username: str = Depends(verify_token)
797
+ ):
798
+ """Compare two versions and return differences"""
799
+ project = ConfigProvider.get_project(project_id)
800
+ if not project:
801
+ raise HTTPException(status_code=404, detail="Project not found")
802
+
803
+ v1 = next((v for v in project.versions if v.no == version1_no), None)
804
+ v2 = next((v for v in project.versions if v.no == version2_no), None)
805
+
806
+ if not v1 or not v2:
807
+ raise HTTPException(status_code=404, detail="Version not found")
808
+
809
+ # Deep comparison
810
+ differences = {
811
+ 'general_prompt': {
812
+ 'changed': v1.general_prompt != v2.general_prompt,
813
+ 'v1': v1.general_prompt,
814
+ 'v2': v2.general_prompt
815
+ },
816
+ 'intents': {
817
+ 'added': [],
818
+ 'removed': [],
819
+ 'modified': []
820
+ }
821
+ }
822
+
823
+ # Compare intents
824
+ v1_intents = {i.name: i for i in v1.intents}
825
+ v2_intents = {i.name: i for i in v2.intents}
826
+
827
+ # Find added/removed
828
+ differences['intents']['added'] = list(set(v2_intents.keys()) - set(v1_intents.keys()))
829
+ differences['intents']['removed'] = list(set(v1_intents.keys()) - set(v2_intents.keys()))
830
+
831
+ # Find modified
832
+ for intent_name in set(v1_intents.keys()) & set(v2_intents.keys()):
833
+ i1, i2 = v1_intents[intent_name], v2_intents[intent_name]
834
+ if i1.model_dump() != i2.model_dump():
835
+ differences['intents']['modified'].append({
836
+ 'name': intent_name,
837
+ 'differences': compare_intent_details(i1, i2)
838
+ })
839
+
840
+ log_info(
841
+ f"Version comparison performed",
842
+ user=username,
843
+ project_id=project_id,
844
+ version1_id=version1_id,
845
+ version2_id=version2_id
846
+ )
847
+
848
+ return differences
849
+
850
+ # ===================== API Endpoints =====================
851
+ @router.get("/apis")
852
+ @handle_exceptions
853
+ async def list_apis(
854
+ include_deleted: bool = False,
855
+ username: str = Depends(verify_token)
856
+ ):
857
+ """List all APIs"""
858
+ cfg = ConfigProvider.get()
859
+ apis = cfg.apis
860
+
861
+ # Filter deleted if needed
862
+ if not include_deleted:
863
+ apis = [a for a in apis if not getattr(a, 'deleted', False)]
864
+
865
+ return [a.model_dump() for a in apis]
866
+
867
+ @router.post("/apis")
868
+ @handle_exceptions
869
+ async def create_api(api: APICreate, username: str = Depends(verify_token)):
870
+ """Create new API"""
871
+ try:
872
+ new_api = ConfigProvider.create_api(api.model_dump(), username)
873
+
874
+ log_info(f"✅ API '{api.name}' created by {username}")
875
+ return new_api.model_dump()
876
+ except DuplicateResourceError as e:
877
+ # DuplicateResourceError'ı handle et
878
+ raise HTTPException(status_code=409, detail=str(e))
879
+
880
+ @router.put("/apis/{api_name}")
881
+ @handle_exceptions
882
+ async def update_api(
883
+ api_name: str,
884
+ update: APIUpdate,
885
+ username: str = Depends(verify_token)
886
+ ):
887
+ """Update API configuration with race condition handling"""
888
+ result = ConfigProvider.update_api(
889
+ api_name,
890
+ update.model_dump(),
891
+ username,
892
+ expected_last_update=update.last_update_date
893
+ )
894
+
895
+ log_info(f"✅ API '{api_name}' updated by {username}")
896
+ return result
897
+
898
+ @router.delete("/apis/{api_name}")
899
+ @handle_exceptions
900
+ async def delete_api(api_name: str, username: str = Depends(verify_token)):
901
+ """Delete API (soft delete)"""
902
+ ConfigProvider.delete_api(api_name, username)
903
+
904
+ log_info(f"✅ API '{api_name}' deleted by {username}")
905
+ return {"success": True}
906
+
907
+ @router.post("/validate/regex")
908
+ @handle_exceptions
909
+ async def validate_regex(
910
+ request: dict = Body(...),
911
+ username: str = Depends(verify_token)
912
+ ):
913
+ """Validate regex pattern"""
914
+ pattern = request.get("pattern", "")
915
+ test_value = request.get("test_value", "")
916
+
917
+ import re
918
+ compiled_regex = re.compile(pattern)
919
+ matches = bool(compiled_regex.match(test_value))
920
+
921
+ return {
922
+ "valid": True,
923
+ "matches": matches,
924
+ "pattern": pattern,
925
+ "test_value": test_value
926
+ }
927
+
928
+ # ===================== Test Endpoints =====================
929
+ @router.post("/test/run-all")
930
+ @handle_exceptions
931
+ async def run_all_tests(
932
+ request: TestRequest,
933
+ username: str = Depends(verify_token)
934
+ ):
935
+ """Run all tests"""
936
+ log_info(f"🧪 Running {request.test_type} tests requested by {username}")
937
+
938
+ # TODO: Implement test runner
939
+ # For now, return mock results
940
+ return {
941
+ "test_run_id": "test_" + datetime.now().isoformat(),
942
+ "status": "running",
943
+ "total_tests": 60,
944
+ "completed": 0,
945
+ "passed": 0,
946
+ "failed": 0,
947
+ "message": "Test run started"
948
+ }
949
+
950
+ @router.get("/test/status/{test_run_id}")
951
+ @handle_exceptions
952
+ async def get_test_status(
953
+ test_run_id: str,
954
+ username: str = Depends(verify_token)
955
+ ):
956
+ """Get test run status"""
957
+ # TODO: Implement test status tracking
958
+ return {
959
+ "test_run_id": test_run_id,
960
+ "status": "completed",
961
+ "total_tests": 60,
962
+ "completed": 60,
963
+ "passed": 57,
964
+ "failed": 3,
965
+ "duration": 340.5,
966
+ "details": []
967
+ }
968
+
969
+ # ===================== Activity Log =====================
970
+ @router.get("/activity-log")
971
+ @handle_exceptions
972
+ async def get_activity_log(
973
+ limit: int = Query(100, ge=1, le=1000),
974
+ entity_type: Optional[str] = None,
975
+ username: str = Depends(verify_token)
976
+ ):
977
+ """Get activity log"""
978
+ cfg = ConfigProvider.get()
979
+ logs = cfg.activity_log
980
+
981
+ # Filter by entity type if specified
982
+ if entity_type:
983
+ logs = [l for l in logs if l.entity_type == entity_type]
984
+
985
+ # Return most recent entries
986
+ return logs[-limit:]
987
+
988
+ # ===================== Helper Functions =====================
989
+ async def notify_llm_startup(project, version):
990
+ """Notify LLM provider about project startup"""
991
+ from llm_factory import LLMFactory
992
+
993
+ try:
994
+ llm_provider = LLMFactory.create_provider()
995
+
996
+ # Build project config for startup
997
+ project_config = {
998
+ "name": project.name,
999
+ "version_no": version.no,
1000
+ "repo_id": version.llm.repo_id,
1001
+ "generation_config": version.llm.generation_config,
1002
+ "use_fine_tune": version.llm.use_fine_tune,
1003
+ "fine_tune_zip": version.llm.fine_tune_zip
1004
+ }
1005
+
1006
+ success = await llm_provider.startup(project_config)
1007
+ if success:
1008
+ log_info(f"✅ LLM provider notified for project '{project.name}'")
1009
+ else:
1010
+ log_info(f"⚠️ LLM provider notification failed for project '{project.name}'")
1011
+
1012
+ except Exception as e:
1013
+ log_error("❌ Error notifying LLM provider", e)
1014
+ raise
1015
+
1016
+ # ===================== Cleanup Task =====================
1017
+ def cleanup_activity_log():
1018
+ """Cleanup old activity log entries"""
1019
+ while True:
1020
+ try:
1021
+ cfg = ConfigProvider.get()
1022
+
1023
+ # Keep only last 30 days
1024
+ cutoff = datetime.now() - timedelta(days=30)
1025
+ cutoff_str = cutoff.isoformat()
1026
+
1027
+ original_count = len(cfg.activity_log)
1028
+ cfg.activity_log = [
1029
+ log for log in cfg.activity_log
1030
+ if hasattr(log, 'timestamp') and str(log.timestamp) >= cutoff_str
1031
+ ]
1032
+
1033
+ if len(cfg.activity_log) < original_count:
1034
+ removed = original_count - len(cfg.activity_log)
1035
+ log_info(f"🧹 Cleaned up {removed} old activity log entries")
1036
+ # ConfigProvider.save(cfg, "system") kullanmalıyız
1037
+ ConfigProvider.save(cfg, "system")
1038
+
1039
+ except Exception as e:
1040
+ log_error("❌ Activity log cleanup error", e)
1041
+
1042
+ # Run every hour
1043
+ time.sleep(3600)
1044
+
1045
+ def start_cleanup_task():
1046
+ """Start the cleanup task in background"""
1047
+ thread = threading.Thread(target=cleanup_activity_log, daemon=True)
1048
+ thread.start()
1049
+ log_info("🧹 Activity log cleanup task started")
routes/audio_routes.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ """Audio API endpoints for Flare
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Provides text-to-speech (TTS) and speech-to-text (STT) endpoints.
5
+ """
6
+
7
+ from fastapi import APIRouter, HTTPException, Response, Body
8
+ from pydantic import BaseModel
9
+ from typing import Optional
10
+ from datetime import datetime
11
+ import sys
12
+
13
+ from logger import log_info, log_error, log_warning, log_debug
14
+ from tts_factory import TTSFactory
15
+ from tts_preprocessor import TTSPreprocessor
16
+ from config_provider import ConfigProvider
17
+
18
+ router = APIRouter(tags=["audio"])
19
+
20
+ # ===================== Models =====================
21
+ class TTSRequest(BaseModel):
22
+ text: str
23
+ voice_id: Optional[str] = None
24
+ language: Optional[str] = "tr-TR"
25
+
26
+ class STTRequest(BaseModel):
27
+ audio_data: str # Base64 encoded audio
28
+ language: Optional[str] = "tr-TR"
29
+ format: Optional[str] = "webm" # webm, wav, mp3
30
+
31
+ # ===================== Helpers =====================
32
+ def log(message: str):
33
+ """Log helper with timestamp"""
34
+ timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
35
+ print(f"[{timestamp}] {message}")
36
+ sys.stdout.flush()
37
+
38
+ # ===================== TTS Endpoints =====================
39
+ @router.post("/tts/generate")
40
+ async def generate_tts(request: TTSRequest):
41
+ """Generate TTS audio from text - public endpoint for chat"""
42
+ try:
43
+ # Create TTS provider
44
+ tts_provider = TTSFactory.create_provider()
45
+
46
+ if not tts_provider:
47
+ # Return empty response for no TTS
48
+ log_info("📵 TTS disabled - returning empty response")
49
+ return Response(
50
+ content=b"",
51
+ media_type="audio/mpeg",
52
+ headers={"X-TTS-Status": "disabled"}
53
+ )
54
+
55
+ log_info(f"🎤 TTS request: '{request.text[:50]}...' with provider: {tts_provider.get_provider_name()}")
56
+
57
+ # Preprocess text if needed
58
+ preprocessor = TTSPreprocessor(language=request.language)
59
+ processed_text = preprocessor.preprocess(
60
+ request.text,
61
+ tts_provider.get_preprocessing_flags()
62
+ )
63
+
64
+ log_debug(f"📝 Preprocessed text: {processed_text[:100]}...")
65
+
66
+ # Generate audio
67
+ audio_data = await tts_provider.synthesize(
68
+ text=processed_text,
69
+ voice_id=request.voice_id
70
+ )
71
+
72
+ log_info(f"✅ TTS generated {len(audio_data)} bytes of audio")
73
+
74
+ # Return audio as binary response
75
+ return Response(
76
+ content=audio_data,
77
+ media_type="audio/mpeg",
78
+ headers={
79
+ "Content-Disposition": 'inline; filename="tts_output.mp3"',
80
+ "X-TTS-Provider": tts_provider.get_provider_name(),
81
+ "X-TTS-Language": request.language,
82
+ "Cache-Control": "no-cache"
83
+ }
84
+ )
85
+
86
+ except Exception as e:
87
+ log_error("❌ TTS generation error", e)
88
+ raise HTTPException(
89
+ status_code=500,
90
+ detail=f"TTS generation failed: {str(e)}"
91
+ )
92
+
93
+ @router.get("/tts/voices")
94
+ async def get_tts_voices():
95
+ """Get available TTS voices - public endpoint"""
96
+ try:
97
+ tts_provider = TTSFactory.create_provider()
98
+
99
+ if not tts_provider:
100
+ return {
101
+ "voices": [],
102
+ "provider": "none",
103
+ "enabled": False
104
+ }
105
+
106
+ voices = tts_provider.get_supported_voices()
107
+
108
+ # Convert dict to list format
109
+ voice_list = [
110
+ {"id": voice_id, "name": voice_name}
111
+ for voice_id, voice_name in voices.items()
112
+ ]
113
+
114
+ return {
115
+ "voices": voice_list,
116
+ "provider": tts_provider.get_provider_name(),
117
+ "enabled": True
118
+ }
119
+
120
+ except Exception as e:
121
+ log_error("❌ Error getting TTS voices", e)
122
+ return {
123
+ "voices": [],
124
+ "provider": "error",
125
+ "enabled": False,
126
+ "error": str(e)
127
+ }
128
+
129
+ @router.get("/tts/status")
130
+ async def get_tts_status():
131
+ """Get TTS service status"""
132
+ cfg = ConfigProvider.get()
133
+
134
+ return {
135
+ "enabled": cfg.global_config.tts_provider.name != "no_tts",
136
+ "provider": cfg.global_config.tts_provider.name,
137
+ "provider_config": {
138
+ "name": cfg.global_config.tts_provider.name,
139
+ "has_api_key": bool(cfg.global_config.tts_provider.api_key),
140
+ "endpoint": cfg.global_config.tts_provider.endpoint
141
+ }
142
+ }
143
+
144
+ # ===================== STT Endpoints =====================
145
+ @router.post("/stt/transcribe")
146
+ async def transcribe_audio(request: STTRequest):
147
+ """Transcribe audio to text"""
148
+ try:
149
+ from stt_factory import STTFactory
150
+ from stt_interface import STTConfig
151
+ import base64
152
+
153
+ # Create STT provider
154
+ stt_provider = STTFactory.create_provider()
155
+
156
+ if not stt_provider or not stt_provider.supports_realtime():
157
+ log_warning("📵 STT disabled or doesn't support transcription")
158
+ raise HTTPException(
159
+ status_code=503,
160
+ detail="STT service not available"
161
+ )
162
+
163
+ # Get config
164
+ cfg = ConfigProvider.get()
165
+ stt_config = cfg.global_config.stt_provider.settings
166
+
167
+ # Decode audio data
168
+ audio_bytes = base64.b64decode(request.audio_data)
169
+
170
+ # Create STT config
171
+ config = STTConfig(
172
+ language=request.language or stt_config.get("language", "tr-TR"),
173
+ sample_rate=16000,
174
+ encoding=request.format.upper() if request.format else "WEBM_OPUS",
175
+ enable_punctuation=stt_config.get("enable_punctuation", True),
176
+ enable_word_timestamps=False,
177
+ model=stt_config.get("model", "latest_long"),
178
+ use_enhanced=stt_config.get("use_enhanced", True),
179
+ single_utterance=True,
180
+ interim_results=False
181
+ )
182
+
183
+ # Start streaming session
184
+ await stt_provider.start_streaming(config)
185
+
186
+ # Process audio
187
+ transcription = ""
188
+ confidence = 0.0
189
+
190
+ try:
191
+ async for result in stt_provider.stream_audio(audio_bytes):
192
+ if result.is_final:
193
+ transcription = result.text
194
+ confidence = result.confidence
195
+ break
196
+ finally:
197
+ # Stop streaming
198
+ await stt_provider.stop_streaming()
199
+
200
+ log_info(f"✅ STT transcription completed: '{transcription[:50]}...'")
201
+
202
+ return {
203
+ "text": transcription,
204
+ "confidence": confidence,
205
+ "language": request.language,
206
+ "provider": stt_provider.get_provider_name()
207
+ }
208
+
209
+ except HTTPException:
210
+ raise
211
+ except Exception as e:
212
+ log_error("❌ STT transcription error", e)
213
+ raise HTTPException(
214
+ status_code=500,
215
+ detail=f"Transcription failed: {str(e)}"
216
+ )
217
+
218
+ @router.get("/stt/languages")
219
+ async def get_stt_languages():
220
+ """Get supported STT languages"""
221
+ try:
222
+ from stt_factory import STTFactory
223
+
224
+ stt_provider = STTFactory.create_provider()
225
+
226
+ if not stt_provider:
227
+ return {
228
+ "languages": [],
229
+ "provider": "none",
230
+ "enabled": False
231
+ }
232
+
233
+ languages = stt_provider.get_supported_languages()
234
+
235
+ return {
236
+ "languages": languages,
237
+ "provider": stt_provider.get_provider_name(),
238
+ "enabled": True
239
+ }
240
+
241
+ except Exception as e:
242
+ log_error("❌ Error getting STT languages", e)
243
+ return {
244
+ "languages": [],
245
+ "provider": "error",
246
+ "enabled": False,
247
+ "error": str(e)
248
+ }
249
+
250
+ @router.get("/stt/status")
251
+ async def get_stt_status():
252
+ """Get STT service status"""
253
+ cfg = ConfigProvider.get()
254
+
255
+ return {
256
+ "enabled": cfg.global_config.stt_provider.name != "no_stt",
257
+ "provider": cfg.global_config.stt_provider.name,
258
+ "provider_config": {
259
+ "name": cfg.global_config.stt_provider.name,
260
+ "has_api_key": bool(cfg.global_config.stt_provider.api_key),
261
+ "endpoint": cfg.global_config.stt_provider.endpoint
262
+ }
263
+ }
routes/chat_handler.py ADDED
@@ -0,0 +1,720 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Flare – Chat Handler (Refactored with LLM Factory)
3
+ ==========================================
4
+ """
5
+
6
+ import re, json, sys, httpx, os
7
+ from datetime import datetime
8
+ from typing import Dict, List, Optional, Any
9
+ from fastapi import APIRouter, HTTPException, Header
10
+ from pydantic import BaseModel
11
+ import requests
12
+
13
+ from prompt_builder import build_intent_prompt, build_parameter_prompt
14
+ from logger import log_info, log_error, log_warning, log_debug
15
+ from api_executor import call_api as execute_api
16
+ from config_provider import ConfigProvider
17
+ from validation_engine import validate
18
+ from session import session_store, Session
19
+
20
+ # Initialize router
21
+ router = APIRouter()
22
+
23
+ # ───────────────────────── GLOBAL VARS ───────────────────────── #
24
+ cfg = ConfigProvider.get()
25
+ llm_provider = None
26
+
27
+ # ───────────────────────── HELPERS ───────────────────────── #
28
+ def _trim_response(raw: str) -> str:
29
+ """
30
+ Remove everything after the first logical assistant block or intent tag.
31
+ Also strips trailing 'assistant' artifacts and prompt injections.
32
+ """
33
+ # Stop at our own rules if model leaked them
34
+ for stop in ["#DETECTED_INTENT", "⚠️", "\nassistant", "assistant\n", "assistant"]:
35
+ idx = raw.find(stop)
36
+ if idx != -1:
37
+ raw = raw[:idx]
38
+ # Normalise selamlama
39
+ raw = re.sub(r"Hoş[\s-]?geldin(iz)?", "Hoş geldiniz", raw, flags=re.IGNORECASE)
40
+ return raw.strip()
41
+
42
+ def _safe_intent_parse(raw: str) -> tuple[str, str]:
43
+ """Extract intent name and extra tail."""
44
+ m = re.search(r"#DETECTED_INTENT:\s*([A-Za-z0-9_-]+)", raw)
45
+ if not m:
46
+ return "", raw
47
+ name = m.group(1)
48
+ # Remove 'assistant' suffix if exists
49
+ if name.endswith("assistant"):
50
+ name = name[:-9] # Remove last 9 chars ("assistant")
51
+ log_info(f"🔧 Removed 'assistant' suffix from intent name")
52
+ tail = raw[m.end():]
53
+ log_info(f"🎯 Parsed intent: {name}")
54
+ return name, tail
55
+
56
+ # ───────────────────────── LLM SETUP ───────────────────────── #
57
+ def setup_llm_provider():
58
+ """Initialize LLM provider using factory pattern"""
59
+ global llm_provider
60
+
61
+ try:
62
+ from llm_factory import LLMFactory
63
+ llm_provider = LLMFactory.create_provider()
64
+ log_info("✅ LLM provider initialized successfully")
65
+ except Exception as e:
66
+ log_error("❌ Failed to initialize LLM provider", e)
67
+ raise
68
+
69
+ # ───────────────────────── LLM GENERATION ───────────────────────── #
70
+ async def llm_generate(s: Session, prompt: str, user_msg: str) -> str:
71
+ """Call LLM provider with proper error handling"""
72
+ global llm_provider
73
+
74
+ if llm_provider is None:
75
+ setup_llm_provider()
76
+
77
+ try:
78
+ # Get version config from session
79
+ version = s.get_version_config()
80
+ if not version:
81
+ # Fallback: get from project config
82
+ project = next((p for p in cfg.projects if p.name == s.project_name), None)
83
+ if not project:
84
+ raise ValueError(f"Project not found: {s.project_name}")
85
+ version = next((v for v in project.versions if v.published), None)
86
+ if not version:
87
+ raise ValueError("No published version found")
88
+
89
+ log_info(f"🚀 Calling LLM for session {s.session_id[:8]}...")
90
+ log_info(f"📋 Prompt preview (first 200 chars): {prompt[:200]}...")
91
+
92
+ history = s.chat_history
93
+
94
+ # Call the configured LLM provider
95
+ raw = await llm_provider.generate(
96
+ user_input=user_msg,
97
+ system_prompt=prompt,
98
+ context=history[-10:] if history else []
99
+ )
100
+
101
+ log_info(f"🪄 LLM raw response: {raw[:100]}...")
102
+ return raw
103
+
104
+ except requests.exceptions.Timeout:
105
+ log_warning(f"⏱️ LLM timeout for session {s.session_id[:8]}")
106
+ raise HTTPException(status_code=504, detail="LLM request timed out")
107
+ except Exception as e:
108
+ log_error("❌ LLM error", e)
109
+ raise HTTPException(status_code=500, detail=f"LLM error: {str(e)}")
110
+
111
+ # ───────────────────────── PARAMETER EXTRACTION ───────────────────────── #
112
+ def _extract_parameters_from_response(raw: str, session: Session, intent_config) -> bool:
113
+ """Extract parameters from the LLM response"""
114
+ try:
115
+ # Look for JSON block in response
116
+ json_match = re.search(r'```json\s*(.*?)\s*```', raw, re.DOTALL)
117
+ if not json_match:
118
+ # Try to find JSON without code block
119
+ json_match = re.search(r'\{[^}]+\}', raw)
120
+
121
+ if not json_match:
122
+ log_info("❌ No JSON found in response")
123
+ return False
124
+
125
+ json_str = json_match.group(1) if '```' in raw else json_match.group(0)
126
+ params = json.loads(json_str)
127
+
128
+ any_valid = False
129
+ for param_name, param_value in params.items():
130
+ # Find parameter config
131
+ param_config = next(
132
+ (p for p in intent_config.parameters if p.name == param_name),
133
+ None
134
+ )
135
+
136
+ if not param_config:
137
+ log_info(f"⚠️ Parameter config not found for: {param_name}")
138
+ continue
139
+
140
+ # Validate parameter
141
+ if validate(str(param_value), param_config):
142
+ session.variables[param_config.variable_name] = str(param_value)
143
+ any_valid = True
144
+ log_info(f"✅ Extracted {param_name}={param_value} → {param_config.variable_name}")
145
+ else:
146
+ log_info(f"❌ Invalid {param_name}={param_value}")
147
+
148
+ return any_valid
149
+
150
+ except json.JSONDecodeError as e:
151
+ log_error("❌ JSON parsing error", e)
152
+ log_error(f"❌ Failed to parse: {raw[:200]}")
153
+ return False
154
+ except Exception as e:
155
+ log_error("❌ Parameter processing error", e)
156
+ return False
157
+
158
+ # ───────────────────────── API EXECUTION ───────────────────────── #
159
+ async def _execute_api_call(session: Session, intent_config) -> str:
160
+ """Execute API call and return humanized response with better error handling"""
161
+ try:
162
+ session.state = "call_api"
163
+ api_name = intent_config.action
164
+ api_config = cfg.get_api(api_name)
165
+
166
+ if not api_config:
167
+ log_info(f"❌ API config not found: {api_name}")
168
+ session.reset_flow()
169
+ return get_user_friendly_error("api_error", {"api_name": api_name})
170
+
171
+ log_info(f"📡 Calling API: {api_name}")
172
+ log_info(f"📦 API variables: {session.variables}")
173
+
174
+ # Execute API call with session
175
+ response = execute_api(api_config, session)
176
+ api_json = response.json()
177
+ log_info(f"✅ API response: {api_json}")
178
+
179
+ # Humanize response
180
+ session.state = "humanize"
181
+ if api_config.response_prompt:
182
+ prompt = api_config.response_prompt.replace(
183
+ "{{api_response}}",
184
+ json.dumps(api_json, ensure_ascii=False)
185
+ )
186
+ human_response = await llm_generate(session, prompt, json.dumps(api_json))
187
+ session.reset_flow()
188
+ return human_response if human_response else f"İşlem sonucu: {api_json}"
189
+ else:
190
+ session.reset_flow()
191
+ return f"İşlem tamamlandı: {api_json}"
192
+
193
+ except requests.exceptions.Timeout:
194
+ log_warning(f"⏱️ API timeout: {api_name}")
195
+ session.reset_flow()
196
+ return get_user_friendly_error("api_timeout")
197
+ except Exception as e:
198
+ log_error("❌ API call error", e)
199
+ session.reset_flow()
200
+ return get_user_friendly_error("api_error", {"api_name": api_name})
201
+
202
+ # ───────────────────────── REQUEST MODELS ───────────────────────── #
203
+ class ChatRequest(BaseModel):
204
+ message: str
205
+
206
+ class StartRequest(BaseModel):
207
+ project_name: str
208
+ version_no: Optional[int] = None # Opsiyonel, belirtilmezse published olan en büyük version no'yu kullan
209
+ is_realtime: bool = False
210
+ locale: Optional[str] = None
211
+
212
+ class ChatResponse(BaseModel):
213
+ session_id: str
214
+ answer: str
215
+
216
+ # ───────────────────────── API ENDPOINTS ───────────────────────── #
217
+ @router.post("/start_session", response_model=ChatResponse)
218
+ async def start_session(req: StartRequest):
219
+ """Create new session"""
220
+ global llm_provider
221
+
222
+ try:
223
+ # Validate project exists
224
+ project = next((p for p in cfg.projects if p.name == req.project_name and p.enabled), None)
225
+ if not project:
226
+ raise HTTPException(404, f"Project '{req.project_name}' not found or disabled")
227
+
228
+ # Determine locale
229
+ session_locale = req.locale
230
+ if not session_locale:
231
+ # Use project's default locale
232
+ session_locale = project.default_locale
233
+
234
+ # Validate locale is supported by project
235
+ if session_locale not in project.supported_locales:
236
+ raise HTTPException(
237
+ 400,
238
+ f"Locale '{session_locale}' not supported by project. Supported: {project.supported_locales}"
239
+ )
240
+
241
+ # Find version
242
+ if req.version_no:
243
+ # Specific version requested
244
+ version = next((v for v in project.versions if v.no == req.version_no), None)
245
+ if not version:
246
+ raise HTTPException(404, f"Version {req.version_no} not found for project '{req.project_name}'")
247
+ else:
248
+ # Find published version with highest version number
249
+ published_versions = [v for v in project.versions if v.published]
250
+ if not published_versions:
251
+ raise HTTPException(404, f"No published version for project '{req.project_name}'")
252
+
253
+ # Sort by version number (no) and get the highest
254
+ version = max(published_versions, key=lambda v: v.no)
255
+
256
+ # Create LLM provider if not exists
257
+ if not llm_provider:
258
+ from llm_factory import LLMFactory
259
+ llm_provider = LLMFactory.create_provider()
260
+ log_info(f"🤖 LLM Provider created: {type(llm_provider).__name__}")
261
+
262
+ # Create session with version config - PARAMETRE DÜZELTMESİ
263
+ session = session_store.create_session(
264
+ project_name=req.project_name,
265
+ version_no=version.no,
266
+ is_realtime=req.is_realtime,
267
+ locale=session_locale
268
+ )
269
+
270
+ # Version config'i session'a ekle
271
+ session.set_version_config(version)
272
+
273
+ # Welcome prompt'u işle
274
+ greeting = "Hoş geldiniz! Size nasıl yardımcı olabilirim?"
275
+ if version.welcome_prompt:
276
+ log_info(f"🎉 Processing welcome prompt for session {session.session_id[:8]}...")
277
+ try:
278
+ # Welcome prompt'u LLM'e gönder
279
+ welcome_result = await llm_provider.generate(
280
+ user_input="",
281
+ system_prompt=version.welcome_prompt,
282
+ context=[]
283
+ )
284
+ if welcome_result and welcome_result.strip():
285
+ greeting = welcome_result.strip()
286
+ except Exception as e:
287
+ log_error("⚠️ Welcome prompt processing failed", e)
288
+ # Fallback to default greeting
289
+
290
+ session.add_turn("assistant", greeting)
291
+
292
+ log_info(f"✅ Session created for project '{req.project_name}' version {version.no} (highest published)")
293
+
294
+ return ChatResponse(session_id=session.session_id, answer=greeting)
295
+
296
+ except HTTPException:
297
+ raise
298
+ except Exception as e:
299
+ log_error("❌ Session creation error", e)
300
+ raise HTTPException(500, f"Session creation failed: {str(e)}")
301
+
302
+ @router.post("/chat")
303
+ async def chat(req: ChatRequest, x_session_id: str = Header(...)):
304
+ """Process chat message"""
305
+ try:
306
+ # Get session
307
+ session = session_store.get_session(x_session_id)
308
+ if not session:
309
+ # Better error message
310
+ raise HTTPException(
311
+ status_code=404,
312
+ detail=get_user_friendly_error("session_not_found")
313
+ )
314
+
315
+ # Session expiry check ekle
316
+ if session.is_expired():
317
+ session_store.delete_session(x_session_id)
318
+ raise HTTPException(
319
+ status_code=401,
320
+ detail=get_user_friendly_error("session_expired")
321
+ )
322
+
323
+ # Update last activity
324
+ session.last_activity = datetime.utcnow().isoformat()
325
+ session_store.update_session(session)
326
+
327
+ # Mevcut kod devam ediyor...
328
+ # Add user message to history
329
+ session.add_message("user", req.message)
330
+ log_info(f"💬 User [{session.session_id[:8]}...]: {req.message}")
331
+
332
+ # Get project and version config
333
+ project = next((p for p in cfg.projects if p.name == session.project_name), None)
334
+ if not project:
335
+ raise HTTPException(
336
+ status_code=404,
337
+ detail=get_user_friendly_error("project_not_found")
338
+ )
339
+
340
+ version = session.get_version_config()
341
+ if not version:
342
+ raise HTTPException(
343
+ status_code=400,
344
+ detail=get_user_friendly_error("version_not_found")
345
+ )
346
+
347
+ # Process based on current state
348
+ if session.state == "idle":
349
+ # Build intent detection prompt
350
+ prompt = build_intent_prompt(version, session.chat_history, project.default_locale)
351
+ raw = await llm_generate(session, prompt, req.message)
352
+
353
+ # Check for intent
354
+ intent_name, tail = _safe_intent_parse(raw)
355
+
356
+ if intent_name:
357
+ # Find intent config
358
+ intent_config = next((i for i in version.intents if i.name == intent_name), None)
359
+
360
+ if intent_config:
361
+ session.current_intent = intent_name
362
+ session.intent_config = intent_config
363
+ session.state = "collect_params"
364
+ log_info(f"🎯 Intent detected: {intent_name}")
365
+
366
+ # Check if parameters were already extracted
367
+ if tail and _extract_parameters_from_response(tail, session, intent_config):
368
+ log_info("📦 Some parameters extracted from initial response")
369
+
370
+ # Check what parameters are missing
371
+ missing_params = [
372
+ p.name for p in intent_config.parameters
373
+ if p.required and p.variable_name not in session.variables
374
+ ]
375
+
376
+ if not missing_params:
377
+ # All required parameters collected, execute API
378
+ response = await _execute_api_call(session, intent_config)
379
+ session.add_message("assistant", response)
380
+ return {"response": response, "intent": intent_name, "state": "completed"}
381
+ else:
382
+ # Need to collect more parameters
383
+ # Get parameter collection config
384
+ collection_config = cfg.global_config.llm_provider.settings.get("parameter_collection_config", {})
385
+ max_params = collection_config.get("max_params_per_question", 2)
386
+
387
+ # Decide which parameters to ask
388
+ params_to_ask = missing_params[:max_params]
389
+
390
+ param_prompt = build_parameter_prompt(
391
+ version=version,
392
+ intent_config=intent_config,
393
+ chat_history=session.chat_history,
394
+ collected_params=session.variables,
395
+ missing_params=missing_params,
396
+ params_to_ask=params_to_ask,
397
+ max_params=max_params,
398
+ project_locale=project.default_locale,
399
+ unanswered_params=session.unanswered_parameters
400
+ )
401
+
402
+ param_question = await llm_generate(session, param_prompt, req.message)
403
+ clean_question = _trim_response(param_question)
404
+ session.add_message("assistant", clean_question)
405
+ return {"response": clean_question, "intent": intent_name, "state": "collecting_params"}
406
+
407
+ else:
408
+ log_info(f"⚠️ Unknown intent: {intent_name}")
409
+
410
+ # No intent detected, return general response
411
+ clean_response = _trim_response(raw)
412
+ session.add_message("assistant", clean_response)
413
+ return {"response": clean_response, "state": "idle"}
414
+
415
+ elif session.state == "collect_params":
416
+ # Continue parameter collection
417
+ intent_config = session.intent_config
418
+
419
+ # Try to extract parameters from user message
420
+ param_prompt = f"""
421
+ Extract parameters from user message: "{req.message}"
422
+
423
+ Expected parameters:
424
+ {json.dumps([{
425
+ 'name': p.name,
426
+ 'type': p.type,
427
+ 'required': p.required,
428
+ 'extraction_prompt': p.extraction_prompt
429
+ } for p in intent_config.parameters if p.variable_name not in session.variables], ensure_ascii=False)}
430
+
431
+ Return as JSON object with parameter names as keys.
432
+ """
433
+
434
+ raw = await llm_generate(session, param_prompt, req.message)
435
+ _extract_parameters_from_response(raw, session, intent_config)
436
+
437
+ # Check what parameters are still missing
438
+ missing_params = [
439
+ p.name for p in intent_config.parameters
440
+ if p.required and p.variable_name not in session.variables
441
+ ]
442
+
443
+ if not missing_params:
444
+ # All parameters collected, execute API
445
+ response = await _execute_api_call(session, intent_config)
446
+ session.add_message("assistant", response)
447
+ return {"response": response, "intent": session.current_intent, "state": "completed"}
448
+ else:
449
+ # Still need more parameters
450
+ # Get parameter collection config
451
+ collection_config = cfg.global_config.llm_provider.settings.get("parameter_collection_config", {})
452
+ max_params = collection_config.get("max_params_per_question", 2)
453
+
454
+ # Decide which parameters to ask
455
+ params_to_ask = missing_params[:max_params]
456
+
457
+ param_prompt = build_parameter_prompt(
458
+ version=version,
459
+ intent_config=intent_config,
460
+ chat_history=session.chat_history,
461
+ collected_params=session.variables,
462
+ missing_params=missing_params,
463
+ params_to_ask=params_to_ask,
464
+ max_params=max_params,
465
+ project_locale=project.default_locale,
466
+ unanswered_params=session.unanswered_parameters
467
+ )
468
+ param_question = await llm_generate(session, param_prompt, req.message)
469
+ clean_question = _trim_response(param_question)
470
+ session.add_message("assistant", clean_question)
471
+ return {"response": clean_question, "intent": session.current_intent, "state": "collecting_params"}
472
+
473
+ else:
474
+ # Unknown state, reset
475
+ session.reset_flow()
476
+ return {"response": get_user_friendly_error("internal_error"), "state": "error"}
477
+
478
+ except HTTPException:
479
+ raise
480
+ except requests.exceptions.Timeout:
481
+ # Better timeout error
482
+ log_error(f"Timeout in chat for session {x_session_id[:8]}")
483
+ return {
484
+ "response": get_user_friendly_error("llm_timeout"),
485
+ "state": "error",
486
+ "error": True
487
+ }
488
+ except Exception as e:
489
+ log_error("❌ Chat error", e)
490
+ import traceback
491
+ traceback.print_exc()
492
+ # Better generic error
493
+ return {
494
+ "response": get_user_friendly_error("internal_error"),
495
+ "state": "error",
496
+ "error": True
497
+ }
498
+
499
+ async def handle_new_message(session: Session, user_input: str) -> str:
500
+ """Handle new message (not parameter followup) - for WebSocket"""
501
+ try:
502
+ # Get version config from session
503
+ version = session.get_version_config()
504
+ if not version:
505
+ log_info("❌ Version config not found")
506
+ return "Bir hata oluştu. Lütfen tekrar deneyin."
507
+
508
+ # Get project config
509
+ project = next((p for p in cfg.projects if p.name == session.project_name), None)
510
+ if not project:
511
+ return "Proje konfigürasyonu bulunamadı."
512
+
513
+ # Build intent detection prompt
514
+ prompt = build_intent_prompt(version, session.chat_history, project.default_locale)
515
+
516
+ # Get LLM response
517
+ raw = await llm_generate(session, prompt, user_input)
518
+
519
+ # Empty response fallback
520
+ if not raw:
521
+ log_info("⚠️ Empty response from LLM")
522
+ return "Üzgünüm, mesajınızı anlayamadım. Lütfen tekrar dener misiniz?"
523
+
524
+ # Check for intent
525
+ intent_name, tail = _safe_intent_parse(raw)
526
+
527
+ if intent_name:
528
+ # Find intent config
529
+ intent_config = next((i for i in version.intents if i.name == intent_name), None)
530
+
531
+ if intent_config:
532
+ session.current_intent = intent_name
533
+ session.intent_config = intent_config
534
+ session.state = "collect_params"
535
+ log_info(f"🎯 Intent detected: {intent_name}")
536
+
537
+ # Check if parameters were already extracted
538
+ if tail and _extract_parameters_from_response(tail, session, intent_config):
539
+ log_info("📦 Some parameters extracted from initial response")
540
+
541
+ # Check what parameters are missing
542
+ missing_params = [
543
+ p.name for p in intent_config.parameters
544
+ if p.required and p.variable_name not in session.variables
545
+ ]
546
+
547
+ if not missing_params:
548
+ # All required parameters collected, execute API
549
+ return await _execute_api_call(session, intent_config)
550
+ else:
551
+ # Need to collect more parameters
552
+ collection_config = cfg.global_config.llm_provider.settings.get("parameter_collection_config", {})
553
+ max_params = collection_config.get("max_params_per_question", 2)
554
+
555
+ # Decide which parameters to ask
556
+ params_to_ask = missing_params[:max_params]
557
+
558
+ param_prompt = build_parameter_prompt(
559
+ version=version,
560
+ intent_config=intent_config,
561
+ chat_history=session.chat_history,
562
+ collected_params=session.variables,
563
+ missing_params=missing_params,
564
+ params_to_ask=params_to_ask,
565
+ max_params=max_params,
566
+ project_locale=project.default_locale,
567
+ unanswered_params=session.unanswered_parameters
568
+ )
569
+ param_question = await llm_generate(session, param_prompt, user_input)
570
+ return _trim_response(param_question)
571
+
572
+ # No intent detected, return general response
573
+ return _trim_response(raw)
574
+
575
+ except Exception as e:
576
+ log_error("❌ Error in handle_new_message", e)
577
+ return "Bir hata oluştu. Lütfen tekrar deneyin."
578
+
579
+ async def handle_parameter_followup(session: Session, user_input: str) -> str:
580
+ """Handle parameter collection followup - for WebSocket"""
581
+ try:
582
+ if not session.intent_config:
583
+ log_info("⚠️ No intent config in session")
584
+ session.reset_flow()
585
+ return "Üzgünüm, hangi işlem için bilgi istediğimi unuttum. Baştan başlayalım."
586
+
587
+ intent_config = session.intent_config
588
+
589
+ # Get project config and version
590
+ project = next((p for p in cfg.projects if p.name == session.project_name), None)
591
+ if not project:
592
+ return "Proje konfigürasyonu bulunamadı."
593
+
594
+ version = session.get_version_config()
595
+ if not version:
596
+ return "Versiyon konfigürasyonu bulunamadı."
597
+
598
+ # Try to extract parameters from user message
599
+ param_prompt = f"""
600
+ Extract parameters from user message: "{user_input}"
601
+
602
+ Expected parameters:
603
+ {json.dumps([{
604
+ 'name': p.name,
605
+ 'type': p.type,
606
+ 'required': p.required,
607
+ 'extraction_prompt': p.extraction_prompt
608
+ } for p in intent_config.parameters if p.variable_name not in session.variables], ensure_ascii=False)}
609
+
610
+ Return as JSON object with parameter names as keys.
611
+ """
612
+
613
+ raw = await llm_generate(session, param_prompt, user_input)
614
+ _extract_parameters_from_response(raw, session, intent_config)
615
+
616
+ # Check what parameters are still missing
617
+ missing_params = [
618
+ p.name for p in intent_config.parameters # p.name olmalı, sadece p değil
619
+ if p.required and p.variable_name not in session.variables
620
+ ]
621
+
622
+ if not missing_params:
623
+ # All parameters collected, execute API
624
+ return await _execute_api_call(session, intent_config)
625
+ else:
626
+ # Still need more parameters
627
+ collection_config = cfg.global_config.llm_provider.settings.get("parameter_collection_config", {})
628
+ max_params = collection_config.get("max_params_per_question", 2)
629
+
630
+ # Decide which parameters to ask
631
+ params_to_ask = missing_params[:max_params]
632
+
633
+ param_prompt = build_parameter_prompt(
634
+ version=version,
635
+ intent_config=intent_config,
636
+ chat_history=session.chat_history,
637
+ collected_params=session.variables,
638
+ missing_params=missing_params,
639
+ params_to_ask=params_to_ask,
640
+ max_params=max_params,
641
+ project_locale=project.default_locale,
642
+ unanswered_params=session.unanswered_parameters
643
+ )
644
+ param_question = await llm_generate(session, param_prompt, user_input)
645
+ return _trim_response(param_question)
646
+
647
+ except Exception as e:
648
+ log_error("❌ Error in handle_parameter_followup", e)
649
+ session.reset_flow()
650
+ return "Bir hata oluştu. Lütfen tekrar deneyin."
651
+
652
+ def get_user_friendly_error(error_type: str, context: dict = None) -> str:
653
+ """Get user-friendly error messages"""
654
+ error_messages = {
655
+ "session_not_found": "Oturumunuz bulunamadı. Lütfen yeni bir konuşma başlatın.",
656
+ "project_not_found": "Proje konfigürasyonu bulunamadı. Lütfen yönetici ile iletişime geçin.",
657
+ "version_not_found": "Proje versiyonu bulunamadı. Lütfen geçerli bir versiyon seçin.",
658
+ "intent_not_found": "Üzgünüm, ne yapmak istediğinizi anlayamadım. Lütfen daha açık bir şekilde belirtir misiniz?",
659
+ "api_timeout": "İşlem zaman aşımına uğradı. Lütfen tekrar deneyin.",
660
+ "api_error": "İşlem sırasında bir hata oluştu. Lütfen daha sonra tekrar deneyin.",
661
+ "parameter_validation": "Girdiğiniz bilgide bir hata var. Lütfen kontrol edip tekrar deneyin.",
662
+ "llm_error": "Sistem yanıt veremedi. Lütfen biraz sonra tekrar deneyin.",
663
+ "llm_timeout": "Sistem meşgul. Lütfen birkaç saniye bekleyip tekrar deneyin.",
664
+ "session_expired": "Oturumunuz zaman aşımına uğradı. Lütfen yeni bir konuşma başlatın.",
665
+ "rate_limit": "Çok fazla istek gönderdiniz. Lütfen biraz bekleyin.",
666
+ "internal_error": "Beklenmeyen bir hata oluştu. Lütfen yönetici ile iletişime geçin."
667
+ }
668
+
669
+ message = error_messages.get(error_type, error_messages["internal_error"])
670
+
671
+ # Context bilgisi varsa ekle
672
+ if context:
673
+ if error_type == "parameter_validation" and "field" in context:
674
+ message = f"{context['field']} alanı için {message}"
675
+ elif error_type == "api_error" and "api_name" in context:
676
+ message = f"{context['api_name']} servisi için {message}"
677
+
678
+ return message
679
+
680
+ def validate_parameter_with_message(param_config, value, locale="tr") -> tuple[bool, str]:
681
+ """Validate parameter and return user-friendly message"""
682
+ try:
683
+ # Type validation
684
+ if param_config.type == "int":
685
+ try:
686
+ int(value)
687
+ except ValueError:
688
+ return False, f"Lütfen geçerli bir sayı girin."
689
+
690
+ elif param_config.type == "float":
691
+ try:
692
+ float(value)
693
+ except ValueError:
694
+ return False, f"Lütfen geçerli bir ondalık sayı girin."
695
+
696
+ elif param_config.type == "date":
697
+ # Date parsing with locale support
698
+ from locale_manager import LocaleManager
699
+ parsed_date = LocaleManager.parse_date_expression(value, locale)
700
+ if not parsed_date:
701
+ return False, f"Lütfen geçerli bir tarih girin (örn: yarın, 15 Haziran, 2025-06-15)."
702
+
703
+ elif param_config.type == "bool":
704
+ if value.lower() not in ["evet", "hayır", "yes", "no", "true", "false"]:
705
+ return False, f"Lütfen 'evet' veya 'hayır' olarak cevaplayın."
706
+
707
+ # Regex validation
708
+ if param_config.validation_regex:
709
+ import re
710
+ if not re.match(param_config.validation_regex, value):
711
+ return False, param_config.invalid_prompt or "Girdiğiniz değer geçerli formatta değil."
712
+
713
+ return True, ""
714
+
715
+ except Exception as e:
716
+ log_error(f"Parameter validation error", e)
717
+ return False, "Değer kontrol edilirken bir hata oluştu."
718
+
719
+ # Initialize LLM on module load
720
+ setup_llm_provider()