histlearn commited on
Commit
265a0ca
·
verified ·
1 Parent(s): 1ada9df

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +185 -441
app.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import gradio as gr
2
  import camelot
3
  import pandas as pd
@@ -11,8 +12,22 @@ import matplotlib
11
  import shutil
12
  import colorsys
13
  from datetime import datetime
 
 
 
 
 
 
 
14
  matplotlib.use('Agg')
15
 
 
 
 
 
 
 
 
16
  # Configurações globais
17
  ESCALA_MAXIMA_NOTAS = 12
18
  LIMITE_APROVACAO_NOTA = 5
@@ -20,7 +35,11 @@ LIMITE_APROVACAO_FREQ = 75
20
  BIMESTRES = ['1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre']
21
  CONCEITOS_VALIDOS = ['ES', 'EP', 'ET']
22
 
23
- # Definição das disciplinas de formação básica
 
 
 
 
24
  FORMACAO_BASICA = {
25
  'fundamental': {
26
  'LINGUA PORTUGUESA',
@@ -40,7 +59,7 @@ FORMACAO_BASICA = {
40
  'BIOLOGIA',
41
  'FISICA',
42
  'QUIMICA',
43
- 'INGLÊS',
44
  'FILOSOFIA',
45
  'SOCIOLOGIA',
46
  'ARTE',
@@ -48,33 +67,42 @@ FORMACAO_BASICA = {
48
  }
49
  }
50
 
51
- def detectar_nivel_ensino(disciplinas):
52
- """Detecta se é ensino fundamental ou médio baseado nas disciplinas presentes."""
53
- disciplinas_set = set(disciplinas)
54
- disciplinas_exclusivas_medio = {'BIOLOGIA', 'FISICA', 'QUIMICA', 'FILOSOFIA', 'SOCIOLOGIA'}
55
- return 'medio' if any(d in disciplinas_set for d in disciplinas_exclusivas_medio) else 'fundamental'
 
 
 
 
56
 
57
- def separar_disciplinas_por_categoria(disciplinas_dados):
58
- """Separa as disciplinas em formação básica e diversificada."""
59
- disciplinas = [d['disciplina'] for d in disciplinas_dados]
60
- nivel = detectar_nivel_ensino(disciplinas)
61
-
62
- formacao_basica = []
63
- diversificada = []
64
-
65
- for disc_data in disciplinas_dados:
66
- if disc_data['disciplina'] in FORMACAO_BASICA[nivel]:
67
- formacao_basica.append(disc_data)
68
- else:
69
- diversificada.append(disc_data)
70
-
71
- return {
72
- 'nivel': nivel,
73
- 'formacao_basica': formacao_basica,
74
- 'diversificada': diversificada
75
- }
 
 
 
 
 
76
 
77
- def converter_nota(valor):
78
  """Converte valor de nota para float, tratando casos especiais e conceitos."""
79
  if pd.isna(valor) or valor == '-' or valor == 'N' or valor == '' or valor == 'None':
80
  return None
@@ -95,14 +123,12 @@ def converter_nota(valor):
95
 
96
  return None
97
 
98
- def calcular_media_bimestres(notas):
99
  """Calcula média considerando apenas bimestres com notas válidas."""
100
  notas_validas = [nota for nota in notas if nota is not None]
101
- if not notas_validas:
102
- return 0
103
- return sum(notas_validas) / len(notas_validas)
104
 
105
- def calcular_frequencia_media(frequencias):
106
  """Calcula média de frequência considerando apenas bimestres cursados."""
107
  freq_validas = []
108
  for freq in frequencias:
@@ -116,12 +142,10 @@ def calcular_frequencia_media(frequencias):
116
  except:
117
  continue
118
 
119
- if not freq_validas:
120
- return 0
121
- return sum(freq_validas) / len(freq_validas)
122
-
123
- def extrair_tabelas_pdf(pdf_path):
124
- """Extrai tabelas do PDF usando stream apenas para o nome e lattice para notas."""
125
  try:
126
  # Extrair nome do aluno usando stream
127
  tables_header = camelot.read_pdf(
@@ -133,7 +157,7 @@ def extrair_tabelas_pdf(pdf_path):
133
 
134
  info_aluno = {}
135
 
136
- # Procurar apenas o nome do aluno
137
  for table in tables_header:
138
  df = table.df
139
  for i in range(len(df)):
@@ -158,7 +182,7 @@ def extrair_tabelas_pdf(pdf_path):
158
  flavor='lattice'
159
  )
160
 
161
- # Encontrar tabela de notas (procurar a maior tabela com 'Disciplina')
162
  df_notas = None
163
  max_rows = 0
164
 
@@ -179,16 +203,22 @@ def extrair_tabelas_pdf(pdf_path):
179
  if df_notas is None:
180
  raise ValueError("Tabela de notas não encontrada")
181
 
182
- # Adicionar apenas o nome ao DataFrame
183
- df_notas.attrs['nome'] = info_aluno.get('nome', 'Nome não encontrado')
184
 
185
  return df_notas
186
 
187
  except Exception as e:
188
- print(f"Erro na extração das tabelas: {str(e)}")
189
  raise
190
-
191
- def obter_disciplinas_validas(df):
 
 
 
 
 
 
192
  """Identifica disciplinas válidas no boletim com seus dados."""
193
  colunas_notas = ['Nota B1', 'Nota B2', 'Nota B3', 'Nota B4']
194
  colunas_freq = ['%Freq B1', '%Freq B2', '%Freq B3', '%Freq B4']
@@ -231,62 +261,72 @@ def obter_disciplinas_validas(df):
231
 
232
  return disciplinas_dados
233
 
234
- def gerar_paleta_cores(n_cores):
235
- """Gera uma paleta de cores distintas para o número de disciplinas."""
236
- cores_base = [
237
- '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
238
- '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf',
239
- '#393b79', '#637939', '#8c6d31', '#843c39', '#7b4173'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  ]
241
 
242
- if n_cores > len(cores_base):
243
- HSV_tuples = [(x/n_cores, 0.7, 0.85) for x in range(n_cores)]
244
- cores_extras = ['#%02x%02x%02x' % tuple(int(x*255) for x in colorsys.hsv_to_rgb(*hsv))
245
- for hsv in HSV_tuples]
246
- return cores_extras
247
 
248
- return cores_base[:n_cores]
 
 
 
249
 
250
- def plotar_evolucao_bimestres(disciplinas_dados, temp_dir, titulo=None, nome_arquivo=None):
251
- """Plota gráfico de evolução das notas por bimestre com visualização refinada."""
 
 
252
  n_disciplinas = len(disciplinas_dados)
253
 
254
  if n_disciplinas == 0:
255
- raise ValueError("Nenhuma disciplina válida encontrada para plotar.")
256
 
257
- plt.figure(figsize=(11.69, 8.27))
 
 
258
 
259
- cores = gerar_paleta_cores(n_disciplinas)
260
- marcadores = ['o', 's', '^', 'D', 'v', '<', '>', 'p', 'h', '*']
261
- estilos_linha = ['-', '--', '-.', ':', '-', '--', '-.', ':', '-', '--']
262
-
263
- plt.grid(True, linestyle='--', alpha=0.3, zorder=0)
264
 
265
- # Deslocamento ainda menor e mais refinado
266
- deslocamentos = np.linspace(-0.03, 0.03, n_disciplinas)
 
267
 
268
- # Estrutura para armazenar as posições das anotações já utilizadas
269
- anotacoes_usadas = {} # formato: {bimestre: [(y, texto)]}
270
 
271
- # Primeira passagem: coletar todos os valores e determinar grupos
272
- grupos_notas = {} # {bimestre: {nota: [índices]}}
273
- for idx, disc_data in enumerate(disciplinas_dados):
274
- notas = pd.Series(disc_data['notas'])
275
- bimestres_cursados = disc_data['bimestres_cursados']
276
-
277
- if bimestres_cursados:
278
- notas_validas = [nota for i, nota in enumerate(notas, 1) if i in bimestres_cursados and nota is not None]
279
- bimestres = [bim for bim in bimestres_cursados if notas[bim-1] is not None]
280
-
281
- for bim, nota in zip(bimestres, notas_validas):
282
- if nota is not None:
283
- if bim not in grupos_notas:
284
- grupos_notas[bim] = {}
285
- if nota not in grupos_notas[bim]:
286
- grupos_notas[bim][nota] = []
287
- grupos_notas[bim][nota].append(idx)
288
-
289
- # Segunda passagem: plotar e anotar
290
  for idx, disc_data in enumerate(disciplinas_dados):
291
  notas = pd.Series(disc_data['notas'])
292
  bimestres_cursados = disc_data['bimestres_cursados']
@@ -298,375 +338,78 @@ def plotar_evolucao_bimestres(disciplinas_dados, temp_dir, titulo=None, nome_arq
298
  bimestres_deslocados = [bim + desloc for bim in bimestres]
299
 
300
  if notas_validas:
301
- # Plotar linha e pontos
302
- plt.plot(bimestres_deslocados, notas_validas,
303
  color=cores[idx % len(cores)],
304
  marker=marcadores[idx % len(marcadores)],
305
- markersize=7,
306
- linewidth=1.5,
307
  label=disc_data['disciplina'],
308
  linestyle=estilos_linha[idx % len(estilos_linha)],
309
- alpha=0.8)
 
310
 
311
- # Adicionar anotações com posicionamento otimizado
312
- for bim_orig, bim_desloc, nota in zip(bimestres, bimestres_deslocados, notas_validas):
 
 
313
  if nota is not None:
314
- # Verificar se é o primeiro índice para esta nota neste bimestre
315
- if grupos_notas[bim_orig][nota][0] == idx:
316
- # Determinar posição vertical da anotação
317
- if bim_orig not in anotacoes_usadas:
318
- anotacoes_usadas[bim_orig] = []
319
-
320
- # Encontrar posição vertical disponível
321
- y_base = nota
322
- y_offset = 10
323
- texto = f"{nota:.1f}"
324
-
325
- # Verificar sobreposição com anotações existentes
326
- while any(abs(y - (y_base + y_offset/20)) < 0.4 for y, _ in anotacoes_usadas.get(bim_orig, [])):
327
- y_offset += 5
328
-
329
- # Adicionar anotação
330
- plt.annotate(texto,
331
- (bim_orig, nota),
332
- textcoords="offset points",
333
- xytext=(0, y_offset),
334
- ha='center',
335
- va='bottom',
336
- fontsize=8,
337
- bbox=dict(facecolor='white',
338
- edgecolor='none',
339
- alpha=0.8,
340
- pad=0.5))
341
-
342
- anotacoes_usadas[bim_orig].append((nota + y_offset/20, texto))
343
-
344
- # Usar título personalizado se fornecido
345
- titulo_grafico = titulo or 'Evolução das Médias por Disciplina ao Longo dos Bimestres'
346
- plt.title(titulo_grafico, pad=20, fontsize=12, fontweight='bold')
347
-
348
- plt.xlabel('Bimestres', fontsize=10)
349
- plt.ylabel('Notas', fontsize=10)
350
- plt.xticks([1, 2, 3, 4], ['1º Bim', '2º Bim', '3º Bim', '4º Bim'])
351
- plt.ylim(0, ESCALA_MAXIMA_NOTAS)
352
 
353
- # Adicionar linha de aprovação
354
- plt.axhline(y=LIMITE_APROVACAO_NOTA, color='r', linestyle='--', alpha=0.3)
355
- plt.text(0.02, LIMITE_APROVACAO_NOTA + 0.1, 'Média mínima para aprovação',
356
- transform=plt.gca().get_yaxis_transform(), color='r', alpha=0.5)
357
-
358
- # Ajustar legenda
359
  if n_disciplinas > 8:
360
- plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left', fontsize=8,
361
  ncol=max(1, n_disciplinas // 12))
362
  else:
363
- plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left', ncol=1)
364
 
365
  plt.tight_layout()
 
 
 
366
 
367
- # Usar nome de arquivo personalizado se fornecido
368
  nome_arquivo = nome_arquivo or 'evolucao_notas.png'
369
  plot_path = os.path.join(temp_dir, nome_arquivo)
370
- plt.savefig(plot_path, bbox_inches='tight', dpi=300)
371
- plt.close()
372
- return plot_path
373
-
374
- def plotar_graficos_destacados(disciplinas_dados, temp_dir):
375
- """Plota gráficos de médias e frequências com destaques."""
376
- n_disciplinas = len(disciplinas_dados)
377
-
378
- if not n_disciplinas:
379
- raise ValueError("Nenhuma disciplina válida encontrada no boletim.")
380
-
381
- # Criar figura
382
- plt.figure(figsize=(12, 10))
383
-
384
- disciplinas = [d['disciplina'] for d in disciplinas_dados]
385
- medias_notas = [d['media_notas'] for d in disciplinas_dados]
386
- medias_freq = [d['media_freq'] for d in disciplinas_dados]
387
-
388
- # Criar subplot com mais espaço entre os gráficos
389
- fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10), height_ratios=[1, 1])
390
- plt.subplots_adjust(hspace=0.5) # Aumentar espaço entre os gráficos
391
-
392
- # Definir cores baseadas nos limites de aprovação
393
- cores_notas = ['red' if media < LIMITE_APROVACAO_NOTA else '#2ecc71' for media in medias_notas]
394
- cores_freq = ['red' if media < LIMITE_APROVACAO_FREQ else '#2ecc71' for media in medias_freq]
395
-
396
- # Calcular médias globais
397
- media_global = np.mean(medias_notas)
398
- freq_global = np.mean(medias_freq)
399
-
400
- # Gráfico de notas
401
- barras_notas = ax1.bar(disciplinas, medias_notas, color=cores_notas)
402
- ax1.set_title('Média de Notas por Disciplina', pad=20, fontsize=12, fontweight='bold')
403
- ax1.set_ylim(0, ESCALA_MAXIMA_NOTAS)
404
- ax1.grid(True, axis='y', alpha=0.3, linestyle='--')
405
-
406
- # Melhorar a apresentação dos rótulos
407
- ax1.set_xticklabels(disciplinas, rotation=45, ha='right', va='top')
408
- ax1.set_ylabel('Notas', fontsize=10, labelpad=10)
409
-
410
- # Adicionar linha de média mínima
411
- ax1.axhline(y=LIMITE_APROVACAO_NOTA, color='r', linestyle='--', alpha=0.3)
412
- ax1.text(0.02, LIMITE_APROVACAO_NOTA + 0.1, 'Média mínima (5,0)',
413
- transform=ax1.get_yaxis_transform(), color='r', alpha=0.7)
414
-
415
- # Valores nas barras de notas
416
- for barra in barras_notas:
417
- altura = barra.get_height()
418
- ax1.text(barra.get_x() + barra.get_width()/2., altura,
419
- f'{altura:.1f}',
420
- ha='center', va='bottom', fontsize=8)
421
-
422
- # Gráfico de frequências
423
- barras_freq = ax2.bar(disciplinas, medias_freq, color=cores_freq)
424
- ax2.set_title('Frequência Média por Disciplina', pad=20, fontsize=12, fontweight='bold')
425
- ax2.set_ylim(0, 110)
426
- ax2.grid(True, axis='y', alpha=0.3, linestyle='--')
427
-
428
- # Melhorar a apresentação dos rótulos
429
- ax2.set_xticklabels(disciplinas, rotation=45, ha='right', va='top')
430
- ax2.set_ylabel('Frequência (%)', fontsize=10, labelpad=10)
431
-
432
- # Adicionar linha de frequência mínima
433
- ax2.axhline(y=LIMITE_APROVACAO_FREQ, color='r', linestyle='--', alpha=0.3)
434
- ax2.text(0.02, LIMITE_APROVACAO_FREQ + 1, 'Frequência mínima (75%)',
435
- transform=ax2.get_yaxis_transform(), color='r', alpha=0.7)
436
-
437
- # Valores nas barras de frequência
438
- for barra in barras_freq:
439
- altura = barra.get_height()
440
- ax2.text(barra.get_x() + barra.get_width()/2., altura,
441
- f'{altura:.1f}%',
442
- ha='center', va='bottom', fontsize=8)
443
-
444
- # Título global com informações de média
445
- plt.suptitle(
446
- f'Desempenho Geral\nMédia Global: {media_global:.1f} | Frequência Global: {freq_global:.1f}%',
447
- y=0.98, fontsize=14, fontweight='bold'
448
- )
449
-
450
- # Aviso de risco de reprovação se necessário
451
- if freq_global < LIMITE_APROVACAO_FREQ:
452
- plt.figtext(0.5, 0.02,
453
- "Atenção: Risco de Reprovação por Baixa Frequência",
454
- ha="center", fontsize=11, color="red", weight='bold')
455
-
456
- plt.tight_layout()
457
-
458
- # Salvar o gráfico
459
- plot_path = os.path.join(temp_dir, 'medias_frequencias.png')
460
- plt.savefig(plot_path, bbox_inches='tight', dpi=300)
461
- plt.close()
462
 
463
  return plot_path
464
 
465
- def gerar_relatorio_pdf(df, disciplinas_dados, grafico_basica, grafico_diversificada, grafico_medias):
466
- """Gera relatório PDF com os gráficos e análises."""
467
- pdf = FPDF()
468
- pdf.set_auto_page_break(auto=True, margin=15)
469
-
470
- # Primeira página - Informações e Formação Básica
471
- pdf.add_page()
472
- pdf.set_font('Helvetica', 'B', 18)
473
- pdf.cell(0, 10, 'Relatório de Desempenho Escolar', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C')
474
- pdf.ln(15)
475
-
476
- # Informações do aluno
477
- pdf.set_font('Helvetica', 'B', 12)
478
- pdf.cell(0, 10, 'Informações do Aluno', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
479
- pdf.line(10, pdf.get_y(), 200, pdf.get_y())
480
- pdf.ln(5)
481
-
482
- # Mostrar apenas o nome
483
- if hasattr(df, 'attrs') and 'nome' in df.attrs:
484
- pdf.set_font('Helvetica', 'B', 11)
485
- pdf.cell(30, 7, 'Nome:', 0, 0)
486
- pdf.set_font('Helvetica', '', 11)
487
- pdf.cell(0, 7, df.attrs['nome'], 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
488
-
489
- pdf.ln(10)
490
-
491
- # Data do relatório
492
- data_atual = datetime.now().strftime('%d/%m/%Y')
493
- pdf.set_font('Helvetica', 'I', 10)
494
- pdf.cell(0, 5, f'Data de geração: {data_atual}', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
495
- pdf.ln(15)
496
-
497
- # Gráfico de evolução da formação básica
498
- pdf.set_font('Helvetica', 'B', 14)
499
- pdf.cell(0, 10, 'Evolução das Notas - Formação Geral Básica', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
500
- pdf.line(10, pdf.get_y(), 200, pdf.get_y())
501
- pdf.ln(10)
502
- pdf.image(grafico_basica, x=10, w=190)
503
-
504
- # Segunda página - Parte Diversificada
505
- pdf.add_page()
506
- pdf.set_font('Helvetica', 'B', 14)
507
- pdf.cell(0, 10, 'Evolução das Notas - Parte Diversificada', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
508
- pdf.line(10, pdf.get_y(), 200, pdf.get_y())
509
- pdf.ln(10)
510
- pdf.image(grafico_diversificada, x=10, w=190)
511
 
512
- # Terceira página - Médias e Frequências
513
- pdf.add_page()
514
- pdf.set_font('Helvetica', 'B', 14)
515
- pdf.cell(0, 10, 'Análise de Médias e Frequências', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
516
- pdf.line(10, pdf.get_y(), 200, pdf.get_y())
517
- pdf.ln(10)
518
- pdf.image(grafico_medias, x=10, w=190)
519
-
520
- # Quarta página - Análise Detalhada
521
- pdf.add_page()
522
- pdf.set_font('Helvetica', 'B', 14)
523
- pdf.cell(0, 10, 'Análise Detalhada', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
524
- pdf.line(10, pdf.get_y(), 200, pdf.get_y())
525
- pdf.ln(10)
526
-
527
- # Calcular médias globais
528
- medias_notas = [d['media_notas'] for d in disciplinas_dados]
529
- medias_freq = [d['media_freq'] for d in disciplinas_dados]
530
- media_global = np.mean(medias_notas)
531
- freq_global = np.mean(medias_freq)
532
-
533
- # Resumo geral
534
- pdf.set_font('Helvetica', 'B', 12)
535
- pdf.cell(0, 7, 'Resumo Geral:', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
536
- pdf.ln(5)
537
-
538
- pdf.set_font('Helvetica', '', 11)
539
- pdf.cell(0, 7, f'Média Global: {media_global:.1f}', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
540
- pdf.cell(0, 7, f'Frequência Global: {freq_global:.1f}%', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
541
- pdf.ln(10)
542
-
543
- # Avisos Importantes
544
- pdf.set_font('Helvetica', 'B', 12)
545
- pdf.cell(0, 10, 'Pontos de Atenção:', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
546
- pdf.ln(5)
547
-
548
- pdf.set_font('Helvetica', '', 10)
549
-
550
- # Disciplinas com baixo desempenho
551
- disciplinas_risco = []
552
- for disc_data in disciplinas_dados:
553
- avisos = []
554
- if disc_data['media_notas'] < LIMITE_APROVACAO_NOTA:
555
- avisos.append(f"Média de notas abaixo de {LIMITE_APROVACAO_NOTA} ({disc_data['media_notas']:.1f})")
556
- if disc_data['media_freq'] < LIMITE_APROVACAO_FREQ:
557
- avisos.append(f"Frequência abaixo de {LIMITE_APROVACAO_FREQ}% ({disc_data['media_freq']:.1f}%)")
558
-
559
- if avisos:
560
- disciplinas_risco.append((disc_data['disciplina'], avisos))
561
-
562
- if disciplinas_risco:
563
- for disc, avisos in disciplinas_risco:
564
- pdf.set_font('Helvetica', 'B', 10)
565
- pdf.cell(0, 7, f'- {disc}:', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
566
- pdf.set_font('Helvetica', '', 10)
567
- for aviso in avisos:
568
- pdf.cell(10) # Indentação
569
- pdf.cell(0, 7, f'- {aviso}', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
570
- else:
571
- pdf.cell(0, 7, 'Nenhum problema identificado.', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
572
-
573
- # Rodapé
574
- pdf.set_y(-30)
575
- pdf.line(10, pdf.get_y(), 200, pdf.get_y())
576
- pdf.ln(5)
577
- pdf.set_font('Helvetica', 'I', 8)
578
- pdf.cell(0, 10, 'Este relatório é uma análise automática e deve ser validado junto à secretaria da escola.',
579
- 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C')
580
-
581
- # Salvar PDF
582
- temp_pdf = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
583
- pdf_path = temp_pdf.name
584
- pdf.output(pdf_path)
585
- return pdf_path
586
-
587
- def processar_boletim(file):
588
- """Função principal que processa o boletim e gera o relatório."""
589
- temp_dir = None
590
- try:
591
- if file is None:
592
- return None, "Nenhum arquivo foi fornecido."
593
-
594
- temp_dir = tempfile.mkdtemp()
595
- print(f"Diretório temporário criado: {temp_dir}")
596
-
597
- # Salvar o arquivo binário como um arquivo PDF temporário
598
- temp_pdf = os.path.join(temp_dir, 'boletim.pdf')
599
- with open(temp_pdf, 'wb') as f:
600
- f.write(file) # Salva os bytes do arquivo no disco
601
- print(f"PDF salvo temporariamente em: {temp_pdf}")
602
-
603
- if os.path.getsize(temp_pdf) == 0:
604
- return None, "O arquivo está vazio."
605
-
606
- print("Iniciando extração das tabelas...")
607
- df = extrair_tabelas_pdf(temp_pdf)
608
- print("Tabelas extraídas com sucesso")
609
-
610
- if df is None or df.empty:
611
- return None, "Não foi possível extrair dados do PDF."
612
-
613
- try:
614
- # Processar disciplinas
615
- disciplinas_dados = obter_disciplinas_validas(df)
616
- if not disciplinas_dados:
617
- return None, "Nenhuma disciplina válida encontrada no boletim."
618
-
619
- # Separar disciplinas por categoria
620
- categorias = separar_disciplinas_por_categoria(disciplinas_dados)
621
- nivel = categorias['nivel']
622
- nivel_texto = "Ensino Médio" if nivel == "medio" else "Ensino Fundamental"
623
-
624
- # Gerar gráficos
625
- print("Gerando gráficos...")
626
- grafico_basica = plotar_evolucao_bimestres(
627
- categorias['formacao_basica'],
628
- temp_dir,
629
- titulo=f"Evolução das Médias - Formação Geral Básica ({nivel_texto})",
630
- nome_arquivo='evolucao_basica.png'
631
- )
632
-
633
- grafico_diversificada = plotar_evolucao_bimestres(
634
- categorias['diversificada'],
635
- temp_dir,
636
- titulo=f"Evolução das Médias - Parte Diversificada ({nivel_texto})",
637
- nome_arquivo='evolucao_diversificada.png'
638
- )
639
-
640
- grafico_medias = plotar_graficos_destacados(disciplinas_dados, temp_dir)
641
- print("Gráficos gerados")
642
-
643
- # Gerar PDF
644
- print("Gerando relatório PDF...")
645
- pdf_path = gerar_relatorio_pdf(df, disciplinas_dados, grafico_basica, grafico_diversificada, grafico_medias)
646
- print("Relatório PDF gerado")
647
-
648
- # Criar arquivo de retorno
649
- output_file = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
650
- output_path = output_file.name
651
- shutil.copy2(pdf_path, output_path)
652
-
653
- return output_path, "Relatório gerado com sucesso!"
654
-
655
- except Exception as e:
656
- return None, f"Erro ao processar os dados: {str(e)}"
657
-
658
- except Exception as e:
659
- print(f"Erro durante o processamento: {str(e)}")
660
- return None, f"Erro ao processar o boletim: {str(e)}"
661
-
662
- finally:
663
- if temp_dir and os.path.exists(temp_dir):
664
- try:
665
- shutil.rmtree(temp_dir)
666
- print("Arquivos temporários limpos")
667
- except Exception as e:
668
- print(f"Erro ao limpar arquivos temporários: {str(e)}")
669
-
670
  # Interface Gradio
671
  iface = gr.Interface(
672
  fn=processar_boletim,
@@ -680,8 +423,9 @@ iface = gr.Interface(
680
  gr.Textbox(label="Status")
681
  ],
682
  title="Análise de Boletim Escolar",
683
- description="Faça upload do boletim em PDF para gerar um relatório com análises e visualizações.",
684
- allow_flagging="never"
 
685
  )
686
 
687
  if __name__ == "__main__":
 
1
+
2
  import gradio as gr
3
  import camelot
4
  import pandas as pd
 
12
  import shutil
13
  import colorsys
14
  from datetime import datetime
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from typing import Dict, List, Tuple, Optional
17
+ from io import BytesIO
18
+ import logging
19
+ from contextlib import contextmanager
20
+
21
+ # Configurar matplotlib
22
  matplotlib.use('Agg')
23
 
24
+ # Configurar logging
25
+ logging.basicConfig(
26
+ level=logging.INFO,
27
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
28
+ )
29
+ logger = logging.getLogger(__name__)
30
+
31
  # Configurações globais
32
  ESCALA_MAXIMA_NOTAS = 12
33
  LIMITE_APROVACAO_NOTA = 5
 
35
  BIMESTRES = ['1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre']
36
  CONCEITOS_VALIDOS = ['ES', 'EP', 'ET']
37
 
38
+ # Cores para os gráficos
39
+ COR_APROVADO = '#2ECC71' # Verde suave
40
+ COR_REPROVADO = '#E74C3C' # Vermelho suave
41
+
42
+ # Definição das disciplinas de formação básica
43
  FORMACAO_BASICA = {
44
  'fundamental': {
45
  'LINGUA PORTUGUESA',
 
59
  'BIOLOGIA',
60
  'FISICA',
61
  'QUIMICA',
62
+ 'INGLES',
63
  'FILOSOFIA',
64
  'SOCIOLOGIA',
65
  'ARTE',
 
67
  }
68
  }
69
 
70
+ # Context managers
71
+ @contextmanager
72
+ def temp_directory():
73
+ temp_dir = tempfile.mkdtemp()
74
+ try:
75
+ yield temp_dir
76
+ finally:
77
+ if os.path.exists(temp_dir):
78
+ shutil.rmtree(temp_dir)
79
 
80
+ @contextmanager
81
+ def temp_file(suffix=None):
82
+ temp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
83
+ try:
84
+ yield temp.name
85
+ finally:
86
+ if os.path.exists(temp.name):
87
+ os.unlink(temp.name)
88
+
89
+ class PDFReport(FPDF):
90
+ """Classe personalizada para geração do relatório PDF."""
91
+ def __init__(self):
92
+ super().__init__()
93
+ self.set_auto_page_break(auto=True, margin=15)
94
+
95
+ def header_footer(self):
96
+ """Adiciona header e footer padrãoo nas páginas."""
97
+ self.set_y(-30)
98
+ self.line(10, self.get_y(), 200, self.get_y())
99
+ self.ln(5)
100
+ self.set_font('Helvetica', 'I', 8)
101
+ self.cell(0, 10,
102
+ 'Este relatório é uma análise automática e deve ser validado junto à secretaria da escola.',
103
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C')
104
 
105
+ def converter_nota(valor) -> Optional[float]:
106
  """Converte valor de nota para float, tratando casos especiais e conceitos."""
107
  if pd.isna(valor) or valor == '-' or valor == 'N' or valor == '' or valor == 'None':
108
  return None
 
123
 
124
  return None
125
 
126
+ def calcular_media_bimestres(notas: List[float]) -> float:
127
  """Calcula média considerando apenas bimestres com notas válidas."""
128
  notas_validas = [nota for nota in notas if nota is not None]
129
+ return sum(notas_validas) / len(notas_validas) if notas_validas else 0
 
 
130
 
131
+ def calcular_frequencia_media(frequencias: List[str]) -> float:
132
  """Calcula média de frequência considerando apenas bimestres cursados."""
133
  freq_validas = []
134
  for freq in frequencias:
 
142
  except:
143
  continue
144
 
145
+ return sum(freq_validas) / len(freq_validas) if freq_validas else 0
146
+
147
+ def extrair_tabelas_pdf(pdf_path: str) -> pd.DataFrame:
148
+ """Extrai tabelas do PDF usando stream para o nome e lattice para notas."""
 
 
149
  try:
150
  # Extrair nome do aluno usando stream
151
  tables_header = camelot.read_pdf(
 
157
 
158
  info_aluno = {}
159
 
160
+ # Procurar nome do aluno
161
  for table in tables_header:
162
  df = table.df
163
  for i in range(len(df)):
 
182
  flavor='lattice'
183
  )
184
 
185
+ # Encontrar tabela de notas
186
  df_notas = None
187
  max_rows = 0
188
 
 
203
  if df_notas is None:
204
  raise ValueError("Tabela de notas não encontrada")
205
 
206
+ # Adicionar informações do aluno ao DataFrame
207
+ df_notas.attrs['nome'] = info_aluno.get('nome', 'Nome não encontrado')
208
 
209
  return df_notas
210
 
211
  except Exception as e:
212
+ logger.error(f"Erro na extração das tabelas: {str(e)}")
213
  raise
214
+
215
+ def detectar_nivel_ensino(disciplinas: List[str]) -> str:
216
+ """Detecta se são ensino fundamental ou médio baseado nas disciplinas."""
217
+ disciplinas_set = set(disciplinas)
218
+ disciplinas_exclusivas_medio = {'BIOLOGIA', 'FISICA', 'QUIMICA', 'FILOSOFIA', 'SOCIOLOGIA'}
219
+ return 'medio' if any(d in disciplinas_set for d in disciplinas_exclusivas_medio) else 'fundamental'
220
+
221
+ def obter_disciplinas_validas(df: pd.DataFrame) -> List[Dict]:
222
  """Identifica disciplinas válidas no boletim com seus dados."""
223
  colunas_notas = ['Nota B1', 'Nota B2', 'Nota B3', 'Nota B4']
224
  colunas_freq = ['%Freq B1', '%Freq B2', '%Freq B3', '%Freq B4']
 
261
 
262
  return disciplinas_dados
263
 
264
+
265
+ def separar_disciplinas_por_categoria(disciplinas_dados: List[Dict]) -> Dict:
266
+ """Separa as disciplinas em formação básica e diversificada."""
267
+ disciplinas = [d['disciplina'] for d in disciplinas_dados]
268
+ nivel = detectar_nivel_ensino(disciplinas)
269
+
270
+ formacao_basica = []
271
+ diversificada = []
272
+
273
+ for disc_data in disciplinas_dados:
274
+ if disc_data['disciplina'] in FORMACAO_BASICA[nivel]:
275
+ formacao_basica.append(disc_data)
276
+ else:
277
+ diversificada.append(disc_data)
278
+
279
+ return {
280
+ 'nivel': nivel,
281
+ 'formacao_basica': formacao_basica,
282
+ 'diversificada': diversificada
283
+ }
284
+ # Funções de plotagem
285
+ def gerar_paleta_cores(n_cores: int) -> List[str]:
286
+ """Gera uma paleta de cores harmoniosa."""
287
+ cores_formacao_basica = [
288
+ '#2E86C1', # Azul royal
289
+ '#2ECC71', # Verde esmeralda
290
+ '#E74C3C', # Vermelho coral
291
+ '#F1C40F', # Amarelo ouro
292
+ '#8E44AD', # Roxo médio
293
+ '#E67E22', # Laranja escuro
294
+ '#16A085', # Verde-água
295
+ '#D35400' # Laranja queimado
296
  ]
297
 
298
+ if n_cores <= len(cores_formacao_basica):
299
+ return cores_formacao_basica[:n_cores]
 
 
 
300
 
301
+ # Gerar cores adicionais se necessário
302
+ HSV_tuples = [(x/n_cores, 0.8, 0.9) for x in range(n_cores)]
303
+ return ['#%02x%02x%02x' % tuple(int(x*255) for x in colorsys.hsv_to_rgb(*hsv))
304
+ for hsv in HSV_tuples]
305
 
306
+ def plotar_evolucao_bimestres(disciplinas_dados: List[Dict], temp_dir: str,
307
+ titulo: Optional[str] = None,
308
+ nome_arquivo: Optional[str] = None) -> str:
309
+ """Plota gráfico de evolução das notas com visual aprimorado."""
310
  n_disciplinas = len(disciplinas_dados)
311
 
312
  if n_disciplinas == 0:
313
+ raise ValueError("Nenhuma disciplina válida encontrada para plotar.")
314
 
315
+ # Configuração do estilo
316
+ plt.style.use('seaborn-v0_8-darkgrid')
317
+ fig, ax = plt.subplots(figsize=(11.69, 8.27))
318
 
319
+ # Configurar grid mais suave
320
+ ax.grid(True, linestyle='--', alpha=0.2, color='gray')
321
+ ax.set_axisbelow(True)
 
 
322
 
323
+ cores = gerar_paleta_cores(n_disciplinas)
324
+ marcadores = ['o', 's', '^', 'D', 'v', '<', '>', 'p']
325
+ estilos_linha = ['-', '--', '-.', ':']
326
 
327
+ deslocamentos = np.linspace(-0.02, 0.02, n_disciplinas)
328
+ anotacoes_usadas = {}
329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  for idx, disc_data in enumerate(disciplinas_dados):
331
  notas = pd.Series(disc_data['notas'])
332
  bimestres_cursados = disc_data['bimestres_cursados']
 
338
  bimestres_deslocados = [bim + desloc for bim in bimestres]
339
 
340
  if notas_validas:
341
+ ax.plot(bimestres_deslocados, notas_validas,
 
342
  color=cores[idx % len(cores)],
343
  marker=marcadores[idx % len(marcadores)],
344
+ markersize=8,
345
+ linewidth=2.5,
346
  label=disc_data['disciplina'],
347
  linestyle=estilos_linha[idx % len(estilos_linha)],
348
+ alpha=0.8,
349
+ zorder=3)
350
 
351
+ ax.fill_between(bimestres_deslocados, 0, notas_validas,
352
+ color=cores[idx % len(cores)], alpha=0.1)
353
+
354
+ for bim, nota in zip(bimestres_deslocados, notas_validas):
355
  if nota is not None:
356
+ y_offset = 10
357
+ while any(abs(y - (nota + y_offset/20)) < 0.4 for y, _ in anotacoes_usadas.get(bim, [])):
358
+ y_offset += 5
359
+
360
+ ax.annotate(f"{nota:.1f}",
361
+ (bim, nota),
362
+ xytext=(0, y_offset),
363
+ textcoords="offset points",
364
+ ha='center',
365
+ va='bottom',
366
+ fontsize=9,
367
+ bbox=dict(facecolor='white',
368
+ edgecolor=cores[idx % len(cores)],
369
+ alpha=0.8,
370
+ pad=2,
371
+ boxstyle='round,pad=0.5'))
372
+
373
+ if bim not in anotacoes_usadas:
374
+ anotacoes_usadas[bim] = []
375
+ anotacoes_usadas[bim].append((nota + y_offset/20, nota))
376
+
377
+ titulo_grafico = titulo or 'Evolução das Médias por Disciplina'
378
+ ax.set_title(titulo_grafico, pad=20, fontsize=14, fontweight='bold')
379
+ ax.set_xlabel('Bimestres', fontsize=12, labelpad=10)
380
+ ax.set_ylabel('Notas', fontsize=12, labelpad=10)
381
+
382
+ ax.spines['top'].set_visible(False)
383
+ ax.spines['right'].set_visible(False)
384
+
385
+ ax.set_xticks([1, 2, 3, 4])
386
+ ax.set_xticklabels(['1º Bim', '2º Bim', '3º Bim', '4º Bim'], fontsize=10)
387
+ ax.set_ylim(0, ESCALA_MAXIMA_NOTAS)
388
+
389
+ ax.axhline(y=LIMITE_APROVACAO_NOTA, color=COR_REPROVADO, linestyle='--', alpha=0.3, linewidth=2)
390
+ ax.text(0.02, LIMITE_APROVACAO_NOTA + 0.1, 'Média mínima para aprovação',
391
+ transform=ax.get_yaxis_transform(), color=COR_REPROVADO, alpha=0.7)
 
 
392
 
 
 
 
 
 
 
393
  if n_disciplinas > 8:
394
+ ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left', fontsize=9, framealpha=0.8, fancybox=True, shadow=True,
395
  ncol=max(1, n_disciplinas // 12))
396
  else:
397
+ ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left', fontsize=10, framealpha=0.8, fancybox=True, shadow=True)
398
 
399
  plt.tight_layout()
400
+
401
+ # Força a renderização para evitar o erro de renderizador
402
+ fig.canvas.draw()
403
 
404
+ # Salvar com alta qualidade
405
  nome_arquivo = nome_arquivo or 'evolucao_notas.png'
406
  plot_path = os.path.join(temp_dir, nome_arquivo)
407
+ fig.savefig(plot_path, bbox_inches='tight', dpi=300, facecolor='white', edgecolor='none')
408
+ plt.close(fig)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
 
410
  return plot_path
411
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
  # Interface Gradio
414
  iface = gr.Interface(
415
  fn=processar_boletim,
 
423
  gr.Textbox(label="Status")
424
  ],
425
  title="Análise de Boletim Escolar",
426
+ description="Faça upload do boletim em PDF para gerar um relatório com análises e visualizações.",
427
+ allow_flagging="never",
428
+ theme=gr.themes.Default()
429
  )
430
 
431
  if __name__ == "__main__":