208 lines
5.7 KiB
Go
208 lines
5.7 KiB
Go
|
|
package progression
|
||
|
|
|
||
|
|
import (
|
||
|
|
"errors"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"tutor/internal/learnermemory"
|
||
|
|
"tutor/internal/workflows"
|
||
|
|
)
|
||
|
|
|
||
|
|
type Service struct {
|
||
|
|
memory *learnermemory.Service
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewService(memory *learnermemory.Service) *Service {
|
||
|
|
return &Service{memory: memory}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) ReadinessMap(userID string) (ReadinessMap, error) {
|
||
|
|
snapshot, err := s.snapshot(userID)
|
||
|
|
if err != nil {
|
||
|
|
return ReadinessMap{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
concepts := make([]ConceptProgress, 0, len(snapshot.Mastery))
|
||
|
|
for _, mastery := range snapshot.Mastery {
|
||
|
|
concepts = append(concepts, ConceptProgress{
|
||
|
|
Concept: mastery.Concept,
|
||
|
|
State: mastery.State,
|
||
|
|
LadderLevel: ladderForState(mastery.State),
|
||
|
|
NextAction: actionForState(mastery.State),
|
||
|
|
Evidence: append([]workflows.EvidenceRef(nil), mastery.Evidence...),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
sort.Slice(concepts, func(i, j int) bool {
|
||
|
|
return concepts[i].Concept.ID < concepts[j].Concept.ID
|
||
|
|
})
|
||
|
|
|
||
|
|
readiness := ReadinessMap{
|
||
|
|
UserID: snapshot.Profile.UserID,
|
||
|
|
Track: trackFromConcepts(concepts),
|
||
|
|
ReadinessPercentage: readinessPercentage(concepts),
|
||
|
|
Concepts: concepts,
|
||
|
|
Rewards: rewardsFromConcepts(concepts),
|
||
|
|
Unlocks: unlocksFromConcepts(concepts),
|
||
|
|
}
|
||
|
|
return readiness, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) NextChallenge(userID string) (workflows.NextChallenge, error) {
|
||
|
|
readiness, err := s.ReadinessMap(userID)
|
||
|
|
if err != nil {
|
||
|
|
return workflows.NextChallenge{}, err
|
||
|
|
}
|
||
|
|
if len(readiness.Concepts) == 0 {
|
||
|
|
return workflows.NextChallenge{}, errors.New("no learner memory concepts available")
|
||
|
|
}
|
||
|
|
|
||
|
|
target := readiness.Concepts[0]
|
||
|
|
for _, concept := range readiness.Concepts[1:] {
|
||
|
|
if readinessScore(concept.State) < readinessScore(target.State) {
|
||
|
|
target = concept
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return workflows.NextChallenge{
|
||
|
|
UserID: readiness.UserID,
|
||
|
|
Track: readiness.Track,
|
||
|
|
Concept: target.Concept,
|
||
|
|
LadderLevel: target.LadderLevel,
|
||
|
|
Question: challengeQuestion(target),
|
||
|
|
Rationale: "Selected from the weakest evidenced learner-memory concept.",
|
||
|
|
DifficultyAction: workflowDifficulty(target.NextAction),
|
||
|
|
Evidence: append([]workflows.EvidenceRef(nil), target.Evidence...),
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) snapshot(userID string) (learnermemory.Snapshot, error) {
|
||
|
|
if s.memory == nil {
|
||
|
|
return learnermemory.Snapshot{}, errors.New("learner memory not configured")
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(userID) == "" {
|
||
|
|
return learnermemory.Snapshot{}, errors.New("user_id is required")
|
||
|
|
}
|
||
|
|
return s.memory.Snapshot(userID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func ladderForState(state workflows.ReadinessState) workflows.LadderLevel {
|
||
|
|
switch state {
|
||
|
|
case workflows.ReadinessFragile:
|
||
|
|
return workflows.LadderDefine
|
||
|
|
case workflows.ReadinessImproving:
|
||
|
|
return workflows.LadderTradeoffs
|
||
|
|
case workflows.ReadinessInterviewReady:
|
||
|
|
return workflows.LadderDesignConstraints
|
||
|
|
case workflows.ReadinessStrongSignal:
|
||
|
|
return workflows.LadderInterviewPressure
|
||
|
|
default:
|
||
|
|
return workflows.LadderDefine
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func actionForState(state workflows.ReadinessState) DifficultyAction {
|
||
|
|
switch state {
|
||
|
|
case workflows.ReadinessFragile:
|
||
|
|
return ActionRecover
|
||
|
|
case workflows.ReadinessImproving:
|
||
|
|
return ActionHold
|
||
|
|
case workflows.ReadinessInterviewReady, workflows.ReadinessStrongSignal:
|
||
|
|
return ActionRaise
|
||
|
|
default:
|
||
|
|
return ActionLower
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func workflowDifficulty(action DifficultyAction) workflows.DifficultyAction {
|
||
|
|
switch action {
|
||
|
|
case ActionRecover:
|
||
|
|
return workflows.DifficultyRecover
|
||
|
|
case ActionLower:
|
||
|
|
return workflows.DifficultyLower
|
||
|
|
case ActionRaise:
|
||
|
|
return workflows.DifficultyRaise
|
||
|
|
default:
|
||
|
|
return workflows.DifficultyHold
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func readinessPercentage(concepts []ConceptProgress) int {
|
||
|
|
if len(concepts) == 0 {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
total := 0
|
||
|
|
for _, concept := range concepts {
|
||
|
|
total += readinessScore(concept.State)
|
||
|
|
}
|
||
|
|
return total * 100 / (len(concepts) * 4)
|
||
|
|
}
|
||
|
|
|
||
|
|
func readinessScore(state workflows.ReadinessState) int {
|
||
|
|
switch state {
|
||
|
|
case workflows.ReadinessFragile:
|
||
|
|
return 1
|
||
|
|
case workflows.ReadinessImproving:
|
||
|
|
return 2
|
||
|
|
case workflows.ReadinessInterviewReady:
|
||
|
|
return 3
|
||
|
|
case workflows.ReadinessStrongSignal:
|
||
|
|
return 4
|
||
|
|
default:
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func rewardsFromConcepts(concepts []ConceptProgress) []Reward {
|
||
|
|
rewards := []Reward{}
|
||
|
|
for _, concept := range concepts {
|
||
|
|
if len(concept.Evidence) == 0 || readinessScore(concept.State) < 2 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
rewards = append(rewards, Reward{
|
||
|
|
Kind: RewardConceptProgress,
|
||
|
|
Label: "Evidence-backed progress on " + concept.Concept.Label,
|
||
|
|
Evidence: append([]workflows.EvidenceRef(nil), concept.Evidence...),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return rewards
|
||
|
|
}
|
||
|
|
|
||
|
|
func unlocksFromConcepts(concepts []ConceptProgress) []Unlock {
|
||
|
|
stable := []workflows.EvidenceRef{}
|
||
|
|
for _, concept := range concepts {
|
||
|
|
if readinessScore(concept.State) < 3 || len(concept.Evidence) == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
stable = append(stable, concept.Evidence...)
|
||
|
|
}
|
||
|
|
if len(stable) < 2 {
|
||
|
|
return []Unlock{}
|
||
|
|
}
|
||
|
|
return []Unlock{{
|
||
|
|
Kind: UnlockBossQuestion,
|
||
|
|
Label: "Integrated backend interview boss question",
|
||
|
|
Evidence: append([]workflows.EvidenceRef(nil), stable...),
|
||
|
|
}}
|
||
|
|
}
|
||
|
|
|
||
|
|
func trackFromConcepts(concepts []ConceptProgress) string {
|
||
|
|
if len(concepts) == 0 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return concepts[0].Concept.Track
|
||
|
|
}
|
||
|
|
|
||
|
|
func challengeQuestion(concept ConceptProgress) string {
|
||
|
|
switch concept.LadderLevel {
|
||
|
|
case workflows.LadderTradeoffs:
|
||
|
|
return "Explain a production tradeoff for " + concept.Concept.Label + "."
|
||
|
|
case workflows.LadderDesignConstraints:
|
||
|
|
return "Design a constrained backend scenario that uses " + concept.Concept.Label + "."
|
||
|
|
case workflows.LadderInterviewPressure:
|
||
|
|
return "Answer a timed interview follow-up about " + concept.Concept.Label + "."
|
||
|
|
default:
|
||
|
|
return "Define " + concept.Concept.Label + " and give one concrete backend example."
|
||
|
|
}
|
||
|
|
}
|