Files
BoostAI/Backend/internal/handlers/api/assignments/handler_generation.go
2026-05-25 17:05:06 +01:00

322 lines
11 KiB
Go

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
}