sajith-0701 commited on
Commit
be9a4dd
·
1 Parent(s): d56d27f
backend/models/collections.py CHANGED
@@ -5,6 +5,7 @@ RESUMES = "resumes"
5
  SKILLS = "skills"
6
  JOB_ROLES = "job_roles"
7
  JOB_DESCRIPTIONS = "job_descriptions"
 
8
  ROLE_REQUIREMENTS = "role_requirements"
9
  QUESTIONS = "questions"
10
  TOPICS = "topics"
 
5
  SKILLS = "skills"
6
  JOB_ROLES = "job_roles"
7
  JOB_DESCRIPTIONS = "job_descriptions"
8
+ JD_VERIFICATIONS = "jd_verifications"
9
  ROLE_REQUIREMENTS = "role_requirements"
10
  QUESTIONS = "questions"
11
  TOPICS = "topics"
backend/routers/interview.py CHANGED
@@ -35,6 +35,8 @@ async def start_interview_endpoint(
35
  job_description_id=request.job_description_id,
36
  )
37
  return result
 
 
38
  except Exception as e:
39
  raise HTTPException(status_code=500, detail=str(e))
40
 
 
35
  job_description_id=request.job_description_id,
36
  )
37
  return result
38
+ except ValueError as e:
39
+ raise HTTPException(status_code=400, detail=str(e))
40
  except Exception as e:
41
  raise HTTPException(status_code=500, detail=str(e))
42
 
backend/routers/speech.py CHANGED
@@ -40,7 +40,13 @@ async def synthesize_speech(
40
  except ValueError as e:
41
  raise HTTPException(status_code=400, detail=str(e))
42
  except RuntimeError as e:
43
- raise HTTPException(status_code=503, detail=str(e))
 
 
 
 
 
 
44
  except Exception as e:
45
  raise HTTPException(status_code=500, detail=f"Speech synthesis failed: {str(e)}")
46
 
 
40
  except ValueError as e:
41
  raise HTTPException(status_code=400, detail=str(e))
42
  except RuntimeError as e:
43
+ # XTTS may be in cold-start transition; warm once and retry before failing.
44
+ try:
45
+ await warmup_xtts_model()
46
+ wav_bytes = await synthesize_wav(request.text, request.voice_gender)
47
+ return Response(content=wav_bytes, media_type="audio/wav")
48
+ except RuntimeError:
49
+ raise HTTPException(status_code=503, detail=str(e))
50
  except Exception as e:
51
  raise HTTPException(status_code=500, detail=f"Speech synthesis failed: {str(e)}")
52
 
backend/services/interview_service.py CHANGED
@@ -1,22 +1,25 @@
1
  import json
2
  import asyncio
3
  import random
 
4
  from bson import ObjectId
5
  from database import get_db, get_redis
6
- from models.collections import SESSIONS, USERS, JOB_ROLES, SKILLS, QUESTIONS, TOPICS, TOPIC_QUESTIONS, ROLE_REQUIREMENTS, RESUMES
7
  from utils.helpers import generate_id, utc_now, str_objectid
8
- from utils.skills import normalize_skill_list, find_matching_skills, find_missing_skills, build_interview_focus_skills
9
  from services.interview_graph import run_interview_graph
10
  from utils.gemini import generate_interview_question_batch, analyze_resume_vs_job_description
11
  from services.job_description_service import get_job_description_for_user
12
  from services.tts_service import prefetch_wav
13
 
14
  MAX_QUESTIONS = 20
 
 
15
  SESSION_TTL = 7200 # 2 hours
16
  BATCH_SIZE = 5
17
  PREGEN_MIN_PENDING = 2
18
- FOLLOWUP_AI_COUNT = 3
19
- FOLLOWUP_BANK_COUNT = 2
20
 
21
  # Local process memory summary requested in workflow.
22
  _LOCAL_SUMMARIES: dict[str, str] = {}
@@ -171,9 +174,48 @@ async def verify_resume_job_description(
171
  jd_required_skills=selected_jd.get("required_skills", []),
172
  )
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  return {
 
 
175
  "role_title": role_title,
176
  "job_description": selected_jd,
 
177
  "jd_alignment": jd_alignment,
178
  "message": "Verification complete",
179
  }
@@ -277,6 +319,7 @@ async def _fetch_question_bank_batch(
277
  role_id: str | None,
278
  excluded_questions: list[str],
279
  limit: int,
 
280
  ) -> list[dict]:
281
  if limit <= 0:
282
  return []
@@ -292,6 +335,16 @@ async def _fetch_question_bank_batch(
292
  pass
293
  query["role_id"] = {"$in": role_candidates}
294
 
 
 
 
 
 
 
 
 
 
 
295
  excluded = {q.strip().lower() for q in excluded_questions if q}
296
  selected: list[dict] = []
297
 
@@ -328,6 +381,7 @@ async def _fetch_question_bank_batch(
328
  role_id=None,
329
  excluded_questions=list(excluded),
330
  limit=limit - len(selected),
 
331
  )
332
  selected.extend(fallback)
333
 
@@ -339,6 +393,37 @@ def _strict_followup_difficulty(answered_count: int) -> str:
339
  return "hard" if answered_count >= 10 else "medium"
340
 
341
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  async def _generate_mixed_followup_batch(
343
  db,
344
  redis,
@@ -362,56 +447,153 @@ async def _generate_mixed_followup_batch(
362
  answered_count = len(qa_pairs)
363
  role_title = session.get("role_title", "Software Developer")
364
  skills = _safe_json_list(session.get("skills", "[]"))
 
 
365
  current_difficulty = _strict_followup_difficulty(answered_count)
366
 
367
- if target >= 5:
368
- ai_target = 3
369
- bank_target = 2
370
- else:
371
- ai_target = min(3, target)
372
- bank_target = min(2, max(0, target - ai_target))
373
-
374
  from utils.gemini import generate_followup_question_batch_from_qa
375
 
376
  gemini_calls = 0
377
  gemini_questions = 0
378
 
379
- ai_items = await generate_followup_question_batch_from_qa(
380
- role_title=role_title,
381
- skills=skills,
382
- qa_pairs=qa_pairs,
383
- previous_questions=previous_questions,
384
- count=ai_target,
385
- difficulty=current_difficulty,
386
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  if ai_target > 0:
 
 
 
 
 
 
 
 
388
  gemini_calls += 1
389
- gemini_questions += len(ai_items)
 
 
 
 
 
 
 
 
 
 
 
390
 
391
- exclude_pool = list(previous_questions) + [i.get("question", "") for i in ai_items]
 
392
  bank_items = await _fetch_question_bank_batch(
393
  db=db,
394
  role_id=session.get("role_id"),
395
  excluded_questions=exclude_pool,
396
  limit=bank_target,
 
397
  )
398
 
 
 
 
 
 
399
  if len(bank_items) < bank_target:
 
400
  refill = bank_target - len(bank_items)
401
- refill_ai = await generate_followup_question_batch_from_qa(
402
- role_title=role_title,
403
- skills=skills,
404
- qa_pairs=qa_pairs,
405
- previous_questions=exclude_pool + [i.get("question", "") for i in bank_items],
406
- count=refill,
407
- difficulty=current_difficulty,
408
- )
409
- ai_items.extend(refill_ai)
410
  if refill > 0:
 
 
 
 
 
 
 
 
411
  gemini_calls += 1
412
- gemini_questions += len(refill_ai)
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
  mixed = (ai_items + bank_items)[:target]
 
 
 
415
  last_difficulty = mixed[-1].get("difficulty", current_difficulty) if mixed else current_difficulty
416
  return mixed, last_difficulty, {
417
  "gemini_calls": gemini_calls,
@@ -530,11 +712,11 @@ async def _start_topic_interview(user_id: str, topic_id: str) -> dict:
530
  await redis.expire(f"session:{session_id}:pending_questions", SESSION_TTL)
531
 
532
  first_q_data = await redis.hgetall(f"session:{session_id}:q:{first_id}")
 
 
 
533
  _schedule_question_audio_prefetch(
534
- [
535
- first_q_data.get("question", ""),
536
- *[q.get("question", "") for q in selected[1:3]],
537
- ],
538
  speech_voice_gender,
539
  )
540
 
@@ -569,12 +751,16 @@ async def _start_topic_interview(user_id: str, topic_id: str) -> dict:
569
 
570
 
571
  async def _async_pregenerate_next_batch(session_id: str) -> None:
 
572
  redis = get_redis()
573
  try:
574
  session = await redis.hgetall(f"session:{session_id}")
575
  if not session or session.get("status") != "in_progress":
576
  return
577
 
 
 
 
578
  pending_len = await redis.llen(f"session:{session_id}:pending_questions")
579
  generated_count = int(session.get("generated_count", 0))
580
  max_questions = int(session.get("max_questions", MAX_QUESTIONS))
@@ -582,21 +768,13 @@ async def _async_pregenerate_next_batch(session_id: str) -> None:
582
  if pending_len >= PREGEN_MIN_PENDING or generated_count >= max_questions:
583
  return
584
 
585
- previous_questions = await _get_generated_question_texts(redis, session_id)
586
- skills = _safe_json_list(session.get("skills", "[]"))
587
- role_title = session.get("role_title", "Software Developer")
588
- current_difficulty = session.get("current_difficulty", "medium")
589
- local_summary = _LOCAL_SUMMARIES.get(session_id)
590
-
591
- batch, last_difficulty = await _generate_question_batch(
592
- role_title=role_title,
593
- skills=skills,
594
- previous_questions=previous_questions,
595
  generated_count=generated_count,
596
  max_questions=max_questions,
597
- current_difficulty=current_difficulty,
598
- local_summary=local_summary,
599
- batch_size=BATCH_SIZE,
600
  )
601
  if not batch:
602
  return
@@ -605,11 +783,38 @@ async def _async_pregenerate_next_batch(session_id: str) -> None:
605
  if new_ids:
606
  await redis.rpush(f"session:{session_id}:pending_questions", *new_ids)
607
  await redis.expire(f"session:{session_id}:pending_questions", SESSION_TTL)
 
 
 
 
 
 
 
 
 
 
608
  await redis.hset(
609
  f"session:{session_id}",
610
  mapping={
611
  "generated_count": str(generated_count + len(new_ids)),
612
  "current_difficulty": last_difficulty,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  },
614
  )
615
  finally:
@@ -617,8 +822,8 @@ async def _async_pregenerate_next_batch(session_id: str) -> None:
617
 
618
 
619
  def _schedule_pregen(session_id: str, answered_count: int) -> None:
620
- # Start pre-generation after user answers Q1 and Q2, then keep it topped up.
621
- if answered_count < 2:
622
  return
623
  if session_id in _PREGEN_IN_FLIGHT:
624
  return
@@ -656,62 +861,72 @@ async def start_interview(
656
  user_skills = skills_doc.get("skills", ["general"]) if skills_doc else ["general"]
657
  user_skills = normalize_skill_list(user_skills)
658
 
 
 
 
659
  # Get role
660
  role_title = await _resolve_role_title(db, role_id=role_id, custom_role=custom_role)
661
 
662
- # Compare role requirements with user skills when admin role requirements exist.
663
- required_skills = []
664
- if role_id and not custom_role:
665
- req_cursor = db[ROLE_REQUIREMENTS].find({"role_id": role_id})
666
- req_docs = await req_cursor.to_list(length=100)
667
- required_skills = [d.get("skill", "") for d in req_docs if d.get("skill")]
668
-
669
- matched_role_skills = find_matching_skills(user_skills, required_skills)
670
- missing_role_skills = find_missing_skills(user_skills, required_skills)
671
-
672
  selected_jd = None
673
  if job_description_id:
674
  selected_jd = await get_job_description_for_user(user_id, job_description_id)
675
 
676
- # Prioritize matched required skills and compress them into cluster-aware focus areas.
677
- base_skills_for_interview = matched_role_skills if matched_role_skills else user_skills
 
 
 
 
 
 
 
 
 
 
 
678
  skills_for_interview = build_interview_focus_skills(base_skills_for_interview)
679
  if not skills_for_interview:
680
- skills_for_interview = ["general"]
681
 
682
- # First set must come from random DB questions when possible.
683
  initial_bank = await _fetch_question_bank_batch(
684
  db=db,
685
  role_id=role_id,
686
  excluded_questions=[],
687
- limit=BATCH_SIZE,
 
688
  )
689
 
690
- initial_batch = list(initial_bank)
691
- initial_ai_items: list[dict] = []
692
- if len(initial_batch) < BATCH_SIZE:
693
- ai_count = BATCH_SIZE - len(initial_batch)
694
- initial_ai_items, _ = await _generate_question_batch(
 
 
 
 
 
695
  role_title=role_title,
696
  skills=skills_for_interview,
697
- previous_questions=[q.get("question", "") for q in initial_batch],
698
  generated_count=0,
699
- max_questions=MAX_QUESTIONS,
700
  current_difficulty="medium",
701
  local_summary=None,
702
- batch_size=ai_count,
703
  )
704
- initial_batch.extend(initial_ai_items)
 
 
 
 
 
705
 
706
  last_difficulty = initial_batch[-1].get("difficulty", "medium") if initial_batch else "medium"
707
  if not initial_batch:
708
  raise ValueError("Failed to generate initial interview questions")
709
 
710
- initial_gemini_calls = 1 if initial_ai_items else 0
711
- initial_gemini_questions = len(initial_ai_items)
712
- initial_bank_questions = len(initial_bank)
713
- initial_bank_shortfall = max(0, BATCH_SIZE - len(initial_bank))
714
-
715
  session_id = generate_id()
716
  _LOCAL_SUMMARIES[session_id] = ""
717
 
@@ -726,7 +941,7 @@ async def start_interview(
726
  "status": "in_progress",
727
  "interview_type": "resume",
728
  "question_count": 1,
729
- "max_questions": MAX_QUESTIONS,
730
  "current_difficulty": initial_batch[0].get("difficulty", "medium"),
731
  "metrics_gemini_calls": initial_gemini_calls,
732
  "metrics_gemini_questions": initial_gemini_questions,
@@ -752,11 +967,13 @@ async def start_interview(
752
  "answered_count": 0,
753
  "served_count": 1,
754
  "generated_count": len(initial_batch),
755
- "max_questions": MAX_QUESTIONS,
756
  "current_difficulty": last_difficulty,
757
  "interview_type": "resume",
758
  "status": "in_progress",
759
  "speech_voice_gender": speech_voice_gender,
 
 
760
  "metrics_gemini_calls": initial_gemini_calls,
761
  "metrics_gemini_questions": initial_gemini_questions,
762
  "metrics_bank_questions": initial_bank_questions,
@@ -775,11 +992,11 @@ async def start_interview(
775
  await redis.expire(f"session:{session_id}:pending_questions", SESSION_TTL)
776
 
777
  first_q_data = await redis.hgetall(f"session:{session_id}:q:{first_id}")
 
 
 
778
  _schedule_question_audio_prefetch(
779
- [
780
- first_q_data.get("question", ""),
781
- *[item.get("question", "") for item in initial_batch[1:4]],
782
- ],
783
  speech_voice_gender,
784
  )
785
 
@@ -797,7 +1014,7 @@ async def start_interview(
797
  "question": first_q_data.get("question", "Tell me about yourself."),
798
  "difficulty": first_q_data.get("difficulty", "medium"),
799
  "question_number": 1,
800
- "total_questions": MAX_QUESTIONS,
801
  },
802
  "timer": {
803
  "enabled": False,
@@ -949,8 +1166,8 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
949
  q_data = await redis.hgetall(f"session:{session_id}:q:{next_question_id}")
950
  speech_voice_gender = _normalize_voice_gender(session.get("speech_voice_gender"))
951
 
952
- # Prefetch the spoken audio for this question and one-ahead question.
953
- prefetch_texts = [q_data.get("question", "")]
954
  peek_next_id = await redis.lindex(f"session:{session_id}:pending_questions", 0)
955
  if peek_next_id:
956
  peek_q = await redis.hgetall(f"session:{session_id}:q:{peek_next_id}")
@@ -968,6 +1185,9 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
968
  "current_difficulty": next_difficulty,
969
  })
970
 
 
 
 
971
  effective_stats = {
972
  "gemini_calls": _safe_int(session.get("metrics_gemini_calls", 0)) + metrics_delta["gemini_calls"],
973
  "gemini_questions": _safe_int(session.get("metrics_gemini_questions", 0)) + metrics_delta["gemini_questions"],
 
1
  import json
2
  import asyncio
3
  import random
4
+ import re
5
  from bson import ObjectId
6
  from database import get_db, get_redis
7
+ from models.collections import SESSIONS, USERS, JOB_ROLES, SKILLS, QUESTIONS, TOPICS, TOPIC_QUESTIONS, RESUMES, JD_VERIFICATIONS
8
  from utils.helpers import generate_id, utc_now, str_objectid
9
+ from utils.skills import normalize_skill_list, build_interview_focus_skills
10
  from services.interview_graph import run_interview_graph
11
  from utils.gemini import generate_interview_question_batch, analyze_resume_vs_job_description
12
  from services.job_description_service import get_job_description_for_user
13
  from services.tts_service import prefetch_wav
14
 
15
  MAX_QUESTIONS = 20
16
+ RESUME_MAX_QUESTIONS = 10
17
+ RESUME_INITIAL_BATCH_SIZE = 2
18
  SESSION_TTL = 7200 # 2 hours
19
  BATCH_SIZE = 5
20
  PREGEN_MIN_PENDING = 2
21
+ FOLLOWUP_AI_COUNT = 2
22
+ FOLLOWUP_BANK_COUNT = 3
23
 
24
  # Local process memory summary requested in workflow.
25
  _LOCAL_SUMMARIES: dict[str, str] = {}
 
174
  jd_required_skills=selected_jd.get("required_skills", []),
175
  )
176
 
177
+ resume_snapshot = {
178
+ "filename": resume_doc.get("original_filename") or resume_doc.get("filename") or "",
179
+ "uploaded_at": resume_doc.get("uploaded_at"),
180
+ "skills": resume_skills,
181
+ "parsed_data": {
182
+ "name": parsed_data.get("name"),
183
+ "email": parsed_data.get("email"),
184
+ "phone": parsed_data.get("phone"),
185
+ "location": parsed_data.get("location"),
186
+ "recommended_roles": parsed_data.get("recommended_roles", []) or [],
187
+ "experience_summary": parsed_data.get("experience_summary", "") or "",
188
+ },
189
+ }
190
+
191
+ verification_id = generate_id()
192
+ saved_at = utc_now()
193
+ await db[JD_VERIFICATIONS].insert_one(
194
+ {
195
+ "verification_id": verification_id,
196
+ "user_id": user_id,
197
+ "role_id": role_id,
198
+ "custom_role": custom_role,
199
+ "role_title": role_title,
200
+ "job_description": {
201
+ "id": selected_jd.get("id"),
202
+ "title": selected_jd.get("title"),
203
+ "company": selected_jd.get("company"),
204
+ "description": selected_jd.get("description"),
205
+ "required_skills": selected_jd.get("required_skills", []) or [],
206
+ },
207
+ "resume_snapshot": resume_snapshot,
208
+ "jd_alignment": jd_alignment,
209
+ "created_at": saved_at,
210
+ }
211
+ )
212
+
213
  return {
214
+ "verification_id": verification_id,
215
+ "saved_at": saved_at,
216
  "role_title": role_title,
217
  "job_description": selected_jd,
218
+ "resume_snapshot": resume_snapshot,
219
  "jd_alignment": jd_alignment,
220
  "message": "Verification complete",
221
  }
 
319
  role_id: str | None,
320
  excluded_questions: list[str],
321
  limit: int,
322
+ skill_hints: list[str] | None = None,
323
  ) -> list[dict]:
324
  if limit <= 0:
325
  return []
 
335
  pass
336
  query["role_id"] = {"$in": role_candidates}
337
 
338
+ normalized_hints = normalize_skill_list(skill_hints or [])
339
+ if normalized_hints:
340
+ scope_match = []
341
+ for skill in normalized_hints:
342
+ token = re.escape(skill)
343
+ scope_match.append({"category": {"$regex": token, "$options": "i"}})
344
+ scope_match.append({"question": {"$regex": token, "$options": "i"}})
345
+ if scope_match:
346
+ query["$or"] = scope_match
347
+
348
  excluded = {q.strip().lower() for q in excluded_questions if q}
349
  selected: list[dict] = []
350
 
 
381
  role_id=None,
382
  excluded_questions=list(excluded),
383
  limit=limit - len(selected),
384
+ skill_hints=normalized_hints,
385
  )
386
  selected.extend(fallback)
387
 
 
393
  return "hard" if answered_count >= 10 else "medium"
394
 
395
 
396
+ def _has_followup_opportunity(qa_pairs: list, window: int = BATCH_SIZE) -> bool:
397
+ """Decide whether Gemini follow-up questions are needed for the latest batch."""
398
+ if not qa_pairs:
399
+ return False
400
+
401
+ weak_markers = {
402
+ "i think",
403
+ "maybe",
404
+ "not sure",
405
+ "dont know",
406
+ "don't know",
407
+ "etc",
408
+ "kind of",
409
+ "sort of",
410
+ }
411
+
412
+ for qa in qa_pairs[-window:]:
413
+ answer = (qa.get("answer") or "").strip()
414
+ if not answer:
415
+ continue
416
+
417
+ if len(answer.split()) < 30:
418
+ return True
419
+
420
+ lowered = answer.lower()
421
+ if any(marker in lowered for marker in weak_markers):
422
+ return True
423
+
424
+ return False
425
+
426
+
427
  async def _generate_mixed_followup_batch(
428
  db,
429
  redis,
 
447
  answered_count = len(qa_pairs)
448
  role_title = session.get("role_title", "Software Developer")
449
  skills = _safe_json_list(session.get("skills", "[]"))
450
+ jd_required_skills = _safe_json_list(session.get("jd_required_skills", "[]"))
451
+ resume_source_mode = (session.get("resume_source_mode") or "db").strip().lower()
452
  current_difficulty = _strict_followup_difficulty(answered_count)
453
 
 
 
 
 
 
 
 
454
  from utils.gemini import generate_followup_question_batch_from_qa
455
 
456
  gemini_calls = 0
457
  gemini_questions = 0
458
 
459
+ if resume_source_mode == "ai":
460
+ ai_items = await generate_followup_question_batch_from_qa(
461
+ role_title=role_title,
462
+ skills=skills,
463
+ qa_pairs=qa_pairs,
464
+ previous_questions=previous_questions,
465
+ count=target,
466
+ difficulty=current_difficulty,
467
+ )
468
+ gemini_calls = 1 if target > 0 else 0
469
+
470
+ deduped_ai = []
471
+ excluded_lower = {q.strip().lower() for q in previous_questions if q}
472
+ for item in ai_items:
473
+ text = (item.get("question") or "").strip()
474
+ if not text:
475
+ continue
476
+ lowered = text.lower()
477
+ if lowered in excluded_lower:
478
+ continue
479
+ deduped_ai.append(item)
480
+ excluded_lower.add(lowered)
481
+ if len(deduped_ai) >= target:
482
+ break
483
+
484
+ if len(deduped_ai) < target:
485
+ refill, refill_last = await _generate_question_batch(
486
+ role_title=role_title,
487
+ skills=skills,
488
+ previous_questions=previous_questions + [i.get("question", "") for i in deduped_ai],
489
+ generated_count=generated_count + len(deduped_ai),
490
+ max_questions=max_questions,
491
+ current_difficulty=current_difficulty,
492
+ local_summary=_LOCAL_SUMMARIES.get(session_id),
493
+ batch_size=target - len(deduped_ai),
494
+ )
495
+ for item in refill:
496
+ text = (item.get("question") or "").strip()
497
+ if not text:
498
+ continue
499
+ lowered = text.lower()
500
+ if lowered in excluded_lower:
501
+ continue
502
+ deduped_ai.append(item)
503
+ excluded_lower.add(lowered)
504
+ if len(deduped_ai) >= target:
505
+ break
506
+ if refill:
507
+ current_difficulty = refill_last
508
+
509
+ final_ai = deduped_ai[:target]
510
+ last_difficulty = final_ai[-1].get("difficulty", current_difficulty) if final_ai else current_difficulty
511
+ return final_ai, last_difficulty, {
512
+ "gemini_calls": gemini_calls,
513
+ "gemini_questions": len(final_ai),
514
+ "bank_questions": 0,
515
+ "bank_shortfall": 0,
516
+ }
517
+
518
+ # Batch policy:
519
+ # - If follow-up opportunity exists: 2 AI + 3 DB
520
+ # - Otherwise: 5 DB
521
+ ai_target = min(FOLLOWUP_AI_COUNT, target) if _has_followup_opportunity(qa_pairs) else 0
522
+
523
+ excluded_lower = {q.strip().lower() for q in previous_questions if q}
524
+ ai_items: list[dict] = []
525
+
526
  if ai_target > 0:
527
+ generated_ai = await generate_followup_question_batch_from_qa(
528
+ role_title=role_title,
529
+ skills=skills,
530
+ qa_pairs=qa_pairs,
531
+ previous_questions=previous_questions,
532
+ count=ai_target,
533
+ difficulty=current_difficulty,
534
+ )
535
  gemini_calls += 1
536
+ for item in generated_ai:
537
+ text = (item.get("question") or "").strip()
538
+ if not text:
539
+ continue
540
+ lowered = text.lower()
541
+ if lowered in excluded_lower:
542
+ continue
543
+ ai_items.append(item)
544
+ excluded_lower.add(lowered)
545
+ if len(ai_items) >= ai_target:
546
+ break
547
+ gemini_questions += len(ai_items)
548
 
549
+ bank_target = max(0, target - len(ai_items))
550
+ exclude_pool = list(excluded_lower)
551
  bank_items = await _fetch_question_bank_batch(
552
  db=db,
553
  role_id=session.get("role_id"),
554
  excluded_questions=exclude_pool,
555
  limit=bank_target,
556
+ skill_hints=jd_required_skills,
557
  )
558
 
559
+ for item in bank_items:
560
+ text = (item.get("question") or "").strip()
561
+ if text:
562
+ excluded_lower.add(text.lower())
563
+
564
  if len(bank_items) < bank_target:
565
+ # Keep total batch size stable if the bank pool is exhausted.
566
  refill = bank_target - len(bank_items)
567
+ refill_ai = []
568
+ added_refill_ai = 0
 
 
 
 
 
 
 
569
  if refill > 0:
570
+ refill_ai = await generate_followup_question_batch_from_qa(
571
+ role_title=role_title,
572
+ skills=skills,
573
+ qa_pairs=qa_pairs,
574
+ previous_questions=list(excluded_lower),
575
+ count=refill,
576
+ difficulty=current_difficulty,
577
+ )
578
  gemini_calls += 1
579
+ for item in refill_ai:
580
+ text = (item.get("question") or "").strip()
581
+ if not text:
582
+ continue
583
+ lowered = text.lower()
584
+ if lowered in excluded_lower:
585
+ continue
586
+ ai_items.append(item)
587
+ added_refill_ai += 1
588
+ excluded_lower.add(lowered)
589
+ if len(ai_items) + len(bank_items) >= target:
590
+ break
591
+ gemini_questions += added_refill_ai
592
 
593
  mixed = (ai_items + bank_items)[:target]
594
+ if len(mixed) > 1:
595
+ random.shuffle(mixed)
596
+
597
  last_difficulty = mixed[-1].get("difficulty", current_difficulty) if mixed else current_difficulty
598
  return mixed, last_difficulty, {
599
  "gemini_calls": gemini_calls,
 
712
  await redis.expire(f"session:{session_id}:pending_questions", SESSION_TTL)
713
 
714
  first_q_data = await redis.hgetall(f"session:{session_id}:q:{first_id}")
715
+ prefetch_targets = []
716
+ if len(selected) > 1:
717
+ prefetch_targets.append(selected[1].get("question", ""))
718
  _schedule_question_audio_prefetch(
719
+ prefetch_targets,
 
 
 
720
  speech_voice_gender,
721
  )
722
 
 
751
 
752
 
753
  async def _async_pregenerate_next_batch(session_id: str) -> None:
754
+ db = get_db()
755
  redis = get_redis()
756
  try:
757
  session = await redis.hgetall(f"session:{session_id}")
758
  if not session or session.get("status") != "in_progress":
759
  return
760
 
761
+ if session.get("interview_type", "resume") != "resume":
762
+ return
763
+
764
  pending_len = await redis.llen(f"session:{session_id}:pending_questions")
765
  generated_count = int(session.get("generated_count", 0))
766
  max_questions = int(session.get("max_questions", MAX_QUESTIONS))
 
768
  if pending_len >= PREGEN_MIN_PENDING or generated_count >= max_questions:
769
  return
770
 
771
+ batch, last_difficulty, batch_metrics = await _generate_mixed_followup_batch(
772
+ db=db,
773
+ redis=redis,
774
+ session_id=session_id,
775
+ session=session,
 
 
 
 
 
776
  generated_count=generated_count,
777
  max_questions=max_questions,
 
 
 
778
  )
779
  if not batch:
780
  return
 
783
  if new_ids:
784
  await redis.rpush(f"session:{session_id}:pending_questions", *new_ids)
785
  await redis.expire(f"session:{session_id}:pending_questions", SESSION_TTL)
786
+
787
+ prefetch_targets = []
788
+ for qid in new_ids[:2]:
789
+ q = await redis.hgetall(f"session:{session_id}:q:{qid}")
790
+ prefetch_targets.append(q.get("question", ""))
791
+ _schedule_question_audio_prefetch(
792
+ prefetch_targets,
793
+ _normalize_voice_gender(session.get("speech_voice_gender")),
794
+ )
795
+
796
  await redis.hset(
797
  f"session:{session_id}",
798
  mapping={
799
  "generated_count": str(generated_count + len(new_ids)),
800
  "current_difficulty": last_difficulty,
801
+ "metrics_gemini_calls": str(_safe_int(session.get("metrics_gemini_calls", 0)) + batch_metrics.get("gemini_calls", 0)),
802
+ "metrics_gemini_questions": str(_safe_int(session.get("metrics_gemini_questions", 0)) + batch_metrics.get("gemini_questions", 0)),
803
+ "metrics_bank_questions": str(_safe_int(session.get("metrics_bank_questions", 0)) + batch_metrics.get("bank_questions", 0)),
804
+ "metrics_bank_shortfall": str(_safe_int(session.get("metrics_bank_shortfall", 0)) + batch_metrics.get("bank_shortfall", 0)),
805
+ "metrics_generation_batches": str(_safe_int(session.get("metrics_generation_batches", 0)) + 1),
806
+ },
807
+ )
808
+ await db[SESSIONS].update_one(
809
+ {"session_id": session_id},
810
+ {
811
+ "$set": {
812
+ "metrics_gemini_calls": _safe_int(session.get("metrics_gemini_calls", 0)) + batch_metrics.get("gemini_calls", 0),
813
+ "metrics_gemini_questions": _safe_int(session.get("metrics_gemini_questions", 0)) + batch_metrics.get("gemini_questions", 0),
814
+ "metrics_bank_questions": _safe_int(session.get("metrics_bank_questions", 0)) + batch_metrics.get("bank_questions", 0),
815
+ "metrics_bank_shortfall": _safe_int(session.get("metrics_bank_shortfall", 0)) + batch_metrics.get("bank_shortfall", 0),
816
+ "metrics_generation_batches": _safe_int(session.get("metrics_generation_batches", 0)) + 1,
817
+ }
818
  },
819
  )
820
  finally:
 
822
 
823
 
824
  def _schedule_pregen(session_id: str, answered_count: int) -> None:
825
+ # Start pre-generation as soon as Q1 is answered, while user is on Q2.
826
+ if answered_count < 1:
827
  return
828
  if session_id in _PREGEN_IN_FLIGHT:
829
  return
 
861
  user_skills = skills_doc.get("skills", ["general"]) if skills_doc else ["general"]
862
  user_skills = normalize_skill_list(user_skills)
863
 
864
+ if not job_description_id:
865
+ raise ValueError("Please select a Job Description before starting Resume Interview")
866
+
867
  # Get role
868
  role_title = await _resolve_role_title(db, role_id=role_id, custom_role=custom_role)
869
 
 
 
 
 
 
 
 
 
 
 
870
  selected_jd = None
871
  if job_description_id:
872
  selected_jd = await get_job_description_for_user(user_id, job_description_id)
873
 
874
+ jd_required_skills = normalize_skill_list((selected_jd or {}).get("required_skills", []))
875
+ if not jd_required_skills:
876
+ raise ValueError(
877
+ "Selected Job Description has no required skills. Add required skills in Settings first."
878
+ )
879
+
880
+ user_skill_set = {s.lower() for s in user_skills}
881
+ matched_role_skills = [s for s in jd_required_skills if s.lower() in user_skill_set]
882
+ missing_role_skills = [s for s in jd_required_skills if s.lower() not in user_skill_set]
883
+ required_skills = list(jd_required_skills)
884
+
885
+ # Resume interview scope is strictly JD-required skills.
886
+ base_skills_for_interview = matched_role_skills + [s for s in missing_role_skills if s not in matched_role_skills]
887
  skills_for_interview = build_interview_focus_skills(base_skills_for_interview)
888
  if not skills_for_interview:
889
+ skills_for_interview = required_skills
890
 
891
+ # Start with two questions ready so Q1 is asked immediately and Q2 is already queued.
892
  initial_bank = await _fetch_question_bank_batch(
893
  db=db,
894
  role_id=role_id,
895
  excluded_questions=[],
896
+ limit=RESUME_INITIAL_BATCH_SIZE,
897
+ skill_hints=required_skills,
898
  )
899
 
900
+ resume_source_mode = "db" if len(initial_bank) >= RESUME_INITIAL_BATCH_SIZE else "ai"
901
+
902
+ if resume_source_mode == "db":
903
+ initial_batch = list(initial_bank[:RESUME_INITIAL_BATCH_SIZE])
904
+ initial_gemini_calls = 0
905
+ initial_gemini_questions = 0
906
+ initial_bank_questions = len(initial_batch)
907
+ initial_bank_shortfall = 0
908
+ else:
909
+ initial_batch, _ = await _generate_question_batch(
910
  role_title=role_title,
911
  skills=skills_for_interview,
912
+ previous_questions=[],
913
  generated_count=0,
914
+ max_questions=RESUME_MAX_QUESTIONS,
915
  current_difficulty="medium",
916
  local_summary=None,
917
+ batch_size=RESUME_INITIAL_BATCH_SIZE,
918
  )
919
+ if not initial_batch:
920
+ raise ValueError("Failed to generate initial resume interview questions")
921
+ initial_gemini_calls = 1
922
+ initial_gemini_questions = len(initial_batch)
923
+ initial_bank_questions = 0
924
+ initial_bank_shortfall = RESUME_INITIAL_BATCH_SIZE
925
 
926
  last_difficulty = initial_batch[-1].get("difficulty", "medium") if initial_batch else "medium"
927
  if not initial_batch:
928
  raise ValueError("Failed to generate initial interview questions")
929
 
 
 
 
 
 
930
  session_id = generate_id()
931
  _LOCAL_SUMMARIES[session_id] = ""
932
 
 
941
  "status": "in_progress",
942
  "interview_type": "resume",
943
  "question_count": 1,
944
+ "max_questions": RESUME_MAX_QUESTIONS,
945
  "current_difficulty": initial_batch[0].get("difficulty", "medium"),
946
  "metrics_gemini_calls": initial_gemini_calls,
947
  "metrics_gemini_questions": initial_gemini_questions,
 
967
  "answered_count": 0,
968
  "served_count": 1,
969
  "generated_count": len(initial_batch),
970
+ "max_questions": RESUME_MAX_QUESTIONS,
971
  "current_difficulty": last_difficulty,
972
  "interview_type": "resume",
973
  "status": "in_progress",
974
  "speech_voice_gender": speech_voice_gender,
975
+ "resume_source_mode": resume_source_mode,
976
+ "jd_required_skills": json.dumps(required_skills),
977
  "metrics_gemini_calls": initial_gemini_calls,
978
  "metrics_gemini_questions": initial_gemini_questions,
979
  "metrics_bank_questions": initial_bank_questions,
 
992
  await redis.expire(f"session:{session_id}:pending_questions", SESSION_TTL)
993
 
994
  first_q_data = await redis.hgetall(f"session:{session_id}:q:{first_id}")
995
+ prefetch_targets = []
996
+ if len(initial_batch) > 1:
997
+ prefetch_targets.append(initial_batch[1].get("question", ""))
998
  _schedule_question_audio_prefetch(
999
+ prefetch_targets,
 
 
 
1000
  speech_voice_gender,
1001
  )
1002
 
 
1014
  "question": first_q_data.get("question", "Tell me about yourself."),
1015
  "difficulty": first_q_data.get("difficulty", "medium"),
1016
  "question_number": 1,
1017
+ "total_questions": RESUME_MAX_QUESTIONS,
1018
  },
1019
  "timer": {
1020
  "enabled": False,
 
1166
  q_data = await redis.hgetall(f"session:{session_id}:q:{next_question_id}")
1167
  speech_voice_gender = _normalize_voice_gender(session.get("speech_voice_gender"))
1168
 
1169
+ # Prefetch one-ahead question only. Current question is synthesized by active playback path.
1170
+ prefetch_texts = []
1171
  peek_next_id = await redis.lindex(f"session:{session_id}:pending_questions", 0)
1172
  if peek_next_id:
1173
  peek_q = await redis.hgetall(f"session:{session_id}:q:{peek_next_id}")
 
1185
  "current_difficulty": next_difficulty,
1186
  })
1187
 
1188
+ if interview_type == "resume":
1189
+ _schedule_pregen(session_id, answered_count)
1190
+
1191
  effective_stats = {
1192
  "gemini_calls": _safe_int(session.get("metrics_gemini_calls", 0)) + metrics_delta["gemini_calls"],
1193
  "gemini_questions": _safe_int(session.get("metrics_gemini_questions", 0)) + metrics_delta["gemini_questions"],
backend/services/stt_service.py CHANGED
@@ -31,7 +31,8 @@ def _resolve_compute_type(device: str) -> str:
31
 
32
 
33
  def _resolve_model_size() -> str:
34
- return os.getenv("WHISPER_MODEL_SIZE", "base").strip() or "base"
 
35
 
36
 
37
  async def _get_whisper_model():
 
31
 
32
 
33
  def _resolve_model_size() -> str:
34
+ # Prefer medium for better interview transcription quality.
35
+ return os.getenv("WHISPER_MODEL_SIZE", "medium").strip() or "medium"
36
 
37
 
38
  async def _get_whisper_model():
backend/services/tts_service.py CHANGED
@@ -8,14 +8,25 @@ _MODEL_CACHE = {}
8
  _MODEL_LOCK = asyncio.Lock()
9
  _AUDIO_CACHE = OrderedDict()
10
  _AUDIO_CACHE_LOCK = asyncio.Lock()
 
11
 
12
  XTTS_MODEL = "tts_models/multilingual/multi-dataset/xtts_v2"
13
  XTTS_LANGUAGE = "en"
14
  XTTS_SPEED = 1.2
15
- MAX_TEXT_LENGTH = 220
16
  _XTTS_WARM = False
17
  AUDIO_CACHE_MAX_ITEMS = 300
18
 
 
 
 
 
 
 
 
 
 
 
 
19
  # User-approved stable voices:
20
  # - Female: index 45 => Alexandra Hisakawa
21
  # - Male: index 21 => Abrahan Mack
@@ -83,8 +94,10 @@ def _resolve_xtts_speaker(voice_gender: str) -> str:
83
  return XTTS_SPEAKER_BY_GENDER[gender]
84
 
85
 
86
- def _truncate_text(value: str, max_length: int = MAX_TEXT_LENGTH) -> str:
87
  content = " ".join((value or "").strip().split())
 
 
88
  if len(content) <= max_length:
89
  return content
90
  trimmed = content[:max_length].rstrip()
@@ -181,7 +194,7 @@ async def prefetch_wav(text: str, voice_gender: str = "female") -> None:
181
 
182
 
183
  async def synthesize_wav(text: str, voice_gender: str = "female") -> bytes:
184
- content = _truncate_text(text)
185
  if not content:
186
  raise ValueError("text is required")
187
 
@@ -194,26 +207,32 @@ async def synthesize_wav(text: str, voice_gender: str = "female") -> bytes:
194
  if cached:
195
  return cached
196
 
197
- speaker = _resolve_xtts_speaker(normalized_gender)
198
- tts = await _get_tts_model(XTTS_MODEL)
 
 
 
199
 
200
- fd, tmp_path = tempfile.mkstemp(suffix=".wav")
201
- os.close(fd)
202
- try:
203
- def _synthesize():
204
- _synthesize_xtts_to_file(tts, text=content, speaker=speaker, file_path=tmp_path)
205
 
 
 
206
  try:
207
- await asyncio.to_thread(_synthesize)
208
- with open(tmp_path, "rb") as f:
209
- wav = f.read()
210
- await _set_cached_audio(cache_key, wav)
211
- return wav
212
- except Exception:
213
- # Keep speech available even if XTTS runtime has temporary issues.
214
- wav = await _synthesize_fallback_wav(content, normalized_gender)
215
- await _set_cached_audio(cache_key, wav)
216
- return wav
217
- finally:
218
- if os.path.exists(tmp_path):
219
- os.remove(tmp_path)
 
 
 
 
 
8
  _MODEL_LOCK = asyncio.Lock()
9
  _AUDIO_CACHE = OrderedDict()
10
  _AUDIO_CACHE_LOCK = asyncio.Lock()
11
+ _SYNTHESIZE_LOCK = asyncio.Lock()
12
 
13
  XTTS_MODEL = "tts_models/multilingual/multi-dataset/xtts_v2"
14
  XTTS_LANGUAGE = "en"
15
  XTTS_SPEED = 1.2
 
16
  _XTTS_WARM = False
17
  AUDIO_CACHE_MAX_ITEMS = 300
18
 
19
+
20
+ def _resolve_xtts_max_text_length() -> int:
21
+ """0 disables truncation so full question text is spoken."""
22
+ try:
23
+ return max(0, int(os.getenv("XTTS_MAX_TEXT_LENGTH", "0")))
24
+ except Exception:
25
+ return 0
26
+
27
+
28
+ XTTS_MAX_TEXT_LENGTH = _resolve_xtts_max_text_length()
29
+
30
  # User-approved stable voices:
31
  # - Female: index 45 => Alexandra Hisakawa
32
  # - Male: index 21 => Abrahan Mack
 
94
  return XTTS_SPEAKER_BY_GENDER[gender]
95
 
96
 
97
+ def _normalize_text_for_speech(value: str, max_length: int = XTTS_MAX_TEXT_LENGTH) -> str:
98
  content = " ".join((value or "").strip().split())
99
+ if max_length <= 0:
100
+ return content
101
  if len(content) <= max_length:
102
  return content
103
  trimmed = content[:max_length].rstrip()
 
194
 
195
 
196
  async def synthesize_wav(text: str, voice_gender: str = "female") -> bytes:
197
+ content = _normalize_text_for_speech(text)
198
  if not content:
199
  raise ValueError("text is required")
200
 
 
207
  if cached:
208
  return cached
209
 
210
+ async with _SYNTHESIZE_LOCK:
211
+ # Recheck cache after waiting for lock in case another request already synthesized it.
212
+ cached = await _get_cached_audio(cache_key)
213
+ if cached:
214
+ return cached
215
 
216
+ speaker = _resolve_xtts_speaker(normalized_gender)
217
+ tts = await _get_tts_model(XTTS_MODEL)
 
 
 
218
 
219
+ fd, tmp_path = tempfile.mkstemp(suffix=".wav")
220
+ os.close(fd)
221
  try:
222
+ def _synthesize():
223
+ _synthesize_xtts_to_file(tts, text=content, speaker=speaker, file_path=tmp_path)
224
+
225
+ try:
226
+ await asyncio.to_thread(_synthesize)
227
+ with open(tmp_path, "rb") as f:
228
+ wav = f.read()
229
+ await _set_cached_audio(cache_key, wav)
230
+ return wav
231
+ except Exception:
232
+ # Keep speech available even if XTTS runtime has temporary issues.
233
+ wav = await _synthesize_fallback_wav(content, normalized_gender)
234
+ await _set_cached_audio(cache_key, wav)
235
+ return wav
236
+ finally:
237
+ if os.path.exists(tmp_path):
238
+ os.remove(tmp_path)
backend/utils/gemini.py CHANGED
@@ -299,6 +299,7 @@ async def generate_interview_question(
299
 
300
  Generate ONE interview question for this candidate. The question should:
301
  1. Be relevant to the role and candidate's skills
 
302
  2. Match the {difficulty} difficulty level
303
  3. Be clear and specific
304
  4. Test practical knowledge
@@ -368,6 +369,7 @@ Generate exactly {count} interview questions as a JSON array where each item fol
368
 
369
  Rules:
370
  1. Questions must be relevant to the role and listed skills.
 
371
  2. Do not repeat or rephrase previous questions.
372
  3. If stage is "foundation": ask only core fundamentals.
373
  4. If stage is "deep": ask applied/scenario/debugging/trade-off questions only.
@@ -463,6 +465,7 @@ Input JSON:
463
  Instructions:
464
  1. Generate exactly {count} follow-up questions using answered_qa context.
465
  2. Questions must continue naturally from candidate's previous answers.
 
466
  3. Do not repeat or paraphrase any question in previous_questions.
467
  4. Prioritize loose_qa first: if any answer is vague/short/uncertain, ask a direct follow-up that probes missing concept depth.
468
  5. Focus on concept validation (why, how, trade-offs, failure modes), not memorized definitions.
 
299
 
300
  Generate ONE interview question for this candidate. The question should:
301
  1. Be relevant to the role and candidate's skills
302
+ 1a. Ask ONLY from the provided Candidate Skill Focus Areas. Do not introduce technologies/skills outside that list.
303
  2. Match the {difficulty} difficulty level
304
  3. Be clear and specific
305
  4. Test practical knowledge
 
369
 
370
  Rules:
371
  1. Questions must be relevant to the role and listed skills.
372
+ 1a. Ask ONLY from the provided Candidate Skill Focus Areas. Do not introduce skills outside this list.
373
  2. Do not repeat or rephrase previous questions.
374
  3. If stage is "foundation": ask only core fundamentals.
375
  4. If stage is "deep": ask applied/scenario/debugging/trade-off questions only.
 
465
  Instructions:
466
  1. Generate exactly {count} follow-up questions using answered_qa context.
467
  2. Questions must continue naturally from candidate's previous answers.
468
+ 2a. Ask ONLY from the provided skills list. Do not introduce new unrelated skills/tools.
469
  3. Do not repeat or paraphrase any question in previous_questions.
470
  4. Prioritize loose_qa first: if any answer is vague/short/uncertain, ask a direct follow-up that probes missing concept depth.
471
  5. Focus on concept validation (why, how, trade-offs, failure modes), not memorized definitions.