TemporalGNN / app.py
Danielfonseca1212's picture
Create app.py
1ed07bc verified
# app.py — TGN Fraud Detection | Temporal Graph Network E-commerce
import streamlit as st
import numpy as np
import torch
import os
from datetime import datetime, timedelta
import time
import random
st.set_page_config(
page_title="TGN Fraud — E-commerce",
page_icon="⏱️",
layout="wide",
initial_sidebar_state="expanded"
)
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;700&family=Fira+Code:wght@400;500&display=swap');
html, body, [class*="css"] { font-family: 'Space Grotesk', sans-serif; background:#07090f; color:#e2e8f0; }
h1,h2,h3 { font-weight: 700; }
code { font-family: 'Fira Code', monospace !important; }
.card {
background: linear-gradient(145deg, #0d1117, #111827);
border: 1px solid #1f2937; border-radius: 14px; padding: 20px;
}
.metric-val { font-size: 2.2rem; font-weight: 700; }
.metric-lbl { font-size: 0.7rem; color: #6b7280; text-transform: uppercase; letter-spacing: 1.5px; }
.fraud-pill { background:#ff4444;color:#fff;padding:3px 12px;border-radius:20px;font-size:.8rem;font-weight:700; }
.safe-pill { background:#22c55e;color:#fff;padding:3px 12px;border-radius:20px;font-size:.8rem;font-weight:700; }
.warn-pill { background:#f59e0b;color:#fff;padding:3px 12px;border-radius:20px;font-size:.8rem;font-weight:700; }
.event-row {
display:flex; align-items:center; gap:12px;
padding:10px 14px; border-radius:10px; margin:4px 0;
border-left:3px solid #1f2937; font-size:.85rem;
}
.event-fraud { border-left-color:#ef4444; background:#1a0a0a; }
.event-safe { border-left-color:#22c55e; background:#071a0f; }
.mem-bar-bg { background:#1f2937; border-radius:4px; height:6px; margin:3px 0; }
.mem-bar { height:6px; border-radius:4px; background:linear-gradient(90deg,#3b82f6,#a78bfa); }
.stProgress > div > div { background: linear-gradient(90deg,#6366f1,#a78bfa) !important; }
</style>
""", unsafe_allow_html=True)
# ── SESSION STATE ─────────────────────────────────────────────
defaults = {
'trainer': None, 'treinado': False, 'data': None,
'df': None, 'usuarios': None, 'comerciantes': None,
'n_usuarios': 0, 'stream_eventos': [], 'stream_running': False,
'neo4j': None, 'neo4j_ok': False,
}
for k, v in defaults.items():
if k not in st.session_state:
st.session_state[k] = v
# ── NEO4J ─────────────────────────────────────────────────────
def get_neo4j_config():
cfg = {}
try:
s = st.secrets
if 'NEO4J_URI' in s:
cfg = {'uri': s['NEO4J_URI'], 'username': s['NEO4J_USERNAME'],
'password': s['NEO4J_PASSWORD'],
'database': s.get('NEO4J_DATABASE', 'neo4j')}
elif 'neo4j' in s:
n = s['neo4j']
cfg = {'uri': n.get('uri',''), 'username': n.get('username',''),
'password': n.get('password',''), 'database': n.get('database','neo4j')}
except Exception:
pass
if not cfg.get('uri'):
cfg = {'uri': os.getenv('NEO4J_URI',''), 'username': os.getenv('NEO4J_USERNAME',''),
'password': os.getenv('NEO4J_PASSWORD',''), 'database': os.getenv('NEO4J_DATABASE','neo4j')}
return cfg
@st.cache_resource
def conectar_neo4j():
try:
from neo4j import GraphDatabase
cfg = get_neo4j_config()
if not all([cfg['uri'], cfg['username'], cfg['password']]):
return None
driver = GraphDatabase.driver(cfg['uri'], auth=(cfg['username'], cfg['password']))
with driver.session(database=cfg['database']) as s:
s.run('RETURN 1')
return driver, cfg['database']
except Exception:
return None
# ── LIBS ──────────────────────────────────────────────────────
@st.cache_resource
def carregar_libs():
try:
from tgn_data import gerar_eventos_ecommerce, df_para_tensores
from tgn_model import TrainerTGN
return gerar_eventos_ecommerce, df_para_tensores, TrainerTGN
except Exception as e:
return str(e), None, None
# ── CHARTS ────────────────────────────────────────────────────
def loss_svg(hist):
lt = hist['loss_train']; lv = hist['loss_val']; av = hist['auc_val']
ep = len(lt)
if ep == 0: return ''
def pts(vals, H=120):
mn,mx = min(vals),max(vals); r = mx-mn or 1
return ' '.join(f'{i*480/max(ep-1,1):.1f},{H-(v-mn)/r*H:.1f}' for i,v in enumerate(vals))
return f"""<div class="card" style="margin-top:12px">
<div style="color:#6b7280;font-size:11px;margin-bottom:6px">
TREINO — <span style="color:#6366f1">— Loss Train</span>
<span style="color:#f59e0b;margin-left:8px">— Loss Val</span>
<span style="color:#22c55e;margin-left:8px">— AUC Val</span>
</div>
<svg viewBox="0 0 490 130" style="width:100%">
<polyline points="{pts(lt)}" fill="none" stroke="#6366f1" stroke-width="2"/>
<polyline points="{pts(lv)}" fill="none" stroke="#f59e0b" stroke-width="2"/>
<polyline points="{pts(av)}" fill="none" stroke="#22c55e" stroke-width="2"/>
<line x1="0" y1="120" x2="480" y2="120" stroke="#1f2937"/>
</svg></div>"""
def roc_svg(y_true, probs):
from sklearn.metrics import roc_curve, auc
fpr,tpr,_ = roc_curve(y_true, probs)
ra = auc(fpr,tpr)
pts = ' '.join(f'{f*460:.1f},{180-t*180:.1f}' for f,t in zip(fpr,tpr))
return f"""<div class="card">
<div style="color:#6b7280;font-size:11px;margin-bottom:6px">
ROC — AUC <b style="color:#6366f1">{ra:.3f}</b></div>
<svg viewBox="0 0 480 195" style="width:100%">
<line x1="0" y1="0" x2="460" y2="180" stroke="#1f2937" stroke-dasharray="4"/>
<polyline points="{pts}" fill="none" stroke="#6366f1" stroke-width="2.5"/>
<line x1="0" y1="180" x2="460" y2="180" stroke="#374151"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#374151"/>
<text x="230" y="194" text-anchor="middle" fill="#4b5563" font-size="10">FPR</text>
</svg></div>"""
def cm_html(cm):
tn,fp,fn,tp = cm.ravel()
items = [('#22c55e',tn,'TN','Legítimas\ncorretas'),
('#ef4444',fp,'FP','Falsos\nalarmes'),
('#f59e0b',fn,'FN','Fraudes\nperdidas'),
('#6366f1',tp,'TP','Fraudes\ncapturadas')]
html = '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">'
for c,v,a,d in items:
html += f'<div style="background:{c}15;border:1px solid {c}40;border-radius:10px;padding:14px;text-align:center"><div style="font-size:1.6rem;font-weight:700;color:{c}">{v}</div><div style="color:{c};font-size:.85rem;font-weight:600">{a}</div><div style="color:#6b7280;font-size:.7rem;white-space:pre-line">{d}</div></div>'
return html + '</div>'
def memoria_html(mem_snap, n_show=20):
"""Visualiza vetores de memória dos usuários como heatmap de barras."""
usuarios_mem = mem_snap['usuarios'][:n_show]
last_upd = mem_snap['last_update'][:n_show]
html = '<div class="card"><div style="color:#6b7280;font-size:11px;margin-bottom:12px">MEMÓRIA DOS NÓS — cada linha é um usuário, cada barra é uma dimensão da memória</div>'
for i, (mem, lu) in enumerate(zip(usuarios_mem, last_upd)):
# Normaliza para [0,1]
mn, mx = mem.min(), mem.max()
norm = (mem - mn) / (mx - mn + 1e-8)
# Mostra primeiras 20 dimensões como barras coloridas
bars = ''
for v in norm[:20]:
pct = float(v) * 100
color = f'hsl({int(220 + v*120)},70%,55%)'
bars += f'<div style="flex:1;height:14px;background:{color};opacity:.85;border-radius:2px"></div>'
ativo = lu > 0
dot = '🟢' if ativo else '⚫'
html += f'<div style="margin:4px 0"><div style="display:flex;align-items:center;gap:6px"><span style="font-size:.72rem;color:#6b7280;width:60px">{dot} U{i:03d}</span><div style="display:flex;flex:1;gap:1px">{bars}</div></div></div>'
html += '</div>'
return html
def timeline_html(eventos):
"""Linha do tempo dos últimos eventos do stream."""
if not eventos:
return '<p style="color:#4b5563">Nenhum evento ainda. Inicie o stream.</p>'
html = ''
for ev in reversed(eventos[-15:]):
cls = 'event-fraud' if ev['fraude'] else 'event-safe'
badge = f'<span class="fraud-pill">FRAUDE {ev["prob"]:.0%}</span>' if ev['fraude'] else f'<span class="safe-pill">{ev["prob"]:.0%}</span>'
html += f'''<div class="event-row {cls}">
<span style="color:#6b7280;font-size:.75rem;min-width:55px">{ev["hora"]}</span>
<span>👤 U{ev["uid"]:03d}</span>
<span style="color:#6b7280">→</span>
<span>🏪 M{ev["mid"]:02d}</span>
<span style="color:#a78bfa">R$ {ev["valor"]:.0f}</span>
<span style="color:#6b7280">{ev["categoria"]}</span>
{badge}
</div>'''
return html
# ── SIDEBAR ───────────────────────────────────────────────────
def sidebar():
st.sidebar.markdown('## ⏱️ TGN Config')
n_usuarios = st.sidebar.slider('Usuários', 100, 500, 300, 50)
n_comerciantes = st.sidebar.slider('Comerciantes', 20, 150, 80, 10)
n_eventos = st.sidebar.slider('Eventos treino', 1000, 5000, 3000, 500)
taxa_fraude = st.sidebar.slider('Taxa fraude %', 3, 20, 8)
st.sidebar.markdown('---')
st.sidebar.markdown('### Hiperparâmetros')
memory_dim = st.sidebar.select_slider('Memory dim', [32, 64, 128], 64)
time_dim = st.sidebar.select_slider('Time dim', [16, 32, 64], 32)
lr = st.sidebar.select_slider('LR', [0.0001, 0.0005, 0.001], 0.0005)
epocas = st.sidebar.slider('Épocas', 10, 60, 25, 5)
st.sidebar.markdown('---')
if st.session_state.neo4j_ok:
st.sidebar.success('🗄️ Neo4j Conectado')
else:
st.sidebar.warning('⚠️ Neo4j Offline')
if st.session_state.treinado and st.session_state.trainer:
st.sidebar.metric('Melhor AUC', f"{st.session_state.trainer.melhor_auc:.3f}")
return dict(n_usuarios=n_usuarios, n_comerciantes=n_comerciantes,
n_eventos=n_eventos, taxa_fraude=taxa_fraude/100,
memory_dim=memory_dim, time_dim=time_dim, lr=lr, epocas=epocas)
# ── MAIN ──────────────────────────────────────────────────────
def main():
if st.session_state.neo4j is None:
conn = conectar_neo4j()
st.session_state.neo4j = conn
st.session_state.neo4j_ok = conn is not None
cfg = sidebar()
st.markdown("""
<div style="margin-bottom:28px">
<h1 style="font-size:2.2rem;margin:0;background:linear-gradient(90deg,#6366f1,#a78bfa,#ec4899);
-webkit-background-clip:text;-webkit-text-fill-color:transparent">
TGN Fraud Detection
</h1>
<p style="color:#6b7280;margin:4px 0 0 2px;font-size:.9rem">
Temporal Graph Network · E-commerce · Memória Evolutiva dos Nós
</p>
</div>
""", unsafe_allow_html=True)
tabs = st.tabs(['📦 Dados', '🧠 Treinar', '📊 Performance', '🧠 Memória dos Nós', '⚡ Stream ao Vivo', '🗄️ Neo4j'])
# ── TAB 0: DADOS ──────────────────────────────────────────
with tabs[0]:
res = carregar_libs()
if isinstance(res[0], str):
st.error(f'Erro: {res[0]}')
st.stop()
gerar_eventos, df_para_tensores, TrainerTGN = res
col1, col2 = st.columns([1, 2])
with col1:
st.markdown('### Padrões de Fraude')
for icone, nome, desc in [
('⚡','Velocity Attack','Muitas tx em pouco tempo'),
('🃏','Card Testing','Pequena tx antes de grande'),
('🌙','Night Transaction','Compras de madrugada'),
('🛒','Categoria Anômala','Merchant incomum para o usuário'),
]:
st.markdown(f'**{icone} {nome}** — {desc}')
st.markdown('### Arquitetura TGN')
st.markdown("""
```
Evento (src, dst, t, feats)
TimeEncoder(Δt) → cos embedding
MessageFunction → msg
GRU MemoryUpdater → nova memória
TemporalEmbedding → node repr
MLP Classifier → P(fraude)
```
""")
if st.button('🔄 Gerar Dados', type='primary', use_container_width=True):
with st.spinner('Gerando eventos de e-commerce...'):
df, usuarios, comerciantes, n_u = gerar_eventos(
n_usuarios=cfg['n_usuarios'],
n_comerciantes=cfg['n_comerciantes'],
n_eventos=cfg['n_eventos'],
taxa_fraude=cfg['taxa_fraude'],
)
data = df_para_tensores(df, n_u, cfg['n_comerciantes'])
st.session_state.df = df
st.session_state.data = data
st.session_state.usuarios = usuarios
st.session_state.comerciantes= comerciantes
st.session_state.n_usuarios = n_u
st.session_state.treinado = False
st.session_state.trainer = None
st.success('✅ Dados gerados!')
with col2:
if st.session_state.df is not None:
df = st.session_state.df
data = st.session_state.data
m1,m2,m3,m4 = st.columns(4)
n_fraudes = int(df['label'].sum())
for col, val, lbl in [
(m1, len(df), 'Eventos'),
(m2, n_fraudes, '🚨 Fraudes'),
(m3, f"{n_fraudes/len(df):.1%}", 'Taxa fraude'),
(m4, data['n_nos'], 'Nós no grafo'),
]:
col.markdown(f'<div class="card" style="text-align:center"><div class="metric-val" style="color:#6366f1">{val}</div><div class="metric-lbl">{lbl}</div></div>', unsafe_allow_html=True)
st.markdown('<br>', unsafe_allow_html=True)
# Distribuição de fraudes por categoria
st.markdown('#### Fraudes por Categoria')
fraudes_cat = df[df['label']==1]['categoria'].value_counts()
legit_cat = df[df['label']==0]['categoria'].value_counts()
cats = list(fraudes_cat.index)
W = 440
max_v = max(fraudes_cat.max(), legit_cat.max())
bars_svg = f'<svg viewBox="0 0 {W} {len(cats)*36+20}" style="width:100%;background:#0d1117;border-radius:10px;padding:8px">'
for i, cat in enumerate(cats):
y = i * 36 + 10
fv = fraudes_cat.get(cat, 0)
lv = legit_cat.get(cat, 0)
fw = int(fv / max_v * (W-120))
lw = int(lv / max_v * (W-120))
bars_svg += (
f'<text x="115" y="{y+10}" text-anchor="end" fill="#9ca3af" font-size="11">{cat}</text>'
f'<rect x="120" y="{y}" width="{lw}" height="10" fill="#22c55e" opacity=".7" rx="2"/>'
f'<rect x="120" y="{y+12}" width="{fw}" height="10" fill="#ef4444" opacity=".8" rx="2"/>'
f'<text x="{120+lw+3}" y="{y+10}" fill="#22c55e" font-size="9">{lv}</text>'
f'<text x="{120+fw+3}" y="{y+22}" fill="#ef4444" font-size="9">{fv}</text>'
)
bars_svg += '</svg>'
st.markdown(bars_svg, unsafe_allow_html=True)
st.caption('🟢 Legítimas 🔴 Fraudes')
else:
st.info('Configure parâmetros e clique **Gerar Dados**.')
# ── TAB 1: TREINAR ────────────────────────────────────────
with tabs[1]:
_, _, TrainerTGN = carregar_libs()
if st.session_state.data is None:
st.warning('⬅️ Gere os dados primeiro.')
else:
col1, col2 = st.columns([1, 2])
with col1:
st.markdown('### Memory dim')
st.markdown(f'`{cfg["memory_dim"]}` dimensões por nó')
st.markdown(f'`{st.session_state.data["n_nos"]}` nós no total')
total_mem = st.session_state.data['n_nos'] * cfg['memory_dim'] * 4
st.markdown(f'Memória total: `{total_mem/1024:.1f} KB`')
st.markdown('### Time Encoding')
st.markdown(f'`{cfg["time_dim"]}` frequências cossenoidais')
st.markdown('`Δt` → comportamento temporal')
if st.button('🚀 Treinar TGN', type='primary', use_container_width=True):
st.session_state.trainer = TrainerTGN(
st.session_state.data,
memory_dim=cfg['memory_dim'],
time_dim=cfg['time_dim'],
lr=cfg['lr'],
)
prog = st.progress(0)
status = st.empty()
logs = []
log_box = col2.empty()
def cb(ep, total, loss, auc, f1):
prog.progress(ep/total)
status.markdown(f'**Época {ep}/{total}** · Loss `{loss:.4f}` · AUC `{auc:.3f}` · F1 `{f1:.3f}`')
if ep % 5 == 0 or ep == total:
logs.append(f'[{ep:>3}] loss={loss:.4f} auc={auc:.3f} f1={f1:.3f}')
log_box.code('\n'.join(logs[-12:]))
with st.spinner('Treinando TGN...'):
st.session_state.trainer.treinar(cfg['epocas'], cb)
st.session_state.treinado = True
st.success(f'✅ Melhor AUC: {st.session_state.trainer.melhor_auc:.3f}')
with col2:
if st.session_state.treinado:
hist = st.session_state.trainer.historico
st.components.v1.html(loss_svg(hist), height=200)
# ── TAB 2: PERFORMANCE ────────────────────────────────────
with tabs[2]:
if not st.session_state.treinado:
st.warning('⬅️ Treine o modelo primeiro.')
else:
m = st.session_state.trainer.metricas_completas()
cols = st.columns(5)
for col, (nome, val) in zip(cols, [
('ROC-AUC', f"{m['auc']:.3f}"),
('Avg Prec', f"{m['ap']:.3f}"),
('F1', f"{m['f1']:.3f}"),
('Precision', f"{m['precision']:.3f}"),
('Recall', f"{m['recall']:.3f}"),
]):
col.markdown(f'<div class="card" style="text-align:center"><div class="metric-val" style="color:#6366f1">{val}</div><div class="metric-lbl">{nome}</div></div>', unsafe_allow_html=True)
st.markdown('<br>', unsafe_allow_html=True)
c1, c2 = st.columns(2)
with c1:
st.markdown('### Matriz de Confusão')
st.markdown(cm_html(m['cm']), unsafe_allow_html=True)
with c2:
st.components.v1.html(roc_svg(m['y_true'], m['probs']), height=240)
# ── TAB 3: MEMÓRIA DOS NÓS ───────────────────────────────
with tabs[3]:
if not st.session_state.treinado:
st.warning('⬅️ Treine o modelo primeiro.')
else:
st.markdown('### 🧠 Estado da Memória — o que o TGN "lembra" de cada usuário')
st.markdown("""
Cada linha é um usuário. Cada barra colorida é uma dimensão do vetor de memória.
**Usuários com padrão de fraude** tendem a ter memória com ativações distintas.
A memória evolui a cada transação via GRU — captura comportamento histórico.
""")
n_show = st.slider('Usuários para mostrar', 10, 50, 25)
mem = st.session_state.trainer.get_memory_snapshot(
st.session_state.n_usuarios)
# Estatísticas da memória
usuarios_mem = mem['usuarios']
normas = np.linalg.norm(usuarios_mem, axis=1)
top_ativos = np.argsort(normas)[::-1]
c1, c2, c3 = st.columns(3)
c1.metric('Usuários com memória ativa', int((normas > 0.1).sum()))
c2.metric('Norma média da memória', f'{normas.mean():.3f}')
c3.metric('Usuário mais ativo', f'U{top_ativos[0]:03d}')
st.markdown('<br>', unsafe_allow_html=True)
# Ordenar por norma (mais ativos primeiro)
idx_sorted = np.argsort(normas)[::-1][:n_show]
mem_sorted = {
'usuarios': usuarios_mem[idx_sorted],
'last_update': mem['last_update'][idx_sorted],
}
st.components.v1.html(memoria_html(mem_sorted, n_show), height=n_show*26+80)
# t-SNE da memória
with st.expander('📊 t-SNE da memória dos usuários'):
try:
from sklearn.manifold import TSNE
tsne = TSNE(n_components=2, random_state=42,
perplexity=min(30, len(usuarios_mem)//2))
coords = tsne.fit_transform(usuarios_mem)
df_ = st.session_state.df
# Usuários com mais fraudes
fraud_per_user = df_.groupby('src')['label'].mean()
colors = [fraud_per_user.get(i, 0) for i in range(len(usuarios_mem))]
# SVG scatter
cx = coords[:,0]; cy = coords[:,1]
mn_x,mx_x = cx.min(),cx.max(); mn_y,mx_y = cy.min(),cy.max()
def sc(v,mn,mx,W): return (v-mn)/(mx-mn+1e-8)*W
circles = ''
for i,(x,y,c) in enumerate(zip(cx,cy,colors)):
px = sc(x,mn_x,mx_x,480); py = sc(y,mn_y,mx_y,280)
hue = int(120*(1-c))
circles += f'<circle cx="{px:.1f}" cy="{py:.1f}" r="5" fill="hsl({hue},70%,50%)" opacity=".75" title="U{i}"/>'
svg = f'<svg viewBox="0 0 480 280" style="width:100%;background:#0d1117;border-radius:10px">{circles}<text x="10" y="20" fill="#6b7280" font-size="11">🟢 Baixo risco 🔴 Alto risco histórico</text></svg>'
st.components.v1.html(svg, height=300)
except Exception as e:
st.info(f't-SNE indisponível: {e}')
# ── TAB 4: STREAM AO VIVO ─────────────────────────────────
with tabs[4]:
if not st.session_state.treinado:
st.warning('⬅️ Treine o modelo primeiro.')
else:
st.markdown('### ⚡ Simulação de Stream em Tempo Real')
st.markdown('Gera transações aleatórias e classifica com o TGN treinado, atualizando a memória dos nós a cada evento.')
col1, col2 = st.columns([1, 2])
with col1:
n_gerar = st.number_input('Eventos a simular', 5, 100, 20)
if st.button('▶️ Simular Stream', type='primary', use_container_width=True):
data = st.session_state.data
usuarios = st.session_state.usuarios
comerciantes = st.session_state.comerciantes
n_u = st.session_state.n_usuarios
trainer = st.session_state.trainer
categorias = ['eletronicos','viagem','alimentacao','roupas','jogos','farmacia']
novos = []
prog = st.progress(0)
for i in range(int(n_gerar)):
uid = random.randint(0, n_u-1)
mid = random.randint(0, len(comerciantes)-1)
u = usuarios[uid]
m = comerciantes[mid]
cat = m['categoria']
valor = max(1.0, np.random.lognormal(5, 1))
hora = random.randint(0, 23)
ts = float(i) / n_gerar
feats = [
np.log1p(valor)/12.0,
hora/24.0,
1.0 if hora < 6 else 0.0,
1.0 if cat=='eletronicos' else 0.0,
1.0 if cat=='viagem' else 0.0,
0.0,
u['score_credito'],
min(u['tempo_cliente_dias']/1000, 1.0),
m['risco_setor'],
]
prob, mem_src = trainer.inferir_evento(uid, mid+n_u, ts, feats)
novos.append({
'uid': uid, 'mid': mid, 'valor': valor,
'categoria': cat, 'hora': f'{hora:02d}h',
'prob': prob, 'fraude': prob > 0.5,
'mem_norm': float(np.linalg.norm(mem_src)),
})
prog.progress((i+1)/n_gerar)
time.sleep(0.05)
st.session_state.stream_eventos = (
st.session_state.stream_eventos + novos)[-50:]
st.success(f'✅ {int(n_gerar)} eventos processados · '
f'{sum(1 for e in novos if e["fraude"])} fraudes detectadas')
with col2:
eventos = st.session_state.stream_eventos
if eventos:
n_fraud = sum(1 for e in eventos if e['fraude'])
m1,m2,m3 = st.columns(3)
m1.metric('Eventos', len(eventos))
m2.metric('Fraudes', n_fraud)
m3.metric('Taxa', f'{n_fraud/len(eventos):.1%}')
st.markdown('<br>', unsafe_allow_html=True)
st.components.v1.html(
f'<div style="background:#07090f;padding:8px;border-radius:10px">'
f'{timeline_html(eventos)}</div>',
height=420, scrolling=True)
else:
st.info('Clique em **Simular Stream** para ver eventos em tempo real.')
# ── TAB 5: NEO4J ─────────────────────────────────────────
with tabs[5]:
st.header('🗄️ Neo4j')
if not st.session_state.neo4j_ok:
st.warning('Neo4j offline.')
with st.expander('Como configurar'):
st.markdown("""
**HF Spaces → Settings → Variables and secrets:**
| Chave | Valor |
|---|---|
| `NEO4J_URI` | `neo4j+s://XXXXXXXX.databases.neo4j.io` |
| `NEO4J_USERNAME` | `neo4j` |
| `NEO4J_PASSWORD` | `sua_senha` |
| `NEO4J_DATABASE` | `neo4j` |
""")
else:
st.success('Conectado!')
if st.session_state.treinado and st.button('💾 Salvar métricas no Neo4j'):
driver, db = st.session_state.neo4j
m = st.session_state.trainer.metricas_completas()
try:
with driver.session(database=db) as s:
s.run("""
MERGE (r:TGNRun {ts: $ts})
SET r.auc=$auc, r.f1=$f1, r.ap=$ap,
r.n_eventos=$n, r.memory_dim=$mem
""", ts=datetime.now().isoformat(),
auc=float(m['auc']), f1=float(m['f1']),
ap=float(m['ap']),
n=len(st.session_state.df),
mem=cfg['memory_dim'])
st.success('✅ Salvo!')
except Exception as e:
st.error(str(e))
if __name__ == '__main__':
main()