feat(equipe): implementar montagem de equipe interdisciplinar com PDF

- Adicionar seleção múltipla de consultores entre diferentes buscas
- Criar endpoint POST /api/v1/equipe/pdf para gerar documento da equipe
- Implementar template HTML profissional para PDF da equipe
- Exibir estatísticas: total, ativos, coordenadores, premiados, IES
- Persistir seleção ao trocar termos de busca (equipe interdisciplinar)
- Mostrar temas combinados na barra flutuante e no PDF
- Corrigir renderização de emojis no PDF (substituir por texto)
- Melhorar contraste da cor do ranking no frontend (roxo → ciano)
- Corrigir validação Pydantic para ignorar campos extras do .env
This commit is contained in:
Frederico Castro
2025-12-20 18:30:37 -03:00
parent 81acf1895f
commit bb36961b4c
8 changed files with 1040 additions and 33 deletions

View File

@@ -165,6 +165,49 @@ class PDFService:
return result
def gerar_pdf_equipe(self, tema: str, area_avaliacao: str, consultores: list) -> bytes:
from weasyprint import HTML, CSS
template = self.env.get_template("equipe_consultores.html")
ies_set = set()
areas_set = set()
for c in consultores:
if c.get('ies'):
ies_set.add(c['ies'])
for area in c.get('areas_avaliacao', []):
areas_set.add(area)
estatisticas = {
'total': len(consultores),
'ativos': sum(1 for c in consultores if c.get('situacao', '').lower().find('atividade') >= 0),
'coordenadores': sum(1 for c in consultores if c.get('foi_coordenador')),
'premiados': sum(1 for c in consultores if c.get('foi_premiado')),
'ies_distintas': len(ies_set),
'areas_cobertas': len(areas_set),
'lista_ies': sorted(list(ies_set)),
'lista_areas': sorted(list(areas_set)),
}
html_content = template.render(
tema=tema,
area_avaliacao=area_avaliacao,
consultores=consultores,
estatisticas=estatisticas,
data_geracao=datetime.now().strftime("%d/%m/%Y às %H:%M"),
)
css_path = self.template_dir / "styles.css"
pdf_bytes = HTML(
string=html_content,
base_url=str(self.template_dir)
).write_pdf(
stylesheets=[CSS(filename=str(css_path))]
)
return pdf_bytes
class ConsultorWrapper:
def __init__(self, data: Dict):

View File

@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>Equipe de Consultores - {{ tema }}</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="documento">
<section class="cover">
<div>
<table class="cover-header">
<tr>
<td>
<strong>MINISTÉRIO DA EDUCAÇÃO</strong><br>
Coordenação de Aperfeiçoamento de Pessoal de Nível Superior
</td>
<td class="right small">Sugestão de Equipe</td>
</tr>
</table>
<div class="cover-title">Equipe de Consultores</div>
<div class="cover-subtitle">Sistema de Ranking AtuaCAPES · Seleção Inteligente</div>
<div class="cover-name">{{ tema }}</div>
{% if area_avaliacao %}
<div class="tag-list">
<span class="badge">{{ area_avaliacao }}</span>
</div>
{% endif %}
</div>
<div class="cover-stats">
<table class="stats-table">
<tr>
<td class="stat-cell">
<div class="stat-value">{{ estatisticas.total }}</div>
<div class="stat-label">Consultores</div>
</td>
<td class="stat-cell">
<div class="stat-value">{{ estatisticas.ativos }}</div>
<div class="stat-label">Ativos</div>
</td>
<td class="stat-cell">
<div class="stat-value">{{ estatisticas.coordenadores }}</div>
<div class="stat-label">Coordenadores</div>
</td>
<td class="stat-cell">
<div class="stat-value">{{ estatisticas.premiados }}</div>
<div class="stat-label">Premiados</div>
</td>
</tr>
<tr>
<td class="stat-cell" colspan="2">
<div class="stat-value">{{ estatisticas.ies_distintas }}</div>
<div class="stat-label">IES Distintas</div>
</td>
<td class="stat-cell" colspan="2">
<div class="stat-value">{{ estatisticas.areas_cobertas }}</div>
<div class="stat-label">Áreas Cobertas</div>
</td>
</tr>
</table>
</div>
<div class="cover-footer">
<p>Documento gerado em {{ data_geracao }}</p>
<p class="small">Ranking de Consultores CAPES · Geração Automática</p>
</div>
</section>
<section class="summary-section">
<h2>Resumo da Equipe</h2>
<div class="summary-grid">
<div class="summary-box">
<h4>Instituições Representadas</h4>
<div class="tag-list">
{% for ies in estatisticas.lista_ies %}
<span class="badge">{{ ies }}</span>
{% endfor %}
</div>
</div>
<div class="summary-box">
<h4>Áreas de Avaliação</h4>
<div class="tag-list">
{% for area in estatisticas.lista_areas %}
<span class="badge info">{{ area }}</span>
{% endfor %}
</div>
</div>
</div>
</section>
<section class="team-section">
<h2>Membros da Equipe</h2>
{% for consultor in consultores %}
<div class="team-member">
<div class="member-header">
<div class="member-rank">#{{ loop.index }}</div>
<div class="member-info">
<div class="member-name">{{ consultor.nome }}</div>
<div class="member-ies">{{ consultor.ies or '-' }}</div>
</div>
<div class="member-badges">
{% if consultor.foi_coordenador %}
<span class="badge success">Coordenador</span>
{% endif %}
{% if consultor.foi_premiado %}
<span class="badge warning">Premiado</span>
{% endif %}
{% if consultor.situacao and 'atividade' in consultor.situacao.lower() %}
<span class="badge">Ativo</span>
{% else %}
<span class="badge muted">Histórico</span>
{% endif %}
</div>
</div>
<div class="member-stats">
{% if consultor.posicao_ranking %}
<div class="stat-inline">
<span class="stat-label">Ranking:</span>
<span class="stat-value-inline">#{{ consultor.posicao_ranking }}</span>
</div>
{% endif %}
{% if consultor.pontuacao_ranking %}
<div class="stat-inline">
<span class="stat-label">Pontos:</span>
<span class="stat-value-inline">{{ consultor.pontuacao_ranking|int }}</span>
</div>
{% endif %}
</div>
{% if consultor.motivos_match %}
<div class="member-motivos">
<strong>Por que este consultor:</strong>
<div class="tag-list">
{% for motivo in consultor.motivos_match %}
<span class="badge info small">{{ motivo }}</span>
{% endfor %}
</div>
</div>
{% endif %}
{% if consultor.areas_avaliacao %}
<div class="member-areas">
<strong>Áreas de Avaliação:</strong>
<span>{{ consultor.areas_avaliacao | join(', ') }}</span>
</div>
{% endif %}
{% if consultor.linhas_pesquisa %}
<div class="member-pesquisa">
<strong>Linhas de Pesquisa:</strong>
<ul>
{% for linha in consultor.linhas_pesquisa[:5] %}
<li>{{ linha }}</li>
{% endfor %}
{% if consultor.linhas_pesquisa|length > 5 %}
<li class="muted">... e mais {{ consultor.linhas_pesquisa|length - 5 }} linhas</li>
{% endif %}
</ul>
</div>
{% endif %}
</div>
{% endfor %}
</section>
<section class="footer-section">
<p class="disclaimer">
Este documento foi gerado automaticamente pelo Sistema de Ranking de Consultores CAPES.
As sugestões são baseadas em análise de relevância temática, posição no ranking geral e diversidade institucional.
</p>
<p class="generation-info">
Gerado em {{ data_geracao }} · Tema: "{{ tema }}"
{% if area_avaliacao %} · Área: {{ area_avaliacao }}{% endif %}
</p>
</section>
</div>
</body>
</html>

View File

@@ -305,3 +305,206 @@ h1, h2, h3, h4 {
.center {
text-align: center;
}
.cover-stats {
margin: 24px 0;
}
.stats-table {
width: 100%;
border-collapse: collapse;
background: white;
border: 1px solid var(--border);
border-radius: 8px;
}
.stat-cell {
padding: 16px 12px;
text-align: center;
border: 1px solid var(--border);
}
.stat-value {
font-size: 24pt;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 9pt;
color: var(--muted);
margin-top: 4px;
}
.summary-section {
page-break-before: always;
padding-top: 20px;
}
.summary-section h2 {
color: var(--accent);
font-size: 14pt;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid var(--accent);
}
.summary-grid {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.summary-box {
flex: 1;
min-width: 200px;
background: var(--bg-soft);
border: 1px solid var(--border);
padding: 16px;
border-radius: 8px;
}
.summary-box h4 {
font-size: 10pt;
color: var(--muted);
margin-bottom: 12px;
}
.team-section {
margin-top: 24px;
}
.team-section h2 {
color: var(--accent);
font-size: 14pt;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid var(--accent);
}
.team-member {
background: var(--bg-soft);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
page-break-inside: avoid;
}
.member-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.member-rank {
background: var(--accent);
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 11pt;
}
.member-info {
flex: 1;
}
.member-name {
font-size: 12pt;
font-weight: 700;
color: var(--ink);
}
.member-ies {
font-size: 9pt;
color: var(--muted);
}
.member-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.member-stats {
display: flex;
gap: 16px;
margin-bottom: 10px;
}
.stat-inline {
display: flex;
align-items: center;
gap: 4px;
font-size: 9pt;
color: var(--muted);
background: var(--bg-soft);
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border);
}
.stat-label {
font-weight: 600;
color: var(--muted);
}
.stat-value-inline {
font-weight: 700;
color: var(--accent);
}
.member-motivos {
background: white;
border: 1px solid var(--border);
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 10px;
font-size: 9pt;
}
.member-areas {
font-size: 9pt;
margin-bottom: 8px;
}
.member-pesquisa {
font-size: 9pt;
}
.member-pesquisa ul {
margin-left: 16px;
margin-top: 4px;
}
.member-pesquisa li {
margin-bottom: 2px;
}
.footer-section {
margin-top: 32px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.disclaimer {
font-size: 8pt;
color: var(--muted);
font-style: italic;
}
.generation-info {
font-size: 8pt;
color: var(--muted);
margin-top: 8px;
}
.muted {
color: var(--muted);
}

View File

@@ -3,7 +3,7 @@ from typing import List
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
ES_URL: str = "http://localhost:9200"
ES_INDEX: str = "atuacapes"

View File

@@ -1,11 +1,23 @@
import asyncio
import html
import unicodedata
from io import BytesIO
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional, List
def normalizar_texto(texto: str) -> str:
if not texto:
return ""
texto = html.unescape(texto)
texto = unicodedata.normalize('NFD', texto)
texto = ''.join(c for c in texto if unicodedata.category(c) != 'Mn')
return texto.lower()
from ...application.use_cases.obter_ranking import ObterRankingUseCase
from ...application.use_cases.obter_consultor import ObterConsultorUseCase
from ...infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl
@@ -415,6 +427,61 @@ async def exportar_ficha_pdf(
)
class ConsultorEquipeSchema(BaseModel):
id_pessoa: int
nome: str
ies: Optional[str] = None
areas_avaliacao: List[str] = []
areas_conhecimento: List[str] = []
linhas_pesquisa: List[str] = []
situacao: str = ""
foi_coordenador: bool = False
foi_premiado: bool = False
posicao_ranking: Optional[int] = None
pontuacao_ranking: float = 0
motivos_match: List[str] = []
class GerarEquipePdfRequest(BaseModel):
tema: str
area_avaliacao: Optional[str] = None
consultores: List[ConsultorEquipeSchema]
@router.post("/equipe/pdf")
async def gerar_pdf_equipe(
request: GerarEquipePdfRequest,
):
from ...application.services.pdf_service import PDFService
if not request.consultores:
raise HTTPException(status_code=400, detail="Nenhum consultor selecionado")
try:
pdf_service = PDFService()
consultores_dict = [c.model_dump() for c in request.consultores]
pdf_bytes = pdf_service.gerar_pdf_equipe(
tema=request.tema,
area_avaliacao=request.area_avaliacao,
consultores=consultores_dict
)
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Erro ao gerar PDF: {str(e)}")
tema_sanitizado = "".join(c if c.isalnum() or c in " -_" else "_" for c in request.tema)
nome_arquivo = f"equipe_{tema_sanitizado[:30]}_{datetime.now().strftime('%Y%m%d')}.pdf"
return StreamingResponse(
BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"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"),
@@ -429,10 +496,10 @@ async def sugerir_consultores(
tema=tema,
area_avaliacao=area_avaliacao,
apenas_ativos=apenas_ativos,
size=quantidade
size=quantidade * 3
)
consultores = []
consultores_raw = []
for doc in resultados:
id_pessoa = doc.get("id")
nome = doc.get("dadosPessoais", {}).get("nome", "")
@@ -445,6 +512,10 @@ async def sugerir_consultores(
ies = None
foi_coordenador = False
foi_premiado = False
motivos_match = set()
tema_norm = normalizar_texto(tema)
tema_palavras = tema_norm.split()
for atuacao in doc.get("atuacoes", []):
tipo = atuacao.get("tipo", "")
@@ -457,14 +528,26 @@ async def sugerir_consultores(
for area in dados.get("areaConhecimentoPos", []):
if area.get("nome"):
areas_conhecimento.add(area["nome"])
area_nome = 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"):
areas_avaliacao.add(area_aval["nome"])
aval_nome = 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):
motivos_match.add(f"Area Avaliacao: {aval_nome}")
for pesq in dados.get("areaPesquisa", []):
if pesq.get("descricao"):
linhas_pesquisa.add(pesq["descricao"])
pesq_desc = 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):
motivos_match.add(f"Pesquisa: {pesq_desc[:50]}...")
elif "Coordenação" in tipo:
foi_coordenador = True
@@ -473,23 +556,79 @@ async def sugerir_consultores(
foi_premiado = True
posicao_ranking = None
pontuacao_ranking = 0
if store.is_ready():
entry = store.get_by_id(id_pessoa)
if entry:
posicao_ranking = entry.posicao
pontuacao_ranking = entry.pontuacao_total
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,
))
score_final = score_match
if posicao_ranking:
bonus_ranking = max(0, (10000 - posicao_ranking) / 100)
score_final += bonus_ranking
if foi_coordenador:
score_final += 20
motivos_match.add("Foi Coordenador de Area")
if foi_premiado:
score_final += 10
motivos_match.add("Foi Premiado")
consultores_raw.append({
"id_pessoa": id_pessoa,
"nome": nome,
"score_match": score_match,
"score_final": score_final,
"posicao_ranking": posicao_ranking,
"pontuacao_ranking": pontuacao_ranking,
"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,
"motivos_match": list(motivos_match)[:5],
})
consultores_raw.sort(key=lambda x: x["score_final"], reverse=True)
ies_selecionadas = set()
consultores_finais = []
for c in consultores_raw:
if len(consultores_finais) >= quantidade:
break
if c["ies"] and c["ies"] in ies_selecionadas and len(consultores_finais) < quantidade // 2:
continue
consultores_finais.append(c)
if c["ies"]:
ies_selecionadas.add(c["ies"])
while len(consultores_finais) < quantidade and len(consultores_finais) < len(consultores_raw):
for c in consultores_raw:
if c not in consultores_finais:
consultores_finais.append(c)
if len(consultores_finais) >= quantidade:
break
consultores = [
SugestaoConsultorSchema(
id_pessoa=c["id_pessoa"],
nome=c["nome"],
score_match=c["score_final"],
areas_avaliacao=c["areas_avaliacao"],
areas_conhecimento=c["areas_conhecimento"],
linhas_pesquisa=c["linhas_pesquisa"],
situacao=c["situacao"],
ies=c["ies"],
foi_coordenador=c["foi_coordenador"],
foi_premiado=c["foi_premiado"],
posicao_ranking=c["posicao_ranking"],
pontuacao_ranking=c["pontuacao_ranking"],
motivos_match=c["motivos_match"],
)
for c in consultores_finais
]
return SugerirConsultoresResponseSchema(
tema_buscado=tema,

View File

@@ -104,6 +104,9 @@ class SugestaoConsultorSchema(BaseModel):
ies: Optional[str] = None
foi_coordenador: bool = False
foi_premiado: bool = False
posicao_ranking: Optional[int] = None
pontuacao_ranking: float = 0
motivos_match: List[str] = []
class SugerirConsultoresRequestSchema(BaseModel):