Documenta queries ES com boost, query Oracle para PPG, regras de pontuação dos 4 componentes e cache do ranking.
442 lines
14 KiB
Markdown
442 lines
14 KiB
Markdown
# Queries Implementadas - Sistema de Ranking de Consultores CAPES
|
|
|
|
> Documentação das queries efetivamente implementadas no sistema, com regras de pontuação aplicadas.
|
|
|
|
## Visão Geral da Arquitetura
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ FLUXO DE DADOS │
|
|
├─────────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ 1. Elasticsearch (AtuaCAPES) │
|
|
│ └─> Query com boost por tipo de atuação │
|
|
│ └─> Retorna top 1000 candidatos pré-ordenados │
|
|
│ │
|
|
│ 2. Oracle (SUCUPIRA_PAINEL) - Opcional │
|
|
│ └─> Busca coordenações de PPG por ID_PESSOA │
|
|
│ │
|
|
│ 3. Python (Backend) │
|
|
│ └─> Calcula pontuação completa (A + B + C + D) │
|
|
│ └─> Reordena por pontuação real │
|
|
│ └─> Cache por 5 minutos │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Query Principal - Elasticsearch
|
|
|
|
### Objetivo
|
|
Buscar candidatos relevantes para o ranking, priorizando por tipo de atuação usando boost.
|
|
|
|
### Query Implementada
|
|
|
|
```json
|
|
{
|
|
"size": 1000,
|
|
"query": {
|
|
"bool": {
|
|
"should": [
|
|
{
|
|
"nested": {
|
|
"path": "atuacoes",
|
|
"query": {
|
|
"bool": {
|
|
"should": [
|
|
{"term": {"atuacoes.tipo": {"value": "Coordenação de Área de Avaliação", "boost": 10}}},
|
|
{"term": {"atuacoes.tipo": {"value": "Histórico de Coordenação de Área de Avaliação", "boost": 5}}}
|
|
]
|
|
}
|
|
},
|
|
"score_mode": "sum"
|
|
}
|
|
},
|
|
{
|
|
"nested": {
|
|
"path": "atuacoes",
|
|
"query": {
|
|
"bool": {
|
|
"should": [
|
|
{"term": {"atuacoes.tipo": {"value": "Consultor", "boost": 2}}},
|
|
{"term": {"atuacoes.tipo": {"value": "Histórico de Consultoria", "boost": 1}}}
|
|
]
|
|
}
|
|
},
|
|
"score_mode": "sum"
|
|
}
|
|
},
|
|
{
|
|
"nested": {
|
|
"path": "atuacoes",
|
|
"query": {
|
|
"bool": {
|
|
"should": [
|
|
{"term": {"atuacoes.tipo": {"value": "Premiação Prêmio", "boost": 3}}},
|
|
{"term": {"atuacoes.tipo": {"value": "Avaliação Prêmio", "boost": 2}}},
|
|
{"term": {"atuacoes.tipo": {"value": "Inscrição Prêmio", "boost": 1}}}
|
|
]
|
|
}
|
|
},
|
|
"score_mode": "sum"
|
|
}
|
|
}
|
|
],
|
|
"minimum_should_match": 1
|
|
}
|
|
},
|
|
"_source": ["id", "dadosPessoais", "atuacoes"],
|
|
"sort": [{"_score": "desc"}]
|
|
}
|
|
```
|
|
|
|
### Explicação dos Boosts
|
|
|
|
| Tipo de Atuação | Boost | Justificativa |
|
|
|-----------------|-------|---------------|
|
|
| Coordenação de Área de Avaliação | 10 | Máxima pontuação (base 200 pts) |
|
|
| Histórico de Coordenação de Área | 5 | Alta pontuação histórica |
|
|
| Premiação Prêmio | 3 | 60 pts por premiação |
|
|
| Consultor | 2 | Base 150 pts se ativo |
|
|
| Avaliação Prêmio | 2 | 40 pts por avaliação |
|
|
| Histórico de Consultoria | 1 | Base 100 pts |
|
|
| Inscrição Prêmio | 1 | 20 pts por inscrição |
|
|
|
|
### Estatísticas de Candidatos
|
|
|
|
| Tipo | Quantidade no ES |
|
|
|------|------------------|
|
|
| Coordenadores de área | 522 |
|
|
| Consultores | 52.551 |
|
|
| Premiações | 63.799 |
|
|
| **Total com atuações relevantes** | **90.482** |
|
|
|
|
---
|
|
|
|
## Query Oracle - Coordenação de Programa (PPG)
|
|
|
|
### Objetivo
|
|
Buscar coordenações de programa por ID_PESSOA para calcular o Componente B.
|
|
|
|
### Query Implementada
|
|
|
|
```sql
|
|
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
|
|
```
|
|
|
|
### Campos Retornados
|
|
|
|
| Campo | Tipo | Uso |
|
|
|-------|------|-----|
|
|
| ID_PROGRAMA_SNPG | NUMBER | Identificador único do programa |
|
|
| NM_PROGRAMA | VARCHAR2 | Nome do programa |
|
|
| CD_PROGRAMA_PPG | VARCHAR2 | Código do PPG |
|
|
| NOTA_PPG | VARCHAR2 | Nota CAPES (3-7, A) |
|
|
| NM_PROGRAMA_MODALIDADE | VARCHAR2 | Acadêmico/Profissional |
|
|
| NM_AREA_AVALIACAO | VARCHAR2 | Área de avaliação |
|
|
| DT_INICIO_VIGENCIA | DATE | Início da coordenação |
|
|
| DT_FIM_VIGENCIA | DATE | Fim (null = ativo) |
|
|
|
|
---
|
|
|
|
## Regras de Pontuação Implementadas
|
|
|
|
### Componente A - Coordenação CAPES (máx 450 pts)
|
|
|
|
**Arquivo**: `backend/src/domain/services/calculador_pontuacao.py`
|
|
|
|
```python
|
|
# Apenas coordenações ATIVAS pontuam
|
|
coord_atual = next((c for c in coordenacoes if c.periodo.ativo), None)
|
|
if not coord_atual:
|
|
return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0, retorno=0)
|
|
|
|
# Valores por tipo
|
|
base_map = {"CA": 200, "CAJ": 150, "CAJ-MP": 120, "CAM": 100}
|
|
tempo_max_map = {"CA": 100, "CAJ": 80, "CAJ-MP": 60, "CAM": 50}
|
|
bonus_atual_map = {"CA": 30, "CAJ": 20, "CAJ-MP": 15, "CAM": 10}
|
|
|
|
# Cálculo
|
|
base = base_map.get(coord_atual.tipo, 0)
|
|
anos = coord_atual.periodo.anos_decorridos
|
|
tempo = min(int(anos * 10), tempo_max_map.get(coord_atual.tipo, 0))
|
|
extras = min(len(coord_atual.areas_adicionais) * 20, 100)
|
|
bonus = bonus_atual_map.get(coord_atual.tipo, 0) if coord_atual.periodo.ativo else 0
|
|
retorno = 20 if coord_atual.ja_coordenou_antes else 0
|
|
```
|
|
|
|
**Tabela de Pontuação**:
|
|
|
|
| Tipo | Base | Tempo (máx) | Bônus Ativo | Áreas (máx) | Retorno |
|
|
|------|------|-------------|-------------|-------------|---------|
|
|
| CA | 200 | 100 (10 pts/ano) | 30 | 100 | 20 |
|
|
| CAJ | 150 | 80 (10 pts/ano) | 20 | 100 | 20 |
|
|
| CAJ-MP | 120 | 60 (10 pts/ano) | 15 | 100 | 20 |
|
|
| CAM | 100 | 50 (10 pts/ano) | 10 | 100 | 20 |
|
|
|
|
### Componente B - Coordenação PPG (máx 180 pts)
|
|
|
|
```python
|
|
if not coordenacoes:
|
|
return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0)
|
|
|
|
base = 70
|
|
anos_totais = sum(c.periodo.anos_decorridos for c in coordenacoes)
|
|
tempo = min(int(anos_totais * 5), 50)
|
|
|
|
programas_distintos = len({c.id_programa for c in coordenacoes})
|
|
extras = min((programas_distintos - 1) * 20, 40)
|
|
|
|
coord_ativa = any(c.periodo.ativo for c in coordenacoes)
|
|
bonus = 20 if coord_ativa else 0
|
|
```
|
|
|
|
**Tabela de Pontuação**:
|
|
|
|
| Critério | Pontos | Máximo |
|
|
|----------|--------|--------|
|
|
| Base (ser coordenador) | 70 | 70 |
|
|
| Tempo | 5 pts/ano | 50 |
|
|
| Programas extras | 20 pts/programa | 40 |
|
|
| Bônus ativo | 20 | 20 |
|
|
| **TOTAL** | - | **180** |
|
|
|
|
### Componente C - Consultoria (máx 230 pts)
|
|
|
|
```python
|
|
if not consultoria:
|
|
return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0)
|
|
|
|
# Base depende de ter eventos recentes (últimos 2 anos)
|
|
base = 150 if consultoria.eventos_recentes > 0 else 100
|
|
|
|
# Tempo desde primeiro evento
|
|
anos = (datetime.now() - consultoria.primeiro_evento).days / 365.25
|
|
tempo = min(int(anos * 5), 50)
|
|
|
|
# Extras
|
|
extras_eventos = min(consultoria.total_eventos * 2, 20)
|
|
extras_responsavel = min(consultoria.vezes_responsavel * 5, 25)
|
|
extras_areas = min((len(consultoria.areas) - 1) * 10, 30) if len(consultoria.areas) > 1 else 0
|
|
extras = extras_eventos + extras_responsavel + extras_areas
|
|
```
|
|
|
|
**Tabela de Pontuação**:
|
|
|
|
| Critério | Pontos | Máximo |
|
|
|----------|--------|--------|
|
|
| Base (ativo/recente) | 150 | 150 |
|
|
| Base (histórico) | 100 | 100 |
|
|
| Tempo | 5 pts/ano | 50 |
|
|
| Eventos | 2 pts/evento | 20 |
|
|
| Responsável | 5 pts/vez | 25 |
|
|
| Áreas extras | 10 pts/área | 30 |
|
|
| **TOTAL** | - | **230** |
|
|
|
|
### Componente D - Premiações (máx 180 pts)
|
|
|
|
```python
|
|
if not premiacoes:
|
|
return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0)
|
|
|
|
# Pontos por tipo de premiação
|
|
mapa = {
|
|
"Premiação Prêmio": 60,
|
|
"Avaliação Prêmio": 40,
|
|
"Inscrição Prêmio": 20,
|
|
}
|
|
|
|
total_pontos = sum(mapa.get(p.tipo, 0) for p in premiacoes)
|
|
total_pontos = min(total_pontos, 180) # Teto
|
|
|
|
return ComponentePontuacao(base=total_pontos, tempo=0, extras=0, bonus=0)
|
|
```
|
|
|
|
**Tabela de Pontuação**:
|
|
|
|
| Tipo | Pontos | Descrição |
|
|
|------|--------|-----------|
|
|
| Premiação Prêmio | 60 | Recebeu prêmio |
|
|
| Avaliação Prêmio | 40 | Participou de banca/comissão |
|
|
| Inscrição Prêmio | 20 | Inscreveu trabalho |
|
|
| **Máximo total** | **180** | - |
|
|
|
|
---
|
|
|
|
## Extração de Dados do Elasticsearch
|
|
|
|
### Coordenações CAPES
|
|
|
|
```python
|
|
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",
|
|
]
|
|
]
|
|
|
|
# Inferir tipo pelo campo 'nome'
|
|
def _inferir_tipo_coordenacao(coord):
|
|
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"
|
|
```
|
|
|
|
### Consultoria
|
|
|
|
```python
|
|
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"]
|
|
]
|
|
|
|
# Calcula eventos recentes (últimos 2 anos)
|
|
limite_recente = datetime.now() - timedelta(days=730)
|
|
eventos_recentes = sum(1 for d in datas_fim if d >= limite_recente)
|
|
|
|
# Extrai áreas únicas
|
|
areas = list({c.get("areaAvaliacao", "N/A") for c in consultorias if c.get("areaAvaliacao")})
|
|
|
|
# Conta vezes como responsável
|
|
vezes_responsavel = sum(1 for c in consultorias if c.get("responsavel", False))
|
|
```
|
|
|
|
### Premiações
|
|
|
|
```python
|
|
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",
|
|
]
|
|
]
|
|
|
|
for prem in premiacoes_data:
|
|
pontos = self._calcular_pontos_premiacao(prem.get("tipo", ""))
|
|
# tipo vem de atuacoes.tipo
|
|
# nome_premio vem de atuacoes.descricao
|
|
# ano vem de atuacoes.inicio (parse para extrair ano)
|
|
```
|
|
|
|
---
|
|
|
|
## Cache de Ranking
|
|
|
|
### Implementação
|
|
|
|
```python
|
|
class RankingCache:
|
|
def __init__(self, ttl_seconds: int = 300): # 5 minutos
|
|
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
|
|
```
|
|
|
|
### Performance
|
|
|
|
| Métrica | Valor |
|
|
|---------|-------|
|
|
| Primeira requisição (cold) | ~1m34s |
|
|
| Requisições cacheadas | ~0.27s |
|
|
| TTL do cache | 5 minutos |
|
|
| Candidatos processados | 1000 |
|
|
|
|
---
|
|
|
|
## Fluxo Completo de Ranking
|
|
|
|
```python
|
|
async def buscar_ranking(self, limite: int = 100) -> List[Consultor]:
|
|
# 1. Verificar cache
|
|
if _ranking_cache.is_valid():
|
|
return _ranking_cache.get()[:limite]
|
|
|
|
async with _ranking_cache._lock:
|
|
# 2. Double-check cache
|
|
if _ranking_cache.is_valid():
|
|
return _ranking_cache.get()[:limite]
|
|
|
|
# 3. Buscar candidatos do ES (ordenados por score ES)
|
|
tamanho_busca = max(limite * 3, 1000)
|
|
docs = await self.es_client.buscar_candidatos_ranking(size=tamanho_busca)
|
|
|
|
# 4. Construir consultores (calcula pontuação completa)
|
|
consultores = []
|
|
for doc in docs:
|
|
consultor = await self._construir_consultor(doc)
|
|
# _construir_consultor busca dados do Oracle se disponível
|
|
consultores.append(consultor)
|
|
|
|
# 5. Reordenar por pontuação real
|
|
consultores_ordenados = sorted(
|
|
consultores, key=lambda c: c.pontuacao_total, reverse=True
|
|
)
|
|
|
|
# 6. Cachear resultado
|
|
_ranking_cache.set(consultores_ordenados)
|
|
|
|
return consultores_ordenados[:limite]
|
|
```
|
|
|
|
---
|
|
|
|
## Limitações Conhecidas
|
|
|
|
1. **Cobertura de candidatos**: Buscamos 1000 candidatos do ES. Se alguém com alta pontuação estiver fora do top 1000 do score ES, não aparecerá.
|
|
|
|
2. **Oracle opcional**: Se Oracle não estiver disponível (TNS error no Docker), Componente B = 0 para todos.
|
|
|
|
3. **Tipos de coordenação**: Inferência pelo campo `nome` pode não ser 100% precisa.
|
|
|
|
4. **Datas inconsistentes**: ES usa formatos `dd/MM/yyyy` e `yyyy-MM-dd` misturados.
|
|
|
|
---
|
|
|
|
## Arquivos de Implementação
|
|
|
|
| Arquivo | Responsabilidade |
|
|
|---------|------------------|
|
|
| `backend/src/infrastructure/elasticsearch/client.py` | Query ES com boost |
|
|
| `backend/src/infrastructure/oracle/client.py` | Query Oracle PPG |
|
|
| `backend/src/infrastructure/repositories/consultor_repository_impl.py` | Cache e orquestração |
|
|
| `backend/src/domain/services/calculador_pontuacao.py` | Regras de pontuação |
|
|
| `backend/src/domain/entities/consultor.py` | Entidades de domínio |
|