976 lines
28 KiB
Go
976 lines
28 KiB
Go
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
|
|
}
|