diff --git a/docs/ranking-queries-implementadas.md b/docs/ranking-queries-implementadas.md new file mode 100644 index 0000000..8cef215 --- /dev/null +++ b/docs/ranking-queries-implementadas.md @@ -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 |