Boost Azure Demo
This commit is contained in:
562
Backend/internal/handlers/api/answers/handler.go
Normal file
562
Backend/internal/handlers/api/answers/handler.go
Normal file
@@ -0,0 +1,562 @@
|
||||
// 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), " ")
|
||||
}
|
||||
16
Backend/internal/handlers/api/answers/routes.go
Normal file
16
Backend/internal/handlers/api/answers/routes.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Path: Backend/internal/handlers/api/answers/routes.go
|
||||
|
||||
package answers
|
||||
|
||||
import (
|
||||
authmw "boostai-backend/internal/middleware"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) {
|
||||
app.Get("/assignments/:assignmentId/answers", auth.RequireTeacher(), h.ListAnswersForAssignment)
|
||||
app.Get("/students/:studentId/answers", auth.RequireStudentSelfOrTeacher("studentId"), h.ListAnswersForStudent)
|
||||
app.Post("/answers", h.UpsertStudentAnswer)
|
||||
app.Patch("/answers/:answerId/review", auth.RequireTeacher(), h.UpdateAnswerReview)
|
||||
}
|
||||
Reference in New Issue
Block a user