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