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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +156 -186
app.py CHANGED
@@ -1,10 +1,6 @@
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,194 +11,172 @@ import plotly.express as px
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,7 +193,7 @@ LOGO_SVG = """<svg xmlns='http://www.w3.org/2000/svg' width='224' height='38' vi
219
  </g>
220
  </svg>"""
221
 
222
- # ---------------- unidecode (fallback si paquet absent) ----------------
223
  try:
224
  from unidecode import unidecode
225
  except Exception:
@@ -230,7 +204,7 @@ 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,7 +235,6 @@ THEMES = {
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,17 +248,14 @@ INTENSIFIERS = [r"\btr[eè]s\b", r"\bvraiment\b", r"\bextr[eê]mement\b", r"\bhy
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,7 +315,6 @@ def anonymize(t:str)->str:
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,7 +326,6 @@ def df_from_pasted(text:str, sep="|", has_score=False) -> pd.DataFrame:
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,7 +358,6 @@ def oa_summary(nps:Optional[float], dist:Dict[str,int], themes_df:pd.DataFrame,
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,22 +367,22 @@ def make_hf_pipe():
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,7 +402,7 @@ def fig_theme_balance(themes_df: pd.DataFrame, k: int) -> go.Figure:
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,7 +414,6 @@ def analyze_text(pasted_txt, has_sc, sep_chr,
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,7 +434,6 @@ def analyze_text(pasted_txt, has_sc, sep_chr,
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,7 +444,6 @@ def analyze_text(pasted_txt, has_sc, sep_chr,
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,7 +454,6 @@ def analyze_text(pasted_txt, has_sc, sep_chr,
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,7 +486,6 @@ def analyze_text(pasted_txt, has_sc, sep_chr,
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,7 +493,6 @@ def analyze_text(pasted_txt, has_sc, sep_chr,
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,21 +559,27 @@ def analyze_text(pasted_txt, has_sc, sep_chr,
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,24 +599,24 @@ with gr.Blocks(title="Verbatify — Analyse NPS", css=VB_CSS) as demo:
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,7 +625,11 @@ with gr.Blocks(title="Verbatify — Analyse NPS", css=VB_CSS) as demo:
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,7 +638,6 @@ with gr.Blocks(title="Verbatify — Analyse NPS", css=VB_CSS) as demo:
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>'
 
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
  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
  </g>
194
  </svg>"""
195
 
196
+ # ====================== UNIDECODE (fallback) ======================
197
  try:
198
  from unidecode import unidecode
199
  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
  "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
  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
  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
  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
  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
  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
  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
  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
  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
  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
  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
  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
  "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
  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
  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
  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
  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>'