fix(backend): corrigir exibicao de idiomas e selos multilingue

- Adicionar idiomas e formacoes ao _source das queries ES (client.py)
- Corrigir type mismatch int/str no endpoint paginado (routes.py)
- Adicionar campo evento nas inscricoes para nome do premio
- Implementar extracao de idiomas do ES no repository
- Ajustar frontend para exibir selo multilingue corretamente
This commit is contained in:
Frederico Castro
2025-12-24 18:12:22 -03:00
parent 0d355e705e
commit 919d95d1e8
14 changed files with 266 additions and 12 deletions

View File

@@ -57,6 +57,7 @@ class InscricaoDTO:
premio: str
ano: int
situacao: str
evento: str = ""
@dataclass
@@ -112,6 +113,15 @@ class MembroBancaDTO:
ano: Optional[int]
@dataclass
class IdiomaDTO:
idioma: str
nivel_leitura: str = ""
nivel_escrita: str = ""
nivel_fala: str = ""
nivel_compreensao: str = ""
@dataclass
class PontuacaoAtuacaoDTO:
codigo: str
@@ -168,6 +178,7 @@ class ConsultorDetalhadoDTO:
participacoes: List[ParticipacaoDTO]
orientacoes: List[OrientacaoDTO]
membros_banca: List[MembroBancaDTO]
idiomas: List[IdiomaDTO]
pontuacao: PontuacaoCompletaDTO
rank: Optional[int] = None

View File

@@ -143,7 +143,8 @@ class ProcessarRankingJob:
"tipo": i.tipo,
"premio": i.premio,
"ano": i.ano,
"situacao": i.situacao
"situacao": i.situacao,
"evento": i.evento
}
for i in consultor.inscricoes
],
@@ -223,6 +224,17 @@ class ProcessarRankingJob:
}
for d in consultor.docencias
],
"idiomas": [
{
"idioma": i.idioma,
"nivel_leitura": i.nivel_leitura,
"nivel_escrita": i.nivel_escrita,
"nivel_fala": i.nivel_fala,
"nivel_compreensao": i.nivel_compreensao,
}
for i in consultor.idiomas
],
"titulacao": consultor.titulacao,
"pontuacao": pontuacao,
}

View File

@@ -17,6 +17,7 @@ from ..dtos.consultor_dto import (
ParticipacaoDTO,
OrientacaoDTO,
MembroBancaDTO,
IdiomaDTO,
PontuacaoAtuacaoDTO,
PontuacaoBlocoDTO,
PontuacaoCompletaDTO,
@@ -118,6 +119,7 @@ class ObterRankingUseCase:
premio=i.premio,
ano=i.ano,
situacao=i.situacao,
evento=i.evento,
)
for i in consultor.inscricoes
],
@@ -180,6 +182,16 @@ class ObterRankingUseCase:
)
for m in consultor.membros_banca
],
idiomas=[
IdiomaDTO(
idioma=i.idioma,
nivel_leitura=i.nivel_leitura,
nivel_escrita=i.nivel_escrita,
nivel_fala=i.nivel_fala,
nivel_compreensao=i.nivel_compreensao,
)
for i in consultor.idiomas
],
pontuacao=PontuacaoCompletaDTO(
bloco_a=PontuacaoBlocoDTO(
bloco="A",

View File

@@ -50,6 +50,7 @@ class Inscricao:
premio: str
ano: int
situacao: str = ""
evento: str = ""
@dataclass
@@ -119,6 +120,15 @@ class DocenciaPPG:
linhas_pesquisa: List[str] = field(default_factory=list)
@dataclass
class Idioma:
idioma: str
nivel_leitura: str = ""
nivel_escrita: str = ""
nivel_fala: str = ""
nivel_compreensao: str = ""
@dataclass
class Consultor:
id_pessoa: int
@@ -135,6 +145,8 @@ class Consultor:
orientacoes: List[Orientacao] = field(default_factory=list)
membros_banca: List[MembroBanca] = field(default_factory=list)
docencias: List[DocenciaPPG] = field(default_factory=list)
idiomas: List[Idioma] = field(default_factory=list)
titulacao: Optional[str] = None
pontuacao: Optional[PontuacaoCompleta] = None
@property

View File

@@ -52,7 +52,7 @@ class ElasticsearchClient:
try:
query = {
"query": {"term": {"id": id_pessoa}},
"_source": ["id", "dadosPessoais", "atuacoes"],
"_source": ["id", "dadosPessoais", "atuacoes", "idiomas", "formacoes"],
"size": 1,
}
@@ -68,6 +68,28 @@ class ElasticsearchClient:
except Exception as e:
raise RuntimeError(f"Erro ao buscar consultor {id_pessoa}: {e}")
async def buscar_por_ids(self, ids: list, source_fields: Optional[list] = None) -> list:
if not ids:
return []
try:
query = {
"query": {"terms": {"id": ids}},
"size": len(ids),
}
if source_fields:
query["_source"] = source_fields
response = await self.client.post(
f"{self.url}/{self.index}/_search",
json=query
)
response.raise_for_status()
data = response.json()
return [hit["_source"] for hit in data.get("hits", {}).get("hits", [])]
except Exception as e:
raise RuntimeError(f"Erro ao buscar consultores por ids: {e}")
async def buscar_documento_completo(self, id_pessoa: int) -> Optional[dict]:
try:
query = {
@@ -104,7 +126,7 @@ class ElasticsearchClient:
"query": {"exists": {"field": "atuacoes.tipo"}}
}
},
"_source": ["id", "dadosPessoais", "atuacoes"],
"_source": ["id", "dadosPessoais", "atuacoes", "idiomas", "formacoes"],
"size": size,
"from": from_,
"sort": [{"id": "asc"}],
@@ -196,7 +218,7 @@ class ElasticsearchClient:
"minimum_should_match": 1
}
},
"_source": ["id", "dadosPessoais", "atuacoes"],
"_source": ["id", "dadosPessoais", "atuacoes", "idiomas", "formacoes"],
"sort": [{"_score": "desc"}]
}
@@ -379,7 +401,7 @@ class ElasticsearchClient:
"boost_mode": "replace"
}
},
"_source": ["id", "dadosPessoais", "atuacoes"],
"_source": ["id", "dadosPessoais", "atuacoes", "idiomas", "formacoes"],
"sort": [{"_score": "desc"}]
}
@@ -412,7 +434,7 @@ class ElasticsearchClient:
"query": {"exists": {"field": "atuacoes.tipo"}}
}
},
"_source": ["id", "dadosPessoais", "atuacoes"],
"_source": ["id", "dadosPessoais", "atuacoes", "idiomas", "formacoes"],
"size": size,
"sort": [{"id": "asc"}]
}
@@ -638,7 +660,7 @@ class ElasticsearchClient:
"minimum_should_match": 1
}
},
"_source": ["id", "dadosPessoais", "atuacoes"],
"_source": ["id", "dadosPessoais", "atuacoes", "idiomas", "formacoes"],
"sort": [{"_score": "desc"}]
}

View File

@@ -18,6 +18,7 @@ from ...domain.entities.consultor import (
Orientacao,
MembroBanca,
DocenciaPPG,
Idioma,
)
from ...domain.repositories.consultor_repository import ConsultorRepository
from ...domain.services.calculador_pontuacao import CalculadorPontuacao
@@ -277,6 +278,7 @@ class ConsultorRepositoryImpl(ConsultorRepository):
premio=nome_premio,
ano=ano,
situacao=dados.get("situacao", ""),
evento=dados.get("evento", ""),
))
return inscricoes
@@ -604,6 +606,77 @@ class ConsultorRepositoryImpl(ConsultorRepository):
docencias.sort(key=lambda d: (d.periodo.fim is not None, d.periodo.inicio or datetime.min), reverse=True)
return docencias
def _extrair_idiomas(self, doc: Dict[str, Any]) -> List[Idioma]:
idiomas_data = doc.get("idiomas") or []
if not idiomas_data:
dados_pessoais = doc.get("dadosPessoais", {}) or {}
idiomas_data = (
dados_pessoais.get("idiomas")
or dados_pessoais.get("idiomasEstrangeiros")
or []
)
idiomas = []
for item in idiomas_data:
if isinstance(item, str):
idioma_nome = item
item_dict = {}
else:
item_dict = item or {}
idioma_nome = item_dict.get("idioma") or item_dict.get("nome") or item_dict.get("descricao")
if isinstance(idioma_nome, dict):
idioma_nome = (
idioma_nome.get("nome")
or idioma_nome.get("descricao")
or idioma_nome.get("idioma")
)
if not idioma_nome:
continue
nivel_padrao = (
item_dict.get("nivel")
or item_dict.get("nivelConhecimento")
or item_dict.get("nivel_leitura")
or item_dict.get("nivelLeitura")
or ""
)
idiomas.append(Idioma(
idioma=idioma_nome,
nivel_leitura=item_dict.get("nivelLeitura", "") or item_dict.get("leitura", "") or nivel_padrao,
nivel_escrita=item_dict.get("nivelEscrita", "") or item_dict.get("escrita", "") or nivel_padrao,
nivel_fala=item_dict.get("nivelFala", "") or item_dict.get("fala", "") or nivel_padrao,
nivel_compreensao=item_dict.get("nivelCompreensao", "") or item_dict.get("compreensao", "") or nivel_padrao,
))
return idiomas
def _extrair_titulacao(self, doc: Dict[str, Any]) -> Optional[str]:
dados_pessoais = doc.get("dadosPessoais", {}) or {}
titulacao = dados_pessoais.get("titulacao") or dados_pessoais.get("grauFormacao")
if titulacao:
return titulacao
formacoes = doc.get("formacoes", []) or []
for f in formacoes:
nivel = f.get("nivel", "").lower()
if "pós-doutorado" in nivel or "pos-doutorado" in nivel or "posdoc" in nivel:
return "Pós-Doutorado"
elif "doutorado" in nivel:
return "Doutorado"
elif "mestrado" in nivel:
return "Mestrado"
atuacoes = doc.get("atuacoes", []) or []
for a in atuacoes:
if a.get("tipo") == "Docência":
dados = a.get("dadosDocencia", {}) or {}
categoria = (dados.get("categoria", "") or "").lower()
if "doutor" in categoria:
return "Doutorado"
elif "mestre" in categoria:
return "Mestrado"
return None
async def _construir_consultor(self, doc: Dict[str, Any]) -> Consultor:
id_pessoa = doc["id"]
dados_pessoais = doc.get("dadosPessoais", {})
@@ -621,6 +694,8 @@ class ConsultorRepositoryImpl(ConsultorRepository):
membros_banca = self._extrair_membros_banca(atuacoes)
docencias = self._extrair_docencias(atuacoes)
coordenador_ppg = self._tem_coordenacao_ppg(atuacoes)
idiomas = self._extrair_idiomas(doc)
titulacao = self._extrair_titulacao(doc)
consultor = Consultor(
id_pessoa=id_pessoa,
@@ -637,6 +712,8 @@ class ConsultorRepositoryImpl(ConsultorRepository):
orientacoes=orientacoes + coorientacoes,
membros_banca=membros_banca,
docencias=docencias,
idiomas=idiomas,
titulacao=titulacao,
)
consultor.pontuacao = self.calculador.calcular_pontuacao_completa(consultor)

View File

@@ -206,6 +206,8 @@ async def ranking_paginado(
ativo: Optional[bool] = Query(default=None, description="Filtrar por status ativo"),
selos: Optional[str] = Query(default=None, description="Filtrar por selos (separados por vírgula)"),
oracle_repo = Depends(get_ranking_oracle_repo),
es_client: ElasticsearchClient = Depends(get_es_client),
repository: ConsultorRepositoryImpl = Depends(get_repository),
):
import json as json_lib
@@ -223,12 +225,46 @@ async def ranking_paginado(
total_pages = (total + size - 1) // size
consultores_schema = []
consultores_dados = []
faltando_idiomas = []
for c in consultores:
try:
d = json_lib.loads(c.json_detalhes) if isinstance(c.json_detalhes, str) else c.json_detalhes or {}
except (json_lib.JSONDecodeError, TypeError):
d = {}
consultores_dados.append((c, d))
if not d.get("idiomas"):
faltando_idiomas.append((c.id_pessoa, d))
if faltando_idiomas:
ids = [item[0] for item in faltando_idiomas]
docs = await es_client.buscar_por_ids(
ids,
source_fields=["id", "dadosPessoais", "idiomas", "atuacoes", "formacoes"],
)
docs_map = {int(doc.get("id")): doc for doc in docs if doc.get("id")}
for id_pessoa, detalhes in faltando_idiomas:
doc = docs_map.get(int(id_pessoa))
if not doc:
continue
idiomas = repository._extrair_idiomas(doc)
if idiomas:
detalhes["idiomas"] = [
{
"idioma": i.idioma,
"nivel_leitura": i.nivel_leitura,
"nivel_escrita": i.nivel_escrita,
"nivel_fala": i.nivel_fala,
"nivel_compreensao": i.nivel_compreensao,
}
for i in idiomas
]
if not detalhes.get("titulacao"):
titulacao = repository._extrair_titulacao(doc)
if titulacao:
detalhes["titulacao"] = titulacao
for c, d in consultores_dados:
tipos_atuacao = RankingMapper._extrair_tipos_atuacao(d)
consultores_schema.append(
ConsultorRankingResumoSchema(
@@ -255,6 +291,8 @@ async def ranking_paginado(
orientacoes=d.get("orientacoes"),
membros_banca=d.get("membros_banca"),
docencias=d.get("docencias"),
idiomas=d.get("idiomas"),
titulacao=d.get("titulacao"),
pontuacao=d.get("pontuacao"),
)
)

View File

@@ -47,6 +47,7 @@ class InscricaoSchema(BaseModel):
premio: str
ano: int
situacao: str
evento: str = ""
class AvaliacaoComissaoSchema(BaseModel):
@@ -96,6 +97,14 @@ class MembroBancaSchema(BaseModel):
ano: Optional[int] = None
class IdiomaSchema(BaseModel):
idioma: str
nivel_leitura: str = ""
nivel_escrita: str = ""
nivel_fala: str = ""
nivel_compreensao: str = ""
class PontuacaoAtuacaoSchema(BaseModel):
codigo: str
base: int
@@ -147,6 +156,7 @@ class ConsultorDetalhadoSchema(BaseModel):
participacoes: List[ParticipacaoSchema]
orientacoes: List[OrientacaoSchema]
membros_banca: List[MembroBancaSchema]
idiomas: List[IdiomaSchema] = []
pontuacao: PontuacaoCompletaSchema
rank: Optional[int] = None

View File

@@ -27,6 +27,8 @@ class ConsultorRankingResumoSchema(BaseModel):
orientacoes: Optional[list] = None
membros_banca: Optional[list] = None
docencias: Optional[list] = None
idiomas: Optional[list] = None
titulacao: Optional[str] = None
pontuacao: Optional[dict] = None

View File

@@ -87,7 +87,6 @@ const DADOS_BLOCOS = {
],
selos: [
{ cod: 'PPG_COORD', nome: 'Coordenador PPG', obs: 'Indicador (sem pontuação V1)' },
{ cod: 'IDIOMA_BILINGUE', nome: 'Bilíngue', obs: '2+ idiomas' },
{ cod: 'IDIOMA_MULTILINGUE', nome: 'Multilíngue', obs: '3+ idiomas' },
{ cod: 'TITULACAO_MESTRE', nome: 'Mestre', obs: 'Maior titulação' },
{ cod: 'TITULACAO_DOUTOR', nome: 'Doutor', obs: 'Maior titulação' },

View File

@@ -28,7 +28,6 @@ const SELOS = {
MB_BANCA_DISS: { codigo: 'MB_BANCA_DISS', label: 'Banca Diss.', cor: 'selo-banca', icone: '📄' },
EVENTO: { codigo: 'EVENTO', label: 'Evento', cor: 'selo-evento', icone: '📅' },
PROJ: { codigo: 'PROJ', label: 'Projeto', cor: 'selo-proj', icone: '📁' },
IDIOMA_BILINGUE: { codigo: 'IDIOMA_BILINGUE', label: 'Bilingue', cor: 'selo-idioma', icone: '🌍' },
IDIOMA_MULTILINGUE: { codigo: 'IDIOMA_MULTILINGUE', label: 'Multilingue', cor: 'selo-idioma', icone: '🌐' },
TITULACAO_MESTRE: { codigo: 'TITULACAO_MESTRE', label: 'Mestre', cor: 'selo-titulacao', icone: '🎓' },
TITULACAO_DOUTOR: { codigo: 'TITULACAO_DOUTOR', label: 'Doutor', cor: 'selo-titulacao', icone: '🎓' },
@@ -49,6 +48,13 @@ const TIPOS_ATUACAO_CONFIG = {
const gerarSelos = (consultor) => {
const selos = [];
const normalizarIdioma = (valor) => (valor || '')
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim();
const normalizarCodigoIdioma = (valor) => normalizarIdioma(valor).replace(/[^a-z0-9]/g, '');
const isPresidCamaraVigente = consultor.coordenacoes_capes?.some(
(c) => c.codigo === 'CAM' && c.presidente && (c.ativo ?? !c.fim)
@@ -132,6 +138,40 @@ const gerarSelos = (consultor) => {
selos.push({ ...SELOS.PROJ, qtd: projetos.length, hint: `Projetos (${projetos.length}x)` });
}
const idiomas = Array.isArray(consultor.idiomas) ? consultor.idiomas : [];
const idiomasUnicosMap = new Map();
let temPortugues = false;
for (const idioma of idiomas) {
const nome = idioma?.idioma || idioma?.nome || idioma?.descricao || '';
const chave = normalizarIdioma(nome);
if (!chave) continue;
if (!idiomasUnicosMap.has(chave)) {
idiomasUnicosMap.set(chave, nome);
}
if (chave.includes('portugues') || chave.includes('portuguese')) {
temPortugues = true;
}
}
const idiomasUnicos = Array.from(idiomasUnicosMap.values());
const totalIdiomas = idiomasUnicos.length + (!temPortugues && idiomasUnicos.length > 0 ? 1 : 0);
if (totalIdiomas >= 3) {
selos.push({
...SELOS.IDIOMA_MULTILINGUE,
qtd: totalIdiomas,
hint: `Multilingue: ${idiomasUnicos.join(', ')}`,
});
}
const titulacao = consultor.titulacao || '';
const titulacaoLower = titulacao.toLowerCase();
if (titulacaoLower.includes('pós-doutorado') || titulacaoLower.includes('pos-doutorado') || titulacaoLower.includes('posdoc') || titulacaoLower.includes('pós-doc')) {
selos.push({ ...SELOS.TITULACAO_POS_DOUTOR, qtd: 1, hint: 'Pós-Doutorado' });
} else if (titulacaoLower.includes('doutorado') || titulacaoLower.includes('doutor')) {
selos.push({ ...SELOS.TITULACAO_DOUTOR, qtd: 1, hint: 'Doutorado' });
} else if (titulacaoLower.includes('mestrado') || titulacaoLower.includes('mestre')) {
selos.push({ ...SELOS.TITULACAO_MESTRE, qtd: 1, hint: 'Mestrado' });
}
return selos;
};
@@ -144,6 +184,8 @@ const SELOS_COM_DADOS = [
'CO_ORIENT_TESE', 'CO_ORIENT_DISS', 'CO_ORIENT_POS_DOC',
'MB_BANCA_POS_DOC', 'MB_BANCA_TESE', 'MB_BANCA_DISS',
'EVENTO', 'PROJ',
'IDIOMA_MULTILINGUE',
'TITULACAO_MESTRE', 'TITULACAO_DOUTOR', 'TITULACAO_POS_DOUTOR',
];
const SelosBadges = ({ selos, compacto = false, onSeloClick }) => {
@@ -355,7 +397,7 @@ const TipoAtuacaoModal = ({ tipo, consultor, onClose }) => {
{[...inscs].sort((a, b) => (b.ano || 0) - (a.ano || 0)).map((ins, i) => (
<div key={i} className="modal-item">
<span className="badge">{ins.codigo}</span>
<span className="modal-item-main">{ins.premio || ins.descricao || '-'}</span>
<span className="modal-item-main">{ins.evento || ins.premio || ins.descricao || '-'}</span>
<span className="muted">{ins.ano || '-'}</span>
</div>
))}
@@ -837,6 +879,12 @@ const ItemDetalheModal = ({ item, tipo, onClose }) => {
<span className="modal-detalhe-label">Código</span>
<span className="badge">{item.codigo}</span>
</div>
{item.evento && (
<div className="modal-detalhe-row">
<span className="modal-detalhe-label">Evento</span>
<span className="modal-detalhe-value">{item.evento}</span>
</div>
)}
<div className="modal-detalhe-row">
<span className="modal-detalhe-label">Prêmio</span>
<span className="modal-detalhe-value">{item.premio || 'N/A'}</span>
@@ -1461,7 +1509,7 @@ const ConsultorCard = memo(({ consultor, highlight, selecionado, onToggleSelecio
onClick={handleRawDataClick}
title="Ver dados completos do ATUACAPES"
>
📋
</button>
<div className="expand-icon">{expanded ? '▲' : '▼'}</div>
</div>
@@ -1687,7 +1735,7 @@ const ConsultorCard = memo(({ consultor, highlight, selecionado, onToggleSelecio
>
<span className="badge">{insc.codigo}</span>
<span className="pontos">{PONTOS_BASE[insc.codigo] || 0} pts</span>
<span>{insc.premio}</span>
<span>{insc.evento || insc.premio}</span>
<span className="muted">{insc.ano}</span>
</div>
))}

View File

@@ -19,6 +19,9 @@ const SELOS_CONFIG = {
{ codigo: 'ORIENT_GP', label: 'Orient. GP', icone: '🏆' },
{ codigo: 'ORIENT_PREMIO', label: 'Orient. Prêmio', icone: '🎖️' },
{ codigo: 'ORIENT_MENCAO', label: 'Orient. Menção', icone: '📜' },
{ codigo: 'COORIENT_GP', label: 'Coorient. GP', icone: '🏆' },
{ codigo: 'COORIENT_PREMIO', label: 'Coorient. Prêmio', icone: '🎖️' },
{ codigo: 'COORIENT_MENCAO', label: 'Coorient. Menção', icone: '📜' },
],
},
orientacoes: {

View File

@@ -493,6 +493,9 @@
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
overflow: hidden;
}
.equipe-count {
@@ -518,6 +521,7 @@
.equipe-acoes {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
.btn-limpar {
@@ -530,6 +534,7 @@
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s;
white-space: nowrap;
}
.btn-limpar:hover {
@@ -547,6 +552,7 @@
font-weight: 700;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
white-space: nowrap;
}
.btn-gerar-pdf:hover:not(:disabled) {

View File

@@ -90,6 +90,8 @@ export const rankingService = {
participacoes: c.participacoes || [],
orientacoes: c.orientacoes || [],
membros_banca: c.membros_banca || [],
idiomas: c.idiomas || [],
titulacao: c.titulacao || '',
};
});