feat: add learner memory ingestion
This commit is contained in:
113
internal/learnermemory/service.go
Normal file
113
internal/learnermemory/service.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package learnermemory
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
ids atomic.Uint64
|
||||
}
|
||||
|
||||
func NewService(store Store) *Service {
|
||||
return &Service{store: store}
|
||||
}
|
||||
|
||||
func (s *Service) EnsureProfile(input ProfileInput) (Profile, error) {
|
||||
if strings.TrimSpace(input.UserID) == "" {
|
||||
return Profile{}, errors.New("user_id is required")
|
||||
}
|
||||
if strings.TrimSpace(input.TargetRole) == "" {
|
||||
return Profile{}, errors.New("target_role is required")
|
||||
}
|
||||
|
||||
profile := Profile{
|
||||
UserID: input.UserID,
|
||||
TargetRole: input.TargetRole,
|
||||
Stack: append([]string(nil), input.Stack...),
|
||||
InterviewTimeline: input.InterviewTimeline,
|
||||
Preferences: append([]string(nil), input.Preferences...),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
return s.store.UpsertProfile(profile)
|
||||
}
|
||||
|
||||
func (s *Service) ApplyCandidate(candidate workflows.MemoryUpdateCandidate) error {
|
||||
if strings.TrimSpace(candidate.UserID) == "" {
|
||||
return errors.New("candidate user_id is required")
|
||||
}
|
||||
|
||||
for _, update := range candidate.Updates {
|
||||
if len(update.Evidence) == 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(update.Concept.ID) == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.applyUpdate(candidate.UserID, update); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Snapshot(userID string) (Snapshot, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return Snapshot{}, errors.New("user_id is required")
|
||||
}
|
||||
return s.store.Snapshot(userID)
|
||||
}
|
||||
|
||||
func (s *Service) applyUpdate(userID string, update workflows.MemoryUpdate) error {
|
||||
now := time.Now().UTC()
|
||||
switch update.Kind {
|
||||
case workflows.MemoryConceptMastery:
|
||||
return s.store.UpsertMastery(ConceptMastery{
|
||||
UserID: userID,
|
||||
Concept: update.Concept,
|
||||
State: update.ProposedState,
|
||||
Evidence: append([]workflows.EvidenceRef(nil), update.Evidence...),
|
||||
UpdatedAt: now,
|
||||
})
|
||||
case workflows.MemoryMisconception:
|
||||
return s.store.AddMisconception(Misconception{
|
||||
ID: s.nextID("misconception"),
|
||||
UserID: userID,
|
||||
Concept: update.Concept,
|
||||
Label: update.Summary,
|
||||
Description: update.Summary,
|
||||
Evidence: append([]workflows.EvidenceRef(nil), update.Evidence...),
|
||||
UpdatedAt: now,
|
||||
})
|
||||
case workflows.MemoryIntervention:
|
||||
return s.store.AddIntervention(Intervention{
|
||||
ID: s.nextID("intervention"),
|
||||
UserID: userID,
|
||||
Concept: update.Concept,
|
||||
Summary: update.Summary,
|
||||
Evidence: append([]workflows.EvidenceRef(nil), update.Evidence...),
|
||||
UpdatedAt: now,
|
||||
})
|
||||
case workflows.MemoryReviewSchedule:
|
||||
return s.store.AddReviewSchedule(ReviewSchedule{
|
||||
ID: s.nextID("review"),
|
||||
UserID: userID,
|
||||
Concept: update.Concept,
|
||||
Reason: update.Summary,
|
||||
Evidence: append([]workflows.EvidenceRef(nil), update.Evidence...),
|
||||
UpdatedAt: now,
|
||||
})
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) nextID(prefix string) string {
|
||||
return fmt.Sprintf("%s-%d", prefix, s.ids.Add(1))
|
||||
}
|
||||
91
internal/learnermemory/service_test.go
Normal file
91
internal/learnermemory/service_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package learnermemory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
func TestApplyCandidateIgnoresUpdatesWithoutEvidence(t *testing.T) {
|
||||
service := NewService(NewMemoryStore())
|
||||
if _, err := service.EnsureProfile(ProfileInput{
|
||||
UserID: "user-1",
|
||||
TargetRole: "junior backend developer",
|
||||
Stack: []string{"go"},
|
||||
}); err != nil {
|
||||
t.Fatalf("EnsureProfile error: %v", err)
|
||||
}
|
||||
|
||||
err := service.ApplyCandidate(workflows.MemoryUpdateCandidate{
|
||||
UserID: "user-1",
|
||||
Updates: []workflows.MemoryUpdate{
|
||||
{
|
||||
Kind: workflows.MemoryConceptMastery,
|
||||
Concept: workflows.ConceptRef{ID: "http-idempotency", Label: "HTTP idempotency"},
|
||||
ProposedState: workflows.ReadinessImproving,
|
||||
Summary: "No evidence should not persist.",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyCandidate error: %v", err)
|
||||
}
|
||||
|
||||
snapshot, err := service.Snapshot("user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot error: %v", err)
|
||||
}
|
||||
if len(snapshot.Mastery) != 0 {
|
||||
t.Fatalf("mastery entries = %d, want 0", len(snapshot.Mastery))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCandidateStoresEvidenceBackedMemory(t *testing.T) {
|
||||
service := NewService(NewMemoryStore())
|
||||
if _, err := service.EnsureProfile(ProfileInput{
|
||||
UserID: "user-1",
|
||||
TargetRole: "junior backend developer",
|
||||
Stack: []string{"go"},
|
||||
}); err != nil {
|
||||
t.Fatalf("EnsureProfile error: %v", err)
|
||||
}
|
||||
|
||||
evidence := []workflows.EvidenceRef{{Kind: workflows.EvidenceAnswer, ID: "answer-1", Confidence: 1}}
|
||||
err := service.ApplyCandidate(workflows.MemoryUpdateCandidate{
|
||||
UserID: "user-1",
|
||||
Updates: []workflows.MemoryUpdate{
|
||||
{
|
||||
Kind: workflows.MemoryConceptMastery,
|
||||
Concept: workflows.ConceptRef{ID: "http-idempotency", Label: "HTTP idempotency"},
|
||||
ProposedState: workflows.ReadinessInterviewReady,
|
||||
Summary: "Evidence-backed concept mastery.",
|
||||
Evidence: evidence,
|
||||
Confidence: 0.8,
|
||||
},
|
||||
{
|
||||
Kind: workflows.MemoryReviewSchedule,
|
||||
Concept: workflows.ConceptRef{ID: "http-idempotency", Label: "HTTP idempotency"},
|
||||
Summary: "Review later.",
|
||||
Evidence: evidence,
|
||||
Confidence: 0.7,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyCandidate error: %v", err)
|
||||
}
|
||||
|
||||
snapshot, err := service.Snapshot("user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot error: %v", err)
|
||||
}
|
||||
if len(snapshot.Mastery) != 1 {
|
||||
t.Fatalf("mastery entries = %d, want 1", len(snapshot.Mastery))
|
||||
}
|
||||
if len(snapshot.ReviewSchedule) != 1 {
|
||||
t.Fatalf("review entries = %d, want 1", len(snapshot.ReviewSchedule))
|
||||
}
|
||||
if len(snapshot.Mastery[0].Evidence) != 1 {
|
||||
t.Fatal("expected mastery evidence")
|
||||
}
|
||||
}
|
||||
174
internal/learnermemory/store.go
Normal file
174
internal/learnermemory/store.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package learnermemory
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
var ErrProfileNotFound = errors.New("learner profile not found")
|
||||
|
||||
type Store interface {
|
||||
UpsertProfile(Profile) (Profile, error)
|
||||
GetProfile(string) (Profile, error)
|
||||
UpsertMastery(ConceptMastery) error
|
||||
AddMisconception(Misconception) error
|
||||
AddIntervention(Intervention) error
|
||||
AddReviewSchedule(ReviewSchedule) error
|
||||
Snapshot(string) (Snapshot, error)
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
profiles map[string]Profile
|
||||
mastery map[string]map[string]ConceptMastery
|
||||
misconceptions map[string][]Misconception
|
||||
interventions map[string][]Intervention
|
||||
reviewSchedules map[string][]ReviewSchedule
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
profiles: make(map[string]Profile),
|
||||
mastery: make(map[string]map[string]ConceptMastery),
|
||||
misconceptions: make(map[string][]Misconception),
|
||||
interventions: make(map[string][]Intervention),
|
||||
reviewSchedules: make(map[string][]ReviewSchedule),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MemoryStore) UpsertProfile(profile Profile) (Profile, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.profiles[profile.UserID] = cloneProfile(profile)
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetProfile(userID string) (Profile, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
profile, ok := s.profiles[userID]
|
||||
if !ok {
|
||||
return Profile{}, ErrProfileNotFound
|
||||
}
|
||||
return cloneProfile(profile), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) UpsertMastery(mastery ConceptMastery) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.mastery[mastery.UserID]; !ok {
|
||||
s.mastery[mastery.UserID] = make(map[string]ConceptMastery)
|
||||
}
|
||||
s.mastery[mastery.UserID][mastery.Concept.ID] = cloneMastery(mastery)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) AddMisconception(misconception Misconception) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.misconceptions[misconception.UserID] = append(
|
||||
s.misconceptions[misconception.UserID],
|
||||
cloneMisconception(misconception),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) AddIntervention(intervention Intervention) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.interventions[intervention.UserID] = append(
|
||||
s.interventions[intervention.UserID],
|
||||
cloneIntervention(intervention),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) AddReviewSchedule(schedule ReviewSchedule) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.reviewSchedules[schedule.UserID] = append(
|
||||
s.reviewSchedules[schedule.UserID],
|
||||
cloneReviewSchedule(schedule),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Snapshot(userID string) (Snapshot, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
profile, ok := s.profiles[userID]
|
||||
if !ok {
|
||||
return Snapshot{}, ErrProfileNotFound
|
||||
}
|
||||
|
||||
snapshot := Snapshot{
|
||||
Profile: cloneProfile(profile),
|
||||
Mastery: make([]ConceptMastery, 0, len(s.mastery[userID])),
|
||||
Misconceptions: cloneMisconceptions(s.misconceptions[userID]),
|
||||
Interventions: cloneInterventions(s.interventions[userID]),
|
||||
ReviewSchedule: cloneReviewSchedules(s.reviewSchedules[userID]),
|
||||
}
|
||||
for _, mastery := range s.mastery[userID] {
|
||||
snapshot.Mastery = append(snapshot.Mastery, cloneMastery(mastery))
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func cloneProfile(profile Profile) Profile {
|
||||
profile.Stack = append([]string(nil), profile.Stack...)
|
||||
profile.Preferences = append([]string(nil), profile.Preferences...)
|
||||
return profile
|
||||
}
|
||||
|
||||
func cloneMastery(mastery ConceptMastery) ConceptMastery {
|
||||
mastery.Evidence = append([]workflows.EvidenceRef(nil), mastery.Evidence...)
|
||||
return mastery
|
||||
}
|
||||
|
||||
func cloneMisconception(misconception Misconception) Misconception {
|
||||
misconception.Evidence = append([]workflows.EvidenceRef(nil), misconception.Evidence...)
|
||||
return misconception
|
||||
}
|
||||
|
||||
func cloneIntervention(intervention Intervention) Intervention {
|
||||
intervention.Evidence = append([]workflows.EvidenceRef(nil), intervention.Evidence...)
|
||||
return intervention
|
||||
}
|
||||
|
||||
func cloneReviewSchedule(schedule ReviewSchedule) ReviewSchedule {
|
||||
schedule.Evidence = append([]workflows.EvidenceRef(nil), schedule.Evidence...)
|
||||
return schedule
|
||||
}
|
||||
|
||||
func cloneMisconceptions(items []Misconception) []Misconception {
|
||||
cloned := make([]Misconception, len(items))
|
||||
for i, item := range items {
|
||||
cloned[i] = cloneMisconception(item)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneInterventions(items []Intervention) []Intervention {
|
||||
cloned := make([]Intervention, len(items))
|
||||
for i, item := range items {
|
||||
cloned[i] = cloneIntervention(item)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneReviewSchedules(items []ReviewSchedule) []ReviewSchedule {
|
||||
cloned := make([]ReviewSchedule, len(items))
|
||||
for i, item := range items {
|
||||
cloned[i] = cloneReviewSchedule(item)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
68
internal/learnermemory/types.go
Normal file
68
internal/learnermemory/types.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package learnermemory
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
UserID string `json:"user_id"`
|
||||
TargetRole string `json:"target_role"`
|
||||
Stack []string `json:"stack"`
|
||||
InterviewTimeline string `json:"interview_timeline,omitempty"`
|
||||
Preferences []string `json:"preferences"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ConceptMastery struct {
|
||||
UserID string `json:"user_id"`
|
||||
Concept workflows.ConceptRef `json:"concept"`
|
||||
State workflows.ReadinessState `json:"state"`
|
||||
Evidence []workflows.EvidenceRef `json:"evidence"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Misconception struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Concept workflows.ConceptRef `json:"concept"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Evidence []workflows.EvidenceRef `json:"evidence"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Intervention struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Concept workflows.ConceptRef `json:"concept"`
|
||||
Summary string `json:"summary"`
|
||||
Evidence []workflows.EvidenceRef `json:"evidence"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ReviewSchedule struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Concept workflows.ConceptRef `json:"concept"`
|
||||
Reason string `json:"reason"`
|
||||
Evidence []workflows.EvidenceRef `json:"evidence"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Profile Profile `json:"profile"`
|
||||
Mastery []ConceptMastery `json:"mastery"`
|
||||
Misconceptions []Misconception `json:"misconceptions"`
|
||||
Interventions []Intervention `json:"interventions"`
|
||||
ReviewSchedule []ReviewSchedule `json:"review_schedule"`
|
||||
}
|
||||
|
||||
type ProfileInput struct {
|
||||
UserID string
|
||||
TargetRole string
|
||||
Stack []string
|
||||
InterviewTimeline string
|
||||
Preferences []string
|
||||
}
|
||||
Reference in New Issue
Block a user