File size: 19,425 Bytes
518eaee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
"""
Flare – Main Application (Refactored with Event-Driven Architecture)
====================================================================
"""
# FastAPI imports
from fastapi import FastAPI, WebSocket, Request, status
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.encoders import jsonable_encoder

# Standard library
import uvicorn
import os
from pathlib import Path
import mimetypes
import uuid
import traceback
from datetime import datetime
import asyncio
import time
from pydantic import ValidationError
from dotenv import load_dotenv

# Event-driven architecture imports
from chat_session.event_bus import event_bus
from chat_session.state_orchestrator import StateOrchestrator
from chat_session.websocket_manager import WebSocketManager
from chat_session.resource_manager import ResourceManager
from stt.stt_lifecycle_manager import STTLifecycleManager
from tts.tts_lifecycle_manager import TTSLifecycleManager
from llm.llm_lifecycle_manager import LLMLifecycleManager
from stt.audio_buffer_manager import AudioBufferManager

# Project imports
from routes.admin_routes import router as admin_router, start_cleanup_task
from llm.llm_startup import run_in_thread
from chat_session.session import session_store, start_session_cleanup
from config.config_provider import ConfigProvider

# Logger imports
from utils.logger import log_error, log_info, log_warning

# Exception imports
from utils.exceptions import (
    DuplicateResourceError,
    RaceConditionError,
    ValidationError as FlareValidationError,
    ResourceNotFoundError,
    AuthenticationError,
    AuthorizationError,
    ConfigurationError,
    get_http_status_code,
    FlareException
)

# Load .env file if exists
load_dotenv()

ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:4200").split(",")

# ===================== Environment Setup =====================
def setup_environment():
    """Setup environment based on deployment mode"""
    cfg = ConfigProvider.get()

    log_info("=" * 60)
    log_info("πŸš€ Flare Starting", version="2.0.0")
    log_info(f"πŸ”Œ LLM Provider: {cfg.global_config.llm_provider.name}")
    log_info(f"🎀 TTS Provider: {cfg.global_config.tts_provider.name}")
    log_info(f"🎧 STT Provider: {cfg.global_config.stt_provider.name}")
    log_info("=" * 60)

    if cfg.global_config.is_cloud_mode():
        log_info("☁️  Cloud Mode: Using HuggingFace Secrets")
        log_info("πŸ“Œ Required secrets: JWT_SECRET, FLARE_TOKEN_KEY")

        # Check for provider-specific tokens
        llm_config = cfg.global_config.get_provider_config("llm", cfg.global_config.llm_provider.name)
        if llm_config and llm_config.requires_repo_info:
            log_info("πŸ“Œ LLM requires SPARK_TOKEN for repository operations")
    else:
        log_info("🏒 On-Premise Mode: Using .env file")
        if not Path(".env").exists():
            log_warning("⚠️  WARNING: .env file not found!")
            log_info("πŸ“Œ Copy .env.example to .env and configure it")

# Run setup
setup_environment()

# Fix MIME types for JavaScript files
mimetypes.add_type("application/javascript", ".js")
mimetypes.add_type("text/css", ".css")

app = FastAPI(
    title="Flare Orchestration Service",
    version="2.0.0",
    description="LLM-driven intent & API flow engine with multi-provider support",
)

# CORS for development
if os.getenv("ENVIRONMENT", "development") == "development":
    app.add_middleware(
        CORSMiddleware,
        allow_origins=ALLOWED_ORIGINS,
        allow_credentials=True,
        allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
        allow_headers=["*"],
        max_age=3600,
        expose_headers=["X-Request-ID"]
    )
    log_info(f"πŸ”§ CORS enabled for origins: {ALLOWED_ORIGINS}")

# Request ID middleware
@app.middleware("http")
async def add_request_id(request: Request, call_next):
    """Add request ID for tracking"""
    request_id = str(uuid.uuid4())
    request.state.request_id = request_id

    # Log request start
    log_info(
        "Request started",
        request_id=request_id,
        method=request.method,
        path=request.url.path,
        client=request.client.host if request.client else "unknown"
    )

    try:
        response = await call_next(request)

        # Add request ID to response headers
        response.headers["X-Request-ID"] = request_id

        # Log request completion
        log_info(
            "Request completed",
            request_id=request_id,
            status_code=response.status_code,
            method=request.method,
            path=request.url.path
        )

        return response
    except Exception as e:
        log_error(
            "Request failed",
            request_id=request_id,
            error=str(e),
            traceback=traceback.format_exc()
        )
        raise

# ===================== Event-Driven Architecture Initialization =====================
@app.on_event("startup")
async def startup_event():
    """Initialize event-driven components on startup"""
    try:
        # Initialize event bus
        await event_bus.start()
        log_info("βœ… Event bus started")

        # Initialize resource manager
        resource_manager = ResourceManager(event_bus)
        await resource_manager.start()
        log_info("βœ… Resource manager started")

        # Initialize managers
        state_orchestrator = StateOrchestrator(event_bus)
        websocket_manager = WebSocketManager(event_bus)
        audio_buffer_manager = AudioBufferManager(event_bus)
        stt_manager = STTLifecycleManager(event_bus, resource_manager)
        tts_manager = TTSLifecycleManager(event_bus, resource_manager)
        llm_manager = LLMLifecycleManager(event_bus, resource_manager)

        # Store in app state for access in routes
        app.state.event_bus = event_bus
        app.state.resource_manager = resource_manager
        app.state.state_orchestrator = state_orchestrator
        app.state.websocket_manager = websocket_manager
        app.state.audio_buffer_manager = audio_buffer_manager
        app.state.stt_manager = stt_manager
        app.state.tts_manager = tts_manager
        app.state.llm_manager = llm_manager

        log_info("βœ… All managers initialized")

        # Start existing background tasks
        run_in_thread()  # Start LLM startup notifier if needed
        start_cleanup_task()  # Activity log cleanup
        start_session_cleanup()  # Session cleanup

        log_info("βœ… Background tasks started")

    except Exception as e:
        log_error("❌ Failed to start event-driven components", error=str(e), traceback=traceback.format_exc())
        raise

@app.on_event("shutdown")
async def shutdown_event():
    """Cleanup event-driven components on shutdown"""
    try:
        # Stop event bus
        await event_bus.stop()
        log_info("βœ… Event bus stopped")

        # Stop resource manager
        if hasattr(app.state, 'resource_manager'):
            await app.state.resource_manager.stop()
            log_info("βœ… Resource manager stopped")

        # Close all WebSocket connections
        if hasattr(app.state, 'websocket_manager'):
            await app.state.websocket_manager.close_all_connections()
            log_info("βœ… All WebSocket connections closed")

    except Exception as e:
        log_error("❌ Error during shutdown", error=str(e))

# ---------------- Core chat/session routes --------------------------
from routes.chat_handler import router as chat_router
app.include_router(chat_router, prefix="/api")

# ---------------- Audio (TTS/STT) routes ------------------------------
from routes.audio_routes import router as audio_router
app.include_router(audio_router, prefix="/api")

# ---------------- Admin API routes ----------------------------------
app.include_router(admin_router, prefix="/api/admin")

# ---------------- WebSocket route for real-time chat ------------------
@app.websocket("/ws/conversation/{session_id}")
async def websocket_route(websocket: WebSocket, session_id: str):
    """Handle WebSocket connections using the new WebSocketManager"""
    if hasattr(app.state, 'websocket_manager'):
        await app.state.websocket_manager.handle_connection(websocket, session_id)
    else:
        log_error("WebSocketManager not initialized")
        await websocket.close(code=1011, reason="Server not ready")

# ---------------- Test endpoint for event-driven flow ------------------
@app.post("/api/test/realtime")
async def test_realtime():
    """Test endpoint for event-driven realtime flow"""
    from chat_session.event_bus import Event, EventType

    try:
        # Create a test session
        session = session_store.create_session(
            project_name="kronos_jet",
            version_no=1,
            is_realtime=True
        )

        # Get version config
        cfg = ConfigProvider.get()
        project = next((p for p in cfg.projects if p.name == "kronos_jet"), None)
        if project:
            version = next((v for v in project.versions if v.no == 1), None)
            if version:
                session.set_version_config(version)

        # Publish session started event
        await app.state.event_bus.publish(Event(
            type=EventType.SESSION_STARTED,
            session_id=session.session_id,
            data={
                "session": session,
                "has_welcome": bool(version and version.welcome_prompt),
                "welcome_text": version.welcome_prompt if version and version.welcome_prompt else "Hoş geldiniz!"
            }
        ))

        return {
            "session_id": session.session_id,
            "message": "Test session created. Connect via WebSocket to continue."
        }

    except Exception as e:
        log_error("Test endpoint error", error=str(e))
        raise HTTPException(500, f"Test failed: {str(e)}")

# ---------------- Exception Handlers ----------------------------------
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
    """Handle all unhandled exceptions"""
    request_id = getattr(request.state, 'request_id', 'unknown')

    # Log the full exception
    log_error(
        "Unhandled exception",
        request_id=request_id,
        endpoint=str(request.url),
        method=request.method,
        error=str(exc),
        error_type=type(exc).__name__,
        traceback=traceback.format_exc()
    )

    # Special handling for FlareExceptions
    if isinstance(exc, FlareException):
        status_code = get_http_status_code(exc)
        response_body = {
            "error": type(exc).__name__,
            "message": str(exc),
            "request_id": request_id,
            "timestamp": datetime.utcnow().isoformat(),
            "details": getattr(exc, 'details', {})
        }

        # Special message for race conditions
        if isinstance(exc, RaceConditionError):
            response_body["user_action"] = "Please reload the data and try again"

        return JSONResponse(
            status_code=status_code,
            content=jsonable_encoder(response_body)
        )

    # Generic error response
    return JSONResponse(
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        content=jsonable_encoder({
            "error": "InternalServerError",
            "message": "An unexpected error occurred. Please try again later.",
            "request_id": request_id,
            "timestamp": datetime.utcnow().isoformat()
        })
    )

# Add custom exception handlers
@app.exception_handler(DuplicateResourceError)
async def duplicate_resource_handler(request: Request, exc: DuplicateResourceError):
    """Handle duplicate resource errors"""
    return JSONResponse(
        status_code=409,
        content={
            "detail": str(exc),
            "error_type": "duplicate_resource",
            "resource_type": exc.details.get("resource_type"),
            "identifier": exc.details.get("identifier")
        }
    )

@app.exception_handler(RaceConditionError)
async def race_condition_handler(request: Request, exc: RaceConditionError):
    """Handle race condition errors"""
    return JSONResponse(
        status_code=409,
        content=exc.to_http_detail()
    )

@app.exception_handler(FlareValidationError)
async def validation_error_handler(request: Request, exc: FlareValidationError):
    """Handle validation errors"""
    return JSONResponse(
        status_code=422,
        content={
            "detail": str(exc),
            "error_type": "validation_error",
            "details": exc.details
        }
    )

@app.exception_handler(ResourceNotFoundError)
async def resource_not_found_handler(request: Request, exc: ResourceNotFoundError):
    """Handle resource not found errors"""
    return JSONResponse(
        status_code=404,
        content={
            "detail": str(exc),
            "error_type": "resource_not_found",
            "resource_type": exc.details.get("resource_type"),
            "identifier": exc.details.get("identifier")
        }
    )

@app.exception_handler(AuthenticationError)
async def authentication_error_handler(request: Request, exc: AuthenticationError):
    """Handle authentication errors"""
    return JSONResponse(
        status_code=401,
        content={
            "detail": str(exc),
            "error_type": "authentication_error"
        }
    )

@app.exception_handler(AuthorizationError)
async def authorization_error_handler(request: Request, exc: AuthorizationError):
    """Handle authorization errors"""
    return JSONResponse(
        status_code=403,
        content={
            "detail": str(exc),
            "error_type": "authorization_error"
        }
    )

@app.exception_handler(ConfigurationError)
async def configuration_error_handler(request: Request, exc: ConfigurationError):
    """Handle configuration errors"""
    return JSONResponse(
        status_code=500,
        content={
            "detail": str(exc),
            "error_type": "configuration_error",
            "config_key": exc.details.get("config_key")
        }
    )

# ---------------- Metrics endpoint -----------------
@app.get("/metrics")
async def get_metrics():
    """Get system metrics including event-driven components"""
    import psutil
    import gc

    # Memory info
    process = psutil.Process()
    memory_info = process.memory_info()

    # Session stats
    session_stats = session_store.get_session_stats()

    # Event-driven component stats
    event_stats = {}
    if hasattr(app.state, 'stt_manager'):
        event_stats['stt'] = app.state.stt_manager.get_stats()
    if hasattr(app.state, 'tts_manager'):
        event_stats['tts'] = app.state.tts_manager.get_stats()
    if hasattr(app.state, 'llm_manager'):
        event_stats['llm'] = app.state.llm_manager.get_stats()
    if hasattr(app.state, 'websocket_manager'):
        event_stats['websocket'] = {
            'active_connections': app.state.websocket_manager.get_connection_count()
        }
    if hasattr(app.state, 'resource_manager'):
        event_stats['resources'] = app.state.resource_manager.get_stats()
    if hasattr(app.state, 'audio_buffer_manager'):
        event_stats['audio_buffers'] = app.state.audio_buffer_manager.get_all_stats()

    metrics = {
        "memory": {
            "rss_mb": memory_info.rss / 1024 / 1024,
            "vms_mb": memory_info.vms / 1024 / 1024,
            "percent": process.memory_percent()
        },
        "cpu": {
            "percent": process.cpu_percent(interval=0.1),
            "num_threads": process.num_threads()
        },
        "sessions": session_stats,
        "event_driven_components": event_stats,
        "gc": {
            "collections": gc.get_count(),
            "objects": len(gc.get_objects())
        },
        "uptime_seconds": time.time() - process.create_time()
    }

    return metrics

# ---------------- Health probe (HF Spaces watchdog) -----------------
@app.get("/api/health")
def health_check():
    """Health check endpoint - moved to /api/health"""
    # Check if event-driven components are healthy
    event_bus_healthy = hasattr(app.state, 'event_bus') and app.state.event_bus._running

    return {
        "status": "ok" if event_bus_healthy else "degraded",
        "version": "2.0.0",
        "timestamp": datetime.utcnow().isoformat(),
        "environment": os.getenv("ENVIRONMENT", "development"),
        "event_driven": {
            "event_bus": "running" if event_bus_healthy else "not_running",
            "managers": {
                "state_orchestrator": "initialized" if hasattr(app.state, 'state_orchestrator') else "not_initialized",
                "websocket_manager": "initialized" if hasattr(app.state, 'websocket_manager') else "not_initialized",
                "stt_manager": "initialized" if hasattr(app.state, 'stt_manager') else "not_initialized",
                "tts_manager": "initialized" if hasattr(app.state, 'tts_manager') else "not_initialized",
                "llm_manager": "initialized" if hasattr(app.state, 'llm_manager') else "not_initialized"
            }
        }
    }

# ---------------- Serve static files ------------------------------------
# UI static files (production build)
static_path = Path(__file__).parent / "static"
if static_path.exists():
    app.mount("/static", StaticFiles(directory=str(static_path)), name="static")

    # Serve index.html for all non-API routes (SPA support)
    @app.get("/", response_class=FileResponse)
    async def serve_index():
        """Serve Angular app"""
        index_path = static_path / "index.html"
        if index_path.exists():
            return FileResponse(str(index_path))
        else:
            return JSONResponse(
                status_code=404,
                content={"error": "UI not found. Please build the Angular app first."}
            )

    # Catch-all route for SPA
    @app.get("/{full_path:path}")
    async def serve_spa(full_path: str):
        """Serve Angular app for all routes"""
        # Skip API routes
        if full_path.startswith("api/"):
            return JSONResponse(status_code=404, content={"error": "Not found"})

        # Serve static files
        file_path = static_path / full_path
        if file_path.exists() and file_path.is_file():
            return FileResponse(str(file_path))

        # Fallback to index.html for SPA routing
        index_path = static_path / "index.html"
        if index_path.exists():
            return FileResponse(str(index_path))

        return JSONResponse(status_code=404, content={"error": "Not found"})
else:
    log_warning(f"⚠️  Static files directory not found at {static_path}")
    log_warning("   Run 'npm run build' in flare-ui directory to build the UI")

    @app.get("/")
    async def no_ui():
        """No UI available"""
        return JSONResponse(
            status_code=503,
            content={
                "error": "UI not available",
                "message": "Please build the Angular UI first. Run: cd flare-ui && npm run build",
                "api_docs": "/docs"
            }
        )

if __name__ == "__main__":
    log_info("🌐 Starting Flare backend on port 7860...")
    uvicorn.run(app, host="0.0.0.0", port=7860)