feat: add progression readiness api
This commit is contained in:
@@ -38,14 +38,14 @@ interview-ready after each short practice loop.
|
|||||||
|
|
||||||
### Progression
|
### Progression
|
||||||
|
|
||||||
- [ ] **PROG-01**: User can see a role-specific readiness map.
|
- [x] **PROG-01**: User can see a role-specific readiness map.
|
||||||
- [ ] **PROG-02**: Concepts have challenge ladders from definition to interview
|
- [x] **PROG-02**: Concepts have challenge ladders from definition to interview
|
||||||
pressure.
|
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.
|
grading evidence.
|
||||||
- [ ] **PROG-04**: System unlocks boss-style integrated questions after
|
- [x] **PROG-04**: System unlocks boss-style integrated questions after
|
||||||
prerequisite stability.
|
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
|
### Ontology and Learning Materials
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ interview-ready after each short practice loop.
|
|||||||
| BACK-01..BACK-05 | Phase 1 | Complete |
|
| BACK-01..BACK-05 | Phase 1 | Complete |
|
||||||
| INT-01..INT-06 | Phase 2 | Complete |
|
| INT-01..INT-06 | Phase 2 | Complete |
|
||||||
| MEM-01..MEM-05 | Phase 3 | 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 |
|
| ONTO-01..ONTO-04 | Phase 5 | Pending |
|
||||||
| ASSET-01..ASSET-03 | Phase 6 | Pending |
|
| ASSET-01..ASSET-03 | Phase 6 | Pending |
|
||||||
|
|
||||||
@@ -108,4 +108,4 @@ interview-ready after each short practice loop.
|
|||||||
|
|
||||||
---
|
---
|
||||||
*Requirements defined: 2026-04-26*
|
*Requirements defined: 2026-04-26*
|
||||||
*Last updated: 2026-04-26 after Phase 3 execution.*
|
*Last updated: 2026-04-26 after Phase 4 execution.*
|
||||||
|
|||||||
@@ -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
|
**Core value:** The user should feel and prove that they are becoming more
|
||||||
interview-ready after each short practice loop.
|
interview-ready after each short practice loop.
|
||||||
|
|
||||||
**Current focus:** Phase 4 planning: Progression.
|
**Current focus:** Phase 5 planning: Ontology and Learning Materials.
|
||||||
|
|
||||||
## Current Decisions
|
## Current Decisions
|
||||||
|
|
||||||
@@ -27,14 +27,16 @@ interview-ready after each short practice loop.
|
|||||||
- Phase 3 learner memory is implemented and verified with evidence-backed
|
- Phase 3 learner memory is implemented and verified with evidence-backed
|
||||||
in-memory profiles, mastery, misconceptions, interventions, and review
|
in-memory profiles, mastery, misconceptions, interventions, and review
|
||||||
schedules.
|
schedules.
|
||||||
|
- Phase 4 progression is implemented and verified with readiness map and next
|
||||||
|
challenge APIs derived from learner memory evidence.
|
||||||
|
|
||||||
## Next Actions
|
## 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
|
2. Keep `docs/planning/WORKFLOW_CONTRACTS.md` aligned with Go structs during
|
||||||
future workflow implementation.
|
future workflow implementation.
|
||||||
3. Decide whether Phase 4 readiness map reads directly from learner memory or
|
3. Decide the MVP ontology storage boundary before accepting uploaded source
|
||||||
introduces a derived progression projection.
|
materials.
|
||||||
|
|
||||||
## Validation Log
|
## Validation Log
|
||||||
|
|
||||||
@@ -51,6 +53,9 @@ interview-ready after each short practice loop.
|
|||||||
- 2026-04-26: Phase 3 implementation verified with `go test ./...`,
|
- 2026-04-26: Phase 3 implementation verified with `go test ./...`,
|
||||||
`openspec validate bootstrap-job-tutor-platform --strict`, live diagnostic
|
`openspec validate bootstrap-job-tutor-platform --strict`, live diagnostic
|
||||||
answer to learner-memory smoke, and Go source line-count check.
|
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.*
|
*State initialized: 2026-04-26.*
|
||||||
|
|||||||
39
.planning/phases/004-progression/004-CONTEXT.md
Normal file
39
.planning/phases/004-progression/004-CONTEXT.md
Normal file
@@ -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.
|
||||||
46
.planning/phases/004-progression/004-PLAN.md
Normal file
46
.planning/phases/004-progression/004-PLAN.md
Normal file
@@ -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.
|
||||||
32
.planning/phases/004-progression/004-RESEARCH.md
Normal file
32
.planning/phases/004-progression/004-RESEARCH.md
Normal file
@@ -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.
|
||||||
35
.planning/phases/004-progression/004-SUMMARY.md
Normal file
35
.planning/phases/004-progression/004-SUMMARY.md
Normal file
@@ -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.
|
||||||
28
.planning/phases/004-progression/004-VERIFICATION.md
Normal file
28
.planning/phases/004-progression/004-VERIFICATION.md
Normal file
@@ -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.
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"tutor/internal/httpapi"
|
"tutor/internal/httpapi"
|
||||||
"tutor/internal/interview"
|
"tutor/internal/interview"
|
||||||
"tutor/internal/learnermemory"
|
"tutor/internal/learnermemory"
|
||||||
|
"tutor/internal/progression"
|
||||||
"tutor/internal/workflows"
|
"tutor/internal/workflows"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,8 +15,9 @@ func NewServer(cfg config.Config) *http.Server {
|
|||||||
runner := workflows.NewStubRunner()
|
runner := workflows.NewStubRunner()
|
||||||
store := interview.NewMemoryStore()
|
store := interview.NewMemoryStore()
|
||||||
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||||
|
progress := progression.NewService(memory)
|
||||||
service := interview.NewService(store, runner, memory)
|
service := interview.NewService(store, runner, memory)
|
||||||
handler := httpapi.NewHandler(cfg, service, memory)
|
handler := httpapi.NewHandler(cfg, service, memory, progress)
|
||||||
|
|
||||||
return &http.Server{
|
return &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ import (
|
|||||||
"tutor/internal/config"
|
"tutor/internal/config"
|
||||||
"tutor/internal/interview"
|
"tutor/internal/interview"
|
||||||
"tutor/internal/learnermemory"
|
"tutor/internal/learnermemory"
|
||||||
|
"tutor/internal/progression"
|
||||||
"tutor/internal/workflows"
|
"tutor/internal/workflows"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDiagnosticHTTPFlow(t *testing.T) {
|
func TestDiagnosticHTTPFlow(t *testing.T) {
|
||||||
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||||
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
|
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()
|
routes := handler.Routes()
|
||||||
|
|
||||||
createBody := bytes.NewBufferString(`{
|
createBody := bytes.NewBufferString(`{
|
||||||
@@ -93,4 +95,20 @@ func TestDiagnosticHTTPFlow(t *testing.T) {
|
|||||||
if len(snapshot.Mastery) == 0 {
|
if len(snapshot.Mastery) == 0 {
|
||||||
t.Fatal("expected mastery entries")
|
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/config"
|
||||||
"tutor/internal/interview"
|
"tutor/internal/interview"
|
||||||
"tutor/internal/learnermemory"
|
"tutor/internal/learnermemory"
|
||||||
|
"tutor/internal/progression"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
diagnostic *interview.Service
|
diagnostic *interview.Service
|
||||||
memory *learnermemory.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{
|
return Handler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
diagnostic: diagnostic,
|
diagnostic: diagnostic,
|
||||||
memory: memory,
|
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("GET /api/v1/diagnostic-sessions/{id}", h.getDiagnosticSession)
|
||||||
mux.HandleFunc("POST /api/v1/diagnostic-sessions/{id}/answers", h.submitDiagnosticAnswer)
|
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}/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
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"tutor/internal/config"
|
"tutor/internal/config"
|
||||||
"tutor/internal/interview"
|
"tutor/internal/interview"
|
||||||
"tutor/internal/learnermemory"
|
"tutor/internal/learnermemory"
|
||||||
|
"tutor/internal/progression"
|
||||||
"tutor/internal/workflows"
|
"tutor/internal/workflows"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,7 +20,8 @@ func TestHealth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||||
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
|
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)
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
rec := httptest.NewRecorder()
|
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"
|
||||||
|
)
|
||||||
@@ -14,3 +14,4 @@
|
|||||||
- [ ] 10. Draft the first `agent-farm-go` YAML workflow package.
|
- [ ] 10. Draft the first `agent-farm-go` YAML workflow package.
|
||||||
- [x] 11. Validate the OpenSpec change.
|
- [x] 11. Validate the OpenSpec change.
|
||||||
- [x] 12. Implement evidence-backed learner memory ingestion and readback.
|
- [x] 12. Implement evidence-backed learner memory ingestion and readback.
|
||||||
|
- [x] 13. Implement evidence-backed readiness map and next challenge APIs.
|
||||||
|
|||||||
Reference in New Issue
Block a user