|
|
import os |
|
|
from pathlib import Path |
|
|
import re |
|
|
import io |
|
|
import time |
|
|
import json |
|
|
from collections import Counter |
|
|
|
|
|
import gradio as gr |
|
|
import numpy as np |
|
|
import faiss |
|
|
from sentence_transformers import SentenceTransformer |
|
|
from openai import OpenAI, OpenAIError |
|
|
|
|
|
|
|
|
NV_API_KEY = os.environ.get("NV_API_KEY") |
|
|
if not NV_API_KEY: |
|
|
raise RuntimeError("🔒 NV_API_KEY not set. Configure it em Settings → Variables & Secrets.") |
|
|
|
|
|
client = OpenAI(base_url="https://integrate.api.nvidia.com/v1", api_key=NV_API_KEY) |
|
|
CHAT_MODEL = "meta/llama3-8b-instruct" |
|
|
|
|
|
|
|
|
APP_TITLE = "CVchat – Ronaldo Menezes" |
|
|
INTRO = ( |
|
|
"👋 Olá! Eu sou o CVchat do Ronaldo Menezes.\n" |
|
|
"Converse sobre minha experiência, projetos, tecnologias e resultados.\n\n" |
|
|
"Exemplos de perguntas:\n" |
|
|
"• Quem é o Ronaldo Menezes\n" |
|
|
"• Resuma sua experiência com Process Mining.\n" |
|
|
"• Que linguagens e ferramentas você domina?\n" |
|
|
"• Fale de um projeto com financiamento público que você liderou.\n" |
|
|
) |
|
|
SUGGESTION_QUESTIONS = [ |
|
|
"Links & exemplos de trabalhos", |
|
|
"Quais tecnologias você mais usa?", |
|
|
"Resuma sua experiência com Machine Learning.", |
|
|
"Artigo sobre Landsat ou Sentinel?", |
|
|
"Você já trabalhou com mainframe/COBOL?", |
|
|
"Certificações?", |
|
|
] |
|
|
|
|
|
|
|
|
SUGGESTIONS_THEMES = { |
|
|
"Projetos financiados": [ |
|
|
"Liste projetos com financiamento público (CNPq, QREN, UE) e resultados.", |
|
|
"Qual foi o impacto de projetos financiados (KPIs, prazos, orçamento)?", |
|
|
], |
|
|
"Artigos & Publicações": [ |
|
|
"Quais artigos/publicações mais relevantes e onde foram publicados?", |
|
|
"Resumo de publicações sobre sensoriamento remoto (Landsat/Sentinel).", |
|
|
], |
|
|
"Habilidades técnicas": [ |
|
|
"Stack técnica principal (linguagens, libs, cloud, bancos).", |
|
|
"Experiência com FAISS, RAG e LLMs na prática.", |
|
|
], |
|
|
"Liderança & Gestão": [ |
|
|
"Experiência liderando equipes/projetos e responsabilidades.", |
|
|
"Exemplos de melhorias de processo e resultados mensuráveis.", |
|
|
], |
|
|
} |
|
|
|
|
|
|
|
|
INDEX_FILE = "r_docs.index" |
|
|
CHUNKS_FILE = "r_chunks.npy" |
|
|
PDF_PATH = "CV-Ronaldo_Menezes_2025_06.pdf" |
|
|
|
|
|
if not Path(INDEX_FILE).exists() or not Path(CHUNKS_FILE).exists(): |
|
|
raise FileNotFoundError("Index not found. Run build_index.py to generate r_docs.index and r_chunks.npy.") |
|
|
|
|
|
|
|
|
index = faiss.read_index(INDEX_FILE) |
|
|
chunks = np.load(CHUNKS_FILE, allow_pickle=True) |
|
|
|
|
|
|
|
|
embedding_model = SentenceTransformer("all-MiniLM-L6-v2") |
|
|
|
|
|
|
|
|
_cv_emb_mean = None |
|
|
def _ensure_cv_mean(): |
|
|
global _cv_emb_mean |
|
|
if _cv_emb_mean is None: |
|
|
embs = embedding_model.encode(list(chunks), convert_to_numpy=True, normalize_embeddings=True) |
|
|
_cv_emb_mean = embs.mean(axis=0) |
|
|
return _cv_emb_mean |
|
|
|
|
|
def retrieve_context(query: str, k: int = 4) -> str: |
|
|
q_emb = embedding_model.encode([query], convert_to_numpy=True, normalize_embeddings=True) |
|
|
_, I = index.search(q_emb, k) |
|
|
return "\n---\n".join(chunks[i] for i in I[0]) |
|
|
|
|
|
|
|
|
|
|
|
dialog_history: list[dict] = [] |
|
|
|
|
|
|
|
|
def nv_stream(messages, temperature, top_p, max_tokens): |
|
|
"""Streaming robusto (evita chunk sem choices e delta sem content).""" |
|
|
assistant_reply = "" |
|
|
stream = client.chat.completions.create( |
|
|
model=CHAT_MODEL, |
|
|
messages=messages, |
|
|
temperature=temperature, |
|
|
top_p=top_p, |
|
|
max_tokens=max_tokens, |
|
|
stream=True, |
|
|
) |
|
|
|
|
|
for chunk in stream: |
|
|
|
|
|
choices = getattr(chunk, "choices", None) |
|
|
if not choices: |
|
|
continue |
|
|
if len(choices) == 0: |
|
|
continue |
|
|
|
|
|
choice0 = choices[0] |
|
|
delta = getattr(choice0, "delta", None) |
|
|
if delta is None: |
|
|
continue |
|
|
|
|
|
content = getattr(delta, "content", None) |
|
|
if content: |
|
|
assistant_reply += content |
|
|
yield assistant_reply |
|
|
|
|
|
finish_reason = getattr(choice0, "finish_reason", None) |
|
|
if finish_reason in ("stop", "length"): |
|
|
break |
|
|
|
|
|
def nv_complete(messages, temperature, top_p, max_tokens) -> str: |
|
|
"""Completa de uma vez (para PDFs e utilitários).""" |
|
|
resp = client.chat.completions.create( |
|
|
model=CHAT_MODEL, |
|
|
messages=messages, |
|
|
temperature=temperature, |
|
|
top_p=top_p, |
|
|
max_tokens=max_tokens, |
|
|
stream=False, |
|
|
) |
|
|
return resp.choices[0].message.content.strip() |
|
|
|
|
|
|
|
|
def _to_pdf_bytes(title: str, body: str) -> bytes: |
|
|
from reportlab.pdfgen import canvas |
|
|
from reportlab.lib.pagesizes import A4 |
|
|
from reportlab.lib.utils import simpleSplit |
|
|
|
|
|
buf = io.BytesIO() |
|
|
c = canvas.Canvas(buf, pagesize=A4) |
|
|
w, h = A4 |
|
|
margin = 50 |
|
|
|
|
|
c.setTitle(title) |
|
|
c.setFont("Helvetica-Bold", 14) |
|
|
c.drawString(margin, h - margin, title) |
|
|
|
|
|
c.setFont("Helvetica", 11) |
|
|
y = h - margin - 30 |
|
|
lines = simpleSplit(body, "Helvetica", 11, w - 2 * margin) |
|
|
|
|
|
for line in lines: |
|
|
if y < margin: |
|
|
c.showPage() |
|
|
c.setFont("Helvetica", 11) |
|
|
y = h - margin |
|
|
c.drawString(margin, y, line) |
|
|
y -= 15 |
|
|
|
|
|
c.showPage() |
|
|
c.save() |
|
|
buf.seek(0) |
|
|
return buf.read() |
|
|
|
|
|
|
|
|
def chatbot(user_input: str, temperature: float, top_p: float, max_tokens: int): |
|
|
global dialog_history |
|
|
if not user_input: |
|
|
return dialog_history, "" |
|
|
|
|
|
context = retrieve_context(user_input) |
|
|
system_msg = { |
|
|
"role": "system", |
|
|
"content": ( |
|
|
"You are an assistant specialized in the candidate's CV. " |
|
|
"Use ONLY the retrieved context to answer. If you don't know, say you don't know.\n\n" |
|
|
f"=== Retrieved Context ===\n{context}\n\n" |
|
|
), |
|
|
} |
|
|
|
|
|
|
|
|
messages = [system_msg] + dialog_history + [{"role": "user", "content": user_input}] |
|
|
|
|
|
reply_full = "" |
|
|
try: |
|
|
for partial in nv_stream(messages, temperature, top_p, max_tokens): |
|
|
reply_full = partial |
|
|
|
|
|
dialog_history.extend([ |
|
|
{"role": "user", "content": user_input}, |
|
|
{"role": "assistant", "content": reply_full}, |
|
|
]) |
|
|
|
|
|
except OpenAIError as e: |
|
|
reply_full = f"⚠️ API Error: {e.__class__.__name__}: {e}" |
|
|
dialog_history.extend([ |
|
|
{"role": "user", "content": user_input}, |
|
|
{"role": "assistant", "content": reply_full}, |
|
|
]) |
|
|
|
|
|
return dialog_history, "" |
|
|
|
|
|
def clear_history(): |
|
|
global dialog_history |
|
|
dialog_history = [] |
|
|
return [], "" |
|
|
|
|
|
|
|
|
MINI_BIO_STYLES = { |
|
|
"Acadêmico": "Estilo acadêmico, objetivo, cite publicações/projetos e área de pesquisa.", |
|
|
"Corporativo": "Tom profissional para negócios, destaque resultados, KPIs e liderança.", |
|
|
"Pitch curto": "3-4 frases diretas, chamando atenção para conquistas-chave.", |
|
|
} |
|
|
|
|
|
def generate_mini_bio(style_key: str, temperature: float, top_p: float, max_tokens: int): |
|
|
if style_key not in MINI_BIO_STYLES: |
|
|
return None, "Selecione um formato de mini-bio." |
|
|
|
|
|
context = retrieve_context("resumo do currículo, principais resultados e tecnologias", k=8) |
|
|
system_msg = { |
|
|
"role": "system", |
|
|
"content": ( |
|
|
"Use apenas o contexto do CV para gerar uma mini-bio. " |
|
|
"Não invente fatos. Seja fiel ao conteúdo.\n\n" |
|
|
f"=== Contexto do CV ===\n{context}\n" |
|
|
), |
|
|
} |
|
|
user_msg = { |
|
|
"role": "user", |
|
|
"content": f"Produza uma mini-bio em português. Estilo: {MINI_BIO_STYLES[style_key]} (150-220 palavras).", |
|
|
} |
|
|
|
|
|
try: |
|
|
text = nv_complete([system_msg, user_msg], temperature, top_p, max_tokens) |
|
|
pdf_bytes = _to_pdf_bytes(f"Mini-bio ({style_key})", text) |
|
|
filename = f"mini_bio_{style_key.replace(' ','_').lower()}_{int(time.time())}.pdf" |
|
|
with open(filename, "wb") as f: |
|
|
f.write(pdf_bytes) |
|
|
return filename, "Mini-bio gerada com sucesso." |
|
|
except OpenAIError as e: |
|
|
return None, f"⚠️ API Error: {e}" |
|
|
|
|
|
|
|
|
def generate_cover_letter(job_desc: str, temperature: float, top_p: float, max_tokens: int): |
|
|
if not job_desc or not job_desc.strip(): |
|
|
return None, "Cole a descrição da vaga primeiro." |
|
|
|
|
|
context = retrieve_context(job_desc, k=8) |
|
|
sys = { |
|
|
"role": "system", |
|
|
"content": ( |
|
|
"Gere uma carta de motivação baseada SOMENTE no CV (contexto) e na vaga. " |
|
|
"Inclua 2-3 conquistas mensuráveis e tecnologias relevantes. 250-350 palavras.\n\n" |
|
|
f"=== Contexto (CV) ===\n{context}\n" |
|
|
), |
|
|
} |
|
|
usr = { |
|
|
"role": "user", |
|
|
"content": f"Descrição da vaga:\n{job_desc}\n\nGerar carta em PT-BR/PT-PT, tom profissional.", |
|
|
} |
|
|
|
|
|
try: |
|
|
text = nv_complete([sys, usr], temperature, top_p, max_tokens) |
|
|
pdf_bytes = _to_pdf_bytes("Carta de Motivação", text) |
|
|
filename = f"carta_{int(time.time())}.pdf" |
|
|
with open(filename, "wb") as f: |
|
|
f.write(pdf_bytes) |
|
|
return filename, "Carta gerada com sucesso." |
|
|
except OpenAIError as e: |
|
|
return None, f"⚠️ API Error: {e}" |
|
|
|
|
|
def compute_match_score(job_desc: str): |
|
|
""" |
|
|
Score 0-100 = 60% similaridade (job vs CV médio) + 40% cobertura de requisitos. |
|
|
Requisitos = palavras-chave (simples) extraídas da vaga; cobertura = % presentes no contexto recuperado. |
|
|
""" |
|
|
if not job_desc or not job_desc.strip(): |
|
|
return "Cole a descrição da vaga para calcular o match score." |
|
|
|
|
|
|
|
|
cv_mean = _ensure_cv_mean() |
|
|
job_emb = embedding_model.encode([job_desc], convert_to_numpy=True, normalize_embeddings=True)[0] |
|
|
sim = float(np.dot(cv_mean, job_emb)) |
|
|
sim_norm = max(0.0, min(1.0, (sim + 1) / 2)) |
|
|
|
|
|
|
|
|
req_tokens = re.findall(r"[a-zA-ZÀ-ÿ0-9\-\+#\.]{3,}", job_desc.lower()) |
|
|
stop = set(["com","para","dos","das","uma","um","de","da","do","and","the","with","sem","em","na","no","os","as","que"]) |
|
|
req_keywords = [t for t in req_tokens if t not in stop] |
|
|
most_common = [w for w, _ in Counter(req_keywords).most_common(20)] |
|
|
|
|
|
retrieved = retrieve_context(job_desc, k=8).lower() |
|
|
hits = sum(1 for w in most_common if w in retrieved) |
|
|
coverage = hits / max(1, len(most_common)) |
|
|
|
|
|
score = int(round(100 * (0.6 * sim_norm + 0.4 * coverage))) |
|
|
explain = ( |
|
|
f"Similaridade global: {int(sim_norm*100)}% | " |
|
|
f"Cobertura de requisitos: {int(coverage*100)}% | " |
|
|
f"→ Match score: **{score}/100**" |
|
|
) |
|
|
return explain |
|
|
|
|
|
|
|
|
TECH_HINTS = [ |
|
|
"python","r","faiss","qdrant","pytorch","tensorflow","scikit","gradio","streamlit", |
|
|
"gis","qgis","gdal","grass","sentinel","landsat","process mining","rag","vit","mask2former" |
|
|
] |
|
|
COUNTRY_HINTS = ["portugal","brasil","germany","alemanh","spain","espanha","europe","europa","france","italy","uk","usa"] |
|
|
|
|
|
def extract_metrics(): |
|
|
text_all = " \n".join(map(str, chunks)) |
|
|
|
|
|
pubs = len(re.findall(r"\b(publica(?:ç(?:ões|ao|ão)|dos?)|paper|article|artigo|ieee|springer|acm)\b", text_all, flags=re.I)) |
|
|
years = sorted(set(re.findall(r"\b(20\d{2}|19\d{2})\b", text_all))) |
|
|
|
|
|
tech_counts = {t: len(re.findall(re.escape(t), text_all, flags=re.I)) for t in TECH_HINTS} |
|
|
top_tech = sorted([k for k,v in tech_counts.items() if v > 0], key=lambda k: tech_counts[k], reverse=True)[:8] |
|
|
|
|
|
intl_hits = sum(len(re.findall(c, text_all, flags=re.I)) for c in COUNTRY_HINTS) |
|
|
|
|
|
md = [ |
|
|
"### Métricas do CV (estimativas)\n", |
|
|
f"- **Publicações (sinalizadas)**: ~{pubs}", |
|
|
f"- **Anos mencionados**: {', '.join(years[:12])}{'…' if len(years) > 12 else ''}", |
|
|
f"- **Tecnologias mais citadas**: {', '.join(top_tech) if top_tech else '—'}", |
|
|
f"- **Menções internacionais**: ~{intl_hits}", |
|
|
"\n> Observação: estimativas baseadas em busca por palavras-chave nos trechos indexados.", |
|
|
] |
|
|
return "\n".join(md) |
|
|
|
|
|
|
|
|
custom_css = r""" |
|
|
:root { --primary:#4a90e2; --bg-light:#f9f9f9; --txt-dark:#333; --radius:8px; --spacing:1rem; } |
|
|
body { background: var(--bg-light); color: var(--txt-dark); font-family: 'Helvetica Neue', sans-serif; } |
|
|
#chat-window { height: 65vh; overflow-y: auto; padding: var(--spacing); border: 1px solid #ddd; border-radius: var(--radius); } |
|
|
.sidebar { background: var(--bg-light); padding: var(--spacing); border-left: 1px solid #eee; } |
|
|
""" |
|
|
|
|
|
with gr.Blocks(title=APP_TITLE, css=custom_css, theme=gr.themes.Base()) as demo: |
|
|
gr.Markdown(f"## {APP_TITLE}") |
|
|
gr.Markdown(INTRO) |
|
|
|
|
|
with gr.Row(): |
|
|
|
|
|
with gr.Column(scale=3): |
|
|
chatbot_ui = gr.Chatbot(type="messages", elem_id="chat-window") |
|
|
txt = gr.Textbox(placeholder="Digite sua pergunta…", lines=2) |
|
|
btn_send = gr.Button("Enviar", variant="primary") |
|
|
btn_clear = gr.Button("Limpar") |
|
|
|
|
|
with gr.Accordion("Parâmetros avançados", open=False): |
|
|
temperature = gr.Slider(0, 1, value=0.6, label="Temperature") |
|
|
top_p = gr.Slider(0, 1, value=0.95, label="Top-p") |
|
|
max_tokens = gr.Slider(64, 2048, value=512, step=64, label="Max Tokens") |
|
|
|
|
|
btn_send.click(chatbot, [txt, temperature, top_p, max_tokens], [chatbot_ui, txt]) |
|
|
txt.submit(chatbot, [txt, temperature, top_p, max_tokens], [chatbot_ui, txt]) |
|
|
btn_clear.click(clear_history, [], [chatbot_ui, txt]) |
|
|
|
|
|
|
|
|
with gr.Column(scale=2, elem_classes="sidebar"): |
|
|
if Path(PDF_PATH).exists(): |
|
|
gr.Markdown(f"[📄 Baixar CV em PDF](/file={PDF_PATH})") |
|
|
|
|
|
gr.Markdown("### Sugestões de Perguntas") |
|
|
for q in SUGGESTION_QUESTIONS: |
|
|
gr.Button(q).click(lambda suggestion=q: suggestion, outputs=[txt]) |
|
|
|
|
|
gr.Markdown("---") |
|
|
gr.Markdown("### Sugestões por tema") |
|
|
for theme, qs in SUGGESTIONS_THEMES.items(): |
|
|
with gr.Accordion(theme, open=False): |
|
|
for q in qs: |
|
|
gr.Button(q).click(lambda s=q: s, outputs=[txt]) |
|
|
|
|
|
gr.Markdown("---") |
|
|
gr.Markdown("### Exportação rápida – Mini-bio (PDF)") |
|
|
bio_style = gr.Dropdown(choices=list(MINI_BIO_STYLES.keys()), value="Corporativo", label="Formato") |
|
|
btn_bio = gr.Button("Gerar Mini-bio (PDF)") |
|
|
bio_file = gr.File(label="Mini-bio gerada") |
|
|
bio_msg = gr.Markdown() |
|
|
btn_bio.click(generate_mini_bio, [bio_style, temperature, top_p, max_tokens], [bio_file, bio_msg]) |
|
|
|
|
|
gr.Markdown("---") |
|
|
gr.Markdown("### Assistente de candidatura") |
|
|
job_desc = gr.Textbox(label="Cole a descrição da vaga", lines=8, placeholder="Cole aqui a JD…") |
|
|
with gr.Row(): |
|
|
btn_cover = gr.Button("Gerar Carta (PDF)") |
|
|
btn_match = gr.Button("Calcular Match Score") |
|
|
|
|
|
cover_file = gr.File(label="Carta gerada") |
|
|
cover_msg = gr.Markdown() |
|
|
match_out = gr.Markdown() |
|
|
|
|
|
btn_cover.click(generate_cover_letter, [job_desc, temperature, top_p, max_tokens], [cover_file, cover_msg]) |
|
|
btn_match.click(lambda jd: compute_match_score(jd), [job_desc], [match_out]) |
|
|
|
|
|
gr.Markdown("---") |
|
|
gr.Markdown("### Métricas do CV") |
|
|
btn_metrics = gr.Button("Recalcular métricas") |
|
|
metrics_md = gr.Markdown(value=extract_metrics()) |
|
|
btn_metrics.click(lambda: extract_metrics(), [], [metrics_md]) |
|
|
|
|
|
gr.Markdown("---") |
|
|
gr.Markdown("### Dicas de Exploração do PDF") |
|
|
gr.Markdown("• Use palavras-chave como 'Process Mining', 'GIS', 'Sentinel' para ir direto à seção relevante.") |
|
|
gr.Markdown("• Peça detalhes de projetos financiados (CNPq, QREN, UE) e resultados mensuráveis.") |
|
|
|
|
|
if __name__ == "__main__": |
|
|
demo.launch(server_name="0.0.0.0", server_port=7860) |
|
|
|