Cristian Martinez
improve UI and enable LLM selection
53c82bf
# app.py
import os
import re
import json
from dotenv import load_dotenv
from llm_client import LLMClient
from pypdf import PdfReader
import gradio as gr
# ─── Helpers ────────────────────────────────────────────────────────────────────
def extract_json(text: str) -> str:
"""Strip markdown fences so we can parse the JSON body."""
text = re.sub(r"```(?:json)?\s*", "", text)
text = re.sub(r"\s*```", "", text)
start, end = text.find("{"), text.rfind("}")
return text[start:end+1] if (start != -1 and end != -1) else text
def read_resume(file_obj):
"""Extract text from PDF or return raw UTF-8 text."""
name = file_obj.name.lower()
if name.endswith(".pdf"):
reader = PdfReader(file_obj.name)
return "\n".join(page.extract_text() or "" for page in reader.pages)
else:
return file_obj.read().decode("utf-8")
def analyze_resume(resume_content: str, job_title: str, job_description: str) -> dict:
"""Analyzes a candidate's resume against a specific job title and description.
This tool evaluates the compatibility of a resume with a given job, providing a comprehensive assessment including an overall score, interview likelihood, matching skills, and areas for improvement.
Args:
resume_content (str): The full text content of the candidate's resume.
job_title (str): The title of the job for which the resume is being evaluated.
job_description (str): The detailed description of the job, including responsibilities and requirements.
Returns:
dict: A JSON object containing the analysis results with the following keys:
- overall_score (int): A score from 1-10 indicating overall fit.
- interview_likelihood (str): "High", "Medium", or "Low" likelihood of an interview.
- matching_skills (list[str]): A list of skills from the resume that match the job description.
- evaluation_summary (str): A concise summary of the resume's evaluation.
- experience_match (str): Assessment of how well the candidate's experience matches the job.
- education_match (str): Assessment of how well the candidate's education matches the job.
- strengths (list[str]): Key strengths identified in the resume relevant to the job.
- missing_skills (list[str]): Skills required by the job but missing from the resume.
- improvement_areas (list[str]): Areas where the resume could be improved for better fit.
- recommendations (list[str]): Actionable recommendations for the candidate.
"""
llm_provider = os.getenv("LLM_PROVIDER", "openai")
llm_model = os.getenv("LLM_MODEL", "gpt-4o-mini")
llm_client = LLMClient(provider=llm_provider, model=llm_model)
prompt = {
"role": "user",
"content": (
"You are an expert career coach.\n\n"
f"**Job Title:** {job_title}\n\n"
"**Job Description (first 5k chars):**\n```\n"
+ job_description.strip()[:5000] + "\n```\n\n"
"**Candidate's Resume** (first 5k chars):\n```\n"
+ resume_content[:5000] + "\n```\n\n"
"Reply only in JSON with these keys:\n"
" overall_score (int 1–10),\n"
" interview_likelihood (\"High\"/\"Medium\"/\"Low\"),\n"
" matching_skills […],\n"
" evaluation_summary (str),\n"
" experience_match (str),\n"
" education_match (str),\n"
" strengths […],\n"
" missing_skills […],\n"
" improvement_areas […],\n"
" recommendations […]"
)
}
resp = llm_client.chat_completion(
messages=[prompt]
)
raw = resp.choices[0].message.content
cleaned = extract_json(raw)
try:
return json.loads(cleaned)
except json.JSONDecodeError:
return {"error": "Failed to parse LLM output", "raw": raw}
# ─── Formatters ─────────────────────────────────────────────────────────────────
def format_summary(res):
return (
f"### 🎯 Overall Score: **{res.get('overall_score','N/A')}/10**\n\n"
f"### 🀝 Interview Likelihood: **{res.get('interview_likelihood','N/A')}**\n\n"
f"### πŸ” Matching Skills: **{len(res.get('matching_skills',[]))} found**"
)
def format_overview(res):
return (
f"**Evaluation Summary:** \n{res.get('evaluation_summary','–')}\n\n"
f"**Experience Match:** \n{res.get('experience_match','–')}\n\n"
f"**Education Match:** \n{res.get('education_match','–')}"
)
def format_strengths(res):
ms = res.get("matching_skills", [])
st = res.get("strengths", [])
parts = ["**πŸ”§ Matching Skills:**"] + [f"- {s}" for s in ms] or ["- None"]
parts += ["\n**πŸ’ͺ Key Strengths:**"] + [f"- {s}" for s in st] or ["- None"]
return "\n".join(parts)
def format_gaps(res):
mg = res.get("missing_skills", [])
ia = res.get("improvement_areas", [])
parts = ["**❌ Missing Skills:**"] + [f"- {s}" for s in mg] or ["- None"]
parts += ["\n**πŸ› οΈ Areas for Improvement:**"] + [f"- {s}" for s in ia] or ["- None"]
return "\n".join(parts)
def format_recs(res):
rc = res.get("recommendations", [])
parts = ["**πŸ’‘ Recommendations:**"] + [f"- {s}" for s in rc] or ["- None"]
return "\n".join(parts)
# ─── Main Logic & UI ─────────────────────────────────────────────────────────────
load_dotenv()
def run_agent(resume_file, job_title, job_desc):
# Validate inputs
if not (resume_file and job_title.strip() and job_desc.strip()):
err = "**Error:** Please provide job title, job description, and resume."
return err, err, err, err, err
# Read and analyze
resume = read_resume(resume_file)
result = analyze_resume(resume, job_title, job_desc)
# Handle parse errors
if "error" in result:
err_md = f"**Error:** {result['error']}\n\n```{result.get('raw','')}```"
return err_md, err_md, err_md, err_md, err_md
# Format each section
summary_md = format_summary(result)
overview_md = format_overview(result)
strengths_md = format_strengths(result)
gaps_md = format_gaps(result)
recs_md = format_recs(result)
return summary_md, overview_md, strengths_md, gaps_md, recs_md
# Build the Gradio app
app = gr.Blocks(
title="Skills Gap Advisor",
theme=gr.themes.Soft(
primary_hue="indigo",
secondary_hue="purple",
neutral_hue="slate",
font=gr.themes.GoogleFont("Inter")
),
css="""
.gradio-container {
max-width: 1200px !important;
margin: 0 auto !important;
background: #0f172a !important;
color: #e2e8f0 !important;
}
.main-header {
text-align: center;
margin-bottom: 2rem;
padding: 2rem 0;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: #ffffff !important;
border-radius: 12px;
margin-bottom: 2rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.input-section {
background: #1e293b !important;
padding: 1.5rem;
border-radius: 12px;
border: 1px solid #334155;
margin-bottom: 1rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
.results-section {
background: #1e293b !important;
border-radius: 12px;
border: 1px solid #334155;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
.summary-card {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: #ffffff !important;
padding: 1.5rem;
border-radius: 12px;
margin-bottom: 1rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
.tabs {
background: #1e293b !important;
border-radius: 12px;
padding: 1rem;
}
.tab-nav {
border-bottom: 1px solid #334155 !important;
}
.tab-nav button {
color: #94a3b8 !important;
}
.tab-nav button.selected {
color: #4f46e5 !important;
border-bottom: 2px solid #4f46e5 !important;
}
.tab-content {
background: #1e293b !important;
color: #e2e8f0 !important;
padding: 1rem;
}
.markdown {
color: #e2e8f0 !important;
}
.markdown h1, .markdown h2, .markdown h3 {
color: #ffffff !important;
}
.markdown strong {
color: #4f46e5 !important;
}
.markdown ul li {
color: #e2e8f0 !important;
}
.accordion {
background: #1e293b !important;
border: 1px solid #334155 !important;
}
.accordion-title {
color: #e2e8f0 !important;
}
.accordion-content {
color: #94a3b8 !important;
}
.button-primary {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%) !important;
color: white !important;
border: none !important;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1) !important;
}
.button-primary:hover {
background: linear-gradient(135deg, #4338ca 0%, #6d28d9 100%) !important;
transform: translateY(-1px);
box-shadow: 0 6px 8px -1px rgb(0 0 0 / 0.1) !important;
}
"""
)
with app:
gr.Markdown(
"""
<div class="main-header">
<h1>πŸ€– Skills Gap Advisor</h1>
<p>AI-Powered Resume Analysis & Career Guidance</p>
<p><em>Powered by configurable LLM providers β€’ Available as MCP Tool</em></p>
</div>
""",
elem_classes=["main-header"]
)
with gr.Row():
# ── Sidebar for inputs ─────────────────────────
with gr.Column(scale=1, elem_classes=["input-section"]):
gr.Markdown("### πŸ“ Job & Resume Information")
title_input = gr.Textbox(
label="🏷️ Job Title",
placeholder="e.g. Senior Data Scientist",
info="Enter the exact job title you're applying for"
)
job_input = gr.Textbox(
label="πŸ“‹ Job Description",
lines=6,
placeholder="Copy-paste the complete job description here...",
info="Include requirements, responsibilities, and qualifications"
)
resume_input = gr.File(
label="πŸ“„ Resume Upload",
file_types=[".pdf", ".txt"]
)
with gr.Accordion("βš™οΈ Advanced Settings", open=False):
gr.Markdown("**LLM Configuration** (via environment variables)")
gr.Markdown(
"""
- `LLM_PROVIDER`: openai, gemini, deepseek
- `LLM_MODEL`: Model name (e.g., gpt-4o-mini, gemini-2.0-flash, deepseek-chat)
- API keys: `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`
"""
)
run_btn = gr.Button(
"πŸ” Analyze Resume",
variant="primary",
size="lg",
scale=1,
elem_classes=["button-primary"]
)
# ── Main area for results ───────────────────────
with gr.Column(scale=2, elem_classes=["results-section"]):
gr.Markdown("### πŸ“Š Analysis Results")
summary_display = gr.Markdown(
elem_classes=["summary-card"],
value="Upload a resume and job description to see the analysis results here."
)
with gr.Tabs(elem_classes=["tabs"]):
with gr.TabItem("πŸ“‹ Overview", elem_id="overview-tab"):
overview_display = gr.Markdown(
value="Detailed evaluation summary will appear here after analysis."
)
with gr.TabItem("πŸ’ͺ Strengths", elem_id="strengths-tab"):
strengths_display = gr.Markdown(
value="Your matching skills and key strengths will be highlighted here."
)
with gr.TabItem("🎯 Gaps", elem_id="gaps-tab"):
gaps_display = gr.Markdown(
value="Missing skills and improvement areas will be identified here."
)
with gr.TabItem("πŸ’‘ Recommendations", elem_id="recommendations-tab"):
recs_display = gr.Markdown(
value="Personalized recommendations for improving your application will appear here."
)
# Wire up the callback
run_btn.click(
fn=run_agent,
inputs=[resume_input, title_input, job_input],
outputs=[
summary_display,
overview_display,
strengths_display,
gaps_display,
recs_display
]
)
if __name__ == "__main__":
app.queue() # ensure loading spinner appears on every run
app.launch(
server_name="0.0.0.0",
server_port=7860,
debug=True,
mcp_server=True
)