Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -10,11 +10,15 @@ import PyPDF2
|
|
| 10 |
import docx2txt
|
| 11 |
import gradio as gr
|
| 12 |
import pandas as pd
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
# Global Configuration
|
| 15 |
-
DEEPINFRA_API_KEY = "
|
| 16 |
-
DEEPINFRA_BASE_URL = "https://api.deepinfra.com/v1/openai
|
| 17 |
-
DEFAULT_MODEL = "
|
| 18 |
REQUEST_TIMEOUT_SECS = 120
|
| 19 |
|
| 20 |
# Prompts for LLM Calls
|
|
@@ -53,11 +57,19 @@ Schema:
|
|
| 53 |
}
|
| 54 |
"""
|
| 55 |
|
| 56 |
-
|
|
|
|
| 57 |
Respond in the job description's language.
|
|
|
|
| 58 |
Schema:
|
| 59 |
{
|
| 60 |
"overall_summary": "",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
"strengths": [],
|
| 62 |
"weaknesses": [],
|
| 63 |
"missing_requirements": [],
|
|
@@ -67,6 +79,13 @@ Keep each bullet short (max ~12 words).
|
|
| 67 |
Output ONLY JSON.
|
| 68 |
"""
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
# Helper Functions
|
| 71 |
def _pdf_to_text(path: str) -> str:
|
| 72 |
text = []
|
|
@@ -94,6 +113,7 @@ def read_file_safely(path: str) -> str:
|
|
| 94 |
return _docx_to_text(path)
|
| 95 |
return f"[Unsupported file type: {os.path.basename(path)}]"
|
| 96 |
except Exception as e:
|
|
|
|
| 97 |
return f"[Error reading file: {e}]"
|
| 98 |
|
| 99 |
def safe_json_loads(text: str) -> dict:
|
|
@@ -101,7 +121,8 @@ def safe_json_loads(text: str) -> dict:
|
|
| 101 |
m = re.search(r"```json\s*(.*?)```", text or "", re.DOTALL | re.IGNORECASE)
|
| 102 |
block = m.group(1) if m else text
|
| 103 |
return json.loads(block)
|
| 104 |
-
except Exception:
|
|
|
|
| 105 |
return {}
|
| 106 |
|
| 107 |
def deepinfra_chat(messages: List[Dict[str, str]], api_key: str, model: str, temperature: float = 0.2) -> str:
|
|
@@ -112,18 +133,22 @@ def deepinfra_chat(messages: List[Dict[str, str]], api_key: str, model: str, tem
|
|
| 112 |
"messages": messages,
|
| 113 |
"temperature": temperature,
|
| 114 |
}
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
def quick_contacts(text: str) -> dict:
|
| 129 |
email_re = re.compile(r"\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b")
|
|
@@ -166,17 +191,26 @@ def llm_extract_resume(resume_text: str, api_key: str, model: str, temperature:
|
|
| 166 |
raw = deepinfra_chat(messages, api_key=api_key, model=model, temperature=temperature)
|
| 167 |
return safe_json_loads(raw)
|
| 168 |
|
| 169 |
-
|
|
|
|
| 170 |
prompt = json.dumps({"job": jd_struct, "candidate": resume_struct}, ensure_ascii=False)
|
| 171 |
messages = [
|
| 172 |
-
{"role": "system", "content":
|
| 173 |
{"role": "user", "content": prompt},
|
| 174 |
]
|
| 175 |
raw = deepinfra_chat(messages, api_key=api_key, model=model, temperature=temperature)
|
| 176 |
return safe_json_loads(raw)
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
-
def prompt_for_match(jd_struct: Dict[str, Any], cv_structs: List[Dict[str, Any]]) -> List[Dict[str, str]]:
|
| 180 |
# compact candidates to reduce tokens
|
| 181 |
compact_cands = []
|
| 182 |
for c in cv_structs:
|
|
@@ -198,6 +232,7 @@ def prompt_for_match(jd_struct: Dict[str, Any], cv_structs: List[Dict[str, Any]]
|
|
| 198 |
"- Nice-to-have skills and domain fit\n"
|
| 199 |
"- Evidence quality in work history/education\n"
|
| 200 |
"- Language/locale requirements if any\n"
|
|
|
|
| 201 |
"IMPORTANT:\n"
|
| 202 |
"- The 'candidate' MUST EXACTLY EQUAL the resume 'name' field provided.\n"
|
| 203 |
"- No extra keys. No markdown."
|
|
@@ -206,14 +241,14 @@ def prompt_for_match(jd_struct: Dict[str, Any], cv_structs: List[Dict[str, Any]]
|
|
| 206 |
"Role (parsed JSON):\n"
|
| 207 |
f"{json.dumps(jd_struct, ensure_ascii=False)}\n\n"
|
| 208 |
"Candidates (compact JSON):\n"
|
| 209 |
-
f"{json.dumps(compact_cands, ensure_ascii=False)}"
|
|
|
|
| 210 |
)
|
| 211 |
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
| 212 |
|
| 213 |
RANK_LINE_RE = re.compile(r"^\s*(\d+)\.\s*(.*?)\s*[β\-]\s*([0-9]+(?:\.[0-9]+)?)\s*/\s*10\b", re.M)
|
| 214 |
|
| 215 |
def parse_ranked_output(content: str) -> List[Dict[str, Any]]:
|
| 216 |
-
# Prefer strict JSON; fallback to "1. Name β 8.0/10" lines.
|
| 217 |
rows: List[Dict[str, Any]] = []
|
| 218 |
parsed = safe_json_loads(content or "")
|
| 219 |
|
|
@@ -251,9 +286,10 @@ def process(
|
|
| 251 |
model_name,
|
| 252 |
temperature,
|
| 253 |
top_n,
|
| 254 |
-
w_skill,
|
| 255 |
-
w_qual,
|
| 256 |
-
w_resp,
|
|
|
|
| 257 |
):
|
| 258 |
t0 = time.perf_counter()
|
| 259 |
|
|
@@ -270,6 +306,7 @@ def process(
|
|
| 270 |
raise gr.Error("Please paste a Job Description or upload a JD file.")
|
| 271 |
jd_struct = llm_extract_jd(jd_raw, api_key=api_key, model=model_name)
|
| 272 |
t_jd = time.perf_counter() - t_jd_start
|
|
|
|
| 273 |
|
| 274 |
# --- Resumes parse ---
|
| 275 |
if not resume_files or len(resume_files) == 0:
|
|
@@ -287,22 +324,30 @@ def process(
|
|
| 287 |
if not isinstance(cand_struct, dict):
|
| 288 |
cand_struct = {}
|
| 289 |
cand_struct.setdefault("name", os.path.splitext(fname)[0])
|
| 290 |
-
cand_struct.setdefault("skills", [])
|
| 291 |
-
cand_struct.setdefault("skills_en", [])
|
| 292 |
-
cand_struct.setdefault("education", [])
|
| 293 |
-
cand_struct.setdefault("experience", [])
|
| 294 |
-
cand_struct.setdefault("languages", [])
|
| 295 |
cand_struct.setdefault("email", cand_struct.get("email") or contacts["email_guess"])
|
| 296 |
cand_struct.setdefault("phone", cand_struct.get("phone") or contacts["phone_guess"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
parsed_cands.append(cand_struct)
|
| 298 |
name_to_file[cand_struct["name"]] = fname
|
| 299 |
t_parse_total += (time.perf_counter() - t_parse_s)
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
t_match_start = time.perf_counter()
|
| 302 |
-
match_msgs = prompt_for_match(jd_struct, parsed_cands)
|
| 303 |
raw_match = deepinfra_chat(match_msgs, api_key=api_key, model=model_name, temperature=temperature)
|
| 304 |
ranked_rows = parse_ranked_output(raw_match)
|
| 305 |
t_match_total = time.perf_counter() - t_match_start
|
|
|
|
| 306 |
|
| 307 |
score_map = {r["candidate"]: (float(r.get("score", 0.0)), r.get("justification","")) for r in ranked_rows}
|
| 308 |
|
|
@@ -310,10 +355,22 @@ def process(
|
|
| 310 |
|
| 311 |
for c in parsed_cands:
|
| 312 |
nm = c.get("name","")
|
| 313 |
-
sc, just = score_map.get(nm, (0.0, ""))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
table_rows.append({
|
| 315 |
"Candidate": nm,
|
| 316 |
-
"Score": round(sc, 1),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
"Email": c.get("email",""),
|
| 318 |
"Phone": c.get("phone",""),
|
| 319 |
"File": name_to_file.get(nm,""),
|
|
@@ -321,51 +378,56 @@ def process(
|
|
| 321 |
export_rows.append({
|
| 322 |
"candidate": nm,
|
| 323 |
"Score": round(sc, 1),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
"file": name_to_file.get(nm,""),
|
| 325 |
"justification": just,
|
| 326 |
})
|
|
|
|
| 327 |
detail_blobs.append((
|
| 328 |
nm, sc,
|
| 329 |
f"""### {nm} β {sc:.1f}/10
|
| 330 |
**File:** {name_to_file.get(nm,'')}
|
| 331 |
**Email:** {c.get('email','')} | **Phone:** {c.get('phone','')}
|
| 332 |
|
| 333 |
-
**Justification:** {just}
|
|
|
|
| 334 |
""",
|
| 335 |
-
name_to_file.get(nm,"")
|
|
|
|
|
|
|
| 336 |
))
|
| 337 |
|
| 338 |
# sort by Score DESC
|
| 339 |
-
df = pd.DataFrame(table_rows).sort_values("Score", ascending=False, kind="mergesort")
|
| 340 |
df_show = df.head(int(top_n)) if top_n and isinstance(top_n, (int, float)) else df
|
| 341 |
|
| 342 |
# CSV export: rank, candidate, Score, file, justification
|
| 343 |
sorted_items = sorted(export_rows, key=lambda r: float(r["Score"]), reverse=True)
|
| 344 |
export_with_rank = []
|
| 345 |
for i, r in enumerate(sorted_items, start=1):
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
"Score": r["Score"],
|
| 350 |
-
"file": r["file"],
|
| 351 |
-
"justification": r["justification"],
|
| 352 |
-
})
|
| 353 |
csv_path = tempfile.NamedTemporaryFile(delete=False, suffix=".csv").name
|
| 354 |
-
pd.DataFrame(export_with_rank,
|
| 355 |
-
.to_csv(csv_path, index=False, encoding="utf-8")
|
| 356 |
|
| 357 |
-
# Candidate Details: top 5 only (based on score)
|
| 358 |
detail_blobs_sorted = sorted(detail_blobs, key=lambda t: t[1], reverse=True)
|
| 359 |
-
top5_md = "
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
| 361 |
# metrics
|
| 362 |
t_total = time.perf_counter() - t0
|
| 363 |
-
avg_parse = (t_parse_total / max(1, len(parsed_cands)))
|
| 364 |
metrics_md = (
|
| 365 |
f"""### Processing Metrics
|
| 366 |
- JD parsing: {t_jd:.2f}s
|
| 367 |
-
- Resume parsing (avg): {avg_parse:.2f}s
|
| 368 |
-
-
|
| 369 |
- Total (all candidates): {t_total:.2f}s
|
| 370 |
""")
|
| 371 |
|
|
@@ -381,7 +443,6 @@ f"""### Processing Metrics
|
|
| 381 |
return metrics_md, df_show, csv_path, jd_pretty, top5_md
|
| 382 |
|
| 383 |
|
| 384 |
-
|
| 385 |
with gr.Blocks(title="JD β Resume Matcher") as demo:
|
| 386 |
gr.Markdown("# π JD β Resume Matcher\nPaste a Job Description and upload resumes to rank candidates (Score 0β10), get Top-5 details, and download a CSV.")
|
| 387 |
|
|
@@ -399,11 +460,14 @@ with gr.Blocks(title="JD β Resume Matcher") as demo:
|
|
| 399 |
model_name = gr.Textbox(label="Model", value=DEFAULT_MODEL)
|
| 400 |
temperature = gr.Slider(label="Model temperature", minimum=0.0, maximum=1.0, value=0.2, step=0.05)
|
| 401 |
top_n = gr.Slider(label="Show top N candidates (table)", minimum=1, maximum=50, value=10, step=1)
|
|
|
|
|
|
|
|
|
|
| 402 |
|
| 403 |
# keep sliders (unused now) to avoid UI breaking changes
|
| 404 |
-
w_skill = gr.Slider(label="(unused) Weight: Skills overlap", minimum=0.0, maximum=1.0, value=0.6, step=0.05)
|
| 405 |
-
w_qual = gr.Slider(label="(unused) Weight: Qualifications match", minimum=0.0, maximum=1.0, value=0.2, step=0.05)
|
| 406 |
-
w_resp = gr.Slider(label="(unused) Weight: Responsibilities match", minimum=0.0, maximum=1.0, value=0.2, step=0.05)
|
| 407 |
|
| 408 |
run_btn = gr.Button("π Rank & Score", variant="primary")
|
| 409 |
clear_btn = gr.Button("Clear")
|
|
@@ -415,12 +479,12 @@ with gr.Blocks(title="JD β Resume Matcher") as demo:
|
|
| 415 |
csv_out = gr.File(label="Download Ranked CSV")
|
| 416 |
gr.Markdown("### π§© Parsed JD")
|
| 417 |
jd_json = gr.JSON()
|
| 418 |
-
gr.Markdown("### ποΈ Candidate Details (Top 5)")
|
| 419 |
details_md = gr.Markdown()
|
| 420 |
|
| 421 |
run_btn.click(
|
| 422 |
fn=process,
|
| 423 |
-
inputs=[jd_text, jd_file, resumes, api_key_pw, model_name, temperature, top_n, w_skill, w_qual, w_resp],
|
| 424 |
outputs=[metrics_md, ranked_df, csv_out, jd_json, details_md]
|
| 425 |
)
|
| 426 |
|
|
@@ -437,12 +501,13 @@ with gr.Blocks(title="JD β Resume Matcher") as demo:
|
|
| 437 |
None, # csv_out
|
| 438 |
{}, # jd_json
|
| 439 |
"", # details_md
|
|
|
|
| 440 |
)
|
| 441 |
|
| 442 |
clear_btn.click(
|
| 443 |
fn=clear_all,
|
| 444 |
inputs=[],
|
| 445 |
-
outputs=[jd_text, jd_file, resumes, api_key_pw, model_name, metrics_md, ranked_df, csv_out, jd_json, details_md]
|
| 446 |
)
|
| 447 |
|
| 448 |
if __name__ == "__main__":
|
|
|
|
| 10 |
import docx2txt
|
| 11 |
import gradio as gr
|
| 12 |
import pandas as pd
|
| 13 |
+
import logging
|
| 14 |
+
|
| 15 |
+
# Configure logging
|
| 16 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 17 |
|
| 18 |
# Global Configuration
|
| 19 |
+
DEEPINFRA_API_KEY = "kPEm10rrnxXrCf0TuB6Xcd7Y7lp3YgKa"
|
| 20 |
+
DEEPINFRA_BASE_URL = "https://api.deepinfra.com/v1/openai"
|
| 21 |
+
DEFAULT_MODEL = "Qwen/Qwen3-32B"
|
| 22 |
REQUEST_TIMEOUT_SECS = 120
|
| 23 |
|
| 24 |
# Prompts for LLM Calls
|
|
|
|
| 57 |
}
|
| 58 |
"""
|
| 59 |
|
| 60 |
+
# New feedback system prompt with detailed scoring
|
| 61 |
+
FEEDBACK_SYSTEM_DETAILED = """You are an expert technical recruiter. Compare a job and a candidate and return STRICT JSON with actionable feedback and a detailed score breakdown.
|
| 62 |
Respond in the job description's language.
|
| 63 |
+
Scores should be out of 100.
|
| 64 |
Schema:
|
| 65 |
{
|
| 66 |
"overall_summary": "",
|
| 67 |
+
"scores": {
|
| 68 |
+
"skills": 0,
|
| 69 |
+
"qualifications": 0,
|
| 70 |
+
"responsibilities": 0,
|
| 71 |
+
"education_and_experience": 0
|
| 72 |
+
},
|
| 73 |
"strengths": [],
|
| 74 |
"weaknesses": [],
|
| 75 |
"missing_requirements": [],
|
|
|
|
| 79 |
Output ONLY JSON.
|
| 80 |
"""
|
| 81 |
|
| 82 |
+
# New recommendation system prompt
|
| 83 |
+
RECOMMEND_SYSTEM = """You are a senior technical recruiter writing a concise recommendation summary for a hiring manager.
|
| 84 |
+
Based on the provided candidate and job description, write a 2-3 sentence summary explaining why this candidate is a good match.
|
| 85 |
+
Focus on key skills, relevant experience, and overall fit. Do not use a conversational tone.
|
| 86 |
+
Output ONLY the summary text, no markdown or extra formatting."""
|
| 87 |
+
|
| 88 |
+
|
| 89 |
# Helper Functions
|
| 90 |
def _pdf_to_text(path: str) -> str:
|
| 91 |
text = []
|
|
|
|
| 113 |
return _docx_to_text(path)
|
| 114 |
return f"[Unsupported file type: {os.path.basename(path)}]"
|
| 115 |
except Exception as e:
|
| 116 |
+
logging.error(f"Error reading file {path}: {e}")
|
| 117 |
return f"[Error reading file: {e}]"
|
| 118 |
|
| 119 |
def safe_json_loads(text: str) -> dict:
|
|
|
|
| 121 |
m = re.search(r"```json\s*(.*?)```", text or "", re.DOTALL | re.IGNORECASE)
|
| 122 |
block = m.group(1) if m else text
|
| 123 |
return json.loads(block)
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logging.error(f"Failed to parse JSON: {e}\nRaw Text: {text[:500]}...")
|
| 126 |
return {}
|
| 127 |
|
| 128 |
def deepinfra_chat(messages: List[Dict[str, str]], api_key: str, model: str, temperature: float = 0.2) -> str:
|
|
|
|
| 133 |
"messages": messages,
|
| 134 |
"temperature": temperature,
|
| 135 |
}
|
| 136 |
+
try:
|
| 137 |
+
resp = requests.post(
|
| 138 |
+
DEEPINFRA_BASE_URL,
|
| 139 |
+
headers={
|
| 140 |
+
"Authorization": f"Bearer {api_key}",
|
| 141 |
+
"Content-Type": "application/json",
|
| 142 |
+
},
|
| 143 |
+
data=json.dumps(payload),
|
| 144 |
+
timeout=REQUEST_TIMEOUT_SECS,
|
| 145 |
+
)
|
| 146 |
+
resp.raise_for_status()
|
| 147 |
+
data = resp.json()
|
| 148 |
+
return (data.get("choices", [{}])[0].get("message", {}).get("content", "") or "").strip()
|
| 149 |
+
except requests.exceptions.RequestException as e:
|
| 150 |
+
logging.error(f"API request failed: {e}")
|
| 151 |
+
raise gr.Error(f"API request failed: {e}. Check your API key and model name.")
|
| 152 |
|
| 153 |
def quick_contacts(text: str) -> dict:
|
| 154 |
email_re = re.compile(r"\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b")
|
|
|
|
| 191 |
raw = deepinfra_chat(messages, api_key=api_key, model=model, temperature=temperature)
|
| 192 |
return safe_json_loads(raw)
|
| 193 |
|
| 194 |
+
# New function for detailed feedback and scoring
|
| 195 |
+
def llm_detailed_feedback(jd_struct: Dict, resume_struct: Dict, api_key: str, model: str, temperature: float = 0.2) -> Dict:
|
| 196 |
prompt = json.dumps({"job": jd_struct, "candidate": resume_struct}, ensure_ascii=False)
|
| 197 |
messages = [
|
| 198 |
+
{"role": "system", "content": FEEDBACK_SYSTEM_DETAILED},
|
| 199 |
{"role": "user", "content": prompt},
|
| 200 |
]
|
| 201 |
raw = deepinfra_chat(messages, api_key=api_key, model=model, temperature=temperature)
|
| 202 |
return safe_json_loads(raw)
|
| 203 |
|
| 204 |
+
# New function for candidate recommendation summary
|
| 205 |
+
def llm_recommend(jd_struct: Dict, resume_struct: Dict, api_key: str, model: str, temperature: float = 0.2) -> str:
|
| 206 |
+
prompt = json.dumps({"job": jd_struct, "candidate": resume_struct}, ensure_ascii=False)
|
| 207 |
+
messages = [
|
| 208 |
+
{"role": "system", "content": RECOMMEND_SYSTEM},
|
| 209 |
+
{"role": "user", "content": prompt},
|
| 210 |
+
]
|
| 211 |
+
return deepinfra_chat(messages, api_key=api_key, model=model, temperature=temperature)
|
| 212 |
|
| 213 |
+
def prompt_for_match(jd_struct: Dict[str, Any], cv_structs: List[Dict[str, Any]], conditional_req: str) -> List[Dict[str, str]]:
|
| 214 |
# compact candidates to reduce tokens
|
| 215 |
compact_cands = []
|
| 216 |
for c in cv_structs:
|
|
|
|
| 232 |
"- Nice-to-have skills and domain fit\n"
|
| 233 |
"- Evidence quality in work history/education\n"
|
| 234 |
"- Language/locale requirements if any\n"
|
| 235 |
+
"- **Conditional Requirement:** If provided, evaluate the candidate's fit against this requirement.\n"
|
| 236 |
"IMPORTANT:\n"
|
| 237 |
"- The 'candidate' MUST EXACTLY EQUAL the resume 'name' field provided.\n"
|
| 238 |
"- No extra keys. No markdown."
|
|
|
|
| 241 |
"Role (parsed JSON):\n"
|
| 242 |
f"{json.dumps(jd_struct, ensure_ascii=False)}\n\n"
|
| 243 |
"Candidates (compact JSON):\n"
|
| 244 |
+
f"{json.dumps(compact_cands, ensure_ascii=False)}\n\n"
|
| 245 |
+
f"Conditional Requirement: {conditional_req}"
|
| 246 |
)
|
| 247 |
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
| 248 |
|
| 249 |
RANK_LINE_RE = re.compile(r"^\s*(\d+)\.\s*(.*?)\s*[β\-]\s*([0-9]+(?:\.[0-9]+)?)\s*/\s*10\b", re.M)
|
| 250 |
|
| 251 |
def parse_ranked_output(content: str) -> List[Dict[str, Any]]:
|
|
|
|
| 252 |
rows: List[Dict[str, Any]] = []
|
| 253 |
parsed = safe_json_loads(content or "")
|
| 254 |
|
|
|
|
| 286 |
model_name,
|
| 287 |
temperature,
|
| 288 |
top_n,
|
| 289 |
+
w_skill, # kept for UI compatibility (unused here)
|
| 290 |
+
w_qual, # kept for UI compatibility (unused here)
|
| 291 |
+
w_resp, # kept for UI compatibility (unused here)
|
| 292 |
+
conditional_req # new input for conditional requirement
|
| 293 |
):
|
| 294 |
t0 = time.perf_counter()
|
| 295 |
|
|
|
|
| 306 |
raise gr.Error("Please paste a Job Description or upload a JD file.")
|
| 307 |
jd_struct = llm_extract_jd(jd_raw, api_key=api_key, model=model_name)
|
| 308 |
t_jd = time.perf_counter() - t_jd_start
|
| 309 |
+
logging.info(f"JD parsing time: {t_jd:.2f}s")
|
| 310 |
|
| 311 |
# --- Resumes parse ---
|
| 312 |
if not resume_files or len(resume_files) == 0:
|
|
|
|
| 324 |
if not isinstance(cand_struct, dict):
|
| 325 |
cand_struct = {}
|
| 326 |
cand_struct.setdefault("name", os.path.splitext(fname)[0])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
cand_struct.setdefault("email", cand_struct.get("email") or contacts["email_guess"])
|
| 328 |
cand_struct.setdefault("phone", cand_struct.get("phone") or contacts["phone_guess"])
|
| 329 |
+
|
| 330 |
+
# Add detailed feedback
|
| 331 |
+
detailed_feedback = llm_detailed_feedback(jd_struct, cand_struct, api_key, model_name)
|
| 332 |
+
cand_struct['detailed_scores'] = detailed_feedback.get('scores', {})
|
| 333 |
+
cand_struct['summary_feedback'] = detailed_feedback.get('overall_summary', '')
|
| 334 |
+
cand_struct['strengths'] = detailed_feedback.get('strengths', [])
|
| 335 |
+
cand_struct['weaknesses'] = detailed_feedback.get('weaknesses', [])
|
| 336 |
+
cand_struct['missing_requirements'] = detailed_feedback.get('missing_requirements', [])
|
| 337 |
+
|
| 338 |
parsed_cands.append(cand_struct)
|
| 339 |
name_to_file[cand_struct["name"]] = fname
|
| 340 |
t_parse_total += (time.perf_counter() - t_parse_s)
|
| 341 |
+
|
| 342 |
+
avg_parse = (t_parse_total / max(1, len(parsed_cands)))
|
| 343 |
+
logging.info(f"Total resume parsing time: {t_parse_total:.2f}s, avg: {avg_parse:.2f}s")
|
| 344 |
|
| 345 |
t_match_start = time.perf_counter()
|
| 346 |
+
match_msgs = prompt_for_match(jd_struct, parsed_cands, conditional_req)
|
| 347 |
raw_match = deepinfra_chat(match_msgs, api_key=api_key, model=model_name, temperature=temperature)
|
| 348 |
ranked_rows = parse_ranked_output(raw_match)
|
| 349 |
t_match_total = time.perf_counter() - t_match_start
|
| 350 |
+
logging.info(f"Matching time: {t_match_total:.2f}s")
|
| 351 |
|
| 352 |
score_map = {r["candidate"]: (float(r.get("score", 0.0)), r.get("justification","")) for r in ranked_rows}
|
| 353 |
|
|
|
|
| 355 |
|
| 356 |
for c in parsed_cands:
|
| 357 |
nm = c.get("name","")
|
| 358 |
+
sc, just = score_map.get(nm, (0.0, ""))
|
| 359 |
+
|
| 360 |
+
# Get detailed scores
|
| 361 |
+
detailed_scores = c.get('detailed_scores', {})
|
| 362 |
+
skills_sc = detailed_scores.get('skills', 0)
|
| 363 |
+
qual_sc = detailed_scores.get('qualifications', 0)
|
| 364 |
+
resp_sc = detailed_scores.get('responsibilities', 0)
|
| 365 |
+
exp_sc = detailed_scores.get('education_and_experience', 0)
|
| 366 |
+
|
| 367 |
table_rows.append({
|
| 368 |
"Candidate": nm,
|
| 369 |
+
"Score (0-10)": round(sc, 1),
|
| 370 |
+
"Skills (0-100)": skills_sc,
|
| 371 |
+
"Qualifications (0-100)": qual_sc,
|
| 372 |
+
"Responsibilities (0-100)": resp_sc,
|
| 373 |
+
"Experience (0-100)": exp_sc,
|
| 374 |
"Email": c.get("email",""),
|
| 375 |
"Phone": c.get("phone",""),
|
| 376 |
"File": name_to_file.get(nm,""),
|
|
|
|
| 378 |
export_rows.append({
|
| 379 |
"candidate": nm,
|
| 380 |
"Score": round(sc, 1),
|
| 381 |
+
"skills_score": skills_sc,
|
| 382 |
+
"qualifications_score": qual_sc,
|
| 383 |
+
"responsibilities_score": resp_sc,
|
| 384 |
+
"experience_score": exp_sc,
|
| 385 |
"file": name_to_file.get(nm,""),
|
| 386 |
"justification": just,
|
| 387 |
})
|
| 388 |
+
|
| 389 |
detail_blobs.append((
|
| 390 |
nm, sc,
|
| 391 |
f"""### {nm} β {sc:.1f}/10
|
| 392 |
**File:** {name_to_file.get(nm,'')}
|
| 393 |
**Email:** {c.get('email','')} | **Phone:** {c.get('phone','')}
|
| 394 |
|
| 395 |
+
**Overall Justification:** {just}
|
| 396 |
+
**Detailed Feedback:** {c.get('summary_feedback', '')}
|
| 397 |
""",
|
| 398 |
+
name_to_file.get(nm,""),
|
| 399 |
+
c.get('detailed_scores', {}),
|
| 400 |
+
c
|
| 401 |
))
|
| 402 |
|
| 403 |
# sort by Score DESC
|
| 404 |
+
df = pd.DataFrame(table_rows).sort_values("Score (0-10)", ascending=False, kind="mergesort")
|
| 405 |
df_show = df.head(int(top_n)) if top_n and isinstance(top_n, (int, float)) else df
|
| 406 |
|
| 407 |
# CSV export: rank, candidate, Score, file, justification
|
| 408 |
sorted_items = sorted(export_rows, key=lambda r: float(r["Score"]), reverse=True)
|
| 409 |
export_with_rank = []
|
| 410 |
for i, r in enumerate(sorted_items, start=1):
|
| 411 |
+
r["rank"] = i
|
| 412 |
+
export_with_rank.append(r)
|
| 413 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
csv_path = tempfile.NamedTemporaryFile(delete=False, suffix=".csv").name
|
| 415 |
+
pd.DataFrame(export_with_rank).to_csv(csv_path, index=False, encoding="utf-8")
|
|
|
|
| 416 |
|
| 417 |
+
# Candidate Details & Recommendation: top 5 only (based on score)
|
| 418 |
detail_blobs_sorted = sorted(detail_blobs, key=lambda t: t[1], reverse=True)
|
| 419 |
+
top5_md = ""
|
| 420 |
+
for (_n, _s, md, _f, _scores, cand_struct) in detail_blobs_sorted[:5]:
|
| 421 |
+
recommendation = llm_recommend(jd_struct, cand_struct, api_key, model_name, temperature)
|
| 422 |
+
top5_md += f"{md}\n**Recommendation:** {recommendation}\n\n"
|
| 423 |
+
|
| 424 |
# metrics
|
| 425 |
t_total = time.perf_counter() - t0
|
|
|
|
| 426 |
metrics_md = (
|
| 427 |
f"""### Processing Metrics
|
| 428 |
- JD parsing: {t_jd:.2f}s
|
| 429 |
+
- Resume parsing & scoring (avg): {avg_parse:.2f}s
|
| 430 |
+
- Ranking (single LLM call): {t_match_total:.2f}s
|
| 431 |
- Total (all candidates): {t_total:.2f}s
|
| 432 |
""")
|
| 433 |
|
|
|
|
| 443 |
return metrics_md, df_show, csv_path, jd_pretty, top5_md
|
| 444 |
|
| 445 |
|
|
|
|
| 446 |
with gr.Blocks(title="JD β Resume Matcher") as demo:
|
| 447 |
gr.Markdown("# π JD β Resume Matcher\nPaste a Job Description and upload resumes to rank candidates (Score 0β10), get Top-5 details, and download a CSV.")
|
| 448 |
|
|
|
|
| 460 |
model_name = gr.Textbox(label="Model", value=DEFAULT_MODEL)
|
| 461 |
temperature = gr.Slider(label="Model temperature", minimum=0.0, maximum=1.0, value=0.2, step=0.05)
|
| 462 |
top_n = gr.Slider(label="Show top N candidates (table)", minimum=1, maximum=50, value=10, step=1)
|
| 463 |
+
|
| 464 |
+
# New input for conditional requirement
|
| 465 |
+
conditional_req = gr.Textbox(label="Conditional Requirement (e.g., must be in Cairo)", placeholder="e.g., 'Must be fluent in French'", lines=2)
|
| 466 |
|
| 467 |
# keep sliders (unused now) to avoid UI breaking changes
|
| 468 |
+
w_skill = gr.Slider(label="(unused) Weight: Skills overlap", minimum=0.0, maximum=1.0, value=0.6, step=0.05, visible=False)
|
| 469 |
+
w_qual = gr.Slider(label="(unused) Weight: Qualifications match", minimum=0.0, maximum=1.0, value=0.2, step=0.05, visible=False)
|
| 470 |
+
w_resp = gr.Slider(label="(unused) Weight: Responsibilities match", minimum=0.0, maximum=1.0, value=0.2, step=0.05, visible=False)
|
| 471 |
|
| 472 |
run_btn = gr.Button("π Rank & Score", variant="primary")
|
| 473 |
clear_btn = gr.Button("Clear")
|
|
|
|
| 479 |
csv_out = gr.File(label="Download Ranked CSV")
|
| 480 |
gr.Markdown("### π§© Parsed JD")
|
| 481 |
jd_json = gr.JSON()
|
| 482 |
+
gr.Markdown("### ποΈ Candidate Details & Recommendation (Top 5)")
|
| 483 |
details_md = gr.Markdown()
|
| 484 |
|
| 485 |
run_btn.click(
|
| 486 |
fn=process,
|
| 487 |
+
inputs=[jd_text, jd_file, resumes, api_key_pw, model_name, temperature, top_n, w_skill, w_qual, w_resp, conditional_req],
|
| 488 |
outputs=[metrics_md, ranked_df, csv_out, jd_json, details_md]
|
| 489 |
)
|
| 490 |
|
|
|
|
| 501 |
None, # csv_out
|
| 502 |
{}, # jd_json
|
| 503 |
"", # details_md
|
| 504 |
+
"" # conditional_req
|
| 505 |
)
|
| 506 |
|
| 507 |
clear_btn.click(
|
| 508 |
fn=clear_all,
|
| 509 |
inputs=[],
|
| 510 |
+
outputs=[jd_text, jd_file, resumes, api_key_pw, model_name, metrics_md, ranked_df, csv_out, jd_json, details_md, conditional_req]
|
| 511 |
)
|
| 512 |
|
| 513 |
if __name__ == "__main__":
|