husseinelsaadi Claude Opus 4.8 commited on
Commit
c28f81f
·
1 Parent(s): b5d621a

Accurate LLM CV parsing + realistic, memory-aware interview

Browse files

CV parsing (resume_parser.py, app.py):
- Parse resumes with the Groq LLM (clean name/skills/education/experience JSON),
far more accurate than the old keyword/regex heuristics; keep the regex/NER
path as a lazy fallback so startup no longer loads the heavy NER model
- Auto-parse the uploaded CV on apply and populate the profile (manual form
fields still override), so an ATS-friendly CV "just works"

Interview realism (interview_engine.py, interview_api.py):
- Give follow-ups real memory: pass the persisted interview_log as
conversation_history (the frontend never sent it)
- Evaluate answers at the real job role + seniority instead of a generic default
- Enrich prompts with job skills/seniority/description; LUNA now acknowledges the
last answer and asks one focused, contextual follow-up
- Fix an empty-experience IndexError that silently dropped the first question

Also includes earlier deploy hardening: env-driven SECRET_KEY/FLASK_DEBUG and
removal of a duplicate generate_next_question definition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

app.py CHANGED
@@ -56,7 +56,7 @@ app = Flask(
56
  instance_path=safe_instance_path
57
  )
58
 
59
- app.config['SECRET_KEY'] = 'saadi'
60
 
61
  # Cookie configuration for Hugging Face Spaces
62
  app.config['SESSION_COOKIE_SAMESITE'] = 'None'
@@ -144,13 +144,32 @@ def apply(job_id):
144
  "education": parse_entries(education_input)
145
  }
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  application = Application(
148
  job_id=job_id,
149
  user_id=current_user.id,
150
  name=current_user.username,
151
  email=current_user.email,
152
  resume_path=filepath,
153
- extracted_features=json.dumps(manual_features)
154
  )
155
 
156
  db.session.add(application)
@@ -399,4 +418,7 @@ if __name__ == '__main__':
399
 
400
  # Use port from environment or default to 7860
401
  port = int(os.environ.get('PORT', 7860))
402
- app.run(debug=True, host='0.0.0.0', port=port)
 
 
 
 
56
  instance_path=safe_instance_path
57
  )
58
 
59
+ app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'saadi')
60
 
61
  # Cookie configuration for Hugging Face Spaces
62
  app.config['SESSION_COOKIE_SAMESITE'] = 'None'
 
144
  "education": parse_entries(education_input)
145
  }
146
 
147
+ # Auto-parse the uploaded CV so the candidate's profile reflects their
148
+ # real resume even when the form fields are left blank. Anything the
149
+ # user typed manually takes precedence; otherwise we use the parsed CV.
150
+ parsed_features = {"skills": [], "experience": [], "education": []}
151
+ try:
152
+ if filepath:
153
+ parsed = _parse_resume_helper(filepath, file.filename)
154
+ for key in parsed_features:
155
+ value = parsed.get(key, '')
156
+ if value and value != "Not Found":
157
+ parsed_features[key] = parse_entries(value)
158
+ except Exception as parse_err:
159
+ print(f"Auto CV parse failed: {parse_err}", file=sys.stderr)
160
+
161
+ merged_features = {
162
+ key: (manual_features[key] if manual_features[key] else parsed_features[key])
163
+ for key in manual_features
164
+ }
165
+
166
  application = Application(
167
  job_id=job_id,
168
  user_id=current_user.id,
169
  name=current_user.username,
170
  email=current_user.email,
171
  resume_path=filepath,
172
+ extracted_features=json.dumps(merged_features)
173
  )
174
 
175
  db.session.add(application)
 
418
 
419
  # Use port from environment or default to 7860
420
  port = int(os.environ.get('PORT', 7860))
421
+ # Debug mode is off by default (safe for the deployed Space); enable it
422
+ # locally by setting FLASK_DEBUG=1.
423
+ debug = os.environ.get('FLASK_DEBUG', '0') == '1'
424
+ app.run(debug=debug, host='0.0.0.0', port=port)
backend/routes/interview_api.py CHANGED
@@ -212,45 +212,55 @@ def process_answer():
212
  # Get the current question for evaluation context
213
  current_question = data.get("current_question", "Tell me about yourself")
214
 
215
- # Evaluate the answer
216
- evaluation_result = evaluate_answer(current_question, answer)
217
-
218
- # 🔥 Save Q&A in interview_log for report
219
  try:
 
 
220
  application = Application.query.filter_by(
221
  user_id=current_user.id,
222
  job_id=job_id
223
  ).first()
 
 
 
 
 
224
 
 
 
 
 
 
 
 
 
 
 
225
  if application:
226
- log_data = []
227
  if application.interview_log:
228
  try:
229
- log_data = json.loads(application.interview_log)
230
  except Exception:
231
- log_data = []
232
 
233
- log_data.append({
234
  "question": current_question,
235
  "answer": answer,
236
  "evaluation": evaluation_result
237
  })
238
 
239
- application.interview_log = json.dumps(log_data, ensure_ascii=False)
240
  db.session.commit()
241
  except Exception as log_err:
242
  logging.error(f"Error saving interview log: {log_err}")
243
 
244
  # Determine the number of questions configured for this job
245
  total_questions = 4
246
- if job_id is not None:
247
- try:
248
- job = Job.query.get(int(job_id))
249
- if job and job.num_questions and job.num_questions > 0:
250
- total_questions = job.num_questions
251
- except Exception:
252
- # If lookup fails, keep default
253
- pass
254
 
255
  # Check completion. ``question_idx`` is zero‑based; the last index
256
  # corresponds to ``total_questions - 1``. When the current index
@@ -274,28 +284,22 @@ def process_answer():
274
  "and do you prefer remote or on-site work?"
275
  )
276
  else:
277
- # 🔥 Use Qdrant-powered next question
 
 
278
  try:
279
- # You need profile + job for Qdrant context
280
- job = Job.query.get(int(job_id)) if job_id else None
281
- application = Application.query.filter_by(
282
- user_id=current_user.id,
283
- job_id=job_id
284
- ).first()
285
-
286
  profile = {}
287
  if application and application.extracted_features:
288
  profile = json.loads(application.extracted_features)
289
 
290
- conversation_history = data.get("conversation_history", [])
291
  next_question_text = generate_next_question(
292
  profile,
293
  job,
294
- conversation_history,
295
  answer
296
  )
297
  except Exception as e:
298
- logging.error(f"Error generating next question from Qdrant: {e}")
299
  next_question_text = "Could you elaborate more on your last point?"
300
 
301
 
 
212
  # Get the current question for evaluation context
213
  current_question = data.get("current_question", "Tell me about yourself")
214
 
215
+ # Load the job and the candidate's application up front so we can
216
+ # evaluate at the right level and give the next question real memory.
217
+ job = None
218
+ application = None
219
  try:
220
+ if job_id is not None:
221
+ job = Job.query.get(int(job_id))
222
  application = Application.query.filter_by(
223
  user_id=current_user.id,
224
  job_id=job_id
225
  ).first()
226
+ except Exception as load_err:
227
+ logging.error(f"Error loading job/application: {load_err}")
228
+
229
+ job_role = job.role if job else "Software Developer"
230
+ seniority = (job.seniority if job and job.seniority else "Mid-level")
231
 
232
+ # Evaluate the answer calibrated to the real role and seniority
233
+ evaluation_result = evaluate_answer(
234
+ current_question, answer, job_role=job_role, seniority=seniority
235
+ )
236
+
237
+ # Append this turn to the persisted interview log. The log doubles as
238
+ # the report source, the final-score source, and the conversation
239
+ # memory passed into the next question.
240
+ conversation_log = []
241
+ try:
242
  if application:
 
243
  if application.interview_log:
244
  try:
245
+ conversation_log = json.loads(application.interview_log)
246
  except Exception:
247
+ conversation_log = []
248
 
249
+ conversation_log.append({
250
  "question": current_question,
251
  "answer": answer,
252
  "evaluation": evaluation_result
253
  })
254
 
255
+ application.interview_log = json.dumps(conversation_log, ensure_ascii=False)
256
  db.session.commit()
257
  except Exception as log_err:
258
  logging.error(f"Error saving interview log: {log_err}")
259
 
260
  # Determine the number of questions configured for this job
261
  total_questions = 4
262
+ if job and job.num_questions and job.num_questions > 0:
263
+ total_questions = job.num_questions
 
 
 
 
 
 
264
 
265
  # Check completion. ``question_idx`` is zero‑based; the last index
266
  # corresponds to ``total_questions - 1``. When the current index
 
284
  "and do you prefer remote or on-site work?"
285
  )
286
  else:
287
+ # Use the Qdrant-powered, memory-aware next question. The job
288
+ # and application were already loaded above, and conversation_log
289
+ # holds every prior turn so LUNA can build on the real dialogue.
290
  try:
 
 
 
 
 
 
 
291
  profile = {}
292
  if application and application.extracted_features:
293
  profile = json.loads(application.extracted_features)
294
 
 
295
  next_question_text = generate_next_question(
296
  profile,
297
  job,
298
+ conversation_log,
299
  answer
300
  )
301
  except Exception as e:
302
+ logging.error(f"Error generating next question: {e}")
303
  next_question_text = "Could you elaborate more on your last point?"
304
 
305
 
backend/services/interview_engine.py CHANGED
@@ -115,6 +115,61 @@ def load_whisper_model():
115
 
116
  load_whisper_model()
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  def generate_first_question(profile, job):
119
  """Generate the first interview question based on profile and job"""
120
  all_roles = extract_all_roles_from_qdrant()
@@ -128,28 +183,34 @@ def generate_first_question(profile, job):
128
  else:
129
  logging.warning("[QDRANT DEBUG] No questions retrieved, falling back to defaults")
130
 
131
- context_data = random_context_chunks(retrieved_data, k=4) if retrieved_data else ""
132
 
133
  try:
134
  prompt = f"""
135
- You are LUNA, an AI recruiter conducting an interview for a {job.role} position at {job.company}.
136
- Candidate profile:
 
 
 
 
137
  - Skills: {profile.get('skills', [])}
138
  - Experience: {profile.get('experience', [])}
139
  - Education: {profile.get('education', [])}
140
 
141
- Interview style:
142
- - Start the interview in a friendly but professional way.
143
- - Always begin with: "Hi, how are you? I'm LUNA, your AI recruiter."
144
- - If the candidate has previous experience, reference their most recent role or company:
145
- Example: "I see you previously worked at {profile.get('experience', [''])[0]}.
146
- Can you tell me more about your time there, along with your education and overall background?"
147
- - If no experience is available, simply ask them to tell you about their background, education, and experience.
 
 
148
 
149
  Respond ONLY with the question text, no formatting or extra notes.
150
  """
151
 
152
-
153
  response = groq_llm.invoke(prompt)
154
 
155
  # Fix: Handle AIMessage object properly
@@ -171,66 +232,6 @@ def generate_first_question(profile, job):
171
  logging.error(f"Error generating first question: {e}")
172
  return "Tell me about yourself and why you're interested in this position."
173
 
174
- def generate_next_question(profile, job, conversation_history, last_answer):
175
- """Generate the next interview question based on profile, job, and conversation so far"""
176
- all_roles = extract_all_roles_from_qdrant()
177
- logging.info(f"[QDRANT DEBUG] Available Roles: {all_roles}")
178
-
179
- retrieved_data = retrieve_interview_data(job.role.lower(), all_roles)
180
- logging.info(f"[QDRANT DEBUG] Role requested: {job.role.lower()}")
181
- logging.info(f"[QDRANT DEBUG] Questions retrieved: {len(retrieved_data)}")
182
- if retrieved_data:
183
- logging.info(f"[QDRANT DEBUG] Sample Next Q: {retrieved_data[0]['question']}")
184
- else:
185
- logging.warning("[QDRANT DEBUG] No questions retrieved, falling back to defaults")
186
-
187
- context_data = random_context_chunks(retrieved_data, k=4) if retrieved_data else ""
188
-
189
- try:
190
- prompt = f"""
191
- You are LUNA, an AI recruiter continuing an interview for a {job.role} position at {job.company}.
192
- Candidate profile:
193
- - Skills: {profile.get('skills', [])}
194
- - Experience: {profile.get('experience', [])}
195
- - Education: {profile.get('education', [])}
196
-
197
- Conversation so far:
198
- {conversation_history}
199
-
200
- Candidate's last answer:
201
- {last_answer}
202
-
203
- Interview style:
204
- - Acknowledge the candidate's last answer naturally (e.g., "That's a great point", "I see what you mean").
205
- - Then ask a related follow-up question that connects to what they just said.
206
- - Keep the tone professional, concise, and relevant to the role.
207
- - If technical, dig deeper into skills or tools they mentioned.
208
- - If behavioral, expand on situations or experiences they described.
209
-
210
- Respond ONLY with the next question text (no formatting, no commentary).
211
- """
212
-
213
-
214
- response = groq_llm.invoke(prompt)
215
-
216
- if hasattr(response, 'content'):
217
- question = response.content.strip()
218
- elif isinstance(response, str):
219
- question = response.strip()
220
- else:
221
- question = str(response).strip()
222
-
223
- if not question or len(question) < 10:
224
- question = "Could you elaborate more on your last point?"
225
-
226
- logging.info(f"Generated next question: {question}")
227
- return question
228
-
229
- except Exception as e:
230
- logging.error(f"Error generating next question: {e}")
231
- return "Could you elaborate more on your last point?"
232
-
233
-
234
  def edge_tts_to_file_sync(text, output_path, voice="en-US-AriaNeural"):
235
  """Synchronous wrapper for edge-tts with better error handling"""
236
  try:
@@ -349,28 +350,40 @@ def generate_next_question(profile, job, conversation_history, last_answer):
349
  logging.warning("[QDRANT DEBUG] No questions retrieved, falling back to defaults")
350
 
351
  context_data = random_context_chunks(retrieved_data, k=4) if retrieved_data else ""
 
352
 
353
  try:
354
  prompt = f"""
355
- You are continuing an interview for a {job.role} position at {job.company}.
356
- Candidate's profile:
 
 
 
 
 
357
  - Skills: {profile.get('skills', [])}
358
  - Experience: {profile.get('experience', [])}
359
  - Education: {profile.get('education', [])}
360
 
361
- Conversation so far:
362
- {conversation_history}
363
 
364
- Candidate's last answer:
365
- {last_answer}
366
 
367
- Use the following context to generate the next question:
368
  {context_data}
369
 
370
- Generate an appropriate follow-up interview question that is professional and relevant.
371
- Keep it concise and clear. If the interview is for a technical role, focus on technical skills.
 
 
 
 
 
 
372
  """
373
-
374
  response = groq_llm.invoke(prompt)
375
 
376
  if hasattr(response, 'content'):
@@ -443,19 +456,21 @@ def evaluate_answer(question, answer, job_role="Software Developer", seniority="
443
  }
444
 
445
  prompt = f"""
446
- You are evaluating a candidate's answer for a {seniority} {job_role} position.
447
-
 
448
  Question: {question}
449
  Candidate Answer: {answer}
450
-
451
- Evaluate based on technical correctness, clarity, and relevance.
452
- Provide a brief evaluation in 1-2 sentences.
453
-
454
- Rate the answer as one of: Poor, Medium, Good, Excellent
455
-
456
- Respond in this exact format:
 
457
  Score: [Poor/Medium/Good/Excellent]
458
- Feedback: [Your brief feedback here]
459
  """
460
 
461
  response = groq_llm.invoke(prompt)
 
115
 
116
  load_whisper_model()
117
 
118
+ def _most_recent_experience(profile):
119
+ """Safely return the candidate's most recent experience entry, or ''.
120
+
121
+ ``profile['experience']`` may be a list (possibly empty) or a string, so we
122
+ guard against IndexError which would otherwise drop the first question to a
123
+ canned fallback.
124
+ """
125
+ exp = profile.get('experience')
126
+ if isinstance(exp, list):
127
+ return str(exp[0]).strip() if exp else ""
128
+ if isinstance(exp, str):
129
+ return exp.strip()
130
+ return ""
131
+
132
+
133
+ def _job_context(job):
134
+ """Build a compact role-context block to inject into prompts."""
135
+ try:
136
+ skills = ", ".join(job.skills_list) if getattr(job, "skills_list", None) else ""
137
+ except Exception:
138
+ skills = ""
139
+ seniority = getattr(job, "seniority", "") or ""
140
+ description = (getattr(job, "description", "") or "").strip()
141
+ if len(description) > 600:
142
+ description = description[:600] + "…"
143
+ return (
144
+ f"- Role: {job.role} at {job.company}\n"
145
+ f"- Seniority: {seniority}\n"
146
+ f"- Required skills: {skills}\n"
147
+ f"- Role description: {description}"
148
+ )
149
+
150
+
151
+ def _format_history(conversation_history):
152
+ """Render the running conversation into readable Q/A lines for the prompt.
153
+
154
+ Accepts either the list of interview_log dicts ({"question", "answer", ...})
155
+ or a plain string / list of strings, so callers can pass whatever they have.
156
+ """
157
+ if not conversation_history:
158
+ return "(this is the first follow-up; no prior turns yet)"
159
+ if isinstance(conversation_history, str):
160
+ return conversation_history
161
+ lines = []
162
+ for i, turn in enumerate(conversation_history, 1):
163
+ if isinstance(turn, dict):
164
+ q = str(turn.get("question", "")).strip()
165
+ a = str(turn.get("answer", "")).strip()
166
+ if q or a:
167
+ lines.append(f"Q{i}: {q}\nA{i}: {a}")
168
+ else:
169
+ lines.append(str(turn).strip())
170
+ return "\n\n".join(lines) if lines else "(no prior turns yet)"
171
+
172
+
173
  def generate_first_question(profile, job):
174
  """Generate the first interview question based on profile and job"""
175
  all_roles = extract_all_roles_from_qdrant()
 
183
  else:
184
  logging.warning("[QDRANT DEBUG] No questions retrieved, falling back to defaults")
185
 
186
+ recent_experience = _most_recent_experience(profile)
187
 
188
  try:
189
  prompt = f"""
190
+ You are LUNA, a warm, professional AI recruiter conducting a real interview.
191
+
192
+ Position context:
193
+ {_job_context(job)}
194
+
195
+ Candidate profile (from their CV):
196
  - Skills: {profile.get('skills', [])}
197
  - Experience: {profile.get('experience', [])}
198
  - Education: {profile.get('education', [])}
199
 
200
+ Your task — write ONLY the opening line of the interview:
201
+ - Always begin with exactly: "Hi, how are you? I'm LUNA, your AI recruiter."
202
+ - Then warmly invite them to introduce themselves.
203
+ - If they have prior experience, reference their most recent role naturally
204
+ (most recent role: "{recent_experience}") and ask them to tell you about that
205
+ experience along with their background and education.
206
+ - If they have no prior experience, simply ask them to tell you about their
207
+ background, education, and what draws them to this {job.role} role.
208
+ - Keep it to 2-3 sentences, conversational and human.
209
 
210
  Respond ONLY with the question text, no formatting or extra notes.
211
  """
212
 
213
+
214
  response = groq_llm.invoke(prompt)
215
 
216
  # Fix: Handle AIMessage object properly
 
232
  logging.error(f"Error generating first question: {e}")
233
  return "Tell me about yourself and why you're interested in this position."
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  def edge_tts_to_file_sync(text, output_path, voice="en-US-AriaNeural"):
236
  """Synchronous wrapper for edge-tts with better error handling"""
237
  try:
 
350
  logging.warning("[QDRANT DEBUG] No questions retrieved, falling back to defaults")
351
 
352
  context_data = random_context_chunks(retrieved_data, k=4) if retrieved_data else ""
353
+ history_text = _format_history(conversation_history)
354
 
355
  try:
356
  prompt = f"""
357
+ You are LUNA, a warm but sharp AI recruiter conducting a live interview. You behave like
358
+ a real human interviewer: you listen, react naturally, and keep the conversation flowing.
359
+
360
+ Position context:
361
+ {_job_context(job)}
362
+
363
+ Candidate's profile (from their CV):
364
  - Skills: {profile.get('skills', [])}
365
  - Experience: {profile.get('experience', [])}
366
  - Education: {profile.get('education', [])}
367
 
368
+ Conversation so far (earlier questions and the candidate's answers):
369
+ {history_text}
370
 
371
+ The candidate just said:
372
+ "{last_answer}"
373
 
374
+ Example questions from this role's question bank (for inspiration on topic/difficulty — do NOT copy verbatim):
375
  {context_data}
376
 
377
+ Write LUNA's next turn:
378
+ - Start with a brief, natural acknowledgement of their last answer (e.g. "That makes sense," "Great, thanks for sharing that.").
379
+ - Then ask exactly ONE focused follow-up question.
380
+ - Build on what they actually said and what's already been discussed — never repeat an earlier question.
381
+ - Stay anchored to the {job.role} role and its required skills; for technical roles, probe deeper into real skills/tools.
382
+ - Keep it concise, conversational, and human (1-2 sentences for the question).
383
+
384
+ Respond ONLY with LUNA's spoken text (acknowledgement + the one question), no labels or formatting.
385
  """
386
+
387
  response = groq_llm.invoke(prompt)
388
 
389
  if hasattr(response, 'content'):
 
456
  }
457
 
458
  prompt = f"""
459
+ You are LUNA, an experienced recruiter evaluating a candidate's spoken answer for a
460
+ {seniority} {job_role} position. Judge it at the level expected for that seniority.
461
+
462
  Question: {question}
463
  Candidate Answer: {answer}
464
+
465
+ Evaluate on: technical correctness, relevance to the question, depth/specificity, and clarity.
466
+ Calibrate to seniority be more demanding for senior roles, more forgiving for junior ones.
467
+ Reward concrete, specific answers; penalise vague, off-topic, or empty ones.
468
+
469
+ Rate the answer as exactly one of: Poor, Medium, Good, Excellent.
470
+
471
+ Respond in this exact format and nothing else:
472
  Score: [Poor/Medium/Good/Excellent]
473
+ Feedback: [one or two specific sentences explaining the rating]
474
  """
475
 
476
  response = groq_llm.invoke(prompt)
backend/services/resume_parser.py CHANGED
@@ -1,32 +1,48 @@
 
1
  import re
 
 
2
  from pathlib import Path
3
  from typing import Dict, List, Tuple
4
- import spacy
5
  from pdfminer.high_level import extract_text as pdf_extract_text
6
  from docx import Document
7
- from transformers import AutoTokenizer, AutoModelForTokenClassification, pipeline
8
- import nltk
9
- from nltk.corpus import stopwords
10
- from dateutil.parser import parse as date_parse
11
-
12
- # Download required NLTK data
13
- try:
14
- nltk.download('stopwords', quiet=True)
15
- nltk.download('punkt', quiet=True)
16
- except:
17
- pass
18
-
19
- # Load spaCy model for better NER
20
- try:
21
- nlp = spacy.load("en_core_web_sm")
22
- except:
23
- print("Please install spacy model: python -m spacy download en_core_web_sm")
24
- nlp = None
25
-
26
- MODEL_NAME = "manishiitg/resume-ner"
27
- tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
28
- model = AutoModelForTokenClassification.from_pretrained(MODEL_NAME)
29
- ner_pipeline = pipeline("ner", model=model, tokenizer=tokenizer, aggregation_strategy="simple")
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  # Expanded keyword lists
32
  SKILL_KEYWORDS = {
@@ -146,6 +162,7 @@ def extract_name(text: str, entities: List) -> str:
146
  return full_name
147
 
148
  # Method 2: Use spaCy if available
 
149
  if nlp:
150
  doc = nlp(text[:500]) # Check first 500 chars
151
  for ent in doc.ents:
@@ -260,36 +277,176 @@ def extract_experience(text: str, exp_section: str = "") -> List[str]:
260
 
261
  return list(dict.fromkeys(experience_info))
262
 
263
- def parse_resume(file_path: str, filename: str = None) -> Dict[str, str]:
264
- """Main function to parse resume"""
265
- # Extract and clean text
266
- raw_text = extract_text(file_path)
267
- text = clean_text(raw_text)
268
-
269
- # Extract sections
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  sections = extract_sections(text)
271
-
272
- # Get NER entities
273
- entities = ner_pipeline(text[:1024]) # Limit for performance
274
-
275
- # Extract information
 
 
 
 
276
  name = extract_name(text, entities)
277
  skills = extract_skills(text, sections.get('skills', ''))
278
  education = extract_education(text, sections.get('education', ''))
279
  experience = extract_experience(text, sections.get('experience', ''))
280
-
281
  return {
282
  "name": name,
283
- "skills": ", ".join(skills[:15]) if skills else "Not Found", # Limit to 15 skills
284
  "education": ", ".join(education[:5]) if education else "Not Found",
285
- "experience": ", ".join(experience[:5]) if experience else "Not Found"
286
  }
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  # Optional: Add confidence scores
289
  def parse_resume_with_confidence(file_path: str) -> Dict[str, Tuple[str, float]]:
290
  """Parse resume with confidence scores for each field"""
291
  result = parse_resume(file_path)
292
-
293
  # Simple confidence calculation based on whether data was found
294
  confidence_scores = {
295
  "name": 0.9 if result["name"] != "Not Found" else 0.1,
@@ -297,8 +454,8 @@ def parse_resume_with_confidence(file_path: str) -> Dict[str, Tuple[str, float]]
297
  "education": 0.8 if result["education"] != "Not Found" else 0.2,
298
  "experience": 0.8 if result["experience"] != "Not Found" else 0.2
299
  }
300
-
301
  return {
302
- key: (value, confidence_scores[key])
303
  for key, value in result.items()
304
  }
 
1
+ import os
2
  import re
3
+ import json
4
+ import logging
5
  from pathlib import Path
6
  from typing import Dict, List, Tuple
7
+
8
  from pdfminer.high_level import extract_text as pdf_extract_text
9
  from docx import Document
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Parsing strategy
13
+ #
14
+ # The primary parser is an LLM (Groq) which reads the raw resume text and
15
+ # returns clean, structured fields. This is far more accurate than regex and
16
+ # handles any ATS-friendly CV. The legacy spaCy/transformer/regex path is kept
17
+ # only as a fallback for when no GROQ_API_KEY is configured or the LLM call
18
+ # fails. The heavy fallback dependencies (spaCy model, the manishiitg/resume-ner
19
+ # transformer, nltk) are imported lazily inside ``_regex_parse`` so the normal
20
+ # (LLM) path does not pay their startup cost.
21
+ # ---------------------------------------------------------------------------
22
+
23
+ # Cached lazily so we only build the Groq client once.
24
+ _llm = None
25
+ _llm_initialised = False
26
+
27
+
28
+ def _get_llm():
29
+ """Return a cached ChatGroq client, or ``None`` when no key is configured."""
30
+ global _llm, _llm_initialised
31
+ if _llm_initialised:
32
+ return _llm
33
+ _llm_initialised = True
34
+ api_key = os.getenv("GROQ_API_KEY")
35
+ if not api_key:
36
+ logging.warning("GROQ_API_KEY not set; resume parsing will use the regex fallback.")
37
+ _llm = None
38
+ return _llm
39
+ try:
40
+ from langchain_groq import ChatGroq
41
+ _llm = ChatGroq(temperature=0, model_name="llama-3.3-70b-versatile", api_key=api_key)
42
+ except Exception as exc:
43
+ logging.error(f"Failed to initialise Groq for resume parsing: {exc}")
44
+ _llm = None
45
+ return _llm
46
 
47
  # Expanded keyword lists
48
  SKILL_KEYWORDS = {
 
162
  return full_name
163
 
164
  # Method 2: Use spaCy if available
165
+ nlp = _get_nlp()
166
  if nlp:
167
  doc = nlp(text[:500]) # Check first 500 chars
168
  for ent in doc.ents:
 
277
 
278
  return list(dict.fromkeys(experience_info))
279
 
280
+ # ---------------------------------------------------------------------------
281
+ # Lazy loaders for the fallback (regex/NER) path
282
+ # ---------------------------------------------------------------------------
283
+ _nlp = None
284
+ _nlp_loaded = False
285
+ _ner_pipeline = None
286
+ _ner_loaded = False
287
+
288
+
289
+ def _get_nlp():
290
+ """Lazily load the spaCy model (used only by the regex fallback)."""
291
+ global _nlp, _nlp_loaded
292
+ if _nlp_loaded:
293
+ return _nlp
294
+ _nlp_loaded = True
295
+ try:
296
+ import spacy
297
+ _nlp = spacy.load("en_core_web_sm")
298
+ except Exception:
299
+ logging.warning("spaCy model en_core_web_sm unavailable; name detection degraded.")
300
+ _nlp = None
301
+ return _nlp
302
+
303
+
304
+ def _get_ner_pipeline():
305
+ """Lazily load the resume-ner transformer (used only by the regex fallback)."""
306
+ global _ner_pipeline, _ner_loaded
307
+ if _ner_loaded:
308
+ return _ner_pipeline
309
+ _ner_loaded = True
310
+ try:
311
+ from transformers import AutoTokenizer, AutoModelForTokenClassification, pipeline
312
+ name = "manishiitg/resume-ner"
313
+ tok = AutoTokenizer.from_pretrained(name)
314
+ mdl = AutoModelForTokenClassification.from_pretrained(name)
315
+ _ner_pipeline = pipeline("ner", model=mdl, tokenizer=tok, aggregation_strategy="simple")
316
+ except Exception as exc:
317
+ logging.warning(f"resume-ner model unavailable ({exc}); using spaCy/regex for names.")
318
+ _ner_pipeline = None
319
+ return _ner_pipeline
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # Primary parser: LLM (Groq)
324
+ # ---------------------------------------------------------------------------
325
+ def _coerce_list(value) -> List[str]:
326
+ """Normalise the LLM's value (list or string) into a clean list of strings."""
327
+ if isinstance(value, list):
328
+ items = [str(v).strip() for v in value]
329
+ elif isinstance(value, str):
330
+ items = [p.strip() for p in re.split(r'[\n;]+', value)]
331
+ else:
332
+ items = []
333
+ # Drop empties and obvious "not found" placeholders, dedupe (case-insensitive)
334
+ seen, out = set(), []
335
+ for it in items:
336
+ if not it or it.lower() in ("not found", "n/a", "none", "-"):
337
+ continue
338
+ key = it.lower()
339
+ if key not in seen:
340
+ seen.add(key)
341
+ out.append(it)
342
+ return out
343
+
344
+
345
+ def _llm_extract(text: str) -> Dict[str, str]:
346
+ """Extract structured fields from resume text with the Groq LLM.
347
+
348
+ Returns the same string-field shape as ``parse_resume``. Raises on any
349
+ failure so the caller can fall back to the regex parser.
350
+ """
351
+ llm = _get_llm()
352
+ if llm is None:
353
+ raise RuntimeError("LLM unavailable")
354
+
355
+ # Keep the prompt bounded; resumes rarely need more than this many chars.
356
+ snippet = text[:6000]
357
+ prompt = f"""You are an expert resume parser. Read the resume text below and extract the candidate's details.
358
+
359
+ Return ONLY a JSON object (no markdown, no commentary) with exactly these keys:
360
+ - "name": the candidate's full name as a string (empty string if not found)
361
+ - "skills": an array of individual technical and professional skills (e.g. ["Python", "React", "AWS", "Project Management"])
362
+ - "education": an array of one-line entries, each "<Degree> in <Field> — <Institution>, <Year>" when available
363
+ - "experience": an array of one-line entries, each "<Job Title> — <Company> (<dates or duration>)" when available
364
+
365
+ Rules:
366
+ - Use the actual content of the resume; never invent information.
367
+ - Keep each array entry concise and on a single line.
368
+ - If a section is missing, return an empty array for it.
369
+
370
+ Resume text:
371
+ \"\"\"
372
+ {snippet}
373
+ \"\"\"
374
+ """
375
+ response = llm.invoke(prompt)
376
+ raw = response.content if hasattr(response, "content") else str(response)
377
+
378
+ # Be robust to code fences or stray prose around the JSON.
379
+ cleaned = raw.strip()
380
+ if cleaned.startswith("```"):
381
+ cleaned = re.sub(r"^```[a-zA-Z]*\n?", "", cleaned).rstrip("`").strip()
382
+ start, end = cleaned.find("{"), cleaned.rfind("}")
383
+ if start == -1 or end == -1:
384
+ raise ValueError("LLM did not return JSON")
385
+ data = json.loads(cleaned[start:end + 1])
386
+
387
+ name = str(data.get("name", "")).strip()
388
+ skills = _coerce_list(data.get("skills"))
389
+ education = _coerce_list(data.get("education"))
390
+ experience = _coerce_list(data.get("experience"))
391
+
392
+ return {
393
+ "name": name if name else "Not Found",
394
+ "skills": ", ".join(skills[:20]) if skills else "Not Found",
395
+ "education": "\n".join(education[:6]) if education else "Not Found",
396
+ "experience": "\n".join(experience[:6]) if experience else "Not Found",
397
+ }
398
+
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # Fallback parser: legacy spaCy/transformer/regex heuristics
402
+ # ---------------------------------------------------------------------------
403
+ def _regex_parse(text: str) -> Dict[str, str]:
404
+ """Heuristic resume parsing used when the LLM is unavailable."""
405
  sections = extract_sections(text)
406
+
407
+ entities = []
408
+ ner = _get_ner_pipeline()
409
+ if ner is not None:
410
+ try:
411
+ entities = ner(text[:1024]) # Limit for performance
412
+ except Exception:
413
+ entities = []
414
+
415
  name = extract_name(text, entities)
416
  skills = extract_skills(text, sections.get('skills', ''))
417
  education = extract_education(text, sections.get('education', ''))
418
  experience = extract_experience(text, sections.get('experience', ''))
419
+
420
  return {
421
  "name": name,
422
+ "skills": ", ".join(skills[:15]) if skills else "Not Found",
423
  "education": ", ".join(education[:5]) if education else "Not Found",
424
+ "experience": ", ".join(experience[:5]) if experience else "Not Found",
425
  }
426
 
427
+
428
+ def parse_resume(file_path: str, filename: str = None) -> Dict[str, str]:
429
+ """Parse a resume into structured fields.
430
+
431
+ Tries the Groq LLM first (accurate, handles any CV layout) and falls back
432
+ to the legacy regex/NER heuristics if the LLM is unavailable or errors.
433
+ The return shape (string fields) is unchanged so existing callers work.
434
+ """
435
+ raw_text = extract_text(file_path)
436
+ text = clean_text(raw_text)
437
+
438
+ try:
439
+ return _llm_extract(text)
440
+ except Exception as exc:
441
+ logging.warning(f"LLM resume parsing failed ({exc}); falling back to regex parser.")
442
+ return _regex_parse(text)
443
+
444
+
445
  # Optional: Add confidence scores
446
  def parse_resume_with_confidence(file_path: str) -> Dict[str, Tuple[str, float]]:
447
  """Parse resume with confidence scores for each field"""
448
  result = parse_resume(file_path)
449
+
450
  # Simple confidence calculation based on whether data was found
451
  confidence_scores = {
452
  "name": 0.9 if result["name"] != "Not Found" else 0.1,
 
454
  "education": 0.8 if result["education"] != "Not Found" else 0.2,
455
  "experience": 0.8 if result["experience"] != "Not Found" else 0.2
456
  }
457
+
458
  return {
459
+ key: (value, confidence_scores[key])
460
  for key, value in result.items()
461
  }