histlearn commited on
Commit
5529d8f
·
verified ·
1 Parent(s): e2ed4f2

fix: contribuições mostram 6 casas decimais (elimina +0.0000/-0.0000)

Browse files
Files changed (1) hide show
  1. app.py +366 -367
app.py CHANGED
@@ -1,367 +1,366 @@
1
- """Gradio app — endpoint de utilidade para community notes em PT-BR.
2
-
3
- Expõe:
4
- - UI web com três abas: Prever / Explicar / Sobre.
5
- - API HTTP em /gradio_api/call/predict e /gradio_api/call/explain (gerada
6
- automaticamente pelo Gradio a partir dos api_name).
7
-
8
- Para clientes Python, use gradio_client:
9
-
10
- from gradio_client import Client
11
- c = Client("<user>/<space>", hf_token="hf_...")
12
- score = c.predict("texto da nota...", api_name="/predict")
13
- """
14
- from __future__ import annotations
15
-
16
- import html
17
- import logging
18
- import os
19
- import traceback
20
- from pathlib import Path
21
-
22
- import gradio as gr
23
-
24
- from config import (
25
- CONFIDENCE_BOUNDS_ALTA,
26
- CONFIDENCE_BOUNDS_MEDIA,
27
- THRESHOLD_UTIL,
28
- )
29
- from inference import DEVICE, explain_occlusion, predict_one, warmup
30
-
31
- logging.basicConfig(
32
- level=logging.INFO,
33
- format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
34
- )
35
- log = logging.getLogger("app")
36
-
37
- # ---------------------------------------------------------------------------
38
- # CSS do projeto
39
- # ---------------------------------------------------------------------------
40
- APP_DIR = Path(__file__).resolve().parent
41
- STYLE_PATH = APP_DIR / "styles.css"
42
- CUSTOM_CSS = STYLE_PATH.read_text(encoding="utf-8") if STYLE_PATH.exists() else ""
43
-
44
-
45
-
46
- # ---------------------------------------------------------------------------
47
- # Warm-up agressivo — queremos que o primeiro request não pague cold-start
48
- # ---------------------------------------------------------------------------
49
- MODEL_READY: bool
50
- MODEL_ERROR: str | None
51
-
52
- try:
53
- warmup()
54
- MODEL_READY = True
55
- MODEL_ERROR = None
56
- log.info("Modelo carregado no startup. Device=%s", DEVICE)
57
- except Exception as exc: # noqa: BLE001 — queremos pegar qualquer falha de carregamento
58
- MODEL_READY = False
59
- MODEL_ERROR = f"{type(exc).__name__}: {exc}"
60
- log.error("Falha ao carregar modelo no startup:\n%s", traceback.format_exc())
61
-
62
-
63
- # ---------------------------------------------------------------------------
64
- # Helpers de apresentação
65
- # ---------------------------------------------------------------------------
66
- def _confidence_band(p: float) -> str:
67
- lo_a, hi_a = CONFIDENCE_BOUNDS_ALTA
68
- lo_m, hi_m = CONFIDENCE_BOUNDS_MEDIA
69
- if p <= lo_a or p >= hi_a:
70
- return "Alta"
71
- if p <= lo_m or p >= hi_m:
72
- return "Média"
73
- return "Baixa"
74
-
75
-
76
- def _label(p: float) -> str:
77
- return "Útil" if p >= THRESHOLD_UTIL else "Não-útil"
78
-
79
-
80
- def _score_card_html(p: float) -> str:
81
- """Card principal do resultado — usando classes CSS do projeto."""
82
- lbl = _label(p)
83
- band = _confidence_band(p)
84
-
85
- lbl_class = "notinhas-badge-util" if lbl == "Útil" else "notinhas-badge-nao-util"
86
-
87
- if band == "Alta":
88
- band_class = lbl_class
89
- elif band == "Média":
90
- band_class = "notinhas-badge-media"
91
- else:
92
- band_class = "notinhas-badge-baixa"
93
-
94
- return f"""
95
- <div class="notinhas-card">
96
- <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;">
97
- <div style="display:flex;gap:8px;flex-wrap:wrap;">
98
- <span class="notinhas-badge {lbl_class}">{lbl}</span>
99
- <span class="notinhas-badge {band_class}">Confiança {band}</span>
100
- </div>
101
-
102
- <div style="text-align:right;">
103
- <div class="notinhas-score-label">P(útil)</div>
104
- <div class="notinhas-score-value">{p:.3f}</div>
105
- </div>
106
- </div>
107
- </div>
108
- """
109
-
110
-
111
- def _contrib_color(v: float, v_max: float) -> str:
112
- if v_max <= 0:
113
- return "transparent"
114
- intensity = min(1.0, abs(v) / v_max)
115
- alpha = 0.15 + 0.65 * intensity # 0.15 .. 0.80
116
- if v > 0:
117
- return f"rgba(95, 168, 143, {alpha:.3f})" # verde (PALETA['util'] do notebook)
118
- return f"rgba(224, 123, 107, {alpha:.3f})" # coral (PALETA['nao_util'])
119
-
120
-
121
- def _highlighted_text_html(tokens: list[str], contribs: list[float]) -> str:
122
- if not tokens:
123
- return "<em>(sem palavras para destacar)</em>"
124
- v_max = max((abs(c) for c in contribs), default=1e-9) or 1e-9
125
- spans = []
126
- for tok, c in zip(tokens, contribs):
127
- bg = _contrib_color(c, v_max)
128
- spans.append(
129
- f'<span style="background:{bg};padding:2px 4px;border-radius:4px;'
130
- f'margin:0 1px;" title="Δ={c:+.4f}">{html.escape(tok)}</span>'
131
- )
132
- return (
133
- '<div style="font-size:15px;line-height:2;color:#212529;'
134
- 'font-family:system-ui, -apple-system, sans-serif;padding:4px;">'
135
- + " ".join(spans)
136
- + "</div>"
137
- )
138
-
139
-
140
- def _top_tokens_table_html(
141
- tokens: list[str], contribs: list[float], k: int = 5
142
- ) -> str:
143
- pairs = list(zip(tokens, contribs))
144
- pos = sorted([p for p in pairs if p[1] > 0], key=lambda x: -x[1])[:k]
145
- neg = sorted([p for p in pairs if p[1] < 0], key=lambda x: x[1])[:k]
146
-
147
- def _row(tok: str, v: float, side: str) -> str:
148
- color = "#1b4332" if side == "pos" else "#9d0208"
149
- sign = "+" if v > 0 else ""
150
- return (
151
- f'<tr><td style="padding:5px 8px;color:{color};">'
152
- f"{html.escape(tok)}</td>"
153
- f'<td style="padding:5px 8px;text-align:right;color:{color};'
154
- f'font-variant-numeric:tabular-nums;">{sign}{v:.4f}</td></tr>'
155
- )
156
-
157
- empty = '<tr><td colspan="2" style="padding:6px;color:#9aa1aa;"><em>—</em></td></tr>'
158
- pos_rows = "".join(_row(t, v, "pos") for t, v in pos) or empty
159
- neg_rows = "".join(_row(t, v, "neg") for t, v in neg) or empty
160
-
161
- return f"""
162
- <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:12px;
163
- font-family:system-ui, -apple-system, sans-serif;">
164
- <div style="background:#fcfcfd;border:1px solid #eef2f7;border-radius:12px;padding:12px;">
165
- <div style="font-size:13px;font-weight:700;color:#1b4332;margin-bottom:6px;">
166
- Empurram para útil
167
- </div>
168
- <table style="width:100%;border-collapse:collapse;font-size:13px;">{pos_rows}</table>
169
- </div>
170
- <div style="background:#fcfcfd;border:1px solid #eef2f7;border-radius:12px;padding:12px;">
171
- <div style="font-size:13px;font-weight:700;color:#9d0208;margin-bottom:6px;">
172
- Empurram para não-útil
173
- </div>
174
- <table style="width:100%;border-collapse:collapse;font-size:13px;">{neg_rows}</table>
175
- </div>
176
- </div>
177
- """
178
-
179
-
180
- # ---------------------------------------------------------------------------
181
- # Handlers — retornam HTML para a UI + JSON para a API
182
- # ---------------------------------------------------------------------------
183
- def handle_predict(text: str):
184
- text = (text or "").strip()
185
- if not text:
186
- return "<em>Forneça um texto.</em>", {"error": "empty_input"}
187
- if not MODEL_READY:
188
- err = MODEL_ERROR or "modelo indisponível"
189
- return (
190
- f"<em>Modelo indisponível: {html.escape(err)}</em>",
191
- {"error": "model_unavailable", "detail": err},
192
- )
193
-
194
- p = predict_one(text)
195
- return (
196
- _score_card_html(p),
197
- {
198
- "proba_util": p,
199
- "label": _label(p),
200
- "confidence_band": _confidence_band(p),
201
- },
202
- )
203
-
204
-
205
- def handle_explain(text: str):
206
- text = (text or "").strip()
207
- if not text:
208
- return "<em>Forneça um texto.</em>", "", "", {"error": "empty_input"}
209
- if not MODEL_READY:
210
- err = MODEL_ERROR or "modelo indisponível"
211
- return (
212
- f"<em>Modelo indisponível: {html.escape(err)}</em>",
213
- "",
214
- "",
215
- {"error": "model_unavailable", "detail": err},
216
- )
217
-
218
- result = explain_occlusion(text)
219
- p = result["proba_full"]
220
- tokens = result["tokens"]
221
- contribs = result["contributions"]
222
-
223
- return (
224
- _score_card_html(p),
225
- _highlighted_text_html(tokens, contribs),
226
- _top_tokens_table_html(tokens, contribs),
227
- {
228
- "proba_util": p,
229
- "label": _label(p),
230
- "confidence_band": _confidence_band(p),
231
- "tokens": tokens,
232
- "contributions": contribs,
233
- },
234
- )
235
-
236
-
237
- # ---------------------------------------------------------------------------
238
- # UI
239
- # ---------------------------------------------------------------------------
240
- EXAMPLE_UTIL = (
241
- "Segundo dados oficiais do Ministério da Saúde, o número citado no tweet é falso. "
242
- "A fonte correta pode ser conferida no link: https://www.gov.br/saude/..."
243
- )
244
- EXAMPLE_NAO = "Essa nota é claramente desnecessária, é opinião pessoal do autor."
245
-
246
- INTRO_MD = """
247
- # Notinhas — endpoint de utilidade (FT-Solo)
248
-
249
- Classificador de utilidade para **community notes em português**, baseado em
250
- **Qwen3-Embedding-4B + LoRA + cabeça linear** (modo fiel do FT-Solo, fold 01).
251
-
252
- - **Prever** — score + label + faixa de confiança.
253
- - **Explicar** — o mesmo + contribuição de cada palavra via leave-one-out.
254
- - **Sobre** — detalhes técnicos e limitações.
255
- """
256
-
257
-
258
- with gr.Blocks(
259
- title="Notinhas — endpoint de utilidade (FT-Solo)",
260
- theme=gr.themes.Base(),
261
- css=CUSTOM_CSS,
262
- ) as demo:
263
- gr.Markdown(INTRO_MD)
264
-
265
- if not MODEL_READY:
266
- gr.Markdown(
267
- f"""
268
- > ⚠️ **Modelo não carregou.** Detalhe: `{html.escape(MODEL_ERROR or '')}`
269
- >
270
- > Verifique que `artifacts/fold_01_adapter/` e `artifacts/fold_01_head.pt` estão presentes
271
- > no repositório do Space. Se o modelo base exigir autenticação, configure `HF_TOKEN` em
272
- > **Settings → Variables and secrets**.
273
- """
274
- )
275
-
276
- with gr.Tab("Prever"):
277
- with gr.Row():
278
- with gr.Column(scale=2):
279
- inp_p = gr.Textbox(
280
- label="Texto da nota",
281
- placeholder="Cole aqui o texto em português...",
282
- lines=7,
283
- max_lines=25,
284
- )
285
- btn_p = gr.Button("Prever", variant="primary")
286
- gr.Examples(examples=[[EXAMPLE_UTIL], [EXAMPLE_NAO]], inputs=[inp_p])
287
- with gr.Column(scale=3):
288
- out_card_p = gr.HTML(label="Resultado")
289
- out_json_p = gr.JSON(label="Resposta da API")
290
-
291
- btn_p.click(
292
- handle_predict,
293
- inputs=[inp_p],
294
- outputs=[out_card_p, out_json_p],
295
- api_name="predict",
296
- )
297
-
298
- with gr.Tab("Explicar"):
299
- with gr.Row():
300
- with gr.Column(scale=2):
301
- inp_e = gr.Textbox(
302
- label="Texto da nota",
303
- placeholder="Cole aqui o texto em português...",
304
- lines=7,
305
- max_lines=25,
306
- )
307
- btn_e = gr.Button("Explicar", variant="primary")
308
- gr.Examples(examples=[[EXAMPLE_UTIL], [EXAMPLE_NAO]], inputs=[inp_e])
309
- with gr.Column(scale=3):
310
- out_card_e = gr.HTML(label="Resultado")
311
- out_hl = gr.HTML(label="Contribuição por palavra")
312
- out_tbl = gr.HTML(label="Top tokens por lado")
313
- out_json_e = gr.JSON(label="Resposta da API")
314
-
315
- btn_e.click(
316
- handle_explain,
317
- inputs=[inp_e],
318
- outputs=[out_card_e, out_hl, out_tbl, out_json_e],
319
- api_name="explain",
320
- )
321
-
322
- with gr.Tab("Sobre"):
323
- gr.Markdown(
324
- f"""
325
- ### Detalhes técnicos
326
-
327
- - **Modelo base**: `Qwen/Qwen3-Embedding-4B` (embedding, 2.560 dims, last-token pooling).
328
- - **Adaptação**: LoRA treinado com alvo `label_binary_strict` (recorte A do projeto).
329
- - **Cabeça**: `nn.Linear(2560, 1)` → sigmoid.
330
- - **Prompt de instrução** (idêntico ao treino):
331
-
332
- > `Instruct: Represent the following Brazilian Portuguese community note for binary classification of helpfulness.`
333
- > `Query: <texto>`
334
-
335
- - **max_length**: 256 tokens.
336
- - **Dispositivo atual**: `{DEVICE}`.
337
- - **Fold servido**: 01 (melhor fold segundo o manifesto do pipeline).
338
-
339
- ### Método de explicação
340
-
341
- A aba **Explicar** usa **occlusion word-level** (leave-one-out): para cada palavra
342
- separada por espaço, calculamos `Δ = P(texto completo) − P(texto sem a palavra)`.
343
-
344
- - Δ positivo ⇒ palavra puxando para **útil** (verde).
345
- - Δ negativo ⇒ palavra puxando para **não-útil** (coral).
346
-
347
- É uma aproximação rápida do SHAP Partition usado no notebook de explicabilidade
348
- (~1–2 s vs ~12–15 s em GPU), com resultados visualmente comparáveis para notas curtas.
349
-
350
- ### Limitações
351
-
352
- - O rótulo `helpful` mede **aceitabilidade bipartidária**, não qualidade editorial.
353
- A galeria curada do notebook mostra casos onde vizinhos semânticos idênticos
354
- recebem rótulos opostos por razões políticas.
355
- - Textos são truncados em 256 tokens.
356
- - Este endpoint serve um único fold. Para produção com ganho marginal de robustez,
357
- subir para ensemble dos 5 folds (média de probabilidades).
358
- """
359
- )
360
-
361
-
362
- if __name__ == "__main__":
363
- demo.queue(default_concurrency_limit=1).launch(
364
- server_name="0.0.0.0",
365
- server_port=int(os.environ.get("PORT", 7860)),
366
- show_api=True,
367
- )
 
1
+ """Gradio app — endpoint de utilidade para community notes em PT-BR.
2
+
3
+ Expõe:
4
+ - UI web com três abas: Prever / Explicar / Sobre.
5
+ - API HTTP em /gradio_api/call/predict e /gradio_api/call/explain (gerada
6
+ automaticamente pelo Gradio a partir dos api_name).
7
+
8
+ Para clientes Python, use gradio_client:
9
+
10
+ from gradio_client import Client
11
+ c = Client("<user>/<space>", hf_token="hf_...")
12
+ score = c.predict("texto da nota...", api_name="/predict")
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import html
17
+ import logging
18
+ import os
19
+ import traceback
20
+ from pathlib import Path
21
+
22
+ import gradio as gr
23
+
24
+ from config import (
25
+ CONFIDENCE_BOUNDS_ALTA,
26
+ CONFIDENCE_BOUNDS_MEDIA,
27
+ THRESHOLD_UTIL,
28
+ )
29
+ from inference import DEVICE, explain_occlusion, predict_one, warmup
30
+
31
+ logging.basicConfig(
32
+ level=logging.INFO,
33
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
34
+ )
35
+ log = logging.getLogger("app")
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # CSS do projeto
39
+ # ---------------------------------------------------------------------------
40
+ APP_DIR = Path(__file__).resolve().parent
41
+ STYLE_PATH = APP_DIR / "styles.css"
42
+ CUSTOM_CSS = STYLE_PATH.read_text(encoding="utf-8") if STYLE_PATH.exists() else ""
43
+
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Warm-up agressivo — queremos que o primeiro request não pague cold-start
48
+ # ---------------------------------------------------------------------------
49
+ MODEL_READY: bool
50
+ MODEL_ERROR: str | None
51
+
52
+ try:
53
+ warmup()
54
+ MODEL_READY = True
55
+ MODEL_ERROR = None
56
+ log.info("Modelo carregado no startup. Device=%s", DEVICE)
57
+ except Exception as exc: # noqa: BLE001 — queremos pegar qualquer falha de carregamento
58
+ MODEL_READY = False
59
+ MODEL_ERROR = f"{type(exc).__name__}: {exc}"
60
+ log.error("Falha ao carregar modelo no startup:\n%s", traceback.format_exc())
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Helpers de apresentação
65
+ # ---------------------------------------------------------------------------
66
+ def _confidence_band(p: float) -> str:
67
+ lo_a, hi_a = CONFIDENCE_BOUNDS_ALTA
68
+ lo_m, hi_m = CONFIDENCE_BOUNDS_MEDIA
69
+ if p <= lo_a or p >= hi_a:
70
+ return "Alta"
71
+ if p <= lo_m or p >= hi_m:
72
+ return "Média"
73
+ return "Baixa"
74
+
75
+
76
+ def _label(p: float) -> str:
77
+ return "Útil" if p >= THRESHOLD_UTIL else "Não-útil"
78
+
79
+
80
+ def _score_card_html(p: float) -> str:
81
+ """Card principal do resultado — usando classes CSS do projeto."""
82
+ lbl = _label(p)
83
+ band = _confidence_band(p)
84
+
85
+ lbl_class = "notinhas-badge-util" if lbl == "Útil" else "notinhas-badge-nao-util"
86
+
87
+ if band == "Alta":
88
+ band_class = lbl_class
89
+ elif band == "Média":
90
+ band_class = "notinhas-badge-media"
91
+ else:
92
+ band_class = "notinhas-badge-baixa"
93
+
94
+ return f"""
95
+ <div class="notinhas-card">
96
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;">
97
+ <div style="display:flex;gap:8px;flex-wrap:wrap;">
98
+ <span class="notinhas-badge {lbl_class}">{lbl}</span>
99
+ <span class="notinhas-badge {band_class}">Confiança {band}</span>
100
+ </div>
101
+
102
+ <div style="text-align:right;">
103
+ <div class="notinhas-score-label">P(útil)</div>
104
+ <div class="notinhas-score-value">{p:.3f}</div>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ """
109
+
110
+
111
+ def _contrib_color(v: float, v_max: float) -> str:
112
+ if v_max <= 0:
113
+ return "transparent"
114
+ intensity = min(1.0, abs(v) / v_max)
115
+ alpha = 0.15 + 0.65 * intensity # 0.15 .. 0.80
116
+ if v > 0:
117
+ return f"rgba(95, 168, 143, {alpha:.3f})" # verde (PALETA['util'] do notebook)
118
+ return f"rgba(224, 123, 107, {alpha:.3f})" # coral (PALETA['nao_util'])
119
+
120
+
121
+ def _highlighted_text_html(tokens: list[str], contribs: list[float]) -> str:
122
+ if not tokens:
123
+ return "<em>(sem palavras para destacar)</em>"
124
+ v_max = max((abs(c) for c in contribs), default=1e-9) or 1e-9
125
+ spans = []
126
+ for tok, c in zip(tokens, contribs):
127
+ bg = _contrib_color(c, v_max)
128
+ spans.append(
129
+ f'<span style="background:{bg};padding:2px 4px;border-radius:4px;'
130
+ f'margin:0 1px;" title="Δ={c:+.6f}">{html.escape(tok)}</span>'
131
+ )
132
+ return (
133
+ '<div style="font-size:15px;line-height:2;color:#212529;'
134
+ 'font-family:system-ui, -apple-system, sans-serif;padding:4px;">'
135
+ + " ".join(spans)
136
+ + "</div>"
137
+ )
138
+
139
+
140
+ def _top_tokens_table_html(
141
+ tokens: list[str], contribs: list[float], k: int = 5
142
+ ) -> str:
143
+ pairs = list(zip(tokens, contribs))
144
+ pos = sorted([p for p in pairs if p[1] > 0], key=lambda x: -x[1])[:k]
145
+ neg = sorted([p for p in pairs if p[1] < 0], key=lambda x: x[1])[:k]
146
+
147
+ def _row(tok: str, v: float, side: str) -> str:
148
+ color = "#1b4332" if side == "pos" else "#9d0208"
149
+ return (
150
+ f'<tr><td style="padding:5px 8px;color:{color};">'
151
+ f"{html.escape(tok)}</td>"
152
+ f'<td style="padding:5px 8px;text-align:right;color:{color};'
153
+ f'font-variant-numeric:tabular-nums;">{v:+.6f}</td></tr>'
154
+ )
155
+
156
+ empty = '<tr><td colspan="2" style="padding:6px;color:#9aa1aa;"><em>—</em></td></tr>'
157
+ pos_rows = "".join(_row(t, v, "pos") for t, v in pos) or empty
158
+ neg_rows = "".join(_row(t, v, "neg") for t, v in neg) or empty
159
+
160
+ return f"""
161
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:12px;
162
+ font-family:system-ui, -apple-system, sans-serif;">
163
+ <div style="background:#fcfcfd;border:1px solid #eef2f7;border-radius:12px;padding:12px;">
164
+ <div style="font-size:13px;font-weight:700;color:#1b4332;margin-bottom:6px;">
165
+ Empurram para útil
166
+ </div>
167
+ <table style="width:100%;border-collapse:collapse;font-size:13px;">{pos_rows}</table>
168
+ </div>
169
+ <div style="background:#fcfcfd;border:1px solid #eef2f7;border-radius:12px;padding:12px;">
170
+ <div style="font-size:13px;font-weight:700;color:#9d0208;margin-bottom:6px;">
171
+ Empurram para não-útil
172
+ </div>
173
+ <table style="width:100%;border-collapse:collapse;font-size:13px;">{neg_rows}</table>
174
+ </div>
175
+ </div>
176
+ """
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Handlers — retornam HTML para a UI + JSON para a API
181
+ # ---------------------------------------------------------------------------
182
+ def handle_predict(text: str):
183
+ text = (text or "").strip()
184
+ if not text:
185
+ return "<em>Forneça um texto.</em>", {"error": "empty_input"}
186
+ if not MODEL_READY:
187
+ err = MODEL_ERROR or "modelo indisponível"
188
+ return (
189
+ f"<em>Modelo indisponível: {html.escape(err)}</em>",
190
+ {"error": "model_unavailable", "detail": err},
191
+ )
192
+
193
+ p = predict_one(text)
194
+ return (
195
+ _score_card_html(p),
196
+ {
197
+ "proba_util": p,
198
+ "label": _label(p),
199
+ "confidence_band": _confidence_band(p),
200
+ },
201
+ )
202
+
203
+
204
+ def handle_explain(text: str):
205
+ text = (text or "").strip()
206
+ if not text:
207
+ return "<em>Forneça um texto.</em>", "", "", {"error": "empty_input"}
208
+ if not MODEL_READY:
209
+ err = MODEL_ERROR or "modelo indisponível"
210
+ return (
211
+ f"<em>Modelo indisponível: {html.escape(err)}</em>",
212
+ "",
213
+ "",
214
+ {"error": "model_unavailable", "detail": err},
215
+ )
216
+
217
+ result = explain_occlusion(text)
218
+ p = result["proba_full"]
219
+ tokens = result["tokens"]
220
+ contribs = result["contributions"]
221
+
222
+ return (
223
+ _score_card_html(p),
224
+ _highlighted_text_html(tokens, contribs),
225
+ _top_tokens_table_html(tokens, contribs),
226
+ {
227
+ "proba_util": p,
228
+ "label": _label(p),
229
+ "confidence_band": _confidence_band(p),
230
+ "tokens": tokens,
231
+ "contributions": contribs,
232
+ },
233
+ )
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # UI
238
+ # ---------------------------------------------------------------------------
239
+ EXAMPLE_UTIL = (
240
+ "Segundo dados oficiais do Ministério da Saúde, o número citado no tweet é falso. "
241
+ "A fonte correta pode ser conferida no link: https://www.gov.br/saude/..."
242
+ )
243
+ EXAMPLE_NAO = "Essa nota é claramente desnecessária, é opinião pessoal do autor."
244
+
245
+ INTRO_MD = """
246
+ # Notinhas — endpoint de utilidade (FT-Solo)
247
+
248
+ Classificador de utilidade para **community notes em português**, baseado em
249
+ **Qwen3-Embedding-4B + LoRA + cabeça linear** (modo fiel do FT-Solo, fold 01).
250
+
251
+ - **Prever** — score + label + faixa de confiança.
252
+ - **Explicar** — o mesmo + contribuição de cada palavra via leave-one-out.
253
+ - **Sobre** — detalhes técnicos e limitações.
254
+ """
255
+
256
+
257
+ with gr.Blocks(
258
+ title="Notinhas — endpoint de utilidade (FT-Solo)",
259
+ theme=gr.themes.Base(),
260
+ css=CUSTOM_CSS,
261
+ ) as demo:
262
+ gr.Markdown(INTRO_MD)
263
+
264
+ if not MODEL_READY:
265
+ gr.Markdown(
266
+ f"""
267
+ > ⚠️ **Modelo não carregou.** Detalhe: `{html.escape(MODEL_ERROR or '')}`
268
+ >
269
+ > Verifique que `artifacts/fold_01_adapter/` e `artifacts/fold_01_head.pt` estão presentes
270
+ > no repositório do Space. Se o modelo base exigir autenticação, configure `HF_TOKEN` em
271
+ > **Settings Variables and secrets**.
272
+ """
273
+ )
274
+
275
+ with gr.Tab("Prever"):
276
+ with gr.Row():
277
+ with gr.Column(scale=2):
278
+ inp_p = gr.Textbox(
279
+ label="Texto da nota",
280
+ placeholder="Cole aqui o texto em português...",
281
+ lines=7,
282
+ max_lines=25,
283
+ )
284
+ btn_p = gr.Button("Prever", variant="primary")
285
+ gr.Examples(examples=[[EXAMPLE_UTIL], [EXAMPLE_NAO]], inputs=[inp_p])
286
+ with gr.Column(scale=3):
287
+ out_card_p = gr.HTML(label="Resultado")
288
+ out_json_p = gr.JSON(label="Resposta da API")
289
+
290
+ btn_p.click(
291
+ handle_predict,
292
+ inputs=[inp_p],
293
+ outputs=[out_card_p, out_json_p],
294
+ api_name="predict",
295
+ )
296
+
297
+ with gr.Tab("Explicar"):
298
+ with gr.Row():
299
+ with gr.Column(scale=2):
300
+ inp_e = gr.Textbox(
301
+ label="Texto da nota",
302
+ placeholder="Cole aqui o texto em português...",
303
+ lines=7,
304
+ max_lines=25,
305
+ )
306
+ btn_e = gr.Button("Explicar", variant="primary")
307
+ gr.Examples(examples=[[EXAMPLE_UTIL], [EXAMPLE_NAO]], inputs=[inp_e])
308
+ with gr.Column(scale=3):
309
+ out_card_e = gr.HTML(label="Resultado")
310
+ out_hl = gr.HTML(label="Contribuição por palavra")
311
+ out_tbl = gr.HTML(label="Top tokens por lado")
312
+ out_json_e = gr.JSON(label="Resposta da API")
313
+
314
+ btn_e.click(
315
+ handle_explain,
316
+ inputs=[inp_e],
317
+ outputs=[out_card_e, out_hl, out_tbl, out_json_e],
318
+ api_name="explain",
319
+ )
320
+
321
+ with gr.Tab("Sobre"):
322
+ gr.Markdown(
323
+ f"""
324
+ ### Detalhes técnicos
325
+
326
+ - **Modelo base**: `Qwen/Qwen3-Embedding-4B` (embedding, 2.560 dims, last-token pooling).
327
+ - **Adaptação**: LoRA treinado com alvo `label_binary_strict` (recorte A do projeto).
328
+ - **Cabeça**: `nn.Linear(2560, 1)` sigmoid.
329
+ - **Prompt de instrução** (idêntico ao treino):
330
+
331
+ > `Instruct: Represent the following Brazilian Portuguese community note for binary classification of helpfulness.`
332
+ > `Query: <texto>`
333
+
334
+ - **max_length**: 256 tokens.
335
+ - **Dispositivo atual**: `{DEVICE}`.
336
+ - **Fold servido**: 01 (melhor fold segundo o manifesto do pipeline).
337
+
338
+ ### Método de explicação
339
+
340
+ A aba **Explicar** usa **occlusion word-level** (leave-one-out): para cada palavra
341
+ separada por espaço, calculamos = P(texto completo) P(texto sem a palavra)`.
342
+
343
+ - Δ positivo ⇒ palavra puxando para **útil** (verde).
344
+ - Δ negativo ⇒ palavra puxando para **não-útil** (coral).
345
+
346
+ É uma aproximação rápida do SHAP Partition usado no notebook de explicabilidade
347
+ (~1–2 s vs ~12–15 s em GPU), com resultados visualmente comparáveis para notas curtas.
348
+
349
+ ### Limitações
350
+
351
+ - O rótulo `helpful` mede **aceitabilidade bipartidária**, não qualidade editorial.
352
+ A galeria curada do notebook mostra casos onde vizinhos semânticos idênticos
353
+ recebem rótulos opostos por razões políticas.
354
+ - Textos são truncados em 256 tokens.
355
+ - Este endpoint serve um único fold. Para produção com ganho marginal de robustez,
356
+ subir para ensemble dos 5 folds (média de probabilidades).
357
+ """
358
+ )
359
+
360
+
361
+ if __name__ == "__main__":
362
+ demo.queue(default_concurrency_limit=1).launch(
363
+ server_name="0.0.0.0",
364
+ server_port=int(os.environ.get("PORT", 7860)),
365
+ show_api=True,
366
+ )