diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
index c28d2bd..4c2d82c 100644
--- a/.planning/REQUIREMENTS.md
+++ b/.planning/REQUIREMENTS.md
@@ -27,13 +27,13 @@ interview-ready after each short practice loop.
### Learner Memory
-- [ ] **MEM-01**: System stores learner profile with role, stack, timeline, and
+- [x] **MEM-01**: System stores learner profile with role, stack, timeline, and
preferences.
-- [ ] **MEM-02**: System stores concept mastery states with evidence.
-- [ ] **MEM-03**: System stores recurring misconceptions with supporting
+- [x] **MEM-02**: System stores concept mastery states with evidence.
+- [x] **MEM-03**: System stores recurring misconceptions with supporting
answers.
-- [ ] **MEM-04**: System stores intervention history and review schedule.
-- [ ] **MEM-05**: Temporary session context does not become durable memory
+- [x] **MEM-04**: System stores intervention history and review schedule.
+- [x] **MEM-05**: Temporary session context does not become durable memory
without evidence.
### Progression
@@ -96,7 +96,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 | Pending |
+| MEM-01..MEM-05 | Phase 3 | Complete |
| PROG-01..PROG-05 | Phase 4 | Pending |
| 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 2 execution.*
+*Last updated: 2026-04-26 after Phase 3 execution.*
diff --git a/.planning/STATE.md b/.planning/STATE.md
index b44cb21..82068a6 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 3 planning: Learner Memory.
+**Current focus:** Phase 4 planning: Progression.
## Current Decisions
@@ -24,14 +24,17 @@ interview-ready after each short practice loop.
- Phase 1 Go backend scaffold is implemented and verified.
- Phase 2 diagnostic interview loop is implemented and verified with in-memory
sessions.
+- Phase 3 learner memory is implemented and verified with evidence-backed
+ in-memory profiles, mastery, misconceptions, interventions, and review
+ schedules.
## Next Actions
-1. Plan Phase 3 learner memory with GSD.
+1. Plan Phase 4 progression with GSD.
2. Keep `docs/planning/WORKFLOW_CONTRACTS.md` aligned with Go structs during
future workflow implementation.
-3. Decide whether Phase 3 learner memory remains in-memory for MVP proof or
- introduces a small persistence boundary.
+3. Decide whether Phase 4 readiness map reads directly from learner memory or
+ introduces a derived progression projection.
## Validation Log
@@ -45,6 +48,9 @@ interview-ready after each short practice loop.
- 2026-04-26: Phase 2 implementation verified with `go test ./...`, live
`/healthz` smoke, live diagnostic create/answer/get smoke, OpenSpec, and Go
source line-count check.
+- 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.
---
*State initialized: 2026-04-26.*
diff --git a/.planning/phases/003-learner-memory/003-CONTEXT.md b/.planning/phases/003-learner-memory/003-CONTEXT.md
new file mode 100644
index 0000000..92d5382
--- /dev/null
+++ b/.planning/phases/003-learner-memory/003-CONTEXT.md
@@ -0,0 +1,79 @@
+# Phase 3: Learner Memory - Context
+
+**Gathered:** 2026-04-26
+**Status:** Ready for planning
+**Source:** GSD continuation after Phase 2 completion
+
+
+## Phase Boundary
+
+Phase 3 converts graded answer evidence into structured learner memory. It
+should build the memory boundary needed by later progression work while keeping
+storage in-memory for now.
+
+
+
+
+## Implementation Decisions
+
+### Persistence
+
+- Use in-memory learner memory storage in Phase 3.
+- Do not introduce a database until the product loop needs durability across
+ restarts.
+- Preserve a clear store interface so persistent storage can replace it later.
+
+### Memory Model
+
+- Store learner profile.
+- Store concept mastery with evidence.
+- Store misconception records linked to answer evidence.
+- Store intervention history and review schedule placeholders.
+- Do not promote temporary session context unless workflow output includes
+ evidence.
+
+### Integration
+
+- Diagnostic answer submission should invoke memory extraction after typed
+ grading.
+- The workflow runner should emit `MemoryUpdateCandidate` values.
+- Memory service should apply only updates with evidence.
+
+
+
+
+## Canonical References
+
+- `docs/planning/PRD.md` - learner memory product goals.
+- `docs/planning/ARCHITECTURE.md` - memory strategy.
+- `docs/planning/WORKFLOW_CONTRACTS.md` - memory update contract.
+- `.planning/REQUIREMENTS.md` - MEM-01 through MEM-05.
+- `.planning/ROADMAP.md` - Phase 3 success criteria.
+- `openspec/changes/bootstrap-job-tutor-platform/specs/learner-memory/spec.md`
+ - learner memory requirements.
+
+
+
+
+## Specific Ideas
+
+- Add `internal/learnermemory`.
+- Add `GET /api/v1/learners/{userID}/memory`.
+- Wire diagnostic answer submission to memory ingestion.
+- Keep memory extraction deterministic in the workflow stub.
+
+
+
+
+## Deferred Ideas
+
+- Database persistence.
+- Cross-session ranking/decay.
+- UI readiness map.
+- Spaced repetition scheduling details.
+
+
+
+---
+*Phase: 003-learner-memory*
+*Context gathered: 2026-04-26*
diff --git a/.planning/phases/003-learner-memory/003-PLAN.md b/.planning/phases/003-learner-memory/003-PLAN.md
new file mode 100644
index 0000000..051ac90
--- /dev/null
+++ b/.planning/phases/003-learner-memory/003-PLAN.md
@@ -0,0 +1,58 @@
+# Phase 3 Plan: Learner Memory
+
+**Status:** Ready for execution
+**Phase Goal:** Convert graded answer evidence into structured learner memory.
+
+## Requirements Covered
+
+- MEM-01: System stores learner profile with role, stack, timeline, and
+ preferences.
+- MEM-02: System stores concept mastery states with evidence.
+- MEM-03: System stores recurring misconceptions with supporting answers.
+- MEM-04: System stores intervention history and review schedule.
+- MEM-05: Temporary session context does not become durable memory without
+ evidence.
+
+## Tasks
+
+### 1. Add learner memory package
+
+- Create `internal/learnermemory`.
+- Define profile, concept mastery, misconception, intervention, review schedule,
+ and snapshot types.
+- Add in-memory store with clear interface.
+
+### 2. Add memory extraction workflow output
+
+- Extend `workflows.StubRunner.ExtractLearningMemory` to return evidenced
+ memory update candidates from a graded answer.
+- Ensure candidates without evidence are not applied.
+
+### 3. Wire diagnostic answers to memory
+
+- Inject learner memory service into interview service.
+- After grading an answer, extract and apply memory updates.
+- Keep diagnostic session records and learner memory records separate.
+
+### 4. Add memory read endpoint
+
+- Add `GET /api/v1/learners/{userID}/memory`.
+- Return learner profile, mastery, misconceptions, interventions, and review
+ schedule.
+
+### 5. Add tests and verification
+
+- Test memory applies only evidenced updates.
+- Test diagnostic answer submission updates learner memory.
+- Test memory HTTP read endpoint.
+- Run Go tests, OpenSpec validation, and line-count check.
+
+## Out of Scope
+
+- Persistent database.
+- Memory ranking/decay.
+- Progression readiness map.
+- Frontend UI.
+
+---
+*Plan created: 2026-04-26*
diff --git a/.planning/phases/003-learner-memory/003-RESEARCH.md b/.planning/phases/003-learner-memory/003-RESEARCH.md
new file mode 100644
index 0000000..783a7ac
--- /dev/null
+++ b/.planning/phases/003-learner-memory/003-RESEARCH.md
@@ -0,0 +1,33 @@
+# Phase 3 Research
+
+## Question
+
+How should learner memory be added without turning the diagnostic session store
+into durable product truth?
+
+## Findings
+
+### Keep memory separate from interview sessions
+
+Diagnostic sessions contain raw interaction records. Learner memory should be a
+separate derived state built from graded evidence. This preserves the boundary
+between temporary/session context and durable learning claims.
+
+### Apply only evidenced updates
+
+The memory service should ignore update candidates that do not include evidence.
+This directly enforces the OpenSpec rule that inferred memory requires evidence.
+
+### In-memory is still enough
+
+The current product does not yet need restart durability. A store interface plus
+tests gives the shape of persistence without adding database complexity early.
+
+## Recommendation
+
+1. Add `internal/learnermemory` with profile, mastery, misconception,
+ intervention, and review schedule records.
+2. Extend workflow runner memory extraction to return evidenced candidates.
+3. Wire diagnostic answer submission to memory ingestion.
+4. Add HTTP read endpoint for learner memory snapshot.
+5. Verify with tests and live smoke.
diff --git a/.planning/phases/003-learner-memory/003-SUMMARY.md b/.planning/phases/003-learner-memory/003-SUMMARY.md
new file mode 100644
index 0000000..7cd158e
--- /dev/null
+++ b/.planning/phases/003-learner-memory/003-SUMMARY.md
@@ -0,0 +1,36 @@
+# Phase 3 Summary
+
+**Status:** Complete
+**Completed:** 2026-04-26
+
+## Delivered
+
+- Added `internal/learnermemory` for profile, concept mastery,
+ misconceptions, interventions, review schedule, and snapshots.
+- Added in-memory learner memory store and service.
+- Extended `GradedAnswer` with `user_id`.
+- Implemented `ExtractLearningMemory` in the workflow stub.
+- Wired diagnostic answer submission to memory extraction and evidence-backed
+ memory application.
+- Added `GET /api/v1/learners/{userID}/memory`.
+- Added unit and HTTP tests for memory application and readback.
+
+## Verification
+
+```powershell
+gofmt -w cmd internal
+go test ./...
+openspec validate bootstrap-job-tutor-platform --strict
+```
+
+Additional smoke check:
+
+- Diagnostic create/answer followed by learner memory read returned 1 mastery
+ entry for `user-1`.
+
+## Deferred
+
+- Durable database persistence.
+- Memory decay and ranking.
+- Repeated-mistake clustering beyond the current evidenced candidate writes.
+- Progression readiness map UI/API.
diff --git a/.planning/phases/003-learner-memory/003-VERIFICATION.md b/.planning/phases/003-learner-memory/003-VERIFICATION.md
new file mode 100644
index 0000000..c18b30b
--- /dev/null
+++ b/.planning/phases/003-learner-memory/003-VERIFICATION.md
@@ -0,0 +1,27 @@
+# Phase 3 Verification
+
+## Verdict
+
+PASS
+
+## Requirement Coverage
+
+- MEM-01: PASS. Diagnostic session creation ensures a learner profile with
+ target role, stack, and timeline.
+- MEM-02: PASS. Graded answer evidence creates concept mastery entries.
+- MEM-03: PASS. Weak or partial answers create evidenced misconception entries.
+- MEM-04: PASS. Weak or partial answers create intervention history and review
+ schedule entries.
+- MEM-05: PASS. Memory service ignores update candidates without evidence.
+
+## Evidence
+
+- `go test ./...` passed.
+- `openspec validate bootstrap-job-tutor-platform --strict` passed.
+- Live diagnostic create/answer plus learner memory read smoke passed.
+- Go source line-count check passed.
+
+## Residual Risk
+
+Learner memory is intentionally in-memory for the MVP proof. Data is lost on
+process restart until a persistence phase is planned.
diff --git a/docs/planning/WORKFLOW_CONTRACTS.md b/docs/planning/WORKFLOW_CONTRACTS.md
index 8bd695e..44d255b 100644
--- a/docs/planning/WORKFLOW_CONTRACTS.md
+++ b/docs/planning/WORKFLOW_CONTRACTS.md
@@ -61,6 +61,7 @@ Produced by `grade_interview_answer`.
```json
{
+ "user_id": "string",
"answer_id": "string",
"question_id": "string",
"concepts": ["concept_ref"],
diff --git a/internal/app/server.go b/internal/app/server.go
index 71a29d7..8884a57 100644
--- a/internal/app/server.go
+++ b/internal/app/server.go
@@ -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,
diff --git a/internal/httpapi/diagnostic_test.go b/internal/httpapi/diagnostic_test.go
index 794325f..52e5dad 100644
--- a/internal/httpapi/diagnostic_test.go
+++ b/internal/httpapi/diagnostic_test.go
@@ -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")
+ }
}
diff --git a/internal/httpapi/handler.go b/internal/httpapi/handler.go
index 0dd2776..e9c4b91 100644
--- a/internal/httpapi/handler.go
+++ b/internal/httpapi/handler.go
@@ -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
}
diff --git a/internal/httpapi/handler_test.go b/internal/httpapi/handler_test.go
index 4829460..219e759 100644
--- a/internal/httpapi/handler_test.go
+++ b/internal/httpapi/handler_test.go
@@ -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()
diff --git a/internal/httpapi/memory.go b/internal/httpapi/memory.go
new file mode 100644
index 0000000..1635297
--- /dev/null
+++ b/internal/httpapi/memory.go
@@ -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)
+}
diff --git a/internal/interview/service.go b/internal/interview/service.go
index 8e92cd4..067b998 100644
--- a/internal/interview/service.go
+++ b/internal/interview/service.go
@@ -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) {
diff --git a/internal/interview/service_test.go b/internal/interview/service_test.go
index 15ff97e..c01a756 100644
--- a/internal/interview/service_test.go
+++ b/internal/interview/service_test.go
@@ -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",
diff --git a/internal/learnermemory/service.go b/internal/learnermemory/service.go
new file mode 100644
index 0000000..8deb962
--- /dev/null
+++ b/internal/learnermemory/service.go
@@ -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))
+}
diff --git a/internal/learnermemory/service_test.go b/internal/learnermemory/service_test.go
new file mode 100644
index 0000000..b077358
--- /dev/null
+++ b/internal/learnermemory/service_test.go
@@ -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")
+ }
+}
diff --git a/internal/learnermemory/store.go b/internal/learnermemory/store.go
new file mode 100644
index 0000000..cdc2f5d
--- /dev/null
+++ b/internal/learnermemory/store.go
@@ -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
+}
diff --git a/internal/learnermemory/types.go b/internal/learnermemory/types.go
new file mode 100644
index 0000000..2050901
--- /dev/null
+++ b/internal/learnermemory/types.go
@@ -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
+}
diff --git a/internal/workflows/contracts.go b/internal/workflows/contracts.go
index 87c991a..46d7a2b 100644
--- a/internal/workflows/contracts.go
+++ b/internal/workflows/contracts.go
@@ -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"`
diff --git a/internal/workflows/runner.go b/internal/workflows/runner.go
index da2e7af..b8c33c8 100644
--- a/internal/workflows/runner.go
+++ b/internal/workflows/runner.go
@@ -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
+ }
+}
diff --git a/internal/workflows/runner_test.go b/internal/workflows/runner_test.go
index c599349..66bfec9 100644
--- a/internal/workflows/runner_test.go
+++ b/internal/workflows/runner_test.go
@@ -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")
+ }
+ }
+}
diff --git a/openspec/changes/bootstrap-job-tutor-platform/tasks.md b/openspec/changes/bootstrap-job-tutor-platform/tasks.md
index 52b046b..60a3f97 100644
--- a/openspec/changes/bootstrap-job-tutor-platform/tasks.md
+++ b/openspec/changes/bootstrap-job-tutor-platform/tasks.md
@@ -13,3 +13,4 @@
- [x] 9. Create Phase 1 GSD context, research, and plan artifacts.
- [ ] 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.