feat: add progression readiness api
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"tutor/internal/httpapi"
|
||||
"tutor/internal/interview"
|
||||
"tutor/internal/learnermemory"
|
||||
"tutor/internal/progression"
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
@@ -14,8 +15,9 @@ func NewServer(cfg config.Config) *http.Server {
|
||||
runner := workflows.NewStubRunner()
|
||||
store := interview.NewMemoryStore()
|
||||
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||
progress := progression.NewService(memory)
|
||||
service := interview.NewService(store, runner, memory)
|
||||
handler := httpapi.NewHandler(cfg, service, memory)
|
||||
handler := httpapi.NewHandler(cfg, service, memory, progress)
|
||||
|
||||
return &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
|
||||
@@ -10,13 +10,15 @@ import (
|
||||
"tutor/internal/config"
|
||||
"tutor/internal/interview"
|
||||
"tutor/internal/learnermemory"
|
||||
"tutor/internal/progression"
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
func TestDiagnosticHTTPFlow(t *testing.T) {
|
||||
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)
|
||||
progress := progression.NewService(memory)
|
||||
handler := NewHandler(config.Config{Environment: "test", ModelKey: "deepseek-v4-flash"}, service, memory, progress)
|
||||
routes := handler.Routes()
|
||||
|
||||
createBody := bytes.NewBufferString(`{
|
||||
@@ -93,4 +95,20 @@ func TestDiagnosticHTTPFlow(t *testing.T) {
|
||||
if len(snapshot.Mastery) == 0 {
|
||||
t.Fatal("expected mastery entries")
|
||||
}
|
||||
|
||||
readinessReq := httptest.NewRequest(http.MethodGet, "/api/v1/learners/user-1/readiness-map", nil)
|
||||
readinessRec := httptest.NewRecorder()
|
||||
routes.ServeHTTP(readinessRec, readinessReq)
|
||||
|
||||
if readinessRec.Code != http.StatusOK {
|
||||
t.Fatalf("readiness status = %d, body = %s", readinessRec.Code, readinessRec.Body.String())
|
||||
}
|
||||
|
||||
challengeReq := httptest.NewRequest(http.MethodGet, "/api/v1/learners/user-1/next-challenge", nil)
|
||||
challengeRec := httptest.NewRecorder()
|
||||
routes.ServeHTTP(challengeRec, challengeReq)
|
||||
|
||||
if challengeRec.Code != http.StatusOK {
|
||||
t.Fatalf("challenge status = %d, body = %s", challengeRec.Code, challengeRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,19 +7,27 @@ import (
|
||||
"tutor/internal/config"
|
||||
"tutor/internal/interview"
|
||||
"tutor/internal/learnermemory"
|
||||
"tutor/internal/progression"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
cfg config.Config
|
||||
diagnostic *interview.Service
|
||||
memory *learnermemory.Service
|
||||
progress *progression.Service
|
||||
}
|
||||
|
||||
func NewHandler(cfg config.Config, diagnostic *interview.Service, memory *learnermemory.Service) Handler {
|
||||
func NewHandler(
|
||||
cfg config.Config,
|
||||
diagnostic *interview.Service,
|
||||
memory *learnermemory.Service,
|
||||
progress *progression.Service,
|
||||
) Handler {
|
||||
return Handler{
|
||||
cfg: cfg,
|
||||
diagnostic: diagnostic,
|
||||
memory: memory,
|
||||
progress: progress,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +38,8 @@ func (h Handler) Routes() http.Handler {
|
||||
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)
|
||||
mux.HandleFunc("GET /api/v1/learners/{userID}/readiness-map", h.getReadinessMap)
|
||||
mux.HandleFunc("GET /api/v1/learners/{userID}/next-challenge", h.getNextChallenge)
|
||||
return mux
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"tutor/internal/config"
|
||||
"tutor/internal/interview"
|
||||
"tutor/internal/learnermemory"
|
||||
"tutor/internal/progression"
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
@@ -19,7 +20,8 @@ func TestHealth(t *testing.T) {
|
||||
}
|
||||
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
|
||||
handler := NewHandler(cfg, service, memory)
|
||||
progress := progression.NewService(memory)
|
||||
handler := NewHandler(cfg, service, memory, progress)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
46
internal/httpapi/progression.go
Normal file
46
internal/httpapi/progression.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"tutor/internal/learnermemory"
|
||||
)
|
||||
|
||||
func (h Handler) getReadinessMap(w http.ResponseWriter, r *http.Request) {
|
||||
if h.progress == nil {
|
||||
writeError(w, http.StatusNotFound, "progression not configured")
|
||||
return
|
||||
}
|
||||
|
||||
readiness, err := h.progress.ReadinessMap(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, readiness)
|
||||
}
|
||||
|
||||
func (h Handler) getNextChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
if h.progress == nil {
|
||||
writeError(w, http.StatusNotFound, "progression not configured")
|
||||
return
|
||||
}
|
||||
|
||||
challenge, err := h.progress.NextChallenge(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, challenge)
|
||||
}
|
||||
207
internal/progression/service.go
Normal file
207
internal/progression/service.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package progression
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"tutor/internal/learnermemory"
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
memory *learnermemory.Service
|
||||
}
|
||||
|
||||
func NewService(memory *learnermemory.Service) *Service {
|
||||
return &Service{memory: memory}
|
||||
}
|
||||
|
||||
func (s *Service) ReadinessMap(userID string) (ReadinessMap, error) {
|
||||
snapshot, err := s.snapshot(userID)
|
||||
if err != nil {
|
||||
return ReadinessMap{}, err
|
||||
}
|
||||
|
||||
concepts := make([]ConceptProgress, 0, len(snapshot.Mastery))
|
||||
for _, mastery := range snapshot.Mastery {
|
||||
concepts = append(concepts, ConceptProgress{
|
||||
Concept: mastery.Concept,
|
||||
State: mastery.State,
|
||||
LadderLevel: ladderForState(mastery.State),
|
||||
NextAction: actionForState(mastery.State),
|
||||
Evidence: append([]workflows.EvidenceRef(nil), mastery.Evidence...),
|
||||
})
|
||||
}
|
||||
sort.Slice(concepts, func(i, j int) bool {
|
||||
return concepts[i].Concept.ID < concepts[j].Concept.ID
|
||||
})
|
||||
|
||||
readiness := ReadinessMap{
|
||||
UserID: snapshot.Profile.UserID,
|
||||
Track: trackFromConcepts(concepts),
|
||||
ReadinessPercentage: readinessPercentage(concepts),
|
||||
Concepts: concepts,
|
||||
Rewards: rewardsFromConcepts(concepts),
|
||||
Unlocks: unlocksFromConcepts(concepts),
|
||||
}
|
||||
return readiness, nil
|
||||
}
|
||||
|
||||
func (s *Service) NextChallenge(userID string) (workflows.NextChallenge, error) {
|
||||
readiness, err := s.ReadinessMap(userID)
|
||||
if err != nil {
|
||||
return workflows.NextChallenge{}, err
|
||||
}
|
||||
if len(readiness.Concepts) == 0 {
|
||||
return workflows.NextChallenge{}, errors.New("no learner memory concepts available")
|
||||
}
|
||||
|
||||
target := readiness.Concepts[0]
|
||||
for _, concept := range readiness.Concepts[1:] {
|
||||
if readinessScore(concept.State) < readinessScore(target.State) {
|
||||
target = concept
|
||||
}
|
||||
}
|
||||
|
||||
return workflows.NextChallenge{
|
||||
UserID: readiness.UserID,
|
||||
Track: readiness.Track,
|
||||
Concept: target.Concept,
|
||||
LadderLevel: target.LadderLevel,
|
||||
Question: challengeQuestion(target),
|
||||
Rationale: "Selected from the weakest evidenced learner-memory concept.",
|
||||
DifficultyAction: workflowDifficulty(target.NextAction),
|
||||
Evidence: append([]workflows.EvidenceRef(nil), target.Evidence...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) snapshot(userID string) (learnermemory.Snapshot, error) {
|
||||
if s.memory == nil {
|
||||
return learnermemory.Snapshot{}, errors.New("learner memory not configured")
|
||||
}
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return learnermemory.Snapshot{}, errors.New("user_id is required")
|
||||
}
|
||||
return s.memory.Snapshot(userID)
|
||||
}
|
||||
|
||||
func ladderForState(state workflows.ReadinessState) workflows.LadderLevel {
|
||||
switch state {
|
||||
case workflows.ReadinessFragile:
|
||||
return workflows.LadderDefine
|
||||
case workflows.ReadinessImproving:
|
||||
return workflows.LadderTradeoffs
|
||||
case workflows.ReadinessInterviewReady:
|
||||
return workflows.LadderDesignConstraints
|
||||
case workflows.ReadinessStrongSignal:
|
||||
return workflows.LadderInterviewPressure
|
||||
default:
|
||||
return workflows.LadderDefine
|
||||
}
|
||||
}
|
||||
|
||||
func actionForState(state workflows.ReadinessState) DifficultyAction {
|
||||
switch state {
|
||||
case workflows.ReadinessFragile:
|
||||
return ActionRecover
|
||||
case workflows.ReadinessImproving:
|
||||
return ActionHold
|
||||
case workflows.ReadinessInterviewReady, workflows.ReadinessStrongSignal:
|
||||
return ActionRaise
|
||||
default:
|
||||
return ActionLower
|
||||
}
|
||||
}
|
||||
|
||||
func workflowDifficulty(action DifficultyAction) workflows.DifficultyAction {
|
||||
switch action {
|
||||
case ActionRecover:
|
||||
return workflows.DifficultyRecover
|
||||
case ActionLower:
|
||||
return workflows.DifficultyLower
|
||||
case ActionRaise:
|
||||
return workflows.DifficultyRaise
|
||||
default:
|
||||
return workflows.DifficultyHold
|
||||
}
|
||||
}
|
||||
|
||||
func readinessPercentage(concepts []ConceptProgress) int {
|
||||
if len(concepts) == 0 {
|
||||
return 0
|
||||
}
|
||||
total := 0
|
||||
for _, concept := range concepts {
|
||||
total += readinessScore(concept.State)
|
||||
}
|
||||
return total * 100 / (len(concepts) * 4)
|
||||
}
|
||||
|
||||
func readinessScore(state workflows.ReadinessState) int {
|
||||
switch state {
|
||||
case workflows.ReadinessFragile:
|
||||
return 1
|
||||
case workflows.ReadinessImproving:
|
||||
return 2
|
||||
case workflows.ReadinessInterviewReady:
|
||||
return 3
|
||||
case workflows.ReadinessStrongSignal:
|
||||
return 4
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func rewardsFromConcepts(concepts []ConceptProgress) []Reward {
|
||||
rewards := []Reward{}
|
||||
for _, concept := range concepts {
|
||||
if len(concept.Evidence) == 0 || readinessScore(concept.State) < 2 {
|
||||
continue
|
||||
}
|
||||
rewards = append(rewards, Reward{
|
||||
Kind: RewardConceptProgress,
|
||||
Label: "Evidence-backed progress on " + concept.Concept.Label,
|
||||
Evidence: append([]workflows.EvidenceRef(nil), concept.Evidence...),
|
||||
})
|
||||
}
|
||||
return rewards
|
||||
}
|
||||
|
||||
func unlocksFromConcepts(concepts []ConceptProgress) []Unlock {
|
||||
stable := []workflows.EvidenceRef{}
|
||||
for _, concept := range concepts {
|
||||
if readinessScore(concept.State) < 3 || len(concept.Evidence) == 0 {
|
||||
continue
|
||||
}
|
||||
stable = append(stable, concept.Evidence...)
|
||||
}
|
||||
if len(stable) < 2 {
|
||||
return []Unlock{}
|
||||
}
|
||||
return []Unlock{{
|
||||
Kind: UnlockBossQuestion,
|
||||
Label: "Integrated backend interview boss question",
|
||||
Evidence: append([]workflows.EvidenceRef(nil), stable...),
|
||||
}}
|
||||
}
|
||||
|
||||
func trackFromConcepts(concepts []ConceptProgress) string {
|
||||
if len(concepts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return concepts[0].Concept.Track
|
||||
}
|
||||
|
||||
func challengeQuestion(concept ConceptProgress) string {
|
||||
switch concept.LadderLevel {
|
||||
case workflows.LadderTradeoffs:
|
||||
return "Explain a production tradeoff for " + concept.Concept.Label + "."
|
||||
case workflows.LadderDesignConstraints:
|
||||
return "Design a constrained backend scenario that uses " + concept.Concept.Label + "."
|
||||
case workflows.LadderInterviewPressure:
|
||||
return "Answer a timed interview follow-up about " + concept.Concept.Label + "."
|
||||
default:
|
||||
return "Define " + concept.Concept.Label + " and give one concrete backend example."
|
||||
}
|
||||
}
|
||||
97
internal/progression/service_test.go
Normal file
97
internal/progression/service_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package progression
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"tutor/internal/learnermemory"
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
func TestReadinessMapUsesEvidenceBackedMemory(t *testing.T) {
|
||||
service := seededService(t, workflows.ReadinessImproving)
|
||||
|
||||
readiness, err := service.ReadinessMap("user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadinessMap error: %v", err)
|
||||
}
|
||||
if readiness.ReadinessPercentage != 50 {
|
||||
t.Fatalf("readiness = %d, want 50", readiness.ReadinessPercentage)
|
||||
}
|
||||
if len(readiness.Concepts) != 1 {
|
||||
t.Fatalf("concepts = %d, want 1", len(readiness.Concepts))
|
||||
}
|
||||
if readiness.Concepts[0].LadderLevel != workflows.LadderTradeoffs {
|
||||
t.Fatalf("ladder = %q", readiness.Concepts[0].LadderLevel)
|
||||
}
|
||||
if len(readiness.Rewards) != 1 {
|
||||
t.Fatalf("rewards = %d, want 1", len(readiness.Rewards))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextChallengeTargetsWeakestConcept(t *testing.T) {
|
||||
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||
if _, err := memory.EnsureProfile(learnermemory.ProfileInput{
|
||||
UserID: "user-1",
|
||||
TargetRole: "backend developer",
|
||||
Stack: []string{"go"},
|
||||
}); err != nil {
|
||||
t.Fatalf("EnsureProfile error: %v", err)
|
||||
}
|
||||
evidence := []workflows.EvidenceRef{{Kind: workflows.EvidenceAnswer, ID: "a-1", Confidence: 1}}
|
||||
if err := memory.ApplyCandidate(workflows.MemoryUpdateCandidate{
|
||||
UserID: "user-1",
|
||||
Updates: []workflows.MemoryUpdate{
|
||||
{
|
||||
Kind: workflows.MemoryConceptMastery,
|
||||
Concept: workflows.ConceptRef{ID: "cache", Label: "Cache invalidation", Track: "backend-developer"},
|
||||
ProposedState: workflows.ReadinessInterviewReady,
|
||||
Evidence: evidence,
|
||||
},
|
||||
{
|
||||
Kind: workflows.MemoryConceptMastery,
|
||||
Concept: workflows.ConceptRef{ID: "indexes", Label: "Database indexes", Track: "backend-developer"},
|
||||
ProposedState: workflows.ReadinessFragile,
|
||||
Evidence: evidence,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyCandidate error: %v", err)
|
||||
}
|
||||
|
||||
challenge, err := NewService(memory).NextChallenge("user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("NextChallenge error: %v", err)
|
||||
}
|
||||
if challenge.Concept.ID != "indexes" {
|
||||
t.Fatalf("challenge concept = %q", challenge.Concept.ID)
|
||||
}
|
||||
if challenge.DifficultyAction != workflows.DifficultyRecover {
|
||||
t.Fatalf("difficulty = %q", challenge.DifficultyAction)
|
||||
}
|
||||
}
|
||||
|
||||
func seededService(t *testing.T, state workflows.ReadinessState) *Service {
|
||||
t.Helper()
|
||||
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||
if _, err := memory.EnsureProfile(learnermemory.ProfileInput{
|
||||
UserID: "user-1",
|
||||
TargetRole: "backend developer",
|
||||
Stack: []string{"go"},
|
||||
}); err != nil {
|
||||
t.Fatalf("EnsureProfile error: %v", err)
|
||||
}
|
||||
if err := memory.ApplyCandidate(workflows.MemoryUpdateCandidate{
|
||||
UserID: "user-1",
|
||||
Updates: []workflows.MemoryUpdate{
|
||||
{
|
||||
Kind: workflows.MemoryConceptMastery,
|
||||
Concept: workflows.ConceptRef{ID: "idempotency", Label: "HTTP idempotency", Track: "backend-developer"},
|
||||
ProposedState: state,
|
||||
Evidence: []workflows.EvidenceRef{{Kind: workflows.EvidenceAnswer, ID: "a-1", Confidence: 1}},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyCandidate error: %v", err)
|
||||
}
|
||||
return NewService(memory)
|
||||
}
|
||||
54
internal/progression/types.go
Normal file
54
internal/progression/types.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package progression
|
||||
|
||||
import "tutor/internal/workflows"
|
||||
|
||||
type ReadinessMap struct {
|
||||
UserID string `json:"user_id"`
|
||||
Track string `json:"track"`
|
||||
ReadinessPercentage int `json:"readiness_percentage"`
|
||||
Concepts []ConceptProgress `json:"concepts"`
|
||||
Rewards []Reward `json:"rewards"`
|
||||
Unlocks []Unlock `json:"unlocks"`
|
||||
}
|
||||
|
||||
type ConceptProgress struct {
|
||||
Concept workflows.ConceptRef `json:"concept"`
|
||||
State workflows.ReadinessState `json:"state"`
|
||||
LadderLevel workflows.LadderLevel `json:"ladder_level"`
|
||||
NextAction DifficultyAction `json:"next_action"`
|
||||
Evidence []workflows.EvidenceRef `json:"evidence"`
|
||||
}
|
||||
|
||||
type DifficultyAction string
|
||||
|
||||
const (
|
||||
ActionRecover DifficultyAction = "recover"
|
||||
ActionLower DifficultyAction = "lower"
|
||||
ActionHold DifficultyAction = "hold"
|
||||
ActionRaise DifficultyAction = "raise"
|
||||
)
|
||||
|
||||
type Reward struct {
|
||||
Kind RewardKind `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Evidence []workflows.EvidenceRef `json:"evidence"`
|
||||
}
|
||||
|
||||
type RewardKind string
|
||||
|
||||
const (
|
||||
RewardConceptProgress RewardKind = "concept_progress"
|
||||
RewardReadiness RewardKind = "readiness"
|
||||
)
|
||||
|
||||
type Unlock struct {
|
||||
Kind UnlockKind `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Evidence []workflows.EvidenceRef `json:"evidence"`
|
||||
}
|
||||
|
||||
type UnlockKind string
|
||||
|
||||
const (
|
||||
UnlockBossQuestion UnlockKind = "boss_question"
|
||||
)
|
||||
Reference in New Issue
Block a user