From 600acf73033216f2428dee2f8e9dae62d73ed0d0 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 26 Apr 2026 16:34:52 +0900 Subject: [PATCH] feat: add learner memory ingestion --- .planning/REQUIREMENTS.md | 14 +- .planning/STATE.md | 14 +- .../phases/003-learner-memory/003-CONTEXT.md | 79 ++++++++ .../phases/003-learner-memory/003-PLAN.md | 58 ++++++ .../phases/003-learner-memory/003-RESEARCH.md | 33 ++++ .../phases/003-learner-memory/003-SUMMARY.md | 36 ++++ .../003-learner-memory/003-VERIFICATION.md | 27 +++ docs/planning/WORKFLOW_CONTRACTS.md | 1 + internal/app/server.go | 6 +- internal/httpapi/diagnostic_test.go | 24 ++- internal/httpapi/handler.go | 6 +- internal/httpapi/handler_test.go | 6 +- internal/httpapi/memory.go | 27 +++ internal/interview/service.go | 25 ++- internal/interview/service_test.go | 18 +- internal/learnermemory/service.go | 113 ++++++++++++ internal/learnermemory/service_test.go | 91 +++++++++ internal/learnermemory/store.go | 174 ++++++++++++++++++ internal/learnermemory/types.go | 68 +++++++ internal/workflows/contracts.go | 1 + internal/workflows/runner.go | 92 ++++++++- internal/workflows/runner_test.go | 41 +++++ .../bootstrap-job-tutor-platform/tasks.md | 1 + 23 files changed, 931 insertions(+), 24 deletions(-) create mode 100644 .planning/phases/003-learner-memory/003-CONTEXT.md create mode 100644 .planning/phases/003-learner-memory/003-PLAN.md create mode 100644 .planning/phases/003-learner-memory/003-RESEARCH.md create mode 100644 .planning/phases/003-learner-memory/003-SUMMARY.md create mode 100644 .planning/phases/003-learner-memory/003-VERIFICATION.md create mode 100644 internal/httpapi/memory.go create mode 100644 internal/learnermemory/service.go create mode 100644 internal/learnermemory/service_test.go create mode 100644 internal/learnermemory/store.go create mode 100644 internal/learnermemory/types.go 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.