From a413f1ef152d5dac42f0826245235537cfd0c66e Mon Sep 17 00:00:00 2001 From: user Date: Sun, 26 Apr 2026 16:39:19 +0900 Subject: [PATCH] feat: add progression readiness api --- .planning/REQUIREMENTS.md | 14 +- .planning/STATE.md | 13 +- .../phases/004-progression/004-CONTEXT.md | 39 ++++ .planning/phases/004-progression/004-PLAN.md | 46 ++++ .../phases/004-progression/004-RESEARCH.md | 32 +++ .../phases/004-progression/004-SUMMARY.md | 35 +++ .../004-progression/004-VERIFICATION.md | 28 +++ internal/app/server.go | 4 +- internal/httpapi/diagnostic_test.go | 20 +- internal/httpapi/handler.go | 12 +- internal/httpapi/handler_test.go | 4 +- internal/httpapi/progression.go | 46 ++++ internal/progression/service.go | 207 ++++++++++++++++++ internal/progression/service_test.go | 97 ++++++++ internal/progression/types.go | 54 +++++ .../bootstrap-job-tutor-platform/tasks.md | 1 + 16 files changed, 637 insertions(+), 15 deletions(-) create mode 100644 .planning/phases/004-progression/004-CONTEXT.md create mode 100644 .planning/phases/004-progression/004-PLAN.md create mode 100644 .planning/phases/004-progression/004-RESEARCH.md create mode 100644 .planning/phases/004-progression/004-SUMMARY.md create mode 100644 .planning/phases/004-progression/004-VERIFICATION.md create mode 100644 internal/httpapi/progression.go create mode 100644 internal/progression/service.go create mode 100644 internal/progression/service_test.go create mode 100644 internal/progression/types.go diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 4c2d82c..85fbed8 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -38,14 +38,14 @@ interview-ready after each short practice loop. ### Progression -- [ ] **PROG-01**: User can see a role-specific readiness map. -- [ ] **PROG-02**: Concepts have challenge ladders from definition to interview +- [x] **PROG-01**: User can see a role-specific readiness map. +- [x] **PROG-02**: Concepts have challenge ladders from definition to interview pressure. -- [ ] **PROG-03**: System selects next challenge based on learner memory and +- [x] **PROG-03**: System selects next challenge based on learner memory and grading evidence. -- [ ] **PROG-04**: System unlocks boss-style integrated questions after +- [x] **PROG-04**: System unlocks boss-style integrated questions after prerequisite stability. -- [ ] **PROG-05**: Streaks and rewards avoid punitive or gambling-like mechanics. +- [x] **PROG-05**: Streaks and rewards avoid punitive or gambling-like mechanics. ### Ontology and Learning Materials @@ -97,7 +97,7 @@ interview-ready after each short practice loop. | BACK-01..BACK-05 | Phase 1 | Complete | | INT-01..INT-06 | Phase 2 | Complete | | MEM-01..MEM-05 | Phase 3 | Complete | -| PROG-01..PROG-05 | Phase 4 | Pending | +| PROG-01..PROG-05 | Phase 4 | Complete | | ONTO-01..ONTO-04 | Phase 5 | Pending | | ASSET-01..ASSET-03 | Phase 6 | Pending | @@ -108,4 +108,4 @@ interview-ready after each short practice loop. --- *Requirements defined: 2026-04-26* -*Last updated: 2026-04-26 after Phase 3 execution.* +*Last updated: 2026-04-26 after Phase 4 execution.* diff --git a/.planning/STATE.md b/.planning/STATE.md index 82068a6..23cffc8 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -7,7 +7,7 @@ See: `.planning/PROJECT.md` (updated 2026-04-26) **Core value:** The user should feel and prove that they are becoming more interview-ready after each short practice loop. -**Current focus:** Phase 4 planning: Progression. +**Current focus:** Phase 5 planning: Ontology and Learning Materials. ## Current Decisions @@ -27,14 +27,16 @@ interview-ready after each short practice loop. - Phase 3 learner memory is implemented and verified with evidence-backed in-memory profiles, mastery, misconceptions, interventions, and review schedules. +- Phase 4 progression is implemented and verified with readiness map and next + challenge APIs derived from learner memory evidence. ## Next Actions -1. Plan Phase 4 progression with GSD. +1. Plan Phase 5 ontology and learning material ingestion with GSD. 2. Keep `docs/planning/WORKFLOW_CONTRACTS.md` aligned with Go structs during future workflow implementation. -3. Decide whether Phase 4 readiness map reads directly from learner memory or - introduces a derived progression projection. +3. Decide the MVP ontology storage boundary before accepting uploaded source + materials. ## Validation Log @@ -51,6 +53,9 @@ interview-ready after each short practice loop. - 2026-04-26: Phase 3 implementation verified with `go test ./...`, `openspec validate bootstrap-job-tutor-platform --strict`, live diagnostic answer to learner-memory smoke, and Go source line-count check. +- 2026-04-26: Phase 4 implementation verified with `go test ./...`, + `openspec validate bootstrap-job-tutor-platform --strict`, live readiness and + next-challenge smoke, and Go source line-count check. --- *State initialized: 2026-04-26.* diff --git a/.planning/phases/004-progression/004-CONTEXT.md b/.planning/phases/004-progression/004-CONTEXT.md new file mode 100644 index 0000000..217f4d1 --- /dev/null +++ b/.planning/phases/004-progression/004-CONTEXT.md @@ -0,0 +1,39 @@ +# Phase 4 Context: Progression + +**Status:** Ready for execution +**Started:** 2026-04-26 + +## Goal + +Expose visible, evidence-backed progression after diagnostic practice. + +## Inputs + +- OpenSpec `learning-progression` requirements. +- `docs/planning/GAMIFICATION.md`. +- Phase 3 learner memory snapshots. +- Existing workflow contracts for `NextChallenge` and `ReadinessUpdate`. + +## Decisions + +- Derive MVP readiness directly from learner memory. +- Keep progression read-only except for future workflow outputs. +- Do not add streak persistence yet. +- Rewards and unlocks must be deterministic and evidence-backed. + +## Boundaries + +In scope: + +- Role readiness map API. +- Concept ladder level calculation. +- Next challenge selection API. +- Boss-style unlock when prerequisite concepts are stable. +- Tests and verification. + +Out of scope: + +- Frontend map UI. +- Persistent campaign/streak storage. +- Social leaderboards. +- Random reward economy. diff --git a/.planning/phases/004-progression/004-PLAN.md b/.planning/phases/004-progression/004-PLAN.md new file mode 100644 index 0000000..cb4620f --- /dev/null +++ b/.planning/phases/004-progression/004-PLAN.md @@ -0,0 +1,46 @@ +# Phase 4 Plan: Progression + +**Status:** Ready for execution +**Phase Goal:** Show evidence-backed readiness and select the next challenge. + +## Requirements Covered + +- PROG-01: User can see a role-specific readiness map. +- PROG-02: Concepts have challenge ladders from definition to interview + pressure. +- PROG-03: System selects next challenge based on learner memory and grading + evidence. +- PROG-04: System unlocks boss-style integrated questions after prerequisite + stability. +- PROG-05: Streaks and rewards avoid punitive or gambling-like mechanics. + +## Tasks + +### 1. Add progression package + +- Define readiness map, concept progress, reward, and unlock types. +- Compute readiness from learner memory snapshots. + +### 2. Add next challenge selection + +- Select the weakest evidenced concept first. +- Use review schedule or misconception evidence to choose recovery difficulty. +- Produce typed `workflows.NextChallenge`. + +### 3. Add HTTP endpoints + +- `GET /api/v1/learners/{userID}/readiness-map` +- `GET /api/v1/learners/{userID}/next-challenge` + +### 4. Add tests and verification + +- Test readiness map projection. +- Test next challenge selection from weak memory. +- Test HTTP readiness flow after diagnostic answer. +- Run Go tests, OpenSpec validation, line-count check, and smoke. + +## Out of Scope + +- Frontend visualization. +- Persistent streak history. +- Multi-track progression graph. diff --git a/.planning/phases/004-progression/004-RESEARCH.md b/.planning/phases/004-progression/004-RESEARCH.md new file mode 100644 index 0000000..c7133d2 --- /dev/null +++ b/.planning/phases/004-progression/004-RESEARCH.md @@ -0,0 +1,32 @@ +# Phase 4 Research: Progression + +## Findings + +Learner memory already stores the minimum evidence needed for progression: + +- concept mastery state +- evidence references +- misconceptions +- review schedules +- interventions + +The MVP progression surface can therefore be computed as a projection rather +than a new durable source of truth. + +## Recommended Shape + +- `internal/progression` owns readiness projection and challenge selection. +- `learnermemory.Service` remains the source for learner state. +- Readiness percentage should be simple and explainable. +- Challenge ladder should map readiness state to the next useful task: + - unknown/fragile: define or recovery + - improving: tradeoffs + - interview-ready: design constraints + - strong signal: interview pressure +- Boss unlock requires at least two stable concepts with evidence. + +## Risks + +- Too much gamification logic can become speculative. Keep it deterministic. +- Readiness percentages can feel fake if not traceable. Include evidence. +- Missing memory should return a normal 404, not invented progress. diff --git a/.planning/phases/004-progression/004-SUMMARY.md b/.planning/phases/004-progression/004-SUMMARY.md new file mode 100644 index 0000000..89c0bd0 --- /dev/null +++ b/.planning/phases/004-progression/004-SUMMARY.md @@ -0,0 +1,35 @@ +# Phase 4 Summary + +**Status:** Complete +**Completed:** 2026-04-26 + +## Delivered + +- Added `internal/progression` for readiness projection and next challenge + selection. +- Added role readiness map calculation from learner memory evidence. +- Added deterministic challenge ladder mapping. +- Added evidence-backed rewards and boss-question unlocks. +- Added HTTP endpoints: + - `GET /api/v1/learners/{userID}/readiness-map` + - `GET /api/v1/learners/{userID}/next-challenge` +- Added progression unit tests and HTTP flow coverage. + +## Verification + +```powershell +gofmt -w cmd internal +go test ./... +openspec validate bootstrap-job-tutor-platform --strict +``` + +Additional smoke check: + +- Diagnostic create/answer followed by readiness-map and next-challenge reads + returned readiness `75`, one concept, and a typed challenge. + +## Deferred + +- Frontend readiness visualization. +- Persistent campaign and streak state. +- Multi-concept cluster graph beyond simple stable-count boss unlock. diff --git a/.planning/phases/004-progression/004-VERIFICATION.md b/.planning/phases/004-progression/004-VERIFICATION.md new file mode 100644 index 0000000..c273345 --- /dev/null +++ b/.planning/phases/004-progression/004-VERIFICATION.md @@ -0,0 +1,28 @@ +# Phase 4 Verification + +## Verdict + +PASS + +## Requirement Coverage + +- PROG-01: PASS. Readiness map API returns learner concept readiness. +- PROG-02: PASS. Each concept maps to a challenge ladder level. +- PROG-03: PASS. Next challenge selection targets the weakest evidenced + learner-memory concept. +- PROG-04: PASS. Boss unlocks are produced only from stable evidenced concepts. +- PROG-05: PASS. Rewards are deterministic, evidence-backed, and do not punish + missed days or use random reward mechanics. + +## Evidence + +- `go test ./...` passed. +- `openspec validate bootstrap-job-tutor-platform --strict` passed. +- Live diagnostic create/answer plus readiness and next-challenge smoke passed. +- Go source line-count check passed. + +## Residual Risk + +Progression is currently an in-memory projection. It is enough for MVP proof but +will need persisted campaign state before real streaks or long-running +readiness histories. diff --git a/internal/app/server.go b/internal/app/server.go index 8884a57..12823ee 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -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, diff --git a/internal/httpapi/diagnostic_test.go b/internal/httpapi/diagnostic_test.go index 52e5dad..3ef1fb5 100644 --- a/internal/httpapi/diagnostic_test.go +++ b/internal/httpapi/diagnostic_test.go @@ -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()) + } } diff --git a/internal/httpapi/handler.go b/internal/httpapi/handler.go index e9c4b91..6a8aaae 100644 --- a/internal/httpapi/handler.go +++ b/internal/httpapi/handler.go @@ -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 } diff --git a/internal/httpapi/handler_test.go b/internal/httpapi/handler_test.go index 219e759..48f143e 100644 --- a/internal/httpapi/handler_test.go +++ b/internal/httpapi/handler_test.go @@ -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() diff --git a/internal/httpapi/progression.go b/internal/httpapi/progression.go new file mode 100644 index 0000000..367095f --- /dev/null +++ b/internal/httpapi/progression.go @@ -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) +} diff --git a/internal/progression/service.go b/internal/progression/service.go new file mode 100644 index 0000000..13c8871 --- /dev/null +++ b/internal/progression/service.go @@ -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." + } +} diff --git a/internal/progression/service_test.go b/internal/progression/service_test.go new file mode 100644 index 0000000..4c76c1e --- /dev/null +++ b/internal/progression/service_test.go @@ -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) +} diff --git a/internal/progression/types.go b/internal/progression/types.go new file mode 100644 index 0000000..89bed57 --- /dev/null +++ b/internal/progression/types.go @@ -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" +) diff --git a/openspec/changes/bootstrap-job-tutor-platform/tasks.md b/openspec/changes/bootstrap-job-tutor-platform/tasks.md index 60a3f97..a5d7ee6 100644 --- a/openspec/changes/bootstrap-job-tutor-platform/tasks.md +++ b/openspec/changes/bootstrap-job-tutor-platform/tasks.md @@ -14,3 +14,4 @@ - [ ] 10. Draft the first `agent-farm-go` YAML workflow package. - [x] 11. Validate the OpenSpec change. - [x] 12. Implement evidence-backed learner memory ingestion and readback. +- [x] 13. Implement evidence-backed readiness map and next challenge APIs.