feat(backend): ranking 100% Elasticsearch e critérios do PDF

This commit is contained in:
Frederico Castro
2025-12-15 00:13:12 -03:00
parent 70787fbb51
commit 2a0dc1a652
25 changed files with 522 additions and 263 deletions

View File

@@ -3,11 +3,19 @@ from typing import Optional, Dict, Any
class ElasticsearchClient:
def __init__(self, url: str, index: str, user: str = "", password: str = ""):
def __init__(
self,
url: str,
index: str,
user: str = "",
password: str = "",
verify_ssl: bool = True,
):
self.url = url.rstrip("/")
self.index = index
self.user = user
self.password = password
self.verify_ssl = verify_ssl
self._client: Optional[httpx.AsyncClient] = None
async def connect(self) -> None:
@@ -21,7 +29,7 @@ class ElasticsearchClient:
"Content-Type": "application/json",
"Accept": "application/json"
},
verify=False,
verify=self.verify_ssl,
timeout=120.0
)

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
@dataclass(frozen=True)
class RankingEntry:
id_pessoa: int
nome: str
posicao: int
pontuacao_total: int
bloco_a: int
bloco_b: int
bloco_c: int
bloco_d: int
ativo: bool
anos_atuacao: float
detalhes: Dict[str, Any]
class RankingStore:
"""
Armazena o ranking pré-calculado em memória.
Fonte única de dados: Elasticsearch (AtuaCAPES).
"""
def __init__(self) -> None:
self._entries: List[RankingEntry] = []
self._last_update: Optional[datetime] = None
self._lock = asyncio.Lock()
@property
def last_update(self) -> Optional[datetime]:
return self._last_update
def is_ready(self) -> bool:
return bool(self._entries)
async def set_entries(self, entries: List[RankingEntry]) -> None:
async with self._lock:
self._entries = entries
self._last_update = datetime.now()
def total(self, filtro_ativo: Optional[bool] = None) -> int:
if filtro_ativo is None:
return len(self._entries)
return sum(1 for e in self._entries if e.ativo == filtro_ativo)
def get_page(
self, page: int, size: int, filtro_ativo: Optional[bool] = None
) -> Tuple[int, List[RankingEntry]]:
if page < 1:
page = 1
if size < 1:
size = 1
if filtro_ativo is None:
entries = self._entries
else:
entries = [e for e in self._entries if e.ativo == filtro_ativo]
total = len(entries)
start = (page - 1) * size
end = start + size
return total, entries[start:end]
def get_slice(
self, offset: int, limit: int, filtro_ativo: Optional[bool] = None
) -> Tuple[int, List[RankingEntry]]:
if offset < 0:
offset = 0
if limit < 1:
limit = 1
if filtro_ativo is None:
entries = self._entries
else:
entries = [e for e in self._entries if e.ativo == filtro_ativo]
total = len(entries)
return total, entries[offset : offset + limit]
def buscar_por_nome(self, nome: str, limit: int = 5) -> List[Dict[str, Any]]:
palavras = [p.strip() for p in (nome or "").upper().split() if len(p.strip()) >= 2]
if not palavras:
return []
results: List[Dict[str, Any]] = []
for e in self._entries:
nome_e = (e.nome or "").upper()
if all(p in nome_e for p in palavras):
results.append(
{
"ID_PESSOA": e.id_pessoa,
"NOME": e.nome,
"POSICAO": e.posicao,
"PONTUACAO_TOTAL": float(e.pontuacao_total),
}
)
if len(results) >= limit:
break
return results
def get_by_id(self, id_pessoa: int) -> Optional[RankingEntry]:
for e in self._entries:
if e.id_pessoa == id_pessoa:
return e
return None
ranking_store = RankingStore()

View File

@@ -21,13 +21,12 @@ from ...domain.services.calculador_pontuacao import CalculadorPontuacao
from ...domain.value_objects.periodo import Periodo, mesclar_periodos
from ..cache import ranking_cache
from ..elasticsearch.client import ElasticsearchClient
from ..oracle.client import OracleClient
logger = logging.getLogger(__name__)
class ConsultorRepositoryImpl(ConsultorRepository):
def __init__(self, es_client: ElasticsearchClient, oracle_client: OracleClient = None):
def __init__(self, es_client: ElasticsearchClient, oracle_client=None):
self.es_client = es_client
self.oracle_client = oracle_client
self.calculador = CalculadorPontuacao()
@@ -75,6 +74,8 @@ class ConsultorRepositoryImpl(ConsultorRepository):
resultado = []
for coord in coordenacoes:
dados_coord = coord.get("dadosCoordenacaoArea", {}) or {}
tipo_coord_raw = str(dados_coord.get("tipo", "") or coord.get("nome", "") or "")
presidente = "presidente" in tipo_coord_raw.lower()
inicio = self._parse_date(dados_coord.get("inicioVinculacao")) or self._parse_date(coord.get("inicio"))
if not inicio:
continue
@@ -97,10 +98,38 @@ class ConsultorRepositoryImpl(ConsultorRepository):
periodo=Periodo(inicio=inicio, fim=fim),
areas_adicionais=[],
ja_coordenou_antes=len(resultado) > 0,
presidente=presidente,
))
return resultado
@staticmethod
def _inferir_premiacao_tipo(texto: str) -> Optional[str]:
"""
Retorna o tipo de premiação em forma normalizada para uso em selos/hints.
"""
t = (texto or "").lower()
if "grande prêmio" in t or "grande premio" in t:
return "GP"
if "menção" in t or "mencao" in t or "honrosa" in t:
return "MENCAO"
if "prêmio" in t or "premio" in t:
return "PREMIO"
return None
def _tem_coordenacao_ppg(self, atuacoes: List[Dict[str, Any]]) -> bool:
"""
Selo PPG_COORD: marca consultor que possui atuação de gestão/coordenação de programa no ATUACAPES.
A pontuação do PPG é reservada (V1), mas o selo é exibido.
"""
for a in atuacoes:
if a.get("dadosGestaoPrograma"):
return True
tipo = str(a.get("tipo", "") or "").lower()
if "programa" in tipo and ("coord" in tipo or "gest" in tipo):
return True
return False
def _extrair_consultoria(self, atuacoes: List[Dict[str, Any]]) -> Optional[Consultoria]:
consultorias = [a for a in atuacoes if a.get("tipo") in ["Consultor", "Histórico de Consultoria"]]
if not consultorias:
@@ -248,17 +277,19 @@ class ConsultorRepositoryImpl(ConsultorRepository):
dados = a.get("dadosPremiacaoPremio", {}) or a.get("dadosPremio", {}) or {}
tipo_premiacao = dados.get("tipoPremiacao") or dados.get("premiacao") or ""
nome_premio = dados.get("nomePremio") or dados.get("evento") or a.get("descricao", "")
papel = dados.get("papelPessoa") or dados.get("papel") or None
ano = dados.get("ano")
if not ano:
inicio = self._parse_date(a.get("inicio"))
ano = inicio.year if inicio else datetime.now().year
tipo_lower = tipo_premiacao.lower()
nome_lower = nome_premio.lower()
tipo_lower = str(tipo_premiacao).lower()
nome_lower = str(nome_premio).lower()
tipo_norm = self._inferir_premiacao_tipo(f"{tipo_lower} {nome_lower}")
if "grande" in nome_lower or "grande" in tipo_lower:
if tipo_norm == "GP":
codigo = "PREMIACAO"
elif "menção" in tipo_lower or "mencao" in tipo_lower or "honrosa" in tipo_lower:
elif tipo_norm == "MENCAO":
codigo = "MENCAO"
else:
codigo = "PREMIACAO_GP"
@@ -268,6 +299,7 @@ class ConsultorRepositoryImpl(ConsultorRepository):
tipo=tipo_premiacao,
nome_premio=nome_premio,
ano=ano,
papel=papel,
))
return premiacoes
@@ -283,10 +315,10 @@ class ConsultorRepositoryImpl(ConsultorRepository):
area = dados.get("areaConhecimento", "") or ""
nivel_lower = nivel.lower()
if "1a" in nivel_lower or "1b" in nivel_lower or "1c" in nivel_lower or "1d" in nivel_lower:
codigo = "BOL_BPQ_SUPERIOR"
if "1d" in nivel_lower or "2" in nivel_lower:
codigo = "BOL_BPQ_INT"
else:
codigo = "BOL_BPQ_INTERMEDIARIO"
codigo = "BOL_BPQ_SUP"
bolsas.append(BolsaCNPQ(
codigo=codigo,
@@ -331,6 +363,15 @@ class ConsultorRepositoryImpl(ConsultorRepository):
dados = a.get("dadosOrientacao", {}) or {}
nivel = dados.get("nivel", "") or dados.get("tipo", "") or ""
premio_texto = (
dados.get("premiacao")
or dados.get("tipoPremiacao")
or dados.get("premio")
or dados.get("resultado")
or ""
)
tipo_prem = self._inferir_premiacao_tipo(str(premio_texto))
premiada = tipo_prem is not None
ano = dados.get("ano")
if not ano:
inicio = self._parse_date(a.get("inicio"))
@@ -349,6 +390,9 @@ class ConsultorRepositoryImpl(ConsultorRepository):
tipo=tipo,
nivel=nivel,
ano=ano,
coorientacao=False,
premiada=premiada,
premiacao_tipo=tipo_prem,
))
return orientacoes
@@ -363,6 +407,15 @@ class ConsultorRepositoryImpl(ConsultorRepository):
dados = a.get("dadosOrientacao", {}) or a.get("dadosCoorientacao", {}) or {}
nivel = dados.get("nivel", "") or dados.get("tipo", "") or ""
premio_texto = (
dados.get("premiacao")
or dados.get("tipoPremiacao")
or dados.get("premio")
or dados.get("resultado")
or ""
)
tipo_prem = self._inferir_premiacao_tipo(str(premio_texto))
premiada = tipo_prem is not None
ano = dados.get("ano")
if not ano:
inicio = self._parse_date(a.get("inicio"))
@@ -381,6 +434,9 @@ class ConsultorRepositoryImpl(ConsultorRepository):
tipo=tipo,
nivel=nivel,
ano=ano,
coorientacao=True,
premiada=premiada,
premiacao_tipo=tipo_prem,
))
return coorientacoes
@@ -432,11 +488,13 @@ class ConsultorRepositoryImpl(ConsultorRepository):
orientacoes = self._extrair_orientacoes(atuacoes)
coorientacoes = self._extrair_coorientacoes(atuacoes)
membros_banca = self._extrair_membros_banca(atuacoes)
coordenador_ppg = self._tem_coordenacao_ppg(atuacoes)
consultor = Consultor(
id_pessoa=id_pessoa,
nome=dados_pessoais.get("nome", "N/A"),
cpf=dados_pessoais.get("cpf"),
coordenador_ppg=coordenador_ppg,
coordenacoes_capes=coordenacoes_capes,
consultoria=consultoria,
inscricoes=inscricoes,