import os import random import torch import pandas as pd from flask import Flask, render_template, request, jsonify from sentence_transformers import SentenceTransformer, util import gradio as gr import uvicorn import nest_asyncio from fastapi import FastAPI from fastapi.middleware.wsgi import WSGIMiddleware # ============================== # CONFIG # ============================== BASE_DIR = os.path.abspath(os.path.dirname(__file__)) CSV_DATA = "dataset_2026.csv" EMB_FILE = "embeddings_questions.pt" TOP_K_RECOMMANDATIONS = 5 # ============================== # FLASK APP # ============================== app = Flask( __name__, template_folder=os.path.join(BASE_DIR, "templates"), static_folder=os.path.join(BASE_DIR, "static") ) # ============================== # MODEL # ============================== print("🔄 Chargement du modèle...") try: model = SentenceTransformer( "OrdalieTech/Solon-embeddings-mini-beta-1.1", device="cpu", trust_remote_code=True ) print("✓ Modèle principal chargé") except Exception as e: print("⚠️ Modèle principal échoué:", e) model = SentenceTransformer( "paraphrase-multilingual-MiniLM-L12-v2", device="cpu" ) print("✓ Modèle fallback chargé") # ============================== # GLOBAL CACHE (IMPORTANT FIX) # ============================== df = None embeddings = None # ============================== # DATA LOADING (ROBUST FIX) # ============================== def load_data(): global df try: try: df = pd.read_excel(CSV_DATA, engine="openpyxl") except Exception: # Fallback for csv df = pd.read_csv(CSV_DATA, sep=None, engine="python", encoding="utf-8", on_bad_lines="skip") # normalize column names (VERY IMPORTANT FIX) df.columns = df.columns.str.strip() print(f"✓ Données chargées: {len(df)} lignes") print("📌 Colonnes:", df.columns.tolist()) return df except FileNotFoundError: print("❌ Dataset introuvable → création...") df = pd.DataFrame({ "Question": ["Bonjour", "Comment ça va?", "Qu'est-ce que c'est?"], "Response": [ "Bonjour! Comment puis-je vous aider?", "Je vais bien, merci!", "C'est une application Q/A" ], "Intent": ["salutation", "conversation", "information"] }) df.to_csv(CSV_DATA, index=False) return df # ============================== # EMBEDDINGS (CACHE FIX) # ============================== def load_embeddings(): global embeddings, df if embeddings is not None: return embeddings if os.path.exists(EMB_FILE): print("📂 Chargement embeddings...") loaded_embs = torch.load(EMB_FILE, map_location="cpu") expected_dim = model.get_sentence_embedding_dimension() if loaded_embs.shape[1] == expected_dim and loaded_embs.shape[0] == len(df): embeddings = loaded_embs return embeddings else: print(f"⚠️ Incohérence détectée (dim: {loaded_embs.shape[1]} vs {expected_dim}, taille: {loaded_embs.shape[0]} vs {len(df)}). Recréation des embeddings...") print("🔨 Création embeddings...") questions = df["Question"].astype(str).tolist() embeddings = model.encode( questions, convert_to_tensor=True, normalize_embeddings=True, show_progress_bar=True ) torch.save(embeddings, EMB_FILE) print("✓ Embeddings sauvegardés") return embeddings # ============================== # UTILS # ============================== def enrich_message(text): prefixes = [ "Bonne question 🙂", "Voici la réponse :", "Intéressant !", "D'après mes données :", "Réponse :", "🤖" ] return f"{random.choice(prefixes)} {text}" def get_column(df, name): """ SAFE column getter (fixes Intent/intent/spacing issues) """ for col in df.columns: if col.lower() == name.lower(): return col raise KeyError(f"Column '{name}' not found. Available: {df.columns.tolist()}") # ============================== # CORE LOGIC (FIXED) # ============================== def process_question(question): global df, embeddings if not question or not question.strip(): return { "response": "Veuillez poser une question valide.", "confidence": 0, "matched": "—", "intent": "Invalid", "recs": [] } try: df = load_data() embeddings = load_embeddings() q_col = get_column(df, "Question") r_col = get_column(df, "Response") i_col = get_column(df, "Intent") service_col = get_column(df, "Service") link_col = get_column(df, "ServiceLink") lat_col = get_column(df, "Latitude") lon_col = get_column(df, "Longitude") img_col = None for col in df.columns: if col.lower() in ["imageplan", "image", "plan"]: img_col = col break emb_q = model.encode( question, convert_to_tensor=True, normalize_embeddings=True ) scores = util.pytorch_cos_sim(emb_q, embeddings)[0] best_idx = torch.argmax(scores).item() confidence = int(scores[best_idx].item() * 100) # LOW CONFIDENCE if confidence < 40: # Orientation : pick a few distinct topics to guide the user try: unique_intents = df[i_col].drop_duplicates() sampled_intents = unique_intents.sample(n=min(TOP_K_RECOMMANDATIONS, len(unique_intents))) orientation_recs = [] for ui in sampled_intents: # Take the first question from this intent first_q = df[df[i_col] == ui][q_col].iloc[0] orientation_recs.append(str(first_q)) except Exception: # Fallback to random if something goes wrong orientation_recs = df[q_col].sample(min(TOP_K_RECOMMANDATIONS, len(df))).tolist() return { "response": "Je ne suis pas certain d'avoir la réponse exacte. Cependant, pour vous orienter, voici quelques exemples de sujets sur lesquels je peux vous accompagner :", "confidence": confidence, "matched": "—", "intent": "Besoin d'orientation", "recs": orientation_recs, "service": None, "link": None, "lat": None, "lon": None } # CONFIDENCE >= 40% (Final answer without recommendations) answer = df[r_col].iloc[best_idx] intent = df[i_col].iloc[best_idx] service = df[service_col].iloc[best_idx] if service_col else None link = df[link_col].iloc[best_idx] if link_col else None lat = df[lat_col].iloc[best_idx] if lat_col else None lon = df[lon_col].iloc[best_idx] if lon_col else None # Clean NaN/Null values service_val = str(service) if pd.notna(service) and str(service).lower() not in ['nan', 'none', 'null', ''] else None link_val = str(link) if pd.notna(link) and str(link).lower() not in ['nan', 'none', 'null', ''] else None image_val = None if img_col: raw_img = df[img_col].iloc[best_idx] image_val = str(raw_img) if pd.notna(raw_img) and str(raw_img).lower() not in ['nan', 'none', 'null', ''] else None try: lat_val = float(lat) if pd.notna(lat) and str(lat).lower() not in ['nan', 'none', 'null', ''] else None lon_val = float(lon) if pd.notna(lon) and str(lon).lower() not in ['nan', 'none', 'null', ''] else None except Exception: lat_val = None lon_val = None # No recommendations if similarity >= 40% recs = [] return { "response": enrich_message(answer), "confidence": confidence, "matched": df[q_col].iloc[best_idx], "intent": intent, "recs": recs, "service": service_val, "link": link_val, "lat": lat_val, "lon": lon_val, "image": image_val } except Exception as e: print("❌ Erreur:", e) return { "response": "Erreur technique.", "confidence": 0, "matched": "—", "intent": "Error", "recs": [], "service": None, "link": None, "lat": None, "lon": None, "image": None } # ============================== # FLASK ROUTES # ============================== @app.route("/") def index(): return render_template("index.html") @app.route("/ask", methods=["POST"]) def ask(): try: data = request.get_json() question = data.get("question", "") return jsonify(process_question(question)) except Exception as e: print(e) return jsonify({"response": "Erreur serveur"}) @app.route("/api/services", methods=["GET"]) def get_services(): try: global df if df is None: df = load_data() service_col = get_column(df, "Service") lat_col = get_column(df, "Latitude") lon_col = get_column(df, "Longitude") # Filter rows having valid lat and lon and service valid_df = df.dropna(subset=[service_col, lat_col, lon_col]) services_list = [] seen = set() for _, row in valid_df.iterrows(): srv = str(row[service_col]).strip() if srv and srv.lower() not in ['nan', 'none', 'null', ''] and srv not in seen: try: lat_val = float(row[lat_col]) lon_val = float(row[lon_col]) if not pd.isna(lat_val) and not pd.isna(lon_val): services_list.append({ "service": srv, "lat": lat_val, "lon": lon_val }) seen.add(srv) except Exception: pass return jsonify({"status": "success", "services": services_list}) except Exception as e: print("❌ Error API Services:", e) return jsonify({"status": "error", "services": []}) # ============================== # GRADIO # ============================== def gradio_chat(message, history): return process_question(message)["response"] iface = gr.ChatInterface( fn=gradio_chat, title="AskLaQ Assistant", description="Posez vos questions" ) # ============================== # FASTAPI WRAPPER # ============================== fastapi_app = FastAPI(title="AskLaQ API") fastapi_app.mount("/", WSGIMiddleware(app)) fastapi_app = gr.mount_gradio_app(fastapi_app, iface, path="/chat") # ============================== # MAIN # ============================== if __name__ == "__main__": nest_asyncio.apply() print("=" * 60) print("🚀 ASKLAQ SYSTEM (ROBUST VERSION)") print("=" * 60) uvicorn.run( fastapi_app, host="0.0.0.0", port=7860, log_level="info" )