Spaces:
Sleeping
Sleeping
| # βββββββββββββββββββββββββββββββββββββββββ | |
| # NovaDXB β app.py | |
| # Flask REST API β Security, Rate Limiting, Caching | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| import os | |
| import re | |
| import time | |
| import logging | |
| import threading | |
| from functools import wraps | |
| from flask import Flask, request, jsonify, render_template | |
| from flask_cors import CORS | |
| from dotenv import load_dotenv | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| # LOGGING β full error details stay server-side | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [%(levelname)s] %(message)s" | |
| ) | |
| logger = logging.getLogger("NovaDXB") | |
| # Load environment variables | |
| load_dotenv() | |
| # Import RAG + Agent | |
| from rag_engine import initialize_rag | |
| from agent import initialize_agent, query_agent, extract_itinerary_json | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| # SECRET_KEY β required, no hardcoded fallback | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| SECRET_KEY = os.environ.get("SECRET_KEY") | |
| if not SECRET_KEY: | |
| raise RuntimeError( | |
| "SECRET_KEY is not set. Add SECRET_KEY to your .env file " | |
| "(generate one with: python -c \"import secrets; print(secrets.token_hex(32))\")" | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| # CORS β restricted to known frontend origins | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| ALLOWED_ORIGINS = os.environ.get( | |
| "ALLOWED_ORIGINS", | |
| "http://localhost:7860" | |
| ).split(",") | |
| # Initialize Flask | |
| app = Flask( | |
| __name__, | |
| static_folder="static", | |
| template_folder="static" | |
| ) | |
| app.config["SECRET_KEY"] = SECRET_KEY | |
| CORS( | |
| app, | |
| resources={ | |
| r"/chat": {"origins": ALLOWED_ORIGINS}, | |
| r"/health": {"origins": ALLOWED_ORIGINS}, | |
| }, | |
| supports_credentials=True | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| # INPUT SANITIZATION + PROMPT-INJECTION GUARD | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| MAX_MESSAGE_LENGTH = 1000 | |
| # Strip control/invisible characters that serve no purpose in a chat message | |
| _control_char_pattern = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") | |
| # Common prompt-injection phrasing β flagged, not silently obeyed | |
| _injection_patterns = [ | |
| r"ignore (all|previous|above) instructions", | |
| r"disregard (all|previous|your) (rules|instructions)", | |
| r"you are now", | |
| r"system prompt", | |
| r"reveal (your|the) (system )?prompt", | |
| r"act as (?!a concierge)", | |
| r"forget (everything|all) (you|above)", | |
| ] | |
| _injection_regex = re.compile("|".join(_injection_patterns), re.IGNORECASE) | |
| def sanitize_input(message: str) -> str: | |
| """Clean and bound user input before it reaches the agent.""" | |
| if not isinstance(message, str): | |
| raise ValueError("Message must be text") | |
| message = _control_char_pattern.sub("", message) | |
| message = re.sub(r"\s+", " ", message).strip() | |
| if len(message) > MAX_MESSAGE_LENGTH: | |
| message = message[:MAX_MESSAGE_LENGTH] | |
| return message | |
| def is_prompt_injection(message: str) -> bool: | |
| """Return True if the message looks like an attempt to override | |
| NovaDXB's system instructions rather than ask a genuine question.""" | |
| return bool(_injection_regex.search(message)) | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| # RATE LIMITING β simple per-IP sliding window | |
| # Protects against abuse and runaway OpenAI/Pinecone costs | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| RATE_LIMIT_WINDOW = 60 # seconds | |
| RATE_LIMIT_MAX_REQUESTS = 15 # max requests per window per IP | |
| _rate_limit_store = {} | |
| def rate_limited(f): | |
| def wrapper(*args, **kwargs): | |
| ip = request.headers.get("X-Forwarded-For", request.remote_addr) | |
| now = time.time() | |
| timestamps = _rate_limit_store.get(ip, []) | |
| timestamps = [t for t in timestamps if now - t < RATE_LIMIT_WINDOW] | |
| if len(timestamps) >= RATE_LIMIT_MAX_REQUESTS: | |
| logger.warning(f"Rate limit exceeded for {ip}") | |
| return jsonify({ | |
| "error": "Too many requests. Please wait a moment and try again." | |
| }), 429 | |
| timestamps.append(now) | |
| _rate_limit_store[ip] = timestamps | |
| return f(*args, **kwargs) | |
| return wrapper | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| # RESPONSE CACHE β identical questions answered instantly | |
| # Reduces repeated OpenAI/Pinecone calls and agent latency | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| CACHE_MAX_SIZE = 200 | |
| _response_cache = {} | |
| def get_cached_response(message: str): | |
| return _response_cache.get(message.lower().strip()) | |
| def set_cached_response(message: str, response: str): | |
| if len(_response_cache) >= CACHE_MAX_SIZE: | |
| oldest_key = next(iter(_response_cache)) | |
| _response_cache.pop(oldest_key, None) | |
| _response_cache[message.lower().strip()] = response | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| # STARTUP β Initialize RAG then Agent | |
| # Background thread β app starts instantly | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| # Track app readiness β never leave the frontend guessing silently | |
| app_state = { | |
| "ready": False, | |
| "startup_error": None | |
| } | |
| def startup(): | |
| try: | |
| initialize_rag() | |
| initialize_agent() | |
| app_state["ready"] = True | |
| logger.info("NovaDXB startup complete β agent ready") | |
| except Exception as e: | |
| app_state["startup_error"] = str(e) | |
| logger.error(f"Startup failed: {e}", exc_info=True) | |
| threading.Thread(target=startup, daemon=True).start() | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| # ROUTES | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| def landing(): | |
| return render_template("landing.html") | |
| def chat_app(): | |
| return render_template("index.html") | |
| def health(): | |
| if app_state["startup_error"]: | |
| status = "error" | |
| elif app_state["ready"]: | |
| status = "ok" | |
| else: | |
| status = "starting" | |
| return jsonify({ | |
| "status": status, | |
| "app": "NovaDXB", | |
| "version": "1.0" | |
| }) | |
| def chat(): | |
| # ββ Check agent readiness before doing any work ββ | |
| if app_state["startup_error"]: | |
| logger.error(f"Chat blocked β startup error: {app_state['startup_error']}") | |
| return jsonify({ | |
| "error": "NovaDXB is temporarily unavailable. Please try again shortly." | |
| }), 503 | |
| if not app_state["ready"]: | |
| return jsonify({ | |
| "error": "NovaDXB is still starting up. Please try again in a moment." | |
| }), 503 | |
| try: | |
| data = request.get_json(silent=True) | |
| if not data or "message" not in data: | |
| return jsonify({ | |
| "error": "Invalid request. Expected JSON with a 'message' field." | |
| }), 400 | |
| raw_message = data.get("message", "") | |
| # ββ Sanitize input βββββββββββββββββ | |
| try: | |
| message = sanitize_input(raw_message) | |
| except ValueError: | |
| return jsonify({ | |
| "error": "Invalid message format." | |
| }), 400 | |
| if not message: | |
| return jsonify({ | |
| "error": "Message cannot be empty." | |
| }), 400 | |
| # ββ Prompt-injection guard βββββββββ | |
| if is_prompt_injection(message): | |
| return jsonify({ | |
| "status": "success", | |
| "response": ( | |
| "I'm NovaDXB, your Dubai travel concierge β I can only " | |
| "help with Dubai tourism questions like itineraries, " | |
| "areas to stay, dining, and budgets. How can I help " | |
| "with your Dubai trip?" | |
| ) | |
| }) | |
| # ββ Check cache before calling the agent ββ | |
| cached = get_cached_response(message) | |
| if cached: | |
| cached_itinerary = None | |
| try: | |
| cached_itinerary = extract_itinerary_json(cached) | |
| except Exception as e: | |
| logger.warning(f"Cached itinerary extraction failed silently: {e}") | |
| return jsonify({ | |
| "status": "success", | |
| "response": cached, | |
| "itinerary": cached_itinerary, | |
| "cached": True | |
| }) | |
| # ββ Agent response ββββββββββββββββ | |
| response = query_agent(message) | |
| # ββ Cache successful response ββββββ | |
| set_cached_response(message, response) | |
| # ββ Extract structured itinerary for side panel (if relevant) ββ | |
| itinerary = None | |
| try: | |
| itinerary = extract_itinerary_json(response) | |
| except Exception as e: | |
| logger.warning(f"Itinerary extraction failed silently: {e}") | |
| return jsonify({ | |
| "status": "success", | |
| "response": response, | |
| "itinerary": itinerary | |
| }) | |
| except Exception as e: | |
| # Full details logged server-side only β never shown to the user | |
| logger.error(f"/chat error: {e}", exc_info=True) | |
| return jsonify({ | |
| "error": "Something went wrong while processing your request. Please try again." | |
| }), 500 | |
| def not_found(e): | |
| return jsonify({"error": "Not found"}), 404 | |
| def server_error(e): | |
| logger.error(f"Unhandled server error: {e}", exc_info=True) | |
| return jsonify({"error": "Internal server error"}), 500 | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| # RUN | |
| # βββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| from waitress import serve | |
| port = int(os.environ.get("PORT", 7860)) | |
| logger.info(f"NovaDXB running on port {port}") | |
| serve(app, host="0.0.0.0", port=port) |