Download app.py from roundb/SLA: direct link, hf CLI and curl.
- Browser
- Download file 62.7 kB
-
https://huggingface.co/spaces/roundb/SLA/resolve/main/app.py
- Command line
-
hf download hf://spaces/roundb/SLA/app.py
-
curl -L -o app.py https://huggingface.co/spaces/roundb/SLA/resolve/main/app.py
62.7 kB
| """ | |
| app_local.py — Dashboard SLA + RAG com NVIDIA NIM (modo local) | |
| =============================================================== | |
| Dashboard SLA com sistema RAG (Retrieval-Augmented Generation) integrado. | |
| Permite fazer perguntas em linguagem natural sobre os dados do dashboard. | |
| Usa a API NVIDIA NIM (gratuita) com o modelo meta/llama-3.3-70b-instruct. | |
| Lógica SLA fiel ao rsla06032026.R: | |
| sla = valor lido de slalicenciamento.csv (join por TIPO) | |
| data_prevista = DATA ADJ CLIENTE + sla | |
| data_ref = DATA DE ENTREGA V0 (se existir) senão hoje | |
| diasV0 = sla + (data_ref - data_prevista) | |
| percV0 / PCT_SLA = (diasV0 / sla) × 100 | |
| faixa_sla = cut(percV0, breaks=[-Inf,50,75,100,Inf]) | |
| """ | |
| 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__)) | |
| # Em modo local OUTPUT_DIR = BASE | |
| OUTPUT_DIR = BASE | |
| os.makedirs(OUTPUT_DIR, exist_ok=True) | |
| # ── Chave API NVIDIA NIM ─────────────────────────────────────────────────────── | |
| NVIDIA_API_KEY_ENV = os.environ.get('NVIDIA_API_KEY', '') | |
| # ── Tipos válidos (igual ao R — sem R?COLEMENTS) ─────────────────────────────── | |
| TIPOS_VALIDOS = [ | |
| 'ART 2 3', 'RAMI', 'CUIVRE', 'PAR', 'FIBRE', | |
| 'RACCO', 'IMMO', 'DESSAT', | |
| 'DISSIM - POI1', 'DISSIM - POI2', 'DISSIM - POI3', 'DISSIM' | |
| ] | |
| 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', | |
| } | |
| # SLA_MAP mantido como fallback quando slalicenciamento.csv não existe | |
| 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, | |
| } | |
| # ── Cores por categoria ──────────────────────────────────────────────────────── | |
| CAT_CORES = { | |
| 'EM CURSO' : ('#0D47A1', '#1565C0'), | |
| 'LICENCIAMENTO': ('#4A148C', '#6A1B9A'), | |
| 'FINALIZADO' : ('#1B5E20', '#2E7D32'), | |
| 'GLOBAL' : ('#212121', '#37474F'), | |
| } | |
| # ── Tabela completa de estados (ETAT / Status / RESP) ───────────────────────── | |
| STATUS_TABLE = [ | |
| (1, '01 POR INICIAR SURVEY', 'RB', 'EM CURSO'), | |
| (1, '01.1 SURVEY EM AGENDAMENTO', 'RB', 'EM CURSO'), | |
| (1, '01.2 SURVEY PENDENTE CLIENTE', 'ORG', 'EM CURSO'), | |
| (1, '01.3 SURVEY EM CURSO', 'RB', 'EM CURSO'), | |
| (1, '01.4 SURVEY CANCELADO', 'RB', 'FINALIZADO'), | |
| (1, '02 POR INICAR PROJETO', 'RB', 'EM CURSO'), | |
| (1, '02.1 PROJETO PENDENTE CLIENTE', 'RB', 'EM CURSO'), | |
| (1, '02.2 PROJETO POR ADJUDICAR', 'RB', 'EM CURSO'), | |
| (1, '02.3 PROJETO EM CURSO', 'ORG', 'EM CURSO'), | |
| (1, '03 POR INICIAR CQ', 'RB', 'EM CURSO'), | |
| (1, '03.1 CQ EM CURSO', 'RB', 'EM CURSO'), | |
| (1, '03.2 CQ TERMINADO', 'RB', 'EM CURSO'), | |
| (1, '03.3 CQ SOGETREL', 'RB', 'EM CURSO'), | |
| (1, '04 PRE VALIDAÇÃO PROJETO', 'RB', 'EM CURSO'), | |
| (1, '05 SUIVI PROJETO', 'RB', 'EM CURSO'), | |
| (1, '06 POR INICIAR LICENCIAMENTOS', 'ORG', 'LICENCIAMENTO'), | |
| (1, '06.1 LICENCIAMENTO POR ADJUDICAR', 'RB', 'LICENCIAMENTO'), | |
| (1, '06.2 AGUARDA DEVIS', 'RB', 'LICENCIAMENTO'), | |
| (1, '06.3 DEVIS OK', 'RB', 'LICENCIAMENTO'), | |
| (1, '06.4 AGUARDA PMV+DT', 'RB', 'LICENCIAMENTO'), | |
| (1, '06.5 PMV + DT OK', 'ORG', 'LICENCIAMENTO'), | |
| (1, '06.6 AGUARDA CRVT', 'ORG', 'LICENCIAMENTO'), | |
| (1, '06.7 CRVT OK', 'SGT', 'LICENCIAMENTO'), | |
| (2, '07 VALIDAÇÃO ORANGE', 'ORG', 'EM CURSO'), | |
| (3, '08 PROJETO VALIDADO', 'ORG', 'FINALIZADO'), | |
| (4, '09 TRABALHOS EM CURSO', 'SGT', 'EM CURSO'), | |
| (4, '10 TRABALHOS TERMINADOS', 'SGT', 'EM CURSO'), | |
| (4, '11 POR INICIAR CADASTRO', 'RB', 'EM CURSO'), | |
| (4, '11.1 AGUARDA RT', 'ORG', 'FINALIZADO'), | |
| (4, '11.2 CADASTRO POR ADJUDICAR', 'RB', 'EM CURSO'), | |
| (5, '11.3 CADASTRO EM CURSO', 'RB', 'EM CURSO'), | |
| (5, '11.4 CADASTRO TERMINADO', 'ORG', 'EM CURSO'), | |
| (6, '11.5 CADASTRO VALIDADO', 'ORG', 'FINALIZADO'), | |
| (0, '12 CANCELADO', '', 'FINALIZADO'), | |
| (6, '13 FATURADO', 'SGT', 'FINALIZADO'), | |
| (6, '14 DOSSIER CONCLUIDO FINI', '', 'FINALIZADO'), | |
| ] | |
| STATUS_CATEGORIA_MAP = {row[1]: row[3] for row in STATUS_TABLE} | |
| STATUS_ETAT_MAP = {row[1]: row[0] for row in STATUS_TABLE} | |
| STATUS_RESP_MAP = {row[1]: row[2] for row in STATUS_TABLE} | |
| EM_CURSO_STATUS = {s for s, c in STATUS_CATEGORIA_MAP.items() if c == 'EM CURSO'} | |
| FINALIZADO_STATUS = {s for s, c in STATUS_CATEGORIA_MAP.items() if c == 'FINALIZADO'} | |
| LICENCIAMENTO_STATUS = {s for s, c in STATUS_CATEGORIA_MAP.items() if c == 'LICENCIAMENTO'} | |
| def ler_status_csv(path): | |
| if not os.path.exists(path): | |
| return [] # ficheiro opcional — ignorar silenciosamente | |
| 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 | |
| return [] | |
| for _s in ler_status_csv(os.path.join(BASE, 'emcurso.csv')): | |
| if _s and _s not in STATUS_CATEGORIA_MAP: | |
| EM_CURSO_STATUS.add(_s) | |
| STATUS_CATEGORIA_MAP[_s] = 'EM CURSO' | |
| for _s in ler_status_csv(os.path.join(BASE, 'finalizado.csv')): | |
| if _s and _s not in STATUS_CATEGORIA_MAP: | |
| FINALIZADO_STATUS.add(_s) | |
| STATUS_CATEGORIA_MAP[_s] = 'FINALIZADO' | |
| for _s in ler_status_csv(os.path.join(BASE, 'licenciamento.csv')): | |
| if _s and _s not in STATUS_CATEGORIA_MAP: | |
| LICENCIAMENTO_STATUS.add(_s) | |
| STATUS_CATEGORIA_MAP[_s] = 'LICENCIAMENTO' | |
| 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' : 'FINALIZADO', | |
| '06 TRABALHOS EM CURSO' : 'EM CURSO', | |
| '10 CANCELADO' : 'FINALIZADO', | |
| '11 FATURADO' : 'FINALIZADO', | |
| '8.3 AGUARDA RT' : 'FINALIZADO', | |
| } | |
| def get_categoria(status: str) -> str: | |
| s = str(status).strip() | |
| if s in STATUS_CATEGORIA_MAP: | |
| return STATUS_CATEGORIA_MAP[s] | |
| if s in STATUS_EXTRA_MAP: | |
| return STATUS_EXTRA_MAP[s] | |
| return 'GLOBAL' | |
| def get_etat(status: str) -> int: | |
| return STATUS_ETAT_MAP.get(str(status).strip(), -1) | |
| def get_resp(status: str) -> str: | |
| return STATUS_RESP_MAP.get(str(status).strip(), '') | |
| # ── Faixa SLA — fiel ao R: cut(percV0, breaks=c(-Inf,50,75,100,Inf)) ────────── | |
| def calcular_faixa(pct): | |
| """ | |
| Replica o cut() do R: | |
| breaks = c(-Inf, 50, 75, 100, Inf) | |
| labels = c('<50%', '50-75%', '75-100%', '>100%') | |
| Intervalos: (-Inf,50], (50,75], (75,100], (100,Inf) | |
| """ | |
| 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 SLA do ficheiro slalicenciamento.csv (igual ao R) ───────────────── | |
| def carregar_sla_csv(path: str) -> dict: | |
| """ | |
| Lê slalicenciamento.csv e devolve um dicionário {TIPO: sla_dias}. | |
| Aceita coluna 'SLA [dias]' ou 'sla'. | |
| Se o ficheiro não existir, devolve o SLA_MAP de fallback. | |
| """ | |
| if not os.path.exists(path): | |
| print(f"[AVISO] {path} não encontrado — usando SLA_MAP de fallback.") | |
| return dict(SLA_MAP) | |
| for enc in ('utf-8', 'latin-1', 'cp1252'): | |
| try: | |
| df_sla = pd.read_csv(path, sep=';', encoding=enc, on_bad_lines='skip') | |
| # Renomear coluna SLA se necessário (igual ao R) | |
| if 'SLA [dias]' in df_sla.columns: | |
| df_sla = df_sla.rename(columns={'SLA [dias]': 'sla'}) | |
| # Detectar coluna de TIPOS | |
| tipo_col = None | |
| for c in df_sla.columns: | |
| if c.strip().upper() in ('TIPOS', 'TIPO', 'TYPE'): | |
| tipo_col = c | |
| break | |
| if tipo_col is None or 'sla' not in df_sla.columns: | |
| print(f"[AVISO] Colunas esperadas não encontradas em {path}. Colunas: {list(df_sla.columns)}") | |
| return dict(SLA_MAP) | |
| df_sla[tipo_col] = df_sla[tipo_col].astype(str).str.strip() | |
| df_sla['sla'] = pd.to_numeric(df_sla['sla'], errors='coerce') | |
| sla_dict = dict(zip(df_sla[tipo_col], df_sla['sla'])) | |
| return sla_dict | |
| except UnicodeDecodeError: | |
| continue | |
| print(f"[AVISO] Não foi possível ler {path} — usando SLA_MAP de fallback.") | |
| return dict(SLA_MAP) | |
| # ── Carregar e processar dados — lógica fiel ao R ───────────────────────────── | |
| def carregar_dados(caminho_csv: str) -> pd.DataFrame: | |
| """ | |
| Replica a lógica do rsla06032026.R: | |
| 1. Ler CSV principal (tarefasss_datas_corrigidas_final.csv) | |
| 2. Renomear colunas (col[10]=TEMPO_EXECUCAO, col[13..15]=re-entregas) | |
| 3. Selecionar colunas necessárias (inclui DATA DE ENTREGA V0) | |
| 4. Filtrar TIPOS válidos (sem R?COLEMENTS) | |
| 5. Converter datas com formato dd/mm/yyyy | |
| 6. Fazer join com slalicenciamento.csv para obter sla por TIPO | |
| 7. Calcular: | |
| data_prevista = DATA ADJ CLIENTE + sla | |
| data_ref = DATA DE ENTREGA V0 se não nulo senão hoje | |
| diasV0 = sla + (data_ref - data_prevista) | |
| percV0 = (diasV0 / sla) * 100 | |
| 8. Calcular faixa_sla com os mesmos breaks do R | |
| """ | |
| # Tentar múltiplos encodings — compatível com ficheiros Windows (latin-1/cp1252) | |
| df_raw = None | |
| for _enc in ('utf-8', 'utf-8-sig', 'latin-1', 'cp1252', 'iso-8859-1'): | |
| try: | |
| df_raw = pd.read_csv(caminho_csv, sep=';', encoding=_enc, on_bad_lines='skip') | |
| break | |
| except (UnicodeDecodeError, Exception): | |
| continue | |
| if df_raw is None: | |
| raise ValueError( | |
| f"Não foi possível ler o ficheiro: {caminho_csv}\n" | |
| "Verifique se o separador é ';' e o encoding é UTF-8 ou Latin-1." | |
| ) | |
| # Renomear colunas pelo índice (igual ao R) | |
| cols = list(df_raw.columns) | |
| if len(cols) > 9: | |
| cols[9] = 'TEMPO_EXECUCAO' | |
| if len(cols) > 12: | |
| cols[12] = 'DATA 1 RE-ENTREGA V1' | |
| if len(cols) > 13: | |
| cols[13] = 'DATA 2 RE-ENTREGA V2' | |
| if len(cols) > 14: | |
| cols[14] = 'DATA 3 RE-ENTREGA V3' | |
| df_raw.columns = cols | |
| # Selecionar colunas necessárias (igual ao R — inclui DATA DE ENTREGA V0) | |
| colunas_necessarias = [ | |
| 'SUB-CIP', 'PROJETO', 'TIPO', 'RB STATUS', | |
| 'TEMPO_EXECUCAO', 'DATA ADJ CLIENTE', | |
| 'DATA DE ENTREGA V0', | |
| ] | |
| # Adicionar colunas opcionais se existirem | |
| for col_opt in ['DATA 1 RE-ENTREGA V1', 'DATA 2 RE-ENTREGA V2', | |
| 'DATA 3 RE-ENTREGA V3', 'Resposta Cliente']: | |
| if col_opt in df_raw.columns: | |
| colunas_necessarias.append(col_opt) | |
| # Manter apenas colunas que existem no CSV | |
| colunas_presentes = [c for c in colunas_necessarias if c in df_raw.columns] | |
| dftipoadj = df_raw[colunas_presentes].copy() | |
| # Compatibilidade: aceitar 'DATA_ADJ_CLIENTE' como alias de 'DATA ADJ CLIENTE' | |
| if 'DATA ADJ CLIENTE' not in dftipoadj.columns and 'DATA_ADJ_CLIENTE' in df_raw.columns: | |
| dftipoadj['DATA ADJ CLIENTE'] = df_raw['DATA_ADJ_CLIENTE'] | |
| # Filtrar TIPOS válidos — sem R?COLEMENTS (igual ao R) | |
| dd = dftipoadj[dftipoadj['TIPO'].isin(TIPOS_VALIDOS)].copy() | |
| dd = dd.sort_values('DATA ADJ CLIENTE').reset_index(drop=True) | |
| # Converter datas (formato dd/mm/yyyy — igual ao R dmy()) | |
| def to_date(col): | |
| return pd.to_datetime(col.astype(str).str.strip().replace({'': pd.NaT, 'nan': pd.NaT}), | |
| format='%d/%m/%Y', errors='coerce') | |
| dd['DATA ADJ CLIENTE'] = to_date(dd['DATA ADJ CLIENTE']) | |
| if 'DATA DE ENTREGA V0' in dd.columns: | |
| dd['DATA DE ENTREGA V0'] = to_date(dd['DATA DE ENTREGA V0']) | |
| else: | |
| dd['DATA DE ENTREGA V0'] = pd.NaT | |
| dd['TEMPO_EXECUCAO'] = pd.to_numeric( | |
| dd['TEMPO_EXECUCAO'].astype(str).str.strip(), errors='coerce' | |
| ) | |
| # ── Join SLA (igual ao R: left_join por TIPO = TIPOS) ───────────────────── | |
| sla_path = os.path.join(BASE, 'slalicenciamento.csv') | |
| sla_dict = carregar_sla_csv(sla_path) | |
| dd['sla'] = dd['TIPO'].map(sla_dict) | |
| # ── Cálculos centrais — replicam exactamente o R ────────────────────────── | |
| hoje = pd.Timestamp.today().normalize() | |
| # data_prevista = DATA ADJ CLIENTE + sla (igual ao R) | |
| dd['DATA_PREVISTA'] = dd.apply( | |
| lambda r: (r['DATA ADJ CLIENTE'] + pd.Timedelta(days=int(r['sla']))) | |
| if pd.notna(r['sla']) and pd.notna(r['DATA ADJ CLIENTE']) | |
| else pd.NaT, | |
| axis=1 | |
| ) | |
| # data_ref = DATA DE ENTREGA V0 se não nulo senão hoje (igual ao R) | |
| dd['data_ref'] = dd['DATA DE ENTREGA V0'].where( | |
| dd['DATA DE ENTREGA V0'].notna(), hoje | |
| ) | |
| # diasV0 = sla + (data_ref - data_prevista) (igual ao R) | |
| dd['diasV0'] = dd.apply( | |
| lambda r: (r['sla'] + (r['data_ref'] - r['DATA_PREVISTA']).days) | |
| if pd.notna(r['sla']) and pd.notna(r['DATA ADJ CLIENTE']) | |
| and pd.notna(r['DATA_PREVISTA']) | |
| else np.nan, | |
| axis=1 | |
| ) | |
| # percV0 = (diasV0 / sla) * 100 (igual ao R) | |
| dd['PCT_SLA'] = np.where( | |
| (dd['sla'].notna()) & (dd['sla'] > 0) & (dd['diasV0'].notna()), | |
| (dd['diasV0'] / dd['sla'] * 100).round(1), | |
| np.nan | |
| ) | |
| # faixa_sla — cut() do R com breaks=c(-Inf,50,75,100,Inf) | |
| dd['FAIXA_SLA'] = dd['PCT_SLA'].apply(calcular_faixa) | |
| # ── Campos auxiliares (mantidos do Python original) ─────────────────────── | |
| dd['SLA_FIXO'] = dd['sla'] # alias para compatibilidade | |
| dd['TIPO_LABEL'] = dd['TIPO'].map(TIPO_LABEL).fillna(dd['TIPO']) | |
| dd['CATEGORIA'] = dd['RB STATUS'].apply(get_categoria) | |
| dd['ETAT'] = dd['RB STATUS'].apply(get_etat) | |
| dd['RESP'] = dd['RB STATUS'].apply(get_resp) | |
| # ATUAL e DIFDIAS — mantidos para compatibilidade com o export CSV | |
| dd['ATUAL'] = (hoje - dd['DATA_PREVISTA']).dt.days | |
| dd['DIFDIAS'] = dd['diasV0'] # equivalente a sla - (hoje - data_prevista) quando data_ref=hoje | |
| # Renomear para compatibilidade com o resto do código | |
| dd = dd.rename(columns={'DATA ADJ CLIENTE': 'DATA_ADJ_CLIENTE'}) | |
| 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 ───────────────────────────────────────────────────── | |
| 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 TIPOS_VALIDOS: # apenas tipos válidos (sem R?COLEMENTS) | |
| sub_t = sub[sub['TIPO'] == tipo] | |
| sla_val = int(sub_t['SLA_FIXO'].dropna().iloc[0]) if len(sub_t) > 0 and sub_t['SLA_FIXO'].notna().any() else SLA_MAP.get(tipo, 0) | |
| row = { | |
| 'TIPOS' : TIPO_LABEL.get(tipo, tipo), | |
| 'SLA [dias]' : sla_val, | |
| '< 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 ───────────────────────────────────────────────────── | |
| 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""" | |
| <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: #e8f0fe !important; | |
| }} | |
| .sla-table td {{ | |
| padding: 10px 16px; | |
| border-bottom: 1px solid #e8ecf0; | |
| vertical-align: middle; | |
| }} | |
| .sla-table td.td-tipo {{ | |
| font-weight: 600; | |
| color: {cor_dark}; | |
| border-right: 1px solid #e8ecf0; | |
| }} | |
| .sla-table td.td-sla {{ | |
| text-align: center; | |
| font-weight: 500; | |
| color: #546e7a; | |
| border-right: 1px solid #e8ecf0; | |
| }} | |
| .sla-table td.td-total {{ | |
| text-align: center; | |
| font-weight: 700; | |
| color: {cor_dark}; | |
| font-size: 15px; | |
| background-color: #f0f4ff !important; | |
| }} | |
| .badge {{ | |
| display: inline-block; | |
| min-width: 36px; | |
| padding: 3px 10px; | |
| border-radius: 20px; | |
| font-weight: 700; | |
| font-size: 13px; | |
| text-align: center; | |
| }} | |
| </style> | |
| <div class="sla-wrap"> | |
| <table class="sla-table"> | |
| <thead> | |
| <tr> | |
| <th class="th-tipo">Tipos</th> | |
| <th class="th-sla">SLA<br>[dias]</th> | |
| """ | |
| for label, bg in zip(faixa_labels, faixa_header_bg): | |
| html += f' <th style="background:{bg};color:#fff;text-align:center;width:110px;">{label}</th>\n' | |
| html += ' <th class="th-total">Total</th>\n </tr>\n </thead>\n <tbody>\n' | |
| for _, row in pivot.iterrows(): | |
| html += ' <tr>\n' | |
| html += f' <td class="td-tipo">{row["TIPOS"]}</td>\n' | |
| html += f' <td class="td-sla">{int(row["SLA [dias]"])}</td>\n' | |
| for col, (bg, cor) in zip(faixa_cols, faixa_cell): | |
| val = int(row[col]) | |
| if val > 0: | |
| html += f' <td style="text-align:center;"><span class="badge" style="background:{bg};color:{cor};">{val}</span></td>\n' | |
| else: | |
| html += f' <td style="text-align:center;color:#bdbdbd;">—</td>\n' | |
| html += f' <td class="td-total">{int(row["TOTAL"])}</td>\n' | |
| html += ' </tr>\n' | |
| # Linha de totais | |
| html += ' <tr style="border-top:2px solid #e0e0e0;">\n' | |
| html += f' <td class="td-tipo" style="font-size:13px;color:{cor_dark};">TOTAL GERAL</td>\n' | |
| html += ' <td class="td-sla">—</td>\n' | |
| for col, (bg, cor) in zip(faixa_cols, faixa_cell): | |
| total_col = int(pivot[col].sum()) | |
| if total_col > 0: | |
| html += f' <td style="text-align:center;"><span class="badge" style="background:{bg};color:{cor};">{total_col}</span></td>\n' | |
| else: | |
| html += f' <td style="text-align:center;color:#bdbdbd;">—</td>\n' | |
| html += f' <td class="td-total" style="font-size:17px;">{int(pivot["TOTAL"].sum())}</td>\n' | |
| html += ' </tr>\n </tbody>\n</table>\n</div>' | |
| return html | |
| # ── Renderizar KPIs ──────────────────────────────────────────────────────────── | |
| 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 % 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">> 100 % 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} %</div> | |
| <div class="kpi-sub">tarefas dentro do SLA</div> | |
| </div> | |
| </div> | |
| """ | |
| return html | |
| # ── Exportações ──────────────────────────────────────────────────────────────── | |
| def _get_export_path(prefixo: str, categoria: str) -> str: | |
| ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') | |
| nome = f"{prefixo}_{categoria.lower().replace(' ', '_')}_{ts}.csv" | |
| for pasta in [OUTPUT_DIR, BASE]: | |
| try: | |
| path = os.path.join(pasta, nome) | |
| open(path, 'w').close() | |
| os.remove(path) | |
| return path | |
| except Exception: | |
| continue | |
| import tempfile | |
| tmp = tempfile.NamedTemporaryFile( | |
| delete=False, suffix='.csv', dir=BASE, prefix=prefixo + '_' | |
| ) | |
| tmp.close() | |
| return tmp.name | |
| def exportar_csv_pivot(categoria: str) -> str: | |
| pivot = build_pivot(DF_GLOBAL, categoria) | |
| path = _get_export_path('sla_pivot', categoria) | |
| 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() | |
| # Colunas exportadas — alinhadas com o R (inclui diasV0 / percV0) | |
| cols_base = [ | |
| 'SUB-CIP', 'PROJETO', 'TIPO', 'TIPO_LABEL', 'RB STATUS', 'CATEGORIA', | |
| 'ETAT', 'RESP', | |
| 'DATA_ADJ_CLIENTE', 'DATA_PREVISTA', 'data_ref', | |
| 'sla', 'diasV0', 'PCT_SLA', 'FAIXA_SLA', | |
| 'TEMPO_EXECUCAO', 'ATUAL', 'DIFDIAS', | |
| 'DATA_CALCULO' | |
| ] | |
| cols = [c for c in cols_base if c in sub.columns] | |
| fact = sub[cols].copy() | |
| for col_dt in ['DATA_ADJ_CLIENTE', 'DATA_PREVISTA', 'data_ref']: | |
| if col_dt in fact.columns: | |
| fact[col_dt] = pd.to_datetime(fact[col_dt], errors='coerce').dt.strftime('%d/%m/%Y') | |
| path = _get_export_path('sla_fact', categoria) | |
| 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: Geração de contexto estruturado a partir dos dados ─────────────────── | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def gerar_contexto_rag() -> str: | |
| """ | |
| Gera um contexto estruturado e rico dos dados do dashboard SLA para RAG. | |
| Inclui cruzamentos avançados, análise de risco, aging, gargalos e 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 PARA GESTOR DE PROJECTO") | |
| 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"FINALIZADO ({(df['CATEGORIA']=='FINALIZADO').sum()})") | |
| linhas.append("") | |
| linhas.append("-" * 70) | |
| linhas.append("KPIs EXECUTIVOS (visão de topo)") | |
| linhas.append("-" * 70) | |
| for cat in ['GLOBAL', 'EM CURSO', 'LICENCIAMENTO', 'FINALIZADO']: | |
| 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}]") | |
| linhas.append(f" Total tarefas : {stats['total']}") | |
| linhas.append(f" Dentro SLA (≤100%) : {stats['dentro']} ({stats['pct_ok']}%)") | |
| linhas.append(f" SLA excedido (>100%): {stats['excedido']} ({round(100-stats['pct_ok'],1)}%)") | |
| linhas.append(f" % SLA médio : {pct_medio}%") | |
| linhas.append("") | |
| linhas.append("-" * 70) | |
| linhas.append("DISTRIBUIÇÃO POR TIPO E FAIXA SLA (por categoria)") | |
| linhas.append("-" * 70) | |
| for cat in ['GLOBAL', 'EM CURSO', 'LICENCIAMENTO', 'FINALIZADO']: | |
| pivot = build_pivot(df, cat) | |
| linhas.append(f" [{cat}]") | |
| for _, row in pivot.iterrows(): | |
| if row['TOTAL'] > 0: | |
| t = int(row['TOTAL']) | |
| exc = int(row['> 100 % [uni]']) | |
| pct_exc = round(exc / t * 100, 1) if t > 0 else 0 | |
| linhas.append( | |
| f" {row['TIPOS']:<20} SLA={int(row['SLA [dias]'])}d | " | |
| f"Total={t:3d} | <50%={int(row['< 50 % [uni]']):3d} | " | |
| f"50-75%={int(row['50 % < X ≤ 75 % [uni]']):3d} | " | |
| f"75-100%={int(row['75 % < X ≤ 100 % [uni]']):3d} | " | |
| f">100%={exc:3d} ({pct_exc}% do tipo)" | |
| ) | |
| linhas.append("") | |
| 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} | " | |
| f"Excedido: {r['excedido']}/{r['total']} ({r['taxa_exc']}%) | " | |
| f"% SLA médio: {r['pct_medio']}%" | |
| ) | |
| linhas.append("") | |
| 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("") | |
| linhas.append("-" * 70) | |
| linhas.append("CRUZAMENTO TIPO × STATUS — ONDE ESTÃO OS GARGALOS") | |
| 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(20) | |
| for _, r in cross.iterrows(): | |
| linhas.append( | |
| f" {r['TIPO_LABEL']:<20} + {r['RB STATUS']:<35} → {r['n_excedidos']} excedidos" | |
| ) | |
| linhas.append("") | |
| linhas.append("-" * 70) | |
| linhas.append("DISTRIBUIÇÃO COMPLETA: CATEGORIA × TIPO (tarefas activas)") | |
| linhas.append("-" * 70) | |
| cross2 = df.groupby(['CATEGORIA', 'TIPO_LABEL']).agg( | |
| total=('PROJETO', 'count'), | |
| excedido=('FAIXA_SLA', lambda x: (x == '> 100 %').sum()) | |
| ).reset_index() | |
| cross2['taxa'] = (cross2['excedido'] / cross2['total'] * 100).round(1) | |
| cross2 = cross2.sort_values(['CATEGORIA', 'excedido'], ascending=[True, False]) | |
| cat_actual = '' | |
| for _, r in cross2.iterrows(): | |
| if r['CATEGORIA'] != cat_actual: | |
| cat_actual = r['CATEGORIA'] | |
| linhas.append(f" [{cat_actual}]") | |
| linhas.append( | |
| f" {r['TIPO_LABEL']:<20} Total: {r['total']:3d} | " | |
| f"Excedido: {r['excedido']:3d} ({r['taxa']}%)" | |
| ) | |
| linhas.append("") | |
| linhas.append("-" * 70) | |
| linhas.append("ADJUDICAÇÕES POR MÊS (volume de entrada de trabalho)") | |
| linhas.append("-" * 70) | |
| df_datas = df[df['DATA_ADJ_CLIENTE'].notna()].copy() | |
| 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} | " | |
| f"Excedidos: {r['excedido']:3d} ({taxa}%)" | |
| ) | |
| linhas.append("") | |
| linhas.append("-" * 70) | |
| linhas.append("PROJECTOS EM CURSO COM MAIOR RISCO IMEDIATO (>75% SLA)") | |
| linhas.append("-" * 70) | |
| em_risco = df[ | |
| (df['CATEGORIA'] == 'EM CURSO') & | |
| (df['PCT_SLA'] >= 75) & | |
| (df['SLA_FIXO'] > 0) | |
| ].sort_values('PCT_SLA', ascending=False).head(20) | |
| for _, row in em_risco.iterrows(): | |
| data_adj = row['DATA_ADJ_CLIENTE'].strftime('%d/%m/%Y') if pd.notna(row['DATA_ADJ_CLIENTE']) else 'N/D' | |
| dias_r = int(row['DIFDIAS']) if pd.notna(row['DIFDIAS']) else 0 | |
| linhas.append( | |
| f" {row['PROJETO']:<14} Tipo: {row['TIPO_LABEL']:<16} " | |
| f"Status: {row['RB STATUS']:<30} " | |
| f"% SLA: {row['PCT_SLA']:>6}% | Dias restantes: {dias_r:>4}d | Adj: {data_adj}" | |
| ) | |
| linhas.append("") | |
| linhas.append("-" * 70) | |
| linhas.append("DISTRIBUIÇÃO POR STATUS (RB STATUS) — com ETAT e RESP") | |
| linhas.append("-" * 70) | |
| status_counts = df['RB STATUS'].value_counts() | |
| for status, count in status_counts.items(): | |
| cat = get_categoria(str(status)) | |
| etat = get_etat(str(status)) | |
| resp = get_resp(str(status)) | |
| sub_s = df[df['RB STATUS'] == status] | |
| exc_s = (sub_s['FAIXA_SLA'] == '> 100 %').sum() | |
| taxa_s = round(exc_s / count * 100, 1) if count > 0 else 0 | |
| linhas.append( | |
| f" {status:<35} | Cat: {cat:<13} | ETAT: {etat:2d} | RESP: {resp:<3} | " | |
| f"{count:3d} tarefas | Excedido: {exc_s} ({taxa_s}%)" | |
| ) | |
| linhas.append("") | |
| linhas.append("-" * 70) | |
| linhas.append("SLA CONTRATUAL POR TIPO DE TAREFA (dias)") | |
| linhas.append("-" * 70) | |
| for tipo in TIPOS_VALIDOS: | |
| label = TIPO_LABEL.get(tipo, tipo) | |
| sla_v = DF_GLOBAL[DF_GLOBAL['TIPO'] == tipo]['sla'].dropna() | |
| sla_dias = int(sla_v.iloc[0]) if len(sla_v) > 0 else SLA_MAP.get(tipo, 0) | |
| linhas.append(f" {label:<20} : {sla_dias} dias") | |
| linhas.append("") | |
| linhas.append("-" * 70) | |
| linhas.append("TABELA DE ESTADOS DO CICLO DE VIDA (ETAT / Status / RESP)") | |
| linhas.append("-" * 70) | |
| linhas.append(" ETAT | Status | RESP | Categoria") | |
| linhas.append(" " + "-" * 72) | |
| for etat, status, resp, cat in STATUS_TABLE: | |
| linhas.append(f" {etat:4d} | {status:<40} | {resp:<4} | {cat}") | |
| linhas.append("") | |
| linhas.append(" Legenda ETAT : 0=Cancelado | 1=Survey/Projecto | 2=Validação Orange") | |
| linhas.append(" 3=Projecto Validado | 4=Trabalhos/Cadastro | 5=Cadastro | 6=Faturado/Concluído") | |
| linhas.append(" Legenda RESP : RB=RB Portugal | ORG=Orange | SGT=Sogetrel") | |
| linhas.append("") | |
| linhas.append("-" * 70) | |
| linhas.append("DISTRIBUIÇÃO POR FASE (ETAT) E RESPONSÁVEL (RESP)") | |
| linhas.append("-" * 70) | |
| etat_labels = { | |
| 0: 'Cancelado', | |
| 1: 'Survey / Projecto', | |
| 2: 'Validação Orange', | |
| 3: 'Projecto Validado', | |
| 4: 'Trabalhos / Início Cadastro', | |
| 5: 'Cadastro em Curso', | |
| 6: 'Faturado / Concluído', | |
| -1: 'Fase não mapeada (status legado)', | |
| } | |
| etat_counts = df['ETAT'].value_counts().sort_index() | |
| for etat, count in etat_counts.items(): | |
| label_etat = etat_labels.get(int(etat), f'ETAT {etat}') | |
| sub_e = df[df['ETAT'] == etat] | |
| exc_e = (sub_e['FAIXA_SLA'] == '> 100 %').sum() | |
| taxa_e = round(exc_e / count * 100, 1) if count > 0 else 0 | |
| linhas.append( | |
| f" ETAT {etat:2d} — {label_etat:<35} : " | |
| f"{count:3d} tarefas | Excedido: {exc_e} ({taxa_e}%)" | |
| ) | |
| linhas.append("") | |
| resp_counts = df['RESP'].value_counts() | |
| resp_labels = {'RB': 'RB Portugal', 'ORG': 'Orange', 'SGT': 'Sogetrel', '': 'Não mapeado'} | |
| for resp, count in resp_counts.items(): | |
| sub_r = df[df['RESP'] == resp] | |
| exc_r = (sub_r['FAIXA_SLA'] == '> 100 %').sum() | |
| taxa_r = round(exc_r / count * 100, 1) if count > 0 else 0 | |
| linhas.append( | |
| f" RESP {resp:<3} ({resp_labels.get(resp, resp):<12}) : " | |
| f"{count:3d} tarefas | Excedido: {exc_r} ({taxa_r}%)" | |
| ) | |
| linhas.append("") | |
| linhas.append("-" * 70) | |
| linhas.append("RESUMO EXECUTIVO AUTOMÁTICO") | |
| linhas.append("-" * 70) | |
| total_g = len(df) | |
| exc_g = (df['FAIXA_SLA'] == '> 100 %').sum() | |
| taxa_g = round(exc_g / total_g * 100, 1) if total_g > 0 else 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 | |
| tipo_melhor = risco.iloc[-1]['TIPO_LABEL'] if len(risco) > 0 else 'N/D' | |
| taxa_melhor = risco.iloc[-1]['taxa_exc'] if len(risco) > 0 else 0 | |
| n_em_curso = (df['CATEGORIA'] == 'EM CURSO').sum() | |
| n_lic = (df['CATEGORIA'] == 'LICENCIAMENTO').sum() | |
| n_fin = (df['CATEGORIA'] == 'FINALIZADO').sum() | |
| n_criticos = len(em_risco) | |
| linhas.append(f" Portfolio total : {total_g} tarefas") | |
| linhas.append(f" Em Curso : {n_em_curso} | Licenciamento: {n_lic} | Finalizado: {n_fin}") | |
| 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(f" Tipo mais saudável : {tipo_melhor} ({taxa_melhor}% de incumprimento)") | |
| linhas.append(f" Projectos em risco : {n_criticos} em curso com SLA ≥ 75%") | |
| linhas.append("") | |
| return "\n".join(linhas) | |
| # Pré-calcular o contexto RAG uma vez na inicialização | |
| CONTEXTO_RAG = gerar_contexto_rag() | |
| def criar_cliente_nvidia(api_key: str) -> OpenAI: | |
| return OpenAI( | |
| base_url="https://integrate.api.nvidia.com/v1", | |
| api_key=api_key | |
| ) | |
| 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 para operadores como a Orange. | |
| O seu papel é analisar os dados reais do dashboard SLA que lhe são fornecidos e responder com a profundidade e rigor de um gestor experiente. Não se limite a citar números — interprete-os, identifique padrões, riscos e oportunidades de melhoria, e sugira acções concretas quando relevante. | |
| Responda SEMPRE em português europeu (Portugal), com linguagem profissional e directa. Use os números exactos dos dados fornecidos. | |
| --- DADOS DO DASHBOARD SLA --- | |
| {CONTEXTO_RAG} | |
| --- FIM DOS DADOS --- | |
| === CAPACIDADES DE ANÁLISE === | |
| Como gestor experiente, pode e deve: | |
| 1. ANÁLISE DE RISCO | |
| - Identificar tipos de tarefa em estado crítico (taxa de incumprimento > 70%) | |
| - Cruzar tipo × status × categoria para localizar gargalos operacionais | |
| - Avaliar o impacto do aging nos projectos com maior desvio SLA | |
| - Distinguir entre risco sistémico (todo o tipo falha) e risco pontual (projectos isolados) | |
| 2. ANÁLISE DE DESEMPENHO | |
| - Comparar taxas de cumprimento entre categorias (EM CURSO vs FINALIZADO vs LICENCIAMENTO) | |
| - Avaliar o % SLA médio por tipo e identificar tendências de deterioração | |
| - Analisar a distribuição por faixas (<50%, 50-75%, 75-100%, >100%) como indicador de maturidade | |
| - Identificar os tipos com melhor e pior desempenho e as razões prováveis | |
| 3. ANÁLISE OPERACIONAL | |
| - Cruzar responsável (RESP: RB/ORG/SGT) com taxa de incumprimento para identificar onde estão os bloqueios | |
| - Analisar o ciclo de vida (ETAT 0-6) e identificar em que fase os projectos ficam parados | |
| - Avaliar o volume de adjudicações por mês e correlacionar com picos de incumprimento | |
| - Identificar status com maior concentração de tarefas excedidas | |
| 4. REPORTE EXECUTIVO | |
| - Produzir resumos executivos concisos para apresentação à direcção | |
| - Formatar tabelas comparativas claras com indicadores RAG (Verde/Amarelo/Vermelho) | |
| - Sugerir KPIs adicionais que deveriam ser monitorizados | |
| - Propor acções correctivas prioritárias com base nos dados | |
| 5. ANÁLISE PREDITIVA | |
| - Com base nos projectos em curso com SLA ≥ 75%, estimar quantos vão exceder o prazo | |
| - Identificar padrões de adjudicação que historicamente levam a incumprimento | |
| - Avaliar se a taxa de incumprimento está a melhorar ou piorar com base nos dados históricos disponíveis | |
| === REGRAS DE RESPOSTA === | |
| - Use SEMPRE os números exactos dos dados fornecidos | |
| - Quando apresentar análises, estruture em: Situação → Análise → Recomendação | |
| - Use tabelas markdown para comparações com 3 ou mais itens | |
| - Classifique o nível de risco: 🔴 CRÍTICO (≥70% incumprimento) | 🟠 ALTO (40-69%) | 🟡 MÉDIO (20-39%) | 🟢 BAIXO (<20%) | |
| - Se a pergunta não puder ser respondida com os dados disponíveis, diga claramente e sugira o que seria necessário para responder | |
| - Para perguntas sobre projectos específicos, forneça o contexto completo (tipo, status, ETAT, % SLA, data adjudicação) | |
| - Quando identificar problemas, proponha sempre pelo menos uma acção correctiva concreta | |
| - Mantenha o contexto da conversa anterior para análises sequenciais | |
| === LEGENDA SLA === | |
| 🟢 < 50% : Dentro do prazo — execução saudável | |
| 🟡 50-75% : Atenção — monitorização reforçada necessária | |
| 🟠 75-100% : Crítico — intervenção urgente recomendada | |
| 🔴 > 100% : SLA excedido — incumprimento contratual, escalada necessária | |
| """ | |
| 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 em [build.nvidia.com](https://build.nvidia.com)." | |
| 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 ───────────────────────────────────────────────────────────────── | |
| 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: 1400px !important; | |
| margin: 0 auto !important; | |
| padding: 0 !important; | |
| } | |
| .sla-header-wrap, | |
| .sla-header-wrap *, | |
| .sla-header-wrap h1, | |
| .sla-header-wrap p, | |
| .sla-header-wrap span { | |
| color: #ffffff !important; | |
| -webkit-text-fill-color: #ffffff !important; | |
| } | |
| .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; | |
| } | |
| .chat-container { | |
| background: white; | |
| border-radius: 12px; | |
| padding: 20px; | |
| box-shadow: 0 2px 10px rgba(0,0,0,0.06); | |
| } | |
| .rag-header { | |
| background: linear-gradient(135deg, #0D47A1 0%, #1565C0 50%, #1976D2 100%); | |
| border-radius: 10px; | |
| padding: 16px 20px; | |
| margin-bottom: 16px; | |
| } | |
| footer { display: none !important; } | |
| .gr-panel, .gr-box { border-radius: 12px !important; } | |
| """ | |
| # ── Interface Gradio ─────────────────────────────────────────────────────────── | |
| CATEGORIAS = ['EM CURSO', 'LICENCIAMENTO', 'FINALIZADO', 'GLOBAL'] | |
| DATA_REF = pd.Timestamp.today().strftime('%d/%m/%Y') | |
| N_TOTAL = len(DF_GLOBAL) | |
| HEADER_HTML = f""" | |
| <style> | |
| div.sla-header-wrap {{ | |
| background: linear-gradient(135deg, #0D47A1 0%, #1565C0 50%, #1976D2 100%) !important; | |
| padding: 28px 36px 22px !important; | |
| border-radius: 0 0 16px 16px !important; | |
| margin-bottom: 24px !important; | |
| box-shadow: 0 4px 20px rgba(13,71,161,0.25) !important; | |
| }} | |
| div.sla-header-wrap h1 {{ | |
| margin: 0 0 6px !important; | |
| font-size: 26px !important; | |
| font-weight: 800 !important; | |
| letter-spacing: -0.3px !important; | |
| color: #ffffff !important; | |
| -webkit-text-fill-color: #ffffff !important; | |
| font-family: 'Inter', 'Segoe UI', Arial, sans-serif !important; | |
| }} | |
| div.sla-header-wrap p {{ | |
| margin: 0 !important; | |
| font-size: 13px !important; | |
| font-weight: 400 !important; | |
| color: #ffffff !important; | |
| -webkit-text-fill-color: #ffffff !important; | |
| opacity: 0.92 !important; | |
| font-family: 'Inter', 'Segoe UI', Arial, sans-serif !important; | |
| }} | |
| </style> | |
| <div class="sla-header-wrap" | |
| 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;-webkit-text-fill-color:#ffffff !important; | |
| font-family:'Inter','Segoe UI',Arial,sans-serif;"> | |
| <font color="#ffffff">📊 Dashboard SLA — Acompanhamento de Tarefas</font> | |
| </h1> | |
| <p style="margin:0;font-size:13px;font-weight:400;opacity:0.92; | |
| color:#ffffff !important;-webkit-text-fill-color:#ffffff !important; | |
| font-family:'Inter','Segoe UI',Arial,sans-serif;"> | |
| <font color="#ffffff"> | |
| Controlo SLA por tipo de tarefa · Distribuição por faixas de percentagem | |
| · {N_TOTAL} registos · Referência: {DATA_REF} | |
| · RAG com NVIDIA NIM | |
| </font> | |
| </p> | |
| </div> | |
| """ | |
| 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 Finalizado", | |
| "⚠️ Quais os projectos em curso com maior risco de incumprimento SLA nas próximas semanas?", | |
| "🔴 Identifica todos 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", | |
| "👥 Qual é a distribuição de tarefas e incumprimento por responsável (RB, Orange, Sogetrel)?", | |
| "🔍 Em que fase do ciclo de vida (ETAT) estão concentrados os maiores atrasos?", | |
| "⏰ 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?", | |
| "🏗️ Qual é o estado do Licenciamento? Quais os tipos mais problemáticos nessa categoria?", | |
| "🔮 Com base nos projectos em curso com SLA ≥75%, quantos estimas que vão exceder o prazo?", | |
| "💡 Que 3 acções correctivas prioritárias recomendas para melhorar a taxa de cumprimento global?", | |
| ] | |
| with gr.Blocks(title="Dashboard SLA + RAG NVIDIA NIM") as demo: | |
| gr.HTML(HEADER_HTML) | |
| with gr.Tabs(): | |
| # ── Tab 1: Dashboard ───────────────────────────────────────────────── | |
| with gr.Tab("📊 Dashboard SLA"): | |
| with gr.Row(): | |
| cat_selector = gr.Radio( | |
| choices=CATEGORIAS, | |
| value='EM CURSO', | |
| label='Categoria', | |
| interactive=True, | |
| elem_classes=['cat-selector'], | |
| ) | |
| 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() | |
| 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 R") | |
| btn_fact = gr.Button("⬇ CSV — Dados Calculados", variant="secondary", elem_classes=["btn-export"]) | |
| file_fact = gr.File(label="", show_label=False) | |
| 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">< 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 % < 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 % < 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">> 100 %</b> — SLA excedido | |
| </span> | |
| </div> | |
| """) | |
| # ── Tab 2: Assistente 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. | |
| O assistente usa os dados reais carregados para responder com precisão. | |
| </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="Introduza a sua chave NVIDIA NIM (build.nvidia.com)." | |
| ) | |
| with gr.Column(scale=2): | |
| modelo_selector = gr.Dropdown( | |
| choices=MODELOS_NVIDIA, | |
| value="meta/llama-3.3-70b-instruct", | |
| label="🧠 Modelo NVIDIA NIM", | |
| info="Llama 3.3 70B é o modelo recomendado" | |
| ) | |
| gr.HTML(""" | |
| <div style="background:#e8f4fd;border-left:4px solid #1976D2;border-radius:6px; | |
| padding:10px 14px;margin:8px 0 16px;font-size:12px;color:#1565C0;"> | |
| A NVIDIA oferece créditos gratuitos para desenvolvimento. | |
| </div> | |
| """) | |
| with gr.Row(): | |
| perguntas_dropdown = gr.Dropdown( | |
| choices=PERGUNTAS_SUGERIDAS, | |
| label="💡 Perguntas sugeridas (clique para usar)", | |
| ) | |
| chatbot = gr.Chatbot( | |
| label="Conversa com o Assistente SLA", | |
| height=480, | |
| avatar_images=(None, "https://build.nvidia.com/favicon.ico"), | |
| placeholder="<div style='text-align:center;padding:40px;color:#9e9e9e;'>" | |
| "<div style='font-size:40px;margin-bottom:12px;'></div>" | |
| "<b>Assistente SLA com NVIDIA NIM</b><br>" | |
| "<span style='font-size:13px;'>Insira a sua chave API e faça uma pergunta sobre os dados do dashboard</span>" | |
| "</div>" | |
| ) | |
| with gr.Row(): | |
| pergunta_input = gr.Textbox( | |
| label="", | |
| placeholder="Ex: Qual é a taxa de cumprimento global? Quais os tipos com mais SLA excedido?", | |
| 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 em cada pergunta)", | |
| lines=20, | |
| interactive=False, | |
| ) | |
| # ── Eventos — 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) | |
| # ── Eventos — RAG Chat ─────────────────────────────────────────────────── | |
| 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, | |
| ) | |
| # ── Modo local: lançar com server_name e server_port ────────────────────────── | |
| if __name__ == "__main__": | |
| demo.launch( | |
| css=CSS, | |
| theme=gr.themes.Base(), | |
| allowed_paths=[OUTPUT_DIR, BASE], | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| ) | |