Cyprien Claude Opus 5 (1M context) commited on
Commit
d27824a
·
1 Parent(s): 1d487ef

Docker SDK + uv lockfile; load the SetFit pieces without setfit

Browse files

gradio 6 requires transformers>=5, which setfit 1.1 cannot import. A SetFit
model is a SentenceTransformer plus a pickled logistic head, so app.py loads
those directly. Same 45/46 on the test split.

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

Files changed (8) hide show
  1. .gitignore +3 -0
  2. .python-version +1 -0
  3. Dockerfile +22 -0
  4. README.md +20 -4
  5. app.py +55 -86
  6. pyproject.toml +26 -0
  7. requirements.txt +0 -10
  8. uv.lock +0 -0
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ .gradio/
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.12
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+ COPY --from=ghcr.io/astral-sh/uv:0.8.13 /uv /uvx /bin/
3
+
4
+ RUN useradd -m -u 1000 user
5
+ USER user
6
+
7
+ ENV HOME=/home/user \
8
+ PATH=/home/user/app/.venv/bin:$PATH \
9
+ HF_HOME=/home/user/.cache/huggingface \
10
+ UV_LINK_MODE=copy \
11
+ UV_PYTHON_DOWNLOADS=never \
12
+ UV_NO_DEV=1
13
+
14
+ WORKDIR $HOME/app
15
+
16
+ COPY --chown=user pyproject.toml uv.lock ./
17
+ RUN uv sync --locked
18
+
19
+ COPY --chown=user app.py examples.json ./
20
+
21
+ EXPOSE 7860
22
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -3,10 +3,8 @@ 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
  ---
@@ -49,3 +47,21 @@ plainte elle-même sortirait de la fenêtre — le classifieur ne verrait plus q
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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  emoji: 🔧
4
  colorFrom: red
5
  colorTo: gray
6
+ sdk: docker
7
+ app_port: 7860
 
 
8
  short_description: Risque de panne depuis les tours de parole
9
  pinned: false
10
  ---
 
47
  Le dépôt du modèle est privé. Le Space le lit via un secret `HF_TOKEN`
48
  (Settings → Secrets), qui doit avoir un accès en lecture à ce dépôt. Les secrets
49
  ne sont pas visibles par les visiteurs du Space.
50
+
51
+ ## Dépendances
52
+
53
+ SDK Docker plutôt que Gradio, pour que les versions viennent d'un `uv.lock` :
54
+
55
+ ```
56
+ uv sync # environnement local, identique à celui de l'image
57
+ uv lock # après toute modification de pyproject.toml
58
+ ```
59
+
60
+ `setfit` n'est pas installé. Un modèle SetFit est un `SentenceTransformer` suivi
61
+ d'une régression logistique picklée, et `app.py` charge ces deux pièces
62
+ directement — `gradio` 6 exige `transformers>=5`, que `setfit` 1.1 ne supporte
63
+ pas (`ImportError: default_logdir`). Les deux chemins donnent les mêmes
64
+ prédictions : 45/46 sur le split de test.
65
+
66
+ `torch` vient de l'index CPU sur Linux, ce qui évite ~2 Go de CUDA inutile dans
67
+ l'image.
app.py CHANGED
@@ -1,103 +1,77 @@
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
  )
@@ -105,34 +79,29 @@ with gr.Blocks(title="Risque d'immobilisation") as demo:
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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import json
2
  import os
3
  import time
4
  from pathlib import Path
5
 
6
  import gradio as gr
7
+ import joblib
8
+ from huggingface_hub import hf_hub_download
9
+ from sentence_transformers import SentenceTransformer
10
 
11
  MODEL_REPO = "bee2link/breakdown-risk-paraphrase-multilingual-MiniLM-L12-v2"
12
  DATASET_REPO = "bee2link/breakdown-risk"
13
+ CALLER_TURNS = 3
14
+ TOKEN = os.environ.get("HF_TOKEN")
15
+
16
+ LABEL_NAMES = {"risk": "Risque de panne", "no_risk": "Pas de risque"}
17
+
18
+ HEADER = f"""
19
+ # Risque d'immobilisation
20
+
21
+ Classe les **tours de parole de l'appelant** d'un appel entrant en `risk` — le
22
+ véhicule est probablement immobilisé, il faut un dépannage ou un créneau urgent
23
+ — ou `no_risk` — le client peut rouler, un rendez-vous normal suffit.
24
+
25
+ <sub>SetFit · encodeur `paraphrase-multilingual-MiniLM-L12-v2` + tête logistique
26
+ · entraîné sur [`{DATASET_REPO}`](https://huggingface.co/datasets/{DATASET_REPO})
27
+ · 45/46 sur le split de test · seuls les {CALLER_TURNS} derniers tours sont
28
+ lus</sub>
29
+ """
30
+
31
+ PLACEHOLDER = "Oui bonjour.\nMa voiture ne démarre plus depuis ce matin."
32
 
 
 
 
 
 
33
 
34
+ def from_hub(filename: str) -> Path:
35
+ return Path(hf_hub_download(MODEL_REPO, filename, token=TOKEN))
 
 
 
36
 
37
+
38
+ encoder = SentenceTransformer(MODEL_REPO, token=TOKEN)
39
+ head = joblib.load(from_hub("model_head.pkl"))
40
+ LABELS = json.loads(from_hub("config_setfit.json").read_text())["labels"]
41
  EXAMPLES = json.loads(Path("examples.json").read_text(encoding="utf-8"))
42
 
43
 
44
+ def last_turns(transcript: str) -> list[str]:
45
+ lines = [line.strip() for line in transcript.splitlines() if line.strip()]
46
+ return lines[-CALLER_TURNS:]
 
47
 
48
 
49
+ def score(text: str) -> dict[str, float]:
50
+ probabilities = head.predict_proba(encoder.encode([text]))[0]
51
+ return {LABEL_NAMES[name]: float(p) for name, p in zip(LABELS, probabilities)}
52
 
 
 
 
 
53
 
54
+ def classify(transcript: str) -> tuple[dict[str, float], str, str]:
55
+ """Score a caller's turns for breakdown risk."""
56
+ turns = last_turns(transcript)
57
+ if not turns:
58
+ return {}, "", ""
 
 
59
 
60
+ text = "\n".join(turns)
61
  started = time.perf_counter()
62
+ scores = score(text)
63
+ return scores, text, f"`{(time.perf_counter() - started) * 1000:.0f} ms`"
 
 
 
 
 
 
 
 
64
 
65
 
66
  with gr.Blocks(title="Risque d'immobilisation") as demo:
67
+ gr.Markdown(HEADER)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  with gr.Row():
70
  with gr.Column(scale=3):
71
  transcript = gr.Textbox(
72
  label="Tours de parole de l'appelant",
73
+ info=f"Un tour par ligne. Seuls les {CALLER_TURNS} derniers sont classés.",
74
+ placeholder=PLACEHOLDER,
75
  lines=7,
76
  max_lines=14,
77
  )
 
79
 
80
  with gr.Column(scale=2):
81
  prediction = gr.Label(label="Prédiction", num_top_classes=2)
82
+ window = gr.Textbox(
83
  label="Ce que le modèle lit",
 
84
  lines=3,
85
  interactive=False,
86
  buttons=["copy"],
87
  )
88
+ latency = gr.Markdown()
89
+
90
+ outputs = [prediction, window, latency]
91
 
92
  gr.Examples(
 
93
  examples=[[row["text"]] for row in EXAMPLES],
94
+ example_labels=[f"{LABEL_NAMES[row['gold']]} — {row['id']}" for row in EXAMPLES],
95
+ label="Exemples du split de test",
96
+ inputs=transcript,
97
+ outputs=outputs,
98
  fn=classify,
99
  cache_examples=True,
100
  cache_mode="lazy",
101
  )
102
 
103
+ gr.on([run.click, transcript.submit], classify, transcript, outputs, api_name="classify")
 
 
 
 
 
 
104
 
105
 
106
  if __name__ == "__main__":
107
+ demo.launch(server_name="0.0.0.0", server_port=7860)
pyproject.toml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "breakdown-risk-demo"
3
+ version = "0.1.0"
4
+ description = "Breakdown-risk classifier demo"
5
+ readme = "README.md"
6
+ requires-python = "==3.12.*"
7
+ dependencies = [
8
+ "gradio==6.20.0",
9
+ "sentence-transformers>=5.6.1",
10
+ "scikit-learn>=1.5",
11
+ "joblib>=1.4",
12
+ "huggingface-hub>=0.30",
13
+ "torch>=2.2",
14
+ "numpy>=2",
15
+ ]
16
+
17
+ [tool.uv]
18
+ package = false
19
+
20
+ [[tool.uv.index]]
21
+ name = "pytorch-cpu"
22
+ url = "https://download.pytorch.org/whl/cpu"
23
+ explicit = true
24
+
25
+ [tool.uv.sources]
26
+ torch = [{ index = "pytorch-cpu", marker = "sys_platform == 'linux'" }]
requirements.txt DELETED
@@ -1,10 +0,0 @@
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 imports `default_logdir` from transformers.training_args, which
6
- # transformers 5 removed -- unpinned, the build resolves to 5.x and the Space
7
- # dies on `import setfit` before it ever reaches the model.
8
- transformers<5
9
- setfit>=1.1.0
10
- numpy
 
 
 
 
 
 
 
 
 
 
 
uv.lock ADDED
The diff for this file is too large to render. See raw diff