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 }