Spaces:
Sleeping
Accurate LLM CV parsing + realistic, memory-aware interview
Browse filesCV 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 +25 -3
- backend/routes/interview_api.py +32 -28
- backend/services/interview_engine.py +106 -91
- backend/services/resume_parser.py +199 -42
|
@@ -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(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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 |
-
#
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 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 |
-
|
| 230 |
except Exception:
|
| 231 |
-
|
| 232 |
|
| 233 |
-
|
| 234 |
"question": current_question,
|
| 235 |
"answer": answer,
|
| 236 |
"evaluation": evaluation_result
|
| 237 |
})
|
| 238 |
|
| 239 |
-
application.interview_log = json.dumps(
|
| 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
|
| 247 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
| 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 |
-
|
| 295 |
answer
|
| 296 |
)
|
| 297 |
except Exception as e:
|
| 298 |
-
logging.error(f"Error generating next question
|
| 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 |
|
|
@@ -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 |
-
|
| 132 |
|
| 133 |
try:
|
| 134 |
prompt = f"""
|
| 135 |
-
You are LUNA,
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
- Skills: {profile.get('skills', [])}
|
| 138 |
- Experience: {profile.get('experience', [])}
|
| 139 |
- Education: {profile.get('education', [])}
|
| 140 |
|
| 141 |
-
|
| 142 |
-
-
|
| 143 |
-
-
|
| 144 |
-
- If
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
- If
|
|
|
|
|
|
|
| 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
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
- Skills: {profile.get('skills', [])}
|
| 358 |
- Experience: {profile.get('experience', [])}
|
| 359 |
- Education: {profile.get('education', [])}
|
| 360 |
|
| 361 |
-
Conversation so far:
|
| 362 |
-
{
|
| 363 |
|
| 364 |
-
|
| 365 |
-
{last_answer}
|
| 366 |
|
| 367 |
-
|
| 368 |
{context_data}
|
| 369 |
|
| 370 |
-
|
| 371 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 447 |
-
|
|
|
|
| 448 |
Question: {question}
|
| 449 |
Candidate Answer: {answer}
|
| 450 |
-
|
| 451 |
-
Evaluate
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
|
|
|
| 457 |
Score: [Poor/Medium/Good/Excellent]
|
| 458 |
-
Feedback: [
|
| 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)
|
|
@@ -1,32 +1,48 @@
|
|
|
|
|
| 1 |
import re
|
|
|
|
|
|
|
| 2 |
from pathlib import Path
|
| 3 |
from typing import Dict, List, Tuple
|
| 4 |
-
|
| 5 |
from pdfminer.high_level import extract_text as pdf_extract_text
|
| 6 |
from docx import Document
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
#
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
# 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 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
sections = extract_sections(text)
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 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 |
}
|