Download app.py from Geoeasy/CVchat: direct link, hf CLI and curl.
- Browser
- Download file 17.7 kB
-
https://huggingface.co/spaces/Geoeasy/CVchat/resolve/main/app.py
- Command line
-
hf download hf://spaces/Geoeasy/CVchat/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Geoeasy/CVchat/resolve/main/app.py
17.7 kB
| 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.") | |
| NV_BASE_URL = os.environ.get("NV_BASE_URL", "https://integrate.api.nvidia.com/v1").rstrip("/") | |
| CHAT_MODEL = os.environ.get("NV_CHAT_MODEL", "meta/llama-3.1-8b-instruct") | |
| client = OpenAI(base_url=NV_BASE_URL, api_key=NV_API_KEY) | |
| def _api_error_message(e: OpenAIError) -> str: | |
| detail = str(e) | |
| if "404" in detail or e.__class__.__name__ == "NotFoundError": | |
| return ( | |
| f"⚠️ API Error: {e.__class__.__name__}: {detail}\n\n" | |
| f"Current NVIDIA model: `{CHAT_MODEL}`\n" | |
| f"Current NVIDIA base URL: `{NV_BASE_URL}`\n\n" | |
| "This usually means the model name is no longer available in the NVIDIA catalog, " | |
| "or the base URL is wrong. Try setting `NV_CHAT_MODEL` to another available model, " | |
| "for example `meta/llama-3.1-8b-instruct`." | |
| ) | |
| return f"⚠️ API Error: {e.__class__.__name__}: {detail}" | |
| # ========= 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 = _api_error_message(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, _api_error_message(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, _api_error_message(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) | |