Boost Azure Demo

This commit is contained in:
MangoPig
2026-05-25 17:05:06 +01:00
parent 675285e99d
commit 4f79137d89
230 changed files with 43275 additions and 2644 deletions

View 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)
}

View 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
}

View 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
}

View 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"`
}

View 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)
}