File size: 14,984 Bytes
233b2df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793670d
233b2df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793670d
 
 
 
 
 
233b2df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0704c04
233b2df
793670d
233b2df
0704c04
233b2df
 
 
 
 
 
 
 
 
 
 
 
793670d
 
 
 
 
 
 
 
 
233b2df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e836a67
233b2df
e836a67
233b2df
 
 
0704c04
233b2df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
"""Gradio app — endpoint de utilidade para community notes em PT-BR.

Expõe:
  - UI web com três abas: Prever / Explicar / Sobre.
  - API HTTP em /gradio_api/call/predict e /gradio_api/call/explain (gerada
    automaticamente pelo Gradio a partir dos api_name).

Para clientes Python, use gradio_client:

    from gradio_client import Client
    c = Client("<user>/<space>", hf_token="hf_...")
    score = c.predict("texto da nota...", api_name="/predict")
"""
from __future__ import annotations

import base64
import html
import logging
import os
import traceback
from pathlib import Path

import gradio as gr

from config import (
    CONFIDENCE_BOUNDS_ALTA,
    CONFIDENCE_BOUNDS_MEDIA,
    THRESHOLD_UTIL,
)
from inference import DEVICE, explain_occlusion, predict_one, warmup

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
log = logging.getLogger("app")

# ---------------------------------------------------------------------------
# CSS do projeto
# ---------------------------------------------------------------------------
APP_DIR = Path(__file__).resolve().parent
STYLE_PATH = APP_DIR / "styles.css"
CUSTOM_CSS = STYLE_PATH.read_text(encoding="utf-8") if STYLE_PATH.exists() else ""

LOGO_PATH = APP_DIR / "logo_notas_svg_suavizado_transparente.svg"
_logo_src = ""
if LOGO_PATH.exists():
    _logo_b64 = base64.b64encode(LOGO_PATH.read_bytes()).decode()
    _logo_src = f"data:image/svg+xml;base64,{_logo_b64}"



# ---------------------------------------------------------------------------
# Warm-up agressivo — queremos que o primeiro request não pague cold-start
# ---------------------------------------------------------------------------
MODEL_READY: bool
MODEL_ERROR: str | None

try:
    warmup()
    MODEL_READY = True
    MODEL_ERROR = None
    log.info("Modelo carregado no startup. Device=%s", DEVICE)
except Exception as exc:  # noqa: BLE001 — queremos pegar qualquer falha de carregamento
    MODEL_READY = False
    MODEL_ERROR = f"{type(exc).__name__}: {exc}"
    log.error("Falha ao carregar modelo no startup:\n%s", traceback.format_exc())


# ---------------------------------------------------------------------------
# Helpers de apresentação
# ---------------------------------------------------------------------------
def _confidence_band(p: float) -> str:
    lo_a, hi_a = CONFIDENCE_BOUNDS_ALTA
    lo_m, hi_m = CONFIDENCE_BOUNDS_MEDIA
    if p <= lo_a or p >= hi_a:
        return "Alta"
    if p <= lo_m or p >= hi_m:
        return "Média"
    return "Baixa"


def _label(p: float) -> str:
    return "Útil" if p >= THRESHOLD_UTIL else "Não-útil"


def _score_card_html(p: float) -> str:
    """Card principal do resultado — usando classes CSS do projeto."""
    lbl = _label(p)
    band = _confidence_band(p)

    lbl_class = "notinhas-badge-util" if lbl == "Útil" else "notinhas-badge-nao-util"

    if band == "Alta":
        band_class = lbl_class
    elif band == "Média":
        band_class = "notinhas-badge-media"
    else:
        band_class = "notinhas-badge-baixa"

    return f"""
    <div class="notinhas-card">
      <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;">
        <div style="display:flex;gap:8px;flex-wrap:wrap;">
          <span class="notinhas-badge {lbl_class}">{lbl}</span>
          <span class="notinhas-badge {band_class}">Confiança {band}</span>
        </div>

        <div style="text-align:right;">
          <div class="notinhas-score-label">P(útil)</div>
          <div class="notinhas-score-value">{p:.4f}</div>
        </div>
      </div>
    </div>
    """


def _contrib_color(v: float, v_max: float) -> str:
    if v_max <= 0:
        return "transparent"
    intensity = min(1.0, abs(v) / v_max)
    alpha = 0.15 + 0.65 * intensity  # 0.15 .. 0.80
    if v > 0:
        return f"rgba(95, 168, 143, {alpha:.3f})"  # verde (PALETA['util'] do notebook)
    return f"rgba(224, 123, 107, {alpha:.3f})"  # coral (PALETA['nao_util'])


def _highlighted_text_html(tokens: list[str], contribs: list[float]) -> str:
    if not tokens:
        return "<em>(sem palavras para destacar)</em>"
    v_max = max((abs(c) for c in contribs), default=1e-9) or 1e-9
    spans = []
    for tok, c in zip(tokens, contribs):
        bg = _contrib_color(c, v_max)
        spans.append(
            f'<span style="background:{bg};padding:2px 4px;border-radius:4px;'
            f'margin:0 1px;" title="Δ={c:+.6f}">{html.escape(tok)}</span>'
        )
    return (
        '<div style="font-size:15px;line-height:2;color:#212529;'
        'font-family:system-ui, -apple-system, sans-serif;padding:4px;">'
        + " ".join(spans)
        + "</div>"
    )


def _top_tokens_table_html(
    tokens: list[str], contribs: list[float], k: int = 5
) -> str:
    pairs = list(zip(tokens, contribs))
    pos = sorted([p for p in pairs if p[1] > 0], key=lambda x: -x[1])[:k]
    neg = sorted([p for p in pairs if p[1] < 0], key=lambda x: x[1])[:k]

    def _row(tok: str, v: float, side: str) -> str:
        color = "#1b4332" if side == "pos" else "#9d0208"
        return (
            f'<tr><td style="padding:5px 8px;color:{color};">'
            f"{html.escape(tok)}</td>"
            f'<td style="padding:5px 8px;text-align:right;color:{color};'
            f'font-variant-numeric:tabular-nums;">{v:+.6f}</td></tr>'
        )

    empty = '<tr><td colspan="2" style="padding:6px;color:#9aa1aa;"><em>—</em></td></tr>'
    pos_rows = "".join(_row(t, v, "pos") for t, v in pos) or empty
    neg_rows = "".join(_row(t, v, "neg") for t, v in neg) or empty

    all_same_side = (not neg and pos) or (not pos and neg)
    if not neg and pos:
        side_warning = (
            '<p style="font-size:12px;color:#6c757d;margin:10px 4px 0 4px;line-height:1.5;">'
            '⚠️ <strong>Nenhuma palavra puxando para não-útil identificada.</strong> '
            'O método leave-one-out compara a frase completa com cada ablação de uma palavra. '
            'Quando todas as contribuições são positivas, a frase completa pontua '
            'marginalmente <em>mais</em> do que qualquer subconjunto — comum em textos '
            'muito curtos ou frases com sentido idiomático. '
            'O texto permanece Não-útil porque P(útil) está longe do limiar (0.5); '
            'o que o define é a <em>ausência</em> de características úteis '
            '(fontes, dados, neutralidade), não palavras negativas específicas.'
            '</p>'
        )
    elif not pos and neg:
        side_warning = (
            '<p style="font-size:12px;color:#6c757d;margin:10px 4px 0 4px;line-height:1.5;">'
            '⚠️ <strong>Nenhuma palavra puxando para útil identificada.</strong> '
            'Todas as palavras reduzem marginalmente P(útil) quando presentes.'
            '</p>'
        )
    else:
        side_warning = ""

    return f"""
    <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:12px;
                font-family:system-ui, -apple-system, sans-serif;">
      <div style="background:#fcfcfd;border:1px solid #eef2f7;border-radius:12px;padding:12px;">
        <div style="font-size:13px;font-weight:700;color:#1b4332;margin-bottom:6px;">
          Empurram para útil
        </div>
        <table style="width:100%;border-collapse:collapse;font-size:13px;">{pos_rows}</table>
      </div>
      <div style="background:#fcfcfd;border:1px solid #eef2f7;border-radius:12px;padding:12px;">
        <div style="font-size:13px;font-weight:700;color:#9d0208;margin-bottom:6px;">
          Empurram para não-útil
        </div>
        <table style="width:100%;border-collapse:collapse;font-size:13px;">{neg_rows}</table>
      </div>
    </div>
    """ + side_warning


# ---------------------------------------------------------------------------
# Handlers — retornam HTML para a UI + JSON para a API
# ---------------------------------------------------------------------------
def handle_predict(text: str):
    text = (text or "").strip()
    if not text:
        return "<em>Forneça um texto.</em>", {"error": "empty_input"}
    if not MODEL_READY:
        err = MODEL_ERROR or "modelo indisponível"
        return (
            f"<em>Modelo indisponível: {html.escape(err)}</em>",
            {"error": "model_unavailable", "detail": err},
        )

    p = predict_one(text)
    return (
        _score_card_html(p),
        {
            "proba_util": p,
            "label": _label(p),
            "confidence_band": _confidence_band(p),
        },
    )


def handle_explain(text: str):
    text = (text or "").strip()
    if not text:
        return "<em>Forneça um texto.</em>", "", "", {"error": "empty_input"}
    if not MODEL_READY:
        err = MODEL_ERROR or "modelo indisponível"
        return (
            f"<em>Modelo indisponível: {html.escape(err)}</em>",
            "",
            "",
            {"error": "model_unavailable", "detail": err},
        )

    result = explain_occlusion(text)
    p = result["proba_full"]
    tokens = result["tokens"]
    contribs = result["contributions"]

    return (
        _score_card_html(p),
        _highlighted_text_html(tokens, contribs),
        _top_tokens_table_html(tokens, contribs),
        {
            "proba_util": p,
            "label": _label(p),
            "confidence_band": _confidence_band(p),
            "tokens": tokens,
            "contributions": contribs,
        },
    )


# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------
EXAMPLE_UTIL = (
    "Segundo dados oficiais do Ministério da Saúde, o número citado no tweet é falso. "
    "A fonte correta pode ser conferida no link: https://www.gov.br/saude/..."
)
EXAMPLE_NAO = "Essa nota é claramente desnecessária, é opinião pessoal do autor."

_APP_TITLE = "Notinhas — endpoint de utilidade"

INTRO_MD = """
Classificador de utilidade para **community notes em português**, baseado em
**bge-m3 (568M params) + LoRA + cabeça linear** (calibrado via Platt scaling).

- **Prever** — score + label + faixa de confiança.
- **Explicar** — o mesmo + contribuição de cada palavra via leave-one-out.
- **Sobre** — detalhes técnicos e limitações.
"""


with gr.Blocks(
    title="Notinhas — endpoint de utilidade (FT-Solo)",
    theme=gr.themes.Base(),
    css=CUSTOM_CSS,
) as demo:
    if _logo_src:
        gr.HTML(
            '<div style="display:flex;align-items:center;gap:20px;padding:12px 0 4px;">'
            f'<img src="{_logo_src}" style="height:80px;width:auto;flex-shrink:0;" alt="Notinhas">'
            f'<h1 style="margin:0;font-size:1.75em;font-weight:700;line-height:1.2;">{_APP_TITLE}</h1>'
            '</div>'
        )
    else:
        gr.Markdown(f"# {_APP_TITLE}")
    gr.Markdown(INTRO_MD)

    if not MODEL_READY:
        gr.Markdown(
            f"""
> ⚠️ **Modelo não carregou.** Detalhe: `{html.escape(MODEL_ERROR or '')}`
>
> Verifique que `artifacts/fold_01_adapter/` e `artifacts/fold_01_head.pt` estão presentes
> no repositório do Space. Se o modelo base exigir autenticação, configure `HF_TOKEN` em
> **Settings → Variables and secrets**.
"""
        )

    with gr.Tab("Prever"):
        with gr.Row():
            with gr.Column(scale=2):
                inp_p = gr.Textbox(
                    label="Texto da nota",
                    placeholder="Cole aqui o texto em português...",
                    lines=7,
                    max_lines=25,
                )
                btn_p = gr.Button("Prever", variant="primary")
                gr.Examples(examples=[[EXAMPLE_UTIL], [EXAMPLE_NAO]], inputs=[inp_p])
            with gr.Column(scale=3):
                out_card_p = gr.HTML(label="Resultado")
                out_json_p = gr.JSON(label="Resposta da API")

        btn_p.click(
            handle_predict,
            inputs=[inp_p],
            outputs=[out_card_p, out_json_p],
            api_name="predict",
        )

    with gr.Tab("Explicar"):
        with gr.Row():
            with gr.Column(scale=2):
                inp_e = gr.Textbox(
                    label="Texto da nota",
                    placeholder="Cole aqui o texto em português...",
                    lines=7,
                    max_lines=25,
                )
                btn_e = gr.Button("Explicar", variant="primary")
                gr.Examples(examples=[[EXAMPLE_UTIL], [EXAMPLE_NAO]], inputs=[inp_e])
            with gr.Column(scale=3):
                out_card_e = gr.HTML(label="Resultado")
                out_hl = gr.HTML(label="Contribuição por palavra")
                out_tbl = gr.HTML(label="Top tokens por lado")
                out_json_e = gr.JSON(label="Resposta da API")

        btn_e.click(
            handle_explain,
            inputs=[inp_e],
            outputs=[out_card_e, out_hl, out_tbl, out_json_e],
            api_name="explain",
        )

    with gr.Tab("Sobre"):
        gr.Markdown(
            f"""
### Detalhes técnicos

- **Modelo base**: `BAAI/bge-m3` (embedding, 1.024 dims, mean pooling, 568M params).
- **Adaptação**: LoRA treinado com alvo `label_binary_strict` (recorte A do projeto).
- **Fold servido**: `fold_04` (melhor fold segundo o manifesto do pipeline).
- **Cabeça**: `nn.Linear(1024, 1)` → sigmoid.
- **Calibração**: Platt scaling pós-treino — `P_calib = sigmoid(CALIB_A × logit(P_raw) + CALIB_B)`. Com os defaults `CALIB_A=1.0, CALIB_B=0.0` equivale a identidade; ajuste em `config.py` com base num conjunto de validação.
- **Prompt de instrução**: nenhum — texto cru (bge-m3 não usa prefix de instrução).
- **max_length**: 256 tokens.
- **Dispositivo atual**: `{DEVICE}`.


### Método de explicação

A aba **Explicar** usa **occlusion word-level** (leave-one-out): para cada palavra
separada por espaço, calculamos `Δ = P(texto completo) − P(texto sem a palavra)`.

- Δ positivo ⇒ palavra puxando para **útil** (verde).
- Δ negativo ⇒ palavra puxando para **não-útil** (coral).

É uma aproximação rápida do SHAP Partition usado no notebook de explicabilidade
(~1–2 s vs ~12–15 s em GPU), com resultados visualmente comparáveis para notas curtas.

### Limitações

- O rótulo `helpful` mede **aceitabilidade bipartidária**, não qualidade editorial.
  A galeria curada do notebook mostra casos onde vizinhos semânticos idênticos
  recebem rótulos opostos por razões políticas.
- Textos são truncados em 256 tokens.
- Este endpoint serve um único fold. Para produção com ganho marginal de robustez,
  subir para ensemble dos 5 folds (média de probabilidades).
"""
        )


if __name__ == "__main__":
    demo.queue(default_concurrency_limit=1).launch(
        server_name="0.0.0.0",
        server_port=int(os.environ.get("PORT", 7860)),
        show_api=True,
    )