Cyprien Claude Opus 5 (1M context) commited on
Commit
976c02a
·
1 Parent(s): e3dd7f9

Gradio demo for the breakdown-risk SetFit classifier

Browse files

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Files changed (4) hide show
  1. README.md +44 -6
  2. app.py +138 -0
  3. examples.json +62 -0
  4. requirements.txt +6 -0
README.md CHANGED
@@ -1,13 +1,51 @@
1
  ---
2
- title: Breakdown Risk Demo
3
- emoji: 🏢
4
- colorFrom: green
5
- colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 6.20.0
8
- python_version: '3.13'
9
  app_file: app.py
 
 
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Risque d'immobilisation
3
+ emoji: 🔧
4
+ colorFrom: red
5
+ colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.20.0
 
8
  app_file: app.py
9
+ python_version: "3.12"
10
+ short_description: Risque de panne depuis les tours de parole
11
  pinned: false
12
  ---
13
 
14
+ # Risque d'immobilisation
15
+
16
+ Démo du classifieur de risque de panne : à partir des **tours de parole de
17
+ l'appelant** d'un appel entrant, le modèle répond
18
+
19
+ - `risk` — le véhicule est probablement immobilisé, l'appel demande un dépannage
20
+ ou un créneau urgent ;
21
+ - `no_risk` — le client peut rouler, un rendez-vous normal suffit.
22
+
23
+ ## Le modèle
24
+
25
+ [SetFit](https://github.com/huggingface/setfit) : l'encodeur de phrases
26
+ `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` affiné par
27
+ apprentissage contrastif, plus une régression logistique sur les embeddings.
28
+ Publié par `train.py` dans
29
+ [`bee2link/breakdown-risk-paraphrase-multilingual-MiniLM-L12-v2`](https://huggingface.co/bee2link/breakdown-risk-paraphrase-multilingual-MiniLM-L12-v2),
30
+ entraîné sur [`bee2link/breakdown-risk`](https://huggingface.co/datasets/bee2link/breakdown-risk).
31
+
32
+ 45/46 sur le split de test. Les exemples de la démo viennent de ce même split :
33
+ ce sont des tours que le modèle n'a jamais vus à l'entraînement.
34
+
35
+ ## La fenêtre de trois tours
36
+
37
+ Le modèle ne lit que les **trois derniers tours de l'appelant**, et la démo
38
+ reproduit ce découpage : un tour par ligne, les plus anciens sont affichés mais
39
+ ignorés.
40
+
41
+ Le filtrage passe avant le découpage. Prendre les trois derniers messages d'un
42
+ dialogue compterait les réponses de l'agent : dans un échange alterné il ne
43
+ resterait que deux tours de l'appelant, et après deux questions de relance la
44
+ plainte elle-même sortirait de la fenêtre — le classifieur ne verrait plus que
45
+ « non / non ».
46
+
47
+ ## Accès au modèle
48
+
49
+ Le dépôt du modèle est privé. Le Space le lit via un secret `HF_TOKEN`
50
+ (Settings → Secrets), qui doit avoir un accès en lecture à ce dépôt. Les secrets
51
+ ne sont pas visibles par les visiteurs du Space.
app.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Breakdown-risk demo: does the caller's car still move?
2
+
3
+ The model is the SetFit classifier `train.py` pushed -- a fine-tuned sentence
4
+ encoder plus a logistic head -- and it decides between `risk` (the vehicle is
5
+ probably immobilised, so the call needs a tow or an urgent slot) and `no_risk`
6
+ (the customer can still drive, so a normal appointment will do).
7
+
8
+ It reads the caller's turns and nothing else. In the live pipeline the agent's
9
+ own replies are filtered out before the last three turns are taken, and this
10
+ demo keeps that: one turn per line, only the last three reach the model. Slicing
11
+ before filtering would spend the window on the agent's follow-up questions and
12
+ push the complaint itself out of it.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import time
18
+ from pathlib import Path
19
+
20
+ import gradio as gr
21
+ import numpy as np
22
+ from setfit import SetFitModel
23
+
24
+ MODEL_REPO = "bee2link/breakdown-risk-paraphrase-multilingual-MiniLM-L12-v2"
25
+ DATASET_REPO = "bee2link/breakdown-risk"
26
+ WINDOW = 3 # caller turns the classifier sees, matching `caller_turns`
27
+
28
+ # The label the model returns -> what to call it on screen.
29
+ DISPLAY = {
30
+ "risk": "Risque de panne",
31
+ "no_risk": "Pas de risque",
32
+ }
33
+
34
+ # The model repo is private, so the Space needs a token to read it. Set as a
35
+ # Space secret, which is an environment variable here and is not exposed to
36
+ # visitors.
37
+ model = SetFitModel.from_pretrained(MODEL_REPO, token=os.environ.get("HF_TOKEN"))
38
+ LABELS: list[str] = list(model.labels)
39
+
40
+ EXAMPLES = json.loads(Path("examples.json").read_text(encoding="utf-8"))
41
+
42
+
43
+ def window(transcript: str) -> str:
44
+ """The last ``WINDOW`` non-empty lines: exactly what the tokenizer is handed."""
45
+ lines = [line.strip() for line in transcript.split("\n") if line.strip()]
46
+ return "\n".join(lines[-WINDOW:])
47
+
48
+
49
+ def classify(transcript: str) -> tuple[dict[str, float], str, str]:
50
+ """Score a caller's turns for breakdown risk.
51
+
52
+ Args:
53
+ transcript: What the caller said, one turn per line. Only the last three
54
+ lines are classified; earlier ones are shown but ignored, the same
55
+ way the live pipeline windows a conversation.
56
+
57
+ Returns:
58
+ The probability per class, the text actually sent to the model, and a
59
+ one-line note on how long it took.
60
+ """
61
+ text = window(transcript)
62
+ if not text:
63
+ return {}, "", "*Saisissez au moins un tour de parole.*"
64
+
65
+ started = time.perf_counter()
66
+ probabilities = np.asarray(model.predict_proba([text])).reshape(-1)
67
+ elapsed = (time.perf_counter() - started) * 1000
68
+
69
+ scores = {DISPLAY.get(name, name): float(p) for name, p in zip(LABELS, probabilities)}
70
+ top = LABELS[int(probabilities.argmax())]
71
+ dropped = len([line for line in transcript.split("\n") if line.strip()]) - len(text.split("\n"))
72
+ note = f"`{top}` en {elapsed:.0f} ms"
73
+ if dropped > 0:
74
+ note += f" · {dropped} tour(s) plus ancien(s) hors fenêtre"
75
+ return scores, text, note
76
+
77
+
78
+ with gr.Blocks(title="Risque d'immobilisation") as demo:
79
+ gr.Markdown(
80
+ f"""
81
+ # Risque d'immobilisation
82
+
83
+ Classe les **tours de parole de l'appelant** d'un appel entrant en
84
+ `risk` — le véhicule est probablement immobilisé, il faut un dépannage
85
+ ou un créneau urgent — ou `no_risk` — le client peut rouler, un
86
+ rendez-vous normal suffit.
87
+
88
+ <sub>SetFit (encodeur `paraphrase-multilingual-MiniLM-L12-v2` + tête
89
+ logistique) · entraîné sur [`{DATASET_REPO}`](https://huggingface.co/datasets/{DATASET_REPO})
90
+ · 45/46 sur le split de test · le modèle ne lit que les {WINDOW} derniers
91
+ tours</sub>
92
+ """
93
+ )
94
+
95
+ with gr.Row():
96
+ with gr.Column(scale=3):
97
+ transcript = gr.Textbox(
98
+ label="Tours de parole de l'appelant",
99
+ info=f"Un tour par ligne. Seuls les {WINDOW} derniers sont classés.",
100
+ placeholder="Oui bonjour.\nMa voiture ne démarre plus depuis ce matin.",
101
+ lines=7,
102
+ max_lines=14,
103
+ )
104
+ run = gr.Button("Classer", variant="primary")
105
+
106
+ with gr.Column(scale=2):
107
+ prediction = gr.Label(label="Prédiction", num_top_classes=2)
108
+ sent = gr.Textbox(
109
+ label="Ce que le modèle lit",
110
+ info=f"Les {WINDOW} derniers tours, tels qu'ils sont tokenisés.",
111
+ lines=3,
112
+ interactive=False,
113
+ buttons=["copy"],
114
+ )
115
+ timing = gr.Markdown()
116
+
117
+ gr.Examples(
118
+ label="Exemples du split de test (jamais vus à l'entraînement)",
119
+ examples=[[row["text"]] for row in EXAMPLES],
120
+ example_labels=[f"{DISPLAY[row['gold']]} — {row['id']}" for row in EXAMPLES],
121
+ inputs=[transcript],
122
+ outputs=[prediction, sent, timing],
123
+ fn=classify,
124
+ cache_examples=True,
125
+ cache_mode="lazy",
126
+ )
127
+
128
+ gr.on(
129
+ triggers=[run.click, transcript.submit],
130
+ fn=classify,
131
+ inputs=[transcript],
132
+ outputs=[prediction, sent, timing],
133
+ api_name="classify",
134
+ )
135
+
136
+
137
+ if __name__ == "__main__":
138
+ demo.launch(mcp_server=True)
examples.json ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "wont_start_to_garage",
4
+ "text": "J'ai un problème avec ma voiture qui ne démarre pas, j'aurais souhaité l'emmener au garage aujourd'hui.",
5
+ "gold": "risk",
6
+ "can_drive": "no"
7
+ },
8
+ {
9
+ "id": "suspension_noise",
10
+ "text": "J'ai un bruit aux amortisseurs, il faudrait éventuellement changer les triangles avant.",
11
+ "gold": "no_risk",
12
+ "can_drive": "unknown"
13
+ },
14
+ {
15
+ "id": "wont_start_needs_tow",
16
+ "text": "J'ai une Mercedes de 2014, elle ne démarre pas et mon assurance m'a proposé un dépanneur.",
17
+ "gold": "risk",
18
+ "can_drive": "no"
19
+ },
20
+ {
21
+ "id": "reversing_camera_fault",
22
+ "text": "Défaut de caméra de recul.",
23
+ "gold": "no_risk",
24
+ "can_drive": "yes"
25
+ },
26
+ {
27
+ "id": "stalls_power_loss",
28
+ "text": "Quand je l'allume, mon véhicule au bout d'une ou deux minutes coupe tout seul, et il y a une perte de puissance.",
29
+ "gold": "risk",
30
+ "can_drive": "no"
31
+ },
32
+ {
33
+ "id": "camera_fault",
34
+ "text": "Bonjour.\nProblème de caméra.",
35
+ "gold": "no_risk",
36
+ "can_drive": "yes"
37
+ },
38
+ {
39
+ "id": "coolant_light_power_loss",
40
+ "text": "Un problème dans le tableau de bord, le voyant de refroidissement liquide s'allume et la voiture n'a plus de puissance.",
41
+ "gold": "risk",
42
+ "can_drive": "no"
43
+ },
44
+ {
45
+ "id": "camera_cover_stuck",
46
+ "text": "Le clapet de la caméra arrière ne se ferme plus, je n'ai plus de caméra et le clapet reste ouvert.",
47
+ "gold": "no_risk",
48
+ "can_drive": "yes"
49
+ },
50
+ {
51
+ "id": "water_light_power_loss",
52
+ "text": "Un voyant d'eau s'allume dans le compteur et la voiture n'a plus de puissance.",
53
+ "gold": "risk",
54
+ "can_drive": "no"
55
+ },
56
+ {
57
+ "id": "driver_window_stuck",
58
+ "text": "Bonjour.\nMa vitre côté conducteur : quand j'appuie sur le lève-vitre, ça ne remonte pas.",
59
+ "gold": "no_risk",
60
+ "can_drive": "yes"
61
+ }
62
+ ]
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # CPU-only torch: the local version tag (+cpu) sorts above the PyPI wheel, so
2
+ # pip takes it from here and the build skips ~2 GB of CUDA the Space cannot use.
3
+ --extra-index-url https://download.pytorch.org/whl/cpu
4
+ torch
5
+ setfit>=1.1.0
6
+ numpy