feat: Sistema de Ranking de Consultores CAPES - versão inicial
Backend (FastAPI + DDD):
- Arquitetura DDD com camadas Domain, Application, Infrastructure, Interface
- Integração com Elasticsearch (ATUACAPES) para dados de consultores
- Integração com Oracle (SUCUPIRA_PAINEL) para coordenações PPG
- Cálculo dos 4 componentes de pontuação (A, B, C, D)
- Cache em memória para otimização de performance
- API REST com endpoints /ranking, /ranking/detalhado, /consultor/{id}
Frontend (React + Vite):
- Interface responsiva com cards expansíveis
- Visualização detalhada de pontuação por componente
- Filtro por quantidade de consultores (Top 10, 50, 100, etc)
Docker:
- docker-compose com shared_network externa
- Backend com Oracle Instant Client
- Frontend com Vite dev server
This commit is contained in:
0
backend/src/infrastructure/__init__.py
Normal file
0
backend/src/infrastructure/__init__.py
Normal file
104
backend/src/infrastructure/elasticsearch/client.py
Normal file
104
backend/src/infrastructure/elasticsearch/client.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class ElasticsearchClient:
|
||||
def __init__(self, url: str, index: str, user: str = "", password: str = ""):
|
||||
self.url = url.rstrip("/")
|
||||
self.index = index
|
||||
self.user = user
|
||||
self.password = password
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
auth = None
|
||||
if self.user and self.password:
|
||||
auth = httpx.BasicAuth(self.user, self.password)
|
||||
|
||||
self._client = httpx.AsyncClient(
|
||||
auth=auth,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
},
|
||||
verify=False,
|
||||
timeout=120.0
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
|
||||
@property
|
||||
def client(self) -> httpx.AsyncClient:
|
||||
if not self._client:
|
||||
raise RuntimeError("Cliente Elasticsearch não conectado. Execute connect() primeiro.")
|
||||
return self._client
|
||||
|
||||
async def buscar_por_id(self, id_pessoa: int) -> Optional[dict]:
|
||||
try:
|
||||
query = {
|
||||
"query": {"term": {"id": id_pessoa}},
|
||||
"_source": ["id", "dadosPessoais", "atuacoes"],
|
||||
"size": 1,
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
f"{self.url}/{self.index}/_search",
|
||||
json=query
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
hits = data.get("hits", {}).get("hits", [])
|
||||
return hits[0]["_source"] if hits else None
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Erro ao buscar consultor {id_pessoa}: {e}")
|
||||
|
||||
async def buscar_com_atuacoes(self, size: int = 1000, from_: int = 0) -> list:
|
||||
try:
|
||||
query = {
|
||||
"query": {
|
||||
"nested": {
|
||||
"path": "atuacoes",
|
||||
"query": {"exists": {"field": "atuacoes.tipo"}}
|
||||
}
|
||||
},
|
||||
"_source": ["id", "dadosPessoais", "atuacoes"],
|
||||
"size": size,
|
||||
"from": from_,
|
||||
"sort": [{"id": "asc"}],
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
f"{self.url}/{self.index}/_search",
|
||||
json=query
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
return [hit["_source"] for hit in data.get("hits", {}).get("hits", [])]
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Erro ao buscar consultores: {e}")
|
||||
|
||||
async def contar_com_atuacoes(self) -> int:
|
||||
try:
|
||||
query = {
|
||||
"query": {
|
||||
"nested": {
|
||||
"path": "atuacoes",
|
||||
"query": {"exists": {"field": "atuacoes.tipo"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
f"{self.url}/{self.index}/_count",
|
||||
json=query
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
return data.get("count", 0)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Erro ao contar consultores: {e}")
|
||||
0
backend/src/infrastructure/mcp/__init__.py
Normal file
0
backend/src/infrastructure/mcp/__init__.py
Normal file
0
backend/src/infrastructure/oracle/__init__.py
Normal file
0
backend/src/infrastructure/oracle/__init__.py
Normal file
112
backend/src/infrastructure/oracle/client.py
Normal file
112
backend/src/infrastructure/oracle/client.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import cx_Oracle
|
||||
from typing import List, Dict, Any, Optional
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
class OracleClient:
|
||||
def __init__(self, user: str, password: str, dsn: str):
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.dsn = dsn
|
||||
self._pool: Optional[cx_Oracle.SessionPool] = None
|
||||
self._connected = False
|
||||
|
||||
def connect(self) -> None:
|
||||
try:
|
||||
self._pool = cx_Oracle.SessionPool(
|
||||
user=self.user,
|
||||
password=self.password,
|
||||
dsn=self.dsn,
|
||||
min=2,
|
||||
max=10,
|
||||
increment=1,
|
||||
encoding="UTF-8",
|
||||
)
|
||||
self._connected = True
|
||||
except Exception as e:
|
||||
print(f"AVISO Oracle: {e}")
|
||||
self._connected = False
|
||||
|
||||
def close(self) -> None:
|
||||
if self._pool:
|
||||
try:
|
||||
self._pool.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._pool is not None
|
||||
|
||||
@contextmanager
|
||||
def get_connection(self):
|
||||
if not self._pool:
|
||||
raise RuntimeError("Pool Oracle não conectado. Execute connect() primeiro.")
|
||||
conn = self._pool.acquire()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
self._pool.release(conn)
|
||||
|
||||
def executar_query(self, query: str, params: Optional[dict] = None) -> List[Dict[str, Any]]:
|
||||
if not self.is_connected:
|
||||
return []
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(query, params or {})
|
||||
columns = [col[0] for col in cursor.description]
|
||||
rows = cursor.fetchall()
|
||||
cursor.close()
|
||||
return [dict(zip(columns, row)) for row in rows]
|
||||
except Exception as e:
|
||||
print(f"AVISO Oracle: falha ao executar query: {e}")
|
||||
self._connected = False
|
||||
return []
|
||||
|
||||
def buscar_coordenacoes_programa(self, id_pessoa: int) -> List[Dict[str, Any]]:
|
||||
query = """
|
||||
SELECT
|
||||
c.ID_PESSOA,
|
||||
c.ID_PROGRAMA_SNPG,
|
||||
p.NM_PROGRAMA,
|
||||
p.CD_PROGRAMA_PPG,
|
||||
p.DS_CONCEITO AS NOTA_PPG,
|
||||
p.NM_PROGRAMA_MODALIDADE,
|
||||
aa.NM_AREA_AVALIACAO,
|
||||
c.DT_INICIO_VIGENCIA,
|
||||
c.DT_FIM_VIGENCIA
|
||||
FROM SUCUPIRA_PAINEL.VM_COORDENADOR c
|
||||
INNER JOIN SUCUPIRA_PAINEL.VM_PROGRAMA_SUCUPIRA p
|
||||
ON c.ID_PROGRAMA_SNPG = p.ID_PROGRAMA
|
||||
LEFT JOIN SUCUPIRA_PAINEL.VM_AREA_CONHECIMENTO ac
|
||||
ON p.ID_AREA_CONHECIMENTO_ATUAL = ac.ID_AREA_CONHECIMENTO
|
||||
LEFT JOIN SUCUPIRA_PAINEL.VM_AREA_AVALIACAO aa
|
||||
ON ac.ID_AREA_AVALIACAO = aa.ID_AREA_AVALIACAO
|
||||
WHERE c.ID_PESSOA = :id_pessoa
|
||||
ORDER BY c.DT_INICIO_VIGENCIA DESC
|
||||
"""
|
||||
return self.executar_query(query, {"id_pessoa": id_pessoa})
|
||||
|
||||
def buscar_todas_coordenacoes_programa(self) -> List[Dict[str, Any]]:
|
||||
query = """
|
||||
SELECT
|
||||
c.ID_PESSOA,
|
||||
c.ID_PROGRAMA_SNPG,
|
||||
p.NM_PROGRAMA,
|
||||
p.CD_PROGRAMA_PPG,
|
||||
p.DS_CONCEITO AS NOTA_PPG,
|
||||
p.NM_PROGRAMA_MODALIDADE,
|
||||
aa.NM_AREA_AVALIACAO,
|
||||
c.DT_INICIO_VIGENCIA,
|
||||
c.DT_FIM_VIGENCIA
|
||||
FROM SUCUPIRA_PAINEL.VM_COORDENADOR c
|
||||
INNER JOIN SUCUPIRA_PAINEL.VM_PROGRAMA_SUCUPIRA p
|
||||
ON c.ID_PROGRAMA_SNPG = p.ID_PROGRAMA
|
||||
LEFT JOIN SUCUPIRA_PAINEL.VM_AREA_CONHECIMENTO ac
|
||||
ON p.ID_AREA_CONHECIMENTO_ATUAL = ac.ID_AREA_CONHECIMENTO
|
||||
LEFT JOIN SUCUPIRA_PAINEL.VM_AREA_AVALIACAO aa
|
||||
ON ac.ID_AREA_AVALIACAO = aa.ID_AREA_AVALIACAO
|
||||
ORDER BY c.ID_PESSOA, c.DT_INICIO_VIGENCIA DESC
|
||||
"""
|
||||
return self.executar_query(query)
|
||||
@@ -0,0 +1,287 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from dateutil import parser as date_parser
|
||||
import asyncio
|
||||
|
||||
from ...domain.entities.consultor import (
|
||||
Consultor,
|
||||
CoordenacaoCapes,
|
||||
CoordenacaoPrograma,
|
||||
Consultoria,
|
||||
Premiacao,
|
||||
)
|
||||
from ...domain.repositories.consultor_repository import ConsultorRepository
|
||||
from ...domain.services.calculador_pontuacao import CalculadorPontuacao
|
||||
from ...domain.value_objects.periodo import Periodo
|
||||
from ..elasticsearch.client import ElasticsearchClient
|
||||
from ..oracle.client import OracleClient
|
||||
|
||||
|
||||
class RankingCache:
|
||||
def __init__(self, ttl_seconds: int = 300):
|
||||
self.ttl = ttl_seconds
|
||||
self._cache: List[Consultor] = []
|
||||
self._last_update: Optional[datetime] = None
|
||||
self._loading = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
if not self._cache or not self._last_update:
|
||||
return False
|
||||
return (datetime.now() - self._last_update).total_seconds() < self.ttl
|
||||
|
||||
def get(self) -> List[Consultor]:
|
||||
return self._cache
|
||||
|
||||
def set(self, consultores: List[Consultor]) -> None:
|
||||
self._cache = consultores
|
||||
self._last_update = datetime.now()
|
||||
|
||||
|
||||
_ranking_cache = RankingCache(ttl_seconds=300)
|
||||
|
||||
|
||||
class ConsultorRepositoryImpl(ConsultorRepository):
|
||||
def __init__(self, es_client: ElasticsearchClient, oracle_client: OracleClient):
|
||||
self.es_client = es_client
|
||||
self.oracle_client = oracle_client
|
||||
self.calculador = CalculadorPontuacao()
|
||||
self.es_disponivel = True
|
||||
|
||||
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
return date_parser.parse(date_str, dayfirst=True)
|
||||
except:
|
||||
return None
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
datas_inicio = [
|
||||
self._parse_date(c.get("inicio"))
|
||||
for c in consultorias
|
||||
]
|
||||
datas_inicio = [d for d in datas_inicio if d]
|
||||
|
||||
datas_fim = [
|
||||
self._parse_date(c.get("fim"))
|
||||
for c in consultorias
|
||||
]
|
||||
datas_fim = [d for d in datas_fim if d]
|
||||
|
||||
if not datas_inicio:
|
||||
return None
|
||||
|
||||
limite_recente = datetime.now() - timedelta(days=730)
|
||||
eventos_recentes = sum(1 for d in datas_fim if d >= limite_recente)
|
||||
|
||||
areas = list({c.get("areaAvaliacao", "N/A") for c in consultorias if c.get("areaAvaliacao")})
|
||||
|
||||
vezes_responsavel = sum(1 for c in consultorias if c.get("responsavel", False))
|
||||
|
||||
return Consultoria(
|
||||
total_eventos=len(consultorias),
|
||||
eventos_recentes=eventos_recentes,
|
||||
primeiro_evento=min(datas_inicio),
|
||||
ultimo_evento=max(datas_fim) if datas_fim else datetime.now(),
|
||||
vezes_responsavel=vezes_responsavel,
|
||||
areas=areas,
|
||||
)
|
||||
|
||||
def _extrair_coordenacoes_capes(
|
||||
self, atuacoes: List[Dict[str, Any]]
|
||||
) -> List[CoordenacaoCapes]:
|
||||
coordenacoes = [
|
||||
a
|
||||
for a in atuacoes
|
||||
if a.get("tipo")
|
||||
in [
|
||||
"Coordenação de Área de Avaliação",
|
||||
"Histórico de Coordenação de Área de Avaliação",
|
||||
]
|
||||
]
|
||||
|
||||
resultado = []
|
||||
for coord in coordenacoes:
|
||||
inicio = self._parse_date(coord.get("inicio"))
|
||||
if not inicio:
|
||||
continue
|
||||
|
||||
tipo = self._inferir_tipo_coordenacao(coord)
|
||||
fim = self._parse_date(coord.get("fim"))
|
||||
|
||||
resultado.append(
|
||||
CoordenacaoCapes(
|
||||
tipo=tipo,
|
||||
area_avaliacao=coord.get("areaAvaliacao", "N/A"),
|
||||
periodo=Periodo(inicio=inicio, fim=fim),
|
||||
areas_adicionais=[],
|
||||
ja_coordenou_antes=False,
|
||||
)
|
||||
)
|
||||
|
||||
return resultado
|
||||
|
||||
def _inferir_tipo_coordenacao(self, coord: Dict[str, Any]) -> str:
|
||||
nome = coord.get("nome", "").lower()
|
||||
if "câmara" in nome or "camara" in nome:
|
||||
return "CAM"
|
||||
elif "mestrado profissional" in nome:
|
||||
return "CAJ-MP"
|
||||
elif "adjunta" in nome:
|
||||
return "CAJ"
|
||||
else:
|
||||
return "CA"
|
||||
|
||||
def _extrair_premiacoes(self, atuacoes: List[Dict[str, Any]]) -> List[Premiacao]:
|
||||
premiacoes_data = [
|
||||
a
|
||||
for a in atuacoes
|
||||
if a.get("tipo")
|
||||
in [
|
||||
"Premiação Prêmio",
|
||||
"Avaliação Prêmio",
|
||||
"Inscrição Prêmio",
|
||||
]
|
||||
]
|
||||
|
||||
premiacoes = []
|
||||
for prem in premiacoes_data:
|
||||
pontos = self._calcular_pontos_premiacao(prem.get("tipo", ""))
|
||||
inicio = self._parse_date(prem.get("inicio"))
|
||||
ano = inicio.year if inicio else datetime.now().year
|
||||
|
||||
premiacoes.append(
|
||||
Premiacao(
|
||||
tipo=prem.get("tipo", "N/A"),
|
||||
nome_premio=prem.get("descricao", "N/A"),
|
||||
ano=ano,
|
||||
pontos=pontos,
|
||||
)
|
||||
)
|
||||
|
||||
return premiacoes
|
||||
|
||||
def _calcular_pontos_premiacao(self, tipo: str) -> int:
|
||||
mapa = {
|
||||
"Premiação Prêmio": 60,
|
||||
"Avaliação Prêmio": 40,
|
||||
"Inscrição Prêmio": 20,
|
||||
}
|
||||
return mapa.get(tipo, 0)
|
||||
|
||||
async def _construir_consultor(self, doc: Dict[str, Any]) -> Consultor:
|
||||
id_pessoa = doc["id"]
|
||||
dados_pessoais = doc.get("dadosPessoais", {})
|
||||
atuacoes = doc.get("atuacoes", [])
|
||||
|
||||
consultoria = self._extrair_consultoria(atuacoes)
|
||||
coordenacoes_capes = self._extrair_coordenacoes_capes(atuacoes)
|
||||
premiacoes = self._extrair_premiacoes(atuacoes)
|
||||
|
||||
coordenacoes_programas_raw = []
|
||||
if self.oracle_client.is_connected:
|
||||
try:
|
||||
coordenacoes_programas_raw = self.oracle_client.buscar_coordenacoes_programa(id_pessoa)
|
||||
except Exception as e:
|
||||
print(f"AVISO Oracle: erro ao buscar coordenacoes do programa para {id_pessoa}: {e}")
|
||||
coordenacoes_programas = [
|
||||
CoordenacaoPrograma(
|
||||
id_programa=c["ID_PROGRAMA_SNPG"],
|
||||
nome_programa=c["NM_PROGRAMA"],
|
||||
codigo_programa=c["CD_PROGRAMA_PPG"],
|
||||
nota_ppg=c["NOTA_PPG"] or "N/A",
|
||||
modalidade=c["NM_PROGRAMA_MODALIDADE"] or "N/A",
|
||||
area_avaliacao=c["NM_AREA_AVALIACAO"] or "N/A",
|
||||
periodo=Periodo(
|
||||
inicio=c["DT_INICIO_VIGENCIA"],
|
||||
fim=c["DT_FIM_VIGENCIA"],
|
||||
),
|
||||
)
|
||||
for c in coordenacoes_programas_raw
|
||||
]
|
||||
|
||||
consultor = Consultor(
|
||||
id_pessoa=id_pessoa,
|
||||
nome=dados_pessoais.get("nome", "N/A"),
|
||||
cpf=dados_pessoais.get("cpf"),
|
||||
coordenacoes_capes=coordenacoes_capes,
|
||||
coordenacoes_programas=coordenacoes_programas,
|
||||
consultoria=consultoria,
|
||||
premiacoes=premiacoes,
|
||||
)
|
||||
|
||||
consultor.pontuacao = self.calculador.calcular_pontuacao_completa(consultor)
|
||||
|
||||
return consultor
|
||||
|
||||
async def buscar_por_id(self, id_pessoa: int) -> Optional[Consultor]:
|
||||
try:
|
||||
doc = await self.es_client.buscar_por_id(id_pessoa)
|
||||
except Exception as e:
|
||||
print(f"AVISO Elasticsearch: falha ao buscar consultor {id_pessoa}: {e}")
|
||||
return None
|
||||
if not doc:
|
||||
return None
|
||||
return await self._construir_consultor(doc)
|
||||
|
||||
async def buscar_todos(
|
||||
self, limite: int = 100, offset: int = 0, filtro_ativo: Optional[bool] = None
|
||||
) -> List[Consultor]:
|
||||
if not self.es_client._client or getattr(self.es_client._client, "is_closed", False):
|
||||
self.es_disponivel = False
|
||||
return []
|
||||
try:
|
||||
docs = await self.es_client.buscar_com_atuacoes(size=limite, from_=offset)
|
||||
self.es_disponivel = True
|
||||
except Exception as e:
|
||||
print(f"AVISO Elasticsearch: falha ao buscar consultores: {e}")
|
||||
self.es_disponivel = False
|
||||
return []
|
||||
consultores = [await self._construir_consultor(doc) for doc in docs]
|
||||
|
||||
if filtro_ativo is not None:
|
||||
consultores = [c for c in consultores if c.ativo == filtro_ativo]
|
||||
|
||||
return consultores
|
||||
|
||||
async def buscar_ranking(
|
||||
self, limite: int = 100, componente: Optional[str] = None
|
||||
) -> List[Consultor]:
|
||||
global _ranking_cache
|
||||
|
||||
if _ranking_cache.is_valid():
|
||||
consultores_ordenados = _ranking_cache.get()
|
||||
return consultores_ordenados[:limite]
|
||||
|
||||
async with _ranking_cache._lock:
|
||||
if _ranking_cache.is_valid():
|
||||
return _ranking_cache.get()[:limite]
|
||||
|
||||
tamanho_busca = 1000
|
||||
consultores = await self.buscar_todos(limite=tamanho_busca)
|
||||
consultores_ordenados = sorted(
|
||||
consultores, key=lambda c: c.pontuacao_total, reverse=True
|
||||
)
|
||||
_ranking_cache.set(consultores_ordenados)
|
||||
|
||||
return consultores_ordenados[:limite]
|
||||
|
||||
async def contar_total(self, filtro_ativo: Optional[bool] = None) -> int:
|
||||
if not self.es_disponivel:
|
||||
return 0
|
||||
if not self.es_client._client or getattr(self.es_client._client, "is_closed", False):
|
||||
self.es_disponivel = False
|
||||
return 0
|
||||
try:
|
||||
return await self.es_client.contar_com_atuacoes()
|
||||
except Exception as e:
|
||||
print(f"AVISO Elasticsearch: falha ao contar consultores: {e}")
|
||||
self.es_disponivel = False
|
||||
return 0
|
||||
Reference in New Issue
Block a user