Boost Azure Demo

This commit is contained in:
MangoPig
2026-05-25 17:05:06 +01:00
parent 675285e99d
commit 4f79137d89
230 changed files with 43275 additions and 2644 deletions

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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"
}
}