sajith-0701 commited on
Commit
a85389f
·
1 Parent(s): 5094515
backend/services/admin_service.py CHANGED
@@ -541,12 +541,17 @@ async def list_admin_reports(limit: int = 100) -> list:
541
  "completed_at": report.get("completed_at", ""),
542
  "session_status": report.get("session_status", "completed"),
543
  "is_quit": bool(report.get("is_quit", False)),
 
 
 
 
 
 
 
544
  }
545
  )
546
 
547
  return output
548
-
549
-
550
  async def get_admin_report_detail(session_id: str) -> dict:
551
  """Get full interview result detail for admin view."""
552
  db = get_db()
 
541
  "completed_at": report.get("completed_at", ""),
542
  "session_status": report.get("session_status", "completed"),
543
  "is_quit": bool(report.get("is_quit", False)),
544
+ "generation_stats": {
545
+ "gemini_calls": int((report.get("generation_stats") or {}).get("gemini_calls", 0) or 0),
546
+ "gemini_questions": int((report.get("generation_stats") or {}).get("gemini_questions", 0) or 0),
547
+ "bank_questions": int((report.get("generation_stats") or {}).get("bank_questions", 0) or 0),
548
+ "bank_shortfall": int((report.get("generation_stats") or {}).get("bank_shortfall", 0) or 0),
549
+ "generation_batches": int((report.get("generation_stats") or {}).get("generation_batches", 0) or 0),
550
+ },
551
  }
552
  )
553
 
554
  return output
 
 
555
  async def get_admin_report_detail(session_id: str) -> dict:
556
  """Get full interview result detail for admin view."""
557
  db = get_db()
backend/services/evaluation_service.py CHANGED
@@ -16,6 +16,13 @@ def _json_safe(value):
16
  return value
17
 
18
 
 
 
 
 
 
 
 
19
  async def generate_report(session_id: str, user_id: str) -> dict:
20
  """Generate final evaluation report from Redis Q&A data using Gemini."""
21
  db = get_db()
@@ -40,6 +47,8 @@ async def generate_report(session_id: str, user_id: str) -> dict:
40
  session_status = session.get("status", "completed")
41
  quit_at = session.get("quit_at")
42
 
 
 
43
  # Get all Q&A from Redis
44
  qa_pairs = await get_session_qa(session_id)
45
  if not qa_pairs:
@@ -62,6 +71,13 @@ async def generate_report(session_id: str, user_id: str) -> dict:
62
  "strengths": evaluation.get("strengths", []),
63
  "weaknesses": evaluation.get("weaknesses", []),
64
  "recommendations": evaluation.get("recommendations", []),
 
 
 
 
 
 
 
65
  "completed_at": utc_now(),
66
  }
67
  inserted = await db[RESULTS].insert_one(result_doc)
 
16
  return value
17
 
18
 
19
+ def _safe_int(value, default: int = 0) -> int:
20
+ try:
21
+ return int(value)
22
+ except Exception:
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()
 
47
  session_status = session.get("status", "completed")
48
  quit_at = session.get("quit_at")
49
 
50
+ redis_session = await redis.hgetall(f"session:{session_id}")
51
+
52
  # Get all Q&A from Redis
53
  qa_pairs = await get_session_qa(session_id)
54
  if not qa_pairs:
 
71
  "strengths": evaluation.get("strengths", []),
72
  "weaknesses": evaluation.get("weaknesses", []),
73
  "recommendations": evaluation.get("recommendations", []),
74
+ "generation_stats": {
75
+ "gemini_calls": _safe_int((redis_session or {}).get("metrics_gemini_calls", 0)),
76
+ "gemini_questions": _safe_int((redis_session or {}).get("metrics_gemini_questions", 0)),
77
+ "bank_questions": _safe_int((redis_session or {}).get("metrics_bank_questions", 0)),
78
+ "bank_shortfall": _safe_int((redis_session or {}).get("metrics_bank_shortfall", 0)),
79
+ "generation_batches": _safe_int((redis_session or {}).get("metrics_generation_batches", 0)),
80
+ },
81
  "completed_at": utc_now(),
82
  }
83
  inserted = await db[RESULTS].insert_one(result_doc)
backend/services/interview_service.py CHANGED
@@ -1,15 +1,19 @@
1
  import json
2
  import asyncio
 
3
  from database import get_db, get_redis
4
  from models.collections import SESSIONS, JOB_ROLES, SKILLS, QUESTIONS, TOPICS, TOPIC_QUESTIONS, ROLE_REQUIREMENTS
5
  from utils.helpers import generate_id, utc_now, str_objectid
6
  from utils.skills import normalize_skill_list, find_matching_skills, find_missing_skills, build_interview_focus_skills
7
  from services.interview_graph import run_interview_graph
 
8
 
9
- MAX_QUESTIONS = 10
10
  SESSION_TTL = 7200 # 2 hours
11
  BATCH_SIZE = 5
12
  PREGEN_MIN_PENDING = 2
 
 
13
 
14
  # Local process memory summary requested in workflow.
15
  _LOCAL_SUMMARIES: dict[str, str] = {}
@@ -31,6 +35,51 @@ def _update_local_summary(session_id: str, question: str, answer: str) -> None:
31
  _LOCAL_SUMMARIES[session_id] = combined[-1500:]
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  async def _get_generated_question_texts(redis, session_id: str) -> list[str]:
35
  qids = await redis.lrange(f"session:{session_id}:questions", 0, -1)
36
  questions = []
@@ -56,6 +105,19 @@ async def _generate_question_batch(
56
  if target <= 0:
57
  return [], current_difficulty
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  generated: list[dict] = []
60
  rolling_questions = list(previous_questions)
61
  rolling_difficulty = current_difficulty
@@ -110,6 +172,129 @@ async def _append_batch_to_redis(redis, session_id: str, batch: list[dict]) -> l
110
  return created_ids
111
 
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  async def _start_topic_interview(user_id: str, topic_id: str) -> dict:
114
  """Start a topic-wise interview with admin-created questions."""
115
  db = get_db()
@@ -145,6 +330,11 @@ async def _start_topic_interview(user_id: str, topic_id: str) -> dict:
145
  "question_count": 1,
146
  "max_questions": total_questions,
147
  "current_difficulty": selected[0].get("difficulty", "medium"),
 
 
 
 
 
148
  "timer_enabled": timer_enabled,
149
  "timer_seconds": timer_seconds,
150
  "started_at": utc_now(),
@@ -170,6 +360,11 @@ async def _start_topic_interview(user_id: str, topic_id: str) -> dict:
170
  "timer_enabled": str(timer_enabled),
171
  "timer_seconds": str(timer_seconds or ""),
172
  "status": "in_progress",
 
 
 
 
 
173
  }
174
  await redis.hset(f"session:{session_id}", mapping=session_state)
175
  await redis.expire(f"session:{session_id}", SESSION_TTL)
@@ -338,16 +533,6 @@ async def start_interview(
338
  if not skills_for_interview:
339
  skills_for_interview = ["general"]
340
 
341
- # Check for existing questions in question bank
342
- bank_questions = []
343
- if role_id and not custom_role:
344
- try:
345
- cursor = db[QUESTIONS].find({"role_id": role_id}).limit(5)
346
- async for q in cursor:
347
- bank_questions.append(q["question"])
348
- except Exception:
349
- pass
350
-
351
  # Workflow: generate first batch upfront, store in Redis, serve Q1.
352
  initial_batch, last_difficulty = await _generate_question_batch(
353
  role_title=role_title,
@@ -376,6 +561,11 @@ async def start_interview(
376
  "question_count": 1,
377
  "max_questions": MAX_QUESTIONS,
378
  "current_difficulty": initial_batch[0].get("difficulty", "medium"),
 
 
 
 
 
379
  "started_at": utc_now(),
380
  }
381
  await db[SESSIONS].insert_one(session_doc)
@@ -383,6 +573,7 @@ async def start_interview(
383
  # Store session state in Redis
384
  session_state = {
385
  "user_id": user_id,
 
386
  "role_title": role_title,
387
  "skills": json.dumps(skills_for_interview),
388
  "user_skills": json.dumps(user_skills),
@@ -397,6 +588,11 @@ async def start_interview(
397
  "current_difficulty": last_difficulty,
398
  "interview_type": "resume",
399
  "status": "in_progress",
 
 
 
 
 
400
  }
401
  await redis.hset(f"session:{session_id}", mapping=session_state)
402
  await redis.expire(f"session:{session_id}", SESSION_TTL)
@@ -489,6 +685,13 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
489
 
490
  # Serve from pending queue first.
491
  next_question_id = await redis.lpop(f"session:{session_id}:pending_questions")
 
 
 
 
 
 
 
492
 
493
  # If queue is empty, generate only for resume interviews.
494
  if not next_question_id:
@@ -508,19 +711,13 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
508
  "message": "Interview complete! Generating your report...",
509
  }
510
 
511
- previous_questions = await _get_generated_question_texts(redis, session_id)
512
- skills = _safe_json_list(session.get("skills", "[]"))
513
- role_title = session.get("role_title", "Software Developer")
514
-
515
- sync_batch, last_difficulty = await _generate_question_batch(
516
- role_title=role_title,
517
- skills=skills,
518
- previous_questions=previous_questions,
519
  generated_count=generated_count,
520
  max_questions=max_questions,
521
- current_difficulty=session.get("current_difficulty", "medium"),
522
- local_summary=_LOCAL_SUMMARIES.get(session_id),
523
- batch_size=BATCH_SIZE,
524
  )
525
  new_ids = await _append_batch_to_redis(redis, session_id, sync_batch)
526
  generated_count += len(new_ids)
@@ -534,8 +731,39 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
534
  mapping={
535
  "generated_count": str(generated_count),
536
  "current_difficulty": last_difficulty,
 
 
 
 
 
537
  },
538
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
 
540
  if not next_question_id:
541
  raise ValueError("Unable to fetch or generate next question")
@@ -553,8 +781,13 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
553
  "current_difficulty": next_difficulty,
554
  })
555
 
556
- if interview_type == "resume":
557
- _schedule_pregen(session_id, answered_count)
 
 
 
 
 
558
 
559
  return {
560
  "session_id": session_id,
@@ -567,6 +800,7 @@ async def submit_answer(session_id: str, question_id: str, answer: str) -> dict:
567
  },
568
  "is_complete": False,
569
  "message": f"Question {new_served_count} of {max_questions}",
 
570
  }
571
 
572
 
 
1
  import json
2
  import asyncio
3
+ from bson import ObjectId
4
  from database import get_db, get_redis
5
  from models.collections import SESSIONS, JOB_ROLES, SKILLS, QUESTIONS, TOPICS, TOPIC_QUESTIONS, ROLE_REQUIREMENTS
6
  from utils.helpers import generate_id, utc_now, str_objectid
7
  from utils.skills import normalize_skill_list, find_matching_skills, find_missing_skills, build_interview_focus_skills
8
  from services.interview_graph import run_interview_graph
9
+ from utils.gemini import generate_interview_question_batch
10
 
11
+ MAX_QUESTIONS = 20
12
  SESSION_TTL = 7200 # 2 hours
13
  BATCH_SIZE = 5
14
  PREGEN_MIN_PENDING = 2
15
+ FOLLOWUP_AI_COUNT = 3
16
+ FOLLOWUP_BANK_COUNT = 2
17
 
18
  # Local process memory summary requested in workflow.
19
  _LOCAL_SUMMARIES: dict[str, str] = {}
 
35
  _LOCAL_SUMMARIES[session_id] = combined[-1500:]
36
 
37
 
38
+ def _safe_int(value, default: int = 0) -> int:
39
+ try:
40
+ return int(value)
41
+ except Exception:
42
+ return default
43
+
44
+
45
+ def _avg_recent_answer_words(qa_pairs: list, window: int = 3) -> int:
46
+ if not qa_pairs:
47
+ return 0
48
+ recent = qa_pairs[-window:]
49
+ lengths = [len((item.get("answer") or "").split()) for item in recent]
50
+ if not lengths:
51
+ return 0
52
+ return sum(lengths) // len(lengths)
53
+
54
+
55
+ def _plan_followup_mix(target: int, qa_pairs: list, has_bank_source: bool) -> tuple[int, int]:
56
+ """Decide AI-vs-bank split for the next batch.
57
+
58
+ Baseline: 3 AI + 2 bank. Adaptation:
59
+ - Short answers -> increase bank ratio for stability.
60
+ - Rich answers -> increase AI follow-up ratio for personalization.
61
+ """
62
+ if target <= 0:
63
+ return 0, 0
64
+ if not has_bank_source:
65
+ return target, 0
66
+
67
+ avg_words = _avg_recent_answer_words(qa_pairs)
68
+
69
+ ai_target = min(FOLLOWUP_AI_COUNT, target)
70
+ if avg_words < 18:
71
+ ai_target = min(2, target)
72
+ elif avg_words > 70:
73
+ ai_target = min(4, target)
74
+
75
+ # Keep at least one bank question when a bank source exists and batch size allows.
76
+ if target > 1:
77
+ ai_target = min(ai_target, target - 1)
78
+
79
+ bank_target = target - ai_target
80
+ return ai_target, bank_target
81
+
82
+
83
  async def _get_generated_question_texts(redis, session_id: str) -> list[str]:
84
  qids = await redis.lrange(f"session:{session_id}:questions", 0, -1)
85
  questions = []
 
105
  if target <= 0:
106
  return [], current_difficulty
107
 
108
+ # Initial resume seed: generate the full first batch in one Gemini call.
109
+ if generated_count == 0 and target > 1 and not local_summary:
110
+ seeded = await generate_interview_question_batch(
111
+ skills=skills,
112
+ role_title=role_title,
113
+ count=target,
114
+ start_question_number=1,
115
+ previous_questions=previous_questions,
116
+ )
117
+ if seeded:
118
+ last = seeded[-1].get("difficulty", current_difficulty)
119
+ return seeded, last
120
+
121
  generated: list[dict] = []
122
  rolling_questions = list(previous_questions)
123
  rolling_difficulty = current_difficulty
 
172
  return created_ids
173
 
174
 
175
+ async def _fetch_question_bank_batch(
176
+ db,
177
+ role_id: str | None,
178
+ excluded_questions: list[str],
179
+ limit: int,
180
+ ) -> list[dict]:
181
+ if not role_id or limit <= 0:
182
+ return []
183
+
184
+ role_candidates = [role_id]
185
+ try:
186
+ oid = ObjectId(role_id)
187
+ role_candidates.append(str(oid))
188
+ role_candidates.append(oid)
189
+ except Exception:
190
+ pass
191
+
192
+ query = {"role_id": {"$in": role_candidates}}
193
+
194
+ excluded = {q.strip().lower() for q in excluded_questions if q}
195
+ cursor = db[QUESTIONS].find(query).limit(200)
196
+ selected: list[dict] = []
197
+
198
+ async for q in cursor:
199
+ text = (q.get("question") or "").strip()
200
+ if not text:
201
+ continue
202
+ if text.lower() in excluded:
203
+ continue
204
+ selected.append(
205
+ {
206
+ "question": text,
207
+ "difficulty": (q.get("difficulty") or "medium").lower(),
208
+ "category": q.get("category") or "question-bank",
209
+ }
210
+ )
211
+ excluded.add(text.lower())
212
+ if len(selected) >= limit:
213
+ break
214
+
215
+ return selected
216
+
217
+
218
+ async def _generate_mixed_followup_batch(
219
+ db,
220
+ redis,
221
+ session_id: str,
222
+ session: dict,
223
+ generated_count: int,
224
+ max_questions: int,
225
+ ) -> tuple[list[dict], str, dict]:
226
+ remaining = max(0, max_questions - generated_count)
227
+ target = min(BATCH_SIZE, remaining)
228
+ if target <= 0:
229
+ return [], session.get("current_difficulty", "medium"), {
230
+ "gemini_calls": 0,
231
+ "gemini_questions": 0,
232
+ "bank_questions": 0,
233
+ "bank_shortfall": 0,
234
+ }
235
+
236
+ previous_questions = await _get_generated_question_texts(redis, session_id)
237
+ qa_pairs = await get_session_qa(session_id)
238
+ role_title = session.get("role_title", "Software Developer")
239
+ skills = _safe_json_list(session.get("skills", "[]"))
240
+ current_difficulty = session.get("current_difficulty", "medium")
241
+
242
+ ai_target, bank_target = _plan_followup_mix(
243
+ target=target,
244
+ qa_pairs=qa_pairs,
245
+ has_bank_source=bool(session.get("role_id")),
246
+ )
247
+
248
+ from utils.gemini import generate_followup_question_batch_from_qa
249
+
250
+ gemini_calls = 0
251
+ gemini_questions = 0
252
+
253
+ ai_items = await generate_followup_question_batch_from_qa(
254
+ role_title=role_title,
255
+ skills=skills,
256
+ qa_pairs=qa_pairs,
257
+ previous_questions=previous_questions,
258
+ count=ai_target,
259
+ difficulty=current_difficulty,
260
+ )
261
+ if ai_target > 0:
262
+ gemini_calls += 1
263
+ gemini_questions += len(ai_items)
264
+
265
+ exclude_pool = list(previous_questions) + [i.get("question", "") for i in ai_items]
266
+ bank_items = await _fetch_question_bank_batch(
267
+ db=db,
268
+ role_id=session.get("role_id"),
269
+ excluded_questions=exclude_pool,
270
+ limit=bank_target,
271
+ )
272
+
273
+ if len(bank_items) < bank_target:
274
+ refill = bank_target - len(bank_items)
275
+ refill_ai = await generate_followup_question_batch_from_qa(
276
+ role_title=role_title,
277
+ skills=skills,
278
+ qa_pairs=qa_pairs,
279
+ previous_questions=exclude_pool + [i.get("question", "") for i in bank_items],
280
+ count=refill,
281
+ difficulty=current_difficulty,
282
+ )
283
+ ai_items.extend(refill_ai)
284
+ if refill > 0:
285
+ gemini_calls += 1
286
+ gemini_questions += len(refill_ai)
287
+
288
+ mixed = (ai_items + bank_items)[:target]
289
+ last_difficulty = mixed[-1].get("difficulty", current_difficulty) if mixed else current_difficulty
290
+ return mixed, last_difficulty, {
291
+ "gemini_calls": gemini_calls,
292
+ "gemini_questions": gemini_questions,
293
+ "bank_questions": len(bank_items),
294
+ "bank_shortfall": max(0, bank_target - len(bank_items)),
295
+ }
296
+
297
+
298
  async def _start_topic_interview(user_id: str, topic_id: str) -> dict:
299
  """Start a topic-wise interview with admin-created questions."""
300
  db = get_db()
 
330
  "question_count": 1,
331
  "max_questions": total_questions,
332
  "current_difficulty": selected[0].get("difficulty", "medium"),
333
+ "metrics_gemini_calls": 0,
334
+ "metrics_gemini_questions": 0,
335
+ "metrics_bank_questions": 0,
336
+ "metrics_bank_shortfall": 0,
337
+ "metrics_generation_batches": 0,
338
  "timer_enabled": timer_enabled,
339
  "timer_seconds": timer_seconds,
340
  "started_at": utc_now(),
 
360
  "timer_enabled": str(timer_enabled),
361
  "timer_seconds": str(timer_seconds or ""),
362
  "status": "in_progress",
363
+ "metrics_gemini_calls": 0,
364
+ "metrics_gemini_questions": 0,
365
+ "metrics_bank_questions": 0,
366
+ "metrics_bank_shortfall": 0,
367
+ "metrics_generation_batches": 0,
368
  }
369
  await redis.hset(f"session:{session_id}", mapping=session_state)
370
  await redis.expire(f"session:{session_id}", SESSION_TTL)
 
533
  if not skills_for_interview:
534
  skills_for_interview = ["general"]
535
 
 
 
 
 
 
 
 
 
 
 
536
  # Workflow: generate first batch upfront, store in Redis, serve Q1.
537
  initial_batch, last_difficulty = await _generate_question_batch(
538
  role_title=role_title,
 
561
  "question_count": 1,
562
  "max_questions": MAX_QUESTIONS,
563
  "current_difficulty": initial_batch[0].get("difficulty", "medium"),
564
+ "metrics_gemini_calls": 1,
565
+ "metrics_gemini_questions": len(initial_batch),
566
+ "metrics_bank_questions": 0,
567
+ "metrics_bank_shortfall": 0,
568
+ "metrics_generation_batches": 1,
569
  "started_at": utc_now(),
570
  }
571
  await db[SESSIONS].insert_one(session_doc)
 
573
  # Store session state in Redis
574
  session_state = {
575
  "user_id": user_id,
576
+ "role_id": role_id or "",
577
  "role_title": role_title,
578
  "skills": json.dumps(skills_for_interview),
579
  "user_skills": json.dumps(user_skills),
 
588
  "current_difficulty": last_difficulty,
589
  "interview_type": "resume",
590
  "status": "in_progress",
591
+ "metrics_gemini_calls": 1,
592
+ "metrics_gemini_questions": len(initial_batch),
593
+ "metrics_bank_questions": 0,
594
+ "metrics_bank_shortfall": 0,
595
+ "metrics_generation_batches": 1,
596
  }
597
  await redis.hset(f"session:{session_id}", mapping=session_state)
598
  await redis.expire(f"session:{session_id}", SESSION_TTL)
 
685
 
686
  # Serve from pending queue first.
687
  next_question_id = await redis.lpop(f"session:{session_id}:pending_questions")
688
+ metrics_delta = {
689
+ "gemini_calls": 0,
690
+ "gemini_questions": 0,
691
+ "bank_questions": 0,
692
+ "bank_shortfall": 0,
693
+ "generation_batches": 0,
694
+ }
695
 
696
  # If queue is empty, generate only for resume interviews.
697
  if not next_question_id:
 
711
  "message": "Interview complete! Generating your report...",
712
  }
713
 
714
+ sync_batch, last_difficulty, batch_metrics = await _generate_mixed_followup_batch(
715
+ db=db,
716
+ redis=redis,
717
+ session_id=session_id,
718
+ session=session,
 
 
 
719
  generated_count=generated_count,
720
  max_questions=max_questions,
 
 
 
721
  )
722
  new_ids = await _append_batch_to_redis(redis, session_id, sync_batch)
723
  generated_count += len(new_ids)
 
731
  mapping={
732
  "generated_count": str(generated_count),
733
  "current_difficulty": last_difficulty,
734
+ "metrics_gemini_calls": str(_safe_int(session.get("metrics_gemini_calls", 0)) + batch_metrics.get("gemini_calls", 0)),
735
+ "metrics_gemini_questions": str(_safe_int(session.get("metrics_gemini_questions", 0)) + batch_metrics.get("gemini_questions", 0)),
736
+ "metrics_bank_questions": str(_safe_int(session.get("metrics_bank_questions", 0)) + batch_metrics.get("bank_questions", 0)),
737
+ "metrics_bank_shortfall": str(_safe_int(session.get("metrics_bank_shortfall", 0)) + batch_metrics.get("bank_shortfall", 0)),
738
+ "metrics_generation_batches": str(_safe_int(session.get("metrics_generation_batches", 0)) + 1),
739
  },
740
  )
741
+ await db[SESSIONS].update_one(
742
+ {"session_id": session_id},
743
+ {
744
+ "$set": {
745
+ "metrics_gemini_calls": _safe_int(session.get("metrics_gemini_calls", 0)) + batch_metrics.get("gemini_calls", 0),
746
+ "metrics_gemini_questions": _safe_int(session.get("metrics_gemini_questions", 0)) + batch_metrics.get("gemini_questions", 0),
747
+ "metrics_bank_questions": _safe_int(session.get("metrics_bank_questions", 0)) + batch_metrics.get("bank_questions", 0),
748
+ "metrics_bank_shortfall": _safe_int(session.get("metrics_bank_shortfall", 0)) + batch_metrics.get("bank_shortfall", 0),
749
+ "metrics_generation_batches": _safe_int(session.get("metrics_generation_batches", 0)) + 1,
750
+ }
751
+ },
752
+ )
753
+ metrics_delta = {
754
+ "gemini_calls": batch_metrics.get("gemini_calls", 0),
755
+ "gemini_questions": batch_metrics.get("gemini_questions", 0),
756
+ "bank_questions": batch_metrics.get("bank_questions", 0),
757
+ "bank_shortfall": batch_metrics.get("bank_shortfall", 0),
758
+ "generation_batches": 1,
759
+ }
760
+ print(
761
+ f"[interview-metrics] session={session_id} "
762
+ f"batch_size={len(new_ids)} gemini_calls+={batch_metrics.get('gemini_calls', 0)} "
763
+ f"gemini_questions+={batch_metrics.get('gemini_questions', 0)} "
764
+ f"bank_questions+={batch_metrics.get('bank_questions', 0)} "
765
+ f"bank_shortfall+={batch_metrics.get('bank_shortfall', 0)}"
766
+ )
767
 
768
  if not next_question_id:
769
  raise ValueError("Unable to fetch or generate next question")
 
781
  "current_difficulty": next_difficulty,
782
  })
783
 
784
+ effective_stats = {
785
+ "gemini_calls": _safe_int(session.get("metrics_gemini_calls", 0)) + metrics_delta["gemini_calls"],
786
+ "gemini_questions": _safe_int(session.get("metrics_gemini_questions", 0)) + metrics_delta["gemini_questions"],
787
+ "bank_questions": _safe_int(session.get("metrics_bank_questions", 0)) + metrics_delta["bank_questions"],
788
+ "bank_shortfall": _safe_int(session.get("metrics_bank_shortfall", 0)) + metrics_delta["bank_shortfall"],
789
+ "generation_batches": _safe_int(session.get("metrics_generation_batches", 0)) + metrics_delta["generation_batches"],
790
+ }
791
 
792
  return {
793
  "session_id": session_id,
 
800
  },
801
  "is_complete": False,
802
  "message": f"Question {new_served_count} of {max_questions}",
803
+ "generation_stats": effective_stats,
804
  }
805
 
806
 
backend/utils/gemini.py CHANGED
@@ -180,6 +180,197 @@ Return ONLY valid JSON, no markdown formatting."""
180
  }
181
 
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  async def evaluate_interview(questions_and_answers: list, role_title: str) -> dict:
184
  """Batch evaluate all interview Q&A pairs using Gemini."""
185
  qa_text = ""
 
180
  }
181
 
182
 
183
+ async def generate_interview_question_batch(
184
+ skills: list,
185
+ role_title: str,
186
+ count: int,
187
+ start_question_number: int = 1,
188
+ previous_questions: list = None,
189
+ foundation_limit: int = 3,
190
+ ) -> list:
191
+ """Generate a batch of interview questions in a single Gemini call."""
192
+ previous_questions = previous_questions or []
193
+ count = max(0, int(count or 0))
194
+ if count == 0:
195
+ return []
196
+
197
+ plan = []
198
+ for i in range(count):
199
+ qn = start_question_number + i
200
+ difficulty = "easy" if qn <= foundation_limit else ("medium" if qn <= foundation_limit + 3 else "hard")
201
+ stage = "foundation" if qn <= foundation_limit else "deep"
202
+ plan.append({"question_number": qn, "difficulty": difficulty, "stage": stage})
203
+
204
+ context = (
205
+ f"Role: {role_title}\n"
206
+ f"Candidate Skill Focus Areas: {', '.join(skills)}\n"
207
+ f"Question Plan: {json.dumps(plan)}\n"
208
+ f"Foundation Question Limit: {foundation_limit}"
209
+ )
210
+
211
+ if previous_questions:
212
+ context += "\n\nPrevious questions asked (do NOT repeat these):\n"
213
+ for i, q in enumerate(previous_questions, 1):
214
+ context += f"{i}. {q}\n"
215
+
216
+ prompt_template = PromptTemplate.from_template(
217
+ """{context}
218
+
219
+ Generate exactly {count} interview questions as a JSON array where each item follows the corresponding Question Plan entry.
220
+
221
+ Rules:
222
+ 1. Questions must be relevant to the role and listed skills.
223
+ 2. Do not repeat or rephrase previous questions.
224
+ 3. If stage is "foundation": ask only core fundamentals.
225
+ 4. If stage is "deep": ask applied/scenario/debugging/trade-off questions only.
226
+ 5. Rotate topics across skills to avoid repetitive focus.
227
+ 6. If a skill is a cluster label like "Deep Learning (CNN, LSTM)", ask about one concrete member skill.
228
+
229
+ Return ONLY valid JSON array with objects of shape:
230
+ - "question": string
231
+ - "difficulty": one of "easy" | "medium" | "hard"
232
+ - "category": string
233
+
234
+ Return ONLY JSON, no markdown."""
235
+ )
236
+ prompt = prompt_template.format(context=context, count=count)
237
+
238
+ result = (await call_gemini(prompt)).strip()
239
+ try:
240
+ data = json.loads(result)
241
+ if not isinstance(data, list):
242
+ raise ValueError("Batch response is not a list")
243
+ normalized = []
244
+ for i, item in enumerate(data[:count]):
245
+ spec = plan[i]
246
+ if not isinstance(item, dict):
247
+ item = {}
248
+ normalized.append(
249
+ {
250
+ "question": item.get("question") or f"Explain your approach for {skills[0] if skills else 'this topic'}.",
251
+ "difficulty": item.get("difficulty") if item.get("difficulty") in {"easy", "medium", "hard"} else spec["difficulty"],
252
+ "category": item.get("category") or "general",
253
+ }
254
+ )
255
+ while len(normalized) < count:
256
+ spec = plan[len(normalized)]
257
+ normalized.append(
258
+ {
259
+ "question": f"Tell me about your experience with {skills[0] if skills else 'software development'}.",
260
+ "difficulty": spec["difficulty"],
261
+ "category": "general",
262
+ }
263
+ )
264
+ return normalized
265
+ except Exception:
266
+ fallback = []
267
+ for i in range(count):
268
+ spec = plan[i]
269
+ fallback.append(
270
+ {
271
+ "question": f"Tell me about your experience with {skills[0] if skills else 'software development'}.",
272
+ "difficulty": spec["difficulty"],
273
+ "category": "general",
274
+ }
275
+ )
276
+ return fallback
277
+
278
+
279
+ async def generate_followup_question_batch_from_qa(
280
+ role_title: str,
281
+ skills: list,
282
+ qa_pairs: list,
283
+ previous_questions: list,
284
+ count: int,
285
+ difficulty: str = "medium",
286
+ ) -> list:
287
+ """Generate follow-up questions from interview Q&A context in a single Gemini call."""
288
+ count = max(0, int(count or 0))
289
+ if count == 0:
290
+ return []
291
+
292
+ compact_qa = []
293
+ for qa in qa_pairs[-8:]:
294
+ q = (qa or {}).get("question", "")
295
+ a = (qa or {}).get("answer", "")
296
+ if q and a:
297
+ compact_qa.append({"question": q, "answer": a})
298
+
299
+ payload = {
300
+ "role_title": role_title,
301
+ "skills": skills,
302
+ "difficulty": difficulty,
303
+ "count": count,
304
+ "answered_qa": compact_qa,
305
+ "previous_questions": previous_questions,
306
+ }
307
+
308
+ prompt_template = PromptTemplate.from_template(
309
+ """You are generating technical interview follow-up questions.
310
+
311
+ Input JSON:
312
+ {payload}
313
+
314
+ Instructions:
315
+ 1. Generate exactly {count} follow-up questions using answered_qa context.
316
+ 2. Questions must continue naturally from candidate's previous answers.
317
+ 3. Do not repeat or paraphrase any question in previous_questions.
318
+ 4. Keep questions practical and role-relevant.
319
+ 5. Use difficulty {difficulty}.
320
+
321
+ Return ONLY valid JSON array with objects:
322
+ - "question": string
323
+ - "difficulty": "easy" | "medium" | "hard"
324
+ - "category": string
325
+
326
+ No markdown, no extra text."""
327
+ )
328
+ prompt = prompt_template.format(
329
+ payload=json.dumps(payload, ensure_ascii=True),
330
+ count=count,
331
+ difficulty=difficulty,
332
+ )
333
+
334
+ result = (await call_gemini(prompt)).strip()
335
+ try:
336
+ data = json.loads(result)
337
+ if not isinstance(data, list):
338
+ raise ValueError("Follow-up batch response is not a list")
339
+
340
+ normalized = []
341
+ for item in data[:count]:
342
+ if not isinstance(item, dict):
343
+ item = {}
344
+ normalized.append(
345
+ {
346
+ "question": item.get("question") or f"Can you explain your approach for {skills[0] if skills else 'this scenario'}?",
347
+ "difficulty": item.get("difficulty") if item.get("difficulty") in {"easy", "medium", "hard"} else difficulty,
348
+ "category": item.get("category") or "follow-up",
349
+ }
350
+ )
351
+
352
+ while len(normalized) < count:
353
+ normalized.append(
354
+ {
355
+ "question": f"Can you explain your approach for {skills[0] if skills else 'this scenario'}?",
356
+ "difficulty": difficulty,
357
+ "category": "follow-up",
358
+ }
359
+ )
360
+ return normalized
361
+ except Exception:
362
+ fallback = []
363
+ for _ in range(count):
364
+ fallback.append(
365
+ {
366
+ "question": f"Can you explain your approach for {skills[0] if skills else 'this scenario'}?",
367
+ "difficulty": difficulty,
368
+ "category": "follow-up",
369
+ }
370
+ )
371
+ return fallback
372
+
373
+
374
  async def evaluate_interview(questions_and_answers: list, role_title: str) -> dict:
375
  """Batch evaluate all interview Q&A pairs using Gemini."""
376
  qa_text = ""