From 45ab7412fe8e0628e5a70e847c33e0fcd2be732d Mon Sep 17 00:00:00 2001 From: Frederico Castro Date: Sat, 20 Dec 2025 07:35:03 -0300 Subject: [PATCH] feat: adicionar sistema de sugestao de consultores por tema - Novo endpoint GET /api/v1/consultores/sugerir com busca por tema - Busca inteligente em areas de avaliacao, conhecimento e pesquisa - Filtro por consultores ativos e area de avaliacao especifica - Endpoint GET /api/v1/consultores/areas-avaliacao com lista de areas - Novo componente SugerirConsultores no frontend - Botao 'Sugerir por Tema' integrado na interface principal - Score de match baseado em relevancia do tema e experiencia --- .../infrastructure/elasticsearch/client.py | 186 ++++++++++ backend/src/interface/api/routes.py | 99 +++++ .../src/interface/schemas/ranking_schema.py | 31 ++ frontend/src/App.css | 19 + frontend/src/App.jsx | 28 ++ .../src/components/SugerirConsultores.css | 337 ++++++++++++++++++ .../src/components/SugerirConsultores.jsx | 203 +++++++++++ frontend/src/services/api.js | 14 + 8 files changed, 917 insertions(+) create mode 100644 frontend/src/components/SugerirConsultores.css create mode 100644 frontend/src/components/SugerirConsultores.jsx diff --git a/backend/src/infrastructure/elasticsearch/client.py b/backend/src/infrastructure/elasticsearch/client.py index b078ecf..f4782b9 100644 --- a/backend/src/infrastructure/elasticsearch/client.py +++ b/backend/src/infrastructure/elasticsearch/client.py @@ -525,3 +525,189 @@ class ElasticsearchClient: finally: if scroll_id: await self.limpar_scroll(scroll_id) + + async def sugerir_consultores( + self, + tema: str, + area_avaliacao: Optional[str] = None, + apenas_ativos: bool = True, + size: int = 20 + ) -> list: + must_conditions = [] + should_conditions = [] + + tema_lower = tema.lower().strip() + + should_conditions.extend([ + { + "nested": { + "path": "atuacoes", + "query": { + "bool": { + "must": [ + {"term": {"atuacoes.tipo": "Consultor"}}, + { + "bool": { + "should": [ + {"match": {"atuacoes.dadosConsultoria.areaConhecimentoPos.nome": {"query": tema, "boost": 3}}}, + {"match": {"atuacoes.dadosConsultoria.areaConhecimentoPos.areaAvaliacao.nome": {"query": tema, "boost": 5}}}, + {"match": {"atuacoes.dadosConsultoria.areaPesquisa.descricao": {"query": tema, "boost": 2}}} + ] + } + } + ] + } + }, + "score_mode": "max", + "boost": 10 + } + }, + { + "nested": { + "path": "atuacoes", + "query": { + "bool": { + "should": [ + {"term": {"atuacoes.tipo": {"value": "Coordenação de Área de Avaliação", "boost": 8}}}, + {"term": {"atuacoes.tipo": {"value": "Histórico de Coordenação de Área de Avaliação", "boost": 4}}} + ] + } + }, + "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}}} + ] + } + }, + "score_mode": "sum" + } + } + ]) + + if area_avaliacao: + must_conditions.append({ + "nested": { + "path": "atuacoes", + "query": { + "bool": { + "must": [ + {"term": {"atuacoes.tipo": "Consultor"}}, + {"match": {"atuacoes.dadosConsultoria.areaConhecimentoPos.areaAvaliacao.nome": area_avaliacao}} + ] + } + } + } + }) + + if apenas_ativos: + must_conditions.append({ + "nested": { + "path": "atuacoes", + "query": { + "bool": { + "must": [ + {"term": {"atuacoes.tipo": "Consultor"}} + ], + "should": [ + {"match": {"atuacoes.dadosConsultoria.situacaoConsultoria": "Atividade Contínua"}}, + {"match": {"atuacoes.dadosConsultoria.situacaoConsultoria": "Ativo"}}, + {"match": {"atuacoes.dadosConsultoria.situacaoConsultoria": "Contínua"}} + ], + "minimum_should_match": 1 + } + } + } + }) + + query = { + "size": size, + "query": { + "bool": { + "must": must_conditions if must_conditions else [{"match_all": {}}], + "should": should_conditions, + "minimum_should_match": 1 + } + }, + "_source": ["id", "dadosPessoais", "atuacoes"], + "sort": [{"_score": "desc"}] + } + + try: + response = await self.client.post( + f"{self.url}/{self.index}/_search", + json=query, + timeout=60.0 + ) + response.raise_for_status() + + data = response.json() + results = [] + for hit in data.get("hits", {}).get("hits", []): + doc = hit["_source"] + doc["_score_match"] = hit.get("_score", 0) + results.append(doc) + return results + except Exception as e: + raise RuntimeError(f"Erro ao sugerir consultores: {e}") + + async def listar_areas_avaliacao(self) -> list: + areas_conhecidas = [ + "ADMINISTRAÇÃO PÚBLICA E DE EMPRESAS, CIÊNCIAS CONTÁBEIS E TURISMO", + "ANTROPOLOGIA E ARQUEOLOGIA", + "ARQUITETURA, URBANISMO E DESIGN", + "ARTES", + "ASTRONOMIA E FÍSICA", + "BIODIVERSIDADE", + "BIOTECNOLOGIA", + "CIÊNCIA DA COMPUTAÇÃO", + "CIÊNCIA DE ALIMENTOS", + "CIÊNCIA POLÍTICA E RELAÇÕES INTERNACIONAIS", + "CIÊNCIAS AGRÁRIAS I", + "CIÊNCIAS AMBIENTAIS", + "CIÊNCIAS BIOLÓGICAS I", + "CIÊNCIAS BIOLÓGICAS II", + "CIÊNCIAS BIOLÓGICAS III", + "CIÊNCIAS DA RELIGIÃO E TEOLOGIA", + "COMUNICAÇÃO E INFORMAÇÃO E MUSEOLOGIA", + "DIREITO", + "ECONOMIA", + "EDUCAÇÃO", + "EDUCAÇÃO FÍSICA", + "ENFERMAGEM", + "ENGENHARIAS I", + "ENGENHARIAS II", + "ENGENHARIAS III", + "ENGENHARIAS IV", + "ENSINO", + "FARMÁCIA", + "FILOSOFIA", + "GEOGRAFIA", + "GEOCIÊNCIAS", + "HISTÓRIA", + "INTERDISCIPLINAR", + "LETRAS E LINGUÍSTICA", + "MATEMÁTICA E ESTATÍSTICA", + "MATERIAIS", + "MEDICINA I", + "MEDICINA II", + "MEDICINA III", + "MEDICINA VETERINÁRIA", + "NUTRIÇÃO", + "ODONTOLOGIA", + "PLANEJAMENTO URBANO E REGIONAL E DEMOGRAFIA", + "PSICOLOGIA", + "QUÍMICA", + "SAÚDE COLETIVA", + "SERVIÇO SOCIAL", + "SOCIOLOGIA", + "ZOOTECNIA E RECURSOS PESQUEIROS" + ] + return [{"nome": area, "count": 0} for area in areas_conhecidas] diff --git a/backend/src/interface/api/routes.py b/backend/src/interface/api/routes.py index ff69084..980e23a 100644 --- a/backend/src/interface/api/routes.py +++ b/backend/src/interface/api/routes.py @@ -24,6 +24,9 @@ from ..schemas.ranking_schema import ( ProcessarRankingResponseSchema, ConsultaNomeSchema, PosicaoRankingSchema, + SugestaoConsultorSchema, + SugerirConsultoresResponseSchema, + AreaAvaliacaoSchema, ) from .dependencies import get_repository, get_ranking_store, get_processar_job, get_es_client from ...infrastructure.elasticsearch.client import ElasticsearchClient @@ -410,3 +413,99 @@ async def exportar_ficha_pdf( "Content-Disposition": f'attachment; filename="{nome_arquivo}"' } ) + + +@router.get("/consultores/sugerir", response_model=SugerirConsultoresResponseSchema) +async def sugerir_consultores( + tema: str = Query(..., min_length=2, description="Tema ou assunto para buscar consultores"), + area_avaliacao: Optional[str] = Query(None, description="Filtrar por area de avaliacao especifica"), + apenas_ativos: bool = Query(True, description="Filtrar apenas consultores ativos"), + quantidade: int = Query(20, ge=1, le=100, description="Quantidade maxima de sugestoes"), + es_client: ElasticsearchClient = Depends(get_es_client), + store = Depends(get_ranking_store), +): + try: + resultados = await es_client.sugerir_consultores( + tema=tema, + area_avaliacao=area_avaliacao, + apenas_ativos=apenas_ativos, + size=quantidade + ) + + consultores = [] + for doc in resultados: + id_pessoa = doc.get("id") + nome = doc.get("dadosPessoais", {}).get("nome", "") + score_match = doc.get("_score_match", 0) + + areas_avaliacao = set() + areas_conhecimento = set() + linhas_pesquisa = set() + situacao = "" + ies = None + foi_coordenador = False + foi_premiado = False + + for atuacao in doc.get("atuacoes", []): + tipo = atuacao.get("tipo", "") + + if tipo == "Consultor": + dados = atuacao.get("dadosConsultoria", {}) + situacao = dados.get("situacaoConsultoria", "") + if dados.get("ies"): + ies = dados["ies"].get("sigla") or dados["ies"].get("nome") + + for area in dados.get("areaConhecimentoPos", []): + if area.get("nome"): + areas_conhecimento.add(area["nome"]) + area_aval = area.get("areaAvaliacao", {}) + if area_aval and area_aval.get("nome"): + areas_avaliacao.add(area_aval["nome"]) + + for pesq in dados.get("areaPesquisa", []): + if pesq.get("descricao"): + linhas_pesquisa.add(pesq["descricao"]) + + elif "Coordenação" in tipo: + foi_coordenador = True + + elif "Premiação" in tipo: + foi_premiado = True + + posicao_ranking = None + if store.is_ready(): + entry = store.get_by_id(id_pessoa) + if entry: + posicao_ranking = entry.posicao + + consultores.append(SugestaoConsultorSchema( + id_pessoa=id_pessoa, + nome=nome, + score_match=score_match, + areas_avaliacao=list(areas_avaliacao), + areas_conhecimento=list(areas_conhecimento), + linhas_pesquisa=list(linhas_pesquisa), + situacao=situacao, + ies=ies, + foi_coordenador=foi_coordenador, + foi_premiado=foi_premiado, + )) + + return SugerirConsultoresResponseSchema( + tema_buscado=tema, + total_encontrados=len(consultores), + consultores=consultores + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erro ao sugerir consultores: {str(e)}") + + +@router.get("/consultores/areas-avaliacao", response_model=List[AreaAvaliacaoSchema]) +async def listar_areas_avaliacao( + es_client: ElasticsearchClient = Depends(get_es_client), +): + try: + areas = await es_client.listar_areas_avaliacao() + return [AreaAvaliacaoSchema(**a) for a in areas] + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erro ao listar areas de avaliacao: {str(e)}") diff --git a/backend/src/interface/schemas/ranking_schema.py b/backend/src/interface/schemas/ranking_schema.py index f7f255e..fbc3b4d 100644 --- a/backend/src/interface/schemas/ranking_schema.py +++ b/backend/src/interface/schemas/ranking_schema.py @@ -91,3 +91,34 @@ class PosicaoRankingSchema(BaseModel): bloco_d: float ativo: bool encontrado: bool = True + + +class SugestaoConsultorSchema(BaseModel): + id_pessoa: int + nome: str + score_match: float + areas_avaliacao: List[str] = [] + areas_conhecimento: List[str] = [] + linhas_pesquisa: List[str] = [] + situacao: str = "" + ies: Optional[str] = None + foi_coordenador: bool = False + foi_premiado: bool = False + + +class SugerirConsultoresRequestSchema(BaseModel): + tema: str = Field(..., min_length=2, description="Tema ou assunto para buscar consultores") + area_avaliacao: Optional[str] = Field(None, description="Filtrar por area de avaliacao especifica") + apenas_ativos: bool = Field(True, description="Filtrar apenas consultores ativos") + quantidade: int = Field(20, ge=1, le=100, description="Quantidade maxima de sugestoes") + + +class SugerirConsultoresResponseSchema(BaseModel): + tema_buscado: str + total_encontrados: int + consultores: List[SugestaoConsultorSchema] + + +class AreaAvaliacaoSchema(BaseModel): + nome: str + count: int diff --git a/frontend/src/App.css b/frontend/src/App.css index 1d6268a..dfaf940 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -126,6 +126,25 @@ background: var(--accent-2); } +.btn-sugerir { + padding: 0.5rem 1rem; + background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(96, 165, 250, 0.15)); + border: 1px solid rgba(139, 92, 246, 0.4); + color: #c4b5fd; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + font-size: 0.85rem; + transition: all 150ms ease; + white-space: nowrap; +} + +.btn-sugerir:hover { + background: linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(96, 165, 250, 0.2)); + border-color: rgba(139, 92, 246, 0.6); + transform: translateY(-1px); +} + .pagination { display: flex; align-items: center; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 47a939e..260bc1e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,7 @@ import Header from './components/Header'; import ConsultorCard from './components/ConsultorCard'; import CompararModal from './components/CompararModal'; import FiltroSelos from './components/FiltroSelos'; +import SugerirConsultores from './components/SugerirConsultores'; import { rankingService } from './services/api'; import './App.css'; @@ -23,6 +24,7 @@ function App() { const [selecionados, setSelecionados] = useState([]); const [modalAberto, setModalAberto] = useState(false); const [filtroSelos, setFiltroSelos] = useState([]); + const [sugerirAberto, setSugerirAberto] = useState(false); const toggleSelecionado = (consultor) => { setSelecionados((prev) => { @@ -42,6 +44,21 @@ function App() { setModalAberto(false); }; + const handleSugestaoSelecionada = async (idPessoa) => { + try { + const resultados = await rankingService.searchConsultor(String(idPessoa), 1); + if (resultados && resultados.length > 0) { + const alvo = resultados[0]; + const pos = alvo.posicao || 1; + const pagina = Math.ceil(pos / pageSize); + setHighlightId(alvo.id_pessoa); + setPage(pagina); + } + } catch (err) { + console.error('Erro ao navegar para consultor:', err); + } + }; + useEffect(() => { loadRanking(); }, [page, pageSize, filtroSelos]); @@ -198,6 +215,10 @@ function App() { onChange={(selos) => { setFiltroSelos(selos); setPage(1); }} /> + +
)} + {sugerirAberto && ( + setSugerirAberto(false)} + onSelectConsultor={handleSugestaoSelecionada} + /> + )} +