| 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) |
|
|