| """ |
| 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 |
|
|
| |
| BASE = os.path.dirname(os.path.abspath(__file__)) |
|
|
| |
| OUTPUT_DIR = BASE |
| os.makedirs(OUTPUT_DIR, exist_ok=True) |
|
|
| |
| NVIDIA_API_KEY_ENV = os.environ.get('NVIDIA_API_KEY', '') |
|
|
| |
| 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 = { |
| '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, |
| } |
|
|
| |
| CAT_CORES = { |
| 'EM CURSO' : ('#0D47A1', '#1565C0'), |
| 'LICENCIAMENTO': ('#4A148C', '#6A1B9A'), |
| 'FINALIZADO' : ('#1B5E20', '#2E7D32'), |
| 'GLOBAL' : ('#212121', '#37474F'), |
| } |
|
|
| |
| 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 [] |
| 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(), '') |
|
|
| |
| 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 %' |
|
|
| |
| 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') |
| |
| if 'SLA [dias]' in df_sla.columns: |
| df_sla = df_sla.rename(columns={'SLA [dias]': 'sla'}) |
| |
| 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) |
|
|
| |
| 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 |
| """ |
| |
| 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." |
| ) |
|
|
| |
| 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 |
|
|
| |
| colunas_necessarias = [ |
| 'SUB-CIP', 'PROJETO', 'TIPO', 'RB STATUS', |
| 'TEMPO_EXECUCAO', 'DATA ADJ CLIENTE', |
| 'DATA DE ENTREGA V0', |
| ] |
| |
| 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) |
|
|
| |
| colunas_presentes = [c for c in colunas_necessarias if c in df_raw.columns] |
| dftipoadj = df_raw[colunas_presentes].copy() |
|
|
| |
| 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'] |
|
|
| |
| dd = dftipoadj[dftipoadj['TIPO'].isin(TIPOS_VALIDOS)].copy() |
| dd = dd.sort_values('DATA ADJ CLIENTE').reset_index(drop=True) |
|
|
| |
| 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' |
| ) |
|
|
| |
| sla_path = os.path.join(BASE, 'slalicenciamento.csv') |
| sla_dict = carregar_sla_csv(sla_path) |
| dd['sla'] = dd['TIPO'].map(sla_dict) |
|
|
| |
| hoje = pd.Timestamp.today().normalize() |
|
|
| |
| 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 |
| ) |
|
|
| |
| dd['data_ref'] = dd['DATA DE ENTREGA V0'].where( |
| dd['DATA DE ENTREGA V0'].notna(), hoje |
| ) |
|
|
| |
| 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 |
| ) |
|
|
| |
| dd['PCT_SLA'] = np.where( |
| (dd['sla'].notna()) & (dd['sla'] > 0) & (dd['diasV0'].notna()), |
| (dd['diasV0'] / dd['sla'] * 100).round(1), |
| np.nan |
| ) |
|
|
| |
| dd['FAIXA_SLA'] = dd['PCT_SLA'].apply(calcular_faixa) |
|
|
| |
| dd['SLA_FIXO'] = dd['sla'] |
| 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) |
|
|
| |
| dd['ATUAL'] = (hoje - dd['DATA_PREVISTA']).dt.days |
| dd['DIFDIAS'] = dd['diasV0'] |
|
|
| |
| dd = dd.rename(columns={'DATA ADJ CLIENTE': 'DATA_ADJ_CLIENTE'}) |
|
|
| dd['DATA_CALCULO'] = hoje.strftime('%Y-%m-%d') |
| return dd |
|
|
| |
| CSV_PATH = os.path.join(BASE, 'tarefasss_datas_corrigidas_final.csv') |
| DF_GLOBAL = carregar_dados(CSV_PATH) |
|
|
| |
| 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: |
| 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) |
|
|
| |
| 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} |
|
|
| |
| 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' |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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() |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| |
| |
|
|
| 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) |
|
|
| |
| 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 = """ |
| @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; } |
| """ |
|
|
| |
| 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(): |
|
|
| |
| 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> |
| """) |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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) |
|
|
| |
| 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], |
| server_name="0.0.0.0", |
| server_port=7860, |
| ) |
|
|