"""UI Gradio do stand: chat à esquerda, painel do lens à direita. Uso: uv run python -m espelho.ui """ import html import queue import threading import time import gradio as gr try: # ZeroGPU: decorator é efeito-nulo fora do Space (verificado nos docs) import spaces _ZERO_GPU = bool(__import__("os").environ.get("SPACES_ZERO_GPU")) _gpu = spaces.GPU(duration=120) except ImportError: # ambiente local sem o pacote _ZERO_GPU = False _gpu = None from espelho import config from espelho.filters import ( aggregate_chips, baseline_blacklist, heatmap_grid, load_stopwords, ) from espelho.model import ( available_models, generate_with_trace, load_model, on_space, text_config, ) RODAPE = ( "Demonstração de pesquisa — não digite dados pessoais. " "As conversas não são armazenadas." ) NOMES = { "google/gemma-2-2b-it": "Gemma 2 (2B)", "google/gemma-3-4b-it": "Gemma 3 (4B)", "google/gemma-2-9b-it": "Gemma 2 (9B)", "google/gemma-3-12b-it": "Gemma 3 (12B)", "Qwen/Qwen3-8B": "Qwen3 (8B)", } PLACEHOLDER = "Digite aqui e pressione Enter…" CSS = """ .chatcol .message, .chatcol .message p { font-size: 20px !important; } .chatcol textarea { font-size: 20px !important; } #chips .chip { display: inline-block; font-size: 18px; padding: 6px 14px; margin: 4px; border-radius: 16px; background: #e8eaf6; border: 1px solid #9fa8da; } #chips .vazio { font-size: 18px; color: #666; } #rodape { position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; text-align: center; padding: 6px; font-size: 16px; background: #fff3cd; border-top: 1px solid #ccc; } #heatmap table { border-collapse: collapse; font-size: 14px; } #heatmap td, #heatmap th { border: 1px solid #ddd; padding: 3px 6px; text-align: center; } footer { display: none !important; } """ # A Source Sans Pro empacotada no tema padrão renderiza "s" como "ſ" (s longo) # no macOS; fontes do sistema evitam o bug e qualquer fetch remoto no stand. TEMA = gr.themes.Default( font=["-apple-system", "system-ui", "Helvetica Neue", "Arial", "sans-serif"], font_mono=["ui-monospace", "SFMono-Regular", "Menlo", "monospace"], ) def _chips_html(chips: list[dict]) -> str: if not chips: return "

Nada destacado neste turno.

" spans = [ f"" f"{html.escape(c['token'])}" for c in chips ] return "
" + "".join(spans) + "
" def _heatmap_html(grid: dict[str, dict[int, int]], band: list[int]) -> str: if not grid: return "" head = "".join(f"{l}" for l in band) rows = [] for token, cells in grid.items(): tds = [] for l in band: rank = cells.get(l) if rank is None: tds.append("") else: # rank 0 (forte) -> verde escuro; rank 24 (fraco) -> quase branco alpha = max(0.15, 1.0 - rank / config.LENS_TOP_K) tds.append( f"{rank + 1}" ) rows.append(f"{html.escape(token)}{''.join(tds)}") return ( "" + head + "" + "".join(rows) + "
token \\ camada
" ) def _explicacao(model) -> str: num_camadas = text_config(model).num_hidden_layers banda = config.layer_band(num_camadas) return ( f"Este modelo processa o texto em {num_camadas} camadas. " f"Com a técnica **logit lens**, traduzimos em palavras o estado " f"interno das camadas {banda[0]} a {banda[-1]} enquanto ele " "escrevia. Os conceitos abaixo estavam ativados nesse processo, " "mas ficaram de fora da resposta final." ) def build_app(bundles: dict[str, list], ativo_id: str | None = None): """Monta a UI sobre bundles [tokenizer, model, blacklist|None] por modelo. No ZeroGPU todos os modelos do seletor chegam carregados no boot (a emulação CUDA não intercepta cargas em runtime) e a calibração da blacklist fica para a 1ª mensagem de cada modelo (roda com GPU real). No local, a calibração pendente roda aqui e outros modelos cacheados podem ser carregados sob demanda na troca. """ stopwords = load_stopwords() if not _ZERO_GPU: for b in bundles.values(): if b[2] is None: print("Calibrando lista-negra do lens (baseline)...") b[2] = baseline_blacklist(b[0], b[1]) ativo = {"id": ativo_id or next(iter(bundles))} if on_space(): disponiveis = list(bundles) else: disponiveis = available_models() or list(bundles) ultima_atividade = {"t": time.time()} def responder(mensagem: str, historico: list[dict]): ultima_atividade["t"] = time.time() bundle = bundles[ativo["id"]] if bundle[2] is None: # calibração adiada (ZeroGPU): 1ª chamada bundle[2] = baseline_blacklist(bundle[0], bundle[1]) tok, mdl, blacklist = bundle historico = list(historico or []) mensagem = (mensagem or "").strip() if not mensagem: yield historico, gr.skip(), gr.skip(), "" return historico.append({"role": "user", "content": mensagem}) historico.append({"role": "assistant", "content": ""}) fila: queue.Queue = queue.Queue() resultado: dict = {} def gerar(): try: _, trace = generate_with_trace( tok, mdl, historico[:-1], max_new_tokens=config.MAX_NEW_TOKENS, temperature=config.TEMPERATURE, on_text=fila.put, ) resultado["trace"] = trace except Exception as exc: # robustez de stand: erro vira mensagem resultado["erro"] = str(exc) finally: fila.put(None) threading.Thread(target=gerar, daemon=True).start() while True: pedaco = fila.get() if pedaco is None: break historico[-1]["content"] += pedaco ultima_atividade["t"] = time.time() yield historico, gr.skip(), gr.skip(), "" trace = resultado.get("trace") if trace is None: historico[-1]["content"] = ( "Desculpe, algo deu errado. Toque em Recomeçar e tente de novo." ) yield historico, _chips_html([]), "", "" return chips = aggregate_chips( trace, mensagem, historico[-1]["content"], stopwords, blacklist=blacklist, ) heat = _heatmap_html(heatmap_grid(trace, chips), trace.band_layers) ultima_atividade["t"] = time.time() yield historico, _chips_html(chips), heat, "" if _gpu is not None: responder = _gpu(responder) def recomecar(): ultima_atividade["t"] = time.time() return [], "", "", "" def tique(historico): if historico and time.time() - ultima_atividade["t"] > config.IDLE_RESET_SECONDS: ultima_atividade["t"] = time.time() return [], "", "", "" return gr.skip(), gr.skip(), gr.skip(), gr.skip() def trocar_modelo(nome: str): """Saídas: chat, chips, heatmap, caixa, explicação, seletor. Durante a carga, caixa e seletor ficam desabilitados — nada digitado se perde e não há troca dupla no meio da carga. """ ultima_atividade["t"] = time.time() model_id = next((k for k, v in NOMES.items() if v == nome), nome) if model_id == ativo["id"]: yield (gr.skip(),) * 6 return if model_id not in bundles: yield ( [], "", "", gr.update(value="", interactive=False, placeholder="Aguarde: carregando o modelo…"), f"Carregando **{nome}** — pode levar alguns instantes…", gr.update(interactive=False), ) try: tok, mdl = load_model(model_id) except Exception as exc: yield ( gr.skip(), gr.skip(), gr.skip(), gr.update(interactive=True, placeholder=PLACEHOLDER), f"Não consegui carregar {nome} ({exc}). Voltando ao anterior.", gr.update(interactive=True, value=NOMES.get(ativo["id"], ativo["id"])), ) return bundles[model_id] = [ tok, mdl, None if _ZERO_GPU else baseline_blacklist(tok, mdl) ] ativo["id"] = model_id # Troca de modelo zera a conversa: traces de modelos diferentes não se misturam. yield ( [], "", "", gr.update(value="", interactive=True, placeholder=PLACEHOLDER), _explicacao(bundles[model_id][1]), gr.update(interactive=True), ) with gr.Blocks(title="Padrões de ativação") as app: with gr.Row(): gr.Markdown("# Padrões de ativação") seletor = gr.Dropdown( choices=[NOMES.get(m, m) for m in disponiveis], value=NOMES.get(ativo["id"], ativo["id"]), label="Modelo", interactive=len(disponiveis) > 1, scale=0, min_width=220, ) with gr.Row(): with gr.Column(scale=3, elem_classes=["chatcol"]): chat = gr.Chatbot(label="Conversa", height=480) caixa = gr.Textbox( label="Sua mensagem", placeholder=PLACEHOLDER, submit_btn=True, ) with gr.Row(): botao_reset = gr.Button( "Recomeçar", variant="secondary", size="sm", scale=0, ) with gr.Column(scale=2): gr.Markdown("## Conceitos ativados dentro do modelo que não apareceram na resposta") explicacao = gr.Markdown( _explicacao(bundles[ativo["id"]][1]), elem_id="explicacao" ) chips_html = gr.HTML(elem_id="chips") with gr.Accordion("ver por camada", open=False): heat_html = gr.HTML(elem_id="heatmap") gr.HTML(f"
{RODAPE}
") saidas = [chat, chips_html, heat_html, caixa] caixa.submit(responder, [caixa, chat], saidas, api_name="enviar") botao_reset.click(recomecar, [], saidas, api_name="recomecar") gr.Timer(5).tick(tique, [chat], saidas) seletor.change( trocar_modelo, [seletor], saidas + [explicacao, seletor], api_name="modelo", ) return app def main() -> None: print(f"Carregando {config.ACTIVE_MODEL} ...") tokenizer, model = load_model() app = build_app({config.ACTIVE_MODEL: [tokenizer, model, None]}) # Gradio 6: css e theme são parâmetros do launch(), não do Blocks. app.launch( server_name="127.0.0.1", server_port=7860, show_error=True, css=CSS, theme=TEMA, ) if __name__ == "__main__": main()