From 6416bcd075af935b7916bd8cb3c055001b1b6d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Mureau?= Date: Mon, 7 Jul 2025 10:30:41 +0200 Subject: [PATCH] Added prediction probabilities on error reports + added unit tests (not finished yet) --- .../api/__pycache__/main.cpython-312.pyc | Bin 7423 -> 7836 bytes deployment/api/app.py | 10 +- deployment/api/main.py | 90 +++++++++++++----- deployment/api/preprocessing.py | 44 +++++++++ deployment/api/test_alias_email.py | 31 ------ deployment/api/tests/test_email_service.py | 77 +++++++++++++++ deployment/api/tests/test_main.py | 27 ++++++ deployment/api/tests/test_preprocessing.py | 36 +++++++ functions.py | 3 +- 9 files changed, 258 insertions(+), 60 deletions(-) create mode 100644 deployment/api/preprocessing.py delete mode 100644 deployment/api/test_alias_email.py create mode 100644 deployment/api/tests/test_email_service.py create mode 100644 deployment/api/tests/test_main.py create mode 100644 deployment/api/tests/test_preprocessing.py diff --git a/deployment/api/__pycache__/main.cpython-312.pyc b/deployment/api/__pycache__/main.cpython-312.pyc index ad93c1169627db8ca1b90aa7bf06c70b27e6c6cb..08c455a434e33daa606632d7a9dcbde0a45220b5 100644 GIT binary patch delta 2107 zcmZ8iYitxn9G|&;^zL?Vdyn4rUTqgjX&3t7(ee;lz!pL&&p=S1TCUwITzkDcXZH%N zSE;3HK|VAY6B`jj{6qy3NlmO@jB-Rx{NVCPs3#_nsEN_w@rp!0=*+=EVUqpLfBy5E z-|YV9KmYx2<%f;Z_cogqkXAX`d+^=h1*sk45x97v<*bQ%0XSzp3m;h!s6Z8>-ag-6 zU(Obz{=UH8z|=Re%;X+qYs0aA1!e>_t`n;x5z-IhUzs|ru%AN(-(tUXRuUntfWTW| zMvz7~As@cLmBMOlaOF~%3$rS-n#>Nc3WxvZbB*;pqElwq<1Z#A0Q4<+ zhQPhisVF}QYjrA{YpNr~odl}$nByck#XgPBm^4;6W*>8Ov#JZvF;}b#rH>WUag_ zJsC|;b{!0%1L3}KOqbQ9-1nFojie{#P*O(8SVu&awPd6}6w~o-%L%vuziyptSBR9x zQN3Z(kRPA7*1%o(C+i|B{n(5Nbt(?*78G)PYlOCf43|I=qHtW;2j}A71Q{;E1!9A} zhLBM$g*plf-Yf>7A0HG;t(%EsDS5O`JT4Byo7iEC`F>$7V;jd`HHzjL^STVR$LQ)c ztkG+1A3kF1vbsDi%a}{cxRy2ePg@*T;dZHgdG27JJ>3k8;&B~qC+fX)2Z_KixGB1g z;x&e_)?n9?^!iJxhPS7Jg&SZ2ql!a(%rtet{t@(|Qo>e5)Ji(kMuElw3An=9YC-fM zlmfH^4>((#`^X@dwDO}y;`D#o}B2Jp4C&e!Y#7aJ_iF6LsBy;)+? z5=ZQ`$I@KSq|5BhbxgXBYp%zOi{`@-{BqH8yuZ*b4lyZ+OevFQ(*^uTk?#njvb`jS zH7>=dTyG2c9x_8-GuKq8^5>X*zKy_?Ni85e^GQ~4qs9J|u3(yPm z-ly~VY*vLqp77Y-DzaC9kOY|39eKKHo4pi=+^%xz6q8>Hfle=Tu00j*apU*h%c_hKYt_h@Ky!=b2%<;)wy&PE4B7)SXi+!S{;iDzwTy zNo&d$AE|WYBvFjG&XGskh#S52!i>{*-C1$jSuxy(U-P>5%oI4scZ?jFSa=40Px_zH%8=>n{h^k8T;SxXgNQI*W(fvb>dRx*

zhOP=Fr{QH`&gmUjg!wc6(y=!DnYSALh;MltdaS_apIBuGl?Ge+@V*6z?z;ezg=U*! zUoq{NW(;Zdb#tR(ZX{birT&|>5Y|5$tz#F37ljr-xa7CAG%=UTrIwYW_4Vk%&kAOW|rf z?GN_Qp9kupK+9c*2_+J<3d>wNLF11)DJ-Wzi&lnB#5Emt(=iS7b3d<)c{M5I$!NF< zy-jB6KUn)1Hw7x88FvSom|H-=7Xn4%12cdNAMgM!$(r%Efz~nx7LT{y2c#nf$l)5c zk-#WRrz`G(8d!a2KduXIZ45wuB6Sy#?j8e7o~(&j*~1W+xOcp7ddKRnLVwm&0;?u! V?gG+LQs54CJj`w};V*(4{s+io^y~lt delta 1827 zcmb7FU2Kz87(VCwY5V>3zw6d^t=;Cfer7*N2Llln^Pcm* z?{mKMp7*@{bn_n@O*f535fITU@dIB~T{G>6&M1fqiJD2*unPjBiwXCnXV`-ez@SIE z!cb=bzL2lLvIYSIVqjtezNEL$EklmM&)N2>BV1c7c}Rhp6iul_8%d9<2e86@125xc z?gv`~3C`#V90yC1>DYGU#@Bcstj7huftz9#4nv)P(*Uv)7v!Sgrr@FAtw0k|GBSY@ z$@|-vEcRoAWk)YQrE|ftxzBW$p`176&3Q}SI?hF{IA>_wLZS zL1T&qFGvau;D^!<+%4~eb@&t658Lr~@;XZkaYC&W+9(7*GJ2o~*BX7|b~2}HnLZpg z&cG%7qcMqhnj#|O3^%ej8u@S={??R&%~&z-?<{5N@9EMwluD^+fN(@8kBPtxxGz@~ zs$VTgTMFD3lKbz?E%4#o19LCrnW&tEbL*|=pE(H;ouN4m2TEwmeQSR)-;Mm;}LZv6HBV`Ndjn`IKD-lMf|mW z5b7}R=rz}^xf;4%*ZE=J{7_yfiug@OHT2>`4h1^#MMqmp2e@wPsR>nqg$^NPV;44C zLPmDc$P<0BN(cqnMV}?q#xAz;p&)+lXo4NMuId8;A|}gZjH-Zts&Y*;YN_PO-XIIR z2u8Uas~YK?%8Ip898vy=j(F+bCijfqjp}o(0913?o2(oDE%q#VP3PDO69lT&Q3+hx zXu^)K>)p8E6sEZ>hrFxE#s46eR+A@dS4GPICo*cvvT?W@?5ijZK>h1OUnDOt$FHI` zX8E(=9J6w&A<`*w{Cj*Qx;#4!4}*8uA#fNf<^i+^U#SkryU6K#AFu=wgWGHF4M@mF zC;KV*D0EVw6{ZniltLX&1h^Qmelw(ij(S*sGWrb%d5B%Ng;m$@4i|=y?5S)82w%Z&3$on~wFLZoI|S z+_zQF?0BjJ2I<8qV=}Ee#z<1OGTY1yaw?b?iRR=kGt1R%fRTK-Bgg8 z3tV%)zoI>RDgcY?F@VJRu52why5@JyhYGeWzsfxYxrgkx67_pgpl;7I^c`RC?CA&v z!D3JdSF?*Z$@0ir|kNDN8XUmKiGP>a|<7~Z;bWT2;itH$r%)3a?WvXb?3qn)4{?Lrzm znoh6k0d>?sbC1R-tfxS`jK-x?ney{4rTQQ3TQhV`rlJQEv2Jt%7yzJuBbiHBt8FGN zfbn)W+yhO&=>S~&h=4rHT+pAGg1#B$paKzj90s|eV`8(&7?9x!3m9jSAzE=QVE7X?M{Dx!cc8n-_+VhR V^)Vm=_3=ES3(tz%Al~ci{SyTZoM`|6 diff --git a/deployment/api/app.py b/deployment/api/app.py index e5b5991..1284019 100644 --- a/deployment/api/app.py +++ b/deployment/api/app.py @@ -53,7 +53,7 @@ if st.session_state.prediction_result: sentiment = st.session_state.prediction_result["sentiment"].capitalize() prob = st.session_state.prediction_result["probability"] st.success(f"Sentiment : {sentiment}") - st.info(f"Probabilité : {prob:.2f}") + st.info(f"Probabilité que le tweet soit positif: {prob:.2f}") # Gestion du bouton signalement if st.session_state.show_report_button and not st.session_state.report_sent: @@ -61,15 +61,19 @@ if st.session_state.show_report_button and not st.session_state.report_sent: try: response = requests.post( "http://localhost:8000/report_error", - json={"tweet": tweet, "prediction": sentiment} + json={ + "tweet": tweet, + "prediction": sentiment, + "probability": st.session_state.prediction_result["probability"] # Transmettre la probabilité + } ) + if response.status_code == 200: data = response.json() # Mettre à jour les états st.session_state.show_report_button = False st.session_state.report_sent = True st.session_state.last_report_sent = data.get("report_sent", False) - # Forcer un re-run st.rerun() else: diff --git a/deployment/api/main.py b/deployment/api/main.py index 67ec948..a123e43 100644 --- a/deployment/api/main.py +++ b/deployment/api/main.py @@ -9,16 +9,20 @@ from mlflow.tracking import MlflowClient from tensorflow.keras.models import load_model from tensorflow.keras.preprocessing.sequence import pad_sequences -# 🔁 Import de la fonction de prétraitement depuis ../../functions.py +# A DECOMMENTER POUR DEVELOPPER. AUTREMENT, LAISSER AINSI +# Accéder à .env PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")) sys.path.append(PARENT_DIR) -from functions import preprocess_tweet +# 🔐 Charger les variables d'environnement (.env à deux niveaux au-dessus) +load_dotenv(dotenv_path=os.path.join(PARENT_DIR, ".env")) + + +# 🔁 Import de la fonction de prétraitement (LOCAL) +from preprocessing import preprocess_tweet # 📧 Import du service email from email_service import send_error_report_email -# 🔐 Charger les variables d'environnement (.env à deux niveaux au-dessus) -load_dotenv(dotenv_path=os.path.join(PARENT_DIR, ".env")) # 🔗 Configurer MLflow mlflow_tracking_uri = os.getenv("MLFLOW_TRACKING_URI") @@ -38,6 +42,7 @@ class TweetRequest(BaseModel): class ReportRequest(BaseModel): tweet: str prediction: str + probability: float # 🧠 Stockage des signalements d'erreurs error_reports = {} @@ -58,6 +63,7 @@ def load_model_and_artifacts(model_name=MODEL_NAME, stage=STAGE): # Extraire le tag d'embedding model_version_info = client.get_model_version(model_name, version_info.version) + # glove est ici la valeur par défaut si embedding_type n'est pas trouvé. embedding_type = model_version_info.tags.get("embedding_type", "glove").lower() # Déterminer les chemins des artefacts @@ -72,6 +78,8 @@ def load_model_and_artifacts(model_name=MODEL_NAME, stage=STAGE): keras_file = "final_model_LSTM_Word2Vec-Fige.keras" else: raise ValueError(f"Embedding type inconnu : {embedding_type}") + + print(f"Modèle {embedding_type} trouvé. Téléchargement du modèle lié au run_id suivant : {run_id}") def dl(path): return client.download_artifacts(run_id=run_id, path=f"{base_path}/{path}", dst_path=local_dir) @@ -117,7 +125,11 @@ def predict_sentiment(request: TweetRequest): sequence = tokenizer.texts_to_sequences([processed_tweet]) sequence_padded = pad_sequences(sequence, maxlen=model.input_shape[1]) prediction_prob = model.predict(sequence_padded)[0][0] - sentiment = "positif" if prediction_prob >= 0.5 else "negatif" + if prediction_prob >=0.5: + sentiment = "positif" + else: + sentiment = "negatif" + # prediction_prob = 1-prediction_prob except Exception as e: raise HTTPException(status_code=500, detail=f"Erreur d'inférence : {e}") @@ -126,35 +138,65 @@ def predict_sentiment(request: TweetRequest): "probability": float(prediction_prob) } +# @app.post("/report_error") +# def report_error(request: ReportRequest): +# tweet = request.tweet.strip() +# prediction = request.prediction.strip().lower() +# probability = request.probability + +# if not tweet or not prediction: +# raise HTTPException(status_code=400, detail="Tweet ou prédiction manquants.") +# if probability < 0 or probability > 1: +# raise HTTPException(status_code=400, detail="Probabilité invalide. Elle doit être entre 0 et 1.") + +# # Ajouter ou mettre à jour le signalement du tweet avec la probabilité +# error_reports[tweet] = prediction + ' (p = ' + str(probability) + ') ' + +# # ✅ Envoi de l'email sans attendre un multiple de 3 +# try: +# email_success = send_error_report_email(error_reports.copy()) +# if email_success: +# print(f"✅ Email envoyé avec succès pour {len(error_reports)} signalements") +# report_sent = True +# else: +# print(f"❌ Échec de l'envoi de l'email") +# report_sent = False +# except Exception as e: +# print(f"❌ Erreur lors de l'envoi de l'email : {e}") +# report_sent = False + +# return {"report_sent": report_sent} + + + @app.post("/report_error") def report_error(request: ReportRequest): tweet = request.tweet.strip() prediction = request.prediction.strip().lower() - + probability = request.probability + if not tweet or not prediction: raise HTTPException(status_code=400, detail="Tweet ou prédiction manquants.") - - # Ajouter ou mettre à jour le signalement du tweet - error_reports[tweet] = prediction - + if probability < 0 or probability > 1: + raise HTTPException(status_code=400, detail="Probabilité invalide. Elle doit être entre 0 et 1.") + + # Ajouter ou mettre à jour le signalement du tweet avec la probabilité + error_reports[tweet] = prediction + f' (p = {probability:.2f} ) ' + report_sent = False - - # Condition : à chaque multiple de 3 signalements totaux - if len(error_reports) % 3 == 0: - report_sent = True - - # 📧 NOUVEAU : Envoyer l'email de rapport - try: + + # Condition : À chaque multiple de 3 signalements, un mail est envoyé + try: + if len(error_reports) % 3 == 0: # La condition reste + report_sent = True print(f"📧 Tentative d'envoi d'email pour {len(error_reports)} signalements...") email_success = send_error_report_email(error_reports.copy()) - if email_success: print(f"✅ Email envoyé avec succès pour {len(error_reports)} signalements") else: print(f"❌ Échec de l'envoi de l'email pour {len(error_reports)} signalements") - - except Exception as e: - print(f"❌ Erreur lors de l'envoi de l'email : {e}") - # On continue même si l'email échoue pour ne pas bloquer l'API - - return {"report_sent": report_sent} + except Exception as e: + print(f"❌ Erreur lors de l'envoi de l'email : {e}") + # On continue même si l'email échoue pour ne pas bloquer l'API + + return {"report_sent": report_sent} \ No newline at end of file diff --git a/deployment/api/preprocessing.py b/deployment/api/preprocessing.py new file mode 100644 index 0000000..5c54737 --- /dev/null +++ b/deployment/api/preprocessing.py @@ -0,0 +1,44 @@ +import re +import html +import nltk +from nltk.corpus import stopwords +from nltk.tokenize import word_tokenize +from nltk.stem import WordNetLemmatizer + +# Assurer que les ressources nécessaires sont disponibles +try: + stopwords.words('english') + word_tokenize("test") + WordNetLemmatizer().lemmatize("testing") +except LookupError: + nltk.download('punkt') + nltk.download('punkt_tab') + nltk.download('stopwords') + nltk.download('wordnet') + nltk.download('omw-1.4') + +def preprocess_tweet(tweet): + """ + Fonction de prétraitement des tweets pour l'API de sentiment analysis + """ + if not isinstance(tweet, str): + return "" + tweet = html.unescape(tweet) + tweet = tweet.lower() + tweet = re.sub(r'https?://\S+|www\.\S+', '', tweet) + tweet = re.sub(r'@\w+', '', tweet) + tweet = re.sub(r'#(\w+)', r'# \1', tweet) + tweet = re.sub(r'[^\w\s<>@#!?]', '', tweet) + + tokens = word_tokenize(tweet) + + lemmatizer = WordNetLemmatizer() + tokens = [lemmatizer.lemmatize(token) for token in tokens] + + stop_words = set(stopwords.words('english')) + important_words = {'no', 'not', 'nor', 'neither', 'never', 'nobody', 'none', 'nothing', 'nowhere'} + stop_words = stop_words - important_words + + tokens = [token for token in tokens if token not in stop_words] + + return ' '.join(tokens) \ No newline at end of file diff --git a/deployment/api/test_alias_email.py b/deployment/api/test_alias_email.py deleted file mode 100644 index e6b5322..0000000 --- a/deployment/api/test_alias_email.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Created on Sat Jul 5 15:03:24 2025 - -@author: madfuckinman -""" - -# test_alias_email.py -import os -from dotenv import load_dotenv -from email_service import send_error_report_email - -load_dotenv() - -print("🔧 Configuration Email:") -print(f"Serveur SMTP: {os.getenv('SMTP_SERVER')}") -print(f"Authentification: {os.getenv('SMTP_EMAIL')}") -print(f"Expéditeur (alias): {os.getenv('SMTP_FROM_ALIAS')}") -print(f"Destinataire: {os.getenv('ADMIN_EMAIL')}") - -# Test -test_reports = { - "Service client décevant": "positif", - "J'adore cette compagnie": "negatif", - "Vol annulé sans prévenir": "positif" -} - -print("\n📧 Test d'envoi depuis l'alias...") -result = send_error_report_email(test_reports) -print(f"Résultat : {'✅ Succès' if result else '❌ Échec'}") \ No newline at end of file diff --git a/deployment/api/tests/test_email_service.py b/deployment/api/tests/test_email_service.py new file mode 100644 index 0000000..dae5002 --- /dev/null +++ b/deployment/api/tests/test_email_service.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Sat Jul 5 15:03:24 2025 + +@author: madfuckinman +""" + +# # test_alias_email.py +# import os +# from dotenv import load_dotenv +# from email_service import send_error_report_email + +# load_dotenv() + +# print("🔧 Configuration Email:") +# print(f"Serveur SMTP: {os.getenv('SMTP_SERVER')}") +# print(f"Authentification: {os.getenv('SMTP_EMAIL')}") +# print(f"Expéditeur (alias): {os.getenv('SMTP_FROM_ALIAS')}") +# print(f"Destinataire: {os.getenv('ADMIN_EMAIL')}") + +# # Test +# test_reports = { +# "Service client décevant": "positif", +# "J'adore cette compagnie": "negatif", +# "Vol annulé sans prévenir": "positif" +# } + +# print("\n📧 Test d'envoi depuis l'alias...") +# result = send_error_report_email(test_reports) +# print(f"Résultat : {'✅ Succès' if result else '❌ Échec'}") + + +import os +import sys +from dotenv import load_dotenv + +# Ajouter le répertoire parent au path pour importer email_service +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))) + +from email_service import send_error_report_email + +# Charger le fichier .env situé un niveau au-dessus +dotenv_path = '../../../.env' +load_dotenv(dotenv_path) + +def test_email_service_with_alias(): + """ + Teste l'envoi d'un email avec la fonction send_error_report_email + """ + # ⚙️ Vérifier si les variables d'environnement nécessaires sont présentes + smtp_server = os.getenv("SMTP_SERVER") + smtp_email = os.getenv("SMTP_EMAIL") + admin_email = os.getenv("ADMIN_EMAIL") + + assert smtp_server, "SMTP_SERVER non défini dans le fichier .env" + assert smtp_email, "SMTP_EMAIL non défini dans le fichier .env" + assert admin_email, "ADMIN_EMAIL non défini dans le fichier .env" + + # Configuration de test + print("🔧 Configuration Email :") + print(f"Serveur SMTP : {smtp_server}") + print(f"Authentification : {smtp_email}") + print(f"Destinataire : {admin_email}") + + # Cas de test + test_reports = { + "Service client décevant": "positif", + "Erreur à l'embarquement": "negatif", + "Vol annulé sans prévenir": "positif" + } + + # 📧 Tester la fonction d'envoi + result = send_error_report_email(test_reports) + + # ✅ Assertion pour vérifier que la fonction fonctionne correctement + assert result is True, "L'email n'a pas été envoyé avec succès" \ No newline at end of file diff --git a/deployment/api/tests/test_main.py b/deployment/api/tests/test_main.py new file mode 100644 index 0000000..1d66f8b --- /dev/null +++ b/deployment/api/tests/test_main.py @@ -0,0 +1,27 @@ +import pytest +from fastapi.testclient import TestClient +from main import app + +client = TestClient(app) + +def test_root_endpoint(): + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"message": "API Sentiment - Air Paradis - en ligne"} + +def test_predict_sentiment(): + response = client.post("/predict", json={"tweet": "J'adore cette compagnie aérienne !"}) + assert response.status_code == 200 + data = response.json() + assert "sentiment" in data + assert "probability" in data + assert data["sentiment"] in ["positif", "negatif"] + +def test_missing_tweet(): + response = client.post("/predict", json={}) + assert response.status_code == 422 # Unprocessable Entity (FastAPI) + +def test_empty_tweet(): + response = client.post("/predict", json={"tweet": ""}) + assert response.status_code == 400 + assert response.json()["detail"] == "Tweet vide ou invalide après prétraitement." \ No newline at end of file diff --git a/deployment/api/tests/test_preprocessing.py b/deployment/api/tests/test_preprocessing.py new file mode 100644 index 0000000..a5ae557 --- /dev/null +++ b/deployment/api/tests/test_preprocessing.py @@ -0,0 +1,36 @@ +import sys +import os + +# Ajouter le répertoire parent au path pour importer email_service +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))) +from preprocessing import preprocess_tweet + + +def test_preprocess_tweet_removes_hashtags(): + tweet = """ + Just flew w/ @AirParadis 🚀 & I’m in LOVE! Épic service, comfy seats. + #bestflight #AirParadis + """ + assert preprocess_tweet(tweet) == """" + aze + """ + +def test_preprocess_tweet_removes_mentions(): + tweet = """ + "Flight of dreams" w/ @AirParadis! Got food, space & smiles 😄 I’ll book again! + #AirParadis + """ + assert preprocess_tweet(tweet) == "merci" + +def test_preprocess_tweet_lowercases_text(): + tweet = """ + My 3rd time w/ @AirParadis & still amazed 😍 Crew = lovely à every step! + #flyhappy #AirParadis + """ + assert preprocess_tweet(tweet) == "je suis content" + +tweet = """ +Just flew w/ @AirParadis 🚀 & I’m in LOVE! Épic service, comfy seats. +#bestflight #AirParadis +""" +preprocess_tweet(tweet) \ No newline at end of file diff --git a/functions.py b/functions.py index e97040b..7663a65 100644 --- a/functions.py +++ b/functions.py @@ -39,12 +39,11 @@ def downsample_data(df, n_samples=50000, random_state=42): def preprocess_tweet(tweet): if not isinstance(tweet, str): return "" - + tweet = html.unescape(tweet) tweet = tweet.lower() tweet = re.sub(r'https?://\S+|www\.\S+', '', tweet) tweet = re.sub(r'@\w+', '', tweet) tweet = re.sub(r'#(\w+)', r'# \1', tweet) - tweet = html.unescape(tweet) tweet = re.sub(r'[^\w\s<>@#!?]', '', tweet) tokens = word_tokenize(tweet)