histlearn commited on
Commit
71b898a
1 Parent(s): 3cd84f7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +410 -284
app.py CHANGED
@@ -11,8 +11,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,6 +34,10 @@ 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': {
@@ -40,7 +58,7 @@ FORMACAO_BASICA = {
40
  'BIOLOGIA',
41
  'FISICA',
42
  'QUIMICA',
43
- 'INGLÊS',
44
  'FILOSOFIA',
45
  'SOCIOLOGIA',
46
  'ARTE',
@@ -48,33 +66,44 @@ 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 +124,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 +143,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 +158,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 +183,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 +204,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,272 +262,370 @@ 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']
293
  desloc = deslocamentos[idx]
294
 
295
  if bimestres_cursados:
296
- notas_validas = [nota for i, nota in enumerate(notas, 1) if i in bimestres_cursados and nota is not None]
297
- bimestres = [bim for bim in bimestres_cursados if notas[bim-1] is not None]
 
 
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)
@@ -504,7 +633,8 @@ def gerar_relatorio_pdf(df, disciplinas_dados, grafico_basica, grafico_diversifi
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)
@@ -512,7 +642,8 @@ def gerar_relatorio_pdf(df, disciplinas_dados, grafico_basica, grafico_diversifi
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)
@@ -520,41 +651,47 @@ def gerar_relatorio_pdf(df, disciplinas_dados, grafico_basica, grafico_diversifi
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))
@@ -562,110 +699,97 @@ def gerar_relatorio_pdf(df, disciplinas_dados, grafico_basica, grafico_diversifi
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(
@@ -681,11 +805,13 @@ iface = gr.Interface(
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__":
688
  iface.launch(
689
  server_name="0.0.0.0",
690
- share=True
 
691
  )
 
11
  import shutil
12
  import colorsys
13
  from datetime import datetime
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from typing import Dict, List, Tuple, Optional
16
+ from io import BytesIO
17
+ import logging
18
+ from contextlib import contextmanager
19
+
20
+ # Configurar matplotlib
21
  matplotlib.use('Agg')
22
 
23
+ # Configurar logging
24
+ logging.basicConfig(
25
+ level=logging.INFO,
26
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
27
+ )
28
+ logger = logging.getLogger(__name__)
29
+
30
  # Configurações globais
31
  ESCALA_MAXIMA_NOTAS = 12
32
  LIMITE_APROVACAO_NOTA = 5
 
34
  BIMESTRES = ['1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre']
35
  CONCEITOS_VALIDOS = ['ES', 'EP', 'ET']
36
 
37
+ # Cores para os gráficos
38
+ COR_APROVADO = '#2ECC71' # Verde suave
39
+ COR_REPROVADO = '#E74C3C' # Vermelho suave
40
+
41
  # Definição das disciplinas de formação básica
42
  FORMACAO_BASICA = {
43
  'fundamental': {
 
58
  'BIOLOGIA',
59
  'FISICA',
60
  'QUIMICA',
61
+ 'INGLES',
62
  'FILOSOFIA',
63
  'SOCIOLOGIA',
64
  'ARTE',
 
66
  }
67
  }
68
 
69
+ # Context managers
70
+ @contextmanager
71
+ def temp_directory():
72
+ """Context manager para diretório temporário."""
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
+ """Context manager para arquivo temporário."""
83
+ temp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
84
+ try:
85
+ yield temp.name
86
+ finally:
87
+ if os.path.exists(temp.name):
88
+ os.unlink(temp.name)
 
 
 
 
 
 
 
 
 
 
89
 
90
+ class PDFReport(FPDF):
91
+ """Classe personalizada para geração do relatório PDF."""
92
+ def __init__(self):
93
+ super().__init__()
94
+ self.set_auto_page_break(auto=True, margin=15)
95
+
96
+ def header_footer(self):
97
+ """Adiciona header e footer padrão nas páginas."""
98
+ self.set_y(-30)
99
+ self.line(10, self.get_y(), 200, self.get_y())
100
+ self.ln(5)
101
+ self.set_font('Helvetica', 'I', 8)
102
+ self.cell(0, 10,
103
+ 'Este relatório é uma análise automática e deve ser validado junto à secretaria da escola.',
104
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C')
105
+
106
+ def converter_nota(valor) -> Optional[float]:
107
  """Converte valor de nota para float, tratando casos especiais e conceitos."""
108
  if pd.isna(valor) or valor == '-' or valor == 'N' or valor == '' or valor == 'None':
109
  return None
 
124
 
125
  return None
126
 
127
+ def calcular_media_bimestres(notas: List[float]) -> float:
128
  """Calcula média considerando apenas bimestres com notas válidas."""
129
  notas_validas = [nota for nota in notas if nota is not None]
130
+ return sum(notas_validas) / len(notas_validas) if notas_validas else 0
 
 
131
 
132
+ def calcular_frequencia_media(frequencias: List[str]) -> float:
133
  """Calcula média de frequência considerando apenas bimestres cursados."""
134
  freq_validas = []
135
  for freq in frequencias:
 
143
  except:
144
  continue
145
 
146
+ return sum(freq_validas) / len(freq_validas) if freq_validas else 0
 
 
147
 
148
+ def extrair_tabelas_pdf(pdf_path: str) -> pd.DataFrame:
149
+ """Extrai tabelas do PDF usando stream para o nome e lattice para notas."""
150
  try:
151
  # Extrair nome do aluno usando stream
152
  tables_header = camelot.read_pdf(
 
158
 
159
  info_aluno = {}
160
 
161
+ # Procurar nome do aluno
162
  for table in tables_header:
163
  df = table.df
164
  for i in range(len(df)):
 
183
  flavor='lattice'
184
  )
185
 
186
+ # Encontrar tabela de notas
187
  df_notas = None
188
  max_rows = 0
189
 
 
204
  if df_notas is None:
205
  raise ValueError("Tabela de notas não encontrada")
206
 
207
+ # Adicionar informações do aluno ao DataFrame
208
  df_notas.attrs['nome'] = info_aluno.get('nome', 'Nome não encontrado')
209
 
210
  return df_notas
211
 
212
  except Exception as e:
213
+ logger.error(f"Erro na extração das tabelas: {str(e)}")
214
  raise
215
+
216
+ def detectar_nivel_ensino(disciplinas: List[str]) -> str:
217
+ """Detecta se é ensino fundamental ou médio baseado nas disciplinas."""
218
+ disciplinas_set = set(disciplinas)
219
+ disciplinas_exclusivas_medio = {'BIOLOGIA', 'FISICA', 'QUIMICA', 'FILOSOFIA', 'SOCIOLOGIA'}
220
+ return 'medio' if any(d in disciplinas_set for d in disciplinas_exclusivas_medio) else 'fundamental'
221
+
222
+ def obter_disciplinas_validas(df: pd.DataFrame) -> List[Dict]:
223
  """Identifica disciplinas válidas no boletim com seus dados."""
224
  colunas_notas = ['Nota B1', 'Nota B2', 'Nota B3', 'Nota B4']
225
  colunas_freq = ['%Freq B1', '%Freq B2', '%Freq B3', '%Freq B4']
 
262
 
263
  return disciplinas_dados
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
+
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')
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
+ # Deslocamento sutil para evitar sobreposição
328
+ deslocamentos = np.linspace(-0.02, 0.02, n_disciplinas)
329
+ anotacoes_usadas = {}
330
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  for idx, disc_data in enumerate(disciplinas_dados):
332
  notas = pd.Series(disc_data['notas'])
333
  bimestres_cursados = disc_data['bimestres_cursados']
334
  desloc = deslocamentos[idx]
335
 
336
  if bimestres_cursados:
337
+ notas_validas = [nota for i, nota in enumerate(notas, 1)
338
+ if i in bimestres_cursados and nota is not None]
339
+ bimestres = [bim for bim in bimestres_cursados
340
+ if notas[bim-1] is not None]
341
  bimestres_deslocados = [bim + desloc for bim in bimestres]
342
 
343
  if notas_validas:
344
+ # Linha com sombreamento
345
  plt.plot(bimestres_deslocados, notas_validas,
346
  color=cores[idx % len(cores)],
347
  marker=marcadores[idx % len(marcadores)],
348
+ markersize=8,
349
+ linewidth=2.5,
350
  label=disc_data['disciplina'],
351
  linestyle=estilos_linha[idx % len(estilos_linha)],
352
+ alpha=0.8,
353
+ zorder=3)
354
+
355
+ # Área sombreada sob a linha
356
+ plt.fill_between(bimestres_deslocados, 0, notas_validas,
357
+ color=cores[idx % len(cores)],
358
+ alpha=0.1)
359
 
360
+ # Anotações elegantes
361
+ for bim, nota in zip(bimestres_deslocados, notas_validas):
362
  if nota is not None:
363
+ y_offset = 10
364
+ while any(abs(y - (nota + y_offset/20)) < 0.4
365
+ for y, _ in anotacoes_usadas.get(bim, [])):
366
+ y_offset += 5
367
+
368
+ plt.annotate(f"{nota:.1f}",
369
+ (bim, nota),
370
+ xytext=(0, y_offset),
371
+ textcoords="offset points",
372
+ ha='center',
373
+ va='bottom',
374
+ fontsize=9,
375
+ bbox=dict(
376
+ facecolor='white',
377
+ edgecolor=cores[idx % len(cores)],
378
+ alpha=0.8,
379
+ pad=2,
380
+ boxstyle='round,pad=0.5'
381
+ ))
382
+
383
+ if bim not in anotacoes_usadas:
384
+ anotacoes_usadas[bim] = []
385
+ anotacoes_usadas[bim].append((nota + y_offset/20, nota))
386
+
387
+ # Estilização
388
+ titulo_grafico = titulo or 'Evolução das Médias por Disciplina'
389
+ plt.title(titulo_grafico, pad=20, fontsize=14, fontweight='bold')
390
+ plt.xlabel('Bimestres', fontsize=12, labelpad=10)
391
+ plt.ylabel('Notas', fontsize=12, labelpad=10)
392
+
393
+ # Remover bordas desnecessárias
394
+ ax.spines['top'].set_visible(False)
395
+ ax.spines['right'].set_visible(False)
396
+
397
+ plt.xticks([1, 2, 3, 4], ['1º Bim', '2º Bim', '3º Bim', '4º Bim'],
398
+ fontsize=10)
 
399
  plt.ylim(0, ESCALA_MAXIMA_NOTAS)
400
 
401
+ # Linha de aprovação estilizada
402
+ plt.axhline(y=LIMITE_APROVACAO_NOTA, color=COR_REPROVADO,
403
+ linestyle='--', alpha=0.3, linewidth=2)
404
+ plt.text(0.02, LIMITE_APROVACAO_NOTA + 0.1,
405
+ 'Média mínima para aprovação',
406
+ transform=plt.gca().get_yaxis_transform(),
407
+ color=COR_REPROVADO, alpha=0.7)
408
 
409
+ # Legenda estilizada
410
  if n_disciplinas > 8:
411
+ plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left',
412
+ fontsize=9, framealpha=0.8,
413
+ fancybox=True, shadow=True,
414
  ncol=max(1, n_disciplinas // 12))
415
  else:
416
+ plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left',
417
+ fontsize=10, framealpha=0.8,
418
+ fancybox=True, shadow=True)
419
 
420
  plt.tight_layout()
421
 
422
+ # Salvar com alta qualidade
423
  nome_arquivo = nome_arquivo or 'evolucao_notas.png'
424
  plot_path = os.path.join(temp_dir, nome_arquivo)
425
+ plt.savefig(plot_path, bbox_inches='tight', dpi=300,
426
+ facecolor='white', edgecolor='none')
427
  plt.close()
428
+
429
  return plot_path
430
 
431
+ def plotar_graficos_destacados(disciplinas_dados: List[Dict], temp_dir: str) -> str:
432
+ """Plota gráficos de médias e frequências com visual aprimorado."""
433
  n_disciplinas = len(disciplinas_dados)
434
 
435
  if not n_disciplinas:
436
  raise ValueError("Nenhuma disciplina válida encontrada no boletim.")
437
 
438
+ # Configuração do estilo
439
+ plt.style.use('seaborn')
440
+ fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10),
441
+ height_ratios=[1, 1])
442
+ plt.subplots_adjust(hspace=0.4)
443
 
444
  disciplinas = [d['disciplina'] for d in disciplinas_dados]
445
  medias_notas = [d['media_notas'] for d in disciplinas_dados]
446
  medias_freq = [d['media_freq'] for d in disciplinas_dados]
447
 
448
+ # Definir cores baseadas nos limites
449
+ cores_notas = [COR_REPROVADO if media < LIMITE_APROVACAO_NOTA
450
+ else COR_APROVADO for media in medias_notas]
451
+ cores_freq = [COR_REPROVADO if media < LIMITE_APROVACAO_FREQ
452
+ else COR_APROVADO for media in medias_freq]
 
 
453
 
454
  # Calcular médias globais
455
  media_global = np.mean(medias_notas)
456
  freq_global = np.mean(medias_freq)
457
 
458
+ # Configurações comuns para os eixos
459
+ for ax in [ax1, ax2]:
460
+ ax.grid(True, axis='y', alpha=0.2, linestyle='--')
461
+ ax.set_axisbelow(True)
462
+ ax.spines['top'].set_visible(False)
463
+ ax.spines['right'].set_visible(False)
464
+
465
  # Gráfico de notas
466
  barras_notas = ax1.bar(disciplinas, medias_notas, color=cores_notas)
467
+ ax1.set_title('Média de Notas por Disciplina',
468
+ pad=20, fontsize=14, fontweight='bold')
469
  ax1.set_ylim(0, ESCALA_MAXIMA_NOTAS)
470
+ ax1.set_xticklabels(disciplinas, rotation=45,
471
+ ha='right', va='top', fontsize=10)
472
+ ax1.set_ylabel('Notas', fontsize=12, labelpad=10)
473
+
474
+ # Linha de média mínima
475
+ ax1.axhline(y=LIMITE_APROVACAO_NOTA,
476
+ color=COR_REPROVADO,
477
+ linestyle='--',
478
+ alpha=0.3,
479
+ linewidth=2)
480
+ ax1.text(0.02, LIMITE_APROVACAO_NOTA + 0.1,
481
+ 'Média mínima (5,0)',
482
+ transform=ax1.get_yaxis_transform(),
483
+ color=COR_REPROVADO,
484
+ alpha=0.7,
485
+ fontsize=10)
486
 
487
  # Valores nas barras de notas
488
  for barra in barras_notas:
489
  altura = barra.get_height()
490
+ cor_texto = 'white' if altura >= LIMITE_APROVACAO_NOTA else 'black'
491
  ax1.text(barra.get_x() + barra.get_width()/2., altura,
492
  f'{altura:.1f}',
493
+ ha='center',
494
+ va='bottom',
495
+ fontsize=10,
496
+ bbox=dict(
497
+ facecolor='white',
498
+ edgecolor='none',
499
+ alpha=0.7,
500
+ pad=1
501
+ ),
502
+ color=cor_texto if altura >= 8 else 'black')
503
 
504
  # Gráfico de frequências
505
  barras_freq = ax2.bar(disciplinas, medias_freq, color=cores_freq)
506
+ ax2.set_title('Frequência Média por Disciplina',
507
+ pad=20, fontsize=14, fontweight='bold')
508
  ax2.set_ylim(0, 110)
509
+ ax2.set_xticklabels(disciplinas, rotation=45,
510
+ ha='right', va='top', fontsize=10)
511
+ ax2.set_ylabel('Frequência (%)', fontsize=12, labelpad=10)
512
+
513
+ # Linha de frequência mínima
514
+ ax2.axhline(y=LIMITE_APROVACAO_FREQ,
515
+ color=COR_REPROVADO,
516
+ linestyle='--',
517
+ alpha=0.3,
518
+ linewidth=2)
519
+ ax2.text(0.02, LIMITE_APROVACAO_FREQ + 1,
520
+ 'Frequência mínima (75%)',
521
+ transform=ax2.get_yaxis_transform(),
522
+ color=COR_REPROVADO,
523
+ alpha=0.7,
524
+ fontsize=10)
525
 
526
  # Valores nas barras de frequência
527
  for barra in barras_freq:
528
  altura = barra.get_height()
529
+ cor_texto = 'white' if altura >= LIMITE_APROVACAO_FREQ else 'black'
530
  ax2.text(barra.get_x() + barra.get_width()/2., altura,
531
  f'{altura:.1f}%',
532
+ ha='center',
533
+ va='bottom',
534
+ fontsize=10,
535
+ bbox=dict(
536
+ facecolor='white',
537
+ edgecolor='none',
538
+ alpha=0.7,
539
+ pad=1
540
+ ),
541
+ color=cor_texto if altura >= 90 else 'black')
542
+
543
+ # Título global com estilo
544
  plt.suptitle(
545
  f'Desempenho Geral\nMédia Global: {media_global:.1f} | Frequência Global: {freq_global:.1f}%',
546
+ y=0.98,
547
+ fontsize=16,
548
+ fontweight='bold',
549
+ bbox=dict(
550
+ facecolor='white',
551
+ edgecolor='none',
552
+ alpha=0.8,
553
+ pad=5,
554
+ boxstyle='round,pad=0.5'
555
+ )
556
  )
557
 
558
+ # Aviso de reprovação estilizado
559
  if freq_global < LIMITE_APROVACAO_FREQ:
560
  plt.figtext(0.5, 0.02,
561
  "Atenção: Risco de Reprovação por Baixa Frequência",
562
+ ha="center",
563
+ fontsize=12,
564
+ color=COR_REPROVADO,
565
+ weight='bold',
566
+ bbox=dict(
567
+ facecolor='#FFEBEE',
568
+ edgecolor=COR_REPROVADO,
569
+ alpha=0.9,
570
+ pad=5,
571
+ boxstyle='round,pad=0.5'
572
+ ))
573
 
574
  plt.tight_layout()
575
 
576
+ # Salvar com alta qualidade
577
  plot_path = os.path.join(temp_dir, 'medias_frequencias.png')
578
+ plt.savefig(plot_path,
579
+ bbox_inches='tight',
580
+ dpi=300,
581
+ facecolor='white',
582
+ edgecolor='none')
583
  plt.close()
584
 
585
  return plot_path
586
 
587
+ def gerar_relatorio_pdf(df: pd.DataFrame, disciplinas_dados: List[Dict],
588
+ grafico_basica: str, grafico_diversificada: str,
589
+ grafico_medias: str) -> str:
590
+ """Gera relatório PDF com análise completa."""
591
+ pdf = PDFReport()
592
  pdf.set_auto_page_break(auto=True, margin=15)
593
 
594
  # Primeira página - Informações e Formação Básica
595
  pdf.add_page()
596
  pdf.set_font('Helvetica', 'B', 18)
597
+ pdf.cell(0, 10, 'Relatório de Desempenho Escolar',
598
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C')
599
  pdf.ln(15)
600
 
601
  # Informações do aluno
602
  pdf.set_font('Helvetica', 'B', 12)
603
+ pdf.cell(0, 10, 'Informações do Aluno',
604
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
605
  pdf.line(10, pdf.get_y(), 200, pdf.get_y())
606
  pdf.ln(5)
607
 
608
+ # Nome do aluno
609
  if hasattr(df, 'attrs') and 'nome' in df.attrs:
610
  pdf.set_font('Helvetica', 'B', 11)
611
  pdf.cell(30, 7, 'Nome:', 0, 0)
612
  pdf.set_font('Helvetica', '', 11)
613
+ pdf.cell(0, 7, df.attrs['nome'],
614
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
615
 
616
  pdf.ln(10)
617
 
618
  # Data do relatório
619
  data_atual = datetime.now().strftime('%d/%m/%Y')
620
  pdf.set_font('Helvetica', 'I', 10)
621
+ pdf.cell(0, 5, f'Data de geração: {data_atual}',
622
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
623
  pdf.ln(15)
624
 
625
+ # Gráficos de evolução
626
  pdf.set_font('Helvetica', 'B', 14)
627
+ pdf.cell(0, 10, 'Evolução das Notas - Formação Geral Básica',
628
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
629
  pdf.line(10, pdf.get_y(), 200, pdf.get_y())
630
  pdf.ln(10)
631
  pdf.image(grafico_basica, x=10, w=190)
 
633
  # Segunda página - Parte Diversificada
634
  pdf.add_page()
635
  pdf.set_font('Helvetica', 'B', 14)
636
+ pdf.cell(0, 10, 'Evolução das Notas - Parte Diversificada',
637
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
638
  pdf.line(10, pdf.get_y(), 200, pdf.get_y())
639
  pdf.ln(10)
640
  pdf.image(grafico_diversificada, x=10, w=190)
 
642
  # Terceira página - Médias e Frequências
643
  pdf.add_page()
644
  pdf.set_font('Helvetica', 'B', 14)
645
+ pdf.cell(0, 10, 'Análise de Médias e Frequências',
646
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
647
  pdf.line(10, pdf.get_y(), 200, pdf.get_y())
648
  pdf.ln(10)
649
  pdf.image(grafico_medias, x=10, w=190)
 
651
  # Quarta página - Análise Detalhada
652
  pdf.add_page()
653
  pdf.set_font('Helvetica', 'B', 14)
654
+ pdf.cell(0, 10, 'Análise Detalhada',
655
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
656
  pdf.line(10, pdf.get_y(), 200, pdf.get_y())
657
  pdf.ln(10)
658
 
659
+ # Resumo geral
660
  medias_notas = [d['media_notas'] for d in disciplinas_dados]
661
  medias_freq = [d['media_freq'] for d in disciplinas_dados]
662
  media_global = np.mean(medias_notas)
663
  freq_global = np.mean(medias_freq)
664
 
 
665
  pdf.set_font('Helvetica', 'B', 12)
666
+ pdf.cell(0, 7, 'Resumo Geral:',
667
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
668
  pdf.ln(5)
669
 
670
  pdf.set_font('Helvetica', '', 11)
671
+ pdf.cell(0, 7, f'Média Global: {media_global:.1f}',
672
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
673
+ pdf.cell(0, 7, f'Frequência Global: {freq_global:.1f}%',
674
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
675
  pdf.ln(10)
676
 
677
+ # Pontos de atenção
678
  pdf.set_font('Helvetica', 'B', 12)
679
+ pdf.cell(0, 10, 'Pontos de Atenção:',
680
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
681
  pdf.ln(5)
682
 
683
  pdf.set_font('Helvetica', '', 10)
 
 
684
  disciplinas_risco = []
685
  for disc_data in disciplinas_dados:
686
  avisos = []
687
  if disc_data['media_notas'] < LIMITE_APROVACAO_NOTA:
688
+ avisos.append(
689
+ f"Média de notas abaixo de {LIMITE_APROVACAO_NOTA} ({disc_data['media_notas']:.1f})"
690
+ )
691
  if disc_data['media_freq'] < LIMITE_APROVACAO_FREQ:
692
+ avisos.append(
693
+ f"Frequência abaixo de {LIMITE_APROVACAO_FREQ}% ({disc_data['media_freq']:.1f}%)"
694
+ )
695
 
696
  if avisos:
697
  disciplinas_risco.append((disc_data['disciplina'], avisos))
 
699
  if disciplinas_risco:
700
  for disc, avisos in disciplinas_risco:
701
  pdf.set_font('Helvetica', 'B', 10)
702
+ pdf.cell(0, 7, f'- {disc}:',
703
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
704
  pdf.set_font('Helvetica', '', 10)
705
  for aviso in avisos:
706
  pdf.cell(10) # Indentação
707
+ pdf.cell(0, 7, f'- {aviso}',
708
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
709
  else:
710
+ pdf.cell(0, 7, 'Nenhum problema identificado.',
711
+ 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
712
 
713
+ pdf.header_footer()
 
 
 
 
 
 
714
 
715
  # Salvar PDF
716
+ with temp_file(suffix='.pdf') as temp_pdf:
717
+ pdf.output(temp_pdf)
718
+ return temp_pdf
 
719
 
720
+ def processar_boletim(file) -> Tuple[Optional[str], str]:
721
  """Função principal que processa o boletim e gera o relatório."""
 
722
  try:
723
  if file is None:
724
  return None, "Nenhum arquivo foi fornecido."
725
 
726
+ with temp_directory() as temp_dir:
727
+ # Salvar arquivo temporário
728
+ temp_pdf = os.path.join(temp_dir, 'boletim.pdf')
729
+ with open(temp_pdf, 'wb') as f:
730
+ f.write(file)
731
+
732
+ if os.path.getsize(temp_pdf) == 0:
733
+ return None, "O arquivo está vazio."
734
+
735
+ # Extrair e processar dados
736
+ df = extrair_tabelas_pdf(temp_pdf)
737
+ if df is None or df.empty:
738
+ return None, "Não foi possível extrair dados do PDF."
739
+
 
 
 
 
 
 
 
740
  disciplinas_dados = obter_disciplinas_validas(df)
741
  if not disciplinas_dados:
742
  return None, "Nenhuma disciplina válida encontrada no boletim."
743
 
744
+ # Separar disciplinas e determinar nível
745
  categorias = separar_disciplinas_por_categoria(disciplinas_dados)
746
+ nivel_texto = "Ensino Médio" if categorias['nivel'] == "medio" else "Ensino Fundamental"
 
747
 
748
+ # Gerar gráficos em paralelo
749
+ with ThreadPoolExecutor() as executor:
750
+ futures = {
751
+ 'basica': executor.submit(
752
+ plotar_evolucao_bimestres,
753
+ categorias['formacao_basica'],
754
+ temp_dir,
755
+ f"Evolução das Médias - Formação Geral Básica ({nivel_texto})",
756
+ 'evolucao_basica.png'
757
+ ),
758
+ 'diversificada': executor.submit(
759
+ plotar_evolucao_bimestres,
760
+ categorias['diversificada'],
761
+ temp_dir,
762
+ f"Evolução das Médias - Parte Diversificada ({nivel_texto})",
763
+ 'evolucao_diversificada.png'
764
+ ),
765
+ 'medias': executor.submit(
766
+ plotar_graficos_destacados,
767
+ disciplinas_dados,
768
+ temp_dir
769
+ )
770
+ }
771
+
772
+ grafico_basica = futures['basica'].result()
773
+ grafico_diversificada = futures['diversificada'].result()
774
+ grafico_medias = futures['medias'].result()
775
 
776
+ # Gerar relatório final
777
+ pdf_path = gerar_relatorio_pdf(
778
+ df,
779
+ disciplinas_dados,
780
+ grafico_basica,
781
+ grafico_diversificada,
782
+ grafico_medias
783
  )
784
 
785
+ # Preparar arquivo de retorno
786
+ output_path = os.path.join(temp_dir, 'relatorio_final.pdf')
 
 
 
 
 
 
 
 
 
787
  shutil.copy2(pdf_path, output_path)
 
788
  return output_path, "Relatório gerado com sucesso!"
789
 
 
 
 
790
  except Exception as e:
791
+ logger.exception("Erro durante o processamento")
792
  return None, f"Erro ao processar o boletim: {str(e)}"
 
 
 
 
 
 
 
 
793
 
794
  # Interface Gradio
795
  iface = gr.Interface(
 
805
  ],
806
  title="Análise de Boletim Escolar",
807
  description="Faça upload do boletim em PDF para gerar um relatório com análises e visualizações.",
808
+ allow_flagging="never",
809
+ theme=gr.themes.Default()
810
  )
811
 
812
  if __name__ == "__main__":
813
  iface.launch(
814
  server_name="0.0.0.0",
815
+ share=True,
816
+ enable_queue=True
817
  )