Boost Azure Demo
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user