husseinelsaadi Claude Opus 4.8 commited on
Commit
c5a47ef
·
1 Parent(s): 4fb152a

Richer CV experience, sharper STT, livelier LUNA voice

Browse files

- CV parsing now captures each role's responsibilities/description (not just
title+company); experience/education are split on newlines so descriptions
with commas stay intact
- Interview prompts direct LUNA to ask specific questions about the candidate's
actual projects and technologies
- Speech-to-text: default to the English small.en Whisper model and bias
decoding with a technical-vocabulary initial_prompt (fixes "roles"->"Rolls")
- LUNA's voice now speaks ~15% faster (tunable via LUNA_TTS_RATE)

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

app.py CHANGED
@@ -125,6 +125,7 @@ def apply(job_id):
125
  return render_template('apply.html', job=job)
126
 
127
  def parse_entries(raw_value: str):
 
128
  import re
129
  entries = []
130
  if raw_value:
@@ -134,19 +135,32 @@ def apply(job_id):
134
  entries.append(item)
135
  return entries
136
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  skills_input = request.form.get('skills', '')
138
  experience_input = request.form.get('experience', '')
139
  education_input = request.form.get('education', '')
140
 
141
  manual_features = {
142
  "skills": parse_entries(skills_input),
143
- "experience": parse_entries(experience_input),
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:
@@ -154,7 +168,7 @@ def apply(job_id):
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
 
 
125
  return render_template('apply.html', job=job)
126
 
127
  def parse_entries(raw_value: str):
128
+ # Skills: split on commas/semicolons/newlines (many short items).
129
  import re
130
  entries = []
131
  if raw_value:
 
135
  entries.append(item)
136
  return entries
137
 
138
+ def parse_lines(raw_value: str):
139
+ # Experience/education: split on newlines (and semicolons) only, so a
140
+ # role's comma-containing description stays as one entry.
141
+ import re
142
+ entries = []
143
+ if raw_value:
144
+ for item in re.split(r'[\n;]+', raw_value):
145
+ item = item.strip()
146
+ if item:
147
+ entries.append(item)
148
+ return entries
149
+
150
  skills_input = request.form.get('skills', '')
151
  experience_input = request.form.get('experience', '')
152
  education_input = request.form.get('education', '')
153
 
154
  manual_features = {
155
  "skills": parse_entries(skills_input),
156
+ "experience": parse_lines(experience_input),
157
+ "education": parse_lines(education_input)
158
  }
159
 
160
  # Auto-parse the uploaded CV so the candidate's profile reflects their
161
  # real resume even when the form fields are left blank. Anything the
162
  # user typed manually takes precedence; otherwise we use the parsed CV.
163
+ _splitters = {"skills": parse_entries, "experience": parse_lines, "education": parse_lines}
164
  parsed_features = {"skills": [], "experience": [], "education": []}
165
  try:
166
  if filepath:
 
168
  for key in parsed_features:
169
  value = parsed.get(key, '')
170
  if value and value != "Not Found":
171
+ parsed_features[key] = _splitters[key](value)
172
  except Exception as parse_err:
173
  print(f"Auto CV parse failed: {parse_err}", file=sys.stderr)
174
 
backend/services/interview_engine.py CHANGED
@@ -104,7 +104,10 @@ def load_whisper_model():
104
  # lightweight model to improve startup times. Available options
105
  # include: tiny, base, base.en, small, medium, large. See
106
  # https://huggingface.co/ggerganov/whisper.cpp for details.
107
- model_name = os.getenv("WHISPER_MODEL_NAME", "base")
 
 
 
108
  whisper_model = WhisperModel(model_name, device=device, compute_type=compute_type)
109
  logging.info(f"Whisper model '{model_name}' loaded on {device} with {compute_type}")
110
  except Exception as e:
@@ -262,9 +265,13 @@ def edge_tts_to_file_sync(text, output_path, voice="en-US-AriaNeural"):
262
  output_path = os.path.join(directory, os.path.basename(output_path))
263
  os.makedirs(directory, exist_ok=True)
264
 
 
 
 
 
265
  async def generate_audio():
266
  try:
267
- communicate = edge_tts.Communicate(text, voice)
268
  await communicate.save(output_path)
269
  logging.info(f"TTS audio saved to: {output_path}")
270
  except Exception as e:
@@ -377,6 +384,9 @@ def generate_next_question(profile, job, conversation_history, last_answer):
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).
@@ -433,12 +443,21 @@ def whisper_stt(audio_path):
433
  # language="en" avoids misdetecting the language; vad_filter drops
434
  # silence so Whisper doesn't hallucinate phrases on quiet gaps;
435
  # condition_on_previous_text=False keeps each answer independent.
 
 
 
 
 
 
 
 
436
  segments, _ = model.transcribe(
437
  wav_path,
438
  language="en",
439
  beam_size=5,
440
  vad_filter=True,
441
  condition_on_previous_text=False,
 
442
  )
443
  transcript = " ".join(segment.text for segment in segments)
444
  return transcript.strip()
 
104
  # lightweight model to improve startup times. Available options
105
  # include: tiny, base, base.en, small, medium, large. See
106
  # https://huggingface.co/ggerganov/whisper.cpp for details.
107
+ # Default to the English "small" model for noticeably better accuracy
108
+ # on technical vocabulary. Override with WHISPER_MODEL_NAME=base.en if
109
+ # transcription feels slow on the free CPU tier.
110
+ model_name = os.getenv("WHISPER_MODEL_NAME", "small.en")
111
  whisper_model = WhisperModel(model_name, device=device, compute_type=compute_type)
112
  logging.info(f"Whisper model '{model_name}' loaded on {device} with {compute_type}")
113
  except Exception as e:
 
265
  output_path = os.path.join(directory, os.path.basename(output_path))
266
  os.makedirs(directory, exist_ok=True)
267
 
268
+ # Speak a little faster than the default so LUNA feels lively, not slow.
269
+ # Tunable via LUNA_TTS_RATE (e.g. "+10%", "+20%").
270
+ tts_rate = os.getenv("LUNA_TTS_RATE", "+15%")
271
+
272
  async def generate_audio():
273
  try:
274
+ communicate = edge_tts.Communicate(text, voice, rate=tts_rate)
275
  await communicate.save(output_path)
276
  logging.info(f"TTS audio saved to: {output_path}")
277
  except Exception as e:
 
384
  Write LUNA's next turn:
385
  - Start with a brief, natural acknowledgement of their last answer (e.g. "That makes sense," "Great, thanks for sharing that.").
386
  - Then ask exactly ONE focused follow-up question.
387
+ - Prefer SPECIFIC questions about the actual projects, responsibilities, and technologies in the
388
+ candidate's experience above (e.g. "You mentioned building X at Y — how did you handle Z?"),
389
+ rather than generic questions.
390
  - Build on what they actually said and what's already been discussed — never repeat an earlier question.
391
  - Stay anchored to the {job.role} role and its required skills; for technical roles, probe deeper into real skills/tools.
392
  - Keep it concise, conversational, and human (1-2 sentences for the question).
 
443
  # language="en" avoids misdetecting the language; vad_filter drops
444
  # silence so Whisper doesn't hallucinate phrases on quiet gaps;
445
  # condition_on_previous_text=False keeps each answer independent.
446
+ # initial_prompt biases decoding toward technical-interview vocabulary,
447
+ # which fixes homophone errors like "roles" -> "Rolls".
448
+ interview_vocab_prompt = (
449
+ "This is a technical job interview about software engineering, data science, "
450
+ "machine learning, AI, web development, cloud, and various job roles. "
451
+ "Common terms include Python, JavaScript, React, SQL, AWS, Docker, Kubernetes, "
452
+ "APIs, databases, algorithms, data roles, and frameworks."
453
+ )
454
  segments, _ = model.transcribe(
455
  wav_path,
456
  language="en",
457
  beam_size=5,
458
  vad_filter=True,
459
  condition_on_previous_text=False,
460
+ initial_prompt=interview_vocab_prompt,
461
  )
462
  transcript = " ".join(segment.text for segment in segments)
463
  return transcript.strip()
backend/services/resume_parser.py CHANGED
@@ -360,11 +360,14 @@ 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:
 
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 with ONE entry per role. Each entry is a single line of the form
364
+ "<Job Title> at <Company> (<dates or duration>): <1-2 sentence summary of the key responsibilities, projects, and technologies for that role>".
365
+ Base the summary strictly on that role's bullet points in the resume.
366
 
367
  Rules:
368
  - Use the actual content of the resume; never invent information.
369
+ - Keep each entry on a single line (no line breaks inside an entry).
370
+ - For experience, always include the role's responsibilities/description after the colon when the resume provides them.
371
  - If a section is missing, return an empty array for it.
372
 
373
  Resume text: