diff --git a/.envsitter/pepper b/.envsitter/pepper
deleted file mode 100644
index 8de9c6e..0000000
--- a/.envsitter/pepper
+++ /dev/null
@@ -1 +0,0 @@
-RgIlJyE1N29vsJg2hyEPwkyf4Fkf7vWFNZggxti97pI=
\ No newline at end of file
diff --git a/Backend/internal/aireview/service.go b/Backend/internal/aireview/service.go
index c404373..8c305ab 100644
--- a/Backend/internal/aireview/service.go
+++ b/Backend/internal/aireview/service.go
@@ -11,6 +11,13 @@ import (
"time"
)
+type requestStyle string
+
+const (
+ requestStyleResponses requestStyle = "responses"
+ requestStyleChatCompletions requestStyle = "chat_completions"
+)
+
type Service struct {
endpoint string
apiKey string
@@ -109,14 +116,9 @@ func (s *Service) ReviewSubmission(ctx context.Context, input AssignmentReviewIn
return nil, fmt.Errorf("marshal AI review input: %w", err)
}
- body := map[string]any{
- "model": s.model,
- "input": []map[string]any{
- {
- "role": "system",
- "content": []map[string]any{{
- "type": "input_text",
- "text": strings.TrimSpace(`You are reviewing student homework submissions for a teacher workflow.
+ outputText, err := s.runStructuredRequest(
+ ctx,
+ strings.TrimSpace(`You are reviewing student homework submissions for a teacher workflow.
You must assess the student's understanding by looking at the student's final answer and working against the saved correct answer when one is available. Do not re-grade weighting.
@@ -142,56 +144,11 @@ Interpretation guidance:
- support = the student shows meaningful gaps and likely needs targeted help.
- redo = the student should redo the assignment because understanding is broadly too weak or incomplete.
- Review the full assignment in one pass and produce a short assignment-level summary.`),
- }},
- },
- {
- "role": "user",
- "content": []map[string]any{{
- "type": "input_text",
- "text": string(payloadJSON),
- }},
- },
- },
- "text": map[string]any{
- "format": map[string]any{
- "type": "json_schema",
- "name": "assignment_review",
- "strict": true,
- "schema": reviewSchema(),
- },
- },
- }
-
- bodyBytes, err := json.Marshal(body)
- if err != nil {
- return nil, fmt.Errorf("marshal AI review request: %w", err)
- }
-
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint, bytes.NewReader(bodyBytes))
- if err != nil {
- return nil, fmt.Errorf("build AI review request: %w", err)
- }
-
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("api-key", s.apiKey)
-
- resp, err := s.client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("send AI review request: %w", err)
- }
- defer resp.Body.Close()
-
- respBytes, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("read AI review response: %w", err)
- }
-
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return nil, fmt.Errorf("AI review request failed: status %d body %s", resp.StatusCode, strings.TrimSpace(string(respBytes)))
- }
-
- outputText, err := extractOutputText(respBytes)
+ Review the full assignment in one pass and produce a short assignment-level summary.`),
+ string(payloadJSON),
+ "assignment_review",
+ reviewSchema(),
+ )
if err != nil {
return nil, err
}
@@ -215,14 +172,9 @@ func (s *Service) PlanRedoAssignment(ctx context.Context, input RedoPlanInput) (
return nil, fmt.Errorf("marshal redo plan input: %w", err)
}
- body := map[string]any{
- "model": s.model,
- "input": []map[string]any{
- {
- "role": "system",
- "content": []map[string]any{{
- "type": "input_text",
- "text": strings.TrimSpace(`You are planning the next redo assignment for a student.
+ outputText, err := s.runStructuredRequest(
+ ctx,
+ strings.TrimSpace(`You are planning the next redo assignment for a student.
You are NOT writing final math questions. You are only producing a structured topic+difficulty blueprint for a later generator layer.
@@ -238,55 +190,10 @@ Rules:
- reason on each item should briefly explain why that topic/difficulty belongs in the redo set.
- Do not invent topics outside the allowed topic vocabulary.
- Do not output prose outside the JSON schema.`),
- }},
- },
- {
- "role": "user",
- "content": []map[string]any{{
- "type": "input_text",
- "text": string(payloadJSON),
- }},
- },
- },
- "text": map[string]any{
- "format": map[string]any{
- "type": "json_schema",
- "name": "redo_assignment_plan",
- "strict": true,
- "schema": redoPlanSchema(),
- },
- },
- }
-
- bodyBytes, err := json.Marshal(body)
- if err != nil {
- return nil, fmt.Errorf("marshal redo plan request: %w", err)
- }
-
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint, bytes.NewReader(bodyBytes))
- if err != nil {
- return nil, fmt.Errorf("build redo plan request: %w", err)
- }
-
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("api-key", s.apiKey)
-
- resp, err := s.client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("send redo plan request: %w", err)
- }
- defer resp.Body.Close()
-
- respBytes, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("read redo plan response: %w", err)
- }
-
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return nil, fmt.Errorf("redo plan request failed: status %d body %s", resp.StatusCode, strings.TrimSpace(string(respBytes)))
- }
-
- outputText, err := extractOutputText(respBytes)
+ string(payloadJSON),
+ "redo_assignment_plan",
+ redoPlanSchema(),
+ )
if err != nil {
return nil, err
}
@@ -300,6 +207,146 @@ Rules:
return &result, nil
}
+func (s *Service) runStructuredRequest(ctx context.Context, systemPrompt, userPrompt, schemaName string, schema map[string]any) (string, error) {
+ respBytes, err := s.sendStructuredRequest(ctx, systemPrompt, userPrompt, schemaName, schema)
+ if err != nil {
+ return "", err
+ }
+
+ switch s.requestStyle() {
+ case requestStyleChatCompletions:
+ return extractChatCompletionText(respBytes)
+ default:
+ return extractResponsesOutputText(respBytes)
+ }
+}
+
+func (s *Service) sendStructuredRequest(ctx context.Context, systemPrompt, userPrompt, schemaName string, schema map[string]any) ([]byte, error) {
+ body, err := s.buildStructuredRequestBody(systemPrompt, userPrompt, schemaName, schema)
+ if err != nil {
+ return nil, err
+ }
+
+ bodyBytes, err := json.Marshal(body)
+ if err != nil {
+ return nil, fmt.Errorf("marshal AI review request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint, bytes.NewReader(bodyBytes))
+ if err != nil {
+ return nil, fmt.Errorf("build AI review request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ s.applyAuthHeader(req)
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("send AI review request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read AI review response: %w", err)
+ }
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("AI review request failed: status %d body %s", resp.StatusCode, strings.TrimSpace(string(respBytes)))
+ }
+
+ return respBytes, nil
+}
+
+func (s *Service) buildStructuredRequestBody(systemPrompt, userPrompt, schemaName string, schema map[string]any) (map[string]any, error) {
+ switch s.requestStyle() {
+ case requestStyleChatCompletions:
+ body := map[string]any{
+ "model": s.model,
+ "messages": []map[string]any{
+ {
+ "role": "system",
+ "content": systemPrompt,
+ },
+ {
+ "role": "user",
+ "content": userPrompt,
+ },
+ },
+ "temperature": 0,
+ "response_format": map[string]any{
+ "type": "json_schema",
+ "json_schema": map[string]any{
+ "name": schemaName,
+ "strict": true,
+ "schema": schema,
+ },
+ },
+ }
+
+ if s.shouldDisableThinking() {
+ body["chat_template_kwargs"] = map[string]any{
+ "enable_thinking": false,
+ }
+ }
+
+ return body, nil
+ default:
+ return map[string]any{
+ "model": s.model,
+ "input": []map[string]any{
+ {
+ "role": "system",
+ "content": []map[string]any{{
+ "type": "input_text",
+ "text": systemPrompt,
+ }},
+ },
+ {
+ "role": "user",
+ "content": []map[string]any{{
+ "type": "input_text",
+ "text": userPrompt,
+ }},
+ },
+ },
+ "text": map[string]any{
+ "format": map[string]any{
+ "type": "json_schema",
+ "name": schemaName,
+ "strict": true,
+ "schema": schema,
+ },
+ },
+ }, nil
+ }
+}
+
+func (s *Service) requestStyle() requestStyle {
+ endpoint := strings.ToLower(strings.TrimSpace(s.endpoint))
+ if strings.Contains(endpoint, "/chat/completions") {
+ return requestStyleChatCompletions
+ }
+ return requestStyleResponses
+}
+
+func (s *Service) applyAuthHeader(req *http.Request) {
+ if s.isAzureEndpoint() {
+ req.Header.Set("api-key", s.apiKey)
+ return
+ }
+ req.Header.Set("Authorization", "Bearer "+s.apiKey)
+}
+
+func (s *Service) shouldDisableThinking() bool {
+ return s.requestStyle() == requestStyleChatCompletions && !s.isAzureEndpoint()
+}
+
+func (s *Service) isAzureEndpoint() bool {
+ endpoint := strings.ToLower(strings.TrimSpace(s.endpoint))
+ return strings.Contains(endpoint, "cognitiveservices.azure.com") || strings.Contains(endpoint, ".openai.azure.com")
+}
+
func reviewSchema() map[string]any {
return map[string]any{
"type": "object",
@@ -358,7 +405,7 @@ func redoPlanSchema() map[string]any {
}
}
-func extractOutputText(respBytes []byte) (string, error) {
+func extractResponsesOutputText(respBytes []byte) (string, error) {
var direct struct {
OutputText string `json:"output_text"`
}
@@ -380,6 +427,47 @@ func extractOutputText(respBytes []byte) (string, error) {
return "", fmt.Errorf("AI review response did not contain structured output text")
}
+func extractChatCompletionText(respBytes []byte) (string, error) {
+ var payload struct {
+ Choices []struct {
+ Message struct {
+ Content any `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ }
+
+ if err := json.Unmarshal(respBytes, &payload); err != nil {
+ return "", fmt.Errorf("decode AI review chat completion response: %w", err)
+ }
+
+ for _, choice := range payload.Choices {
+ if text := strings.TrimSpace(extractMessageContent(choice.Message.Content)); text != "" {
+ return text, nil
+ }
+ }
+
+ return "", fmt.Errorf("AI review chat completion response did not contain message content")
+}
+
+func extractMessageContent(content any) string {
+ switch typed := content.(type) {
+ case string:
+ return typed
+ case []any:
+ parts := make([]string, 0, len(typed))
+ for _, item := range typed {
+ text := strings.TrimSpace(findOutputText(item))
+ if text == "" {
+ continue
+ }
+ parts = append(parts, text)
+ }
+ return strings.Join(parts, "\n")
+ default:
+ return ""
+ }
+}
+
func findOutputText(value any) string {
switch typed := value.(type) {
case map[string]any:
diff --git a/Backend/internal/aireview/service_test.go b/Backend/internal/aireview/service_test.go
new file mode 100644
index 0000000..7b0fc47
--- /dev/null
+++ b/Backend/internal/aireview/service_test.go
@@ -0,0 +1,177 @@
+package aireview
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+)
+
+func TestBuildStructuredRequestBodyResponsesStyle(t *testing.T) {
+ t.Parallel()
+
+ svc := NewService("https://example.test/v1/responses", "test-key", "test-model")
+ body, err := svc.buildStructuredRequestBody("system prompt", "user prompt", "test_schema", reviewSchema())
+ if err != nil {
+ t.Fatalf("buildStructuredRequestBody returned error: %v", err)
+ }
+
+ if got := body["model"]; got != "test-model" {
+ t.Fatalf("model = %v, want test-model", got)
+ }
+
+ if _, ok := body["input"]; !ok {
+ t.Fatalf("responses body missing input field: %#v", body)
+ }
+
+ if _, ok := body["text"]; !ok {
+ t.Fatalf("responses body missing text field: %#v", body)
+ }
+
+ if _, ok := body["messages"]; ok {
+ t.Fatalf("responses body should not include messages: %#v", body)
+ }
+ if _, ok := body["response_format"]; ok {
+ t.Fatalf("responses body should not include response_format: %#v", body)
+ }
+}
+
+func TestBuildStructuredRequestBodyVLLMChatStyle(t *testing.T) {
+ t.Parallel()
+
+ svc := NewService("http://100.92.130.19:8000/v1/chat/completions", "test-key", "qwen3.6-27b")
+ body, err := svc.buildStructuredRequestBody("system prompt", "user prompt", "assignment_review", reviewSchema())
+ if err != nil {
+ t.Fatalf("buildStructuredRequestBody returned error: %v", err)
+ }
+
+ if got := body["model"]; got != "qwen3.6-27b" {
+ t.Fatalf("model = %v, want qwen3.6-27b", got)
+ }
+
+ if _, ok := body["messages"]; !ok {
+ t.Fatalf("chat body missing messages: %#v", body)
+ }
+
+ responseFormat, ok := body["response_format"].(map[string]any)
+ if !ok {
+ t.Fatalf("response_format missing or wrong type: %#v", body["response_format"])
+ }
+ if got := responseFormat["type"]; got != "json_schema" {
+ t.Fatalf("response_format.type = %v, want json_schema", got)
+ }
+
+ kwargs, ok := body["chat_template_kwargs"].(map[string]any)
+ if !ok {
+ t.Fatalf("expected chat_template_kwargs for vLLM chat style: %#v", body)
+ }
+ if got := kwargs["enable_thinking"]; got != false {
+ t.Fatalf("enable_thinking = %v, want false", got)
+ }
+
+ if _, ok := body["input"]; ok {
+ t.Fatalf("chat body should not include responses input field: %#v", body)
+ }
+}
+
+func TestBuildStructuredRequestBodyAzureChatStyleDoesNotDisableThinking(t *testing.T) {
+ t.Parallel()
+
+ svc := NewService("https://example.openai.azure.com/openai/deployments/review/chat/completions?api-version=2025-01-01-preview", "test-key", "gpt-4.1-mini")
+ body, err := svc.buildStructuredRequestBody("system prompt", "user prompt", "assignment_review", reviewSchema())
+ if err != nil {
+ t.Fatalf("buildStructuredRequestBody returned error: %v", err)
+ }
+
+ if _, ok := body["chat_template_kwargs"]; ok {
+ t.Fatalf("azure chat body should not include chat_template_kwargs: %#v", body)
+ }
+}
+
+func TestApplyAuthHeader(t *testing.T) {
+ t.Parallel()
+
+ t.Run("azure uses api-key", func(t *testing.T) {
+ t.Parallel()
+
+ svc := NewService("https://example.openai.azure.com/openai/deployments/review/chat/completions?api-version=2025-01-01-preview", "azure-key", "gpt-4.1-mini")
+ req, err := http.NewRequest(http.MethodPost, svc.endpoint, nil)
+ if err != nil {
+ t.Fatalf("http.NewRequest returned error: %v", err)
+ }
+
+ svc.applyAuthHeader(req)
+
+ if got := req.Header.Get("api-key"); got != "azure-key" {
+ t.Fatalf("api-key header = %q, want azure-key", got)
+ }
+ if got := req.Header.Get("Authorization"); got != "" {
+ t.Fatalf("Authorization header = %q, want empty", got)
+ }
+ })
+
+ t.Run("non-azure uses bearer auth", func(t *testing.T) {
+ t.Parallel()
+
+ svc := NewService("http://100.92.130.19:8000/v1/chat/completions", "vllm-key", "qwen3.6-27b")
+ req, err := http.NewRequest(http.MethodPost, svc.endpoint, nil)
+ if err != nil {
+ t.Fatalf("http.NewRequest returned error: %v", err)
+ }
+
+ svc.applyAuthHeader(req)
+
+ if got := req.Header.Get("Authorization"); got != "Bearer vllm-key" {
+ t.Fatalf("Authorization header = %q, want %q", got, "Bearer vllm-key")
+ }
+ if got := req.Header.Get("api-key"); got != "" {
+ t.Fatalf("api-key header = %q, want empty", got)
+ }
+ })
+}
+
+func TestExtractChatCompletionText(t *testing.T) {
+ t.Parallel()
+
+ t.Run("string content", func(t *testing.T) {
+ t.Parallel()
+
+ respBytes := []byte(`{"choices":[{"message":{"content":"{\"status\":\"ok\"}"}}]}`)
+ got, err := extractChatCompletionText(respBytes)
+ if err != nil {
+ t.Fatalf("extractChatCompletionText returned error: %v", err)
+ }
+ if got != `{"status":"ok"}` {
+ t.Fatalf("content = %q, want %q", got, `{"status":"ok"}`)
+ }
+ })
+
+ t.Run("array content", func(t *testing.T) {
+ t.Parallel()
+
+ payload := map[string]any{
+ "choices": []any{
+ map[string]any{
+ "message": map[string]any{
+ "content": []any{
+ map[string]any{"type": "text", "text": "{\"status\":"},
+ map[string]any{"type": "text", "text": "\"ok\"}"},
+ },
+ },
+ },
+ },
+ }
+
+ respBytes, err := json.Marshal(payload)
+ if err != nil {
+ t.Fatalf("json.Marshal returned error: %v", err)
+ }
+
+ got, err := extractChatCompletionText(respBytes)
+ if err != nil {
+ t.Fatalf("extractChatCompletionText returned error: %v", err)
+ }
+ if got != "{\"status\":\n\"ok\"}" {
+ t.Fatalf("content = %q, want joined text output", got)
+ }
+ })
+}
diff --git a/Backend/internal/config/config.go b/Backend/internal/config/config.go
index 37e3f30..f66617d 100644
--- a/Backend/internal/config/config.go
+++ b/Backend/internal/config/config.go
@@ -8,28 +8,36 @@ import (
)
type Config struct {
- Port string
- Environment string
- AllowedOrigins string
- DatabaseURL string
- JWTSecret string
- SessionCookie string
- AIReviewEndpoint string
- AIReviewAPIKey string
- AIReviewModel string
+ Port string
+ Environment string
+ AllowedOrigins string
+ DatabaseURL string
+ JWTSecret string
+ SessionCookie string
+ MockDataDir string
+ AdminReseedEnabled bool
+ AdminReseedSecret string
+ ReseedPagePassword string
+ AIReviewEndpoint string
+ AIReviewAPIKey string
+ AIReviewModel string
}
func Load() *Config {
return &Config{
- Port: getEnv("BACKEND_INTERNAL_PORT", "8081"),
- Environment: getEnv("GO_ENV", "development"),
- AllowedOrigins: getEnv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:4321,http://localhost:8080,http://windows-wsl:8080"),
- DatabaseURL: getEnv("DATABASE_URL", "postgres://boostai:boostai_dev_password@localhost:5439/boostai?sslmode=disable"),
- JWTSecret: getEnv("JWT_SECRET", "boostai-dev-jwt-secret-change-me"),
- SessionCookie: getEnv("SESSION_COOKIE_NAME", "boostai_session"),
- AIReviewEndpoint: getEnv("AI_REVIEW_ENDPOINT", ""),
- AIReviewAPIKey: getEnv("AI_REVIEW_API_KEY", ""),
- AIReviewModel: getEnv("AI_REVIEW_MODEL", ""),
+ Port: getEnv("BACKEND_INTERNAL_PORT", "8081"),
+ Environment: getEnv("GO_ENV", "development"),
+ AllowedOrigins: getEnv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:4321,http://localhost:8080,http://windows-wsl:8080"),
+ DatabaseURL: getEnv("DATABASE_URL", "postgres://boostai:boostai_dev_password@localhost:5439/boostai?sslmode=disable"),
+ JWTSecret: getEnv("JWT_SECRET", "boostai-dev-jwt-secret-change-me"),
+ SessionCookie: getEnv("SESSION_COOKIE_NAME", "boostai_session"),
+ MockDataDir: getEnv("MOCK_DATA_DIR", "../Mock-Data"),
+ AdminReseedEnabled: getEnvBool("ENABLE_ADMIN_RESEED", false),
+ AdminReseedSecret: getEnv("ADMIN_RESEED_SECRET", ""),
+ ReseedPagePassword: getEnv("RESEED_PAGE_PASSWORD", "1588"),
+ AIReviewEndpoint: getEnv("AI_REVIEW_ENDPOINT", ""),
+ AIReviewAPIKey: getEnv("AI_REVIEW_API_KEY", ""),
+ AIReviewModel: getEnv("AI_REVIEW_MODEL", ""),
}
}
@@ -48,3 +56,19 @@ func getEnv(key, fallback string) string {
return fallback
}
+
+func getEnvBool(key string, fallback bool) bool {
+ value, exists := os.LookupEnv(key)
+ if !exists {
+ return fallback
+ }
+
+ switch strings.TrimSpace(strings.ToLower(value)) {
+ case "1", "true", "yes", "on":
+ return true
+ case "0", "false", "no", "off":
+ return false
+ default:
+ return fallback
+ }
+}
diff --git a/Backend/internal/handlers/api/admin/handler.go b/Backend/internal/handlers/api/admin/handler.go
new file mode 100644
index 0000000..ee88408
--- /dev/null
+++ b/Backend/internal/handlers/api/admin/handler.go
@@ -0,0 +1,100 @@
+package admin
+
+import (
+ "context"
+ "crypto/subtle"
+ "log"
+ "strings"
+ "time"
+
+ "boostai-backend/internal/config"
+ "boostai-backend/internal/database"
+ "boostai-backend/internal/http/respond"
+ "boostai-backend/internal/seeddata"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+const (
+ ReseedHeaderName = "X-Admin-Reseed-Secret"
+ ReseedConfirm = "RESEED"
+)
+
+type Runner interface {
+ Run(ctx context.Context, mockDataDir string) (seeddata.Summary, error)
+}
+
+type Handler struct {
+ cfg *config.Config
+ runner runFunc
+}
+
+type runFunc func(timeoutCtx context.Context, mockDataDir string) (seeddata.Summary, error)
+
+type reseedRequest struct {
+ Confirm string `json:"confirm"`
+}
+
+type reseedResponse struct {
+ OK bool `json:"ok"`
+ Environment string `json:"environment"`
+ TriggeredBy string `json:"triggered_by,omitempty"`
+ TriggeredAt time.Time `json:"triggered_at"`
+ Summary seeddata.Summary `json:"summary"`
+}
+
+func NewHandler(db *database.DB, cfg *config.Config) *Handler {
+ return &Handler{
+ cfg: cfg,
+ runner: func(timeoutCtx context.Context, mockDataDir string) (seeddata.Summary, error) {
+ return seeddata.Run(timeoutCtx, db, mockDataDir)
+ },
+ }
+}
+
+func (h *Handler) ReseedDatabase(c *fiber.Ctx) error {
+ if !h.cfg.AdminReseedEnabled {
+ return respond.Error(c, fiber.StatusNotFound, "not_found", "The requested endpoint does not exist")
+ }
+
+ if strings.TrimSpace(h.cfg.AdminReseedSecret) == "" {
+ return respond.Error(c, fiber.StatusServiceUnavailable, "admin_reseed_unavailable", "Admin reseed is not configured")
+ }
+
+ providedSecret := strings.TrimSpace(c.Get(ReseedHeaderName))
+ if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(h.cfg.AdminReseedSecret)) != 1 {
+ return respond.Error(c, fiber.StatusForbidden, "forbidden", "Valid reseed secret required")
+ }
+
+ var req reseedRequest
+ if err := c.BodyParser(&req); err != nil {
+ return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Invalid request body")
+ }
+ if strings.TrimSpace(req.Confirm) != ReseedConfirm {
+ return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "confirm must equal RESEED")
+ }
+
+ triggeredBy, _ := c.Locals("auth.email").(string)
+ userID, _ := c.Locals("auth.user_id").(int64)
+ startedAt := time.Now().UTC()
+ log.Printf("admin reseed requested environment=%s user_id=%d email=%s ip=%s", h.cfg.Environment, userID, triggeredBy, c.IP())
+
+ timeoutCtx, cancelTimeout := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancelTimeout()
+
+ summary, err := h.runner(timeoutCtx, h.cfg.MockDataDir)
+ if err != nil {
+ log.Printf("admin reseed failed environment=%s user_id=%d email=%s err=%v", h.cfg.Environment, userID, triggeredBy, err)
+ return respond.Error(c, fiber.StatusInternalServerError, "admin_reseed_failed", err.Error())
+ }
+
+ log.Printf("admin reseed completed environment=%s user_id=%d email=%s users=%d assignments=%d student_answers=%d", h.cfg.Environment, userID, triggeredBy, summary.Users, summary.Assignments, summary.StudentAnswers)
+
+ return c.JSON(reseedResponse{
+ OK: true,
+ Environment: h.cfg.Environment,
+ TriggeredBy: triggeredBy,
+ TriggeredAt: startedAt,
+ Summary: summary,
+ })
+}
diff --git a/Backend/internal/handlers/api/admin/handler_test.go b/Backend/internal/handlers/api/admin/handler_test.go
new file mode 100644
index 0000000..1923507
--- /dev/null
+++ b/Backend/internal/handlers/api/admin/handler_test.go
@@ -0,0 +1,108 @@
+package admin
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "boostai-backend/internal/config"
+ "boostai-backend/internal/seeddata"
+ "boostai-backend/internal/sqlc"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func TestReseedDatabaseRequiresEnableFlag(t *testing.T) {
+ t.Parallel()
+ h := &Handler{cfg: &config.Config{Environment: "production"}}
+ status := performReseedRequest(t, h, map[string]any{"confirm": "RESEED"}, "secret", true)
+ if status != fiber.StatusNotFound {
+ t.Fatalf("expected 404, got %d", status)
+ }
+}
+
+func TestReseedDatabaseRequiresSecret(t *testing.T) {
+ t.Parallel()
+ h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
+ return seeddata.Summary{}, nil
+ })
+ status := performReseedRequest(t, h, map[string]any{"confirm": "RESEED"}, "wrong", true)
+ if status != fiber.StatusForbidden {
+ t.Fatalf("expected 403, got %d", status)
+ }
+}
+
+func TestReseedDatabaseRequiresConfirm(t *testing.T) {
+ t.Parallel()
+ h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
+ return seeddata.Summary{}, nil
+ })
+ status := performReseedRequest(t, h, map[string]any{"confirm": "nope"}, "secret", true)
+ if status != fiber.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", status)
+ }
+}
+
+func TestReseedDatabaseReturnsSummary(t *testing.T) {
+ t.Parallel()
+ h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
+ if mockDataDir != "/app/Mock-Data" {
+ t.Fatalf("expected mock data dir /app/Mock-Data, got %q", mockDataDir)
+ }
+ return seeddata.Summary{Users: 13, Assignments: 8, StudentAnswers: 588}, nil
+ })
+ status := performReseedRequest(t, h, map[string]any{"confirm": "RESEED"}, "secret", true)
+ if status != fiber.StatusOK {
+ t.Fatalf("expected 200, got %d", status)
+ }
+}
+
+func TestReseedDatabaseSurfacesRunnerError(t *testing.T) {
+ t.Parallel()
+ h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
+ return seeddata.Summary{}, errors.New("boom")
+ })
+ status := performReseedRequest(t, h, map[string]any{"confirm": "RESEED"}, "secret", true)
+ if status != fiber.StatusInternalServerError {
+ t.Fatalf("expected 500, got %d", status)
+ }
+}
+
+func newTestHandler(fn func(context.Context, string) (seeddata.Summary, error)) *Handler {
+ return &Handler{
+ cfg: &config.Config{Environment: "production", AdminReseedEnabled: true, AdminReseedSecret: "secret", MockDataDir: "/app/Mock-Data"},
+ runner: fn,
+ }
+}
+
+func performReseedRequest(t *testing.T, handler *Handler, payload map[string]any, secret string, authenticated bool) int {
+ t.Helper()
+ app := fiber.New()
+ app.Post("/internal/admin/reseed", func(c *fiber.Ctx) error {
+ if authenticated {
+ c.Locals("auth.user_id", int64(42))
+ c.Locals("auth.role", sqlc.UserRoleTeacher)
+ c.Locals("auth.email", "teacher@example.com")
+ }
+ return handler.ReseedDatabase(c)
+ })
+ bodyBytes, err := json.Marshal(payload)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ req := httptest.NewRequest(http.MethodPost, "/internal/admin/reseed", bytes.NewReader(bodyBytes))
+ req.Header.Set("Content-Type", "application/json")
+ if secret != "" {
+ req.Header.Set(ReseedHeaderName, secret)
+ }
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+ defer resp.Body.Close()
+ return resp.StatusCode
+}
diff --git a/Backend/internal/handlers/api/admin/routes.go b/Backend/internal/handlers/api/admin/routes.go
new file mode 100644
index 0000000..a707df3
--- /dev/null
+++ b/Backend/internal/handlers/api/admin/routes.go
@@ -0,0 +1,11 @@
+package admin
+
+import (
+ authmw "boostai-backend/internal/middleware"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) {
+ app.Post("/internal/admin/reseed", auth.RequireTeacher(), h.ReseedDatabase)
+}
diff --git a/Backend/internal/handlers/api/handler.go b/Backend/internal/handlers/api/handler.go
index d8a7d92..c0944e6 100644
--- a/Backend/internal/handlers/api/handler.go
+++ b/Backend/internal/handlers/api/handler.go
@@ -5,6 +5,7 @@ import (
"boostai-backend/internal/assignmentgen"
"boostai-backend/internal/config"
"boostai-backend/internal/database"
+ adminhandler "boostai-backend/internal/handlers/api/admin"
answershandler "boostai-backend/internal/handlers/api/answers"
assignmentshandler "boostai-backend/internal/handlers/api/assignments"
classroomshandler "boostai-backend/internal/handlers/api/classrooms"
@@ -22,6 +23,7 @@ type Handler struct {
questions *questionshandler.Handler
assignments *assignmentshandler.Handler
answers *answershandler.Handler
+ admin *adminhandler.Handler
}
func NewHandler(db *database.DB, cfg *config.Config) *Handler {
@@ -37,5 +39,6 @@ func NewHandler(db *database.DB, cfg *config.Config) *Handler {
questions: questionshandler.NewHandler(queries, questionGenerator),
assignments: assignmentshandler.NewHandler(queries, aiReviewService, assignmentGenerator),
answers: answershandler.NewHandler(queries, aiReviewService),
+ admin: adminhandler.NewHandler(db, cfg),
}
}
diff --git a/Backend/internal/handlers/api/routes.go b/Backend/internal/handlers/api/routes.go
index 6293382..3d59d2f 100644
--- a/Backend/internal/handlers/api/routes.go
+++ b/Backend/internal/handlers/api/routes.go
@@ -1,6 +1,7 @@
package api
import (
+ "boostai-backend/internal/handlers/api/admin"
"boostai-backend/internal/handlers/api/answers"
"boostai-backend/internal/handlers/api/assignments"
"boostai-backend/internal/handlers/api/classrooms"
@@ -19,4 +20,5 @@ func (h *Handler) Register(app fiber.Router, auth *authmw.AuthMiddleware) {
questions.RegisterRoutes(app, auth, h.questions)
assignments.RegisterRoutes(app, auth, h.assignments)
answers.RegisterRoutes(app, auth, h.answers)
+ admin.RegisterRoutes(app, auth, h.admin)
}
diff --git a/Backend/internal/handlers/web/reseed/reseed.go b/Backend/internal/handlers/web/reseed/reseed.go
new file mode 100644
index 0000000..212068e
--- /dev/null
+++ b/Backend/internal/handlers/web/reseed/reseed.go
@@ -0,0 +1,338 @@
+package reseed
+
+import (
+ "context"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/hex"
+ "fmt"
+ "html"
+ "log"
+ "strings"
+ "time"
+
+ "boostai-backend/internal/config"
+ "boostai-backend/internal/database"
+ "boostai-backend/internal/seeddata"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+const reseedCookieName = "boostai_reseed_auth"
+
+type runFunc func(ctx context.Context, mockDataDir string) (seeddata.Summary, error)
+
+type Handler struct {
+ cfg *config.Config
+ runner runFunc
+}
+
+type pageData struct {
+ Authorized bool
+ Environment string
+ MockDataDir string
+ Error string
+ Success string
+ Summary *seeddata.Summary
+}
+
+func NewHandler(db *database.DB, cfg *config.Config) *Handler {
+ return &Handler{
+ cfg: cfg,
+ runner: func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
+ return seeddata.Run(ctx, db, mockDataDir)
+ },
+ }
+}
+
+func (h *Handler) Page(c *fiber.Ctx) error {
+ if !h.cfg.AdminReseedEnabled {
+ return fiber.ErrNotFound
+ }
+
+ return h.renderPage(c, pageData{
+ Authorized: h.isAuthorized(c),
+ Environment: h.cfg.Environment,
+ MockDataDir: h.cfg.MockDataDir,
+ })
+}
+
+func (h *Handler) Login(c *fiber.Ctx) error {
+ if !h.cfg.AdminReseedEnabled {
+ return fiber.ErrNotFound
+ }
+
+ password := strings.TrimSpace(c.FormValue("password"))
+ if subtle.ConstantTimeCompare([]byte(password), []byte(h.cfg.ReseedPagePassword)) != 1 {
+ return h.renderPage(c.Status(fiber.StatusUnauthorized), pageData{
+ Authorized: false,
+ Environment: h.cfg.Environment,
+ MockDataDir: h.cfg.MockDataDir,
+ Error: "Invalid password",
+ })
+ }
+
+ h.setAuthCookie(c)
+ return c.Redirect("/reseed", fiber.StatusSeeOther)
+}
+
+func (h *Handler) Run(c *fiber.Ctx) error {
+ if !h.cfg.AdminReseedEnabled {
+ return fiber.ErrNotFound
+ }
+
+ if !h.isAuthorized(c) {
+ return h.renderPage(c.Status(fiber.StatusUnauthorized), pageData{
+ Authorized: false,
+ Environment: h.cfg.Environment,
+ MockDataDir: h.cfg.MockDataDir,
+ Error: "Please unlock the reseed page first",
+ })
+ }
+
+ startedAt := time.Now().UTC()
+ log.Printf("browser reseed requested environment=%s ip=%s", h.cfg.Environment, c.IP())
+
+ timeoutCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ summary, err := h.runner(timeoutCtx, h.cfg.MockDataDir)
+ if err != nil {
+ log.Printf("browser reseed failed environment=%s ip=%s err=%v", h.cfg.Environment, c.IP(), err)
+ return h.renderPage(c.Status(fiber.StatusInternalServerError), pageData{
+ Authorized: true,
+ Environment: h.cfg.Environment,
+ MockDataDir: h.cfg.MockDataDir,
+ Error: err.Error(),
+ })
+ }
+
+ log.Printf("browser reseed completed environment=%s ip=%s users=%d assignments=%d student_answers=%d", h.cfg.Environment, c.IP(), summary.Users, summary.Assignments, summary.StudentAnswers)
+
+ return h.renderPage(c, pageData{
+ Authorized: true,
+ Environment: h.cfg.Environment,
+ MockDataDir: h.cfg.MockDataDir,
+ Success: fmt.Sprintf("Reseed completed at %s UTC", startedAt.Format("2006-01-02 15:04:05")),
+ Summary: &summary,
+ })
+}
+
+func (h *Handler) Logout(c *fiber.Ctx) error {
+ if !h.cfg.AdminReseedEnabled {
+ return fiber.ErrNotFound
+ }
+
+ h.clearAuthCookie(c)
+ return c.Redirect("/reseed", fiber.StatusSeeOther)
+}
+
+func (h *Handler) isAuthorized(c *fiber.Ctx) bool {
+ provided := strings.TrimSpace(c.Cookies(reseedCookieName))
+ expected := h.authCookieValue()
+ if provided == "" || expected == "" {
+ return false
+ }
+
+ return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
+}
+
+func (h *Handler) setAuthCookie(c *fiber.Ctx) {
+ c.Cookie(&fiber.Cookie{
+ Name: reseedCookieName,
+ Value: h.authCookieValue(),
+ HTTPOnly: true,
+ Secure: h.cfg.IsProduction(),
+ SameSite: fiber.CookieSameSiteLaxMode,
+ Path: "/",
+ Expires: time.Now().UTC().Add(12 * time.Hour),
+ })
+}
+
+func (h *Handler) clearAuthCookie(c *fiber.Ctx) {
+ c.Cookie(&fiber.Cookie{
+ Name: reseedCookieName,
+ Value: "",
+ HTTPOnly: true,
+ Secure: h.cfg.IsProduction(),
+ SameSite: fiber.CookieSameSiteLaxMode,
+ Path: "/",
+ Expires: time.Unix(0, 0),
+ MaxAge: -1,
+ })
+}
+
+func (h *Handler) authCookieValue() string {
+ sum := sha256.Sum256([]byte(h.cfg.JWTSecret + "|" + h.cfg.ReseedPagePassword))
+ return hex.EncodeToString(sum[:])
+}
+
+func (h *Handler) renderPage(c *fiber.Ctx, data pageData) error {
+ content := reseedPageHTML(data)
+ c.Type("html", "utf-8")
+ return c.SendString(content)
+}
+
+func reseedPageHTML(data pageData) string {
+ var statusHTML strings.Builder
+ if data.Error != "" {
+ statusHTML.WriteString(`
` + html.EscapeString(data.Error) + `
`)
+ }
+ if data.Success != "" {
+ statusHTML.WriteString(`` + html.EscapeString(data.Success) + `
`)
+ }
+ if data.Summary != nil {
+ statusHTML.WriteString(``)
+ statusHTML.WriteString(html.EscapeString(fmt.Sprintf(
+ "users: %d\nclassrooms: %d\nquestions: %d\ntags: %d\nassignments: %d\nassignment_links: %d\nstudent_answers: %d\nmock_data_dir: %s",
+ data.Summary.Users,
+ data.Summary.Classrooms,
+ data.Summary.Questions,
+ data.Summary.Tags,
+ data.Summary.Assignments,
+ data.Summary.AssignmentLinks,
+ data.Summary.StudentAnswers,
+ data.Summary.MockDataDir,
+ )))
+ statusHTML.WriteString(` `)
+ }
+
+ var body strings.Builder
+ if data.Authorized {
+ body.WriteString(`
+
+
Reseed database
+
This will clear seeded app data and repopulate it from Mock-Data.
+
+
+
+ `)
+ } else {
+ body.WriteString(`
+
+
Unlock reseed
+
+
+ `)
+ }
+
+ return fmt.Sprintf(`
+
+
+
+
+ BoostAI Reseed
+
+
+
+
+
+
BoostAI reseed
+
+ Environment %s
+ Mock data path %s
+ Mode Browser-protected destructive reseed
+
+ %s
+
+ %s
+
+
+`,
+ html.EscapeString(data.Environment),
+ html.EscapeString(data.MockDataDir),
+ statusHTML.String(),
+ body.String(),
+ )
+}
diff --git a/Backend/internal/handlers/web/reseed/reseed_test.go b/Backend/internal/handlers/web/reseed/reseed_test.go
new file mode 100644
index 0000000..ba191b5
--- /dev/null
+++ b/Backend/internal/handlers/web/reseed/reseed_test.go
@@ -0,0 +1,137 @@
+package reseed
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "boostai-backend/internal/config"
+ "boostai-backend/internal/seeddata"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func TestPageRequiresEnableFlag(t *testing.T) {
+ t.Parallel()
+ h := &Handler{cfg: &config.Config{Environment: "production"}}
+ status, _ := performRequest(t, h, http.MethodGet, "/reseed", "", "")
+ if status != fiber.StatusNotFound {
+ t.Fatalf("expected 404, got %d", status)
+ }
+}
+
+func TestLoginSetsCookieAndRedirects(t *testing.T) {
+ t.Parallel()
+ h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
+ return seeddata.Summary{}, nil
+ })
+ status, resp := performRequest(t, h, http.MethodPost, "/reseed/login", "password=1588", "")
+ if status != fiber.StatusSeeOther {
+ t.Fatalf("expected 303, got %d", status)
+ }
+ if location := resp.Header.Get("Location"); location != "/reseed" {
+ t.Fatalf("expected redirect to /reseed, got %q", location)
+ }
+ if cookie := resp.Header.Get("Set-Cookie"); !strings.Contains(cookie, reseedCookieName+"=") {
+ t.Fatalf("expected auth cookie, got %q", cookie)
+ }
+}
+
+func TestRunRequiresAuthCookie(t *testing.T) {
+ t.Parallel()
+ h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
+ return seeddata.Summary{}, nil
+ })
+ status, body := performRequestBody(t, h, http.MethodPost, "/reseed/run", "", "")
+ if status != fiber.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", status)
+ }
+ if !strings.Contains(body, "Please unlock the reseed page first") {
+ t.Fatalf("expected unlock message, got %q", body)
+ }
+}
+
+func TestRunExecutesReseed(t *testing.T) {
+ t.Parallel()
+ h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
+ if mockDataDir != "/app/Mock-Data" {
+ t.Fatalf("expected mock data dir /app/Mock-Data, got %q", mockDataDir)
+ }
+ return seeddata.Summary{Users: 13, Assignments: 8, StudentAnswers: 588, MockDataDir: mockDataDir}, nil
+ })
+ status, body := performRequestBody(t, h, http.MethodPost, "/reseed/run", "", reseedCookieName+"="+h.authCookieValue())
+ if status != fiber.StatusOK {
+ t.Fatalf("expected 200, got %d", status)
+ }
+ if !strings.Contains(body, "Reseed completed") || !strings.Contains(body, "student_answers: 588") {
+ t.Fatalf("expected success summary, got %q", body)
+ }
+}
+
+func TestRunSurfacesRunnerError(t *testing.T) {
+ t.Parallel()
+ h := newTestHandler(func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
+ return seeddata.Summary{}, errors.New("boom")
+ })
+ status, body := performRequestBody(t, h, http.MethodPost, "/reseed/run", "", reseedCookieName+"="+h.authCookieValue())
+ if status != fiber.StatusInternalServerError {
+ t.Fatalf("expected 500, got %d", status)
+ }
+ if !strings.Contains(body, "boom") {
+ t.Fatalf("expected error body, got %q", body)
+ }
+}
+
+func newTestHandler(fn func(context.Context, string) (seeddata.Summary, error)) *Handler {
+ return &Handler{
+ cfg: &config.Config{
+ Environment: "production",
+ AdminReseedEnabled: true,
+ MockDataDir: "/app/Mock-Data",
+ JWTSecret: "jwt-secret",
+ ReseedPagePassword: "1588",
+ },
+ runner: fn,
+ }
+}
+
+func performRequest(t *testing.T, handler *Handler, method, path, formBody, cookieHeader string) (int, *http.Response) {
+ t.Helper()
+ app := testApp(handler)
+ req := httptest.NewRequest(method, path, strings.NewReader(formBody))
+ if formBody != "" {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ if cookieHeader != "" {
+ req.Header.Set("Cookie", cookieHeader)
+ }
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("app.Test: %v", err)
+ }
+ return resp.StatusCode, resp
+}
+
+func performRequestBody(t *testing.T, handler *Handler, method, path, formBody, cookieHeader string) (int, string) {
+ t.Helper()
+ status, resp := performRequest(t, handler, method, path, formBody, cookieHeader)
+ defer resp.Body.Close()
+ bodyBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("ReadAll: %v", err)
+ }
+ return status, string(bodyBytes)
+}
+
+func testApp(handler *Handler) *fiber.App {
+ app := fiber.New()
+ app.Get("/reseed", handler.Page)
+ app.Post("/reseed/login", handler.Login)
+ app.Post("/reseed/run", handler.Run)
+ app.Post("/reseed/logout", handler.Logout)
+ return app
+}
diff --git a/Backend/internal/router/web.go b/Backend/internal/router/web.go
index 7f7b7b7..8a9422b 100644
--- a/Backend/internal/router/web.go
+++ b/Backend/internal/router/web.go
@@ -5,6 +5,7 @@ import (
"boostai-backend/internal/database"
webAuth "boostai-backend/internal/handlers/web/auth"
"boostai-backend/internal/handlers/web/health"
+ webReseed "boostai-backend/internal/handlers/web/reseed"
"boostai-backend/internal/handlers/web/root"
authmw "boostai-backend/internal/middleware"
@@ -19,9 +20,14 @@ func registerWebRoutes(app *fiber.App, cfg *config.Config, db *database.DB, auth
rootHandler := root.NewHandler()
healthHandler := health.NewHandler(cfg.Environment, db)
authHandler := webAuth.NewHandler(cfg, db, authMiddleware)
+ reseedHandler := webReseed.NewHandler(db, cfg)
app.Get("/", rootHandler.Index)
app.Get("/health", healthHandler.Check)
+ app.Get("/reseed", reseedHandler.Page)
+ app.Post("/reseed/login", reseedHandler.Login)
+ app.Post("/reseed/run", reseedHandler.Run)
+ app.Post("/reseed/logout", reseedHandler.Logout)
authGroup := app.Group("/auth")
authGroup.Post("/register", authHandler.RegisterUser)
diff --git a/Backend/internal/seeddata/seed.go b/Backend/internal/seeddata/seed.go
new file mode 100644
index 0000000..f2857a3
--- /dev/null
+++ b/Backend/internal/seeddata/seed.go
@@ -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
+}
diff --git a/Caddyfile.prod-a b/Caddyfile.prod-a
index 3cb9e68..6409d49 100644
--- a/Caddyfile.prod-a
+++ b/Caddyfile.prod-a
@@ -7,6 +7,10 @@
reverse_proxy {$BACKEND_UPSTREAM}
}
+ handle /reseed* {
+ reverse_proxy {$BACKEND_UPSTREAM}
+ }
+
handle {
reverse_proxy {$FRONTEND_UPSTREAM}
}
diff --git a/Caddyfile.prod-b b/Caddyfile.prod-b
new file mode 100644
index 0000000..4294e32
--- /dev/null
+++ b/Caddyfile.prod-b
@@ -0,0 +1,23 @@
+{$BASE_DOMAIN} {
+ handle /health {
+ respond "ok" 200
+ }
+
+ handle_path /api/* {
+ reverse_proxy {$BACKEND_UPSTREAM}
+ }
+
+ handle /reseed* {
+ reverse_proxy {$BACKEND_UPSTREAM}
+ }
+
+ handle {
+ reverse_proxy {$FRONTEND_UPSTREAM}
+ }
+}
+
+boost.finetune.moku.build {
+ handle {
+ reverse_proxy {$FINE_TUNE_UPSTREAM}
+ }
+}
diff --git a/FineTune/.env.example b/FineTune/.env.example
new file mode 100644
index 0000000..48e3c99
--- /dev/null
+++ b/FineTune/.env.example
@@ -0,0 +1,6 @@
+FINE_TUNE_PORT=4310
+FINE_TUNE_AI_ENDPOINT=http://moku-a100:8000/v1/chat/completions
+FINE_TUNE_AI_API_KEY=WnJN_dq5BC3KCb0dJqHUtAWWqjBHhMJxLhDQIwCug5w
+FINE_TUNE_AI_MODEL=qwen3.6-27b
+FINE_TUNE_BACKEND_URL=https://boost.ai.moku.build
+FINE_TUNE_BACKEND_TOKEN=replace-with-teacher-session-jwt
diff --git a/FineTune/README.md b/FineTune/README.md
new file mode 100644
index 0000000..6086875
--- /dev/null
+++ b/FineTune/README.md
@@ -0,0 +1,81 @@
+# FineTune Helper
+
+Local helper app for creating assignment-level fine-tuning records.
+
+## Start
+
+1. Copy `.env.example` to `.env`
+2. Fill in the hosted AI endpoint, key, and model
+3. Fill in the backend generator URL and teacher token
+4. Run from repo root:
+
+```bash
+make fine-tune
+```
+
+Then open:
+
+```text
+http://localhost:4310
+```
+
+## What it does now
+
+- generates a full assignment from the real backend `POST /api/questions/generate` endpoint using:
+ - `topic`
+ - `difficulty`
+ - `count`
+- stores the assignment in the same shape the real review flow expects:
+ - assignment metadata
+ - question list
+ - student submission per question
+ - teacher review per question
+ - assignment summary
+ - recommended next step
+- can ask the hosted model to draft:
+ - the full student submission for all questions
+ - the full teacher review package for all questions plus assignment summary
+- shows:
+ - a canonical saved record preview
+ - a chat-style fine-tune JSON preview
+- saves reviewed examples locally in your browser
+- lets you load, update, and delete saved examples
+- exports either:
+ - `dataset.jsonl`
+ - `train.jsonl` + `val.jsonl`
+
+## Saved record shape
+
+The helper now targets one saved row per assignment:
+
+```text
+assignment-review-v1
+ assignment
+ studentSubmission
+ teacherReview.questions[]
+ teacherReview.assignmentSummary
+ teacherReview.recommendedNextStep
+```
+
+This matches the real app's mixed-granularity review flow:
+
+```text
+one assignment review call
+ -> question-level labels for every question
+ -> one assignment-level summary
+```
+
+## Backend generator auth
+
+Set:
+
+- `FINE_TUNE_BACKEND_URL` to your BoostAI base URL, for example `https://boost.ai.moku.build`
+- `FINE_TUNE_BACKEND_TOKEN` to a valid teacher JWT/session token value
+
+The helper forwards that token as:
+
+```text
+Authorization: Bearer
+```
+
+so it can call the protected backend generator endpoint from the separate local helper app.
diff --git a/FineTune/docker-compose.yaml b/FineTune/docker-compose.yaml
new file mode 100644
index 0000000..5661465
--- /dev/null
+++ b/FineTune/docker-compose.yaml
@@ -0,0 +1,21 @@
+services:
+ fine-tune-helper:
+ image: node:22-alpine
+ working_dir: /app
+ command: sh -lc "npm ci && npm run dev"
+ ports:
+ - "127.0.0.1:${FINE_TUNE_PORT:-4310}:4310"
+ environment:
+ PORT: 4310
+ HOST: 0.0.0.0
+ FINE_TUNE_AI_ENDPOINT: ${FINE_TUNE_AI_ENDPOINT:-}
+ FINE_TUNE_AI_API_KEY: ${FINE_TUNE_AI_API_KEY:-}
+ FINE_TUNE_AI_MODEL: ${FINE_TUNE_AI_MODEL:-}
+ FINE_TUNE_BACKEND_URL: ${FINE_TUNE_BACKEND_URL:-}
+ FINE_TUNE_BACKEND_TOKEN: ${FINE_TUNE_BACKEND_TOKEN:-}
+ volumes:
+ - ./:/app
+ - fine_tune_node_modules:/app/node_modules
+
+volumes:
+ fine_tune_node_modules:
diff --git a/FineTune/package-lock.json b/FineTune/package-lock.json
new file mode 100644
index 0000000..c35d362
--- /dev/null
+++ b/FineTune/package-lock.json
@@ -0,0 +1,850 @@
+{
+ "name": "boostai-fine-tune-helper",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "boostai-fine-tune-helper",
+ "dependencies": {
+ "express": "^4.21.2",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/FineTune/package.json b/FineTune/package.json
new file mode 100644
index 0000000..c7bd9aa
--- /dev/null
+++ b/FineTune/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "boostai-fine-tune-helper",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "node server.mjs",
+ "start": "node server.mjs"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "express": "^4.21.2",
+ "ws": "^8.21.0"
+ }
+}
diff --git a/FineTune/public/app.js b/FineTune/public/app.js
new file mode 100644
index 0000000..a802ae7
--- /dev/null
+++ b/FineTune/public/app.js
@@ -0,0 +1,1844 @@
+const STORAGE_KEY = "boostai-finetune-helper-state-v3";
+const DATASET_STORAGE_KEY = "boostai-finetune-helper-dataset-v2";
+const DRAFT_STORAGE_KEY = "boostai-finetune-helper-drafts-v1";
+const ACTIVE_DRAFT_STORAGE_KEY = "boostai-finetune-helper-active-draft-v1";
+const TRAIN_VAL_SPLIT = 0.2;
+
+const fixedFieldIds = [
+ "assignmentId",
+ "studentId",
+ "assignmentTitle",
+ "instructions",
+ "passThreshold",
+ "topic",
+ "difficulty",
+ "questionCount",
+ "generatorSeed",
+ "assignmentSummary",
+ "recommendedNextStep",
+];
+
+const fixedElements = Object.fromEntries(fixedFieldIds.map((id) => [id, document.getElementById(id)]));
+
+const questionsContainer = document.getElementById("questions-container");
+const questionSummary = document.getElementById("question-summary");
+const aiConfigStatus = document.getElementById("ai-config-status");
+const questionGeneratorStatus = document.getElementById("question-generator-status");
+const collabStatus = document.getElementById("collab-status");
+const presenceStatus = document.getElementById("presence-status");
+const saveStatus = document.getElementById("save-status");
+const recordPreview = document.getElementById("recordPreview");
+const trainingPreview = document.getElementById("trainingPreview");
+const datasetStatus = document.getElementById("datasetStatus");
+const datasetEmpty = document.getElementById("datasetEmpty");
+const datasetList = document.getElementById("datasetList");
+const draftStatus = document.getElementById("draft-status");
+const draftSearch = document.getElementById("draft-search");
+const draftSort = document.getElementById("draft-sort");
+const draftList = document.getElementById("draft-list");
+const toast = document.getElementById("toast");
+
+const generateAssignmentButton = document.getElementById("generate-assignment");
+const addQuestionButton = document.getElementById("add-question");
+const generateStudentsButton = document.getElementById("generate-students");
+const generateTeacherButton = document.getElementById("generate-teacher");
+const saveExampleButton = document.getElementById("save-example");
+const exportDatasetButton = document.getElementById("export-dataset");
+const exportSplitButton = document.getElementById("export-split");
+const filterAllButton = document.getElementById("filter-all");
+const filterAttentionButton = document.getElementById("filter-attention");
+const filterUnlabeledButton = document.getElementById("filter-unlabeled");
+const expandAllQuestionsButton = document.getElementById("expand-all-questions");
+const collapseAllQuestionsButton = document.getElementById("collapse-all-questions");
+const newDraftButton = document.getElementById("new-draft");
+const duplicateDraftButton = document.getElementById("duplicate-draft");
+const renameDraftButton = document.getElementById("rename-draft");
+const deleteDraftButton = document.getElementById("delete-draft");
+const DEFAULT_WORKSPACE_ID = "shared";
+
+let state = createSampleState();
+let savedExamples = [];
+let activeExampleId = null;
+let drafts = [];
+let activeDraftId = null;
+let toastTimeout = null;
+let uiState = {
+ questionFilter: "all",
+ collapsedQuestions: new Set(),
+ draftFilter: "",
+ draftSort: "updated",
+};
+let collaboration = {
+ clientId: null,
+ ws: null,
+ workspaceId: resolveWorkspaceId(),
+ connected: false,
+ ready: false,
+ presenceCount: 1,
+ serverVersion: 0,
+ reconnectTimer: null,
+ publishTimer: null,
+ applyingRemoteState: false,
+ lastSentStateHash: "",
+};
+
+initialize();
+
+async function initialize() {
+ bindEvents();
+ hydrateDrafts();
+ hydrateDataset();
+ renderAll();
+ renderCollaborationStatus();
+ connectCollaboration();
+ await loadConfig();
+}
+
+function bindEvents() {
+ for (const [field, element] of Object.entries(fixedElements)) {
+ element.addEventListener("input", () => {
+ state[field] = element.value;
+ persistState();
+ renderPreviews();
+ if (field === "questionCount") renderQuestionSummary();
+ });
+ }
+
+ draftSearch.addEventListener("input", () => {
+ uiState.draftFilter = draftSearch.value.trim().toLowerCase();
+ renderDrafts();
+ });
+
+ draftSort.addEventListener("change", () => {
+ uiState.draftSort = draftSort.value;
+ renderDrafts();
+ });
+
+ document.getElementById("load-sample").addEventListener("click", () => {
+ activeExampleId = null;
+ state = createSampleState();
+ renderAll();
+ showToast("Loaded sample assignment.", "success");
+ });
+
+ document.getElementById("clear-form").addEventListener("click", () => {
+ activeExampleId = null;
+ state = createEmptyState();
+ renderAll();
+ showToast("Cleared assignment workspace.", "success");
+ });
+
+ newDraftButton.addEventListener("click", () => {
+ createDraftFromCurrentState({
+ state: createEmptyState(),
+ label: "New assignment draft",
+ successMessage: "Created a fresh assignment draft.",
+ });
+ });
+
+ duplicateDraftButton.addEventListener("click", () => {
+ createDraftFromCurrentState({
+ state,
+ label: `${getActiveDraftTitle()} copy`,
+ successMessage: "Duplicated the current assignment draft.",
+ });
+ });
+
+ renameDraftButton.addEventListener("click", () => {
+ const current = getActiveDraft();
+ if (!current) return;
+ const nextLabel = window.prompt("Rename the current assignment draft", current.label || getDraftTitle(current));
+ if (nextLabel === null) return;
+ current.label = nextLabel.trim();
+ persistDrafts();
+ renderDrafts();
+ showToast("Updated draft name.", "success");
+ });
+
+ deleteDraftButton.addEventListener("click", () => {
+ deleteActiveDraft();
+ });
+
+ addQuestionButton.addEventListener("click", () => {
+ state.questions.push(createBlankQuestion(state.questions.length + 1));
+ state.questionCount = String(state.questions.length);
+ expandQuestionAtIndex(state.questions.length - 1);
+ renderAll();
+ showToast("Added blank question.", "success");
+ });
+
+ filterAllButton.addEventListener("click", () => {
+ uiState.questionFilter = "all";
+ renderQuestions();
+ renderQuestionSummary();
+ });
+
+ filterAttentionButton.addEventListener("click", () => {
+ uiState.questionFilter = "attention";
+ renderQuestions();
+ renderQuestionSummary();
+ });
+
+ filterUnlabeledButton.addEventListener("click", () => {
+ uiState.questionFilter = "unlabeled";
+ renderQuestions();
+ renderQuestionSummary();
+ });
+
+ expandAllQuestionsButton.addEventListener("click", () => {
+ uiState.collapsedQuestions.clear();
+ renderQuestions();
+ });
+
+ collapseAllQuestionsButton.addEventListener("click", () => {
+ uiState.collapsedQuestions = new Set(state.questions.map((question, index) => getQuestionUiKey(question, index)));
+ renderQuestions();
+ });
+
+ generateAssignmentButton.addEventListener("click", async () => {
+ await runAssignmentGeneration();
+ });
+
+ generateStudentsButton.addEventListener("click", async () => {
+ await runAssignmentDraft({
+ button: generateStudentsButton,
+ path: "/api/assignment/student-draft",
+ successMessage: "Student submission drafted.",
+ apply(result) {
+ const byId = new Map((result.questions || []).map((question) => [String(question.questionId), question]));
+ state.questions = state.questions.map((question) => {
+ const drafted = byId.get(question.questionId) || byId.get(String(question.questionId));
+ if (!drafted) return question;
+ return {
+ ...question,
+ studentAnswer: drafted.answerText || "",
+ workingSteps: drafted.workingSteps || "",
+ solveMode: drafted.solveMode || question.solveMode || "show_work",
+ };
+ });
+ },
+ });
+ });
+
+ generateTeacherButton.addEventListener("click", async () => {
+ await runAssignmentDraft({
+ button: generateTeacherButton,
+ path: "/api/assignment/teacher-draft",
+ successMessage: "Teacher review package drafted.",
+ apply(result) {
+ const byId = new Map((result.questions || []).map((question) => [String(question.questionId), question]));
+ state.questions = state.questions.map((question) => {
+ const drafted = byId.get(question.questionId) || byId.get(String(question.questionId));
+ if (!drafted) return question;
+ return {
+ ...question,
+ aiFeedback: drafted.aiFeedback || "",
+ understandingScore: formatScore(drafted.understandingScore),
+ confidence: formatScore(drafted.confidence),
+ needsAttention: formatBoolean(drafted.needsAttention),
+ issueReason: drafted.issueReason || "",
+ };
+ });
+ state.assignmentSummary = result.assignmentSummary || "";
+ state.recommendedNextStep = result.recommendedNextStep || "";
+ },
+ });
+ });
+
+ saveExampleButton.addEventListener("click", () => {
+ try {
+ saveCurrentExample();
+ } catch (error) {
+ showToast(error instanceof Error ? error.message : "Could not save example.", "error");
+ }
+ });
+
+ document.getElementById("copy-record").addEventListener("click", async () => {
+ await copyPreview(recordPreview.textContent, "Labeled record copied.");
+ });
+
+ document.getElementById("copy-training").addEventListener("click", async () => {
+ await copyPreview(trainingPreview.textContent, "Fine-tune example copied.");
+ });
+
+ exportDatasetButton.addEventListener("click", () => {
+ try {
+ exportDatasetJsonl();
+ } catch (error) {
+ showToast(error instanceof Error ? error.message : "Export failed.", "error");
+ }
+ });
+
+ exportSplitButton.addEventListener("click", () => {
+ try {
+ exportTrainValidationSplit();
+ } catch (error) {
+ showToast(error instanceof Error ? error.message : "Split export failed.", "error");
+ }
+ });
+
+ questionsContainer.addEventListener("input", (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) return;
+ const index = Number(target.dataset.questionIndex);
+ const field = target.dataset.questionField;
+ if (!Number.isInteger(index) || !field || !state.questions[index]) return;
+
+ state.questions[index][field] = target.value;
+ persistState();
+ renderPreviews();
+ renderQuestionSummary();
+ });
+
+ questionsContainer.addEventListener("click", (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) return;
+ const actionTarget = target.closest("[data-action]");
+ if (!(actionTarget instanceof HTMLElement)) return;
+
+ const action = actionTarget.dataset.action;
+ const index = Number(actionTarget.dataset.questionIndex);
+ if (!Number.isInteger(index) || !state.questions[index]) return;
+
+ if (action === "remove-question") {
+ uiState.collapsedQuestions.delete(getQuestionUiKey(state.questions[index], index));
+ state.questions.splice(index, 1);
+ reindexQuestions();
+ renderAll();
+ showToast("Removed question.", "success");
+ return;
+ }
+
+ if (action === "duplicate-question") {
+ const duplicate = normalizeQuestion(state.questions[index], index + 1);
+ duplicate.position = index + 2;
+ state.questions.splice(index + 1, 0, duplicate);
+ reindexQuestions();
+ expandQuestionAtIndex(index + 1);
+ renderAll();
+ showToast("Duplicated question.", "success");
+ return;
+ }
+
+ if (action === "move-question-up" && index > 0) {
+ [state.questions[index - 1], state.questions[index]] = [state.questions[index], state.questions[index - 1]];
+ reindexQuestions();
+ renderAll();
+ showToast("Moved question up.", "success");
+ return;
+ }
+
+ if (action === "move-question-down" && index < state.questions.length - 1) {
+ [state.questions[index], state.questions[index + 1]] = [state.questions[index + 1], state.questions[index]];
+ reindexQuestions();
+ renderAll();
+ showToast("Moved question down.", "success");
+ return;
+ }
+
+ if (action === "toggle-question") {
+ toggleQuestionCollapsed(index);
+ renderQuestions();
+ }
+ });
+
+ draftList.addEventListener("click", (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) return;
+ const actionTarget = target.closest("[data-draft-action]");
+ if (!(actionTarget instanceof HTMLElement)) return;
+
+ const action = actionTarget.dataset.draftAction;
+ const draftId = actionTarget.dataset.draftId;
+ if (!draftId) return;
+
+ if (action === "open-draft") {
+ loadDraft(draftId);
+ }
+ });
+ }
+
+function hydrateDrafts() {
+ const rawDrafts = localStorage.getItem(DRAFT_STORAGE_KEY);
+ const legacyState = localStorage.getItem(STORAGE_KEY);
+
+ if (rawDrafts) {
+ try {
+ const parsed = JSON.parse(rawDrafts);
+ drafts = Array.isArray(parsed) ? parsed.map(normalizeDraft).filter(Boolean) : [];
+ } catch {
+ drafts = [];
+ }
+ }
+
+ if (!drafts.length && legacyState) {
+ try {
+ drafts = [createDraftRecord(normalizeState(JSON.parse(legacyState)), { id: crypto.randomUUID() })];
+ } catch {
+ drafts = [];
+ }
+ }
+
+ if (!drafts.length) {
+ drafts = [createDraftRecord(createSampleState(), { id: crypto.randomUUID(), label: "Sample assignment" })];
+ }
+
+ const storedActiveDraftId = localStorage.getItem(ACTIVE_DRAFT_STORAGE_KEY);
+ activeDraftId = drafts.some((draft) => draft.id === storedActiveDraftId) ? storedActiveDraftId : drafts[0].id;
+ state = normalizeState(getActiveDraft()?.state || createSampleState());
+}
+
+function hydrateDataset() {
+ const raw = localStorage.getItem(DATASET_STORAGE_KEY);
+ if (!raw) {
+ savedExamples = [];
+ return;
+ }
+
+ try {
+ const parsed = JSON.parse(raw);
+ savedExamples = Array.isArray(parsed) ? parsed.filter(isSavedExampleShape) : [];
+ } catch {
+ savedExamples = [];
+ }
+}
+
+function renderAll() {
+ renderFixedFields();
+ draftSearch.value = uiState.draftFilter;
+ draftSort.value = uiState.draftSort;
+ renderQuestions();
+ renderQuestionSummary();
+ renderQuestionToolbar();
+ persistState();
+ renderPreviews();
+ renderDrafts();
+ renderDataset();
+}
+
+function renderFixedFields() {
+ for (const [field, element] of Object.entries(fixedElements)) {
+ element.value = state[field] || "";
+ }
+}
+
+function renderQuestions() {
+ questionsContainer.innerHTML = "";
+ const visibleQuestions = state.questions.filter((question, index) => matchesQuestionFilter(question, index));
+
+ if (!visibleQuestions.length) {
+ const empty = document.createElement("div");
+ empty.className = "empty-state question-empty-state";
+ empty.textContent =
+ uiState.questionFilter === "all"
+ ? "No questions yet. Generate an assignment or add a blank question."
+ : "No questions match the current filter.";
+ questionsContainer.appendChild(empty);
+ return;
+ }
+
+ for (const question of visibleQuestions) {
+ const index = Math.max(0, (question.position || 1) - 1);
+ const collapsed = isQuestionCollapsed(index);
+ const statusPills = buildQuestionStatusPills(question);
+ const article = document.createElement("article");
+ article.className = `question-card${collapsed ? " is-collapsed" : ""}`;
+ article.innerHTML = `
+
+
+
+ `;
+
+ questionsContainer.appendChild(article);
+ }
+}
+
+function renderQuestionSummary() {
+ const total = state.questions.length;
+ const reviewed = state.questions.filter((question) => question.aiFeedback && question.issueReason).length;
+ const studentDrafted = state.questions.filter((question) => question.studentAnswer || question.workingSteps).length;
+ const needsAttention = state.questions.filter((question) => parseBoolean(question.needsAttention) === true).length;
+ const visible = state.questions.filter((question, index) => matchesQuestionFilter(question, index)).length;
+ questionSummary.textContent = total
+ ? `${total} question${total === 1 ? "" : "s"} • ${studentDrafted} with student work • ${reviewed} labeled • ${needsAttention} need attention${visible !== total ? ` • showing ${visible}` : ""}`
+ : "No questions yet.";
+}
+
+function renderQuestionToolbar() {
+ const filters = {
+ all: filterAllButton,
+ attention: filterAttentionButton,
+ unlabeled: filterUnlabeledButton,
+ };
+
+ for (const [mode, button] of Object.entries(filters)) {
+ button.classList.toggle("is-active", uiState.questionFilter === mode);
+ }
+}
+
+function persistState() {
+ syncCurrentStateIntoActiveDraft();
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+ persistDrafts();
+ saveStatus.textContent = `Autosaved draft at ${new Date().toLocaleTimeString()}`;
+ if (!collaboration.applyingRemoteState) {
+ scheduleWorkspacePublish();
+ }
+}
+
+function persistDrafts() {
+ localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(drafts));
+ if (activeDraftId) {
+ localStorage.setItem(ACTIVE_DRAFT_STORAGE_KEY, activeDraftId);
+ }
+}
+
+function persistDataset() {
+ localStorage.setItem(DATASET_STORAGE_KEY, JSON.stringify(savedExamples));
+}
+
+function renderPreviews() {
+ const derived = buildDerivedExample(state);
+ recordPreview.textContent = JSON.stringify(derived.record, null, 2);
+ trainingPreview.textContent = JSON.stringify(derived.trainingExample, null, 2);
+ updateSaveButtonLabel();
+}
+
+function renderDataset() {
+ const count = savedExamples.length;
+ datasetStatus.textContent =
+ count === 0
+ ? "No saved examples yet."
+ : `${count} saved assignment example${count === 1 ? "" : "s"} ready for export.`;
+ datasetEmpty.hidden = count > 0;
+ datasetList.innerHTML = "";
+
+ for (const example of savedExamples) {
+ const article = document.createElement("article");
+ article.className = `dataset-item${example.id === activeExampleId ? " active" : ""}`;
+
+ const header = document.createElement("div");
+ header.className = "dataset-item-header";
+
+ const summary = document.createElement("div");
+ const title = document.createElement("h3");
+ title.className = "dataset-item-title";
+ title.textContent = example.title;
+ summary.appendChild(title);
+
+ const meta = document.createElement("p");
+ meta.className = "dataset-item-meta";
+ meta.textContent = [
+ example.assignmentId || "No assignment ID",
+ example.studentId || "No student ID",
+ example.savedAt ? `Saved ${new Date(example.savedAt).toLocaleString()}` : null,
+ ]
+ .filter(Boolean)
+ .join(" • ");
+ summary.appendChild(meta);
+
+ const actions = document.createElement("div");
+ actions.className = "dataset-item-actions";
+
+ const loadButton = document.createElement("button");
+ loadButton.type = "button";
+ loadButton.className = "button-secondary";
+ loadButton.textContent = example.id === activeExampleId ? "Loaded" : "Load";
+ loadButton.addEventListener("click", () => loadSavedExample(example.id));
+ actions.appendChild(loadButton);
+
+ const deleteButton = document.createElement("button");
+ deleteButton.type = "button";
+ deleteButton.className = "button-danger";
+ deleteButton.textContent = "Delete";
+ deleteButton.addEventListener("click", () => deleteSavedExample(example.id));
+ actions.appendChild(deleteButton);
+
+ header.append(summary, actions);
+ article.appendChild(header);
+
+ const pills = document.createElement("div");
+ pills.className = "dataset-item-tags";
+ for (const label of buildDatasetPills(example)) {
+ const pill = document.createElement("span");
+ pill.className = "dataset-pill";
+ pill.textContent = label;
+ pills.appendChild(pill);
+ }
+ article.appendChild(pills);
+
+ datasetList.appendChild(article);
+ }
+
+ updateSaveButtonLabel();
+}
+
+function renderDrafts() {
+ const visibleDrafts = drafts.filter(matchesDraftFilter).sort(compareDraftsForList);
+ const count = drafts.length;
+ const activeTitle = getActiveDraftTitle();
+ draftStatus.textContent = `${count} local assignment${count === 1 ? "" : "s"}${activeTitle ? ` • Editing ${activeTitle}` : ""}`;
+ draftList.innerHTML = "";
+
+ if (!visibleDrafts.length) {
+ const empty = document.createElement("div");
+ empty.className = "empty-state draft-empty-state";
+ empty.textContent = drafts.length ? "No drafts match this search." : "Create a new assignment draft to start working on more than one assignment.";
+ draftList.appendChild(empty);
+ return;
+ }
+
+ for (const draft of visibleDrafts) {
+ const article = document.createElement("article");
+ article.className = `draft-item${draft.id === activeDraftId ? " active" : ""}`;
+ article.setAttribute("role", "option");
+ article.setAttribute("aria-selected", draft.id === activeDraftId ? "true" : "false");
+ article.innerHTML = `
+
+
+
${escapeHtml(getDraftTitle(draft))}
+
${escapeHtml(formatDraftUpdatedAt(draft.updatedAt))}
+
+
${escapeHtml(buildDraftMeta(draft))}
+
+
+ ${draft.id === activeDraftId ? "Editing" : "Open"}
+
+ `;
+
+ const pills = document.createElement("div");
+ pills.className = "dataset-item-tags";
+ for (const label of buildDraftPills(draft)) {
+ const pill = document.createElement("span");
+ pill.className = "dataset-pill";
+ pill.textContent = label;
+ pills.appendChild(pill);
+ }
+ article.appendChild(pills);
+ draftList.appendChild(article);
+ }
+}
+
+function updateSaveButtonLabel() {
+ saveExampleButton.textContent = activeExampleId ? "Update saved assignment example" : "Save assignment example";
+}
+
+function getActiveDraft() {
+ return drafts.find((draft) => draft.id === activeDraftId) || null;
+}
+
+function getActiveDraftTitle() {
+ return getDraftTitle(getActiveDraft());
+}
+
+function getDraftTitle(draft) {
+ if (!draft) return "Untitled assignment";
+ if (draft.label) return draft.label;
+ if (draft.state?.assignmentTitle) return draft.state.assignmentTitle;
+ if (draft.assignmentId) return draft.assignmentId;
+ if (draft.topic) return `${draft.topic} draft`;
+ return "Untitled assignment";
+}
+
+function buildDraftMeta(draft) {
+ return [
+ draft.assignmentId || "No assignment ID",
+ draft.studentId || "No student ID",
+ draft.topic ? `Topic ${draft.topic}` : null,
+ draft.questionCount ? `${draft.questionCount} question${draft.questionCount === 1 ? "" : "s"}` : null,
+ ]
+ .filter(Boolean)
+ .join(" • ");
+}
+
+function buildDraftPills(draft) {
+ const pills = [];
+ if (draft.topic) pills.push(`topic ${draft.topic}`);
+ if (draft.difficulty) pills.push(draft.difficulty);
+ if (draft.questionCount) pills.push(`${draft.questionCount} questions`);
+ if (draft.id === activeDraftId) pills.push("active editor");
+ return pills;
+}
+
+function matchesDraftFilter(draft) {
+ if (!uiState.draftFilter) return true;
+ const haystack = [
+ getDraftTitle(draft),
+ draft.assignmentId,
+ draft.studentId,
+ draft.topic,
+ draft.difficulty,
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ return haystack.includes(uiState.draftFilter);
+}
+
+function compareDraftsForList(left, right) {
+ const mode = uiState.draftSort || "updated";
+ if (mode === "title") {
+ return getDraftTitle(left).localeCompare(getDraftTitle(right));
+ }
+
+ if (mode === "topic") {
+ const topicCompare = (left.topic || "zzzz").localeCompare(right.topic || "zzzz");
+ if (topicCompare !== 0) return topicCompare;
+ return getDraftTitle(left).localeCompare(getDraftTitle(right));
+ }
+
+ if (mode === "questions") {
+ const questionCompare = (right.questionCount || 0) - (left.questionCount || 0);
+ if (questionCompare !== 0) return questionCompare;
+ return getDraftTitle(left).localeCompare(getDraftTitle(right));
+ }
+
+ return (Date.parse(right.updatedAt || 0) || 0) - (Date.parse(left.updatedAt || 0) || 0);
+}
+
+function formatDraftUpdatedAt(value) {
+ if (!value) return "No edits yet";
+ const timestamp = Date.parse(value);
+ if (Number.isNaN(timestamp)) return "Updated recently";
+ const diffMinutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000));
+ if (diffMinutes < 1) return "Updated just now";
+ if (diffMinutes < 60) return `Updated ${diffMinutes}m ago`;
+ const diffHours = Math.round(diffMinutes / 60);
+ if (diffHours < 24) return `Updated ${diffHours}h ago`;
+ const diffDays = Math.round(diffHours / 24);
+ if (diffDays < 7) return `Updated ${diffDays}d ago`;
+ return `Updated ${new Date(timestamp).toLocaleDateString()}`;
+}
+
+function normalizeDraft(raw) {
+ if (!raw || typeof raw !== "object") return null;
+ const normalizedState = normalizeState(raw.state || raw);
+ return createDraftRecord(normalizedState, {
+ id: typeof raw.id === "string" && raw.id ? raw.id : crypto.randomUUID(),
+ label: typeof raw.label === "string" ? raw.label.trim() : "",
+ createdAt: typeof raw.createdAt === "string" ? raw.createdAt : null,
+ updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null,
+ });
+}
+
+function createDraftRecord(nextState, overrides = {}) {
+ const normalizedState = normalizeState(nextState);
+ const now = new Date().toISOString();
+ return {
+ id: overrides.id || crypto.randomUUID(),
+ label: typeof overrides.label === "string" ? overrides.label.trim() : "",
+ createdAt: overrides.createdAt || now,
+ updatedAt: overrides.updatedAt || now,
+ assignmentId: normalizedState.assignmentId,
+ studentId: normalizedState.studentId,
+ topic: normalizedState.topic,
+ difficulty: normalizedState.difficulty,
+ questionCount: normalizedState.questions.length,
+ state: normalizedState,
+ };
+}
+
+function syncCurrentStateIntoActiveDraft() {
+ const current = getActiveDraft();
+ if (!current) return;
+ const normalizedState = normalizeState(state);
+ const nextRecord = createDraftRecord(normalizedState, {
+ id: current.id,
+ label: current.label,
+ createdAt: current.createdAt,
+ });
+ const index = drafts.findIndex((draft) => draft.id === current.id);
+ if (index >= 0) {
+ drafts.splice(index, 1, nextRecord);
+ }
+}
+
+function createDraftFromCurrentState({ state: nextState, label = "", successMessage }) {
+ const draft = createDraftRecord(nextState, { label });
+ drafts.unshift(draft);
+ activeDraftId = draft.id;
+ activeExampleId = null;
+ state = normalizeState(draft.state);
+ primeQuestionCollapsing(state.questions.length, { preserveExisting: false });
+ renderAll();
+ if (successMessage) showToast(successMessage, "success");
+}
+
+function loadDraft(id) {
+ const draft = drafts.find((item) => item.id === id);
+ if (!draft) {
+ showToast("Draft not found.", "error");
+ return;
+ }
+
+ activeDraftId = id;
+ activeExampleId = null;
+ state = normalizeState(draft.state || createEmptyState());
+ primeQuestionCollapsing(state.questions.length, { preserveExisting: false });
+ renderAll();
+ showToast("Loaded assignment draft into the editor.", "success");
+}
+
+function deleteActiveDraft() {
+ if (!activeDraftId) return;
+ const current = getActiveDraft();
+ if (!current) return;
+
+ if (drafts.length === 1) {
+ drafts = [createDraftRecord(createEmptyState(), { label: "New assignment draft" })];
+ activeDraftId = drafts[0].id;
+ state = normalizeState(drafts[0].state);
+ activeExampleId = null;
+ primeQuestionCollapsing(state.questions.length, { preserveExisting: false });
+ renderAll();
+ showToast("Reset the final remaining draft to a blank assignment.", "success");
+ return;
+ }
+
+ const currentIndex = drafts.findIndex((draft) => draft.id === activeDraftId);
+ drafts = drafts.filter((draft) => draft.id !== activeDraftId);
+ const fallback = drafts[Math.max(0, Math.min(currentIndex, drafts.length - 1))];
+ activeDraftId = fallback.id;
+ state = normalizeState(fallback.state);
+ activeExampleId = null;
+ primeQuestionCollapsing(state.questions.length, { preserveExisting: false });
+ renderAll();
+ showToast(`Deleted “${getDraftTitle(current)}”.`, "success");
+}
+
+async function loadConfig() {
+ try {
+ const response = await fetch("/api/config");
+ const data = await response.json();
+ aiConfigStatus.textContent = data.hasAiConfig
+ ? `Connected to ${data.model} via ${data.endpoint}`
+ : "Missing local AI env config";
+
+ questionGeneratorStatus.textContent = data.hasQuestionGeneratorConfig
+ ? `Ready via ${data.backendUrl}`
+ : "Missing backend generator env config";
+ } catch {
+ aiConfigStatus.textContent = "Could not load local config";
+ questionGeneratorStatus.textContent = "Could not load local config";
+ }
+}
+
+function connectCollaboration() {
+ clearTimeout(collaboration.reconnectTimer);
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const url = new URL(`${protocol}//${window.location.host}/ws`);
+ url.searchParams.set("workspace", collaboration.workspaceId);
+
+ collaboration.connected = false;
+ collaboration.ready = false;
+ renderCollaborationStatus();
+
+ const ws = new WebSocket(url);
+ collaboration.ws = ws;
+
+ ws.addEventListener("open", () => {
+ renderCollaborationStatus();
+ });
+
+ ws.addEventListener("message", (event) => {
+ handleCollaborationMessage(event.data);
+ });
+
+ ws.addEventListener("close", () => {
+ if (collaboration.ws === ws) {
+ collaboration.connected = false;
+ collaboration.ready = false;
+ renderCollaborationStatus();
+ collaboration.reconnectTimer = setTimeout(() => {
+ connectCollaboration();
+ }, 1500);
+ }
+ });
+
+ ws.addEventListener("error", () => {});
+}
+
+function handleCollaborationMessage(raw) {
+ let message;
+ try {
+ message = JSON.parse(raw);
+ } catch {
+ return;
+ }
+
+ if (message.type === "workspace:init") {
+ collaboration.clientId = message.clientId || null;
+ collaboration.connected = true;
+ collaboration.ready = true;
+ collaboration.serverVersion = Number(message.version || 0);
+ collaboration.presenceCount = Number(message.presenceCount || 1);
+ renderCollaborationStatus();
+
+ if (message.state && typeof message.state === "object") {
+ applyRemoteWorkspaceState(message.state);
+ } else {
+ publishWorkspaceNow({ force: true });
+ }
+ return;
+ }
+
+ if (message.type === "workspace:presence") {
+ collaboration.connected = true;
+ collaboration.presenceCount = Number(message.presenceCount || 1);
+ renderCollaborationStatus();
+ return;
+ }
+
+ if (message.type === "workspace:snapshot") {
+ collaboration.connected = true;
+ collaboration.ready = true;
+ collaboration.serverVersion = Number(message.version || collaboration.serverVersion || 0);
+ collaboration.presenceCount = Number(message.presenceCount || collaboration.presenceCount || 1);
+ renderCollaborationStatus();
+
+ if (message.actorClientId === collaboration.clientId) {
+ return;
+ }
+
+ if (message.state && typeof message.state === "object") {
+ applyRemoteWorkspaceState(message.state);
+ }
+ return;
+ }
+
+ if (message.type === "workspace:error" && message.message) {
+ showToast(message.message, "error");
+ }
+}
+
+function applyRemoteWorkspaceState(nextState) {
+ const normalized = normalizeState(nextState);
+ const nextHash = JSON.stringify(normalized);
+ const currentHash = JSON.stringify(normalizeState(state));
+ collaboration.lastSentStateHash = nextHash;
+
+ if (nextHash === currentHash) {
+ return;
+ }
+
+ collaboration.applyingRemoteState = true;
+ activeExampleId = null;
+ state = normalized;
+ renderAll();
+ collaboration.applyingRemoteState = false;
+ saveStatus.textContent = `Synced shared workspace at ${new Date().toLocaleTimeString()}`;
+}
+
+function scheduleWorkspacePublish() {
+ if (!collaboration.ready) return;
+ clearTimeout(collaboration.publishTimer);
+ collaboration.publishTimer = setTimeout(() => {
+ publishWorkspaceNow();
+ }, 150);
+}
+
+function publishWorkspaceNow(options = {}) {
+ if (!collaboration.ready) return false;
+ if (!collaboration.ws || collaboration.ws.readyState !== WebSocket.OPEN) return false;
+
+ const normalized = normalizeState(state);
+ const stateHash = JSON.stringify(normalized);
+ if (!options.force && stateHash === collaboration.lastSentStateHash) {
+ return false;
+ }
+
+ collaboration.lastSentStateHash = stateHash;
+ collaboration.ws.send(
+ JSON.stringify({
+ type: "workspace:update",
+ workspaceId: collaboration.workspaceId,
+ state: normalized,
+ }),
+ );
+ return true;
+}
+
+function renderCollaborationStatus() {
+ if (collabStatus) {
+ collabStatus.textContent = collaboration.connected && collaboration.ready
+ ? `${collaboration.workspaceId} (live)`
+ : `${collaboration.workspaceId} (reconnecting…)`;
+ }
+
+ if (presenceStatus) {
+ const count = Math.max(1, Number(collaboration.presenceCount || 1));
+ presenceStatus.textContent = `${count} connected`;
+ }
+}
+
+function resolveWorkspaceId() {
+ const params = new URLSearchParams(window.location.search);
+ const raw = String(params.get("workspace") || DEFAULT_WORKSPACE_ID).trim();
+ if (!raw) return DEFAULT_WORKSPACE_ID;
+ return /^[a-zA-Z0-9._-]{1,64}$/.test(raw) ? raw : DEFAULT_WORKSPACE_ID;
+}
+
+async function runAssignmentGeneration() {
+ const topic = state.topic.trim();
+ const difficulty = state.difficulty.trim();
+ const count = clampInteger(state.questionCount) || 4;
+
+ if (!topic) {
+ showToast("Pick a topic before generating an assignment.", "error");
+ return;
+ }
+
+ if (!difficulty) {
+ showToast("Pick a difficulty before generating an assignment.", "error");
+ return;
+ }
+
+ generateAssignmentButton.disabled = true;
+ const originalLabel = generateAssignmentButton.textContent;
+ generateAssignmentButton.textContent = "Generating…";
+
+ try {
+ const response = await fetch("/api/assignment/generate", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ topic, difficulty, count }),
+ });
+
+ const payload = await response.json();
+ if (!response.ok) {
+ throw new Error(payload.message || "Assignment generation failed.");
+ }
+
+ if (!Array.isArray(payload.questions) || payload.questions.length === 0) {
+ throw new Error("Assignment generator returned no questions.");
+ }
+
+ state.questions = payload.questions.map((question, index) => normalizeQuestion(question, index));
+ primeQuestionCollapsing(state.questions.length);
+ state.questionCount = String(state.questions.length);
+ state.generatorSeed = payload.seed ? String(payload.seed) : "";
+ if (!state.assignmentId) state.assignmentId = buildSuggestedAssignmentId(state.topic, state.difficulty);
+ if (!state.assignmentTitle) state.assignmentTitle = buildSuggestedAssignmentTitle(state.topic, state.difficulty, state.questions.length);
+ if (!state.instructions) state.instructions = "Show your working for each question.";
+ if (!state.studentId) state.studentId = "student-001";
+ if (!state.passThreshold) state.passThreshold = "0.70";
+
+ renderAll();
+ showToast("Assignment context filled from backend generator.", "success");
+ } catch (error) {
+ showToast(error instanceof Error ? error.message : "Assignment generation failed.", "error");
+ } finally {
+ generateAssignmentButton.disabled = false;
+ generateAssignmentButton.textContent = originalLabel;
+ }
+}
+
+async function runAssignmentDraft({ button, path, apply, successMessage }) {
+ button.disabled = true;
+ const originalLabel = button.textContent;
+ button.textContent = "Working…";
+
+ try {
+ const response = await fetch(path, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(buildAssignmentPayload(state)),
+ });
+
+ const payload = await response.json();
+ if (!response.ok) {
+ throw new Error(payload.message || "AI request failed.");
+ }
+
+ apply(payload);
+ renderAll();
+ showToast(successMessage, "success");
+ } catch (error) {
+ showToast(error instanceof Error ? error.message : "AI request failed.", "error");
+ } finally {
+ button.disabled = false;
+ button.textContent = originalLabel;
+ }
+}
+
+function saveCurrentExample() {
+ const normalized = normalizeState(state);
+ const derived = buildDerivedExample(normalized);
+ const errors = validateStateForDataset(normalized, derived.record.teacherReview);
+ if (errors.length > 0) {
+ throw new Error(`Save blocked: ${errors[0]}`);
+ }
+
+ const timestamp = new Date().toISOString();
+ const example = {
+ id: activeExampleId || crypto.randomUUID(),
+ savedAt: timestamp,
+ title: deriveTitle(normalized),
+ assignmentId: normalized.assignmentId,
+ studentId: normalized.studentId,
+ questionCount: normalized.questions.length,
+ topic: normalized.topic,
+ difficulty: normalized.difficulty,
+ state: normalized,
+ record: derived.record,
+ trainingExample: derived.trainingExample,
+ };
+
+ const existingIndex = savedExamples.findIndex((item) => item.id === example.id);
+ if (existingIndex >= 0) {
+ savedExamples.splice(existingIndex, 1, example);
+ } else {
+ savedExamples.unshift(example);
+ }
+
+ activeExampleId = example.id;
+ persistDataset();
+ renderDataset();
+ showToast(existingIndex >= 0 ? "Saved example updated." : "Saved assignment example added to local dataset.", "success");
+}
+
+function loadSavedExample(id) {
+ const example = savedExamples.find((item) => item.id === id);
+ if (!example) {
+ showToast("Saved example not found.", "error");
+ return;
+ }
+
+ activeExampleId = id;
+ state = normalizeState(example.state || createEmptyState());
+ primeQuestionCollapsing(state.questions.length, { preserveExisting: false });
+ renderAll();
+ showToast("Loaded saved assignment example into the workspace.", "success");
+}
+
+function deleteSavedExample(id) {
+ const example = savedExamples.find((item) => item.id === id);
+ if (!example) {
+ showToast("Saved example not found.", "error");
+ return;
+ }
+
+ savedExamples = savedExamples.filter((item) => item.id !== id);
+ if (activeExampleId === id) {
+ activeExampleId = null;
+ }
+ persistDataset();
+ renderDataset();
+ showToast(`Deleted “${example.title}”.`, "success");
+}
+
+function exportDatasetJsonl() {
+ const prepared = prepareSavedExamplesForExport();
+ const jsonl = prepared.map((entry) => JSON.stringify(entry.trainingExample)).join("\n");
+ downloadTextFile("dataset.jsonl", `${jsonl}\n`);
+ showToast(`Exported ${prepared.length} example${prepared.length === 1 ? "" : "s"} to dataset.jsonl.`, "success");
+}
+
+function exportTrainValidationSplit() {
+ const prepared = prepareSavedExamplesForExport();
+ if (prepared.length < 2) {
+ throw new Error("Need at least 2 saved examples before exporting a train/val split.");
+ }
+
+ const ordered = [...prepared].sort((left, right) => {
+ const leftTime = left.savedAt ? Date.parse(left.savedAt) : 0;
+ const rightTime = right.savedAt ? Date.parse(right.savedAt) : 0;
+ return leftTime - rightTime;
+ });
+
+ const validationCount = Math.max(1, Math.round(ordered.length * TRAIN_VAL_SPLIT));
+ const splitIndex = Math.max(1, ordered.length - validationCount);
+ const train = ordered.slice(0, splitIndex);
+ const validation = ordered.slice(splitIndex);
+
+ downloadTextFile("train.jsonl", `${train.map((entry) => JSON.stringify(entry.trainingExample)).join("\n")}\n`);
+ downloadTextFile("val.jsonl", `${validation.map((entry) => JSON.stringify(entry.trainingExample)).join("\n")}\n`);
+
+ showToast(
+ `Exported ${train.length} train and ${validation.length} validation example${validation.length === 1 ? "" : "s"}.`,
+ "success",
+ );
+}
+
+function prepareSavedExamplesForExport() {
+ if (savedExamples.length === 0) {
+ throw new Error("No saved examples yet. Save at least one example first.");
+ }
+
+ const invalidExamples = [];
+ const prepared = savedExamples.map((example) => {
+ const normalized = normalizeState(example.state || createEmptyState());
+ const derived = buildDerivedExample(normalized);
+ const errors = validateStateForDataset(normalized, derived.record.teacherReview);
+ if (errors.length > 0) {
+ invalidExamples.push(`${example.title}: ${errors[0]}`);
+ }
+ return {
+ ...example,
+ record: derived.record,
+ trainingExample: derived.trainingExample,
+ };
+ });
+
+ if (invalidExamples.length > 0) {
+ throw new Error(`Fix saved examples before export. First issue: ${invalidExamples[0]}`);
+ }
+
+ return prepared;
+}
+
+function buildDerivedExample(currentState) {
+ const normalized = normalizeState(currentState);
+ const assignmentQuestions = normalized.questions.map((question, index) => ({
+ questionId: clampInteger(question.questionId),
+ position: index + 1,
+ title: question.title || `Question ${index + 1}`,
+ prompt: question.prompt,
+ correctAnswer: question.correctAnswer,
+ tags: splitTags(question.tags),
+ workedSolution: splitLines(question.workedSolution),
+ subject: question.subject || "Mathematics",
+ source: question.source || "rng_generated",
+ difficulty: question.difficulty || normalized.difficulty,
+ }));
+
+ const studentSubmissionQuestions = normalized.questions.map((question, index) => ({
+ questionId: clampInteger(question.questionId),
+ position: index + 1,
+ answerText: question.studentAnswer,
+ workingSteps: question.workingSteps,
+ solveMode: question.solveMode || "show_work",
+ }));
+
+ const teacherReviewQuestions = normalized.questions.map((question) => ({
+ questionId: clampInteger(question.questionId),
+ aiFeedback: question.aiFeedback,
+ understandingScore: clampScore(question.understandingScore),
+ confidence: clampScore(question.confidence),
+ needsAttention: parseBoolean(question.needsAttention),
+ issueReason: question.issueReason,
+ }));
+
+ const record = {
+ version: "assignment-review-v1",
+ metadata: {
+ createdAt: new Date().toISOString(),
+ topic: normalized.topic,
+ difficulty: normalized.difficulty,
+ questionCount: normalized.questions.length,
+ },
+ assignment: {
+ assignmentId: normalized.assignmentId,
+ assignmentTitle: normalized.assignmentTitle,
+ instructions: normalized.instructions,
+ passThreshold: clampScore(normalized.passThreshold),
+ questions: assignmentQuestions,
+ },
+ studentSubmission: {
+ studentId: normalized.studentId,
+ questions: studentSubmissionQuestions,
+ },
+ teacherReview: {
+ questions: teacherReviewQuestions,
+ assignmentSummary: normalized.assignmentSummary,
+ recommendedNextStep: normalized.recommendedNextStep,
+ },
+ };
+
+ const trainingExample = {
+ messages: [
+ {
+ role: "system",
+ content:
+ "You are an expert teacher reviewer. Review the full assignment in one pass. Return a JSON object with question-level teacher review labels for every question plus assignmentSummary and recommendedNextStep.",
+ },
+ {
+ role: "user",
+ content: JSON.stringify(
+ {
+ assignment: record.assignment,
+ studentSubmission: record.studentSubmission,
+ },
+ null,
+ 2,
+ ),
+ },
+ {
+ role: "assistant",
+ content: JSON.stringify(record.teacherReview, null, 2),
+ },
+ ],
+ metadata: {
+ version: "assignment-review-v1",
+ assignmentId: normalized.assignmentId,
+ studentId: normalized.studentId,
+ questionCount: normalized.questions.length,
+ topic: normalized.topic,
+ difficulty: normalized.difficulty,
+ },
+ };
+
+ return { record, trainingExample };
+}
+
+function validateStateForDataset(currentState, teacherReview) {
+ const errors = [];
+ if (!currentState.assignmentId) errors.push("Assignment ID is required.");
+ if (!currentState.studentId) errors.push("Student ID is required.");
+ if (!currentState.assignmentTitle) errors.push("Assignment title is required.");
+ if (currentState.questions.length === 0) errors.push("At least one question is required.");
+ if (!currentState.assignmentSummary) errors.push("Assignment summary is required.");
+ if (!currentState.recommendedNextStep) errors.push("Recommended next step is required.");
+
+ for (const [index, question] of currentState.questions.entries()) {
+ const label = `Question ${index + 1}`;
+ if (!clampInteger(question.questionId)) errors.push(`${label} needs a valid question ID.`);
+ if (!question.prompt) errors.push(`${label} prompt is required.`);
+ if (!question.correctAnswer) errors.push(`${label} correct answer is required.`);
+ if (!question.workedSolution) errors.push(`${label} worked solution is required.`);
+ if (!question.studentAnswer && !question.workingSteps) errors.push(`${label} needs student work.`);
+ if (!question.aiFeedback) errors.push(`${label} AI feedback is required.`);
+ if (teacherReview.questions[index]?.understandingScore === null) errors.push(`${label} understanding score must be between 0.00 and 1.00.`);
+ if (teacherReview.questions[index]?.confidence === null) errors.push(`${label} confidence must be between 0.00 and 1.00.`);
+ if (teacherReview.questions[index]?.needsAttention === null) errors.push(`${label} needsAttention must be true or false.`);
+ if (!question.issueReason) errors.push(`${label} issue reason is required.`);
+ }
+
+ return errors;
+}
+
+function deriveTitle(currentState) {
+ const count = currentState.questions.length;
+ const topic = currentState.topic || "assignment";
+ return `${currentState.assignmentTitle || "Untitled assignment"} • ${count} question${count === 1 ? "" : "s"} • ${topic}`;
+}
+
+function buildDatasetPills(example) {
+ const pills = [];
+ if (example.topic) pills.push(`topic ${example.topic}`);
+ if (example.difficulty) pills.push(example.difficulty);
+ if (example.questionCount) pills.push(`${example.questionCount} questions`);
+ const reviewed = example.state?.questions?.filter((question) => question.aiFeedback).length || 0;
+ if (reviewed) pills.push(`${reviewed} labeled`);
+ return pills;
+}
+
+function buildAssignmentPayload(currentState) {
+ const normalized = normalizeState(currentState);
+ return {
+ assignmentId: normalized.assignmentId,
+ studentId: normalized.studentId,
+ assignmentTitle: normalized.assignmentTitle,
+ instructions: normalized.instructions,
+ passThreshold: clampScore(normalized.passThreshold),
+ topic: normalized.topic,
+ difficulty: normalized.difficulty,
+ questions: normalized.questions.map((question, index) => ({
+ questionId: clampInteger(question.questionId),
+ position: index + 1,
+ title: question.title,
+ prompt: question.prompt,
+ subject: question.subject,
+ source: question.source,
+ difficulty: question.difficulty,
+ correctAnswer: question.correctAnswer,
+ workedSolution: question.workedSolution,
+ tags: splitTags(question.tags),
+ studentAnswer: question.studentAnswer,
+ workingSteps: question.workingSteps,
+ solveMode: question.solveMode,
+ aiFeedback: question.aiFeedback,
+ understandingScore: clampScore(question.understandingScore),
+ confidence: clampScore(question.confidence),
+ needsAttention: parseBoolean(question.needsAttention),
+ issueReason: question.issueReason,
+ })),
+ };
+}
+
+function normalizeState(raw = {}) {
+ const next = createEmptyState();
+ for (const key of fixedFieldIds) {
+ next[key] = typeof raw[key] === "string" ? raw[key] : next[key];
+ }
+
+ next.questions = Array.isArray(raw.questions) && raw.questions.length
+ ? raw.questions.map((question, index) => normalizeQuestion(question, index))
+ : [];
+
+ if (!next.questions.length && raw.questionPrompt) {
+ next.questions = [normalizeLegacyQuestion(raw)];
+ }
+
+ next.questionCount = String(next.questions.length || clampInteger(raw.questionCount) || 0);
+ return next;
+}
+
+function normalizeLegacyQuestion(raw) {
+ return normalizeQuestion(
+ {
+ questionId: raw.questionId,
+ title: raw.subject || "Legacy question",
+ prompt: raw.questionPrompt,
+ subject: raw.subject || "Mathematics",
+ source: "legacy_helper",
+ difficulty: raw.difficulty || "",
+ correctAnswer: raw.correctAnswer,
+ workedSolution: raw.correctReasoning,
+ tags: raw.tags,
+ studentAnswer: raw.studentAnswer,
+ workingSteps: raw.studentReasoning,
+ solveMode: "show_work",
+ aiFeedback: raw.aiFeedback,
+ understandingScore: raw.understandingScore,
+ confidence: raw.confidence,
+ needsAttention: raw.needsAttention,
+ issueReason: raw.issueReason,
+ },
+ 0,
+ );
+}
+
+function normalizeQuestion(question = {}, index = 0) {
+ return {
+ questionId: stringValue(question.questionId),
+ position: index + 1,
+ title: stringValue(question.title),
+ prompt: stringValue(question.prompt),
+ subject: stringValue(question.subject) || "Mathematics",
+ source: stringValue(question.source) || "rng_generated",
+ difficulty: stringValue(question.difficulty),
+ correctAnswer: stringValue(question.correctAnswer),
+ workedSolution: Array.isArray(question.workedSolution)
+ ? question.workedSolution.join("\n")
+ : stringValue(question.workedSolution),
+ tags: Array.isArray(question.tags) ? question.tags.join(", ") : stringValue(question.tags),
+ studentAnswer: stringValue(question.studentAnswer),
+ workingSteps: stringValue(question.workingSteps),
+ solveMode: stringValue(question.solveMode) || "show_work",
+ aiFeedback: stringValue(question.aiFeedback),
+ understandingScore: stringValue(question.understandingScore),
+ confidence: stringValue(question.confidence),
+ needsAttention: stringValue(question.needsAttention),
+ issueReason: stringValue(question.issueReason),
+ };
+}
+
+function getQuestionUiKey(question, index) {
+ return `${stringValue(question.questionId) || `idx-${index + 1}`}`;
+}
+
+function isQuestionCollapsed(index) {
+ return uiState.collapsedQuestions.has(getQuestionUiKey(state.questions[index], index));
+}
+
+function toggleQuestionCollapsed(index) {
+ const key = getQuestionUiKey(state.questions[index], index);
+ if (uiState.collapsedQuestions.has(key)) {
+ uiState.collapsedQuestions.delete(key);
+ } else {
+ uiState.collapsedQuestions.add(key);
+ }
+}
+
+function expandQuestionAtIndex(index) {
+ if (!state.questions[index]) return;
+ uiState.collapsedQuestions.delete(getQuestionUiKey(state.questions[index], index));
+}
+
+function primeQuestionCollapsing(questionCount, options = {}) {
+ const preserveExisting = options.preserveExisting ?? true;
+ if (!preserveExisting) {
+ uiState.collapsedQuestions.clear();
+ }
+
+ if (questionCount <= 2) {
+ uiState.collapsedQuestions.clear();
+ return;
+ }
+
+ uiState.collapsedQuestions = new Set(
+ state.questions
+ .map((question, index) => ({ key: getQuestionUiKey(question, index), index }))
+ .filter(({ index }) => index > 0)
+ .map(({ key }) => key),
+ );
+ uiState.collapsedQuestions.delete(getQuestionUiKey(state.questions[0], 0));
+ }
+
+function matchesQuestionFilter(question) {
+ if (uiState.questionFilter === "attention") {
+ return parseBoolean(question.needsAttention) === true;
+ }
+
+ if (uiState.questionFilter === "unlabeled") {
+ return !question.aiFeedback || !question.issueReason;
+ }
+
+ return true;
+}
+
+function hasStudentWork(question) {
+ return Boolean(question.studentAnswer || question.workingSteps);
+}
+
+function isQuestionReviewed(question) {
+ return Boolean(question.aiFeedback && question.issueReason);
+}
+
+function buildQuestionStatusPills(question) {
+ const pills = [];
+ pills.push(`${hasStudentWork(question) ? "Student drafted" : "No student work"} `);
+ pills.push(`${isQuestionReviewed(question) ? "Teacher labeled" : "Needs labels"} `);
+ if (parseBoolean(question.needsAttention) === true) {
+ pills.push('Needs attention ');
+ }
+ if (question.understandingScore) {
+ pills.push(`Understanding ${escapeHtml(question.understandingScore)} `);
+ }
+ if (question.confidence) {
+ pills.push(`Confidence ${escapeHtml(question.confidence)} `);
+ }
+ return pills.join("");
+}
+
+function reindexQuestions() {
+ state.questions = state.questions.map((question, index) => ({
+ ...question,
+ position: index + 1,
+ }));
+ state.questionCount = String(state.questions.length);
+}
+
+function createEmptyState() {
+ return {
+ assignmentId: "",
+ studentId: "",
+ assignmentTitle: "",
+ instructions: "",
+ passThreshold: "0.70",
+ topic: "",
+ difficulty: "",
+ questionCount: "0",
+ generatorSeed: "",
+ assignmentSummary: "",
+ recommendedNextStep: "",
+ questions: [],
+ };
+}
+
+function createBlankQuestion(position) {
+ return normalizeQuestion({
+ questionId: "",
+ title: `Question ${position}`,
+ prompt: "",
+ subject: "Mathematics",
+ source: "manual",
+ difficulty: state.difficulty || "",
+ correctAnswer: "",
+ workedSolution: "",
+ tags: "",
+ studentAnswer: "",
+ workingSteps: "",
+ solveMode: "show_work",
+ aiFeedback: "",
+ understandingScore: "",
+ confidence: "",
+ needsAttention: "",
+ issueReason: "",
+ }, position - 1);
+}
+
+function createSampleState() {
+ return normalizeState({
+ assignmentId: "assignment-fractions-01",
+ studentId: "student-17",
+ assignmentTitle: "Fractions review",
+ instructions: "Show all working and explain your reasoning when you can.",
+ passThreshold: "0.70",
+ topic: "fractions",
+ difficulty: "medium",
+ questionCount: "2",
+ generatorSeed: "sample-seed",
+ assignmentSummary:
+ "The student shows a workable idea for finding a fraction of an amount, but the assignment reveals inconsistent understanding when simplifying and converting between equivalent forms.",
+ recommendedNextStep:
+ "Reinforce fraction simplification and equivalent-fraction checks with one worked example before asking the student to retry a similar mixed set.",
+ questions: [
+ {
+ questionId: "401",
+ title: "Find a fraction of an amount",
+ prompt: "Find 3/4 of 20.",
+ subject: "Mathematics",
+ source: "rng_generated",
+ difficulty: "medium",
+ correctAnswer: "15",
+ workedSolution: "First find 1/4 of 20 by dividing 20 by 4 to get 5. Then multiply 5 by 3 to get 15.",
+ tags: "fractions, fraction_of_amount, rng_generated",
+ studentAnswer: "15",
+ workingSteps: "I did 20 divided by 4 which is 5, then 5 times 3 is 15.",
+ solveMode: "show_work",
+ aiFeedback:
+ "The student uses the correct two-step method and explains the quarter-then-multiply structure clearly, showing secure understanding for this question.",
+ understandingScore: "0.90",
+ confidence: "0.95",
+ needsAttention: "false",
+ issueReason: "The response is correct and the working shows sound understanding of finding a fraction of an amount.",
+ },
+ {
+ questionId: "402",
+ title: "Simplify a fraction",
+ prompt: "Simplify 6/8.",
+ subject: "Mathematics",
+ source: "rng_generated",
+ difficulty: "medium",
+ correctAnswer: "3/4",
+ workedSolution: "Divide numerator and denominator by their greatest common factor, 2. 6 ÷ 2 = 3 and 8 ÷ 2 = 4, so 6/8 = 3/4.",
+ tags: "fractions, simplify, rng_generated",
+ studentAnswer: "2/4",
+ workingSteps: "I cut both numbers in half, so 6 became 2 and 8 became 4.",
+ solveMode: "show_work",
+ aiFeedback:
+ "The student knows the fraction should be reduced but does not preserve the equivalence correctly. Their numerator change suggests shaky control of the simplification step rather than secure understanding.",
+ understandingScore: "0.42",
+ confidence: "0.89",
+ needsAttention: "true",
+ issueReason:
+ "The student attempted to simplify but changed 6 to 2 instead of 3, so the reduced fraction is not equivalent to the original.",
+ },
+ ],
+ });
+}
+
+function buildSuggestedAssignmentId(topic, difficulty) {
+ return `assignment-${topic || "topic"}-${difficulty || "level"}-${Date.now()}`;
+}
+
+function buildSuggestedAssignmentTitle(topic, difficulty, count) {
+ const topicLabel = topic ? topic.replace(/_/g, " ") : "Mixed maths";
+ return `${capitalize(topicLabel)} ${difficulty || ""} review • ${count} question${count === 1 ? "" : "s"}`.trim();
+}
+
+function capitalize(text) {
+ return text ? text.charAt(0).toUpperCase() + text.slice(1) : "";
+}
+
+function splitTags(tags) {
+ return String(tags || "")
+ .split(",")
+ .map((tag) => tag.trim())
+ .filter(Boolean);
+}
+
+function splitLines(text) {
+ return String(text || "")
+ .split(/\n+/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+}
+
+function clampScore(raw) {
+ const parsed = Number(raw);
+ if (!Number.isFinite(parsed)) return null;
+ return Math.max(0, Math.min(1, Number(parsed.toFixed(2))));
+}
+
+function formatScore(value) {
+ if (typeof value !== "number" || Number.isNaN(value)) return "";
+ return Math.max(0, Math.min(1, value)).toFixed(2);
+}
+
+function clampInteger(raw) {
+ const parsed = Number.parseInt(raw, 10);
+ if (!Number.isInteger(parsed) || parsed < 1) return null;
+ return parsed;
+}
+
+function parseBoolean(raw) {
+ if (raw === true || raw === "true") return true;
+ if (raw === false || raw === "false") return false;
+ return null;
+}
+
+function formatBoolean(value) {
+ if (value === true) return "true";
+ if (value === false) return "false";
+ return "";
+}
+
+function stringValue(value) {
+ if (value === null || value === undefined) return "";
+ return String(value).trim();
+}
+
+function summarizeText(text, limit) {
+ if (!text) return "";
+ const normalized = text.replace(/\s+/g, " ").trim();
+ if (normalized.length <= limit) return normalized;
+ return `${normalized.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
+}
+
+function renderSelectOptions(options, selected) {
+ return options
+ .map((option) => `${escapeHtml(option)} `)
+ .join("");
+}
+
+function escapeHtml(value) {
+ return String(value || "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
+
+function escapeAttribute(value) {
+ return escapeHtml(value).replaceAll("'", "'");
+}
+
+function isSavedExampleShape(value) {
+ return Boolean(value && typeof value === "object" && typeof value.id === "string" && value.id);
+}
+
+async function copyPreview(text, successMessage) {
+ try {
+ await navigator.clipboard.writeText(text);
+ showToast(successMessage, "success");
+ } catch {
+ showToast("Clipboard copy failed. You can still select and copy manually.", "error");
+ }
+}
+
+function downloadTextFile(filename, content) {
+ const blob = new Blob([content], { type: "application/x-ndjson;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+}
+
+function showToast(message, tone) {
+ toast.textContent = message;
+ toast.className = `toast ${tone}`;
+ clearTimeout(toastTimeout);
+ toastTimeout = setTimeout(() => {
+ toast.className = "toast hidden";
+ }, 3200);
+}
diff --git a/FineTune/public/index.html b/FineTune/public/index.html
new file mode 100644
index 0000000..2a5c006
--- /dev/null
+++ b/FineTune/public/index.html
@@ -0,0 +1,250 @@
+
+
+
+
+
+ BoostAI FineTune Helper
+
+
+
+
+
+
+
Local helper app
+
BoostAI FineTune Data Helper
+
+ Create full assignment-review examples that match the real app shape: one assignment, many question-level labels, and one assignment-level summary.
+
+
+
+ Save assignment example
+ Load sample
+ Clear
+
+
+
+
+
+ Hosted AI:
+ Checking…
+
+
+ Assignment generator:
+ Checking…
+
+
+ Shared workspace:
+ Connecting…
+
+
+ Editors:
+ 1 connected
+
+ Autosave ready
+
+
+
+
+
+
+
+
+
Student submission
+
Draft the whole student submission at once, then tweak any question manually.
+
+
AI draft student submission
+
+
+ One student voice should carry across the full assignment. Each question stores answerText, workingSteps, and solveMode.
+
+
+
+
+
+
+
Teacher review package
+
Generate per-question review labels plus one assignment summary, matching the real production shape.
+
+
AI draft full teacher review
+
+
+
+ Assignment summary
+
+
+
+ Recommended next step
+
+
+
+
+
+
+
Labeled record preview
+ Copy JSON
+
+
+
+
+
+
+
Fine-tune example preview
+ Copy JSON
+
+
+
+
+
+
+
+
Saved dataset
+
No saved examples yet.
+
+
+ Export dataset.jsonl
+ Export train/val split
+
+
+
+ Save full assignment-review examples here. Each saved item becomes one training row.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FineTune/public/styles.css b/FineTune/public/styles.css
new file mode 100644
index 0000000..94cac25
--- /dev/null
+++ b/FineTune/public/styles.css
@@ -0,0 +1,679 @@
+:root {
+ color-scheme: light;
+ --bg: #f6f4fb;
+ --panel: #ffffff;
+ --panel-alt: #f9f7fd;
+ --subpanel: #f3effc;
+ --text: #211a2f;
+ --muted: #6c6383;
+ --accent: #7443da;
+ --accent-soft: #ede6fb;
+ --border: #ddd5ef;
+ --success: #0d8f4f;
+ --danger: #b63a59;
+ --shadow: 0 18px 40px rgba(43, 29, 76, 0.08);
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: linear-gradient(180deg, #faf8ff 0%, var(--bg) 100%);
+ color: var(--text);
+}
+
+button,
+input,
+textarea,
+select {
+ font: inherit;
+}
+
+button {
+ border: none;
+ border-radius: 12px;
+ padding: 0.8rem 1rem;
+ background: var(--accent);
+ color: white;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+button:hover {
+ filter: brightness(1.03);
+}
+
+button:disabled {
+ opacity: 0.6;
+ cursor: progress;
+}
+
+.button-secondary {
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+
+.button-danger {
+ background: #fbe7ed;
+ color: var(--danger);
+}
+
+.button-compact {
+ padding: 0.55rem 0.8rem;
+ border-radius: 10px;
+ font-size: 0.9rem;
+}
+
+.page-shell {
+ max-width: 1600px;
+ margin: 0 auto;
+ padding: 2rem;
+ padding-bottom: 4rem;
+}
+
+.hero,
+.status-bar,
+.card {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 24px;
+ box-shadow: var(--shadow);
+}
+
+.hero {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1.5rem 1.75rem;
+ align-items: end;
+ margin-bottom: 1rem;
+}
+
+.eyebrow {
+ margin: 0 0 0.5rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-size: 0.78rem;
+ font-weight: 800;
+ color: var(--accent);
+}
+
+.hero h1 {
+ margin: 0;
+ font-size: clamp(2rem, 4vw, 3rem);
+}
+
+.hero-copy {
+ margin: 0.75rem 0 0;
+ max-width: 70ch;
+ color: var(--muted);
+ line-height: 1.55;
+}
+
+.hero-actions {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.status-bar {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.95rem 1.25rem;
+ margin-bottom: 1rem;
+ color: var(--muted);
+ font-size: 0.95rem;
+}
+
+.layout-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+ align-items: start;
+}
+
+.editor-shell {
+ display: grid;
+ grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
+ gap: 1rem;
+ align-items: start;
+}
+
+.span-2,
+.dataset-card {
+ grid-column: 1 / -1;
+}
+
+.card {
+ padding: 1.25rem;
+}
+
+.card h2,
+.card h3,
+.card h4 {
+ margin: 0;
+}
+
+.section-copy {
+ margin: 0.35rem 0 0;
+ color: var(--muted);
+ font-size: 0.95rem;
+ line-height: 1.5;
+}
+
+.section-heading {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.form-grid {
+ display: grid;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.two-up {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.three-up {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.four-up {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+label {
+ display: grid;
+ gap: 0.45rem;
+ margin-bottom: 1rem;
+}
+
+label span {
+ font-weight: 700;
+ font-size: 0.95rem;
+}
+
+input,
+textarea,
+select {
+ width: 100%;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background: var(--panel-alt);
+ padding: 0.9rem 1rem;
+ color: var(--text);
+ line-height: 1.5;
+ resize: vertical;
+}
+
+input:focus,
+textarea:focus,
+select:focus {
+ outline: 2px solid rgba(116, 67, 218, 0.18);
+ border-color: var(--accent);
+}
+
+.summary-strip,
+.mini-note {
+ padding: 0.9rem 1rem;
+ border-radius: 16px;
+ border: 1px solid var(--border);
+ background: #fcfbff;
+ color: var(--muted);
+ margin-bottom: 1rem;
+ line-height: 1.5;
+}
+
+.question-toolbar {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
+ margin-bottom: 1rem;
+ padding: 0.9rem 1rem;
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ background: #fcfbff;
+}
+
+.question-toolbar-group {
+ display: flex;
+ gap: 0.6rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.question-toolbar-label {
+ font-size: 0.86rem;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--muted);
+}
+
+.question-toolbar .button-secondary.is-active {
+ background: var(--accent);
+ color: white;
+}
+
+.question-stack {
+ display: grid;
+ gap: 1rem;
+}
+
+.question-card {
+ padding: 1rem;
+ border-radius: 20px;
+ border: 1px solid var(--border);
+ background: var(--panel-alt);
+ display: grid;
+ gap: 0.8rem;
+}
+
+.question-card.is-collapsed {
+ gap: 0.4rem;
+}
+
+.question-card-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: start;
+}
+
+.question-card-header p {
+ margin: 0.35rem 0 0;
+ color: var(--muted);
+ line-height: 1.5;
+}
+
+.question-card-heading {
+ display: grid;
+ gap: 0.45rem;
+ min-width: 0;
+}
+
+.question-heading-topline {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.question-card-actions {
+ justify-content: flex-end;
+}
+
+.question-status-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+}
+
+.question-card-body {
+ display: grid;
+ gap: 0.8rem;
+}
+
+.question-card-body[hidden] {
+ display: none;
+}
+
+.subpanel {
+ padding: 1rem;
+ border-radius: 18px;
+ border: 1px solid var(--border);
+ background: var(--subpanel);
+}
+
+.subpanel h4 {
+ margin-bottom: 0.8rem;
+ font-size: 1rem;
+}
+
+.dataset-actions {
+ justify-content: flex-end;
+}
+
+.empty-state {
+ padding: 1rem 1.1rem;
+ border: 1px dashed var(--border);
+ border-radius: 16px;
+ background: #fcfbff;
+ color: var(--muted);
+}
+
+.dataset-list {
+ display: grid;
+ gap: 0.9rem;
+}
+
+.dataset-item {
+ display: grid;
+ gap: 0.8rem;
+ padding: 1rem 1.1rem;
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ background: var(--panel-alt);
+}
+
+.dataset-item.active {
+ border-color: var(--accent);
+ box-shadow: inset 0 0 0 1px rgba(116, 67, 218, 0.15);
+}
+
+.dataset-item-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: start;
+}
+
+.dataset-item-title {
+ font-size: 1rem;
+ font-weight: 800;
+}
+
+.dataset-item-meta {
+ margin: 0.25rem 0 0;
+ color: var(--muted);
+ font-size: 0.92rem;
+ line-height: 1.45;
+}
+
+.dataset-item-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+}
+
+.dataset-pill {
+ padding: 0.35rem 0.6rem;
+ border-radius: 999px;
+ background: var(--accent-soft);
+ color: var(--accent);
+ font-size: 0.82rem;
+ font-weight: 700;
+}
+
+.pill-success {
+ background: #e3f6ec;
+ color: var(--success);
+}
+
+.pill-warning {
+ background: #fff1d9;
+ color: #a46a00;
+}
+
+.pill-muted {
+ background: #efedf5;
+ color: var(--muted);
+}
+
+.dataset-item-actions {
+ display: flex;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+}
+
+.workspace-nav-card {
+ display: grid;
+ gap: 1rem;
+ position: sticky;
+ top: 1rem;
+ max-height: calc(100vh - 2rem);
+ overflow: hidden;
+}
+
+.workspace-toolbar {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: start;
+}
+
+.workspace-controls {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 160px;
+ gap: 0.85rem;
+ align-items: end;
+}
+
+.control-field {
+ margin-bottom: 0;
+}
+
+.control-field-compact span {
+ font-size: 0.85rem;
+}
+
+.workspace-search-field input,
+.control-field select {
+ padding-block: 0.8rem;
+}
+
+.workspace-meta-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.compact-strip {
+ margin-bottom: 0;
+ padding: 0.75rem 0.9rem;
+ font-size: 0.92rem;
+}
+
+.workspace-actions {
+ display: flex;
+ gap: 0.55rem;
+ flex-wrap: wrap;
+}
+
+.draft-list {
+ display: grid;
+ gap: 0.65rem;
+ overflow: auto;
+ padding-right: 0.2rem;
+}
+
+.draft-item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.85rem;
+ padding: 0.9rem 0.95rem;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: var(--panel-alt);
+ align-items: start;
+}
+
+.draft-item.active {
+ border-color: var(--accent);
+ box-shadow: inset 0 0 0 1px rgba(116, 67, 218, 0.15);
+ background: #f8f4ff;
+}
+
+.draft-item-main {
+ display: grid;
+ gap: 0.45rem;
+ min-width: 0;
+ cursor: pointer;
+}
+
+.draft-item-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+ align-items: baseline;
+}
+
+.draft-item-title {
+ font-size: 0.98rem;
+ font-weight: 800;
+ margin: 0;
+ line-height: 1.3;
+}
+
+.draft-item-meta {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.88rem;
+ line-height: 1.4;
+}
+
+.draft-item-updated {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.82rem;
+ white-space: nowrap;
+}
+
+.draft-item-actions {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.draft-item-actions button {
+ min-width: 82px;
+}
+
+.draft-empty-state {
+ margin-top: 0.25rem;
+}
+
+.editor-card {
+ min-width: 0;
+}
+
+.dataset-item-actions button {
+ padding: 0.65rem 0.9rem;
+ font-size: 0.92rem;
+}
+
+pre {
+ margin: 0;
+ padding: 1rem;
+ border-radius: 16px;
+ border: 1px solid var(--border);
+ background: #171222;
+ color: #f2ecff;
+ overflow: auto;
+ max-height: 520px;
+ font-size: 0.9rem;
+ line-height: 1.5;
+}
+
+code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+.footer-note {
+ margin-top: 1rem;
+ color: var(--muted);
+ background: #fcfbff;
+ line-height: 1.5;
+}
+
+.toast {
+ position: fixed;
+ right: 1.25rem;
+ bottom: 1.25rem;
+ padding: 0.9rem 1.1rem;
+ border-radius: 12px;
+ background: var(--text);
+ color: white;
+ box-shadow: var(--shadow);
+ max-width: 360px;
+ z-index: 10;
+}
+
+.toast.hidden {
+ display: none;
+}
+
+.toast.error {
+ background: var(--danger);
+}
+
+.toast.success {
+ background: var(--success);
+}
+
+@media (max-width: 1200px) {
+ .layout-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .editor-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .span-2,
+ .dataset-card {
+ grid-column: auto;
+ }
+
+ .workspace-nav-card {
+ position: static;
+ max-height: none;
+ }
+
+ .four-up {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 800px) {
+ .page-shell {
+ padding: 1rem;
+ padding-bottom: 3rem;
+ }
+
+ .hero,
+ .status-bar,
+ .section-heading,
+ .dataset-item-header,
+ .workspace-meta-row,
+ .workspace-toolbar,
+ .draft-item-row,
+ .question-card-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .workspace-controls,
+ .draft-item {
+ grid-template-columns: 1fr;
+ }
+
+ .draft-item-actions {
+ justify-content: flex-start;
+ }
+
+ .question-toolbar,
+ .question-toolbar-group,
+ .question-heading-topline {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .two-up,
+ .three-up,
+ .four-up {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/FineTune/server.mjs b/FineTune/server.mjs
new file mode 100644
index 0000000..7710923
--- /dev/null
+++ b/FineTune/server.mjs
@@ -0,0 +1,801 @@
+import express from "express";
+import { createServer } from "node:http";
+import crypto from "node:crypto";
+import path from "node:path";
+import { fileURLToPath, URL } from "node:url";
+import { WebSocketServer } from "ws";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const app = express();
+const host = process.env.HOST || "0.0.0.0";
+const port = Number(process.env.PORT || 4310);
+const server = createServer(app);
+const websocketServer = new WebSocketServer({ noServer: true });
+const sharedWorkspaces = new Map();
+
+app.use(express.json({ limit: "2mb" }));
+app.use(express.static(path.join(__dirname, "public")));
+
+app.get("/health", (_req, res) => {
+ res.json({ ok: true });
+});
+
+app.get("/api/config", (_req, res) => {
+ res.json({
+ hasAiConfig: hasAiConfig(),
+ endpoint: process.env.FINE_TUNE_AI_ENDPOINT || "",
+ model: process.env.FINE_TUNE_AI_MODEL || "",
+ hasQuestionGeneratorConfig: hasQuestionGeneratorConfig(),
+ backendUrl: process.env.FINE_TUNE_BACKEND_URL || "",
+ });
+});
+
+app.post("/api/questions/generate", async (req, res) => {
+ try {
+ assertQuestionGeneratorConfig();
+ const input = sanitizeGeneratorInput(req.body);
+ assertGeneratorInput(input);
+
+ const result = await callQuestionGenerator(input);
+ res.json(result);
+ } catch (error) {
+ handleError(res, error);
+ }
+});
+
+app.post("/api/assignment/generate", async (req, res) => {
+ try {
+ assertQuestionGeneratorConfig();
+ const input = sanitizeGeneratorInput(req.body);
+ assertGeneratorInput(input);
+
+ const result = await callQuestionGenerator(input);
+ res.json({
+ seed: result.seed ?? null,
+ count: result.count ?? input.count,
+ questions: Array.isArray(result.data) ? result.data.map(mapGeneratedQuestion) : [],
+ });
+ } catch (error) {
+ handleError(res, error);
+ }
+});
+
+app.post("/api/assignment/student-draft", async (req, res) => {
+ try {
+ assertAiConfig();
+ const input = sanitizeAssignmentInput(req.body);
+ assertAssignmentForStudentDraft(input);
+
+ const result = await callAiJson({
+ schemaName: "fine_tune_assignment_student_draft",
+ schema: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ questions: {
+ type: "array",
+ items: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ questionId: { type: "integer" },
+ answerText: { type: "string" },
+ workingSteps: { type: "string" },
+ solveMode: { type: "string" },
+ },
+ required: ["questionId", "answerText", "workingSteps", "solveMode"],
+ },
+ },
+ },
+ required: ["questions"],
+ },
+ systemPrompt:
+ "You are helping create high-quality fine-tuning data for assignment review. Generate realistic student submissions for every question in the assignment. The work should sound like one student completed the whole assignment. Some answers may be correct, partially correct, or incorrect, but they should stay plausible and classroom-realistic. Return only the requested JSON.",
+ userPrompt: buildStudentDraftPrompt(input),
+ });
+
+ res.json({
+ questions: normalizeStudentDraftQuestions(input.questions, result.questions),
+ });
+ } catch (error) {
+ handleError(res, error);
+ }
+});
+
+app.post("/api/assignment/teacher-draft", async (req, res) => {
+ try {
+ assertAiConfig();
+ const input = sanitizeAssignmentInput(req.body);
+ assertAssignmentForTeacherDraft(input);
+
+ const result = await callAiJson({
+ schemaName: "fine_tune_assignment_teacher_draft",
+ schema: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ questions: {
+ type: "array",
+ items: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ questionId: { type: "integer" },
+ aiFeedback: { type: "string" },
+ understandingScore: { type: "number" },
+ confidence: { type: "number" },
+ needsAttention: { type: "boolean" },
+ issueReason: { type: "string" },
+ },
+ required: [
+ "questionId",
+ "aiFeedback",
+ "understandingScore",
+ "confidence",
+ "needsAttention",
+ "issueReason",
+ ],
+ },
+ },
+ assignmentSummary: { type: "string" },
+ recommendedNextStep: { type: "string" },
+ },
+ required: ["questions", "assignmentSummary", "recommendedNextStep"],
+ },
+ systemPrompt:
+ "You are helping create fine-tuning labels for a teacher review system. Review the full assignment in one pass. Return one question-level review for every question using the exact backend-aligned fields questionId, aiFeedback, understandingScore, confidence, needsAttention, issueReason, plus assignmentSummary and recommendedNextStep for the whole assignment. Be strict but fair. Focus on conceptual understanding, not just final correctness. Return only the requested JSON.",
+ userPrompt: buildTeacherDraftPrompt(input),
+ });
+
+ res.json({
+ questions: normalizeTeacherDraftQuestions(input.questions, result.questions),
+ assignmentSummary: stringOrEmpty(result.assignmentSummary),
+ recommendedNextStep: stringOrEmpty(result.recommendedNextStep),
+ });
+ } catch (error) {
+ handleError(res, error);
+ }
+});
+
+app.get("*", (_req, res) => {
+ res.sendFile(path.join(__dirname, "public", "index.html"));
+});
+
+server.on("upgrade", (req, socket, head) => {
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
+ if (url.pathname !== "/ws") {
+ socket.destroy();
+ return;
+ }
+
+ websocketServer.handleUpgrade(req, socket, head, (ws) => {
+ websocketServer.emit("connection", ws, req, url);
+ });
+});
+
+websocketServer.on("connection", (ws, _req, url) => {
+ const workspaceId = sanitizeWorkspaceId(url.searchParams.get("workspace"));
+ const clientId = crypto.randomUUID();
+ const workspace = getSharedWorkspace(workspaceId);
+
+ workspace.clients.set(clientId, ws);
+ sendSocketMessage(ws, {
+ type: "workspace:init",
+ workspaceId,
+ clientId,
+ version: workspace.version,
+ state: cloneJson(workspace.state),
+ presenceCount: workspace.clients.size,
+ updatedAt: workspace.updatedAt,
+ });
+ broadcastWorkspacePresence(workspaceId);
+
+ ws.on("message", (raw) => {
+ let message;
+ try {
+ message = JSON.parse(String(raw));
+ } catch {
+ sendSocketMessage(ws, { type: "workspace:error", message: "Invalid collaboration payload." });
+ return;
+ }
+
+ if (message?.type !== "workspace:update") {
+ sendSocketMessage(ws, { type: "workspace:error", message: "Unsupported collaboration message." });
+ return;
+ }
+
+ if (!isPlainObject(message.state)) {
+ sendSocketMessage(ws, { type: "workspace:error", message: "Workspace state must be an object." });
+ return;
+ }
+
+ workspace.version += 1;
+ workspace.state = cloneJson(message.state);
+ workspace.updatedAt = new Date().toISOString();
+ broadcastWorkspaceSnapshot(workspaceId, clientId);
+ });
+
+ ws.on("close", () => {
+ workspace.clients.delete(clientId);
+ broadcastWorkspacePresence(workspaceId);
+ });
+
+ ws.on("error", () => {});
+});
+
+server.listen(port, host, () => {
+ console.log(`FineTune helper listening on http://${host}:${port}`);
+});
+
+function getSharedWorkspace(workspaceId) {
+ if (!sharedWorkspaces.has(workspaceId)) {
+ sharedWorkspaces.set(workspaceId, {
+ version: 0,
+ updatedAt: null,
+ state: null,
+ clients: new Map(),
+ });
+ }
+
+ return sharedWorkspaces.get(workspaceId);
+}
+
+function broadcastWorkspaceSnapshot(workspaceId, actorClientId) {
+ const workspace = getSharedWorkspace(workspaceId);
+ broadcastWorkspaceMessage(workspaceId, {
+ type: "workspace:snapshot",
+ workspaceId,
+ version: workspace.version,
+ state: cloneJson(workspace.state),
+ actorClientId,
+ presenceCount: workspace.clients.size,
+ updatedAt: workspace.updatedAt,
+ });
+}
+
+function broadcastWorkspacePresence(workspaceId) {
+ const workspace = getSharedWorkspace(workspaceId);
+ broadcastWorkspaceMessage(workspaceId, {
+ type: "workspace:presence",
+ workspaceId,
+ presenceCount: workspace.clients.size,
+ version: workspace.version,
+ });
+}
+
+function broadcastWorkspaceMessage(workspaceId, payload) {
+ const workspace = getSharedWorkspace(workspaceId);
+ for (const ws of workspace.clients.values()) {
+ sendSocketMessage(ws, payload);
+ }
+}
+
+function sendSocketMessage(ws, payload) {
+ if (ws.readyState !== 1) return;
+ ws.send(JSON.stringify(payload));
+}
+
+function sanitizeWorkspaceId(value) {
+ const raw = String(value || "shared").trim();
+ if (!raw) return "shared";
+ return /^[a-zA-Z0-9._-]{1,64}$/.test(raw) ? raw : "shared";
+}
+
+function isPlainObject(value) {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+function cloneJson(value) {
+ return value == null ? null : JSON.parse(JSON.stringify(value));
+}
+
+function hasAiConfig() {
+ return Boolean(process.env.FINE_TUNE_AI_ENDPOINT && process.env.FINE_TUNE_AI_API_KEY && process.env.FINE_TUNE_AI_MODEL);
+}
+
+function hasQuestionGeneratorConfig() {
+ return Boolean(process.env.FINE_TUNE_BACKEND_URL && process.env.FINE_TUNE_BACKEND_TOKEN);
+}
+
+function assertAiConfig() {
+ if (!hasAiConfig()) {
+ const error = new Error("AI config is missing. Set FINE_TUNE_AI_ENDPOINT, FINE_TUNE_AI_API_KEY, and FINE_TUNE_AI_MODEL.");
+ error.status = 503;
+ throw error;
+ }
+}
+
+function assertQuestionGeneratorConfig() {
+ if (!hasQuestionGeneratorConfig()) {
+ const error = new Error(
+ "Question generator config is missing. Set FINE_TUNE_BACKEND_URL and FINE_TUNE_BACKEND_TOKEN.",
+ );
+ error.status = 503;
+ throw error;
+ }
+}
+
+function sanitizeGeneratorInput(payload = {}) {
+ return {
+ topic: stringOrEmpty(payload.topic),
+ difficulty: stringOrEmpty(payload.difficulty),
+ count: integerOrDefault(payload.count, 1),
+ };
+}
+
+function sanitizeAssignmentInput(payload = {}) {
+ const rawQuestions = Array.isArray(payload.questions) ? payload.questions : [];
+
+ return {
+ assignmentId: stringOrEmpty(payload.assignmentId),
+ studentId: stringOrEmpty(payload.studentId),
+ assignmentTitle: stringOrEmpty(payload.assignmentTitle),
+ instructions: stringOrEmpty(payload.instructions),
+ passThreshold: numberOrNull(payload.passThreshold),
+ topic: stringOrEmpty(payload.topic),
+ difficulty: stringOrEmpty(payload.difficulty),
+ questions: rawQuestions.map((question, index) => sanitizeQuestionInput(question, index)),
+ };
+}
+
+function sanitizeQuestionInput(payload = {}, index = 0) {
+ return {
+ questionId: integerOrNull(payload.questionId),
+ position: integerOrDefault(payload.position, index + 1),
+ title: stringOrEmpty(payload.title),
+ prompt: stringOrEmpty(payload.prompt),
+ subject: stringOrEmpty(payload.subject),
+ source: stringOrEmpty(payload.source),
+ difficulty: stringOrEmpty(payload.difficulty),
+ correctAnswer: stringOrEmpty(payload.correctAnswer),
+ workedSolution: stringOrEmpty(payload.workedSolution),
+ tags: stringArray(payload.tags),
+ studentAnswer: stringOrEmpty(payload.studentAnswer),
+ workingSteps: stringOrEmpty(payload.workingSteps),
+ solveMode: stringOrEmpty(payload.solveMode) || "show_work",
+ aiFeedback: stringOrEmpty(payload.aiFeedback),
+ understandingScore: numberOrNull(payload.understandingScore),
+ confidence: numberOrNull(payload.confidence),
+ needsAttention: booleanOrNull(payload.needsAttention),
+ issueReason: stringOrEmpty(payload.issueReason),
+ };
+}
+
+function assertGeneratorInput(input) {
+ if (!input.topic) {
+ const error = new Error("Topic is required.");
+ error.status = 400;
+ throw error;
+ }
+
+ if (!input.difficulty) {
+ const error = new Error("Difficulty is required.");
+ error.status = 400;
+ throw error;
+ }
+
+ if (!Number.isInteger(input.count) || input.count < 1 || input.count > 25) {
+ const error = new Error("Question count must be between 1 and 25.");
+ error.status = 400;
+ throw error;
+ }
+}
+
+function assertAssignmentForStudentDraft(input) {
+ assertAssignmentBase(input);
+
+ for (const question of input.questions) {
+ if (!question.correctAnswer) {
+ const error = new Error(`Question ${question.position} is missing a correct answer.`);
+ error.status = 400;
+ throw error;
+ }
+ if (!question.workedSolution) {
+ const error = new Error(`Question ${question.position} is missing a worked solution.`);
+ error.status = 400;
+ throw error;
+ }
+ }
+}
+
+function assertAssignmentForTeacherDraft(input) {
+ assertAssignmentBase(input);
+
+ for (const question of input.questions) {
+ if (!question.studentAnswer && !question.workingSteps) {
+ const error = new Error(`Question ${question.position} needs student work before teacher review can be drafted.`);
+ error.status = 400;
+ throw error;
+ }
+ }
+}
+
+function assertAssignmentBase(input) {
+ if (!input.assignmentTitle) {
+ const error = new Error("Assignment title is required.");
+ error.status = 400;
+ throw error;
+ }
+
+ if (!input.studentId) {
+ const error = new Error("Student ID is required.");
+ error.status = 400;
+ throw error;
+ }
+
+ if (!Array.isArray(input.questions) || input.questions.length === 0) {
+ const error = new Error("At least one question is required.");
+ error.status = 400;
+ throw error;
+ }
+
+ for (const question of input.questions) {
+ if (!Number.isInteger(question.questionId) || question.questionId < 1) {
+ const error = new Error(`Question ${question.position} needs a valid question ID.`);
+ error.status = 400;
+ throw error;
+ }
+ if (!question.prompt) {
+ const error = new Error(`Question ${question.position} is missing a prompt.`);
+ error.status = 400;
+ throw error;
+ }
+ }
+}
+
+async function callQuestionGenerator({ topic, difficulty, count }) {
+ const endpoint = `${trimTrailingSlash(process.env.FINE_TUNE_BACKEND_URL)}/api/questions/generate`;
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${process.env.FINE_TUNE_BACKEND_TOKEN}`,
+ },
+ body: JSON.stringify({ topic, difficulty, count }),
+ });
+
+ if (!response.ok) {
+ const errorBody = await response.text();
+ const error = new Error(`Question generator failed (${response.status}): ${errorBody}`);
+ error.status = response.status;
+ throw error;
+ }
+
+ return response.json();
+}
+
+function mapGeneratedQuestion(generated, index) {
+ const question = generated?.question || {};
+ const workedSolution = Array.isArray(generated?.worked_solution) ? generated.worked_solution : [];
+ const tags = Array.isArray(generated?.tags) ? generated.tags.filter((tag) => typeof tag === "string" && tag.trim()) : [];
+
+ return {
+ questionId: integerOrNull(question.id),
+ position: index + 1,
+ title: stringOrEmpty(question.title),
+ prompt: stringOrEmpty(question.prompt),
+ subject: stringOrEmpty(question.subject) || "Mathematics",
+ source: stringOrEmpty(question.source),
+ difficulty: stringOrEmpty(question.difficulty),
+ correctAnswer: stringOrEmpty(question.correct_answer),
+ workedSolution: workedSolution.join("\n"),
+ tags,
+ studentAnswer: "",
+ workingSteps: "",
+ solveMode: "show_work",
+ aiFeedback: "",
+ understandingScore: null,
+ confidence: null,
+ needsAttention: null,
+ issueReason: "",
+ };
+}
+
+function buildStudentDraftPrompt(input) {
+ return [
+ `Assignment ID: ${input.assignmentId || "draft-assignment"}`,
+ `Assignment title: ${input.assignmentTitle}`,
+ `Instructions: ${input.instructions || "No extra instructions."}`,
+ `Student ID: ${input.studentId}`,
+ `Pass threshold: ${typeof input.passThreshold === "number" ? input.passThreshold : "Not set"}`,
+ "Generate a realistic student submission for every question below.",
+ ...input.questions.map((question) =>
+ [
+ `Question ${question.position}`,
+ `questionId: ${question.questionId}`,
+ `title: ${question.title || "Untitled"}`,
+ `prompt: ${question.prompt}`,
+ `subject: ${question.subject || "Mathematics"}`,
+ `difficulty: ${question.difficulty || input.difficulty || "Not specified"}`,
+ `tags: ${question.tags.join(", ") || "None"}`,
+ `correctAnswer: ${question.correctAnswer}`,
+ `workedSolution: ${question.workedSolution}`,
+ "Return answerText, workingSteps, and solveMode for this question.",
+ ].join("\n"),
+ ),
+ ].join("\n\n");
+}
+
+function buildTeacherDraftPrompt(input) {
+ return [
+ `Assignment ID: ${input.assignmentId || "draft-assignment"}`,
+ `Assignment title: ${input.assignmentTitle}`,
+ `Instructions: ${input.instructions || "No extra instructions."}`,
+ `Student ID: ${input.studentId}`,
+ `Pass threshold: ${typeof input.passThreshold === "number" ? input.passThreshold : "Not set"}`,
+ "Review the full assignment in one pass. Return every question review plus an assignmentSummary and recommendedNextStep.",
+ ...input.questions.map((question) =>
+ [
+ `Question ${question.position}`,
+ `questionId: ${question.questionId}`,
+ `title: ${question.title || "Untitled"}`,
+ `prompt: ${question.prompt}`,
+ `subject: ${question.subject || "Mathematics"}`,
+ `source: ${question.source || "rng_generated"}`,
+ `correctAnswer: ${question.correctAnswer}`,
+ `questionTags: ${question.tags.join(", ") || "None"}`,
+ `solveMode: ${question.solveMode || "show_work"}`,
+ `answerText: ${question.studentAnswer || "No answer provided."}`,
+ `workingSteps: ${question.workingSteps || "No working shown."}`,
+ `answerStatus: ${deriveAnswerStatus(question)}`,
+ `isCorrect: ${deriveIsCorrect(question)}`,
+ ].join("\n"),
+ ),
+ ].join("\n\n");
+}
+
+function normalizeStudentDraftQuestions(sourceQuestions, generatedQuestions) {
+ const generatedById = new Map();
+ if (Array.isArray(generatedQuestions)) {
+ for (const item of generatedQuestions) {
+ const questionId = integerOrNull(item?.questionId);
+ if (questionId) generatedById.set(questionId, item);
+ }
+ }
+
+ return sourceQuestions.map((question, index) => {
+ const draft = generatedById.get(question.questionId) || generatedQuestions?.[index] || {};
+ return {
+ questionId: question.questionId,
+ answerText: stringOrEmpty(draft.answerText),
+ workingSteps: stringOrEmpty(draft.workingSteps),
+ solveMode: stringOrEmpty(draft.solveMode) || question.solveMode || "show_work",
+ };
+ });
+}
+
+function normalizeTeacherDraftQuestions(sourceQuestions, generatedQuestions) {
+ const generatedById = new Map();
+ if (Array.isArray(generatedQuestions)) {
+ for (const item of generatedQuestions) {
+ const questionId = integerOrNull(item?.questionId);
+ if (questionId) generatedById.set(questionId, item);
+ }
+ }
+
+ return sourceQuestions.map((question, index) => {
+ const draft = generatedById.get(question.questionId) || generatedQuestions?.[index] || {};
+ return {
+ questionId: question.questionId,
+ aiFeedback: stringOrEmpty(draft.aiFeedback),
+ understandingScore: clampScore(draft.understandingScore),
+ confidence: clampScore(draft.confidence),
+ needsAttention: booleanOrDefault(draft.needsAttention, null),
+ issueReason: stringOrEmpty(draft.issueReason),
+ };
+ });
+}
+
+function deriveAnswerStatus(question) {
+ if (!question.studentAnswer && !question.workingSteps) return "unanswered";
+ return "submitted";
+}
+
+function deriveIsCorrect(question) {
+ if (!question.studentAnswer) return false;
+ return normalizeComparable(question.studentAnswer) === normalizeComparable(question.correctAnswer);
+}
+
+function normalizeComparable(value) {
+ return String(value || "")
+ .toLowerCase()
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function stringOrEmpty(value) {
+ return typeof value === "string" ? value.trim() : "";
+}
+
+function stringArray(value) {
+ if (Array.isArray(value)) {
+ return value.map((item) => stringOrEmpty(item)).filter(Boolean);
+ }
+ if (typeof value === "string") {
+ return value
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean);
+ }
+ return [];
+}
+
+function integerOrNull(value) {
+ const parsed = Number.parseInt(String(value ?? "").trim(), 10);
+ if (!Number.isInteger(parsed) || parsed < 1) return null;
+ return parsed;
+}
+
+function integerOrDefault(value, fallback) {
+ const parsed = integerOrNull(value);
+ return parsed ?? fallback;
+}
+
+function numberOrNull(value) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) return null;
+ return parsed;
+}
+
+function clampScore(value) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed)) return null;
+ return Math.max(0, Math.min(1, Number(parsed.toFixed(2))));
+}
+
+function booleanOrNull(value) {
+ if (value === true || value === "true") return true;
+ if (value === false || value === "false") return false;
+ return null;
+}
+
+function booleanOrDefault(value, fallback) {
+ const parsed = booleanOrNull(value);
+ return parsed === null ? fallback : parsed;
+}
+
+function trimTrailingSlash(value) {
+ return String(value || "").replace(/\/+$/, "");
+}
+
+async function callAiJson({ systemPrompt, userPrompt, schemaName, schema }) {
+ const endpoint = process.env.FINE_TUNE_AI_ENDPOINT;
+ const model = process.env.FINE_TUNE_AI_MODEL;
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: buildHeaders(endpoint),
+ body: JSON.stringify(buildRequestBody({ endpoint, model, systemPrompt, userPrompt, schemaName, schema })),
+ });
+
+ if (!response.ok) {
+ const errorBody = await response.text();
+ const error = new Error(`AI request failed (${response.status}): ${errorBody}`);
+ error.status = response.status;
+ throw error;
+ }
+
+ const payload = await response.json();
+ const rawText = isChatEndpoint(endpoint) ? extractChatText(payload) : extractResponsesText(payload);
+
+ if (!rawText) {
+ throw new Error("AI response did not include usable text output.");
+ }
+
+ return JSON.parse(rawText);
+}
+
+function buildHeaders(endpoint) {
+ const headers = {
+ "content-type": "application/json",
+ };
+
+ if (isAzureEndpoint(endpoint)) {
+ headers["api-key"] = process.env.FINE_TUNE_AI_API_KEY;
+ } else {
+ headers.authorization = `Bearer ${process.env.FINE_TUNE_AI_API_KEY}`;
+ }
+
+ return headers;
+}
+
+function buildRequestBody({ endpoint, model, systemPrompt, userPrompt, schemaName, schema }) {
+ if (isChatEndpoint(endpoint)) {
+ const body = {
+ model,
+ temperature: 0,
+ messages: [
+ { role: "system", content: systemPrompt },
+ { role: "user", content: userPrompt },
+ ],
+ response_format: {
+ type: "json_schema",
+ json_schema: {
+ name: schemaName,
+ strict: true,
+ schema,
+ },
+ },
+ };
+
+ if (!isAzureEndpoint(endpoint)) {
+ body.chat_template_kwargs = { enable_thinking: false };
+ }
+
+ return body;
+ }
+
+ return {
+ model,
+ input: [
+ {
+ role: "system",
+ content: [{ type: "input_text", text: systemPrompt }],
+ },
+ {
+ role: "user",
+ content: [{ type: "input_text", text: userPrompt }],
+ },
+ ],
+ text: {
+ format: {
+ type: "json_schema",
+ name: schemaName,
+ strict: true,
+ schema,
+ },
+ },
+ };
+}
+
+function isChatEndpoint(endpoint) {
+ return typeof endpoint === "string" && endpoint.includes("/chat/completions");
+}
+
+function isAzureEndpoint(endpoint) {
+ return typeof endpoint === "string" && (endpoint.includes("cognitiveservices.azure.com") || endpoint.includes(".openai.azure.com"));
+}
+
+function extractChatText(payload) {
+ const content = payload?.choices?.[0]?.message?.content;
+ if (typeof content === "string") return content;
+ if (!Array.isArray(content)) return "";
+
+ return content
+ .map((part) => {
+ if (typeof part === "string") return part;
+ if (part && typeof part.text === "string") return part.text;
+ return "";
+ })
+ .filter(Boolean)
+ .join("\n");
+}
+
+function extractResponsesText(payload) {
+ if (typeof payload?.output_text === "string" && payload.output_text.trim()) {
+ return payload.output_text;
+ }
+
+ const queue = [payload];
+ while (queue.length) {
+ const current = queue.shift();
+ if (!current || typeof current !== "object") continue;
+ if (typeof current.output_text === "string" && current.output_text.trim()) return current.output_text;
+ if (typeof current.text === "string" && current.text.trim()) return current.text;
+
+ for (const value of Object.values(current)) {
+ if (Array.isArray(value)) queue.push(...value);
+ else if (value && typeof value === "object") queue.push(value);
+ }
+ }
+
+ return "";
+}
+
+function handleError(res, error) {
+ const status = Number.isInteger(error?.status) ? error.status : 500;
+ res.status(status).json({
+ message: error instanceof Error ? error.message : "Unexpected error",
+ });
+}
diff --git a/Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.data.ts b/Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.data.ts
index f1b8743..a3c990f 100644
--- a/Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.data.ts
+++ b/Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.data.ts
@@ -1,5 +1,5 @@
import { apiFetchJson } from "../../../lib/api";
-import type { ApiAssignment, ApiClassroom, ApiListResponse, ApiReviewQueueItem, ApiReviewSummary, ApiStudent } from "../../../lib/api-types";
+import type { ApiAssignment, ApiAssignmentStudentQuestionDetail, ApiClassroom, ApiListResponse, ApiReviewQueueItem, ApiReviewSummary, ApiStudent } from "../../../lib/api-types";
import {
getAssignmentReviewHref,
getDashboardTeacherClassroomHref,
@@ -10,6 +10,7 @@ import {
buildTeacherShell,
formatRelativeTime,
formatShortDate,
+ formatCombinedScoreLabel,
initialsFor,
queueStatusLabel,
queueStatusTone,
@@ -45,6 +46,43 @@ export const getTeacherClassroomDetailData = async (
const students = studentsResponse.data.slice().sort((left, right) => left.full_name.localeCompare(right.full_name));
const reviewSummaryByAssignment = new Map(reviewSummaryEntries);
const reviewQueueByAssignment = new Map(reviewQueueEntries);
+ const scoredAssignments = classroomAssignments.filter((assignment) => assignment.status !== "draft");
+
+ const studentScoreEntries = await Promise.all(
+ students.map(async (student) => {
+ const scoreRows = await Promise.all(
+ scoredAssignments.map(async (assignment) => {
+ const questions = (
+ await apiFetchJson>(`/api/assignments/${assignment.id}/students/${student.id}/questions`)
+ ).data;
+ const overallScore = questions.find((question) => typeof question.overall_score === "number")?.overall_score;
+ return typeof overallScore === "number" ? overallScore : null;
+ }),
+ );
+
+ const numericScores = scoreRows.filter((score): score is number => typeof score === "number");
+ const combinedScore = numericScores.length > 0 ? numericScores.reduce((sum, value) => sum + value, 0) : null;
+
+ return [student.id, { combinedScore, scoredAssignments: numericScores.length }] as const;
+ }),
+ );
+
+ const scoreByStudentId = new Map(studentScoreEntries);
+ const endangeredRankByStudentId = new Map(
+ studentScoreEntries
+ .filter(([, entry]) => entry.combinedScore != null)
+ .sort((left, right) => {
+ const scoreDelta = (left[1].combinedScore ?? Number.POSITIVE_INFINITY) - (right[1].combinedScore ?? Number.POSITIVE_INFINITY);
+ if (scoreDelta !== 0) return scoreDelta;
+ const scoredAssignmentDelta = left[1].scoredAssignments - right[1].scoredAssignments;
+ if (scoredAssignmentDelta !== 0) return scoredAssignmentDelta;
+ const leftStudent = students.find((student) => student.id === left[0]);
+ const rightStudent = students.find((student) => student.id === right[0]);
+ return (leftStudent?.full_name ?? "").localeCompare(rightStudent?.full_name ?? "");
+ })
+ .slice(0, 3)
+ .map(([studentId], index) => [studentId, (index + 1) as 1 | 2 | 3]),
+ );
const selectedStudent =
(selectedStudentId != null ? students.find((student) => student.id === selectedStudentId) : null) ?? students[0] ?? null;
@@ -57,6 +95,7 @@ export const getTeacherClassroomDetailData = async (
const liveRedoCount = studentRedoRows.filter((entry) => entry.assignment.status !== "closed").length;
const closedRedoCount = studentRedoRows.filter((entry) => entry.assignment.status === "closed").length;
const submittedCount = studentRedoRows.filter((entry) => (entry.row?.submitted_questions ?? 0) > 0).length;
+ const scoring = scoreByStudentId.get(student.id) ?? { combinedScore: null, scoredAssignments: 0 };
return {
id: student.id,
@@ -65,9 +104,11 @@ export const getTeacherClassroomDetailData = async (
initials: initialsFor(student.full_name),
statusLabel: submittedCount > 0 ? "Needs review" : liveRedoCount > 0 ? "Redo active" : closedRedoCount > 0 ? "Redo history" : "No redo",
redoCountLabel: `${studentRedoRows.length} redo assignment${studentRedoRows.length === 1 ? "" : "s"}`,
+ combinedScoreLabel: formatCombinedScoreLabel(scoring.combinedScore, scoring.scoredAssignments),
note: studentNote(submittedCount, liveRedoCount, closedRedoCount),
href: getDashboardTeacherClassroomHref(classroomId, student.id),
selected: student.id === selectedStudent?.id,
+ endangeredRank: endangeredRankByStudentId.get(student.id) ?? null,
};
});
diff --git a/Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.helpers.ts b/Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.helpers.ts
index 61e6498..a4f393b 100644
--- a/Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.helpers.ts
+++ b/Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.helpers.ts
@@ -105,3 +105,11 @@ export const studentNote = (submittedCount: number, activeRedoCount: number, clo
if (closedRedoCount > 0) return `${closedRedoCount} closed redo assignment${closedRedoCount === 1 ? "" : "s"} available for reference.`;
return "No individual redo assignments for this student yet.";
};
+
+export const formatCombinedScoreLabel = (combinedScore: number | null, scoredAssignments: number) => {
+ if (combinedScore == null || scoredAssignments <= 0) {
+ return "No scored assignments yet";
+ }
+
+ return `Combined score ${combinedScore.toFixed(1)} across ${scoredAssignments} assignment${scoredAssignments === 1 ? "" : "s"}`;
+};
diff --git a/Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.tsx b/Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.tsx
index 75666cd..2e2bcfc 100644
--- a/Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.tsx
+++ b/Frontend/src/components/dashboard/teacher/dashboard-teacher-classroom-detail.tsx
@@ -52,7 +52,16 @@ const DashboardTeacherClassroomDetail: Component = (props) => {