feat: add progression readiness api

This commit is contained in:
user
2026-04-26 16:39:19 +09:00
parent 600acf7303
commit a413f1ef15
16 changed files with 637 additions and 15 deletions

View File

@@ -0,0 +1,207 @@
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."
}
}