Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| import camelot | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| import numpy as np | |
| from fpdf import FPDF | |
| from fpdf.enums import XPos, YPos | |
| import tempfile | |
| import os | |
| import matplotlib | |
| import shutil | |
| import colorsys | |
| from datetime import datetime | |
| from concurrent.futures import ThreadPoolExecutor | |
| from typing import Dict, List, Tuple, Optional | |
| from io import BytesIO | |
| import logging | |
| from contextlib import contextmanager | |
| # Configurar matplotlib | |
| matplotlib.use('Agg') | |
| # Configurar logging | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # Configurações globais | |
| ESCALA_MAXIMA_NOTAS = 12 | |
| LIMITE_APROVACAO_NOTA = 5 | |
| LIMITE_APROVACAO_FREQ = 75 | |
| BIMESTRES = ['1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre'] | |
| CONCEITOS_VALIDOS = ['ES', 'EP', 'ET'] | |
| # Cores para os gráficos | |
| COR_APROVADO = '#2ECC71' # Verde suave | |
| COR_REPROVADO = '#E74C3C' # Vermelho suave | |
| # Definição das disciplinas de formação básica | |
| FORMACAO_BASICA = { | |
| 'fundamental': { | |
| 'LINGUA PORTUGUESA', | |
| 'MATEMATICA', | |
| 'HISTORIA', | |
| 'GEOGRAFIA', | |
| 'CIENCIAS', | |
| 'LINGUA ESTRANGEIRA INGLES', | |
| 'ARTE', | |
| 'EDUCACAO FISICA' | |
| }, | |
| 'medio': { | |
| 'LINGUA PORTUGUESA', | |
| 'MATEMATICA', | |
| 'HISTORIA', | |
| 'GEOGRAFIA', | |
| 'BIOLOGIA', | |
| 'FISICA', | |
| 'QUIMICA', | |
| 'INGLES', | |
| 'FILOSOFIA', | |
| 'SOCIOLOGIA', | |
| 'ARTE', | |
| 'EDUCACAO FISICA' | |
| } | |
| } | |
| # Context managers | |
| def temp_directory(): | |
| """Context manager para diretório temporário.""" | |
| temp_dir = tempfile.mkdtemp() | |
| try: | |
| yield temp_dir | |
| finally: | |
| if os.path.exists(temp_dir): | |
| shutil.rmtree(temp_dir) | |
| def temp_file(suffix=None): | |
| """Context manager para arquivo temporário.""" | |
| temp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) | |
| try: | |
| yield temp.name | |
| finally: | |
| if os.path.exists(temp.name): | |
| os.unlink(temp.name) | |
| class PDFReport(FPDF): | |
| """Classe personalizada para geração do relatório PDF.""" | |
| def __init__(self): | |
| super().__init__() | |
| self.set_auto_page_break(auto=True, margin=15) | |
| def header_footer(self): | |
| """Adiciona header e footer padrão nas páginas.""" | |
| self.set_y(-30) | |
| self.line(10, self.get_y(), 200, self.get_y()) | |
| self.ln(5) | |
| self.set_font('Helvetica', 'I', 8) | |
| self.cell(0, 10, | |
| 'Este relatório é uma análise automática e deve ser validado junto à secretaria da escola.', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C') | |
| def converter_nota(valor) -> Optional[float]: | |
| """Converte valor de nota para float, tratando casos especiais e conceitos.""" | |
| if pd.isna(valor) or valor == '-' or valor == 'N' or valor == '' or valor == 'None': | |
| return None | |
| if isinstance(valor, str): | |
| valor_limpo = valor.strip().upper() | |
| if valor_limpo in CONCEITOS_VALIDOS: | |
| conceitos_map = {'ET': 10, 'ES': 8, 'EP': 6} | |
| return conceitos_map.get(valor_limpo) | |
| try: | |
| return float(valor_limpo.replace(',', '.')) | |
| except: | |
| return None | |
| if isinstance(valor, (int, float)): | |
| return float(valor) | |
| return None | |
| def calcular_media_bimestres(notas: List[float]) -> float: | |
| """Calcula média considerando apenas bimestres com notas válidas.""" | |
| notas_validas = [nota for nota in notas if nota is not None] | |
| return sum(notas_validas) / len(notas_validas) if notas_validas else 0 | |
| def calcular_frequencia_media(frequencias: List[str]) -> float: | |
| """Calcula média de frequência considerando apenas bimestres cursados.""" | |
| freq_validas = [] | |
| for freq in frequencias: | |
| try: | |
| if isinstance(freq, str): | |
| freq = freq.strip().replace('%', '').replace(',', '.') | |
| if freq and freq != '-': | |
| valor = float(freq) | |
| if valor > 0: | |
| freq_validas.append(valor) | |
| except: | |
| continue | |
| return sum(freq_validas) / len(freq_validas) if freq_validas else 0 | |
| def extrair_tabelas_pdf(pdf_path: str) -> pd.DataFrame: | |
| """Extrai tabelas do PDF usando stream para o nome e lattice para notas.""" | |
| try: | |
| # Extrair nome do aluno usando stream | |
| tables_header = camelot.read_pdf( | |
| pdf_path, | |
| pages='1', | |
| flavor='stream', | |
| edge_tol=500 | |
| ) | |
| info_aluno = {} | |
| # Procurar nome do aluno | |
| for table in tables_header: | |
| df = table.df | |
| for i in range(len(df)): | |
| for j in range(len(df.columns)): | |
| texto = str(df.iloc[i,j]).strip() | |
| if 'Nome do Aluno' in texto: | |
| try: | |
| if j + 1 < len(df.columns): | |
| nome = str(df.iloc[i,j+1]).strip() | |
| elif i + 1 < len(df): | |
| nome = str(df.iloc[i+1,j]).strip() | |
| if nome and nome != 'Nome do Aluno:': | |
| info_aluno['nome'] = nome | |
| break | |
| except: | |
| continue | |
| # Extrair tabela de notas usando lattice | |
| tables_notas = camelot.read_pdf( | |
| pdf_path, | |
| pages='all', | |
| flavor='lattice' | |
| ) | |
| # Encontrar tabela de notas | |
| df_notas = None | |
| max_rows = 0 | |
| for table in tables_notas: | |
| df_temp = table.df | |
| if len(df_temp) > max_rows and 'Disciplina' in str(df_temp.iloc[0,0]): | |
| max_rows = len(df_temp) | |
| df_notas = df_temp.copy() | |
| df_notas = df_notas.rename(columns={ | |
| 0: 'Disciplina', | |
| 1: 'Nota B1', 2: 'Freq B1', 3: '%Freq B1', 4: 'AC B1', | |
| 5: 'Nota B2', 6: 'Freq B2', 7: '%Freq B2', 8: 'AC B2', | |
| 9: 'Nota B3', 10: 'Freq B3', 11: '%Freq B3', 12: 'AC B3', | |
| 13: 'Nota B4', 14: 'Freq B4', 15: '%Freq B4', 16: 'AC B4', | |
| 17: 'CF', 18: 'Nota Final', 19: 'Freq Final', 20: 'AC Final' | |
| }) | |
| if df_notas is None: | |
| raise ValueError("Tabela de notas não encontrada") | |
| # Adicionar informações do aluno ao DataFrame | |
| df_notas.attrs['nome'] = info_aluno.get('nome', 'Nome não encontrado') | |
| return df_notas | |
| except Exception as e: | |
| logger.error(f"Erro na extração das tabelas: {str(e)}") | |
| raise | |
| def detectar_nivel_ensino(disciplinas: List[str]) -> str: | |
| """Detecta se é ensino fundamental ou médio baseado nas disciplinas.""" | |
| disciplinas_set = set(disciplinas) | |
| disciplinas_exclusivas_medio = {'BIOLOGIA', 'FISICA', 'QUIMICA', 'FILOSOFIA', 'SOCIOLOGIA'} | |
| return 'medio' if any(d in disciplinas_set for d in disciplinas_exclusivas_medio) else 'fundamental' | |
| def obter_disciplinas_validas(df: pd.DataFrame) -> List[Dict]: | |
| """Identifica disciplinas válidas no boletim com seus dados.""" | |
| colunas_notas = ['Nota B1', 'Nota B2', 'Nota B3', 'Nota B4'] | |
| colunas_freq = ['%Freq B1', '%Freq B2', '%Freq B3', '%Freq B4'] | |
| disciplinas_dados = [] | |
| for _, row in df.iterrows(): | |
| disciplina = row['Disciplina'] | |
| if pd.isna(disciplina) or disciplina == '': | |
| continue | |
| notas = [] | |
| freqs = [] | |
| bimestres_cursados = [] | |
| for i, (col_nota, col_freq) in enumerate(zip(colunas_notas, colunas_freq), 1): | |
| nota = converter_nota(row[col_nota]) | |
| freq = row[col_freq] if col_freq in row else None | |
| if nota is not None or (freq and freq != '-'): | |
| bimestres_cursados.append(i) | |
| notas.append(nota if nota is not None else 0) | |
| freqs.append(freq) | |
| else: | |
| notas.append(None) | |
| freqs.append(None) | |
| if bimestres_cursados: | |
| media_notas = calcular_media_bimestres(notas) | |
| media_freq = calcular_frequencia_media(freqs) | |
| disciplinas_dados.append({ | |
| 'disciplina': disciplina, | |
| 'notas': notas, | |
| 'frequencias': freqs, | |
| 'media_notas': media_notas, | |
| 'media_freq': media_freq, | |
| 'bimestres_cursados': bimestres_cursados | |
| }) | |
| return disciplinas_dados | |
| def separar_disciplinas_por_categoria(disciplinas_dados: List[Dict]) -> Dict: | |
| """Separa as disciplinas em formação básica e diversificada.""" | |
| disciplinas = [d['disciplina'] for d in disciplinas_dados] | |
| nivel = detectar_nivel_ensino(disciplinas) | |
| formacao_basica = [] | |
| diversificada = [] | |
| for disc_data in disciplinas_dados: | |
| if disc_data['disciplina'] in FORMACAO_BASICA[nivel]: | |
| formacao_basica.append(disc_data) | |
| else: | |
| diversificada.append(disc_data) | |
| return { | |
| 'nivel': nivel, | |
| 'formacao_basica': formacao_basica, | |
| 'diversificada': diversificada | |
| } | |
| def gerar_paleta_cores(n_cores: int) -> List[str]: | |
| """Gera uma paleta de cores harmoniosa.""" | |
| cores_formacao_basica = [ | |
| '#2E86C1', # Azul royal | |
| '#2ECC71', # Verde esmeralda | |
| '#E74C3C', # Vermelho coral | |
| '#F1C40F', # Amarelo ouro | |
| '#8E44AD', # Roxo médio | |
| '#E67E22', # Laranja escuro | |
| '#16A085', # Verde-água | |
| '#D35400' # Laranja queimado | |
| ] | |
| if n_cores <= len(cores_formacao_basica): | |
| return cores_formacao_basica[:n_cores] | |
| # Gerar cores adicionais se necessário | |
| HSV_tuples = [(x/n_cores, 0.8, 0.9) for x in range(n_cores)] | |
| return ['#%02x%02x%02x' % tuple(int(x*255) for x in colorsys.hsv_to_rgb(*hsv)) | |
| for hsv in HSV_tuples] | |
| def plotar_evolucao_bimestres(disciplinas_dados: List[Dict], temp_dir: str, | |
| titulo: Optional[str] = None, | |
| nome_arquivo: Optional[str] = None) -> str: | |
| """Plota gráfico de evolução das notas com visual aprimorado.""" | |
| n_disciplinas = len(disciplinas_dados) | |
| if n_disciplinas == 0: | |
| raise ValueError("Nenhuma disciplina válida encontrada para plotar.") | |
| # Configuração do estilo | |
| plt.style.use('seaborn') | |
| fig, ax = plt.subplots(figsize=(11.69, 8.27)) | |
| # Configurar grid mais suave | |
| ax.grid(True, linestyle='--', alpha=0.2, color='gray') | |
| ax.set_axisbelow(True) | |
| cores = gerar_paleta_cores(n_disciplinas) | |
| marcadores = ['o', 's', '^', 'D', 'v', '<', '>', 'p'] | |
| estilos_linha = ['-', '--', '-.', ':'] | |
| # Deslocamento sutil para evitar sobreposição | |
| deslocamentos = np.linspace(-0.02, 0.02, n_disciplinas) | |
| anotacoes_usadas = {} | |
| for idx, disc_data in enumerate(disciplinas_dados): | |
| notas = pd.Series(disc_data['notas']) | |
| bimestres_cursados = disc_data['bimestres_cursados'] | |
| desloc = deslocamentos[idx] | |
| if bimestres_cursados: | |
| notas_validas = [nota for i, nota in enumerate(notas, 1) | |
| if i in bimestres_cursados and nota is not None] | |
| bimestres = [bim for bim in bimestres_cursados | |
| if notas[bim-1] is not None] | |
| bimestres_deslocados = [bim + desloc for bim in bimestres] | |
| if notas_validas: | |
| # Linha com sombreamento | |
| plt.plot(bimestres_deslocados, notas_validas, | |
| color=cores[idx % len(cores)], | |
| marker=marcadores[idx % len(marcadores)], | |
| markersize=8, | |
| linewidth=2.5, | |
| label=disc_data['disciplina'], | |
| linestyle=estilos_linha[idx % len(estilos_linha)], | |
| alpha=0.8, | |
| zorder=3) | |
| # Área sombreada sob a linha | |
| plt.fill_between(bimestres_deslocados, 0, notas_validas, | |
| color=cores[idx % len(cores)], | |
| alpha=0.1) | |
| # Anotações elegantes | |
| for bim, nota in zip(bimestres_deslocados, notas_validas): | |
| if nota is not None: | |
| y_offset = 10 | |
| while any(abs(y - (nota + y_offset/20)) < 0.4 | |
| for y, _ in anotacoes_usadas.get(bim, [])): | |
| y_offset += 5 | |
| plt.annotate(f"{nota:.1f}", | |
| (bim, nota), | |
| xytext=(0, y_offset), | |
| textcoords="offset points", | |
| ha='center', | |
| va='bottom', | |
| fontsize=9, | |
| bbox=dict( | |
| facecolor='white', | |
| edgecolor=cores[idx % len(cores)], | |
| alpha=0.8, | |
| pad=2, | |
| boxstyle='round,pad=0.5' | |
| )) | |
| if bim not in anotacoes_usadas: | |
| anotacoes_usadas[bim] = [] | |
| anotacoes_usadas[bim].append((nota + y_offset/20, nota)) | |
| # Estilização | |
| titulo_grafico = titulo or 'Evolução das Médias por Disciplina' | |
| plt.title(titulo_grafico, pad=20, fontsize=14, fontweight='bold') | |
| plt.xlabel('Bimestres', fontsize=12, labelpad=10) | |
| plt.ylabel('Notas', fontsize=12, labelpad=10) | |
| # Remover bordas desnecessárias | |
| ax.spines['top'].set_visible(False) | |
| ax.spines['right'].set_visible(False) | |
| plt.xticks([1, 2, 3, 4], ['1º Bim', '2º Bim', '3º Bim', '4º Bim'], | |
| fontsize=10) | |
| plt.ylim(0, ESCALA_MAXIMA_NOTAS) | |
| # Linha de aprovação estilizada | |
| plt.axhline(y=LIMITE_APROVACAO_NOTA, color=COR_REPROVADO, | |
| linestyle='--', alpha=0.3, linewidth=2) | |
| plt.text(0.02, LIMITE_APROVACAO_NOTA + 0.1, | |
| 'Média mínima para aprovação', | |
| transform=plt.gca().get_yaxis_transform(), | |
| color=COR_REPROVADO, alpha=0.7) | |
| # Legenda estilizada | |
| if n_disciplinas > 8: | |
| plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left', | |
| fontsize=9, framealpha=0.8, | |
| fancybox=True, shadow=True, | |
| ncol=max(1, n_disciplinas // 12)) | |
| else: | |
| plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left', | |
| fontsize=10, framealpha=0.8, | |
| fancybox=True, shadow=True) | |
| plt.tight_layout() | |
| # Salvar com alta qualidade | |
| nome_arquivo = nome_arquivo or 'evolucao_notas.png' | |
| plot_path = os.path.join(temp_dir, nome_arquivo) | |
| plt.savefig(plot_path, bbox_inches='tight', dpi=300, | |
| facecolor='white', edgecolor='none') | |
| plt.close() | |
| return plot_path | |
| def plotar_graficos_destacados(disciplinas_dados: List[Dict], temp_dir: str) -> str: | |
| """Plota gráficos de médias e frequências com visual aprimorado.""" | |
| n_disciplinas = len(disciplinas_dados) | |
| if not n_disciplinas: | |
| raise ValueError("Nenhuma disciplina válida encontrada no boletim.") | |
| # Configuração do estilo | |
| plt.style.use('seaborn') | |
| fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10), | |
| height_ratios=[1, 1]) | |
| plt.subplots_adjust(hspace=0.4) | |
| disciplinas = [d['disciplina'] for d in disciplinas_dados] | |
| medias_notas = [d['media_notas'] for d in disciplinas_dados] | |
| medias_freq = [d['media_freq'] for d in disciplinas_dados] | |
| # Definir cores baseadas nos limites | |
| cores_notas = [COR_REPROVADO if media < LIMITE_APROVACAO_NOTA | |
| else COR_APROVADO for media in medias_notas] | |
| cores_freq = [COR_REPROVADO if media < LIMITE_APROVACAO_FREQ | |
| else COR_APROVADO for media in medias_freq] | |
| # Calcular médias globais | |
| media_global = np.mean(medias_notas) | |
| freq_global = np.mean(medias_freq) | |
| # Configurações comuns para os eixos | |
| for ax in [ax1, ax2]: | |
| ax.grid(True, axis='y', alpha=0.2, linestyle='--') | |
| ax.set_axisbelow(True) | |
| ax.spines['top'].set_visible(False) | |
| ax.spines['right'].set_visible(False) | |
| # Gráfico de notas | |
| barras_notas = ax1.bar(disciplinas, medias_notas, color=cores_notas) | |
| ax1.set_title('Média de Notas por Disciplina', | |
| pad=20, fontsize=14, fontweight='bold') | |
| ax1.set_ylim(0, ESCALA_MAXIMA_NOTAS) | |
| ax1.set_xticklabels(disciplinas, rotation=45, | |
| ha='right', va='top', fontsize=10) | |
| ax1.set_ylabel('Notas', fontsize=12, labelpad=10) | |
| # Linha de média mínima | |
| ax1.axhline(y=LIMITE_APROVACAO_NOTA, | |
| color=COR_REPROVADO, | |
| linestyle='--', | |
| alpha=0.3, | |
| linewidth=2) | |
| ax1.text(0.02, LIMITE_APROVACAO_NOTA + 0.1, | |
| 'Média mínima (5,0)', | |
| transform=ax1.get_yaxis_transform(), | |
| color=COR_REPROVADO, | |
| alpha=0.7, | |
| fontsize=10) | |
| # Valores nas barras de notas | |
| for barra in barras_notas: | |
| altura = barra.get_height() | |
| cor_texto = 'white' if altura >= LIMITE_APROVACAO_NOTA else 'black' | |
| ax1.text(barra.get_x() + barra.get_width()/2., altura, | |
| f'{altura:.1f}', | |
| ha='center', | |
| va='bottom', | |
| fontsize=10, | |
| bbox=dict( | |
| facecolor='white', | |
| edgecolor='none', | |
| alpha=0.7, | |
| pad=1 | |
| ), | |
| color=cor_texto if altura >= 8 else 'black') | |
| # Gráfico de frequências | |
| barras_freq = ax2.bar(disciplinas, medias_freq, color=cores_freq) | |
| ax2.set_title('Frequência Média por Disciplina', | |
| pad=20, fontsize=14, fontweight='bold') | |
| ax2.set_ylim(0, 110) | |
| ax2.set_xticklabels(disciplinas, rotation=45, | |
| ha='right', va='top', fontsize=10) | |
| ax2.set_ylabel('Frequência (%)', fontsize=12, labelpad=10) | |
| # Linha de frequência mínima | |
| ax2.axhline(y=LIMITE_APROVACAO_FREQ, | |
| color=COR_REPROVADO, | |
| linestyle='--', | |
| alpha=0.3, | |
| linewidth=2) | |
| ax2.text(0.02, LIMITE_APROVACAO_FREQ + 1, | |
| 'Frequência mínima (75%)', | |
| transform=ax2.get_yaxis_transform(), | |
| color=COR_REPROVADO, | |
| alpha=0.7, | |
| fontsize=10) | |
| # Valores nas barras de frequência | |
| for barra in barras_freq: | |
| altura = barra.get_height() | |
| cor_texto = 'white' if altura >= LIMITE_APROVACAO_FREQ else 'black' | |
| ax2.text(barra.get_x() + barra.get_width()/2., altura, | |
| f'{altura:.1f}%', | |
| ha='center', | |
| va='bottom', | |
| fontsize=10, | |
| bbox=dict( | |
| facecolor='white', | |
| edgecolor='none', | |
| alpha=0.7, | |
| pad=1 | |
| ), | |
| color=cor_texto if altura >= 90 else 'black') | |
| # Título global com estilo | |
| plt.suptitle( | |
| f'Desempenho Geral\nMédia Global: {media_global:.1f} | Frequência Global: {freq_global:.1f}%', | |
| y=0.98, | |
| fontsize=16, | |
| fontweight='bold', | |
| bbox=dict( | |
| facecolor='white', | |
| edgecolor='none', | |
| alpha=0.8, | |
| pad=5, | |
| boxstyle='round,pad=0.5' | |
| ) | |
| ) | |
| # Aviso de reprovação estilizado | |
| if freq_global < LIMITE_APROVACAO_FREQ: | |
| plt.figtext(0.5, 0.02, | |
| "Atenção: Risco de Reprovação por Baixa Frequência", | |
| ha="center", | |
| fontsize=12, | |
| color=COR_REPROVADO, | |
| weight='bold', | |
| bbox=dict( | |
| facecolor='#FFEBEE', | |
| edgecolor=COR_REPROVADO, | |
| alpha=0.9, | |
| pad=5, | |
| boxstyle='round,pad=0.5' | |
| )) | |
| plt.tight_layout() | |
| # Salvar com alta qualidade | |
| plot_path = os.path.join(temp_dir, 'medias_frequencias.png') | |
| plt.savefig(plot_path, | |
| bbox_inches='tight', | |
| dpi=300, | |
| facecolor='white', | |
| edgecolor='none') | |
| plt.close() | |
| return plot_path | |
| def gerar_relatorio_pdf(df: pd.DataFrame, disciplinas_dados: List[Dict], | |
| grafico_basica: str, grafico_diversificada: str, | |
| grafico_medias: str) -> str: | |
| """Gera relatório PDF com análise completa.""" | |
| pdf = PDFReport() | |
| pdf.set_auto_page_break(auto=True, margin=15) | |
| # Primeira página - Informações e Formação Básica | |
| pdf.add_page() | |
| pdf.set_font('Helvetica', 'B', 18) | |
| pdf.cell(0, 10, 'Relatório de Desempenho Escolar', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C') | |
| pdf.ln(15) | |
| # Informações do aluno | |
| pdf.set_font('Helvetica', 'B', 12) | |
| pdf.cell(0, 10, 'Informações do Aluno', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') | |
| pdf.line(10, pdf.get_y(), 200, pdf.get_y()) | |
| pdf.ln(5) | |
| # Nome do aluno | |
| if hasattr(df, 'attrs') and 'nome' in df.attrs: | |
| pdf.set_font('Helvetica', 'B', 11) | |
| pdf.cell(30, 7, 'Nome:', 0, 0) | |
| pdf.set_font('Helvetica', '', 11) | |
| pdf.cell(0, 7, df.attrs['nome'], | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT) | |
| pdf.ln(10) | |
| # Data do relatório | |
| data_atual = datetime.now().strftime('%d/%m/%Y') | |
| pdf.set_font('Helvetica', 'I', 10) | |
| pdf.cell(0, 5, f'Data de geração: {data_atual}', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R') | |
| pdf.ln(15) | |
| # Gráficos de evolução | |
| pdf.set_font('Helvetica', 'B', 14) | |
| pdf.cell(0, 10, 'Evolução das Notas - Formação Geral Básica', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') | |
| pdf.line(10, pdf.get_y(), 200, pdf.get_y()) | |
| pdf.ln(10) | |
| pdf.image(grafico_basica, x=10, w=190) | |
| # Segunda página - Parte Diversificada | |
| pdf.add_page() | |
| pdf.set_font('Helvetica', 'B', 14) | |
| pdf.cell(0, 10, 'Evolução das Notas - Parte Diversificada', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') | |
| pdf.line(10, pdf.get_y(), 200, pdf.get_y()) | |
| pdf.ln(10) | |
| pdf.image(grafico_diversificada, x=10, w=190) | |
| # Terceira página - Médias e Frequências | |
| pdf.add_page() | |
| pdf.set_font('Helvetica', 'B', 14) | |
| pdf.cell(0, 10, 'Análise de Médias e Frequências', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') | |
| pdf.line(10, pdf.get_y(), 200, pdf.get_y()) | |
| pdf.ln(10) | |
| pdf.image(grafico_medias, x=10, w=190) | |
| # Quarta página - Análise Detalhada | |
| pdf.add_page() | |
| pdf.set_font('Helvetica', 'B', 14) | |
| pdf.cell(0, 10, 'Análise Detalhada', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') | |
| pdf.line(10, pdf.get_y(), 200, pdf.get_y()) | |
| pdf.ln(10) | |
| # Resumo geral | |
| medias_notas = [d['media_notas'] for d in disciplinas_dados] | |
| medias_freq = [d['media_freq'] for d in disciplinas_dados] | |
| media_global = np.mean(medias_notas) | |
| freq_global = np.mean(medias_freq) | |
| pdf.set_font('Helvetica', 'B', 12) | |
| pdf.cell(0, 7, 'Resumo Geral:', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') | |
| pdf.ln(5) | |
| pdf.set_font('Helvetica', '', 11) | |
| pdf.cell(0, 7, f'Média Global: {media_global:.1f}', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') | |
| pdf.cell(0, 7, f'Frequência Global: {freq_global:.1f}%', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') | |
| pdf.ln(10) | |
| # Pontos de atenção | |
| pdf.set_font('Helvetica', 'B', 12) | |
| pdf.cell(0, 10, 'Pontos de Atenção:', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') | |
| pdf.ln(5) | |
| pdf.set_font('Helvetica', '', 10) | |
| disciplinas_risco = [] | |
| for disc_data in disciplinas_dados: | |
| avisos = [] | |
| if disc_data['media_notas'] < LIMITE_APROVACAO_NOTA: | |
| avisos.append( | |
| f"Média de notas abaixo de {LIMITE_APROVACAO_NOTA} ({disc_data['media_notas']:.1f})" | |
| ) | |
| if disc_data['media_freq'] < LIMITE_APROVACAO_FREQ: | |
| avisos.append( | |
| f"Frequência abaixo de {LIMITE_APROVACAO_FREQ}% ({disc_data['media_freq']:.1f}%)" | |
| ) | |
| if avisos: | |
| disciplinas_risco.append((disc_data['disciplina'], avisos)) | |
| if disciplinas_risco: | |
| for disc, avisos in disciplinas_risco: | |
| pdf.set_font('Helvetica', 'B', 10) | |
| pdf.cell(0, 7, f'- {disc}:', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') | |
| pdf.set_font('Helvetica', '', 10) | |
| for aviso in avisos: | |
| pdf.cell(10) # Indentação | |
| pdf.cell(0, 7, f'- {aviso}', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') | |
| else: | |
| pdf.cell(0, 7, 'Nenhum problema identificado.', | |
| 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L') | |
| pdf.header_footer() | |
| # Salvar PDF | |
| with temp_file(suffix='.pdf') as temp_pdf: | |
| pdf.output(temp_pdf) | |
| return temp_pdf | |
| def processar_boletim(file) -> Tuple[Optional[str], str]: | |
| """Função principal que processa o boletim e gera o relatório.""" | |
| try: | |
| if file is None: | |
| return None, "Nenhum arquivo foi fornecido." | |
| with temp_directory() as temp_dir: | |
| # Salvar arquivo temporário | |
| temp_pdf = os.path.join(temp_dir, 'boletim.pdf') | |
| with open(temp_pdf, 'wb') as f: | |
| f.write(file) | |
| if os.path.getsize(temp_pdf) == 0: | |
| return None, "O arquivo está vazio." | |
| # Extrair e processar dados | |
| df = extrair_tabelas_pdf(temp_pdf) | |
| if df is None or df.empty: | |
| return None, "Não foi possível extrair dados do PDF." | |
| disciplinas_dados = obter_disciplinas_validas(df) | |
| if not disciplinas_dados: | |
| return None, "Nenhuma disciplina válida encontrada no boletim." | |
| # Separar disciplinas e determinar nível | |
| categorias = separar_disciplinas_por_categoria(disciplinas_dados) | |
| nivel_texto = "Ensino Médio" if categorias['nivel'] == "medio" else "Ensino Fundamental" | |
| # Gerar gráficos em paralelo | |
| with ThreadPoolExecutor() as executor: | |
| futures = { | |
| 'basica': executor.submit( | |
| plotar_evolucao_bimestres, | |
| categorias['formacao_basica'], | |
| temp_dir, | |
| f"Evolução das Médias - Formação Geral Básica ({nivel_texto})", | |
| 'evolucao_basica.png' | |
| ), | |
| 'diversificada': executor.submit( | |
| plotar_evolucao_bimestres, | |
| categorias['diversificada'], | |
| temp_dir, | |
| f"Evolução das Médias - Parte Diversificada ({nivel_texto})", | |
| 'evolucao_diversificada.png' | |
| ), | |
| 'medias': executor.submit( | |
| plotar_graficos_destacados, | |
| disciplinas_dados, | |
| temp_dir | |
| ) | |
| } | |
| grafico_basica = futures['basica'].result() | |
| grafico_diversificada = futures['diversificada'].result() | |
| grafico_medias = futures['medias'].result() | |
| # Gerar relatório final | |
| pdf_path = gerar_relatorio_pdf( | |
| df, | |
| disciplinas_dados, | |
| grafico_basica, | |
| grafico_diversificada, | |
| grafico_medias | |
| ) | |
| # Preparar arquivo de retorno | |
| output_path = os.path.join(temp_dir, 'relatorio_final.pdf') | |
| shutil.copy2(pdf_path, output_path) | |
| return output_path, "Relatório gerado com sucesso!" | |
| except Exception as e: | |
| logger.exception("Erro durante o processamento") | |
| return None, f"Erro ao processar o boletim: {str(e)}" | |
| # Interface Gradio | |
| iface = gr.Interface( | |
| fn=processar_boletim, | |
| inputs=gr.File( | |
| label="Upload do Boletim (PDF)", | |
| type="binary", | |
| file_types=[".pdf"] | |
| ), | |
| outputs=[ | |
| gr.File(label="Relatório (PDF)"), | |
| gr.Textbox(label="Status") | |
| ], | |
| title="Análise de Boletim Escolar", | |
| description="Faça upload do boletim em PDF para gerar um relatório com análises e visualizações.", | |
| allow_flagging="never", | |
| theme=gr.themes.Default() | |
| ) | |
| if __name__ == "__main__": | |
| iface.launch( | |
| server_name="0.0.0.0", | |
| share=True, | |
| enable_queue=True | |
| ) |