sajith-0701 commited on
Commit
15145f6
Β·
1 Parent(s): ed1123c

updated for chatbot

backend/models/collections.py CHANGED
@@ -13,3 +13,5 @@ TOPIC_QUESTIONS = "topic_questions"
13
  SESSIONS = "sessions"
14
  ANSWERS = "answers"
15
  RESULTS = "results"
 
 
 
13
  SESSIONS = "sessions"
14
  ANSWERS = "answers"
15
  RESULTS = "results"
16
+ GROUP_TESTS = "group_tests"
17
+ GROUP_TEST_RESULTS = "group_test_results"
backend/requirements.txt CHANGED
@@ -17,3 +17,4 @@ aiofiles==24.1.0
17
  pypdf==5.4.0
18
  python-docx==1.1.2
19
  faster-whisper==1.0.3
 
 
17
  pypdf==5.4.0
18
  python-docx==1.1.2
19
  faster-whisper==1.0.3
20
+ openpyxl>=3.1.0
backend/routers/admin.py CHANGED
@@ -1,11 +1,14 @@
1
  import json
2
  from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
 
3
  from auth.jwt import require_role, get_current_user
4
  from schemas.admin import (
5
  JobRoleCreate, JobRoleUpdate,
6
  QuestionCreate, QuestionUpdate,
7
  RoleRequirementCreate,
8
  TopicCreate, TopicUpdate, TopicPublishUpdate,
 
 
9
  )
10
  from services.admin_service import (
11
  create_role, update_role, delete_role, list_roles,
@@ -21,6 +24,16 @@ from services.job_description_service import (
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
 
@@ -417,6 +430,33 @@ async def delete_admin_job_description_endpoint(
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,
@@ -432,3 +472,143 @@ async def delete_admin_user_endpoint(
432
  detail = str(e)
433
  status_code = 404 if "not found" in detail.lower() else 400
434
  raise HTTPException(status_code=status_code, detail=detail)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import json
2
  from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
3
+ from fastapi.responses import StreamingResponse
4
  from auth.jwt import require_role, get_current_user
5
  from schemas.admin import (
6
  JobRoleCreate, JobRoleUpdate,
7
  QuestionCreate, QuestionUpdate,
8
  RoleRequirementCreate,
9
  TopicCreate, TopicUpdate, TopicPublishUpdate,
10
+ GroupTestCreate, GroupTestUpdate, GroupTestPublishUpdate,
11
+ ChatbotQueryRequest, ChatbotExportRequest, ChatbotStudentUpdate,
12
  )
13
  from services.admin_service import (
14
  create_role, update_role, delete_role, list_roles,
 
24
  list_admin_job_descriptions,
25
  update_admin_job_description,
26
  delete_admin_job_description,
27
+ parse_jd_from_file,
28
+ )
29
+ from services.group_test_service import (
30
+ create_group_test,
31
+ list_group_tests,
32
+ get_group_test,
33
+ update_group_test,
34
+ delete_group_test,
35
+ set_group_test_publish,
36
+ get_group_test_results_admin,
37
  )
38
  from services.analytics_service import get_admin_analytics
39
 
 
430
  return {"message": "Job description deleted"}
431
 
432
 
433
+ @router.post("/job-descriptions/parse-file")
434
+ async def parse_admin_jd_file(
435
+ file: UploadFile = File(...),
436
+ current_user: dict = Depends(require_role("admin")),
437
+ ):
438
+ """Upload a JD file (PDF/DOCX/TXT) and extract structured fields via AI (admin only)."""
439
+ if not file.filename:
440
+ raise HTTPException(status_code=400, detail="No file provided")
441
+
442
+ allowed_ext = {".pdf", ".doc", ".docx", ".txt"}
443
+ ext = "." + file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
444
+ if ext not in allowed_ext:
445
+ raise HTTPException(status_code=400, detail="Unsupported file type. Allowed: PDF, DOC, DOCX, TXT")
446
+
447
+ content = await file.read()
448
+ if len(content) > 10 * 1024 * 1024:
449
+ raise HTTPException(status_code=400, detail="File too large. Maximum 10MB")
450
+
451
+ try:
452
+ result = await parse_jd_from_file(file.filename, content)
453
+ return result
454
+ except ValueError as e:
455
+ raise HTTPException(status_code=400, detail=str(e))
456
+ except Exception as e:
457
+ raise HTTPException(status_code=500, detail=f"Failed to parse JD file: {str(e)}")
458
+
459
+
460
  @router.delete("/users/{user_id}")
461
  async def delete_admin_user_endpoint(
462
  user_id: str,
 
472
  detail = str(e)
473
  status_code = 404 if "not found" in detail.lower() else 400
474
  raise HTTPException(status_code=status_code, detail=detail)
475
+
476
+
477
+ # ─── Group Tests ─────────────────────────────────────────────────────────────
478
+
479
+ @router.get("/group-tests")
480
+ async def list_group_tests_endpoint(
481
+ current_user: dict = Depends(require_role("admin")),
482
+ ):
483
+ items = await list_group_tests(only_published=False)
484
+ return {"items": items}
485
+
486
+
487
+ @router.post("/group-tests")
488
+ async def create_group_test_endpoint(
489
+ request: GroupTestCreate,
490
+ current_user: dict = Depends(require_role("admin")),
491
+ ):
492
+ try:
493
+ result = await create_group_test(
494
+ name=request.name,
495
+ description=request.description,
496
+ topic_ids=request.topic_ids,
497
+ time_limit_minutes=request.time_limit_minutes,
498
+ max_attempts=request.max_attempts,
499
+ created_by=current_user["user_id"],
500
+ )
501
+ return result
502
+ except ValueError as e:
503
+ raise HTTPException(status_code=400, detail=str(e))
504
+
505
+
506
+ @router.get("/group-tests/{group_test_id}")
507
+ async def get_group_test_endpoint(
508
+ group_test_id: str,
509
+ current_user: dict = Depends(require_role("admin")),
510
+ ):
511
+ try:
512
+ return await get_group_test(group_test_id)
513
+ except ValueError as e:
514
+ raise HTTPException(status_code=404, detail=str(e))
515
+
516
+
517
+ @router.put("/group-tests/{group_test_id}")
518
+ async def update_group_test_endpoint(
519
+ group_test_id: str,
520
+ request: GroupTestUpdate,
521
+ current_user: dict = Depends(require_role("admin")),
522
+ ):
523
+ try:
524
+ return await update_group_test(group_test_id, request.model_dump(exclude_none=True))
525
+ except ValueError as e:
526
+ raise HTTPException(status_code=400, detail=str(e))
527
+
528
+
529
+ @router.delete("/group-tests/{group_test_id}")
530
+ async def delete_group_test_endpoint(
531
+ group_test_id: str,
532
+ current_user: dict = Depends(require_role("admin")),
533
+ ):
534
+ success = await delete_group_test(group_test_id)
535
+ if not success:
536
+ raise HTTPException(status_code=404, detail="Group test not found")
537
+ return {"message": "Group test deleted"}
538
+
539
+
540
+ @router.patch("/group-tests/{group_test_id}/publish")
541
+ async def publish_group_test_endpoint(
542
+ group_test_id: str,
543
+ request: GroupTestPublishUpdate,
544
+ current_user: dict = Depends(require_role("admin")),
545
+ ):
546
+ try:
547
+ return await set_group_test_publish(group_test_id, request.is_published)
548
+ except ValueError as e:
549
+ raise HTTPException(status_code=404, detail=str(e))
550
+
551
+
552
+ @router.get("/group-tests/{group_test_id}/results")
553
+ async def get_group_test_results_endpoint(
554
+ group_test_id: str,
555
+ current_user: dict = Depends(require_role("admin")),
556
+ ):
557
+ results = await get_group_test_results_admin(group_test_id)
558
+ return {"items": results}
559
+
560
+
561
+ # ─── Chatbot ──────────────────────────────────────────────────────────────────
562
+ from services.chatbot_service import (
563
+ process_chatbot_query,
564
+ update_student_info,
565
+ generate_excel,
566
+ )
567
+
568
+
569
+ @router.post("/chatbot/query")
570
+ async def chatbot_query(
571
+ request: ChatbotQueryRequest,
572
+ current_user: dict = Depends(require_role("admin")),
573
+ ):
574
+ """AI-powered student filter β€” returns ranked student rows."""
575
+ try:
576
+ result = await process_chatbot_query(request.query, request.jd_id)
577
+ return result
578
+ except Exception as e:
579
+ raise HTTPException(status_code=500, detail=str(e))
580
+
581
+
582
+ @router.post("/chatbot/export-excel")
583
+ async def chatbot_export_excel(
584
+ request: ChatbotExportRequest,
585
+ current_user: dict = Depends(require_role("admin")),
586
+ ):
587
+ """Generate styled Excel (.xlsx) from current chatbot result rows."""
588
+ try:
589
+ bio = generate_excel(
590
+ rows=request.rows,
591
+ topic_columns=request.topic_columns,
592
+ group_test_name=request.group_test_name,
593
+ )
594
+ safe_name = request.group_test_name.replace(" ", "_").replace("/", "-")[:40]
595
+ filename = f"{safe_name}_students.xlsx"
596
+ return StreamingResponse(
597
+ bio,
598
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
599
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
600
+ )
601
+ except Exception as e:
602
+ raise HTTPException(status_code=500, detail=str(e))
603
+
604
+
605
+ @router.patch("/chatbot/students")
606
+ async def chatbot_update_student(
607
+ request: ChatbotStudentUpdate,
608
+ current_user: dict = Depends(require_role("admin")),
609
+ ):
610
+ """Admin corrects a student's reg_no or name."""
611
+ try:
612
+ return await update_student_info(request.user_id, request.reg_no, request.name)
613
+ except ValueError as e:
614
+ raise HTTPException(status_code=400, detail=str(e))
backend/routers/profile.py CHANGED
@@ -10,7 +10,18 @@ from services.job_description_service import (
10
  list_my_job_descriptions,
11
  update_my_job_description,
12
  delete_my_job_description,
 
13
  )
 
 
 
 
 
 
 
 
 
 
14
 
15
  router = APIRouter()
16
 
@@ -33,6 +44,7 @@ async def get_profile(current_user: dict = Depends(get_current_user)):
33
  "speech_settings": {
34
  "voice_gender": (user or {}).get("speech_settings", {}).get("voice_gender", "female"),
35
  },
 
36
  }
37
 
38
  # Get resume info
@@ -169,3 +181,102 @@ async def delete_my_job_description_endpoint(
169
  if not success:
170
  raise HTTPException(status_code=404, detail="Job description not found")
171
  return {"message": "Job description deleted"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  list_my_job_descriptions,
11
  update_my_job_description,
12
  delete_my_job_description,
13
+ parse_jd_from_file,
14
  )
15
+ from services.group_test_service import (
16
+ list_group_tests,
17
+ get_group_test,
18
+ start_group_test_attempt,
19
+ get_group_test_result,
20
+ link_topic_session,
21
+ get_my_group_test_results,
22
+ get_my_group_test_attempt,
23
+ )
24
+ from fastapi import UploadFile, File
25
 
26
  router = APIRouter()
27
 
 
44
  "speech_settings": {
45
  "voice_gender": (user or {}).get("speech_settings", {}).get("voice_gender", "female"),
46
  },
47
+ "reg_no": (user or {}).get("reg_no") or None,
48
  }
49
 
50
  # Get resume info
 
181
  if not success:
182
  raise HTTPException(status_code=404, detail="Job description not found")
183
  return {"message": "Job description deleted"}
184
+
185
+
186
+ @router.post("/job-descriptions/parse-file")
187
+ async def parse_jd_file_for_user(
188
+ file: UploadFile = File(...),
189
+ current_user: dict = Depends(get_current_user),
190
+ ):
191
+ """Upload a JD file (PDF/DOCX/TXT) and extract structured fields via AI."""
192
+ if not file.filename:
193
+ raise HTTPException(status_code=400, detail="No file provided")
194
+
195
+ allowed_ext = {".pdf", ".doc", ".docx", ".txt"}
196
+ ext = "." + file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
197
+ if ext not in allowed_ext:
198
+ raise HTTPException(status_code=400, detail="Unsupported file type. Allowed: PDF, DOC, DOCX, TXT")
199
+
200
+ content = await file.read()
201
+ if len(content) > 10 * 1024 * 1024:
202
+ raise HTTPException(status_code=400, detail="File too large. Maximum 10MB")
203
+
204
+ try:
205
+ result = await parse_jd_from_file(file.filename, content)
206
+ return result
207
+ except ValueError as e:
208
+ raise HTTPException(status_code=400, detail=str(e))
209
+ except Exception as e:
210
+ raise HTTPException(status_code=500, detail=f"Failed to parse JD file: {str(e)}")
211
+
212
+
213
+ # ─── Group Tests (student) ───────────────────────────────────────────────────
214
+
215
+ @router.get("/group-tests")
216
+ async def list_available_group_tests(
217
+ current_user: dict = Depends(get_current_user),
218
+ ):
219
+ """List published group tests available to students."""
220
+ items = await list_group_tests(only_published=True)
221
+ return {"items": items}
222
+
223
+
224
+ @router.get("/group-tests/my-results")
225
+ async def my_group_test_results(
226
+ current_user: dict = Depends(get_current_user),
227
+ ):
228
+ """List all group test results for the current student."""
229
+ items = await get_my_group_test_results(current_user["user_id"])
230
+ return {"items": items}
231
+
232
+
233
+ @router.post("/group-tests/{group_test_id}/start")
234
+ async def start_group_test(
235
+ group_test_id: str,
236
+ current_user: dict = Depends(get_current_user),
237
+ ):
238
+ """Start a new attempt for a group test."""
239
+ try:
240
+ result = await start_group_test_attempt(group_test_id, current_user["user_id"])
241
+ return result
242
+ except ValueError as e:
243
+ raise HTTPException(status_code=400, detail=str(e))
244
+
245
+
246
+ @router.get("/group-tests/{group_test_id}/my-attempt")
247
+ async def get_my_attempt(
248
+ group_test_id: str,
249
+ current_user: dict = Depends(get_current_user),
250
+ ):
251
+ """Get student's latest attempt at a group test."""
252
+ result = await get_my_group_test_attempt(group_test_id, current_user["user_id"])
253
+ return result # may be None
254
+
255
+
256
+ @router.get("/group-tests/results/{result_id}")
257
+ async def get_result_detail(
258
+ result_id: str,
259
+ current_user: dict = Depends(get_current_user),
260
+ ):
261
+ """Get full detail of a group test result."""
262
+ try:
263
+ return await get_group_test_result(result_id, current_user["user_id"])
264
+ except ValueError as e:
265
+ raise HTTPException(status_code=404, detail=str(e))
266
+
267
+
268
+ @router.post("/group-tests/results/{result_id}/link-topic")
269
+ async def link_topic_to_result(
270
+ result_id: str,
271
+ request_data: dict,
272
+ current_user: dict = Depends(get_current_user),
273
+ ):
274
+ """Link a completed interview session to a topic inside a group test result."""
275
+ topic_id = (request_data.get("topic_id") or "").strip()
276
+ session_id = (request_data.get("session_id") or "").strip()
277
+ if not topic_id or not session_id:
278
+ raise HTTPException(status_code=400, detail="topic_id and session_id are required")
279
+ try:
280
+ return await link_topic_session(result_id, current_user["user_id"], topic_id, session_id)
281
+ except ValueError as e:
282
+ raise HTTPException(status_code=400, detail=str(e))
backend/schemas/admin.py CHANGED
@@ -79,8 +79,52 @@ class RoleRequirementCreate(BaseModel):
79
  level: str = "intermediate"
80
 
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  class RoleRequirementResponse(BaseModel):
83
  id: str
84
  role_id: str
85
  skill: str
86
  level: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  level: str = "intermediate"
80
 
81
 
82
+ class GroupTestCreate(BaseModel):
83
+ name: str
84
+ description: Optional[str] = None
85
+ topic_ids: List[str]
86
+ time_limit_minutes: Optional[int] = None
87
+ max_attempts: int = 1
88
+
89
+
90
+ class GroupTestUpdate(BaseModel):
91
+ name: Optional[str] = None
92
+ description: Optional[str] = None
93
+ topic_ids: Optional[List[str]] = None
94
+ time_limit_minutes: Optional[int] = None
95
+ max_attempts: Optional[int] = None
96
+
97
+
98
+ class GroupTestPublishUpdate(BaseModel):
99
+ is_published: bool
100
+
101
+
102
+ class LinkTopicSessionRequest(BaseModel):
103
+ topic_id: str
104
+ session_id: str
105
+
106
+
107
  class RoleRequirementResponse(BaseModel):
108
  id: str
109
  role_id: str
110
  skill: str
111
  level: str
112
+
113
+
114
+ # ── Chatbot ───────────────────────────────────────────────────────────────────
115
+
116
+ class ChatbotQueryRequest(BaseModel):
117
+ query: str
118
+ jd_id: Optional[str] = None
119
+
120
+
121
+ class ChatbotExportRequest(BaseModel):
122
+ rows: List[dict]
123
+ topic_columns: List[dict]
124
+ group_test_name: str
125
+
126
+
127
+ class ChatbotStudentUpdate(BaseModel):
128
+ user_id: str
129
+ reg_no: Optional[str] = None
130
+ name: Optional[str] = None
backend/services/admin_service.py CHANGED
@@ -600,6 +600,7 @@ async def list_admin_users(limit: int = 500) -> list:
600
  "name": normalized.get("name", ""),
601
  "email": normalized.get("email", ""),
602
  "role": normalized.get("role", "student"),
 
603
  "created_at": normalized.get("created_at", ""),
604
  "interview_count": interview_map.get(user_id, 0),
605
  "report_count": report_map.get(user_id, 0),
 
600
  "name": normalized.get("name", ""),
601
  "email": normalized.get("email", ""),
602
  "role": normalized.get("role", "student"),
603
+ "reg_no": normalized.get("reg_no") or None,
604
  "created_at": normalized.get("created_at", ""),
605
  "interview_count": interview_map.get(user_id, 0),
606
  "report_count": report_map.get(user_id, 0),
backend/services/chatbot_service.py ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin chatbot service β€” AI-powered student filtering & report generation."""
2
+
3
+ import json
4
+ import re
5
+ from collections import defaultdict
6
+ from io import BytesIO
7
+
8
+ from bson import ObjectId
9
+ from openpyxl import Workbook
10
+ from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
11
+ from openpyxl.utils import get_column_letter
12
+
13
+ from database import get_db
14
+ from models.collections import (
15
+ GROUP_TESTS,
16
+ GROUP_TEST_RESULTS,
17
+ JOB_DESCRIPTIONS,
18
+ SKILLS,
19
+ TOPICS,
20
+ USERS,
21
+ )
22
+ from services.group_test_service import _refresh_topic_statuses
23
+ from utils.gemini import call_gemini
24
+ from utils.helpers import str_objectids
25
+
26
+
27
+ # ─── Gemini query parser ─────────────────────────────────────────────────────
28
+
29
+ async def _parse_query(query: str, group_tests: list[dict], jd_content: str | None) -> dict:
30
+ """Ask Gemini to extract structured filter parameters from a natural-language query."""
31
+ gt_list = [{"id": gt["id"], "name": gt["name"]} for gt in group_tests]
32
+
33
+ jd_context = ""
34
+ if jd_content:
35
+ jd_context = (
36
+ f"\n\nJob Description:\n{jd_content}\n"
37
+ "Use this JD to rank students by skill relevance when use_jd_ranking is true."
38
+ )
39
+
40
+ prompt = (
41
+ f'Admin query: "{query}"\n\n'
42
+ f"Available group tests: {json.dumps(gt_list)}"
43
+ f"{jd_context}\n\n"
44
+ "Extract filter parameters and return ONLY a JSON object (no markdown, no extra text):\n"
45
+ "{\n"
46
+ ' "group_test_id": "<id from the list, or null if none matches>",\n'
47
+ ' "group_test_name": "<matched name or null>",\n'
48
+ ' "top_k": <integer or null>,\n'
49
+ ' "min_score": <number 0-100 or null>,\n'
50
+ ' "use_jd_ranking": <true if JD was provided and should influence ranking>,\n'
51
+ ' "response_message": "<short 1-2 sentence message describing the filter result>"\n'
52
+ "}\n\n"
53
+ "Rules:\n"
54
+ "- Match group_test_id to the best-fitting group test. null = show all students across all tests.\n"
55
+ "- top_k: number from phrases like 'top 5', 'top k', 'best 10'. null = all.\n"
56
+ "- min_score: extract from 'score above 70', 'minimum 80%'. null = no filter.\n"
57
+ "- response_message: friendly description of what was filtered.\n"
58
+ "Return ONLY valid JSON, no other text."
59
+ )
60
+
61
+ raw = await call_gemini(prompt)
62
+ cleaned = raw.strip()
63
+ if cleaned.startswith("```"):
64
+ cleaned = re.sub(r"```[a-z]*\n?", "", cleaned).strip().rstrip("`").strip()
65
+ try:
66
+ return json.loads(cleaned)
67
+ except Exception:
68
+ # Fallback: return empty params so the caller can show a helpful message
69
+ return {
70
+ "group_test_id": None,
71
+ "group_test_name": None,
72
+ "top_k": None,
73
+ "min_score": None,
74
+ "use_jd_ranking": False,
75
+ "response_message": "I couldn't understand the query. Please try something like: 'top 5 students in SWE group'.",
76
+ }
77
+
78
+
79
+ # ─── Data helpers ─────────────────────────────────────────────────────────────
80
+
81
+ async def _user_info(user_id: str, db) -> dict:
82
+ try:
83
+ user = await db[USERS].find_one({"_id": ObjectId(user_id)})
84
+ except Exception:
85
+ user = None
86
+ if not user:
87
+ return {"reg_no": "N/A", "name": "", "email": ""}
88
+ return {
89
+ "reg_no": user.get("reg_no") or "N/A",
90
+ "name": user.get("name", ""),
91
+ "email": user.get("email", ""),
92
+ }
93
+
94
+
95
+ async def _jd_skills(jd_id: str, db) -> tuple[str | None, list[str]]:
96
+ """Return (jd_content_str, required_skills_list)."""
97
+ try:
98
+ doc = await db[JOB_DESCRIPTIONS].find_one({"_id": ObjectId(jd_id)})
99
+ except Exception:
100
+ doc = None
101
+ if not doc:
102
+ return None, []
103
+ content = f"Title: {doc.get('title', '')}\n{doc.get('description', '')}"
104
+ return content, doc.get("required_skills") or []
105
+
106
+
107
+ def _skill_match_pct(student_skills: list[str], jd_skills: list[str]) -> float | None:
108
+ if not jd_skills:
109
+ return None
110
+ s_lower = [s.lower() for s in student_skills]
111
+ j_lower = [s.lower() for s in jd_skills]
112
+ matched = sum(1 for j in j_lower if any(j in s or s in j for s in s_lower))
113
+ return round(matched / len(j_lower) * 100, 1)
114
+
115
+
116
+ # ─── Main query processor ────────────────────────────────────────────────────
117
+
118
+ async def process_chatbot_query(query: str, jd_id: str | None) -> dict:
119
+ """Parse admin query, aggregate student data, apply filters, return ranked rows."""
120
+ db = get_db()
121
+
122
+ # Fetch all group tests
123
+ gt_cursor = db[GROUP_TESTS].find({}).sort("created_at", -1)
124
+ gt_docs = await gt_cursor.to_list(length=300)
125
+ all_group_tests = [
126
+ {
127
+ "id": str(d["_id"]),
128
+ "name": d.get("name", ""),
129
+ "topic_ids": d.get("topic_ids") or [],
130
+ }
131
+ for d in gt_docs
132
+ ]
133
+
134
+ # Fetch JD if provided
135
+ jd_content, jd_req_skills = (None, [])
136
+ if jd_id:
137
+ jd_content, jd_req_skills = await _jd_skills(jd_id, db)
138
+
139
+ # Let Gemini parse the query
140
+ parsed = await _parse_query(query, all_group_tests, jd_content)
141
+
142
+ group_test_id: str | None = parsed.get("group_test_id")
143
+ top_k: int | None = parsed.get("top_k")
144
+ min_score: float | None = parsed.get("min_score")
145
+ use_jd_ranking: bool = bool(parsed.get("use_jd_ranking")) and bool(jd_req_skills)
146
+ response_message: str = parsed.get("response_message") or "Here are the filtered results."
147
+
148
+ # Build topic column list from the matched group test
149
+ topic_columns: list[dict] = []
150
+ group_test_name: str = ""
151
+
152
+ if group_test_id:
153
+ gt_doc = next((g for g in all_group_tests if g["id"] == group_test_id), None)
154
+ if gt_doc:
155
+ group_test_name = gt_doc["name"]
156
+ for tid in gt_doc["topic_ids"]:
157
+ try:
158
+ t = await db[TOPICS].find_one({"_id": ObjectId(tid)})
159
+ except Exception:
160
+ t = None
161
+ if t:
162
+ topic_columns.append({"id": tid, "name": t.get("name", tid)})
163
+
164
+ # Fetch relevant results
165
+ results_filter = {"group_test_id": group_test_id} if group_test_id else {}
166
+ results_cursor = db[GROUP_TEST_RESULTS].find(results_filter)
167
+ results_docs = await results_cursor.to_list(length=2000)
168
+
169
+ # Group by user_id; pick best attempt per user per group_test
170
+ # Key: (user_id, group_test_id) β†’ list of attempts
171
+ attempts_map: dict[tuple, list] = defaultdict(list)
172
+ for r in results_docs:
173
+ key = (r.get("user_id", ""), r.get("group_test_id", ""))
174
+ attempts_map[key].append(r)
175
+
176
+ rows: list[dict] = []
177
+ seen_users: set[str] = set()
178
+
179
+ for (uid, gt_id), attempts in attempts_map.items():
180
+ if not uid:
181
+ continue
182
+
183
+ # Refresh topic statuses and choose best attempt
184
+ best = None
185
+ for attempt in attempts:
186
+ attempt = await _refresh_topic_statuses(attempt, db)
187
+ score = attempt.get("overall_score") or 0
188
+ if best is None or score > (best.get("overall_score") or 0):
189
+ best = attempt
190
+
191
+ user = await _user_info(uid, db)
192
+
193
+ # Per-topic scores from best attempt
194
+ topic_scores: dict[str, dict] = {}
195
+ for tr in best.get("topic_results") or []:
196
+ tid = tr.get("topic_id", "")
197
+ topic_scores[tid] = {
198
+ "topic_name": tr.get("topic_name", ""),
199
+ "score": tr.get("overall_score"),
200
+ "status": tr.get("status", "pending"),
201
+ }
202
+
203
+ # JD skill match
204
+ skill_match: float | None = None
205
+ if use_jd_ranking:
206
+ skills_doc = await db[SKILLS].find_one({"user_id": uid})
207
+ student_skills = (skills_doc or {}).get("skills") or []
208
+ skill_match = _skill_match_pct(student_skills, jd_req_skills)
209
+
210
+ row = {
211
+ "user_id": uid,
212
+ "reg_no": user["reg_no"],
213
+ "name": user["name"],
214
+ "email": user["email"],
215
+ "group_test_id": gt_id,
216
+ "group_test_name": best.get("group_test_name") or group_test_name,
217
+ "overall_score": round(best.get("overall_score") or 0, 1),
218
+ "total_attempts": len(attempts),
219
+ "status": best.get("status", "in_progress"),
220
+ "topic_scores": topic_scores,
221
+ "skill_match": skill_match,
222
+ "rank": 0, # assigned below
223
+ }
224
+ rows.append(row)
225
+
226
+ # If multiple group tests queried (no filter), collect unique topic columns
227
+ if not group_test_id:
228
+ topic_set: dict[str, str] = {}
229
+ for r in rows:
230
+ for tid, ts in r["topic_scores"].items():
231
+ if tid not in topic_set:
232
+ topic_set[tid] = ts["topic_name"]
233
+ topic_columns = [{"id": tid, "name": name} for tid, name in topic_set.items()]
234
+
235
+ # Sort
236
+ if use_jd_ranking:
237
+ rows.sort(
238
+ key=lambda r: (r["skill_match"] or 0) * 0.4 + (r["overall_score"] or 0) * 0.6,
239
+ reverse=True,
240
+ )
241
+ else:
242
+ rows.sort(key=lambda r: r["overall_score"] or 0, reverse=True)
243
+
244
+ # Min score filter
245
+ if min_score is not None:
246
+ rows = [r for r in rows if (r["overall_score"] or 0) >= min_score]
247
+
248
+ # Assign ranks
249
+ for i, row in enumerate(rows):
250
+ row["rank"] = i + 1
251
+
252
+ # Top-k slice
253
+ if top_k and top_k > 0:
254
+ rows = rows[:top_k]
255
+
256
+ return {
257
+ "message": response_message,
258
+ "group_test_name": group_test_name or "All Group Tests",
259
+ "group_test_id": group_test_id,
260
+ "topic_columns": topic_columns,
261
+ "rows": rows,
262
+ "total": len(rows),
263
+ }
264
+
265
+
266
+ # ─── Update student ───────────────────────────────────────────────────────────
267
+
268
+ async def update_student_info(user_id: str, reg_no: str | None, name: str | None) -> dict:
269
+ """Allow admin to correct a student's reg_no or name."""
270
+ db = get_db()
271
+ update: dict = {}
272
+ if reg_no is not None:
273
+ reg_no = reg_no.strip()
274
+ if reg_no:
275
+ # Uniqueness check
276
+ existing = await db[USERS].find_one(
277
+ {"reg_no": reg_no, "_id": {"$ne": ObjectId(user_id)}}
278
+ )
279
+ if existing:
280
+ raise ValueError("This register number is already used by another student.")
281
+ update["reg_no"] = reg_no
282
+ if name is not None:
283
+ name = name.strip()
284
+ if name:
285
+ update["name"] = name
286
+ if not update:
287
+ raise ValueError("Nothing to update.")
288
+ await db[USERS].update_one({"_id": ObjectId(user_id)}, {"$set": update})
289
+ user = await db[USERS].find_one({"_id": ObjectId(user_id)})
290
+ return {
291
+ "user_id": user_id,
292
+ "reg_no": (user or {}).get("reg_no") or "N/A",
293
+ "name": (user or {}).get("name", ""),
294
+ "email": (user or {}).get("email", ""),
295
+ }
296
+
297
+
298
+ # ─── Excel export ─────────────────────────────────────────────────────────────
299
+
300
+ _HEADER_FILL = PatternFill(start_color="1F4E79", end_color="1F4E79", fill_type="solid")
301
+ _ALT_FILL = PatternFill(start_color="D6E4F0", end_color="D6E4F0", fill_type="solid")
302
+ _SCORE_FILL = PatternFill(start_color="E8F5E9", end_color="E8F5E9", fill_type="solid")
303
+ _HEADER_FONT = Font(name="Calibri", bold=True, color="FFFFFF", size=11)
304
+ _DATA_FONT = Font(name="Calibri", size=10)
305
+ _BOLD_DATA_FONT = Font(name="Calibri", bold=True, size=10)
306
+ _CENTER = Alignment(horizontal="center", vertical="center", wrap_text=True)
307
+ _LEFT = Alignment(horizontal="left", vertical="center")
308
+
309
+
310
+ def _thin_border() -> Border:
311
+ s = Side(style="thin", color="B0BEC5")
312
+ return Border(left=s, right=s, top=s, bottom=s)
313
+
314
+
315
+ def generate_excel(rows: list[dict], topic_columns: list[dict], group_test_name: str) -> BytesIO:
316
+ wb = Workbook()
317
+ ws = wb.active
318
+ ws.title = "Students"
319
+
320
+ border = _thin_border()
321
+
322
+ # ── Header row ────────────────────────────────────────────────────────────
323
+ headers = ["Rank", "Reg No", "Name", "Email"]
324
+ for tc in topic_columns:
325
+ headers.append(f"{tc['name']}\nScore")
326
+ headers += ["Overall\nScore", "Attempts", "Status"]
327
+ if any(r.get("skill_match") is not None for r in rows):
328
+ headers.append("JD Match\n(%)")
329
+
330
+ for col_idx, header in enumerate(headers, 1):
331
+ cell = ws.cell(row=1, column=col_idx, value=header)
332
+ cell.font = _HEADER_FONT
333
+ cell.fill = _HEADER_FILL
334
+ cell.alignment = _CENTER
335
+ cell.border = border
336
+ ws.row_dimensions[1].height = 36
337
+
338
+ # ── Data rows ─────────────────────────────────────────────────────────────
339
+ for row_num, row in enumerate(rows, 2):
340
+ use_alt = row_num % 2 == 0
341
+ row_fill = _ALT_FILL if use_alt else None
342
+
343
+ data: list = [
344
+ row.get("rank", row_num - 1),
345
+ row.get("reg_no", ""),
346
+ row.get("name", ""),
347
+ row.get("email", ""),
348
+ ]
349
+
350
+ for tc in topic_columns:
351
+ ts = row.get("topic_scores", {}).get(tc["id"], {})
352
+ score = ts.get("score")
353
+ data.append(f"{score:.1f}%" if score is not None else "β€”")
354
+
355
+ overall = row.get("overall_score")
356
+ data.append(f"{overall:.1f}%" if overall is not None else "β€”")
357
+ data.append(row.get("total_attempts", 1))
358
+ data.append((row.get("status") or "").replace("_", " ").title())
359
+
360
+ if any(r.get("skill_match") is not None for r in rows):
361
+ sm = row.get("skill_match")
362
+ data.append(f"{sm:.1f}%" if sm is not None else "β€”")
363
+
364
+ for col_idx, value in enumerate(data, 1):
365
+ cell = ws.cell(row=row_num, column=col_idx, value=value)
366
+ cell.border = border
367
+ cell.font = _DATA_FONT
368
+ if col_idx in (1,): # rank β†’ bold + centered
369
+ cell.font = _BOLD_DATA_FONT
370
+ cell.alignment = _CENTER
371
+ elif col_idx in (2, 3, 4):
372
+ cell.alignment = _LEFT
373
+ else:
374
+ cell.alignment = _CENTER
375
+ if row_fill:
376
+ cell.fill = row_fill
377
+
378
+ ws.row_dimensions[row_num].height = 20
379
+
380
+ # ── Column widths ────────────────────────────────────────���─────────────────
381
+ col_widths = [6, 16, 22, 28]
382
+ for _ in topic_columns:
383
+ col_widths.append(14)
384
+ col_widths += [14, 10, 14]
385
+ if any(r.get("skill_match") is not None for r in rows):
386
+ col_widths.append(12)
387
+
388
+ for i, width in enumerate(col_widths, 1):
389
+ ws.column_dimensions[get_column_letter(i)].width = width
390
+
391
+ # ── Title row above headers ────────────────────────────────────────────────
392
+ ws.insert_rows(1)
393
+ title_cell = ws.cell(row=1, column=1, value=f"Student Results β€” {group_test_name}")
394
+ title_cell.font = Font(name="Calibri", bold=True, size=13, color="1F4E79")
395
+ title_cell.alignment = _LEFT
396
+ ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max(len(headers), 5))
397
+ ws.row_dimensions[1].height = 28
398
+
399
+ # ── Freeze header row ──────────────────────────────────────────────────────
400
+ ws.freeze_panes = "A3"
401
+
402
+ bio = BytesIO()
403
+ wb.save(bio)
404
+ bio.seek(0)
405
+ return bio
backend/services/group_test_service.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from bson import ObjectId
2
+ from database import get_db
3
+ from models.collections import GROUP_TESTS, GROUP_TEST_RESULTS, TOPICS, USERS, RESULTS
4
+ from utils.helpers import utc_now, str_objectid, str_objectids
5
+
6
+
7
+ # ─── helpers ─────────────────────────────────────────────────────────────────
8
+
9
+ async def _enrich_group_test(doc: dict, db) -> dict:
10
+ """Attach topic names to a group test document."""
11
+ enriched = str_objectid(doc)
12
+ topics = []
13
+ for tid in (doc.get("topic_ids") or []):
14
+ try:
15
+ t = await db[TOPICS].find_one({"_id": ObjectId(tid)})
16
+ except Exception:
17
+ t = None
18
+ if t:
19
+ topics.append({"id": str(t["_id"]), "name": t.get("name", "")})
20
+ enriched["topics"] = topics
21
+ return enriched
22
+
23
+
24
+ async def _refresh_topic_statuses(result: dict, db) -> dict:
25
+ """Check RESULTS collection for each topic session and update statuses in-place."""
26
+ topic_results = result.get("topic_results") or []
27
+ updated = False
28
+ for tr in topic_results:
29
+ if tr.get("status") == "completed":
30
+ continue
31
+ session_id = tr.get("session_id")
32
+ if not session_id:
33
+ continue
34
+ report = await db[RESULTS].find_one({"session_id": session_id})
35
+ if report:
36
+ tr["status"] = "completed"
37
+ tr["overall_score"] = report.get("overall_score", 0)
38
+ tr["total_questions"] = report.get("total_questions", 0)
39
+ tr["completed_at"] = report.get("completed_at")
40
+ updated = True
41
+
42
+ if updated:
43
+ result_id = str(result.get("_id") or result.get("id", ""))
44
+ all_done = all(tr.get("status") == "completed" for tr in topic_results)
45
+ if all_done:
46
+ scores = [tr.get("overall_score") or 0 for tr in topic_results]
47
+ overall = round(sum(scores) / len(scores), 1) if scores else 0.0
48
+ await db[GROUP_TEST_RESULTS].update_one(
49
+ {"_id": ObjectId(result_id)},
50
+ {
51
+ "$set": {
52
+ "topic_results": topic_results,
53
+ "status": "completed",
54
+ "overall_score": overall,
55
+ "completed_at": utc_now(),
56
+ }
57
+ },
58
+ )
59
+ result["status"] = "completed"
60
+ result["overall_score"] = overall
61
+ else:
62
+ await db[GROUP_TEST_RESULTS].update_one(
63
+ {"_id": ObjectId(result_id)},
64
+ {"$set": {"topic_results": topic_results}},
65
+ )
66
+ result["topic_results"] = topic_results
67
+ return result
68
+
69
+
70
+ # ─── Admin CRUD ───────────────────────────────────────────────────────────────
71
+
72
+ async def create_group_test(
73
+ name: str,
74
+ description: str | None,
75
+ topic_ids: list[str],
76
+ time_limit_minutes: int | None,
77
+ max_attempts: int,
78
+ created_by: str,
79
+ ) -> dict:
80
+ db = get_db()
81
+
82
+ if not topic_ids:
83
+ raise ValueError("At least one topic is required")
84
+
85
+ # Validate every topic exists
86
+ for tid in topic_ids:
87
+ try:
88
+ t = await db[TOPICS].find_one({"_id": ObjectId(tid)})
89
+ except Exception:
90
+ raise ValueError(f"Invalid topic ID: {tid}")
91
+ if not t:
92
+ raise ValueError(
93
+ f"Topic with ID '{tid}' does not exist. Create it in Topics before adding here."
94
+ )
95
+
96
+ doc = {
97
+ "name": name.strip(),
98
+ "description": (description or "").strip() or None,
99
+ "topic_ids": topic_ids,
100
+ "time_limit_minutes": time_limit_minutes,
101
+ "max_attempts": max(1, int(max_attempts)),
102
+ "is_published": False,
103
+ "created_by": created_by,
104
+ "created_at": utc_now(),
105
+ }
106
+ result = await db[GROUP_TESTS].insert_one(doc)
107
+ doc["_id"] = result.inserted_id
108
+ return await _enrich_group_test(doc, db)
109
+
110
+
111
+ async def list_group_tests(only_published: bool = False) -> list:
112
+ db = get_db()
113
+ query = {"is_published": True} if only_published else {}
114
+ cursor = db[GROUP_TESTS].find(query).sort("created_at", -1)
115
+ docs = await cursor.to_list(length=200)
116
+ return [await _enrich_group_test(d, db) for d in docs]
117
+
118
+
119
+ async def get_group_test(group_test_id: str) -> dict:
120
+ db = get_db()
121
+ doc = await db[GROUP_TESTS].find_one({"_id": ObjectId(group_test_id)})
122
+ if not doc:
123
+ raise ValueError("Group test not found")
124
+ return await _enrich_group_test(doc, db)
125
+
126
+
127
+ async def update_group_test(group_test_id: str, data: dict) -> dict:
128
+ db = get_db()
129
+
130
+ # Validate topic IDs if provided
131
+ if "topic_ids" in data and data["topic_ids"] is not None:
132
+ if not data["topic_ids"]:
133
+ raise ValueError("At least one topic is required")
134
+ for tid in data["topic_ids"]:
135
+ try:
136
+ t = await db[TOPICS].find_one({"_id": ObjectId(tid)})
137
+ except Exception:
138
+ raise ValueError(f"Invalid topic ID: {tid}")
139
+ if not t:
140
+ raise ValueError(
141
+ f"Topic with ID '{tid}' does not exist. Create it in Topics before adding here."
142
+ )
143
+
144
+ update_data = {k: v for k, v in data.items() if v is not None}
145
+ if "max_attempts" in update_data:
146
+ update_data["max_attempts"] = max(1, int(update_data["max_attempts"]))
147
+ update_data["updated_at"] = utc_now()
148
+
149
+ await db[GROUP_TESTS].update_one(
150
+ {"_id": ObjectId(group_test_id)}, {"$set": update_data}
151
+ )
152
+ return await get_group_test(group_test_id)
153
+
154
+
155
+ async def delete_group_test(group_test_id: str) -> bool:
156
+ db = get_db()
157
+ result = await db[GROUP_TESTS].delete_one({"_id": ObjectId(group_test_id)})
158
+ return result.deleted_count > 0
159
+
160
+
161
+ async def set_group_test_publish(group_test_id: str, is_published: bool) -> dict:
162
+ db = get_db()
163
+ await db[GROUP_TESTS].update_one(
164
+ {"_id": ObjectId(group_test_id)},
165
+ {"$set": {"is_published": is_published, "updated_at": utc_now()}},
166
+ )
167
+ return await get_group_test(group_test_id)
168
+
169
+
170
+ async def get_group_test_results_admin(group_test_id: str) -> list:
171
+ """All student results for a given group test (admin view)."""
172
+ db = get_db()
173
+ cursor = db[GROUP_TEST_RESULTS].find({"group_test_id": group_test_id}).sort(
174
+ "started_at", -1
175
+ )
176
+ docs = await cursor.to_list(length=500)
177
+ return str_objectids(docs)
178
+
179
+
180
+ # ─── Student ──────────────────────────────────────────────────────────────────
181
+
182
+ async def start_group_test_attempt(group_test_id: str, user_id: str) -> dict:
183
+ db = get_db()
184
+
185
+ group_test = await db[GROUP_TESTS].find_one({"_id": ObjectId(group_test_id)})
186
+ if not group_test:
187
+ raise ValueError("Group test not found")
188
+ if not group_test.get("is_published"):
189
+ raise ValueError("This group test is not available")
190
+
191
+ max_attempts = int(group_test.get("max_attempts") or 1)
192
+ attempt_count = await db[GROUP_TEST_RESULTS].count_documents(
193
+ {"group_test_id": group_test_id, "user_id": user_id}
194
+ )
195
+ if attempt_count >= max_attempts:
196
+ raise ValueError(
197
+ f"You have reached the maximum number of attempts ({max_attempts}) for this group test."
198
+ )
199
+
200
+ user = await db[USERS].find_one({"_id": ObjectId(user_id)})
201
+
202
+ topic_results = []
203
+ for tid in (group_test.get("topic_ids") or []):
204
+ t = await db[TOPICS].find_one({"_id": ObjectId(tid)})
205
+ topic_results.append(
206
+ {
207
+ "topic_id": tid,
208
+ "topic_name": t.get("name", "") if t else "",
209
+ "session_id": None,
210
+ "status": "pending",
211
+ "overall_score": None,
212
+ "total_questions": None,
213
+ "completed_at": None,
214
+ }
215
+ )
216
+
217
+ doc = {
218
+ "group_test_id": group_test_id,
219
+ "group_test_name": group_test.get("name", ""),
220
+ "user_id": user_id,
221
+ "user_name": (user or {}).get("name", ""),
222
+ "user_email": (user or {}).get("email", ""),
223
+ "attempt_number": attempt_count + 1,
224
+ "topic_results": topic_results,
225
+ "overall_score": None,
226
+ "status": "in_progress",
227
+ "started_at": utc_now(),
228
+ "completed_at": None,
229
+ "time_limit_minutes": group_test.get("time_limit_minutes"),
230
+ }
231
+ result = await db[GROUP_TEST_RESULTS].insert_one(doc)
232
+ doc["_id"] = result.inserted_id
233
+ return str_objectid(doc)
234
+
235
+
236
+ async def get_group_test_result(result_id: str, user_id: str) -> dict:
237
+ db = get_db()
238
+ result = await db[GROUP_TEST_RESULTS].find_one({"_id": ObjectId(result_id)})
239
+ if not result:
240
+ raise ValueError("Group test result not found")
241
+ if result.get("user_id") != user_id:
242
+ raise ValueError("Unauthorized")
243
+ result = await _refresh_topic_statuses(result, db)
244
+ return str_objectid(result)
245
+
246
+
247
+ async def link_topic_session(
248
+ result_id: str, user_id: str, topic_id: str, session_id: str
249
+ ) -> dict:
250
+ """Store session_id for a topic so its completion can be tracked."""
251
+ db = get_db()
252
+ result = await db[GROUP_TEST_RESULTS].find_one({"_id": ObjectId(result_id)})
253
+ if not result:
254
+ raise ValueError("Group test result not found")
255
+ if result.get("user_id") != user_id:
256
+ raise ValueError("Unauthorized")
257
+
258
+ topic_results = result.get("topic_results") or []
259
+ found = False
260
+ for tr in topic_results:
261
+ if tr.get("topic_id") == topic_id:
262
+ tr["session_id"] = session_id
263
+ if tr.get("status") == "pending":
264
+ tr["status"] = "in_progress"
265
+ found = True
266
+ break
267
+
268
+ if not found:
269
+ raise ValueError("Topic not found in this group test")
270
+
271
+ await db[GROUP_TEST_RESULTS].update_one(
272
+ {"_id": ObjectId(result_id)},
273
+ {"$set": {"topic_results": topic_results}},
274
+ )
275
+ return await get_group_test_result(result_id, user_id)
276
+
277
+
278
+ async def get_my_group_test_results(user_id: str) -> list:
279
+ db = get_db()
280
+ cursor = db[GROUP_TEST_RESULTS].find({"user_id": user_id}).sort("started_at", -1)
281
+ docs = await cursor.to_list(length=100)
282
+ return str_objectids(docs)
283
+
284
+
285
+ async def get_my_group_test_attempt(group_test_id: str, user_id: str) -> dict | None:
286
+ """Return latest attempt result for a group test, or None."""
287
+ db = get_db()
288
+ doc = await db[GROUP_TEST_RESULTS].find_one(
289
+ {"group_test_id": group_test_id, "user_id": user_id},
290
+ sort=[("attempt_number", -1)],
291
+ )
292
+ if not doc:
293
+ return None
294
+ doc = await _refresh_topic_statuses(doc, db)
295
+ return str_objectid(doc)
backend/services/job_description_service.py CHANGED
@@ -3,6 +3,8 @@ from bson import ObjectId
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):
@@ -160,3 +162,11 @@ async def get_job_description_for_user(user_id: str, jd_id: str) -> dict:
160
  if not doc:
161
  raise ValueError("Job description not found")
162
  return str_objectid(doc)
 
 
 
 
 
 
 
 
 
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
+ from utils.resume_text import extract_resume_text
7
+ from utils.gemini import parse_jd_with_gemini
8
 
9
 
10
  def _normalize_required_skills(required_skills):
 
162
  if not doc:
163
  raise ValueError("Job description not found")
164
  return str_objectid(doc)
165
+
166
+
167
+ async def parse_jd_from_file(filename: str, file_content: bytes) -> dict:
168
+ """Extract text from an uploaded JD file and use AI to parse it into structured fields."""
169
+ text = extract_resume_text(filename, file_content)
170
+ if not text or len(text.strip()) < 20:
171
+ raise ValueError("Could not extract readable text from the uploaded file")
172
+ return await parse_jd_with_gemini(text)
backend/services/resume_service.py CHANGED
@@ -1,20 +1,56 @@
1
  import os
 
2
  import aiofiles
3
  from database import get_db
4
- from models.collections import RESUMES, SKILLS
5
  from utils.helpers import utc_now, str_objectid
6
  from utils.gemini import parse_resume_with_gemini
7
  from utils.resume_text import extract_resume_text
8
  from utils.skills import normalize_skill_list
9
  from config import get_settings
 
10
 
11
  settings = get_settings()
12
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  async def upload_and_parse_resume(user_id: str, filename: str, file_content: bytes) -> dict:
15
- """Upload resume file, parse with Gemini, extract skills."""
 
 
 
 
16
  db = get_db()
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  # Save file locally
19
  safe_filename = f"{user_id}_{filename}"
20
  file_path = os.path.join(settings.UPLOAD_DIR, safe_filename)
@@ -39,6 +75,7 @@ async def upload_and_parse_resume(user_id: str, filename: str, file_content: byt
39
  "file_path": file_path,
40
  "parsed_text": parsed_data.get("experience_summary", ""),
41
  "parsed_data": parsed_data,
 
42
  "uploaded_at": utc_now(),
43
  }
44
 
@@ -48,6 +85,12 @@ async def upload_and_parse_resume(user_id: str, filename: str, file_content: byt
48
  upsert=True,
49
  )
50
 
 
 
 
 
 
 
51
  # Upsert skills
52
  await db[SKILLS].update_one(
53
  {"user_id": user_id},
@@ -67,6 +110,7 @@ async def upload_and_parse_resume(user_id: str, filename: str, file_content: byt
67
  "filename": filename,
68
  "parsed_text": resume_doc["parsed_text"],
69
  "skills": skills,
 
70
  "uploaded_at": resume_doc["uploaded_at"],
71
  }
72
 
 
1
  import os
2
+ import re
3
  import aiofiles
4
  from database import get_db
5
+ from models.collections import RESUMES, SKILLS, USERS
6
  from utils.helpers import utc_now, str_objectid
7
  from utils.gemini import parse_resume_with_gemini
8
  from utils.resume_text import extract_resume_text
9
  from utils.skills import normalize_skill_list
10
  from config import get_settings
11
+ from bson import ObjectId
12
 
13
  settings = get_settings()
14
 
15
+ # Expected filename format: {12 digits}_{name}.{ext}
16
+ # Example: 714023243122_Sajith J.pdf
17
+ _RESUME_FILENAME_RE = re.compile(r'^(\d{12})_(.+)\.(pdf|doc|docx|txt)$', re.IGNORECASE)
18
+
19
+
20
+ def extract_reg_no_from_filename(filename: str) -> str | None:
21
+ """Return the 12-digit register number from the filename, or None if format is invalid."""
22
+ m = _RESUME_FILENAME_RE.match(filename or "")
23
+ return m.group(1) if m else None
24
+
25
 
26
  async def upload_and_parse_resume(user_id: str, filename: str, file_content: bytes) -> dict:
27
+ """Upload resume file, parse with Gemini, extract skills.
28
+
29
+ Filename must match: {12_digit_reg_no}_{name}.{ext}
30
+ Example: 714023243122_Sajith J.pdf
31
+ """
32
  db = get_db()
33
 
34
+ # ── Validate filename format ──────────────────────────────────────────────
35
+ reg_no = extract_reg_no_from_filename(filename)
36
+ if reg_no is None:
37
+ raise ValueError(
38
+ "Invalid filename format. Resume filename must start with your 12-digit register number "
39
+ "followed by an underscore and your name. "
40
+ "Example: 714023243122_Sajith J.pdf"
41
+ )
42
+
43
+ # ── Check reg_no uniqueness ───────────────────────────────────────────────
44
+ # Allow the same user to re-upload (same reg_no), but block if another user already holds it.
45
+ existing_owner = await db[USERS].find_one(
46
+ {"reg_no": reg_no, "_id": {"$ne": ObjectId(user_id)}}
47
+ )
48
+ if existing_owner:
49
+ raise ValueError(
50
+ "This register number is already associated with another account. "
51
+ "Each student must use their own unique register number."
52
+ )
53
+
54
  # Save file locally
55
  safe_filename = f"{user_id}_{filename}"
56
  file_path = os.path.join(settings.UPLOAD_DIR, safe_filename)
 
75
  "file_path": file_path,
76
  "parsed_text": parsed_data.get("experience_summary", ""),
77
  "parsed_data": parsed_data,
78
+ "reg_no": reg_no,
79
  "uploaded_at": utc_now(),
80
  }
81
 
 
85
  upsert=True,
86
  )
87
 
88
+ # Persist reg_no on the user document for quick lookup and admin display
89
+ await db[USERS].update_one(
90
+ {"_id": ObjectId(user_id)},
91
+ {"$set": {"reg_no": reg_no}},
92
+ )
93
+
94
  # Upsert skills
95
  await db[SKILLS].update_one(
96
  {"user_id": user_id},
 
110
  "filename": filename,
111
  "parsed_text": resume_doc["parsed_text"],
112
  "skills": skills,
113
+ "reg_no": reg_no,
114
  "uploaded_at": resume_doc["uploaded_at"],
115
  }
116
 
backend/utils/gemini.py CHANGED
@@ -306,6 +306,50 @@ Return ONLY valid JSON, no markdown formatting."""
306
  }
307
 
308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  async def analyze_resume_vs_job_description(
310
  role_title: str,
311
  resume_skills: list,
 
306
  }
307
 
308
 
309
+ async def parse_jd_with_gemini(jd_text: str) -> dict:
310
+ """Extract structured job description data (title, description, required_skills) from raw text."""
311
+ prompt = f"""You are a job description parser. Extract structured information from the given job description text.
312
+
313
+ Return ONLY valid JSON with exactly these fields:
314
+ {{
315
+ "title": "job title (string)",
316
+ "company": "company name if present, else null",
317
+ "description": "cleaned full job description text (string)",
318
+ "required_skills": ["skill1", "skill2", ...]
319
+ }}
320
+
321
+ Rules:
322
+ 1. "title" β€” infer the most appropriate job title from the content (e.g. "Software Engineer", "Data Analyst").
323
+ 2. "company" β€” extract if explicitly mentioned, otherwise null.
324
+ 3. "description" β€” cleaned, coherent description text; keep it as a single string.
325
+ 4. "required_skills" β€” extract only specific, concrete technical skills, tools, languages, or certifications; no vague traits like "teamwork".
326
+
327
+ Job Description Text:
328
+ ---
329
+ {jd_text}
330
+ ---
331
+
332
+ Return ONLY valid JSON, no markdown."""
333
+
334
+ try:
335
+ raw = await call_gemini(prompt)
336
+ cleaned = _extract_json_object(raw)
337
+ parsed = json.loads(cleaned)
338
+ return {
339
+ "title": (parsed.get("title") or "").strip() or "Untitled",
340
+ "company": (parsed.get("company") or "").strip() or None,
341
+ "description": (parsed.get("description") or "").strip() or jd_text[:2000],
342
+ "required_skills": normalize_skill_list(parsed.get("required_skills") or []),
343
+ }
344
+ except Exception:
345
+ return {
346
+ "title": "Untitled",
347
+ "company": None,
348
+ "description": jd_text[:2000],
349
+ "required_skills": [],
350
+ }
351
+
352
+
353
  async def analyze_resume_vs_job_description(
354
  role_title: str,
355
  resume_skills: list,