Files
BoostAI/Backend/internal/handlers/api/answers/handler.go
2026-05-25 17:05:06 +01:00

563 lines
19 KiB
Go

// 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), " ")
}