adding score to DB
Browse files
backend/alembic/versions/290ee4ce077e_add_score_and_question_scores_columns_.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Add score and question_scores columns to applications table
|
| 2 |
+
|
| 3 |
+
Revision ID: 290ee4ce077e
|
| 4 |
+
Revises: f9f1aa7380ab
|
| 5 |
+
Create Date: 2026-02-09 11:29:38.655897
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from typing import Sequence, Union
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
import sqlalchemy as sa
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# revision identifiers, used by Alembic.
|
| 15 |
+
revision: str = '290ee4ce077e'
|
| 16 |
+
down_revision: Union[str, Sequence[str], None] = 'f9f1aa7380ab'
|
| 17 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 18 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
"""Upgrade schema."""
|
| 23 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 24 |
+
op.add_column('applications', sa.Column('score', sa.Float(), nullable=True))
|
| 25 |
+
op.add_column('applications', sa.Column('question_scores', sa.Text(), nullable=True))
|
| 26 |
+
# ### end Alembic commands ###
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def downgrade() -> None:
|
| 30 |
+
"""Downgrade schema."""
|
| 31 |
+
# ### commands auto generated by Alembic - please adjust! ###
|
| 32 |
+
op.drop_column('applications', 'question_scores')
|
| 33 |
+
op.drop_column('applications', 'score')
|
| 34 |
+
# ### end Alembic commands ###
|
backend/api/application_routes.py
CHANGED
|
@@ -53,6 +53,15 @@ def get_applications_list(jid: str, aid: str, page: int = 1, limit: int = 10, db
|
|
| 53 |
from services.user_service import get_user
|
| 54 |
user = get_user(db, application.user_id)
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
# Create response object that matches technical requirements exactly
|
| 57 |
application_response = {
|
| 58 |
'id': application.id,
|
|
@@ -62,6 +71,7 @@ def get_applications_list(jid: str, aid: str, page: int = 1, limit: int = 10, db
|
|
| 62 |
'answers': [], # Not including answers in the list view for performance
|
| 63 |
'score': score,
|
| 64 |
'passing_score': assessment.passing_score,
|
|
|
|
| 65 |
'assessment_details': {
|
| 66 |
'id': assessment.id,
|
| 67 |
'title': assessment.title,
|
|
@@ -202,6 +212,15 @@ def get_application_detail(jid: str, aid: str, id: str, db: Session = Depends(ge
|
|
| 202 |
logger.error(f"Error creating assessment details: {str(e)}")
|
| 203 |
assessment_details_obj = None
|
| 204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
application_detail = ApplicationDetailedResponse(
|
| 206 |
id=application.id,
|
| 207 |
job_id=application.job_id,
|
|
@@ -210,6 +229,7 @@ def get_application_detail(jid: str, aid: str, id: str, db: Session = Depends(ge
|
|
| 210 |
answers=enriched_answers,
|
| 211 |
score=score,
|
| 212 |
passing_score=assessment.passing_score,
|
|
|
|
| 213 |
assessment_details=assessment_details_obj,
|
| 214 |
user={
|
| 215 |
'id': user.id if user else None,
|
|
@@ -416,6 +436,15 @@ def get_my_application(id: str, db: Session = Depends(get_db), current_user: Use
|
|
| 416 |
logger.error(f"Error creating assessment details: {str(e)}")
|
| 417 |
assessment_details_obj = None
|
| 418 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
application_detail = ApplicationDetailedResponse(
|
| 420 |
id=application.id,
|
| 421 |
job_id=application.job_id,
|
|
@@ -424,6 +453,7 @@ def get_my_application(id: str, db: Session = Depends(get_db), current_user: Use
|
|
| 424 |
answers=enriched_answers,
|
| 425 |
score=score,
|
| 426 |
passing_score=assessment.passing_score,
|
|
|
|
| 427 |
assessment_details=assessment_details_obj,
|
| 428 |
user={
|
| 429 |
'id': user.id if user else None,
|
|
|
|
| 53 |
from services.user_service import get_user
|
| 54 |
user = get_user(db, application.user_id)
|
| 55 |
|
| 56 |
+
# Parse question scores from JSON string
|
| 57 |
+
question_scores = []
|
| 58 |
+
if application.question_scores:
|
| 59 |
+
try:
|
| 60 |
+
question_scores = json.loads(application.question_scores)
|
| 61 |
+
except json.JSONDecodeError:
|
| 62 |
+
logger.warning(f"Failed to parse question scores for application ID: {application.id}")
|
| 63 |
+
question_scores = []
|
| 64 |
+
|
| 65 |
# Create response object that matches technical requirements exactly
|
| 66 |
application_response = {
|
| 67 |
'id': application.id,
|
|
|
|
| 71 |
'answers': [], # Not including answers in the list view for performance
|
| 72 |
'score': score,
|
| 73 |
'passing_score': assessment.passing_score,
|
| 74 |
+
'question_scores': question_scores, # Include individual question scores
|
| 75 |
'assessment_details': {
|
| 76 |
'id': assessment.id,
|
| 77 |
'title': assessment.title,
|
|
|
|
| 212 |
logger.error(f"Error creating assessment details: {str(e)}")
|
| 213 |
assessment_details_obj = None
|
| 214 |
|
| 215 |
+
# Parse question scores from JSON string
|
| 216 |
+
question_scores = []
|
| 217 |
+
if application.question_scores:
|
| 218 |
+
try:
|
| 219 |
+
question_scores = json.loads(application.question_scores)
|
| 220 |
+
except json.JSONDecodeError:
|
| 221 |
+
logger.warning(f"Failed to parse question scores for application ID: {application.id}")
|
| 222 |
+
question_scores = []
|
| 223 |
+
|
| 224 |
application_detail = ApplicationDetailedResponse(
|
| 225 |
id=application.id,
|
| 226 |
job_id=application.job_id,
|
|
|
|
| 229 |
answers=enriched_answers,
|
| 230 |
score=score,
|
| 231 |
passing_score=assessment.passing_score,
|
| 232 |
+
question_scores=question_scores, # Include individual question scores
|
| 233 |
assessment_details=assessment_details_obj,
|
| 234 |
user={
|
| 235 |
'id': user.id if user else None,
|
|
|
|
| 436 |
logger.error(f"Error creating assessment details: {str(e)}")
|
| 437 |
assessment_details_obj = None
|
| 438 |
|
| 439 |
+
# Parse question scores from JSON string
|
| 440 |
+
question_scores = []
|
| 441 |
+
if application.question_scores:
|
| 442 |
+
try:
|
| 443 |
+
question_scores = json.loads(application.question_scores)
|
| 444 |
+
except json.JSONDecodeError:
|
| 445 |
+
logger.warning(f"Failed to parse question scores for application ID: {application.id}")
|
| 446 |
+
question_scores = []
|
| 447 |
+
|
| 448 |
application_detail = ApplicationDetailedResponse(
|
| 449 |
id=application.id,
|
| 450 |
job_id=application.job_id,
|
|
|
|
| 453 |
answers=enriched_answers,
|
| 454 |
score=score,
|
| 455 |
passing_score=assessment.passing_score,
|
| 456 |
+
question_scores=question_scores, # Include individual question scores
|
| 457 |
assessment_details=assessment_details_obj,
|
| 458 |
user={
|
| 459 |
'id': user.id if user else None,
|
backend/models/application.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from sqlalchemy import Column, String, Text, ForeignKey, DateTime
|
| 2 |
from sqlalchemy.sql import func
|
| 3 |
from .base import Base
|
| 4 |
import uuid
|
|
@@ -11,5 +11,7 @@ class Application(Base):
|
|
| 11 |
assessment_id = Column(String, ForeignKey("assessments.id"), nullable=False)
|
| 12 |
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 13 |
answers = Column(Text) # Stored as JSON string
|
|
|
|
|
|
|
| 14 |
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 15 |
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
|
|
| 1 |
+
from sqlalchemy import Column, String, Text, ForeignKey, DateTime, Float
|
| 2 |
from sqlalchemy.sql import func
|
| 3 |
from .base import Base
|
| 4 |
import uuid
|
|
|
|
| 11 |
assessment_id = Column(String, ForeignKey("assessments.id"), nullable=False)
|
| 12 |
user_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 13 |
answers = Column(Text) # Stored as JSON string
|
| 14 |
+
score = Column(Float) # Overall application score
|
| 15 |
+
question_scores = Column(Text) # Individual question scores stored as JSON string
|
| 16 |
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 17 |
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
backend/schemas/application.py
CHANGED
|
@@ -23,6 +23,11 @@ class ApplicationQuestion(BaseModel):
|
|
| 23 |
options: Optional[List[dict]] = [] # Using dict for simplicity
|
| 24 |
correct_options: Optional[List[str]] = []
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
class ApplicationAnswerWithQuestion(ApplicationAnswer):
|
| 27 |
question_text: str = Field(..., min_length=1, max_length=1000)
|
| 28 |
weight: int = Field(..., ge=1, le=5) # range 1-5
|
|
@@ -31,6 +36,7 @@ class ApplicationAnswerWithQuestion(ApplicationAnswer):
|
|
| 31 |
question_options: Optional[List[dict]] = [] # Options for the question
|
| 32 |
correct_options: Optional[List[str]] = []
|
| 33 |
rationale: str = Field(..., min_length=1, max_length=1000)
|
|
|
|
| 34 |
|
| 35 |
class ApplicationBase(BaseSchema):
|
| 36 |
job_id: str = Field(..., min_length=1)
|
|
@@ -52,9 +58,10 @@ class ApplicationAssessment(BaseModel):
|
|
| 52 |
|
| 53 |
class ApplicationResponse(ApplicationBase):
|
| 54 |
id: str
|
| 55 |
-
score: Optional[float] = None
|
| 56 |
passing_score: Optional[float] = None
|
| 57 |
assessment_details: Optional[ApplicationAssessment] = None
|
|
|
|
| 58 |
|
| 59 |
class Config:
|
| 60 |
from_attributes = True
|
|
|
|
| 23 |
options: Optional[List[dict]] = [] # Using dict for simplicity
|
| 24 |
correct_options: Optional[List[str]] = []
|
| 25 |
|
| 26 |
+
class ApplicationAnswerScore(BaseModel):
|
| 27 |
+
question_id: str
|
| 28 |
+
score: float # Score between 0 and 1
|
| 29 |
+
rationale: str
|
| 30 |
+
|
| 31 |
class ApplicationAnswerWithQuestion(ApplicationAnswer):
|
| 32 |
question_text: str = Field(..., min_length=1, max_length=1000)
|
| 33 |
weight: int = Field(..., ge=1, le=5) # range 1-5
|
|
|
|
| 36 |
question_options: Optional[List[dict]] = [] # Options for the question
|
| 37 |
correct_options: Optional[List[str]] = []
|
| 38 |
rationale: str = Field(..., min_length=1, max_length=1000)
|
| 39 |
+
score: Optional[float] = None # Score for this specific answer
|
| 40 |
|
| 41 |
class ApplicationBase(BaseSchema):
|
| 42 |
job_id: str = Field(..., min_length=1)
|
|
|
|
| 58 |
|
| 59 |
class ApplicationResponse(ApplicationBase):
|
| 60 |
id: str
|
| 61 |
+
score: Optional[float] = None # Overall application score
|
| 62 |
passing_score: Optional[float] = None
|
| 63 |
assessment_details: Optional[ApplicationAssessment] = None
|
| 64 |
+
question_scores: Optional[List[ApplicationAnswerScore]] = None # Individual question scores
|
| 65 |
|
| 66 |
class Config:
|
| 67 |
from_attributes = True
|
backend/services/application_service.py
CHANGED
|
@@ -51,19 +51,25 @@ def get_applications_by_user(db: Session, user_id: str, skip: int = 0, limit: in
|
|
| 51 |
return applications
|
| 52 |
|
| 53 |
def create_application(db: Session, application: ApplicationCreate) -> Application:
|
| 54 |
-
"""Create a new application"""
|
| 55 |
logger.info(f"Creating new application for job ID: {application.job_id}, assessment ID: {application.assessment_id}, user ID: {application.user_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
db_application = Application(
|
| 57 |
id=str(uuid.uuid4()),
|
| 58 |
job_id=application.job_id,
|
| 59 |
assessment_id=application.assessment_id,
|
| 60 |
user_id=application.user_id,
|
| 61 |
-
answers=json.dumps([ans.dict() for ans in application.answers]) # Store as JSON string
|
|
|
|
|
|
|
| 62 |
)
|
| 63 |
db.add(db_application)
|
| 64 |
db.commit()
|
| 65 |
db.refresh(db_application)
|
| 66 |
-
logger.info(f"Successfully created application with ID: {db_application.id}")
|
| 67 |
return db_application
|
| 68 |
|
| 69 |
def update_application(db: Session, application_id: str, **kwargs) -> Optional[Application]:
|
|
@@ -95,6 +101,106 @@ def delete_application(db: Session, application_id: str) -> bool:
|
|
| 95 |
logger.warning(f"Failed to delete application - application not found: {application_id}")
|
| 96 |
return False
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
def calculate_application_score(db: Session, application_id: str) -> float:
|
| 99 |
"""Calculate the score for an application"""
|
| 100 |
logger.debug(f"Calculating score for application ID: {application_id}")
|
|
|
|
| 51 |
return applications
|
| 52 |
|
| 53 |
def create_application(db: Session, application: ApplicationCreate) -> Application:
|
| 54 |
+
"""Create a new application and calculate scores"""
|
| 55 |
logger.info(f"Creating new application for job ID: {application.job_id}, assessment ID: {application.assessment_id}, user ID: {application.user_id}")
|
| 56 |
+
|
| 57 |
+
# Calculate scores for the application
|
| 58 |
+
score, question_scores = calculate_detailed_application_score(db, application)
|
| 59 |
+
|
| 60 |
db_application = Application(
|
| 61 |
id=str(uuid.uuid4()),
|
| 62 |
job_id=application.job_id,
|
| 63 |
assessment_id=application.assessment_id,
|
| 64 |
user_id=application.user_id,
|
| 65 |
+
answers=json.dumps([ans.dict() for ans in application.answers]), # Store as JSON string
|
| 66 |
+
score=score, # Store the overall application score
|
| 67 |
+
question_scores=json.dumps([qs.dict() for qs in question_scores]) # Store individual question scores as JSON
|
| 68 |
)
|
| 69 |
db.add(db_application)
|
| 70 |
db.commit()
|
| 71 |
db.refresh(db_application)
|
| 72 |
+
logger.info(f"Successfully created application with ID: {db_application.id} and overall score: {score}")
|
| 73 |
return db_application
|
| 74 |
|
| 75 |
def update_application(db: Session, application_id: str, **kwargs) -> Optional[Application]:
|
|
|
|
| 101 |
logger.warning(f"Failed to delete application - application not found: {application_id}")
|
| 102 |
return False
|
| 103 |
|
| 104 |
+
def calculate_detailed_application_score(db: Session, application_create: ApplicationCreate):
|
| 105 |
+
"""Calculate detailed scores for an application including individual question scores"""
|
| 106 |
+
from models.assessment import Assessment
|
| 107 |
+
from schemas.application import ApplicationAnswerScore
|
| 108 |
+
|
| 109 |
+
logger.debug(f"Calculating detailed scores for application - job ID: {application_create.job_id}, assessment ID: {application_create.assessment_id}")
|
| 110 |
+
|
| 111 |
+
# Get the associated assessment to compare answers with correct answers
|
| 112 |
+
assessment = db.query(Assessment).filter(Assessment.id == application_create.assessment_id).first()
|
| 113 |
+
if not assessment:
|
| 114 |
+
logger.warning(f"Assessment not found for ID: {application_create.assessment_id}")
|
| 115 |
+
return 0.0, []
|
| 116 |
+
|
| 117 |
+
# Parse the questions
|
| 118 |
+
import json
|
| 119 |
+
try:
|
| 120 |
+
questions = json.loads(assessment.questions) if assessment.questions else []
|
| 121 |
+
except json.JSONDecodeError:
|
| 122 |
+
logger.error(f"Failed to parse questions for assessment ID: {application_create.assessment_id}")
|
| 123 |
+
return 0.0, []
|
| 124 |
+
|
| 125 |
+
# Create a mapping of question_id to question for easy lookup
|
| 126 |
+
question_map = {q['id']: q for q in questions}
|
| 127 |
+
|
| 128 |
+
# Calculate the scores
|
| 129 |
+
total_points = 0
|
| 130 |
+
earned_points = 0
|
| 131 |
+
question_scores = []
|
| 132 |
+
|
| 133 |
+
for answer in application_create.answers:
|
| 134 |
+
question_id = answer.question_id
|
| 135 |
+
if not question_id or question_id not in question_map:
|
| 136 |
+
continue
|
| 137 |
+
|
| 138 |
+
question_data = question_map[question_id]
|
| 139 |
+
|
| 140 |
+
# Calculate weighted score
|
| 141 |
+
question_weight = question_data.get('weight', 1) # Default weight is 1
|
| 142 |
+
total_points += question_weight
|
| 143 |
+
|
| 144 |
+
# Initialize question score object
|
| 145 |
+
question_score_obj = ApplicationAnswerScore(
|
| 146 |
+
question_id=question_id,
|
| 147 |
+
score=0.0,
|
| 148 |
+
rationale="No rationale available"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
# For multiple choice questions, score directly without AI
|
| 152 |
+
if question_data['type'] in ['choose_one', 'choose_many']:
|
| 153 |
+
correct_options = set(question_data.get('correct_options', []))
|
| 154 |
+
selected_options = set(answer.options or [])
|
| 155 |
+
|
| 156 |
+
# Check if the selected options match the correct options exactly
|
| 157 |
+
if selected_options == correct_options:
|
| 158 |
+
earned_points += question_weight # Full points for correct answer
|
| 159 |
+
question_score_obj.score = 1.0 # Perfect score
|
| 160 |
+
question_score_obj.rationale = "Correct answer"
|
| 161 |
+
else:
|
| 162 |
+
question_score_obj.score = 0.0 # No points for incorrect answer
|
| 163 |
+
question_score_obj.rationale = f"Incorrect. Correct options: {list(correct_options)}, Selected: {list(selected_options)}"
|
| 164 |
+
|
| 165 |
+
# For text-based questions, use AI to evaluate the answer
|
| 166 |
+
elif question_data['type'] == 'text_based':
|
| 167 |
+
# Convert the question data to an AssessmentQuestion object
|
| 168 |
+
from schemas.assessment import AssessmentQuestion, AssessmentQuestionOption
|
| 169 |
+
from schemas.enums import QuestionType
|
| 170 |
+
question_obj = AssessmentQuestion(
|
| 171 |
+
id=question_data['id'],
|
| 172 |
+
text=question_data['text'],
|
| 173 |
+
weight=question_data['weight'],
|
| 174 |
+
skill_categories=question_data['skill_categories'],
|
| 175 |
+
type=QuestionType(question_data['type']),
|
| 176 |
+
options=[AssessmentQuestionOption(text=opt['text'], value=opt['value']) for opt in question_data.get('options', [])],
|
| 177 |
+
correct_options=question_data.get('correct_options', [])
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
# Use AI service to score the text-based answer
|
| 181 |
+
from services.ai_service import score_answer
|
| 182 |
+
score_result = score_answer(
|
| 183 |
+
question=question_obj,
|
| 184 |
+
answer_text=answer.text or '',
|
| 185 |
+
selected_options=answer.options or []
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
earned_points += score_result['score'] * question_weight
|
| 189 |
+
question_score_obj.score = score_result['score']
|
| 190 |
+
question_score_obj.rationale = score_result['rationale']
|
| 191 |
+
|
| 192 |
+
question_scores.append(question_score_obj)
|
| 193 |
+
|
| 194 |
+
# Calculate percentage score
|
| 195 |
+
if total_points > 0:
|
| 196 |
+
overall_score = (earned_points / total_points) * 100
|
| 197 |
+
else:
|
| 198 |
+
overall_score = 0.0
|
| 199 |
+
|
| 200 |
+
logger.debug(f"Calculated detailed scores: overall {overall_score}% ({earned_points}/{total_points} points), {len(question_scores)} questions scored")
|
| 201 |
+
return round(overall_score, 2), question_scores
|
| 202 |
+
|
| 203 |
+
|
| 204 |
def calculate_application_score(db: Session, application_id: str) -> float:
|
| 205 |
"""Calculate the score for an application"""
|
| 206 |
logger.debug(f"Calculating score for application ID: {application_id}")
|