Spaces:
Runtime error
Runtime error
| """ | |
| app.py — NPS AI Inference API | |
| HuggingFace Space (Docker SDK) — expose un endpoint REST pour l'Automation 2 SFMC | |
| Endpoints : | |
| GET /health → statut + modèles chargés | |
| POST /predict → score IA + description CRM | |
| Déploiement : | |
| 1. Créer un Space HuggingFace (SDK: Docker) | |
| 2. Uploader ce dossier (app.py, modeling_nps_score.py, Dockerfile, requirements.txt) | |
| 3. Ajouter le secret HF_TOKEN dans les Settings du Space | |
| """ | |
| import os | |
| import re | |
| import logging | |
| from contextlib import asynccontextmanager | |
| from typing import Optional | |
| import torch | |
| import numpy as np | |
| from fastapi import FastAPI, HTTPException, Security | |
| from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials | |
| from pydantic import BaseModel, Field | |
| from transformers import AutoTokenizer, AutoModelForSeq2SeqLM | |
| from modeling_nps_score import NPSScoreModel, NPSScoreConfig | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| from huggingface_hub import login | |
| import os | |
| # ─── Auth HuggingFace ───────────────────────────────────────────────────────── | |
| hf_token = os.getenv("HF_TOKEN") | |
| if hf_token: | |
| login(token=hf_token) | |
| # ─── Config ────────────────────────────────────────────────────────────────── | |
| SCORE_REPO = os.getenv("SCORE_REPO", "nada-05/nps-score-xlm-roberta") | |
| DESC_REPO = os.getenv("DESC_REPO", "nada-05/nps-description-generator-mt5") | |
| API_SECRET = os.getenv("API_SECRET", "") # Secret à définir dans le Space HF | |
| DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| # ─── Seuils NPS ────────────────────────────────────────────────────────────── | |
| def score_to_segment(score: float) -> str: | |
| s = round(score) | |
| if s <= 6: return "Detractor" | |
| if s <= 8: return "Passive" | |
| return "Promoter" | |
| def estimate_urgency(ai_score: float, clicked_score: float, comments: str) -> str: | |
| avg = (ai_score + clicked_score) / 2 | |
| txt = comments.lower() | |
| urgent_kw = [ | |
| "humiliant", "ignoré", "abîmé", "défectueux", "menti", "retour impossible", | |
| "damaged", "refused", "40 minutes", "30 minutes", "waiting" | |
| ] | |
| has_urgent = any(k in txt for k in urgent_kw) | |
| if avg <= 3 or (avg <= 5 and has_urgent): return "high" | |
| if avg <= 5: return "medium" | |
| if avg <= 6 and has_urgent: return "medium" | |
| if avg <= 6: return "low" | |
| return "none" | |
| # ─── Nettoyage texte ───────────────────────────────────────────────────────── | |
| def clean_text(text: str) -> str: | |
| if not isinstance(text, str) or not text.strip(): | |
| return "" | |
| text = re.sub(r"Q\d\s*:", "", text) | |
| text = text.replace("|", " ") | |
| text = re.sub(r"\b(really|hm|hm!)\b", "", text, flags=re.IGNORECASE) | |
| return re.sub(r"\s+", " ", text).strip() | |
| def build_input_text(comments: str, improvements: str) -> str: | |
| c = clean_text(comments) | |
| parts = [c] if c else [] | |
| if improvements.strip(): | |
| parts.append(f"Points signalés : {improvements.strip()}") | |
| return " ".join(parts) if parts else "[aucun commentaire]" | |
| def build_desc_prompt(comments: str, improvements: str, ai_score: float, | |
| segment: str, urgency: str, language: str) -> str: | |
| c = clean_text(comments) | |
| lang_hint = f"[lang:{language}] " if language else "" | |
| prompt = ( | |
| f"generate nps description: {lang_hint}" | |
| f"score:{ai_score:.0f}/10 segment:{segment} urgency:{urgency} " | |
| f"comments: {c}" | |
| ) | |
| if improvements.strip(): | |
| prompt += f" improvements: {improvements.strip()}" | |
| return prompt | |
| # ─── État global des modèles ────────────────────────────────────────────────── | |
| models = {} | |
| async def lifespan(app: FastAPI): | |
| """Chargement des modèles au démarrage (une seule fois).""" | |
| logger.info(f"Chargement des modèles sur {DEVICE}...") | |
| logger.info(f" Score model : {SCORE_REPO}") | |
| models["score_tokenizer"] = AutoTokenizer.from_pretrained( | |
| SCORE_REPO, trust_remote_code=True | |
| ) | |
| models["score_model"] = NPSScoreModel.from_pretrained( | |
| SCORE_REPO, trust_remote_code=True | |
| ).to(DEVICE) | |
| models["score_model"].eval() | |
| logger.info(f" Description model : {DESC_REPO}") | |
| models["desc_tokenizer"] = AutoTokenizer.from_pretrained(DESC_REPO) | |
| models["desc_model"] = AutoModelForSeq2SeqLM.from_pretrained(DESC_REPO).to(DEVICE) | |
| models["desc_model"].eval() | |
| logger.info("✓ Modèles prêts") | |
| yield | |
| models.clear() | |
| # ─── App FastAPI ────────────────────────────────────────────────────────────── | |
| app = FastAPI( | |
| title="NPS AI Inference API", | |
| description="Score sentimental NPS + génération de descriptions CRM", | |
| version="1.0.0", | |
| lifespan=lifespan, | |
| ) | |
| security = HTTPBearer(auto_error=False) | |
| def verify_token(credentials: HTTPAuthorizationCredentials = Security(security)): | |
| """Vérifie le Bearer token si API_SECRET est défini.""" | |
| if API_SECRET and (not credentials or credentials.credentials != API_SECRET): | |
| raise HTTPException(status_code=401, detail="Token invalide") | |
| # ─── Schémas ───────────────────────────────────────────────────────────────── | |
| class NPSRequest(BaseModel): | |
| comments: str = Field(..., description="AllComments de NPS_Responses") | |
| improvements: str = Field("", description="Improvements de NPS_Responses") | |
| score_nps: float = Field(5.0, ge=0, le=10, description="Score cliqué par le client") | |
| language: str = Field("fr", description="'fr' ou 'en'") | |
| class NPSResponse(BaseModel): | |
| # Score | |
| score_nps_clicked: float | |
| segment_clicked: str | |
| ai_score: float | |
| segment_ai: str | |
| # CRM | |
| description: str # Texte complet pour DE_Tasks.Description (≤ 4000 chars) | |
| ai_reason: str # Version courte pour DE_Tasks.AIReason (≤ 48 chars) | |
| ai_reason_full: str # Version longue pour NPS_CloseTheLoop_History.AIReason (≤ 498 chars) | |
| urgency_ai: str # high / medium / low / none | |
| needs_task: bool # Recommandation IA de créer une tâche | |
| # Méta | |
| model_version: str = "xlm-roberta-v1" | |
| # ─── Endpoints ─────────────────────────────────────────────────────────────── | |
| def health(): | |
| return { | |
| "status": "ok", | |
| "models_loaded": list(models.keys()), | |
| "device": str(DEVICE), | |
| "score_repo": SCORE_REPO, | |
| "desc_repo": DESC_REPO, | |
| } | |
| def predict(req: NPSRequest, _=Security(verify_token)): | |
| if not models: | |
| raise HTTPException(status_code=503, detail="Modèles non chargés") | |
| # ── 1. Score IA ───────────────────────────────────────────── | |
| input_text = build_input_text(req.comments, req.improvements) | |
| enc = models["score_tokenizer"]( | |
| input_text, return_tensors="pt", | |
| max_length=256, truncation=True, padding="max_length", | |
| ).to(DEVICE) | |
| with torch.no_grad(): | |
| out = models["score_model"](**enc) | |
| ai_score = round(float(out.logits.item()) * 10, 1) | |
| ai_score = max(0.0, min(10.0, ai_score)) | |
| segment_ai = score_to_segment(ai_score) | |
| seg_clicked = score_to_segment(req.score_nps) | |
| urgency_ai = estimate_urgency(ai_score, req.score_nps, req.comments) | |
| needs_task = segment_ai == "Detractor" | |
| # ── 2. Description (seulement si détracteur IA) ───────────── | |
| description = "" | |
| if needs_task: | |
| prompt = build_desc_prompt( | |
| req.comments, req.improvements, | |
| ai_score, segment_ai, urgency_ai, req.language | |
| ) | |
| enc_desc = models["desc_tokenizer"]( | |
| prompt, return_tensors="pt", max_length=256, truncation=True | |
| ).to(DEVICE) | |
| with torch.no_grad(): | |
| out_desc = models["desc_model"].generate( | |
| **enc_desc, | |
| max_new_tokens=200, | |
| num_beams=4, | |
| no_repeat_ngram_size=3, | |
| early_stopping=True, | |
| ) | |
| description = models["desc_tokenizer"].decode(out_desc[0], skip_special_tokens=True) | |
| # ── 3. Versions tronquées pour les DE ──────────────────────── | |
| ai_reason = description[:48] if description else "" # DE_Tasks.AIReason (50) | |
| ai_reason_full = description[:498] if description else "" # History.AIReason (500) | |
| return NPSResponse( | |
| score_nps_clicked = req.score_nps, | |
| segment_clicked = seg_clicked, | |
| ai_score = ai_score, | |
| segment_ai = segment_ai, | |
| description = description[:4000], | |
| ai_reason = ai_reason, | |
| ai_reason_full = ai_reason_full, | |
| urgency_ai = urgency_ai, | |
| needs_task = needs_task, | |
| ) | |
| # ─── Lancement local ───────────────────────────────────────────────────────── | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False) | |