feat: add PostgreSQL persistence layer with Neon DB support
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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
29
internal/db/db.go
Normal 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
20
internal/db/migrate.go
Normal 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
|
||||
}
|
||||
106
internal/db/migrations/001_init.sql
Normal file
106
internal/db/migrations/001_init.sql
Normal 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()
|
||||
);
|
||||
77
internal/interview/store_pg.go
Normal file
77
internal/interview/store_pg.go
Normal 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
|
||||
}
|
||||
195
internal/learnermemory/store_pg.go
Normal file
195
internal/learnermemory/store_pg.go
Normal 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
|
||||
}
|
||||
111
internal/ontology/store_pg.go
Normal file
111
internal/ontology/store_pg.go
Normal 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
|
||||
}
|
||||
53
internal/teachingassets/store_pg.go
Normal file
53
internal/teachingassets/store_pg.go
Normal 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}
|
||||
}
|
||||
Reference in New Issue
Block a user