roundb commited on
Commit
45f64e5
·
verified ·
1 Parent(s): b436eb9

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +814 -260
app.py CHANGED
@@ -1,21 +1,15 @@
1
  """
2
- app.py — Dashboard SLA (conversão do dash.R para Python + Gradio)
3
- =================================================================
4
- Reproduz a tabela da imagem:
5
- TIPOS × SLA [dias] × Faixas de % SLA × TOTAL
6
- para os contextos: Em Curso | Licenciamento | Finalizado | Global
7
 
8
- Lógica fiel ao R (dash.R):
9
  prev = DATA_ADJ_CLIENTE + TEMPO_EXECUCAO
10
  atual = hoje - prev
11
  difdias = TEMPO_EXECUCAO - atual
12
  % SLA = TEMPO_EXECUCAO / SLA_FIXO × 100
13
-
14
- Funcionalidades:
15
- - Exportação CSV da categoria seleccionada (pivot calculado)
16
- - Exportação CSV da tabela de factos completa (todos os dados calculados)
17
- - Exportação Excel completo (6 sheets para Power BI)
18
- - Design visual profissional com indicadores de KPI
19
  """
20
 
21
  import os
@@ -23,26 +17,23 @@ import datetime
23
  import pandas as pd
24
  import numpy as np
25
  import gradio as gr
 
26
 
27
  # ── Paths ──────────────────────────────────────────────────────────────────────
28
- BASE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'upload')
29
- BASE = os.path.abspath(BASE)
30
 
31
- # /tmp é sempre gravável no Hugging Face Spaces e localmente
32
- # Fallback: se /tmp não existir, usa pasta output/ local
33
  _tmp_dir = '/tmp/sla_output'
34
  try:
35
  os.makedirs(_tmp_dir, exist_ok=True)
36
- # Testar escrita
37
  _test = os.path.join(_tmp_dir, '.write_test')
38
  open(_test, 'w').close()
39
  os.remove(_test)
40
  OUTPUT_DIR = _tmp_dir
41
  except Exception:
42
- OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'output')
43
  os.makedirs(OUTPUT_DIR, exist_ok=True)
44
 
45
- # ── SLA fixo por TIPO (tabela da imagem) ──────────────────────────────────────
46
  SLA_MAP = {
47
  'ART 2 3' : 30,
48
  'RAMI' : 30,
@@ -89,7 +80,61 @@ CAT_CORES = {
89
  'GLOBAL' : ('#212121', '#37474F'),
90
  }
91
 
92
- # ── Leitura dos CSVs de categoria ─────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  def ler_status_csv(path):
94
  for enc in ('utf-8', 'latin-1', 'cp1252'):
95
  try:
@@ -109,10 +154,21 @@ def ler_status_csv(path):
109
  continue
110
  return []
111
 
112
- EM_CURSO_STATUS = set(ler_status_csv(os.path.join(BASE, 'emcurso.csv')))
113
- FINALIZADO_STATUS = set(ler_status_csv(os.path.join(BASE, 'finalizado.csv')))
114
- LICENCIAMENTO_STATUS = set(ler_status_csv(os.path.join(BASE, 'licenciamento.csv')))
115
-
 
 
 
 
 
 
 
 
 
 
 
116
  STATUS_EXTRA_MAP = {
117
  '02.1 PROJETO POR ADJUDICAR' : 'EM CURSO',
118
  '02.10 PRE VALIDA??O PROJETO' : 'EM CURSO',
@@ -130,10 +186,21 @@ STATUS_EXTRA_MAP = {
130
 
131
  def get_categoria(status: str) -> str:
132
  s = str(status).strip()
133
- if s in FINALIZADO_STATUS: return 'FINALIZADO'
134
- if s in LICENCIAMENTO_STATUS: return 'LICENCIAMENTO'
135
- if s in EM_CURSO_STATUS: return 'EM CURSO'
136
- return STATUS_EXTRA_MAP.get(s, 'GLOBAL')
 
 
 
 
 
 
 
 
 
 
 
137
 
138
  def calcular_faixa(pct):
139
  if pd.isna(pct): return 'N/A'
@@ -142,7 +209,7 @@ def calcular_faixa(pct):
142
  elif pct <= 100: return '75 % < X ≤ 100 %'
143
  else: return '> 100 %'
144
 
145
- # ── Carregar e processar dados (lógica do dash.R) ─────────────────────────────
146
  def carregar_dados(caminho_csv: str) -> pd.DataFrame:
147
  df_raw = pd.read_csv(caminho_csv, sep=';', encoding='utf-8', on_bad_lines='skip')
148
  df_raw.rename(columns={df_raw.columns[9]: 'TEMPO_EXECUCAO'}, inplace=True)
@@ -170,6 +237,8 @@ def carregar_dados(caminho_csv: str) -> pd.DataFrame:
170
  dd['SLA_FIXO'] = dd['TIPO'].map(SLA_MAP)
171
  dd['TIPO_LABEL'] = dd['TIPO'].map(TIPO_LABEL).fillna(dd['TIPO'])
172
  dd['CATEGORIA'] = dd['RB STATUS'].apply(get_categoria)
 
 
173
 
174
  dd['PCT_SLA'] = np.where(
175
  (dd['SLA_FIXO'] > 0) & (dd['TEMPO_EXECUCAO'] >= 0),
@@ -184,7 +253,7 @@ def carregar_dados(caminho_csv: str) -> pd.DataFrame:
184
  CSV_PATH = os.path.join(BASE, 'tarefasss_datas_corrigidas_final.csv')
185
  DF_GLOBAL = carregar_dados(CSV_PATH)
186
 
187
- # ── Construir tabela pivot para uma categoria ──────────────────────────────────
188
  def build_pivot(df: pd.DataFrame, categoria: str) -> pd.DataFrame:
189
  if categoria == 'GLOBAL':
190
  sub = df.copy()
@@ -222,7 +291,7 @@ def get_stats(categoria: str) -> dict:
222
 
223
  return {'total': total, 'dentro': dentro, 'excedido': excedido, 'pct_ok': pct_ok}
224
 
225
- # ── Renderizar tabela HTML com design profissional ────────────────────────────
226
  def render_html_table(pivot: pd.DataFrame, categoria: str) -> str:
227
  cor_dark, cor_mid = CAT_CORES.get(categoria, ('#212121', '#37474F'))
228
 
@@ -299,102 +368,80 @@ def render_html_table(pivot: pd.DataFrame, categoria: str) -> str:
299
  background-color: #ffffff;
300
  }}
301
  .sla-table tbody tr:hover td {{
302
- background-color: #eef2ff !important;
303
  }}
304
  .sla-table td {{
305
- padding: 9px 16px;
306
- border-bottom: 1px solid #e8eaf0;
307
  vertical-align: middle;
308
  }}
309
  .sla-table td.td-tipo {{
310
  font-weight: 600;
311
- color: #1a1a2e;
312
- border-left: 4px solid {cor_mid};
313
- text-align: left;
314
- background-color: inherit;
315
  }}
316
  .sla-table td.td-sla {{
317
  text-align: center;
 
318
  color: #546e7a;
319
- font-style: italic;
320
- font-size: 12px;
321
- }}
322
- .sla-table td.td-faixa {{
323
- text-align: center;
324
  }}
325
  .sla-table td.td-total {{
326
  text-align: center;
327
- font-weight: 800;
328
- font-size: 14px;
329
  color: {cor_dark};
 
330
  background-color: #f0f4ff !important;
331
- border-left: 2px solid #c5cae9;
332
  }}
333
  .badge {{
334
- display: inline-flex;
335
- align-items: center;
336
- justify-content: center;
337
  min-width: 36px;
338
- height: 26px;
339
- padding: 0 10px;
340
  border-radius: 20px;
341
  font-weight: 700;
342
  font-size: 13px;
343
- line-height: 1;
344
- }}
345
- .badge-zero {{
346
- color: #bdbdbd;
347
- font-size: 16px;
348
- font-weight: 400;
349
- }}
350
- .sub-label {{
351
- display: block;
352
- font-size: 10px;
353
- font-weight: 400;
354
- opacity: 0.85;
355
- margin-top: 2px;
356
- text-transform: none;
357
- letter-spacing: 0;
358
- color: #ffffff !important;
359
  }}
360
  </style>
361
  <div class="sla-wrap">
362
  <table class="sla-table">
363
  <thead>
364
  <tr>
365
- <th class="th-tipo">TIPOS</th>
366
- <th class="th-sla">SLA<span class="sub-label">[dias]</span></th>
367
  """
368
  for label, bg in zip(faixa_labels, faixa_header_bg):
369
- html += (f' <th style="background:{bg};color:#ffffff !important;'
370
- f'text-align:center;min-width:90px;padding:11px 16px;font-weight:700;'
371
- f'font-size:12px;text-transform:uppercase;letter-spacing:0.4px;">'
372
- f'{label}<span class="sub-label">[uni]</span></th>\n')
373
-
374
- html += ' <th class="th-total">TOTAL</th>\n </tr>\n </thead>\n <tbody>\n'
375
 
376
  for _, row in pivot.iterrows():
377
- sla_val = row['SLA [dias]']
378
- sla_str = str(int(sla_val)) if sla_val > 0 else '—'
379
- html += f' <tr>\n <td class="td-tipo">{row["TIPOS"]}</td>\n'
380
- html += f' <td class="td-sla">{sla_str}</td>\n'
381
-
382
- for col, (bg, fg) in zip(faixa_cols, faixa_cell):
383
  val = int(row[col])
384
- if val == 0:
385
- html += ' <td class="td-faixa"><span class="badge badge-zero"></span></td>\n'
386
  else:
387
- html += (f' <td class="td-faixa">'
388
- f'<span class="badge" style="background:{bg};color:{fg};">{val}</span>'
389
- f'</td>\n')
390
-
391
- total = int(row['TOTAL'])
392
- html += f' <td class="td-total">{total}</td>\n </tr>\n'
393
-
394
- html += ' </tbody>\n</table>\n</div>'
 
 
 
 
 
 
 
 
395
  return html
396
 
397
- # ── Renderizar KPI cards HTML ─────────────────────────────────────────────────
398
  def render_kpi_html(stats: dict, categoria: str) -> str:
399
  cor_dark, cor_mid = CAT_CORES.get(categoria, ('#212121', '#37474F'))
400
  pct = stats['pct_ok']
@@ -465,107 +512,46 @@ def render_kpi_html(stats: dict, categoria: str) -> str:
465
  return html
466
 
467
  # ── Exportações ────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
 
469
  def exportar_csv_pivot(categoria: str) -> str:
470
- try:
471
- pivot = build_pivot(DF_GLOBAL, categoria)
472
- ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
473
- nome = f"sla_pivot_{categoria.lower().replace(' ', '_')}_{ts}.csv"
474
- path = os.path.join(OUTPUT_DIR, nome)
475
- pivot.to_csv(path, index=False, encoding='utf-8-sig', sep=';')
476
- return path
477
- except Exception as e:
478
- # Fallback: gravar em /tmp directamente
479
- import tempfile
480
- tmp = tempfile.NamedTemporaryFile(
481
- delete=False, suffix='.csv',
482
- prefix=f"sla_pivot_{categoria.lower().replace(' ','_')}_"
483
- )
484
- build_pivot(DF_GLOBAL, categoria).to_csv(tmp.name, index=False, encoding='utf-8-sig', sep=';')
485
- return tmp.name
486
 
487
  def exportar_csv_fact(categoria: str) -> str:
488
- try:
489
- sub = DF_GLOBAL.copy() if categoria == 'GLOBAL' else DF_GLOBAL[DF_GLOBAL['CATEGORIA'] == categoria].copy()
490
- fact = sub[[
491
- 'SUB-CIP', 'PROJETO', 'TIPO', 'TIPO_LABEL', 'RB STATUS', 'CATEGORIA',
492
- 'DATA_ADJ_CLIENTE', 'DATA_PREVISTA', 'TEMPO_EXECUCAO',
493
- 'ATUAL', 'DIFDIAS', 'SLA_FIXO', 'PCT_SLA', 'FAIXA_SLA', 'DATA_CALCULO'
494
- ]].copy()
495
- fact['DATA_ADJ_CLIENTE'] = fact['DATA_ADJ_CLIENTE'].dt.strftime('%d/%m/%Y')
496
- fact['DATA_PREVISTA'] = fact['DATA_PREVISTA'].dt.strftime('%d/%m/%Y')
497
- ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
498
- nome = f"sla_fact_{categoria.lower().replace(' ', '_')}_{ts}.csv"
499
- path = os.path.join(OUTPUT_DIR, nome)
500
- fact.to_csv(path, index=False, encoding='utf-8-sig', sep=';')
501
- return path
502
- except Exception as e:
503
- import tempfile
504
- sub = DF_GLOBAL.copy() if categoria == 'GLOBAL' else DF_GLOBAL[DF_GLOBAL['CATEGORIA'] == categoria].copy()
505
- fact = sub[[
506
- 'SUB-CIP', 'PROJETO', 'TIPO', 'TIPO_LABEL', 'RB STATUS', 'CATEGORIA',
507
- 'DATA_ADJ_CLIENTE', 'DATA_PREVISTA', 'TEMPO_EXECUCAO',
508
- 'ATUAL', 'DIFDIAS', 'SLA_FIXO', 'PCT_SLA', 'FAIXA_SLA', 'DATA_CALCULO'
509
- ]].copy()
510
- fact['DATA_ADJ_CLIENTE'] = fact['DATA_ADJ_CLIENTE'].dt.strftime('%d/%m/%Y')
511
- fact['DATA_PREVISTA'] = fact['DATA_PREVISTA'].dt.strftime('%d/%m/%Y')
512
- tmp = tempfile.NamedTemporaryFile(
513
- delete=False, suffix='.csv',
514
- prefix=f"sla_fact_{categoria.lower().replace(' ','_')}_"
515
- )
516
- fact.to_csv(tmp.name, index=False, encoding='utf-8-sig', sep=';')
517
- return tmp.name
518
-
519
- def exportar_excel_powerbi() -> str:
520
- import tempfile
521
- ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
522
- # Tentar OUTPUT_DIR; se falhar, usar tempfile
523
- try:
524
- path = os.path.join(OUTPUT_DIR, f'modelo_sla_powerbi_{ts}.xlsx')
525
- open(path, 'wb').close() # testar escrita
526
- except Exception:
527
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx', prefix='modelo_sla_powerbi_')
528
- path = tmp.name
529
- tmp.close()
530
-
531
- with pd.ExcelWriter(path, engine='openpyxl') as writer:
532
- fact = DF_GLOBAL[[
533
- 'SUB-CIP', 'PROJETO', 'TIPO', 'TIPO_LABEL', 'RB STATUS', 'CATEGORIA',
534
- 'DATA_ADJ_CLIENTE', 'DATA_PREVISTA', 'TEMPO_EXECUCAO',
535
- 'ATUAL', 'DIFDIAS', 'SLA_FIXO', 'PCT_SLA', 'FAIXA_SLA', 'DATA_CALCULO'
536
- ]].copy()
537
- fact['DATA_ADJ_CLIENTE'] = fact['DATA_ADJ_CLIENTE'].dt.strftime('%d/%m/%Y')
538
- fact['DATA_PREVISTA'] = fact['DATA_PREVISTA'].dt.strftime('%d/%m/%Y')
539
- fact.to_excel(writer, sheet_name='Fact_Tarefas', index=False)
540
-
541
- for cat in ['EM CURSO', 'LICENCIAMENTO', 'FINALIZADO', 'GLOBAL']:
542
- pivot = build_pivot(DF_GLOBAL, cat)
543
- pivot['CATEGORIA'] = cat
544
- pivot.to_excel(writer, sheet_name=cat.replace(' ', '_')[:31], index=False)
545
-
546
- pd.DataFrame([
547
- {'TIPO': t, 'TIPO_LABEL': TIPO_LABEL.get(t, t),
548
- 'SLA_DIAS': SLA_MAP.get(t, 0), 'ORDEM': i + 1}
549
- for i, t in enumerate(TIPO_ORDER)
550
- ]).to_excel(writer, sheet_name='Dim_Tipo', index=False)
551
-
552
- all_status = sorted(DF_GLOBAL['RB STATUS'].dropna().unique())
553
- pd.DataFrame({
554
- 'RB_STATUS': all_status,
555
- 'CATEGORIA': [get_categoria(s) for s in all_status],
556
- }).to_excel(writer, sheet_name='Dim_Status', index=False)
557
-
558
- pd.DataFrame({
559
- 'CATEGORIA': ['EM CURSO', 'LICENCIAMENTO', 'FINALIZADO', 'GLOBAL'],
560
- 'ORDEM' : [1, 2, 3, 4],
561
- }).to_excel(writer, sheet_name='Dim_Categoria', index=False)
562
-
563
- pd.DataFrame({
564
- 'FAIXA_SLA': ['< 50 %', '50 % < X ≤ 75 %', '75 % < X ≤ 100 %', '> 100 %'],
565
- 'ORDEM' : [1, 2, 3, 4],
566
- 'COR_HEX' : ['#2ECC71', '#F1C40F', '#E67E22', '#E74C3C'],
567
- }).to_excel(writer, sheet_name='Dim_Faixa_SLA', index=False)
568
-
569
  return path
570
 
571
  # ── Actualizar vista principal ─────────────────────────────────────────────────
@@ -576,22 +562,439 @@ def atualizar_vista(categoria: str):
576
  kpi_html = render_kpi_html(stats, categoria)
577
  return tabela_html, kpi_html
578
 
579
- # ── CSS global ────────────────────────────────────────────────────────────────
580
- # NOTA: No Hugging Face Spaces o CSS externo pode ser sobreposto pelo tema.
581
- # O header usa inline styles directamente no HTML para garantir cor branca.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
  CSS = """
583
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
584
-
585
  body, .gradio-container {
586
  font-family: 'Inter', 'Segoe UI', Arial, sans-serif !important;
587
  background: #F0F2F8 !important;
588
  }
589
  .gradio-container {
590
- max-width: 1280px !important;
591
  margin: 0 auto !important;
592
  padding: 0 !important;
593
  }
594
- /* Header — reforço via CSS (o inline style no HTML é a fonte primária) */
595
  .sla-header-wrap,
596
  .sla-header-wrap *,
597
  .sla-header-wrap h1,
@@ -651,6 +1054,18 @@ body, .gradio-container {
651
  margin-top: 16px;
652
  flex-wrap: wrap;
653
  }
 
 
 
 
 
 
 
 
 
 
 
 
654
  footer { display: none !important; }
655
  .gr-panel, .gr-box { border-radius: 12px !important; }
656
  """
@@ -660,13 +1075,8 @@ CATEGORIAS = ['EM CURSO', 'LICENCIAMENTO', 'FINALIZADO', 'GLOBAL']
660
  DATA_REF = pd.Timestamp.today().strftime('%d/%m/%Y')
661
  N_TOTAL = len(DF_GLOBAL)
662
 
663
- # ── Header HTML — cor branca garantida via 3 mecanismos simultâneos:
664
- # 1. style="" inline em cada elemento
665
- # 2. -webkit-text-fill-color (sobrepõe alguns temas WebKit/Blink)
666
- # 3. <font color> como fallback para parsers que removem style
667
  HEADER_HTML = f"""
668
  <style>
669
- /* Especificidade máxima para o header — funciona no Hugging Face Spaces */
670
  div.sla-header-wrap {{
671
  background: linear-gradient(135deg, #0D47A1 0%, #1565C0 50%, #1976D2 100%) !important;
672
  padding: 28px 36px 22px !important;
@@ -708,78 +1118,221 @@ HEADER_HTML = f"""
708
  <font color="#ffffff">
709
  Controlo SLA por tipo de tarefa &nbsp;·&nbsp; Distribuição por faixas de percentagem
710
  &nbsp;·&nbsp; {N_TOTAL} registos &nbsp;·&nbsp; Referência: {DATA_REF}
 
711
  </font>
712
  </p>
713
  </div>
714
  """
715
 
716
- with gr.Blocks(title="Dashboard SLA") as demo:
 
 
 
 
 
 
 
 
717
 
718
- gr.HTML(HEADER_HTML)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
719
 
720
- # ── Selector de categoria ────────────────────────────────────────────────
721
- with gr.Row():
722
- cat_selector = gr.Radio(
723
- choices=CATEGORIAS,
724
- value='EM CURSO',
725
- label='Categoria',
726
- interactive=True,
727
- elem_classes=['cat-selector'],
728
- )
729
 
730
- # ── Tabela + KPIs ────────────────────────────────────────────────────────
731
- with gr.Row(equal_height=True):
732
- with gr.Column(scale=4):
733
- tabela_out = gr.HTML()
734
- with gr.Column(scale=1, min_width=220):
735
- kpi_out = gr.HTML()
736
-
737
- # ── Secção de exportação ─────────────────────────────────────────────────
738
- gr.HTML('<div class="export-section"><b style="font-size:13px;color:#37474F;'
739
- 'text-transform:uppercase;letter-spacing:0.6px;">⬇ Exportar Dados</b></div>')
740
-
741
- with gr.Row():
742
- with gr.Column(scale=1):
743
- gr.Markdown("**Pivot da categoria** — distribuição por faixas SLA")
744
- btn_pivot = gr.Button("⬇ CSV — Tabela Pivot", variant="secondary", elem_classes=["btn-export"])
745
- file_pivot = gr.File(label="", show_label=False)
746
-
747
- with gr.Column(scale=1):
748
- gr.Markdown("**Dados calculados completos** — todos os campos do dash.R")
749
- btn_fact = gr.Button("⬇ CSV — Dados Calculados", variant="secondary", elem_classes=["btn-export"])
750
- file_fact = gr.File(label="", show_label=False)
751
-
752
-
753
-
754
- # ── Legenda ──────────────────────────────────────────────────────────────
755
- gr.HTML("""
756
- <div class="legenda-bar">
757
- <span style="font-size:12px;font-weight:700;color:#546e7a;text-transform:uppercase;letter-spacing:0.5px;">Legenda:</span>
758
- <span style="display:inline-flex;align-items:center;gap:6px;font-size:13px;">
759
- <span style="width:14px;height:14px;border-radius:50%;background:#2E7D32;display:inline-block;"></span>
760
- <b style="color:#1B5E20">&lt; 50 %</b> — Dentro do prazo
761
- </span>
762
- <span style="display:inline-flex;align-items:center;gap:6px;font-size:13px;">
763
- <span style="width:14px;height:14px;border-radius:50%;background:#E65100;display:inline-block;"></span>
764
- <b style="color:#E65100">50 % &lt; X 75 %</b> — Atenção
765
- </span>
766
- <span style="display:inline-flex;align-items:center;gap:6px;font-size:13px;">
767
- <span style="width:14px;height:14px;border-radius:50%;background:#BF360C;display:inline-block;"></span>
768
- <b style="color:#BF360C">75 % &lt; X ≤ 100 %</b> — Crítico
769
- </span>
770
- <span style="display:inline-flex;align-items:center;gap:6px;font-size:13px;">
771
- <span style="width:14px;height:14px;border-radius:50%;background:#B71C1C;display:inline-block;"></span>
772
- <b style="color:#B71C1C">&gt; 100 %</b> — SLA excedido
773
- </span>
774
- </div>
775
- """)
776
-
777
- # ── Eventos ──────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
778
  cat_selector.change(fn=atualizar_vista, inputs=cat_selector, outputs=[tabela_out, kpi_out])
779
  demo.load(fn=lambda: atualizar_vista('EM CURSO'), outputs=[tabela_out, kpi_out])
780
  btn_pivot.click(fn=exportar_csv_pivot, inputs=cat_selector, outputs=file_pivot)
781
  btn_fact.click(fn=exportar_csv_fact, inputs=cat_selector, outputs=file_fact)
782
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
783
 
784
  if __name__ == '__main__':
785
  demo.launch(
@@ -788,4 +1341,5 @@ if __name__ == '__main__':
788
  share=False,
789
  css=CSS,
790
  theme=gr.themes.Base(),
791
- )
 
 
1
  """
2
+ app.py — Dashboard SLA + RAG com NVIDIA NIM
3
+ ============================================
4
+ Dashboard SLA com sistema RAG (Retrieval-Augmented Generation) integrado.
5
+ Permite fazer perguntas em linguagem natural sobre os dados do dashboard.
6
+ Usa a API NVIDIA NIM (gratuita) com o modelo meta/llama-3.3-70b-instruct.
7
 
8
+ Lógica SLA fiel ao dash.R original:
9
  prev = DATA_ADJ_CLIENTE + TEMPO_EXECUCAO
10
  atual = hoje - prev
11
  difdias = TEMPO_EXECUCAO - atual
12
  % SLA = TEMPO_EXECUCAO / SLA_FIXO × 100
 
 
 
 
 
 
13
  """
14
 
15
  import os
 
17
  import pandas as pd
18
  import numpy as np
19
  import gradio as gr
20
+ from openai import OpenAI
21
 
22
  # ── Paths ──────────────────────────────────────────────────────────────────────
23
+ BASE = os.path.dirname(os.path.abspath(__file__))
 
24
 
 
 
25
  _tmp_dir = '/tmp/sla_output'
26
  try:
27
  os.makedirs(_tmp_dir, exist_ok=True)
 
28
  _test = os.path.join(_tmp_dir, '.write_test')
29
  open(_test, 'w').close()
30
  os.remove(_test)
31
  OUTPUT_DIR = _tmp_dir
32
  except Exception:
33
+ OUTPUT_DIR = os.path.join(BASE, 'output')
34
  os.makedirs(OUTPUT_DIR, exist_ok=True)
35
 
36
+ # ── SLA fixo por TIPO ──────────────────────────────────────────────────────────
37
  SLA_MAP = {
38
  'ART 2 3' : 30,
39
  'RAMI' : 30,
 
80
  'GLOBAL' : ('#212121', '#37474F'),
81
  }
82
 
83
+ # ── Tabela completa de estados (ETAT / Status / RESP) ─────────────────────────
84
+ # ETAT: fase do ciclo de vida (0=cancelado, 1=survey/projeto, 2=validação,
85
+ # 3=validado, 4=trabalhos, 5=cadastro, 6=faturado/concluído)
86
+ # RESP: entidade responsável (RB=RB Portugal, ORG=Orange, SGT=Sogetrel)
87
+ STATUS_TABLE = [
88
+ # (ETAT, Status, RESP, CATEGORIA)
89
+ (1, '01 POR INICIAR SURVEY', 'RB', 'EM CURSO'),
90
+ (1, '01.1 SURVEY EM AGENDAMENTO', 'RB', 'EM CURSO'),
91
+ (1, '01.2 SURVEY PENDENTE CLIENTE', 'ORG', 'EM CURSO'),
92
+ (1, '01.3 SURVEY EM CURSO', 'RB', 'EM CURSO'),
93
+ (1, '01.4 SURVEY CANCELADO', 'RB', 'FINALIZADO'),
94
+ (1, '02 POR INICAR PROJETO', 'RB', 'EM CURSO'),
95
+ (1, '02.1 PROJETO PENDENTE CLIENTE', 'RB', 'EM CURSO'),
96
+ (1, '02.2 PROJETO POR ADJUDICAR', 'RB', 'EM CURSO'),
97
+ (1, '02.3 PROJETO EM CURSO', 'ORG', 'EM CURSO'),
98
+ (1, '03 POR INICIAR CQ', 'RB', 'EM CURSO'),
99
+ (1, '03.1 CQ EM CURSO', 'RB', 'EM CURSO'),
100
+ (1, '03.2 CQ TERMINADO', 'RB', 'EM CURSO'),
101
+ (1, '03.3 CQ SOGETREL', 'RB', 'EM CURSO'),
102
+ (1, '04 PRE VALIDAÇÃO PROJETO', 'RB', 'EM CURSO'),
103
+ (1, '05 SUIVI PROJETO', 'RB', 'EM CURSO'),
104
+ (1, '06 POR INICIAR LICENCIAMENTOS', 'ORG', 'LICENCIAMENTO'),
105
+ (1, '06.1 LICENCIAMENTO POR ADJUDICAR', 'RB', 'LICENCIAMENTO'),
106
+ (1, '06.2 AGUARDA DEVIS', 'RB', 'LICENCIAMENTO'),
107
+ (1, '06.3 DEVIS OK', 'RB', 'LICENCIAMENTO'),
108
+ (1, '06.4 AGUARDA PMV+DT', 'RB', 'LICENCIAMENTO'),
109
+ (1, '06.5 PMV + DT OK', 'ORG', 'LICENCIAMENTO'),
110
+ (1, '06.6 AGUARDA CRVT', 'ORG', 'LICENCIAMENTO'),
111
+ (1, '06.7 CRVT OK', 'SGT', 'LICENCIAMENTO'),
112
+ (2, '07 VALIDAÇÃO ORANGE', 'ORG', 'EM CURSO'),
113
+ (3, '08 PROJETO VALIDADO', 'ORG', 'FINALIZADO'),
114
+ (4, '09 TRABALHOS EM CURSO', 'SGT', 'EM CURSO'),
115
+ (4, '10 TRABALHOS TERMINADOS', 'SGT', 'EM CURSO'),
116
+ (4, '11 POR INICIAR CADASTRO', 'RB', 'EM CURSO'),
117
+ (4, '11.1 AGUARDA RT', 'ORG', 'FINALIZADO'),
118
+ (4, '11.2 CADASTRO POR ADJUDICAR', 'RB', 'EM CURSO'),
119
+ (5, '11.3 CADASTRO EM CURSO', 'RB', 'EM CURSO'),
120
+ (5, '11.4 CADASTRO TERMINADO', 'ORG', 'EM CURSO'),
121
+ (6, '11.5 CADASTRO VALIDADO', 'ORG', 'FINALIZADO'),
122
+ (0, '12 CANCELADO', '', 'FINALIZADO'),
123
+ (6, '13 FATURADO', 'SGT', 'FINALIZADO'),
124
+ (6, '14 DOSSIER CONCLUIDO FINI', '', 'FINALIZADO'),
125
+ ]
126
+
127
+ # Dicionários derivados da tabela para uso rápido
128
+ STATUS_CATEGORIA_MAP = {row[1]: row[3] for row in STATUS_TABLE}
129
+ STATUS_ETAT_MAP = {row[1]: row[0] for row in STATUS_TABLE}
130
+ STATUS_RESP_MAP = {row[1]: row[2] for row in STATUS_TABLE}
131
+
132
+ # Conjuntos por categoria (para compatibilidade com CSVs originais)
133
+ EM_CURSO_STATUS = {s for s, c in STATUS_CATEGORIA_MAP.items() if c == 'EM CURSO'}
134
+ FINALIZADO_STATUS = {s for s, c in STATUS_CATEGORIA_MAP.items() if c == 'FINALIZADO'}
135
+ LICENCIAMENTO_STATUS = {s for s, c in STATUS_CATEGORIA_MAP.items() if c == 'LICENCIAMENTO'}
136
+
137
+ # Enriquecer com os CSVs originais (fallback)
138
  def ler_status_csv(path):
139
  for enc in ('utf-8', 'latin-1', 'cp1252'):
140
  try:
 
154
  continue
155
  return []
156
 
157
+ # Adicionar entradas dos CSVs originais que não estejam já na tabela
158
+ for _s in ler_status_csv(os.path.join(BASE, 'emcurso.csv')):
159
+ if _s and _s not in STATUS_CATEGORIA_MAP:
160
+ EM_CURSO_STATUS.add(_s)
161
+ STATUS_CATEGORIA_MAP[_s] = 'EM CURSO'
162
+ for _s in ler_status_csv(os.path.join(BASE, 'finalizado.csv')):
163
+ if _s and _s not in STATUS_CATEGORIA_MAP:
164
+ FINALIZADO_STATUS.add(_s)
165
+ STATUS_CATEGORIA_MAP[_s] = 'FINALIZADO'
166
+ for _s in ler_status_csv(os.path.join(BASE, 'licenciamento.csv')):
167
+ if _s and _s not in STATUS_CATEGORIA_MAP:
168
+ LICENCIAMENTO_STATUS.add(_s)
169
+ STATUS_CATEGORIA_MAP[_s] = 'LICENCIAMENTO'
170
+
171
+ # Mapeamentos legados (compatibilidade com dados antigos nos CSVs)
172
  STATUS_EXTRA_MAP = {
173
  '02.1 PROJETO POR ADJUDICAR' : 'EM CURSO',
174
  '02.10 PRE VALIDA??O PROJETO' : 'EM CURSO',
 
186
 
187
  def get_categoria(status: str) -> str:
188
  s = str(status).strip()
189
+ # 1. Tabela principal (STATUS_TABLE)
190
+ if s in STATUS_CATEGORIA_MAP:
191
+ return STATUS_CATEGORIA_MAP[s]
192
+ # 2. Mapeamentos legados
193
+ if s in STATUS_EXTRA_MAP:
194
+ return STATUS_EXTRA_MAP[s]
195
+ return 'GLOBAL'
196
+
197
+ def get_etat(status: str) -> int:
198
+ """Retorna o ETAT (fase) de um status, ou -1 se desconhecido."""
199
+ return STATUS_ETAT_MAP.get(str(status).strip(), -1)
200
+
201
+ def get_resp(status: str) -> str:
202
+ """Retorna a entidade responsável (RB/ORG/SGT) de um status."""
203
+ return STATUS_RESP_MAP.get(str(status).strip(), '')
204
 
205
  def calcular_faixa(pct):
206
  if pd.isna(pct): return 'N/A'
 
209
  elif pct <= 100: return '75 % < X ≤ 100 %'
210
  else: return '> 100 %'
211
 
212
+ # ── Carregar e processar dados ─────────────────────────────────────────────────
213
  def carregar_dados(caminho_csv: str) -> pd.DataFrame:
214
  df_raw = pd.read_csv(caminho_csv, sep=';', encoding='utf-8', on_bad_lines='skip')
215
  df_raw.rename(columns={df_raw.columns[9]: 'TEMPO_EXECUCAO'}, inplace=True)
 
237
  dd['SLA_FIXO'] = dd['TIPO'].map(SLA_MAP)
238
  dd['TIPO_LABEL'] = dd['TIPO'].map(TIPO_LABEL).fillna(dd['TIPO'])
239
  dd['CATEGORIA'] = dd['RB STATUS'].apply(get_categoria)
240
+ dd['ETAT'] = dd['RB STATUS'].apply(get_etat)
241
+ dd['RESP'] = dd['RB STATUS'].apply(get_resp)
242
 
243
  dd['PCT_SLA'] = np.where(
244
  (dd['SLA_FIXO'] > 0) & (dd['TEMPO_EXECUCAO'] >= 0),
 
253
  CSV_PATH = os.path.join(BASE, 'tarefasss_datas_corrigidas_final.csv')
254
  DF_GLOBAL = carregar_dados(CSV_PATH)
255
 
256
+ # ── Construir tabela pivot ─────────────────────────────────────────────────────
257
  def build_pivot(df: pd.DataFrame, categoria: str) -> pd.DataFrame:
258
  if categoria == 'GLOBAL':
259
  sub = df.copy()
 
291
 
292
  return {'total': total, 'dentro': dentro, 'excedido': excedido, 'pct_ok': pct_ok}
293
 
294
+ # ── Renderizar tabela HTML ─────────────────────────────────────────────────────
295
  def render_html_table(pivot: pd.DataFrame, categoria: str) -> str:
296
  cor_dark, cor_mid = CAT_CORES.get(categoria, ('#212121', '#37474F'))
297
 
 
368
  background-color: #ffffff;
369
  }}
370
  .sla-table tbody tr:hover td {{
371
+ background-color: #e8f0fe !important;
372
  }}
373
  .sla-table td {{
374
+ padding: 10px 16px;
375
+ border-bottom: 1px solid #e8ecf0;
376
  vertical-align: middle;
377
  }}
378
  .sla-table td.td-tipo {{
379
  font-weight: 600;
380
+ color: {cor_dark};
381
+ border-right: 1px solid #e8ecf0;
 
 
382
  }}
383
  .sla-table td.td-sla {{
384
  text-align: center;
385
+ font-weight: 500;
386
  color: #546e7a;
387
+ border-right: 1px solid #e8ecf0;
 
 
 
 
388
  }}
389
  .sla-table td.td-total {{
390
  text-align: center;
391
+ font-weight: 700;
 
392
  color: {cor_dark};
393
+ font-size: 15px;
394
  background-color: #f0f4ff !important;
 
395
  }}
396
  .badge {{
397
+ display: inline-block;
 
 
398
  min-width: 36px;
399
+ padding: 3px 10px;
 
400
  border-radius: 20px;
401
  font-weight: 700;
402
  font-size: 13px;
403
+ text-align: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  }}
405
  </style>
406
  <div class="sla-wrap">
407
  <table class="sla-table">
408
  <thead>
409
  <tr>
410
+ <th class="th-tipo">Tipos</th>
411
+ <th class="th-sla">SLA<br>[dias]</th>
412
  """
413
  for label, bg in zip(faixa_labels, faixa_header_bg):
414
+ html += f' <th style="background:{bg};color:#fff;text-align:center;width:110px;">{label}</th>\n'
415
+ html += ' <th class="th-total">Total</th>\n </tr>\n </thead>\n <tbody>\n'
 
 
 
 
416
 
417
  for _, row in pivot.iterrows():
418
+ html += ' <tr>\n'
419
+ html += f' <td class="td-tipo">{row["TIPOS"]}</td>\n'
420
+ html += f' <td class="td-sla">{int(row["SLA [dias]"])}</td>\n'
421
+ for col, (bg, cor) in zip(faixa_cols, faixa_cell):
 
 
422
  val = int(row[col])
423
+ if val > 0:
424
+ html += f' <td style="text-align:center;"><span class="badge" style="background:{bg};color:{cor};">{val}</span></td>\n'
425
  else:
426
+ html += f' <td style="text-align:center;color:#bdbdbd;">—</td>\n'
427
+ html += f' <td class="td-total">{int(row["TOTAL"])}</td>\n'
428
+ html += ' </tr>\n'
429
+
430
+ # Linha de totais
431
+ html += ' <tr style="border-top:2px solid #e0e0e0;">\n'
432
+ html += f' <td class="td-tipo" style="font-size:13px;color:{cor_dark};">TOTAL GERAL</td>\n'
433
+ html += ' <td class="td-sla"></td>\n'
434
+ for col, (bg, cor) in zip(faixa_cols, faixa_cell):
435
+ total_col = int(pivot[col].sum())
436
+ if total_col > 0:
437
+ html += f' <td style="text-align:center;"><span class="badge" style="background:{bg};color:{cor};">{total_col}</span></td>\n'
438
+ else:
439
+ html += f' <td style="text-align:center;color:#bdbdbd;">—</td>\n'
440
+ html += f' <td class="td-total" style="font-size:17px;">{int(pivot["TOTAL"].sum())}</td>\n'
441
+ html += ' </tr>\n </tbody>\n</table>\n</div>'
442
  return html
443
 
444
+ # ── Renderizar KPIs ────────────────────────────────────────────────────────────
445
  def render_kpi_html(stats: dict, categoria: str) -> str:
446
  cor_dark, cor_mid = CAT_CORES.get(categoria, ('#212121', '#37474F'))
447
  pct = stats['pct_ok']
 
512
  return html
513
 
514
  # ── Exportações ────────────────────────────────────────────────────────────────
515
+ def _get_export_path(prefixo: str, categoria: str) -> str:
516
+ """Devolve um caminho de ficheiro CSV acessível pelo Gradio."""
517
+ ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
518
+ nome = f"{prefixo}_{categoria.lower().replace(' ', '_')}_{ts}.csv"
519
+ # Tentar OUTPUT_DIR primeiro; fallback para BASE (sempre permitido)
520
+ for pasta in [OUTPUT_DIR, BASE]:
521
+ try:
522
+ path = os.path.join(pasta, nome)
523
+ open(path, 'w').close() # teste de escrita
524
+ os.remove(path)
525
+ return path
526
+ except Exception:
527
+ continue
528
+ # Último recurso: ficheiro temporário no directório do projecto
529
+ import tempfile
530
+ tmp = tempfile.NamedTemporaryFile(
531
+ delete=False, suffix='.csv', dir=BASE, prefix=prefixo + '_'
532
+ )
533
+ tmp.close()
534
+ return tmp.name
535
 
536
  def exportar_csv_pivot(categoria: str) -> str:
537
+ pivot = build_pivot(DF_GLOBAL, categoria)
538
+ path = _get_export_path('sla_pivot', categoria)
539
+ pivot.to_csv(path, index=False, encoding='utf-8-sig', sep=';')
540
+ return path
 
 
 
 
 
 
 
 
 
 
 
 
541
 
542
  def exportar_csv_fact(categoria: str) -> str:
543
+ sub = DF_GLOBAL.copy() if categoria == 'GLOBAL' else DF_GLOBAL[DF_GLOBAL['CATEGORIA'] == categoria].copy()
544
+ cols = [
545
+ 'SUB-CIP', 'PROJETO', 'TIPO', 'TIPO_LABEL', 'RB STATUS', 'CATEGORIA',
546
+ 'ETAT', 'RESP',
547
+ 'DATA_ADJ_CLIENTE', 'DATA_PREVISTA', 'TEMPO_EXECUCAO',
548
+ 'ATUAL', 'DIFDIAS', 'SLA_FIXO', 'PCT_SLA', 'FAIXA_SLA', 'DATA_CALCULO'
549
+ ]
550
+ fact = sub[cols].copy()
551
+ fact['DATA_ADJ_CLIENTE'] = fact['DATA_ADJ_CLIENTE'].dt.strftime('%d/%m/%Y')
552
+ fact['DATA_PREVISTA'] = fact['DATA_PREVISTA'].dt.strftime('%d/%m/%Y')
553
+ path = _get_export_path('sla_fact', categoria)
554
+ fact.to_csv(path, index=False, encoding='utf-8-sig', sep=';')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
555
  return path
556
 
557
  # ── Actualizar vista principal ─────────────────────────────────────────────────
 
562
  kpi_html = render_kpi_html(stats, categoria)
563
  return tabela_html, kpi_html
564
 
565
+ # ═══════════════════════════════════════════════════════════════════════════════
566
+ # ── RAG: Geração de contexto estruturado a partir dos dados ───────────────────
567
+ # ═══════════════════════════════════════════════════════════════════════════════
568
+
569
+ def gerar_contexto_rag() -> str:
570
+ """
571
+ Gera um contexto estruturado e rico dos dados do dashboard SLA para RAG.
572
+ Inclui cruzamentos avançados, análise de risco, aging, gargalos e tendências.
573
+ """
574
+ hoje_ts = pd.Timestamp.today().normalize()
575
+ hoje_str = hoje_ts.strftime('%d/%m/%Y')
576
+ df = DF_GLOBAL.copy()
577
+ linhas = []
578
+
579
+ # ── 1. CABEÇALHO ─────────────────────────────────────────────────────────
580
+ linhas.append("=" * 70)
581
+ linhas.append("DASHBOARD SLA — CONTEXTO COMPLETO PARA GESTOR DE PROJECTO")
582
+ linhas.append("=" * 70)
583
+ linhas.append(f"Data de referência : {hoje_str}")
584
+ linhas.append(f"Total de registos : {len(df)}")
585
+ linhas.append(f"Tipos de tarefa : {', '.join(sorted(df['TIPO_LABEL'].unique()))}")
586
+ linhas.append(f"Categorias activas : EM CURSO ({(df['CATEGORIA']=='EM CURSO').sum()}) | "
587
+ f"LICENCIAMENTO ({(df['CATEGORIA']=='LICENCIAMENTO').sum()}) | "
588
+ f"FINALIZADO ({(df['CATEGORIA']=='FINALIZADO').sum()})")
589
+ linhas.append("")
590
+
591
+ # ── 2. KPIs EXECUTIVOS ────────────────────────────────────────────────────
592
+ linhas.append("-" * 70)
593
+ linhas.append("KPIs EXECUTIVOS (visão de topo)")
594
+ linhas.append("-" * 70)
595
+ for cat in ['GLOBAL', 'EM CURSO', 'LICENCIAMENTO', 'FINALIZADO']:
596
+ stats = get_stats(cat)
597
+ sub = df if cat == 'GLOBAL' else df[df['CATEGORIA'] == cat]
598
+ n_sla = sub[sub['SLA_FIXO'] > 0]
599
+ pct_medio = round(n_sla['PCT_SLA'].mean(), 1) if len(n_sla) > 0 else 0
600
+ linhas.append(f" [{cat}]")
601
+ linhas.append(f" Total tarefas : {stats['total']}")
602
+ linhas.append(f" Dentro SLA (≤100%) : {stats['dentro']} ({stats['pct_ok']}%)")
603
+ linhas.append(f" SLA excedido (>100%): {stats['excedido']} ({round(100-stats['pct_ok'],1)}%)")
604
+ linhas.append(f" % SLA médio : {pct_medio}%")
605
+ linhas.append("")
606
+
607
+ # ── 3. DISTRIBUIÇÃO DETALHADA POR TIPO E FAIXA ───────────────────────────
608
+ linhas.append("-" * 70)
609
+ linhas.append("DISTRIBUIÇÃO POR TIPO E FAIXA SLA (por categoria)")
610
+ linhas.append("-" * 70)
611
+ for cat in ['GLOBAL', 'EM CURSO', 'LICENCIAMENTO', 'FINALIZADO']:
612
+ pivot = build_pivot(df, cat)
613
+ linhas.append(f" [{cat}]")
614
+ for _, row in pivot.iterrows():
615
+ if row['TOTAL'] > 0:
616
+ t = int(row['TOTAL'])
617
+ exc = int(row['> 100 % [uni]'])
618
+ pct_exc = round(exc / t * 100, 1) if t > 0 else 0
619
+ linhas.append(
620
+ f" {row['TIPOS']:<20} SLA={int(row['SLA [dias]'])}d | "
621
+ f"Total={t:3d} | <50%={int(row['< 50 % [uni]']):3d} | "
622
+ f"50-75%={int(row['50 % < X ≤ 75 % [uni]']):3d} | "
623
+ f"75-100%={int(row['75 % < X ≤ 100 % [uni]']):3d} | "
624
+ f">100%={exc:3d} ({pct_exc}% do tipo)"
625
+ )
626
+ linhas.append("")
627
+
628
+ # ── 4. ANÁLISE DE RISCO: TIPOS MAIS CRÍTICOS ─────────────────────────────
629
+ linhas.append("-" * 70)
630
+ linhas.append("ANÁLISE DE RISCO — TIPOS ORDENADOS POR TAXA DE INCUMPRIMENTO")
631
+ linhas.append("-" * 70)
632
+ risco = df[df['SLA_FIXO'] > 0].groupby('TIPO_LABEL').agg(
633
+ total=('TIPO_LABEL', 'count'),
634
+ excedido=('FAIXA_SLA', lambda x: (x == '> 100 %').sum()),
635
+ pct_medio=('PCT_SLA', 'mean')
636
+ ).reset_index()
637
+ risco['taxa_exc'] = (risco['excedido'] / risco['total'] * 100).round(1)
638
+ risco['pct_medio'] = risco['pct_medio'].round(1)
639
+ risco = risco.sort_values('taxa_exc', ascending=False)
640
+ for _, r in risco.iterrows():
641
+ nivel = "CRITICO" if r['taxa_exc'] >= 70 else ("ALTO" if r['taxa_exc'] >= 40 else ("MEDIO" if r['taxa_exc'] >= 20 else "BAIXO"))
642
+ linhas.append(
643
+ f" [{nivel}] {r['TIPO_LABEL']:<20} | "
644
+ f"Excedido: {r['excedido']}/{r['total']} ({r['taxa_exc']}%) | "
645
+ f"% SLA médio: {r['pct_medio']}%"
646
+ )
647
+ linhas.append("")
648
+
649
+ # ── 5. AGING: PROJECTOS COM MAIOR DESVIO ─────────────────────────────────
650
+ linhas.append("-" * 70)
651
+ linhas.append("AGING — TOP 20 PROJECTOS COM MAIOR EXCESSO DE SLA")
652
+ linhas.append("-" * 70)
653
+ excedidos = df[df['FAIXA_SLA'] == '> 100 %'].copy()
654
+ excedidos = excedidos.sort_values('PCT_SLA', ascending=False).head(20)
655
+ for _, row in excedidos.iterrows():
656
+ data_adj = row['DATA_ADJ_CLIENTE'].strftime('%d/%m/%Y') if pd.notna(row['DATA_ADJ_CLIENTE']) else 'N/D'
657
+ dias_atraso = int(row['ATUAL']) if pd.notna(row['ATUAL']) else 0
658
+ linhas.append(
659
+ f" {row['PROJETO']:<14} [{row['CATEGORIA']:<13}] "
660
+ f"Tipo: {row['TIPO_LABEL']:<16} Status: {row['RB STATUS']:<30} "
661
+ f"% SLA: {row['PCT_SLA']:>6}% | Atraso: {dias_atraso:>4}d | Adj: {data_adj}"
662
+ )
663
+ linhas.append("")
664
+
665
+ # ── 6. CRUZAMENTO TIPO × STATUS (gargalos operacionais) ──────────────────
666
+ linhas.append("-" * 70)
667
+ linhas.append("CRUZAMENTO TIPO × STATUS — ONDE ESTÃO OS GARGALOS")
668
+ linhas.append("-" * 70)
669
+ cross = df[df['FAIXA_SLA'] == '> 100 %'].groupby(
670
+ ['TIPO_LABEL', 'RB STATUS']
671
+ ).size().reset_index(name='n_excedidos')
672
+ cross = cross.sort_values('n_excedidos', ascending=False).head(20)
673
+ for _, r in cross.iterrows():
674
+ linhas.append(
675
+ f" {r['TIPO_LABEL']:<20} + {r['RB STATUS']:<35} → {r['n_excedidos']} excedidos"
676
+ )
677
+ linhas.append("")
678
+
679
+ # ── 7. CRUZAMENTO CATEGORIA × TIPO × STATUS (visão completa) ─────────────
680
+ linhas.append("-" * 70)
681
+ linhas.append("DISTRIBUIÇÃO COMPLETA: CATEGORIA × TIPO (tarefas activas)")
682
+ linhas.append("-" * 70)
683
+ cross2 = df.groupby(['CATEGORIA', 'TIPO_LABEL']).agg(
684
+ total=('PROJETO', 'count'),
685
+ excedido=('FAIXA_SLA', lambda x: (x == '> 100 %').sum())
686
+ ).reset_index()
687
+ cross2['taxa'] = (cross2['excedido'] / cross2['total'] * 100).round(1)
688
+ cross2 = cross2.sort_values(['CATEGORIA', 'excedido'], ascending=[True, False])
689
+ cat_actual = ''
690
+ for _, r in cross2.iterrows():
691
+ if r['CATEGORIA'] != cat_actual:
692
+ cat_actual = r['CATEGORIA']
693
+ linhas.append(f" [{cat_actual}]")
694
+ linhas.append(
695
+ f" {r['TIPO_LABEL']:<20} Total: {r['total']:3d} | "
696
+ f"Excedido: {r['excedido']:3d} ({r['taxa']}%)"
697
+ )
698
+ linhas.append("")
699
+
700
+ # ── 8. ANÁLISE TEMPORAL: ADJUDICAÇÕES POR MÊS ────────────────────────────
701
+ linhas.append("-" * 70)
702
+ linhas.append("ADJUDICAÇÕES POR MÊS (volume de entrada de trabalho)")
703
+ linhas.append("-" * 70)
704
+ df_datas = df[df['DATA_ADJ_CLIENTE'].notna()].copy()
705
+ df_datas['MES_ADJ'] = df_datas['DATA_ADJ_CLIENTE'].dt.to_period('M')
706
+ mes_counts = df_datas.groupby('MES_ADJ').agg(
707
+ total=('PROJETO', 'count'),
708
+ excedido=('FAIXA_SLA', lambda x: (x == '> 100 %').sum())
709
+ ).reset_index().sort_values('MES_ADJ', ascending=False).head(12)
710
+ for _, r in mes_counts.iterrows():
711
+ taxa = round(r['excedido'] / r['total'] * 100, 1) if r['total'] > 0 else 0
712
+ linhas.append(
713
+ f" {str(r['MES_ADJ']):<10} | Adjudicados: {r['total']:3d} | "
714
+ f"Excedidos: {r['excedido']:3d} ({taxa}%)"
715
+ )
716
+ linhas.append("")
717
+
718
+ # ── 9. PROJECTOS EM CURSO COM MAIOR RISCO IMEDIATO ────────────────────────
719
+ linhas.append("-" * 70)
720
+ linhas.append("PROJECTOS EM CURSO COM MAIOR RISCO IMEDIATO (>75% SLA)")
721
+ linhas.append("-" * 70)
722
+ em_risco = df[
723
+ (df['CATEGORIA'] == 'EM CURSO') &
724
+ (df['PCT_SLA'] >= 75) &
725
+ (df['SLA_FIXO'] > 0)
726
+ ].sort_values('PCT_SLA', ascending=False).head(20)
727
+ for _, row in em_risco.iterrows():
728
+ data_adj = row['DATA_ADJ_CLIENTE'].strftime('%d/%m/%Y') if pd.notna(row['DATA_ADJ_CLIENTE']) else 'N/D'
729
+ dias_r = int(row['DIFDIAS']) if pd.notna(row['DIFDIAS']) else 0
730
+ linhas.append(
731
+ f" {row['PROJETO']:<14} Tipo: {row['TIPO_LABEL']:<16} "
732
+ f"Status: {row['RB STATUS']:<30} "
733
+ f"% SLA: {row['PCT_SLA']:>6}% | Dias restantes: {dias_r:>4}d | Adj: {data_adj}"
734
+ )
735
+ linhas.append("")
736
+
737
+ # ── 10. DISTRIBUIÇÃO POR STATUS (todos com ETAT/RESP) ───────────────────
738
+ linhas.append("-" * 70)
739
+ linhas.append("DISTRIBUIÇÃO POR STATUS (RB STATUS) — com ETAT e RESP")
740
+ linhas.append("-" * 70)
741
+ status_counts = df['RB STATUS'].value_counts()
742
+ for status, count in status_counts.items():
743
+ cat = get_categoria(str(status))
744
+ etat = get_etat(str(status))
745
+ resp = get_resp(str(status))
746
+ sub_s = df[df['RB STATUS'] == status]
747
+ exc_s = (sub_s['FAIXA_SLA'] == '> 100 %').sum()
748
+ taxa_s = round(exc_s / count * 100, 1) if count > 0 else 0
749
+ linhas.append(
750
+ f" {status:<35} | Cat: {cat:<13} | ETAT: {etat:2d} | RESP: {resp:<3} | "
751
+ f"{count:3d} tarefas | Excedido: {exc_s} ({taxa_s}%)"
752
+ )
753
+ linhas.append("")
754
+
755
+ # ── 11. SLA FIXO POR TIPO ─────────────────────────────────────────────────
756
+ linhas.append("-" * 70)
757
+ linhas.append("SLA CONTRATUAL POR TIPO DE TAREFA (dias)")
758
+ linhas.append("-" * 70)
759
+ for tipo, sla in SLA_MAP.items():
760
+ label = TIPO_LABEL.get(tipo, tipo)
761
+ linhas.append(f" {label:<20} : {sla} dias")
762
+ linhas.append("")
763
+
764
+ # ── 12. TABELA DE ESTADOS DO CICLO DE VIDA ────────────────────────────────
765
+ linhas.append("-" * 70)
766
+ linhas.append("TABELA DE ESTADOS DO CICLO DE VIDA (ETAT / Status / RESP)")
767
+ linhas.append("-" * 70)
768
+ linhas.append(" ETAT | Status | RESP | Categoria")
769
+ linhas.append(" " + "-" * 72)
770
+ for etat, status, resp, cat in STATUS_TABLE:
771
+ linhas.append(f" {etat:4d} | {status:<40} | {resp:<4} | {cat}")
772
+ linhas.append("")
773
+ linhas.append(" Legenda ETAT : 0=Cancelado | 1=Survey/Projecto | 2=Validação Orange")
774
+ linhas.append(" 3=Projecto Validado | 4=Trabalhos/Cadastro | 5=Cadastro | 6=Faturado/Concluído")
775
+ linhas.append(" Legenda RESP : RB=RB Portugal | ORG=Orange | SGT=Sogetrel")
776
+ linhas.append("")
777
+
778
+ # ── 13. DISTRIBUIÇÃO POR FASE (ETAT) E RESPONSÁVEL ─────────────────────
779
+ linhas.append("-" * 70)
780
+ linhas.append("DISTRIBUIÇÃO POR FASE (ETAT) E RESPONSÁVEL (RESP)")
781
+ linhas.append("-" * 70)
782
+ etat_labels = {
783
+ 0: 'Cancelado',
784
+ 1: 'Survey / Projecto',
785
+ 2: 'Validação Orange',
786
+ 3: 'Projecto Validado',
787
+ 4: 'Trabalhos / Início Cadastro',
788
+ 5: 'Cadastro em Curso',
789
+ 6: 'Faturado / Concluído',
790
+ -1: 'Fase não mapeada (status legado)',
791
+ }
792
+ etat_counts = df['ETAT'].value_counts().sort_index()
793
+ for etat, count in etat_counts.items():
794
+ label_etat = etat_labels.get(int(etat), f'ETAT {etat}')
795
+ sub_e = df[df['ETAT'] == etat]
796
+ exc_e = (sub_e['FAIXA_SLA'] == '> 100 %').sum()
797
+ taxa_e = round(exc_e / count * 100, 1) if count > 0 else 0
798
+ linhas.append(
799
+ f" ETAT {etat:2d} — {label_etat:<35} : "
800
+ f"{count:3d} tarefas | Excedido: {exc_e} ({taxa_e}%)"
801
+ )
802
+ linhas.append("")
803
+ resp_counts = df['RESP'].value_counts()
804
+ resp_labels = {'RB': 'RB Portugal', 'ORG': 'Orange', 'SGT': 'Sogetrel', '': 'Não mapeado'}
805
+ for resp, count in resp_counts.items():
806
+ sub_r = df[df['RESP'] == resp]
807
+ exc_r = (sub_r['FAIXA_SLA'] == '> 100 %').sum()
808
+ taxa_r = round(exc_r / count * 100, 1) if count > 0 else 0
809
+ linhas.append(
810
+ f" RESP {resp:<3} ({resp_labels.get(resp, resp):<12}) : "
811
+ f"{count:3d} tarefas | Excedido: {exc_r} ({taxa_r}%)"
812
+ )
813
+ linhas.append("")
814
+
815
+ # ── 14. RESUMO EXECUTIVO AUTOMÁTICO ──────────────────────────────────────
816
+ linhas.append("-" * 70)
817
+ linhas.append("RESUMO EXECUTIVO AUTOMÁTICO")
818
+ linhas.append("-" * 70)
819
+ total_g = len(df)
820
+ exc_g = (df['FAIXA_SLA'] == '> 100 %').sum()
821
+ taxa_g = round(exc_g / total_g * 100, 1) if total_g > 0 else 0
822
+ tipo_pior = risco.iloc[0]['TIPO_LABEL'] if len(risco) > 0 else 'N/D'
823
+ taxa_pior = risco.iloc[0]['taxa_exc'] if len(risco) > 0 else 0
824
+ tipo_melhor = risco.iloc[-1]['TIPO_LABEL'] if len(risco) > 0 else 'N/D'
825
+ taxa_melhor = risco.iloc[-1]['taxa_exc'] if len(risco) > 0 else 0
826
+ n_em_curso = (df['CATEGORIA'] == 'EM CURSO').sum()
827
+ n_lic = (df['CATEGORIA'] == 'LICENCIAMENTO').sum()
828
+ n_fin = (df['CATEGORIA'] == 'FINALIZADO').sum()
829
+ n_criticos = len(em_risco)
830
+ linhas.append(f" Portfolio total : {total_g} tarefas")
831
+ linhas.append(f" Em Curso : {n_em_curso} | Licenciamento: {n_lic} | Finalizado: {n_fin}")
832
+ linhas.append(f" Taxa incumprimento : {taxa_g}% ({exc_g} tarefas com SLA > 100%)")
833
+ linhas.append(f" Tipo mais crítico : {tipo_pior} ({taxa_pior}% de incumprimento)")
834
+ linhas.append(f" Tipo mais saudável : {tipo_melhor} ({taxa_melhor}% de incumprimento)")
835
+ linhas.append(f" Projectos em risco : {n_criticos} em curso com SLA ≥ 75%")
836
+ linhas.append("")
837
+
838
+ return "\n".join(linhas)
839
+
840
+ # Pré-calcular o contexto RAG uma vez na inicialização
841
+ CONTEXTO_RAG = gerar_contexto_rag()
842
+
843
+ def criar_cliente_nvidia(api_key: str) -> OpenAI:
844
+ """Cria um cliente OpenAI compatível com a API NVIDIA NIM."""
845
+ return OpenAI(
846
+ base_url="https://integrate.api.nvidia.com/v1",
847
+ api_key=api_key
848
+ )
849
+
850
+ def responder_pergunta(
851
+ pergunta: str,
852
+ historico: list,
853
+ api_key: str,
854
+ modelo: str
855
+ ) -> tuple:
856
+ """
857
+ Função RAG principal: recebe a pergunta, recupera o contexto dos dados
858
+ e gera uma resposta usando o modelo NVIDIA NIM selecionado.
859
+ """
860
+ if not api_key or not api_key.strip():
861
+ historico = historico + [
862
+ {"role": "user", "content": pergunta},
863
+ {"role": "assistant", "content": "⚠️ Por favor, insira a sua chave API da NVIDIA NIM no campo acima para usar o assistente."}
864
+ ]
865
+ return historico, ""
866
+
867
+ if not pergunta or not pergunta.strip():
868
+ return historico, ""
869
+
870
+ try:
871
+ client = criar_cliente_nvidia(api_key.strip())
872
+
873
+ # Construir o prompt de sistema com o contexto dos dados
874
+ system_prompt = f"""Você é um Gestor de Projecto Sénior com mais de 15 anos de experiência em gestão de portfolios de telecomunicações, especializado em controlo SLA, análise de risco operacional e reporte executivo para operadores como a Orange.
875
+
876
+ O seu papel é analisar os dados reais do dashboard SLA que lhe são fornecidos e responder com a profundidade e rigor de um gestor experiente. Não se limite a citar números — interprete-os, identifique padrões, riscos e oportunidades de melhoria, e sugira acções concretas quando relevante.
877
+
878
+ Responda SEMPRE em português europeu (Portugal), com linguagem profissional e directa. Use os números exactos dos dados fornecidos.
879
+
880
+ --- DADOS DO DASHBOARD SLA ---
881
+ {CONTEXTO_RAG}
882
+ --- FIM DOS DADOS ---
883
+
884
+ === CAPACIDADES DE ANÁLISE ===
885
+ Como gestor experiente, pode e deve:
886
+
887
+ 1. ANÁLISE DE RISCO
888
+ - Identificar tipos de tarefa em estado crítico (taxa de incumprimento > 70%)
889
+ - Cruzar tipo × status × categoria para localizar gargalos operacionais
890
+ - Avaliar o impacto do aging nos projectos com maior desvio SLA
891
+ - Distinguir entre risco sistémico (todo o tipo falha) e risco pontual (projectos isolados)
892
+
893
+ 2. ANÁLISE DE DESEMPENHO
894
+ - Comparar taxas de cumprimento entre categorias (EM CURSO vs FINALIZADO vs LICENCIAMENTO)
895
+ - Avaliar o % SLA médio por tipo e identificar tendências de deterioração
896
+ - Analisar a distribuição por faixas (<50%, 50-75%, 75-100%, >100%) como indicador de maturidade
897
+ - Identificar os tipos com melhor e pior desempenho e as razões prováveis
898
+
899
+ 3. ANÁLISE OPERACIONAL
900
+ - Cruzar responsável (RESP: RB/ORG/SGT) com taxa de incumprimento para identificar onde estão os bloqueios
901
+ - Analisar o ciclo de vida (ETAT 0-6) e identificar em que fase os projectos ficam parados
902
+ - Avaliar o volume de adjudicações por mês e correlacionar com picos de incumprimento
903
+ - Identificar status com maior concentração de tarefas excedidas
904
+
905
+ 4. REPORTE EXECUTIVO
906
+ - Produzir resumos executivos concisos para apresentação à direcção
907
+ - Formatar tabelas comparativas claras com indicadores RAG (Verde/Amarelo/Vermelho)
908
+ - Sugerir KPIs adicionais que deveriam ser monitorizados
909
+ - Propor acções correctivas prioritárias com base nos dados
910
+
911
+ 5. ANÁLISE PREDITIVA
912
+ - Com base nos projectos em curso com SLA ≥ 75%, estimar quantos vão exceder o prazo
913
+ - Identificar padrões de adjudicação que historicamente levam a incumprimento
914
+ - Avaliar se a taxa de incumprimento está a melhorar ou piorar com base nos dados históricos disponíveis
915
+
916
+ === REGRAS DE RESPOSTA ===
917
+ - Use SEMPRE os números exactos dos dados fornecidos
918
+ - Quando apresentar análises, estruture em: Situação → Análise → Recomendação
919
+ - Use tabelas markdown para comparações com 3 ou mais itens
920
+ - Classifique o nível de risco: 🔴 CRÍTICO (≥70% incumprimento) | 🟠 ALTO (40-69%) | 🟡 MÉDIO (20-39%) | 🟢 BAIXO (<20%)
921
+ - Se a pergunta não puder ser respondida com os dados disponíveis, diga claramente e sugira o que seria necessário para responder
922
+ - Para perguntas sobre projectos específicos, forneça o contexto completo (tipo, status, ETAT, % SLA, data adjudicação)
923
+ - Quando identificar problemas, proponha sempre pelo menos uma acção correctiva concreta
924
+ - Mantenha o contexto da conversa anterior para análises sequenciais
925
+
926
+ === LEGENDA SLA ===
927
+ 🟢 < 50% : Dentro do prazo — execução saudável
928
+ 🟡 50-75% : Atenção — monitorização reforçada necessária
929
+ 🟠 75-100% : Crítico — intervenção urgente recomendada
930
+ 🔴 > 100% : SLA excedido — incumprimento contratual, escalada necessária
931
+ """
932
+
933
+ # Construir o histórico de mensagens para o modelo
934
+ messages = [{"role": "system", "content": system_prompt}]
935
+
936
+ # Adicionar histórico anterior (máximo 10 turnos para não exceder contexto)
937
+ for msg in historico[-10:]:
938
+ messages.append({"role": msg["role"], "content": msg["content"]})
939
+
940
+ # Adicionar a pergunta atual
941
+ messages.append({"role": "user", "content": pergunta})
942
+
943
+ # Chamar a API NVIDIA NIM
944
+ response = client.chat.completions.create(
945
+ model=modelo,
946
+ messages=messages,
947
+ temperature=0.2,
948
+ max_tokens=1500,
949
+ )
950
+
951
+ resposta = response.choices[0].message.content
952
+
953
+ # Atualizar o histórico
954
+ historico = historico + [
955
+ {"role": "user", "content": pergunta},
956
+ {"role": "assistant", "content": resposta}
957
+ ]
958
+
959
+ return historico, ""
960
+
961
+ except Exception as e:
962
+ erro = str(e)
963
+ if "401" in erro or "Unauthorized" in erro or "invalid_api_key" in erro.lower():
964
+ msg_erro = "❌ Chave API inválida ou sem autorização. Verifique a sua chave NVIDIA NIM em [build.nvidia.com](https://build.nvidia.com)."
965
+ elif "429" in erro or "rate_limit" in erro.lower():
966
+ msg_erro = "⏳ Limite de pedidos atingido. Aguarde alguns segundos e tente novamente."
967
+ elif "model" in erro.lower() and "not found" in erro.lower():
968
+ msg_erro = f"❌ Modelo '{modelo}' não encontrado. Tente selecionar outro modelo."
969
+ else:
970
+ msg_erro = f"❌ Erro ao contactar a API NVIDIA NIM: {erro}"
971
+
972
+ historico = historico + [
973
+ {"role": "user", "content": pergunta},
974
+ {"role": "assistant", "content": msg_erro}
975
+ ]
976
+ return historico, ""
977
+
978
+ def limpar_chat():
979
+ """Limpa o histórico do chat."""
980
+ return [], ""
981
+
982
+ def perguntas_rapidas(pergunta_selecionada: str) -> str:
983
+ """Retorna a pergunta rápida selecionada para o campo de input."""
984
+ return pergunta_selecionada
985
+
986
+ # ── CSS global ─────────────────────────────────────────────────────────────────
987
  CSS = """
988
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
 
989
  body, .gradio-container {
990
  font-family: 'Inter', 'Segoe UI', Arial, sans-serif !important;
991
  background: #F0F2F8 !important;
992
  }
993
  .gradio-container {
994
+ max-width: 1400px !important;
995
  margin: 0 auto !important;
996
  padding: 0 !important;
997
  }
 
998
  .sla-header-wrap,
999
  .sla-header-wrap *,
1000
  .sla-header-wrap h1,
 
1054
  margin-top: 16px;
1055
  flex-wrap: wrap;
1056
  }
1057
+ .chat-container {
1058
+ background: white;
1059
+ border-radius: 12px;
1060
+ padding: 20px;
1061
+ box-shadow: 0 2px 10px rgba(0,0,0,0.06);
1062
+ }
1063
+ .rag-header {
1064
+ background: linear-gradient(135deg, #0D47A1 0%, #1565C0 50%, #1976D2 100%);
1065
+ border-radius: 10px;
1066
+ padding: 16px 20px;
1067
+ margin-bottom: 16px;
1068
+ }
1069
  footer { display: none !important; }
1070
  .gr-panel, .gr-box { border-radius: 12px !important; }
1071
  """
 
1075
  DATA_REF = pd.Timestamp.today().strftime('%d/%m/%Y')
1076
  N_TOTAL = len(DF_GLOBAL)
1077
 
 
 
 
 
1078
  HEADER_HTML = f"""
1079
  <style>
 
1080
  div.sla-header-wrap {{
1081
  background: linear-gradient(135deg, #0D47A1 0%, #1565C0 50%, #1976D2 100%) !important;
1082
  padding: 28px 36px 22px !important;
 
1118
  <font color="#ffffff">
1119
  Controlo SLA por tipo de tarefa &nbsp;·&nbsp; Distribuição por faixas de percentagem
1120
  &nbsp;·&nbsp; {N_TOTAL} registos &nbsp;·&nbsp; Referência: {DATA_REF}
1121
+ &nbsp;·&nbsp; 🤖 RAG com NVIDIA NIM
1122
  </font>
1123
  </p>
1124
  </div>
1125
  """
1126
 
1127
+ MODELOS_NVIDIA = [
1128
+ "meta/llama-3.3-70b-instruct",
1129
+ "meta/llama-3.1-70b-instruct",
1130
+ "meta/llama-3.1-8b-instruct",
1131
+ "mistralai/mistral-7b-instruct-v0.3",
1132
+ "mistralai/mixtral-8x7b-instruct-v0.1",
1133
+ "microsoft/phi-3-mini-128k-instruct",
1134
+ "google/gemma-2-9b-it",
1135
+ ]
1136
 
1137
+ PERGUNTAS_SUGERIDAS = [
1138
+ # ─ Resumos executivos
1139
+ "📊 Faz um resumo executivo completo do estado actual do portfolio SLA",
1140
+ "🚨 Quais são os 3 tipos de tarefa mais críticos neste momento e que acções recomendas?",
1141
+ "📈 Compara o desempenho SLA entre as categorias Em Curso, Licenciamento e Finalizado",
1142
+ # ─ Análise de risco
1143
+ "⚠️ Quais os projectos em curso com maior risco de incumprimento SLA nas próximas semanas?",
1144
+ "🔴 Identifica todos os gargalos operacionais cruzando tipo de tarefa com status actual",
1145
+ "🔍 Qual é o padrão de incumprimento no tipo PAR? Porque acha que acontece?",
1146
+ # ─ Análise por tipo
1147
+ "📉 Qual o tipo de tarefa com maior taxa de incumprimento e qual o % SLA médio?",
1148
+ "📈 Qual o tipo de tarefa com melhor desempenho SLA? O que pode explicar esse resultado?",
1149
+ "📊 Faz uma tabela comparativa de todos os tipos com: total, excedidos, taxa e % SLA médio",
1150
+ # ─ Análise por responsavel
1151
+ "👥 Qual é a distribuição de tarefas e incumprimento por responsável (RB, Orange, Sogetrel)?",
1152
+ "🔍 Em que fase do ciclo de vida (ETAT) estão concentrados os maiores atrasos?",
1153
+ # ─ Aging e projectos específicos
1154
+ "⏰ Lista os 10 projectos com maior desvio SLA e o número de dias de atraso",
1155
+ "📅 Analisa as adjudicações por mês: há meses com maior volume e pior desempenho?",
1156
+ # ─ Licenciamento
1157
+ "🏗️ Qual é o estado do Licenciamento? Quais os tipos mais problemáticos nessa categoria?",
1158
+ # ─ Preditivo e recomendações
1159
+ "🔮 Com base nos projectos em curso com SLA ≥75%, quantos estimas que vão exceder o prazo?",
1160
+ "💡 Que 3 acções correctivas prioritárias recomendas para melhorar a taxa de cumprimento global?",
1161
+ ]
1162
 
1163
+ with gr.Blocks(title="Dashboard SLA + RAG NVIDIA NIM") as demo:
 
 
 
 
 
 
 
 
1164
 
1165
+ gr.HTML(HEADER_HTML)
1166
+
1167
+ # ── Tabs principais ──────────────────────────────────────────────────────
1168
+ with gr.Tabs():
1169
+
1170
+ # ── Tab 1: Dashboard ─────────────────────────────────────────────────
1171
+ with gr.Tab("📊 Dashboard SLA"):
1172
+
1173
+ with gr.Row():
1174
+ cat_selector = gr.Radio(
1175
+ choices=CATEGORIAS,
1176
+ value='EM CURSO',
1177
+ label='Categoria',
1178
+ interactive=True,
1179
+ elem_classes=['cat-selector'],
1180
+ )
1181
+
1182
+ with gr.Row(equal_height=True):
1183
+ with gr.Column(scale=4):
1184
+ tabela_out = gr.HTML()
1185
+ with gr.Column(scale=1, min_width=220):
1186
+ kpi_out = gr.HTML()
1187
+
1188
+ gr.HTML('<div class="export-section"><b style="font-size:13px;color:#37474F;'
1189
+ 'text-transform:uppercase;letter-spacing:0.6px;">⬇ Exportar Dados</b></div>')
1190
+
1191
+ with gr.Row():
1192
+ with gr.Column(scale=1):
1193
+ gr.Markdown("**Pivot da categoria** — distribuição por faixas SLA")
1194
+ btn_pivot = gr.Button("⬇ CSV — Tabela Pivot", variant="secondary", elem_classes=["btn-export"])
1195
+ file_pivot = gr.File(label="", show_label=False)
1196
+
1197
+ with gr.Column(scale=1):
1198
+ gr.Markdown("**Dados calculados completos** — todos os campos do dash.R")
1199
+ btn_fact = gr.Button(" CSV Dados Calculados", variant="secondary", elem_classes=["btn-export"])
1200
+ file_fact = gr.File(label="", show_label=False)
1201
+
1202
+ gr.HTML("""
1203
+ <div class="legenda-bar">
1204
+ <span style="font-size:12px;font-weight:700;color:#546e7a;text-transform:uppercase;letter-spacing:0.5px;">Legenda:</span>
1205
+ <span style="display:inline-flex;align-items:center;gap:6px;font-size:13px;">
1206
+ <span style="width:14px;height:14px;border-radius:50%;background:#2E7D32;display:inline-block;"></span>
1207
+ <b style="color:#1B5E20">&lt; 50 %</b> — Dentro do prazo
1208
+ </span>
1209
+ <span style="display:inline-flex;align-items:center;gap:6px;font-size:13px;">
1210
+ <span style="width:14px;height:14px;border-radius:50%;background:#E65100;display:inline-block;"></span>
1211
+ <b style="color:#E65100">50 % &lt; X ≤ 75 %</b> — Atenção
1212
+ </span>
1213
+ <span style="display:inline-flex;align-items:center;gap:6px;font-size:13px;">
1214
+ <span style="width:14px;height:14px;border-radius:50%;background:#BF360C;display:inline-block;"></span>
1215
+ <b style="color:#BF360C">75 % &lt; X ≤ 100 %</b> — Crítico
1216
+ </span>
1217
+ <span style="display:inline-flex;align-items:center;gap:6px;font-size:13px;">
1218
+ <span style="width:14px;height:14px;border-radius:50%;background:#B71C1C;display:inline-block;"></span>
1219
+ <b style="color:#B71C1C">&gt; 100 %</b> — SLA excedido
1220
+ </span>
1221
+ </div>
1222
+ """)
1223
+
1224
+ # ── Tab 2: Assistente RAG ────────────────────────────────────────────
1225
+ with gr.Tab("🤖 Assistente IA (RAG)"):
1226
+
1227
+ gr.HTML("""
1228
+ <div style="background:linear-gradient(135deg,#0D47A1 0%,#1565C0 50%,#1976D2 100%);
1229
+ border-radius:10px;padding:16px 20px;margin-bottom:16px;">
1230
+ <h3 style="margin:0 0 6px;color:#fff;font-size:16px;font-weight:700;">
1231
+ 🤖 Assistente RAG — NVIDIA NIM
1232
+ </h3>
1233
+ <p style="margin:0;color:rgba(255,255,255,0.85);font-size:13px;">
1234
+ Faça perguntas em linguagem natural sobre os dados do dashboard SLA.
1235
+ O assistente usa os dados reais carregados para responder com precisão.
1236
+ </p>
1237
+ </div>
1238
+ """)
1239
+
1240
+ # Configuração da API
1241
+ with gr.Row():
1242
+ with gr.Column(scale=3):
1243
+ api_key_input = gr.Textbox(
1244
+ label="🔑 Chave API NVIDIA NIM",
1245
+ placeholder="nvapi-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
1246
+ type="password",
1247
+ info="Obtenha a sua chave gratuita em build.nvidia.com → Get API Key"
1248
+ )
1249
+ with gr.Column(scale=2):
1250
+ modelo_selector = gr.Dropdown(
1251
+ choices=MODELOS_NVIDIA,
1252
+ value="meta/llama-3.3-70b-instruct",
1253
+ label="🧠 Modelo NVIDIA NIM",
1254
+ info="Llama 3.3 70B é o modelo recomendado"
1255
+ )
1256
+
1257
+ gr.HTML("""
1258
+ <div style="background:#e8f4fd;border-left:4px solid #1976D2;border-radius:6px;
1259
+ padding:10px 14px;margin:8px 0 16px;font-size:12px;color:#1565C0;">
1260
+ <b>Como obter a chave API gratuita:</b> Aceda a
1261
+ <a href="https://build.nvidia.com" target="_blank" style="color:#0D47A1;font-weight:600;">build.nvidia.com</a>
1262
+ → faça login → clique em "Get API Key" → copie e cole acima.
1263
+ A NVIDIA oferece créditos gratuitos para desenvolvimento.
1264
+ </div>
1265
+ """)
1266
+
1267
+ # Perguntas rápidas
1268
+ with gr.Row():
1269
+ perguntas_dropdown = gr.Dropdown(
1270
+ choices=PERGUNTAS_SUGERIDAS,
1271
+ label="💡 Perguntas sugeridas (clique para usar)",
1272
+ value=None,
1273
+ interactive=True,
1274
+ )
1275
+
1276
+ # Chat
1277
+ chatbot = gr.Chatbot(
1278
+ label="Conversa com o Assistente SLA",
1279
+ height=480,
1280
+ avatar_images=(None, "https://build.nvidia.com/favicon.ico"),
1281
+ placeholder="<div style='text-align:center;padding:40px;color:#9e9e9e;'>"
1282
+ "<div style='font-size:40px;margin-bottom:12px;'>🤖</div>"
1283
+ "<b>Assistente SLA com NVIDIA NIM</b><br>"
1284
+ "<span style='font-size:13px;'>Insira a sua chave API e faça uma pergunta sobre os dados do dashboard</span>"
1285
+ "</div>"
1286
+ )
1287
+
1288
+ with gr.Row():
1289
+ pergunta_input = gr.Textbox(
1290
+ label="",
1291
+ placeholder="Ex: Qual é a taxa de cumprimento global? Quais os tipos com mais SLA excedido?",
1292
+ lines=2,
1293
+ scale=5,
1294
+ show_label=False,
1295
+ )
1296
+ with gr.Column(scale=1, min_width=120):
1297
+ btn_enviar = gr.Button("Enviar ▶", variant="primary", size="lg")
1298
+ btn_limpar = gr.Button("🗑 Limpar", variant="secondary")
1299
+
1300
+ # Informação sobre o contexto RAG
1301
+ with gr.Accordion("ℹ️ Ver contexto RAG (dados enviados ao modelo)", open=False):
1302
+ gr.Textbox(
1303
+ value=CONTEXTO_RAG,
1304
+ label="Contexto estruturado dos dados (enviado ao modelo em cada pergunta)",
1305
+ lines=20,
1306
+ interactive=False,
1307
+ )
1308
+
1309
+ # ── Eventos — Dashboard ──────────────────────────────────────────────────
1310
  cat_selector.change(fn=atualizar_vista, inputs=cat_selector, outputs=[tabela_out, kpi_out])
1311
  demo.load(fn=lambda: atualizar_vista('EM CURSO'), outputs=[tabela_out, kpi_out])
1312
  btn_pivot.click(fn=exportar_csv_pivot, inputs=cat_selector, outputs=file_pivot)
1313
  btn_fact.click(fn=exportar_csv_fact, inputs=cat_selector, outputs=file_fact)
1314
 
1315
+ # ── Eventos — RAG Chat ───────────────────────────────────────────────────
1316
+ btn_enviar.click(
1317
+ fn=responder_pergunta,
1318
+ inputs=[pergunta_input, chatbot, api_key_input, modelo_selector],
1319
+ outputs=[chatbot, pergunta_input],
1320
+ )
1321
+ pergunta_input.submit(
1322
+ fn=responder_pergunta,
1323
+ inputs=[pergunta_input, chatbot, api_key_input, modelo_selector],
1324
+ outputs=[chatbot, pergunta_input],
1325
+ )
1326
+ btn_limpar.click(
1327
+ fn=limpar_chat,
1328
+ outputs=[chatbot, pergunta_input],
1329
+ )
1330
+ perguntas_dropdown.change(
1331
+ fn=perguntas_rapidas,
1332
+ inputs=perguntas_dropdown,
1333
+ outputs=pergunta_input,
1334
+ )
1335
+
1336
 
1337
  if __name__ == '__main__':
1338
  demo.launch(
 
1341
  share=False,
1342
  css=CSS,
1343
  theme=gr.themes.Base(),
1344
+ allowed_paths=[OUTPUT_DIR, BASE],
1345
+ )