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.
This commit is contained in:
441
docs/ranking-queries-implementadas.md
Normal file
441
docs/ranking-queries-implementadas.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# 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 |
|
||||
Reference in New Issue
Block a user