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) ---