Spaces:
Sleeping
Sleeping
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 +17 -3
- backend/services/interview_engine.py +21 -2
- backend/services/resume_parser.py +5 -2
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":
|
| 144 |
-
"education":
|
| 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] =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 364 |
|
| 365 |
Rules:
|
| 366 |
- Use the actual content of the resume; never invent information.
|
| 367 |
-
- Keep each
|
|
|
|
| 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:
|