sajith-0701 commited on
Commit
e39cad1
·
1 Parent(s): efa0074
backend/config.py CHANGED
@@ -11,11 +11,13 @@ load_dotenv(os.path.join(os.path.dirname(__file__), ".env"))
11
  class Settings(BaseSettings):
12
  # App
13
  APP_ENV: str = "production"
 
14
  APP_PORT: int = 8000
15
 
16
  # Gemini
17
  GEMINI_API_KEY: str
18
  GEMINI_MODEL: str = "gemini-2.5-flash"
 
19
 
20
  # MongoDB Atlas
21
  MONGO_URI: str
 
11
  class Settings(BaseSettings):
12
  # App
13
  APP_ENV: str = "production"
14
+ APP_HOST: str = "0.0.0.0"
15
  APP_PORT: int = 8000
16
 
17
  # Gemini
18
  GEMINI_API_KEY: str
19
  GEMINI_MODEL: str = "gemini-2.5-flash"
20
+ GEMINI_FALLBACK_MODELS: str = ""
21
 
22
  # MongoDB Atlas
23
  MONGO_URI: str
backend/main.py CHANGED
@@ -4,6 +4,7 @@ from fastapi import FastAPI
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from fastapi.staticfiles import StaticFiles
6
  import os
 
7
 
8
 
9
 
@@ -68,3 +69,12 @@ app.include_router(speech.router, prefix="/speech", tags=["Speech"])
68
  @app.get("/health")
69
  async def health_check():
70
  return {"status": "healthy", "version": "1.0.0"}
 
 
 
 
 
 
 
 
 
 
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from fastapi.staticfiles import StaticFiles
6
  import os
7
+ import uvicorn
8
 
9
 
10
 
 
69
  @app.get("/health")
70
  async def health_check():
71
  return {"status": "healthy", "version": "1.0.0"}
72
+
73
+
74
+ if __name__ == "__main__":
75
+ uvicorn.run(
76
+ "main:app",
77
+ host=settings.APP_HOST,
78
+ port=settings.APP_PORT,
79
+ reload=settings.APP_ENV != "production",
80
+ )
backend/services/evaluation_service.py CHANGED
@@ -23,6 +23,20 @@ def _safe_int(value, default: int = 0) -> int:
23
  return default
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  async def generate_report(session_id: str, user_id: str) -> dict:
27
  """Generate final evaluation report from Redis Q&A data using Gemini."""
28
  db = get_db()
@@ -30,7 +44,7 @@ async def generate_report(session_id: str, user_id: str) -> dict:
30
 
31
  # Check if report already exists
32
  existing = await db[RESULTS].find_one({"session_id": session_id})
33
- if existing:
34
  existing["id"] = str(existing["_id"])
35
  del existing["_id"]
36
  return _json_safe(existing)
@@ -51,6 +65,25 @@ async def generate_report(session_id: str, user_id: str) -> dict:
51
 
52
  # Get all Q&A from Redis
53
  qa_pairs = await get_session_qa(session_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  if not qa_pairs:
55
  raise ValueError("No Q&A data found for this session")
56
 
@@ -80,21 +113,42 @@ async def generate_report(session_id: str, user_id: str) -> dict:
80
  },
81
  "completed_at": utc_now(),
82
  }
83
- inserted = await db[RESULTS].insert_one(result_doc)
 
 
 
 
 
 
 
 
84
 
85
  # Store final answers in MongoDB
86
  for qa in qa_pairs:
87
- answer_doc = {
 
88
  "session_id": session_id,
89
  "user_id": user_id,
90
- "question_id": qa["question_id"],
91
- "question": qa["question"],
92
- "answer": qa["answer"],
93
- "difficulty": qa["difficulty"],
94
- "category": qa["category"],
95
- "stored_at": utc_now(),
96
  }
97
- await db[ANSWERS].insert_one(answer_doc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
  # Clean up Redis session data
100
  question_ids = await redis.lrange(f"session:{session_id}:questions", 0, -1)
@@ -133,5 +187,5 @@ async def generate_report(session_id: str, user_id: str) -> dict:
133
 
134
  cleanup_interview_local_state(session_id)
135
 
136
- result_doc["id"] = str(inserted.inserted_id)
137
  return _json_safe(result_doc)
 
23
  return default
24
 
25
 
26
+ def _is_placeholder_report(report: dict) -> bool:
27
+ strengths = [str(item).strip().lower() for item in (report.get("strengths") or []) if str(item).strip()]
28
+ weaknesses = [str(item).strip().lower() for item in (report.get("weaknesses") or []) if str(item).strip()]
29
+ recommendations = [str(item).strip().lower() for item in (report.get("recommendations") or []) if str(item).strip()]
30
+
31
+ if any("unable to evaluate" in item for item in strengths + weaknesses):
32
+ return True
33
+ if any("please retry the interview" in item for item in recommendations):
34
+ return True
35
+ if not (report.get("detailed_scores") or []):
36
+ return True
37
+ return False
38
+
39
+
40
  async def generate_report(session_id: str, user_id: str) -> dict:
41
  """Generate final evaluation report from Redis Q&A data using Gemini."""
42
  db = get_db()
 
44
 
45
  # Check if report already exists
46
  existing = await db[RESULTS].find_one({"session_id": session_id})
47
+ if existing and not _is_placeholder_report(existing):
48
  existing["id"] = str(existing["_id"])
49
  del existing["_id"]
50
  return _json_safe(existing)
 
65
 
66
  # Get all Q&A from Redis
67
  qa_pairs = await get_session_qa(session_id)
68
+ if not qa_pairs:
69
+ archived_answers = await db[ANSWERS].find(
70
+ {"session_id": session_id, "user_id": user_id}
71
+ ).sort("stored_at", 1).to_list(length=200)
72
+ for item in archived_answers:
73
+ question = (item.get("question") or "").strip()
74
+ answer = (item.get("answer") or "").strip()
75
+ if not question or not answer:
76
+ continue
77
+ qa_pairs.append(
78
+ {
79
+ "question_id": item.get("question_id") or "",
80
+ "question": question,
81
+ "answer": answer,
82
+ "difficulty": item.get("difficulty", "medium"),
83
+ "category": item.get("category", "general"),
84
+ }
85
+ )
86
+
87
  if not qa_pairs:
88
  raise ValueError("No Q&A data found for this session")
89
 
 
113
  },
114
  "completed_at": utc_now(),
115
  }
116
+ if existing:
117
+ await db[RESULTS].update_one(
118
+ {"_id": existing["_id"]},
119
+ {"$set": result_doc},
120
+ )
121
+ result_doc_id = str(existing["_id"])
122
+ else:
123
+ inserted = await db[RESULTS].insert_one(result_doc)
124
+ result_doc_id = str(inserted.inserted_id)
125
 
126
  # Store final answers in MongoDB
127
  for qa in qa_pairs:
128
+ question_id = (qa.get("question_id") or "").strip()
129
+ upsert_filter = {
130
  "session_id": session_id,
131
  "user_id": user_id,
 
 
 
 
 
 
132
  }
133
+ if question_id:
134
+ upsert_filter["question_id"] = question_id
135
+ else:
136
+ upsert_filter["question"] = qa.get("question", "")
137
+
138
+ await db[ANSWERS].update_one(
139
+ upsert_filter,
140
+ {
141
+ "$set": {
142
+ "question_id": question_id,
143
+ "question": qa.get("question", ""),
144
+ "answer": qa.get("answer", ""),
145
+ "difficulty": qa.get("difficulty", "medium"),
146
+ "category": qa.get("category", "general"),
147
+ "stored_at": utc_now(),
148
+ }
149
+ },
150
+ upsert=True,
151
+ )
152
 
153
  # Clean up Redis session data
154
  question_ids = await redis.lrange(f"session:{session_id}:questions", 0, -1)
 
187
 
188
  cleanup_interview_local_state(session_id)
189
 
190
+ result_doc["id"] = result_doc_id
191
  return _json_safe(result_doc)
backend/services/gemini_service.py CHANGED
@@ -1,5 +1,6 @@
1
  import json
2
  import re
 
3
 
4
  from utils.gemini import call_gemini
5
 
@@ -42,6 +43,30 @@ def _extract_json_array(text: str) -> str:
42
  return value
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def _fallback_score(answer: str) -> int:
46
  text = (answer or "").strip().lower()
47
  words = len(text.split())
@@ -91,6 +116,9 @@ Rules:
91
  2) Use resume context for relevance.
92
  3) Do not repeat or paraphrase excluded_questions.
93
  4) Keep questions concise and practical.
 
 
 
94
 
95
  Return ONLY valid JSON array with objects:
96
  - question (string)
@@ -99,16 +127,12 @@ Return ONLY valid JSON array with objects:
99
  """
100
 
101
  try:
102
- result = _extract_json_array(
103
- await call_gemini(
104
- prompt,
105
- max_attempts=1,
106
- request_timeout_seconds=3.5,
107
- )
108
  )
109
- data = json.loads(result)
110
- if not isinstance(data, list):
111
- raise ValueError("seed output is not a list")
112
 
113
  output = []
114
  for item in data[:count]:
@@ -124,15 +148,19 @@ Return ONLY valid JSON array with objects:
124
  return [q for q in output if q.get("question")]
125
  except Exception:
126
  base_skill = jd_required_skills[0] if jd_required_skills else (resume_skills[0] if resume_skills else "this role")
 
 
 
 
 
 
 
127
  fallback = []
128
  for i in range(count):
 
129
  fallback.append(
130
  {
131
- "question": (
132
- f"Explain your hands-on experience with {base_skill} in a project relevant to {role_title}."
133
- if i == 0
134
- else f"What trade-offs did you consider when working with {base_skill}?"
135
- ),
136
  "difficulty": "medium",
137
  "category": "resume-seed",
138
  }
@@ -147,6 +175,8 @@ async def evaluate_and_generate_followup(
147
  current_question: str,
148
  current_answer: str,
149
  excluded_questions: list[str],
 
 
150
  ) -> dict:
151
  payload = {
152
  "role_title": role_title,
@@ -155,6 +185,8 @@ async def evaluate_and_generate_followup(
155
  "current_question": current_question,
156
  "current_answer": current_answer,
157
  "excluded_questions": excluded_questions[-25:] if excluded_questions else [],
 
 
158
  }
159
 
160
  prompt = f"""You are a strict technical interviewer.
@@ -171,40 +203,60 @@ Rules:
171
  2) Use recent_context for continuity.
172
  3) Do not repeat/paraphrase excluded_questions.
173
  4) Score should reflect conceptual correctness, not verbosity.
 
 
 
 
174
 
175
  Return ONLY valid JSON object:
176
  {{
177
  "score": 0-100,
178
  "feedback": "short technical feedback",
179
  "followup_question": "...",
 
 
180
  "difficulty": "easy|medium|hard",
181
  "category": "..."
182
  }}
183
  """
184
 
185
  try:
186
- result = _extract_json_object(
187
- await call_gemini(
188
- prompt,
189
- max_attempts=1,
190
- request_timeout_seconds=2.8,
191
- )
192
  )
193
- data = json.loads(result)
194
  followup = (data.get("followup_question") or "").strip()
 
 
 
 
 
195
  return {
196
  "score": int(data.get("score", 0)),
197
  "feedback": (data.get("feedback") or "").strip() or "Answer reviewed.",
198
  "followup_question": followup,
 
 
199
  "difficulty": data.get("difficulty") if data.get("difficulty") in {"easy", "medium", "hard"} else "medium",
200
  "category": data.get("category") or "follow-up",
201
  }
202
  except Exception:
203
  fallback_skill = required_skills[0] if required_skills else "the selected role requirement"
 
 
 
 
 
 
 
204
  return {
205
  "score": _fallback_score(current_answer),
206
  "feedback": "Try to explain the mechanism, trade-offs, and one concrete example.",
207
- "followup_question": f"Can you walk me through a real scenario where you applied {fallback_skill} and what trade-offs you handled?",
 
 
208
  "difficulty": "medium",
209
  "category": "follow-up",
210
  }
@@ -242,16 +294,12 @@ Return ONLY valid JSON array with objects:
242
  """
243
 
244
  try:
245
- result = _extract_json_array(
246
- await call_gemini(
247
- prompt,
248
- max_attempts=1,
249
- request_timeout_seconds=3.5,
250
- )
251
  )
252
- data = json.loads(result)
253
- if not isinstance(data, list):
254
- raise ValueError("topic output is not a list")
255
 
256
  out = []
257
  for item in data[:count]:
 
1
  import json
2
  import re
3
+ import random
4
 
5
  from utils.gemini import call_gemini
6
 
 
43
  return value
44
 
45
 
46
+ def _parse_json_object_loose(text: str) -> dict:
47
+ value = _extract_json_object(text)
48
+ try:
49
+ parsed = json.loads(value)
50
+ except Exception:
51
+ cleaned = re.sub(r",\s*([}\]])", r"\1", value)
52
+ parsed = json.loads(cleaned)
53
+ if not isinstance(parsed, dict):
54
+ raise ValueError("Parsed payload is not a JSON object")
55
+ return parsed
56
+
57
+
58
+ def _parse_json_array_loose(text: str) -> list:
59
+ value = _extract_json_array(text)
60
+ try:
61
+ parsed = json.loads(value)
62
+ except Exception:
63
+ cleaned = re.sub(r",\s*([}\]])", r"\1", value)
64
+ parsed = json.loads(cleaned)
65
+ if not isinstance(parsed, list):
66
+ raise ValueError("Parsed payload is not a JSON array")
67
+ return parsed
68
+
69
+
70
  def _fallback_score(answer: str) -> int:
71
  text = (answer or "").strip().lower()
72
  words = len(text.split())
 
116
  2) Use resume context for relevance.
117
  3) Do not repeat or paraphrase excluded_questions.
118
  4) Keep questions concise and practical.
119
+ 5) Make the set diverse: use different styles (scenario, debugging, trade-off, implementation, testing).
120
+ 6) Do not prefix with numbering like "Question 1:".
121
+ 7) Avoid generic repeats like "Explain your hands-on experience" for every question.
122
 
123
  Return ONLY valid JSON array with objects:
124
  - question (string)
 
127
  """
128
 
129
  try:
130
+ result = await call_gemini(
131
+ prompt,
132
+ max_attempts=3,
133
+ request_timeout_seconds=20,
 
 
134
  )
135
+ data = _parse_json_array_loose(result)
 
 
136
 
137
  output = []
138
  for item in data[:count]:
 
148
  return [q for q in output if q.get("question")]
149
  except Exception:
150
  base_skill = jd_required_skills[0] if jd_required_skills else (resume_skills[0] if resume_skills else "this role")
151
+ fallback_templates = [
152
+ "In a project aligned with {role}, where did {skill} materially change your design decisions?",
153
+ "If your {skill} implementation regressed after deployment for {role}, how would you triage it?",
154
+ "What trade-offs did you make while using {skill} under real delivery constraints in {role}?",
155
+ "How did you test and validate a {skill}-based feature before production in {role}?",
156
+ "Describe one architecture decision around {skill} that improved reliability or scale for {role}.",
157
+ ]
158
  fallback = []
159
  for i in range(count):
160
+ template = fallback_templates[i % len(fallback_templates)]
161
  fallback.append(
162
  {
163
+ "question": template.format(skill=base_skill, role=role_title),
 
 
 
 
164
  "difficulty": "medium",
165
  "category": "resume-seed",
166
  }
 
175
  current_question: str,
176
  current_answer: str,
177
  excluded_questions: list[str],
178
+ focus_topic: str = "",
179
+ same_topic_streak: int = 0,
180
  ) -> dict:
181
  payload = {
182
  "role_title": role_title,
 
185
  "current_question": current_question,
186
  "current_answer": current_answer,
187
  "excluded_questions": excluded_questions[-25:] if excluded_questions else [],
188
+ "focus_topic": focus_topic,
189
+ "same_topic_streak": int(same_topic_streak or 0),
190
  }
191
 
192
  prompt = f"""You are a strict technical interviewer.
 
203
  2) Use recent_context for continuity.
204
  3) Do not repeat/paraphrase excluded_questions.
205
  4) Score should reflect conceptual correctness, not verbosity.
206
+ 5) If same_topic_streak is 2 or more, avoid another same-topic follow-up unless truly critical.
207
+ 6) Ask in realistic live-interview style (specific scenario, debugging, trade-off, design decision), not generic textbook phrasing.
208
+ 7) Do not prefix numbering like "Question 4:".
209
+ 8) Avoid repeating the previous follow-up wording pattern.
210
 
211
  Return ONLY valid JSON object:
212
  {{
213
  "score": 0-100,
214
  "feedback": "short technical feedback",
215
  "followup_question": "...",
216
+ "followup_topic": "specific required skill/topic for the follow-up",
217
+ "followup_need_score": 0-100,
218
  "difficulty": "easy|medium|hard",
219
  "category": "..."
220
  }}
221
  """
222
 
223
  try:
224
+ result = await call_gemini(
225
+ prompt,
226
+ max_attempts=3,
227
+ request_timeout_seconds=18,
 
 
228
  )
229
+ data = _parse_json_object_loose(result)
230
  followup = (data.get("followup_question") or "").strip()
231
+ try:
232
+ followup_need_score = int(data.get("followup_need_score", 70))
233
+ except Exception:
234
+ followup_need_score = 70
235
+ followup_need_score = max(0, min(100, followup_need_score))
236
  return {
237
  "score": int(data.get("score", 0)),
238
  "feedback": (data.get("feedback") or "").strip() or "Answer reviewed.",
239
  "followup_question": followup,
240
+ "followup_topic": (data.get("followup_topic") or "").strip(),
241
+ "followup_need_score": followup_need_score,
242
  "difficulty": data.get("difficulty") if data.get("difficulty") in {"easy", "medium", "hard"} else "medium",
243
  "category": data.get("category") or "follow-up",
244
  }
245
  except Exception:
246
  fallback_skill = required_skills[0] if required_skills else "the selected role requirement"
247
+ fallback_templates = [
248
+ "In a production system for {role}, describe a failure you would expect around {skill} and how you would debug it end-to-end.",
249
+ "Given a feature built with {skill}, what trade-offs would you make between speed, reliability, and maintainability in {role}?",
250
+ "How would you test and validate a {skill}-based implementation before release for {role}?",
251
+ "Walk through one real incident where {skill} decisions changed the final architecture for {role}.",
252
+ ]
253
+ template = random.choice(fallback_templates)
254
  return {
255
  "score": _fallback_score(current_answer),
256
  "feedback": "Try to explain the mechanism, trade-offs, and one concrete example.",
257
+ "followup_question": template.format(skill=fallback_skill, role=role_title),
258
+ "followup_topic": fallback_skill,
259
+ "followup_need_score": 70,
260
  "difficulty": "medium",
261
  "category": "follow-up",
262
  }
 
294
  """
295
 
296
  try:
297
+ result = await call_gemini(
298
+ prompt,
299
+ max_attempts=3,
300
+ request_timeout_seconds=20,
 
 
301
  )
302
+ data = _parse_json_array_loose(result)
 
 
303
 
304
  out = []
305
  for item in data[:count]:
backend/services/interview_service.py CHANGED
@@ -21,6 +21,7 @@ from services.queue_service import (
21
  flush_backlog_to_queue,
22
  get_recent_context_items,
23
  mark_question_asked,
 
24
  peek_next_question,
25
  pop_next_question,
26
  push_context_item,
@@ -45,11 +46,28 @@ TOPIC_INITIAL_ASK_COUNT = 4
45
  TOPIC_AI_FOLLOWUPS = 3
46
  TOPIC_DB_FOLLOWUPS = 2
47
  TOPIC_TOTAL_QUESTIONS = 10
 
 
48
 
49
  # Local process memory summary requested in workflow.
50
  _LOCAL_SUMMARIES: dict[str, str] = {}
51
  _PREGEN_IN_FLIGHT: set[str] = set()
52
  _POST_SUBMIT_LOCKS: dict[str, asyncio.Lock] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
 
55
  def _safe_json_list(value: str) -> list:
@@ -67,6 +85,47 @@ def _question_fingerprint(text: str) -> str:
67
  return base
68
 
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  def _unique_question_items(items: list[dict], *, excluded_questions: list[str], limit: int) -> list[dict]:
71
  excluded = {_question_fingerprint(q) for q in excluded_questions if q}
72
  unique: list[dict] = []
@@ -104,6 +163,15 @@ def _safe_int(value, default: int = 0) -> int:
104
  return default
105
 
106
 
 
 
 
 
 
 
 
 
 
107
  def _normalize_voice_gender(value: str | None) -> str:
108
  return "male" if (value or "").strip().lower() == "male" else "female"
109
 
@@ -164,6 +232,123 @@ def _normalize_bank_difficulty(value: str) -> str:
164
  return difficulty
165
 
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  def _avg_recent_answer_words(qa_pairs: list, window: int = 3) -> int:
168
  if not qa_pairs:
169
  return 0
@@ -235,32 +420,68 @@ async def _get_recent_user_questions(db, user_id: str, limit: int = 40) -> list[
235
 
236
 
237
  def _build_resume_intro_question(role_title: str, jd_title: str) -> str:
238
- title = (jd_title or "the selected job description").strip()
239
  role = (role_title or "this role").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  return (
241
- f"Introduce yourself and explain how your background aligns with {role} "
242
- f"for {title}."
243
  )
244
 
245
 
246
- def _build_resume_resilient_followup_question(session: dict, question_number: int, variant: int = 0) -> str:
 
 
 
 
 
247
  role_title = (session.get("role_title") or "this role").strip()
248
- jd_skills = _safe_json_list(session.get("jd_required_skills", "[]"))
249
- focus_skills = _safe_json_list(session.get("skills", "[]"))
250
- skill_pool = jd_skills or focus_skills or ["core technical concepts"]
251
 
252
  index = max(0, question_number - 1) + max(0, variant)
253
- skill = skill_pool[index % len(skill_pool)]
254
 
255
  templates = [
256
- "Question {n}: Describe a real project where you applied {skill} for {role}. What constraints and trade-offs shaped your design?",
257
- "Question {n}: If {skill} failed in production for a {role} workflow, how would you debug it step by step?",
258
- "Question {n}: Explain how you would test and validate a solution using {skill} before shipping it for {role}.",
259
- "Question {n}: Compare two approaches for {skill} in a {role} context and justify the final choice.",
260
- "Question {n}: Design an improvement plan to make your {skill} implementation more scalable and reliable for {role}.",
 
 
 
 
 
261
  ]
262
  template = templates[index % len(templates)]
263
- return template.format(n=question_number, skill=skill, role=role_title)
264
 
265
 
266
  def _build_topic_resilient_followup_question(session: dict, question_number: int, variant: int = 0) -> str:
@@ -268,14 +489,14 @@ def _build_topic_resilient_followup_question(session: dict, question_number: int
268
  index = max(0, question_number - 1) + max(0, variant)
269
 
270
  templates = [
271
- "Question {n}: Explain {topic} with a practical example from a production-like scenario.",
272
- "Question {n}: What are the most common failure patterns in {topic}, and how would you detect them early?",
273
- "Question {n}: Design a step-by-step implementation plan for {topic} with measurable checkpoints.",
274
- "Question {n}: Compare two approaches in {topic}, including trade-offs in scalability, latency, and maintainability.",
275
- "Question {n}: If a {topic} solution regressed after deployment, how would you triage and recover safely?",
276
  ]
277
  template = templates[index % len(templates)]
278
- return template.format(n=question_number, topic=topic_name)
279
 
280
 
281
  async def _enqueue_resume_followup_with_fallback(
@@ -287,8 +508,10 @@ async def _enqueue_resume_followup_with_fallback(
287
  suggested_text: str,
288
  suggested_difficulty: str,
289
  suggested_category: str,
 
290
  ) -> tuple[str | None, bool]:
291
  candidates: list[tuple[str, str, str, bool]] = []
 
292
 
293
  primary = (suggested_text or "").strip()
294
  if primary:
@@ -302,12 +525,17 @@ async def _enqueue_resume_followup_with_fallback(
302
  session=session,
303
  question_number=question_number,
304
  variant=variant,
 
305
  )
306
  candidates.append((fallback_text, "medium", "resume-fallback", False))
307
 
308
  seen: set[str] = set()
309
  for text, difficulty, category, is_primary in candidates:
310
- key = _question_fingerprint(text)
 
 
 
 
311
  if not key or key in seen:
312
  continue
313
  seen.add(key)
@@ -315,13 +543,14 @@ async def _enqueue_resume_followup_with_fallback(
315
  qid = await enqueue_question(
316
  redis=redis,
317
  session_id=session_id,
318
- question=text,
319
  difficulty=difficulty,
320
  category=category,
321
  ttl_seconds=SESSION_TTL,
322
  max_queue_size=MAX_QUEUE_SIZE,
323
  )
324
  if qid:
 
325
  return qid, is_primary
326
 
327
  return None, False
@@ -338,6 +567,22 @@ async def _get_session_question_texts(redis, session_id: str) -> list[str]:
338
  return output
339
 
340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  async def _sample_topic_questions(
342
  db,
343
  topic_id: str,
@@ -706,13 +951,16 @@ async def _generate_question_batch(
706
  async def _append_batch_to_redis(redis, session_id: str, batch: list[dict]) -> list[str]:
707
  created_ids: list[str] = []
708
  for item in batch:
 
 
 
709
  qid = generate_id()
710
  created_ids.append(qid)
711
  await redis.hset(
712
  f"session:{session_id}:q:{qid}",
713
  mapping={
714
  "question_id": qid,
715
- "question": item.get("question", "Can you explain your approach?"),
716
  "difficulty": item.get("difficulty", "medium"),
717
  "category": item.get("category", "general"),
718
  },
@@ -1054,7 +1302,7 @@ async def _start_topic_interview(user_id: str, topic_id: str) -> dict:
1054
  f"session:{session_id}:q:{first_id}",
1055
  mapping={
1056
  "question_id": first_id,
1057
- "question": first_question.get("question", "Can you explain this topic?"),
1058
  "difficulty": first_question.get("difficulty", "medium"),
1059
  "category": first_question.get("category", topic.get("name", "topic")),
1060
  },
@@ -1166,7 +1414,7 @@ async def _start_topic_interview(user_id: str, topic_id: str) -> dict:
1166
  },
1167
  "question": {
1168
  "question_id": first_id,
1169
- "question": first_question.get("question", "Can you explain this topic?"),
1170
  "difficulty": first_question.get("difficulty", "medium"),
1171
  "question_number": 1,
1172
  "total_questions": TOPIC_TOTAL_QUESTIONS,
@@ -1316,6 +1564,7 @@ async def start_interview(
1316
  skills_for_interview = build_interview_focus_skills(base_skills_for_interview) or list(jd_required_skills)
1317
 
1318
  intro_question = _build_resume_intro_question(role_title=role_title, jd_title=selected_jd.get("title", ""))
 
1319
 
1320
  session_id = generate_id()
1321
  _LOCAL_SUMMARIES[session_id] = ""
@@ -1497,6 +1746,17 @@ async def _post_submit_resume_processing(
1497
  if not session:
1498
  return
1499
 
 
 
 
 
 
 
 
 
 
 
 
1500
  recent_context = await get_recent_context_items(
1501
  redis=redis,
1502
  session_id=session_id,
@@ -1510,6 +1770,8 @@ async def _post_submit_resume_processing(
1510
  current_question=question_text,
1511
  current_answer=answer,
1512
  excluded_questions=excluded_questions,
 
 
1513
  )
1514
 
1515
  await redis.hset(
@@ -1529,7 +1791,16 @@ async def _post_submit_resume_processing(
1529
  }
1530
  generated_count = _safe_int(session.get("generated_count", 0))
1531
 
1532
- follow_text = (evaluation.get("followup_question") or "").strip()
 
 
 
 
 
 
 
 
 
1533
  if answered_count < max_questions and session.get("status") == "in_progress":
1534
  qid, used_model_followup = await _enqueue_resume_followup_with_fallback(
1535
  redis=redis,
@@ -1539,6 +1810,7 @@ async def _post_submit_resume_processing(
1539
  suggested_text=follow_text,
1540
  suggested_difficulty=evaluation.get("difficulty", "medium"),
1541
  suggested_category=evaluation.get("category", "follow-up"),
 
1542
  )
1543
  if qid:
1544
  generated_count += 1
@@ -1775,6 +2047,9 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
1775
  mapping={
1776
  "question_id": question_id,
1777
  "answer": answer,
 
 
 
1778
  "submitted_at": utc_now(),
1779
  },
1780
  )
@@ -1782,6 +2057,24 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
1782
  await redis.expire(f"session:{session_id}:a:{question_id}", SESSION_TTL)
1783
  await redis.expire(f"session:{session_id}:answers", SESSION_TTL)
1784
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1785
  question_count = _safe_int(session.get("question_count", 1))
1786
  answered_count = _safe_int(session.get("answered_count", 0)) + 1
1787
  served_count = _safe_int(session.get("served_count", 1))
@@ -1853,6 +2146,17 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
1853
 
1854
  # Emergency fallback for rare queue-empty cases.
1855
  if not next_question_id and interview_type == "resume":
 
 
 
 
 
 
 
 
 
 
 
1856
  recent_context = await get_recent_context_items(
1857
  redis=redis,
1858
  session_id=session_id,
@@ -1866,6 +2170,8 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
1866
  current_question=current_question_text,
1867
  current_answer=answer,
1868
  excluded_questions=excluded_questions,
 
 
1869
  )
1870
 
1871
  await redis.hset(
@@ -1883,7 +2189,16 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
1883
  "bank_shortfall": 0,
1884
  "generation_batches": 1,
1885
  }
1886
- follow_text = (fallback_evaluation.get("followup_question") or "").strip()
 
 
 
 
 
 
 
 
 
1887
  if answered_count < max_questions:
1888
  qid, used_model_followup = await _enqueue_resume_followup_with_fallback(
1889
  redis=redis,
@@ -1893,6 +2208,7 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
1893
  suggested_text=follow_text,
1894
  suggested_difficulty=fallback_evaluation.get("difficulty", "medium"),
1895
  suggested_category=fallback_evaluation.get("category", "follow-up"),
 
1896
  )
1897
  if qid:
1898
  generated_count += 1
@@ -2143,9 +2459,33 @@ async def get_session_qa(session_id: str) -> list:
2143
  """Get all Q&A pairs from Redis for a session."""
2144
  redis = get_redis()
2145
 
2146
- question_ids = await redis.lrange(f"session:{session_id}:questions", 0, -1)
2147
  qa_pairs = []
2148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2149
  for qid in question_ids:
2150
  q = await redis.hgetall(f"session:{session_id}:q:{qid}")
2151
  a = await redis.hgetall(f"session:{session_id}:a:{qid}")
 
21
  flush_backlog_to_queue,
22
  get_recent_context_items,
23
  mark_question_asked,
24
+ normalize_question_text,
25
  peek_next_question,
26
  pop_next_question,
27
  push_context_item,
 
46
  TOPIC_AI_FOLLOWUPS = 3
47
  TOPIC_DB_FOLLOWUPS = 2
48
  TOPIC_TOTAL_QUESTIONS = 10
49
+ MAX_SAME_TOPIC_FOLLOWUPS = 2
50
+ THIRD_FOLLOWUP_NEED_SCORE = 95
51
 
52
  # Local process memory summary requested in workflow.
53
  _LOCAL_SUMMARIES: dict[str, str] = {}
54
  _PREGEN_IN_FLIGHT: set[str] = set()
55
  _POST_SUBMIT_LOCKS: dict[str, asyncio.Lock] = {}
56
+ _QUESTION_STOPWORDS = {
57
+ "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "how", "if", "in", "into",
58
+ "is", "it", "of", "on", "or", "that", "the", "this", "to", "using", "what", "when", "with", "would",
59
+ }
60
+ _GENERIC_SOFT_SKILL_KEYS = {
61
+ "problem solving",
62
+ "analytical skills",
63
+ "communication",
64
+ "communication skills",
65
+ "teamwork",
66
+ "leadership",
67
+ "adaptability",
68
+ "time management",
69
+ "critical thinking",
70
+ }
71
 
72
 
73
  def _safe_json_list(value: str) -> list:
 
85
  return base
86
 
87
 
88
+ def _question_token_set(text: str) -> set[str]:
89
+ key = _question_fingerprint(text)
90
+ tokens = [token for token in key.split() if token and token not in _QUESTION_STOPWORDS]
91
+ return set(tokens)
92
+
93
+
94
+ def _is_question_too_similar(candidate: str, recent_questions: list[str]) -> bool:
95
+ candidate_key = _question_fingerprint(candidate)
96
+ if not candidate_key:
97
+ return True
98
+
99
+ candidate_tokens = _question_token_set(candidate)
100
+ candidate_opening = " ".join(candidate_key.split()[:6])
101
+
102
+ for text in (recent_questions or [])[-5:]:
103
+ other_key = _question_fingerprint(text)
104
+ if not other_key:
105
+ continue
106
+ if candidate_key == other_key:
107
+ return True
108
+
109
+ other_opening = " ".join(other_key.split()[:6])
110
+ if candidate_opening and candidate_opening == other_opening:
111
+ return True
112
+
113
+ other_tokens = _question_token_set(text)
114
+ if not candidate_tokens or not other_tokens:
115
+ continue
116
+
117
+ intersection = len(candidate_tokens & other_tokens)
118
+ union = len(candidate_tokens | other_tokens)
119
+ if union <= 0:
120
+ continue
121
+
122
+ jaccard = intersection / union
123
+ if jaccard >= 0.72:
124
+ return True
125
+
126
+ return False
127
+
128
+
129
  def _unique_question_items(items: list[dict], *, excluded_questions: list[str], limit: int) -> list[dict]:
130
  excluded = {_question_fingerprint(q) for q in excluded_questions if q}
131
  unique: list[dict] = []
 
163
  return default
164
 
165
 
166
+ def _safe_score_0_100(value, default: int = 0) -> int:
167
+ score = _safe_int(value, default)
168
+ if score < 0:
169
+ return 0
170
+ if score > 100:
171
+ return 100
172
+ return score
173
+
174
+
175
  def _normalize_voice_gender(value: str | None) -> str:
176
  return "male" if (value or "").strip().lower() == "male" else "female"
177
 
 
232
  return difficulty
233
 
234
 
235
+ def _resume_skill_pool(session: dict) -> list[str]:
236
+ jd_skills = normalize_skill_list(_safe_json_list(session.get("jd_required_skills", "[]")))
237
+ focus_skills = normalize_skill_list(_safe_json_list(session.get("skills", "[]")))
238
+
239
+ ordered: list[str] = []
240
+ seen: set[str] = set()
241
+ for skill in jd_skills + focus_skills:
242
+ key = _question_fingerprint(skill)
243
+ if not key or key in seen:
244
+ continue
245
+ seen.add(key)
246
+ ordered.append(skill)
247
+
248
+ concrete = [skill for skill in ordered if _question_fingerprint(skill) not in _GENERIC_SOFT_SKILL_KEYS]
249
+ if len(concrete) >= 2:
250
+ return concrete
251
+
252
+ return ordered or ["core technical concepts"]
253
+
254
+
255
+ def _infer_focus_skill_from_question(question_text: str, skill_pool: list[str]) -> str | None:
256
+ normalized_question = _question_fingerprint(question_text)
257
+ if not normalized_question:
258
+ return None
259
+
260
+ best_skill = None
261
+ best_score = 0
262
+
263
+ for skill in skill_pool:
264
+ normalized_skill = _question_fingerprint(skill)
265
+ if not normalized_skill:
266
+ continue
267
+
268
+ tokens = [token for token in normalized_skill.split() if len(token) >= 3]
269
+ if not tokens:
270
+ tokens = normalized_skill.split()
271
+
272
+ score = sum(1 for token in tokens if token and token in normalized_question)
273
+ if normalized_skill in normalized_question:
274
+ score = max(score, len(tokens) + 1)
275
+
276
+ if score > best_score:
277
+ best_score = score
278
+ best_skill = skill
279
+
280
+ return best_skill if best_score > 0 else None
281
+
282
+
283
+ def _recent_focus_streak(question_texts: list[str], skill_pool: list[str]) -> tuple[str | None, int]:
284
+ active_skill = None
285
+ streak = 0
286
+
287
+ for text in reversed(question_texts):
288
+ skill = _infer_focus_skill_from_question(text, skill_pool)
289
+ if not skill:
290
+ break
291
+
292
+ if active_skill is None:
293
+ active_skill = skill
294
+ streak = 1
295
+ continue
296
+
297
+ if _question_fingerprint(skill) == _question_fingerprint(active_skill):
298
+ streak += 1
299
+ continue
300
+
301
+ break
302
+
303
+ return active_skill, streak
304
+
305
+
306
+ def _pick_alternate_focus_skill(skill_pool: list[str], current_skill: str | None, seed: int) -> str | None:
307
+ if not skill_pool:
308
+ return None
309
+
310
+ if current_skill:
311
+ current_key = _question_fingerprint(current_skill)
312
+ alternatives = [skill for skill in skill_pool if _question_fingerprint(skill) != current_key]
313
+ if alternatives:
314
+ return alternatives[max(0, seed) % len(alternatives)]
315
+
316
+ return skill_pool[max(0, seed) % len(skill_pool)]
317
+
318
+
319
+ def _apply_resume_followup_policy(
320
+ *,
321
+ skill_pool: list[str],
322
+ recent_focus_topic: str | None,
323
+ same_topic_streak: int,
324
+ suggested_question: str,
325
+ suggested_topic: str | None,
326
+ followup_need_score: int,
327
+ answered_count: int,
328
+ ) -> tuple[str, str | None]:
329
+ follow_text = (suggested_question or "").strip()
330
+ topic = (suggested_topic or "").strip()
331
+
332
+ if not topic and follow_text:
333
+ inferred = _infer_focus_skill_from_question(follow_text, skill_pool)
334
+ if inferred:
335
+ topic = inferred
336
+
337
+ topic_key = _question_fingerprint(topic)
338
+ recent_key = _question_fingerprint(recent_focus_topic or "")
339
+
340
+ if (
341
+ same_topic_streak >= MAX_SAME_TOPIC_FOLLOWUPS
342
+ and topic_key
343
+ and recent_key
344
+ and topic_key == recent_key
345
+ and _safe_score_0_100(followup_need_score) < THIRD_FOLLOWUP_NEED_SCORE
346
+ ):
347
+ return "", _pick_alternate_focus_skill(skill_pool, recent_focus_topic, answered_count)
348
+
349
+ return follow_text, None
350
+
351
+
352
  def _avg_recent_answer_words(qa_pairs: list, window: int = 3) -> int:
353
  if not qa_pairs:
354
  return 0
 
420
 
421
 
422
  def _build_resume_intro_question(role_title: str, jd_title: str) -> str:
 
423
  role = (role_title or "this role").strip()
424
+ title = (jd_title or "").strip()
425
+
426
+ def _normalized_key(value: str) -> str:
427
+ key = re.sub(r"[^a-z0-9\s]", " ", (value or "").lower())
428
+ key = re.sub(r"\s+", " ", key).strip()
429
+ for prefix in ("the ", "an ", "a "):
430
+ if key.startswith(prefix):
431
+ key = key[len(prefix):].strip()
432
+ break
433
+ return key
434
+
435
+ role_clean = re.sub(r"\s+", " ", role).strip()
436
+ if role_clean.lower().startswith("the "):
437
+ role_clean = role_clean[4:].strip()
438
+ role_phrase = f"the {role_clean}" if role_clean.lower().endswith(" role") else f"the {role_clean} role"
439
+
440
+ role_key = _normalized_key(role_clean)
441
+ title_key = _normalized_key(title)
442
+ is_generic_title = title_key in {
443
+ "",
444
+ "selected job description",
445
+ role_key,
446
+ f"{role_key} role",
447
+ }
448
+
449
+ if is_generic_title:
450
+ return f"Introduce yourself and explain how your background aligns with {role_phrase}."
451
+
452
+ title_phrase = title if title.lower().startswith(("the ", "an ", "a ")) else f"the {title}"
453
  return (
454
+ f"Introduce yourself and explain how your background aligns with {role_phrase} "
455
+ f"in {title_phrase} job description."
456
  )
457
 
458
 
459
+ def _build_resume_resilient_followup_question(
460
+ session: dict,
461
+ question_number: int,
462
+ variant: int = 0,
463
+ focus_skill: str | None = None,
464
+ ) -> str:
465
  role_title = (session.get("role_title") or "this role").strip()
466
+ skill_pool = _resume_skill_pool(session)
 
 
467
 
468
  index = max(0, question_number - 1) + max(0, variant)
469
+ skill = (focus_skill or "").strip() or skill_pool[index % len(skill_pool)]
470
 
471
  templates = [
472
+ "Describe a real project where you applied {skill} for {role}. What constraints and trade-offs shaped your design?",
473
+ "If {skill} failed in production for a {role} workflow, how would you debug it step by step?",
474
+ "Explain how you would test and validate a solution using {skill} before shipping it for {role}.",
475
+ "Compare two approaches for {skill} in a {role} context and justify the final choice.",
476
+ "Design an improvement plan to make your {skill} implementation more scalable and reliable for {role}.",
477
+ "Your {role} service using {skill} has intermittent latency spikes. How would you investigate and stabilize it?",
478
+ "During code review, what risks would you look for in a {skill} implementation for {role}, and why?",
479
+ "How would you design rollback and observability for a feature centered on {skill} in {role}?",
480
+ "Assume two engineers propose different {skill} strategies for {role}. How would you evaluate and choose between them?",
481
+ "What failure modes around {skill} are easiest to miss in {role}, and how would you proactively test them?",
482
  ]
483
  template = templates[index % len(templates)]
484
+ return template.format(skill=skill, role=role_title)
485
 
486
 
487
  def _build_topic_resilient_followup_question(session: dict, question_number: int, variant: int = 0) -> str:
 
489
  index = max(0, question_number - 1) + max(0, variant)
490
 
491
  templates = [
492
+ "Explain {topic} with a practical example from a production-like scenario.",
493
+ "What are the most common failure patterns in {topic}, and how would you detect them early?",
494
+ "Design a step-by-step implementation plan for {topic} with measurable checkpoints.",
495
+ "Compare two approaches in {topic}, including trade-offs in scalability, latency, and maintainability.",
496
+ "If a {topic} solution regressed after deployment, how would you triage and recover safely?",
497
  ]
498
  template = templates[index % len(templates)]
499
+ return template.format(topic=topic_name)
500
 
501
 
502
  async def _enqueue_resume_followup_with_fallback(
 
508
  suggested_text: str,
509
  suggested_difficulty: str,
510
  suggested_category: str,
511
+ focus_skill_override: str | None = None,
512
  ) -> tuple[str | None, bool]:
513
  candidates: list[tuple[str, str, str, bool]] = []
514
+ existing_questions = await _get_session_question_texts(redis, session_id)
515
 
516
  primary = (suggested_text or "").strip()
517
  if primary:
 
525
  session=session,
526
  question_number=question_number,
527
  variant=variant,
528
+ focus_skill=focus_skill_override,
529
  )
530
  candidates.append((fallback_text, "medium", "resume-fallback", False))
531
 
532
  seen: set[str] = set()
533
  for text, difficulty, category, is_primary in candidates:
534
+ normalized_text = normalize_question_text(text)
535
+ if _is_question_too_similar(normalized_text, existing_questions):
536
+ continue
537
+
538
+ key = _question_fingerprint(normalized_text)
539
  if not key or key in seen:
540
  continue
541
  seen.add(key)
 
543
  qid = await enqueue_question(
544
  redis=redis,
545
  session_id=session_id,
546
+ question=normalized_text,
547
  difficulty=difficulty,
548
  category=category,
549
  ttl_seconds=SESSION_TTL,
550
  max_queue_size=MAX_QUEUE_SIZE,
551
  )
552
  if qid:
553
+ existing_questions.append(normalized_text)
554
  return qid, is_primary
555
 
556
  return None, False
 
567
  return output
568
 
569
 
570
+ async def _get_answered_question_texts(redis, session_id: str, limit: int = 4) -> list[str]:
571
+ answer_ids = await redis.lrange(f"session:{session_id}:answers", -max(1, limit), -1)
572
+ output: list[str] = []
573
+
574
+ for qid in answer_ids:
575
+ answer_data = await redis.hgetall(f"session:{session_id}:a:{qid}")
576
+ text = (answer_data.get("question") or "").strip()
577
+ if not text:
578
+ q = await redis.hgetall(f"session:{session_id}:q:{qid}")
579
+ text = (q.get("question") or "").strip()
580
+ if text:
581
+ output.append(text)
582
+
583
+ return output
584
+
585
+
586
  async def _sample_topic_questions(
587
  db,
588
  topic_id: str,
 
951
  async def _append_batch_to_redis(redis, session_id: str, batch: list[dict]) -> list[str]:
952
  created_ids: list[str] = []
953
  for item in batch:
954
+ normalized_question = normalize_question_text(item.get("question", "Can you explain your approach?"))
955
+ if not normalized_question:
956
+ continue
957
  qid = generate_id()
958
  created_ids.append(qid)
959
  await redis.hset(
960
  f"session:{session_id}:q:{qid}",
961
  mapping={
962
  "question_id": qid,
963
+ "question": normalized_question,
964
  "difficulty": item.get("difficulty", "medium"),
965
  "category": item.get("category", "general"),
966
  },
 
1302
  f"session:{session_id}:q:{first_id}",
1303
  mapping={
1304
  "question_id": first_id,
1305
+ "question": normalize_question_text(first_question.get("question", "Can you explain this topic?")),
1306
  "difficulty": first_question.get("difficulty", "medium"),
1307
  "category": first_question.get("category", topic.get("name", "topic")),
1308
  },
 
1414
  },
1415
  "question": {
1416
  "question_id": first_id,
1417
+ "question": normalize_question_text(first_question.get("question", "Can you explain this topic?")),
1418
  "difficulty": first_question.get("difficulty", "medium"),
1419
  "question_number": 1,
1420
  "total_questions": TOPIC_TOTAL_QUESTIONS,
 
1564
  skills_for_interview = build_interview_focus_skills(base_skills_for_interview) or list(jd_required_skills)
1565
 
1566
  intro_question = _build_resume_intro_question(role_title=role_title, jd_title=selected_jd.get("title", ""))
1567
+ intro_question = normalize_question_text(intro_question)
1568
 
1569
  session_id = generate_id()
1570
  _LOCAL_SUMMARIES[session_id] = ""
 
1746
  if not session:
1747
  return
1748
 
1749
+ skill_pool = _resume_skill_pool(session)
1750
+ recent_answered_questions = await _get_answered_question_texts(
1751
+ redis=redis,
1752
+ session_id=session_id,
1753
+ limit=4,
1754
+ )
1755
+ recent_focus_topic, same_topic_streak = _recent_focus_streak(
1756
+ recent_answered_questions,
1757
+ skill_pool,
1758
+ )
1759
+
1760
  recent_context = await get_recent_context_items(
1761
  redis=redis,
1762
  session_id=session_id,
 
1770
  current_question=question_text,
1771
  current_answer=answer,
1772
  excluded_questions=excluded_questions,
1773
+ focus_topic=recent_focus_topic or "",
1774
+ same_topic_streak=same_topic_streak,
1775
  )
1776
 
1777
  await redis.hset(
 
1791
  }
1792
  generated_count = _safe_int(session.get("generated_count", 0))
1793
 
1794
+ follow_text, focus_skill_override = _apply_resume_followup_policy(
1795
+ skill_pool=skill_pool,
1796
+ recent_focus_topic=recent_focus_topic,
1797
+ same_topic_streak=same_topic_streak,
1798
+ suggested_question=(evaluation.get("followup_question") or "").strip(),
1799
+ suggested_topic=(evaluation.get("followup_topic") or "").strip(),
1800
+ followup_need_score=_safe_score_0_100(evaluation.get("followup_need_score", 0)),
1801
+ answered_count=answered_count,
1802
+ )
1803
+
1804
  if answered_count < max_questions and session.get("status") == "in_progress":
1805
  qid, used_model_followup = await _enqueue_resume_followup_with_fallback(
1806
  redis=redis,
 
1810
  suggested_text=follow_text,
1811
  suggested_difficulty=evaluation.get("difficulty", "medium"),
1812
  suggested_category=evaluation.get("category", "follow-up"),
1813
+ focus_skill_override=focus_skill_override,
1814
  )
1815
  if qid:
1816
  generated_count += 1
 
2047
  mapping={
2048
  "question_id": question_id,
2049
  "answer": answer,
2050
+ "question": current_question_text,
2051
+ "difficulty": current_q.get("difficulty", "medium"),
2052
+ "category": current_q.get("category", "general"),
2053
  "submitted_at": utc_now(),
2054
  },
2055
  )
 
2057
  await redis.expire(f"session:{session_id}:a:{question_id}", SESSION_TTL)
2058
  await redis.expire(f"session:{session_id}:answers", SESSION_TTL)
2059
 
2060
+ await db[ANSWERS].update_one(
2061
+ {
2062
+ "session_id": session_id,
2063
+ "question_id": question_id,
2064
+ "user_id": session.get("user_id"),
2065
+ },
2066
+ {
2067
+ "$set": {
2068
+ "question": current_question_text,
2069
+ "answer": answer,
2070
+ "difficulty": current_q.get("difficulty", "medium"),
2071
+ "category": current_q.get("category", "general"),
2072
+ "stored_at": utc_now(),
2073
+ }
2074
+ },
2075
+ upsert=True,
2076
+ )
2077
+
2078
  question_count = _safe_int(session.get("question_count", 1))
2079
  answered_count = _safe_int(session.get("answered_count", 0)) + 1
2080
  served_count = _safe_int(session.get("served_count", 1))
 
2146
 
2147
  # Emergency fallback for rare queue-empty cases.
2148
  if not next_question_id and interview_type == "resume":
2149
+ skill_pool = _resume_skill_pool(session)
2150
+ recent_answered_questions = await _get_answered_question_texts(
2151
+ redis=redis,
2152
+ session_id=session_id,
2153
+ limit=4,
2154
+ )
2155
+ recent_focus_topic, same_topic_streak = _recent_focus_streak(
2156
+ recent_answered_questions,
2157
+ skill_pool,
2158
+ )
2159
+
2160
  recent_context = await get_recent_context_items(
2161
  redis=redis,
2162
  session_id=session_id,
 
2170
  current_question=current_question_text,
2171
  current_answer=answer,
2172
  excluded_questions=excluded_questions,
2173
+ focus_topic=recent_focus_topic or "",
2174
+ same_topic_streak=same_topic_streak,
2175
  )
2176
 
2177
  await redis.hset(
 
2189
  "bank_shortfall": 0,
2190
  "generation_batches": 1,
2191
  }
2192
+ follow_text, focus_skill_override = _apply_resume_followup_policy(
2193
+ skill_pool=skill_pool,
2194
+ recent_focus_topic=recent_focus_topic,
2195
+ same_topic_streak=same_topic_streak,
2196
+ suggested_question=(fallback_evaluation.get("followup_question") or "").strip(),
2197
+ suggested_topic=(fallback_evaluation.get("followup_topic") or "").strip(),
2198
+ followup_need_score=_safe_score_0_100(fallback_evaluation.get("followup_need_score", 0)),
2199
+ answered_count=answered_count,
2200
+ )
2201
+
2202
  if answered_count < max_questions:
2203
  qid, used_model_followup = await _enqueue_resume_followup_with_fallback(
2204
  redis=redis,
 
2208
  suggested_text=follow_text,
2209
  suggested_difficulty=fallback_evaluation.get("difficulty", "medium"),
2210
  suggested_category=fallback_evaluation.get("category", "follow-up"),
2211
+ focus_skill_override=focus_skill_override,
2212
  )
2213
  if qid:
2214
  generated_count += 1
 
2459
  """Get all Q&A pairs from Redis for a session."""
2460
  redis = get_redis()
2461
 
2462
+ answer_ids = await redis.lrange(f"session:{session_id}:answers", 0, -1)
2463
  qa_pairs = []
2464
 
2465
+ if answer_ids:
2466
+ for qid in answer_ids:
2467
+ q = await redis.hgetall(f"session:{session_id}:q:{qid}")
2468
+ a = await redis.hgetall(f"session:{session_id}:a:{qid}")
2469
+ if not a:
2470
+ continue
2471
+
2472
+ question_text = (a.get("question") or q.get("question") or "").strip()
2473
+ answer_text = (a.get("answer") or "").strip()
2474
+ if not question_text or not answer_text:
2475
+ continue
2476
+
2477
+ qa_pairs.append({
2478
+ "question_id": qid,
2479
+ "question": question_text,
2480
+ "answer": answer_text,
2481
+ "difficulty": a.get("difficulty") or q.get("difficulty", "medium"),
2482
+ "category": a.get("category") or q.get("category", "general"),
2483
+ })
2484
+
2485
+ if qa_pairs:
2486
+ return qa_pairs
2487
+
2488
+ question_ids = await redis.lrange(f"session:{session_id}:questions", 0, -1)
2489
  for qid in question_ids:
2490
  q = await redis.hgetall(f"session:{session_id}:q:{qid}")
2491
  a = await redis.hgetall(f"session:{session_id}:a:{qid}")
backend/services/queue_service.py CHANGED
@@ -9,14 +9,32 @@ QUESTION_QUEUE_SUFFIX = "question_queue"
9
  QUESTION_BACKLOG_SUFFIX = "question_backlog"
10
  CONTEXT_CACHE_SUFFIX = "context_cache"
11
  ASKED_SET_SUFFIX = "asked_questions_set"
 
 
 
 
12
 
13
 
14
  def _key(session_id: str, suffix: str) -> str:
15
  return f"session:{session_id}:{suffix}"
16
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  def question_fingerprint(text: str) -> str:
19
- value = (text or "").strip().lower()
20
  value = re.sub(r"[^a-z0-9\s]", " ", value)
21
  value = re.sub(r"\s+", " ", value).strip()
22
  return value
@@ -61,6 +79,7 @@ async def _append_question_object(
61
  category: str,
62
  ttl_seconds: int,
63
  ) -> str:
 
64
  qid = generate_id()
65
  q_key = f"session:{session_id}:q:{qid}"
66
 
@@ -68,7 +87,7 @@ async def _append_question_object(
68
  q_key,
69
  mapping={
70
  "question_id": qid,
71
- "question": question,
72
  "difficulty": difficulty or "medium",
73
  "category": category or "general",
74
  },
@@ -90,7 +109,7 @@ async def enqueue_question(
90
  ttl_seconds: int = 7200,
91
  max_queue_size: int = 3,
92
  ) -> Optional[str]:
93
- text = (question or "").strip()
94
  if not text:
95
  return None
96
 
 
9
  QUESTION_BACKLOG_SUFFIX = "question_backlog"
10
  CONTEXT_CACHE_SUFFIX = "context_cache"
11
  ASKED_SET_SUFFIX = "asked_questions_set"
12
+ QUESTION_PREFIX_RE = re.compile(
13
+ r"^\s*(?:question|q)\s*#?\s*\d+(?:\s*of\s*\d+)?\s*[\:\-\)\.]\s*",
14
+ re.IGNORECASE,
15
+ )
16
 
17
 
18
  def _key(session_id: str, suffix: str) -> str:
19
  return f"session:{session_id}:{suffix}"
20
 
21
 
22
+ def normalize_question_text(text: str) -> str:
23
+ value = (text or "").strip()
24
+ if not value:
25
+ return ""
26
+
27
+ while True:
28
+ updated = QUESTION_PREFIX_RE.sub("", value).strip()
29
+ if updated == value:
30
+ break
31
+ value = updated
32
+
33
+ return value
34
+
35
+
36
  def question_fingerprint(text: str) -> str:
37
+ value = normalize_question_text(text).lower()
38
  value = re.sub(r"[^a-z0-9\s]", " ", value)
39
  value = re.sub(r"\s+", " ", value).strip()
40
  return value
 
79
  category: str,
80
  ttl_seconds: int,
81
  ) -> str:
82
+ normalized_question = normalize_question_text(question)
83
  qid = generate_id()
84
  q_key = f"session:{session_id}:q:{qid}"
85
 
 
87
  q_key,
88
  mapping={
89
  "question_id": qid,
90
+ "question": normalized_question,
91
  "difficulty": difficulty or "medium",
92
  "category": category or "general",
93
  },
 
109
  ttl_seconds: int = 7200,
110
  max_queue_size: int = 3,
111
  ) -> Optional[str]:
112
+ text = normalize_question_text(question)
113
  if not text:
114
  return None
115
 
backend/utils/gemini.py CHANGED
@@ -14,6 +14,29 @@ settings = get_settings()
14
  client = genai.Client(api_key=settings.GEMINI_API_KEY)
15
 
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  def _is_transient_gemini_error(error: Exception) -> bool:
18
  message = str(error or "").lower()
19
  transient_markers = [
@@ -28,6 +51,26 @@ def _is_transient_gemini_error(error: Exception) -> bool:
28
  return any(marker in message for marker in transient_markers)
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  async def call_gemini(
32
  prompt: str,
33
  system_instruction: str = None,
@@ -43,34 +86,51 @@ async def call_gemini(
43
  config["response_mime_type"] = "application/json"
44
 
45
  last_error = None
 
46
 
47
  attempts = max(1, int(max_attempts or 1))
48
  for attempt in range(attempts):
49
- try:
50
- def _invoke():
51
- return client.models.generate_content(
52
- model=settings.GEMINI_MODEL,
53
- contents=prompt,
54
- config=config if config else None,
55
- )
56
-
57
- if request_timeout_seconds and request_timeout_seconds > 0:
58
- response = await asyncio.wait_for(
59
- asyncio.to_thread(_invoke),
60
- timeout=request_timeout_seconds,
61
- )
62
- else:
63
- response = await asyncio.to_thread(_invoke)
64
-
65
- elapsed_ms = (perf_counter() - started_at) * 1000.0
66
- await record_latency("gemini_ms", elapsed_ms)
67
- return (response.text or "").strip()
68
- except Exception as exc:
69
- last_error = exc
70
- if _is_transient_gemini_error(exc) and attempt < attempts - 1:
71
- await asyncio.sleep(0.8 * (attempt + 1))
72
- continue
73
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  elapsed_ms = (perf_counter() - started_at) * 1000.0
76
  await record_latency("gemini_ms", elapsed_ms)
@@ -705,53 +765,158 @@ No markdown, no extra text."""
705
 
706
  async def evaluate_interview(questions_and_answers: list, role_title: str) -> dict:
707
  """Batch evaluate all interview Q&A pairs using Gemini."""
708
- qa_text = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
709
  for i, qa in enumerate(questions_and_answers, 1):
710
- qa_text += f"\nQ{i}: {qa['question']}\nA{i}: {qa['answer']}\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
711
 
712
  prompt_template = PromptTemplate.from_template(
713
- """You are a strict technical interviewer evaluating a candidate for the role: {role_title}.
714
-
715
- Here are the interview questions and the candidate's answers:
716
- {qa_text}
717
-
718
- Scoring policy (concept-first, strict):
719
- 1. Score primarily on conceptual correctness, depth, and reasoning quality.
720
- 2. Do NOT reward answer length, confidence, or communication style when concepts are wrong.
721
- 3. Penalize vague, hand-wavy, or uncertain answers.
722
- 4. Penalize technically incorrect claims even if explanation sounds fluent.
723
- 5. Reward precise mechanisms, trade-offs, edge cases, and debugging logic.
724
-
725
- Score rubric per answer:
726
- - 90-100: conceptually correct, deep, and accurate with strong reasoning
727
- - 70-89: mostly correct with minor conceptual gaps
728
- - 50-69: partially correct but misses key mechanisms
729
- - 30-49: shallow/vague with major conceptual gaps
730
- - 0-29: incorrect or off-topic
731
-
732
- Return a JSON object with:
733
- - "overall_score": integer from 0-100
734
- - "detailed_scores": list of objects, each with:
735
- - "question": the question text
736
- - "answer": the answer text
737
- - "score": integer 0-100
738
- - "feedback": concise concept-focused feedback for this answer
739
- - "strengths": list of 3-5 strength areas
740
- - "weaknesses": list of 3-5 concept gaps
741
- - "recommendations": list of 3-5 actionable concept-improvement recommendations
742
 
743
- Return ONLY valid JSON, no markdown formatting."""
744
- )
745
- prompt = prompt_template.format(role_title=role_title, qa_text=qa_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
746
 
 
747
  try:
748
- result = _extract_json_object(await call_gemini(prompt))
749
- return json.loads(result)
 
 
 
 
 
 
750
  except Exception:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
  return {
752
- "overall_score": 50,
753
- "detailed_scores": [],
754
- "strengths": ["Unable to evaluate"],
755
- "weaknesses": ["Unable to evaluate"],
756
- "recommendations": ["Please retry the interview"],
757
  }
 
 
 
 
 
 
 
 
 
 
14
  client = genai.Client(api_key=settings.GEMINI_API_KEY)
15
 
16
 
17
+ def _extract_response_text(response) -> str:
18
+ text = (getattr(response, "text", None) or "").strip()
19
+ if text:
20
+ return text
21
+
22
+ try:
23
+ candidates = getattr(response, "candidates", None) or []
24
+ for candidate in candidates:
25
+ content = getattr(candidate, "content", None)
26
+ parts = getattr(content, "parts", None) or []
27
+ gathered = []
28
+ for part in parts:
29
+ part_text = getattr(part, "text", None)
30
+ if isinstance(part_text, str) and part_text.strip():
31
+ gathered.append(part_text.strip())
32
+ if gathered:
33
+ return "\n".join(gathered).strip()
34
+ except Exception:
35
+ return ""
36
+
37
+ return ""
38
+
39
+
40
  def _is_transient_gemini_error(error: Exception) -> bool:
41
  message = str(error or "").lower()
42
  transient_markers = [
 
51
  return any(marker in message for marker in transient_markers)
52
 
53
 
54
+ def _candidate_gemini_models() -> list[str]:
55
+ configured = [
56
+ item.strip()
57
+ for item in (getattr(settings, "GEMINI_FALLBACK_MODELS", "") or "").split(",")
58
+ if item and item.strip()
59
+ ]
60
+ defaults = ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-flash-latest"]
61
+
62
+ ordered = [settings.GEMINI_MODEL, *configured, *defaults]
63
+ seen: set[str] = set()
64
+ unique: list[str] = []
65
+ for model in ordered:
66
+ key = (model or "").strip()
67
+ if not key or key in seen:
68
+ continue
69
+ seen.add(key)
70
+ unique.append(key)
71
+ return unique
72
+
73
+
74
  async def call_gemini(
75
  prompt: str,
76
  system_instruction: str = None,
 
86
  config["response_mime_type"] = "application/json"
87
 
88
  last_error = None
89
+ model_candidates = _candidate_gemini_models()
90
 
91
  attempts = max(1, int(max_attempts or 1))
92
  for attempt in range(attempts):
93
+ for model_name in model_candidates:
94
+ try:
95
+ def _invoke():
96
+ return client.models.generate_content(
97
+ model=model_name,
98
+ contents=prompt,
99
+ config=config if config else None,
100
+ )
101
+
102
+ if request_timeout_seconds and request_timeout_seconds > 0:
103
+ response = await asyncio.wait_for(
104
+ asyncio.to_thread(_invoke),
105
+ timeout=request_timeout_seconds,
106
+ )
107
+ else:
108
+ response = await asyncio.to_thread(_invoke)
109
+
110
+ response_text = _extract_response_text(response)
111
+ if not response_text:
112
+ raise RuntimeError("Gemini returned an empty response")
113
+
114
+ elapsed_ms = (perf_counter() - started_at) * 1000.0
115
+ await record_latency("gemini_ms", elapsed_ms)
116
+ return response_text
117
+ except Exception as exc:
118
+ last_error = exc
119
+ # Try next model candidate immediately on transient/unavailable errors.
120
+ if _is_transient_gemini_error(exc):
121
+ continue
122
+
123
+ # Model-not-found style errors should try the next candidate too.
124
+ message = str(exc or "").lower()
125
+ if "not found" in message or "unsupported" in message:
126
+ continue
127
+
128
+ break
129
+
130
+ if _is_transient_gemini_error(last_error) and attempt < attempts - 1:
131
+ await asyncio.sleep(0.8 * (attempt + 1))
132
+ continue
133
+ break
134
 
135
  elapsed_ms = (perf_counter() - started_at) * 1000.0
136
  await record_latency("gemini_ms", elapsed_ms)
 
765
 
766
  async def evaluate_interview(questions_and_answers: list, role_title: str) -> dict:
767
  """Batch evaluate all interview Q&A pairs using Gemini."""
768
+
769
+ def _clamp_score(value, default: int = 50) -> int:
770
+ try:
771
+ score = int(value)
772
+ except Exception:
773
+ score = default
774
+ return max(0, min(100, score))
775
+
776
+ def _fallback_item_score(answer: str) -> int:
777
+ text = (answer or "").strip().lower()
778
+ words = len(text.split())
779
+ if words < 10:
780
+ return 35
781
+ if words < 25:
782
+ return 52
783
+ if any(marker in text for marker in ["not sure", "maybe", "i think", "dont know", "don't know"]):
784
+ return 50
785
+ if words > 90:
786
+ return 74
787
+ return 64
788
+
789
+ if not questions_and_answers:
790
+ return {
791
+ "overall_score": 50,
792
+ "detailed_scores": [],
793
+ "strengths": ["No answers were available for evaluation"],
794
+ "weaknesses": ["No answers were available for evaluation"],
795
+ "recommendations": ["Complete the interview and generate report again"],
796
+ }
797
+
798
+ compact_qa = []
799
  for i, qa in enumerate(questions_and_answers, 1):
800
+ question = (qa.get("question") or "").strip()
801
+ answer = (qa.get("answer") or "").strip()
802
+ compact_qa.append(
803
+ {
804
+ "index": i,
805
+ "question": question[:260],
806
+ "answer": answer[:520],
807
+ }
808
+ )
809
+
810
+ payload = {
811
+ "role_title": role_title,
812
+ "question_count": len(compact_qa),
813
+ "qa": compact_qa,
814
+ }
815
 
816
  prompt_template = PromptTemplate.from_template(
817
+ """You are a strict technical interviewer evaluating a candidate for role: {role_title}.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
818
 
819
+ Input JSON:
820
+ {payload}
821
+
822
+ Scoring policy:
823
+ 1) Score conceptual correctness and depth, not verbosity.
824
+ 2) Penalize vague, uncertain, or incorrect technical claims.
825
+ 3) Reward concrete reasoning, trade-offs, and debugging clarity.
826
+
827
+ Return ONLY valid JSON object with this exact schema:
828
+ {{
829
+ "overall_score": 0-100 integer,
830
+ "per_question": [
831
+ {{"index": 1-based integer, "score": 0-100 integer, "feedback": "short concept-focused feedback"}}
832
+ ],
833
+ "strengths": ["3 to 5 concise points"],
834
+ "weaknesses": ["3 to 5 concise points"],
835
+ "recommendations": ["3 to 5 actionable points"]
836
+ }}
837
+
838
+ Rules:
839
+ - per_question must include every question index from 1..question_count exactly once.
840
+ - Do NOT echo full question or answer text in output.
841
+ - Keep each feedback under 220 characters.
842
+ """
843
+ )
844
+ prompt = prompt_template.format(
845
+ role_title=role_title,
846
+ payload=json.dumps(payload, ensure_ascii=True),
847
+ )
848
 
849
+ parsed = None
850
  try:
851
+ result = _extract_json_object(
852
+ await call_gemini(
853
+ prompt,
854
+ max_attempts=3,
855
+ request_timeout_seconds=45,
856
+ )
857
+ )
858
+ parsed = json.loads(result)
859
  except Exception:
860
+ parsed = None
861
+
862
+ score_map: dict[int, tuple[int, str]] = {}
863
+ if isinstance(parsed, dict):
864
+ for item in parsed.get("per_question", []) or []:
865
+ if not isinstance(item, dict):
866
+ continue
867
+ idx = item.get("index")
868
+ try:
869
+ index = int(idx)
870
+ except Exception:
871
+ continue
872
+ if index < 1 or index > len(questions_and_answers):
873
+ continue
874
+ score = _clamp_score(item.get("score"), _fallback_item_score(questions_and_answers[index - 1].get("answer", "")))
875
+ feedback = (item.get("feedback") or "").strip() or "Answer reviewed with focus on conceptual correctness."
876
+ score_map[index] = (score, feedback)
877
+
878
+ detailed_scores = []
879
+ for index, qa in enumerate(questions_and_answers, 1):
880
+ fallback_score = _fallback_item_score(qa.get("answer", ""))
881
+ score, feedback = score_map.get(
882
+ index,
883
+ (fallback_score, "Could not derive detailed AI feedback for this answer; score based on response quality signals."),
884
+ )
885
+ detailed_scores.append(
886
+ {
887
+ "question": qa.get("question", ""),
888
+ "answer": qa.get("answer", ""),
889
+ "score": score,
890
+ "feedback": feedback,
891
+ }
892
+ )
893
+
894
+ if isinstance(parsed, dict):
895
+ overall_score = _clamp_score(parsed.get("overall_score"), int(round(sum(item["score"] for item in detailed_scores) / max(1, len(detailed_scores)))))
896
+ strengths = [str(s).strip() for s in (parsed.get("strengths") or []) if str(s).strip()][:5]
897
+ weaknesses = [str(w).strip() for w in (parsed.get("weaknesses") or []) if str(w).strip()][:5]
898
+ recommendations = [str(r).strip() for r in (parsed.get("recommendations") or []) if str(r).strip()][:5]
899
+
900
+ if not strengths:
901
+ strengths = ["Shows baseline understanding in parts of the discussion"]
902
+ if not weaknesses:
903
+ weaknesses = ["Needs deeper concept-level reasoning and sharper technical precision"]
904
+ if not recommendations:
905
+ recommendations = ["Practice answering with mechanisms, trade-offs, and one concrete production example per question"]
906
+
907
  return {
908
+ "overall_score": overall_score,
909
+ "detailed_scores": detailed_scores,
910
+ "strengths": strengths,
911
+ "weaknesses": weaknesses,
912
+ "recommendations": recommendations,
913
  }
914
+
915
+ fallback_overall = int(round(sum(item["score"] for item in detailed_scores) / max(1, len(detailed_scores))))
916
+ return {
917
+ "overall_score": _clamp_score(fallback_overall, 50),
918
+ "detailed_scores": detailed_scores,
919
+ "strengths": ["Attempted responses for all interview prompts"],
920
+ "weaknesses": ["Detailed AI evaluation was unavailable for this run"],
921
+ "recommendations": ["Retry report generation to get full AI feedback"],
922
+ }