JerLag commited on
Commit
af97b36
·
verified ·
1 Parent(s): bc06a2e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +186 -156
app.py CHANGED
@@ -1,6 +1,10 @@
1
  # -*- coding: utf-8 -*-
2
  """
3
  Verbatify — Analyse sémantique NPS (Paste-only, NPS inféré)
 
 
 
 
4
  """
5
 
6
  import os, re, json, collections, tempfile, zipfile
@@ -11,172 +15,194 @@ import plotly.express as px
11
  import plotly.graph_objects as go
12
  import plotly.io as pio
13
 
14
- # ====================== BRANDING (CSS + PLOTLY) ======================
15
-
16
  VB_CSS = r"""
17
  @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&display=swap');
18
 
 
19
  :root{
20
- --body-background-fill:#F8FAFC;
21
- --panel-background-fill:#FFFFFF;
22
- --block-background-fill:#FFFFFF;
23
- --block-border-color:#E2E8F0;
24
- --text-color:#0F172A;
25
- --muted-text-color:#475569;
26
- --radius-lg:14px;
27
-
28
- --vb-primary:#7C3AED;
29
- --vb-primary-2:#06B6D4;
30
- --vb-border:#E2E8F0;
31
- --vb-shadow:0 10px 26px rgba(2,6,23,.08);
32
-
33
- /* force gradio accent (anti-orange) */
34
- --color-accent:#7C3AED;
35
  }
36
 
37
- * { color-scheme: light !important; }
 
38
  html,body,.gradio-container{
39
- background:#F8FAFC !important;
40
- color:var(--text-color) !important;
41
- font-family:Manrope,system-ui,-apple-system,'Segoe UI',Roboto,Arial,sans-serif !important;
42
  }
43
  .gradio-container{max-width:1120px !important;margin:0 auto !important}
44
 
45
  /* ---------- Hero ---------- */
46
  .vb-hero{
47
- display:flex;align-items:center;gap:16px;
48
- padding:20px 22px;margin:10px 0 20px;
49
- background:linear-gradient(90deg, rgba(124,58,237,.18), rgba(6,182,212,.18));
50
- border-radius:14px;box-shadow:var(--vb-shadow);
51
- border:none;
52
  }
53
- .vb-hero .vb-title{font-size:22px;font-weight:800;color:#0F172A}
54
- .vb-hero .vb-sub{color:var(--muted-text-color);font-size:13px;margin-top:-2px}
55
 
56
- /* ---------- Cartes / blocs généraux ---------- */
57
- .gradio-container .block,.gradio-container .gr-box,.gradio-container .gr-block,
58
- .gradio-container .panel,.gradio-container .row,.gradio-container .column{
59
- background:#fff !important;border:1px solid var(--vb-border) !important;
60
- border-radius:14px !important; box-shadow:var(--vb-shadow);
 
61
  }
62
 
63
- /* ---------- Labels & titres (NOIR, sans fond/bordure) ---------- */
64
- /* 1) labels de champs générés par Gradio */
65
- .gradio-container [data-testid="block-label"],
66
- .gradio-container .component .label,
67
- .gradio-container .wrap > .label{
68
- background:transparent !important;
69
- color:#0F172A !important;
70
- padding:0 0 6px 0 !important;
71
- border:none !important;
72
- box-shadow:none !important;
73
- font-weight:700 !important;
74
  }
75
 
76
- /* 2) le "block-info" (span qui contient le texte du label) */
77
- .gradio-container [data-testid="block-info"]{
78
- color:#0F172A !important;
79
- background:transparent !important;
80
- border:none !important;
81
- box-shadow:none !important;
82
- font-weight:700 !important;
 
 
83
  }
84
 
85
- /* 3) conteneur label qui ajoute une bordure par défaut chez Gradio */
86
- .gradio-container label.container.show_textbox_border{
87
- border:none !important;
88
- background:transparent !important;
89
- box-shadow:none !important;
 
 
 
 
90
  }
91
 
92
- /* ---------- ENCARTS DE SECTION (bandeau) ---------- */
93
- .vb-section{
94
- display:block; width:100%;
95
- background:linear-gradient(90deg, var(--vb-primary), var(--vb-primary-2));
96
- color:#fff; padding:12px 16px; border-radius:12px;
97
- font-weight:800; letter-spacing:.2px; box-shadow:0 10px 26px rgba(124,58,237,.22);
98
- margin:20px 0 10px 0;
99
- border:none;
100
- }
101
 
102
- /* ---------- Inputs ---------- */
103
- .gradio-container input[type="text"], .gradio-container input[type="number"],
104
- .gradio-container textarea, .gradio-container select, .gradio-container .gr-textbox,
105
- .gradio-container .gr-textbox textarea{
106
- background:#fff !important; color:var(--text-color) !important;
107
- border:1px solid var(--vb-border) !important; border-radius:10px !important;
108
- }
109
- .gradio-container input::placeholder, .gradio-container textarea::placeholder{color:#6B7280}
110
- .gradio-container input:focus, .gradio-container textarea:focus{
111
- border-color:transparent !important;
112
- box-shadow:0 0 0 2px rgba(124,58,237,.35), 0 0 0 4px rgba(6,182,212,.25) !important;
113
- }
114
 
115
- /* ---------- Checkboxes (pas d’orange) ---------- */
116
  .gradio-container input[type="checkbox"]{
117
- accent-color:var(--vb-primary) !important;
 
 
 
 
 
 
118
  }
119
- .gradio-container input[type="checkbox"]:focus-visible{
120
- outline:none; box-shadow:0 0 0 2px rgba(124,58,237,.35), 0 0 0 4px rgba(6,182,212,.25) !important;
 
 
 
 
 
 
 
 
 
 
 
121
  }
 
 
122
 
123
- /* ---------- Sliders (barre de jauge non orange) ---------- */
124
  .gradio-container input[type="range"]{
125
- height:8px !important; border-radius:999px !important;
126
- background:
127
- linear-gradient(90deg, var(--vb-primary), var(--vb-primary-2)) 0/ var(--range_progress, 0%) 100% no-repeat,
128
- #EEF2FF !important;
 
 
 
129
  }
130
- .gradio-container input[type="range"]::-webkit-slider-runnable-track{height:8px;background:transparent;border-radius:999px}
131
- .gradio-container input[type="range"]::-moz-range-track{height:8px;background:transparent;border-radius:999px}
132
  .gradio-container input[type="range"]::-webkit-slider-thumb{
133
- -webkit-appearance:none;width:18px;height:18px;border-radius:50%;
134
- background:#fff;border:2px solid var(--vb-primary);box-shadow:0 2px 10px rgba(124,58,237,.3);margin-top:-5px
 
 
 
 
 
 
 
 
135
  }
136
  .gradio-container input[type="range"]::-moz-range-thumb{
137
  width:18px;height:18px;border-radius:50%;
138
- background:#fff;border:2px solid var(--vb-primary);box-shadow:0 2px 10px rgba(124,58,237,.3);
 
139
  }
140
 
141
- /* ---------- Bouton principal ---------- */
142
- .gradio-container .vb-cta{
143
- background:linear-gradient(90deg, var(--vb-primary), var(--vb-primary-2)) !important;
144
- color:#fff !important; border:0 !important; font-weight:800 !important;
145
- padding:16px 32px !important; font-size:17px !important; min-height:52px !important;
146
- border-radius:14px !important; box-shadow:0 12px 28px rgba(124,58,237,.28);
 
 
147
  }
148
- .gradio-container .vb-cta:hover{transform:translateY(-2px);filter:brightness(1.05)}
149
 
150
- /* ---------- DataFrames / Tables : pas de bandeaux sombres ---------- */
151
- .gradio-container .table, .gradio-container .svelte-virtual-table-viewport,
152
- .gradio-container .table-wrap, .gradio-container .table *{
153
- background:#fff !important; color:#0F172A !important; border-color:#E2E8F0 !important;
 
154
  }
155
- .gradio-container .table thead, .gradio-container .table thead tr, .gradio-container .table thead th{
156
- background:linear-gradient(90deg, rgba(124,58,237,.12), rgba(6,182,212,.12)) !important;
157
- color:#0F172A !important; border-bottom:1px solid #E2E8F0 !important;
158
  }
159
- .gradio-container .header-button{background:transparent !important;color:#0F172A !important;border:none !important;box-shadow:none !important}
160
 
161
- /* ---------- Files / Placeholders : on cache les icônes (non pro) ---------- */
162
- .gradio-container .empty, .gradio-container .icon{ display:none !important; }
163
- .gradio-container [class*="unbounded"], .gradio-container [class*="unbounded_box"]{ display:none !important; }
164
-
165
- /* ---------- Plotly ---------- */
166
  .js-plotly-plot .plotly .bg{fill:#fff !important}
167
  .js-plotly-plot .plotly .xgrid,.js-plotly-plot .plotly .ygrid{stroke:#E2E8F0 !important;opacity:1}
 
 
 
 
 
 
 
 
168
 
169
- /* ---------- Footer ---------- */
170
- .vb-footer{color:#475569;font-size:12px;text-align:center;margin:16px 0}
171
  """
172
 
173
  def apply_plotly_theme():
174
  pio.templates["verbatify"] = go.layout.Template(
175
  layout=go.Layout(
176
- font=dict(family="Manrope, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
177
- size=13, color="#0F172A"),
178
  paper_bgcolor="white", plot_bgcolor="white",
179
- colorway=["#7C3AED","#06B6D4","#2563EB","#10B981","#A855F7","#22D3EE","#1D4ED8","#0EA5E9"],
180
  xaxis=dict(gridcolor="#E2E8F0", zerolinecolor="#E2E8F0"),
181
  yaxis=dict(gridcolor="#E2E8F0", zerolinecolor="#E2E8F0"),
182
  legend=dict(borderwidth=0, bgcolor="rgba(255,255,255,0)")
@@ -193,7 +219,7 @@ LOGO_SVG = """<svg xmlns='http://www.w3.org/2000/svg' width='224' height='38' vi
193
  </g>
194
  </svg>"""
195
 
196
- # ====================== UNIDECODE (fallback) ======================
197
  try:
198
  from unidecode import unidecode
199
  except Exception:
@@ -204,7 +230,7 @@ except Exception:
204
  except Exception:
205
  return str(x)
206
 
207
- # ====================== THÉSAURUS, SENTIMENT, OpenAI (identiques) ======================
208
  THEMES = {
209
  "Remboursements santé":[r"\bremboursement[s]?\b", r"\bt[eé]l[eé]transmission\b", r"\bno[eé]mie\b",
210
  r"\bprise\s*en\s*charge[s]?\b", r"\btaux\s+de\s+remboursement[s]?\b", r"\b(ameli|cpam)\b",
@@ -235,6 +261,7 @@ THEMES = {
235
  "Agence / Accueil":[r"\bagence[s]?\b", r"\bboutique[s]?\b", r"\baccueil\b", r"\bconseil[s]?\b", r"\battente\b", r"\bcaisse[s]?\b"],
236
  }
237
 
 
238
  POS_WORDS = {"bien":1.0,"super":1.2,"parfait":1.4,"excellent":1.5,"ravi":1.2,"satisfait":1.0,
239
  "rapide":0.8,"efficace":1.0,"fiable":1.0,"simple":0.8,"facile":0.8,"clair":0.8,"conforme":0.8,
240
  "sympa":0.8,"professionnel":1.0,"réactif":1.0,"reactif":1.0,"compétent":1.0,"competent":1.0,
@@ -248,14 +275,17 @@ INTENSIFIERS = [r"\btr[eè]s\b", r"\bvraiment\b", r"\bextr[eê]mement\b", r"\bhy
248
  DIMINISHERS = [r"\bun[e]?\s+peu\b", r"\bassez\b", r"\bplut[oô]t\b", r"\bl[eé]g[eè]rement\b"]
249
  INTENSIFIER_W, DIMINISHER_W = 1.5, 0.7
250
 
 
251
  OPENAI_AVAILABLE = False
252
  try:
253
- from openai import OpenAI
254
- if os.getenv("OPENAI_API_KEY"):
255
- _client = OpenAI(); OPENAI_AVAILABLE = True
 
256
  except Exception:
257
- OPENAI_AVAILABLE = False
258
 
 
259
  def normalize(t:str)->str:
260
  if not isinstance(t,str): return ""
261
  return re.sub(r"\s+"," ",t.strip())
@@ -315,6 +345,7 @@ def anonymize(t:str)->str:
315
  t=re.sub(r"\b(?:\+?\d[\s.-]?){7,}\b","[tel]",t)
316
  return t
317
 
 
318
  def df_from_pasted(text:str, sep="|", has_score=False) -> pd.DataFrame:
319
  lines = [l.strip() for l in (text or "").splitlines() if l.strip()]
320
  rows = []
@@ -326,6 +357,7 @@ def df_from_pasted(text:str, sep="|", has_score=False) -> pd.DataFrame:
326
  rows.append({"id": i, "comment": line.strip(), "nps_score": None})
327
  return pd.DataFrame(rows)
328
 
 
329
  def openai_json(model:str, system:str, user:str, temperature:float=0.0) -> Optional[dict]:
330
  if not OPENAI_AVAILABLE: return None
331
  try:
@@ -358,6 +390,7 @@ def oa_summary(nps:Optional[float], dist:Dict[str,int], themes_df:pd.DataFrame,
358
  if isinstance(j, dict): return ' '.join(str(v) for v in j.values())
359
  return None
360
 
 
361
  def make_hf_pipe():
362
  try:
363
  from transformers import pipeline
@@ -367,22 +400,22 @@ def make_hf_pipe():
367
  except Exception:
368
  return None
369
 
 
370
  def infer_nps_from_sentiment(label: str, score: float) -> int:
371
- scaled = int(round((float(score) + 4.0) * 1.25))
372
  scaled = max(0, min(10, scaled))
373
- if label == "positive": return max(9, scaled)
374
- if label == "negatif": return min(6, scaled)
 
 
375
  return 8 if score >= 0 else 7
376
 
377
  # --------- Graphiques ----------
378
  def fig_nps_gauge(nps: Optional[float]) -> go.Figure:
379
  v = 0.0 if nps is None else float(nps)
380
- return go.Figure(go.Indicator(
381
- mode="gauge+number", value=v,
382
- gauge={"axis":{"range":[-100,100]},
383
- "bar":{"thickness":0.3, "color":"#7C3AED"}}, # violet
384
- title={"text":"NPS (−100 à +100)"}
385
- ))
386
 
387
  def fig_sentiment_bar(dist: Dict[str,int]) -> go.Figure:
388
  order = ["negatif","neutre","positive"]
@@ -402,7 +435,7 @@ def fig_theme_balance(themes_df: pd.DataFrame, k: int) -> go.Figure:
402
  fig = px.bar(d2, x="theme", y="count", color="type", barmode="stack", title=f"Top {k} thèmes — balance Pos/Neg")
403
  fig.update_layout(xaxis_tickangle=-30); return fig
404
 
405
- # ====================== ANALYSE ======================
406
  def analyze_text(pasted_txt, has_sc, sep_chr,
407
  do_anonymize, use_oa_sent, use_oa_themes, use_oa_summary,
408
  oa_model, oa_temp, top_k):
@@ -414,6 +447,7 @@ def analyze_text(pasted_txt, has_sc, sep_chr,
414
  if do_anonymize:
415
  df["comment"]=df["comment"].apply(anonymize)
416
 
 
417
  if (use_oa_sent or use_oa_themes or use_oa_summary) and not OPENAI_AVAILABLE:
418
  use_oa_sent = use_oa_themes = use_oa_summary = False
419
 
@@ -434,6 +468,7 @@ def analyze_text(pasted_txt, has_sc, sep_chr,
434
  for idx, r in df.iterrows():
435
  cid=r.get("id", idx+1); comment=normalize(str(r["comment"]))
436
 
 
437
  sent=None
438
  if use_oa_sent:
439
  sent=oa_sentiment(comment, oa_model, float(oa_temp or 0.0)); used_oa = used_oa or bool(sent)
@@ -444,6 +479,7 @@ def analyze_text(pasted_txt, has_sc, sep_chr,
444
  s=float(lexical_sentiment_score(comment))
445
  sent={"label":lexical_sentiment_label(s),"score":s}
446
 
 
447
  themes, counts = detect_themes_regex(comment)
448
  if use_oa_themes:
449
  tjson=oa_themes(comment, oa_model, float(oa_temp or 0.0))
@@ -454,6 +490,7 @@ def analyze_text(pasted_txt, has_sc, sep_chr,
454
  counts[th] = max(counts.get(th, 0), int(c))
455
  themes = [th for th, c in counts.items() if c > 0]
456
 
 
457
  given = r.get("nps_score", None)
458
  try:
459
  given = int(given) if given is not None and str(given).strip() != "" else None
@@ -486,6 +523,7 @@ def analyze_text(pasted_txt, has_sc, sep_chr,
486
  nps=compute_nps(out_df["nps_score_final"])
487
  dist=out_df["sentiment_label"].value_counts().to_dict()
488
 
 
489
  trs=[]
490
  for th, d in theme_agg.items():
491
  trs.append({"theme":th,"total_mentions":int(d["mentions"]),
@@ -493,6 +531,7 @@ def analyze_text(pasted_txt, has_sc, sep_chr,
493
  "net_sentiment":int(d["pos"]-d["neg"])})
494
  themes_df=pd.DataFrame(trs).sort_values(["total_mentions","net_sentiment"],ascending=[False,False])
495
 
 
496
  method = "OpenAI + HF + règles" if (use_oa_sent and used_hf) else ("OpenAI + règles" if use_oa_sent else ("HF + règles" if used_hf else "Règles"))
497
  nps_label = "NPS global (inféré)" if any_inferred else "NPS global"
498
  lines=[ "# Synthèse NPS & ressentis clients",
@@ -559,27 +598,21 @@ def analyze_text(pasted_txt, has_sc, sep_chr,
559
  return (summary_md, themes_df.head(100), out_df.head(200), [enriched, themes, summ, zip_path],
560
  ench_md, irr_md, reco_md, fig_gauge, fig_emots, fig_top, fig_bal)
561
 
562
- # ====================== UI ======================
563
-
564
- def apply_plotly_theme_wrapper(): apply_plotly_theme()
565
- apply_plotly_theme_wrapper()
566
 
567
  with gr.Blocks(title="Verbatify — Analyse NPS", css=VB_CSS) as demo:
 
568
  gr.HTML(
569
  "<div class='vb-hero'>"
570
- """<svg xmlns='http://www.w3.org/2000/svg' width='224' height='38' viewBox='0 0 224 38'>
571
- <defs><linearGradient id='g' x1='0%' y1='0%' x2='100%'><stop offset='0%' stop-color='#7C3AED'/><stop offset='100%' stop-color='#06B6D4'/></linearGradient></defs>
572
- <g fill='none' fill-rule='evenodd'>
573
- <rect x='0' y='7' width='38' height='24' rx='12' fill='url(#g)'/>
574
- <circle cx='13' cy='19' r='5' fill='#fff' opacity='0.95'/><circle cx='25' cy='19' r='5' fill='#fff' opacity='0.72'/>
575
- <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>
576
- </g></svg>"""
577
  "<div><div class='vb-title'>Verbatify — Analyse NPS</div>"
578
  "<div class='vb-sub'>Émotions • Thématiques • Occurrences • Synthèse</div></div>"
579
  "</div>"
580
  )
581
 
582
- # ---------- Inputs ----------
 
583
  with gr.Column():
584
  pasted = gr.Textbox(
585
  label="Verbatims (un par ligne)", lines=10,
@@ -599,24 +632,24 @@ with gr.Blocks(title="Verbatify — Analyse NPS", css=VB_CSS) as demo:
599
  oa_model=gr.Textbox(label="Modèle OpenAI", value="gpt-4o-mini")
600
  oa_temp=gr.Slider(label="Température", minimum=0.0, maximum=1.0, value=0.1, step=0.1)
601
  top_k=gr.Slider(label="Top thèmes (K) pour les graphes", minimum=5, maximum=20, value=10, step=1)
602
- run=gr.Button("Lancer l'analyse", elem_classes=["vb-cta"])
603
 
604
- # ---------- Panneaux courts ----------
605
  with gr.Row():
606
  ench_panel=gr.Markdown()
607
  irr_panel=gr.Markdown()
608
  reco_panel=gr.Markdown()
609
 
610
- # ---------- Encarts + tableaux ----------
 
 
611
  gr.HTML("<div class='vb-section'>Thèmes — statistiques</div>")
612
- themes_table=gr.Dataframe(label="") # label vide, encart fait office de titre
613
 
614
  gr.HTML("<div class='vb-section'>Verbatims enrichis (aperçu)</div>")
615
- enriched_table=gr.Dataframe(label="")
616
-
617
  files_out=gr.Files(label="Téléchargements (CSV & ZIP)")
618
 
619
- # ---------- Graphes ----------
620
  gr.HTML("<div class='vb-section'>Graphiques</div>")
621
  with gr.Row():
622
  plot_nps = gr.Plot(label="NPS — Jauge")
@@ -625,11 +658,7 @@ with gr.Blocks(title="Verbatify — Analyse NPS", css=VB_CSS) as demo:
625
  plot_top = gr.Plot(label="Top thèmes — occurrences")
626
  plot_bal = gr.Plot(label="Top thèmes — balance Pos/Neg")
627
 
628
- # ---------- Synthèse ----------
629
- gr.HTML("<div class='vb-section'>Synthèse NPS & ressentis clients</div>")
630
- summary=gr.Markdown()
631
-
632
- # ---------- Action ----------
633
  run.click(
634
  analyze_text,
635
  inputs=[pasted, has_score, sep, anon, use_oa_sent, use_oa_themes, use_oa_summary, oa_model, oa_temp, top_k],
@@ -638,6 +667,7 @@ with gr.Blocks(title="Verbatify — Analyse NPS", css=VB_CSS) as demo:
638
  plot_nps, plot_sent, plot_top, plot_bal]
639
  )
640
 
 
641
  gr.HTML(
642
  '<div class="vb-footer">© Verbatify.com — Construit par '
643
  '<a href="https://jeremy-lagache.fr/" target="_blank" rel="noopener">Jérémy Lagache</a></div>'
 
1
  # -*- coding: utf-8 -*-
2
  """
3
  Verbatify — Analyse sémantique NPS (Paste-only, NPS inféré)
4
+ - Entrée : verbatims collés (1 par ligne, score NPS optionnel après |)
5
+ - Sorties : émotion, thématiques, occurrences, synthèse, graphiques Plotly + exports
6
+ - IA (facultatif) : OpenAI (robuste), fallback CamemBERT si installé, puis règles
7
+ - Branding : thème Plotly + CSS Manrope intégrés, logo inline (aucun fichier externe)
8
  """
9
 
10
  import os, re, json, collections, tempfile, zipfile
 
15
  import plotly.graph_objects as go
16
  import plotly.io as pio
17
 
18
+ # ---------------- Branding Verbatify (CSS + Plotly) ----------------
 
19
  VB_CSS = r"""
20
  @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&display=swap');
21
 
22
+ /* ---------- Palette Verbatify ---------- */
23
  :root{
24
+ --vb-bg:#F7FAFF;
25
+ --vb-card:#FFFFFF;
26
+ --vb-text:#0F172A;
27
+ --vb-muted:#475569;
28
+ --vb-primary:#7C3AED; /* violet */
29
+ --vb-primary-2:#06B6D4; /* cyan */
30
+ --vb-border:#E7EEF7; /* bordures douces */
31
+ --vb-radius:14px;
32
+ --vb-shadow:0 10px 26px rgba(2,6,23,.06);
 
 
 
 
 
 
33
  }
34
 
35
+ /* Forcer un look clair partout */
36
+ *{color-scheme:light !important}
37
  html,body,.gradio-container{
38
+ background:var(--vb-bg) !important;
39
+ color:var(--vb-text);
40
+ font-family:Manrope,system-ui,-apple-system,'Segoe UI',Roboto,Arial,sans-serif;
41
  }
42
  .gradio-container{max-width:1120px !important;margin:0 auto !important}
43
 
44
  /* ---------- Hero ---------- */
45
  .vb-hero{
46
+ display:flex;align-items:center;gap:14px;padding:16px 18px;margin:8px 0 16px;
47
+ background:linear-gradient(90deg, rgba(124,58,237,.12), rgba(6,182,212,.12));
48
+ border:1px solid var(--vb-border);border-radius:var(--vb-radius);box-shadow:var(--vb-shadow);
 
 
49
  }
50
+ .vb-title{font-size:20px;font-weight:800;letter-spacing:.2px;color:var(--vb-text)}
51
+ .vb-sub{color:var(--vb-muted);font-size:13px;margin-top:-2px}
52
 
53
+ /* ---------- Sections (encarts titres) ---------- */
54
+ .vb-section{
55
+ display:inline-block;margin:10px 0 8px;padding:8px 12px;
56
+ border-radius:999px;color:#fff;font-weight:800;font-size:13px;letter-spacing:.2px;
57
+ background:linear-gradient(90deg,var(--vb-primary),var(--vb-primary-2));
58
+ box-shadow:0 6px 18px rgba(124,58,237,.18);
59
  }
60
 
61
+ /* ---------- Cartes / panneaux : plus de bordures noires ---------- */
62
+ .gradio-container .gr-box,
63
+ .gradio-container .gr-panel,
64
+ .gradio-container .gr-accordion,
65
+ .gradio-container .gr-group,
66
+ .gradio-container .gr-form,
67
+ .gradio-container .gr-article{
68
+ background:var(--vb-card) !important;
69
+ border:1px solid var(--vb-border) !important; /* jamais noir */
70
+ border-radius:var(--vb-radius) !important;
71
+ box-shadow:var(--vb-shadow);
72
  }
73
 
74
+ /* En-têtes internes générés par Gradio/Svelte : neutraliser les bordures sombres */
75
+ .gradio-container [class*="block-title"],
76
+ .gradio-container .header,
77
+ .gradio-container .header-button,
78
+ .gradio-container span[class*="block"],
79
+ .gradio-container .container.show_textbox_border,
80
+ .gradio-container .container.show_textbox_border *{
81
+ border-color:var(--vb-border) !important;
82
+ color:var(--vb-text) !important;
83
  }
84
 
85
+ /* ---------- Labels & textes : tous en noir lisible ---------- */
86
+ .gradio-container label,
87
+ .gradio-container .label,
88
+ .gradio-container .gr-text,
89
+ .gradio-container .wrap .label,
90
+ .gradio-container .form .label,
91
+ .gradio-container .input-label,
92
+ .gradio-container .input-label *{
93
+ color:var(--vb-text) !important;
94
  }
95
 
96
+ /* Titres explicitement demandés en noir */
97
+ .gradio-container label:has(+ input),
98
+ .gradio-container span[dir="ltr"] { color:var(--vb-text) !important; }
 
 
 
 
 
 
99
 
100
+ /* Checkbox texte noir + case dégradée bouton */
101
+ .gradio-container label:has(> input[type="checkbox"]){ color:var(--vb-text) !important; border:none !important; box-shadow:none !important; background:transparent !important; }
102
+ .gradio-container label:has(> input[type="checkbox"]) > span{ color:var(--vb-text) !important; }
103
+ /* Fallback si :has n'est pas supporté */
104
+ .gradio-container input[type="checkbox"] + span,
105
+ .gradio-container input[type="checkbox"] ~ span{ color:var(--vb-text) !important; }
 
 
 
 
 
 
106
 
107
+ /* Cases à cocher : */
108
  .gradio-container input[type="checkbox"]{
109
+ -webkit-appearance:none; appearance:none;
110
+ width:18px;height:18px;border-radius:4px;border:1px solid var(--vb-border);
111
+ background:#fff; display:inline-grid; place-content:center; margin-right:8px;
112
+ }
113
+ .gradio-container input[type="checkbox"]:checked{
114
+ background:linear-gradient(135deg,var(--vb-primary),var(--vb-primary-2));
115
+ border-color:transparent;
116
  }
117
+ .gradio-container input[type="checkbox"]:checked::after{
118
+ content:""; width:10px;height:10px;border-radius:2px;background:#fff;
119
+ }
120
+
121
+ /* ---------- Inputs ---------- */
122
+ .gradio-container input[type="text"],
123
+ .gradio-container input[type="number"],
124
+ .gradio-container textarea,
125
+ .gradio-container select,
126
+ .gradio-container .gr-textbox,
127
+ .gradio-container .gr-textbox textarea{
128
+ background:#fff !important;border:1px solid var(--vb-border) !important;
129
+ border-radius:calc(var(--vb-radius) - 4px) !important; box-shadow:none !important; color:var(--vb-text);
130
  }
131
+ .gradio-container input::placeholder,
132
+ .gradio-container textarea::placeholder{color:#9AA4B2}
133
 
134
+ /* ---------- Sliders (barres de jauge) dégradé, plus d'orange ---------- */
135
  .gradio-container input[type="range"]{
136
+ width:100%; background:transparent; outline:none; height:20px;
137
+ }
138
+ .gradio-container input[type="range"]::-webkit-slider-runnable-track{
139
+ height:6px; border-radius:999px;
140
+ background:linear-gradient(90deg, var(--vb-primary) 0 calc(var(--range_progress, 0%)),
141
+ var(--vb-primary-2) calc(var(--range_progress, 0%)),
142
+ #E5EAF3 calc(var(--range_progress, 0%)));
143
  }
 
 
144
  .gradio-container input[type="range"]::-webkit-slider-thumb{
145
+ -webkit-appearance:none; width:18px;height:18px;border-radius:50%;
146
+ background:linear-gradient(135deg,var(--vb-primary),var(--vb-primary-2));
147
+ border:0; box-shadow:0 2px 8px rgba(124,58,237,.35); margin-top:-6px;
148
+ }
149
+ /* Firefox */
150
+ .gradio-container input[type="range"]::-moz-range-track{
151
+ height:6px;border-radius:999px;background:#E5EAF3;
152
+ }
153
+ .gradio-container input[type="range"]::-moz-range-progress{
154
+ height:6px;border-radius:999px;background:linear-gradient(90deg,var(--vb-primary),var(--vb-primary-2));
155
  }
156
  .gradio-container input[type="range"]::-moz-range-thumb{
157
  width:18px;height:18px;border-radius:50%;
158
+ background:linear-gradient(135deg,var(--vb-primary),var(--vb-primary-2));
159
+ border:0; box-shadow:0 2px 8px rgba(124,58,237,.35);
160
  }
161
 
162
+ /* ---------- Boutons ---------- */
163
+ button,.gr-button{
164
+ border-radius:var(--vb-radius) !important;border:0 !important;font-weight:800 !important;
165
+ box-shadow:var(--vb-shadow);
166
+ }
167
+ .gr-button-primary{
168
+ background:linear-gradient(90deg,var(--vb-primary),var(--vb-primary-2)) !important;
169
+ color:#fff !important; padding:14px 22px !important; font-size:16px !important;
170
  }
171
+ .gr-button-primary:hover{filter:brightness(1.06); transform:translateY(-1px)}
172
 
173
+ /* ---------- DataFrames / Tables ---------- */
174
+ .gradio-container table{border-collapse:separate;border-spacing:0;width:100%}
175
+ .gradio-container thead th{
176
+ background:linear-gradient(90deg,rgba(124,58,237,.06),rgba(6,182,212,.06)) !important;
177
+ color:#0F172A !important;font-weight:800 !important;border-bottom:1px solid var(--vb-border) !important;
178
  }
179
+ .gradio-container td, .gradio-container th{
180
+ padding:10px;border-bottom:1px solid #EEF3FA;
181
+ background:#fff !important; color:#0F172A !important;
182
  }
 
183
 
184
+ /* ---------- Graphiques (Plotly) ---------- */
 
 
 
 
185
  .js-plotly-plot .plotly .bg{fill:#fff !important}
186
  .js-plotly-plot .plotly .xgrid,.js-plotly-plot .plotly .ygrid{stroke:#E2E8F0 !important;opacity:1}
187
+ .js-plotly-plot .plotly .legend text{font-weight:600}
188
+
189
+ /* ---------- Nettoyage des icônes/grilles sombres Gradio ---------- */
190
+ .gradio-container .icon,
191
+ .gradio-container .empty,
192
+ .gradio-container .icon.svelte-1oiin9d,
193
+ .gradio-container .empty.svelte-1oiin9d,
194
+ .gradio-container .unpadded_box{ display:none !important }
195
 
196
+ /* Footer */
197
+ .vb-footer{color:var(--vb-muted);font-size:12px;text-align:center;margin:18px 0}
198
  """
199
 
200
  def apply_plotly_theme():
201
  pio.templates["verbatify"] = go.layout.Template(
202
  layout=go.Layout(
203
+ font=dict(family="Manrope, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif", size=13, color="#0F172A"),
 
204
  paper_bgcolor="white", plot_bgcolor="white",
205
+ colorway=["#7C3AED","#06B6D4","#2563EB","#10B981","#EF4444","#14B8A6","#F59E0B","#F43F5E"],
206
  xaxis=dict(gridcolor="#E2E8F0", zerolinecolor="#E2E8F0"),
207
  yaxis=dict(gridcolor="#E2E8F0", zerolinecolor="#E2E8F0"),
208
  legend=dict(borderwidth=0, bgcolor="rgba(255,255,255,0)")
 
219
  </g>
220
  </svg>"""
221
 
222
+ # ---------------- unidecode (fallback si paquet absent) ----------------
223
  try:
224
  from unidecode import unidecode
225
  except Exception:
 
230
  except Exception:
231
  return str(x)
232
 
233
+ # ---------------- Thésaurus ASSURANCE ----------------
234
  THEMES = {
235
  "Remboursements santé":[r"\bremboursement[s]?\b", r"\bt[eé]l[eé]transmission\b", r"\bno[eé]mie\b",
236
  r"\bprise\s*en\s*charge[s]?\b", r"\btaux\s+de\s+remboursement[s]?\b", r"\b(ameli|cpam)\b",
 
261
  "Agence / Accueil":[r"\bagence[s]?\b", r"\bboutique[s]?\b", r"\baccueil\b", r"\bconseil[s]?\b", r"\battente\b", r"\bcaisse[s]?\b"],
262
  }
263
 
264
+ # ---------------- Sentiment (règles) ----------------
265
  POS_WORDS = {"bien":1.0,"super":1.2,"parfait":1.4,"excellent":1.5,"ravi":1.2,"satisfait":1.0,
266
  "rapide":0.8,"efficace":1.0,"fiable":1.0,"simple":0.8,"facile":0.8,"clair":0.8,"conforme":0.8,
267
  "sympa":0.8,"professionnel":1.0,"réactif":1.0,"reactif":1.0,"compétent":1.0,"competent":1.0,
 
275
  DIMINISHERS = [r"\bun[e]?\s+peu\b", r"\bassez\b", r"\bplut[oô]t\b", r"\bl[eé]g[eè]rement\b"]
276
  INTENSIFIER_W, DIMINISHER_W = 1.5, 0.7
277
 
278
+ # ---------------- OpenAI (optionnel, robuste) ----------------
279
  OPENAI_AVAILABLE = False
280
  try:
281
+ from openai import OpenAI
282
+ if os.getenv("OPENAI_API_KEY"):
283
+ _client = OpenAI()
284
+ OPENAI_AVAILABLE = True
285
  except Exception:
286
+ OPENAI_AVAILABLE = False
287
 
288
+ # ---------------- Utils ----------------
289
  def normalize(t:str)->str:
290
  if not isinstance(t,str): return ""
291
  return re.sub(r"\s+"," ",t.strip())
 
345
  t=re.sub(r"\b(?:\+?\d[\s.-]?){7,}\b","[tel]",t)
346
  return t
347
 
348
+ # --------- Coller du texte → DataFrame ----------
349
  def df_from_pasted(text:str, sep="|", has_score=False) -> pd.DataFrame:
350
  lines = [l.strip() for l in (text or "").splitlines() if l.strip()]
351
  rows = []
 
357
  rows.append({"id": i, "comment": line.strip(), "nps_score": None})
358
  return pd.DataFrame(rows)
359
 
360
+ # --------- OpenAI helpers (optionnels) ----------
361
  def openai_json(model:str, system:str, user:str, temperature:float=0.0) -> Optional[dict]:
362
  if not OPENAI_AVAILABLE: return None
363
  try:
 
390
  if isinstance(j, dict): return ' '.join(str(v) for v in j.values())
391
  return None
392
 
393
+ # --------- HF sentiment (optionnel)
394
  def make_hf_pipe():
395
  try:
396
  from transformers import pipeline
 
400
  except Exception:
401
  return None
402
 
403
+ # --------- Inférence de note NPS depuis le sentiment ----------
404
  def infer_nps_from_sentiment(label: str, score: float) -> int:
405
+ scaled = int(round((float(score) + 4.0) * 1.25)) # -4 -> 0, 0 -> 5, +4 -> 10
406
  scaled = max(0, min(10, scaled))
407
+ if label == "positive":
408
+ return max(9, scaled)
409
+ if label == "negatif":
410
+ return min(6, scaled)
411
  return 8 if score >= 0 else 7
412
 
413
  # --------- Graphiques ----------
414
  def fig_nps_gauge(nps: Optional[float]) -> go.Figure:
415
  v = 0.0 if nps is None else float(nps)
416
+ return go.Figure(go.Indicator(mode="gauge+number", value=v,
417
+ gauge={"axis":{"range":[-100,100]}, "bar":{"thickness":0.3}},
418
+ title={"text":"NPS (−100 à +100)"}))
 
 
 
419
 
420
  def fig_sentiment_bar(dist: Dict[str,int]) -> go.Figure:
421
  order = ["negatif","neutre","positive"]
 
435
  fig = px.bar(d2, x="theme", y="count", color="type", barmode="stack", title=f"Top {k} thèmes — balance Pos/Neg")
436
  fig.update_layout(xaxis_tickangle=-30); return fig
437
 
438
+ # --------- Analyse principale ----------
439
  def analyze_text(pasted_txt, has_sc, sep_chr,
440
  do_anonymize, use_oa_sent, use_oa_themes, use_oa_summary,
441
  oa_model, oa_temp, top_k):
 
447
  if do_anonymize:
448
  df["comment"]=df["comment"].apply(anonymize)
449
 
450
+ # OpenAI indisponible → on bascule silencieusement
451
  if (use_oa_sent or use_oa_themes or use_oa_summary) and not OPENAI_AVAILABLE:
452
  use_oa_sent = use_oa_themes = use_oa_summary = False
453
 
 
468
  for idx, r in df.iterrows():
469
  cid=r.get("id", idx+1); comment=normalize(str(r["comment"]))
470
 
471
+ # Sentiment: OpenAI -> HF -> règles
472
  sent=None
473
  if use_oa_sent:
474
  sent=oa_sentiment(comment, oa_model, float(oa_temp or 0.0)); used_oa = used_oa or bool(sent)
 
479
  s=float(lexical_sentiment_score(comment))
480
  sent={"label":lexical_sentiment_label(s),"score":s}
481
 
482
+ # Thèmes: regex (+ fusion OpenAI)
483
  themes, counts = detect_themes_regex(comment)
484
  if use_oa_themes:
485
  tjson=oa_themes(comment, oa_model, float(oa_temp or 0.0))
 
490
  counts[th] = max(counts.get(th, 0), int(c))
491
  themes = [th for th, c in counts.items() if c > 0]
492
 
493
+ # Note NPS : donnée ou inférée
494
  given = r.get("nps_score", None)
495
  try:
496
  given = int(given) if given is not None and str(given).strip() != "" else None
 
523
  nps=compute_nps(out_df["nps_score_final"])
524
  dist=out_df["sentiment_label"].value_counts().to_dict()
525
 
526
+ # Stats par thème
527
  trs=[]
528
  for th, d in theme_agg.items():
529
  trs.append({"theme":th,"total_mentions":int(d["mentions"]),
 
531
  "net_sentiment":int(d["pos"]-d["neg"])})
532
  themes_df=pd.DataFrame(trs).sort_values(["total_mentions","net_sentiment"],ascending=[False,False])
533
 
534
+ # Synthèse
535
  method = "OpenAI + HF + règles" if (use_oa_sent and used_hf) else ("OpenAI + règles" if use_oa_sent else ("HF + règles" if used_hf else "Règles"))
536
  nps_label = "NPS global (inféré)" if any_inferred else "NPS global"
537
  lines=[ "# Synthèse NPS & ressentis clients",
 
598
  return (summary_md, themes_df.head(100), out_df.head(200), [enriched, themes, summ, zip_path],
599
  ench_md, irr_md, reco_md, fig_gauge, fig_emots, fig_top, fig_bal)
600
 
601
+ # ---------------- UI ----------------
602
+ apply_plotly_theme()
 
 
603
 
604
  with gr.Blocks(title="Verbatify — Analyse NPS", css=VB_CSS) as demo:
605
+ # Header
606
  gr.HTML(
607
  "<div class='vb-hero'>"
608
+ f"{LOGO_SVG}"
 
 
 
 
 
 
609
  "<div><div class='vb-title'>Verbatify — Analyse NPS</div>"
610
  "<div class='vb-sub'>Émotions • Thématiques • Occurrences • Synthèse</div></div>"
611
  "</div>"
612
  )
613
 
614
+ # Inputs
615
+ gr.HTML("<div class='vb-section'>Entrées</div>")
616
  with gr.Column():
617
  pasted = gr.Textbox(
618
  label="Verbatims (un par ligne)", lines=10,
 
632
  oa_model=gr.Textbox(label="Modèle OpenAI", value="gpt-4o-mini")
633
  oa_temp=gr.Slider(label="Température", minimum=0.0, maximum=1.0, value=0.1, step=0.1)
634
  top_k=gr.Slider(label="Top thèmes (K) pour les graphes", minimum=5, maximum=20, value=10, step=1)
635
+ run=gr.Button("Lancer l'analyse", variant="primary")
636
 
637
+ gr.HTML("<div class='vb-section'>Cartes synthétiques</div>")
638
  with gr.Row():
639
  ench_panel=gr.Markdown()
640
  irr_panel=gr.Markdown()
641
  reco_panel=gr.Markdown()
642
 
643
+ gr.HTML("<div class='vb-section'>Synthèse NPS & ressentis clients</div>")
644
+ summary=gr.Markdown()
645
+
646
  gr.HTML("<div class='vb-section'>Thèmes — statistiques</div>")
647
+ themes_table=gr.Dataframe()
648
 
649
  gr.HTML("<div class='vb-section'>Verbatims enrichis (aperçu)</div>")
650
+ enriched_table=gr.Dataframe()
 
651
  files_out=gr.Files(label="Téléchargements (CSV & ZIP)")
652
 
 
653
  gr.HTML("<div class='vb-section'>Graphiques</div>")
654
  with gr.Row():
655
  plot_nps = gr.Plot(label="NPS — Jauge")
 
658
  plot_top = gr.Plot(label="Top thèmes — occurrences")
659
  plot_bal = gr.Plot(label="Top thèmes — balance Pos/Neg")
660
 
661
+ # Lancer
 
 
 
 
662
  run.click(
663
  analyze_text,
664
  inputs=[pasted, has_score, sep, anon, use_oa_sent, use_oa_themes, use_oa_summary, oa_model, oa_temp, top_k],
 
667
  plot_nps, plot_sent, plot_top, plot_bal]
668
  )
669
 
670
+ # Footer
671
  gr.HTML(
672
  '<div class="vb-footer">© Verbatify.com — Construit par '
673
  '<a href="https://jeremy-lagache.fr/" target="_blank" rel="noopener">Jérémy Lagache</a></div>'