Boost Azure Demo
This commit is contained in:
660
Backend/internal/handlers/api/assignments/handler.go
Normal file
660
Backend/internal/handlers/api/assignments/handler.go
Normal file
@@ -0,0 +1,660 @@
|
||||
package assignments
|
||||
|
||||
import (
|
||||
"boostai-backend/internal/aireview"
|
||||
"boostai-backend/internal/assignmentgen"
|
||||
"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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
queries *sqlc.Queries
|
||||
aiReview *aireview.Service
|
||||
assignmentGenerator *assignmentgen.Service
|
||||
}
|
||||
|
||||
const fixedPassThreshold = 6.0
|
||||
|
||||
func NewHandler(queries *sqlc.Queries, aiReview *aireview.Service, assignmentGenerator *assignmentgen.Service) *Handler {
|
||||
return &Handler{queries: queries, aiReview: aiReview, assignmentGenerator: assignmentGenerator}
|
||||
}
|
||||
|
||||
func (h *Handler) ListAssignmentsByTeacher(c *fiber.Ctx) error {
|
||||
teacherID, err := params.Int64PathParam(c, "teacherId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
assignments, err := h.queries.ListAssignmentsByTeacher(ctx, teacherID)
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
items := make([]AssignmentResponse, 0, len(assignments))
|
||||
for _, assignment := range assignments {
|
||||
items = append(items, mapAssignment(assignment))
|
||||
}
|
||||
|
||||
return c.JSON(shared.ListResponse[AssignmentResponse]{Data: items})
|
||||
}
|
||||
|
||||
func (h *Handler) ListAssignmentsForStudent(c *fiber.Ctx) error {
|
||||
studentID, err := params.Int64PathParam(c, "studentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
assignments, err := h.queries.ListAssignmentsForStudent(ctx, studentID)
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
items := make([]AssignmentResponse, 0, len(assignments))
|
||||
for _, assignment := range assignments {
|
||||
items = append(items, mapAssignment(assignment))
|
||||
}
|
||||
|
||||
return c.JSON(shared.ListResponse[AssignmentResponse]{Data: items})
|
||||
}
|
||||
|
||||
func (h *Handler) GetAssignmentByID(c *fiber.Ctx) error {
|
||||
assignmentID, err := params.Int64PathParam(c, "assignmentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment not found")
|
||||
}
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(mapAssignment(assignment))
|
||||
}
|
||||
|
||||
func (h *Handler) ListQuestionsForAssignment(c *fiber.Ctx) error {
|
||||
assignmentID, err := params.Int64PathParam(c, "assignmentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
questions, err := h.queries.ListQuestionsForAssignment(ctx, assignmentID)
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
items := make([]AssignmentQuestionResponse, 0, len(questions))
|
||||
for _, question := range questions {
|
||||
items = append(items, mapAssignmentQuestion(question))
|
||||
}
|
||||
|
||||
return c.JSON(shared.ListResponse[AssignmentQuestionResponse]{Data: items})
|
||||
}
|
||||
|
||||
func (h *Handler) ListQuestionDetailsForAssignmentStudent(c *fiber.Ctx) error {
|
||||
assignmentID, err := params.Int64PathParam(c, "assignmentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
studentID, err := params.Int64PathParam(c, "studentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
rows, err := h.queries.ListQuestionDetailsForAssignmentStudent(ctx, sqlc.ListQuestionDetailsForAssignmentStudentParams{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: studentID,
|
||||
})
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
items := make([]AssignmentStudentQuestionDetailResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, mapAssignmentStudentQuestionDetail(row, studentID))
|
||||
}
|
||||
|
||||
return c.JSON(shared.ListResponse[AssignmentStudentQuestionDetailResponse]{Data: items})
|
||||
}
|
||||
|
||||
func (h *Handler) GetAssignmentReviewSummary(c *fiber.Ctx) error {
|
||||
assignmentID, err := params.Int64PathParam(c, "assignmentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
summary, err := h.queries.GetAssignmentReviewSummary(ctx, assignmentID)
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(mapAssignmentReviewSummary(summary))
|
||||
}
|
||||
|
||||
func (h *Handler) GetAssignmentRedoPlan(c *fiber.Ctx) error {
|
||||
assignmentID, err := params.Int64PathParam(c, "assignmentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
studentID, err := params.Int64PathParam(c, "studentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
row, err := h.queries.GetAssignmentRedoPlan(ctx, sqlc.GetAssignmentRedoPlanParams{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: studentID,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment student record not found")
|
||||
}
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
summary, err := h.buildStudentWeaknessSummary(studentID)
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
response := AssignmentRedoPlanResponse{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: studentID,
|
||||
RedoPlanGeneratedAt: shared.TimePointer(row.RedoPlanGeneratedAt),
|
||||
WeaknessSummary: mapWeaknessSummary(studentID, summary),
|
||||
}
|
||||
|
||||
if row.RedoPlan.Valid {
|
||||
stored, err := parseStoredRedoPlan(row.RedoPlan.String)
|
||||
if err != nil {
|
||||
response.Error = fmt.Sprintf("stored redo plan could not be parsed: %v", err)
|
||||
} else {
|
||||
response.TeacherFeedback = emptyStringPointer(stored.TeacherFeedback)
|
||||
response.Error = stored.Error
|
||||
response.Plan = stored.Plan
|
||||
if len(stored.WeaknessSummary.TopicScores) > 0 || len(stored.WeaknessSummary.WeakTags) > 0 || len(stored.WeaknessSummary.RecentIssues) > 0 {
|
||||
response.WeaknessSummary = mapWeaknessSummary(studentID, stored.WeaknessSummary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateAssignmentDraft(c *fiber.Ctx) error {
|
||||
assignmentID, err := params.Int64PathParam(c, "assignmentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req updateAssignmentDraftRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
|
||||
}
|
||||
|
||||
teacherID := authmw.CurrentUserID(c)
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if req.ClassroomID == 0 || teacherID == 0 || title == "" {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "classroom_id, teacher authentication, and title are required")
|
||||
}
|
||||
|
||||
passThreshold, err := shared.NullableFloat64AsNumeric(pointerToFloat64(fixedPassThreshold))
|
||||
if err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "pass_threshold must be a valid number")
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment not found")
|
||||
}
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
if assignment.TeacherID != teacherID {
|
||||
return respond.Error(c, fiber.StatusForbidden, "forbidden", "You can only edit your own draft assignments")
|
||||
}
|
||||
|
||||
if assignment.Status != sqlc.AssignmentStatusDraft {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Only draft assignments can be edited here")
|
||||
}
|
||||
|
||||
classrooms, err := h.queries.ListClassroomsByTeacher(ctx, teacherID)
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
classroomAllowed := false
|
||||
for _, classroom := range classrooms {
|
||||
if classroom.ID == req.ClassroomID {
|
||||
classroomAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !classroomAllowed {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Choose one of your classrooms for this draft")
|
||||
}
|
||||
|
||||
updatedAssignment, err := h.queries.UpdateAssignmentDraft(ctx, sqlc.UpdateAssignmentDraftParams{
|
||||
ID: assignmentID,
|
||||
ClassroomID: req.ClassroomID,
|
||||
Title: title,
|
||||
Instructions: shared.NullableText(req.Instructions),
|
||||
PassThreshold: passThreshold,
|
||||
DueAt: shared.NullableTime(req.DueAt),
|
||||
})
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(mapAssignment(updatedAssignment))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateAssignmentTeacherFeedback(c *fiber.Ctx) error {
|
||||
assignmentID, err := params.Int64PathParam(c, "assignmentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
studentID, err := params.Int64PathParam(c, "studentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req updateAssignmentTeacherFeedbackRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
|
||||
}
|
||||
|
||||
passStatusOverrideValue := ""
|
||||
if req.PassStatusOverride != nil {
|
||||
passStatusOverrideValue = strings.TrimSpace(*req.PassStatusOverride)
|
||||
if passStatusOverrideValue != "" && !isValidAssignmentPassStatus(passStatusOverrideValue) {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "pass_status_override must be pending, pass, no_pass, or empty")
|
||||
}
|
||||
}
|
||||
|
||||
nextStepOutcomeValue := ""
|
||||
if req.NextStepOutcome != nil {
|
||||
nextStepOutcomeValue = strings.TrimSpace(*req.NextStepOutcome)
|
||||
if nextStepOutcomeValue != "" && !isValidAssignmentNextStepOutcome(nextStepOutcomeValue) {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "next_step_outcome must be redo, accept, support, or empty")
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
row, err := h.queries.UpdateAssignmentTeacherFeedback(ctx, sqlc.UpdateAssignmentTeacherFeedbackParams{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: studentID,
|
||||
TeacherFeedback: shared.NullableText(req.TeacherFeedback),
|
||||
Column4: passStatusOverrideValue,
|
||||
Column5: nextStepOutcomeValue,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment student record not found")
|
||||
}
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
if nextStepOutcomeValue == string(sqlc.AssignmentNextStepOutcomeRedo) {
|
||||
if err := h.generateAndStoreRedoPlan(assignmentID, studentID, strings.TrimSpace(shared.TextValue(row.TeacherFeedback))); err != nil {
|
||||
fmt.Printf("redo plan generation failed for assignment %d student %d: %v\n", assignmentID, studentID, err)
|
||||
}
|
||||
} else {
|
||||
clearCtx, clearCancel := shared.WithTimeout()
|
||||
_, clearErr := h.queries.UpdateAssignmentRedoPlan(clearCtx, sqlc.UpdateAssignmentRedoPlanParams{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: studentID,
|
||||
Column3: "",
|
||||
})
|
||||
clearCancel()
|
||||
if clearErr != nil && !errors.Is(clearErr, pgx.ErrNoRows) {
|
||||
fmt.Printf("redo plan clear failed for assignment %d student %d: %v\n", assignmentID, studentID, clearErr)
|
||||
}
|
||||
}
|
||||
|
||||
var passStatusOverride *string
|
||||
if row.PassStatusOverride.Valid {
|
||||
status := string(row.PassStatusOverride.AssignmentPassStatus)
|
||||
passStatusOverride = &status
|
||||
}
|
||||
|
||||
var nextStepOutcome *string
|
||||
if row.NextStepOutcome.Valid {
|
||||
outcome := string(row.NextStepOutcome.AssignmentNextStepOutcome)
|
||||
nextStepOutcome = &outcome
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"assignment_id": assignmentID,
|
||||
"student_id": studentID,
|
||||
"ai_feedback": shared.TextPointer(row.AiFeedback),
|
||||
"teacher_feedback": shared.TextPointer(row.TeacherFeedback),
|
||||
"overall_score": shared.NumericPointer(row.OverallScore),
|
||||
"pass_threshold": shared.NumericPointer(row.PassThreshold),
|
||||
"next_step_outcome": nextStepOutcome,
|
||||
"pass_status_override": passStatusOverride,
|
||||
"pass_status": string(row.PassStatus),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) CloseAssignment(c *fiber.Ctx) error {
|
||||
assignmentID, err := params.Int64PathParam(c, "assignmentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
teacherID := authmw.CurrentUserID(c)
|
||||
if teacherID == 0 {
|
||||
return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "Teacher authentication is required")
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment not found")
|
||||
}
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
if assignment.TeacherID != teacherID {
|
||||
return respond.Error(c, fiber.StatusForbidden, "forbidden", "You can only close your own assignments")
|
||||
}
|
||||
|
||||
if assignment.Status == sqlc.AssignmentStatusDraft {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Draft assignments cannot be closed")
|
||||
}
|
||||
|
||||
if assignment.Status == sqlc.AssignmentStatusClosed {
|
||||
return c.JSON(mapAssignment(assignment))
|
||||
}
|
||||
|
||||
queue, err := h.queries.ListAssignmentReviewQueue(ctx, sqlc.ListAssignmentReviewQueueParams{
|
||||
AssignmentID: assignmentID,
|
||||
Column2: "",
|
||||
})
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
readiness := buildAssignmentCloseReadiness(queue)
|
||||
if !readiness.CanClose {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "assignment_not_ready_to_close",
|
||||
"message": "This assignment still has open review blockers.",
|
||||
"blockers": readiness.Blockers,
|
||||
})
|
||||
}
|
||||
|
||||
closedAssignment, err := h.queries.CloseAssignment(ctx, assignmentID)
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(mapAssignment(closedAssignment))
|
||||
}
|
||||
|
||||
func (h *Handler) ListAssignmentReviewQueue(c *fiber.Ctx) error {
|
||||
assignmentID, err := params.Int64PathParam(c, "assignmentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
statusFilter := strings.TrimSpace(c.Query("status"))
|
||||
if statusFilter != "" && !shared.IsValidAnswerStatus(statusFilter) {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Invalid review status filter")
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
rows, err := h.queries.ListAssignmentReviewQueue(ctx, sqlc.ListAssignmentReviewQueueParams{
|
||||
AssignmentID: assignmentID,
|
||||
Column2: statusFilter,
|
||||
})
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
items := make([]AssignmentReviewQueueItemResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, mapAssignmentReviewQueueItem(row))
|
||||
}
|
||||
|
||||
return c.JSON(shared.ListResponse[AssignmentReviewQueueItemResponse]{Data: items})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateAssignment(c *fiber.Ctx) error {
|
||||
var req createAssignmentRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
|
||||
}
|
||||
|
||||
teacherID := authmw.CurrentUserID(c)
|
||||
if req.ClassroomID == 0 || teacherID == 0 || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.Status) == "" {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "classroom_id, teacher authentication, title, and status are required")
|
||||
}
|
||||
|
||||
passThreshold, err := shared.NullableFloat64AsNumeric(pointerToFloat64(fixedPassThreshold))
|
||||
if err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "pass_threshold must be a valid number")
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
assignment, err := h.queries.CreateAssignment(ctx, sqlc.CreateAssignmentParams{
|
||||
ClassroomID: req.ClassroomID,
|
||||
TeacherID: teacherID,
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
Instructions: shared.NullableText(req.Instructions),
|
||||
PassThreshold: passThreshold,
|
||||
Status: sqlc.AssignmentStatus(strings.TrimSpace(req.Status)),
|
||||
DueAt: shared.NullableTime(req.DueAt),
|
||||
PublishedAt: shared.NullableTime(req.PublishedAt),
|
||||
})
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(mapAssignment(assignment))
|
||||
}
|
||||
|
||||
func (h *Handler) AssignStudentToAssignment(c *fiber.Ctx) error {
|
||||
assignmentID, err := params.Int64PathParam(c, "assignmentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req assignStudentToAssignmentRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
|
||||
}
|
||||
|
||||
if req.StudentID == 0 {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "student_id is required")
|
||||
}
|
||||
|
||||
teacherID := authmw.CurrentUserID(c)
|
||||
if teacherID == 0 {
|
||||
return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "Teacher authentication is required")
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return respond.Error(c, fiber.StatusNotFound, "not_found", "Assignment not found")
|
||||
}
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
if assignment.TeacherID != teacherID {
|
||||
return respond.Error(c, fiber.StatusForbidden, "forbidden", "You can only assign students to your own assignments")
|
||||
}
|
||||
|
||||
err = h.queries.AssignStudentToAssignment(ctx, sqlc.AssignStudentToAssignmentParams{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: req.StudentID,
|
||||
})
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
response := fiber.Map{
|
||||
"status": "ok",
|
||||
"assignment_id": assignmentID,
|
||||
"student_id": req.StudentID,
|
||||
}
|
||||
|
||||
if req.MixedGeneration != nil {
|
||||
if h.assignmentGenerator == nil {
|
||||
cleanupErr := h.queries.DeleteAssignmentAssignee(ctx, sqlc.DeleteAssignmentAssigneeParams{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: req.StudentID,
|
||||
})
|
||||
if cleanupErr != nil {
|
||||
return respond.DatabaseError(c, cleanupErr)
|
||||
}
|
||||
return respond.Error(c, fiber.StatusServiceUnavailable, "generator_unavailable", "Assignment generator is not configured")
|
||||
}
|
||||
|
||||
generated, generationErr := h.generateMixedStudentQuestionsForAssignmentStudent(ctx, assignmentID, req.StudentID, teacherID, req.MixedGeneration)
|
||||
if generationErr != nil {
|
||||
cleanupErr := h.queries.DeleteAssignmentAssignee(ctx, sqlc.DeleteAssignmentAssigneeParams{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: req.StudentID,
|
||||
})
|
||||
if cleanupErr != nil {
|
||||
return respond.DatabaseError(c, cleanupErr)
|
||||
}
|
||||
if apiErr, ok := generationErr.(*assignmentAPIError); ok {
|
||||
return respond.Error(c, apiErr.status, apiErr.code, apiErr.message)
|
||||
}
|
||||
return respond.DatabaseError(c, generationErr)
|
||||
}
|
||||
|
||||
response["mixed_generation"] = generated
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(response)
|
||||
}
|
||||
|
||||
func (h *Handler) AddQuestionToAssignment(c *fiber.Ctx) error {
|
||||
assignmentID, err := params.Int64PathParam(c, "assignmentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req addQuestionToAssignmentRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
|
||||
}
|
||||
|
||||
if req.QuestionID == 0 || req.Position <= 0 {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "question_id and positive position are required")
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
err = h.queries.AddQuestionToAssignment(ctx, sqlc.AddQuestionToAssignmentParams{
|
||||
AssignmentID: assignmentID,
|
||||
QuestionID: req.QuestionID,
|
||||
Position: req.Position,
|
||||
})
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
"assignment_id": assignmentID,
|
||||
"question_id": req.QuestionID,
|
||||
"position": req.Position,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) GenerateMixedStudentQuestions(c *fiber.Ctx) error {
|
||||
assignmentID, err := params.Int64PathParam(c, "assignmentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
studentID, err := params.Int64PathParam(c, "studentId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req generateMixedStudentQuestionsRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
|
||||
}
|
||||
|
||||
teacherID := authmw.CurrentUserID(c)
|
||||
if teacherID == 0 {
|
||||
return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "Teacher authentication is required")
|
||||
}
|
||||
|
||||
if h.assignmentGenerator == nil {
|
||||
return respond.Error(c, fiber.StatusServiceUnavailable, "generator_unavailable", "Assignment generator is not configured")
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
response, generationErr := h.generateMixedStudentQuestionsForAssignmentStudent(ctx, assignmentID, studentID, teacherID, &req)
|
||||
if generationErr != nil {
|
||||
if apiErr, ok := generationErr.(*assignmentAPIError); ok {
|
||||
return respond.Error(c, apiErr.status, apiErr.code, apiErr.message)
|
||||
}
|
||||
return respond.DatabaseError(c, generationErr)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(response)
|
||||
}
|
||||
321
Backend/internal/handlers/api/assignments/handler_generation.go
Normal file
321
Backend/internal/handlers/api/assignments/handler_generation.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package assignments
|
||||
|
||||
import (
|
||||
"boostai-backend/internal/aireview"
|
||||
"boostai-backend/internal/assignmentgen"
|
||||
"boostai-backend/internal/handlers/api/shared"
|
||||
"boostai-backend/internal/sqlc"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (h *Handler) generateMixedStudentQuestionsForAssignmentStudent(
|
||||
ctx context.Context,
|
||||
assignmentID int64,
|
||||
studentID int64,
|
||||
teacherID int64,
|
||||
req *generateMixedStudentQuestionsRequest,
|
||||
) (generateMixedStudentQuestionsResponse, error) {
|
||||
if h.assignmentGenerator == nil {
|
||||
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusServiceUnavailable, code: "generator_unavailable", message: "Assignment generator is not configured"}
|
||||
}
|
||||
|
||||
primaryTopic, err := parseQuestionTopicValue(req.PrimaryTopic)
|
||||
if err != nil {
|
||||
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: err.Error()}
|
||||
}
|
||||
|
||||
primaryDifficulty, err := parseQuestionDifficultyValue(req.PrimaryDifficulty)
|
||||
if err != nil {
|
||||
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: err.Error()}
|
||||
}
|
||||
|
||||
if req.TotalQuestions <= 0 {
|
||||
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: "total_questions must be greater than 0"}
|
||||
}
|
||||
|
||||
personalizedDifficulty := primaryDifficulty
|
||||
if req.PersonalizedDifficulty != nil && strings.TrimSpace(*req.PersonalizedDifficulty) != "" {
|
||||
personalizedDifficulty, err = parseQuestionDifficultyValue(*req.PersonalizedDifficulty)
|
||||
if err != nil {
|
||||
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: err.Error()}
|
||||
}
|
||||
}
|
||||
|
||||
questionStatus := sqlc.QuestionStatusDraft
|
||||
if req.QuestionStatus != nil && strings.TrimSpace(*req.QuestionStatus) != "" {
|
||||
questionStatus, err = parseQuestionStatusValue(*req.QuestionStatus)
|
||||
if err != nil {
|
||||
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "invalid_request", message: err.Error()}
|
||||
}
|
||||
}
|
||||
|
||||
personalizedRatio := 0.0
|
||||
if req.PersonalizedRatio != nil {
|
||||
personalizedRatio = *req.PersonalizedRatio
|
||||
}
|
||||
|
||||
assignment, err := h.queries.GetAssignmentByID(ctx, assignmentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusNotFound, code: "not_found", message: "Assignment not found"}
|
||||
}
|
||||
return generateMixedStudentQuestionsResponse{}, err
|
||||
}
|
||||
|
||||
if assignment.TeacherID != teacherID {
|
||||
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusForbidden, code: "forbidden", message: "You can only generate questions for your own assignments"}
|
||||
}
|
||||
|
||||
_, err = h.queries.GetAssignmentAssignee(ctx, sqlc.GetAssignmentAssigneeParams{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: studentID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusNotFound, code: "not_found", message: "Student is not assigned to this assignment"}
|
||||
}
|
||||
return generateMixedStudentQuestionsResponse{}, err
|
||||
}
|
||||
|
||||
result, err := h.assignmentGenerator.GenerateAndStoreMixedStudentQuestions(ctx, assignmentgen.GenerateMixedStudentQuestionSetParams{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: studentID,
|
||||
TeacherID: teacherID,
|
||||
Subject: trimmedPointerValue(req.Subject),
|
||||
QuestionStatus: questionStatus,
|
||||
QuestionSource: trimmedPointerValue(req.QuestionSource),
|
||||
PrimaryTopic: primaryTopic,
|
||||
PrimaryDifficulty: primaryDifficulty,
|
||||
TotalQuestions: req.TotalQuestions,
|
||||
PersonalizedRatio: personalizedRatio,
|
||||
Seed: int64Value(req.Seed),
|
||||
PersonalizedDifficulty: personalizedDifficulty,
|
||||
})
|
||||
if err != nil {
|
||||
return generateMixedStudentQuestionsResponse{}, &assignmentAPIError{status: fiber.StatusBadRequest, code: "generation_failed", message: err.Error()}
|
||||
}
|
||||
|
||||
questions := make([]mixedPlanQuestionResponse, 0, len(result.StoredQuestions))
|
||||
for _, item := range result.StoredQuestions {
|
||||
questions = append(questions, mixedPlanQuestionResponse{
|
||||
MappingID: item.Mapping.ID,
|
||||
QuestionID: item.Question.ID,
|
||||
Position: item.Mapping.Position,
|
||||
SourceBucket: item.Mapping.SourceBucket,
|
||||
SourceTopic: questionTopicPointer(item.Mapping.SourceTopic),
|
||||
SourceDifficulty: questionDifficultyPointer(item.Mapping.SourceDifficulty),
|
||||
GeneratorSeed: int64Pointer(item.UsedSeed),
|
||||
Title: item.Question.Title,
|
||||
Prompt: item.Question.Prompt,
|
||||
Subject: shared.TextPointer(item.Question.Subject),
|
||||
QuestionStatus: string(item.Question.Status),
|
||||
QuestionSource: shared.TextPointer(item.Question.Source),
|
||||
CorrectAnswer: shared.TextPointer(item.Question.CorrectAnswer),
|
||||
Tags: item.Tags,
|
||||
QuestionCreatedAt: shared.TimePointer(item.Question.CreatedAt),
|
||||
QuestionUpdatedAt: shared.TimePointer(item.Question.UpdatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
response := generateMixedStudentQuestionsResponse{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: studentID,
|
||||
PrimaryTopic: string(primaryTopic),
|
||||
PrimaryDifficulty: string(primaryDifficulty),
|
||||
TotalQuestions: req.TotalQuestions,
|
||||
CoreCount: result.MixedPlan.CoreCount,
|
||||
PersonalizedCount: result.MixedPlan.PersonalizedCount,
|
||||
PersonalizedApplied: result.MixedPlan.PersonalizedApplied,
|
||||
PersonalizedRatio: personalizedRatioValue(req.PersonalizedRatio),
|
||||
BaseSeed: result.MixedPlan.BaseSeed,
|
||||
WeaknessSummary: mapAssignmentGenerationWeaknessSummary(result.MixedPlan.WeaknessSummary),
|
||||
Questions: questions,
|
||||
}
|
||||
if result.MixedPlan.PersonalizedApplied {
|
||||
response.PersonalizedTopic = stringPointer(string(result.MixedPlan.PersonalizedTopic))
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (h *Handler) generateAndStoreRedoPlan(assignmentID, studentID int64, teacherFeedback string) error {
|
||||
summary, err := h.buildStudentWeaknessSummary(studentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build weakness summary: %w", err)
|
||||
}
|
||||
|
||||
stored := storedRedoPlan{
|
||||
TeacherFeedback: teacherFeedback,
|
||||
WeaknessSummary: summary,
|
||||
}
|
||||
|
||||
if h.aiReview != nil && h.aiReview.Enabled() {
|
||||
assignmentCtx, assignmentCancel := shared.WithTimeout()
|
||||
assignment, err := h.queries.GetAssignmentByID(assignmentCtx, assignmentID)
|
||||
assignmentCancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load assignment for redo plan: %w", err)
|
||||
}
|
||||
|
||||
passThreshold := fixedPassThreshold
|
||||
if value := shared.NumericPointer(assignment.PassThreshold); value != nil {
|
||||
passThreshold = *value
|
||||
}
|
||||
|
||||
planCtx, planCancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer planCancel()
|
||||
|
||||
plan, planErr := h.aiReview.PlanRedoAssignment(planCtx, aireview.RedoPlanInput{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: studentID,
|
||||
AssignmentTitle: assignment.Title,
|
||||
Instructions: strings.TrimSpace(shared.TextValue(assignment.Instructions)),
|
||||
TeacherFeedback: teacherFeedback,
|
||||
PassThreshold: passThreshold,
|
||||
TopicScores: summary.TopicScores,
|
||||
WeakTags: summary.WeakTags,
|
||||
RecentIssues: summary.RecentIssues,
|
||||
AllowedTopics: allowedQuestionTopics(),
|
||||
AllowedDifficulties: []string{"easy", "medium", "hard"},
|
||||
})
|
||||
if planErr != nil {
|
||||
stored.Error = fmt.Sprintf("AI redo plan could not be generated automatically: %v", planErr)
|
||||
} else {
|
||||
stored.Plan = plan
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal redo plan payload: %w", err)
|
||||
}
|
||||
|
||||
updateCtx, updateCancel := shared.WithTimeout()
|
||||
defer updateCancel()
|
||||
_, err = h.queries.UpdateAssignmentRedoPlan(updateCtx, sqlc.UpdateAssignmentRedoPlanParams{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: studentID,
|
||||
Column3: string(payload),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("persist redo plan: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) buildStudentWeaknessSummary(studentID int64) (weaknessSummary, error) {
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
rows, err := h.queries.ListStudentPlanningPerformance(ctx, studentID)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return weaknessSummary{}, err
|
||||
}
|
||||
|
||||
topicTotals := map[string]struct {
|
||||
sum float64
|
||||
count int
|
||||
}{}
|
||||
tagTotals := map[string]struct {
|
||||
sum float64
|
||||
count int
|
||||
flagged int
|
||||
}{}
|
||||
recentIssues := make([]string, 0, 5)
|
||||
seenIssues := map[string]struct{}{}
|
||||
|
||||
for _, row := range rows {
|
||||
score := planningScore(row.IsCorrect, row.ReviewUnderstandingScore)
|
||||
if row.Topic.Valid {
|
||||
key := string(row.Topic.QuestionTopic)
|
||||
total := topicTotals[key]
|
||||
total.sum += score
|
||||
total.count++
|
||||
topicTotals[key] = total
|
||||
}
|
||||
|
||||
for _, tag := range row.QuestionTags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
total := tagTotals[tag]
|
||||
total.sum += score
|
||||
total.count++
|
||||
if row.ReviewNeedsAttention {
|
||||
total.flagged++
|
||||
}
|
||||
tagTotals[tag] = total
|
||||
}
|
||||
|
||||
issue := strings.TrimSpace(shared.TextValue(row.ReviewIssueReason))
|
||||
if issue != "" {
|
||||
if _, exists := seenIssues[issue]; !exists {
|
||||
seenIssues[issue] = struct{}{}
|
||||
recentIssues = append(recentIssues, issue)
|
||||
if len(recentIssues) >= 5 {
|
||||
// keep collecting scores, but no need for more issue strings
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
topicScores := make(map[string]float64, len(topicTotals))
|
||||
for topic, total := range topicTotals {
|
||||
if total.count == 0 {
|
||||
continue
|
||||
}
|
||||
topicScores[topic] = roundToOneDecimal((total.sum / float64(total.count)) * 100)
|
||||
}
|
||||
|
||||
type weakTagCandidate struct {
|
||||
tag string
|
||||
score float64
|
||||
flagged int
|
||||
}
|
||||
candidates := make([]weakTagCandidate, 0, len(tagTotals))
|
||||
for tag, total := range tagTotals {
|
||||
if total.count == 0 {
|
||||
continue
|
||||
}
|
||||
avg := (total.sum / float64(total.count)) * 100
|
||||
if avg < 70 || total.flagged > 0 {
|
||||
candidates = append(candidates, weakTagCandidate{tag: tag, score: avg, flagged: total.flagged})
|
||||
}
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].score == candidates[j].score {
|
||||
if candidates[i].flagged == candidates[j].flagged {
|
||||
return candidates[i].tag < candidates[j].tag
|
||||
}
|
||||
return candidates[i].flagged > candidates[j].flagged
|
||||
}
|
||||
return candidates[i].score < candidates[j].score
|
||||
})
|
||||
weakTags := make([]string, 0, minInt(len(candidates), 6))
|
||||
for _, candidate := range candidates {
|
||||
weakTags = append(weakTags, candidate.tag)
|
||||
if len(weakTags) >= 6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(recentIssues) > 5 {
|
||||
recentIssues = recentIssues[:5]
|
||||
}
|
||||
|
||||
return weaknessSummary{
|
||||
TopicScores: topicScores,
|
||||
WeakTags: weakTags,
|
||||
RecentIssues: recentIssues,
|
||||
}, nil
|
||||
}
|
||||
375
Backend/internal/handlers/api/assignments/handler_helpers.go
Normal file
375
Backend/internal/handlers/api/assignments/handler_helpers.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package assignments
|
||||
|
||||
import (
|
||||
"boostai-backend/internal/assignmentgen"
|
||||
"boostai-backend/internal/handlers/api/shared"
|
||||
"boostai-backend/internal/sqlc"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func mapAssignment(assignment sqlc.Assignment) AssignmentResponse {
|
||||
return AssignmentResponse{
|
||||
ID: assignment.ID,
|
||||
ClassroomID: assignment.ClassroomID,
|
||||
TeacherID: assignment.TeacherID,
|
||||
Title: assignment.Title,
|
||||
Instructions: shared.TextPointer(assignment.Instructions),
|
||||
PassThreshold: shared.NumericPointer(assignment.PassThreshold),
|
||||
Status: string(assignment.Status),
|
||||
DueAt: shared.TimePointer(assignment.DueAt),
|
||||
PublishedAt: shared.TimePointer(assignment.PublishedAt),
|
||||
CreatedAt: shared.TimePointer(assignment.CreatedAt),
|
||||
UpdatedAt: shared.TimePointer(assignment.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func parseQuestionTopicValue(value string) (sqlc.QuestionTopic, error) {
|
||||
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||
case string(sqlc.QuestionTopicPlaceValue):
|
||||
return sqlc.QuestionTopicPlaceValue, nil
|
||||
case string(sqlc.QuestionTopicArithmetic):
|
||||
return sqlc.QuestionTopicArithmetic, nil
|
||||
case string(sqlc.QuestionTopicNegativeNumbers):
|
||||
return sqlc.QuestionTopicNegativeNumbers, nil
|
||||
case string(sqlc.QuestionTopicBidmas):
|
||||
return sqlc.QuestionTopicBidmas, nil
|
||||
case string(sqlc.QuestionTopicFractions):
|
||||
return sqlc.QuestionTopicFractions, nil
|
||||
case string(sqlc.QuestionTopicAlgebra):
|
||||
return sqlc.QuestionTopicAlgebra, nil
|
||||
case string(sqlc.QuestionTopicGeometry):
|
||||
return sqlc.QuestionTopicGeometry, nil
|
||||
case string(sqlc.QuestionTopicData):
|
||||
return sqlc.QuestionTopicData, nil
|
||||
default:
|
||||
return "", fmt.Errorf("primary_topic must be one of place_value, arithmetic, negative_numbers, bidmas, fractions, algebra, geometry, or data")
|
||||
}
|
||||
}
|
||||
|
||||
func parseQuestionDifficultyValue(value string) (sqlc.QuestionDifficulty, error) {
|
||||
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||
case string(sqlc.QuestionDifficultyEasy):
|
||||
return sqlc.QuestionDifficultyEasy, nil
|
||||
case string(sqlc.QuestionDifficultyMedium):
|
||||
return sqlc.QuestionDifficultyMedium, nil
|
||||
case string(sqlc.QuestionDifficultyHard):
|
||||
return sqlc.QuestionDifficultyHard, nil
|
||||
default:
|
||||
return "", fmt.Errorf("difficulty must be one of easy, medium, or hard")
|
||||
}
|
||||
}
|
||||
|
||||
func parseQuestionStatusValue(value string) (sqlc.QuestionStatus, error) {
|
||||
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||
case string(sqlc.QuestionStatusDraft):
|
||||
return sqlc.QuestionStatusDraft, nil
|
||||
case string(sqlc.QuestionStatusPublished):
|
||||
return sqlc.QuestionStatusPublished, nil
|
||||
case string(sqlc.QuestionStatusArchived):
|
||||
return sqlc.QuestionStatusArchived, nil
|
||||
default:
|
||||
return "", fmt.Errorf("question_status must be one of draft, published, or archived")
|
||||
}
|
||||
}
|
||||
|
||||
func mapAssignmentGenerationWeaknessSummary(summary assignmentgen.WeaknessSummary) mixedPlanWeaknessSummaryResponse {
|
||||
topicScores := make(map[string]float64, len(summary.TopicScores))
|
||||
for topic, score := range summary.TopicScores {
|
||||
topicScores[string(topic)] = score
|
||||
}
|
||||
|
||||
return mixedPlanWeaknessSummaryResponse{
|
||||
TopicScores: topicScores,
|
||||
WeakTags: append([]string(nil), summary.WeakTags...),
|
||||
RecentIssues: append([]string(nil), summary.RecentIssues...),
|
||||
}
|
||||
}
|
||||
|
||||
func questionTopicPointer(topic sqlc.NullQuestionTopic) *string {
|
||||
if !topic.Valid {
|
||||
return nil
|
||||
}
|
||||
value := string(topic.QuestionTopic)
|
||||
return &value
|
||||
}
|
||||
|
||||
func questionDifficultyPointer(difficulty sqlc.NullQuestionDifficulty) *string {
|
||||
if !difficulty.Valid {
|
||||
return nil
|
||||
}
|
||||
value := string(difficulty.QuestionDifficulty)
|
||||
return &value
|
||||
}
|
||||
|
||||
func personalizedRatioValue(value *float64) float64 {
|
||||
if value == nil || *value == 0 {
|
||||
return 0.30
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func int64Value(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func int64Pointer(value int64) *int64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func stringPointer(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func trimmedPointerValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*value)
|
||||
}
|
||||
|
||||
func mapAssignmentQuestion(question sqlc.ListQuestionsForAssignmentRow) AssignmentQuestionResponse {
|
||||
return AssignmentQuestionResponse{
|
||||
AssignmentID: question.AssignmentID,
|
||||
QuestionID: question.QuestionID,
|
||||
Position: question.Position,
|
||||
AuthorTeacherID: question.AuthorTeacherID,
|
||||
Title: question.Title,
|
||||
Prompt: question.Prompt,
|
||||
Subject: shared.TextPointer(question.Subject),
|
||||
Source: shared.TextPointer(question.Source),
|
||||
QuestionStatus: string(question.Status),
|
||||
QuestionCreatedAt: shared.TimePointer(question.CreatedAt),
|
||||
QuestionUpdatedAt: shared.TimePointer(question.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func mapAssignmentStudentQuestionDetail(row sqlc.ListQuestionDetailsForAssignmentStudentRow, studentID int64) AssignmentStudentQuestionDetailResponse {
|
||||
var answerStatus *string
|
||||
if row.AnswerStatus.Valid {
|
||||
status := string(row.AnswerStatus.AnswerStatus)
|
||||
answerStatus = &status
|
||||
}
|
||||
|
||||
var passStatus *string
|
||||
if row.PassStatus.Valid {
|
||||
status := string(row.PassStatus.AssignmentPassStatus)
|
||||
passStatus = &status
|
||||
}
|
||||
|
||||
var passStatusOverride *string
|
||||
if row.PassStatusOverride.Valid {
|
||||
status := string(row.PassStatusOverride.AssignmentPassStatus)
|
||||
passStatusOverride = &status
|
||||
}
|
||||
|
||||
var nextStepOutcome *string
|
||||
if row.NextStepOutcome.Valid {
|
||||
outcome := string(row.NextStepOutcome.AssignmentNextStepOutcome)
|
||||
nextStepOutcome = &outcome
|
||||
}
|
||||
|
||||
var reviewNeedsAttention *bool
|
||||
if row.AnswerID.Valid {
|
||||
reviewNeedsAttention = shared.BoolPointer(row.ReviewNeedsAttention)
|
||||
}
|
||||
|
||||
return AssignmentStudentQuestionDetailResponse{
|
||||
AssignmentID: row.AssignmentID,
|
||||
StudentID: studentID,
|
||||
QuestionID: row.QuestionID,
|
||||
Position: row.Position,
|
||||
Title: row.Title,
|
||||
Prompt: row.Prompt,
|
||||
Subject: shared.TextPointer(row.Subject),
|
||||
Source: shared.TextPointer(row.Source),
|
||||
QuestionTags: row.QuestionTags,
|
||||
QuestionStatus: string(row.QuestionStatus),
|
||||
CorrectAnswer: shared.TextPointer(row.CorrectAnswer),
|
||||
AssignmentAiFeedback: shared.TextPointer(row.AssignmentAiFeedback),
|
||||
AssignmentTeacherFeedback: shared.TextPointer(row.AssignmentTeacherFeedback),
|
||||
OverallScore: shared.NumericPointer(row.OverallScore),
|
||||
PassThreshold: shared.NumericPointer(row.PassThreshold),
|
||||
NextStepOutcome: nextStepOutcome,
|
||||
PassStatusOverride: passStatusOverride,
|
||||
PassStatus: passStatus,
|
||||
AnswerID: shared.Int64Pointer(row.AnswerID),
|
||||
AnswerText: shared.TextPointer(row.AnswerText),
|
||||
SolveMode: shared.TextPointer(row.SolveMode),
|
||||
WorkingSteps: shared.TextPointer(row.WorkingSteps),
|
||||
IsCorrect: shared.BoolPointer(row.IsCorrect),
|
||||
AiFeedback: shared.TextPointer(row.AiFeedback),
|
||||
TeacherFeedback: shared.TextPointer(row.TeacherFeedback),
|
||||
AnswerStatus: answerStatus,
|
||||
ReviewNeedsAttention: reviewNeedsAttention,
|
||||
ReviewIssueReason: shared.TextPointer(row.ReviewIssueReason),
|
||||
ReviewCorrectnessScore: shared.NumericPointer(row.ReviewCorrectnessScore),
|
||||
ReviewUnderstandingScore: shared.NumericPointer(row.ReviewUnderstandingScore),
|
||||
ReviewQuestionScore: shared.NumericPointer(row.ReviewQuestionScore),
|
||||
ReviewConfidence: shared.NumericPointer(row.ReviewConfidence),
|
||||
ReviewTags: row.ReviewTags,
|
||||
SubmittedAt: shared.TimePointer(row.SubmittedAt),
|
||||
ReviewedAt: shared.TimePointer(row.ReviewedAt),
|
||||
AnswerCreatedAt: shared.TimePointer(row.AnswerCreatedAt),
|
||||
AnswerUpdatedAt: shared.TimePointer(row.AnswerUpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func mapAssignmentReviewSummary(summary sqlc.GetAssignmentReviewSummaryRow) AssignmentReviewSummaryResponse {
|
||||
return AssignmentReviewSummaryResponse{
|
||||
AssignmentID: summary.AssignmentID,
|
||||
TotalQuestions: summary.TotalQuestions,
|
||||
TotalAssigned: summary.TotalAssigned,
|
||||
NotStarted: summary.NotStarted,
|
||||
InProgress: summary.InProgress,
|
||||
Submitted: summary.Submitted,
|
||||
Reviewed: summary.Reviewed,
|
||||
}
|
||||
}
|
||||
|
||||
func mapAssignmentReviewQueueItem(row sqlc.ListAssignmentReviewQueueRow) AssignmentReviewQueueItemResponse {
|
||||
var nextStepOutcome *string
|
||||
if row.NextStepOutcome.Valid {
|
||||
outcome := string(row.NextStepOutcome.AssignmentNextStepOutcome)
|
||||
nextStepOutcome = &outcome
|
||||
}
|
||||
|
||||
return AssignmentReviewQueueItemResponse{
|
||||
AssignmentID: row.AssignmentID,
|
||||
StudentID: row.StudentID,
|
||||
NextStepOutcome: nextStepOutcome,
|
||||
StudentName: row.StudentName,
|
||||
StudentEmail: row.StudentEmail,
|
||||
TotalQuestions: row.TotalQuestions,
|
||||
AnsweredQuestions: row.AnsweredQuestions,
|
||||
ReviewedQuestions: row.ReviewedQuestions,
|
||||
SubmittedQuestions: row.SubmittedQuestions,
|
||||
InProgressQuestions: row.InProgressQuestions,
|
||||
ReviewStatus: string(row.ReviewStatus),
|
||||
LatestSubmittedAt: shared.TimePointer(row.LatestSubmittedAt),
|
||||
LatestReviewedAt: shared.TimePointer(row.LatestReviewedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func buildAssignmentCloseReadiness(queue []sqlc.ListAssignmentReviewQueueRow) assignmentCloseReadiness {
|
||||
blockers := make([]string, 0)
|
||||
if len(queue) == 0 {
|
||||
return assignmentCloseReadiness{
|
||||
CanClose: false,
|
||||
Blockers: []string{"No students have been assigned yet."},
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range queue {
|
||||
name := strings.TrimSpace(item.StudentName)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("Student %d", item.StudentID)
|
||||
}
|
||||
|
||||
switch {
|
||||
case item.SubmittedQuestions > 0 || item.ReviewStatus == sqlc.AnswerStatusSubmitted:
|
||||
blockers = append(blockers, fmt.Sprintf("%s still has submitted work waiting for review.", name))
|
||||
case item.InProgressQuestions > 0 || item.ReviewStatus == sqlc.AnswerStatusInProgress:
|
||||
blockers = append(blockers, fmt.Sprintf("%s still has work in progress.", name))
|
||||
case item.AnsweredQuestions == 0 || item.ReviewStatus == sqlc.AnswerStatusNotStarted:
|
||||
blockers = append(blockers, fmt.Sprintf("%s has not started this assignment yet.", name))
|
||||
case !item.NextStepOutcome.Valid:
|
||||
blockers = append(blockers, fmt.Sprintf("%s still needs a next-step decision.", name))
|
||||
}
|
||||
}
|
||||
|
||||
return assignmentCloseReadiness{
|
||||
CanClose: len(blockers) == 0,
|
||||
Blockers: blockers,
|
||||
}
|
||||
}
|
||||
|
||||
func parseStoredRedoPlan(value string) (storedRedoPlan, error) {
|
||||
var payload storedRedoPlan
|
||||
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
||||
return storedRedoPlan{}, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func mapWeaknessSummary(studentID int64, summary weaknessSummary) StudentWeaknessSummaryResponse {
|
||||
return StudentWeaknessSummaryResponse{
|
||||
StudentID: studentID,
|
||||
TopicScores: summary.TopicScores,
|
||||
WeakTags: summary.WeakTags,
|
||||
RecentIssues: summary.RecentIssues,
|
||||
}
|
||||
}
|
||||
|
||||
func planningScore(isCorrect pgtype.Bool, understanding pgtype.Numeric) float64 {
|
||||
understandingValue := 0.0
|
||||
if value := shared.NumericPointer(understanding); value != nil {
|
||||
understandingValue = *value
|
||||
}
|
||||
correctnessValue := 0.0
|
||||
if isCorrect.Valid && isCorrect.Bool {
|
||||
correctnessValue = 1.0
|
||||
}
|
||||
return (correctnessValue + understandingValue) / 2
|
||||
}
|
||||
|
||||
func roundToOneDecimal(value float64) float64 {
|
||||
return math.Round(value*10) / 10
|
||||
}
|
||||
|
||||
func allowedQuestionTopics() []string {
|
||||
return []string{
|
||||
string(sqlc.QuestionTopicPlaceValue),
|
||||
string(sqlc.QuestionTopicArithmetic),
|
||||
string(sqlc.QuestionTopicNegativeNumbers),
|
||||
string(sqlc.QuestionTopicBidmas),
|
||||
string(sqlc.QuestionTopicFractions),
|
||||
string(sqlc.QuestionTopicAlgebra),
|
||||
string(sqlc.QuestionTopicGeometry),
|
||||
string(sqlc.QuestionTopicData),
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringPointer(value string) *string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func isValidAssignmentPassStatus(value string) bool {
|
||||
switch value {
|
||||
case string(sqlc.AssignmentPassStatusPending), string(sqlc.AssignmentPassStatusPass), string(sqlc.AssignmentPassStatusNoPass):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isValidAssignmentNextStepOutcome(value string) bool {
|
||||
switch value {
|
||||
case "redo", "accept", "support":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func pointerToFloat64(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
236
Backend/internal/handlers/api/assignments/handler_types.go
Normal file
236
Backend/internal/handlers/api/assignments/handler_types.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package assignments
|
||||
|
||||
import (
|
||||
"boostai-backend/internal/aireview"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AssignmentResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ClassroomID int64 `json:"classroom_id"`
|
||||
TeacherID int64 `json:"teacher_id"`
|
||||
Title string `json:"title"`
|
||||
Instructions *string `json:"instructions,omitempty"`
|
||||
PassThreshold *float64 `json:"pass_threshold,omitempty"`
|
||||
Status string `json:"status"`
|
||||
DueAt *time.Time `json:"due_at,omitempty"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type AssignmentQuestionResponse struct {
|
||||
AssignmentID int64 `json:"assignment_id"`
|
||||
QuestionID int64 `json:"question_id"`
|
||||
Position int32 `json:"position"`
|
||||
AuthorTeacherID int64 `json:"author_teacher_id"`
|
||||
Title string `json:"title"`
|
||||
Prompt string `json:"prompt"`
|
||||
Subject *string `json:"subject,omitempty"`
|
||||
Source *string `json:"source,omitempty"`
|
||||
QuestionStatus string `json:"question_status"`
|
||||
QuestionCreatedAt *time.Time `json:"question_created_at,omitempty"`
|
||||
QuestionUpdatedAt *time.Time `json:"question_updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type AssignmentStudentQuestionDetailResponse struct {
|
||||
AssignmentID int64 `json:"assignment_id"`
|
||||
StudentID int64 `json:"student_id"`
|
||||
QuestionID int64 `json:"question_id"`
|
||||
Position int32 `json:"position"`
|
||||
Title string `json:"title"`
|
||||
Prompt string `json:"prompt"`
|
||||
Subject *string `json:"subject,omitempty"`
|
||||
Source *string `json:"source,omitempty"`
|
||||
QuestionTags []string `json:"question_tags,omitempty"`
|
||||
QuestionStatus string `json:"question_status"`
|
||||
CorrectAnswer *string `json:"correct_answer,omitempty"`
|
||||
AssignmentAiFeedback *string `json:"assignment_ai_feedback,omitempty"`
|
||||
AssignmentTeacherFeedback *string `json:"assignment_teacher_feedback,omitempty"`
|
||||
OverallScore *float64 `json:"overall_score,omitempty"`
|
||||
PassThreshold *float64 `json:"pass_threshold,omitempty"`
|
||||
NextStepOutcome *string `json:"next_step_outcome,omitempty"`
|
||||
PassStatusOverride *string `json:"pass_status_override,omitempty"`
|
||||
PassStatus *string `json:"pass_status,omitempty"`
|
||||
AnswerID *int64 `json:"answer_id,omitempty"`
|
||||
AnswerText *string `json:"answer_text,omitempty"`
|
||||
SolveMode *string `json:"solve_mode,omitempty"`
|
||||
WorkingSteps *string `json:"working_steps,omitempty"`
|
||||
IsCorrect *bool `json:"is_correct,omitempty"`
|
||||
AiFeedback *string `json:"ai_feedback,omitempty"`
|
||||
TeacherFeedback *string `json:"teacher_feedback,omitempty"`
|
||||
AnswerStatus *string `json:"answer_status,omitempty"`
|
||||
ReviewNeedsAttention *bool `json:"review_needs_attention,omitempty"`
|
||||
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,omitempty"`
|
||||
SubmittedAt *time.Time `json:"submitted_at,omitempty"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||
AnswerCreatedAt *time.Time `json:"answer_created_at,omitempty"`
|
||||
AnswerUpdatedAt *time.Time `json:"answer_updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type updateAssignmentTeacherFeedbackRequest struct {
|
||||
TeacherFeedback *string `json:"teacher_feedback"`
|
||||
PassStatusOverride *string `json:"pass_status_override"`
|
||||
NextStepOutcome *string `json:"next_step_outcome"`
|
||||
}
|
||||
|
||||
type updateAssignmentDraftRequest struct {
|
||||
ClassroomID int64 `json:"classroom_id"`
|
||||
Title string `json:"title"`
|
||||
Instructions *string `json:"instructions"`
|
||||
PassThreshold *float64 `json:"pass_threshold"`
|
||||
DueAt *time.Time `json:"due_at"`
|
||||
}
|
||||
|
||||
type AssignmentReviewSummaryResponse struct {
|
||||
AssignmentID int64 `json:"assignment_id"`
|
||||
TotalQuestions int64 `json:"total_questions"`
|
||||
TotalAssigned int64 `json:"total_assigned"`
|
||||
NotStarted int64 `json:"not_started"`
|
||||
InProgress int64 `json:"in_progress"`
|
||||
Submitted int64 `json:"submitted"`
|
||||
Reviewed int64 `json:"reviewed"`
|
||||
}
|
||||
|
||||
type AssignmentReviewQueueItemResponse struct {
|
||||
AssignmentID int64 `json:"assignment_id"`
|
||||
StudentID int64 `json:"student_id"`
|
||||
NextStepOutcome *string `json:"next_step_outcome,omitempty"`
|
||||
StudentName string `json:"student_name"`
|
||||
StudentEmail string `json:"student_email"`
|
||||
TotalQuestions int64 `json:"total_questions"`
|
||||
AnsweredQuestions int64 `json:"answered_questions"`
|
||||
ReviewedQuestions int64 `json:"reviewed_questions"`
|
||||
SubmittedQuestions int64 `json:"submitted_questions"`
|
||||
InProgressQuestions int64 `json:"in_progress_questions"`
|
||||
ReviewStatus string `json:"review_status"`
|
||||
LatestSubmittedAt *time.Time `json:"latest_submitted_at,omitempty"`
|
||||
LatestReviewedAt *time.Time `json:"latest_reviewed_at,omitempty"`
|
||||
}
|
||||
|
||||
type assignmentCloseReadiness struct {
|
||||
CanClose bool
|
||||
Blockers []string
|
||||
}
|
||||
|
||||
type StudentWeaknessSummaryResponse struct {
|
||||
StudentID int64 `json:"student_id"`
|
||||
TopicScores map[string]float64 `json:"topic_scores"`
|
||||
WeakTags []string `json:"weak_tags"`
|
||||
RecentIssues []string `json:"recent_issues"`
|
||||
}
|
||||
|
||||
type AssignmentRedoPlanResponse struct {
|
||||
AssignmentID int64 `json:"assignment_id"`
|
||||
StudentID int64 `json:"student_id"`
|
||||
RedoPlanGeneratedAt *time.Time `json:"redo_plan_generated_at,omitempty"`
|
||||
TeacherFeedback *string `json:"teacher_feedback,omitempty"`
|
||||
WeaknessSummary StudentWeaknessSummaryResponse `json:"weakness_summary"`
|
||||
Plan *aireview.RedoPlanResult `json:"plan,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type createAssignmentRequest struct {
|
||||
ClassroomID int64 `json:"classroom_id"`
|
||||
TeacherID int64 `json:"teacher_id"`
|
||||
Title string `json:"title"`
|
||||
Instructions *string `json:"instructions"`
|
||||
PassThreshold *float64 `json:"pass_threshold"`
|
||||
Status string `json:"status"`
|
||||
DueAt *time.Time `json:"due_at"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
}
|
||||
|
||||
type assignStudentToAssignmentRequest struct {
|
||||
StudentID int64 `json:"student_id"`
|
||||
MixedGeneration *generateMixedStudentQuestionsRequest `json:"mixed_generation"`
|
||||
}
|
||||
|
||||
type addQuestionToAssignmentRequest struct {
|
||||
QuestionID int64 `json:"question_id"`
|
||||
Position int32 `json:"position"`
|
||||
}
|
||||
|
||||
type generateMixedStudentQuestionsRequest struct {
|
||||
PrimaryTopic string `json:"primary_topic"`
|
||||
PrimaryDifficulty string `json:"primary_difficulty"`
|
||||
TotalQuestions int `json:"total_questions"`
|
||||
PersonalizedRatio *float64 `json:"personalized_ratio"`
|
||||
Seed *int64 `json:"seed"`
|
||||
PersonalizedDifficulty *string `json:"personalized_difficulty"`
|
||||
Subject *string `json:"subject"`
|
||||
QuestionStatus *string `json:"question_status"`
|
||||
QuestionSource *string `json:"question_source"`
|
||||
}
|
||||
|
||||
type mixedPlanWeaknessSummaryResponse struct {
|
||||
TopicScores map[string]float64 `json:"topic_scores"`
|
||||
WeakTags []string `json:"weak_tags"`
|
||||
RecentIssues []string `json:"recent_issues"`
|
||||
}
|
||||
|
||||
type mixedPlanQuestionResponse struct {
|
||||
MappingID int64 `json:"mapping_id"`
|
||||
QuestionID int64 `json:"question_id"`
|
||||
Position int32 `json:"position"`
|
||||
SourceBucket string `json:"source_bucket"`
|
||||
SourceTopic *string `json:"source_topic,omitempty"`
|
||||
SourceDifficulty *string `json:"source_difficulty,omitempty"`
|
||||
GeneratorSeed *int64 `json:"generator_seed,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Prompt string `json:"prompt"`
|
||||
Subject *string `json:"subject,omitempty"`
|
||||
QuestionStatus string `json:"question_status"`
|
||||
QuestionSource *string `json:"question_source,omitempty"`
|
||||
CorrectAnswer *string `json:"correct_answer,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
QuestionCreatedAt *time.Time `json:"question_created_at,omitempty"`
|
||||
QuestionUpdatedAt *time.Time `json:"question_updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type generateMixedStudentQuestionsResponse struct {
|
||||
AssignmentID int64 `json:"assignment_id"`
|
||||
StudentID int64 `json:"student_id"`
|
||||
PrimaryTopic string `json:"primary_topic"`
|
||||
PrimaryDifficulty string `json:"primary_difficulty"`
|
||||
TotalQuestions int `json:"total_questions"`
|
||||
CoreCount int `json:"core_count"`
|
||||
PersonalizedCount int `json:"personalized_count"`
|
||||
PersonalizedApplied bool `json:"personalized_applied"`
|
||||
PersonalizedTopic *string `json:"personalized_topic,omitempty"`
|
||||
PersonalizedRatio float64 `json:"personalized_ratio"`
|
||||
BaseSeed int64 `json:"base_seed"`
|
||||
WeaknessSummary mixedPlanWeaknessSummaryResponse `json:"weakness_summary"`
|
||||
Questions []mixedPlanQuestionResponse `json:"questions"`
|
||||
}
|
||||
|
||||
type assignmentAPIError struct {
|
||||
status int
|
||||
code string
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *assignmentAPIError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.message
|
||||
}
|
||||
|
||||
type weaknessSummary struct {
|
||||
TopicScores map[string]float64 `json:"topicScores"`
|
||||
WeakTags []string `json:"weakTags"`
|
||||
RecentIssues []string `json:"recentIssues"`
|
||||
}
|
||||
|
||||
type storedRedoPlan struct {
|
||||
TeacherFeedback string `json:"teacherFeedback,omitempty"`
|
||||
WeaknessSummary weaknessSummary `json:"weaknessSummary"`
|
||||
Plan *aireview.RedoPlanResult `json:"plan,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
25
Backend/internal/handlers/api/assignments/routes.go
Normal file
25
Backend/internal/handlers/api/assignments/routes.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package assignments
|
||||
|
||||
import (
|
||||
authmw "boostai-backend/internal/middleware"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) {
|
||||
app.Get("/teachers/:teacherId/assignments", auth.RequireTeacherSelf("teacherId"), h.ListAssignmentsByTeacher)
|
||||
app.Get("/students/:studentId/assignments", auth.RequireStudentSelfOrTeacher("studentId"), h.ListAssignmentsForStudent)
|
||||
app.Get("/assignments/:assignmentId", h.GetAssignmentByID)
|
||||
app.Get("/assignments/:assignmentId/questions", h.ListQuestionsForAssignment)
|
||||
app.Get("/assignments/:assignmentId/students/:studentId/questions", auth.RequireStudentSelfOrTeacher("studentId"), h.ListQuestionDetailsForAssignmentStudent)
|
||||
app.Get("/assignments/:assignmentId/students/:studentId/redo-plan", auth.RequireTeacher(), h.GetAssignmentRedoPlan)
|
||||
app.Post("/assignments/:assignmentId/students/:studentId/generate-mixed-questions", auth.RequireTeacher(), h.GenerateMixedStudentQuestions)
|
||||
app.Patch("/assignments/:assignmentId", auth.RequireTeacher(), h.UpdateAssignmentDraft)
|
||||
app.Post("/assignments/:assignmentId/close", auth.RequireTeacher(), h.CloseAssignment)
|
||||
app.Patch("/assignments/:assignmentId/students/:studentId/feedback", auth.RequireTeacher(), h.UpdateAssignmentTeacherFeedback)
|
||||
app.Get("/assignments/:assignmentId/review-summary", auth.RequireTeacher(), h.GetAssignmentReviewSummary)
|
||||
app.Get("/assignments/:assignmentId/review", auth.RequireTeacher(), h.ListAssignmentReviewQueue)
|
||||
app.Post("/assignments", auth.RequireTeacher(), h.CreateAssignment)
|
||||
app.Post("/assignments/:assignmentId/students", auth.RequireTeacher(), h.AssignStudentToAssignment)
|
||||
app.Post("/assignments/:assignmentId/questions", auth.RequireTeacher(), h.AddQuestionToAssignment)
|
||||
}
|
||||
Reference in New Issue
Block a user