afouda commited on
Commit
1687f4a
Β·
verified Β·
1 Parent(s): b819e7b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +123 -58
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 = "285LUJulGIprqT6hcPhiXtcrphU04FG4"
16
- DEEPINFRA_BASE_URL = "https://api.deepinfra.com/v1/openai/chat/completions"
17
- DEFAULT_MODEL = "openai/gpt-oss-120b"
18
  REQUEST_TIMEOUT_SECS = 120
19
 
20
  # Prompts for LLM Calls
@@ -53,11 +57,19 @@ Schema:
53
  }
54
  """
55
 
56
- FEEDBACK_SYSTEM = """You are an expert technical recruiter. Compare a job and a candidate and return STRICT JSON with actionable feedback.
 
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
- resp = requests.post(
116
- DEEPINFRA_BASE_URL,
117
- headers={
118
- "Authorization": f"Bearer {api_key}",
119
- "Content-Type": "application/json",
120
- },
121
- data=json.dumps(payload),
122
- timeout=REQUEST_TIMEOUT_SECS,
123
- )
124
- resp.raise_for_status()
125
- data = resp.json()
126
- return (data.get("choices", [{}])[0].get("message", {}).get("content", "") or "").strip()
 
 
 
 
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
- def llm_feedback(jd_struct: Dict, resume_struct: Dict, api_key: str, model: str, temperature: float = 0.2) -> Dict:
 
170
  prompt = json.dumps({"job": jd_struct, "candidate": resume_struct}, ensure_ascii=False)
171
  messages = [
172
- {"role": "system", "content": FEEDBACK_SYSTEM},
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, # kept for UI compatibility (unused here)
255
- w_qual, # kept for UI compatibility (unused here)
256
- w_resp, # kept for UI compatibility (unused here)
 
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, "")) # if LLM didn't return this name, default 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
- export_with_rank.append({
347
- "rank": i,
348
- "candidate": r["candidate"],
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, columns=["rank", "candidate", "Score", "file", "justification"]) \
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 = "\n\n".join(md for (_n, _s, md, _f) in detail_blobs_sorted[:5])
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
- - Matching (single LLM call): {t_match_total:.2f}s
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__":