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

@@ -2,7 +2,7 @@ ES_URL=http://seu-elasticsearch:9200
ES_INDEX=atuacapes ES_INDEX=atuacapes
ES_USER=seu_usuario_elastic ES_USER=seu_usuario_elastic
ES_PASSWORD=sua_senha_elastic ES_PASSWORD=sua_senha_elastic
ES_VERIFY_SSL=true
ORACLE_USER=FREDERICOAC SCHEDULER_ENABLED=false
ORACLE_PASSWORD=FREDEricoac SCHEDULER_HOUR=3
ORACLE_DSN=oracledhtsrv02.hom.capes.gov.br:1521/hom_dr

View File

@@ -2,11 +2,7 @@ ES_URL=http://localhost:9200
ES_INDEX=atuacapes__1763197236 ES_INDEX=atuacapes__1763197236
ES_USER=seu_usuario_elastic ES_USER=seu_usuario_elastic
ES_PASSWORD=sua_senha_elastic ES_PASSWORD=sua_senha_elastic
ES_DEFAULT_SOURCE_FIELDS=id,dadosPessoais.nome,dadosPessoais.cpf,atuacoes ES_VERIFY_SSL=true
ORACLE_USER=seu_usuario
ORACLE_PASSWORD=sua_senha
ORACLE_DSN=host:1521/service_name
API_HOST=0.0.0.0 API_HOST=0.0.0.0
API_PORT=8000 API_PORT=8000
@@ -14,3 +10,5 @@ API_RELOAD=true
CORS_ORIGINS=http://localhost:3000,http://localhost:5173 CORS_ORIGINS=http://localhost:3000,http://localhost:5173
LOG_LEVEL=INFO LOG_LEVEL=INFO
SCHEDULER_ENABLED=false
SCHEDULER_HOUR=3

View File

@@ -2,23 +2,6 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y \
gcc \
wget \
unzip \
&& (apt-get install -y libaio1t64 || apt-get install -y libaio1 || true) \
&& rm -rf /var/lib/apt/lists/*
RUN wget https://download.oracle.com/otn_software/linux/instantclient/2115000/instantclient-basic-linux.x64-21.15.0.0.0dbru.zip \
&& unzip instantclient-basic-linux.x64-21.15.0.0.0dbru.zip -d /opt/oracle \
&& rm instantclient-basic-linux.x64-21.15.0.0.0dbru.zip \
&& sh -c "echo /opt/oracle/instantclient_21_15 > /etc/ld.so.conf.d/oracle-instantclient.conf" \
&& ln -sf /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/x86_64-linux-gnu/libaio.so.1 || true \
&& ldconfig
ENV LD_LIBRARY_PATH=/opt/oracle/instantclient_21_15:$LD_LIBRARY_PATH
ENV PATH=/opt/oracle/instantclient_21_15:$PATH
RUN pip install --upgrade pip RUN pip install --upgrade pip
COPY requirements.txt ./ COPY requirements.txt ./

View File

@@ -11,8 +11,6 @@ fastapi = "^0.109.0"
uvicorn = {extras = ["standard"], version = "^0.27.0"} uvicorn = {extras = ["standard"], version = "^0.27.0"}
pydantic = "^2.5.3" pydantic = "^2.5.3"
pydantic-settings = "^2.1.0" pydantic-settings = "^2.1.0"
elasticsearch = "^8.11.1"
cx-Oracle = "^8.3.0"
python-dateutil = "^2.8.2" python-dateutil = "^2.8.2"
httpx = "^0.26.0" httpx = "^0.26.0"
python-dotenv = "^1.0.0" python-dotenv = "^1.0.0"

View File

@@ -2,8 +2,7 @@ fastapi==0.109.0
uvicorn[standard]==0.27.0 uvicorn[standard]==0.27.0
pydantic==2.5.3 pydantic==2.5.3
pydantic-settings==2.1.0 pydantic-settings==2.1.0
cx-Oracle==8.3.0
python-dateutil==2.8.2 python-dateutil==2.8.2
httpx==0.26.0 httpx==0.26.0
python-dotenv==1.0.0 python-dotenv==1.0.0
aiohttp==3.9.1 rich==13.7.0

View File

@@ -18,6 +18,7 @@ class CoordenacaoCapesDTO:
periodo: PeriodoDTO periodo: PeriodoDTO
areas_adicionais: List[str] areas_adicionais: List[str]
ja_coordenou_antes: bool ja_coordenou_antes: bool
presidente: bool
@dataclass @dataclass
@@ -54,6 +55,7 @@ class PremiacaoDTO:
tipo: str tipo: str
nome_premio: str nome_premio: str
ano: int ano: int
papel: Optional[str] = None
@dataclass @dataclass
@@ -77,6 +79,9 @@ class OrientacaoDTO:
tipo: str tipo: str
nivel: str nivel: str
ano: Optional[int] ano: Optional[int]
coorientacao: bool = False
premiada: bool = False
premiacao_tipo: Optional[str] = None
@dataclass @dataclass
@@ -130,10 +135,10 @@ class ConsultorResumoDTO:
class ConsultorDetalhadoDTO: class ConsultorDetalhadoDTO:
id_pessoa: int id_pessoa: int
nome: str nome: str
cpf: Optional[str]
anos_atuacao: float anos_atuacao: float
ativo: bool ativo: bool
veterano: bool veterano: bool
coordenador_ppg: bool
coordenacoes_capes: List[CoordenacaoCapesDTO] coordenacoes_capes: List[CoordenacaoCapesDTO]
consultoria: Optional[ConsultoriaDTO] consultoria: Optional[ConsultoriaDTO]
inscricoes: List[InscricaoDTO] inscricoes: List[InscricaoDTO]

View File

@@ -1,15 +1,11 @@
import json
import logging import logging
from datetime import datetime from typing import Dict, Any, List
from typing import Optional, Dict, Any
from ...infrastructure.elasticsearch.client import ElasticsearchClient from ...infrastructure.elasticsearch.client import ElasticsearchClient
from ...infrastructure.ranking_store import RankingEntry, RankingStore
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from ...infrastructure.oracle.client import OracleClient
from ...infrastructure.oracle.ranking_repository import RankingOracleRepository
from ...infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl from ...infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl
from ...domain.services.calculador_pontuacao import CalculadorPontuacao
from .job_status import job_status from .job_status import job_status
@@ -17,16 +13,12 @@ class ProcessarRankingJob:
def __init__( def __init__(
self, self,
es_client: ElasticsearchClient, es_client: ElasticsearchClient,
oracle_remote_client: OracleClient, ranking_store: RankingStore,
oracle_local_client: OracleClient,
ranking_repo: RankingOracleRepository,
): ):
self.es_client = es_client self.es_client = es_client
self.oracle_remote_client = oracle_remote_client self.ranking_store = ranking_store
self.oracle_local_client = oracle_local_client
self.ranking_repo = ranking_repo
self.consultor_repo = ConsultorRepositoryImpl(es_client, oracle_client=None) self.consultor_repo = ConsultorRepositoryImpl(es_client, oracle_client=None)
self.calculador = CalculadorPontuacao() self._consultores: List[dict] = []
async def executar(self, limpar_antes: bool = True) -> Dict[str, Any]: async def executar(self, limpar_antes: bool = True) -> Dict[str, Any]:
if job_status.is_running: if job_status.is_running:
@@ -36,28 +28,26 @@ class ProcessarRankingJob:
total = await self.es_client.contar_com_atuacoes() total = await self.es_client.contar_com_atuacoes()
job_status.iniciar(total_esperado=total) job_status.iniciar(total_esperado=total)
if limpar_antes: self._consultores = []
job_status.mensagem = "Limpando tabela de ranking..." job_status.mensagem = "Iniciando processamento do ranking via Scroll API (Elasticsearch)..."
self.ranking_repo.limpar_tabela()
job_status.mensagem = "Iniciando processamento via Scroll API..."
resultado = await self.es_client.buscar_todos_consultores( resultado = await self.es_client.buscar_todos_consultores(
callback=self._processar_batch, callback=self._processar_batch,
batch_size=5000 batch_size=5000
) )
job_status.mensagem = "Atualizando posições no ranking..." job_status.mensagem = "Ordenando e gerando posições..."
self.ranking_repo.atualizar_posicoes() entries = self._gerar_entries_ordenadas(self._consultores)
await self.ranking_store.set_entries(entries)
estatisticas = self.ranking_repo.obter_estatisticas() estatisticas = self._obter_estatisticas(entries)
job_status.finalizar(sucesso=True) job_status.finalizar(sucesso=True)
return { return {
"sucesso": True, "sucesso": True,
"total_processados": resultado["processados"], "total_processados": resultado.get("processados", len(entries)),
"total_batches": resultado["batches"], "total_batches": resultado.get("batches", 0),
"tempo_decorrido": job_status.tempo_decorrido, "tempo_decorrido": job_status.tempo_decorrido,
"estatisticas": estatisticas "estatisticas": estatisticas
} }
@@ -67,26 +57,11 @@ class ProcessarRankingJob:
raise RuntimeError(f"Erro ao processar ranking: {e}") raise RuntimeError(f"Erro ao processar ranking: {e}")
async def _processar_batch(self, docs: list, progress: dict) -> None: async def _processar_batch(self, docs: list, progress: dict) -> None:
consultores_para_inserir = []
for doc in docs: for doc in docs:
try: try:
consultor = await self.consultor_repo._construir_consultor(doc) consultor = await self.consultor_repo._construir_consultor(doc)
consultor_dict = { self._consultores.append(self._gerar_json_detalhes(consultor))
"id_pessoa": consultor.id_pessoa,
"nome": consultor.nome,
"pontuacao_total": consultor.pontuacao_total,
"componente_a": consultor.pontuacao_bloco_a,
"componente_b": 0,
"componente_c": consultor.pontuacao_bloco_c,
"componente_d": consultor.pontuacao_bloco_d,
"ativo": consultor.ativo,
"anos_atuacao": consultor.anos_atuacao,
"detalhes": self._gerar_json_detalhes(consultor)
}
consultores_para_inserir.append(consultor_dict)
except Exception as e: except Exception as e:
import traceback import traceback
@@ -94,9 +69,6 @@ class ProcessarRankingJob:
logger.debug(f"Traceback: {traceback.format_exc()}") logger.debug(f"Traceback: {traceback.format_exc()}")
continue continue
if consultores_para_inserir:
self.ranking_repo.inserir_batch(consultores_para_inserir)
job_status.atualizar_progresso( job_status.atualizar_progresso(
processados=progress["processados"], processados=progress["processados"],
batch_atual=progress["batch_atual"], batch_atual=progress["batch_atual"],
@@ -104,10 +76,29 @@ class ProcessarRankingJob:
) )
def _gerar_json_detalhes(self, consultor) -> dict: def _gerar_json_detalhes(self, consultor) -> dict:
bloco_b = 0 # reservado no V1 (dados incompletos)
pontuacao = consultor.pontuacao.to_dict() if consultor.pontuacao else None
if isinstance(pontuacao, dict):
pontuacao = dict(pontuacao)
pontuacao["bloco_b"] = {"bloco": "B", "total": bloco_b, "atuacoes": []}
pontuacao["pontuacao_total"] = (
pontuacao.get("pontuacao_total", 0) + bloco_b
if isinstance(pontuacao.get("pontuacao_total"), (int, float))
else consultor.pontuacao_total + bloco_b
)
return { return {
"id_pessoa": consultor.id_pessoa, "id_pessoa": consultor.id_pessoa,
"nome": consultor.nome, "nome": consultor.nome,
"cpf": consultor.cpf, "posicao": None,
"pontuacao_total": consultor.pontuacao_total + bloco_b,
"bloco_a": consultor.pontuacao_bloco_a,
"bloco_b": bloco_b,
"bloco_c": consultor.pontuacao_bloco_c,
"bloco_d": consultor.pontuacao_bloco_d,
"ativo": consultor.ativo,
"anos_atuacao": consultor.anos_atuacao,
"coordenador_ppg": consultor.coordenador_ppg,
"coordenacoes_capes": [ "coordenacoes_capes": [
{ {
"codigo": c.codigo, "codigo": c.codigo,
@@ -115,7 +106,8 @@ class ProcessarRankingJob:
"area_avaliacao": c.area_avaliacao, "area_avaliacao": c.area_avaliacao,
"inicio": c.periodo.inicio.isoformat() if c.periodo.inicio else None, "inicio": c.periodo.inicio.isoformat() if c.periodo.inicio else None,
"fim": c.periodo.fim.isoformat() if c.periodo.fim else None, "fim": c.periodo.fim.isoformat() if c.periodo.fim else None,
"ativo": c.periodo.ativo "ativo": c.periodo.ativo,
"presidente": c.presidente,
} }
for c in consultor.coordenacoes_capes for c in consultor.coordenacoes_capes
], ],
@@ -153,7 +145,8 @@ class ProcessarRankingJob:
"codigo": p.codigo, "codigo": p.codigo,
"tipo": p.tipo, "tipo": p.tipo,
"nome_premio": p.nome_premio, "nome_premio": p.nome_premio,
"ano": p.ano "ano": p.ano,
"papel": p.papel,
} }
for p in consultor.premiacoes for p in consultor.premiacoes
], ],
@@ -179,7 +172,10 @@ class ProcessarRankingJob:
"codigo": o.codigo, "codigo": o.codigo,
"tipo": o.tipo, "tipo": o.tipo,
"nivel": o.nivel, "nivel": o.nivel,
"ano": o.ano "ano": o.ano,
"coorientacao": o.coorientacao,
"premiada": o.premiada,
"premiacao_tipo": o.premiacao_tipo,
} }
for o in consultor.orientacoes for o in consultor.orientacoes
], ],
@@ -192,5 +188,68 @@ class ProcessarRankingJob:
} }
for m in consultor.membros_banca for m in consultor.membros_banca
], ],
"pontuacao": consultor.pontuacao.to_dict() if consultor.pontuacao else None "pontuacao": pontuacao,
}
@staticmethod
def _gerar_entries_ordenadas(consultores: List[dict]) -> List[RankingEntry]:
consultores_ordenados = sorted(
consultores,
key=lambda c: (int(c.get("pontuacao_total", 0)), -int(c.get("id_pessoa", 0))),
reverse=True,
)
entries: List[RankingEntry] = []
for idx, c in enumerate(consultores_ordenados, start=1):
c["posicao"] = idx
entries.append(
RankingEntry(
id_pessoa=int(c["id_pessoa"]),
nome=str(c.get("nome", "")),
posicao=idx,
pontuacao_total=int(c.get("pontuacao_total", 0)),
bloco_a=int(c.get("bloco_a", 0)),
bloco_b=int(c.get("bloco_b", 0)),
bloco_c=int(c.get("bloco_c", 0)),
bloco_d=int(c.get("bloco_d", 0)),
ativo=bool(c.get("ativo", False)),
anos_atuacao=float(c.get("anos_atuacao", 0) or 0),
detalhes=c,
)
)
return entries
@staticmethod
def _obter_estatisticas(entries: List[RankingEntry]) -> Dict[str, Any]:
if not entries:
return {
"total_consultores": 0,
"total_ativos": 0,
"total_inativos": 0,
"ultima_atualizacao": None,
"pontuacao_media": 0,
"pontuacao_maxima": 0,
"pontuacao_minima": 0,
"media_componentes": {"a": 0, "b": 0, "c": 0, "d": 0},
}
total = len(entries)
ativos = sum(1 for e in entries if e.ativo)
inativos = total - ativos
totais = [e.pontuacao_total for e in entries]
media_total = sum(totais) / total if total else 0
return {
"total_consultores": total,
"total_ativos": ativos,
"total_inativos": inativos,
"ultima_atualizacao": None,
"pontuacao_media": float(round(media_total, 2)),
"pontuacao_maxima": float(max(totais) if totais else 0),
"pontuacao_minima": float(min(totais) if totais else 0),
"media_componentes": {
"a": float(round(sum(e.bloco_a for e in entries) / total, 2)),
"b": float(round(sum(e.bloco_b for e in entries) / total, 2)),
"c": float(round(sum(e.bloco_c for e in entries) / total, 2)),
"d": float(round(sum(e.bloco_d for e in entries) / total, 2)),
},
} }

View File

@@ -4,15 +4,13 @@ from datetime import datetime, time, timedelta
from typing import Optional from typing import Optional
from .processar_ranking import ProcessarRankingJob from .processar_ranking import ProcessarRankingJob
from .popular_componente_b_job import PopularComponenteBJob
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RankingScheduler: class RankingScheduler:
def __init__(self, job: ProcessarRankingJob, job_componente_b: PopularComponenteBJob | None = None): def __init__(self, job: ProcessarRankingJob):
self.job = job self.job = job
self.job_componente_b = job_componente_b
self.task: Optional[asyncio.Task] = None self.task: Optional[asyncio.Task] = None
self.running = False self.running = False
@@ -45,10 +43,6 @@ class RankingScheduler:
logger.info("Executando job de ranking automático") logger.info("Executando job de ranking automático")
await self.job.executar(limpar_antes=True) await self.job.executar(limpar_antes=True)
if self.job_componente_b:
logger.info("Executando popular_componente_b após ranking")
await asyncio.to_thread(self.job_componente_b.executar)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("Scheduler cancelado") logger.info("Scheduler cancelado")
break break

View File

@@ -10,14 +10,16 @@ class ObterConsultorUseCase:
self.repository = repository self.repository = repository
self.ranking_use_case = ObterRankingUseCase(repository) self.ranking_use_case = ObterRankingUseCase(repository)
async def executar(self, id_pessoa: int) -> Optional[ConsultorDetalhadoDTO]: async def executar(self, id_pessoa: int, rank: Optional[int] = None) -> Optional[ConsultorDetalhadoDTO]:
consultor = await self.repository.buscar_por_id(id_pessoa) consultor = await self.repository.buscar_por_id(id_pessoa)
if not consultor: if not consultor:
return None return None
ranking_completo = await self.repository.buscar_ranking(limite=1000) if rank is None:
rank = next( # Fallback legado: pode ser incompleto em grandes bases.
(idx + 1 for idx, c in enumerate(ranking_completo) if c.id_pessoa == id_pessoa), None ranking_completo = await self.repository.buscar_ranking(limite=1000)
) rank = next(
(idx + 1 for idx, c in enumerate(ranking_completo) if c.id_pessoa == id_pessoa), None
)
return self.ranking_use_case._converter_para_dto_detalhado(consultor, rank or 0) return self.ranking_use_case._converter_para_dto_detalhado(consultor, rank or 0)

View File

@@ -58,10 +58,10 @@ class ObterRankingUseCase:
return ConsultorDetalhadoDTO( return ConsultorDetalhadoDTO(
id_pessoa=consultor.id_pessoa, id_pessoa=consultor.id_pessoa,
nome=consultor.nome, nome=consultor.nome,
cpf=consultor.cpf,
anos_atuacao=consultor.anos_atuacao, anos_atuacao=consultor.anos_atuacao,
ativo=consultor.ativo, ativo=consultor.ativo,
veterano=consultor.veterano, veterano=consultor.veterano,
coordenador_ppg=consultor.coordenador_ppg,
coordenacoes_capes=[ coordenacoes_capes=[
CoordenacaoCapesDTO( CoordenacaoCapesDTO(
codigo=cc.codigo, codigo=cc.codigo,
@@ -75,6 +75,7 @@ class ObterRankingUseCase:
), ),
areas_adicionais=cc.areas_adicionais, areas_adicionais=cc.areas_adicionais,
ja_coordenou_antes=cc.ja_coordenou_antes, ja_coordenou_antes=cc.ja_coordenou_antes,
presidente=cc.presidente,
) )
for cc in consultor.coordenacoes_capes for cc in consultor.coordenacoes_capes
], ],
@@ -117,6 +118,7 @@ class ObterRankingUseCase:
tipo=p.tipo, tipo=p.tipo,
nome_premio=p.nome_premio, nome_premio=p.nome_premio,
ano=p.ano, ano=p.ano,
papel=p.papel,
) )
for p in consultor.premiacoes for p in consultor.premiacoes
], ],
@@ -143,6 +145,9 @@ class ObterRankingUseCase:
tipo=o.tipo, tipo=o.tipo,
nivel=o.nivel, nivel=o.nivel,
ano=o.ano, ano=o.ano,
coorientacao=o.coorientacao,
premiada=o.premiada,
premiacao_tipo=o.premiacao_tipo,
) )
for o in consultor.orientacoes for o in consultor.orientacoes
], ],

View File

@@ -14,6 +14,7 @@ class CoordenacaoCapes:
periodo: Periodo periodo: Periodo
areas_adicionais: List[str] = field(default_factory=list) areas_adicionais: List[str] = field(default_factory=list)
ja_coordenou_antes: bool = False ja_coordenou_antes: bool = False
presidente: bool = False
@dataclass @dataclass
@@ -51,6 +52,7 @@ class Premiacao:
tipo: str tipo: str
nome_premio: str nome_premio: str
ano: int ano: int
papel: Optional[str] = None
@dataclass @dataclass
@@ -74,6 +76,9 @@ class Orientacao:
tipo: str tipo: str
nivel: str nivel: str
ano: Optional[int] = None ano: Optional[int] = None
coorientacao: bool = False
premiada: bool = False
premiacao_tipo: Optional[str] = None
@dataclass @dataclass
@@ -89,6 +94,7 @@ class Consultor:
id_pessoa: int id_pessoa: int
nome: str nome: str
cpf: Optional[str] = None cpf: Optional[str] = None
coordenador_ppg: bool = False
coordenacoes_capes: List[CoordenacaoCapes] = field(default_factory=list) coordenacoes_capes: List[CoordenacaoCapes] = field(default_factory=list)
consultoria: Optional[Consultoria] = None consultoria: Optional[Consultoria] = None
inscricoes: List[Inscricao] = field(default_factory=list) inscricoes: List[Inscricao] = field(default_factory=list)

View File

@@ -89,10 +89,19 @@ class CalculadorPontuacao:
tempo = min(anos_total * criterio.multiplicador_tempo, criterio.teto_tempo) tempo = min(anos_total * criterio.multiplicador_tempo, criterio.teto_tempo)
bonus = 0 bonus = 0
if consultoria.anos_consecutivos >= 8 and criterio.bonus_continuidade_8anos:
bonus += criterio.bonus_continuidade_8anos # Bônus de continuidade (escalonado, não cumulativo) - apenas CONS_ATIVO
if codigo == "CONS_ATIVO" and consultoria.retornos > 0 and criterio.bonus_retorno: if codigo == "CONS_ATIVO":
bonus += criterio.bonus_retorno if consultoria.anos_consecutivos >= 8:
bonus += 15
elif consultoria.anos_consecutivos >= 5:
bonus += 10
elif consultoria.anos_consecutivos >= 3:
bonus += 5
# Bônus de retorno (uma vez) - apenas CONS_ATIVO
if consultoria.retornos > 0 and criterio.bonus_retorno:
bonus += criterio.bonus_retorno
total_bruto = base + tempo + bonus total_bruto = base + tempo + bonus
total = min(total_bruto, criterio.teto) if criterio.teto > 0 else total_bruto total = min(total_bruto, criterio.teto) if criterio.teto > 0 else total_bruto

View File

@@ -101,7 +101,6 @@ CRITERIOS: Dict[str, CriterioPontuacao] = {
pontua_tempo=True, pontua_tempo=True,
multiplicador_tempo=5, multiplicador_tempo=5,
teto_tempo=50, teto_tempo=50,
bonus_continuidade_8anos=20,
bonus_retorno=15, bonus_retorno=15,
), ),
"CONS_HIST": CriterioPontuacao( "CONS_HIST": CriterioPontuacao(
@@ -113,7 +112,6 @@ CRITERIOS: Dict[str, CriterioPontuacao] = {
pontua_tempo=True, pontua_tempo=True,
multiplicador_tempo=5, multiplicador_tempo=5,
teto_tempo=50, teto_tempo=50,
bonus_continuidade_8anos=20,
), ),
"CONS_FALECIDO": CriterioPontuacao( "CONS_FALECIDO": CriterioPontuacao(
codigo="CONS_FALECIDO", codigo="CONS_FALECIDO",
@@ -124,7 +122,6 @@ CRITERIOS: Dict[str, CriterioPontuacao] = {
pontua_tempo=True, pontua_tempo=True,
multiplicador_tempo=5, multiplicador_tempo=5,
teto_tempo=50, teto_tempo=50,
bonus_continuidade_8anos=20,
), ),
"INSC_AUTOR": CriterioPontuacao( "INSC_AUTOR": CriterioPontuacao(
codigo="INSC_AUTOR", codigo="INSC_AUTOR",
@@ -154,7 +151,7 @@ CRITERIOS: Dict[str, CriterioPontuacao] = {
bloco=Bloco.D, bloco=Bloco.D,
tipo=TipoAtuacao.PAPEL, tipo=TipoAtuacao.PAPEL,
base=50, base=50,
teto=80, teto=100,
bonus_recorrencia_anual=3, bonus_recorrencia_anual=3,
teto_recorrencia=20, teto_recorrencia=20,
), ),
@@ -187,9 +184,23 @@ CRITERIOS: Dict[str, CriterioPontuacao] = {
codigo="BOL_BPQ_INTERMEDIARIO", codigo="BOL_BPQ_INTERMEDIARIO",
bloco=Bloco.D, bloco=Bloco.D,
tipo=TipoAtuacao.COMPETENCIA_RECONHECIMENTO, tipo=TipoAtuacao.COMPETENCIA_RECONHECIMENTO,
base=50,
teto=100,
),
"BOL_BPQ_SUP": CriterioPontuacao(
codigo="BOL_BPQ_SUP",
bloco=Bloco.D,
tipo=TipoAtuacao.COMPETENCIA_RECONHECIMENTO,
base=30, base=30,
teto=60, teto=60,
), ),
"BOL_BPQ_INT": CriterioPontuacao(
codigo="BOL_BPQ_INT",
bloco=Bloco.D,
tipo=TipoAtuacao.COMPETENCIA_RECONHECIMENTO,
base=50,
teto=100,
),
"PREMIACAO": CriterioPontuacao( "PREMIACAO": CriterioPontuacao(
codigo="PREMIACAO", codigo="PREMIACAO",
bloco=Bloco.D, bloco=Bloco.D,
@@ -201,14 +212,14 @@ CRITERIOS: Dict[str, CriterioPontuacao] = {
codigo="PREMIACAO_GP", codigo="PREMIACAO_GP",
bloco=Bloco.D, bloco=Bloco.D,
tipo=TipoAtuacao.COMPETENCIA_RECONHECIMENTO, tipo=TipoAtuacao.COMPETENCIA_RECONHECIMENTO,
base=50, base=30,
teto=60, teto=60,
), ),
"MENCAO": CriterioPontuacao( "MENCAO": CriterioPontuacao(
codigo="MENCAO", codigo="MENCAO",
bloco=Bloco.D, bloco=Bloco.D,
tipo=TipoAtuacao.COMPETENCIA_RECONHECIMENTO, tipo=TipoAtuacao.COMPETENCIA_RECONHECIMENTO,
base=30, base=10,
teto=20, teto=20,
), ),
"EVENTO": CriterioPontuacao( "EVENTO": CriterioPontuacao(
@@ -230,63 +241,63 @@ CRITERIOS: Dict[str, CriterioPontuacao] = {
bloco=Bloco.D, bloco=Bloco.D,
tipo=TipoAtuacao.PARTICIPACAO, tipo=TipoAtuacao.PARTICIPACAO,
base=15, base=15,
teto=0, teto=100,
), ),
"ORIENT_TESE": CriterioPontuacao( "ORIENT_TESE": CriterioPontuacao(
codigo="ORIENT_TESE", codigo="ORIENT_TESE",
bloco=Bloco.D, bloco=Bloco.D,
tipo=TipoAtuacao.PARTICIPACAO, tipo=TipoAtuacao.PARTICIPACAO,
base=10, base=10,
teto=0, teto=50,
), ),
"ORIENT_DISS": CriterioPontuacao( "ORIENT_DISS": CriterioPontuacao(
codigo="ORIENT_DISS", codigo="ORIENT_DISS",
bloco=Bloco.D, bloco=Bloco.D,
tipo=TipoAtuacao.PARTICIPACAO, tipo=TipoAtuacao.PARTICIPACAO,
base=5, base=5,
teto=0, teto=25,
), ),
"CO_ORIENT_POS_DOC": CriterioPontuacao( "CO_ORIENT_POS_DOC": CriterioPontuacao(
codigo="CO_ORIENT_POS_DOC", codigo="CO_ORIENT_POS_DOC",
bloco=Bloco.D, bloco=Bloco.D,
tipo=TipoAtuacao.PARTICIPACAO, tipo=TipoAtuacao.PARTICIPACAO,
base=7, base=7,
teto=0, teto=35,
), ),
"CO_ORIENT_TESE": CriterioPontuacao( "CO_ORIENT_TESE": CriterioPontuacao(
codigo="CO_ORIENT_TESE", codigo="CO_ORIENT_TESE",
bloco=Bloco.D, bloco=Bloco.D,
tipo=TipoAtuacao.PARTICIPACAO, tipo=TipoAtuacao.PARTICIPACAO,
base=5, base=5,
teto=0, teto=25,
), ),
"CO_ORIENT_DISS": CriterioPontuacao( "CO_ORIENT_DISS": CriterioPontuacao(
codigo="CO_ORIENT_DISS", codigo="CO_ORIENT_DISS",
bloco=Bloco.D, bloco=Bloco.D,
tipo=TipoAtuacao.PARTICIPACAO, tipo=TipoAtuacao.PARTICIPACAO,
base=3, base=3,
teto=0, teto=15,
), ),
"MB_BANCA_POS_DOC": CriterioPontuacao( "MB_BANCA_POS_DOC": CriterioPontuacao(
codigo="MB_BANCA_POS_DOC", codigo="MB_BANCA_POS_DOC",
bloco=Bloco.D, bloco=Bloco.D,
tipo=TipoAtuacao.PARTICIPACAO, tipo=TipoAtuacao.PARTICIPACAO,
base=3, base=3,
teto=0, teto=15,
), ),
"MB_BANCA_TESE": CriterioPontuacao( "MB_BANCA_TESE": CriterioPontuacao(
codigo="MB_BANCA_TESE", codigo="MB_BANCA_TESE",
bloco=Bloco.D, bloco=Bloco.D,
tipo=TipoAtuacao.PARTICIPACAO, tipo=TipoAtuacao.PARTICIPACAO,
base=3, base=3,
teto=0, teto=15,
), ),
"MB_BANCA_DISS": CriterioPontuacao( "MB_BANCA_DISS": CriterioPontuacao(
codigo="MB_BANCA_DISS", codigo="MB_BANCA_DISS",
bloco=Bloco.D, bloco=Bloco.D,
tipo=TipoAtuacao.PARTICIPACAO, tipo=TipoAtuacao.PARTICIPACAO,
base=2, base=2,
teto=0, teto=10,
), ),
} }

View File

@@ -3,11 +3,19 @@ from typing import Optional, Dict, Any
class ElasticsearchClient: 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.url = url.rstrip("/")
self.index = index self.index = index
self.user = user self.user = user
self.password = password self.password = password
self.verify_ssl = verify_ssl
self._client: Optional[httpx.AsyncClient] = None self._client: Optional[httpx.AsyncClient] = None
async def connect(self) -> None: async def connect(self) -> None:
@@ -21,7 +29,7 @@ class ElasticsearchClient:
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json" "Accept": "application/json"
}, },
verify=False, verify=self.verify_ssl,
timeout=120.0 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 ...domain.value_objects.periodo import Periodo, mesclar_periodos
from ..cache import ranking_cache from ..cache import ranking_cache
from ..elasticsearch.client import ElasticsearchClient from ..elasticsearch.client import ElasticsearchClient
from ..oracle.client import OracleClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ConsultorRepositoryImpl(ConsultorRepository): 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.es_client = es_client
self.oracle_client = oracle_client self.oracle_client = oracle_client
self.calculador = CalculadorPontuacao() self.calculador = CalculadorPontuacao()
@@ -75,6 +74,8 @@ class ConsultorRepositoryImpl(ConsultorRepository):
resultado = [] resultado = []
for coord in coordenacoes: for coord in coordenacoes:
dados_coord = coord.get("dadosCoordenacaoArea", {}) or {} 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")) inicio = self._parse_date(dados_coord.get("inicioVinculacao")) or self._parse_date(coord.get("inicio"))
if not inicio: if not inicio:
continue continue
@@ -97,10 +98,38 @@ class ConsultorRepositoryImpl(ConsultorRepository):
periodo=Periodo(inicio=inicio, fim=fim), periodo=Periodo(inicio=inicio, fim=fim),
areas_adicionais=[], areas_adicionais=[],
ja_coordenou_antes=len(resultado) > 0, ja_coordenou_antes=len(resultado) > 0,
presidente=presidente,
)) ))
return resultado 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]: 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"]] consultorias = [a for a in atuacoes if a.get("tipo") in ["Consultor", "Histórico de Consultoria"]]
if not consultorias: if not consultorias:
@@ -248,17 +277,19 @@ class ConsultorRepositoryImpl(ConsultorRepository):
dados = a.get("dadosPremiacaoPremio", {}) or a.get("dadosPremio", {}) or {} dados = a.get("dadosPremiacaoPremio", {}) or a.get("dadosPremio", {}) or {}
tipo_premiacao = dados.get("tipoPremiacao") or dados.get("premiacao") or "" tipo_premiacao = dados.get("tipoPremiacao") or dados.get("premiacao") or ""
nome_premio = dados.get("nomePremio") or dados.get("evento") or a.get("descricao", "") 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") ano = dados.get("ano")
if not ano: if not ano:
inicio = self._parse_date(a.get("inicio")) inicio = self._parse_date(a.get("inicio"))
ano = inicio.year if inicio else datetime.now().year ano = inicio.year if inicio else datetime.now().year
tipo_lower = tipo_premiacao.lower() tipo_lower = str(tipo_premiacao).lower()
nome_lower = nome_premio.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" codigo = "PREMIACAO"
elif "menção" in tipo_lower or "mencao" in tipo_lower or "honrosa" in tipo_lower: elif tipo_norm == "MENCAO":
codigo = "MENCAO" codigo = "MENCAO"
else: else:
codigo = "PREMIACAO_GP" codigo = "PREMIACAO_GP"
@@ -268,6 +299,7 @@ class ConsultorRepositoryImpl(ConsultorRepository):
tipo=tipo_premiacao, tipo=tipo_premiacao,
nome_premio=nome_premio, nome_premio=nome_premio,
ano=ano, ano=ano,
papel=papel,
)) ))
return premiacoes return premiacoes
@@ -283,10 +315,10 @@ class ConsultorRepositoryImpl(ConsultorRepository):
area = dados.get("areaConhecimento", "") or "" area = dados.get("areaConhecimento", "") or ""
nivel_lower = nivel.lower() nivel_lower = nivel.lower()
if "1a" in nivel_lower or "1b" in nivel_lower or "1c" in nivel_lower or "1d" in nivel_lower: if "1d" in nivel_lower or "2" in nivel_lower:
codigo = "BOL_BPQ_SUPERIOR" codigo = "BOL_BPQ_INT"
else: else:
codigo = "BOL_BPQ_INTERMEDIARIO" codigo = "BOL_BPQ_SUP"
bolsas.append(BolsaCNPQ( bolsas.append(BolsaCNPQ(
codigo=codigo, codigo=codigo,
@@ -331,6 +363,15 @@ class ConsultorRepositoryImpl(ConsultorRepository):
dados = a.get("dadosOrientacao", {}) or {} dados = a.get("dadosOrientacao", {}) or {}
nivel = dados.get("nivel", "") or dados.get("tipo", "") 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") ano = dados.get("ano")
if not ano: if not ano:
inicio = self._parse_date(a.get("inicio")) inicio = self._parse_date(a.get("inicio"))
@@ -349,6 +390,9 @@ class ConsultorRepositoryImpl(ConsultorRepository):
tipo=tipo, tipo=tipo,
nivel=nivel, nivel=nivel,
ano=ano, ano=ano,
coorientacao=False,
premiada=premiada,
premiacao_tipo=tipo_prem,
)) ))
return orientacoes return orientacoes
@@ -363,6 +407,15 @@ class ConsultorRepositoryImpl(ConsultorRepository):
dados = a.get("dadosOrientacao", {}) or a.get("dadosCoorientacao", {}) or {} dados = a.get("dadosOrientacao", {}) or a.get("dadosCoorientacao", {}) or {}
nivel = dados.get("nivel", "") or dados.get("tipo", "") 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") ano = dados.get("ano")
if not ano: if not ano:
inicio = self._parse_date(a.get("inicio")) inicio = self._parse_date(a.get("inicio"))
@@ -381,6 +434,9 @@ class ConsultorRepositoryImpl(ConsultorRepository):
tipo=tipo, tipo=tipo,
nivel=nivel, nivel=nivel,
ano=ano, ano=ano,
coorientacao=True,
premiada=premiada,
premiacao_tipo=tipo_prem,
)) ))
return coorientacoes return coorientacoes
@@ -432,11 +488,13 @@ class ConsultorRepositoryImpl(ConsultorRepository):
orientacoes = self._extrair_orientacoes(atuacoes) orientacoes = self._extrair_orientacoes(atuacoes)
coorientacoes = self._extrair_coorientacoes(atuacoes) coorientacoes = self._extrair_coorientacoes(atuacoes)
membros_banca = self._extrair_membros_banca(atuacoes) membros_banca = self._extrair_membros_banca(atuacoes)
coordenador_ppg = self._tem_coordenacao_ppg(atuacoes)
consultor = Consultor( consultor = Consultor(
id_pessoa=id_pessoa, id_pessoa=id_pessoa,
nome=dados_pessoais.get("nome", "N/A"), nome=dados_pessoais.get("nome", "N/A"),
cpf=dados_pessoais.get("cpf"), cpf=dados_pessoais.get("cpf"),
coordenador_ppg=coordenador_ppg,
coordenacoes_capes=coordenacoes_capes, coordenacoes_capes=coordenacoes_capes,
consultoria=consultoria, consultoria=consultoria,
inscricoes=inscricoes, inscricoes=inscricoes,

View File

@@ -8,13 +8,7 @@ from .routes import router
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from .config import settings from .config import settings
from .dependencies import ( from .dependencies import es_client, get_processar_job
es_client,
oracle_local_client,
oracle_remote_client,
get_processar_job,
get_popular_componente_b_job,
)
from ...application.jobs.scheduler import RankingScheduler from ...application.jobs.scheduler import RankingScheduler
@@ -22,24 +16,12 @@ from ...application.jobs.scheduler import RankingScheduler
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await es_client.connect() await es_client.connect()
try:
oracle_local_client.connect()
logger.info("Oracle LOCAL conectado (Docker)")
except Exception as e:
logger.warning(f"Oracle LOCAL não conectou: {e}")
try:
oracle_remote_client.connect()
logger.info("Oracle REMOTO conectado (CAPES)")
except Exception as e:
logger.warning(f"Oracle REMOTO não conectou: {e}. Sistema rodando sem Componente B (PPG).")
scheduler = None scheduler = None
try: try:
job = get_processar_job() if settings.SCHEDULER_ENABLED:
job_b = get_popular_componente_b_job() job = get_processar_job()
scheduler = RankingScheduler(job, job_componente_b=job_b) scheduler = RankingScheduler(job)
await scheduler.iniciar() await scheduler.iniciar(hora_alvo=settings.SCHEDULER_HOUR)
except Exception as e: except Exception as e:
logger.warning(f"Scheduler não iniciou: {e}") logger.warning(f"Scheduler não iniciou: {e}")
@@ -53,16 +35,6 @@ async def lifespan(app: FastAPI):
await es_client.close() await es_client.close()
try:
oracle_local_client.close()
except:
pass
try:
oracle_remote_client.close()
except:
pass
app = FastAPI( app = FastAPI(
title="Ranking de Consultores CAPES", title="Ranking de Consultores CAPES",

View File

@@ -10,16 +10,7 @@ class Settings(BaseSettings):
ES_INDEX: str = "atuacapes" ES_INDEX: str = "atuacapes"
ES_USER: str = "" ES_USER: str = ""
ES_PASSWORD: str = "" ES_PASSWORD: str = ""
ES_VERIFY_SSL: bool = True
# Oracle LOCAL (Docker) - Para salvar ranking
ORACLE_LOCAL_USER: str
ORACLE_LOCAL_PASSWORD: str
ORACLE_LOCAL_DSN: str
# Oracle REMOTO (CAPES) - Para ler dados de programas
ORACLE_REMOTE_USER: str
ORACLE_REMOTE_PASSWORD: str
ORACLE_REMOTE_DSN: str
API_HOST: str = "0.0.0.0" API_HOST: str = "0.0.0.0"
API_PORT: int = 8000 API_PORT: int = 8000
@@ -28,6 +19,8 @@ class Settings(BaseSettings):
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:5173" CORS_ORIGINS: str = "http://localhost:3000,http://localhost:5173"
LOG_LEVEL: str = "INFO" LOG_LEVEL: str = "INFO"
SCHEDULER_ENABLED: bool = False
SCHEDULER_HOUR: int = 3
@property @property
def cors_origins_list(self) -> List[str]: def cors_origins_list(self) -> List[str]:

View File

@@ -1,9 +1,7 @@
from ...infrastructure.elasticsearch.client import ElasticsearchClient from ...infrastructure.elasticsearch.client import ElasticsearchClient
from ...infrastructure.oracle.client import OracleClient
from ...infrastructure.oracle.ranking_repository import RankingOracleRepository
from ...infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl from ...infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl
from ...application.jobs.processar_ranking import ProcessarRankingJob from ...application.jobs.processar_ranking import ProcessarRankingJob
from ...application.jobs.popular_componente_b_job import PopularComponenteBJob from ...infrastructure.ranking_store import ranking_store, RankingStore
from .config import settings from .config import settings
@@ -11,41 +9,23 @@ es_client = ElasticsearchClient(
url=settings.ES_URL, url=settings.ES_URL,
index=settings.ES_INDEX, index=settings.ES_INDEX,
user=settings.ES_USER, user=settings.ES_USER,
password=settings.ES_PASSWORD password=settings.ES_PASSWORD,
) verify_ssl=settings.ES_VERIFY_SSL,
# Oracle LOCAL (Docker) - Para salvar ranking
oracle_local_client = OracleClient(
user=settings.ORACLE_LOCAL_USER,
password=settings.ORACLE_LOCAL_PASSWORD,
dsn=settings.ORACLE_LOCAL_DSN
)
# Oracle REMOTO (CAPES) - Para ler dados de programas
oracle_remote_client = OracleClient(
user=settings.ORACLE_REMOTE_USER,
password=settings.ORACLE_REMOTE_PASSWORD,
dsn=settings.ORACLE_REMOTE_DSN
) )
_repository: ConsultorRepositoryImpl = None _repository: ConsultorRepositoryImpl = None
_ranking_repository: RankingOracleRepository = None
_processar_job: ProcessarRankingJob = None _processar_job: ProcessarRankingJob = None
_popular_b_job: PopularComponenteBJob = None
def get_repository() -> ConsultorRepositoryImpl: def get_repository() -> ConsultorRepositoryImpl:
global _repository global _repository
if _repository is None: if _repository is None:
_repository = ConsultorRepositoryImpl(es_client=es_client, oracle_client=oracle_remote_client) _repository = ConsultorRepositoryImpl(es_client=es_client, oracle_client=None)
return _repository return _repository
def get_ranking_repository() -> RankingOracleRepository: def get_ranking_store() -> RankingStore:
global _ranking_repository return ranking_store
if _ranking_repository is None:
_ranking_repository = RankingOracleRepository(oracle_client=oracle_local_client)
return _ranking_repository
def get_processar_job() -> ProcessarRankingJob: def get_processar_job() -> ProcessarRankingJob:
@@ -53,18 +33,6 @@ def get_processar_job() -> ProcessarRankingJob:
if _processar_job is None: if _processar_job is None:
_processar_job = ProcessarRankingJob( _processar_job = ProcessarRankingJob(
es_client=es_client, es_client=es_client,
oracle_remote_client=oracle_remote_client, ranking_store=ranking_store,
oracle_local_client=oracle_local_client,
ranking_repo=get_ranking_repository()
) )
return _processar_job return _processar_job
def get_popular_componente_b_job() -> PopularComponenteBJob:
global _popular_b_job
if _popular_b_job is None:
_popular_b_job = PopularComponenteBJob(
oracle_local_client=oracle_local_client,
oracle_remote_client=oracle_remote_client
)
return _popular_b_job

View File

@@ -1,9 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks import asyncio
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Optional, List from typing import Optional, List
from ...application.use_cases.obter_ranking import ObterRankingUseCase from ...application.use_cases.obter_ranking import ObterRankingUseCase
from ...application.use_cases.obter_consultor import ObterConsultorUseCase from ...application.use_cases.obter_consultor import ObterConsultorUseCase
from ...application.mappers import RankingMapper
from ...infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl from ...infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl
from ..schemas.consultor_schema import ( from ..schemas.consultor_schema import (
RankingResponseSchema, RankingResponseSchema,
@@ -20,7 +21,7 @@ from ..schemas.ranking_schema import (
ProcessarRankingResponseSchema, ProcessarRankingResponseSchema,
ConsultaNomeSchema, ConsultaNomeSchema,
) )
from .dependencies import get_repository, get_ranking_repository, get_processar_job from .dependencies import get_repository, get_ranking_store, get_processar_job
from ...application.jobs.job_status import job_status from ...application.jobs.job_status import job_status
router = APIRouter(prefix="/api/v1", tags=["ranking"]) router = APIRouter(prefix="/api/v1", tags=["ranking"])
@@ -34,19 +35,34 @@ async def obter_ranking(
default=None, description="Filtrar por bloco (a, c, d)" default=None, description="Filtrar por bloco (a, c, d)"
), ),
repository: ConsultorRepositoryImpl = Depends(get_repository), repository: ConsultorRepositoryImpl = Depends(get_repository),
store = Depends(get_ranking_store),
): ):
if store.is_ready():
total, entries = store.get_slice(offset=offset, limit=limite)
consultores_schema = [
ConsultorResumoSchema(
id_pessoa=e.id_pessoa,
nome=e.nome,
anos_atuacao=e.anos_atuacao,
ativo=e.ativo,
veterano=e.anos_atuacao >= 10,
pontuacao_total=e.pontuacao_total,
bloco_a=e.bloco_a,
bloco_c=e.bloco_c,
bloco_d=e.bloco_d,
rank=e.posicao,
)
for e in entries
]
return RankingResponseSchema(
total=total, limite=limite, offset=offset, consultores=consultores_schema
)
use_case = ObterRankingUseCase(repository=repository) use_case = ObterRankingUseCase(repository=repository)
consultores_dto = await use_case.executar(limite=limite, componente=componente) consultores_dto = await use_case.executar(limite=limite, componente=componente)
total = await repository.contar_total() total = await repository.contar_total()
consultores_schema = [ConsultorResumoSchema(**vars(dto)) for dto in consultores_dto]
consultores_schema = [ return RankingResponseSchema(total=total, limite=limite, offset=offset, consultores=consultores_schema)
ConsultorResumoSchema(**vars(dto)) for dto in consultores_dto
]
return RankingResponseSchema(
total=total, limite=limite, offset=offset, consultores=consultores_schema
)
@router.get("/ranking/detalhado", response_model=RankingDetalhadoResponseSchema) @router.get("/ranking/detalhado", response_model=RankingDetalhadoResponseSchema)
@@ -73,9 +89,15 @@ async def obter_ranking_detalhado(
async def obter_consultor( async def obter_consultor(
id_pessoa: int, id_pessoa: int,
repository: ConsultorRepositoryImpl = Depends(get_repository), repository: ConsultorRepositoryImpl = Depends(get_repository),
store = Depends(get_ranking_store),
): ):
use_case = ObterConsultorUseCase(repository=repository) use_case = ObterConsultorUseCase(repository=repository)
consultor = await use_case.executar(id_pessoa=id_pessoa) rank = None
if store.is_ready():
found = store.get_by_id(id_pessoa)
rank = found.posicao if found else None
consultor = await use_case.executar(id_pessoa=id_pessoa, rank=rank)
if not consultor: if not consultor:
raise HTTPException(status_code=404, detail=f"Consultor {id_pessoa} não encontrado") raise HTTPException(status_code=404, detail=f"Consultor {id_pessoa} não encontrado")
@@ -93,14 +115,46 @@ async def ranking_paginado(
page: int = Query(default=1, ge=1, description="Número da página"), page: int = Query(default=1, ge=1, description="Número da página"),
size: int = Query(default=50, ge=1, le=1000, description="Tamanho da página (máx 1000)"), size: int = Query(default=50, ge=1, le=1000, description="Tamanho da página (máx 1000)"),
ativo: Optional[bool] = Query(default=None, description="Filtrar por status ativo"), ativo: Optional[bool] = Query(default=None, description="Filtrar por status ativo"),
ranking_repo = Depends(get_ranking_repository), store = Depends(get_ranking_store),
): ):
total = ranking_repo.contar_total(filtro_ativo=ativo) if not store.is_ready():
consultores = ranking_repo.buscar_paginado(page=page, size=size, filtro_ativo=ativo) raise HTTPException(
status_code=503,
detail="Ranking ainda não foi processado. Execute POST /api/v1/ranking/processar.",
)
total, entries = store.get_page(page=page, size=size, filtro_ativo=ativo)
total_pages = (total + size - 1) // size total_pages = (total + size - 1) // size
consultores_schema = [RankingMapper.consultor_ranking_to_schema(c) for c in consultores] consultores_schema = []
for e in entries:
d = e.detalhes
consultores_schema.append(
ConsultorRankingResumoSchema(
id_pessoa=e.id_pessoa,
nome=e.nome,
posicao=e.posicao,
pontuacao_total=float(e.pontuacao_total),
bloco_a=float(e.bloco_a),
bloco_b=float(e.bloco_b),
bloco_c=float(e.bloco_c),
bloco_d=float(e.bloco_d),
ativo=e.ativo,
anos_atuacao=float(e.anos_atuacao),
coordenador_ppg=bool(d.get("coordenador_ppg", False)),
consultoria=d.get("consultoria"),
coordenacoes_capes=d.get("coordenacoes_capes"),
inscricoes=d.get("inscricoes"),
avaliacoes_comissao=d.get("avaliacoes_comissao"),
premiacoes=d.get("premiacoes"),
bolsas_cnpq=d.get("bolsas_cnpq"),
participacoes=d.get("participacoes"),
orientacoes=d.get("orientacoes"),
membros_banca=d.get("membros_banca"),
pontuacao=d.get("pontuacao"),
)
)
return RankingPaginadoResponseSchema( return RankingPaginadoResponseSchema(
total=total, total=total,
@@ -115,9 +169,15 @@ async def ranking_paginado(
async def buscar_por_nome( async def buscar_por_nome(
nome: str = Query(..., min_length=3, description="Nome (ou parte) para buscar"), nome: str = Query(..., min_length=3, description="Nome (ou parte) para buscar"),
limit: int = Query(default=5, ge=1, le=20, description="Limite de resultados"), limit: int = Query(default=5, ge=1, le=20, description="Limite de resultados"),
ranking_repo = Depends(get_ranking_repository), store = Depends(get_ranking_store),
): ):
resultados = ranking_repo.buscar_por_nome(nome=nome, limit=limit) if not store.is_ready():
raise HTTPException(
status_code=503,
detail="Ranking ainda não foi processado. Execute POST /api/v1/ranking/processar.",
)
resultados = store.buscar_por_nome(nome=nome, limit=limit)
return [ return [
ConsultaNomeSchema( ConsultaNomeSchema(
id_pessoa=r["ID_PESSOA"], id_pessoa=r["ID_PESSOA"],
@@ -131,10 +191,53 @@ async def buscar_por_nome(
@router.get("/ranking/estatisticas", response_model=EstatisticasRankingSchema) @router.get("/ranking/estatisticas", response_model=EstatisticasRankingSchema)
async def ranking_estatisticas( async def ranking_estatisticas(
ranking_repo = Depends(get_ranking_repository), store = Depends(get_ranking_store),
): ):
estatisticas = ranking_repo.obter_estatisticas() if not store.is_ready():
distribuicao = ranking_repo.obter_distribuicao() raise HTTPException(
status_code=503,
detail="Ranking ainda não foi processado. Execute POST /api/v1/ranking/processar.",
)
total = store.total()
ativos = store.total(filtro_ativo=True)
inativos = total - ativos
entries = store.get_page(page=1, size=total)[1] if total else []
totais = [e.pontuacao_total for e in entries]
distribuicao = []
if total:
buckets = [
("800+", lambda x: x >= 800),
("600-799", lambda x: 600 <= x < 800),
("400-599", lambda x: 400 <= x < 600),
("200-399", lambda x: 200 <= x < 400),
("0-199", lambda x: x < 200),
]
for faixa, pred in buckets:
qtd = sum(1 for x in totais if pred(x))
distribuicao.append(
{
"faixa": faixa,
"quantidade": qtd,
"percentual": round((qtd * 100.0 / total), 2) if total else 0,
}
)
estatisticas = {
"total_consultores": total,
"total_ativos": ativos,
"total_inativos": inativos,
"ultima_atualizacao": store.last_update.isoformat() if store.last_update else None,
"pontuacao_media": (sum(totais) / total) if total else 0,
"pontuacao_maxima": max(totais) if totais else 0,
"pontuacao_minima": min(totais) if totais else 0,
"media_componentes": {
"a": (sum(e.bloco_a for e in entries) / total) if total else 0,
"b": (sum(e.bloco_b for e in entries) / total) if total else 0,
"c": (sum(e.bloco_c for e in entries) / total) if total else 0,
"d": (sum(e.bloco_d for e in entries) / total) if total else 0,
},
}
return EstatisticasRankingSchema( return EstatisticasRankingSchema(
total_consultores=estatisticas.get("total_consultores", 0), total_consultores=estatisticas.get("total_consultores", 0),
@@ -156,14 +259,13 @@ async def status_processamento():
@router.post("/ranking/processar", response_model=ProcessarRankingResponseSchema) @router.post("/ranking/processar", response_model=ProcessarRankingResponseSchema)
async def processar_ranking( async def processar_ranking(
background_tasks: BackgroundTasks,
request: ProcessarRankingRequestSchema = ProcessarRankingRequestSchema(), request: ProcessarRankingRequestSchema = ProcessarRankingRequestSchema(),
job = Depends(get_processar_job), job = Depends(get_processar_job),
): ):
if job_status.is_running: if job_status.is_running:
raise HTTPException(status_code=409, detail="Job já está em execução") raise HTTPException(status_code=409, detail="Job já está em execução")
background_tasks.add_task(job.executar, limpar_antes=request.limpar_antes) asyncio.create_task(job.executar(limpar_antes=request.limpar_antes))
return ProcessarRankingResponseSchema( return ProcessarRankingResponseSchema(
sucesso=True, sucesso=True,

View File

@@ -16,6 +16,7 @@ class CoordenacaoCapesSchema(BaseModel):
periodo: PeriodoSchema periodo: PeriodoSchema
areas_adicionais: List[str] areas_adicionais: List[str]
ja_coordenou_antes: bool ja_coordenou_antes: bool
presidente: bool = False
class ConsultoriaSchema(BaseModel): class ConsultoriaSchema(BaseModel):
@@ -48,6 +49,7 @@ class PremiacaoSchema(BaseModel):
tipo: str tipo: str
nome_premio: str nome_premio: str
ano: int ano: int
papel: Optional[str] = None
class BolsaCNPQSchema(BaseModel): class BolsaCNPQSchema(BaseModel):
@@ -68,6 +70,9 @@ class OrientacaoSchema(BaseModel):
tipo: str tipo: str
nivel: str nivel: str
ano: Optional[int] = None ano: Optional[int] = None
coorientacao: bool = False
premiada: bool = False
premiacao_tipo: Optional[str] = None
class MembroBancaSchema(BaseModel): class MembroBancaSchema(BaseModel):
@@ -115,10 +120,10 @@ class ConsultorResumoSchema(BaseModel):
class ConsultorDetalhadoSchema(BaseModel): class ConsultorDetalhadoSchema(BaseModel):
id_pessoa: int id_pessoa: int
nome: str nome: str
cpf: Optional[str] = None
anos_atuacao: float anos_atuacao: float
ativo: bool ativo: bool
veterano: bool veterano: bool
coordenador_ppg: bool = False
coordenacoes_capes: List[CoordenacaoCapesSchema] coordenacoes_capes: List[CoordenacaoCapesSchema]
consultoria: Optional[ConsultoriaSchema] = None consultoria: Optional[ConsultoriaSchema] = None
inscricoes: List[InscricaoSchema] inscricoes: List[InscricaoSchema]

View File

@@ -14,6 +14,7 @@ class ConsultorRankingResumoSchema(BaseModel):
bloco_d: float bloco_d: float
ativo: bool ativo: bool
anos_atuacao: float anos_atuacao: float
coordenador_ppg: Optional[bool] = None
consultoria: Optional[dict] = None consultoria: Optional[dict] = None
coordenacoes_capes: Optional[list] = None coordenacoes_capes: Optional[list] = None
inscricoes: Optional[list] = None inscricoes: Optional[list] = None

View File

@@ -22,9 +22,6 @@ services:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
networks: networks:
- shared_network - shared_network
depends_on:
oracle18c:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
frontend: frontend:
@@ -47,32 +44,6 @@ services:
- shared_network - shared_network
restart: unless-stopped restart: unless-stopped
oracle18c:
container_name: mqapilc_oracle18c
image: gvenzl/oracle-xe:18-slim
environment:
- ORACLE_PASSWORD=local123
- ORACLE_CHARACTERSET=AL32UTF8
- APP_USER=local123
- APP_USER_PASSWORD=local123
- TZ=America/Sao_Paulo
ports:
- "1521:1521"
- "5500:5500"
volumes:
- oracle_data:/opt/oracle/oradata
healthcheck:
test: ["CMD", "bash", "-c", "echo 'SELECT 1 FROM DUAL;' | sqlplus -s SYSTEM/\"$${ORACLE_PASSWORD}\"@localhost:1521/XEPDB1"]
interval: 30s
timeout: 10s
retries: 20
networks:
- shared_network
networks: networks:
shared_network: shared_network:
external: true external: true
volumes:
oracle_data:
driver: local

View File

@@ -15,7 +15,7 @@ for i in {1..30}; do
if docker compose exec backend python - <<'PY' >/dev/null 2>&1; then if docker compose exec backend python - <<'PY' >/dev/null 2>&1; then
import httpx, sys import httpx, sys
try: try:
r = httpx.get("http://localhost:8000/api/v1/health", verify=False, timeout=15) r = httpx.get("http://localhost:8000/api/v1/health", timeout=15)
if r.status_code == 200: if r.status_code == 200:
sys.exit(0) sys.exit(0)
except Exception: except Exception:
@@ -36,7 +36,7 @@ done
echo "[4/5] Disparando job do ranking (limpar_antes=true)..." echo "[4/5] Disparando job do ranking (limpar_antes=true)..."
docker compose exec backend python - <<'PY' docker compose exec backend python - <<'PY'
import httpx import httpx
client = httpx.Client(verify=False, timeout=120) client = httpx.Client(timeout=120)
resp = client.post("http://localhost:8000/api/v1/ranking/processar", json={"limpar_antes": True}) resp = client.post("http://localhost:8000/api/v1/ranking/processar", json={"limpar_antes": True})
print("POST /api/v1/ranking/processar ->", resp.status_code, resp.text) print("POST /api/v1/ranking/processar ->", resp.status_code, resp.text)
PY PY
@@ -44,7 +44,7 @@ PY
echo "[5/5] Acompanhando status até finalizar..." echo "[5/5] Acompanhando status até finalizar..."
docker compose exec backend python - <<'PY' docker compose exec backend python - <<'PY'
import httpx, time import httpx, time
client = httpx.Client(verify=False, timeout=120) client = httpx.Client(timeout=120)
while True: while True:
r = client.get("http://localhost:8000/api/v1/ranking/status") r = client.get("http://localhost:8000/api/v1/ranking/status")
data = r.json() data = r.json()

View File

@@ -17,7 +17,7 @@ pkill -f "ngrok http 5173" 2>/dev/null
echo "[4/5] Criando rede e subindo containers..." echo "[4/5] Criando rede e subindo containers..."
docker network create shared_network 2>/dev/null docker network create shared_network 2>/dev/null
docker compose up -d backend frontend docker compose up -d backend frontend
echo "Aguardando backend subir (e Oracle ficar healthy)..." echo "Aguardando backend subir..."
sleep 10 sleep 10
docker compose up -d backend frontend >/dev/null docker compose up -d backend frontend >/dev/null
@@ -33,9 +33,6 @@ if ! docker ps | grep -q ranking_frontend; then
exit 1 exit 1
fi fi
echo "[4b/5] Rodando bootstrap do ranking dentro do backend..."
docker exec ranking_backend bash -lc "ORACLE_LOCAL_DSN=oracle18c:1521/XEPDB1 /app/scripts/bootstrap_ranking.sh"
echo "[5/5] Iniciando ngrok..." echo "[5/5] Iniciando ngrok..."
ngrok http 5173 --log=stdout > /tmp/ngrok-ranking.log 2>&1 & ngrok http 5173 --log=stdout > /tmp/ngrok-ranking.log 2>&1 &