feat: add PostgreSQL persistence layer with Neon DB support

This commit is contained in:
user
2026-04-27 12:35:03 +09:00
parent 01d102f5ef
commit bfdc7399eb
12 changed files with 671 additions and 6 deletions

8
.env Normal file
View File

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

12
go.mod
View File

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

26
go.sum Normal file
View File

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

View File

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

View File

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

29
internal/db/db.go Normal file
View File

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

20
internal/db/migrate.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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