sajith-0701 commited on
Commit
d50ee26
·
1 Parent(s): f4178e2
backend/.dockerignore ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environments
2
+ .env
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+ # Virtual environments
10
+ venv/
11
+ .venv/
12
+ env/
13
+ .env/
14
+
15
+ # Distribution / packaging
16
+ build/
17
+ develop-eggs/
18
+ dist/
19
+ downloads/
20
+ eggs/
21
+ .eggs/
22
+ lib/
23
+ lib64/
24
+ parts/
25
+ sdist/
26
+ var/
27
+ wheels/
28
+ share/python-wheels/
29
+ *.egg-info/
30
+ .installed.cfg
31
+ *.egg
32
+
33
+ # PyInstaller
34
+ *.manifest
35
+ *.spec
36
+
37
+ # Installer logs
38
+ pip-log.txt
39
+ pip-delete-this-directory.txt
40
+
41
+ # Unit test / coverage reports
42
+ htmlcov/
43
+ .tox/
44
+ .nox/
45
+ .coverage
46
+ .coverage.*
47
+ .cache
48
+ nosetests.xml
49
+ coverage.xml
50
+ *.cover
51
+ *.py,cover
52
+ .hypothesis/
53
+ .pytest_cache/
54
+ cover/
55
+
56
+ # IDEs
57
+ .idea/
58
+ .vscode/
59
+ *.swp
60
+ *.swo
61
+
62
+ # OS generated files
63
+ .DS_Store
64
+ .DS_Store?
65
+ ._*
66
+ .Spotlight-V100
67
+ .Trashes
68
+ ehthumbs.db
69
+ Thumbs.db
70
+
71
+ # Git
72
+ .git/
73
+ .gitignore
backend/Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use the official Python base image
2
+ FROM python:3.10-slim
3
+
4
+ # Set environment variables
5
+ ENV PYTHONDONTWRITEBYTECODE=1
6
+ ENV PYTHONUNBUFFERED=1
7
+
8
+ # Set the working directory in the container
9
+ WORKDIR /app
10
+
11
+ # Copy the requirements file into the container
12
+ COPY requirements.txt .
13
+
14
+ # Install dependencies
15
+ RUN pip install --no-cache-dir -r requirements.txt
16
+
17
+ # Copy the rest of the application code
18
+ COPY . .
19
+
20
+ # Expose port 8000
21
+ EXPOSE 8000
22
+
23
+ # Command to run the application
24
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
backend/_probe_models.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import tempfile
4
+ import time
5
+
6
+ CANDIDATES = [
7
+ ("tts_models/en/ljspeech/speedy-speech", None),
8
+ ("tts_models/en/ljspeech/vits", None),
9
+ ("tts_models/en/ljspeech/glow-tts", None),
10
+ ("tts_models/en/ljspeech/tacotron2-DDC", None),
11
+ ("tts_models/en/ljspeech/fast_pitch", None),
12
+ ("tts_models/en/vctk/vits", "AUTO_SPEAKERS"),
13
+ ("tts_models/en/sam/tacotron-DDC", None),
14
+ ("tts_models/en/blizzard2013/capacitron-t2-c50", None),
15
+ ("tts_models/en/jenny/jenny", None),
16
+ ]
17
+
18
+ TEST_TEXT = "Hello, this is a short interview voice quality sample."
19
+
20
+ async def synth_once(tts, speaker=None):
21
+ fd, path = tempfile.mkstemp(suffix=".wav")
22
+ os.close(fd)
23
+ t0 = time.perf_counter()
24
+ try:
25
+ kwargs = {"text": TEST_TEXT, "file_path": path}
26
+ if speaker:
27
+ kwargs["speaker"] = speaker
28
+ await asyncio.to_thread(lambda: tts.tts_to_file(**kwargs))
29
+ elapsed = time.perf_counter() - t0
30
+ size = os.path.getsize(path)
31
+ return True, elapsed, size, None
32
+ except Exception as e:
33
+ return False, 0.0, 0, str(e)
34
+ finally:
35
+ if os.path.exists(path):
36
+ os.remove(path)
37
+
38
+
39
+ async def run():
40
+ from TTS.api import TTS
41
+
42
+ for model_name, speaker_mode in CANDIDATES:
43
+ print(f"MODEL {model_name}")
44
+ try:
45
+ t_load = time.perf_counter()
46
+ tts = await asyncio.to_thread(lambda: TTS(model_name=model_name, progress_bar=False, gpu=False))
47
+ print(f" LOAD_OK {time.perf_counter() - t_load:.2f}s")
48
+
49
+ if speaker_mode == "AUTO_SPEAKERS":
50
+ speakers = list(getattr(tts, "speakers", []) or [])
51
+ if not speakers:
52
+ print(" NO_SPEAKERS_FOUND")
53
+ ok, elapsed, size, err = await synth_once(tts)
54
+ if ok:
55
+ print(f" SYNTH_OK elapsed={elapsed:.2f}s bytes={size}")
56
+ else:
57
+ print(f" SYNTH_FAIL {err}")
58
+ else:
59
+ print(f" SPEAKER_COUNT {len(speakers)}")
60
+ test_speakers = speakers[:12]
61
+ for spk in test_speakers:
62
+ ok, elapsed, size, err = await synth_once(tts, speaker=spk)
63
+ if ok:
64
+ print(f" SPEAKER_OK {spk} elapsed={elapsed:.2f}s bytes={size}")
65
+ else:
66
+ print(f" SPEAKER_FAIL {spk} err={err}")
67
+ else:
68
+ ok, elapsed, size, err = await synth_once(tts)
69
+ if ok:
70
+ print(f" SYNTH_OK elapsed={elapsed:.2f}s bytes={size}")
71
+ else:
72
+ print(f" SYNTH_FAIL {err}")
73
+ except Exception as e:
74
+ print(f" LOAD_FAIL {e}")
75
+
76
+ asyncio.run(run())
backend/models/collections.py CHANGED
@@ -4,6 +4,7 @@ USERS = "users"
4
  RESUMES = "resumes"
5
  SKILLS = "skills"
6
  JOB_ROLES = "job_roles"
 
7
  ROLE_REQUIREMENTS = "role_requirements"
8
  QUESTIONS = "questions"
9
  TOPICS = "topics"
 
4
  RESUMES = "resumes"
5
  SKILLS = "skills"
6
  JOB_ROLES = "job_roles"
7
+ JOB_DESCRIPTIONS = "job_descriptions"
8
  ROLE_REQUIREMENTS = "role_requirements"
9
  QUESTIONS = "questions"
10
  TOPICS = "topics"
backend/routers/admin.py CHANGED
@@ -16,6 +16,12 @@ from services.admin_service import (
16
  list_quit_interviews, list_admin_reports, get_admin_report_detail,
17
  list_admin_users, delete_admin_user,
18
  )
 
 
 
 
 
 
19
  from services.analytics_service import get_admin_analytics
20
 
21
  router = APIRouter()
@@ -354,6 +360,63 @@ async def get_admin_users(
354
  return {"items": items}
355
 
356
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  @router.delete("/users/{user_id}")
358
  async def delete_admin_user_endpoint(
359
  user_id: str,
 
16
  list_quit_interviews, list_admin_reports, get_admin_report_detail,
17
  list_admin_users, delete_admin_user,
18
  )
19
+ from services.job_description_service import (
20
+ create_job_description,
21
+ list_admin_job_descriptions,
22
+ update_admin_job_description,
23
+ delete_admin_job_description,
24
+ )
25
  from services.analytics_service import get_admin_analytics
26
 
27
  router = APIRouter()
 
360
  return {"items": items}
361
 
362
 
363
+ @router.get("/job-descriptions")
364
+ async def get_admin_job_descriptions(
365
+ owner_user_id: str = Query(None),
366
+ current_user: dict = Depends(require_role("admin")),
367
+ ):
368
+ """List job descriptions for admin management."""
369
+ items = await list_admin_job_descriptions(owner_user_id=owner_user_id)
370
+ return {"items": items}
371
+
372
+
373
+ @router.post("/job-descriptions")
374
+ async def create_admin_job_description_endpoint(
375
+ request_data: dict,
376
+ current_user: dict = Depends(require_role("admin")),
377
+ ):
378
+ """Create a job description as admin."""
379
+ try:
380
+ item = await create_job_description(
381
+ user_id=current_user["user_id"],
382
+ owner_role="admin",
383
+ title=request_data.get("title"),
384
+ company=request_data.get("company"),
385
+ description=request_data.get("description"),
386
+ required_skills=request_data.get("required_skills"),
387
+ )
388
+ return item
389
+ except ValueError as e:
390
+ raise HTTPException(status_code=400, detail=str(e))
391
+
392
+
393
+ @router.put("/job-descriptions/{jd_id}")
394
+ async def update_admin_job_description_endpoint(
395
+ jd_id: str,
396
+ request_data: dict,
397
+ current_user: dict = Depends(require_role("admin")),
398
+ ):
399
+ """Update any job description (admin only)."""
400
+ try:
401
+ item = await update_admin_job_description(jd_id, request_data)
402
+ return item
403
+ except ValueError as e:
404
+ status_code = 404 if "not found" in str(e).lower() else 400
405
+ raise HTTPException(status_code=status_code, detail=str(e))
406
+
407
+
408
+ @router.delete("/job-descriptions/{jd_id}")
409
+ async def delete_admin_job_description_endpoint(
410
+ jd_id: str,
411
+ current_user: dict = Depends(require_role("admin")),
412
+ ):
413
+ """Delete any job description (admin only)."""
414
+ success = await delete_admin_job_description(jd_id)
415
+ if not success:
416
+ raise HTTPException(status_code=404, detail="Job description not found")
417
+ return {"message": "Job description deleted"}
418
+
419
+
420
  @router.delete("/users/{user_id}")
421
  async def delete_admin_user_endpoint(
422
  user_id: str,
backend/routers/interview.py CHANGED
@@ -2,12 +2,18 @@ from fastapi import APIRouter, Depends, HTTPException
2
  from auth.jwt import get_current_user
3
  from schemas.interview import (
4
  StartInterviewRequest,
 
5
  SubmitAnswerRequest,
6
  QuitInterviewRequest,
7
  InterviewStartResponse,
8
  AnswerResponse,
9
  )
10
- from services.interview_service import start_interview, submit_answer, quit_interview
 
 
 
 
 
11
  from services.evaluation_service import generate_report
12
 
13
  router = APIRouter()
@@ -26,12 +32,33 @@ async def start_interview_endpoint(
26
  custom_role=request.custom_role,
27
  interview_type=request.interview_type,
28
  topic_id=request.topic_id,
 
29
  )
30
  return result
31
  except Exception as e:
32
  raise HTTPException(status_code=500, detail=str(e))
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  @router.post("/answer")
36
  async def submit_answer_endpoint(
37
  request: SubmitAnswerRequest,
 
2
  from auth.jwt import get_current_user
3
  from schemas.interview import (
4
  StartInterviewRequest,
5
+ VerifyResumeJdRequest,
6
  SubmitAnswerRequest,
7
  QuitInterviewRequest,
8
  InterviewStartResponse,
9
  AnswerResponse,
10
  )
11
+ from services.interview_service import (
12
+ start_interview,
13
+ verify_resume_job_description,
14
+ submit_answer,
15
+ quit_interview,
16
+ )
17
  from services.evaluation_service import generate_report
18
 
19
  router = APIRouter()
 
32
  custom_role=request.custom_role,
33
  interview_type=request.interview_type,
34
  topic_id=request.topic_id,
35
+ job_description_id=request.job_description_id,
36
  )
37
  return result
38
  except Exception as e:
39
  raise HTTPException(status_code=500, detail=str(e))
40
 
41
 
42
+ @router.post("/verify")
43
+ async def verify_resume_job_description_endpoint(
44
+ request: VerifyResumeJdRequest,
45
+ current_user: dict = Depends(get_current_user),
46
+ ):
47
+ """Verify resume vs selected job description before starting interview."""
48
+ try:
49
+ result = await verify_resume_job_description(
50
+ user_id=current_user["user_id"],
51
+ role_id=request.role_id,
52
+ custom_role=request.custom_role,
53
+ job_description_id=request.job_description_id,
54
+ )
55
+ return result
56
+ except ValueError as e:
57
+ raise HTTPException(status_code=400, detail=str(e))
58
+ except Exception as e:
59
+ raise HTTPException(status_code=500, detail=str(e))
60
+
61
+
62
  @router.post("/answer")
63
  async def submit_answer_endpoint(
64
  request: SubmitAnswerRequest,
backend/routers/profile.py CHANGED
@@ -5,6 +5,12 @@ from models.collections import USERS, RESUMES, SKILLS
5
  from utils.helpers import str_objectid
6
  from utils.skills import normalize_skill_list, cluster_skills
7
  from bson import ObjectId
 
 
 
 
 
 
8
 
9
  router = APIRouter()
10
 
@@ -107,3 +113,59 @@ async def update_resume_data(
107
  raise HTTPException(status_code=404, detail="Resume not found. Upload a resume first.")
108
 
109
  return {"message": "Resume details updated successfully", "parsed_data": parsed_data}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  from utils.helpers import str_objectid
6
  from utils.skills import normalize_skill_list, cluster_skills
7
  from bson import ObjectId
8
+ from services.job_description_service import (
9
+ create_job_description,
10
+ list_my_job_descriptions,
11
+ update_my_job_description,
12
+ delete_my_job_description,
13
+ )
14
 
15
  router = APIRouter()
16
 
 
113
  raise HTTPException(status_code=404, detail="Resume not found. Upload a resume first.")
114
 
115
  return {"message": "Resume details updated successfully", "parsed_data": parsed_data}
116
+
117
+
118
+ @router.get("/job-descriptions")
119
+ async def get_my_job_descriptions(
120
+ current_user: dict = Depends(get_current_user),
121
+ ):
122
+ """List current user's job descriptions."""
123
+ items = await list_my_job_descriptions(current_user["user_id"])
124
+ return {"items": items}
125
+
126
+
127
+ @router.post("/job-descriptions")
128
+ async def create_my_job_description(
129
+ request_data: dict,
130
+ current_user: dict = Depends(get_current_user),
131
+ ):
132
+ """Create a new job description for current user."""
133
+ try:
134
+ item = await create_job_description(
135
+ user_id=current_user["user_id"],
136
+ owner_role=current_user.get("role", "student"),
137
+ title=request_data.get("title"),
138
+ company=request_data.get("company"),
139
+ description=request_data.get("description"),
140
+ required_skills=request_data.get("required_skills"),
141
+ )
142
+ return item
143
+ except ValueError as e:
144
+ raise HTTPException(status_code=400, detail=str(e))
145
+
146
+
147
+ @router.put("/job-descriptions/{jd_id}")
148
+ async def update_my_job_description_endpoint(
149
+ jd_id: str,
150
+ request_data: dict,
151
+ current_user: dict = Depends(get_current_user),
152
+ ):
153
+ """Update a current user's job description."""
154
+ try:
155
+ item = await update_my_job_description(current_user["user_id"], jd_id, request_data)
156
+ return item
157
+ except ValueError as e:
158
+ status_code = 404 if "not found" in str(e).lower() else 400
159
+ raise HTTPException(status_code=status_code, detail=str(e))
160
+
161
+
162
+ @router.delete("/job-descriptions/{jd_id}")
163
+ async def delete_my_job_description_endpoint(
164
+ jd_id: str,
165
+ current_user: dict = Depends(get_current_user),
166
+ ):
167
+ """Delete a current user's job description."""
168
+ success = await delete_my_job_description(current_user["user_id"], jd_id)
169
+ if not success:
170
+ raise HTTPException(status_code=404, detail="Job description not found")
171
+ return {"message": "Job description deleted"}
backend/schemas/interview.py CHANGED
@@ -7,6 +7,13 @@ class StartInterviewRequest(BaseModel):
7
  custom_role: Optional[str] = None
8
  interview_type: Optional[str] = "resume"
9
  topic_id: Optional[str] = None
 
 
 
 
 
 
 
10
 
11
 
12
  class SubmitAnswerRequest(BaseModel):
 
7
  custom_role: Optional[str] = None
8
  interview_type: Optional[str] = "resume"
9
  topic_id: Optional[str] = None
10
+ job_description_id: Optional[str] = None
11
+
12
+
13
+ class VerifyResumeJdRequest(BaseModel):
14
+ role_id: Optional[str] = None
15
+ custom_role: Optional[str] = None
16
+ job_description_id: str
17
 
18
 
19
  class SubmitAnswerRequest(BaseModel):
backend/services/admin_service.py CHANGED
@@ -3,7 +3,7 @@ import json
3
  import re
4
  from datetime import datetime
5
  from database import get_db
6
- from models.collections import JOB_ROLES, ROLE_REQUIREMENTS, QUESTIONS, TOPICS, TOPIC_QUESTIONS, SESSIONS, USERS, RESULTS, RESUMES, SKILLS, ANSWERS
7
  from utils.helpers import utc_now, str_objectid, str_objectids
8
  from utils.gemini import call_gemini
9
  from utils.resume_text import extract_resume_text
@@ -625,6 +625,7 @@ async def delete_admin_user(target_user_id: str, current_admin_user_id: str) ->
625
 
626
  await db[RESUMES].delete_many({"user_id": target_user_id})
627
  await db[SKILLS].delete_many({"user_id": target_user_id})
 
628
  await db[SESSIONS].delete_many({"user_id": target_user_id})
629
  await db[ANSWERS].delete_many({"user_id": target_user_id})
630
  await db[RESULTS].delete_many({"user_id": target_user_id})
 
3
  import re
4
  from datetime import datetime
5
  from database import get_db
6
+ from models.collections import JOB_ROLES, ROLE_REQUIREMENTS, QUESTIONS, TOPICS, TOPIC_QUESTIONS, SESSIONS, USERS, RESULTS, RESUMES, SKILLS, ANSWERS, JOB_DESCRIPTIONS
7
  from utils.helpers import utc_now, str_objectid, str_objectids
8
  from utils.gemini import call_gemini
9
  from utils.resume_text import extract_resume_text
 
625
 
626
  await db[RESUMES].delete_many({"user_id": target_user_id})
627
  await db[SKILLS].delete_many({"user_id": target_user_id})
628
+ await db[JOB_DESCRIPTIONS].delete_many({"user_id": target_user_id})
629
  await db[SESSIONS].delete_many({"user_id": target_user_id})
630
  await db[ANSWERS].delete_many({"user_id": target_user_id})
631
  await db[RESULTS].delete_many({"user_id": target_user_id})
backend/services/interview_graph.py CHANGED
@@ -19,13 +19,11 @@ class InterviewGraphState(TypedDict, total=False):
19
  question_data: Dict[str, Any]
20
 
21
 
22
- FOUNDATION_QUESTION_LIMIT = 3
23
 
24
 
25
  def _difficulty_for_question_number(question_number: int, foundation_limit: int = FOUNDATION_QUESTION_LIMIT) -> str:
26
- if question_number <= foundation_limit:
27
- return "easy"
28
- if question_number <= foundation_limit + 3:
29
  return "medium"
30
  return "hard"
31
 
 
19
  question_data: Dict[str, Any]
20
 
21
 
22
+ FOUNDATION_QUESTION_LIMIT = 0
23
 
24
 
25
  def _difficulty_for_question_number(question_number: int, foundation_limit: int = FOUNDATION_QUESTION_LIMIT) -> str:
26
+ if question_number <= 5:
 
 
27
  return "medium"
28
  return "hard"
29
 
backend/services/interview_service.py CHANGED
@@ -2,11 +2,12 @@ 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
@@ -42,6 +43,15 @@ def _safe_int(value, default: int = 0) -> int:
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
@@ -80,6 +90,68 @@ def _plan_followup_mix(target: int, qa_pairs: list, has_bank_source: bool) -> tu
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 = []
@@ -113,6 +185,7 @@ async def _generate_question_batch(
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)
@@ -178,43 +251,67 @@ async def _fetch_question_bank_batch(
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,
@@ -235,15 +332,17 @@ async def _generate_mixed_followup_batch(
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
 
@@ -487,6 +586,7 @@ async def start_interview(
487
  custom_role: str = None,
488
  interview_type: str = "resume",
489
  topic_id: str = None,
 
490
  ) -> dict:
491
  """Start a new interview session."""
492
  interview_type = (interview_type or "resume").strip().lower()
@@ -504,18 +604,7 @@ async def start_interview(
504
  user_skills = normalize_skill_list(user_skills)
505
 
506
  # Get role
507
- role_title = "Software Developer"
508
- if custom_role:
509
- role_title = custom_role
510
- elif role_id:
511
- from bson import ObjectId
512
- try:
513
- role = await db[JOB_ROLES].find_one({"_id": ObjectId(role_id)})
514
- if role:
515
- role_title = role["title"]
516
- except Exception:
517
- # If it's not a valid ObjectId, assume it's a raw generic title passed from frontend
518
- role_title = role_id
519
 
520
  # Compare role requirements with user skills when admin role requirements exist.
521
  required_skills = []
@@ -527,26 +616,49 @@ async def start_interview(
527
  matched_role_skills = find_matching_skills(user_skills, required_skills)
528
  missing_role_skills = find_missing_skills(user_skills, required_skills)
529
 
 
 
 
 
530
  # Prioritize matched required skills and compress them into cluster-aware focus areas.
531
  base_skills_for_interview = matched_role_skills if matched_role_skills else user_skills
532
  skills_for_interview = build_interview_focus_skills(base_skills_for_interview)
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,
539
- skills=skills_for_interview,
540
- previous_questions=[],
541
- generated_count=0,
542
- max_questions=MAX_QUESTIONS,
543
- current_difficulty="medium",
544
- local_summary=None,
545
- batch_size=BATCH_SIZE,
546
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
547
  if not initial_batch:
548
  raise ValueError("Failed to generate initial interview questions")
549
 
 
 
 
 
 
550
  session_id = generate_id()
551
  _LOCAL_SUMMARIES[session_id] = ""
552
 
@@ -556,15 +668,17 @@ async def start_interview(
556
  "user_id": user_id,
557
  "role_id": role_id,
558
  "role_title": role_title,
 
 
559
  "status": "in_progress",
560
  "interview_type": "resume",
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
  }
@@ -588,10 +702,10 @@ async def start_interview(
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)
@@ -628,6 +742,8 @@ async def start_interview(
628
  "seconds": None,
629
  },
630
  "message": "Interview started. Good luck!",
 
 
631
  }
632
 
633
 
 
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, RESUMES
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, analyze_resume_vs_job_description
10
+ from services.job_description_service import get_job_description_for_user
11
 
12
  MAX_QUESTIONS = 20
13
  SESSION_TTL = 7200 # 2 hours
 
43
  return default
44
 
45
 
46
+ def _normalize_bank_difficulty(value: str) -> str:
47
+ difficulty = (value or "medium").strip().lower()
48
+ if difficulty not in {"easy", "medium", "hard"}:
49
+ return "medium"
50
+ if difficulty == "easy":
51
+ return "medium"
52
+ return difficulty
53
+
54
+
55
  def _avg_recent_answer_words(qa_pairs: list, window: int = 3) -> int:
56
  if not qa_pairs:
57
  return 0
 
90
  return ai_target, bank_target
91
 
92
 
93
+ async def _resolve_role_title(db, role_id: str | None, custom_role: str | None) -> str:
94
+ if custom_role and custom_role.strip():
95
+ return custom_role.strip()
96
+
97
+ if role_id:
98
+ try:
99
+ role = await db[JOB_ROLES].find_one({"_id": ObjectId(role_id)})
100
+ if role:
101
+ return role["title"]
102
+ except Exception:
103
+ # If it's not a valid ObjectId, treat it as a direct generic title.
104
+ return role_id
105
+
106
+ return "Software Developer"
107
+
108
+
109
+ async def verify_resume_job_description(
110
+ user_id: str,
111
+ role_id: str = None,
112
+ custom_role: str = None,
113
+ job_description_id: str = None,
114
+ ) -> dict:
115
+ """Run resume-vs-job-description verification without starting an interview."""
116
+ if not job_description_id:
117
+ raise ValueError("job_description_id is required for verification")
118
+
119
+ db = get_db()
120
+
121
+ resume_doc = await db[RESUMES].find_one({"user_id": user_id})
122
+ if not resume_doc:
123
+ raise ValueError("Please upload your resume before running verification")
124
+
125
+ skills_doc = await db[SKILLS].find_one({"user_id": user_id})
126
+ resume_skills = normalize_skill_list(skills_doc.get("skills", [])) if skills_doc else []
127
+
128
+ parsed_data = (resume_doc or {}).get("parsed_data", {}) or {}
129
+ summary_parts = [
130
+ parsed_data.get("experience_summary") or "",
131
+ " ".join(parsed_data.get("recommended_roles", []) or []),
132
+ ]
133
+ resume_summary = "\n".join([part for part in summary_parts if part]).strip() or "No summary available"
134
+
135
+ role_title = await _resolve_role_title(db, role_id=role_id, custom_role=custom_role)
136
+ selected_jd = await get_job_description_for_user(user_id, job_description_id)
137
+
138
+ jd_alignment = await analyze_resume_vs_job_description(
139
+ role_title=role_title,
140
+ resume_skills=resume_skills if resume_skills else ["general"],
141
+ resume_summary=resume_summary,
142
+ jd_title=selected_jd.get("title", ""),
143
+ jd_description=selected_jd.get("description", ""),
144
+ jd_required_skills=selected_jd.get("required_skills", []),
145
+ )
146
+
147
+ return {
148
+ "role_title": role_title,
149
+ "job_description": selected_jd,
150
+ "jd_alignment": jd_alignment,
151
+ "message": "Verification complete",
152
+ }
153
+
154
+
155
  async def _get_generated_question_texts(redis, session_id: str) -> list[str]:
156
  qids = await redis.lrange(f"session:{session_id}:questions", 0, -1)
157
  questions = []
 
185
  count=target,
186
  start_question_number=1,
187
  previous_questions=previous_questions,
188
+ foundation_limit=0,
189
  )
190
  if seeded:
191
  last = seeded[-1].get("difficulty", current_difficulty)
 
251
  excluded_questions: list[str],
252
  limit: int,
253
  ) -> list[dict]:
254
+ if limit <= 0:
255
  return []
256
 
257
+ query = {"question": {"$exists": True, "$ne": ""}}
258
+ if role_id:
259
+ role_candidates = [role_id]
260
+ try:
261
+ oid = ObjectId(role_id)
262
+ role_candidates.append(str(oid))
263
+ role_candidates.append(oid)
264
+ except Exception:
265
+ pass
266
+ query["role_id"] = {"$in": role_candidates}
267
 
268
  excluded = {q.strip().lower() for q in excluded_questions if q}
 
269
  selected: list[dict] = []
270
 
271
+ for sample_size in (max(limit * 12, 80), max(limit * 24, 160)):
272
+ pipeline = [
273
+ {"$match": query},
274
+ {"$sample": {"size": sample_size}},
275
+ ]
276
+
277
+ async for q in db[QUESTIONS].aggregate(pipeline):
278
+ text = (q.get("question") or "").strip()
279
+ if not text:
280
+ continue
281
+ if text.lower() in excluded:
282
+ continue
283
+ selected.append(
284
+ {
285
+ "question": text,
286
+ "difficulty": _normalize_bank_difficulty(q.get("difficulty") or "medium"),
287
+ "category": q.get("category") or "question-bank",
288
+ }
289
+ )
290
+ excluded.add(text.lower())
291
+ if len(selected) >= limit:
292
+ break
293
+
294
  if len(selected) >= limit:
295
  break
296
 
297
+ # If role-scoped pool is too small, widen to global random pool.
298
+ if len(selected) < limit and role_id:
299
+ fallback = await _fetch_question_bank_batch(
300
+ db=db,
301
+ role_id=None,
302
+ excluded_questions=list(excluded),
303
+ limit=limit - len(selected),
304
+ )
305
+ selected.extend(fallback)
306
+
307
  return selected
308
 
309
 
310
+ def _strict_followup_difficulty(answered_count: int) -> str:
311
+ # After first DB set (Q1-5), follow-ups should feel like real interview pressure.
312
+ return "hard" if answered_count >= 10 else "medium"
313
+
314
+
315
  async def _generate_mixed_followup_batch(
316
  db,
317
  redis,
 
332
 
333
  previous_questions = await _get_generated_question_texts(redis, session_id)
334
  qa_pairs = await get_session_qa(session_id)
335
+ answered_count = len(qa_pairs)
336
  role_title = session.get("role_title", "Software Developer")
337
  skills = _safe_json_list(session.get("skills", "[]"))
338
+ current_difficulty = _strict_followup_difficulty(answered_count)
339
 
340
+ if target >= 5:
341
+ ai_target = 3
342
+ bank_target = 2
343
+ else:
344
+ ai_target = min(3, target)
345
+ bank_target = min(2, max(0, target - ai_target))
346
 
347
  from utils.gemini import generate_followup_question_batch_from_qa
348
 
 
586
  custom_role: str = None,
587
  interview_type: str = "resume",
588
  topic_id: str = None,
589
+ job_description_id: str = None,
590
  ) -> dict:
591
  """Start a new interview session."""
592
  interview_type = (interview_type or "resume").strip().lower()
 
604
  user_skills = normalize_skill_list(user_skills)
605
 
606
  # Get role
607
+ role_title = await _resolve_role_title(db, role_id=role_id, custom_role=custom_role)
 
 
 
 
 
 
 
 
 
 
 
608
 
609
  # Compare role requirements with user skills when admin role requirements exist.
610
  required_skills = []
 
616
  matched_role_skills = find_matching_skills(user_skills, required_skills)
617
  missing_role_skills = find_missing_skills(user_skills, required_skills)
618
 
619
+ selected_jd = None
620
+ if job_description_id:
621
+ selected_jd = await get_job_description_for_user(user_id, job_description_id)
622
+
623
  # Prioritize matched required skills and compress them into cluster-aware focus areas.
624
  base_skills_for_interview = matched_role_skills if matched_role_skills else user_skills
625
  skills_for_interview = build_interview_focus_skills(base_skills_for_interview)
626
  if not skills_for_interview:
627
  skills_for_interview = ["general"]
628
 
629
+ # First set must come from random DB questions when possible.
630
+ initial_bank = await _fetch_question_bank_batch(
631
+ db=db,
632
+ role_id=role_id,
633
+ excluded_questions=[],
634
+ limit=BATCH_SIZE,
 
 
 
 
635
  )
636
+
637
+ initial_batch = list(initial_bank)
638
+ initial_ai_items: list[dict] = []
639
+ if len(initial_batch) < BATCH_SIZE:
640
+ ai_count = BATCH_SIZE - len(initial_batch)
641
+ initial_ai_items, _ = await _generate_question_batch(
642
+ role_title=role_title,
643
+ skills=skills_for_interview,
644
+ previous_questions=[q.get("question", "") for q in initial_batch],
645
+ generated_count=0,
646
+ max_questions=MAX_QUESTIONS,
647
+ current_difficulty="medium",
648
+ local_summary=None,
649
+ batch_size=ai_count,
650
+ )
651
+ initial_batch.extend(initial_ai_items)
652
+
653
+ last_difficulty = initial_batch[-1].get("difficulty", "medium") if initial_batch else "medium"
654
  if not initial_batch:
655
  raise ValueError("Failed to generate initial interview questions")
656
 
657
+ initial_gemini_calls = 1 if initial_ai_items else 0
658
+ initial_gemini_questions = len(initial_ai_items)
659
+ initial_bank_questions = len(initial_bank)
660
+ initial_bank_shortfall = max(0, BATCH_SIZE - len(initial_bank))
661
+
662
  session_id = generate_id()
663
  _LOCAL_SUMMARIES[session_id] = ""
664
 
 
668
  "user_id": user_id,
669
  "role_id": role_id,
670
  "role_title": role_title,
671
+ "job_description_id": selected_jd.get("id") if selected_jd else None,
672
+ "job_description_title": selected_jd.get("title") if selected_jd else None,
673
  "status": "in_progress",
674
  "interview_type": "resume",
675
  "question_count": 1,
676
  "max_questions": MAX_QUESTIONS,
677
  "current_difficulty": initial_batch[0].get("difficulty", "medium"),
678
+ "metrics_gemini_calls": initial_gemini_calls,
679
+ "metrics_gemini_questions": initial_gemini_questions,
680
+ "metrics_bank_questions": initial_bank_questions,
681
+ "metrics_bank_shortfall": initial_bank_shortfall,
682
  "metrics_generation_batches": 1,
683
  "started_at": utc_now(),
684
  }
 
702
  "current_difficulty": last_difficulty,
703
  "interview_type": "resume",
704
  "status": "in_progress",
705
+ "metrics_gemini_calls": initial_gemini_calls,
706
+ "metrics_gemini_questions": initial_gemini_questions,
707
+ "metrics_bank_questions": initial_bank_questions,
708
+ "metrics_bank_shortfall": initial_bank_shortfall,
709
  "metrics_generation_batches": 1,
710
  }
711
  await redis.hset(f"session:{session_id}", mapping=session_state)
 
742
  "seconds": None,
743
  },
744
  "message": "Interview started. Good luck!",
745
+ "job_description": selected_jd,
746
+ "jd_alignment": None,
747
  }
748
 
749
 
backend/services/job_description_service.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from bson import ObjectId
2
+
3
+ from database import get_db
4
+ from models.collections import JOB_DESCRIPTIONS
5
+ from utils.helpers import utc_now, str_objectid, str_objectids
6
+
7
+
8
+ def _normalize_required_skills(required_skills):
9
+ items = required_skills or []
10
+ if not isinstance(items, list):
11
+ return []
12
+ seen = set()
13
+ output = []
14
+ for raw in items:
15
+ skill = (raw or "").strip()
16
+ if not skill:
17
+ continue
18
+ key = skill.lower()
19
+ if key in seen:
20
+ continue
21
+ seen.add(key)
22
+ output.append(skill)
23
+ return output
24
+
25
+
26
+ def _build_update_data(data: dict) -> dict:
27
+ update_data = {}
28
+ if "title" in data:
29
+ title = (data.get("title") or "").strip()
30
+ if not title:
31
+ raise ValueError("title is required")
32
+ update_data["title"] = title
33
+
34
+ if "company" in data:
35
+ update_data["company"] = (data.get("company") or "").strip() or None
36
+
37
+ if "description" in data:
38
+ description = (data.get("description") or "").strip()
39
+ if not description:
40
+ raise ValueError("description is required")
41
+ update_data["description"] = description
42
+
43
+ if "required_skills" in data:
44
+ update_data["required_skills"] = _normalize_required_skills(data.get("required_skills"))
45
+
46
+ if not update_data:
47
+ raise ValueError("No fields to update")
48
+
49
+ update_data["updated_at"] = utc_now()
50
+ return update_data
51
+
52
+
53
+ async def create_job_description(
54
+ user_id: str,
55
+ owner_role: str,
56
+ title: str,
57
+ description: str,
58
+ company: str | None = None,
59
+ required_skills: list[str] | None = None,
60
+ ) -> dict:
61
+ db = get_db()
62
+
63
+ title = (title or "").strip()
64
+ description = (description or "").strip()
65
+ if not title:
66
+ raise ValueError("title is required")
67
+ if not description:
68
+ raise ValueError("description is required")
69
+
70
+ doc = {
71
+ "user_id": user_id,
72
+ "owner_role": owner_role if owner_role in {"student", "admin"} else "student",
73
+ "title": title,
74
+ "company": (company or "").strip() or None,
75
+ "description": description,
76
+ "required_skills": _normalize_required_skills(required_skills),
77
+ "created_at": utc_now(),
78
+ "updated_at": utc_now(),
79
+ }
80
+ result = await db[JOB_DESCRIPTIONS].insert_one(doc)
81
+ doc["_id"] = result.inserted_id
82
+ return str_objectid(doc)
83
+
84
+
85
+ async def list_my_job_descriptions(user_id: str) -> list:
86
+ db = get_db()
87
+ docs = await db[JOB_DESCRIPTIONS].find({"user_id": user_id}).sort("updated_at", -1).to_list(length=300)
88
+ return str_objectids(docs)
89
+
90
+
91
+ async def update_my_job_description(user_id: str, jd_id: str, data: dict) -> dict:
92
+ db = get_db()
93
+ try:
94
+ oid = ObjectId(jd_id)
95
+ except Exception as exc:
96
+ raise ValueError("Invalid job description id") from exc
97
+
98
+ existing = await db[JOB_DESCRIPTIONS].find_one({"_id": oid, "user_id": user_id})
99
+ if not existing:
100
+ raise ValueError("Job description not found")
101
+
102
+ update_data = _build_update_data(data)
103
+ await db[JOB_DESCRIPTIONS].update_one({"_id": oid}, {"$set": update_data})
104
+ updated = await db[JOB_DESCRIPTIONS].find_one({"_id": oid})
105
+ return str_objectid(updated)
106
+
107
+
108
+ async def delete_my_job_description(user_id: str, jd_id: str) -> bool:
109
+ db = get_db()
110
+ try:
111
+ oid = ObjectId(jd_id)
112
+ except Exception:
113
+ return False
114
+ result = await db[JOB_DESCRIPTIONS].delete_one({"_id": oid, "user_id": user_id})
115
+ return result.deleted_count > 0
116
+
117
+
118
+ async def list_admin_job_descriptions(owner_user_id: str | None = None) -> list:
119
+ db = get_db()
120
+ query = {"user_id": owner_user_id} if owner_user_id else {}
121
+ docs = await db[JOB_DESCRIPTIONS].find(query).sort("updated_at", -1).to_list(length=1000)
122
+ return str_objectids(docs)
123
+
124
+
125
+ async def update_admin_job_description(jd_id: str, data: dict) -> dict:
126
+ db = get_db()
127
+ try:
128
+ oid = ObjectId(jd_id)
129
+ except Exception as exc:
130
+ raise ValueError("Invalid job description id") from exc
131
+
132
+ existing = await db[JOB_DESCRIPTIONS].find_one({"_id": oid})
133
+ if not existing:
134
+ raise ValueError("Job description not found")
135
+
136
+ update_data = _build_update_data(data)
137
+ await db[JOB_DESCRIPTIONS].update_one({"_id": oid}, {"$set": update_data})
138
+ updated = await db[JOB_DESCRIPTIONS].find_one({"_id": oid})
139
+ return str_objectid(updated)
140
+
141
+
142
+ async def delete_admin_job_description(jd_id: str) -> bool:
143
+ db = get_db()
144
+ try:
145
+ oid = ObjectId(jd_id)
146
+ except Exception:
147
+ return False
148
+ result = await db[JOB_DESCRIPTIONS].delete_one({"_id": oid})
149
+ return result.deleted_count > 0
150
+
151
+
152
+ async def get_job_description_for_user(user_id: str, jd_id: str) -> dict:
153
+ db = get_db()
154
+ try:
155
+ oid = ObjectId(jd_id)
156
+ except Exception as exc:
157
+ raise ValueError("Invalid job description id") from exc
158
+
159
+ doc = await db[JOB_DESCRIPTIONS].find_one({"_id": oid, "user_id": user_id})
160
+ if not doc:
161
+ raise ValueError("Job description not found")
162
+ return str_objectid(doc)
backend/utils/gemini.py CHANGED
@@ -1,6 +1,7 @@
1
  from google import genai
2
  from config import get_settings
3
  from utils.skills import normalize_skill_list
 
4
  import json
5
  import re
6
  from langchain_core.prompts import PromptTemplate
@@ -10,6 +11,20 @@ settings = get_settings()
10
  client = genai.Client(api_key=settings.GEMINI_API_KEY)
11
 
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  async def call_gemini(prompt: str, system_instruction: str = None) -> str:
14
  """Call Gemini API with a prompt and optional system instruction."""
15
  config = {}
@@ -17,12 +32,24 @@ async def call_gemini(prompt: str, system_instruction: str = None) -> str:
17
  config["system_instruction"] = system_instruction
18
  config["response_mime_type"] = "application/json"
19
 
20
- response = client.models.generate_content(
21
- model=settings.GEMINI_MODEL,
22
- contents=prompt,
23
- config=config if config else None,
24
- )
25
- return response.text
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
 
28
  def _extract_json_object(text: str) -> str:
@@ -61,6 +88,44 @@ def _fallback_skill_scan(resume_text: str) -> list:
61
  return normalize_skill_list(found)
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  async def parse_resume_with_gemini(resume_text: str) -> dict:
65
  """Parse resume text and extract structured data using Gemini."""
66
  prompt = f"""Analyze the following resume and extract structured information.
@@ -89,8 +154,22 @@ Resume text:
89
 
90
  Return ONLY valid JSON, no markdown formatting."""
91
 
92
- result = await call_gemini(prompt)
93
- result = _extract_json_object(result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
  try:
96
  parsed = json.loads(result)
@@ -123,6 +202,75 @@ Return ONLY valid JSON, no markdown formatting."""
123
  }
124
 
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  async def generate_interview_question(
127
  skills: list,
128
  role_title: str,
@@ -169,10 +317,10 @@ Return ONLY valid JSON, no markdown formatting."""
169
  )
170
  prompt = prompt_template.format(context=context, difficulty=difficulty)
171
 
172
- result = _extract_json_object(await call_gemini(prompt))
173
  try:
 
174
  return json.loads(result)
175
- except json.JSONDecodeError:
176
  return {
177
  "question": f"Tell me about your experience with {skills[0] if skills else 'software development'}.",
178
  "difficulty": difficulty,
@@ -235,8 +383,8 @@ 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")
@@ -302,21 +450,24 @@ async def generate_followup_question_batch_from_qa(
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
@@ -331,8 +482,8 @@ No markdown, no extra text."""
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")
@@ -377,20 +528,25 @@ async def evaluate_interview(questions_and_answers: list, role_title: str) -> di
377
  for i, qa in enumerate(questions_and_answers, 1):
378
  qa_text += f"\nQ{i}: {qa['question']}\nA{i}: {qa['answer']}\n"
379
 
380
- prompt_template = PromptTemplate.from_template(
381
- """You are an interview coach for college students evaluating a candidate for the role: {role_title}
382
 
383
  Here are the interview questions and the candidate's answers:
384
  {qa_text}
385
 
386
- Evaluation style requirements:
387
- 1. Evaluate based on concept understanding (not perfection), effort, and clarity.
388
- 2. If an answer is incomplete:
389
- - acknowledge what is correct,
390
- - gently point out what is missing,
391
- - give a hint instead of giving the full direct answer.
392
- 3. Avoid harsh, discouraging, or overly critical language.
393
- 4. Keep feedback constructive, encouraging, and learning-oriented.
 
 
 
 
 
394
 
395
  Return a JSON object with:
396
  - "overall_score": integer from 0-100
@@ -398,19 +554,19 @@ Return a JSON object with:
398
  - "question": the question text
399
  - "answer": the answer text
400
  - "score": integer 0-100
401
- - "feedback": specific but supportive coaching feedback for this answer
402
  - "strengths": list of 3-5 strength areas
403
- - "weaknesses": list of 3-5 areas for improvement (worded supportively)
404
- - "recommendations": list of 3-5 actionable learning recommendations
405
 
406
  Return ONLY valid JSON, no markdown formatting."""
407
  )
408
  prompt = prompt_template.format(role_title=role_title, qa_text=qa_text)
409
 
410
- result = _extract_json_object(await call_gemini(prompt))
411
  try:
 
412
  return json.loads(result)
413
- except json.JSONDecodeError:
414
  return {
415
  "overall_score": 50,
416
  "detailed_scores": [],
 
1
  from google import genai
2
  from config import get_settings
3
  from utils.skills import normalize_skill_list
4
+ import asyncio
5
  import json
6
  import re
7
  from langchain_core.prompts import PromptTemplate
 
11
  client = genai.Client(api_key=settings.GEMINI_API_KEY)
12
 
13
 
14
+ def _is_transient_gemini_error(error: Exception) -> bool:
15
+ message = str(error or "").lower()
16
+ transient_markers = [
17
+ "503",
18
+ "unavailable",
19
+ "resource_exhausted",
20
+ "high demand",
21
+ "deadline",
22
+ "timed out",
23
+ "timeout",
24
+ ]
25
+ return any(marker in message for marker in transient_markers)
26
+
27
+
28
  async def call_gemini(prompt: str, system_instruction: str = None) -> str:
29
  """Call Gemini API with a prompt and optional system instruction."""
30
  config = {}
 
32
  config["system_instruction"] = system_instruction
33
  config["response_mime_type"] = "application/json"
34
 
35
+ last_error = None
36
+ max_attempts = 3
37
+ for attempt in range(max_attempts):
38
+ try:
39
+ response = client.models.generate_content(
40
+ model=settings.GEMINI_MODEL,
41
+ contents=prompt,
42
+ config=config if config else None,
43
+ )
44
+ return (response.text or "").strip()
45
+ except Exception as exc:
46
+ last_error = exc
47
+ if _is_transient_gemini_error(exc) and attempt < max_attempts - 1:
48
+ await asyncio.sleep(0.8 * (attempt + 1))
49
+ continue
50
+ break
51
+
52
+ raise RuntimeError(f"Gemini request failed: {last_error}")
53
 
54
 
55
  def _extract_json_object(text: str) -> str:
 
88
  return normalize_skill_list(found)
89
 
90
 
91
+ def _is_loose_answer(answer: str) -> bool:
92
+ text = (answer or "").strip().lower()
93
+ if not text:
94
+ return True
95
+
96
+ word_count = len(text.split())
97
+ if word_count < 18:
98
+ return True
99
+
100
+ weak_markers = [
101
+ "i think",
102
+ "maybe",
103
+ "not sure",
104
+ "dont know",
105
+ "don't know",
106
+ "something like",
107
+ "etc",
108
+ "kind of",
109
+ "sort of",
110
+ ]
111
+ return any(marker in text for marker in weak_markers)
112
+
113
+
114
+ def _collect_loose_qa(qa_pairs: list, limit: int = 4) -> list:
115
+ loose = []
116
+ for qa in reversed(qa_pairs or []):
117
+ question = (qa or {}).get("question", "")
118
+ answer = (qa or {}).get("answer", "")
119
+ if not question or not answer:
120
+ continue
121
+ if _is_loose_answer(answer):
122
+ loose.append({"question": question, "answer": answer})
123
+ if len(loose) >= limit:
124
+ break
125
+ loose.reverse()
126
+ return loose
127
+
128
+
129
  async def parse_resume_with_gemini(resume_text: str) -> dict:
130
  """Parse resume text and extract structured data using Gemini."""
131
  prompt = f"""Analyze the following resume and extract structured information.
 
154
 
155
  Return ONLY valid JSON, no markdown formatting."""
156
 
157
+ try:
158
+ result = await call_gemini(prompt)
159
+ result = _extract_json_object(result)
160
+ except Exception:
161
+ return {
162
+ "name": None,
163
+ "email": None,
164
+ "phone": None,
165
+ "location": None,
166
+ "skills": _fallback_skill_scan(resume_text),
167
+ "recommended_roles": [],
168
+ "experience_summary": "Unable to parse with AI right now. Please retry.",
169
+ "experience": [],
170
+ "education": [],
171
+ "projects": [],
172
+ }
173
 
174
  try:
175
  parsed = json.loads(result)
 
202
  }
203
 
204
 
205
+ async def analyze_resume_vs_job_description(
206
+ role_title: str,
207
+ resume_skills: list,
208
+ resume_summary: str,
209
+ jd_title: str,
210
+ jd_description: str,
211
+ jd_required_skills: list | None = None,
212
+ ) -> dict:
213
+ """Compare resume and job description to produce interview guidance."""
214
+ jd_required_skills = jd_required_skills or []
215
+ prompt = f"""You are an interview coach helping a student prepare for a job.
216
+
217
+ Role title: {role_title}
218
+ Job Description Title: {jd_title}
219
+ Job Description Text:
220
+ ---
221
+ {jd_description}
222
+ ---
223
+
224
+ Job Description Required Skills (if provided): {json.dumps(jd_required_skills)}
225
+
226
+ Student Resume Skills: {json.dumps(resume_skills)}
227
+ Student Resume Summary:
228
+ ---
229
+ {resume_summary}
230
+ ---
231
+
232
+ Return ONLY valid JSON with this structure:
233
+ {{
234
+ "meeting_expectations": ["..."],
235
+ "missing_expectations": ["..."],
236
+ "improvement_suggestions": ["..."],
237
+ "fit_summary": "short summary"
238
+ }}
239
+
240
+ Rules:
241
+ 1) Be practical and concise.
242
+ 2) Mention what already matches first.
243
+ 3) Missing expectations should be specific and skill/experience-oriented.
244
+ 4) Suggestions should be actionable and student-friendly.
245
+ 5) Avoid harsh wording.
246
+ """
247
+
248
+ try:
249
+ result = _extract_json_object(await call_gemini(prompt))
250
+ parsed = json.loads(result)
251
+ return {
252
+ "meeting_expectations": parsed.get("meeting_expectations", [])[:10],
253
+ "missing_expectations": parsed.get("missing_expectations", [])[:10],
254
+ "improvement_suggestions": parsed.get("improvement_suggestions", [])[:10],
255
+ "fit_summary": parsed.get("fit_summary", ""),
256
+ }
257
+ except Exception:
258
+ resume_set = {s.lower() for s in normalize_skill_list(resume_skills)}
259
+ required = normalize_skill_list(jd_required_skills)
260
+ missing = [s for s in required if s.lower() not in resume_set]
261
+ met = [s for s in required if s.lower() in resume_set]
262
+ return {
263
+ "meeting_expectations": met[:6],
264
+ "missing_expectations": missing[:6],
265
+ "improvement_suggestions": [
266
+ "Build 1-2 focused projects aligned with missing JD skills.",
267
+ "Use STAR-style examples for your strongest matching skills.",
268
+ "Revise resume bullets to highlight measurable impact.",
269
+ ],
270
+ "fit_summary": "You match some expectations and can improve fit by addressing the missing skills.",
271
+ }
272
+
273
+
274
  async def generate_interview_question(
275
  skills: list,
276
  role_title: str,
 
317
  )
318
  prompt = prompt_template.format(context=context, difficulty=difficulty)
319
 
 
320
  try:
321
+ result = _extract_json_object(await call_gemini(prompt))
322
  return json.loads(result)
323
+ except Exception:
324
  return {
325
  "question": f"Tell me about your experience with {skills[0] if skills else 'software development'}.",
326
  "difficulty": difficulty,
 
383
  )
384
  prompt = prompt_template.format(context=context, count=count)
385
 
 
386
  try:
387
+ result = (await call_gemini(prompt)).strip()
388
  data = json.loads(result)
389
  if not isinstance(data, list):
390
  raise ValueError("Batch response is not a list")
 
450
  "difficulty": difficulty,
451
  "count": count,
452
  "answered_qa": compact_qa,
453
+ "loose_qa": _collect_loose_qa(qa_pairs),
454
  "previous_questions": previous_questions,
455
  }
456
 
457
  prompt_template = PromptTemplate.from_template(
458
+ """You are generating strict, concept-focused technical interview follow-up questions.
459
 
460
  Input JSON:
461
  {payload}
462
 
463
  Instructions:
464
  1. Generate exactly {count} follow-up questions using answered_qa context.
465
+ 2. Questions must continue naturally from candidate's previous answers.
466
  3. Do not repeat or paraphrase any question in previous_questions.
467
+ 4. Prioritize loose_qa first: if any answer is vague/short/uncertain, ask a direct follow-up that probes missing concept depth.
468
+ 5. Focus on concept validation (why, how, trade-offs, failure modes), not memorized definitions.
469
+ 6. Keep questions practical and role-relevant.
470
+ 7. Use difficulty {difficulty}. Do not output easy/basic-level questions.
471
 
472
  Return ONLY valid JSON array with objects:
473
  - "question": string
 
482
  difficulty=difficulty,
483
  )
484
 
 
485
  try:
486
+ result = (await call_gemini(prompt)).strip()
487
  data = json.loads(result)
488
  if not isinstance(data, list):
489
  raise ValueError("Follow-up batch response is not a list")
 
528
  for i, qa in enumerate(questions_and_answers, 1):
529
  qa_text += f"\nQ{i}: {qa['question']}\nA{i}: {qa['answer']}\n"
530
 
531
+ prompt_template = PromptTemplate.from_template(
532
+ """You are a strict technical interviewer evaluating a candidate for the role: {role_title}.
533
 
534
  Here are the interview questions and the candidate's answers:
535
  {qa_text}
536
 
537
+ Scoring policy (concept-first, strict):
538
+ 1. Score primarily on conceptual correctness, depth, and reasoning quality.
539
+ 2. Do NOT reward answer length, confidence, or communication style when concepts are wrong.
540
+ 3. Penalize vague, hand-wavy, or uncertain answers.
541
+ 4. Penalize technically incorrect claims even if explanation sounds fluent.
542
+ 5. Reward precise mechanisms, trade-offs, edge cases, and debugging logic.
543
+
544
+ Score rubric per answer:
545
+ - 90-100: conceptually correct, deep, and accurate with strong reasoning
546
+ - 70-89: mostly correct with minor conceptual gaps
547
+ - 50-69: partially correct but misses key mechanisms
548
+ - 30-49: shallow/vague with major conceptual gaps
549
+ - 0-29: incorrect or off-topic
550
 
551
  Return a JSON object with:
552
  - "overall_score": integer from 0-100
 
554
  - "question": the question text
555
  - "answer": the answer text
556
  - "score": integer 0-100
557
+ - "feedback": concise concept-focused feedback for this answer
558
  - "strengths": list of 3-5 strength areas
559
+ - "weaknesses": list of 3-5 concept gaps
560
+ - "recommendations": list of 3-5 actionable concept-improvement recommendations
561
 
562
  Return ONLY valid JSON, no markdown formatting."""
563
  )
564
  prompt = prompt_template.format(role_title=role_title, qa_text=qa_text)
565
 
 
566
  try:
567
+ result = _extract_json_object(await call_gemini(prompt))
568
  return json.loads(result)
569
+ except Exception:
570
  return {
571
  "overall_score": 50,
572
  "detailed_scores": [],