| |
| """ |
| Verbatify — Analyse sémantique NPS (Paste-only, NPS inféré) |
| Interface simplifiée : toutes les options sont appliquées automatiquement. |
| """ |
|
|
| import os, re, json, collections, tempfile, zipfile |
| from typing import List, Dict, Optional |
| import pandas as pd |
| import gradio as gr |
| import plotly.express as px |
| import plotly.graph_objects as go |
| import plotly.io as pio |
|
|
| |
|
|
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) |
| CSS_FILE = os.path.join(BASE_DIR, "verbatim.css") |
|
|
| VB_CSS_FALLBACK = r""" |
| @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;700;800&display=swap'); |
| :root{--vb-bg:#F8FAFC;--vb-text:#0F172A;--vb-primary:#7C3AED;--vb-primary-2:#06B6D4;--vb-border:#E2E8F0;} |
| *{color-scheme:light !important} |
| html,body,.gradio-container{background:var(--vb-bg)!important;color:var(--vb-text)!important; |
| font-family:Manrope,system-ui,-apple-system,'Segoe UI',Roboto,Arial,sans-serif!important} |
| .gradio-container{max-width:1120px!important;margin:0 auto!important} |
| .vb-hero{display:flex;align-items:center;gap:16px;padding:20px 22px;margin:10px 0 20px; |
| background:linear-gradient(90deg,rgba(124,58,237,.18),rgba(6,182,212,.18));border:1px solid var(--vb-border); |
| border-radius:14px;box-shadow:0 10px 26px rgba(2,6,23,.08)} |
| .vb-hero .vb-title{font-size:22px;color:#0F172A;font-weight:500} |
| .vb-hero .vb-sub{font-size:13px;color:#0F172A} |
| .gradio-container .vb-cta{background:linear-gradient(90deg,var(--vb-primary),var(--vb-primary-2))!important;color:#fff!important; |
| border:0!important;font-weight:700!important;font-size:16px!important;padding:14px 18px!important;border-radius:14px!important; |
| box-shadow:0 10px 24px rgba(124,58,237,.28)} |
| .gradio-container .vb-cta:hover{transform:translateY(-2px);filter:brightness(1.05)} |
| /* Patch encarts vides & texte noir partout */ |
| .gradio-container .empty, |
| .gradio-container [class*="unpadded_box"], |
| .gradio-container [class*="unpadded-box"], |
| .gradio-container .empty[class*="box"]{background:#FFFFFF!important;background-image:none!important;border:1px solid transparent!important;box-shadow:none!important} |
| .gradio-container .empty *, .gradio-container [class*="unpadded_box"] *{color:#0F172A!important;fill:#0F172A!important} |
| """ |
|
|
| VB_CSS = None |
| try: |
| if os.path.exists(CSS_FILE): |
| with open(CSS_FILE, "r", encoding="utf-8") as f: |
| VB_CSS = f.read() |
| except Exception: |
| VB_CSS = None |
| if not VB_CSS: |
| VB_CSS = VB_CSS_FALLBACK |
|
|
| |
|
|
| def apply_plotly_theme(): |
| pio.templates["verbatify"] = go.layout.Template( |
| layout=go.Layout( |
| font=dict(family="Manrope, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif", |
| size=13, color="#0F172A"), |
| paper_bgcolor="white", plot_bgcolor="white", |
| colorway=["#7C3AED","#06B6D4","#2563EB","#10B981","#A855F7","#22D3EE","#1D4ED8","#0EA5E9"], |
| xaxis=dict(gridcolor="#E2E8F0", zerolinecolor="#E2E8F0"), |
| yaxis=dict(gridcolor="#E2E8F0", zerolinecolor="#E2E8F0"), |
| legend=dict(borderwidth=0, bgcolor="rgba(255,255,255,0)") |
| ) |
| ) |
| pio.templates.default = "verbatify" |
|
|
| LOGO_SVG = """<svg xmlns='http://www.w3.org/2000/svg' width='224' height='38' viewBox='0 0 224 38'> |
| <defs><linearGradient id='g' x1='0%' y1='0%' x2='100%'><stop offset='0%' stop-color='#7C3AED'/><stop offset='100%' stop-color='#06B6D4'/></linearGradient></defs> |
| <g fill='none' fill-rule='evenodd'> |
| <rect x='0' y='7' width='38' height='24' rx='12' fill='url(#g)'/> |
| <circle cx='13' cy='19' r='5' fill='#fff' opacity='0.95'/><circle cx='25' cy='19' r='5' fill='#fff' opacity='0.72'/> |
| <text x='46' y='25' font-family='Manrope, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' font-size='20' font-weight='800' fill='#0F172A' letter-spacing='0.2'>Verbatify</text> |
| </g> |
| </svg>""" |
|
|
| |
|
|
| try: |
| from unidecode import unidecode |
| except Exception: |
| import unicodedata |
| def unidecode(x): |
| try: |
| return unicodedata.normalize('NFKD', str(x)).encode('ascii','ignore').decode('ascii') |
| except Exception: |
| return str(x) |
|
|
| |
|
|
| THEMES = { |
| "Remboursements santé":[r"\bremboursement[s]?\b", r"\bt[eé]l[eé]transmission\b", r"\bno[eé]mie\b", |
| r"\bprise\s*en\s*charge[s]?\b", r"\btaux\s+de\s+remboursement[s]?\b", r"\b(ameli|cpam)\b", |
| r"\bcompl[eé]mentaire\s+sant[eé]\b", r"\bmutuelle\b", r"\battestation[s]?\b", r"\bcarte\s+(mutuelle|tiers\s*payant)\b"], |
| "Tiers payant / Réseau de soins":[r"\btiers\s*payant\b", r"\br[ée]seau[x]?\s+de\s+soins\b", |
| r"\b(optique|dentaire|hospitalisation|pharmacie)\b", r"\bitelis\b", r"\bsant[eé]clair\b", r"\bkalixia\b"], |
| "Sinistres / Indemnisation":[r"\bsinistre[s]?\b", r"\bindemni(sation|ser)\b", r"\bexpertis[ea]\b", |
| r"\bd[eé]claration\s+de\s+sinistre\b", r"\bconstat\b", r"\bbris\s+de\s+glace\b", r"\bassistance\b", r"\bd[ée]pannage\b"], |
| "Adhésion / Contrat":[r"\badh[eé]sion[s]?\b", r"\bsouscription[s]?\b", r"\baffiliation[s]?\b", r"\bcontrat[s]?\b", |
| r"\bavenant[s]?\b", r"\bcarence[s]?\b", r"\brenouvellement[s]?\b", r"\br[eé]siliation[s]?\b"], |
| "Garanties / Exclusions / Franchise":[r"\bgarantie[s]?\b", r"\bexclusion[s]?\b", r"\bplafond[s]?\b", |
| r"\bfranchise[s]?\b", r"\bconditions\s+g[eé]n[eé]rales\b", r"\bnotice\b"], |
| "Cotisations / Facturation":[r"\bcotisation[s]?\b", r"\bpr[eé]l[eè]vement[s]?\b", r"\bech[eé]ancier[s]?\b", |
| r"\bfacture[s]?\b", r"\berreur[s]?\s+de\s+facturation\b", r"\bremboursement[s]?\b", r"\bRIB\b", r"\bIBAN\b"], |
| "Délais & Suivi dossier":[r"\bd[eé]lai[s]?\b", r"\btraitement[s]?\b", r"\bsuivi[s]?\b", r"\brelance[s]?\b", r"\bretard[s]?\b"], |
| "Espace client / App / Connexion":[r"\bespace\s+client\b", r"\bapplication\b", r"\bapp\b", r"\bsite\b", |
| r"\bconnexion\b", r"\bidentifiant[s]?\b", r"\bmot\s+de\s+passe\b", r"\bpaiement\s+en\s+ligne\b", |
| r"\bbogue[s]?\b", r"\bbug[s]?\b", r"\bnavigation\b", r"\binterface\b", r"\bUX\b"], |
| "Support / Conseiller":[r"\bSAV\b", r"\bservice[s]?\s+client[s]?\b", r"\bconseiller[s]?\b", |
| r"\b[rR][eé]ponse[s]?\b", r"\bjoignable[s]?\b", r"\brapp?el\b"], |
| "Communication / Transparence":[r"\binformation[s]?\b", r"\bcommunication\b", r"\btransparence\b", |
| r"\bclart[eé]\b", r"\bcourrier[s]?\b", r"\bmail[s]?\b", r"\bnotification[s]?\b"], |
| "Prix":[r"\bprix\b", r"\bcher[s]?\b", r"\bco[uû]t[s]?\b", r"\btarif[s]?\b", |
| r"\bcomp[eé]titif[s]?\b", r"\babusif[s]?\b", r"\bbon\s+rapport\s+qualit[eé]\s*prix\b"], |
| "Offre / Gamme":[r"\boffre[s]?\b", r"\bgamme[s]?\b", r"\bdisponibilit[eé][s]?\b", r"\bdevis\b", r"\bchoix\b", r"\bcatalogue[s]?\b"], |
| "Produit/Qualité":[r"\bqualit[eé]s?\b", r"\bfiable[s]?\b", r"\bconforme[s]?\b", r"\bnon\s+conforme[s]?\b", |
| r"\bd[eé]fectueux?[es]?\b", r"\bperformant[e]?[s]?\b"], |
| "Agence / Accueil":[r"\bagence[s]?\b", r"\bboutique[s]?\b", r"\baccueil\b", r"\bconseil[s]?\b", r"\battente\b", r"\bcaisse[s]?\b"], |
| } |
|
|
| |
|
|
| POS_WORDS = { |
| "bien":1.0,"super":1.2,"parfait":1.4,"excellent":1.5,"ravi":1.2,"satisfait":1.0, |
| "rapide":0.8,"efficace":1.0,"fiable":1.0,"simple":0.8,"facile":0.8,"clair":0.8,"conforme":0.8, |
| "sympa":0.8,"professionnel":1.0,"réactif":1.0,"reactif":1.0,"compétent":1.0,"competent":1.0, |
| "top":1.2,"recommande":1.2,"recommandé":1.2,"bon":0.8 |
| } |
| NEG_WORDS = { |
| "mauvais":-1.2,"horrible":-1.5,"nul":-1.2,"lent":-0.8,"cher":-0.9,"arnaque":-1.5, |
| "déçu":-1.2,"decu":-1.2,"incompétent":-1.3,"bug":-0.9,"bogue":-0.9,"problème":-1.0, |
| "probleme":-1.0,"attente":-0.6,"retard":-0.9,"erreur":-1.0,"compliqué":-0.8,"complique":-0.8, |
| "défectueux":-1.3,"defectueux":-1.3,"non conforme":-1.2,"impossible":-1.0,"difficile":-0.7 |
| } |
| NEGATIONS = [r"\bpas\b", r"\bjamais\b", r"\bplus\b", r"\baucun[e]?\b", r"\brien\b", r"\bni\b", r"\bgu[eè]re\b"] |
| INTENSIFIERS = [r"\btr[eè]s\b", r"\bvraiment\b", r"\bextr[eê]mement\b", r"\bhyper\b"] |
| DIMINISHERS = [r"\bun[e]?\s+peu\b", r"\bassez\b", r"\bplut[oô]t\b", r"\bl[eé]g[eè]rement\b"] |
| INTENSIFIER_W, DIMINISHER_W = 1.5, 0.7 |
|
|
| |
|
|
| OPENAI_AVAILABLE = False |
| try: |
| from openai import OpenAI |
| if os.getenv("OPENAI_API_KEY"): |
| _client = OpenAI() |
| OPENAI_AVAILABLE = True |
| except Exception: |
| OPENAI_AVAILABLE = False |
|
|
| OA_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") |
| OA_TEMP = float(os.getenv("OPENAI_TEMP", "0.1")) |
| TOP_K = int(os.getenv("VERBATIFY_TOPK", "10")) |
|
|
| |
|
|
| def normalize(t:str)->str: |
| if not isinstance(t,str): return "" |
| return re.sub(r"\s+"," ",t.strip()) |
|
|
| def to_analyzable(t:str)->str: |
| return unidecode(normalize(t.lower())) |
|
|
| def window_has(patterns:List[str], toks:List[str], i:int, w:int=3)->bool: |
| s=max(0,i-w); e=min(len(toks),i+w+1); win=" ".join(toks[s:e]) |
| return any(re.search(p,win) for p in patterns) |
|
|
| def lexical_sentiment_score(text:str)->float: |
| toks = to_analyzable(text).split(); score=0.0 |
| for i,t in enumerate(toks): |
| base = POS_WORDS.get(t,0.0) or NEG_WORDS.get(t,0.0) |
| if not base and i<len(toks)-1: |
| bi=f"{t} {toks[i+1]}"; base = NEG_WORDS.get(bi,0.0) |
| if base: |
| w=1.0 |
| if window_has(INTENSIFIERS,toks,i): w*=INTENSIFIER_W |
| if window_has(DIMINISHERS,toks,i): w*=DIMINISHER_W |
| if window_has(NEGATIONS,toks,i): base*=-1 |
| score+=base*w |
| return max(min(score,4.0),-4.0) |
|
|
| def lexical_sentiment_label(s:float)->str: |
| return "positive" if s>=0.3 else ("negatif" if s<=-0.3 else "neutre") |
|
|
| def detect_themes_regex(text:str): |
| t=to_analyzable(text); counts={} |
| for th,pats in THEMES.items(): |
| c=sum(len(re.findall(p,t)) for p in pats) |
| if c>0: counts[th]=c |
| return list(counts.keys()), counts |
|
|
| def nps_bucket(s): |
| try: |
| v=int(s) |
| except: |
| return "inconnu" |
| return "promoter" if v>=9 else ("passive" if v>=7 else ("detractor" if v>=0 else "inconnu")) |
|
|
| def compute_nps(series): |
| vals=[] |
| for x in series.dropna().tolist(): |
| try: |
| v=int(x) |
| if 0<=v<=10: vals.append(v) |
| except: pass |
| if not vals: return None |
| tot=len(vals); pro=sum(1 for v in vals if v>=9); det=sum(1 for v in vals if v<=6) |
| return 100.0*(pro/tot - det/tot) |
|
|
| def anonymize(t:str)->str: |
| if not isinstance(t,str): return "" |
| t=re.sub(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}","[email]",t) |
| t=re.sub(r"\b(?:\+?\d[\s.-]?){7,}\b","[tel]",t) |
| return t |
|
|
| |
| def df_from_pasted_auto(text:str) -> pd.DataFrame: |
| lines = [l.strip() for l in (text or "").splitlines() if l.strip()] |
| rows = [] |
| pat = re.compile(r"\|\s*(-?\d{1,2})\s*$") |
| for i, line in enumerate(lines, 1): |
| m = pat.search(line) |
| if m: |
| verb = line[:m.start()].strip() |
| score = m.group(1) |
| rows.append({"id": i, "comment": verb, "nps_score": pd.to_numeric(score, errors="coerce")}) |
| else: |
| rows.append({"id": i, "comment": line, "nps_score": None}) |
| return pd.DataFrame(rows) |
|
|
| |
| def openai_json(model:str, system:str, user:str, temperature:float=0.0) -> Optional[dict]: |
| if not OPENAI_AVAILABLE: return None |
| try: |
| resp = _client.chat.completions.create( |
| model=model, temperature=temperature, |
| messages=[{"role":"system","content":system},{"role":"user","content":user}], |
| ) |
| txt = resp.choices[0].message.content.strip() |
| m = re.search(r"\{.*\}", txt, re.S) |
| return json.loads(m.group(0) if m else txt) |
| except Exception: |
| return None |
|
|
| def oa_sentiment(comment:str) -> Optional[dict]: |
| system = "Tu es un classifieur FR. Réponds strictement en JSON." |
| user = f'Texte: {comment}\nDonne "label" parmi ["positive","neutre","negatif"] et "score" entre -4 et 4. JSON.' |
| return openai_json(OA_MODEL, system, user, OA_TEMP) |
|
|
| def oa_themes(comment:str) -> Optional[dict]: |
| system = "Tu maps le texte client vers un thésaurus assurance. Réponds strictement en JSON." |
| user = f"Texte: {comment}\nThésaurus: {json.dumps(list(THEMES.keys()), ensure_ascii=False)}\nRetourne {{'themes': [...], 'counts': {{...}}}}" |
| return openai_json(OA_MODEL, system, user, OA_TEMP) |
|
|
| def oa_summary(nps:Optional[float], dist:Dict[str,int], themes_df:pd.DataFrame) -> Optional[str]: |
| system = "Tu es un analyste CX FR. Donne une synthèse courte et actionnable en Markdown." |
| top = [] if themes_df is None else themes_df.head(6).to_dict(orient="records") |
| user = f"Données: NPS={None if nps is None else round(nps,1)}, Répartition={dist}, Thèmes={json.dumps(top, ensure_ascii=False)}" |
| j = openai_json(OA_MODEL, system, user, 0.2) |
| if isinstance(j, dict) and "text" in j: return j["text"] |
| if isinstance(j, dict): return ' '.join(str(v) for v in j.values()) |
| return None |
|
|
| |
| def make_hf_pipe(): |
| try: |
| from transformers import pipeline |
| return pipeline("text-classification", |
| model="cmarkea/distilcamembert-base-sentiment", |
| tokenizer="cmarkea/distilcamembert-base-sentiment") |
| except Exception: |
| return None |
|
|
| |
| def infer_nps_from_sentiment(label: str, score: float) -> int: |
| scaled = int(round((float(score) + 4.0) * 1.25)) |
| scaled = max(0, min(10, scaled)) |
| if label == "positive": |
| return max(9, scaled) |
| if label == "negatif": |
| return min(6, scaled) |
| return 8 if score >= 0 else 7 |
|
|
| |
|
|
| def fig_nps_gauge(nps: Optional[float]) -> go.Figure: |
| v = 0.0 if nps is None else float(nps) |
| return go.Figure(go.Indicator(mode="gauge+number", value=v, |
| gauge={"axis":{"range":[-100,100]}, "bar":{"thickness":0.3}}, |
| title={"text":"NPS (−100 à +100)"})) |
|
|
| def fig_sentiment_bar(dist: Dict[str,int]) -> go.Figure: |
| order = ["negatif","neutre","positive"] |
| x = [o for o in order if o in dist]; y = [dist.get(o,0) for o in x] |
| return px.bar(x=x, y=y, labels={"x":"Sentiment","y":"Nombre"}, title="Répartition des émotions") |
|
|
| def fig_top_themes(themes_df: pd.DataFrame, k: int) -> go.Figure: |
| if themes_df is None or themes_df.empty: return go.Figure() |
| d = themes_df.head(k); fig = px.bar(d, x="theme", y="total_mentions", title=f"Top {k} thèmes — occurrences") |
| fig.update_layout(xaxis_tickangle=-30); return fig |
|
|
| def fig_theme_balance(themes_df: pd.DataFrame, k: int) -> go.Figure: |
| if themes_df is None or themes_df.empty: return go.Figure() |
| d = themes_df.head(k) |
| d2 = d.melt(id_vars=["theme"], value_vars=["verbatims_pos","verbatims_neg"], var_name="type", value_name="count") |
| d2["type"] = d2["type"].map({"verbatims_pos":"Positifs","verbatims_neg":"Négatifs"}) |
| fig = px.bar(d2, x="theme", y="count", color="type", barmode="stack", title=f"Top {k} thèmes — balance Pos/Neg") |
| fig.update_layout(xaxis_tickangle=-30); return fig |
|
|
| |
|
|
| def analyze_text(pasted_txt: str): |
| |
| df = df_from_pasted_auto(pasted_txt or "") |
| if df.empty: |
| raise gr.Error("Colle au moins un verbatim (une ligne).") |
| df["comment"] = df["comment"].apply(anonymize) |
|
|
| |
| use_oa_sent = use_oa_themes = use_oa_summary = True |
| if not OPENAI_AVAILABLE: |
| use_oa_sent = use_oa_themes = use_oa_summary = False |
| hf_pipe = make_hf_pipe() |
|
|
| |
| rows=[]; theme_agg=collections.defaultdict(lambda:{"mentions":0,"pos":0,"neg":0}) |
| used_hf=False; used_oa=False; any_inferred=False |
|
|
| for idx, r in df.iterrows(): |
| cid=r.get("id", idx+1); comment=normalize(str(r["comment"])) |
|
|
| |
| sent=None |
| if use_oa_sent: |
| sent=oa_sentiment(comment); used_oa = used_oa or bool(sent) |
| if not sent and hf_pipe is not None and comment.strip(): |
| try: |
| res=hf_pipe(comment); lab=str(res[0]["label"]).lower(); p=float(res[0].get("score",0.5)) |
| if "1" in lab or "2" in lab: sent = {"label":"negatif","score":-4*p} |
| elif "3" in lab: sent = {"label":"neutre","score":0.0} |
| else: sent = {"label":"positive","score":4*p} |
| used_hf=True |
| except Exception: |
| sent=None |
| if not sent: |
| s=float(lexical_sentiment_score(comment)) |
| sent={"label":lexical_sentiment_label(s),"score":s} |
|
|
| |
| themes, counts = detect_themes_regex(comment) |
| if use_oa_themes: |
| tjson=oa_themes(comment) |
| if isinstance(tjson, dict): |
| used_oa=True |
| for th, c in (tjson.get("counts",{}) or {}).items(): |
| if th in THEMES and int(c) > 0: |
| counts[th] = max(counts.get(th, 0), int(c)) |
| themes = [th for th, c in counts.items() if c > 0] |
|
|
| |
| given = r.get("nps_score", None) |
| try: |
| given = int(given) if given is not None and str(given).strip() != "" else None |
| except Exception: |
| given = None |
|
|
| if given is None: |
| inferred = infer_nps_from_sentiment(sent["label"], float(sent["score"])) |
| nps_final, nps_source, any_inferred = inferred, "inferred", True |
| else: |
| nps_final, nps_source = given, "given" |
|
|
| bucket = nps_bucket(nps_final) |
|
|
| for th, c in counts.items(): |
| theme_agg[th]["mentions"] += c |
| if sent["label"] == "positive": theme_agg[th]["pos"] += 1 |
| elif sent["label"] == "negatif": theme_agg[th]["neg"] += 1 |
|
|
| rows.append({ |
| "id": cid, "comment": comment, |
| "nps_score_given": given, "nps_score_inferred": nps_final if given is None else None, |
| "nps_score_final": nps_final, "nps_source": nps_source, "nps_bucket": bucket, |
| "sentiment_score": round(float(sent["score"]), 3), "sentiment_label": sent["label"], |
| "sentiment_source": "openai" if (use_oa_sent and used_oa) else ("huggingface" if used_hf else "rules"), |
| "themes": ", ".join(themes) if themes else "", "theme_counts_json": json.dumps(counts, ensure_ascii=False) |
| }) |
|
|
| out_df=pd.DataFrame(rows) |
| nps=compute_nps(out_df["nps_score_final"]) |
| dist=out_df["sentiment_label"].value_counts().to_dict() |
|
|
| |
| trs=[] |
| for th, d in theme_agg.items(): |
| trs.append({"theme":th,"total_mentions":int(d["mentions"]), |
| "verbatims_pos":int(d["pos"]),"verbatims_neg":int(d["neg"]), |
| "net_sentiment":int(d["pos"]-d["neg"])}) |
| themes_df=pd.DataFrame(trs).sort_values(["total_mentions","net_sentiment"],ascending=[False,False]) |
|
|
| |
| method = "OpenAI + HF + règles" if (OPENAI_AVAILABLE and used_hf) else ("OpenAI + règles" if OPENAI_AVAILABLE else ("HF + règles" if used_hf else "Règles")) |
| nps_label = "NPS global (inféré)" if any_inferred else "NPS global" |
| lines=[ "# Synthèse NPS & ressentis clients", |
| f"- **Méthode** : {method}", |
| f"- **{nps_label}** : {nps:.1f}" if nps is not None else f"- **{nps_label}** : n/a" ] |
| if dist: |
| tot=sum(dist.values()); pos=dist.get("positive",0); neg=dist.get("negatif",0); neu=dist.get("neutre",0) |
| lines.append(f"- **Répartition émotions** : positive {pos}/{tot}, neutre {neu}/{tot}, négative {neg}/{tot}") |
| if not themes_df.empty: |
| lines.append("\n## Thèmes les plus cités") |
| for th,m in themes_df.head(5)[["theme","total_mentions"]].values.tolist(): |
| lines.append(f"- **{th}** : {m} occurrence(s)") |
| summary_md="\n".join(lines) |
|
|
| if OPENAI_AVAILABLE: |
| md = oa_summary(nps, dist, themes_df) |
| if md: summary_md = md + "\n\n---\n" + summary_md |
|
|
| |
| tmpdir=tempfile.mkdtemp(prefix="nps_gradio_") |
| enriched=os.path.join(tmpdir,"enriched_comments.csv") |
| themes=os.path.join(tmpdir,"themes_stats.csv") |
| summ=os.path.join(tmpdir,"summary.md") |
| out_df.to_csv(enriched,index=False,encoding="utf-8-sig") |
| themes_df.to_csv(themes,index=False,encoding="utf-8-sig") |
| with open(summ,"w",encoding="utf-8") as f: f.write(summary_md) |
| zip_path=os.path.join(tmpdir,"nps_outputs.zip") |
| with zipfile.ZipFile(zip_path,"w",zipfile.ZIP_DEFLATED) as z: |
| z.write(enriched,arcname="enriched_comments.csv") |
| z.write(themes,arcname="themes_stats.csv") |
| z.write(summ,arcname="summary.md") |
|
|
| |
| def make_panels(dfT: pd.DataFrame): |
| if dfT is None or dfT.empty: return "—","—","—" |
| pos_top = dfT.sort_values(["verbatims_pos","total_mentions"], ascending=[False,False]).head(4) |
| neg_top = dfT.sort_values(["verbatims_neg","total_mentions"], ascending=[False,False]).head(4) |
| def bullets(df, col, label): |
| lines=[f"**{label}**"] |
| for _, r in df.iterrows(): lines.append(f"- **{r['theme']}** — {int(r[col])} verbatims") |
| return "\n".join(lines) |
| ench_md = bullets(pos_top, "verbatims_pos", "Points d’enchantement") |
| irr_md = bullets(neg_top, "verbatims_neg", "Irritants") |
| RECO_RULES = { |
| "Délais & Suivi dossier": "Réduire les délais (SLA), suivi proactif.", |
| "Cotisations / Facturation": "Clarifier factures, alerter anomalies.", |
| "Espace client / App / Connexion": "Corriger login/MDP, QA navigateurs.", |
| "Support / Conseiller": "Améliorer joignabilité, scripts, rappel auto.", |
| "Communication / Transparence": "Notifications étapes clés, messages clairs.", |
| "Sinistres / Indemnisation": "Transparence délais + suivi dossier.", |
| } |
| rec_lines=["**Recommandations**"] |
| for _, r in neg_top.iterrows(): |
| rec_lines.append(f"- **{r['theme']}** — {RECO_RULES.get(r['theme'],'Plan d’action dédié')}") |
| return ench_md, irr_md, "\n".join(rec_lines) |
|
|
| ench_md, irr_md, reco_md = make_panels(themes_df) |
| fig_gauge = fig_nps_gauge(nps) |
| fig_emots = fig_sentiment_bar(dist) |
| k = max(1, int(TOP_K)) |
| fig_top = fig_top_themes(themes_df, k) |
| fig_bal = fig_theme_balance(themes_df, k) |
|
|
| return (summary_md, themes_df.head(100), out_df.head(200), [enriched, themes, summ, zip_path], |
| ench_md, irr_md, reco_md, fig_gauge, fig_emots, fig_top, fig_bal) |
|
|
| |
|
|
| apply_plotly_theme() |
|
|
| with gr.Blocks(title="Verbatify, révélez la voix de vos assurés, simplement...", css=VB_CSS) as demo: |
| |
| gr.HTML( |
| "<div class='vb-hero'>" |
| f"{LOGO_SVG}" |
| "<div><div class='vb-title'>Verbatify, révélez la voix de vos assurés, simplement...</div>" |
| "<div class='vb-sub'>Émotions • Thématiques • Occurrences • Synthèse • NPS</div></div>" |
| "</div>" |
| ) |
|
|
| |
| with gr.Row(): |
| pasted = gr.Textbox( |
| label="Verbatims (un par ligne)", lines=10, |
| placeholder="Exemple :\nRemboursement rapide, télétransmission OK | 10\nImpossible de joindre un conseiller | 3\nEspace client : bug à la connexion | 4", |
| scale=4 |
| ) |
| |
| with gr.Column(elem_id="vb-cta-cell", scale=1): |
| run = gr.Button("Lancer l'analyse", elem_classes=["vb-cta"]) |
|
|
| |
| with gr.Row(): |
| ench_panel=gr.Markdown() |
| irr_panel=gr.Markdown() |
| reco_panel=gr.Markdown() |
|
|
| |
| summary=gr.Markdown(label="Synthèse NPS & ressentis clients") |
| themes_table=gr.Dataframe(label="Thèmes — statistiques") |
| enriched_table=gr.Dataframe(label="Verbatims enrichis (aperçu)") |
| files_out=gr.Files(label="Téléchargements (CSV & ZIP)") |
|
|
| |
| with gr.Row(): |
| plot_nps = gr.Plot(label="NPS — Jauge") |
| plot_sent= gr.Plot(label="Répartition des émotions") |
| with gr.Row(): |
| plot_top = gr.Plot(label="Top thèmes — occurrences") |
| plot_bal = gr.Plot(label="Top thèmes — balance Pos/Neg") |
|
|
| |
| run.click( |
| analyze_text, |
| inputs=[pasted], |
| outputs=[summary, themes_table, enriched_table, files_out, |
| ench_panel, irr_panel, reco_panel, |
| plot_nps, plot_sent, plot_top, plot_bal] |
| ) |
|
|
| gr.HTML( |
| '<div class="vb-footer">© Verbatify.com — Construit par ' |
| '<a href="https://jeremy-lagache.fr/" target="_blank" rel="dofollow">Jérémy Lagache</a></div>' |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch(share=False, show_api=False) |
|
|