From bfdc7399eb7db1cc38277d56a160b3b2ecc11b73 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 27 Apr 2026 12:35:03 +0900 Subject: [PATCH] feat: add PostgreSQL persistence layer with Neon DB support --- .env | 8 ++ go.mod | 12 +- go.sum | 26 ++++ internal/app/server.go | 38 +++++- internal/config/config.go | 2 + internal/db/db.go | 29 +++++ internal/db/migrate.go | 20 +++ internal/db/migrations/001_init.sql | 106 +++++++++++++++ internal/interview/store_pg.go | 77 +++++++++++ internal/learnermemory/store_pg.go | 195 ++++++++++++++++++++++++++++ internal/ontology/store_pg.go | 111 ++++++++++++++++ internal/teachingassets/store_pg.go | 53 ++++++++ 12 files changed, 671 insertions(+), 6 deletions(-) create mode 100644 .env create mode 100644 go.sum create mode 100644 internal/db/db.go create mode 100644 internal/db/migrate.go create mode 100644 internal/db/migrations/001_init.sql create mode 100644 internal/interview/store_pg.go create mode 100644 internal/learnermemory/store_pg.go create mode 100644 internal/ontology/store_pg.go create mode 100644 internal/teachingassets/store_pg.go diff --git a/.env b/.env new file mode 100644 index 0000000..a7bf8c7 --- /dev/null +++ b/.env @@ -0,0 +1,8 @@ +TUTOR_HTTP_ADDR=:8080 +DATABASE_URL=postgresql://neondb_owner:npg_MNHX2arVQqI3@ep-dry-star-akdkpb5p.c-3.us-west-2.aws.neon.tech/neondb?sslmode=require +TUTOR_ENV=development +TUTOR_WORKFLOW_RUNTIME=deepseek-v4-flash +TUTOR_MODEL_KEY=deepseek-v4-flash +TUTOR_IMAGE_MODEL_KEY=gpt-image-v2 +THIRDONE_BIN=thirdone +TUTOR_PUBLIC_URL=https://tutor.uljisoft.com diff --git a/go.mod b/go.mod index f4770f7..3501071 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,13 @@ module tutor -go 1.23.10 +go 1.25.0 + +require github.com/jackc/pgx/v5 v5.9.2 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f5b2410 --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/server.go b/internal/app/server.go index 7b9830f..71c931c 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -1,9 +1,11 @@ package app import ( + "log" "net/http" "tutor/internal/config" + "tutor/internal/db" "tutor/internal/httpapi" "tutor/internal/interview" "tutor/internal/learnermemory" @@ -15,12 +17,38 @@ import ( func NewServer(cfg config.Config) *http.Server { runner := workflows.NewStubRunner() - store := interview.NewMemoryStore() - memory := learnermemory.NewService(learnermemory.NewMemoryStore()) + + var interviewStore interview.Store + var memoryStore learnermemory.Store + var ontologyStore ontology.Store + var assetsStore teachingassets.Store + + if cfg.DatabaseURL != "" { + pool, err := db.Open(cfg.DatabaseURL) + if err != nil { + log.Fatalf("open database: %v", err) + } + if err := db.Migrate(pool); err != nil { + log.Fatalf("migrate database: %v", err) + } + interviewStore = interview.NewPostgresStore(pool) + memoryStore = learnermemory.NewPostgresStore(pool) + ontologyStore = ontology.NewPostgresStore(pool) + assetsStore = teachingassets.NewPostgresStore(pool) + log.Println("using postgres persistence") + } else { + interviewStore = interview.NewMemoryStore() + memoryStore = learnermemory.NewMemoryStore() + ontologyStore = ontology.NewMemoryStore() + assetsStore = teachingassets.NewMemoryStore() + log.Println("using in-memory persistence") + } + + memory := learnermemory.NewService(memoryStore) progress := progression.NewService(memory) - onto := ontology.NewService(ontology.NewMemoryStore()) - assets := teachingassets.NewService(teachingassets.NewMemoryStore(), onto, cfg.ImageModelKey) - service := interview.NewService(store, runner, memory) + onto := ontology.NewService(ontologyStore) + assets := teachingassets.NewService(assetsStore, onto, cfg.ImageModelKey) + service := interview.NewService(interviewStore, runner, memory) handler := httpapi.NewHandler(cfg, service, memory, progress, onto, assets) return &http.Server{ diff --git a/internal/config/config.go b/internal/config/config.go index 7fe2697..4647722 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,7 @@ const ( type Config struct { HTTPAddr string + DatabaseURL string Environment string WorkflowRuntime string ModelKey string @@ -23,6 +24,7 @@ type Config struct { func LoadFromEnv() Config { return Config{ HTTPAddr: envOrDefault("TUTOR_HTTP_ADDR", defaultHTTPAddr), + DatabaseURL: envOrDefault("DATABASE_URL", ""), Environment: envOrDefault("TUTOR_ENV", defaultEnvironment), WorkflowRuntime: envOrDefault("TUTOR_WORKFLOW_RUNTIME", defaultWorkflowRuntime), ModelKey: envOrDefault("TUTOR_MODEL_KEY", defaultModelKey), diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..084d617 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,29 @@ +package db + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func Open(databaseURL string) (*pgxpool.Pool, error) { + if databaseURL == "" { + return nil, fmt.Errorf("database URL is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("create connection pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + return nil, fmt.Errorf("ping database: %w", err) + } + + return pool, nil +} diff --git a/internal/db/migrate.go b/internal/db/migrate.go new file mode 100644 index 0000000..28ec5d5 --- /dev/null +++ b/internal/db/migrate.go @@ -0,0 +1,20 @@ +package db + +import ( + "context" + _ "embed" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed migrations/001_init.sql +var initSQL string + +func Migrate(pool *pgxpool.Pool) error { + _, err := pool.Exec(context.Background(), initSQL) + if err != nil { + return fmt.Errorf("run migration: %w", err) + } + return nil +} diff --git a/internal/db/migrations/001_init.sql b/internal/db/migrations/001_init.sql new file mode 100644 index 0000000..d8a583d --- /dev/null +++ b/internal/db/migrations/001_init.sql @@ -0,0 +1,106 @@ +-- Tutor Platform Initial Schema + +CREATE TABLE IF NOT EXISTS interview_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + target_role TEXT NOT NULL, + stack JSONB NOT NULL DEFAULT '[]', + interview_timeline TEXT NOT NULL, + questions JSONB NOT NULL DEFAULT '[]', + answers JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS learner_profiles ( + user_id TEXT PRIMARY KEY, + target_role TEXT NOT NULL, + stack JSONB NOT NULL DEFAULT '[]', + interview_timeline TEXT NOT NULL, + preferences JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS learner_mastery ( + user_id TEXT NOT NULL, + concept_id TEXT NOT NULL, + concept_label TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'unknown', + evidence JSONB NOT NULL DEFAULT '[]', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, concept_id) +); + +CREATE TABLE IF NOT EXISTS learner_misconceptions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + concept JSONB NOT NULL DEFAULT '{}', + description TEXT NOT NULL, + evidence JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS learner_interventions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + kind TEXT NOT NULL, + reason TEXT NOT NULL, + concept JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS learner_review_schedules ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + concept JSONB NOT NULL DEFAULT '{}', + due_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS ontology_materials ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + source_type TEXT NOT NULL, + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS ontology_concepts ( + id TEXT PRIMARY KEY, + material_id TEXT NOT NULL, + concept_id TEXT NOT NULL, + concept_label TEXT NOT NULL, + summary TEXT NOT NULL, + review_state TEXT NOT NULL DEFAULT 'candidate', + evidence JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS ontology_edges ( + id TEXT PRIMARY KEY, + from_concept_id TEXT NOT NULL, + to_concept_id TEXT NOT NULL, + kind TEXT NOT NULL, + evidence JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS ontology_gaps ( + id TEXT PRIMARY KEY, + concept_id TEXT NOT NULL, + reason TEXT NOT NULL, + evidence JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS teaching_asset_prompts ( + id TEXT PRIMARY KEY, + concept_id TEXT NOT NULL, + asset_type TEXT NOT NULL, + prompt TEXT NOT NULL, + model_key TEXT NOT NULL, + review_state TEXT NOT NULL DEFAULT 'candidate', + requires_model_id_verification BOOLEAN NOT NULL DEFAULT TRUE, + source_evidence JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/internal/interview/store_pg.go b/internal/interview/store_pg.go new file mode 100644 index 0000000..ed44fe5 --- /dev/null +++ b/internal/interview/store_pg.go @@ -0,0 +1,77 @@ +package interview + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type PostgresStore struct { + pool *pgxpool.Pool +} + +func NewPostgresStore(pool *pgxpool.Pool) *PostgresStore { + return &PostgresStore{pool: pool} +} + +func toJSON(v any) string { + b, _ := json.Marshal(v) + return string(b) +} + +func (s *PostgresStore) Create(session Session) (Session, error) { + _, err := s.pool.Exec(context.Background(), + `INSERT INTO interview_sessions (id, user_id, target_role, stack, interview_timeline, questions, answers, created_at) + VALUES ($1, $2, $3, $4::jsonb, $5, $6::jsonb, $7::jsonb, $8) + ON CONFLICT (id) DO UPDATE SET + user_id = EXCLUDED.user_id, + target_role = EXCLUDED.target_role, + stack = EXCLUDED.stack, + interview_timeline = EXCLUDED.interview_timeline, + questions = EXCLUDED.questions, + answers = EXCLUDED.answers, + created_at = EXCLUDED.created_at`, + session.ID, session.UserID, session.TargetRole, toJSON(session.Stack), + session.InterviewTimeline, toJSON(session.Questions), toJSON(session.Answers), session.CreatedAt, + ) + if err != nil { + return Session{}, fmt.Errorf("insert session: %w", err) + } + return session, nil +} + +func (s *PostgresStore) Get(id string) (Session, error) { + var session Session + var stackJSON, questionsJSON, answersJSON string + + err := s.pool.QueryRow(context.Background(), + `SELECT id, user_id, target_role, stack, interview_timeline, questions, answers, created_at + FROM interview_sessions WHERE id = $1`, id, + ).Scan(&session.ID, &session.UserID, &session.TargetRole, &stackJSON, + &session.InterviewTimeline, &questionsJSON, &answersJSON, &session.CreatedAt) + if err != nil { + return Session{}, ErrSessionNotFound + } + + json.Unmarshal([]byte(stackJSON), &session.Stack) + json.Unmarshal([]byte(questionsJSON), &session.Questions) + json.Unmarshal([]byte(answersJSON), &session.Answers) + return session, nil +} + +func (s *PostgresStore) Update(session Session) (Session, error) { + _, err := s.pool.Exec(context.Background(), + `UPDATE interview_sessions SET + user_id = $2, target_role = $3, stack = $4::jsonb, interview_timeline = $5, + questions = $6::jsonb, answers = $7::jsonb, created_at = $8 + WHERE id = $1`, + session.ID, session.UserID, session.TargetRole, toJSON(session.Stack), + session.InterviewTimeline, toJSON(session.Questions), toJSON(session.Answers), session.CreatedAt, + ) + if err != nil { + return Session{}, fmt.Errorf("update session: %w", err) + } + return session, nil +} diff --git a/internal/learnermemory/store_pg.go b/internal/learnermemory/store_pg.go new file mode 100644 index 0000000..6f279c8 --- /dev/null +++ b/internal/learnermemory/store_pg.go @@ -0,0 +1,195 @@ +package learnermemory + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type PostgresStore struct { + pool *pgxpool.Pool +} + +func NewPostgresStore(pool *pgxpool.Pool) *PostgresStore { + return &PostgresStore{pool: pool} +} + +func toJSON(v any) string { + b, _ := json.Marshal(v) + return string(b) +} + +func (s *PostgresStore) UpsertProfile(profile Profile) (Profile, error) { + _, err := s.pool.Exec(context.Background(), + `INSERT INTO learner_profiles (user_id, target_role, stack, interview_timeline, preferences, updated_at) + VALUES ($1, $2, $3::jsonb, $4, $5::jsonb, $6) + ON CONFLICT (user_id) DO UPDATE SET + target_role = EXCLUDED.target_role, + stack = EXCLUDED.stack, + interview_timeline = EXCLUDED.interview_timeline, + preferences = EXCLUDED.preferences, + updated_at = EXCLUDED.updated_at`, + profile.UserID, profile.TargetRole, toJSON(profile.Stack), + profile.InterviewTimeline, toJSON(profile.Preferences), profile.UpdatedAt, + ) + if err != nil { + return Profile{}, fmt.Errorf("upsert profile: %w", err) + } + return profile, nil +} + +func (s *PostgresStore) GetProfile(userID string) (Profile, error) { + var p Profile + var stackJSON, prefsJSON string + err := s.pool.QueryRow(context.Background(), + `SELECT user_id, target_role, stack, interview_timeline, preferences, updated_at + FROM learner_profiles WHERE user_id = $1`, userID, + ).Scan(&p.UserID, &p.TargetRole, &stackJSON, &p.InterviewTimeline, &prefsJSON, &p.UpdatedAt) + if err != nil { + return Profile{}, ErrProfileNotFound + } + json.Unmarshal([]byte(stackJSON), &p.Stack) + json.Unmarshal([]byte(prefsJSON), &p.Preferences) + return p, nil +} + +func (s *PostgresStore) UpsertMastery(mastery ConceptMastery) error { + _, err := s.pool.Exec(context.Background(), + `INSERT INTO learner_mastery (user_id, concept_id, concept_label, state, evidence, updated_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6) + ON CONFLICT (user_id, concept_id) DO UPDATE SET + concept_label = EXCLUDED.concept_label, + state = EXCLUDED.state, + evidence = EXCLUDED.evidence, + updated_at = EXCLUDED.updated_at`, + mastery.UserID, mastery.Concept.ID, mastery.Concept.Label, string(mastery.State), + toJSON(mastery.Evidence), mastery.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("upsert mastery: %w", err) + } + return nil +} + +func (s *PostgresStore) AddMisconception(m Misconception) error { + _, err := s.pool.Exec(context.Background(), + `INSERT INTO learner_misconceptions (id, user_id, concept, description, evidence, created_at) + VALUES ($1, $2, $3::jsonb, $4, $5::jsonb, $6)`, + m.ID, m.UserID, toJSON(m.Concept), m.Description, + toJSON(m.Evidence), m.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("insert misconception: %w", err) + } + return nil +} + +func (s *PostgresStore) AddIntervention(i Intervention) error { + _, err := s.pool.Exec(context.Background(), + `INSERT INTO learner_interventions (id, user_id, kind, reason, concept, created_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6)`, + i.ID, i.UserID, i.Summary, i.Summary, toJSON(i.Concept), i.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("insert intervention: %w", err) + } + return nil +} + +func (s *PostgresStore) AddReviewSchedule(r ReviewSchedule) error { + _, err := s.pool.Exec(context.Background(), + `INSERT INTO learner_review_schedules (id, user_id, concept, due_at, created_at) + VALUES ($1, $2, $3::jsonb, $4, $5)`, + r.ID, r.UserID, toJSON(r.Concept), r.UpdatedAt, r.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("insert review schedule: %w", err) + } + return nil +} + +func (s *PostgresStore) Snapshot(userID string) (Snapshot, error) { + profile, err := s.GetProfile(userID) + if err != nil { + return Snapshot{}, err + } + + rows, err := s.pool.Query(context.Background(), + `SELECT concept_id, concept_label, state, evidence FROM learner_mastery WHERE user_id = $1`, userID) + if err != nil { + return Snapshot{}, fmt.Errorf("query mastery: %w", err) + } + defer rows.Close() + + var mastery []ConceptMastery + for rows.Next() { + var m ConceptMastery + var evidenceJSON string + err := rows.Scan(&m.Concept.ID, &m.Concept.Label, &m.State, &evidenceJSON) + if err != nil { + continue + } + m.UserID = userID + json.Unmarshal([]byte(evidenceJSON), &m.Evidence) + mastery = append(mastery, m) + } + + return Snapshot{ + Profile: profile, + Mastery: mastery, + Misconceptions: fetchMisconceptions(s.pool, userID), + Interventions: fetchInterventions(s.pool, userID), + ReviewSchedule: fetchReviewSchedules(s.pool, userID), + }, nil +} + +func fetchMisconceptions(pool *pgxpool.Pool, userID string) []Misconception { + rows, _ := pool.Query(context.Background(), + `SELECT id, concept, description, evidence, created_at FROM learner_misconceptions WHERE user_id = $1`, userID) + defer rows.Close() + var items []Misconception + for rows.Next() { + var m Misconception + var conceptJSON, evidenceJSON string + rows.Scan(&m.ID, &conceptJSON, &m.Description, &evidenceJSON, &m.UpdatedAt) + m.UserID = userID + json.Unmarshal([]byte(conceptJSON), &m.Concept) + json.Unmarshal([]byte(evidenceJSON), &m.Evidence) + items = append(items, m) + } + return items +} + +func fetchInterventions(pool *pgxpool.Pool, userID string) []Intervention { + rows, _ := pool.Query(context.Background(), + `SELECT id, kind, reason, concept, created_at FROM learner_interventions WHERE user_id = $1`, userID) + defer rows.Close() + var items []Intervention + for rows.Next() { + var i Intervention + var conceptJSON string + rows.Scan(&i.ID, &i.Summary, &i.Summary, &conceptJSON, &i.UpdatedAt) + i.UserID = userID + json.Unmarshal([]byte(conceptJSON), &i.Concept) + items = append(items, i) + } + return items +} + +func fetchReviewSchedules(pool *pgxpool.Pool, userID string) []ReviewSchedule { + rows, _ := pool.Query(context.Background(), + `SELECT id, concept, due_at, created_at FROM learner_review_schedules WHERE user_id = $1`, userID) + defer rows.Close() + var items []ReviewSchedule + for rows.Next() { + var r ReviewSchedule + var conceptJSON string + rows.Scan(&r.ID, &conceptJSON, &r.UpdatedAt, &r.UpdatedAt) + r.UserID = userID + json.Unmarshal([]byte(conceptJSON), &r.Concept) + items = append(items, r) + } + return items +} diff --git a/internal/ontology/store_pg.go b/internal/ontology/store_pg.go new file mode 100644 index 0000000..62e027c --- /dev/null +++ b/internal/ontology/store_pg.go @@ -0,0 +1,111 @@ +package ontology + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type PostgresStore struct { + pool *pgxpool.Pool +} + +func NewPostgresStore(pool *pgxpool.Pool) *PostgresStore { + return &PostgresStore{pool: pool} +} + +func toJSON(v any) string { + b, _ := json.Marshal(v) + return string(b) +} + +func (s *PostgresStore) Save(material Material, concepts []ConceptCandidate, edges []EdgeCandidate, gaps []Gap) error { + ctx := context.Background() + _, err := s.pool.Exec(ctx, + `INSERT INTO ontology_materials (id, title, source_type, body, created_at) VALUES ($1, $2, $3, $4, $5)`, + material.ID, material.Title, material.SourceType, material.Body, material.CreatedAt) + if err != nil { + return fmt.Errorf("insert material: %w", err) + } + + for _, c := range concepts { + _, err := s.pool.Exec(ctx, + `INSERT INTO ontology_concepts (id, material_id, concept_id, concept_label, summary, review_state, evidence, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)`, + c.ID, material.ID, c.Concept.ID, c.Concept.Label, c.Summary, string(c.ReviewState), + toJSON(c.Evidence), c.CreatedAt) + if err != nil { + return fmt.Errorf("insert concept: %w", err) + } + } + + for _, e := range edges { + _, err := s.pool.Exec(ctx, + `INSERT INTO ontology_edges (id, from_concept_id, to_concept_id, kind, evidence, created_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6)`, + e.ID, e.From.ID, e.To.ID, string(e.Kind), + toJSON(e.Evidence), e.CreatedAt) + if err != nil { + return fmt.Errorf("insert edge: %w", err) + } + } + + for _, g := range gaps { + _, err := s.pool.Exec(ctx, + `INSERT INTO ontology_gaps (id, concept_id, reason, evidence, created_at) + VALUES ($1, $2, $3, $4::jsonb, $5)`, + g.ID, g.Concept.ID, g.Reason, + toJSON(g.SupportingEvidence), g.CreatedAt) + if err != nil { + return fmt.Errorf("insert gap: %w", err) + } + } + return nil +} + +func (s *PostgresStore) Snapshot() Snapshot { + ctx := context.Background() + var snap Snapshot + + matRows, _ := s.pool.Query(ctx, `SELECT id, title, source_type, body, created_at FROM ontology_materials`) + defer matRows.Close() + for matRows.Next() { + var m Material + matRows.Scan(&m.ID, &m.Title, &m.SourceType, &m.Body, &m.CreatedAt) + snap.Materials = append(snap.Materials, m) + } + + cRows, _ := s.pool.Query(ctx, `SELECT id, concept_id, concept_label, summary, review_state, evidence, created_at FROM ontology_concepts`) + defer cRows.Close() + for cRows.Next() { + var c ConceptCandidate + var evidenceJSON string + cRows.Scan(&c.ID, &c.Concept.ID, &c.Concept.Label, &c.Summary, &c.ReviewState, &evidenceJSON, &c.CreatedAt) + json.Unmarshal([]byte(evidenceJSON), &c.Evidence) + snap.Concepts = append(snap.Concepts, c) + } + + eRows, _ := s.pool.Query(ctx, `SELECT id, from_concept_id, to_concept_id, kind, evidence, created_at FROM ontology_edges`) + defer eRows.Close() + for eRows.Next() { + var e EdgeCandidate + var evidenceJSON string + eRows.Scan(&e.ID, &e.From.ID, &e.To.ID, &e.Kind, &evidenceJSON, &e.CreatedAt) + json.Unmarshal([]byte(evidenceJSON), &e.Evidence) + snap.Edges = append(snap.Edges, e) + } + + gRows, _ := s.pool.Query(ctx, `SELECT id, concept_id, reason, evidence, created_at FROM ontology_gaps`) + defer gRows.Close() + for gRows.Next() { + var g Gap + var evidenceJSON string + gRows.Scan(&g.ID, &g.Concept.ID, &g.Reason, &evidenceJSON, &g.CreatedAt) + json.Unmarshal([]byte(evidenceJSON), &g.SupportingEvidence) + snap.Gaps = append(snap.Gaps, g) + } + + return snap +} diff --git a/internal/teachingassets/store_pg.go b/internal/teachingassets/store_pg.go new file mode 100644 index 0000000..0b8c62d --- /dev/null +++ b/internal/teachingassets/store_pg.go @@ -0,0 +1,53 @@ +package teachingassets + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type PostgresStore struct { + pool *pgxpool.Pool +} + +func NewPostgresStore(pool *pgxpool.Pool) *PostgresStore { + return &PostgresStore{pool: pool} +} + +func toJSON(v any) string { + b, _ := json.Marshal(v) + return string(b) +} + +func (s *PostgresStore) SavePrompt(prompt PromptCandidate) (PromptCandidate, error) { + _, err := s.pool.Exec(context.Background(), + `INSERT INTO teaching_asset_prompts (id, concept_id, asset_type, prompt, model_key, review_state, requires_model_id_verification, source_evidence, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`, + prompt.ID, prompt.Concept.ID, string(prompt.AssetType), prompt.Prompt, + prompt.ModelKey, string(prompt.ReviewState), prompt.RequiresModelIDVerification, + toJSON(prompt.SourceEvidence), prompt.CreatedAt, + ) + if err != nil { + return PromptCandidate{}, fmt.Errorf("insert prompt: %w", err) + } + return prompt, nil +} + +func (s *PostgresStore) Snapshot() Snapshot { + rows, _ := s.pool.Query(context.Background(), + `SELECT id, concept_id, asset_type, prompt, model_key, review_state, requires_model_id_verification, source_evidence, created_at FROM teaching_asset_prompts`) + defer rows.Close() + + var prompts []PromptCandidate + for rows.Next() { + var p PromptCandidate + var evidenceJSON string + rows.Scan(&p.ID, &p.Concept.ID, &p.AssetType, &p.Prompt, &p.ModelKey, + &p.ReviewState, &p.RequiresModelIDVerification, &evidenceJSON, &p.CreatedAt) + json.Unmarshal([]byte(evidenceJSON), &p.SourceEvidence) + prompts = append(prompts, p) + } + return Snapshot{Prompts: prompts} +}