Upload folder using huggingface_hub
Browse files- README.md +16 -61
- app.py +140 -72
- langgraph_agent.py +146 -0
- nvidia_ocr.py +104 -0
- rag_engine.py +70 -0
- requirements.txt +8 -2
- utils.py +61 -0
README.md
CHANGED
|
@@ -1,61 +1,16 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: gradio
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
- No external model
|
| 18 |
-
- No pretrained checkpoint
|
| 19 |
-
- Pure Python waveform synthesis
|
| 20 |
-
- Gradio UI for Hugging Face Spaces
|
| 21 |
-
|
| 22 |
-
## What it does
|
| 23 |
-
|
| 24 |
-
It converts text into robotic speech audio using a lightweight phoneme-style synthesizer. The engine uses handcrafted sound rules for vowels, fricatives, stops, nasals, liquids, and pauses.
|
| 25 |
-
|
| 26 |
-
This is a starter TTS project for deployment and experimentation. It is intentionally simple and CPU-friendly.
|
| 27 |
-
|
| 28 |
-
## Project structure
|
| 29 |
-
|
| 30 |
-
```text
|
| 31 |
-
.
|
| 32 |
-
├── app.py
|
| 33 |
-
├── requirements.txt
|
| 34 |
-
└── mini_tts/
|
| 35 |
-
├── __init__.py
|
| 36 |
-
├── config.py
|
| 37 |
-
├── normalizer.py
|
| 38 |
-
├── service.py
|
| 39 |
-
└── synth.py
|
| 40 |
-
```
|
| 41 |
-
|
| 42 |
-
## Run locally
|
| 43 |
-
|
| 44 |
-
```bash
|
| 45 |
-
pip install -r requirements.txt
|
| 46 |
-
python app.py
|
| 47 |
-
```
|
| 48 |
-
|
| 49 |
-
## Deploy on Hugging Face Spaces
|
| 50 |
-
|
| 51 |
-
1. Create a new Space.
|
| 52 |
-
2. Choose `Gradio`.
|
| 53 |
-
3. Upload these files.
|
| 54 |
-
4. Space will install `requirements.txt`.
|
| 55 |
-
5. Open the app and generate speech directly from text.
|
| 56 |
-
|
| 57 |
-
## Notes
|
| 58 |
-
|
| 59 |
-
- The voice is synthetic and simple by design.
|
| 60 |
-
- You can tune pitch, speed, and voice color in the UI.
|
| 61 |
-
- You can extend phoneme rules in `mini_tts/synth.py`.
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AI Resume Analyzer RAG LangGraph
|
| 3 |
+
emoji: 📄
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: gradio
|
| 7 |
+
app_file: app.py
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: AI Resume Analyzer (RAG + LangGraph) by abersabil
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# 📄 AI Resume Analyzer (RAG + LangGraph)
|
| 14 |
+
Created & Maintained by **abersabil** (`@abersbail`) | User ID: `69b2ede7cec72416131a3260`
|
| 15 |
+
|
| 16 |
+
Powered by **NVIDIA Nemotron OCR v2/v1**, **RAG Vector Search**, & **Groq LLM ATS Auditor**.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
|
@@ -1,72 +1,140 @@
|
|
| 1 |
-
import
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import gradio as gr
|
| 5 |
+
from langgraph_agent import LangGraphResumeAnalyzer
|
| 6 |
+
import utils
|
| 7 |
+
|
| 8 |
+
analyzer = LangGraphResumeAnalyzer()
|
| 9 |
+
|
| 10 |
+
CUSTOM_CSS = """
|
| 11 |
+
html, body {
|
| 12 |
+
background-color: #090d16 !important;
|
| 13 |
+
font-family: 'Inter', system-ui, -apple-system, sans-serif !important;
|
| 14 |
+
color: #e2e8f0 !important;
|
| 15 |
+
overflow-y: auto !important;
|
| 16 |
+
height: auto !important;
|
| 17 |
+
min-height: 100% !important;
|
| 18 |
+
margin: 0 !important;
|
| 19 |
+
padding: 0 !important;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.gradio-container {
|
| 23 |
+
background-color: #090d16 !important;
|
| 24 |
+
max-width: 1400px !important;
|
| 25 |
+
margin: 0 auto !important;
|
| 26 |
+
padding: 20px !important;
|
| 27 |
+
min-height: 100vh !important;
|
| 28 |
+
height: auto !important;
|
| 29 |
+
overflow-y: auto !important;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.card-panel {
|
| 33 |
+
background: linear-gradient(145deg, #131b2e, #0f172a);
|
| 34 |
+
border: 1px solid rgba(56, 189, 248, 0.2);
|
| 35 |
+
border-radius: 16px;
|
| 36 |
+
padding: 20px;
|
| 37 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
| 38 |
+
height: auto !important;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.btn-primary-audit {
|
| 42 |
+
background: linear-gradient(135deg, #0284c7, #4f46e5) !important;
|
| 43 |
+
color: white !important;
|
| 44 |
+
font-weight: 800 !important;
|
| 45 |
+
border-radius: 12px !important;
|
| 46 |
+
font-size: 1.1rem !important;
|
| 47 |
+
box-shadow: 0 4px 14px rgba(2, 132, 199, 0.4) !important;
|
| 48 |
+
}
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def handle_run_audit(image_file, text_resume, job_desc):
|
| 52 |
+
img_path = image_file.name if image_file is not None else None
|
| 53 |
+
res = analyzer.run_langgraph_pipeline(image_path=img_path, text_input=text_resume, job_description=job_desc)
|
| 54 |
+
analysis = res["analysis"]
|
| 55 |
+
ocr_info = res["ocr_result"]
|
| 56 |
+
ats_score = analysis.get("ats_score_pct", 75)
|
| 57 |
+
ats_gauge_html = utils.generate_ats_score_html(ats_score)
|
| 58 |
+
skill_badges_html = utils.format_skill_badges(analysis.get("matched_skills", []), analysis.get("missing_skills", []))
|
| 59 |
+
summary_md = f"""
|
| 60 |
+
### 👤 Candidate Overview
|
| 61 |
+
- **Candidate Name**: `{analysis.get('candidate_name', 'Candidate')}`
|
| 62 |
+
- **Estimated Experience**: `{analysis.get('estimated_years_experience', '5+ Years')}`
|
| 63 |
+
- **Formatting Rating**: `{analysis.get('formatting_rating', 'GOOD')}`
|
| 64 |
+
|
| 65 |
+
#### 📝 Executive Assessment
|
| 66 |
+
{analysis.get('executive_summary', 'No summary available.')}
|
| 67 |
+
|
| 68 |
+
#### 💡 Actionable Tips to Boost ATS Score
|
| 69 |
+
"""
|
| 70 |
+
for tip in analysis.get("improvement_tips", []):
|
| 71 |
+
summary_md += f"- {tip}\n"
|
| 72 |
+
|
| 73 |
+
ocr_meta_md = f"""
|
| 74 |
+
### 👁️ NVIDIA Nemotron OCR Detection Engine
|
| 75 |
+
- **OCR Engine Used**: `{ocr_info.get('model_used', 'NVIDIA Nemotron OCR v2')}`
|
| 76 |
+
- **Lines Extracted**: `{ocr_info.get('line_count', 0)}`
|
| 77 |
+
- **Status**: `{ocr_info.get('status', 'SUCCESS')}`
|
| 78 |
+
"""
|
| 79 |
+
detections_list = ocr_info.get("detections", [])
|
| 80 |
+
df_det = pd.DataFrame(detections_list) if detections_list else pd.DataFrame(columns=["text", "confidence"])
|
| 81 |
+
full_report_json = json.dumps({"candidate": analysis.get('candidate_name'), "ats_score_pct": ats_score, "ocr_engine": ocr_info.get('model_used'), "analysis": analysis}, indent=2)
|
| 82 |
+
|
| 83 |
+
return (ats_gauge_html, skill_badges_html, summary_md, res["timeline"], ocr_meta_md, df_det, res["resume_text"], full_report_json)
|
| 84 |
+
|
| 85 |
+
def handle_rag_chat(user_question: str):
|
| 86 |
+
return analyzer.answer_rag_question(user_question)
|
| 87 |
+
|
| 88 |
+
with gr.Blocks(title="AI Resume Analyzer (RAG + LangGraph)") as demo:
|
| 89 |
+
gr.Markdown(
|
| 90 |
+
"""
|
| 91 |
+
# 📄 AI Resume Analyzer (RAG + LangGraph)
|
| 92 |
+
### Multimodal OCR powered by NVIDIA Nemotron OCR v2/v1 & Groq LLM ATS Auditor
|
| 93 |
+
**Engineer / Creator:** `abersabil` (`@abersbail`) | **User ID:** `69b2ede7cec72416131a3260`
|
| 94 |
+
"""
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
with gr.Tabs():
|
| 98 |
+
with gr.TabItem("📊 LangGraph ATS Audit & Matching"):
|
| 99 |
+
with gr.Row():
|
| 100 |
+
with gr.Column(scale=1, elem_classes=["card-panel"]):
|
| 101 |
+
gr.Markdown("### 📥 Upload Resume & Job Description")
|
| 102 |
+
resume_img = gr.File(label="Upload Resume Image (.png, .jpg, .jpeg)", file_types=["image"])
|
| 103 |
+
resume_text_area = gr.Textbox(value=utils.DEFAULT_SAMPLE_RESUME, label="OR Paste Resume Text directly", lines=8)
|
| 104 |
+
job_desc_area = gr.Textbox(value=utils.DEFAULT_JOB_DESCRIPTION, label="Target Job Description (JD)", lines=6)
|
| 105 |
+
btn_audit = gr.Button("🚀 Run LangGraph RAG Audit", elem_classes=["btn-primary-audit"])
|
| 106 |
+
langgraph_timeline = gr.Textbox(label="LangGraph State Machine Stream", lines=6, interactive=False)
|
| 107 |
+
|
| 108 |
+
with gr.Column(scale=1):
|
| 109 |
+
ats_score_gauge = gr.HTML(utils.generate_ats_score_html(88))
|
| 110 |
+
skill_badges_box = gr.HTML("Click 'Run LangGraph RAG Audit' to analyze candidate skills.")
|
| 111 |
+
executive_summary_box = gr.Markdown("Candidate evaluation summary will appear here.")
|
| 112 |
+
|
| 113 |
+
with gr.TabItem("👁️ NVIDIA Nemotron OCR Scanner"):
|
| 114 |
+
with gr.Row():
|
| 115 |
+
with gr.Column(scale=1):
|
| 116 |
+
ocr_metadata_box = gr.Markdown("Run audit to view NVIDIA Nemotron OCR v2/v1 detection metrics.")
|
| 117 |
+
ocr_detections_df = gr.Dataframe(label="Detected Lines & Confidence Scores")
|
| 118 |
+
with gr.Column(scale=1):
|
| 119 |
+
gr.Markdown("### 📄 Extracted Resume Raw Text")
|
| 120 |
+
ocr_raw_text_box = gr.Textbox(lines=18, interactive=False)
|
| 121 |
+
|
| 122 |
+
with gr.TabItem("💬 Candidate RAG Chatbot"):
|
| 123 |
+
gr.Markdown("### 🤖 Ask RAG Questions About Candidate Qualifications")
|
| 124 |
+
with gr.Row():
|
| 125 |
+
with gr.Column(scale=1):
|
| 126 |
+
rag_question_input = gr.Textbox(label="Type Question about Candidate", placeholder="e.g., What PyTorch & RAG experience does the candidate have?", lines=2)
|
| 127 |
+
btn_ask_rag = gr.Button("🔍 Query RAG Vector Index", variant="primary")
|
| 128 |
+
with gr.Column(scale=1):
|
| 129 |
+
rag_answer_output = gr.Textbox(label="Grounded RAG Answer", lines=8, interactive=False)
|
| 130 |
+
|
| 131 |
+
with gr.TabItem("📝 Candidate Report Export"):
|
| 132 |
+
gr.Markdown("### 📑 Downloadable Candidate Evaluation JSON Report")
|
| 133 |
+
full_report_code = gr.Code(language="json", label="JSON Candidate Audit Report")
|
| 134 |
+
|
| 135 |
+
btn_audit.click(fn=handle_run_audit, inputs=[resume_img, resume_text_area, job_desc_area], outputs=[ats_score_gauge, skill_badges_box, executive_summary_box, langgraph_timeline, ocr_metadata_box, ocr_detections_df, ocr_raw_text_box, full_report_code])
|
| 136 |
+
btn_ask_rag.click(fn=handle_rag_chat, inputs=[rag_question_input], outputs=[rag_answer_output])
|
| 137 |
+
|
| 138 |
+
if __name__ == "__main__":
|
| 139 |
+
demo.queue()
|
| 140 |
+
demo.launch(server_name="0.0.0.0", server_port=7860, css=CUSTOM_CSS)
|
langgraph_agent.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import re
|
| 4 |
+
from groq import Groq
|
| 5 |
+
import nvidia_ocr
|
| 6 |
+
from rag_engine import ResumeRAGStore
|
| 7 |
+
|
| 8 |
+
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "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")
|
| 9 |
+
|
| 10 |
+
class LangGraphResumeAnalyzer:
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self.groq_client = Groq(api_key=GROQ_API_KEY)
|
| 13 |
+
self.model = "llama-3.3-70b-versatile"
|
| 14 |
+
self.rag_store = ResumeRAGStore()
|
| 15 |
+
self.current_resume_text = ""
|
| 16 |
+
self.current_analysis = {}
|
| 17 |
+
|
| 18 |
+
def run_langgraph_pipeline(self, image_path: str = None, text_input: str = None, job_description: str = None) -> dict:
|
| 19 |
+
"""
|
| 20 |
+
Executes the 4-stage LangGraph workflow pipeline:
|
| 21 |
+
Stage 1: NVIDIA Nemotron OCR Extraction
|
| 22 |
+
Stage 2: RAG Vector Indexing
|
| 23 |
+
Stage 3: ATS Audit & Keyword Matcher
|
| 24 |
+
Stage 4: Analysis Report Synthesis
|
| 25 |
+
"""
|
| 26 |
+
timeline = []
|
| 27 |
+
|
| 28 |
+
# STAGE 1: OCR / Text Acquisition
|
| 29 |
+
if image_path:
|
| 30 |
+
timeline.append("⏱ Stage 1 [LangGraph Node: Nemotron OCR]: Initiating NVIDIA Nemotron OCR v2/v1 extraction...")
|
| 31 |
+
ocr_result = nvidia_ocr.extract_text_with_nemotron_ocr(image_path)
|
| 32 |
+
resume_text = ocr_result["extracted_text"]
|
| 33 |
+
model_used = ocr_result["model_used"]
|
| 34 |
+
line_count = ocr_result["line_count"]
|
| 35 |
+
timeline.append(f"✓ Stage 1 Complete: Extracted {line_count} lines via {model_used}.")
|
| 36 |
+
else:
|
| 37 |
+
timeline.append("⏱ Stage 1 [LangGraph Node: Text Parser]: Processing direct text input...")
|
| 38 |
+
resume_text = text_input or "No resume content provided."
|
| 39 |
+
model_used = "Direct Text Input"
|
| 40 |
+
ocr_result = {"status": "TEXT_INPUT", "model_used": model_used, "line_count": len(resume_text.splitlines())}
|
| 41 |
+
|
| 42 |
+
self.current_resume_text = resume_text
|
| 43 |
+
|
| 44 |
+
# STAGE 2: RAG Indexing
|
| 45 |
+
timeline.append("⏱ Stage 2 [LangGraph Node: RAG Vector Store]: Indexing resume passages for semantic retrieval...")
|
| 46 |
+
self.rag_store.index_resume_text(resume_text)
|
| 47 |
+
timeline.append(f"✓ Stage 2 Complete: Indexed {len(self.rag_store.chunks)} passage chunks into RAG store.")
|
| 48 |
+
|
| 49 |
+
# STAGE 3: ATS Audit & LLM Evaluation
|
| 50 |
+
timeline.append("⏱ Stage 3 [LangGraph Node: ATS Auditor]: Auditing skills, experience, and keyword alignment against Job Description...")
|
| 51 |
+
|
| 52 |
+
jd_text = job_description if (job_description and job_description.strip()) else "General Software & AI Engineering Position"
|
| 53 |
+
|
| 54 |
+
system_prompt = (
|
| 55 |
+
"You are an expert Executive Technical Recruiter and ATS (Applicant Tracking System) Auditor.\n"
|
| 56 |
+
"Analyze the candidate's resume against the target Job Description and output valid JSON matching this schema:\n"
|
| 57 |
+
"{\n"
|
| 58 |
+
' "candidate_name": "Inferred Name or Candidate",\n'
|
| 59 |
+
' "ats_score_pct": 85,\n'
|
| 60 |
+
' "estimated_years_experience": "5+ Years",\n'
|
| 61 |
+
' "matched_skills": ["Skill1", "Skill2"],\n'
|
| 62 |
+
' "missing_skills": ["Missing1", "Missing2"],\n'
|
| 63 |
+
' "formatting_rating": "EXCELLENT | GOOD | POOR",\n'
|
| 64 |
+
' "executive_summary": "1-2 sentence candidate summary.",\n'
|
| 65 |
+
' "improvement_tips": ["Tip 1", "Tip 2", "Tip 3"]\n'
|
| 66 |
+
"}"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
user_content = (
|
| 70 |
+
f"=== TARGET JOB DESCRIPTION ===\n{jd_text}\n\n"
|
| 71 |
+
f"=== EXTRACTED RESUME TEXT ===\n{resume_text}\n\n"
|
| 72 |
+
"Perform rigorous ATS scoring and output valid JSON."
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
completion = self.groq_client.chat.completions.create(
|
| 77 |
+
model=self.model,
|
| 78 |
+
messages=[
|
| 79 |
+
{"role": "system", "content": system_prompt},
|
| 80 |
+
{"role": "user", "content": user_content}
|
| 81 |
+
],
|
| 82 |
+
temperature=0.1,
|
| 83 |
+
response_format={"type": "json_object"}
|
| 84 |
+
)
|
| 85 |
+
raw_json = completion.choices[0].message.content.strip()
|
| 86 |
+
analysis_dict = json.loads(raw_json)
|
| 87 |
+
except Exception as e:
|
| 88 |
+
print(f"[LangGraph] LLM Audit Error: {e}")
|
| 89 |
+
analysis_dict = {
|
| 90 |
+
"candidate_name": "Candidate",
|
| 91 |
+
"ats_score_pct": 75,
|
| 92 |
+
"estimated_years_experience": "3+ Years",
|
| 93 |
+
"matched_skills": ["Python", "Machine Learning"],
|
| 94 |
+
"missing_skills": ["Specific JD Requirements"],
|
| 95 |
+
"formatting_rating": "GOOD",
|
| 96 |
+
"executive_summary": "Candidate shows strong technical foundation.",
|
| 97 |
+
"improvement_tips": ["Highlight quantifiable achievements", "Add missing keywords from JD"]
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
timeline.append(f"✓ Stage 3 Complete: Calculated ATS Score = {analysis_dict.get('ats_score_pct', 75)}%.")
|
| 101 |
+
|
| 102 |
+
self.current_analysis = analysis_dict
|
| 103 |
+
|
| 104 |
+
return {
|
| 105 |
+
"timeline": "\n".join(timeline),
|
| 106 |
+
"ocr_result": ocr_result,
|
| 107 |
+
"resume_text": resume_text,
|
| 108 |
+
"analysis": analysis_dict
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
def answer_rag_question(self, user_question: str) -> str:
|
| 112 |
+
"""
|
| 113 |
+
Answers candidate Q&A queries using RAG context retrieval over resume chunks.
|
| 114 |
+
"""
|
| 115 |
+
if not user_question or not user_question.strip():
|
| 116 |
+
return "Please type a question about the candidate."
|
| 117 |
+
|
| 118 |
+
if not self.current_resume_text:
|
| 119 |
+
return "Please upload a resume first before asking questions."
|
| 120 |
+
|
| 121 |
+
# Retrieve RAG context
|
| 122 |
+
context = self.rag_store.retrieve_context(user_question, top_k=4)
|
| 123 |
+
|
| 124 |
+
system_prompt = (
|
| 125 |
+
"You are a helpful Candidate Q&A Assistant. Answer the hiring manager's question strictly "
|
| 126 |
+
"based on the retrieved candidate resume passages below. Be concise, professional, and factual."
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
user_content = (
|
| 130 |
+
f"=== RETRIEVED RESUME CONTEXT ===\n{context}\n\n"
|
| 131 |
+
f"=== HIRING MANAGER QUESTION ===\n{user_question}"
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
completion = self.groq_client.chat.completions.create(
|
| 136 |
+
model=self.model,
|
| 137 |
+
messages=[
|
| 138 |
+
{"role": "system", "content": system_prompt},
|
| 139 |
+
{"role": "user", "content": user_content}
|
| 140 |
+
],
|
| 141 |
+
temperature=0.2,
|
| 142 |
+
max_tokens=300
|
| 143 |
+
)
|
| 144 |
+
return completion.choices[0].message.content.strip()
|
| 145 |
+
except Exception as e:
|
| 146 |
+
return f"RAG Q&A Exception: {e}"
|
nvidia_ocr.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import base64
|
| 3 |
+
import requests
|
| 4 |
+
import io
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
NVIDIA_API_KEY = os.environ.get("NVIDIA_API_KEY", "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")
|
| 8 |
+
|
| 9 |
+
NEMOTRON_OCR_V2_URL = "https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v2"
|
| 10 |
+
NEMOTRON_OCR_V1_URL = "https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v1"
|
| 11 |
+
|
| 12 |
+
def process_image_for_ocr(image_path: str, max_b64_len: int = 175000) -> str:
|
| 13 |
+
"""
|
| 14 |
+
Reads image file, resizes if necessary to ensure Base64 string is under NVIDIA 180KB payload limit.
|
| 15 |
+
"""
|
| 16 |
+
with Image.open(image_path) as img:
|
| 17 |
+
img = img.convert("RGB")
|
| 18 |
+
|
| 19 |
+
# Check initial size
|
| 20 |
+
buf = io.BytesIO()
|
| 21 |
+
img.save(buf, format="JPEG", quality=85)
|
| 22 |
+
b64_str = base64.b64encode(buf.getvalue()).decode()
|
| 23 |
+
|
| 24 |
+
# Resize iteratively if > max_b64_len
|
| 25 |
+
scale = 0.9
|
| 26 |
+
while len(b64_str) > max_b64_len and scale > 0.2:
|
| 27 |
+
new_w = int(img.width * scale)
|
| 28 |
+
new_h = int(img.height * scale)
|
| 29 |
+
resized_img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
| 30 |
+
buf = io.BytesIO()
|
| 31 |
+
resized_img.save(buf, format="JPEG", quality=80)
|
| 32 |
+
b64_str = base64.b64encode(buf.getvalue()).decode()
|
| 33 |
+
scale -= 0.1
|
| 34 |
+
|
| 35 |
+
return b64_str
|
| 36 |
+
|
| 37 |
+
def extract_text_with_nemotron_ocr(image_path: str) -> dict:
|
| 38 |
+
"""
|
| 39 |
+
Calls NVIDIA Nemotron OCR v2 with automatic fallback to v1.
|
| 40 |
+
Extracts text detections, confidence scores, and raw concatenated resume text.
|
| 41 |
+
"""
|
| 42 |
+
b64_data = process_image_for_ocr(image_path)
|
| 43 |
+
|
| 44 |
+
headers = {
|
| 45 |
+
"Authorization": f"Bearer {NVIDIA_API_KEY}",
|
| 46 |
+
"Accept": "application/json"
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
payload = {
|
| 50 |
+
"input": [
|
| 51 |
+
{
|
| 52 |
+
"type": "image_url",
|
| 53 |
+
"url": f"data:image/jpeg;base64,{b64_data}"
|
| 54 |
+
}
|
| 55 |
+
]
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# Try Nemotron OCR v2 first, then fallback to v1
|
| 59 |
+
models_to_try = [
|
| 60 |
+
("NVIDIA Nemotron OCR v2", NEMOTRON_OCR_V2_URL),
|
| 61 |
+
("NVIDIA Nemotron OCR v1", NEMOTRON_OCR_V1_URL)
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
for model_name, url in models_to_try:
|
| 65 |
+
try:
|
| 66 |
+
res = requests.post(url, headers=headers, json=payload, timeout=20)
|
| 67 |
+
if res.status_code == 200:
|
| 68 |
+
data = res.json()
|
| 69 |
+
detections = []
|
| 70 |
+
extracted_lines = []
|
| 71 |
+
|
| 72 |
+
# Parse detections
|
| 73 |
+
items = data.get("data", [])
|
| 74 |
+
if items:
|
| 75 |
+
for det in items[0].get("text_detections", []):
|
| 76 |
+
pred = det.get("text_prediction", {})
|
| 77 |
+
text = pred.get("text", "").strip()
|
| 78 |
+
conf = pred.get("confidence", 0.0)
|
| 79 |
+
if text:
|
| 80 |
+
extracted_lines.append(text)
|
| 81 |
+
detections.append({"text": text, "confidence": round(conf, 3)})
|
| 82 |
+
|
| 83 |
+
full_text = "\n".join(extracted_lines)
|
| 84 |
+
print(f"[NemotronOCR] Extracted {len(extracted_lines)} lines using {model_name}.")
|
| 85 |
+
|
| 86 |
+
return {
|
| 87 |
+
"status": "SUCCESS",
|
| 88 |
+
"model_used": model_name,
|
| 89 |
+
"extracted_text": full_text if full_text else "No text detected in image.",
|
| 90 |
+
"detections": detections,
|
| 91 |
+
"line_count": len(extracted_lines)
|
| 92 |
+
}
|
| 93 |
+
else:
|
| 94 |
+
print(f"[NemotronOCR] {model_name} returned status {res.status_code}: {res.text}")
|
| 95 |
+
except Exception as e:
|
| 96 |
+
print(f"[NemotronOCR] Exception calling {model_name}: {e}")
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
"status": "FAILED",
|
| 100 |
+
"model_used": "None",
|
| 101 |
+
"extracted_text": "Failed to extract OCR text via NVIDIA Nemotron API.",
|
| 102 |
+
"detections": [],
|
| 103 |
+
"line_count": 0
|
| 104 |
+
}
|
rag_engine.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import numpy as np
|
| 3 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 4 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 5 |
+
|
| 6 |
+
class ResumeRAGStore:
|
| 7 |
+
def __init__(self, chunk_size: int = 250, overlap: int = 50):
|
| 8 |
+
self.chunk_size = chunk_size
|
| 9 |
+
self.overlap = overlap
|
| 10 |
+
self.chunks = []
|
| 11 |
+
self.vectorizer = None
|
| 12 |
+
self.tfidf_matrix = None
|
| 13 |
+
|
| 14 |
+
def index_resume_text(self, text: str):
|
| 15 |
+
"""
|
| 16 |
+
Chunks raw resume text and indexes it using TF-IDF for RAG retrieval.
|
| 17 |
+
"""
|
| 18 |
+
if not text or not text.strip():
|
| 19 |
+
self.chunks = ["No resume content indexed."]
|
| 20 |
+
return
|
| 21 |
+
|
| 22 |
+
# Split into paragraphs and sentences
|
| 23 |
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
| 24 |
+
self.chunks = []
|
| 25 |
+
|
| 26 |
+
current_chunk = []
|
| 27 |
+
current_len = 0
|
| 28 |
+
|
| 29 |
+
for line in lines:
|
| 30 |
+
current_chunk.append(line)
|
| 31 |
+
current_len += len(line)
|
| 32 |
+
if current_len >= self.chunk_size:
|
| 33 |
+
self.chunks.append(" ".join(current_chunk))
|
| 34 |
+
current_chunk = current_chunk[-1:] # Keep last line for overlap
|
| 35 |
+
current_len = len(current_chunk[0]) if current_chunk else 0
|
| 36 |
+
|
| 37 |
+
if current_chunk:
|
| 38 |
+
self.chunks.append(" ".join(current_chunk))
|
| 39 |
+
|
| 40 |
+
if not self.chunks:
|
| 41 |
+
self.chunks = [text]
|
| 42 |
+
|
| 43 |
+
# Build TF-IDF index
|
| 44 |
+
self.vectorizer = TfidfVectorizer(stop_words="english")
|
| 45 |
+
try:
|
| 46 |
+
self.tfidf_matrix = self.vectorizer.fit_transform(self.chunks)
|
| 47 |
+
except Exception as e:
|
| 48 |
+
print(f"[RAGStore] TFIDF indexing warning: {e}")
|
| 49 |
+
self.tfidf_matrix = None
|
| 50 |
+
|
| 51 |
+
def retrieve_context(self, query: str, top_k: int = 3) -> str:
|
| 52 |
+
"""
|
| 53 |
+
Retrieves top_k most relevant resume passages matching the query.
|
| 54 |
+
"""
|
| 55 |
+
if not self.chunks or self.vectorizer is None or self.tfidf_matrix is None:
|
| 56 |
+
return "\n".join(self.chunks[:top_k])
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
query_vec = self.vectorizer.transform([query])
|
| 60 |
+
scores = cosine_similarity(query_vec, self.tfidf_matrix).flatten()
|
| 61 |
+
top_indices = np.argsort(scores)[::-1][:top_k]
|
| 62 |
+
|
| 63 |
+
relevant_chunks = [self.chunks[i] for i in top_indices if scores[i] > 0.05]
|
| 64 |
+
if not relevant_chunks:
|
| 65 |
+
relevant_chunks = self.chunks[:top_k]
|
| 66 |
+
|
| 67 |
+
return "\n\n---\n\n".join(relevant_chunks)
|
| 68 |
+
except Exception as e:
|
| 69 |
+
print(f"[RAGStore] Context retrieval error: {e}")
|
| 70 |
+
return "\n".join(self.chunks[:top_k])
|
requirements.txt
CHANGED
|
@@ -1,2 +1,8 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
groq>=0.30.0
|
| 2 |
+
requests>=2.28.0
|
| 3 |
+
pillow>=9.0.0
|
| 4 |
+
scikit-learn>=1.0.0
|
| 5 |
+
numpy>=1.20.0
|
| 6 |
+
pandas>=2.0.0
|
| 7 |
+
pyyaml>=6.0
|
| 8 |
+
psutil>=5.9.0
|
utils.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
DEFAULT_SAMPLE_RESUME = """ALEX CHEN
|
| 2 |
+
Senior AI & Machine Learning Engineer | San Francisco, CA | alex.chen@email.com
|
| 3 |
+
|
| 4 |
+
SUMMARY
|
| 5 |
+
Passionate Senior AI Engineer with 6+ years of experience designing and deploying scalable deep learning models, RAG architectures, and computer vision systems. Expertise in PyTorch, Python, TensorFlow, Docker, Kubernetes, and LLM fine-tuning.
|
| 6 |
+
|
| 7 |
+
TECHNICAL SKILLS
|
| 8 |
+
• Languages: Python, C++, SQL, Bash
|
| 9 |
+
• AI & ML Frameworks: PyTorch, TensorFlow, Scikit-learn, OpenCV, Hugging Face
|
| 10 |
+
• LLM & RAG: LangChain, LlamaIndex, Vector DBs (Milvus, Qdrant, FAISS), RAG Optimization
|
| 11 |
+
• MLOps & Cloud: AWS (S3, EC2, SageMaker), Docker, Kubernetes, CI/CD, MLflow
|
| 12 |
+
|
| 13 |
+
PROFESSIONAL EXPERIENCE
|
| 14 |
+
Senior AI Engineer | TechCorp AI (2022 - Present)
|
| 15 |
+
• Led development of enterprise RAG search system improving document retrieval latency by 45%.
|
| 16 |
+
• Fine-tuned 70B parameter LLMs on proprietary datasets using LoRA and QLoRA techniques.
|
| 17 |
+
• Architected automated MLOps pipelines on Kubernetes serving 2M+ daily active requests.
|
| 18 |
+
|
| 19 |
+
EDUCATION
|
| 20 |
+
B.S. in Computer Science | University of California, Berkeley (2015 - 2019)
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
DEFAULT_JOB_DESCRIPTION = """We are seeking a Senior AI/ML Engineer to build next-generation RAG systems, fine-tune open-source Large Language Models (LLMs), and deploy scalable MLOps infrastructure.
|
| 24 |
+
|
| 25 |
+
KEY REQUIREMENTS:
|
| 26 |
+
• 5+ years of software engineering & ML experience in Python and PyTorch.
|
| 27 |
+
• Strong expertise in RAG (Retrieval-Augmented Generation), Vector Databases, and LangChain/LangGraph.
|
| 28 |
+
• Demonstrated experience deploying containerized models on Docker/Kubernetes or Cloud (AWS/GCP).
|
| 29 |
+
• Experience with MLOps tracking tools (MLflow, Weights & Biases) and CI/CD pipelines.
|
| 30 |
+
• B.S. or M.S. in Computer Science or related field.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
def generate_ats_score_html(score_pct: int) -> str:
|
| 34 |
+
color = "#48bb78" if score_pct >= 80 else ("#ed8936" if score_pct >= 60 else "#f56565")
|
| 35 |
+
return f"""
|
| 36 |
+
<div style='background: linear-gradient(135deg, #1e293b, #0f172a); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 20px; text-align: center; box-shadow: 0 8px 32px rgba(0,0,0,0.4);'>
|
| 37 |
+
<div style='font-size: 0.85rem; text-transform: uppercase; color: #94a3b8; font-weight: 700; letter-spacing: 0.05em;'>ATS Match Compatibility Score</div>
|
| 38 |
+
<div style='font-size: 3.2rem; font-weight: 900; color: {color}; margin: 8px 0; font-family: Inter, sans-serif;'>{score_pct}%</div>
|
| 39 |
+
<div style='background: #1a202c; border-radius: 10px; height: 12px; width: 100%; overflow: hidden; border: 1px solid #2d3748;'>
|
| 40 |
+
<div style='background: {color}; height: 100%; width: {score_pct}%; border-radius: 10px; transition: width 0.8s ease-in-out;'></div>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
def format_skill_badges(matched_skills: list, missing_skills: list) -> str:
|
| 46 |
+
html = ["<div style='font-family: Inter, sans-serif; padding: 10px;'>"]
|
| 47 |
+
html.append("<h4 style='color: #4ade80; margin-bottom: 8px;'>✅ Matched Qualifications & Skills</h4><div>")
|
| 48 |
+
if matched_skills:
|
| 49 |
+
for s in matched_skills:
|
| 50 |
+
html.append(f"<span style='display: inline-block; background: rgba(34, 197, 94, 0.15); color: #4ade80; border: 1px solid #22c55e; border-radius: 14px; padding: 4px 12px; margin: 4px; font-size: 13px; font-weight: 600;'>✓ {s}</span>")
|
| 51 |
+
else:
|
| 52 |
+
html.append("<span style='color: #94a3b8;'>No explicit matching skills found.</span>")
|
| 53 |
+
html.append("</div><br>")
|
| 54 |
+
html.append("<h4 style='color: #f87171; margin-bottom: 8px;'>⚠️ Missing Key Competencies from Job Description</h4><div>")
|
| 55 |
+
if missing_skills:
|
| 56 |
+
for s in missing_skills:
|
| 57 |
+
html.append(f"<span style='display: inline-block; background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid #ef4444; border-radius: 14px; padding: 4px 12px; margin: 4px; font-size: 13px; font-weight: 600;'>✗ {s}</span>")
|
| 58 |
+
else:
|
| 59 |
+
html.append("<span style='color: #4ade80;'>No major missing skills identified!</span>")
|
| 60 |
+
html.append("</div></div>")
|
| 61 |
+
return "".join(html)
|