Files
ranking/docs/ranking-queries-implementadas.md
Frederico Castro 59ae516ee0 docs: Adiciona documentação das queries implementadas
Documenta queries ES com boost, query Oracle para PPG,
regras de pontuação dos 4 componentes e cache do ranking.
2025-12-09 10:04:19 -03:00

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 |