package interview import ( "context" "errors" "fmt" "strings" "sync/atomic" "time" "tutor/internal/learnermemory" "tutor/internal/workflows" ) 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, memory *learnermemory.Service) *Service { return &Service{store: store, runner: runner, memory: memory} } func (s *Service) CreateSession(_ context.Context, input CreateSessionInput) (Session, error) { if strings.TrimSpace(input.UserID) == "" { return Session{}, errors.New("user_id is required") } if strings.TrimSpace(input.TargetRole) == "" { return Session{}, errors.New("target_role is required") } if len(input.Stack) == 0 { return Session{}, errors.New("stack is required") } session := Session{ ID: s.nextID("diag"), UserID: input.UserID, Track: BackendDeveloperTrack, Status: SessionInProgress, TargetRole: input.TargetRole, Stack: append([]string(nil), input.Stack...), InterviewTimeline: input.InterviewTimeline, Questions: BackendDeveloperQuestions(input.Lang), 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) } func (s *Service) GetSession(id string) (Session, error) { return s.store.Get(id) } func (s *Service) SubmitAnswer(ctx context.Context, input SubmitAnswerInput) (Answer, error) { if strings.TrimSpace(input.AnswerText) == "" { return Answer{}, errors.New("answer_text is required") } session, err := s.store.Get(input.SessionID) if err != nil { return Answer{}, err } question, ok := findQuestion(session.Questions, input.QuestionID) if !ok { return Answer{}, ErrQuestionNotFound } answer := Answer{ ID: s.nextID("answer"), QuestionID: input.QuestionID, Text: input.AnswerText, CreatedAt: time.Now().UTC(), } grade, err := s.runner.GradeInterviewAnswer(ctx, workflows.GradeAnswerInput{ UserID: session.UserID, QuestionID: question.ID, AnswerID: answer.ID, AnswerText: answer.Text, Concepts: question.Concepts, }) if err != nil { 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) { session.Status = SessionComplete } if _, err := s.store.Update(session); err != nil { return Answer{}, err } return answer, nil } func answeredQuestionCount(answers []Answer) int { answered := make(map[string]struct{}, len(answers)) for _, answer := range answers { answered[answer.QuestionID] = struct{}{} } return len(answered) } func (s *Service) nextID(prefix string) string { return fmt.Sprintf("%s-%d", prefix, s.ids.Add(1)) } func findQuestion(questions []Question, id string) (Question, bool) { for _, question := range questions { if question.ID == id { return question, true } } return Question{}, false }