File size: 8,336 Bytes
308d297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f39c70
308d297
 
 
 
 
 
 
 
9f39c70
308d297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f39c70
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
"""
app.py — tracer scan en tant que mini-app Gradio, à héberger sur un HuggingFace
Space (SDK: gradio). Permet d'uploader n'importe quel .jsonl compatible TRACER,
de choisir l'embedder / le target / le mode force, et de récupérer le rapport
HTML interactif + un résumé terminal-like du balayage de targets.

Déploiement :
  1. huggingface.co/new-space -> SDK "Gradio" -> hardware "CPU basic" suffit
  2. Uploader ce fichier (app.py) + requirements.txt à la racine du Space
  3. Le Space build automatiquement et expose l'UI

Aucun souci réseau/SSL attendu ici : le Space tourne sur l'infra HuggingFace,
donc le téléchargement du modèle sentence-transformers se fait en local au
Hub, sans proxy ni certificat tiers à valider.
"""

from __future__ import annotations

import tempfile
from pathlib import Path

import gradio as gr
import numpy as np

import tracer
from tracer.scanner import ThinDataError, load_scan_traces, scan_html
from tracer.embeddings.index import embed_texts

EMBED_MODEL_CHOICES = [
    "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",  # défaut fit_tracer.py
    "BAAI/bge-m3",                                                   # exigé par la spec
    "all-MiniLM-L6-v2",                                              # défaut CLI tracer (EN only)
]

# Cache mémoire (process du Space) pour éviter de ré-embruter le même
# fichier+modèle entre deux clics (target/force/viz-layout différents).
_EMBED_CACHE: dict[str, np.ndarray] = {}


def _cache_key(path: Path, model: str) -> str:
    import hashlib
    h = hashlib.sha256()
    h.update(path.read_bytes())
    h.update(model.encode("utf-8"))
    return h.hexdigest()


def _get_embeddings(path: Path, model: str, inputs: list[str], progress: gr.Progress | None = None) -> np.ndarray:
    key = _cache_key(path, model)
    if key in _EMBED_CACHE:
        return _EMBED_CACHE[key]
    if progress:
        progress(0.1, desc=f"Téléchargement / chargement de {model}...")
    X = embed_texts(inputs, model=model, show_progress=False)
    _EMBED_CACHE[key] = X
    return X


def run_scan(
    files,
    embed_model: str,
    primary_target: float,
    sweep_targets_str: str,
    force_mode: str,
    price: float,
    monthly_calls: int,
    viz_layout: str,
    progress: gr.Progress = gr.Progress(),
):
    """Callback principal du bouton 'Lancer le scan'."""
    if not files:
        return "⚠ Aucun fichier fourni.", "", []

    try:
        sweep_targets = sorted(
            {float(t.strip()) for t in sweep_targets_str.split(",") if t.strip()}
            | {primary_target}
        )
    except ValueError:
        return "⚠ Targets de balayage invalides (attendu : ex. '0.8, 0.85, 0.9, 0.95').", "", []

    summary_lines: list[str] = []
    html_reports: list[str] = []
    html_files: list[str] = []

    for i, file_obj in enumerate(files):
        path = Path(file_obj.name if hasattr(file_obj, "name") else file_obj)
        progress((i) / len(files), desc=f"Lecture de {path.name}...")

        try:
            inputs, labels = load_scan_traces(path)
        except Exception as exc:
            summary_lines.append(f"=== {path.name} ===\n⚠ Erreur de lecture : {exc}\n")
            continue

        n = len(inputs)
        n_classes = len(set(labels))
        summary_lines.append(f"=== {path.name} ===")
        summary_lines.append(f"{n} traces utilisables · {n_classes} labels distincts")

        if force_mode == "auto":
            effective_force = n < 1000
            if effective_force:
                summary_lines.append(f"⚠ {n} < 1000 traces : force appliqué automatiquement (résultat best-effort).")
        elif force_mode == "force":
            effective_force = True
        else:
            effective_force = False

        progress((i + 0.3) / len(files), desc=f"Embeddings ({embed_model})...")
        try:
            X = _get_embeddings(path, embed_model, inputs, progress=progress)
        except Exception as exc:
            summary_lines.append(f"⚠ Échec du calcul des embeddings : {exc}\n")
            continue

        progress((i + 0.6) / len(files), desc="Balayage de targets...")
        for t in sweep_targets:
            try:
                result = tracer.scan(path, target=t, embeddings=X, force=effective_force, viz_layout=viz_layout)
                summary_lines.append(
                    f"  target {t:.0%}   certifiable = {result.certifiable_share:6.1%}   "
                    f"({result.n_clusters} cellules{'  [forced]' if result.forced else ''})"
                )
            except ThinDataError as exc:
                summary_lines.append(f"  target {t:.0%}   {exc}")

        progress((i + 0.9) / len(files), desc="Génération du rapport HTML...")
        try:
            result = tracer.scan(
                path,
                target=primary_target,
                embeddings=X,
                force=effective_force,
                teacher_price_per_1k=(price or None),
                monthly_calls=(monthly_calls or None),
                viz_layout=viz_layout,
            )
            html = scan_html(result, source_name=path.name)
            tmp = tempfile.NamedTemporaryFile(
                delete=False, suffix=".html", prefix=f"{path.stem}_scan_"
            )
            tmp.write(html.encode("utf-8"))
            tmp.close()
            html_files.append(tmp.name)
            html_reports.append(html)
            summary_lines.append(f"  → rapport HTML généré ({path.name})")
        except ThinDataError as exc:
            summary_lines.append(f"  ⚠ Rapport HTML non généré : {exc}")

        summary_lines.append("")

    terminal_summary = "\n".join(summary_lines)
    preview_html = html_reports[0] if html_reports else "<p>Aucun rapport généré.</p>"
    return terminal_summary, preview_html, html_files


with gr.Blocks(title="TRACER scan — testeur de dataset") as demo:
    gr.Markdown(
        "# tracer scan — testeur de dataset\n"
        "Upload un ou plusieurs `.jsonl` compatibles TRACER (`input`+`teacher`, "
        "ou alias tolérés) pour voir leur routabilité probable **avant** de lancer "
        "`fit`. Tourne sur l'infra HuggingFace : le modèle d'embedding se "
        "télécharge directement depuis le Hub, sans souci réseau/proxy local."
    )

    with gr.Row():
        with gr.Column(scale=1):
            files_in = gr.File(
                label="Fichier(s) .jsonl", file_count="multiple", file_types=[".jsonl"]
            )
            embed_model_in = gr.Dropdown(
                label="Embedder",
                choices=EMBED_MODEL_CHOICES,
                value=EMBED_MODEL_CHOICES[0],
                allow_custom_value=True,
            )
            target_in = gr.Slider(
                label="Target primaire (rapport HTML détaillé)",
                minimum=0.5, maximum=0.99, step=0.01, value=0.95,
            )
            sweep_in = gr.Textbox(
                label="Targets de balayage (séparés par des virgules)",
                value="0.80, 0.85, 0.90, 0.95",
            )
            force_in = gr.Radio(
                label="Mode thin-data",
                choices=[("auto (force si <1000 traces)", "auto"), ("forcer", "force"), ("refuser", "no_force")],
                value="auto",
            )
            with gr.Accordion("Options économiques (facultatif)", open=False):
                price_in = gr.Number(label="Prix teacher $/1k appels", value=None)
                monthly_in = gr.Number(label="Volume mensuel d'appels", value=None, precision=0)
            viz_in = gr.Radio(
                label="Layout 3D du rapport", choices=["pca", "umap", "tsne", "auto"], value="pca"
            )
            run_btn = gr.Button("Lancer le scan", variant="primary")

        with gr.Column(scale=2):
            summary_out = gr.Textbox(label="Résumé (terminal-like)", lines=24)
            html_preview = gr.HTML(label="Aperçu du rapport (1er fichier)")
            html_download = gr.Files(label="Télécharger le(s) rapport(s) HTML")

    run_btn.click(
        run_scan,
        inputs=[files_in, embed_model_in, target_in, sweep_in, force_in, price_in, monthly_in, viz_in],
        outputs=[summary_out, html_preview, html_download],
    )

if __name__ == "__main__":
    demo.launch(show_api=False)