using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Validation; using System.ComponentModel.DataAnnotations; using TMSBLL.Assessment; using TMSBLL.AssessmentAssignment; using TMSBLL.TxSkillUpgrade; using TMSDAL.CustomCode.Assessment; using TMSDAL.CustomCode.AssessmentAttempt; using TMSDAL.CustomCode.AttemptResponse; using TMSDAL.DTO.AssessmentAttempt; using TMSDAL.DTO.AttemptResponse; namespace TMSBLL.AssessmentAttempt { public class AssessmentAttemptBLL : IAssessmentAttemptBLL { private readonly IAssessmentAttemptDAL _AssessmentAttemptDAL; private readonly IAttemptResponseDAL _AttemptResponseDAL; private readonly IAssessmentDAL _AssessmentDAL; private readonly IAssessmentAssignmentBLL _AssessmentAssignmentBLL; private readonly ITxSkillUpgradeBLL _TxSkillUpgradeBLL; private readonly IAssessmentAnswerEvaluationService _AssessmentAnswerEvaluationService; private readonly AutoNumber _AutoNumber; private readonly IValidation _Validation; public AssessmentAttemptBLL( IAssessmentAttemptDAL assessmentAttemptDAL, IAttemptResponseDAL attemptResponseDAL, IAssessmentDAL assessmentDAL, IAssessmentAssignmentBLL assessmentAssignmentBLL, ITxSkillUpgradeBLL txSkillUpgradeBLL, IAssessmentAnswerEvaluationService assessmentAnswerEvaluationService, AutoNumber autoNumber, IValidation validation) { _AssessmentAttemptDAL = assessmentAttemptDAL; _AttemptResponseDAL = attemptResponseDAL; _AssessmentDAL = assessmentDAL; _AssessmentAssignmentBLL = assessmentAssignmentBLL; _TxSkillUpgradeBLL = txSkillUpgradeBLL; _AssessmentAnswerEvaluationService = assessmentAnswerEvaluationService; _AutoNumber = autoNumber; _Validation = validation; } // ── GET ─────────────────────────────────────────────── public async Task GetAssessmentAttempt(int assessmentAttemptId, LoginDTO loginDTO) { try { return await _AssessmentAttemptDAL.GetAssessmentAttempt(assessmentAttemptId, loginDTO); } catch (Exception) { throw; } } public async Task GetAssessmentAttemptList(int enrollmentId, LoginDTO loginDTO) { try { return await _AssessmentAttemptDAL.GetAssessmentAttemptList(enrollmentId, loginDTO); } catch (Exception) { throw; } } public async Task GetAssessmentAttemptFullList(LoginDTO loginDTO) { try { return await _AssessmentAttemptDAL.GetAssessmentAttemptFullList(loginDTO); } catch (Exception) { throw; } } public async Task GetSelectListAssessmentAttempt(CriteriaDTO criteriaDTO, LoginDTO loginDTO) => await _AssessmentAttemptDAL.GetSelectListAssessmentAttempt(criteriaDTO, loginDTO); // ── START ATTEMPT ───────────────────────────────────── public async Task StartAssessmentAttempt(AssessmentAttemptDTO dto, LoginDTO loginDTO) { if (dto == null) throw new ArgumentNullException(nameof(dto)); AutoNumberDTO? attemptNumber = null; try { await _Validation.NotEmpty(dto.AssessmentId.ToString(), nameof(dto.AssessmentId)); await _Validation.NotEmpty(dto.EnrollmentId.ToString(), nameof(dto.EnrollmentId)); dto.TenantId = loginDTO.ClientId; dto.CreatedById = loginDTO.UserId; dto.CreatedOn = DateTime.UtcNow; dto.ModifiedById = loginDTO.UserId; dto.ModifiedOn = DateTime.UtcNow; dto.StartedOn = DateTime.UtcNow; // Use POST-supplied evaluator if provided (non-zero), otherwise -1 (auto-evaluated sentinel) if (dto.EvaluatedById == 0) dto.EvaluatedById = -1; dto.Version = 0; dto.Status = 1; dto.SortOrder = 9999; dto.SourceType = 5; // Compute AttemptNo int count = await _AssessmentAttemptDAL.GetAttemptCount(dto.AssessmentId, dto.EnrollmentId, loginDTO); dto.AttemptNo = (short)(count + 1); // Generate AttemptId attemptNumber = await _AutoNumber.GetAutoNumber(1, "ASSESSMENTATTEMPT", loginDTO); dto.AssessmentAttemptId = attemptNumber.StartNumber; await _AssessmentAttemptDAL.SaveAssessmentAttempt(dto, loginDTO); // Save responses if (dto.Responses != null && dto.Responses.Any()) { foreach (var response in dto.Responses) { var rn = await _AutoNumber.GetAutoNumber(1, "ATTEMPTRESPONSE", loginDTO); response.AttemptResponseId = rn.StartNumber; response.AssessmentAttemptId = dto.AssessmentAttemptId; if (response.QuestionBankId == 0) response.QuestionBankId = -1; if (response.AssessmentQuestionId == 0) response.AssessmentQuestionId = -1; await _AttemptResponseDAL.SaveAttemptResponse(response, loginDTO); } } return $"{SuccessResponse.SaveSuccessMessage} {dto.AssessmentAttemptId}"; } catch (ValidationException vex) { throw new Exception(vex.Message); } catch (Exception) { if (attemptNumber != null && dto.AssessmentAttemptId > 0) await _AutoNumber.RollbackAutoNumber("ASSESSMENTATTEMPT", dto.AssessmentAttemptId, loginDTO); throw; } } // ── SUBMIT ATTEMPT ──────────────────────────────────── public async Task SubmitAssessmentAttempt(AssessmentAttemptDTO dto, LoginDTO loginDTO) { if (dto == null) throw new ArgumentNullException(nameof(dto)); try { await _Validation.NotEmpty(dto.AssessmentAttemptId.ToString(), nameof(dto.AssessmentAttemptId)); dto.TenantId = loginDTO.ClientId; dto.ModifiedById = loginDTO.UserId; dto.ModifiedOn = DateTime.UtcNow; dto.CompletedOn = DateTime.UtcNow; // Fetch authoritative assessment data (TotalMarks, PassThresholdPct) var assessment = await _AssessmentDAL.GetAssessment(dto.AssessmentId, loginDTO); if (assessment == null) throw new Exception($"Assessment {dto.AssessmentId} not found."); dto.TotalMarks = assessment.TotalMarks; // Fetch correct answers for server-side auto-evaluation var questions = await _AssessmentDAL.GetSimpleQuestions(dto.AssessmentId, loginDTO); var questionMap = questions.ToDictionary(q => q.AssessmentQuestionId); if (dto.Responses != null && dto.Responses.Any()) { foreach (var response in dto.Responses) { // Auto-evaluate if question found, has a correct answer, and isn't // explicitly flagged as needing human/AI judgement (RequiresManualEvaluation // — distinct from the blank-CorrectAnswer fallback below: an essay-type // question can carry a model CorrectAnswer for reference while still // requiring a human to actually grade the response). if (questionMap.TryGetValue(response.AssessmentQuestionId, out var question) && question.RequiresManualEvaluation == 0 && !string.IsNullOrWhiteSpace(question.CorrectAnswer)) { bool isCorrect = IsAnswerCorrect(question.QuestionType, response.ResponseGiven, question.CorrectAnswer); response.IsCorrect = (short)(isCorrect ? 1 : 0); response.MarksAwarded = isCorrect ? question.Marks : 0; } else if (question != null) { // Subjective/no-correct-answer question — offer it to the pluggable // evaluator (no-op by default, real AI evaluator later via DI). Falls // back to pending (IsCorrect = -1) for a human grader via // EvaluateAssessmentAttempt when the evaluator declines. var evalResult = await _AssessmentAnswerEvaluationService.EvaluateAsync(response, question, default).ConfigureAwait(false); if (evalResult.Evaluated) { response.IsCorrect = (short)(evalResult.IsCorrect ? 1 : 0); response.MarksAwarded = evalResult.MarksAwarded; } else { response.IsCorrect = -1; response.MarksAwarded = 0; } } else { // Question not found in this assessment's current question set. response.IsCorrect = -1; response.MarksAwarded = 0; } // Ensure AttemptResponseId is set for INSERT path if (response.AttemptResponseId == 0) { var rn = await _AutoNumber.GetAutoNumber(1, "ATTEMPTRESPONSE", loginDTO); response.AttemptResponseId = rn.StartNumber; } response.AssessmentAttemptId = dto.AssessmentAttemptId; // Normalize sentinel values — 0 is invalid for FK columns, must be -1 if (response.QuestionBankId == 0) response.QuestionBankId = -1; if (response.AssessmentQuestionId == 0) response.AssessmentQuestionId = -1; await _AttemptResponseDAL.UpsertAttemptResponseByQuestion(response, loginDTO); } // Score computed from actual responses — never trust caller-supplied ScorePct dto.MarksObtained = dto.Responses.Sum(r => r.MarksAwarded); dto.ScorePct = dto.TotalMarks > 0 ? Math.Round((dto.MarksObtained / dto.TotalMarks) * 100, 4) : 0; } else { // Summary-only mode (RecordManualAttempt with no response detail) — // caller supplies ScorePct directly; clamp to [0,100] and derive MarksObtained. dto.ScorePct = Math.Max(0, Math.Min(100, dto.ScorePct)); dto.MarksObtained = dto.TotalMarks > 0 ? Math.Round(dto.ScorePct / 100 * dto.TotalMarks, 4) : 0; } // Determine pass/fail from assessment threshold dto.AttemptIsPassed = (byte)(dto.ScorePct >= assessment.PassThresholdPct ? 1 : 0); await _AssessmentAttemptDAL.UpdateAssessmentAttemptScore(dto, loginDTO); // Non-fatal: flips a matching Assigned/InProgress assignment to Completed, if one // exists for this (AssessmentId, EnrollmentId) — a no-op when the attempt was taken // without a formal assignment (the common in-app case today). await _AssessmentAssignmentBLL.ResolveAssignmentOnAttemptAsync(dto.AssessmentId, dto.EnrollmentId, dto.AssessmentAttemptId, loginDTO, default); if (dto.AttemptIsPassed == 1) await _TxSkillUpgradeBLL.ApplySkillMappingAsync(assessment.ProgrammeId, dto.AssessmentId, dto.EnrollmentId, loginDTO, default); return $"{string.Format(SuccessResponse.UpdateSuccessWithName, dto.AssessmentTitle)} — Score: {dto.ScorePct}%, {(dto.AttemptIsPassed == 1 ? "PASSED" : "FAILED")}"; } catch (Exception) { throw; } } // ── EVALUATE ATTEMPT (Manual) ───────────────────────── public async Task EvaluateAssessmentAttempt(AssessmentAttemptDTO dto, LoginDTO loginDTO) { if (dto == null) throw new ArgumentNullException(nameof(dto)); try { await _Validation.NotEmpty(dto.AssessmentAttemptId.ToString(), nameof(dto.AssessmentAttemptId)); if (dto.Responses == null || !dto.Responses.Any()) throw new ValidationException("Responses are required for manual evaluation."); dto.TenantId = loginDTO.ClientId; dto.ModifiedById = loginDTO.UserId; dto.ModifiedOn = DateTime.UtcNow; dto.EvaluatedById = loginDTO.UserId; // Fetch authoritative TotalMarks + PassThresholdPct from DB var assessment = await _AssessmentDAL.GetAssessment(dto.AssessmentId, loginDTO); if (assessment == null) throw new Exception($"Assessment {dto.AssessmentId} not found."); dto.TotalMarks = assessment.TotalMarks; if (dto.Responses != null && dto.Responses.Any()) { foreach (var response in dto.Responses) await _AttemptResponseDAL.UpdateAttemptResponse(response, loginDTO); dto.MarksObtained = dto.Responses.Sum(r => r.MarksAwarded); dto.ScorePct = dto.TotalMarks > 0 ? Math.Round((dto.MarksObtained / dto.TotalMarks) * 100, 4) : 0; } dto.AttemptIsPassed = (byte)(dto.ScorePct >= assessment.PassThresholdPct ? 1 : 0); await _AssessmentAttemptDAL.UpdateAssessmentAttemptScore(dto, loginDTO); await _AssessmentAssignmentBLL.ResolveAssignmentOnAttemptAsync(dto.AssessmentId, dto.EnrollmentId, dto.AssessmentAttemptId, loginDTO, default); if (dto.AttemptIsPassed == 1) await _TxSkillUpgradeBLL.ApplySkillMappingAsync(assessment.ProgrammeId, dto.AssessmentId, dto.EnrollmentId, loginDTO, default); return $"{string.Format(SuccessResponse.UpdateSuccessWithName, dto.AssessmentTitle)} — Score: {dto.ScorePct}%, {(dto.AttemptIsPassed == 1 ? "PASSED" : "FAILED")}"; } catch (Exception) { throw; } } // ── MANUAL / OFFLINE ENTRY ───────────────────────────── public async Task RecordManualAttempt(AssessmentAttemptDTO dto, LoginDTO loginDTO) { if (dto == null) throw new ArgumentNullException(nameof(dto)); dto.DeliveryChannel = 2; // Manual // StartAssessmentAttempt saves the header + any full-detail responses (blank IsCorrect/ // MarksAwarded); SubmitAssessmentAttempt then either auto-evaluates those responses // (full-detail mode) or, when dto.Responses is empty, uses the caller-supplied // dto.ScorePct directly (summary-only mode) — same existing logic, no new scoring path. await StartAssessmentAttempt(dto, loginDTO); return await SubmitAssessmentAttempt(dto, loginDTO); } // ── ANSWER EVALUATION HELPER ────────────────────────── /// /// Type-aware answer comparison: /// MCQ(0), TrueFalse(1), ShortAnswer(2) → case-insensitive string match /// MultiSelect(3) → both sides split by comma, sorted, then compared (order-independent) /// Matching(4), Ordering(5) → JSON-token-normalized compare (whitespace-insensitive) /// private static bool IsAnswerCorrect(byte questionType, string? responseGiven, string correctAnswer) { if (string.IsNullOrWhiteSpace(responseGiven)) return false; switch (questionType) { case 3: // MultiSelect — comma-separated option ids, order-independent var given = responseGiven.Split(',').Select(x => x.Trim()).OrderBy(x => x); var correct = correctAnswer.Split(',').Select(x => x.Trim()).OrderBy(x => x); return given.SequenceEqual(correct, StringComparer.OrdinalIgnoreCase); case 4: // Matching — JSON pairs; normalize whitespace before compare case 5: // Ordering — JSON array; normalize whitespace before compare var normalizedGiven = System.Text.RegularExpressions.Regex.Replace(responseGiven.Trim(), @"\s+", ""); var normalizedCorrect = System.Text.RegularExpressions.Regex.Replace(correctAnswer.Trim(), @"\s+", ""); return string.Equals(normalizedGiven, normalizedCorrect, StringComparison.OrdinalIgnoreCase); default: // MCQ(0), TrueFalse(1), ShortAnswer(2) return string.Equals(responseGiven.Trim(), correctAnswer.Trim(), StringComparison.OrdinalIgnoreCase); } } // ── DELETE ──────────────────────────────────────────── public async Task DeleteAssessmentAttempt(int assessmentAttemptId, LoginDTO loginDTO) { try { return await _AssessmentAttemptDAL.DeleteAssessmentAttempt(assessmentAttemptId, loginDTO); } catch (Exception) { throw; } } } }