"""
app.py —
CIRCET SLA (dash.R → Python + Gradio) + RAG NVIDIA NIM
==============================================================
✅ O que foi pedido e foi feito:
- Mantive o layout do 2º script (header, CSS, tabela, KPIs, exportação, legenda) SEM alterações.
- Apenas ADICIONEI uma nova aba "Assistente IA (RAG)" igual ao estilo do 1º script.
- O RAG aqui é o mesmo conceito do 1º script: contexto estruturado (CONTEXTO_RAG) + LLM via NVIDIA NIM.
- NÃO mexi no design do dashboard. Só encapsulei o dashboard numa Tab (sem mudar o conteúdo).
Requisitos:
pip install gradio pandas numpy openai
Estrutura esperada:
./upload/
tarefasss_datas_corrigidas_final.csv
emcurso.csv
licenciamento.csv
validado.csv
./output/ (criado automaticamente)
Chave NVIDIA:
- Pode vir de env: NVIDIA_API_KEY
- Ou ser inserida no campo na aba do assistente
"""
import os
import datetime
import pandas as pd
import numpy as np
import gradio as gr
from openai import OpenAI
# ── Paths ──────────────────────────────────────────────────────────────────────
BASE = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.abspath(BASE)
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'output')
os.makedirs(OUTPUT_DIR, exist_ok=True)
# ── Chave API NVIDIA NIM ───────────────────────────────────────────────────────
NVIDIA_API_KEY_ENV = os.environ.get('NVIDIA_API_KEY', '').strip()
MODELOS_NVIDIA = [
"meta/llama-3.3-70b-instruct",
"meta/llama-3.1-70b-instruct",
"meta/llama-3.1-8b-instruct",
"mistralai/mistral-7b-instruct-v0.3",
"mistralai/mixtral-8x7b-instruct-v0.1",
"microsoft/phi-3-mini-128k-instruct",
"google/gemma-2-9b-it",
]
PERGUNTAS_SUGERIDAS = [
"📊 Faz um resumo executivo completo do estado actual do portfolio SLA",
"🚨 Quais são os 3 tipos de tarefa mais críticos neste momento e que acções recomendas?",
"📈 Compara o desempenho SLA entre as categorias Em Curso, Licenciamento e Validado",
"⚠️ Quais os projectos em curso com maior risco de incumprimento SLA nas próximas semanas?",
"🔴 Identifica os gargalos operacionais cruzando tipo de tarefa com status actual",
"📉 Qual o tipo de tarefa com maior taxa de incumprimento e qual o % SLA médio?",
"📈 Qual o tipo de tarefa com melhor desempenho SLA? O que pode explicar esse resultado?",
"📊 Faz uma tabela comparativa de todos os tipos com: total, excedidos, taxa e % SLA médio",
"⏰ Lista os 10 projectos com maior desvio SLA e o número de dias de atraso",
"📅 Analisa as adjudicações por mês: há meses com maior volume e pior desempenho?",
"💡 Que 3 acções correctivas prioritárias recomendas para melhorar a taxa de cumprimento global?",
]
def criar_cliente_nvidia(api_key: str) -> OpenAI:
"""Cliente OpenAI compatível com a API NVIDIA NIM."""
return OpenAI(
base_url="https://integrate.api.nvidia.com/v1",
api_key=api_key
)
# ── SLA fixo por TIPO (tabela da imagem) ──────────────────────────────────────
SLA_MAP = {
'ART 2 3' : 30,
'RAMI' : 30,
'CUIVRE' : 30,
'R?COLEMENTS' : 0,
'PAR' : 10,
'FIBRE' : 30,
'RACCO' : 30,
'IMMO' : 30,
'DESSAT' : 10,
'DISSIM - POI1': 30,
'DISSIM - POI2': 15,
'DISSIM - POI3': 15,
'DISSIM' : 30,
}
TIPO_ORDER = [
'ART 2 3', 'RAMI', 'CUIVRE', 'R?COLEMENTS', 'PAR', 'FIBRE',
'RACCO', 'IMMO', 'DESSAT', 'DISSIM - POI1', 'DISSIM - POI2',
'DISSIM - POI3', 'DISSIM'
]
TIPO_LABEL = {
'ART 2 3' : 'ART 2 3',
'RAMI' : 'RAMI',
'CUIVRE' : 'CUIVRE',
'R?COLEMENTS' : 'RÉCOLEMENTS',
'PAR' : 'PAR',
'FIBRE' : 'FIBRE',
'RACCO' : 'RACCO',
'IMMO' : 'IMMO',
'DESSAT' : 'DESSAT',
'DISSIM - POI1': 'DISSIM - POI1',
'DISSIM - POI2': 'DISSIM - POI2',
'DISSIM - POI3': 'DISSIM - POI3',
'DISSIM' : 'DISSIM',
}
# ── Cores por categoria ────────────────────────────────────────────────────────
CAT_CORES = {
'EM CURSO' : ('#0D47A1', '#1565C0'),
'LICENCIAMENTO': ('#4A148C', '#6A1B9A'),
'VALIDADO' : ('#1B5E20', '#2E7D32'),
'GLOBAL' : ('#212121', '#37474F'),
}
# ── Leitura dos CSVs de categoria ─────────────────────────────────────────────
def ler_status_csv(path):
for enc in ('utf-8', 'latin-1', 'cp1252'):
try:
result = []
with open(path, encoding=enc) as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split(',')
if len(parts) >= 3:
result.append(parts[2].strip())
elif len(parts) == 2:
result.append(parts[1].strip())
return [s for s in result if s]
except UnicodeDecodeError:
continue
except FileNotFoundError:
return []
return []
EM_CURSO_STATUS = set(ler_status_csv(os.path.join(BASE, 'emcurso.csv')))
VALIDADO_STATUS = set(ler_status_csv(os.path.join(BASE, 'validado.csv')))
LICENCIAMENTO_STATUS = set(ler_status_csv(os.path.join(BASE, 'licenciamento.csv')))
STATUS_EXTRA_MAP = {
'02.1 PROJETO POR ADJUDICAR' : 'EM CURSO',
'02.10 PRE VALIDA??O PROJETO' : 'EM CURSO',
'02.2 PROJETO EM CURSO' : 'EM CURSO',
'02.3 PROJETO PENDENTE CLIENTE': 'EM CURSO',
'02.4 AGUARDA DEVIS' : 'LICENCIAMENTO',
'02.6 AGUARDA PMV+DT' : 'LICENCIAMENTO',
'04 VALIDA??O ORANGE' : 'EM CURSO',
'05 PROJETO VALIDADO' : 'VALIDADO',
'06 TRABALHOS EM CURSO' : 'EM CURSO',
'10 CANCELADO' : 'VALIDADO',
'11 FATURADO' : 'VALIDADO',
'8.3 AGUARDA RT' : 'VALIDADO',
}
def get_categoria(status: str) -> str:
s = str(status).strip()
if s in VALIDADO_STATUS: return 'VALIDADO'
if s in LICENCIAMENTO_STATUS: return 'LICENCIAMENTO'
if s in EM_CURSO_STATUS: return 'EM CURSO'
return STATUS_EXTRA_MAP.get(s, 'GLOBAL')
def calcular_faixa(pct):
if pd.isna(pct): return 'N/A'
if pct < 50: return '< 50 %'
elif pct <= 75: return '50 % < X ≤ 75 %'
elif pct <= 100: return '75 % < X ≤ 100 %'
else: return '> 100 %'
# ── Carregar e processar dados (lógica do dash.R) ─────────────────────────────
def carregar_dados(caminho_csv: str) -> pd.DataFrame:
df_raw = pd.read_csv(caminho_csv, sep=';', encoding='latin-1', on_bad_lines='skip')
df_raw.rename(columns={df_raw.columns[9]: 'TEMPO_EXECUCAO'}, inplace=True)
dftipoadj = df_raw[[
'SUB-CIP', 'PROJETO', 'TIPO', 'RB STATUS',
'TEMPO_EXECUCAO', 'DATA_ADJ_CLIENTE'
]].copy()
dd = dftipoadj[dftipoadj['TIPO'].isin(list(SLA_MAP.keys()))].copy()
dd = dd.sort_values('DATA_ADJ_CLIENTE').reset_index(drop=True)
dd['DATA_ADJ_CLIENTE'] = pd.to_datetime(
dd['DATA_ADJ_CLIENTE'], format='%d/%m/%Y', errors='coerce'
)
dd['TEMPO_EXECUCAO'] = pd.to_numeric(
dd['TEMPO_EXECUCAO'].astype(str).str.strip(), errors='coerce'
)
hoje = pd.Timestamp.today().normalize()
dd['DATA_PREVISTA'] = dd['DATA_ADJ_CLIENTE'] + pd.to_timedelta(dd['TEMPO_EXECUCAO'], unit='D')
dd['ATUAL'] = (hoje - dd['DATA_PREVISTA']).dt.days
dd['DIFDIAS'] = dd['TEMPO_EXECUCAO'] - dd['ATUAL']
dd['SLA_FIXO'] = dd['TIPO'].map(SLA_MAP)
dd['TIPO_LABEL'] = dd['TIPO'].map(TIPO_LABEL).fillna(dd['TIPO'])
dd['CATEGORIA'] = dd['RB STATUS'].apply(get_categoria)
dd['PCT_SLA'] = np.where(
(dd['SLA_FIXO'] > 0) & (dd['TEMPO_EXECUCAO'] >= 0),
(dd['TEMPO_EXECUCAO'] / dd['SLA_FIXO'] * 100).round(1),
np.nan
)
dd['FAIXA_SLA'] = dd['PCT_SLA'].apply(calcular_faixa)
dd['DATA_CALCULO'] = hoje.strftime('%Y-%m-%d')
return dd
# Carregar dados na inicialização
CSV_PATH = os.path.join(BASE, 'tarefasss_datas_corrigidas_final.csv')
DF_GLOBAL = carregar_dados(CSV_PATH)
# ── Construir tabela pivot para uma categoria ──────────────────────────────────
def build_pivot(df: pd.DataFrame, categoria: str) -> pd.DataFrame:
if categoria == 'GLOBAL':
sub = df.copy()
else:
sub = df[df['CATEGORIA'] == categoria].copy()
rows = []
for tipo in TIPO_ORDER:
sub_t = sub[sub['TIPO'] == tipo]
row = {
'TIPOS' : TIPO_LABEL.get(tipo, tipo),
'SLA [dias]' : SLA_MAP.get(tipo, 0),
'< 50 % [uni]' : int((sub_t['FAIXA_SLA'] == '< 50 %').sum()),
'50 % < X ≤ 75 % [uni]' : int((sub_t['FAIXA_SLA'] == '50 % < X ≤ 75 %').sum()),
'75 % < X ≤ 100 % [uni]' : int((sub_t['FAIXA_SLA'] == '75 % < X ≤ 100 %').sum()),
'> 100 % [uni]' : int((sub_t['FAIXA_SLA'] == '> 100 %').sum()),
'TOTAL' : len(sub_t),
}
rows.append(row)
return pd.DataFrame(rows)
# ── Estatísticas de resumo ─────────────────────────────────────────────────────
def get_stats(categoria: str) -> dict:
if categoria == 'GLOBAL':
sub = DF_GLOBAL.copy()
else:
sub = DF_GLOBAL[DF_GLOBAL['CATEGORIA'] == categoria]
total = len(sub)
validos = sub[sub['SLA_FIXO'] > 0]
dentro = int((validos['FAIXA_SLA'].isin(['< 50 %', '50 % < X ≤ 75 %', '75 % < X ≤ 100 %'])).sum())
excedido = int((validos['FAIXA_SLA'] == '> 100 %').sum())
pct_ok = round(dentro / len(validos) * 100, 1) if len(validos) > 0 else 0.0
return {'total': total, 'dentro': dentro, 'excedido': excedido, 'pct_ok': pct_ok}
# ── Renderizar tabela HTML com design profissional ────────────────────────────
def render_html_table(pivot: pd.DataFrame, categoria: str) -> str:
cor_dark, cor_mid = CAT_CORES.get(categoria, ('#212121', '#37474F'))
faixa_header_bg = ['#1B5E20', '#E65100', '#BF360C', '#B71C1C']
faixa_cell = [
('#E8F5E9', '#1B5E20'),
('#FFF8E1', '#E65100'),
('#FBE9E7', '#BF360C'),
('#FFEBEE', '#B71C1C'),
]
faixa_cols = [
'< 50 % [uni]',
'50 % < X ≤ 75 % [uni]',
'75 % < X ≤ 100 % [uni]',
'> 100 % [uni]',
]
faixa_labels = [
'< 50 %',
'50 % < X ≤ 75 %',
'75 % < X ≤ 100 %',
'> 100 %',
]
html = f"""
| TIPOS |
SLA[dias] |
"""
for label, bg in zip(faixa_labels, faixa_header_bg):
html += (f' '
f'{label}[uni] | \n')
html += ' TOTAL | \n
\n \n \n'
for _, row in pivot.iterrows():
sla_val = row['SLA [dias]']
sla_str = str(int(sla_val)) if sla_val > 0 else '—'
html += f' \n | {row["TIPOS"]} | \n'
html += f' {sla_str} | \n'
for col, (bg, fg) in zip(faixa_cols, faixa_cell):
val = int(row[col])
if val == 0:
html += ' — | \n'
else:
html += (f' '
f'{val}'
f' | \n')
total = int(row['TOTAL'])
html += f' {total} | \n
\n'
html += ' \n
\n
'
return html
# ── Renderizar KPI cards HTML ─────────────────────────────────────────────────
def render_kpi_html(stats: dict, categoria: str) -> str:
cor_dark, cor_mid = CAT_CORES.get(categoria, ('#212121', '#37474F'))
pct = stats['pct_ok']
if pct >= 80:
taxa_cor, taxa_bg = '#1B5E20', '#E8F5E9'
elif pct >= 60:
taxa_cor, taxa_bg = '#E65100', '#FFF8E1'
else:
taxa_cor, taxa_bg = '#B71C1C', '#FFEBEE'
html = f"""
Total de Tarefas
{stats['total']}
registos processados
Dentro do SLA
{stats['dentro']}
≤ 100 % SLA
SLA Excedido
{stats['excedido']}
> 100 % SLA
Taxa de Cumprimento
{pct} %
tarefas dentro do SLA
"""
return html
# ── Exportações ────────────────────────────────────────────────────────────────
def exportar_csv_pivot(categoria: str) -> str:
pivot = build_pivot(DF_GLOBAL, categoria)
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
nome = f"sla_pivot_{categoria.lower().replace(' ', '_')}_{ts}.csv"
path = os.path.join(OUTPUT_DIR, nome)
pivot.to_csv(path, index=False, encoding='utf-8-sig', sep=';')
return path
def exportar_csv_fact(categoria: str) -> str:
sub = DF_GLOBAL.copy() if categoria == 'GLOBAL' else DF_GLOBAL[DF_GLOBAL['CATEGORIA'] == categoria].copy()
fact = sub[[
'SUB-CIP', 'PROJETO', 'TIPO', 'TIPO_LABEL', 'RB STATUS', 'CATEGORIA',
'DATA_ADJ_CLIENTE', 'DATA_PREVISTA', 'TEMPO_EXECUCAO',
'ATUAL', 'DIFDIAS', 'SLA_FIXO', 'PCT_SLA', 'FAIXA_SLA', 'DATA_CALCULO'
]].copy()
fact['DATA_ADJ_CLIENTE'] = fact['DATA_ADJ_CLIENTE'].dt.strftime('%d/%m/%Y')
fact['DATA_PREVISTA'] = fact['DATA_PREVISTA'].dt.strftime('%d/%m/%Y')
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
nome = f"sla_fact_{categoria.lower().replace(' ', '_')}_{ts}.csv"
path = os.path.join(OUTPUT_DIR, nome)
fact.to_csv(path, index=False, encoding='utf-8-sig', sep=';')
return path
# ── Actualizar vista principal ─────────────────────────────────────────────────
def atualizar_vista(categoria: str):
pivot = build_pivot(DF_GLOBAL, categoria)
tabela_html = render_html_table(pivot, categoria)
stats = get_stats(categoria)
kpi_html = render_kpi_html(stats, categoria)
return tabela_html, kpi_html
# ═══════════════════════════════════════════════════════════════════════════════
# ── RAG (igual ao 1º script): contexto estruturado + LLM NVIDIA ───────────────
# ═══════════════════════════════════════════════════════════════════════════════
def gerar_contexto_rag() -> str:
"""
Contexto estruturado dos dados do dashboard.
(Mesmo estilo do 1º script: rico, com cruzamentos, riscos, aging, tendências)
"""
hoje_ts = pd.Timestamp.today().normalize()
hoje_str = hoje_ts.strftime('%d/%m/%Y')
df = DF_GLOBAL.copy()
linhas = []
linhas.append("=" * 70)
linhas.append("DASHBOARD SLA — CONTEXTO COMPLETO (CIRCET)")
linhas.append("=" * 70)
linhas.append(f"Data de referência : {hoje_str}")
linhas.append(f"Total de registos : {len(df)}")
linhas.append(f"Tipos de tarefa : {', '.join(sorted(df['TIPO_LABEL'].unique()))}")
linhas.append(f"Categorias activas : EM CURSO ({(df['CATEGORIA']=='EM CURSO').sum()}) | "
f"LICENCIAMENTO ({(df['CATEGORIA']=='LICENCIAMENTO').sum()}) | "
f"VALIDADO ({(df['CATEGORIA']=='VALIDADO').sum()}) | "
f"GLOBAL ({(df['CATEGORIA']=='GLOBAL').sum()})")
linhas.append("")
# KPIs por categoria
linhas.append("-" * 70)
linhas.append("KPIs EXECUTIVOS (por categoria)")
linhas.append("-" * 70)
for cat in ['GLOBAL', 'EM CURSO', 'LICENCIAMENTO', 'VALIDADO']:
stats = get_stats(cat)
sub = df if cat == 'GLOBAL' else df[df['CATEGORIA'] == cat]
n_sla = sub[sub['SLA_FIXO'] > 0]
pct_medio = round(n_sla['PCT_SLA'].mean(), 1) if len(n_sla) > 0 else 0
linhas.append(f" [{cat}] Total={stats['total']} | Dentro SLA={stats['dentro']} ({stats['pct_ok']}%) | "
f"Excedido={stats['excedido']} | %SLA médio={pct_medio}%")
linhas.append("")
# Risco por tipo
linhas.append("-" * 70)
linhas.append("ANÁLISE DE RISCO — TIPOS ORDENADOS POR TAXA DE INCUMPRIMENTO")
linhas.append("-" * 70)
risco = df[df['SLA_FIXO'] > 0].groupby('TIPO_LABEL').agg(
total=('TIPO_LABEL', 'count'),
excedido=('FAIXA_SLA', lambda x: (x == '> 100 %').sum()),
pct_medio=('PCT_SLA', 'mean')
).reset_index()
risco['taxa_exc'] = (risco['excedido'] / risco['total'] * 100).round(1)
risco['pct_medio'] = risco['pct_medio'].round(1)
risco = risco.sort_values('taxa_exc', ascending=False)
for _, r in risco.iterrows():
nivel = "CRITICO" if r['taxa_exc'] >= 70 else ("ALTO" if r['taxa_exc'] >= 40 else ("MEDIO" if r['taxa_exc'] >= 20 else "BAIXO"))
linhas.append(
f" [{nivel}] {r['TIPO_LABEL']:<20} | Excedido: {r['excedido']}/{r['total']} ({r['taxa_exc']}%) | %SLA médio: {r['pct_medio']}%"
)
linhas.append("")
# Aging (top 20)
linhas.append("-" * 70)
linhas.append("AGING — TOP 20 PROJECTOS COM MAIOR EXCESSO DE SLA")
linhas.append("-" * 70)
excedidos = df[df['FAIXA_SLA'] == '> 100 %'].copy()
excedidos = excedidos.sort_values('PCT_SLA', ascending=False).head(20)
for _, row in excedidos.iterrows():
data_adj = row['DATA_ADJ_CLIENTE'].strftime('%d/%m/%Y') if pd.notna(row['DATA_ADJ_CLIENTE']) else 'N/D'
dias_atraso = int(row['ATUAL']) if pd.notna(row['ATUAL']) else 0
linhas.append(
f" {row['PROJETO']:<14} [{row['CATEGORIA']:<13}] "
f"Tipo: {row['TIPO_LABEL']:<16} Status: {row['RB STATUS']:<30} "
f"% SLA: {row['PCT_SLA']:>6}% | Atraso: {dias_atraso:>4}d | Adj: {data_adj}"
)
linhas.append("")
# Gargalos: tipo x status em excedidos
linhas.append("-" * 70)
linhas.append("GARGALOS — TIPO × STATUS (excedidos)")
linhas.append("-" * 70)
cross = df[df['FAIXA_SLA'] == '> 100 %'].groupby(['TIPO_LABEL', 'RB STATUS']).size().reset_index(name='n_excedidos')
cross = cross.sort_values('n_excedidos', ascending=False).head(25)
for _, r in cross.iterrows():
linhas.append(f" {r['TIPO_LABEL']:<20} + {r['RB STATUS']:<35} → {r['n_excedidos']} excedidos")
linhas.append("")
# Volume por mês (últimos 12)
linhas.append("-" * 70)
linhas.append("ADJUDICAÇÕES POR MÊS (últimos 12 meses disponíveis)")
linhas.append("-" * 70)
df_datas = df[df['DATA_ADJ_CLIENTE'].notna()].copy()
if len(df_datas) > 0:
df_datas['MES_ADJ'] = df_datas['DATA_ADJ_CLIENTE'].dt.to_period('M')
mes_counts = df_datas.groupby('MES_ADJ').agg(
total=('PROJETO', 'count'),
excedido=('FAIXA_SLA', lambda x: (x == '> 100 %').sum())
).reset_index().sort_values('MES_ADJ', ascending=False).head(12)
for _, r in mes_counts.iterrows():
taxa = round(r['excedido'] / r['total'] * 100, 1) if r['total'] > 0 else 0
linhas.append(f" {str(r['MES_ADJ']):<10} | Adjudicados: {r['total']:3d} | Excedidos: {r['excedido']:3d} ({taxa}%)")
else:
linhas.append(" (sem datas válidas em DATA_ADJ_CLIENTE)")
linhas.append("")
# Resumo executivo automático
linhas.append("-" * 70)
linhas.append("RESUMO EXECUTIVO AUTOMÁTICO")
linhas.append("-" * 70)
total_g = len(df)
exc_g = int((df['FAIXA_SLA'] == '> 100 %').sum())
taxa_g = round(exc_g / total_g * 100, 1) if total_g > 0 else 0.0
tipo_pior = risco.iloc[0]['TIPO_LABEL'] if len(risco) > 0 else 'N/D'
taxa_pior = risco.iloc[0]['taxa_exc'] if len(risco) > 0 else 0
linhas.append(f" Portfolio total : {total_g} tarefas")
linhas.append(f" Taxa incumprimento : {taxa_g}% ({exc_g} tarefas com SLA > 100%)")
linhas.append(f" Tipo mais crítico : {tipo_pior} ({taxa_pior}% de incumprimento)")
linhas.append("")
return "\n".join(linhas)
# Pré-calcular o contexto
CONTEXTO_RAG = gerar_contexto_rag()
def responder_pergunta(pergunta: str, historico: list, api_key: str, modelo: str) -> tuple:
if not api_key or not api_key.strip():
historico = historico + [
{"role": "user", "content": pergunta},
{"role": "assistant", "content": "Por favor, insira a sua chave API da NVIDIA NIM no campo acima para usar o assistente."}
]
return historico, ""
if not pergunta or not pergunta.strip():
return historico, ""
try:
client = criar_cliente_nvidia(api_key.strip())
system_prompt = f"""Você é um Gestor de Projecto Sénior com mais de 15 anos de experiência em gestão de portfolios de telecomunicações, especializado em controlo SLA, análise de risco operacional e reporte executivo.
O seu papel é analisar os dados reais do dashboard SLA e responder com rigor. Interprete números, identifique padrões, riscos e proponha acções.
Responda SEMPRE em português europeu (Portugal), linguagem profissional e directa. Use números exactos sempre que possível.
--- DADOS DO DASHBOARD SLA ---
{CONTEXTO_RAG}
--- FIM DOS DADOS ---
Regras:
- Estruture: Situação → Análise → Recomendação (quando aplicável)
- Use tabelas markdown para comparações com 3+ itens
- Classifique risco: 🔴 CRÍTICO (≥70%) | 🟠 ALTO (40-69%) | 🟡 MÉDIO (20-39%) | 🟢 BAIXO (<20%)
- Se não houver dados para responder, diga claramente o que falta
"""
messages = [{"role": "system", "content": system_prompt}]
for msg in historico[-10:]:
messages.append({"role": msg["role"], "content": msg["content"]})
messages.append({"role": "user", "content": pergunta})
response = client.chat.completions.create(
model=modelo,
messages=messages,
temperature=0.2,
max_tokens=1500,
)
resposta = response.choices[0].message.content
historico = historico + [
{"role": "user", "content": pergunta},
{"role": "assistant", "content": resposta}
]
return historico, ""
except Exception as e:
erro = str(e)
if "401" in erro or "Unauthorized" in erro or "invalid_api_key" in erro.lower():
msg_erro = "❌ Chave API inválida ou sem autorização. Verifique a sua chave NVIDIA NIM."
elif "429" in erro or "rate_limit" in erro.lower():
msg_erro = "⏳ Limite de pedidos atingido. Aguarde alguns segundos e tente novamente."
elif "model" in erro.lower() and "not found" in erro.lower():
msg_erro = f"❌ Modelo '{modelo}' não encontrado. Tente selecionar outro modelo."
else:
msg_erro = f"❌ Erro ao contactar a API NVIDIA NIM: {erro}"
historico = historico + [
{"role": "user", "content": pergunta},
{"role": "assistant", "content": msg_erro}
]
return historico, ""
def limpar_chat():
return [], ""
def perguntas_rapidas(pergunta_selecionada: str) -> str:
return pergunta_selecionada
# ── CSS global (IGUAL AO SEU 2º SCRIPT) ────────────────────────────────────────
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
body, .gradio-container {
font-family: 'Inter', 'Segoe UI', Arial, sans-serif !important;
background: #F0F2F8 !important;
}
.gradio-container {
max-width: 1280px !important;
margin: 0 auto !important;
padding: 0 !important;
}
.app-header {
background: linear-gradient(135deg, #0D47A1 0%, #1565C0 50%, #1976D2 100%);
padding: 28px 36px 22px;
border-radius: 0 0 16px 16px;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(13,71,161,0.25);
}
.app-header * {
color: #ffffff !important;
}
.app-header h1 {
margin: 0 0 6px;
font-size: 26px;
font-weight: 800;
letter-spacing: -0.3px;
color: #ffffff !important;
}
.app-header p {
margin: 0;
font-size: 13px;
font-weight: 400;
color: #ffffff !important;
opacity: 0.92;
}
.cat-selector label {
font-weight: 700 !important;
font-size: 12px !important;
text-transform: uppercase !important;
letter-spacing: 0.6px !important;
color: #546e7a !important;
margin-bottom: 8px !important;
}
.cat-selector .wrap { gap: 10px !important; }
.cat-selector input[type=radio] + span {
border-radius: 8px !important;
padding: 9px 22px !important;
font-weight: 600 !important;
font-size: 13px !important;
border: 2px solid #e0e0e0 !important;
background: white !important;
color: #37474F !important;
transition: all 0.2s !important;
cursor: pointer !important;
}
.cat-selector input[type=radio]:checked + span {
background: #0D47A1 !important;
color: white !important;
border-color: #0D47A1 !important;
box-shadow: 0 4px 12px rgba(13,71,161,0.3) !important;
}
.btn-export {
border-radius: 8px !important;
font-weight: 600 !important;
font-size: 13px !important;
padding: 10px 20px !important;
transition: all 0.2s !important;
}
.export-section {
background: white;
border-radius: 12px;
padding: 20px 24px;
box-shadow: 0 2px 10px rgba(0,0,0,0.06);
margin-top: 16px;
}
.legenda-bar {
display: flex;
gap: 20px;
align-items: center;
background: white;
border-radius: 10px;
padding: 12px 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
margin-top: 16px;
flex-wrap: wrap;
}
footer { display: none !important; }
.gr-panel, .gr-box { border-radius: 12px !important; }
"""
# ── Interface Gradio ───────────────────────────────────────────────────────────
CATEGORIAS = ['EM CURSO', 'LICENCIAMENTO', 'VALIDADO', 'GLOBAL']
DATA_REF = pd.Timestamp.today().strftime('%d/%m/%Y')
N_TOTAL = len(DF_GLOBAL)
with gr.Blocks(title="CIRCET SLA") as demo:
# ── Header — IGUAL AO SEU ────────────────────────────────────────────────
gr.HTML(f"""
""")
# ── Tabs (apenas para adicionar RAG sem mexer no layout do dashboard) ─────
with gr.Tabs():
# ── TAB 1: Dashboard (conteúdo IGUAL AO SEU) ────────────────────────
with gr.Tab("📊 Dashboard SLA"):
# Selector de categoria
with gr.Row():
cat_selector = gr.Radio(
choices=CATEGORIAS,
value='EM CURSO',
label='Categoria',
interactive=True,
elem_classes=['cat-selector'],
)
# Tabela + KPIs
with gr.Row(equal_height=True):
with gr.Column(scale=4):
tabela_out = gr.HTML()
with gr.Column(scale=1, min_width=220):
kpi_out = gr.HTML()
# Secção de exportação
gr.HTML('⬇ Exportar Dados
')
with gr.Row():
with gr.Column(scale=1):
gr.Markdown("**Pivot da categoria** — distribuição por faixas SLA")
btn_pivot = gr.Button("⬇ CSV — Tabela Pivot", variant="secondary", elem_classes=["btn-export"])
file_pivot = gr.File(label="", show_label=False)
with gr.Column(scale=1):
gr.Markdown("**Dados calculados completos** — todos os campos do dash.R")
btn_fact = gr.Button("⬇ CSV — Dados Calculados", variant="secondary", elem_classes=["btn-export"])
file_fact = gr.File(label="", show_label=False)
# Legenda
gr.HTML("""
Legenda:
< 50 % — Dentro do prazo
50 % < X ≤ 75 % — Atenção
75 % < X ≤ 100 % — Crítico
> 100 % — SLA excedido
""")
# Eventos do dashboard
cat_selector.change(fn=atualizar_vista, inputs=cat_selector, outputs=[tabela_out, kpi_out])
demo.load(fn=lambda: atualizar_vista('EM CURSO'), outputs=[tabela_out, kpi_out])
btn_pivot.click(fn=exportar_csv_pivot, inputs=cat_selector, outputs=file_pivot)
btn_fact.click(fn=exportar_csv_fact, inputs=cat_selector, outputs=file_fact)
# ── TAB 2: Assistente IA (RAG) ───────────────────────────────────────
with gr.Tab("Assistente IA (RAG)"):
gr.HTML("""
Assistente RAG — NVIDIA NIM
Faça perguntas em linguagem natural sobre os dados do dashboard SLA.
""")
with gr.Row():
with gr.Column(scale=3):
api_key_input = gr.Textbox(
label="🔑 Chave API NVIDIA NIM",
placeholder="nvapi-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
value=NVIDIA_API_KEY_ENV,
type="password",
info="Pode vir de env NVIDIA_API_KEY ou ser inserida manualmente."
)
with gr.Column(scale=2):
modelo_selector = gr.Dropdown(
choices=MODELOS_NVIDIA,
value="meta/llama-3.3-70b-instruct",
label="🧠 Modelo NVIDIA NIM"
)
with gr.Row():
perguntas_dropdown = gr.Dropdown(
choices=PERGUNTAS_SUGERIDAS,
label="💡 Perguntas sugeridas (clique para usar)",
value=None,
interactive=True,
)
chatbot = gr.Chatbot(
label="Conversa com o Assistente SLA",
height=480,
placeholder=""
"Assistente SLA com NVIDIA NIM
"
"Insira a sua chave e faça uma pergunta"
"
"
)
with gr.Row():
pergunta_input = gr.Textbox(
label="",
placeholder="Ex: Quais os tipos mais críticos? Qual a taxa global? Onde estão os gargalos?",
lines=2,
scale=5,
show_label=False,
)
with gr.Column(scale=1, min_width=120):
btn_enviar = gr.Button("Enviar ▶", variant="primary", size="lg")
btn_limpar = gr.Button("🗑 Limpar", variant="secondary")
with gr.Accordion("ℹ️ Ver contexto RAG (dados enviados ao modelo)", open=False):
gr.Textbox(
value=CONTEXTO_RAG,
label="Contexto estruturado dos dados (enviado ao modelo)",
lines=20,
interactive=False,
)
btn_enviar.click(
fn=responder_pergunta,
inputs=[pergunta_input, chatbot, api_key_input, modelo_selector],
outputs=[chatbot, pergunta_input],
)
pergunta_input.submit(
fn=responder_pergunta,
inputs=[pergunta_input, chatbot, api_key_input, modelo_selector],
outputs=[chatbot, pergunta_input],
)
btn_limpar.click(fn=limpar_chat, outputs=[chatbot, pergunta_input])
perguntas_dropdown.change(fn=perguntas_rapidas, inputs=perguntas_dropdown, outputs=pergunta_input)
if __name__ == "__main__":
demo.launch(
css=CSS,
theme=gr.themes.Base(),
allowed_paths=[OUTPUT_DIR, BASE],
)