Before Fine Tune
This commit is contained in:
975
Backend/internal/seeddata/seed.go
Normal file
975
Backend/internal/seeddata/seed.go
Normal file
@@ -0,0 +1,975 @@
|
||||
package seeddata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"boostai-backend/internal/database"
|
||||
sharedapi "boostai-backend/internal/handlers/api/shared"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const SeededPassword = "password123"
|
||||
|
||||
type studentRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
FullName string `json:"fullname"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Active bool `json:"active"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type classroomFile struct {
|
||||
Classroom classroomRecord `json:"classroom"`
|
||||
Tutor tutorRecord `json:"tutor"`
|
||||
ClassroomStudentRs []classroomStudentRecord `json:"classroom_student_rs"`
|
||||
}
|
||||
|
||||
type classroomRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TutorID int64 `json:"tutor_id"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type tutorRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
FullName string `json:"fullname"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Active bool `json:"active"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type classroomStudentRecord struct {
|
||||
ClassroomID int64 `json:"classroom_id"`
|
||||
StudentID int64 `json:"student_id"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type questionRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
SubTopic *string `json:"sub_topic"`
|
||||
Tag *string `json:"tag"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
QuestionText string `json:"question_text"`
|
||||
CorrectAnswer string `json:"correct_answer"`
|
||||
Source string `json:"source"`
|
||||
TeacherID int64 `json:"teacher_id"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type assignmentRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TeacherID int64 `json:"teacher_id"`
|
||||
Topic string `json:"topic"`
|
||||
DueDate int64 `json:"due_date"`
|
||||
Status string `json:"status"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type assignmentQuestionRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
AssignmentID int64 `json:"assignment_id"`
|
||||
QuestionBankID int64 `json:"question_bank_id"`
|
||||
QuestionOrder int32 `json:"question_order"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type assignmentAssigneeRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
AssignmentID int64 `json:"assignment_id"`
|
||||
StudentID int64 `json:"student_id"`
|
||||
Status string `json:"status"`
|
||||
StartedAt *int64 `json:"started_at"`
|
||||
SubmittedAt *int64 `json:"submitted_at"`
|
||||
OverallScore *float64 `json:"overall_score"`
|
||||
AIFeedback *string `json:"ai_feedback"`
|
||||
NextStepOutcome *string `json:"next_step_outcome"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type studentAnswerRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
AssigneeID int64 `json:"assignee_id"`
|
||||
AssignmentQuestionID int64 `json:"assignment_question_id"`
|
||||
AnswerLatex *string `json:"answer_latex"`
|
||||
ExtractedAnswer *string `json:"extracted_answer"`
|
||||
SolveMode *string `json:"solve_mode"`
|
||||
WorkingSteps *string `json:"working_steps"`
|
||||
AIReasoning *string `json:"ai_reasoning"`
|
||||
IsCorrect *bool `json:"is_correct"`
|
||||
AIFeedback *string `json:"ai_feedback"`
|
||||
ReviewNeedsAttention *bool `json:"review_needs_attention"`
|
||||
ReviewIssueReason *string `json:"review_issue_reason"`
|
||||
ReviewCorrectnessScore *float64 `json:"review_correctness_score"`
|
||||
ReviewUnderstandingScore *float64 `json:"review_understanding_score"`
|
||||
ReviewQuestionScore *float64 `json:"review_question_score"`
|
||||
ReviewConfidence *float64 `json:"review_confidence"`
|
||||
ReviewTags []string `json:"review_tags"`
|
||||
GradingStatus string `json:"grading_status"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
AnsweredAt *int64 `json:"_answered_at"`
|
||||
UnderSolveMode *string `json:"_solve_mode"`
|
||||
UnderIsCorrect *bool `json:"_is_correct"`
|
||||
UnderMisconceptionTag *string `json:"_misconception_tag"`
|
||||
}
|
||||
|
||||
type assignmentQuestionRef struct {
|
||||
AssignmentID int64
|
||||
QuestionID int64
|
||||
Position int32
|
||||
}
|
||||
|
||||
const DefaultMockDataDir = "../Mock-Data"
|
||||
|
||||
type Summary struct {
|
||||
Users int `json:"users"`
|
||||
Classrooms int `json:"classrooms"`
|
||||
Questions int `json:"questions"`
|
||||
Tags int `json:"tags"`
|
||||
Assignments int `json:"assignments"`
|
||||
AssignmentLinks int `json:"assignment_links"`
|
||||
StudentAnswers int `json:"student_answers"`
|
||||
MockDataDir string `json:"mock_data_dir"`
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, db *database.DB, mockDataDir string) (Summary, error) {
|
||||
if strings.TrimSpace(mockDataDir) == "" {
|
||||
mockDataDir = filepath.Clean(filepath.Join("..", "Mock-Data"))
|
||||
}
|
||||
|
||||
var (
|
||||
students []studentRecord
|
||||
classroomPayload classroomFile
|
||||
questions []questionRecord
|
||||
assignments []assignmentRecord
|
||||
assignmentQuestions []assignmentQuestionRecord
|
||||
assignmentAssignees []assignmentAssigneeRecord
|
||||
studentAnswers []studentAnswerRecord
|
||||
)
|
||||
|
||||
if err := loadJSON(filepath.Join(mockDataDir, "students.json"), &students); err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
if err := loadJSON(filepath.Join(mockDataDir, "classroom.json"), &classroomPayload); err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
if err := loadJSON(filepath.Join(mockDataDir, "question_bank.json"), &questions); err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
if err := loadJSON(filepath.Join(mockDataDir, "assignments.json"), &assignments); err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
if err := loadJSON(filepath.Join(mockDataDir, "assignment_questions.json"), &assignmentQuestions); err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
if err := loadJSON(filepath.Join(mockDataDir, "assignment_assignees.json"), &assignmentAssignees); err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
if err := loadJSON(filepath.Join(mockDataDir, "student_answers.json"), &studentAnswers); err != nil {
|
||||
return Summary{}, err
|
||||
}
|
||||
|
||||
if err := db.Migrate(); err != nil {
|
||||
return Summary{}, fmt.Errorf("migrate database: %w", err)
|
||||
}
|
||||
|
||||
tx, err := db.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return Summary{}, fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if err := resetSeedData(ctx, tx); err != nil {
|
||||
return Summary{}, fmt.Errorf("reset data: %w", err)
|
||||
}
|
||||
|
||||
if err := seedUsers(ctx, tx, classroomPayload.Tutor, students); err != nil {
|
||||
return Summary{}, fmt.Errorf("seed users: %w", err)
|
||||
}
|
||||
|
||||
if err := seedClassroom(ctx, tx, classroomPayload); err != nil {
|
||||
return Summary{}, fmt.Errorf("seed classroom: %w", err)
|
||||
}
|
||||
|
||||
tagIDs, err := seedQuestionsAndTags(ctx, tx, questions)
|
||||
if err != nil {
|
||||
return Summary{}, fmt.Errorf("seed questions: %w", err)
|
||||
}
|
||||
|
||||
if err := seedAssignments(ctx, tx, classroomPayload.Classroom.ID, assignments); err != nil {
|
||||
return Summary{}, fmt.Errorf("seed assignments: %w", err)
|
||||
}
|
||||
|
||||
if err := seedAssignmentAssignees(ctx, tx, assignmentAssignees); err != nil {
|
||||
return Summary{}, fmt.Errorf("seed assignment assignees: %w", err)
|
||||
}
|
||||
|
||||
assignmentQuestionMap, err := seedAssignmentQuestions(ctx, tx, assignmentQuestions)
|
||||
if err != nil {
|
||||
return Summary{}, fmt.Errorf("seed assignment questions: %w", err)
|
||||
}
|
||||
|
||||
if err := seedStudentAnswers(ctx, tx, assignmentAssignees, assignmentQuestionMap, studentAnswers); err != nil {
|
||||
return Summary{}, fmt.Errorf("seed student answers: %w", err)
|
||||
}
|
||||
|
||||
if err := seedMessages(ctx, tx, classroomPayload.Tutor, students); err != nil {
|
||||
return Summary{}, fmt.Errorf("seed messages: %w", err)
|
||||
}
|
||||
|
||||
if err := syncSequences(ctx, tx); err != nil {
|
||||
return Summary{}, fmt.Errorf("sync sequences: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Summary{}, fmt.Errorf("commit seed transaction: %w", err)
|
||||
}
|
||||
|
||||
return Summary{
|
||||
Users: len(students) + 1,
|
||||
Classrooms: 1,
|
||||
Questions: len(questions),
|
||||
Tags: len(tagIDs),
|
||||
Assignments: len(assignments),
|
||||
AssignmentLinks: len(assignmentQuestions),
|
||||
StudentAnswers: len(studentAnswers),
|
||||
MockDataDir: mockDataDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadJSON(path string, target any) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetSeedData(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
TRUNCATE TABLE
|
||||
messages,
|
||||
message_thread_participants,
|
||||
message_threads,
|
||||
assignment_student_questions,
|
||||
student_answers,
|
||||
assignment_questions,
|
||||
assignment_assignees,
|
||||
assignments,
|
||||
question_tags,
|
||||
tags,
|
||||
questions,
|
||||
classroom_students,
|
||||
classrooms,
|
||||
users
|
||||
RESTART IDENTITY CASCADE`)
|
||||
return err
|
||||
}
|
||||
|
||||
func seedUsers(ctx context.Context, tx pgx.Tx, tutor tutorRecord, students []studentRecord) error {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(SeededPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := insertUser(ctx, tx, tutor.ID, tutor.Email, string(hashedPassword), "teacher", tutor.FullName, tutor.Active && !tutor.IsDeleted, tutor.CreatedAt, tutor.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, student := range students {
|
||||
if student.IsDeleted {
|
||||
continue
|
||||
}
|
||||
if err := insertUser(ctx, tx, student.ID, student.Email, string(hashedPassword), "student", student.FullName, student.Active && !student.IsDeleted, student.CreatedAt, student.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedMessages(ctx context.Context, tx pgx.Tx, tutor tutorRecord, students []studentRecord) error {
|
||||
activeStudents := make([]studentRecord, 0, len(students))
|
||||
for _, student := range students {
|
||||
if student.IsDeleted || !student.Active {
|
||||
continue
|
||||
}
|
||||
activeStudents = append(activeStudents, student)
|
||||
}
|
||||
|
||||
if len(activeStudents) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
threadSeedCount := len(activeStudents)
|
||||
if threadSeedCount > 3 {
|
||||
threadSeedCount = 3
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
for idx := 0; idx < threadSeedCount; idx++ {
|
||||
student := activeStudents[idx]
|
||||
threadCreatedAt := now.Add(-time.Duration(threadSeedCount-idx) * 6 * time.Hour)
|
||||
subject := fmt.Sprintf("Study check-in for %s", firstName(student.FullName))
|
||||
|
||||
var threadID int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO message_threads (created_by_user_id, subject, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $3)
|
||||
RETURNING id`, tutor.ID, subject, threadCreatedAt).Scan(&threadID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
teacherBody := fmt.Sprintf("Hi %s, send me a quick update when you finish today's maths block. If one question feels sticky, tell me which one and I'll help.", firstName(student.FullName))
|
||||
studentBody := fmt.Sprintf("Thanks %s — I started the assignment set and I'm feeling better about the fraction questions now.", firstName(tutor.FullName))
|
||||
|
||||
messageTimes := []time.Time{threadCreatedAt.Add(12 * time.Minute), threadCreatedAt.Add(54 * time.Minute)}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO messages (thread_id, sender_user_id, body, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $4), ($1, $5, $6, $7, $7)`, threadID, tutor.ID, teacherBody, messageTimes[0], student.ID, studentBody, messageTimes[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
teacherReadAt := pgtype.Timestamptz{Time: messageTimes[1], Valid: true}
|
||||
studentReadAt := pgtype.Timestamptz{}
|
||||
if idx == 0 {
|
||||
studentReadAt = pgtype.Timestamptz{Time: messageTimes[1], Valid: true}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO message_thread_participants (thread_id, user_id, joined_at, last_read_at)
|
||||
VALUES ($1, $2, $3, $4), ($1, $5, $3, $6)`, threadID, tutor.ID, threadCreatedAt, teacherReadAt, student.ID, studentReadAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `UPDATE message_threads SET updated_at = $2 WHERE id = $1`, threadID, messageTimes[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertUser(ctx context.Context, tx pgx.Tx, id int64, email, passwordHash, role, fullName string, active bool, createdAtMs, updatedAtMs int64) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO users (id, email, password_hash, role, full_name, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::user_role, $5, $6, $7, $8)`,
|
||||
id,
|
||||
email,
|
||||
passwordHash,
|
||||
role,
|
||||
fullName,
|
||||
active,
|
||||
msToTime(createdAtMs),
|
||||
msToTime(updatedAtMs),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func seedClassroom(ctx context.Context, tx pgx.Tx, payload classroomFile) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO classrooms (id, teacher_id, name, code, description, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
payload.Classroom.ID,
|
||||
payload.Classroom.TutorID,
|
||||
payload.Classroom.Name,
|
||||
sharedapi.NullableText(optionalString(payload.Classroom.InviteCode)),
|
||||
sharedapi.NullableText(classroomDescription(payload.Classroom.Name)),
|
||||
msToTime(payload.Classroom.CreatedAt),
|
||||
msToTime(payload.Classroom.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, relation := range payload.ClassroomStudentRs {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO classroom_students (classroom_id, student_id, joined_at)
|
||||
VALUES ($1, $2, $3)`, relation.ClassroomID, relation.StudentID, msToTime(relation.CreatedAt)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedQuestionsAndTags(ctx context.Context, tx pgx.Tx, questions []questionRecord) ([]int64, error) {
|
||||
tagSet := map[string]struct{}{}
|
||||
for _, question := range questions {
|
||||
if question.Tag != nil {
|
||||
tag := strings.TrimSpace(*question.Tag)
|
||||
if tag != "" {
|
||||
tagSet[tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tagNames := make([]string, 0, len(tagSet))
|
||||
for tag := range tagSet {
|
||||
tagNames = append(tagNames, tag)
|
||||
}
|
||||
sort.Strings(tagNames)
|
||||
|
||||
tagIDByName := make(map[string]int64, len(tagNames))
|
||||
tagIDs := make([]int64, 0, len(tagNames))
|
||||
for _, tagName := range tagNames {
|
||||
var tagID int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO tags (name)
|
||||
VALUES ($1)
|
||||
RETURNING id`, tagName).Scan(&tagID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagIDByName[tagName] = tagID
|
||||
tagIDs = append(tagIDs, tagID)
|
||||
}
|
||||
|
||||
for _, question := range questions {
|
||||
if err := insertQuestion(ctx, tx, question); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if question.Tag == nil {
|
||||
continue
|
||||
}
|
||||
tagName := strings.TrimSpace(*question.Tag)
|
||||
if tagName == "" {
|
||||
continue
|
||||
}
|
||||
tagID := tagIDByName[tagName]
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO question_tags (question_id, tag_id) VALUES ($1, $2)`, question.ID, tagID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return tagIDs, nil
|
||||
}
|
||||
|
||||
func insertQuestion(ctx context.Context, tx pgx.Tx, record questionRecord) error {
|
||||
status := "published"
|
||||
if record.IsDeleted {
|
||||
status = "archived"
|
||||
}
|
||||
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO questions (id, author_teacher_id, title, prompt, topic, subject, difficulty, source, correct_answer, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5::question_topic, $6, $7::question_difficulty, $8, $9, $10::question_status, $11, $12)`,
|
||||
record.ID,
|
||||
record.TeacherID,
|
||||
questionTitle(record.QuestionText),
|
||||
record.QuestionText,
|
||||
nullableTopic(record.Topic),
|
||||
nullableSubject(record.SubTopic, record.Topic),
|
||||
nullableDifficulty(record.Difficulty),
|
||||
nullableSource(record.Source),
|
||||
sharedapi.NullableText(&record.CorrectAnswer),
|
||||
status,
|
||||
msToTime(record.CreatedAt),
|
||||
msToTime(record.UpdatedAt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func seedAssignments(ctx context.Context, tx pgx.Tx, classroomID int64, assignments []assignmentRecord) error {
|
||||
for _, assignment := range assignments {
|
||||
if assignment.IsDeleted {
|
||||
continue
|
||||
}
|
||||
|
||||
assignmentStatus := normalizeAssignmentStatus(assignment.Status)
|
||||
publishedAt := optionalPublishedAt(assignmentStatus, assignment.CreatedAt, assignment.UpdatedAt)
|
||||
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO assignments (id, teacher_id, classroom_id, title, instructions, due_at, published_at, pass_threshold, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::assignment_status, $10, $11)`,
|
||||
assignment.ID,
|
||||
assignment.TeacherID,
|
||||
classroomID,
|
||||
assignment.Name,
|
||||
sharedapi.NullableText(optionalString(assignmentInstructions(assignment.Topic))),
|
||||
optionalDueAt(assignment.DueDate),
|
||||
publishedAt,
|
||||
requiredNumeric(6.0),
|
||||
assignmentStatus,
|
||||
msToTime(assignment.CreatedAt),
|
||||
msToTime(assignment.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedAssignmentQuestions(ctx context.Context, tx pgx.Tx, rows []assignmentQuestionRecord) (map[int64]assignmentQuestionRef, error) {
|
||||
assignmentQuestionMap := make(map[int64]assignmentQuestionRef, len(rows))
|
||||
for _, row := range rows {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO assignment_questions (assignment_id, question_id, position)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (assignment_id, question_id) DO UPDATE
|
||||
SET position = EXCLUDED.position`, row.AssignmentID, row.QuestionBankID, row.QuestionOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assignmentQuestionMap[row.ID] = assignmentQuestionRef{
|
||||
AssignmentID: row.AssignmentID,
|
||||
QuestionID: row.QuestionBankID,
|
||||
Position: row.QuestionOrder,
|
||||
}
|
||||
}
|
||||
return assignmentQuestionMap, nil
|
||||
}
|
||||
|
||||
func seedAssignmentAssignees(ctx context.Context, tx pgx.Tx, rows []assignmentAssigneeRecord) error {
|
||||
for _, row := range rows {
|
||||
assignedAt := firstValidMs(row.StartedAt, row.SubmittedAt, row.CreatedAt)
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO assignment_assignees (assignment_id, student_id, assigned_at, ai_feedback, overall_score, pass_threshold, pass_status, next_step_outcome)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::assignment_pass_status, $8::assignment_next_step_outcome)`,
|
||||
row.AssignmentID,
|
||||
row.StudentID,
|
||||
assignedAt,
|
||||
sharedapi.NullableText(row.AIFeedback),
|
||||
optionalNumeric(row.OverallScore),
|
||||
requiredNumeric(6.0),
|
||||
normalizePassStatus(row.OverallScore, 6.0),
|
||||
normalizeNextStepOutcome(row.NextStepOutcome),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedStudentAnswers(ctx context.Context, tx pgx.Tx, assignees []assignmentAssigneeRecord, assignmentQuestionMap map[int64]assignmentQuestionRef, answers []studentAnswerRecord) error {
|
||||
type assigneeRef struct {
|
||||
AssignmentID int64
|
||||
StudentID int64
|
||||
}
|
||||
|
||||
assigneeAssignment := make(map[int64]assigneeRef, len(assignees))
|
||||
for _, row := range assignees {
|
||||
assigneeAssignment[row.ID] = assigneeRef{AssignmentID: row.AssignmentID, StudentID: row.StudentID}
|
||||
}
|
||||
|
||||
for _, answer := range answers {
|
||||
questionRef, ok := assignmentQuestionMap[answer.AssignmentQuestionID]
|
||||
if !ok {
|
||||
return fmt.Errorf("missing assignment question mapping for %d", answer.AssignmentQuestionID)
|
||||
}
|
||||
|
||||
assigneeRef, ok := assigneeAssignment[answer.AssigneeID]
|
||||
if !ok {
|
||||
return fmt.Errorf("missing assignee mapping for %d", answer.AssigneeID)
|
||||
}
|
||||
|
||||
if assigneeRef.AssignmentID != questionRef.AssignmentID {
|
||||
return fmt.Errorf("assignment mismatch for assignee %d and assignment question %d", answer.AssigneeID, answer.AssignmentQuestionID)
|
||||
}
|
||||
|
||||
answerText := firstNonEmpty(answer.ExtractedAnswer, answer.AnswerLatex)
|
||||
answerStatus := normalizeAnswerStatus(answer.GradingStatus)
|
||||
solveMode := normalizeSolveMode(firstNonEmpty(answer.SolveMode, answer.UnderSolveMode))
|
||||
reviewedAt := optionalReviewedAt(answerStatus, answer.CreatedAt)
|
||||
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO student_answers (
|
||||
id,
|
||||
assignment_id,
|
||||
question_id,
|
||||
student_id,
|
||||
answer_text,
|
||||
ai_feedback,
|
||||
teacher_feedback,
|
||||
status,
|
||||
submitted_at,
|
||||
reviewed_at,
|
||||
created_at,
|
||||
updated_at,
|
||||
solve_mode,
|
||||
working_steps,
|
||||
is_correct,
|
||||
review_needs_attention,
|
||||
review_issue_reason,
|
||||
review_correctness_score,
|
||||
review_understanding_score,
|
||||
review_question_score,
|
||||
review_confidence,
|
||||
review_tags
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8::answer_status, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22
|
||||
)`,
|
||||
answer.ID,
|
||||
assigneeRef.AssignmentID,
|
||||
questionRef.QuestionID,
|
||||
assigneeRef.StudentID,
|
||||
sharedapi.NullableText(answerText),
|
||||
sharedapi.NullableText(answer.AIFeedback),
|
||||
nil,
|
||||
answerStatus,
|
||||
optionalMsToTime(answer.AnsweredAt),
|
||||
reviewedAt,
|
||||
msToTime(answer.CreatedAt),
|
||||
deriveUpdatedAt(answer.CreatedAt, questionRef.Position),
|
||||
solveMode,
|
||||
sharedapi.NullableText(answer.WorkingSteps),
|
||||
sharedapi.NullableBool(firstNonNilBool(answer.IsCorrect, answer.UnderIsCorrect)),
|
||||
boolOrDefault(answer.ReviewNeedsAttention, false),
|
||||
sharedapi.NullableText(answer.ReviewIssueReason),
|
||||
optionalNumeric(answer.ReviewCorrectnessScore),
|
||||
optionalNumeric(answer.ReviewUnderstandingScore),
|
||||
optionalNumeric(answer.ReviewQuestionScore),
|
||||
optionalNumeric(answer.ReviewConfidence),
|
||||
stringsArray(answer.ReviewTags),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncSequences(ctx context.Context, tx pgx.Tx) error {
|
||||
statements := []string{
|
||||
"SELECT setval('users_id_seq', COALESCE((SELECT MAX(id) FROM users), 1))",
|
||||
"SELECT setval('classrooms_id_seq', COALESCE((SELECT MAX(id) FROM classrooms), 1))",
|
||||
"SELECT setval('questions_id_seq', COALESCE((SELECT MAX(id) FROM questions), 1))",
|
||||
"SELECT setval('tags_id_seq', COALESCE((SELECT MAX(id) FROM tags), 1))",
|
||||
"SELECT setval('assignments_id_seq', COALESCE((SELECT MAX(id) FROM assignments), 1))",
|
||||
"SELECT setval('assignment_student_questions_id_seq', COALESCE((SELECT MAX(id) FROM assignment_student_questions), 1))",
|
||||
"SELECT setval('student_answers_id_seq', COALESCE((SELECT MAX(id) FROM student_answers), 1))",
|
||||
"SELECT setval('message_threads_id_seq', COALESCE((SELECT MAX(id) FROM message_threads), 1))",
|
||||
"SELECT setval('messages_id_seq', COALESCE((SELECT MAX(id) FROM messages), 1))",
|
||||
}
|
||||
|
||||
for _, statement := range statements {
|
||||
if _, err := tx.Exec(ctx, statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func msToTime(value int64) time.Time {
|
||||
if value <= 0 {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return time.UnixMilli(value).UTC()
|
||||
}
|
||||
|
||||
func optionalMsToTime(value *int64) pgtype.Timestamptz {
|
||||
if value == nil || *value <= 0 {
|
||||
return pgtype.Timestamptz{}
|
||||
}
|
||||
return pgtype.Timestamptz{Time: time.UnixMilli(*value).UTC(), Valid: true}
|
||||
}
|
||||
|
||||
func optionalDueAt(value int64) pgtype.Timestamptz {
|
||||
if value <= 0 {
|
||||
return pgtype.Timestamptz{}
|
||||
}
|
||||
return pgtype.Timestamptz{Time: time.UnixMilli(value).UTC(), Valid: true}
|
||||
}
|
||||
|
||||
func deriveUpdatedAt(createdAtMs int64, position int32) time.Time {
|
||||
created := msToTime(createdAtMs)
|
||||
if position <= 0 {
|
||||
return created
|
||||
}
|
||||
return created.Add(time.Duration(position) * time.Minute)
|
||||
}
|
||||
|
||||
func normalizeAssignmentStatus(value string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||
case "published", "assigned", "open":
|
||||
return "assigned"
|
||||
case "closed", "complete", "completed":
|
||||
return "closed"
|
||||
default:
|
||||
return "draft"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAnswerStatus(value string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||
case "in_progress":
|
||||
return "in_progress"
|
||||
case "submitted":
|
||||
return "submitted"
|
||||
case "reviewed", "graded":
|
||||
return "reviewed"
|
||||
default:
|
||||
return "not_started"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSolveMode(primary *string) string {
|
||||
if primary == nil {
|
||||
return "just_answer"
|
||||
}
|
||||
switch strings.TrimSpace(strings.ToLower(*primary)) {
|
||||
case "just_answer", "mental":
|
||||
return "just_answer"
|
||||
case "step_by_step", "calculator":
|
||||
return "step_by_step"
|
||||
case "solve_together":
|
||||
return "solve_together"
|
||||
case "handwritten", "written":
|
||||
return "handwritten"
|
||||
default:
|
||||
return "handwritten"
|
||||
}
|
||||
}
|
||||
|
||||
func nullableTopic(value string) any {
|
||||
return normalizeQuestionTopic(value)
|
||||
}
|
||||
|
||||
func nullableSubject(subTopic *string, topic string) any {
|
||||
if subTopic != nil {
|
||||
if trimmed := strings.TrimSpace(*subTopic); trimmed != "" {
|
||||
return sharedapi.NullableText(&trimmed)
|
||||
}
|
||||
}
|
||||
trimmed := strings.TrimSpace(topic)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return sharedapi.NullableText(&trimmed)
|
||||
}
|
||||
|
||||
func nullableDifficulty(value string) any {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(value))
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func nullableSource(value string) any {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(value))
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func assignmentInstructions(topic string) string {
|
||||
trimmed := strings.TrimSpace(topic)
|
||||
if trimmed == "" {
|
||||
return "Complete each question and show your reasoning where needed."
|
||||
}
|
||||
return fmt.Sprintf("Complete each %s question and show your reasoning where needed.", strings.ReplaceAll(trimmed, "_", " "))
|
||||
}
|
||||
|
||||
func questionTitle(prompt string) string {
|
||||
trimmed := strings.TrimSpace(prompt)
|
||||
if trimmed == "" {
|
||||
return "Seeded question"
|
||||
}
|
||||
if len(trimmed) <= 60 {
|
||||
return trimmed
|
||||
}
|
||||
return trimmed[:57] + "..."
|
||||
}
|
||||
|
||||
func firstName(fullName string) string {
|
||||
parts := strings.Fields(strings.TrimSpace(fullName))
|
||||
if len(parts) == 0 {
|
||||
return "there"
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...*string) *string {
|
||||
for _, value := range values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
copyValue := trimmed
|
||||
return ©Value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonNilBool(values ...*bool) *bool {
|
||||
for _, value := range values {
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func optionalNumeric(value *float64) pgtype.Numeric {
|
||||
if value == nil {
|
||||
return pgtype.Numeric{}
|
||||
}
|
||||
numeric, err := sharedapi.NullableFloat64AsNumeric(value)
|
||||
if err != nil {
|
||||
return pgtype.Numeric{}
|
||||
}
|
||||
return numeric
|
||||
}
|
||||
|
||||
func stringsArray(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeQuestionTopic(value string) any {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(value))
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
mapped := map[string]string{
|
||||
"place value": "place_value",
|
||||
"place_value": "place_value",
|
||||
"arithmetic": "arithmetic",
|
||||
"negative numbers": "negative_numbers",
|
||||
"negative_numbers": "negative_numbers",
|
||||
"bidmas": "bidmas",
|
||||
"fractions": "fractions",
|
||||
"algebra": "algebra",
|
||||
"geometry": "geometry",
|
||||
"data": "data",
|
||||
}
|
||||
if normalized, ok := mapped[trimmed]; ok {
|
||||
return normalized
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizePassStatus(score *float64, threshold float64) string {
|
||||
if score == nil {
|
||||
return "pending"
|
||||
}
|
||||
if *score >= threshold {
|
||||
return "pass"
|
||||
}
|
||||
return "no_pass"
|
||||
}
|
||||
|
||||
func normalizeNextStepOutcome(value *string) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(strings.ToLower(*value))
|
||||
switch trimmed {
|
||||
case "redo", "accept", "support":
|
||||
return trimmed
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func requiredNumeric(value float64) pgtype.Numeric {
|
||||
numeric, err := sharedapi.NullableFloat64AsNumeric(&value)
|
||||
if err != nil {
|
||||
return pgtype.Numeric{}
|
||||
}
|
||||
return numeric
|
||||
}
|
||||
|
||||
func boolOrDefault(value *bool, fallback bool) bool {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func firstValidMs(values ...any) time.Time {
|
||||
for _, value := range values {
|
||||
switch typed := value.(type) {
|
||||
case *int64:
|
||||
if typed != nil && *typed > 0 {
|
||||
return time.UnixMilli(*typed).UTC()
|
||||
}
|
||||
case int64:
|
||||
if typed > 0 {
|
||||
return time.UnixMilli(typed).UTC()
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func optionalPublishedAt(status string, createdAtMs, updatedAtMs int64) pgtype.Timestamptz {
|
||||
if status != "assigned" && status != "closed" {
|
||||
return pgtype.Timestamptz{}
|
||||
}
|
||||
timestamp := createdAtMs
|
||||
if updatedAtMs > 0 {
|
||||
timestamp = updatedAtMs
|
||||
}
|
||||
return optionalDueAt(timestamp)
|
||||
}
|
||||
|
||||
func optionalReviewedAt(status string, createdAtMs int64) pgtype.Timestamptz {
|
||||
if status != "reviewed" {
|
||||
return pgtype.Timestamptz{}
|
||||
}
|
||||
return pgtype.Timestamptz{Time: msToTime(createdAtMs), Valid: true}
|
||||
}
|
||||
|
||||
func optionalString(value string) *string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func classroomDescription(name string) *string {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
description := fmt.Sprintf("Seeded classroom for %s", trimmed)
|
||||
return &description
|
||||
}
|
||||
Reference in New Issue
Block a user