Boost Azure Demo
This commit is contained in:
106
Backend/internal/assignmentgen/personalization.go
Normal file
106
Backend/internal/assignmentgen/personalization.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package assignmentgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"boostai-backend/internal/sqlc"
|
||||
)
|
||||
|
||||
const defaultPersonalizedRatio = 0.30
|
||||
|
||||
type WeaknessSummary struct {
|
||||
TopicScores map[sqlc.QuestionTopic]float64
|
||||
WeakTags []string
|
||||
RecentIssues []string
|
||||
}
|
||||
|
||||
type MixedPlanParams struct {
|
||||
StudentID int64
|
||||
PrimaryTopic sqlc.QuestionTopic
|
||||
PrimaryDifficulty sqlc.QuestionDifficulty
|
||||
TotalQuestions int
|
||||
PersonalizedRatio float64
|
||||
BaseSeed int64
|
||||
PersonalizedDifficulty sqlc.QuestionDifficulty
|
||||
}
|
||||
|
||||
type MixedPlan struct {
|
||||
Plan []PlanItem
|
||||
WeaknessSummary WeaknessSummary
|
||||
CoreCount int
|
||||
PersonalizedCount int
|
||||
PersonalizedTopic sqlc.QuestionTopic
|
||||
PersonalizedApplied bool
|
||||
BaseSeed int64
|
||||
}
|
||||
|
||||
type GenerateMixedStudentQuestionSetParams struct {
|
||||
AssignmentID int64
|
||||
StudentID int64
|
||||
TeacherID int64
|
||||
Subject string
|
||||
QuestionStatus sqlc.QuestionStatus
|
||||
QuestionSource string
|
||||
PrimaryTopic sqlc.QuestionTopic
|
||||
PrimaryDifficulty sqlc.QuestionDifficulty
|
||||
TotalQuestions int
|
||||
PersonalizedRatio float64
|
||||
Seed int64
|
||||
PersonalizedDifficulty sqlc.QuestionDifficulty
|
||||
}
|
||||
|
||||
type GenerateMixedStudentQuestionSetResult struct {
|
||||
StoredQuestions []StoredStudentQuestion
|
||||
MixedPlan MixedPlan
|
||||
}
|
||||
|
||||
func (s *Service) BuildWeaknessSummary(ctx context.Context, studentID int64) (WeaknessSummary, error) {
|
||||
if s == nil || s.db == nil || s.db.Pool == nil {
|
||||
return WeaknessSummary{}, fmt.Errorf("assignment question generator database is not configured")
|
||||
}
|
||||
if studentID <= 0 {
|
||||
return WeaknessSummary{}, fmt.Errorf("student_id is required")
|
||||
}
|
||||
|
||||
queries := sqlc.New(s.db.Pool)
|
||||
rows, err := queries.ListStudentPlanningPerformance(ctx, studentID)
|
||||
if err != nil {
|
||||
return WeaknessSummary{}, err
|
||||
}
|
||||
|
||||
return buildWeaknessSummary(rows), nil
|
||||
}
|
||||
|
||||
func (s *Service) GenerateAndStoreMixedStudentQuestions(ctx context.Context, params GenerateMixedStudentQuestionSetParams) (GenerateMixedStudentQuestionSetResult, error) {
|
||||
mixedPlan, err := s.BuildMixedPlan(ctx, MixedPlanParams{
|
||||
StudentID: params.StudentID,
|
||||
PrimaryTopic: params.PrimaryTopic,
|
||||
PrimaryDifficulty: params.PrimaryDifficulty,
|
||||
TotalQuestions: params.TotalQuestions,
|
||||
PersonalizedRatio: params.PersonalizedRatio,
|
||||
BaseSeed: params.Seed,
|
||||
PersonalizedDifficulty: params.PersonalizedDifficulty,
|
||||
})
|
||||
if err != nil {
|
||||
return GenerateMixedStudentQuestionSetResult{}, err
|
||||
}
|
||||
|
||||
storedQuestions, err := s.GenerateAndStoreStudentQuestions(ctx, GenerateStudentQuestionSetParams{
|
||||
AssignmentID: params.AssignmentID,
|
||||
StudentID: params.StudentID,
|
||||
TeacherID: params.TeacherID,
|
||||
Subject: params.Subject,
|
||||
QuestionStatus: params.QuestionStatus,
|
||||
QuestionSource: params.QuestionSource,
|
||||
Plan: mixedPlan.Plan,
|
||||
})
|
||||
if err != nil {
|
||||
return GenerateMixedStudentQuestionSetResult{}, err
|
||||
}
|
||||
|
||||
return GenerateMixedStudentQuestionSetResult{
|
||||
StoredQuestions: storedQuestions,
|
||||
MixedPlan: mixedPlan,
|
||||
}, nil
|
||||
}
|
||||
172
Backend/internal/assignmentgen/personalization_plan.go
Normal file
172
Backend/internal/assignmentgen/personalization_plan.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package assignmentgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"boostai-backend/internal/sqlc"
|
||||
)
|
||||
|
||||
func (s *Service) BuildMixedPlan(ctx context.Context, params MixedPlanParams) (MixedPlan, error) {
|
||||
if err := validateMixedPlanParams(params); err != nil {
|
||||
return MixedPlan{}, err
|
||||
}
|
||||
|
||||
ratio := normalizePersonalizedRatio(params.PersonalizedRatio)
|
||||
|
||||
weaknessSummary, err := s.BuildWeaknessSummary(ctx, params.StudentID)
|
||||
if err != nil {
|
||||
return MixedPlan{}, err
|
||||
}
|
||||
|
||||
personalizedTopic, personalizedApplied := selectPersonalizedTopic(params.PrimaryTopic, weaknessSummary)
|
||||
personalizedCount := calculatePersonalizedCount(params.TotalQuestions, ratio, personalizedApplied)
|
||||
coreCount := calculateCoreCount(params.TotalQuestions, personalizedCount)
|
||||
baseSeed := normalizeBaseSeed(params.BaseSeed)
|
||||
personalizedDifficulty := normalizePersonalizedDifficulty(params)
|
||||
|
||||
return MixedPlan{
|
||||
Plan: buildMixedPlanItems(params, coreCount, personalizedCount, personalizedTopic, personalizedDifficulty, baseSeed),
|
||||
WeaknessSummary: weaknessSummary,
|
||||
CoreCount: coreCount,
|
||||
PersonalizedCount: personalizedCount,
|
||||
PersonalizedTopic: personalizedTopic,
|
||||
PersonalizedApplied: personalizedCount > 0,
|
||||
BaseSeed: baseSeed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateMixedPlanParams(params MixedPlanParams) error {
|
||||
if params.StudentID <= 0 {
|
||||
return fmt.Errorf("student_id is required")
|
||||
}
|
||||
if params.PrimaryTopic == "" {
|
||||
return fmt.Errorf("primary topic is required")
|
||||
}
|
||||
if params.PrimaryDifficulty == "" {
|
||||
return fmt.Errorf("primary difficulty is required")
|
||||
}
|
||||
if params.TotalQuestions <= 0 {
|
||||
return fmt.Errorf("total_questions must be positive")
|
||||
}
|
||||
if params.PersonalizedRatio < 0 || params.PersonalizedRatio >= 1 {
|
||||
return fmt.Errorf("personalized_ratio must be between 0 and less than 1")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizePersonalizedRatio(ratio float64) float64 {
|
||||
if ratio == 0 {
|
||||
return defaultPersonalizedRatio
|
||||
}
|
||||
return ratio
|
||||
}
|
||||
|
||||
func normalizeBaseSeed(seed int64) int64 {
|
||||
if seed == 0 {
|
||||
return time.Now().UnixNano()
|
||||
}
|
||||
return seed
|
||||
}
|
||||
|
||||
func normalizePersonalizedDifficulty(params MixedPlanParams) sqlc.QuestionDifficulty {
|
||||
if params.PersonalizedDifficulty == "" {
|
||||
return params.PrimaryDifficulty
|
||||
}
|
||||
return params.PersonalizedDifficulty
|
||||
}
|
||||
|
||||
func calculateCoreCount(totalQuestions, personalizedCount int) int {
|
||||
coreCount := totalQuestions - personalizedCount
|
||||
if totalQuestions > 0 && coreCount <= 0 {
|
||||
return 1
|
||||
}
|
||||
return coreCount
|
||||
}
|
||||
|
||||
func buildMixedPlanItems(
|
||||
params MixedPlanParams,
|
||||
coreCount int,
|
||||
personalizedCount int,
|
||||
personalizedTopic sqlc.QuestionTopic,
|
||||
personalizedDifficulty sqlc.QuestionDifficulty,
|
||||
baseSeed int64,
|
||||
) []PlanItem {
|
||||
plan := make([]PlanItem, 0, 2)
|
||||
|
||||
if coreCount > 0 {
|
||||
plan = append(plan, PlanItem{
|
||||
Topic: params.PrimaryTopic,
|
||||
Difficulty: params.PrimaryDifficulty,
|
||||
Count: coreCount,
|
||||
SourceBucket: SourceBucketCoreTopic,
|
||||
Seed: baseSeed,
|
||||
})
|
||||
}
|
||||
|
||||
if personalizedCount > 0 {
|
||||
plan = append(plan, PlanItem{
|
||||
Topic: personalizedTopic,
|
||||
Difficulty: personalizedDifficulty,
|
||||
Count: personalizedCount,
|
||||
SourceBucket: SourceBucketPersonalized,
|
||||
Seed: baseSeed + 7919,
|
||||
})
|
||||
}
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
func selectPersonalizedTopic(primaryTopic sqlc.QuestionTopic, summary WeaknessSummary) (sqlc.QuestionTopic, bool) {
|
||||
if len(summary.TopicScores) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
type scoredTopic struct {
|
||||
topic sqlc.QuestionTopic
|
||||
score float64
|
||||
}
|
||||
|
||||
topics := make([]scoredTopic, 0, len(summary.TopicScores))
|
||||
for topic, score := range summary.TopicScores {
|
||||
topics = append(topics, scoredTopic{topic: topic, score: score})
|
||||
}
|
||||
|
||||
sort.SliceStable(topics, func(i, j int) bool {
|
||||
if topics[i].score == topics[j].score {
|
||||
return string(topics[i].topic) < string(topics[j].topic)
|
||||
}
|
||||
return topics[i].score < topics[j].score
|
||||
})
|
||||
|
||||
for _, candidate := range topics {
|
||||
if candidate.topic != primaryTopic {
|
||||
return candidate.topic, true
|
||||
}
|
||||
}
|
||||
|
||||
return topics[0].topic, true
|
||||
}
|
||||
|
||||
func calculatePersonalizedCount(totalQuestions int, ratio float64, personalizedApplied bool) int {
|
||||
if !personalizedApplied || totalQuestions <= 1 || ratio <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
count := int(math.Floor(float64(totalQuestions) * ratio))
|
||||
if count == 0 && totalQuestions >= 3 {
|
||||
count = 1
|
||||
}
|
||||
if count >= totalQuestions {
|
||||
count = totalQuestions - 1
|
||||
}
|
||||
if count < 0 {
|
||||
count = 0
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
85
Backend/internal/assignmentgen/personalization_test.go
Normal file
85
Backend/internal/assignmentgen/personalization_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package assignmentgen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"boostai-backend/internal/sqlc"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func TestBuildWeaknessSummaryAggregatesSignals(t *testing.T) {
|
||||
rows := []sqlc.ListStudentPlanningPerformanceRow{
|
||||
{
|
||||
Topic: sqlc.NullQuestionTopic{QuestionTopic: sqlc.QuestionTopicFractions, Valid: true},
|
||||
QuestionTags: []string{"fractions", "simplify"},
|
||||
IsCorrect: pgtype.Bool{Bool: false, Valid: true},
|
||||
ReviewUnderstandingScore: mustNumeric(t, 0.2),
|
||||
ReviewNeedsAttention: true,
|
||||
ReviewIssueReason: pgtype.Text{String: "Missed simplification", Valid: true},
|
||||
},
|
||||
{
|
||||
Topic: sqlc.NullQuestionTopic{QuestionTopic: sqlc.QuestionTopicGeometry, Valid: true},
|
||||
QuestionTags: []string{"geometry", "angles"},
|
||||
IsCorrect: pgtype.Bool{Bool: true, Valid: true},
|
||||
ReviewUnderstandingScore: mustNumeric(t, 0.9),
|
||||
ReviewIssueReason: pgtype.Text{String: "Explained angle sum well", Valid: true},
|
||||
},
|
||||
}
|
||||
|
||||
summary := buildWeaknessSummary(rows)
|
||||
|
||||
if got := summary.TopicScores[sqlc.QuestionTopicFractions]; got != 10 {
|
||||
t.Fatalf("expected fractions score 10, got %v", got)
|
||||
}
|
||||
if got := summary.TopicScores[sqlc.QuestionTopicGeometry]; got != 95 {
|
||||
t.Fatalf("expected geometry score 95, got %v", got)
|
||||
}
|
||||
if len(summary.WeakTags) == 0 || summary.WeakTags[0] != "fractions" {
|
||||
t.Fatalf("expected fractions weak tag to rank first, got %#v", summary.WeakTags)
|
||||
}
|
||||
if len(summary.RecentIssues) != 2 {
|
||||
t.Fatalf("expected 2 recent issues, got %d", len(summary.RecentIssues))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectPersonalizedTopicPrefersWeakestNonPrimary(t *testing.T) {
|
||||
summary := WeaknessSummary{
|
||||
TopicScores: map[sqlc.QuestionTopic]float64{
|
||||
sqlc.QuestionTopicFractions: 35,
|
||||
sqlc.QuestionTopicGeometry: 82,
|
||||
sqlc.QuestionTopicAlgebra: 61,
|
||||
},
|
||||
}
|
||||
|
||||
topic, ok := selectPersonalizedTopic(sqlc.QuestionTopicGeometry, summary)
|
||||
if !ok {
|
||||
t.Fatal("expected personalized topic to be selected")
|
||||
}
|
||||
if topic != sqlc.QuestionTopicFractions {
|
||||
t.Fatalf("expected fractions, got %q", topic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculatePersonalizedCountUsesSafeSplit(t *testing.T) {
|
||||
if got := calculatePersonalizedCount(10, 0.3, true); got != 3 {
|
||||
t.Fatalf("expected 3 personalized questions, got %d", got)
|
||||
}
|
||||
if got := calculatePersonalizedCount(2, 0.3, true); got != 0 {
|
||||
t.Fatalf("expected 0 personalized questions for tiny set, got %d", got)
|
||||
}
|
||||
if got := calculatePersonalizedCount(5, 0.3, false); got != 0 {
|
||||
t.Fatalf("expected 0 personalized questions without weakness topic, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func mustNumeric(t *testing.T, value float64) pgtype.Numeric {
|
||||
t.Helper()
|
||||
|
||||
var numeric pgtype.Numeric
|
||||
if err := numeric.ScanScientific(fmt.Sprintf("%f", value)); err != nil {
|
||||
t.Fatalf("failed to build numeric: %v", err)
|
||||
}
|
||||
return numeric
|
||||
}
|
||||
171
Backend/internal/assignmentgen/personalization_weakness.go
Normal file
171
Backend/internal/assignmentgen/personalization_weakness.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package assignmentgen
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"boostai-backend/internal/sqlc"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func buildWeaknessSummary(rows []sqlc.ListStudentPlanningPerformanceRow) WeaknessSummary {
|
||||
topicTotals := make(map[sqlc.QuestionTopic]float64)
|
||||
topicCounts := make(map[sqlc.QuestionTopic]int)
|
||||
tagStats := make(map[string]*tagWeaknessStats)
|
||||
recentIssues := make([]string, 0, 5)
|
||||
seenIssues := make(map[string]struct{})
|
||||
|
||||
for _, row := range rows {
|
||||
score := planningScore(row.IsCorrect.Bool, numericFloat64OrZero(row.ReviewUnderstandingScore))
|
||||
accumulateTopicScore(topicTotals, topicCounts, row, score)
|
||||
accumulateTagStats(tagStats, row, score)
|
||||
recentIssues = appendRecentIssue(recentIssues, seenIssues, row.ReviewIssueReason.String)
|
||||
if len(recentIssues) >= 5 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return WeaknessSummary{
|
||||
TopicScores: buildTopicScores(topicTotals, topicCounts),
|
||||
WeakTags: collectWeakTags(tagStats),
|
||||
RecentIssues: recentIssues,
|
||||
}
|
||||
}
|
||||
|
||||
type tagWeaknessStats struct {
|
||||
total float64
|
||||
count int
|
||||
flaggedCount int
|
||||
}
|
||||
|
||||
type scoredTag struct {
|
||||
tag string
|
||||
score float64
|
||||
flaggedCount int
|
||||
}
|
||||
|
||||
func accumulateTopicScore(
|
||||
topicTotals map[sqlc.QuestionTopic]float64,
|
||||
topicCounts map[sqlc.QuestionTopic]int,
|
||||
row sqlc.ListStudentPlanningPerformanceRow,
|
||||
score float64,
|
||||
) {
|
||||
if !row.Topic.Valid {
|
||||
return
|
||||
}
|
||||
|
||||
topicTotals[row.Topic.QuestionTopic] += score
|
||||
topicCounts[row.Topic.QuestionTopic]++
|
||||
}
|
||||
|
||||
func accumulateTagStats(tagStats map[string]*tagWeaknessStats, row sqlc.ListStudentPlanningPerformanceRow, score float64) {
|
||||
for _, rawTag := range row.QuestionTags {
|
||||
tag := strings.TrimSpace(strings.ToLower(rawTag))
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
stats, ok := tagStats[tag]
|
||||
if !ok {
|
||||
stats = &tagWeaknessStats{}
|
||||
tagStats[tag] = stats
|
||||
}
|
||||
|
||||
stats.total += score
|
||||
stats.count++
|
||||
if row.ReviewNeedsAttention {
|
||||
stats.flaggedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendRecentIssue(recentIssues []string, seenIssues map[string]struct{}, rawIssue string) []string {
|
||||
issue := strings.TrimSpace(rawIssue)
|
||||
if issue == "" {
|
||||
return recentIssues
|
||||
}
|
||||
if _, exists := seenIssues[issue]; exists {
|
||||
return recentIssues
|
||||
}
|
||||
|
||||
seenIssues[issue] = struct{}{}
|
||||
return append(recentIssues, issue)
|
||||
}
|
||||
|
||||
func buildTopicScores(topicTotals map[sqlc.QuestionTopic]float64, topicCounts map[sqlc.QuestionTopic]int) map[sqlc.QuestionTopic]float64 {
|
||||
topicScores := make(map[sqlc.QuestionTopic]float64, len(topicTotals))
|
||||
for topic, total := range topicTotals {
|
||||
count := topicCounts[topic]
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
topicScores[topic] = roundToOneDecimal((total / float64(count)) * 100)
|
||||
}
|
||||
return topicScores
|
||||
}
|
||||
|
||||
func collectWeakTags(tagStats map[string]*tagWeaknessStats) []string {
|
||||
if len(tagStats) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
weakCandidates := make([]scoredTag, 0, len(tagStats))
|
||||
for tag, stats := range tagStats {
|
||||
if stats == nil || stats.count == 0 {
|
||||
continue
|
||||
}
|
||||
average := (stats.total / float64(stats.count)) * 100
|
||||
if average >= 70 && stats.flaggedCount == 0 {
|
||||
continue
|
||||
}
|
||||
weakCandidates = append(weakCandidates, scoredTag{
|
||||
tag: tag,
|
||||
score: roundToOneDecimal(average),
|
||||
flaggedCount: stats.flaggedCount,
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(weakCandidates, func(i, j int) bool {
|
||||
if weakCandidates[i].score == weakCandidates[j].score {
|
||||
if weakCandidates[i].flaggedCount == weakCandidates[j].flaggedCount {
|
||||
return weakCandidates[i].tag < weakCandidates[j].tag
|
||||
}
|
||||
return weakCandidates[i].flaggedCount > weakCandidates[j].flaggedCount
|
||||
}
|
||||
return weakCandidates[i].score < weakCandidates[j].score
|
||||
})
|
||||
|
||||
limit := 6
|
||||
if len(weakCandidates) < limit {
|
||||
limit = len(weakCandidates)
|
||||
}
|
||||
|
||||
weakTags := make([]string, 0, limit)
|
||||
for idx := 0; idx < limit; idx++ {
|
||||
weakTags = append(weakTags, weakCandidates[idx].tag)
|
||||
}
|
||||
|
||||
return weakTags
|
||||
}
|
||||
|
||||
func planningScore(isCorrect bool, understandingScore float64) float64 {
|
||||
correctness := 0.0
|
||||
if isCorrect {
|
||||
correctness = 1.0
|
||||
}
|
||||
return (correctness + understandingScore) / 2
|
||||
}
|
||||
|
||||
func roundToOneDecimal(value float64) float64 {
|
||||
return math.Round(value*10) / 10
|
||||
}
|
||||
|
||||
func numericFloat64OrZero(value pgtype.Numeric) float64 {
|
||||
floatValue, err := value.Float64Value()
|
||||
if err != nil || !floatValue.Valid {
|
||||
return 0
|
||||
}
|
||||
return floatValue.Float64
|
||||
}
|
||||
91
Backend/internal/assignmentgen/service.go
Normal file
91
Backend/internal/assignmentgen/service.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package assignmentgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"boostai-backend/internal/database"
|
||||
"boostai-backend/internal/questiongen"
|
||||
"boostai-backend/internal/sqlc"
|
||||
)
|
||||
|
||||
type SourceBucket string
|
||||
|
||||
const (
|
||||
SourceBucketCoreTopic SourceBucket = "core_topic"
|
||||
SourceBucketPersonalized SourceBucket = "personalized"
|
||||
defaultQuestionSource = "assignment_student_generated"
|
||||
)
|
||||
|
||||
type PlanItem struct {
|
||||
Topic sqlc.QuestionTopic
|
||||
Difficulty sqlc.QuestionDifficulty
|
||||
Count int
|
||||
SourceBucket SourceBucket
|
||||
Seed int64
|
||||
}
|
||||
|
||||
type GenerateStudentQuestionSetParams struct {
|
||||
AssignmentID int64
|
||||
StudentID int64
|
||||
TeacherID int64
|
||||
Subject string
|
||||
QuestionStatus sqlc.QuestionStatus
|
||||
QuestionSource string
|
||||
Plan []PlanItem
|
||||
}
|
||||
|
||||
type StoredStudentQuestion struct {
|
||||
Mapping sqlc.AssignmentStudentQuestion
|
||||
Question sqlc.Question
|
||||
Tags []string
|
||||
UsedSeed int64
|
||||
SourceBucket string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *database.DB
|
||||
generator *questiongen.Service
|
||||
}
|
||||
|
||||
func NewService(db *database.DB, generator *questiongen.Service) *Service {
|
||||
return &Service{db: db, generator: generator}
|
||||
}
|
||||
|
||||
func (s *Service) GenerateAndStoreStudentQuestions(ctx context.Context, params GenerateStudentQuestionSetParams) ([]StoredStudentQuestion, error) {
|
||||
if err := s.validateGenerateRequest(params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback(ctx)
|
||||
}()
|
||||
|
||||
queries := sqlc.New(tx)
|
||||
|
||||
if err := validateAssignmentOwnership(ctx, queries, params.AssignmentID, params.TeacherID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateStudentAssignment(ctx, queries, params.AssignmentID, params.StudentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := clearStudentQuestionMappings(ctx, queries, params.AssignmentID, params.StudentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
questionStatus, questionSource := normalizeQuestionDefaults(params)
|
||||
|
||||
stored, err := s.generateAndStorePlan(ctx, queries, params, questionStatus, questionSource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stored, nil
|
||||
}
|
||||
244
Backend/internal/assignmentgen/service_generate.go
Normal file
244
Backend/internal/assignmentgen/service_generate.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package assignmentgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"boostai-backend/internal/questiongen"
|
||||
"boostai-backend/internal/sqlc"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func (s *Service) validateGenerateRequest(params GenerateStudentQuestionSetParams) error {
|
||||
if s == nil || s.db == nil || s.db.Pool == nil {
|
||||
return errors.New("assignment question generator database is not configured")
|
||||
}
|
||||
if s.generator == nil {
|
||||
return errors.New("assignment question generator is not configured")
|
||||
}
|
||||
if params.AssignmentID <= 0 || params.StudentID <= 0 || params.TeacherID <= 0 {
|
||||
return errors.New("assignment_id, student_id, and teacher_id are required")
|
||||
}
|
||||
return validatePlanItems(params.Plan)
|
||||
}
|
||||
|
||||
func validatePlanItems(plan []PlanItem) error {
|
||||
if len(plan) == 0 {
|
||||
return errors.New("at least one generation plan item is required")
|
||||
}
|
||||
|
||||
for _, item := range plan {
|
||||
if item.Count <= 0 {
|
||||
return fmt.Errorf("generation count must be positive for bucket %q", item.SourceBucket)
|
||||
}
|
||||
if strings.TrimSpace(string(item.SourceBucket)) == "" {
|
||||
return errors.New("source bucket is required for every generation plan item")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAssignmentOwnership(ctx context.Context, queries *sqlc.Queries, assignmentID, teacherID int64) error {
|
||||
assignment, err := queries.GetAssignmentByID(ctx, assignmentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("assignment %d not found", assignmentID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if assignment.TeacherID != teacherID {
|
||||
return fmt.Errorf("assignment %d does not belong to teacher %d", assignmentID, teacherID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStudentAssignment(ctx context.Context, queries *sqlc.Queries, assignmentID, studentID int64) error {
|
||||
_, err := queries.GetAssignmentAssignee(ctx, sqlc.GetAssignmentAssigneeParams{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: studentID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("student %d is not assigned to assignment %d", studentID, assignmentID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearStudentQuestionMappings(ctx context.Context, queries *sqlc.Queries, assignmentID, studentID int64) error {
|
||||
return queries.DeleteAssignmentStudentQuestions(ctx, sqlc.DeleteAssignmentStudentQuestionsParams{
|
||||
AssignmentID: assignmentID,
|
||||
StudentID: studentID,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeQuestionDefaults(params GenerateStudentQuestionSetParams) (sqlc.QuestionStatus, string) {
|
||||
questionStatus := params.QuestionStatus
|
||||
if questionStatus == "" {
|
||||
questionStatus = sqlc.QuestionStatusDraft
|
||||
}
|
||||
|
||||
questionSource := strings.TrimSpace(params.QuestionSource)
|
||||
if questionSource == "" {
|
||||
questionSource = defaultQuestionSource
|
||||
}
|
||||
|
||||
return questionStatus, questionSource
|
||||
}
|
||||
|
||||
func (s *Service) generateAndStorePlan(
|
||||
ctx context.Context,
|
||||
queries *sqlc.Queries,
|
||||
params GenerateStudentQuestionSetParams,
|
||||
questionStatus sqlc.QuestionStatus,
|
||||
questionSource string,
|
||||
) ([]StoredStudentQuestion, error) {
|
||||
stored := make([]StoredStudentQuestion, 0)
|
||||
position := int32(1)
|
||||
|
||||
for _, item := range params.Plan {
|
||||
generatedQuestions, usedSeed, err := s.generator.Generate(questiongen.GenerateParams{
|
||||
Topic: item.Topic,
|
||||
Difficulty: item.Difficulty,
|
||||
Count: item.Count,
|
||||
Seed: item.Seed,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storedBatch, nextPosition, err := storeGeneratedQuestionBatch(
|
||||
ctx,
|
||||
queries,
|
||||
params,
|
||||
item,
|
||||
generatedQuestions,
|
||||
questionStatus,
|
||||
questionSource,
|
||||
usedSeed,
|
||||
position,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored = append(stored, storedBatch...)
|
||||
position = nextPosition
|
||||
}
|
||||
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
func storeGeneratedQuestionBatch(
|
||||
ctx context.Context,
|
||||
queries *sqlc.Queries,
|
||||
params GenerateStudentQuestionSetParams,
|
||||
item PlanItem,
|
||||
generatedQuestions []questiongen.GeneratedQuestion,
|
||||
questionStatus sqlc.QuestionStatus,
|
||||
questionSource string,
|
||||
usedSeed int64,
|
||||
startPosition int32,
|
||||
) ([]StoredStudentQuestion, int32, error) {
|
||||
stored := make([]StoredStudentQuestion, 0, len(generatedQuestions))
|
||||
position := startPosition
|
||||
|
||||
for _, generated := range generatedQuestions {
|
||||
storedQuestion, err := storeGeneratedQuestion(
|
||||
ctx,
|
||||
queries,
|
||||
params,
|
||||
item,
|
||||
generated,
|
||||
questionStatus,
|
||||
questionSource,
|
||||
usedSeed,
|
||||
position,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, startPosition, err
|
||||
}
|
||||
|
||||
stored = append(stored, storedQuestion)
|
||||
position++
|
||||
}
|
||||
|
||||
return stored, position, nil
|
||||
}
|
||||
|
||||
func storeGeneratedQuestion(
|
||||
ctx context.Context,
|
||||
queries *sqlc.Queries,
|
||||
params GenerateStudentQuestionSetParams,
|
||||
item PlanItem,
|
||||
generated questiongen.GeneratedQuestion,
|
||||
questionStatus sqlc.QuestionStatus,
|
||||
questionSource string,
|
||||
usedSeed int64,
|
||||
position int32,
|
||||
) (StoredStudentQuestion, error) {
|
||||
question, err := queries.CreateQuestion(ctx, sqlc.CreateQuestionParams{
|
||||
AuthorTeacherID: params.TeacherID,
|
||||
Title: generated.Title,
|
||||
Prompt: generated.Prompt,
|
||||
Topic: nullableQuestionTopic(item.Topic),
|
||||
Subject: textValue(firstNonEmpty(params.Subject, questionTopicLabel(item.Topic))),
|
||||
Difficulty: nullableQuestionDifficulty(item.Difficulty),
|
||||
Source: textValue(questionSource),
|
||||
Status: questionStatus,
|
||||
CorrectAnswer: textValue(generated.CorrectAnswer),
|
||||
})
|
||||
if err != nil {
|
||||
return StoredStudentQuestion{}, err
|
||||
}
|
||||
|
||||
tags := mergeTags(generated.Tags, string(item.SourceBucket), questionSource)
|
||||
if err := attachQuestionTags(ctx, queries, question.ID, tags); err != nil {
|
||||
return StoredStudentQuestion{}, err
|
||||
}
|
||||
|
||||
mapping, err := queries.AddAssignmentStudentQuestion(ctx, sqlc.AddAssignmentStudentQuestionParams{
|
||||
AssignmentID: params.AssignmentID,
|
||||
StudentID: params.StudentID,
|
||||
QuestionID: question.ID,
|
||||
Position: position,
|
||||
SourceBucket: string(item.SourceBucket),
|
||||
SourceTopic: nullableQuestionTopic(item.Topic),
|
||||
SourceDifficulty: nullableQuestionDifficulty(item.Difficulty),
|
||||
GeneratorSeed: pgtype.Int8{Int64: usedSeed, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return StoredStudentQuestion{}, err
|
||||
}
|
||||
|
||||
return StoredStudentQuestion{
|
||||
Mapping: mapping,
|
||||
Question: question,
|
||||
Tags: tags,
|
||||
UsedSeed: usedSeed,
|
||||
SourceBucket: string(item.SourceBucket),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func attachQuestionTags(ctx context.Context, queries *sqlc.Queries, questionID int64, tagNames []string) error {
|
||||
for _, tagName := range tagNames {
|
||||
tag, err := queries.CreateTag(ctx, tagName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := queries.AttachTagToQuestion(ctx, sqlc.AttachTagToQuestionParams{
|
||||
QuestionID: questionID,
|
||||
TagID: tag.ID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
89
Backend/internal/assignmentgen/service_helpers.go
Normal file
89
Backend/internal/assignmentgen/service_helpers.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package assignmentgen
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"boostai-backend/internal/sqlc"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func nullableQuestionTopic(topic sqlc.QuestionTopic) sqlc.NullQuestionTopic {
|
||||
if topic == "" {
|
||||
return sqlc.NullQuestionTopic{}
|
||||
}
|
||||
return sqlc.NullQuestionTopic{QuestionTopic: topic, Valid: true}
|
||||
}
|
||||
|
||||
func nullableQuestionDifficulty(difficulty sqlc.QuestionDifficulty) sqlc.NullQuestionDifficulty {
|
||||
if difficulty == "" {
|
||||
return sqlc.NullQuestionDifficulty{}
|
||||
}
|
||||
return sqlc.NullQuestionDifficulty{QuestionDifficulty: difficulty, Valid: true}
|
||||
}
|
||||
|
||||
func textValue(value string) pgtype.Text {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return pgtype.Text{}
|
||||
}
|
||||
return pgtype.Text{String: trimmed, Valid: true}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mergeTags(base []string, extras ...string) []string {
|
||||
seen := make(map[string]struct{}, len(base)+len(extras))
|
||||
merged := make([]string, 0, len(base)+len(extras))
|
||||
|
||||
appendTag := func(tag string) {
|
||||
normalized := strings.TrimSpace(strings.ToLower(tag))
|
||||
if normalized == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := seen[normalized]; exists {
|
||||
return
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
merged = append(merged, normalized)
|
||||
}
|
||||
|
||||
for _, tag := range base {
|
||||
appendTag(tag)
|
||||
}
|
||||
for _, tag := range extras {
|
||||
appendTag(tag)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func questionTopicLabel(topic sqlc.QuestionTopic) string {
|
||||
switch topic {
|
||||
case sqlc.QuestionTopicPlaceValue:
|
||||
return "Place value"
|
||||
case sqlc.QuestionTopicArithmetic:
|
||||
return "Arithmetic"
|
||||
case sqlc.QuestionTopicNegativeNumbers:
|
||||
return "Negative numbers"
|
||||
case sqlc.QuestionTopicBidmas:
|
||||
return "BIDMAS"
|
||||
case sqlc.QuestionTopicFractions:
|
||||
return "Fractions"
|
||||
case sqlc.QuestionTopicAlgebra:
|
||||
return "Algebra"
|
||||
case sqlc.QuestionTopicGeometry:
|
||||
return "Geometry"
|
||||
case sqlc.QuestionTopicData:
|
||||
return "Data"
|
||||
default:
|
||||
return "Maths"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user