feat: add learner memory ingestion

This commit is contained in:
user
2026-04-26 16:34:52 +09:00
parent 4a4240fea2
commit 600acf7303
23 changed files with 931 additions and 24 deletions

View File

@@ -6,14 +6,16 @@ import (
"tutor/internal/config"
"tutor/internal/httpapi"
"tutor/internal/interview"
"tutor/internal/learnermemory"
"tutor/internal/workflows"
)
func NewServer(cfg config.Config) *http.Server {
runner := workflows.NewStubRunner()
store := interview.NewMemoryStore()
service := interview.NewService(store, runner)
handler := httpapi.NewHandler(cfg, service)
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
service := interview.NewService(store, runner, memory)
handler := httpapi.NewHandler(cfg, service, memory)
return &http.Server{
Addr: cfg.HTTPAddr,

View File

@@ -9,12 +9,14 @@ import (
"tutor/internal/config"
"tutor/internal/interview"
"tutor/internal/learnermemory"
"tutor/internal/workflows"
)
func TestDiagnosticHTTPFlow(t *testing.T) {
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner())
handler := NewHandler(config.Config{Environment: "test", ModelKey: "deepseek-v4-flash"}, service)
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
handler := NewHandler(config.Config{Environment: "test", ModelKey: "deepseek-v4-flash"}, service, memory)
routes := handler.Routes()
createBody := bytes.NewBufferString(`{
@@ -73,4 +75,22 @@ func TestDiagnosticHTTPFlow(t *testing.T) {
if len(loaded.Answers) != 1 {
t.Fatalf("answers = %d, want 1", len(loaded.Answers))
}
memoryReq := httptest.NewRequest(http.MethodGet, "/api/v1/learners/user-1/memory", nil)
memoryRec := httptest.NewRecorder()
routes.ServeHTTP(memoryRec, memoryReq)
if memoryRec.Code != http.StatusOK {
t.Fatalf("memory status = %d, body = %s", memoryRec.Code, memoryRec.Body.String())
}
var snapshot learnermemory.Snapshot
if err := json.NewDecoder(memoryRec.Body).Decode(&snapshot); err != nil {
t.Fatalf("decode memory response: %v", err)
}
if snapshot.Profile.UserID != "user-1" {
t.Fatalf("memory profile user = %q", snapshot.Profile.UserID)
}
if len(snapshot.Mastery) == 0 {
t.Fatal("expected mastery entries")
}
}

View File

@@ -6,17 +6,20 @@ import (
"tutor/internal/config"
"tutor/internal/interview"
"tutor/internal/learnermemory"
)
type Handler struct {
cfg config.Config
diagnostic *interview.Service
memory *learnermemory.Service
}
func NewHandler(cfg config.Config, diagnostic *interview.Service) Handler {
func NewHandler(cfg config.Config, diagnostic *interview.Service, memory *learnermemory.Service) Handler {
return Handler{
cfg: cfg,
diagnostic: diagnostic,
memory: memory,
}
}
@@ -26,6 +29,7 @@ func (h Handler) Routes() http.Handler {
mux.HandleFunc("POST /api/v1/diagnostic-sessions", h.createDiagnosticSession)
mux.HandleFunc("GET /api/v1/diagnostic-sessions/{id}", h.getDiagnosticSession)
mux.HandleFunc("POST /api/v1/diagnostic-sessions/{id}/answers", h.submitDiagnosticAnswer)
mux.HandleFunc("GET /api/v1/learners/{userID}/memory", h.getLearnerMemory)
return mux
}

View File

@@ -8,6 +8,7 @@ import (
"tutor/internal/config"
"tutor/internal/interview"
"tutor/internal/learnermemory"
"tutor/internal/workflows"
)
@@ -16,8 +17,9 @@ func TestHealth(t *testing.T) {
Environment: "test",
ModelKey: "deepseek-v4-flash",
}
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner())
handler := NewHandler(cfg, service)
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
handler := NewHandler(cfg, service, memory)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()

View File

@@ -0,0 +1,27 @@
package httpapi
import (
"errors"
"net/http"
"tutor/internal/learnermemory"
)
func (h Handler) getLearnerMemory(w http.ResponseWriter, r *http.Request) {
if h.memory == nil {
writeError(w, http.StatusNotFound, "learner memory not configured")
return
}
snapshot, err := h.memory.Snapshot(r.PathValue("userID"))
if errors.Is(err, learnermemory.ErrProfileNotFound) {
writeError(w, http.StatusNotFound, "learner memory not found")
return
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, snapshot)
}

View File

@@ -8,6 +8,7 @@ import (
"sync/atomic"
"time"
"tutor/internal/learnermemory"
"tutor/internal/workflows"
)
@@ -16,11 +17,12 @@ var ErrQuestionNotFound = errors.New("diagnostic question not found")
type Service struct {
store Store
runner workflows.Runner
memory *learnermemory.Service
ids atomic.Uint64
}
func NewService(store Store, runner workflows.Runner) *Service {
return &Service{store: store, runner: runner}
func NewService(store Store, runner workflows.Runner, memory *learnermemory.Service) *Service {
return &Service{store: store, runner: runner, memory: memory}
}
func (s *Service) CreateSession(_ context.Context, input CreateSessionInput) (Session, error) {
@@ -45,6 +47,16 @@ func (s *Service) CreateSession(_ context.Context, input CreateSessionInput) (Se
Questions: BackendDeveloperQuestions(),
CreatedAt: time.Now().UTC(),
}
if s.memory != nil {
if _, err := s.memory.EnsureProfile(learnermemory.ProfileInput{
UserID: session.UserID,
TargetRole: session.TargetRole,
Stack: session.Stack,
InterviewTimeline: session.InterviewTimeline,
}); err != nil {
return Session{}, err
}
}
return s.store.Create(session)
}
@@ -84,6 +96,15 @@ func (s *Service) SubmitAnswer(ctx context.Context, input SubmitAnswerInput) (An
return Answer{}, err
}
answer.Grade = grade
if s.memory != nil {
candidate, err := s.runner.ExtractLearningMemory(ctx, grade)
if err != nil {
return Answer{}, err
}
if err := s.memory.ApplyCandidate(candidate); err != nil {
return Answer{}, err
}
}
session.Answers = append(session.Answers, answer)
if answeredQuestionCount(session.Answers) >= len(session.Questions) {

View File

@@ -4,11 +4,13 @@ import (
"context"
"testing"
"tutor/internal/learnermemory"
"tutor/internal/workflows"
)
func TestDiagnosticSessionAnswerFlow(t *testing.T) {
service := NewService(NewMemoryStore(), workflows.NewStubRunner())
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
service := NewService(NewMemoryStore(), workflows.NewStubRunner(), memory)
session, err := service.CreateSession(context.Background(), CreateSessionInput{
UserID: "user-1",
@@ -53,10 +55,22 @@ func TestDiagnosticSessionAnswerFlow(t *testing.T) {
if len(loaded.Answers) != 1 {
t.Fatalf("answers = %d, want 1", len(loaded.Answers))
}
snapshot, err := memory.Snapshot(session.UserID)
if err != nil {
t.Fatalf("memory snapshot error: %v", err)
}
if len(snapshot.Mastery) == 0 {
t.Fatal("expected memory mastery updates")
}
if len(snapshot.Mastery[0].Evidence) == 0 {
t.Fatal("expected memory evidence")
}
}
func TestDiagnosticSessionCompletesAfterAllQuestionsAnswered(t *testing.T) {
service := NewService(NewMemoryStore(), workflows.NewStubRunner())
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
service := NewService(NewMemoryStore(), workflows.NewStubRunner(), memory)
session, err := service.CreateSession(context.Background(), CreateSessionInput{
UserID: "user-1",

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

View 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")
}
}

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

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

View File

@@ -51,6 +51,7 @@ type ConceptFinding struct {
}
type GradedAnswer struct {
UserID string `json:"user_id"`
AnswerID string `json:"answer_id"`
QuestionID string `json:"question_id"`
Concepts []ConceptRef `json:"concepts"`

View File

@@ -62,6 +62,7 @@ func (StubRunner) GradeInterviewAnswer(_ context.Context, input GradeAnswerInput
}
grade := GradedAnswer{
UserID: input.UserID,
AnswerID: input.AnswerID,
QuestionID: input.QuestionID,
Concepts: append([]ConceptRef(nil), input.Concepts...),
@@ -96,8 +97,65 @@ func (StubRunner) GradeInterviewAnswer(_ context.Context, input GradeAnswerInput
return grade, nil
}
func (StubRunner) ExtractLearningMemory(context.Context, GradedAnswer) (MemoryUpdateCandidate, error) {
return MemoryUpdateCandidate{}, ErrNotImplemented
func (StubRunner) ExtractLearningMemory(_ context.Context, grade GradedAnswer) (MemoryUpdateCandidate, error) {
candidate := MemoryUpdateCandidate{
UserID: grade.UserID,
SourceAnswerID: grade.AnswerID,
Updates: []MemoryUpdate{},
}
if len(grade.Evidence) == 0 {
return candidate, nil
}
state := readinessFromOverall(grade.Overall)
durability := DurabilityTentative
if grade.Overall == AnswerStrong {
durability = DurabilityConfirmed
}
for _, concept := range grade.Concepts {
candidate.Updates = append(candidate.Updates, MemoryUpdate{
Kind: MemoryConceptMastery,
Concept: concept,
ProposedState: state,
Summary: "Concept readiness updated from diagnostic interview answer.",
Evidence: append([]EvidenceRef(nil), grade.Evidence...),
Confidence: confidenceFromOverall(grade.Overall),
Durability: durability,
})
if grade.FollowUp.Needed {
candidate.Updates = append(candidate.Updates,
MemoryUpdate{
Kind: MemoryMisconception,
Concept: concept,
ProposedState: ReadinessFragile,
Summary: "Needs more concrete reasoning and tradeoff discussion.",
Evidence: append([]EvidenceRef(nil), grade.Evidence...),
Confidence: 0.62,
Durability: DurabilityTentative,
},
MemoryUpdate{
Kind: MemoryIntervention,
Concept: concept,
ProposedState: state,
Summary: grade.FollowUp.Question,
Evidence: append([]EvidenceRef(nil), grade.Evidence...),
Confidence: 0.7,
Durability: DurabilityTentative,
},
MemoryUpdate{
Kind: MemoryReviewSchedule,
Concept: concept,
ProposedState: state,
Summary: "Review with a concrete production example before raising difficulty.",
Evidence: append([]EvidenceRef(nil), grade.Evidence...),
Confidence: 0.7,
Durability: DurabilityTentative,
},
)
}
}
return candidate, nil
}
func (StubRunner) SelectNextChallenge(context.Context, NextChallengeInput) (NextChallenge, error) {
@@ -117,3 +175,33 @@ func scoreFromWords(wordCount int, target int) int {
}
return 1
}
func readinessFromOverall(overall AnswerOverall) ReadinessState {
switch overall {
case AnswerMiss:
return ReadinessFragile
case AnswerPartial:
return ReadinessImproving
case AnswerSolid:
return ReadinessInterviewReady
case AnswerStrong:
return ReadinessStrongSignal
default:
return ReadinessUnknown
}
}
func confidenceFromOverall(overall AnswerOverall) float64 {
switch overall {
case AnswerMiss:
return 0.58
case AnswerPartial:
return 0.68
case AnswerSolid:
return 0.82
case AnswerStrong:
return 0.9
default:
return 0.5
}
}

View File

@@ -24,6 +24,7 @@ func TestStubRunnerGradesAnswer(t *testing.T) {
runner := NewStubRunner()
grade, err := runner.GradeInterviewAnswer(context.Background(), GradeAnswerInput{
UserID: "user-1",
QuestionID: "q-1",
AnswerID: "a-1",
AnswerText: "Indexes can speed reads by helping the database find rows, but they add write overhead.",
@@ -37,6 +38,9 @@ func TestStubRunnerGradesAnswer(t *testing.T) {
if grade.AnswerID != "a-1" {
t.Fatalf("AnswerID = %q", grade.AnswerID)
}
if grade.UserID != "user-1" {
t.Fatalf("UserID = %q", grade.UserID)
}
if len(grade.Concepts) != 1 {
t.Fatalf("concepts = %d, want 1", len(grade.Concepts))
}
@@ -44,3 +48,40 @@ func TestStubRunnerGradesAnswer(t *testing.T) {
t.Fatalf("evidence = %d, want 1", len(grade.Evidence))
}
}
func TestStubRunnerExtractsLearningMemory(t *testing.T) {
runner := NewStubRunner()
grade := GradedAnswer{
UserID: "user-1",
AnswerID: "a-1",
QuestionID: "q-1",
Concepts: []ConceptRef{
{ID: "cache-invalidation", Label: "Cache invalidation", Track: "backend-developer"},
},
Overall: AnswerPartial,
Evidence: []EvidenceRef{
{Kind: EvidenceAnswer, ID: "a-1", Confidence: 1},
},
FollowUp: FollowUpRecommendation{
Needed: true,
Question: "Can you explain the tradeoff?",
Purpose: FollowUpRepair,
},
}
candidate, err := runner.ExtractLearningMemory(context.Background(), grade)
if err != nil {
t.Fatalf("ExtractLearningMemory error: %v", err)
}
if candidate.UserID != "user-1" {
t.Fatalf("UserID = %q", candidate.UserID)
}
if len(candidate.Updates) != 4 {
t.Fatalf("updates = %d, want 4", len(candidate.Updates))
}
for _, update := range candidate.Updates {
if len(update.Evidence) == 0 {
t.Fatal("expected every memory update to carry evidence")
}
}
}