Before Fine Tune

This commit is contained in:
MangoPig
2026-05-26 13:43:09 +01:00
parent 4f79137d89
commit f29aff25f5
35 changed files with 6953 additions and 142 deletions

View File

@@ -1 +0,0 @@
RgIlJyE1N29vsJg2hyEPwkyf4Fkf7vWFNZggxti97pI=

View File

@@ -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.
@@ -143,55 +145,10 @@ Interpretation guidance:
- 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)
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:

View File

@@ -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)
}
})
}

View File

@@ -14,6 +14,10 @@ type Config struct {
DatabaseURL string
JWTSecret string
SessionCookie string
MockDataDir string
AdminReseedEnabled bool
AdminReseedSecret string
ReseedPagePassword string
AIReviewEndpoint string
AIReviewAPIKey string
AIReviewModel string
@@ -27,6 +31,10 @@ func Load() *Config {
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
}
}

View File

@@ -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,
})
}

View File

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

View File

@@ -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)
}

View File

@@ -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),
}
}

View File

@@ -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)
}

View File

@@ -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(`<div class="notice notice-error">` + html.EscapeString(data.Error) + `</div>`)
}
if data.Success != "" {
statusHTML.WriteString(`<div class="notice notice-success">` + html.EscapeString(data.Success) + `</div>`)
}
if data.Summary != nil {
statusHTML.WriteString(`<pre class="summary">`)
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(`</pre>`)
}
var body strings.Builder
if data.Authorized {
body.WriteString(`
<div class="card">
<h2>Reseed database</h2>
<p>This will clear seeded app data and repopulate it from Mock-Data.</p>
<form method="post" action="/reseed/run">
<button class="danger" type="submit">Reseed now</button>
</form>
<form method="post" action="/reseed/logout">
<button type="submit">Lock page</button>
</form>
</div>
`)
} else {
body.WriteString(`
<div class="card">
<h2>Unlock reseed</h2>
<form method="post" action="/reseed/login">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required />
<button type="submit">Unlock</button>
</form>
</div>
`)
}
return fmt.Sprintf(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BoostAI Reseed</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
font-family: Inter, Arial, sans-serif;
background: #0f172a;
color: #e2e8f0;
}
main {
max-width: 720px;
margin: 48px auto;
padding: 0 20px 48px;
}
h1, h2 { margin-top: 0; }
.card {
background: #111827;
border: 1px solid #334155;
border-radius: 16px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 12px 30px rgba(0,0,0,0.25);
}
.meta {
display: grid;
grid-template-columns: 180px 1fr;
gap: 8px 12px;
font-size: 14px;
margin-bottom: 20px;
}
.meta strong { color: #93c5fd; }
label {
display: block;
margin-bottom: 8px;
font-weight: 600;
}
input {
width: 100%%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid #475569;
background: #0f172a;
color: #e2e8f0;
margin-bottom: 14px;
}
button {
padding: 12px 16px;
border-radius: 10px;
border: 0;
cursor: pointer;
font-weight: 700;
background: #38bdf8;
color: #082f49;
margin-right: 12px;
margin-bottom: 12px;
}
button.danger {
background: #f87171;
color: #450a0a;
}
.notice {
padding: 14px 16px;
border-radius: 12px;
margin-bottom: 16px;
font-weight: 600;
}
.notice-error {
background: rgba(220, 38, 38, 0.18);
border: 1px solid rgba(248, 113, 113, 0.45);
color: #fecaca;
}
.notice-success {
background: rgba(22, 163, 74, 0.18);
border: 1px solid rgba(74, 222, 128, 0.45);
color: #bbf7d0;
}
.summary {
background: #020617;
border: 1px solid #334155;
border-radius: 12px;
padding: 16px;
overflow: auto;
}
</style>
</head>
<body>
<main>
<div class="card">
<h1>BoostAI reseed</h1>
<div class="meta">
<strong>Environment</strong><span>%s</span>
<strong>Mock data path</strong><span>%s</span>
<strong>Mode</strong><span>Browser-protected destructive reseed</span>
</div>
%s
</div>
%s
</main>
</body>
</html>`,
html.EscapeString(data.Environment),
html.EscapeString(data.MockDataDir),
statusHTML.String(),
body.String(),
)
}

View File

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

View File

@@ -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)

View File

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

View File

@@ -7,6 +7,10 @@
reverse_proxy {$BACKEND_UPSTREAM}
}
handle /reseed* {
reverse_proxy {$BACKEND_UPSTREAM}
}
handle {
reverse_proxy {$FRONTEND_UPSTREAM}
}

23
Caddyfile.prod-b Normal file
View File

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

6
FineTune/.env.example Normal file
View File

@@ -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

81
FineTune/README.md Normal file
View File

@@ -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 <token>
```
so it can call the protected backend generator endpoint from the separate local helper app.

View File

@@ -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:

850
FineTune/package-lock.json generated Normal file
View File

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

16
FineTune/package.json Normal file
View File

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

1844
FineTune/public/app.js Normal file

File diff suppressed because it is too large Load Diff

250
FineTune/public/index.html Normal file
View File

@@ -0,0 +1,250 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BoostAI FineTune Helper</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="page-shell">
<header class="hero">
<div>
<p class="eyebrow">Local helper app</p>
<h1>BoostAI FineTune Data Helper</h1>
<p class="hero-copy">
Create full assignment-review examples that match the real app shape: one assignment, many question-level labels, and one assignment-level summary.
</p>
</div>
<div class="hero-actions">
<button id="save-example" type="button">Save assignment example</button>
<button id="load-sample" type="button">Load sample</button>
<button id="clear-form" type="button" class="button-secondary">Clear</button>
</div>
</header>
<section class="status-bar">
<div>
<strong>Hosted AI:</strong>
<span id="ai-config-status">Checking…</span>
</div>
<div>
<strong>Assignment generator:</strong>
<span id="question-generator-status">Checking…</span>
</div>
<div>
<strong>Shared workspace:</strong>
<span id="collab-status">Connecting…</span>
</div>
<div>
<strong>Editors:</strong>
<span id="presence-status">1 connected</span>
</div>
<div id="save-status">Autosave ready</div>
</section>
<main class="layout-grid">
<section class="editor-shell span-2">
<aside class="card workspace-nav-card">
<div class="workspace-toolbar">
<div>
<h2>Assignments</h2>
<p class="section-copy">Search, sort, and switch between lots of in-progress assignment drafts without losing the shared editor.</p>
</div>
<button id="new-draft" type="button">New draft</button>
</div>
<div class="workspace-controls">
<label class="control-field workspace-search-field">
<span>Find an assignment</span>
<input id="draft-search" type="text" placeholder="Search by title, assignment ID, topic, or student" />
</label>
<label class="control-field control-field-compact">
<span>Sort</span>
<select id="draft-sort">
<option value="updated">Recently updated</option>
<option value="title">Title</option>
<option value="topic">Topic</option>
<option value="questions">Question count</option>
</select>
</label>
</div>
<div class="workspace-meta-row">
<div id="draft-status" class="summary-strip compact-strip">1 local draft</div>
<div class="workspace-actions">
<button id="duplicate-draft" type="button" class="button-secondary">Duplicate</button>
<button id="rename-draft" type="button" class="button-secondary">Rename</button>
<button id="delete-draft" type="button" class="button-danger">Delete</button>
</div>
</div>
<div id="draft-list" class="draft-list" role="listbox" aria-label="Assignment drafts"></div>
</aside>
<section class="card editor-card">
<div class="section-heading">
<div>
<h2>Assignment builder</h2>
<p class="section-copy">Generate a multi-question assignment from the real backend, then edit the assignment metadata or any question before saving.</p>
</div>
<div class="hero-actions">
<button id="generate-assignment" type="button">Generate assignment</button>
<button id="add-question" type="button" class="button-secondary">Add blank question</button>
</div>
</div>
<div class="form-grid three-up">
<label>
<span>Assignment ID</span>
<input id="assignmentId" type="text" placeholder="assignment-fractions-01" />
</label>
<label>
<span>Student ID</span>
<input id="studentId" type="text" placeholder="student-17" />
</label>
<label>
<span>Pass threshold</span>
<input id="passThreshold" type="number" min="0" max="1" step="0.01" placeholder="0.70" />
</label>
</div>
<div class="form-grid two-up">
<label>
<span>Assignment title</span>
<input id="assignmentTitle" type="text" placeholder="Fractions practice review" />
</label>
<label>
<span>Instructions</span>
<input id="instructions" type="text" placeholder="Show working for every question." />
</label>
</div>
<div class="form-grid four-up">
<label>
<span>Topic</span>
<select id="topic">
<option value="">Select…</option>
<option value="place_value">place_value</option>
<option value="arithmetic">arithmetic</option>
<option value="negative_numbers">negative_numbers</option>
<option value="bidmas">bidmas</option>
<option value="fractions">fractions</option>
<option value="algebra">algebra</option>
<option value="geometry">geometry</option>
<option value="data">data</option>
</select>
</label>
<label>
<span>Difficulty</span>
<select id="difficulty">
<option value="">Select…</option>
<option value="easy">easy</option>
<option value="medium">medium</option>
<option value="hard">hard</option>
</select>
</label>
<label>
<span>Question count</span>
<input id="questionCount" type="number" min="1" max="25" step="1" placeholder="4" />
</label>
<label>
<span>Generation seed (optional)</span>
<input id="generatorSeed" type="text" placeholder="auto-filled after generation" readonly />
</label>
</div>
<div class="question-toolbar">
<div class="question-toolbar-group">
<span class="question-toolbar-label">View</span>
<button id="filter-all" type="button" class="button-secondary is-active">All questions</button>
<button id="filter-attention" type="button" class="button-secondary">Needs attention</button>
<button id="filter-unlabeled" type="button" class="button-secondary">Unlabeled</button>
</div>
<div class="question-toolbar-group">
<span class="question-toolbar-label">Layout</span>
<button id="expand-all-questions" type="button" class="button-secondary">Expand all</button>
<button id="collapse-all-questions" type="button" class="button-secondary">Collapse all</button>
</div>
</div>
<div id="question-summary" class="summary-strip">No questions yet.</div>
<div id="questions-container" class="question-stack"></div>
</section>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>Student submission</h2>
<p class="section-copy">Draft the whole student submission at once, then tweak any question manually.</p>
</div>
<button id="generate-students" type="button">AI draft student submission</button>
</div>
<div class="mini-note">
One student voice should carry across the full assignment. Each question stores <code>answerText</code>, <code>workingSteps</code>, and <code>solveMode</code>.
</div>
</section>
<section class="card">
<div class="section-heading">
<div>
<h2>Teacher review package</h2>
<p class="section-copy">Generate per-question review labels plus one assignment summary, matching the real production shape.</p>
</div>
<button id="generate-teacher" type="button">AI draft full teacher review</button>
</div>
<label>
<span>Assignment summary</span>
<textarea id="assignmentSummary" rows="5" placeholder="Short whole-assignment summary"></textarea>
</label>
<label>
<span>Recommended next step</span>
<textarea id="recommendedNextStep" rows="4" placeholder="What the teacher should do next"></textarea>
</label>
</section>
<section class="card preview-card">
<div class="section-heading">
<h2>Labeled record preview</h2>
<button id="copy-record" type="button" class="button-secondary">Copy JSON</button>
</div>
<pre id="recordPreview"></pre>
</section>
<section class="card preview-card">
<div class="section-heading">
<h2>Fine-tune example preview</h2>
<button id="copy-training" type="button" class="button-secondary">Copy JSON</button>
</div>
<pre id="trainingPreview"></pre>
</section>
<section class="card dataset-card span-2">
<div class="section-heading">
<div>
<h2>Saved dataset</h2>
<p id="datasetStatus" class="section-copy">No saved examples yet.</p>
</div>
<div class="hero-actions dataset-actions">
<button id="export-dataset" type="button" class="button-secondary">Export dataset.jsonl</button>
<button id="export-split" type="button" class="button-secondary">Export train/val split</button>
</div>
</div>
<div id="datasetEmpty" class="empty-state">
Save full assignment-review examples here. Each saved item becomes one training row.
</div>
<div id="datasetList" class="dataset-list"></div>
</section>
</main>
<section class="card footer-note">
<strong>Shape target:</strong> one assignment-level review call, question-level labels for every question, and one assignment summary. Treat AI drafts as prefill only.
</section>
</div>
<div id="toast" class="toast hidden"></div>
<script type="module" src="/app.js"></script>
</body>
</html>

679
FineTune/public/styles.css Normal file
View File

@@ -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;
}
}

801
FineTune/server.mjs Normal file
View File

@@ -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",
});
}

View File

@@ -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<ApiListResponse<ApiAssignmentStudentQuestionDetail>>(`/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,
};
});

View File

@@ -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"}`;
};

View File

@@ -52,7 +52,16 @@ const DashboardTeacherClassroomDetail: Component<Props> = (props) => {
<div class={styles.studentList}>
<For each={props.data.students.items}>
{(student) => (
<A href={student.href} class={`${styles.studentCard} ${student.selected ? styles.studentCardSelected : ""}`.trim()}>
<A
href={student.href}
class={[
styles.studentCard,
student.selected ? styles.studentCardSelected : "",
student.endangeredRank != null ? styles[`studentCardEndangered${student.endangeredRank}`] : "",
]
.filter(Boolean)
.join(" ")}
>
<div class={styles.studentCardHeader}>
<div class={styles.studentAvatar}>{student.initials}</div>
<div>
@@ -63,7 +72,11 @@ const DashboardTeacherClassroomDetail: Component<Props> = (props) => {
<div class={styles.metaRow}>
<span>{student.statusLabel}</span>
<span>{student.redoCountLabel}</span>
<Show when={student.endangeredRank != null}>
<span>{student.endangeredRank === 1 ? "Most endangered" : student.endangeredRank === 2 ? "Second most endangered" : "Third most endangered"}</span>
</Show>
</div>
<p class={styles.studentScoreLabel}>{student.combinedScoreLabel}</p>
<p class={styles.studentNote}>{student.note}</p>
</A>
)}

View File

@@ -7,9 +7,11 @@ export type TeacherClassroomDetailStudentItem = {
initials: string;
statusLabel: string;
redoCountLabel: string;
combinedScoreLabel: string;
note: string;
href: string;
selected: boolean;
endangeredRank: 1 | 2 | 3 | null;
};
export type TeacherClassroomRedoAssignmentItem = {

View File

@@ -251,6 +251,21 @@
background: color-mix(in srgb, var(--surface-info) 12%, var(--surface-panel-strong) 88%);
}
.studentCardEndangered1 {
border-color: color-mix(in srgb, var(--danger) 36%, var(--border-soft) 64%);
background: color-mix(in srgb, var(--surface-danger) 22%, var(--surface-panel-strong) 78%);
}
.studentCardEndangered2 {
border-color: color-mix(in srgb, var(--warning) 30%, var(--border-soft) 70%);
background: color-mix(in srgb, var(--surface-warning) 18%, var(--surface-panel-strong) 82%);
}
.studentCardEndangered3 {
border-color: color-mix(in srgb, var(--info) 22%, var(--border-soft) 78%);
background: color-mix(in srgb, var(--surface-info) 12%, var(--surface-panel-strong) 88%);
}
.studentCardHeader {
display: flex;
align-items: center;
@@ -285,6 +300,12 @@
font-size: 0.95rem;
}
.studentScoreLabel {
font-size: 0.88rem;
font-weight: 700;
color: var(--text-subtle);
}
.selectedStudentCard {
display: grid;
gap: 0.45rem;

View File

@@ -4,7 +4,7 @@ EARTHLY_ENV_CLEAN := env -u AI_REVIEW_ENDPOINT -u AI_REVIEW_API_KEY -u AI_REVIEW
.DEFAULT_GOAL := help
.PHONY: help guard-dev-tag down dev dev-build dev-up prod-a prod-a-build prod-a-up prod-a-down prod-a-config sqlc db-status db-up db-seed
.PHONY: help guard-dev-tag down dev dev-build dev-up prod-a prod-a-build prod-a-up prod-a-down prod-a-config prod-b prod-b-build prod-b-up prod-b-down prod-b-config fine-tune fine-tune-down fine-tune-config sqlc db-status db-up db-seed
FRONTEND_DIR := Frontend
FRONTEND_DIST_DIR := $(FRONTEND_DIR)/dist
@@ -14,6 +14,9 @@ EARTHLY ?= earthly
COMPOSE ?= docker compose
PROD_A_TAG ?= latest
PROD_A_ENV_FILE ?= .env.prod-a
PROD_B_TAG ?= latest
PROD_B_ENV_FILE ?= .env.prod-b
FINE_TUNE_ENV_FILE ?= FineTune/.env
DATABASE_URL ?= postgres://boostai:boostai_dev_password@localhost:5439/boostai?sslmode=disable
MOCK_DATA_DIR ?= ../Mock-Data
GOOSE := go run github.com/pressly/goose/v3/cmd/goose@v3.26.0
@@ -54,13 +57,37 @@ prod-a-build: ## Build the production prod-a images for frontend and backend
@$(EARTHLY_ENV_CLEAN) $(EARTHLY) "./$(BACKEND_DIR)+prod-image" --IMAGE_NAME="boost-ai/demo-backend-prod-a" --TAG=$(PROD_A_TAG)
prod-a-up: ## Start the prod-a stack using docker compose
@$(COMPOSE) --env-file $(PROD_A_ENV_FILE) -f docker-compose.prod-a.yaml up -d --remove-orphans --force-recreate
@PROD_A_ENV_FILE=$(PROD_A_ENV_FILE) $(COMPOSE) --env-file $(PROD_A_ENV_FILE) -f docker-compose.prod-a.yaml up -d --remove-orphans --force-recreate
prod-a-down: ## Stop the prod-a stack
@$(COMPOSE) --env-file $(PROD_A_ENV_FILE) -f docker-compose.prod-a.yaml down --remove-orphans
@PROD_A_ENV_FILE=$(PROD_A_ENV_FILE) $(COMPOSE) --env-file $(PROD_A_ENV_FILE) -f docker-compose.prod-a.yaml down --remove-orphans
prod-a-config: ## Render the prod-a docker compose configuration
@$(COMPOSE) --env-file $(PROD_A_ENV_FILE) -f docker-compose.prod-a.yaml config
@PROD_A_ENV_FILE=$(PROD_A_ENV_FILE) $(COMPOSE) --env-file $(PROD_A_ENV_FILE) -f docker-compose.prod-a.yaml config
prod-b: prod-b-build prod-b-up ## Build and start the prod-b stack
prod-b-build: ## Build the production prod-b images for frontend and backend
@$(EARTHLY_ENV_CLEAN) $(EARTHLY) "+frontend-prod-image" --IMAGE_NAME="boost-ai/demo-frontend-prod-b" --TAG=$(PROD_B_TAG)
@$(EARTHLY_ENV_CLEAN) $(EARTHLY) "./$(BACKEND_DIR)+prod-image" --IMAGE_NAME="boost-ai/demo-backend-prod-b" --TAG=$(PROD_B_TAG)
prod-b-up: ## Start the prod-b stack using docker compose
@PROD_B_ENV_FILE=$(PROD_B_ENV_FILE) $(COMPOSE) --env-file $(PROD_B_ENV_FILE) -f docker-compose.prod-b.yaml up -d --remove-orphans --force-recreate
prod-b-down: ## Stop the prod-b stack
@PROD_B_ENV_FILE=$(PROD_B_ENV_FILE) $(COMPOSE) --env-file $(PROD_B_ENV_FILE) -f docker-compose.prod-b.yaml down --remove-orphans
prod-b-config: ## Render the prod-b docker compose configuration
@PROD_B_ENV_FILE=$(PROD_B_ENV_FILE) $(COMPOSE) --env-file $(PROD_B_ENV_FILE) -f docker-compose.prod-b.yaml config
fine-tune: ## Start the isolated local fine-tune helper app
@$(COMPOSE) --env-file $(FINE_TUNE_ENV_FILE) -f FineTune/docker-compose.yaml up -d --remove-orphans --force-recreate
fine-tune-down: ## Stop the isolated local fine-tune helper app
@$(COMPOSE) --env-file $(FINE_TUNE_ENV_FILE) -f FineTune/docker-compose.yaml down --remove-orphans
fine-tune-config: ## Render the local fine-tune helper docker compose configuration
@$(COMPOSE) --env-file $(FINE_TUNE_ENV_FILE) -f FineTune/docker-compose.yaml config
sqlc: ## Generate typed SQL code for the backend
@cd "$(BACKEND_DIR)" && $(SQLC) generate -f db/sqlc.yaml

11
TODO.md
View File

@@ -1 +1,12 @@
# Things to add
- [ ] Add Feature: Both prod-a and prod-b. Add the feature that when teacher click on the classroom view in `dashboard/teacher/classrooms/[id]` it should highlight (change background-color) of the three students that are most endangered (based on lowest combined overall scores)
## Manual
- [ ] 50 assignments: Focus on jailbreaks
## Post Manual
- [ ] Fine tune the Qwen3 model in `ssh moku-a100` hosted on vllm
- [ ] Run the `boost.ai.moku.build` / `prod-b` with the fine tuned version

View File

@@ -25,7 +25,7 @@ services:
container_name: backend-prod-a
restart: unless-stopped
env_file:
- ./.env.prod-a
- ${PROD_A_ENV_FILE:-.env.prod-a}
environment:
GO_ENV: production
BACKEND_INTERNAL_PORT: 8081
@@ -38,6 +38,8 @@ services:
condition: service_healthy
expose:
- "8081"
volumes:
- ./Mock-Data:/app/Mock-Data:ro
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/health"]
interval: 10s

116
docker-compose.prod-b.yaml Normal file
View File

@@ -0,0 +1,116 @@
services:
caddy-prod-b:
image: caddy:2-alpine
container_name: caddy-prod-b
restart: unless-stopped
environment:
BASE_DOMAIN: ${BASE_DOMAIN}
FRONTEND_UPSTREAM: frontend-prod-b:3000
BACKEND_UPSTREAM: backend-prod-b:8081
FINE_TUNE_UPSTREAM: fine-tune-helper-prod-b:4310
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile.prod-b:/etc/caddy/Caddyfile:ro
- BoostAI_caddy_data_prod_b:/data
- BoostAI_caddy_config_prod_b:/config
depends_on:
backend-prod-b:
condition: service_healthy
frontend-prod-b:
condition: service_started
fine-tune-helper-prod-b:
condition: service_healthy
backend-prod-b:
image: boost-ai/demo-backend-prod-b:${PROD_B_TAG:-latest}
container_name: backend-prod-b
restart: unless-stopped
env_file:
- ${PROD_B_ENV_FILE:-.env.prod-b}
environment:
GO_ENV: production
BACKEND_INTERNAL_PORT: 8081
ALLOWED_ORIGINS: http://${BASE_DOMAIN},https://${BASE_DOMAIN}
DATABASE_URL: postgres://boostai:${POSTGRES_PASSWORD}@postgres-prod-b:5432/boostai?sslmode=disable
JWT_SECRET: ${JWT_SECRET}
SESSION_COOKIE_NAME: ${SESSION_COOKIE_NAME:-boostai_session}
depends_on:
postgres-prod-b:
condition: service_healthy
expose:
- "8081"
volumes:
- ./Mock-Data:/app/Mock-Data:ro
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/health"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
frontend-prod-b:
image: boost-ai/demo-frontend-prod-b:${PROD_B_TAG:-latest}
container_name: frontend-prod-b
restart: unless-stopped
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 3000
NITRO_HOST: 0.0.0.0
NITRO_PORT: 3000
ALLOWED_HOSTS: ${BASE_DOMAIN}
__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: ${BASE_DOMAIN}
expose:
- "3000"
fine-tune-helper-prod-b:
image: node:22-alpine
container_name: fine-tune-helper-prod-b
restart: unless-stopped
working_dir: /app
command: sh -lc "npm ci && npm run start"
env_file:
- ${PROD_B_ENV_FILE:-.env.prod-b}
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}
expose:
- "4310"
volumes:
- ./FineTune:/app
- BoostAI_fine_tune_node_modules_prod_b:/app/node_modules
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4310/health"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
postgres-prod-b:
image: postgres:16-alpine
container_name: postgres-prod-b
restart: unless-stopped
environment:
POSTGRES_DB: boostai
POSTGRES_USER: boostai
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- BoostAI_postgres_prod_b_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U boostai -d boostai"]
interval: 5s
timeout: 5s
retries: 10
volumes:
BoostAI_postgres_prod_b_data:
BoostAI_caddy_data_prod_b:
BoostAI_caddy_config_prod_b:
BoostAI_fine_tune_node_modules_prod_b:

11
env.prod-a.example Normal file
View File

@@ -0,0 +1,11 @@
PROD_A_TAG=latest
BASE_DOMAIN=boostai.demo.moku.build
POSTGRES_PASSWORD=replace-with-strong-password
JWT_SECRET=replace-with-strong-jwt-secret
SESSION_COOKIE_NAME=boostai_session
ENABLE_ADMIN_RESEED=false
ADMIN_RESEED_SECRET=replace-with-long-random-secret
MOCK_DATA_DIR=/app/Mock-Data
AI_REVIEW_ENDPOINT=
AI_REVIEW_API_KEY=
AI_REVIEW_MODEL=

16
env.prod-b.example Normal file
View File

@@ -0,0 +1,16 @@
PROD_B_TAG=latest
BASE_DOMAIN=boost.ai.moku.build
POSTGRES_PASSWORD=replace-with-strong-password
JWT_SECRET=replace-with-strong-jwt-secret
SESSION_COOKIE_NAME=boostai_session
ENABLE_ADMIN_RESEED=false
ADMIN_RESEED_SECRET=replace-with-long-random-secret
MOCK_DATA_DIR=/app/Mock-Data
AI_REVIEW_ENDPOINT=http://moku-a100:8000/v1/chat/completions
AI_REVIEW_API_KEY=replace-with-vllm-api-key
AI_REVIEW_MODEL=qwen3.6-27b
FINE_TUNE_AI_ENDPOINT=http://moku-a100:8000/v1/chat/completions
FINE_TUNE_AI_API_KEY=replace-with-vllm-api-key
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