// Path: Backend/internal/handlers/api/answers/handler.go package answers import ( "boostai-backend/internal/aireview" "boostai-backend/internal/handlers/api/shared" "boostai-backend/internal/http/params" "boostai-backend/internal/http/respond" authmw "boostai-backend/internal/middleware" "boostai-backend/internal/sqlc" "context" "errors" "fmt" "log" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) type Handler struct { queries *sqlc.Queries aiReview *aireview.Service } type StudentAnswerResponse struct { ID int64 `json:"id"` AssignmentID int64 `json:"assignment_id"` QuestionID int64 `json:"question_id"` StudentID int64 `json:"student_id"` AnswerText *string `json:"answer_text,omitempty"` IsCorrect *bool `json:"is_correct,omitempty"` SolveMode string `json:"solve_mode"` WorkingSteps *string `json:"working_steps,omitempty"` AiFeedback *string `json:"ai_feedback,omitempty"` TeacherFeedback *string `json:"teacher_feedback,omitempty"` Status string `json:"status"` ReviewNeedsAttention bool `json:"review_needs_attention"` ReviewIssueReason *string `json:"review_issue_reason,omitempty"` ReviewCorrectnessScore *float64 `json:"review_correctness_score,omitempty"` ReviewUnderstandingScore *float64 `json:"review_understanding_score,omitempty"` ReviewQuestionScore *float64 `json:"review_question_score,omitempty"` ReviewConfidence *float64 `json:"review_confidence,omitempty"` ReviewTags []string `json:"review_tags"` SubmittedAt *time.Time `json:"submitted_at,omitempty"` ReviewedAt *time.Time `json:"reviewed_at,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` } type upsertStudentAnswerRequest struct { AssignmentID int64 `json:"assignment_id"` QuestionID int64 `json:"question_id"` StudentID int64 `json:"student_id"` AnswerText *string `json:"answer_text"` SolveMode string `json:"solve_mode"` WorkingSteps *string `json:"working_steps"` AiFeedback *string `json:"ai_feedback"` TeacherFeedback *string `json:"teacher_feedback"` Status string `json:"status"` SubmittedAt *time.Time `json:"submitted_at"` ReviewedAt *time.Time `json:"reviewed_at"` } type updateAnswerReviewRequest struct { Status string `json:"status"` ReviewNeedsAttention *bool `json:"review_needs_attention"` ReviewIssueReason *string `json:"review_issue_reason"` ReviewCorrectnessScore *float64 `json:"review_correctness_score"` ReviewUnderstandingScore *float64 `json:"review_understanding_score"` ReviewQuestionScore *float64 `json:"review_question_score"` ReviewConfidence *float64 `json:"review_confidence"` ReviewTags []string `json:"review_tags"` } func NewHandler(queries *sqlc.Queries, aiReview *aireview.Service) *Handler { return &Handler{queries: queries, aiReview: aiReview} } func (h *Handler) ListAnswersForAssignment(c *fiber.Ctx) error { assignmentID, err := params.Int64PathParam(c, "assignmentId") if err != nil { return err } ctx, cancel := shared.WithTimeout() defer cancel() answers, err := h.queries.ListAnswersForAssignment(ctx, assignmentID) if err != nil { return respond.DatabaseError(c, err) } items := make([]StudentAnswerResponse, 0, len(answers)) for _, answer := range answers { items = append(items, mapStudentAnswer(answer)) } return c.JSON(shared.ListResponse[StudentAnswerResponse]{Data: items}) } func (h *Handler) ListAnswersForStudent(c *fiber.Ctx) error { studentID, err := params.Int64PathParam(c, "studentId") if err != nil { return err } ctx, cancel := shared.WithTimeout() defer cancel() answers, err := h.queries.ListAnswersForStudent(ctx, studentID) if err != nil { return respond.DatabaseError(c, err) } items := make([]StudentAnswerResponse, 0, len(answers)) for _, answer := range answers { items = append(items, mapStudentAnswer(answer)) } return c.JSON(shared.ListResponse[StudentAnswerResponse]{Data: items}) } func (h *Handler) UpsertStudentAnswer(c *fiber.Ctx) error { var req upsertStudentAnswerRequest if err := c.BodyParser(&req); err != nil { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") } studentID := req.StudentID if authmw.CurrentUserRole(c) == sqlc.UserRoleStudent { studentID = authmw.CurrentUserID(c) } if req.AssignmentID == 0 || req.QuestionID == 0 || studentID == 0 || strings.TrimSpace(req.Status) == "" { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "assignment_id, question_id, student identity, and status are required") } solveMode := strings.TrimSpace(req.SolveMode) if solveMode == "" { solveMode = "just_answer" } if !isValidSolveMode(solveMode) { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "A valid solve_mode is required") } ctx, cancel := shared.WithTimeout() defer cancel() question, err := h.queries.GetQuestionByID(ctx, req.QuestionID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return respond.Error(c, fiber.StatusNotFound, "not_found", "Question not found") } return respond.DatabaseError(c, err) } isCorrect := compareAnswer(question.CorrectAnswer, req.AnswerText) answer, err := h.queries.UpsertStudentAnswer(ctx, sqlc.UpsertStudentAnswerParams{ AssignmentID: req.AssignmentID, QuestionID: req.QuestionID, StudentID: studentID, AnswerText: shared.NullableText(req.AnswerText), SolveMode: solveMode, WorkingSteps: shared.NullableText(req.WorkingSteps), AiFeedback: shared.NullableText(req.AiFeedback), TeacherFeedback: shared.NullableText(req.TeacherFeedback), Status: sqlc.AnswerStatus(strings.TrimSpace(req.Status)), SubmittedAt: shared.NullableTime(req.SubmittedAt), ReviewedAt: shared.NullableTime(req.ReviewedAt), IsCorrect: shared.NullableBool(isCorrect), }) if err != nil { return respond.DatabaseError(c, err) } if strings.TrimSpace(req.Status) == string(sqlc.AnswerStatusSubmitted) { updatedAnswer, aiErr := h.runAISubmissionReview(context.Background(), req.AssignmentID, studentID, answer) if aiErr != nil { log.Printf("AI review failed for assignment %d student %d: %v", req.AssignmentID, studentID, aiErr) } else { answer = updatedAnswer } } return c.Status(fiber.StatusCreated).JSON(mapStudentAnswer(answer)) } func (h *Handler) runAISubmissionReview(parentCtx context.Context, assignmentID, studentID int64, currentAnswer sqlc.StudentAnswer) (sqlc.StudentAnswer, error) { if h.aiReview == nil || !h.aiReview.Enabled() { return currentAnswer, nil } dbCtx, cancel := shared.WithTimeout() assignment, err := h.queries.GetAssignmentByID(dbCtx, assignmentID) cancel() if err != nil { return currentAnswer, fmt.Errorf("load assignment for AI review: %w", err) } detailCtx, cancel := shared.WithTimeout() questions, err := h.queries.ListQuestionDetailsForAssignmentStudent(detailCtx, sqlc.ListQuestionDetailsForAssignmentStudentParams{ AssignmentID: assignmentID, StudentID: studentID, }) cancel() if err != nil { return currentAnswer, fmt.Errorf("load assignment question details for AI review: %w", err) } input := buildAssignmentReviewInput(assignment, studentID, questions) if len(input.Questions) == 0 { return currentAnswer, nil } var result *aireview.AssignmentReviewResult var lastErr error for attempt := 1; attempt <= 3; attempt++ { attemptCtx, attemptCancel := context.WithTimeout(parentCtx, 45*time.Second) result, lastErr = h.aiReview.ReviewSubmission(attemptCtx, input) attemptCancel() if lastErr == nil { break } if attempt < 3 { time.Sleep(time.Duration(attempt) * time.Second) } } if lastErr != nil { fallbackMessage := fmt.Sprintf("AI review could not be completed automatically after 3 attempts. Please review manually. Last error: %v", lastErr) updateCtx, updateCancel := shared.WithTimeout() _, updateErr := h.queries.UpdateAssignmentAIReview(updateCtx, sqlc.UpdateAssignmentAIReviewParams{ AssignmentID: assignmentID, StudentID: studentID, AiFeedback: shared.NullableText(&fallbackMessage), Column4: "", }) updateCancel() if updateErr != nil { return currentAnswer, fmt.Errorf("AI review failed (%v) and fallback update failed: %w", lastErr, updateErr) } return currentAnswer, lastErr } questionByID := make(map[int64]sqlc.ListQuestionDetailsForAssignmentStudentRow, len(questions)) for _, question := range questions { if question.AnswerID.Valid { questionByID[question.QuestionID] = question } } updatedAnswer := currentAnswer for _, review := range result.Questions { question, ok := questionByID[review.QuestionID] if !ok || !question.AnswerID.Valid { continue } aiFeedback := review.AiFeedback issueReason := review.IssueReason correctnessScore := 1.0 questionScore := 1.0 understandingScore := review.UnderstandingScore confidence := review.Confidence updateCtx, updateCancel := shared.WithTimeout() answer, updateErr := h.queries.UpdateAnswerAIReview(updateCtx, sqlc.UpdateAnswerAIReviewParams{ ID: question.AnswerID.Int64, AiFeedback: shared.NullableText(&aiFeedback), ReviewNeedsAttention: review.NeedsAttention, ReviewIssueReason: shared.NullableText(&issueReason), ReviewCorrectnessScore: mustNumeric(correctnessScore), ReviewUnderstandingScore: mustNumeric(understandingScore), ReviewQuestionScore: mustNumeric(questionScore), ReviewConfidence: mustNumeric(confidence), }) updateCancel() if updateErr != nil { return currentAnswer, fmt.Errorf("persist AI answer review for answer %d: %w", question.AnswerID.Int64, updateErr) } if answer.ID == currentAnswer.ID { updatedAnswer = answer } } for _, question := range questions { if !question.AnswerID.Valid { continue } answerText := strings.TrimSpace(shared.TextValue(question.AnswerText)) workingSteps := strings.TrimSpace(shared.TextValue(question.WorkingSteps)) if answerText != "" || workingSteps != "" { continue } updateCtx, updateCancel := shared.WithTimeout() answer, updateErr := h.queries.UpdateAnswerAIReview(updateCtx, sqlc.UpdateAnswerAIReviewParams{ ID: question.AnswerID.Int64, AiFeedback: shared.NullableText(pointerToString("No answer was submitted for this question.")), ReviewNeedsAttention: true, ReviewIssueReason: shared.NullableText(pointerToString("No answer submitted.")), ReviewCorrectnessScore: mustNumeric(1.0), ReviewUnderstandingScore: mustNumeric(0.0), ReviewQuestionScore: mustNumeric(1.0), ReviewConfidence: mustNumeric(1.0), }) updateCancel() if updateErr != nil { return currentAnswer, fmt.Errorf("persist blank-answer AI review for answer %d: %w", question.AnswerID.Int64, updateErr) } if answer.ID == currentAnswer.ID { updatedAnswer = answer } } assignmentSummary := strings.TrimSpace(result.AssignmentSummary) nextStepOutcome := sqlc.NullAssignmentNextStepOutcome{} if result.RecommendedNextStep != "" { nextStepOutcome = sqlc.NullAssignmentNextStepOutcome{ AssignmentNextStepOutcome: sqlc.AssignmentNextStepOutcome(result.RecommendedNextStep), Valid: true, } } updateCtx, updateCancel := shared.WithTimeout() _, err = h.queries.UpdateAssignmentAIReview(updateCtx, sqlc.UpdateAssignmentAIReviewParams{ AssignmentID: assignmentID, StudentID: studentID, AiFeedback: shared.NullableText(&assignmentSummary), Column4: nextStepOutcomeString(nextStepOutcome), }) updateCancel() if err != nil { return currentAnswer, fmt.Errorf("persist assignment AI review: %w", err) } return updatedAnswer, nil } func buildAssignmentReviewInput(assignment sqlc.Assignment, studentID int64, questions []sqlc.ListQuestionDetailsForAssignmentStudentRow) aireview.AssignmentReviewInput { passThreshold := 6.0 if value := shared.NumericPointer(assignment.PassThreshold); value != nil { passThreshold = *value } input := aireview.AssignmentReviewInput{ AssignmentID: assignment.ID, StudentID: studentID, AssignmentTitle: assignment.Title, Instructions: strings.TrimSpace(shared.TextValue(assignment.Instructions)), PassThreshold: passThreshold, Questions: make([]aireview.AssignmentQuestionInput, 0, len(questions)), } for _, question := range questions { answerText := strings.TrimSpace(shared.TextValue(question.AnswerText)) workingSteps := strings.TrimSpace(shared.TextValue(question.WorkingSteps)) answerStatus := "" if question.AnswerStatus.Valid { answerStatus = string(question.AnswerStatus.AnswerStatus) } input.Questions = append(input.Questions, aireview.AssignmentQuestionInput{ QuestionID: question.QuestionID, Position: question.Position, Title: question.Title, Prompt: question.Prompt, Subject: strings.TrimSpace(shared.TextValue(question.Subject)), Source: strings.TrimSpace(shared.TextValue(question.Source)), CorrectAnswer: strings.TrimSpace(shared.TextValue(question.CorrectAnswer)), QuestionTags: question.QuestionTags, SolveMode: strings.TrimSpace(shared.TextValue(question.SolveMode)), AnswerText: answerText, WorkingSteps: workingSteps, IsCorrect: shared.BoolPointer(question.IsCorrect), AnswerStatus: answerStatus, }) } return input } func mustNumeric(value float64) pgtype.Numeric { numeric, err := shared.NullableFloat64AsNumeric(&value) if err != nil { panic(err) } return numeric } func nextStepOutcomeString(value sqlc.NullAssignmentNextStepOutcome) string { if !value.Valid { return "" } return string(value.AssignmentNextStepOutcome) } func pointerToString(value string) *string { return &value } func (h *Handler) UpdateAnswerReview(c *fiber.Ctx) error { answerID, err := params.Int64PathParam(c, "answerId") if err != nil { return err } var req updateAnswerReviewRequest if err := c.BodyParser(&req); err != nil { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body") } status := strings.TrimSpace(req.Status) if status == "" || !shared.IsValidAnswerStatus(status) { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "A valid answer status is required") } for _, score := range []struct { name string value *float64 }{ {name: "review_correctness_score", value: req.ReviewCorrectnessScore}, {name: "review_understanding_score", value: req.ReviewUnderstandingScore}, {name: "review_question_score", value: req.ReviewQuestionScore}, {name: "review_confidence", value: req.ReviewConfidence}, } { if score.value != nil && (*score.value < 0 || *score.value > 1) { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", score.name+" must be between 0 and 1") } } reviewCorrectnessScore, err := shared.NullableFloat64AsNumeric(req.ReviewCorrectnessScore) if err != nil { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "review_correctness_score must be a valid number") } reviewUnderstandingScore, err := shared.NullableFloat64AsNumeric(req.ReviewUnderstandingScore) if err != nil { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "review_understanding_score must be a valid number") } reviewQuestionScore, err := shared.NullableFloat64AsNumeric(req.ReviewQuestionScore) if err != nil { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "review_question_score must be a valid number") } reviewConfidence, err := shared.NullableFloat64AsNumeric(req.ReviewConfidence) if err != nil { return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "review_confidence must be a valid number") } reviewNeedsAttention := false if req.ReviewNeedsAttention != nil { reviewNeedsAttention = *req.ReviewNeedsAttention } reviewTags := req.ReviewTags if reviewTags == nil { reviewTags = []string{} } ctx, cancel := shared.WithTimeout() defer cancel() answer, err := h.queries.UpdateAnswerReview(ctx, sqlc.UpdateAnswerReviewParams{ ID: answerID, Status: sqlc.AnswerStatus(status), ReviewNeedsAttention: reviewNeedsAttention, ReviewIssueReason: shared.NullableText(req.ReviewIssueReason), ReviewCorrectnessScore: reviewCorrectnessScore, ReviewUnderstandingScore: reviewUnderstandingScore, ReviewQuestionScore: reviewQuestionScore, ReviewConfidence: reviewConfidence, ReviewTags: reviewTags, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return respond.Error(c, fiber.StatusNotFound, "not_found", "Answer not found") } return respond.DatabaseError(c, err) } return c.JSON(mapStudentAnswer(answer)) } func mapStudentAnswer(answer sqlc.StudentAnswer) StudentAnswerResponse { return StudentAnswerResponse{ ID: answer.ID, AssignmentID: answer.AssignmentID, QuestionID: answer.QuestionID, StudentID: answer.StudentID, AnswerText: shared.TextPointer(answer.AnswerText), IsCorrect: shared.BoolPointer(answer.IsCorrect), SolveMode: answer.SolveMode, WorkingSteps: shared.TextPointer(answer.WorkingSteps), AiFeedback: shared.TextPointer(answer.AiFeedback), TeacherFeedback: shared.TextPointer(answer.TeacherFeedback), Status: string(answer.Status), ReviewNeedsAttention: answer.ReviewNeedsAttention, ReviewIssueReason: shared.TextPointer(answer.ReviewIssueReason), ReviewCorrectnessScore: shared.NumericPointer(answer.ReviewCorrectnessScore), ReviewUnderstandingScore: shared.NumericPointer(answer.ReviewUnderstandingScore), ReviewQuestionScore: shared.NumericPointer(answer.ReviewQuestionScore), ReviewConfidence: shared.NumericPointer(answer.ReviewConfidence), ReviewTags: answer.ReviewTags, SubmittedAt: shared.TimePointer(answer.SubmittedAt), ReviewedAt: shared.TimePointer(answer.ReviewedAt), CreatedAt: shared.TimePointer(answer.CreatedAt), UpdatedAt: shared.TimePointer(answer.UpdatedAt), } } func isValidSolveMode(value string) bool { switch value { case "just_answer", "step_by_step", "solve_together", "handwritten": return true default: return false } } func compareAnswer(correctAnswer pgtype.Text, studentAnswer *string) *bool { if !correctAnswer.Valid { return nil } canonical := normalizeComparableAnswer(correctAnswer.String) if canonical == "" { return nil } if studentAnswer == nil { return nil } student := normalizeComparableAnswer(*studentAnswer) if student == "" { return nil } result := student == canonical return &result } func normalizeComparableAnswer(value string) string { trimmed := strings.TrimSpace(strings.ToLower(value)) if trimmed == "" { return "" } return strings.Join(strings.Fields(trimmed), " ") }