563 lines
19 KiB
Go
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), " ")
|
|
}
|