feat: add learner memory ingestion

This commit is contained in:
user
2026-04-26 16:34:52 +09:00
parent 4a4240fea2
commit 600acf7303
23 changed files with 931 additions and 24 deletions

View File

@@ -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.*

View File

@@ -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.*

View File

@@ -0,0 +1,79 @@
# Phase 3: Learner Memory - Context
**Gathered:** 2026-04-26
**Status:** Ready for planning
**Source:** GSD continuation after Phase 2 completion
<domain>
## 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.
</domain>
<decisions>
## 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.
</decisions>
<canonical_refs>
## 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.
</canonical_refs>
<specifics>
## 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.
</specifics>
<deferred>
## Deferred Ideas
- Database persistence.
- Cross-session ranking/decay.
- UI readiness map.
- Spaced repetition scheduling details.
</deferred>
---
*Phase: 003-learner-memory*
*Context gathered: 2026-04-26*

View File

@@ -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*

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -61,6 +61,7 @@ Produced by `grade_interview_answer`.
```json
{
"user_id": "string",
"answer_id": "string",
"question_id": "string",
"concepts": ["concept_ref"],

View File

@@ -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,

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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))
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"`

View File

@@ -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
}
}

View File

@@ -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")
}
}
}

View File

@@ -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.