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 # ========= NVIDIA API ========= 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 config ========= 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?", ] # (NEW) sugestões por tema 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.", ], } # ========= Paths ========= 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.") # Load FAISS & chunks index = faiss.read_index(INDEX_FILE) chunks = np.load(CHUNKS_FILE, allow_pickle=True) # ========= Embeddings ========= embedding_model = SentenceTransformer("all-MiniLM-L6-v2") # (NEW) pré-cálculo de embedding médio do CV (para match score global) _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]) # ========= Chat state ========= # Agora no formato OpenAI-style, compatível com gr.Chatbot(type="messages") dialog_history: list[dict] = [] # ========= Helpers – NVIDIA chat ========= 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: # Alguns chunks podem vir sem "choices" (keep-alive / metadados) 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() # ========= PDF utils (NEW) ========= 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() # ========= Chat principal ========= 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" ), } # mensagens = system + histórico + user 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 [], "" # ========= (NEW) Mini-bio multi-formato ========= 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}" # ========= (NEW) Carta de motivação + Match score ========= 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." # Similaridade global 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)) # [-1,1] sim_norm = max(0.0, min(1.0, (sim + 1) / 2)) # [0,1] # Requisitos/cobertura (heurística simples) 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 # ========= (NEW) Métricas do CV ========= 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) # ========= UI ========= 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(): # Main chat 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]) # Sidebar 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)