diff --git a/Principal.py b/Principal.py index 498b56e..f6b3f86 100644 --- a/Principal.py +++ b/Principal.py @@ -7,13 +7,25 @@ import plotly.express as px import re import unicodedata +import difflib +import hashlib as _hashlib from collections import Counter, defaultdict from streamlit_agraph import Node, Edge import time + +def _chave_widget(prefixo: str, texto: str, indice: int = 0) -> str: + """ + Gera chave determinística e segura para widgets Streamlit. + Usa MD5 (não criptográfico, apenas para unicidade de chave). + Inclui índice para evitar colisão quando mesmo texto aparece N vezes. + """ + digest = _hashlib.md5(f"{texto}_{indice}".encode('utf-8')).hexdigest()[:12] + return f"{prefixo}_{digest}" + from app_config import get_gemini_api_key from backend import ( - conectar_neo4j, + conectar_neo4j, extrair_subgrafo_neo4j, gerar_orbita_local, navegar_para, @@ -31,7 +43,9 @@ carregar_catalogo_tcc_frontend, plotar_mapa_tematico, calcular_similares_rede, - plotar_grafico_3d_sna + plotar_grafico_3d_sna, + STOPWORDS_ACADEMICAS, + _normalizar_nivel, ) # --- CONFIGURAÇÃO DA PÁGINA --- @@ -118,10 +132,11 @@ st.session_state['dados_completos'] = dados_combinados st.session_state['programas_selecionados_lista'] = programas_selecionados st.session_state['tccs_selecionados_lista'] = tccs_selecionados - + nomes_combinados = programas_selecionados + tccs_selecionados st.session_state['nome_programa'] = f"{len(nomes_combinados)} Origem(ns) Selecionada(s): {', '.join(nomes_combinados)}" st.session_state['macrotemas_computados'] = True + st.session_state['sna_global_stale'] = True # força recomputação do SNA para a nova base st.session_state['recarregar'] = False st.rerun() else: @@ -287,8 +302,8 @@ c1, c2, c3 = st.columns(3) c1.metric("📄 Documentos Totais", len(dados_completos)) -c2.metric("🎓 Teses (Doutorado)", len([d for d in dados_completos if "Tese" in d.get('nivel_academico', '')])) -c3.metric("📜 Dissertações", len([d for d in dados_completos if "Disserta" in d.get('nivel_academico', '')])) +c2.metric("🎓 Teses (Doutorado)", len([d for d in dados_completos if _normalizar_nivel(d.get('nivel_academico')) == 'Teses'])) +c3.metric("📜 Dissertações", len([d for d in dados_completos if _normalizar_nivel(d.get('nivel_academico')) == 'Dissertações'])) c4, c5, c6, c7 = st.columns(4) c4.metric("✍️ Autores Únicos", len(autores_set)) @@ -298,6 +313,8 @@ # --- APRESENTAÇÃO DO PERFIL DINÂMICO --- +nomes_ppgs = list(set([d.get('programa_origem', 'Programa Desconhecido') for d in dados_completos if d.get('programa_origem')])) + if api_key_app: # Extrai até 25 documentos espaçados uniformemente para criar uma amostra representativa amostra_docs = [] @@ -306,8 +323,6 @@ d = dados_completos[i] amostra_docs.append(f"- {d.get('titulo', '')} | {', '.join(d.get('palavras_chave', []))}") if len(amostra_docs) >= 25: break - - nomes_ppgs = list(set([d.get('programa_origem', 'Programa Desconhecido') for d in dados_completos if d.get('programa_origem')])) else: st.warning("🔑 Chave da API do Gemini não configurada em variáveis de ambiente ou secrets. O perfil dinâmico está desativado.") st.markdown("
", unsafe_allow_html=True) @@ -319,8 +334,7 @@ st.subheader("📊 Comparativo entre PPGs") comparativo_data = [] - - from collections import defaultdict + dados_por_ppg = defaultdict(list) for d in dados_completos: ppg = d.get('programa_origem', 'Desconhecido') @@ -330,8 +344,8 @@ comparativo_data.append({ "PPG": ppg, "📄 Documentos Totais": len(docs_ppg), - "🎓 Teses (Doutorado)": len([d for d in docs_ppg if "Tese" in d.get('nivel_academico', '')]), - "📜 Dissertações": len([d for d in docs_ppg if "Disserta" in d.get('nivel_academico', '')]), + "🎓 Teses (Doutorado)": len([d for d in docs_ppg if _normalizar_nivel(d.get('nivel_academico')) == 'Teses']), + "📜 Dissertações": len([d for d in docs_ppg if _normalizar_nivel(d.get('nivel_academico')) == 'Dissertações']), "✍️ Autores Únicos": len(set([a for d in docs_ppg for a in d.get('autores', [])])), "🏫 Orientadores": len(set([d.get('orientador') for d in docs_ppg if d.get('orientador')])), "🤝 Co-orientadores": len(set([co for d in docs_ppg for co in d.get('co_orientadores', [])])), @@ -348,7 +362,7 @@ color="PPG", facet_col="Métrica", facet_col_wrap=4, - template="plotly_dark", + template="streamlit", text="Quantidade", height=650 ) @@ -363,8 +377,6 @@ # --- APRESENTAÇÃO DO PERFIL E DADOS OFICIAIS --- st.markdown("#### 🏛️ Ficha Técnica e Perfil Institucional") -nomes_ppgs = list(set([d.get('programa_origem', 'Programa Desconhecido') for d in dados_completos if d.get('programa_origem')])) - # Puxa o dicionário completo da CAPES da memória (Instantâneo) catalogo_capes = carregar_catalogo_capes_ufsc() @@ -378,8 +390,6 @@ # 3. Inteligência de Strings (Fuzzy Matching) para lidar com variações da UFSC/CAPES if not dados_capes: - import difflib # Biblioteca nativa do Python para cálculo de similaridade - chaves_disponiveis = list(catalogo_capes.keys()) # Procura a chave mais parecida com pelo menos 65% de similaridade estrutural melhores_matches = difflib.get_close_matches(nome_norm_busca, chaves_disponiveis, n=1, cutoff=0.65) @@ -418,23 +428,29 @@ # 4. Descritivo Dinâmico da IA (A Alma do Programa) if api_key_app: - amostra_docs = [] - salto = max(1, len(dados_completos) // 25) - for i in range(0, len(dados_completos), salto): - d = dados_completos[i] - amostra_docs.append(f"- {d.get('titulo', '')} | {', '.join(d.get('palavras_chave', []))}") - if len(amostra_docs) >= 25: break - with st.spinner("A IA está analisando a amostra de documentos para sintetizar o perfil epistemológico..."): - descritivo_dinamico = gerar_descritivo_sessao(tuple(nomes_ppgs), "\n".join(amostra_docs), api_key_app) - st.info(f"**Síntese de Pesquisa do PPG:** {descritivo_dinamico}") + try: + descritivo_dinamico = gerar_descritivo_sessao(tuple(nomes_ppgs), "\n".join(amostra_docs), api_key_app) + st.info(f"**Síntese de Pesquisa do PPG:** {descritivo_dinamico}") + except Exception as _e_gemini: + st.warning(f"⚠️ Não foi possível gerar a síntese dinâmica no momento (a IA pode estar sobrecarregada). Tente novamente em instantes. (Detalhe: {_e_gemini})") else: st.warning("🔑 Chave da API do Gemini não configurada em variáveis de ambiente ou secrets.") st.markdown("---") # IMPORTANTE: Movemos o cálculo SNA para cá para alimentar a nova Super-Tabela -sna_global = calcular_sna_global(dados_completos) +# O flag sna_global_stale garante que, em hits de cache do @st.cache_data, os dados +# com métricas SNA injetadas sejam recuperados do cache e re-aplicados ao session_state, +# evitando que seções dependentes fiquem invisíveis após recarregamentos sem recomputação. +if st.session_state.get('sna_global_stale', True): + df_met_globais, dados_atualizados = calcular_sna_global(st.session_state['dados_completos']) + st.session_state['df_metricas_globais'] = df_met_globais + st.session_state['dados_completos'] = dados_atualizados # Recupera os dados do cache com as colunas SNA injetadas + st.session_state['sna_global_stale'] = False + +sna_global = st.session_state['df_metricas_globais'] +dados_completos = st.session_state['dados_completos'] # ========================================================================= # 🏆 DESTAQUES DO ECOSSISTEMA (RANKINGS DE REDE) @@ -487,9 +503,10 @@ def get_top_sna(entidades, metrica): top_coori_close = get_top_sna(coorientadores_set, 'Closeness') top_coori_bet = get_top_sna(coorientadores_set, 'Betweenness') -# SNA Teses e Dissertações -teses_titulos = [d.get('titulo') for d in dados_completos if 'Tese' in d.get('nivel_academico', '')] -dissertacoes_titulos = [d.get('titulo') for d in dados_completos if 'Disserta' in d.get('nivel_academico', '')] +# SNA Teses e Dissertações — usa _normalizar_nivel para correspondência robusta +# (case-insensitive, suporta variações como "Tese", "tese", "Doutorado", etc.) +teses_titulos = set(d.get('titulo') for d in dados_completos if _normalizar_nivel(d.get('nivel_academico')) == 'Teses') +dissertacoes_titulos = set(d.get('titulo') for d in dados_completos if _normalizar_nivel(d.get('nivel_academico')) == 'Dissertações') top_tese_close = get_top_sna(teses_titulos, 'Closeness') top_tese_bet = get_top_sna(teses_titulos, 'Betweenness') @@ -515,20 +532,20 @@ def get_top_sna(entidades, metrica): c1_0, c2_0 = st.columns(2) with c1_0: fig_ori = px.bar(df_ori, y='Orientador', x='Orientações', orientation='h', title='Top 10 Orientadores', text='Orientações') - fig_ori.update_layout(showlegend=False, xaxis_title="", yaxis_title="", template="plotly_dark", height=350, margin=dict(l=0, r=0, t=40, b=0)) + fig_ori.update_layout(showlegend=False, xaxis_title="", yaxis_title="", template="streamlit", height=350, margin=dict(l=0, r=0, t=40, b=0)) st.plotly_chart(fig_ori, width='stretch') fig_coori = px.bar(df_coori, y='Coorientador', x='Coorientações', orientation='h', title='Top 10 Coorientadores', text='Coorientações') - fig_coori.update_layout(showlegend=False, xaxis_title="", yaxis_title="", template="plotly_dark", height=350, margin=dict(l=0, r=0, t=40, b=0)) + fig_coori.update_layout(showlegend=False, xaxis_title="", yaxis_title="", template="streamlit", height=350, margin=dict(l=0, r=0, t=40, b=0)) st.plotly_chart(fig_coori, width='stretch') with c2_0: fig_kw = px.bar(df_kw, y='Palavra-chave', x='Ocorrências', orientation='h', title='Top 10 Palavras-chave', text='Ocorrências') - fig_kw.update_layout(showlegend=False, xaxis_title="", yaxis_title="", template="plotly_dark", height=350, margin=dict(l=0, r=0, t=40, b=0)) + fig_kw.update_layout(showlegend=False, xaxis_title="", yaxis_title="", template="streamlit", height=350, margin=dict(l=0, r=0, t=40, b=0)) st.plotly_chart(fig_kw, width='stretch') fig_mt = px.bar(df_mt, y='Macrotema', x='Documentos', orientation='h', title='Top 10 Macrotemas', text='Documentos') - fig_mt.update_layout(showlegend=False, xaxis_title="", yaxis_title="", template="plotly_dark", height=350, margin=dict(l=0, r=0, t=40, b=0)) + fig_mt.update_layout(showlegend=False, xaxis_title="", yaxis_title="", template="streamlit", height=350, margin=dict(l=0, r=0, t=40, b=0)) st.plotly_chart(fig_mt, width='stretch') with tab_dest1: @@ -732,9 +749,9 @@ def get_top_sna(entidades, metrica): if orientadores or co_orientadores: st.write("**👨‍🏫 Orientadores e Co-orientadores:**") for ori in sorted(list(orientadores)): - st.button(f"🏫 Orientador: {ori}", key=f"btn_ori_aut_{abs(hash(ori))}", on_click=navegar_para, args=("Orientador", ori)) + st.button(f"🏫 Orientador: {ori}", key=_chave_widget("btn_ori_aut", ori), on_click=navegar_para, args=("Orientador", ori)) for co in sorted(list(co_orientadores)): - st.button(f"🤝 Co-orientador: {co}", key=f"btn_co_aut_{abs(hash(co))}", on_click=navegar_para, args=("Co-orientador", co)) + st.button(f"🤝 Co-orientador: {co}", key=_chave_widget("btn_co_aut", co), on_click=navegar_para, args=("Co-orientador", co)) st.markdown("
", unsafe_allow_html=True) @@ -830,7 +847,7 @@ def get_top_sna(entidades, metrica): for i, aluno in enumerate(alunos_professores): # Roteamento inteligente: manda pro perfil de Orientador ou Co-orientador dependendo de onde ele atua tipo_nav = "Orientador" if aluno in orientadores_set else "Co-orientador" - chave_nav = f"btn_descendente_{abs(hash(aluno))}_{i}" + chave_nav = _chave_widget("btn_descendente", aluno, i) st.button(f"🎓 {aluno} (Ver Perfil Acadêmico)", key=chave_nav, on_click=navegar_para, args=(tipo_nav, aluno)) st.markdown("
", unsafe_allow_html=True) @@ -853,14 +870,13 @@ def get_top_sna(entidades, metrica): with st.expander(f"Ver lista de todos os {len(alunos_orientados)} alunos"): for i, aluno in enumerate(alunos_orientados): icone_aluno = "🎓🔁" if aluno in alunos_duplos else "👤" - st.button(f"{icone_aluno} {aluno}", key=f"btn_aluno_{tipo_busca}_{abs(hash(aluno))}_{i}", on_click=navegar_para, args=("Autor", aluno)) + st.button(f"{icone_aluno} {aluno}", key=_chave_widget(f"btn_aluno_{tipo_busca}", aluno, i), on_click=navegar_para, args=("Autor", aluno)) st.markdown("
", unsafe_allow_html=True) # --- 4. LISTA DE DOCUMENTOS --- st.write(f"**Documentos {'Orientados' if tipo_busca == 'Orientador' else 'Co-orientados'} ({len(docs)}):**") - - from collections import defaultdict + docs_por_mt = defaultdict(list) for d in docs: docs_por_mt[d.get('macrotema', 'Multidisciplinar / Transversal')].append(d) @@ -869,7 +885,7 @@ def get_top_sna(entidades, metrica): for mt, docs_mt in docs_por_mt.items(): st.markdown(f"**🏷️ {mt}**") for i, d in enumerate(docs_mt): - chave_unica = f"btn_{tipo_busca}_{abs(hash(d['titulo']))}_{i}" + chave_unica = _chave_widget(f"btn_{tipo_busca}", d['titulo'], i) st.button(f"📄 {d['titulo']}", key=chave_unica, on_click=navegar_para, args=("Documento", d['titulo'])) elif tipo_busca == "Palavra-chave": @@ -883,7 +899,7 @@ def get_top_sna(entidades, metrica): with st.expander(f"📚 Ver Lista Completa de Documentos Associados ({len(docs)})"): for i, d in enumerate(docs): - chave_unica = f"btn_pk_{abs(hash(d['titulo']))}_{i}" + chave_unica = _chave_widget("btn_pk", d['titulo'], i) st.button(f"📄 {d['titulo']}", key=chave_unica, on_click=navegar_para, args=("Documento", d['titulo'])) elif tipo_busca == "Macrotema": @@ -892,7 +908,7 @@ def get_top_sna(entidades, metrica): with st.expander(f"📚 Explorar Teses e Dissertações da Categoria ({len(docs)})"): for i, d in enumerate(docs): - chave_unica = f"btn_mt_{abs(hash(d['titulo']))}_{i}" + chave_unica = _chave_widget("btn_mt", d['titulo'], i) st.button(f"📄 {d['titulo']}", key=chave_unica, on_click=navegar_para, args=("Documento", d['titulo'])) with col_sna: @@ -989,9 +1005,9 @@ def get_top_sna(entidades, metrica): df_plot['Volume'] = df_plot['Volume'].cumsum() if color_col: - fig = graf_func(df_plot, x='ano', y='Volume', color=color_col, title=title_fig, template="plotly_dark", **barmode_kw, **marker_kw, **facet_kws) + fig = graf_func(df_plot, x='ano', y='Volume', color=color_col, title=title_fig, template="streamlit", **barmode_kw, **marker_kw, **facet_kws) else: - fig = graf_func(df_plot, x='ano', y='Volume', title=title_fig, template="plotly_dark", **marker_kw, **facet_kws) + fig = graf_func(df_plot, x='ano', y='Volume', title=title_fig, template="streamlit", **marker_kw, **facet_kws) fig.update_layout(xaxis_title="Ano", yaxis_title="Quantidade", xaxis=dict(tickmode='linear', dtick=1)) if separar_ppg_hist: fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1])) @@ -1010,7 +1026,7 @@ def get_top_sna(entidades, metrica): if tem_multiplos_ppgs: separar_nuvem_ppg = col_nuvem2.radio("Separar Nuvem por PPG:", ["Não", "Sim"], horizontal=True, key="sep_nuvem_ppg_perfil") == "Sim" - stopwords = set(['de', 'a', 'o', 'que', 'e', 'do', 'da', 'em', 'um', 'uma', 'para', 'com', 'não', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'das', 'à', 'seu', 'sua', 'ou', 'nos', 'já', 'eu', 'também', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'sem', 'mesmo', 'aos', 'nas', 'me', 'esse', 'essa', 'num', 'nem', 'numa', 'pelos', 'pelas', 'este', 'esta', 'sobre', 'estudo', 'análise', 'proposta', 'uso', 'aplicação', 'desenvolvimento', 'modelo', 'sistema', 'avaliação', 'gestão', 'conhecimento', 'engenharia', 'objetivo', 'pesquisa', 'trabalho', 'resultados', 'método', 'foi', 'foram', 'são', 'ser', 'através', 'forma', 'apresenta', 'the', 'of', 'and', 'in', 'to', 'a', 'is', 'for', 'by', 'on', 'with', 'an', 'as', 'this', 'that', 'which', 'from', 'it', 'or', 'be', 'are', 'at', 'has', 'have', 'was', 'were', 'not', 'but', 'by']) + stopwords = STOPWORDS_ACADEMICAS def extrair_texto_docs(lista_docs): texto_completo = [] @@ -1167,7 +1183,7 @@ def render_tabela_similares(lista_dados, titulo_coluna_item, tipo_nav): with st.expander(f"Navegar para os perfis ({titulo_coluna_item})"): for idx, row in df.iterrows(): item_nome = row[titulo_coluna_item] - st.button(f"Ir para: {item_nome}", key=f"btn_sim_{tipo_nav}_{hash(item_nome)}_{idx}", on_click=navegar_para, args=(tipo_nav, item_nome)) + st.button(f"Ir para: {item_nome}", key=_chave_widget(f"btn_sim_{tipo_nav}", item_nome, idx), on_click=navegar_para, args=(tipo_nav, item_nome)) if not similares or all(len(v) == 0 for v in similares.values()): st.warning("Este item possui conexões muito isoladas do resto do programa para que vizinhos próximos sejam calculados com precisão.") diff --git a/backend.py b/backend.py index d198384..491e42a 100644 --- a/backend.py +++ b/backend.py @@ -28,6 +28,54 @@ from app_config import get_gemini_api_key, get_neo4j_credentials from gemini_utils import DEFAULT_FAST_MODELS, DEFAULT_TEXT_MODELS, generate_content, response_text +def _estimar_gamma_lei_potencia(degrees: list) -> float: + """ + Estimador MLE (máxima verossimilhança) do expoente γ da lei de potência. + Robusto a redes degeneradas (graus iguais, nós isolados, amostra pequena). + Retorna 0.0 em vez de lançar exceção para qualquer caso edge. + + Fórmula: γ = 1 + n / Σ ln(xi / x_min) + Referência: Clauset, Shalizi & Newman (2009), SIAM Review. + """ + degrees_validos = [d for d in degrees if isinstance(d, (int, float)) and d > 0] + + if len(degrees_validos) < 10: + return 0.0 # amostra insuficiente para estimativa confiável + + d_min = min(degrees_validos) + if d_min < 1: + return 0.0 # graus devem ser ≥ 1 para log ser definido + + try: + log_sum = sum(math.log(d / d_min) for d in degrees_validos) + if log_sum <= 0: + return 0.0 # rede degenerada: todos os graus iguais → log_sum = 0 + return 1.0 + len(degrees_validos) / log_sum + except (ValueError, ZeroDivisionError, OverflowError): + return 0.0 + + +# --- CONSTANTE GLOBAL: Stopwords para análise lexical --- +# Unificado para PT e EN. Inclui termos acadêmicos genéricos que poluem nuvens e redes. +STOPWORDS_ACADEMICAS: frozenset = frozenset({ + # Português — conectivos e artigos + 'de', 'a', 'o', 'que', 'e', 'do', 'da', 'em', 'um', 'uma', 'para', 'com', + 'não', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', + 'ao', 'das', 'à', 'seu', 'sua', 'ou', 'nos', 'já', 'eu', 'também', 'pelo', + 'pela', 'até', 'isso', 'ela', 'entre', 'sem', 'mesmo', 'aos', 'nas', 'me', + 'esse', 'essa', 'num', 'nem', 'numa', 'pelos', 'pelas', 'este', 'esta', + 'sob', 'perspectiva', 'frente', 'partir', 'baseado', + # Português — termos acadêmicos genéricos + 'sobre', 'estudo', 'análise', 'proposta', 'uso', 'aplicação', 'desenvolvimento', + 'modelo', 'sistema', 'avaliação', 'gestão', 'conhecimento', 'engenharia', + 'objetivo', 'pesquisa', 'trabalho', 'resultados', 'método', 'foi', 'foram', + 'são', 'ser', 'através', 'forma', 'apresenta', + # Inglês — conectivos + 'the', 'of', 'and', 'in', 'to', 'a', 'is', 'for', 'by', 'on', 'with', 'an', + 'as', 'this', 'that', 'which', 'from', 'it', 'or', 'be', 'are', 'at', 'has', + 'have', 'was', 'were', 'not', 'but', +}) + @st.cache_data(show_spinner=False) def gerar_grafo_ecologia_memes_agraph(dados_lista, min_coocorrencia=1, fonte_memes="Artefatos Extraídos"): @@ -41,7 +89,7 @@ def gerar_grafo_ecologia_memes_agraph(dados_lista, min_coocorrencia=1, fonte_mem import unicodedata # Stopwords para limpar os títulos no método Tradicional - stopwords = {'de', 'a', 'o', 'que', 'e', 'do', 'da', 'em', 'um', 'uma', 'para', 'com', 'não', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'das', 'à', 'seu', 'sua', 'ou', 'nos', 'já', 'eu', 'também', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'sem', 'mesmo', 'aos', 'nas', 'me', 'esse', 'essa', 'num', 'nem', 'numa', 'pelos', 'pelas', 'este', 'esta', 'sobre', 'estudo', 'análise', 'proposta', 'uso', 'aplicação', 'desenvolvimento', 'modelo', 'sistema', 'avaliação', 'gestão', 'conhecimento', 'engenharia', 'objetivo', 'pesquisa', 'trabalho', 'resultados', 'método', 'foi', 'foram', 'são', 'ser', 'através', 'forma', 'apresenta', 'the', 'of', 'and', 'in', 'to', 'is', 'for', 'by', 'on', 'with', 'an', 'as', 'this', 'that', 'which', 'from', 'it', 'or', 'be', 'are', 'at', 'has', 'have', 'was', 'were', 'not', 'but', 'baseado', 'partir', 'sob', 'perspectiva', 'frente'} + stopwords = STOPWORDS_ACADEMICAS def remover_acentos(texto): if not isinstance(texto, str): return "" @@ -116,7 +164,7 @@ def safe_list(l): return [str(x).strip().title() for x in l if str(x).strip()] i # 3. MÉTRICAS TOTAIS (Cards) degrees = list(grau_abs_full.values()) try: pr = nx.pagerank(G_completo, weight='weight') - except: pr = {n:0 for n in G_completo.nodes()} + except Exception: pr = {n: 0 for n in G_completo.nodes()} net_metrics = { 'densidade': nx.density(G_completo), @@ -166,7 +214,7 @@ def safe_list(l): return [str(x).strip().title() for x in l if str(x).strip()] i for u, v, data in G_display.edges(data=True): edges.append(Edge(source=u, target=v, value=data['weight'], color=cor_dinamica_aresta)) - gamma = 1 + len(degrees) / sum(np.log(d / min(degrees)) for d in degrees) if min(degrees) > 0 else 0 + gamma = _estimar_gamma_lei_potencia(degrees) spearman, _ = sps.spearmanr(list(deg_cent_full.values()), list(bet_cent_full.values())) net_maturity = { @@ -183,6 +231,35 @@ def configurar_gemini(): """Confere se há chave disponível via env ou Streamlit secrets.""" return bool(get_gemini_api_key()) + +def _chamar_gemini_com_retry(prompt: str, model_candidates, temperature: float, + response_mime_type: str, + max_tentativas: int = 3, + delay_base: float = 2.0): + """ + Wrapper com backoff exponencial para chamadas à API do Gemini. + Tentativa 1: imediata + Tentativa 2: aguarda delay_base segundos (2s por padrão) + Tentativa 3: aguarda delay_base * 2 segundos (4s por padrão) + Lança a última exceção se todas as tentativas falharem. + """ + ultima_excecao = None + for tentativa in range(max_tentativas): + try: + return generate_content( + prompt=prompt, + model_candidates=model_candidates, + temperature=temperature, + response_mime_type=response_mime_type, + ) + except Exception as e: + ultima_excecao = e + if tentativa < max_tentativas - 1: + espera = delay_base * (2 ** tentativa) + time.sleep(espera) + raise ultima_excecao + + def extrair_artefatos_llm(titulo, resumo): """Envia um prompt estruturado ao Gemini forçando um retorno em JSON.""" if not titulo or not resumo: @@ -210,7 +287,7 @@ def extrair_artefatos_llm(titulo, resumo): """ try: - response = generate_content( + response = _chamar_gemini_com_retry( prompt=prompt, model_candidates=DEFAULT_FAST_MODELS, temperature=0.2, @@ -433,7 +510,7 @@ def calcular_metricas_memeticas(df_base, fonte_memes="Palavras-chave"): if df_base.empty: return pd.DataFrame(), pd.DataFrame(), 0, 0, pd.DataFrame(), pd.DataFrame() - stopwords = {'de', 'a', 'o', 'que', 'e', 'do', 'da', 'em', 'um', 'uma', 'para', 'com', 'não', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'das', 'à', 'seu', 'sua', 'ou', 'nos', 'já', 'eu', 'também', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'sem', 'mesmo', 'aos', 'nas', 'me', 'esse', 'essa', 'num', 'nem', 'numa', 'pelos', 'pelas', 'este', 'esta', 'sobre', 'estudo', 'análise', 'proposta', 'uso', 'aplicação', 'desenvolvimento', 'modelo', 'sistema', 'avaliação', 'gestão', 'conhecimento', 'engenharia', 'objetivo', 'pesquisa', 'trabalho', 'resultados', 'método', 'foi', 'foram', 'são', 'ser', 'através', 'forma', 'apresenta', 'the', 'of', 'and', 'in', 'to', 'is', 'for', 'by', 'on', 'with', 'an', 'as', 'this', 'that', 'which', 'from', 'it', 'or', 'be', 'are', 'at', 'has', 'have', 'was', 'were', 'not', 'but', 'baseado', 'partir', 'sob', 'perspectiva', 'frente'} + stopwords = STOPWORDS_ACADEMICAS def remover_acentos(texto): if not isinstance(texto, str): return "" @@ -863,7 +940,7 @@ def calcular_maturidade_rede(dados_completos, _sna_global): # 1. Assortatividade (Quem se conecta com quem?) try: assortatividade = nx.degree_assortativity_coefficient(G) - except: + except Exception: assortatividade = 0.0 # 2. Rich-Club Coefficient (Os Hubs se conversam?) @@ -871,10 +948,10 @@ def calcular_maturidade_rede(dados_completos, _sna_global): rc = nx.rich_club_coefficient(G, normalized=False) # Pega a probabilidade de conexão apenas para o "Clube" dos top 20% maiores nós k_max = max(rc.keys()) if rc else 1 - k_target = int(k_max * 0.8) + k_target = int(k_max * 0.8) chaves_validas = [k for k in rc.keys() if k >= k_target] rich_club_val = rc[chaves_validas[0]] if chaves_validas else list(rc.values())[-1] - except: + except Exception: rich_club_val = 0.0 # 3. Expoente Gamma da Lei de Potência (O grau de monopolização) @@ -883,18 +960,18 @@ def calcular_maturidade_rede(dados_completos, _sna_global): contagem = Counter(graus) k = np.array(list(contagem.keys())) Pk = np.array(list(contagem.values())) / len(graus) - + # Filtra k > 1 para focar na "cauda longa" da distribuição (onde a magia acontece) mask = k > 1 log_k = np.log10(k[mask]) log_Pk = np.log10(Pk[mask]) - + if len(log_k) > 1: slope, _, _, _, _ = stats.linregress(log_k, log_Pk) gamma = abs(slope) else: gamma = 0.0 - except: + except Exception: gamma = 0.0 # 4. Correlação de Spearman (A barriga da curva 3D: Brokers vs Hubs) @@ -903,7 +980,7 @@ def calcular_maturidade_rede(dados_completos, _sna_global): graus_list = [v.get('Grau Absoluto', 0) for v in _sna_global.values()] bet_list = [v.get('Betweenness', 0) for v in _sna_global.values()] spearman_rho, _ = stats.spearmanr(graus_list, bet_list) - except: + except Exception: spearman_rho = 0.0 return { @@ -969,67 +1046,78 @@ def plotar_grafico_3d_sna(sna_global, tipo_alvo, termo_destaque=None): ) return fig -@st.cache_data -def calcular_similares_rede(termo_foco, tipo_busca, dados_completos): - """Calcula os nós mais próximos usando o Índice de Jaccard (Similaridade de Vizinhança).""" - +@st.cache_data(show_spinner=False) +def construir_perfis_similaridade(dados_lista): + """ + Pré-computa o 'DNA acadêmico' de todas as entidades para comparação Jaccard. + Separado de calcular_similares_rede para ser cacheado independentemente. + Cache hit nas chamadas subsequentes — O(1) em vez de O(n) por busca. + """ perfis = {} tipos = {} niveis_docs = {} - + def add_feature(entidade, feature, tipo_entidade): - if entidade not in perfis: + if entidade not in perfis: perfis[entidade] = set() tipos[entidade] = tipo_entidade perfis[entidade].add(feature) - - # 1. Constrói o "DNA" (Perfil de Conexões) de cada entidade - for d in dados_completos: + + for d in dados_lista: doc = d.get('titulo') if not doc: continue - + niveis_docs[doc] = d.get('nivel_academico', 'Outros') - + autores = d.get('autores', []) ori = d.get('orientador') cooris = d.get('co_orientadores', []) pks = d.get('palavras_chave', []) mt = d.get('macrotema') - + todas_entidades_doc = autores + ([ori] if ori else []) + cooris + pks + ([mt] if mt else []) - + # DNA do Documento: Todas as pessoas e conceitos atrelados a ele for ent in todas_entidades_doc: if ent: add_feature(doc, ent, 'Documento') - + # DNA do Autor: Seus orientadores, palavras-chave e temas que costuma escrever for a in autores: if ori: add_feature(a, ori, 'Autor') for pk in pks: add_feature(a, pk, 'Autor') if mt: add_feature(a, mt, 'Autor') - + # DNA dos Professores: Seus parceiros de banca, palavras-chave e temas dos alunos if ori: for pk in pks: add_feature(ori, pk, 'Orientador') if mt: add_feature(ori, mt, 'Orientador') for co in cooris: add_feature(ori, co, 'Orientador') - + for co in cooris: for pk in pks: add_feature(co, pk, 'Co-orientador') if mt: add_feature(co, mt, 'Co-orientador') if ori: add_feature(co, ori, 'Co-orientador') - + # DNA dos Conceitos: Outras palavras usadas juntas, macrotema e orientadores for pk in pks: if mt: add_feature(pk, mt, 'Palavra-chave') - for pk2 in pks: + for pk2 in pks: if pk != pk2: add_feature(pk, pk2, 'Palavra-chave') - + # DNA do Macrotema: Suas palavras-chave base e orientadores principais if mt: for pk in pks: add_feature(mt, pk, 'Macrotema') if ori: add_feature(mt, ori, 'Macrotema') + return perfis, tipos, niveis_docs + + +@st.cache_data +def calcular_similares_rede(termo_foco, tipo_busca, dados_completos): + """Calcula os nós mais próximos usando o Índice de Jaccard (Similaridade de Vizinhança).""" + + perfis, tipos, niveis_docs = construir_perfis_similaridade(dados_completos) + if termo_foco not in perfis: return {} @@ -1141,13 +1229,22 @@ def navegar_para(novo_tipo, novo_termo): st.session_state.update({'busca_tipo': novo_tipo, 'busca_termo': novo_termo}) # --- FUNÇÕES DE BACKEND (EXTRAÇÃO E BUSCA) --- -def _normalizar_nivel(nivel): - nivel_txt = str(nivel or "") - if "Tese" in nivel_txt: - return "Teses" - if "Disserta" in nivel_txt: - return "Dissertações" - return "Outros" +def _normalizar_nivel(nivel) -> str: + """ + Normaliza o campo nivel_academico para um dos 4 valores canônicos: + 'Teses', 'Dissertações', 'TCC', 'Outros'. + Case-insensitive. Robusto a variações de nomenclatura. + """ + if not nivel: + return 'Outros' + nivel_str = str(nivel).lower().strip() + if 'tese' in nivel_str or 'doutor' in nivel_str or 'thesis' in nivel_str: + return 'Teses' + if 'disserta' in nivel_str or 'mestrado' in nivel_str or 'dissertation' in nivel_str or 'master' in nivel_str: + return 'Dissertações' + if 'tcc' in nivel_str or 'conclus' in nivel_str or 'gradua' in nivel_str: + return 'TCC' + return 'Outros' def _extrair_entidades_por_tipo(doc, tipo): if tipo == "Orientador": @@ -1177,7 +1274,7 @@ def color_ql(val): if v < 1: return "color: #FF4B4B;" return "color: #F8E71C;" - except: + except Exception: return "" styler = resumo_df.style.map(color_ql, subset=["Valor QL"]) @@ -1364,8 +1461,8 @@ def color_ql(val): if v > 1: return 'color: #00FF00; font-weight: bold;' elif v < 1: return 'color: #FF4B4B;' return 'color: #F8E71C;' - except: return '' - + except Exception: return '' + styler = resumo.style.map(color_ql, subset=['QL (Especialização)']) st.dataframe( @@ -1382,16 +1479,16 @@ def color_ql(val): st.markdown("
", unsafe_allow_html=True) @st.cache_data(show_spinner=False) # Desativamos o spinner padrão para usar nossa barra customizada -def calcular_sna_global(dados): +def calcular_sna_global(dados_lista): # 1. Inicia a barra de progresso no topo progresso_texto = "🚀 Iniciando análise de rede complexa..." barra = st.progress(0, text=progresso_texto) - + G = nx.Graph() - total_docs = len(dados) - + total_docs = len(dados_lista) + # 2. Construção do Grafo (20% do progresso) - for i, d in enumerate(dados): + for i, d in enumerate(dados_lista): doc = d.get('titulo') if not doc: continue G.add_node(doc, tipo='Documento') @@ -1445,7 +1542,7 @@ def calcular_sna_global(dados): barra.progress(85, text="🏘️ Detectando Clusters e Comunidades (Algoritmo de Louvain)...") try: mapa_comunidades = {node: i+1 for i, comm in enumerate(nx_comm.louvain_communities(G)) for node in comm} - except: + except Exception: mapa_comunidades = {} # 7. Finalização e Ranking (100%) @@ -1465,55 +1562,85 @@ def calcular_sna_global(dados): # Remove a barra da tela ao finalizar barra.empty() - - return resultado + + # G não é retornado: grafos NetworkX não são serializáveis via pickle do + # @st.cache_data. Quem precisar do grafo deve usar @st.cache_resource separado. + return resultado, list(dados_lista) @st.cache_resource -def gerar_orbita_local(termo_foco, tipo_busca, profundidade=1, _sna_global=None, metodo_tamanho="Tamanho Fixo", ano_limite=2026, dados_completos=None): +def _construir_grafo_historico(dados_lista: tuple) -> nx.Graph: """ - Motor in-memory de renderização visual (Fallback sem Neo4j para TCCs). + Constrói o grafo completo com metadado de ano em cada aresta. + Cacheada separadamente de gerar_orbita_local para reutilizar entre frames de animação. + + Recebe dados_lista como tuple de tuplas (para ser hashável pelo st.cache_resource). """ - if not dados_completos: return [], [] - - # 1. Constrói a Rede Temporária Baseada no Limite de Ano G = nx.Graph() - for d in dados_completos: - # Pula se o doc exceder o ano_limite - ano = int(d['ano']) if d.get('ano') and str(d['ano']).isdigit() else 2000 - if ano > ano_limite: + for d in dados_lista: + titulo, ano_raw, orientador, autores, cooris, pks, macrotema = d + try: + ano = int(ano_raw) if ano_raw and str(ano_raw).isdigit() else 0 + except (ValueError, TypeError): + ano = 0 + + if not titulo: continue - - doc = d.get('titulo') - if not doc: continue - - G.add_node(doc, tipo='Documento') - - for a in d.get('autores', []): - G.add_node(a, tipo='Autor') - G.add_edge(doc, a) - - ori = d.get('orientador') - if ori: - G.add_node(ori, tipo='Orientador') - G.add_edge(doc, ori) - - # Adicionar co-orientadores para fidelidade - for co in d.get('co_orientadores', []): - G.add_node(co, tipo='Co-orientador') - G.add_edge(doc, co) - - for pk in d.get('palavras_chave', []): - G.add_node(pk, tipo='Conceito') - G.add_edge(doc, pk) - - mt = d.get('macrotema') - if mt: - G.add_node(mt, tipo='Macrotema') - G.add_edge(doc, mt) - - if termo_foco not in G: + + G.add_node(titulo, tipo='Documento', ano=ano) + + for a in autores: + if a: + G.add_node(a, tipo='Autor') + G.add_edge(titulo, a, ano=ano) + + if orientador: + G.add_node(orientador, tipo='Orientador') + G.add_edge(titulo, orientador, ano=ano) + + for co in cooris: + if co: + G.add_node(co, tipo='Co-orientador') + G.add_edge(titulo, co, ano=ano) + + for pk in pks: + if pk: + G.add_node(pk, tipo='Conceito') + G.add_edge(titulo, pk, ano=ano) + + if macrotema: + G.add_node(macrotema, tipo='Macrotema') + G.add_edge(titulo, macrotema, ano=ano) + + return G + + +@st.cache_resource +def gerar_orbita_local(termo_foco, tipo_busca, profundidade=1, _sna_global=None, metodo_tamanho="Tamanho Fixo", ano_limite=2026, dados_completos=None): + """Motor in-memory de renderização visual (Fallback sem Neo4j para TCCs).""" + if not dados_completos: return [], [] + + # Constrói ou recupera do cache o grafo completo datado + G_completo = _construir_grafo_historico(tuple( + (d.get('titulo', ''), d.get('ano', 0), d.get('orientador', ''), + tuple(d.get('autores', [])), tuple(d.get('co_orientadores', [])), + tuple(d.get('palavras_chave', [])), d.get('macrotema', '')) + for d in dados_completos + )) + + # Filtra o grafo pelo ano_limite de forma eficiente + arestas_no_periodo = [ + (u, v) for u, v, data in G_completo.edges(data=True) + if data.get('ano', 0) <= ano_limite + ] + + if not arestas_no_periodo: return [], [] - + + G = G_completo.edge_subgraph(arestas_no_periodo).copy() + + if termo_foco not in G: + return [], [] + # 2. Extrai o Ego-Graph de acordo com a profundidade ego_net = nx.ego_graph(G, termo_foco, radius=profundidade) @@ -1683,7 +1810,7 @@ def color_ql2(val): if v > 1: return 'color: #00FF00; font-weight: bold;' elif v < 1: return 'color: #FF4B4B;' return 'color: #F8E71C;' - except: return '' + except Exception: return '' styler2 = resumo.style.map(color_ql2, subset=['QL (Especialização)']) @@ -1742,16 +1869,16 @@ def gerar_descritivo_sessao(nomes_programas, amostra_textos, api_key): - NÃO repita o nome do(s) programa(s) no texto. - Retorne APENAS o parágrafo limpo. """ - try: - response = generate_content( - prompt=prompt, - api_key=api_key, - model_candidates=DEFAULT_TEXT_MODELS, - temperature=0.3, - ) - return response_text(response).strip().replace('**', '').replace('"', '') - except Exception as e: - return f"Não foi possível gerar a síntese dinâmica no momento. (Aviso: {e})" + # Usa retry com backoff para tolerar picos de demanda (503/UNAVAILABLE). + # Lança exceção em vez de retornar string de erro, evitando que @st.cache_data + # persista a falha como resultado válido até expirar o TTL. + response = _chamar_gemini_com_retry( + prompt=prompt, + model_candidates=DEFAULT_TEXT_MODELS, + temperature=0.3, + response_mime_type=None, + ) + return response_text(response).strip().replace('**', '').replace('"', '') # --- MOTOR DE CONSULTA À CAPES SUCUPIRA --- @@ -1799,22 +1926,70 @@ def carregar_catalogo_capes_ufsc(): return programas_capes # --- CARREGAMENTO E SELEÇÃO DA BASE CONSOLIDADA --- -@st.cache_data +def _normalizar_documentos(dados: list) -> list: + """ + Garante tipos consistentes em todos os documentos. + Chamada pelos loaders para evitar bugs de tipo downstream. + Modifica a lista in-place para economizar memória e retorna ela. + """ + for d in dados: + # Ano: int quando possível, None caso contrário + ano_raw = d.get('ano') + if ano_raw is not None: + try: + d['ano'] = int(str(ano_raw).strip()) + except (ValueError, TypeError): + d['ano'] = None + + # Listas: garante list, nunca None + for campo in ('autores', 'co_orientadores', 'palavras_chave'): + if not isinstance(d.get(campo), list): + d[campo] = [] + + # Strings: garante string vazia em vez de None + for campo in ('titulo', 'orientador', 'macrotema', 'resumo', + 'programa_origem', 'nivel_academico', 'url'): + if d.get(campo) is None: + d[campo] = '' + + # Palavras-chave: remove itens None ou string vazia + d['palavras_chave'] = [ + pk for pk in d['palavras_chave'] + if pk and str(pk).strip() + ] + return dados + + +@st.cache_data(show_spinner="📚 Carregando base de teses e dissertações...", ttl=3600) def carregar_base_consolidada(): try: - # Lê o arquivo GZIP diretamente da memória ('rt' = Read Text) with gzip.open('base_consolidada_ufsc.json.gz', 'rt', encoding='utf-8') as f: - return json.load(f) + dados = json.load(f) + return _normalizar_documentos(dados) except FileNotFoundError: return [] + except json.JSONDecodeError as e: + st.error(f"⚠️ Arquivo 'base_consolidada_ufsc.json.gz' está corrompido: {e}") + return [] + except Exception as e: + st.error(f"⚠️ Erro inesperado ao carregar base consolidada: {e}") + return [] -@st.cache_data + +@st.cache_data(show_spinner="📚 Carregando base de TCCs...", ttl=3600) def carregar_base_tcc(): try: with gzip.open('base_tcc_ufsc.json.gz', 'rt', encoding='utf-8') as f: - return json.load(f) + dados = json.load(f) + return _normalizar_documentos(dados) except FileNotFoundError: return [] + except json.JSONDecodeError as e: + st.error(f"⚠️ Arquivo 'base_tcc_ufsc.json.gz' está corrompido: {e}") + return [] + except Exception as e: + st.error(f"⚠️ Erro inesperado ao carregar base de TCCs: {e}") + return [] # --- FUNÇÃO GERADORA DO MAPA TEMÁTICO (BIBLIOMETRIX STYLE) ---