""" 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"""
""" for label, bg in zip(faixa_labels, faixa_header_bg): html += (f' \n') html += ' \n \n \n \n' for _, row in pivot.iterrows(): sla_val = row['SLA [dias]'] sla_str = str(int(sla_val)) if sla_val > 0 else '—' html += f' \n \n' html += f' \n' for col, (bg, fg) in zip(faixa_cols, faixa_cell): val = int(row[col]) if val == 0: html += ' \n' else: html += (f' \n') total = int(row['TOTAL']) html += f' \n \n' html += ' \n
TIPOS SLA[dias]' f'{label}[uni]TOTAL
{row["TIPOS"]}{sla_str}' f'{val}' f'{total}
\n
' 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"""
Total de Tarefas
{stats['total']}
registos processados
Dentro do SLA
{stats['dentro']}
≤ 100 % SLA
SLA Excedido
{stats['excedido']}
> 100 % SLA
Taxa de Cumprimento
{pct} %
tarefas dentro do SLA
""" 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"""

📊 CIRCET SLA — Acompanhamento de Tarefas

Controlo SLA por tipo de tarefa  ·  Distribuição por faixas de percentagem  ·  {N_TOTAL} registos  ·  Referência: {DATA_REF}

""") # ── 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('
⬇ Exportar Dados
') 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("""
Legenda: < 50 % — Dentro do prazo 50 % < X ≤ 75 % — Atenção 75 % < X ≤ 100 % — Crítico > 100 % — SLA excedido
""") # 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("""

Assistente RAG — NVIDIA NIM

Faça perguntas em linguagem natural sobre os dados do dashboard SLA.

""") 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="
" "Assistente SLA com NVIDIA NIM
" "Insira a sua chave e faça uma pergunta" "
" ) 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], )