CIRCET / app.py
roundb's picture
Update app.py
924cc3a verified
"""
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 = [
'&lt; 50&nbsp;%',
'50&nbsp;% &lt; X ≀ 75&nbsp;%',
'75&nbsp;% &lt; X ≀ 100&nbsp;%',
'&gt; 100&nbsp;%',
]
html = f"""
<style>
.sla-wrap {{
font-family: 'Inter', 'Segoe UI', Arial, sans-serif;
font-size: 13px;
}}
.sla-table {{
border-collapse: separate;
border-spacing: 0;
width: 100%;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.10);
}}
.sla-table thead tr th {{
padding: 11px 16px;
font-weight: 700;
letter-spacing: 0.4px;
font-size: 12px;
text-transform: uppercase;
border-bottom: 2px solid rgba(255,255,255,0.18);
color: #ffffff !important;
}}
.sla-table th.th-tipo {{
background: {cor_dark};
color: #ffffff !important;
text-align: left;
min-width: 150px;
border-right: 1px solid rgba(255,255,255,0.15);
}}
.sla-table th.th-sla {{
background: {cor_mid};
color: #ffffff !important;
text-align: center;
width: 80px;
border-right: 1px solid rgba(255,255,255,0.15);
}}
.sla-table th.th-total {{
background: {cor_dark};
color: #ffffff !important;
text-align: center;
width: 70px;
}}
.sla-table tbody tr {{
transition: background 0.15s;
}}
.sla-table tbody tr:nth-child(even) td {{
background-color: #f7f9fc;
}}
.sla-table tbody tr:nth-child(odd) td {{
background-color: #ffffff;
}}
.sla-table tbody tr:hover td {{
background-color: #eef2ff !important;
}}
.sla-table td {{
padding: 9px 16px;
border-bottom: 1px solid #e8eaf0;
vertical-align: middle;
}}
.sla-table td.td-tipo {{
font-weight: 600;
color: #1a1a2e;
border-left: 4px solid {cor_mid};
text-align: left;
background-color: inherit;
}}
.sla-table td.td-sla {{
text-align: center;
color: #546e7a;
font-style: italic;
font-size: 12px;
}}
.sla-table td.td-faixa {{
text-align: center;
}}
.sla-table td.td-total {{
text-align: center;
font-weight: 800;
font-size: 14px;
color: {cor_dark};
background-color: #f0f4ff !important;
border-left: 2px solid #c5cae9;
}}
.badge {{
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 26px;
padding: 0 10px;
border-radius: 20px;
font-weight: 700;
font-size: 13px;
line-height: 1;
}}
.badge-zero {{
color: #bdbdbd;
font-size: 16px;
font-weight: 400;
}}
.sub-label {{
display: block;
font-size: 10px;
font-weight: 400;
opacity: 0.85;
margin-top: 2px;
text-transform: none;
letter-spacing: 0;
color: #ffffff !important;
}}
</style>
<div class="sla-wrap">
<table class="sla-table">
<thead>
<tr>
<th class="th-tipo">TIPOS</th>
<th class="th-sla">SLA<span class="sub-label">[dias]</span></th>
"""
for label, bg in zip(faixa_labels, faixa_header_bg):
html += (f' <th style="background:{bg};color:#ffffff !important;'
f'text-align:center;min-width:90px;">'
f'{label}<span class="sub-label">[uni]</span></th>\n')
html += ' <th class="th-total">TOTAL</th>\n </tr>\n </thead>\n <tbody>\n'
for _, row in pivot.iterrows():
sla_val = row['SLA [dias]']
sla_str = str(int(sla_val)) if sla_val > 0 else 'β€”'
html += f' <tr>\n <td class="td-tipo">{row["TIPOS"]}</td>\n'
html += f' <td class="td-sla">{sla_str}</td>\n'
for col, (bg, fg) in zip(faixa_cols, faixa_cell):
val = int(row[col])
if val == 0:
html += ' <td class="td-faixa"><span class="badge badge-zero">β€”</span></td>\n'
else:
html += (f' <td class="td-faixa">'
f'<span class="badge" style="background:{bg};color:{fg};">{val}</span>'
f'</td>\n')
total = int(row['TOTAL'])
html += f' <td class="td-total">{total}</td>\n </tr>\n'
html += ' </tbody>\n</table>\n</div>'
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"""
<style>
.kpi-grid {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
font-family: 'Inter', 'Segoe UI', Arial, sans-serif;
}}
.kpi-card {{
border-radius: 12px;
padding: 18px 16px 14px;
text-align: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
border-top: 4px solid;
}}
.kpi-label {{
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 8px;
opacity: 0.75;
}}
.kpi-value {{
font-size: 36px;
font-weight: 800;
line-height: 1;
}}
.kpi-sub {{
font-size: 11px;
margin-top: 6px;
opacity: 0.6;
}}
</style>
<div class="kpi-grid">
<div class="kpi-card" style="background:#F3F4F6;border-color:{cor_mid};color:{cor_dark};">
<div class="kpi-label">Total de Tarefas</div>
<div class="kpi-value">{stats['total']}</div>
<div class="kpi-sub">registos processados</div>
</div>
<div class="kpi-card" style="background:#E8F5E9;border-color:#2E7D32;color:#1B5E20;">
<div class="kpi-label">Dentro do SLA</div>
<div class="kpi-value">{stats['dentro']}</div>
<div class="kpi-sub">≀ 100&nbsp;% SLA</div>
</div>
<div class="kpi-card" style="background:#FFEBEE;border-color:#C62828;color:#B71C1C;">
<div class="kpi-label">SLA Excedido</div>
<div class="kpi-value">{stats['excedido']}</div>
<div class="kpi-sub">&gt; 100&nbsp;% SLA</div>
</div>
<div class="kpi-card" style="background:{taxa_bg};border-color:{taxa_cor};color:{taxa_cor};">
<div class="kpi-label">Taxa de Cumprimento</div>
<div class="kpi-value">{pct}&nbsp;%</div>
<div class="kpi-sub">tarefas dentro do SLA</div>
</div>
</div>
"""
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"""
<div class="app-header" style="
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);
">
<h1 style="margin:0 0 6px;font-size:26px;font-weight:800;letter-spacing:-0.3px;
color:#ffffff !important;font-family:'Inter','Segoe UI',Arial,sans-serif;">
πŸ“Š CIRCET SLA β€” Acompanhamento de Tarefas
</h1>
<p style="margin:0;font-size:13px;font-weight:400;
color:#ffffff !important;opacity:0.92;
font-family:'Inter','Segoe UI',Arial,sans-serif;">
Controlo SLA por tipo de tarefa &nbsp;Β·&nbsp; DistribuiΓ§Γ£o por faixas de percentagem
&nbsp;Β·&nbsp; {N_TOTAL} registos &nbsp;Β·&nbsp; ReferΓͺncia: {DATA_REF}
</p>
</div>
""")
# ── 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('<div class="export-section"><b style="font-size:13px;color:#37474F;'
'text-transform:uppercase;letter-spacing:0.6px;">⬇ Exportar Dados</b></div>')
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("""
<div class="legenda-bar">
<span style="font-size:12px;font-weight:700;color:#546e7a;text-transform:uppercase;letter-spacing:0.5px;">Legenda:</span>
<span style="display:inline-flex;align-items:center;gap:6px;font-size:13px;">
<span style="width:14px;height:14px;border-radius:50%;background:#2E7D32;display:inline-block;"></span>
<b style="color:#1B5E20">&lt; 50 %</b> β€” Dentro do prazo
</span>
<span style="display:inline-flex;align-items:center;gap:6px;font-size:13px;">
<span style="width:14px;height:14px;border-radius:50%;background:#E65100;display:inline-block;"></span>
<b style="color:#E65100">50 % &lt; X ≀ 75 %</b> β€” AtenΓ§Γ£o
</span>
<span style="display:inline-flex;align-items:center;gap:6px;font-size:13px;">
<span style="width:14px;height:14px;border-radius:50%;background:#BF360C;display:inline-block;"></span>
<b style="color:#BF360C">75 % &lt; X ≀ 100 %</b> β€” CrΓ­tico
</span>
<span style="display:inline-flex;align-items:center;gap:6px;font-size:13px;">
<span style="width:14px;height:14px;border-radius:50%;background:#B71C1C;display:inline-block;"></span>
<b style="color:#B71C1C">&gt; 100 %</b> β€” SLA excedido
</span>
</div>
""")
# 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("""
<div style="background:linear-gradient(135deg,#0D47A1 0%,#1565C0 50%,#1976D2 100%);
border-radius:10px;padding:16px 20px;margin-bottom:16px;">
<h3 style="margin:0 0 6px;color:#fff;font-size:16px;font-weight:700;">
Assistente RAG β€” NVIDIA NIM
</h3>
<p style="margin:0;color:rgba(255,255,255,0.85);font-size:13px;">
FaΓ§a perguntas em linguagem natural sobre os dados do dashboard SLA.
</p>
</div>
""")
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="<div style='text-align:center;padding:40px;color:#9e9e9e;'>"
"<b>Assistente SLA com NVIDIA NIM</b><br>"
"<span style='font-size:13px;'>Insira a sua chave e faΓ§a uma pergunta</span>"
"</div>"
)
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],
)