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

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