feat: add learner memory ingestion
This commit is contained in:
@@ -27,13 +27,13 @@ interview-ready after each short practice loop.
|
|||||||
|
|
||||||
### Learner Memory
|
### 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.
|
preferences.
|
||||||
- [ ] **MEM-02**: System stores concept mastery states with evidence.
|
- [x] **MEM-02**: System stores concept mastery states with evidence.
|
||||||
- [ ] **MEM-03**: System stores recurring misconceptions with supporting
|
- [x] **MEM-03**: System stores recurring misconceptions with supporting
|
||||||
answers.
|
answers.
|
||||||
- [ ] **MEM-04**: System stores intervention history and review schedule.
|
- [x] **MEM-04**: System stores intervention history and review schedule.
|
||||||
- [ ] **MEM-05**: Temporary session context does not become durable memory
|
- [x] **MEM-05**: Temporary session context does not become durable memory
|
||||||
without evidence.
|
without evidence.
|
||||||
|
|
||||||
### Progression
|
### Progression
|
||||||
@@ -96,7 +96,7 @@ interview-ready after each short practice loop.
|
|||||||
|-------------|-------|--------|
|
|-------------|-------|--------|
|
||||||
| BACK-01..BACK-05 | Phase 1 | Complete |
|
| BACK-01..BACK-05 | Phase 1 | Complete |
|
||||||
| INT-01..INT-06 | Phase 2 | Complete |
|
| INT-01..INT-06 | Phase 2 | Complete |
|
||||||
| MEM-01..MEM-05 | Phase 3 | Pending |
|
| MEM-01..MEM-05 | Phase 3 | Complete |
|
||||||
| PROG-01..PROG-05 | Phase 4 | Pending |
|
| PROG-01..PROG-05 | Phase 4 | Pending |
|
||||||
| ONTO-01..ONTO-04 | Phase 5 | Pending |
|
| ONTO-01..ONTO-04 | Phase 5 | Pending |
|
||||||
| ASSET-01..ASSET-03 | Phase 6 | Pending |
|
| ASSET-01..ASSET-03 | Phase 6 | Pending |
|
||||||
@@ -108,4 +108,4 @@ interview-ready after each short practice loop.
|
|||||||
|
|
||||||
---
|
---
|
||||||
*Requirements defined: 2026-04-26*
|
*Requirements defined: 2026-04-26*
|
||||||
*Last updated: 2026-04-26 after Phase 2 execution.*
|
*Last updated: 2026-04-26 after Phase 3 execution.*
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ See: `.planning/PROJECT.md` (updated 2026-04-26)
|
|||||||
**Core value:** The user should feel and prove that they are becoming more
|
**Core value:** The user should feel and prove that they are becoming more
|
||||||
interview-ready after each short practice loop.
|
interview-ready after each short practice loop.
|
||||||
|
|
||||||
**Current focus:** Phase 3 planning: Learner Memory.
|
**Current focus:** Phase 4 planning: Progression.
|
||||||
|
|
||||||
## Current Decisions
|
## Current Decisions
|
||||||
|
|
||||||
@@ -24,14 +24,17 @@ interview-ready after each short practice loop.
|
|||||||
- Phase 1 Go backend scaffold is implemented and verified.
|
- Phase 1 Go backend scaffold is implemented and verified.
|
||||||
- Phase 2 diagnostic interview loop is implemented and verified with in-memory
|
- Phase 2 diagnostic interview loop is implemented and verified with in-memory
|
||||||
sessions.
|
sessions.
|
||||||
|
- Phase 3 learner memory is implemented and verified with evidence-backed
|
||||||
|
in-memory profiles, mastery, misconceptions, interventions, and review
|
||||||
|
schedules.
|
||||||
|
|
||||||
## Next Actions
|
## 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
|
2. Keep `docs/planning/WORKFLOW_CONTRACTS.md` aligned with Go structs during
|
||||||
future workflow implementation.
|
future workflow implementation.
|
||||||
3. Decide whether Phase 3 learner memory remains in-memory for MVP proof or
|
3. Decide whether Phase 4 readiness map reads directly from learner memory or
|
||||||
introduces a small persistence boundary.
|
introduces a derived progression projection.
|
||||||
|
|
||||||
## Validation Log
|
## Validation Log
|
||||||
|
|
||||||
@@ -45,6 +48,9 @@ interview-ready after each short practice loop.
|
|||||||
- 2026-04-26: Phase 2 implementation verified with `go test ./...`, live
|
- 2026-04-26: Phase 2 implementation verified with `go test ./...`, live
|
||||||
`/healthz` smoke, live diagnostic create/answer/get smoke, OpenSpec, and Go
|
`/healthz` smoke, live diagnostic create/answer/get smoke, OpenSpec, and Go
|
||||||
source line-count check.
|
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.*
|
*State initialized: 2026-04-26.*
|
||||||
|
|||||||
79
.planning/phases/003-learner-memory/003-CONTEXT.md
Normal file
79
.planning/phases/003-learner-memory/003-CONTEXT.md
Normal 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*
|
||||||
58
.planning/phases/003-learner-memory/003-PLAN.md
Normal file
58
.planning/phases/003-learner-memory/003-PLAN.md
Normal 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*
|
||||||
33
.planning/phases/003-learner-memory/003-RESEARCH.md
Normal file
33
.planning/phases/003-learner-memory/003-RESEARCH.md
Normal 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.
|
||||||
36
.planning/phases/003-learner-memory/003-SUMMARY.md
Normal file
36
.planning/phases/003-learner-memory/003-SUMMARY.md
Normal 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.
|
||||||
27
.planning/phases/003-learner-memory/003-VERIFICATION.md
Normal file
27
.planning/phases/003-learner-memory/003-VERIFICATION.md
Normal 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.
|
||||||
@@ -61,6 +61,7 @@ Produced by `grade_interview_answer`.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"user_id": "string",
|
||||||
"answer_id": "string",
|
"answer_id": "string",
|
||||||
"question_id": "string",
|
"question_id": "string",
|
||||||
"concepts": ["concept_ref"],
|
"concepts": ["concept_ref"],
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ import (
|
|||||||
"tutor/internal/config"
|
"tutor/internal/config"
|
||||||
"tutor/internal/httpapi"
|
"tutor/internal/httpapi"
|
||||||
"tutor/internal/interview"
|
"tutor/internal/interview"
|
||||||
|
"tutor/internal/learnermemory"
|
||||||
"tutor/internal/workflows"
|
"tutor/internal/workflows"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewServer(cfg config.Config) *http.Server {
|
func NewServer(cfg config.Config) *http.Server {
|
||||||
runner := workflows.NewStubRunner()
|
runner := workflows.NewStubRunner()
|
||||||
store := interview.NewMemoryStore()
|
store := interview.NewMemoryStore()
|
||||||
service := interview.NewService(store, runner)
|
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||||
handler := httpapi.NewHandler(cfg, service)
|
service := interview.NewService(store, runner, memory)
|
||||||
|
handler := httpapi.NewHandler(cfg, service, memory)
|
||||||
|
|
||||||
return &http.Server{
|
return &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import (
|
|||||||
|
|
||||||
"tutor/internal/config"
|
"tutor/internal/config"
|
||||||
"tutor/internal/interview"
|
"tutor/internal/interview"
|
||||||
|
"tutor/internal/learnermemory"
|
||||||
"tutor/internal/workflows"
|
"tutor/internal/workflows"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDiagnosticHTTPFlow(t *testing.T) {
|
func TestDiagnosticHTTPFlow(t *testing.T) {
|
||||||
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner())
|
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||||
handler := NewHandler(config.Config{Environment: "test", ModelKey: "deepseek-v4-flash"}, service)
|
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
|
||||||
|
handler := NewHandler(config.Config{Environment: "test", ModelKey: "deepseek-v4-flash"}, service, memory)
|
||||||
routes := handler.Routes()
|
routes := handler.Routes()
|
||||||
|
|
||||||
createBody := bytes.NewBufferString(`{
|
createBody := bytes.NewBufferString(`{
|
||||||
@@ -73,4 +75,22 @@ func TestDiagnosticHTTPFlow(t *testing.T) {
|
|||||||
if len(loaded.Answers) != 1 {
|
if len(loaded.Answers) != 1 {
|
||||||
t.Fatalf("answers = %d, want 1", len(loaded.Answers))
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,20 @@ import (
|
|||||||
|
|
||||||
"tutor/internal/config"
|
"tutor/internal/config"
|
||||||
"tutor/internal/interview"
|
"tutor/internal/interview"
|
||||||
|
"tutor/internal/learnermemory"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
diagnostic *interview.Service
|
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{
|
return Handler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
diagnostic: diagnostic,
|
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("POST /api/v1/diagnostic-sessions", h.createDiagnosticSession)
|
||||||
mux.HandleFunc("GET /api/v1/diagnostic-sessions/{id}", h.getDiagnosticSession)
|
mux.HandleFunc("GET /api/v1/diagnostic-sessions/{id}", h.getDiagnosticSession)
|
||||||
mux.HandleFunc("POST /api/v1/diagnostic-sessions/{id}/answers", h.submitDiagnosticAnswer)
|
mux.HandleFunc("POST /api/v1/diagnostic-sessions/{id}/answers", h.submitDiagnosticAnswer)
|
||||||
|
mux.HandleFunc("GET /api/v1/learners/{userID}/memory", h.getLearnerMemory)
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"tutor/internal/config"
|
"tutor/internal/config"
|
||||||
"tutor/internal/interview"
|
"tutor/internal/interview"
|
||||||
|
"tutor/internal/learnermemory"
|
||||||
"tutor/internal/workflows"
|
"tutor/internal/workflows"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,8 +17,9 @@ func TestHealth(t *testing.T) {
|
|||||||
Environment: "test",
|
Environment: "test",
|
||||||
ModelKey: "deepseek-v4-flash",
|
ModelKey: "deepseek-v4-flash",
|
||||||
}
|
}
|
||||||
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner())
|
memory := learnermemory.NewService(learnermemory.NewMemoryStore())
|
||||||
handler := NewHandler(cfg, service)
|
service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory)
|
||||||
|
handler := NewHandler(cfg, service, memory)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
27
internal/httpapi/memory.go
Normal file
27
internal/httpapi/memory.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"tutor/internal/learnermemory"
|
||||||
"tutor/internal/workflows"
|
"tutor/internal/workflows"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,11 +17,12 @@ var ErrQuestionNotFound = errors.New("diagnostic question not found")
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
store Store
|
store Store
|
||||||
runner workflows.Runner
|
runner workflows.Runner
|
||||||
|
memory *learnermemory.Service
|
||||||
ids atomic.Uint64
|
ids atomic.Uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(store Store, runner workflows.Runner) *Service {
|
func NewService(store Store, runner workflows.Runner, memory *learnermemory.Service) *Service {
|
||||||
return &Service{store: store, runner: runner}
|
return &Service{store: store, runner: runner, memory: memory}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) CreateSession(_ context.Context, input CreateSessionInput) (Session, error) {
|
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(),
|
Questions: BackendDeveloperQuestions(),
|
||||||
CreatedAt: time.Now().UTC(),
|
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)
|
return s.store.Create(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +96,15 @@ func (s *Service) SubmitAnswer(ctx context.Context, input SubmitAnswerInput) (An
|
|||||||
return Answer{}, err
|
return Answer{}, err
|
||||||
}
|
}
|
||||||
answer.Grade = grade
|
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)
|
session.Answers = append(session.Answers, answer)
|
||||||
if answeredQuestionCount(session.Answers) >= len(session.Questions) {
|
if answeredQuestionCount(session.Answers) >= len(session.Questions) {
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"tutor/internal/learnermemory"
|
||||||
"tutor/internal/workflows"
|
"tutor/internal/workflows"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDiagnosticSessionAnswerFlow(t *testing.T) {
|
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{
|
session, err := service.CreateSession(context.Background(), CreateSessionInput{
|
||||||
UserID: "user-1",
|
UserID: "user-1",
|
||||||
@@ -53,10 +55,22 @@ func TestDiagnosticSessionAnswerFlow(t *testing.T) {
|
|||||||
if len(loaded.Answers) != 1 {
|
if len(loaded.Answers) != 1 {
|
||||||
t.Fatalf("answers = %d, want 1", len(loaded.Answers))
|
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) {
|
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{
|
session, err := service.CreateSession(context.Background(), CreateSessionInput{
|
||||||
UserID: "user-1",
|
UserID: "user-1",
|
||||||
|
|||||||
113
internal/learnermemory/service.go
Normal file
113
internal/learnermemory/service.go
Normal 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))
|
||||||
|
}
|
||||||
91
internal/learnermemory/service_test.go
Normal file
91
internal/learnermemory/service_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
174
internal/learnermemory/store.go
Normal file
174
internal/learnermemory/store.go
Normal 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
|
||||||
|
}
|
||||||
68
internal/learnermemory/types.go
Normal file
68
internal/learnermemory/types.go
Normal 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
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ type ConceptFinding struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GradedAnswer struct {
|
type GradedAnswer struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
AnswerID string `json:"answer_id"`
|
AnswerID string `json:"answer_id"`
|
||||||
QuestionID string `json:"question_id"`
|
QuestionID string `json:"question_id"`
|
||||||
Concepts []ConceptRef `json:"concepts"`
|
Concepts []ConceptRef `json:"concepts"`
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func (StubRunner) GradeInterviewAnswer(_ context.Context, input GradeAnswerInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
grade := GradedAnswer{
|
grade := GradedAnswer{
|
||||||
|
UserID: input.UserID,
|
||||||
AnswerID: input.AnswerID,
|
AnswerID: input.AnswerID,
|
||||||
QuestionID: input.QuestionID,
|
QuestionID: input.QuestionID,
|
||||||
Concepts: append([]ConceptRef(nil), input.Concepts...),
|
Concepts: append([]ConceptRef(nil), input.Concepts...),
|
||||||
@@ -96,8 +97,65 @@ func (StubRunner) GradeInterviewAnswer(_ context.Context, input GradeAnswerInput
|
|||||||
return grade, nil
|
return grade, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (StubRunner) ExtractLearningMemory(context.Context, GradedAnswer) (MemoryUpdateCandidate, error) {
|
func (StubRunner) ExtractLearningMemory(_ context.Context, grade GradedAnswer) (MemoryUpdateCandidate, error) {
|
||||||
return MemoryUpdateCandidate{}, ErrNotImplemented
|
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) {
|
func (StubRunner) SelectNextChallenge(context.Context, NextChallengeInput) (NextChallenge, error) {
|
||||||
@@ -117,3 +175,33 @@ func scoreFromWords(wordCount int, target int) int {
|
|||||||
}
|
}
|
||||||
return 1
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func TestStubRunnerGradesAnswer(t *testing.T) {
|
|||||||
runner := NewStubRunner()
|
runner := NewStubRunner()
|
||||||
|
|
||||||
grade, err := runner.GradeInterviewAnswer(context.Background(), GradeAnswerInput{
|
grade, err := runner.GradeInterviewAnswer(context.Background(), GradeAnswerInput{
|
||||||
|
UserID: "user-1",
|
||||||
QuestionID: "q-1",
|
QuestionID: "q-1",
|
||||||
AnswerID: "a-1",
|
AnswerID: "a-1",
|
||||||
AnswerText: "Indexes can speed reads by helping the database find rows, but they add write overhead.",
|
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" {
|
if grade.AnswerID != "a-1" {
|
||||||
t.Fatalf("AnswerID = %q", grade.AnswerID)
|
t.Fatalf("AnswerID = %q", grade.AnswerID)
|
||||||
}
|
}
|
||||||
|
if grade.UserID != "user-1" {
|
||||||
|
t.Fatalf("UserID = %q", grade.UserID)
|
||||||
|
}
|
||||||
if len(grade.Concepts) != 1 {
|
if len(grade.Concepts) != 1 {
|
||||||
t.Fatalf("concepts = %d, want 1", len(grade.Concepts))
|
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))
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,3 +13,4 @@
|
|||||||
- [x] 9. Create Phase 1 GSD context, research, and plan artifacts.
|
- [x] 9. Create Phase 1 GSD context, research, and plan artifacts.
|
||||||
- [ ] 10. Draft the first `agent-farm-go` YAML workflow package.
|
- [ ] 10. Draft the first `agent-farm-go` YAML workflow package.
|
||||||
- [x] 11. Validate the OpenSpec change.
|
- [x] 11. Validate the OpenSpec change.
|
||||||
|
- [x] 12. Implement evidence-backed learner memory ingestion and readback.
|
||||||
|
|||||||
Reference in New Issue
Block a user