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