322 lines
11 KiB
Go
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
|
|
}
|