Spaces:
Sleeping
Sleeping
Commit Β·
15145f6
1
Parent(s): ed1123c
v5
Browse filesupdated for chatbot
- backend/models/collections.py +2 -0
- backend/requirements.txt +1 -0
- backend/routers/admin.py +180 -0
- backend/routers/profile.py +111 -0
- backend/schemas/admin.py +44 -0
- backend/services/admin_service.py +1 -0
- backend/services/chatbot_service.py +405 -0
- backend/services/group_test_service.py +295 -0
- backend/services/job_description_service.py +10 -0
- backend/services/resume_service.py +46 -2
- backend/utils/gemini.py +44 -0
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,
|