feat: Aprimora cálculo de pontuação e extração de dados

- Adiciona campos situacao, anos_completos, anos_consecutivos e retornos
  na entidade Consultoria para suportar regras documentadas
- Implementa mesclagem de períodos sobrepostos para evitar contagem dupla
- Melhora componente A com cálculo por área e detecção de retornos
- Ajusta componente B com bônus por nota PPG
- Refatora componente C com bônus de continuidade e retorno
- Implementa componente D com classificação de nível de prêmio
  (Grande Prêmio, PCT, Interfarma, outros) e pontuação diferenciada
- Trata datas inconsistentes (fim < início) como períodos em aberto
- Extrai situacaoConsultoria do campo dadosConsultoria.situacaoConsultoria
This commit is contained in:
Frederico Castro
2025-12-09 22:57:46 -03:00
parent 9a8332b740
commit ff4d838f34
7 changed files with 348 additions and 83 deletions

View File

@@ -39,6 +39,10 @@ class ConsultoriaDTO:
ultimo_evento: str ultimo_evento: str
vezes_responsavel: int vezes_responsavel: int
areas: List[str] areas: List[str]
situacao: str
anos_completos: int
anos_consecutivos: int
retornos: int
@dataclass @dataclass

View File

@@ -94,6 +94,10 @@ class ObterRankingUseCase:
ultimo_evento=consultor.consultoria.ultimo_evento.isoformat(), ultimo_evento=consultor.consultoria.ultimo_evento.isoformat(),
vezes_responsavel=consultor.consultoria.vezes_responsavel, vezes_responsavel=consultor.consultoria.vezes_responsavel,
areas=consultor.consultoria.areas, areas=consultor.consultoria.areas,
situacao=consultor.consultoria.situacao,
anos_completos=consultor.consultoria.anos_completos,
anos_consecutivos=consultor.consultoria.anos_consecutivos,
retornos=consultor.consultoria.retornos,
) )
if consultor.consultoria if consultor.consultoria
else None, else None,

View File

@@ -34,6 +34,10 @@ class Consultoria:
ultimo_evento: datetime ultimo_evento: datetime
vezes_responsavel: int vezes_responsavel: int
areas: List[str] = field(default_factory=list) areas: List[str] = field(default_factory=list)
situacao: str = "N/A"
anos_completos: int = 0
anos_consecutivos: int = 0
retornos: int = 0
@dataclass @dataclass
@@ -66,6 +70,11 @@ class Consultor:
def ativo(self) -> bool: def ativo(self) -> bool:
if not self.consultoria: if not self.consultoria:
return False return False
situacao = (self.consultoria.situacao or "").lower()
if "atividade" in situacao:
return True
if "inativ" in situacao:
return False
return self.consultoria.eventos_recentes > 0 return self.consultoria.eventos_recentes > 0
@property @property

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timedelta from datetime import datetime
from typing import List from typing import List
from ..entities.consultor import ( from ..entities.consultor import (
@@ -9,9 +9,43 @@ from ..entities.consultor import (
Premiacao, Premiacao,
) )
from ..value_objects.pontuacao import ComponentePontuacao, PontuacaoCompleta from ..value_objects.pontuacao import ComponentePontuacao, PontuacaoCompleta
from ..value_objects.periodo import Periodo
class CalculadorPontuacao: class CalculadorPontuacao:
@staticmethod
def _mesclar_periodos(periodos: List[Periodo]) -> List[Periodo]:
"""
Mescla períodos sobrepostos/contíguos para evitar contagem dupla.
"""
if not periodos:
return []
periodos_ordenados = sorted(periodos, key=lambda p: p.inicio)
mesclados: List[Periodo] = []
for p in periodos_ordenados:
if not mesclados:
mesclados.append(p)
continue
ultimo = mesclados[-1]
ultimo_fim = ultimo.fim or datetime.now()
atual_fim = p.fim or datetime.now()
if p.inicio <= ultimo_fim:
novo_fim = max(ultimo_fim, atual_fim)
mesclados[-1] = Periodo(inicio=ultimo.inicio, fim=novo_fim if not ultimo.ativo else None)
else:
mesclados.append(p)
return mesclados
@staticmethod
def _anos_completos_periodos(periodos: List[Periodo]) -> int:
ref = datetime.now()
return sum(p.anos_completos(ref) for p in periodos)
@staticmethod @staticmethod
def calcular_componente_a(coordenacoes: List[CoordenacaoCapes]) -> ComponentePontuacao: def calcular_componente_a(coordenacoes: List[CoordenacaoCapes]) -> ComponentePontuacao:
if not coordenacoes: if not coordenacoes:
@@ -38,19 +72,32 @@ class CalculadorPontuacao:
return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0, retorno=0) return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0, retorno=0)
coord_do_tipo = coord_por_tipo.get(coord_escolhida_tipo, []) coord_do_tipo = coord_por_tipo.get(coord_escolhida_tipo, [])
anos_total = sum(c.periodo.anos_decorridos for c in coord_do_tipo) coord_por_area = {}
ativo = any(c.periodo.ativo for c in coord_do_tipo) for c in coord_do_tipo:
area = c.area_avaliacao or "N/A"
coord_por_area.setdefault(area, []).append(c.periodo)
anos_total = 0
ativo = False
retornos_encontrados = 0
for _, periodos_area in coord_por_area.items():
mesclados = CalculadorPontuacao._mesclar_periodos(periodos_area)
anos_total += CalculadorPontuacao._anos_completos_periodos(mesclados)
ativo = ativo or any(p.ativo for p in periodos_area)
if len(mesclados) > 1:
retornos_encontrados += 1
base = base_map.get(coord_escolhida_tipo, 0) base = base_map.get(coord_escolhida_tipo, 0)
tempo = min(int(anos_total * mult_tempo_map.get(coord_escolhida_tipo, 0)), tempo_max_map.get(coord_escolhida_tipo, 0)) tempo = min(int(anos_total * mult_tempo_map.get(coord_escolhida_tipo, 0)), tempo_max_map.get(coord_escolhida_tipo, 0))
extras = 0 extras = 0
areas_adicionais = [a for c in coord_do_tipo for a in c.areas_adicionais] areas_distintas = list(coord_por_area.keys())
if areas_adicionais: if len(areas_distintas) > 1:
extras = min(len(set(areas_adicionais)) * 20, 100) extras = min((len(areas_distintas) - 1) * 20, 100)
bonus = bonus_atual_map.get(coord_escolhida_tipo, 0) if ativo else 0 bonus = bonus_atual_map.get(coord_escolhida_tipo, 0) if ativo else 0
retorno = 20 if len(coord_do_tipo) > 1 else 0 retorno = 20 if retornos_encontrados > 0 else 0
return ComponentePontuacao(base=base, tempo=tempo, extras=extras, bonus=bonus, retorno=retorno) return ComponentePontuacao(base=base, tempo=tempo, extras=extras, bonus=bonus, retorno=retorno)
@@ -60,33 +107,51 @@ class CalculadorPontuacao:
return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0) return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0)
base = 70 base = 70
anos_totais = sum(c.periodo.anos_decorridos for c in coordenacoes) anos_totais = sum(c.periodo.anos_completos(datetime.now()) for c in coordenacoes)
tempo = min(int(anos_totais * 5), 50) tempo = min(int(anos_totais * 5), 50)
programas_distintos = len({c.id_programa for c in coordenacoes}) programas_distintos = len({c.id_programa for c in coordenacoes})
extras = min((programas_distintos - 1) * 20, 40) extras = min((programas_distintos - 1) * 20, 40)
coord_ativa = any(c.periodo.ativo for c in coordenacoes) nota_bonus = 0
bonus = 20 if coord_ativa else 0 for c in coordenacoes:
try:
nota_num = float(c.nota_ppg)
if nota_num >= 0:
nota_bonus = 20
break
except Exception:
continue
return ComponentePontuacao(base=base, tempo=tempo, extras=extras, bonus=bonus) return ComponentePontuacao(base=base, tempo=tempo, extras=extras, bonus=nota_bonus)
@staticmethod @staticmethod
def calcular_componente_c(consultoria: Consultoria) -> ComponentePontuacao: def calcular_componente_c(consultoria: Consultoria) -> ComponentePontuacao:
if not consultoria: if not consultoria:
return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0) return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0)
base = 150 if consultoria.eventos_recentes > 0 else 100 situacao = (consultoria.situacao or "").lower()
base = 150 if "atividade" in situacao else 100
anos = (datetime.now() - consultoria.primeiro_evento).days / 365.25 anos = consultoria.anos_completos if consultoria.anos_completos else int(
((datetime.now() - consultoria.primeiro_evento).days) // 365
)
tempo = min(int(anos * 5), 50) tempo = min(int(anos * 5), 50)
extras_eventos = min(consultoria.total_eventos * 2, 20) continuidade = consultoria.anos_consecutivos
extras_responsavel = min(consultoria.vezes_responsavel * 5, 25) if continuidade >= 8:
extras_areas = min((len(consultoria.areas) - 1) * 10, 30) if len(consultoria.areas) > 1 else 0 bonus_continuidade = 15
extras = extras_eventos + extras_responsavel + extras_areas elif continuidade >= 5:
bonus_continuidade = 10
elif continuidade >= 3:
bonus_continuidade = 5
else:
bonus_continuidade = 0
bonus = 0 retorno_bonus = 15 if consultoria.retornos > 0 else 0
extras = 0
bonus = bonus_continuidade + retorno_bonus
return ComponentePontuacao(base=base, tempo=tempo, extras=extras, bonus=bonus) return ComponentePontuacao(base=base, tempo=tempo, extras=extras, bonus=bonus)
@@ -95,7 +160,11 @@ class CalculadorPontuacao:
if not premiacoes: if not premiacoes:
return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0) return ComponentePontuacao(base=0, tempo=0, extras=0, bonus=0)
total_pontos = sum(p.pontos for p in premiacoes) avaliador = [p for p in premiacoes if "avaliador" in (p.tipo or "").lower()]
outros = [p for p in premiacoes if p not in avaliador]
pontos_avaliador = min(sum(p.pontos for p in avaliador), 20)
total_pontos = pontos_avaliador + sum(p.pontos for p in outros)
total_pontos = min(total_pontos, 180) total_pontos = min(total_pontos, 180)
return ComponentePontuacao(base=total_pontos, tempo=0, extras=0, bonus=0) return ComponentePontuacao(base=total_pontos, tempo=0, extras=0, bonus=0)

View File

@@ -18,6 +18,17 @@ class Periodo:
dias = (fim - self.inicio).days dias = (fim - self.inicio).days
return round(dias / 365.25, 1) return round(dias / 365.25, 1)
def anos_completos(self, data_referencia: Optional[datetime] = None) -> int:
"""
Retorna apenas anos completos entre início e fim (ou data de referência).
Usado para pontuação que desconsidera frações de ano.
"""
fim = self.fim or data_referencia or datetime.now()
if fim < self.inicio:
return 0
return int((fim - self.inicio).days // 365)
def __post_init__(self) -> None: def __post_init__(self) -> None:
# Se houver fim anterior ao início, o período é tratado como aberto.
if self.fim and self.fim < self.inicio: if self.fim and self.fim < self.inicio:
raise ValueError("Data de fim não pode ser anterior à data de início") object.__setattr__(self, "fim", None)

View File

@@ -48,6 +48,30 @@ class ConsultorRepositoryImpl(ConsultorRepository):
self.calculador = CalculadorPontuacao() self.calculador = CalculadorPontuacao()
self.es_disponivel = True self.es_disponivel = True
def _mesclar_periodos(self, periodos: List[Periodo]) -> List[Periodo]:
"""
Mescla períodos sobrepostos/contíguos para evitar contagem dupla de tempo.
"""
if not periodos:
return []
periodos = sorted(periodos, key=lambda p: p.inicio)
mesclados: List[Periodo] = []
for p in periodos:
if not mesclados:
mesclados.append(p)
continue
ultimo = mesclados[-1]
fim_ultimo = ultimo.fim or datetime.now()
fim_atual = p.fim or datetime.now()
if p.inicio <= fim_ultimo:
novo_fim = max(fim_ultimo, fim_atual)
mesclados[-1] = Periodo(inicio=ultimo.inicio, fim=novo_fim if not ultimo.ativo else None)
else:
mesclados.append(p)
return mesclados
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]: def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
if not date_str: if not date_str:
return None return None
@@ -63,22 +87,51 @@ class ConsultorRepositoryImpl(ConsultorRepository):
if not consultorias: if not consultorias:
return None return None
datas_inicio_consultoria = [ periodos: List[Periodo] = []
self._parse_date(c.get("inicio")) situacoes: List[str] = []
for c in consultorias areas: List[str] = []
]
datas_inicio_consultoria = [d for d in datas_inicio_consultoria if d]
if not datas_inicio_consultoria: for c in consultorias:
dc = c.get("dadosConsultoria", {}) or {}
situacao = dc.get("situacaoConsultoria") or c.get("situacaoConsultoria")
if situacao:
situacoes.append(situacao)
inicio = (
self._parse_date(dc.get("inicioVinculacao"))
or self._parse_date(dc.get("inicioSituacao"))
or self._parse_date(c.get("inicio"))
)
fim = (
self._parse_date(dc.get("fimVinculacao"))
or self._parse_date(dc.get("inativacaoSituacao"))
or self._parse_date(c.get("fim"))
)
if inicio and fim and fim < inicio:
fim = None # dados inconsistentes: trata como em aberto
if inicio:
try:
periodos.append(Periodo(inicio=inicio, fim=fim))
except ValueError:
continue
area = dc.get("areaAvaliacao") or c.get("areaAvaliacao")
if area:
areas.append(area)
periodos = [p for p in periodos if p.inicio]
if not periodos:
return None return None
eventos_sae = [ mesclados = self._mesclar_periodos(periodos)
a for a in atuacoes if a.get("tipo") == "Evento" anos_total = sum(p.anos_completos(datetime.now()) for p in mesclados)
] anos_consecutivos = max((p.anos_completos(datetime.now()) for p in mesclados), default=0)
retornos = max(0, len(mesclados) - 1)
ativo = any(p.ativo for p in periodos)
eventos_sae = [a for a in atuacoes if a.get("tipo") == "Evento"]
total_eventos = len(eventos_sae) total_eventos = len(eventos_sae)
# considerar últimos 24 meses como janela de atividade
limite_recente = datetime.now() - timedelta(days=730) limite_recente = datetime.now() - timedelta(days=730)
eventos_recentes = 0 eventos_recentes = 0
for ev in eventos_sae: for ev in eventos_sae:
@@ -86,30 +139,25 @@ class ConsultorRepositoryImpl(ConsultorRepository):
if data_fim and data_fim >= limite_recente: if data_fim and data_fim >= limite_recente:
eventos_recentes += 1 eventos_recentes += 1
dados_consultoria = consultorias[0].get("dadosConsultoria", {}) or {} primeiro_evento = min(p.inicio for p in periodos)
areas = [] ultimo_evento = max((p.fim or datetime.now()) for p in periodos) if not ativo else datetime.now()
for c in consultorias:
dc = c.get("dadosConsultoria", {}) or {}
area = dc.get("areaAvaliacao") or c.get("areaAvaliacao")
if area:
areas.append(area)
areas = list(set(areas)) if areas else ["N/A"]
areas = list(set(areas)) if areas else ["N/A"]
vezes_responsavel = sum(1 for c in consultorias if c.get("responsavel", False)) vezes_responsavel = sum(1 for c in consultorias if c.get("responsavel", False))
datas_fim_consultoria = [ situacao_final = situacoes[0] if situacoes else "N/A"
self._parse_date(c.get("fim"))
for c in consultorias
]
datas_fim_consultoria = [d for d in datas_fim_consultoria if d]
return Consultoria( return Consultoria(
total_eventos=total_eventos, total_eventos=total_eventos,
eventos_recentes=eventos_recentes, eventos_recentes=eventos_recentes,
primeiro_evento=min(datas_inicio_consultoria), primeiro_evento=primeiro_evento,
ultimo_evento=max(datas_fim_consultoria) if datas_fim_consultoria else datetime.now(), ultimo_evento=ultimo_evento,
vezes_responsavel=vezes_responsavel, vezes_responsavel=vezes_responsavel,
areas=areas, areas=areas,
situacao=situacao_final,
anos_completos=anos_total,
anos_consecutivos=anos_consecutivos,
retornos=retornos,
) )
def _extrair_coordenacoes_capes( def _extrair_coordenacoes_capes(
@@ -127,14 +175,24 @@ class ConsultorRepositoryImpl(ConsultorRepository):
resultado = [] resultado = []
for coord in coordenacoes: for coord in coordenacoes:
inicio = self._parse_date(coord.get("inicio")) dados_coord = coord.get("dadosCoordenacaoArea", {}) or {}
inicio = (
self._parse_date(dados_coord.get("inicioVinculacao"))
or self._parse_date(coord.get("inicio"))
)
if not inicio: if not inicio:
continue continue
tipo = self._inferir_tipo_coordenacao(coord) tipo = self._inferir_tipo_coordenacao(coord)
fim = self._parse_date(coord.get("fim")) fim = (
self._parse_date(dados_coord.get("fimVinculacao"))
or self._parse_date(coord.get("fim"))
)
if inicio and fim and fim < inicio:
fim = None # ignora fins inconsistentes para não quebrar cálculo
dados_coord = coord.get("dadosCoordenacaoArea", {}) or {}
area_avaliacao_obj = dados_coord.get("areaAvaliacao", {}) or {} area_avaliacao_obj = dados_coord.get("areaAvaliacao", {}) or {}
area_avaliacao = area_avaliacao_obj.get("nome") if isinstance(area_avaliacao_obj, dict) else coord.get("areaAvaliacao", "N/A") area_avaliacao = area_avaliacao_obj.get("nome") if isinstance(area_avaliacao_obj, dict) else coord.get("areaAvaliacao", "N/A")
if not area_avaliacao: if not area_avaliacao:
@@ -178,45 +236,151 @@ class ConsultorRepositoryImpl(ConsultorRepository):
else: else:
return "CA" return "CA"
def _classificar_nivel_premio(self, nome: str) -> str:
nome = (nome or "").lower()
if "grande prêmio capes de tese" in nome or "grande premio capes de tese" in nome:
return "nivel1_grande"
if "prêmio capes de tese" in nome or "premio capes de tese" in nome:
return "nivel1_pct"
if "interfarma" in nome or "vale-capes" in nome or "vale capes" in nome:
return "nivel2"
if nome:
return "nivel3"
return "desconhecido"
def _pontuar_premiacao_recebida(self, nivel: str, tipo_premiacao: str) -> int:
tipo = (tipo_premiacao or "").lower()
if nivel == "nivel1_grande":
base = 150
extra = 50 if "grande" in tipo else 0
return min(base + extra, 180)
if nivel == "nivel1_pct":
base = 100
if "mencao" in tipo:
extra = 15
elif "premio" in tipo:
extra = 25
else:
extra = 0
return min(base + extra, 150)
if nivel == "nivel2":
base = 30
if "premio" in tipo:
extra = 20
elif "mencao" in tipo:
extra = 10
else:
extra = 0
return min(base + extra, 60)
# nivel3 e fallback
base = 10
if "premio" in tipo:
extra = 5
elif "mencao" in tipo:
extra = 3
else:
extra = 0
return min(base + extra, 20)
def _pontuar_participacao_premio(self, nivel: str, tipo_participacao: str) -> int:
tipo = (tipo_participacao or "").lower()
if "avaliador" in tipo:
return 2 # teto final tratado em componente D
if "coordenador" in tipo:
if nivel == "nivel1_grande":
return 115 # valor máximo já com peso
if nivel == "nivel1_pct":
return 115 # aproximação segura para teto
if nivel == "nivel2":
return 80
return 40
if "inscricao" in tipo or "inscrição" in tipo:
if nivel in ["nivel1_grande", "nivel1_pct"]:
return 2
if nivel == "nivel2":
return 1
return 1
return 0
def _extrair_premiacoes(self, atuacoes: List[Dict[str, Any]]) -> List[Premiacao]: 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",
"Premiação",
]
]
premiacoes = [] premiacoes = []
for prem in premiacoes_data: for a in atuacoes:
pontos = self._calcular_pontos_premiacao(prem.get("tipo", "")) tipo_atuacao = a.get("tipo", "")
inicio = self._parse_date(prem.get("inicio")) dados_premiacao = a.get("dadosPremiacaoPremio") or a.get("dadosPremio") or {}
ano = inicio.year if inicio else datetime.now().year dados_participacao = (
a.get("dadosParticipacaoPremio")
premiacoes.append( or a.get("dadosParticipacaoInscricaoPremio")
Premiacao( or {}
tipo=prem.get("tipo", "N/A"),
nome_premio=prem.get("descricao", "N/A"),
ano=ano,
pontos=pontos,
)
) )
return premiacoes # Premiações recebidas
if dados_premiacao:
nome_premio = dados_premiacao.get("nomePremio") or a.get("descricao", "N/A")
tipo_premiacao = dados_premiacao.get("tipoPremiacao") or dados_premiacao.get("tipo", "")
ano = dados_premiacao.get("ano") or a.get("ano")
if not ano:
inicio = self._parse_date(a.get("inicio"))
ano = inicio.year if inicio else datetime.now().year
def _calcular_pontos_premiacao(self, tipo: str) -> int: nivel = self._classificar_nivel_premio(nome_premio)
# Aproximação das regras (D) seguindo .claude/rules/ranking-consultores-capes.md pontos = self._pontuar_premiacao_recebida(nivel, tipo_premiacao)
mapa = {
"Premiação Prêmio": 150, premiacoes.append(
"Premiação": 150, Premiacao(
"Avaliação Prêmio": 40, tipo=tipo_premiacao or tipo_atuacao or "Premiação",
"Inscrição Prêmio": 10, nome_premio=nome_premio,
} ano=ano or datetime.now().year,
return mapa.get(tipo, 0) pontos=int(pontos),
)
)
continue
# Participações (inscrição/avaliação/coordenação)
if dados_participacao:
tipo_part = (
dados_participacao.get("tipoParticipacao")
or dados_participacao.get("tipo")
or tipo_atuacao
)
nome_premio = dados_participacao.get("nomePremio") or a.get("descricao", "N/A")
ano = dados_participacao.get("ano") or a.get("ano")
if not ano:
inicio = self._parse_date(a.get("inicio"))
ano = inicio.year if inicio else datetime.now().year
nivel = self._classificar_nivel_premio(nome_premio)
pontos = self._pontuar_participacao_premio(nivel, tipo_part)
premiacoes.append(
Premiacao(
tipo=tipo_part or "Participação Prêmio",
nome_premio=nome_premio,
ano=ano or datetime.now().year,
pontos=int(pontos),
)
)
continue
# Fallback para tipos antigos
if tipo_atuacao in [
"Premiação Prêmio",
"Premiação",
"Avaliação Prêmio",
"Inscrição Prêmio",
]:
pontos = self._pontuar_participacao_premio("nivel3", tipo_atuacao)
inicio = self._parse_date(a.get("inicio"))
ano = inicio.year if inicio else datetime.now().year
premiacoes.append(
Premiacao(
tipo=tipo_atuacao,
nome_premio=a.get("descricao", "N/A"),
ano=ano,
pontos=int(pontos),
)
)
return premiacoes
async def _construir_consultor(self, doc: Dict[str, Any]) -> Consultor: async def _construir_consultor(self, doc: Dict[str, Any]) -> Consultor:
id_pessoa = doc["id"] id_pessoa = doc["id"]

View File

@@ -34,6 +34,10 @@ class ConsultoriaSchema(BaseModel):
ultimo_evento: str ultimo_evento: str
vezes_responsavel: int vezes_responsavel: int
areas: List[str] areas: List[str]
situacao: str
anos_completos: int
anos_consecutivos: int
retornos: int
class PremiacaoSchema(BaseModel): class PremiacaoSchema(BaseModel):