From 7d7351010103214ded17bfb464ecac29a6e3695d Mon Sep 17 00:00:00 2001 From: Frederico Castro Date: Sun, 21 Dec 2025 22:22:58 -0300 Subject: [PATCH] =?UTF-8?q?perf(ranking):=20otimizar=20pagina=C3=A7=C3=A3o?= =?UTF-8?q?=20usando=20Oracle=20direto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remover carregamento de 350k registros em memória no startup - Refatorar endpoints para buscar dados direto do Oracle: - ranking_paginado - buscar_por_nome - ranking_estatisticas - obter_posicao_ranking - Adicionar healthcheck leve no Oracle com start_period de 60s - Corrigir start-ngrok.sh para subir todos os containers - Adicionar domínio ngrok-free.dev no vite.config.js --- backend/src/interface/api/app.py | 81 +------------- backend/src/interface/api/routes.py | 168 ++++++++++++++-------------- docker-compose.yml | 43 ++++--- frontend/vite.config.js | 2 +- start-ngrok.sh | 5 +- 5 files changed, 113 insertions(+), 186 deletions(-) diff --git a/backend/src/interface/api/app.py b/backend/src/interface/api/app.py index 4dc8231..b9070d7 100644 --- a/backend/src/interface/api/app.py +++ b/backend/src/interface/api/app.py @@ -1,6 +1,4 @@ import logging -import json -import asyncio from pathlib import Path from fastapi import FastAPI @@ -14,77 +12,6 @@ logger = logging.getLogger(__name__) from .config import settings from .dependencies import es_client, oracle_client, ranking_oracle_repo, get_processar_job from ...application.jobs.scheduler import RankingScheduler -from ...infrastructure.ranking_store import ranking_store, RankingEntry - - -async def carregar_ranking_do_oracle() -> int: - if not ranking_oracle_repo or not oracle_client: - logger.warning("Oracle não configurado - ranking será carregado do Elasticsearch quando solicitado") - return 0 - - try: - if not oracle_client.is_connected: - oracle_client.connect() - if not oracle_client.is_connected: - logger.warning("Não foi possível conectar ao Oracle") - return 0 - - def _sync_load(): - total = ranking_oracle_repo.contar_total() - if total == 0: - return [] - - consultores = [] - batch_size = 10000 - total_pages = (total + batch_size - 1) // batch_size - logger.info(f"Carregando {total} consultores do Oracle em {total_pages} batches...") - - for page in range(1, total_pages + 1): - batch = ranking_oracle_repo.buscar_paginado(page=page, size=batch_size) - consultores.extend(batch) - - return consultores - - consultores = await asyncio.wait_for( - asyncio.get_event_loop().run_in_executor(None, _sync_load), - timeout=300.0 - ) - - if not consultores: - logger.info("Nenhum dado encontrado no Oracle - ranking vazio") - return 0 - - entries = [] - for c in consultores: - try: - detalhes = json.loads(c.json_detalhes) if isinstance(c.json_detalhes, str) else c.json_detalhes or {} - except (json.JSONDecodeError, TypeError, ValueError): - detalhes = {} - - entries.append( - RankingEntry( - id_pessoa=c.id_pessoa, - nome=c.nome, - posicao=c.posicao or 0, - pontuacao_total=int(c.pontuacao_total), - bloco_a=int(c.componente_a), - bloco_b=int(c.componente_b), - bloco_c=int(c.componente_c), - bloco_d=int(c.componente_d), - ativo=c.ativo, - anos_atuacao=float(c.anos_atuacao or 0), - detalhes=detalhes, - ) - ) - - await ranking_store.set_entries(entries) - return len(entries) - except asyncio.TimeoutError: - logger.warning("Timeout ao carregar ranking do Oracle") - return 0 - except Exception as e: - logger.warning(f"Erro ao carregar ranking do Oracle: {e}") - return 0 @asynccontextmanager @@ -95,12 +22,8 @@ async def lifespan(app: FastAPI): if oracle_client: oracle_client.connect() if oracle_client.is_connected: - logger.info("Conectado ao Oracle") - total = await carregar_ranking_do_oracle() - if total > 0: - logger.info(f"Ranking carregado do Oracle: {total} consultores") - else: - logger.info("Ranking vazio no Oracle - aguardando processamento") + total = ranking_oracle_repo.contar_total() if ranking_oracle_repo else 0 + logger.info(f"Conectado ao Oracle - {total} consultores no ranking") else: logger.warning("Não foi possível conectar ao Oracle") except Exception as e: diff --git a/backend/src/interface/api/routes.py b/backend/src/interface/api/routes.py index 0c9d8eb..6510795 100644 --- a/backend/src/interface/api/routes.py +++ b/backend/src/interface/api/routes.py @@ -1,5 +1,6 @@ import asyncio import html +import re import unicodedata from io import BytesIO from datetime import datetime @@ -10,6 +11,42 @@ from pydantic import BaseModel from typing import Optional, List +LATIN1_BYTE_MAP = { + 192: 'À', 193: 'Á', 194: 'Â', 195: 'Ã', 196: 'Ä', + 199: 'Ç', + 200: 'È', 201: 'É', 202: 'Ê', 203: 'Ë', + 204: 'Ì', 205: 'Í', 206: 'Î', 207: 'Ï', + 209: 'Ñ', + 210: 'Ò', 211: 'Ó', 212: 'Ô', 213: 'Õ', 214: 'Ö', + 217: 'Ù', 218: 'Ú', 219: 'Û', 220: 'Ü', + 224: 'à', 225: 'á', 226: 'â', 227: 'ã', 228: 'ä', + 231: 'ç', + 232: 'è', 233: 'é', 234: 'ê', 235: 'ë', + 236: 'ì', 237: 'í', 238: 'î', 239: 'ï', + 241: 'ñ', + 242: 'ò', 243: 'ó', 244: 'ô', 245: 'õ', 246: 'ö', + 249: 'ù', 250: 'ú', 251: 'û', 252: 'ü', +} + + +def corrigir_encoding(texto: str) -> str: + if not texto: + return texto + + def substituir_byte(match): + byte_val = int(match.group(1)) + return LATIN1_BYTE_MAP.get(byte_val, match.group(0)) + + pattern = r'(?<=[\w\u00C0-\u00FF])(\d{3})(?=[\w\u00C0-\u00FF])' + resultado = texto + for _ in range(5): + novo = re.sub(pattern, substituir_byte, resultado) + if novo == resultado: + break + resultado = novo + return resultado + + def normalizar_texto(texto: str) -> str: if not texto: return "" @@ -41,7 +78,7 @@ from ..schemas.ranking_schema import ( SugerirConsultoresResponseSchema, AreaAvaliacaoSchema, ) -from .dependencies import get_repository, get_ranking_store, get_processar_job, get_es_client +from .dependencies import get_repository, get_ranking_store, get_processar_job, get_es_client, get_ranking_oracle_repo from ...infrastructure.elasticsearch.client import ElasticsearchClient from ...application.jobs.job_status import job_status @@ -168,35 +205,43 @@ async def ranking_paginado( size: int = Query(default=50, ge=1, le=1000, description="Tamanho da página (máx 1000)"), 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)"), - store = Depends(get_ranking_store), + oracle_repo = Depends(get_ranking_oracle_repo), ): - if not store.is_ready(): + import json as json_lib + + if not oracle_repo: + raise HTTPException(status_code=503, detail="Oracle não configurado") + + total = oracle_repo.contar_total(filtro_ativo=ativo) + if total == 0: raise HTTPException( status_code=503, detail="Ranking ainda não foi processado. Execute POST /api/v1/ranking/processar.", ) - filtro_selos = [s.strip() for s in selos.split(",") if s.strip()] if selos else None - total, entries = store.get_page(page=page, size=size, filtro_ativo=ativo, filtro_selos=filtro_selos) - + consultores = oracle_repo.buscar_paginado(page=page, size=size, filtro_ativo=ativo) total_pages = (total + size - 1) // size consultores_schema = [] - for e in entries: - d = e.detalhes + 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 = {} + tipos_atuacao = RankingMapper._extrair_tipos_atuacao(d) consultores_schema.append( ConsultorRankingResumoSchema( - id_pessoa=e.id_pessoa, - nome=e.nome, - posicao=e.posicao, - pontuacao_total=float(e.pontuacao_total), - bloco_a=float(e.bloco_a), - bloco_b=float(e.bloco_b), - bloco_c=float(e.bloco_c), - bloco_d=float(e.bloco_d), - ativo=e.ativo, - anos_atuacao=float(e.anos_atuacao), + id_pessoa=c.id_pessoa, + nome=c.nome, + posicao=c.posicao, + pontuacao_total=float(c.pontuacao_total), + bloco_a=float(c.componente_a), + bloco_b=float(c.componente_b), + bloco_c=float(c.componente_c), + bloco_d=float(c.componente_d), + ativo=c.ativo, + anos_atuacao=float(c.anos_atuacao), tipos_atuacao=tipos_atuacao, coordenador_ppg=bool(d.get("coordenador_ppg", False)), consultoria=d.get("consultoria"), @@ -225,15 +270,12 @@ async def ranking_paginado( async def buscar_por_nome( nome: str = Query(..., min_length=3, description="Nome (ou parte) para buscar"), limit: int = Query(default=5, ge=1, le=20, description="Limite de resultados"), - store = Depends(get_ranking_store), + oracle_repo = Depends(get_ranking_oracle_repo), ): - if not store.is_ready(): - raise HTTPException( - status_code=503, - detail="Ranking ainda não foi processado. Execute POST /api/v1/ranking/processar.", - ) + if not oracle_repo: + raise HTTPException(status_code=503, detail="Oracle não configurado") - resultados = store.buscar_por_nome(nome=nome, limit=limit) + resultados = oracle_repo.buscar_por_nome(nome=nome, limit=limit) return [ ConsultaNomeSchema( id_pessoa=r["ID_PESSOA"], @@ -247,53 +289,20 @@ async def buscar_por_nome( @router.get("/ranking/estatisticas", response_model=EstatisticasRankingSchema) async def ranking_estatisticas( - store = Depends(get_ranking_store), + oracle_repo = Depends(get_ranking_oracle_repo), ): - if not store.is_ready(): + if not oracle_repo: + raise HTTPException(status_code=503, detail="Oracle não configurado") + + total = oracle_repo.contar_total() + if total == 0: raise HTTPException( status_code=503, detail="Ranking ainda não foi processado. Execute POST /api/v1/ranking/processar.", ) - total = store.total() - ativos = store.total(filtro_ativo=True) - inativos = total - ativos - entries = store.get_page(page=1, size=total)[1] if total else [] - totais = [e.pontuacao_total for e in entries] - distribuicao = [] - if total: - buckets = [ - ("800+", lambda x: x >= 800), - ("600-799", lambda x: 600 <= x < 800), - ("400-599", lambda x: 400 <= x < 600), - ("200-399", lambda x: 200 <= x < 400), - ("0-199", lambda x: x < 200), - ] - for faixa, pred in buckets: - qtd = sum(1 for x in totais if pred(x)) - distribuicao.append( - { - "faixa": faixa, - "quantidade": qtd, - "percentual": round((qtd * 100.0 / total), 2) if total else 0, - } - ) - - estatisticas = { - "total_consultores": total, - "total_ativos": ativos, - "total_inativos": inativos, - "ultima_atualizacao": store.last_update.isoformat() if store.last_update else None, - "pontuacao_media": (sum(totais) / total) if total else 0, - "pontuacao_maxima": max(totais) if totais else 0, - "pontuacao_minima": min(totais) if totais else 0, - "media_componentes": { - "a": (sum(e.bloco_a for e in entries) / total) if total else 0, - "b": (sum(e.bloco_b for e in entries) / total) if total else 0, - "c": (sum(e.bloco_c for e in entries) / total) if total else 0, - "d": (sum(e.bloco_d for e in entries) / total) if total else 0, - }, - } + estatisticas = oracle_repo.obter_estatisticas() + distribuicao = oracle_repo.obter_distribuicao() return EstatisticasRankingSchema( total_consultores=estatisticas.get("total_consultores", 0), @@ -333,16 +342,13 @@ async def processar_ranking( @router.get("/ranking/posicao/{id_pessoa}", response_model=PosicaoRankingSchema) async def obter_posicao_ranking( id_pessoa: int, - store = Depends(get_ranking_store), + oracle_repo = Depends(get_ranking_oracle_repo), ): - if not store.is_ready(): - raise HTTPException( - status_code=503, - detail="Ranking ainda não foi processado. Execute POST /api/v1/ranking/processar.", - ) + if not oracle_repo: + raise HTTPException(status_code=503, detail="Oracle não configurado") - entry = store.get_by_id(id_pessoa) - total = store.total() + total = oracle_repo.contar_total() + entry = oracle_repo.buscar_por_id(id_pessoa) if not entry: return PosicaoRankingSchema( @@ -365,10 +371,10 @@ async def obter_posicao_ranking( posicao=entry.posicao, total_consultores=total, pontuacao_total=float(entry.pontuacao_total), - bloco_a=float(entry.bloco_a), - bloco_b=float(entry.bloco_b), - bloco_c=float(entry.bloco_c), - bloco_d=float(entry.bloco_d), + bloco_a=float(entry.componente_a), + bloco_b=float(entry.componente_b), + bloco_c=float(entry.componente_c), + bloco_d=float(entry.componente_d), ativo=entry.ativo, encontrado=True, ) @@ -531,14 +537,14 @@ async def sugerir_consultores( for area in dados.get("areaConhecimentoPos", []): if area.get("nome"): - area_nome = html.unescape(area["nome"]) + area_nome = corrigir_encoding(html.unescape(area["nome"])) areas_conhecimento.add(area_nome) area_norm = normalizar_texto(area["nome"]) if tema_norm in area_norm or any(p in area_norm for p in tema_palavras if len(p) > 3): motivos_match.add(f"Area: {area_nome}") area_aval = area.get("areaAvaliacao", {}) if area_aval and area_aval.get("nome"): - aval_nome = html.unescape(area_aval["nome"]) + aval_nome = corrigir_encoding(html.unescape(area_aval["nome"])) areas_avaliacao.add(aval_nome) aval_norm = normalizar_texto(area_aval["nome"]) if tema_norm in aval_norm or any(p in aval_norm for p in tema_palavras if len(p) > 3): @@ -546,7 +552,7 @@ async def sugerir_consultores( for pesq in dados.get("areaPesquisa", []): if pesq.get("descricao"): - pesq_desc = html.unescape(pesq["descricao"]) + pesq_desc = corrigir_encoding(html.unescape(pesq["descricao"])) linhas_pesquisa.add(pesq_desc) pesq_norm = normalizar_texto(pesq["descricao"]) if tema_norm in pesq_norm or any(p in pesq_norm for p in tema_palavras if len(p) > 3): diff --git a/docker-compose.yml b/docker-compose.yml index 1e21a73..fe6c9a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,8 +33,6 @@ services: build: context: ./frontend dockerfile: Dockerfile - depends_on: - - backend ports: - "5173:5173" environment: @@ -52,26 +50,27 @@ services: restart: unless-stopped oracle18c: - container_name: oracle18c - image: gvenzl/oracle-xe:18-slim - environment: - - ORACLE_PASSWORD=local123 - - ORACLE_CHARACTERSET=AL32UTF8 - - APP_USER=local123 - - APP_USER_PASSWORD=local123 - - TZ=America/Sao_Paulo - ports: - - "1521:1521" - - "5500:5500" - volumes: - - oracle_data:/opt/oracle/oradata - healthcheck: - test: ["CMD", "bash", "-c", "echo 'SELECT 1 FROM DUAL;' | sqlplus -s SYSTEM/\"$${ORACLE_PASSWORD}\"@localhost:1521/XEPDB1"] - interval: 30s - timeout: 10s - retries: 20 - networks: - - shared_network + container_name: oracle18c + image: gvenzl/oracle-xe:18-slim + environment: + - ORACLE_PASSWORD=local123 + - ORACLE_CHARACTERSET=AL32UTF8 + - APP_USER=local123 + - APP_USER_PASSWORD=local123 + - TZ=America/Sao_Paulo + ports: + - "1521:1521" + - "5500:5500" + volumes: + - oracle_data:/opt/oracle/oradata + healthcheck: + test: ["CMD", "bash", "-c", "echo 'SELECT 1 FROM DUAL;' | sqlplus -s SYSTEM/local123@localhost:1521/XEPDB1 | grep -q 1"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 60s + networks: + - shared_network networks: shared_network: diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 1bb7f83..3f4c31b 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -6,7 +6,7 @@ export default defineConfig({ server: { host: '0.0.0.0', port: 5173, - allowedHosts: ['.ngrok-free.app', 'localhost', '127.0.0.1'], + allowedHosts: ['.ngrok-free.app', '.ngrok-free.dev', 'localhost', '127.0.0.1'], proxy: { '/api': { target: 'http://backend:8000', diff --git a/start-ngrok.sh b/start-ngrok.sh index 3e737d5..6f728b9 100755 --- a/start-ngrok.sh +++ b/start-ngrok.sh @@ -16,10 +16,9 @@ pkill -f "ngrok http 5173" 2>/dev/null echo "[4/5] Criando rede e subindo containers..." docker network create shared_network 2>/dev/null -docker compose up -d backend frontend -echo "Aguardando backend subir..." +docker compose up -d +echo "Aguardando containers subirem..." sleep 10 -docker compose up -d backend frontend >/dev/null sleep 2