feat: add diagnostic interview loop
This commit is contained in:
29
internal/interview/catalog.go
Normal file
29
internal/interview/catalog.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package interview
|
||||
|
||||
import "tutor/internal/workflows"
|
||||
|
||||
func BackendDeveloperQuestions() []Question {
|
||||
return []Question{
|
||||
{
|
||||
ID: "backend-http-idempotency",
|
||||
Prompt: "What makes an HTTP method idempotent, and why does that matter for retries?",
|
||||
Concepts: []workflows.ConceptRef{
|
||||
{ID: "http-idempotency", Label: "HTTP idempotency", Track: BackendDeveloperTrack},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "backend-db-index-tradeoff",
|
||||
Prompt: "When would adding a database index improve an API, and what tradeoffs can it introduce?",
|
||||
Concepts: []workflows.ConceptRef{
|
||||
{ID: "database-indexes", Label: "Database indexes", Track: BackendDeveloperTrack},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "backend-cache-invalidation",
|
||||
Prompt: "How would you decide whether to cache an API response, and how would you handle stale data?",
|
||||
Concepts: []workflows.ConceptRef{
|
||||
{ID: "cache-invalidation", Label: "Cache invalidation", Track: BackendDeveloperTrack},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
117
internal/interview/service.go
Normal file
117
internal/interview/service.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package interview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
var ErrQuestionNotFound = errors.New("diagnostic question not found")
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
runner workflows.Runner
|
||||
ids atomic.Uint64
|
||||
}
|
||||
|
||||
func NewService(store Store, runner workflows.Runner) *Service {
|
||||
return &Service{store: store, runner: runner}
|
||||
}
|
||||
|
||||
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(),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
87
internal/interview/service_test.go
Normal file
87
internal/interview/service_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package interview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
func TestDiagnosticSessionAnswerFlow(t *testing.T) {
|
||||
service := NewService(NewMemoryStore(), workflows.NewStubRunner())
|
||||
|
||||
session, err := service.CreateSession(context.Background(), CreateSessionInput{
|
||||
UserID: "user-1",
|
||||
TargetRole: "junior backend developer",
|
||||
Stack: []string{"go", "postgres"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession error: %v", err)
|
||||
}
|
||||
if session.Track != BackendDeveloperTrack {
|
||||
t.Fatalf("Track = %q", session.Track)
|
||||
}
|
||||
if session.Status != SessionInProgress {
|
||||
t.Fatalf("Status = %q, want %q", session.Status, SessionInProgress)
|
||||
}
|
||||
if len(session.Questions) == 0 {
|
||||
t.Fatal("expected diagnostic questions")
|
||||
}
|
||||
|
||||
answer, err := service.SubmitAnswer(context.Background(), SubmitAnswerInput{
|
||||
SessionID: session.ID,
|
||||
QuestionID: session.Questions[0].ID,
|
||||
AnswerText: "Idempotent methods can be retried safely because repeated calls have the same intended effect.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitAnswer error: %v", err)
|
||||
}
|
||||
if answer.Grade.AnswerID != answer.ID {
|
||||
t.Fatalf("grade answer id = %q, want %q", answer.Grade.AnswerID, answer.ID)
|
||||
}
|
||||
if len(answer.Grade.Concepts) == 0 {
|
||||
t.Fatal("expected graded concepts")
|
||||
}
|
||||
if len(answer.Grade.Evidence) == 0 {
|
||||
t.Fatal("expected grading evidence")
|
||||
}
|
||||
|
||||
loaded, err := service.GetSession(session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession error: %v", err)
|
||||
}
|
||||
if len(loaded.Answers) != 1 {
|
||||
t.Fatalf("answers = %d, want 1", len(loaded.Answers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticSessionCompletesAfterAllQuestionsAnswered(t *testing.T) {
|
||||
service := NewService(NewMemoryStore(), workflows.NewStubRunner())
|
||||
|
||||
session, err := service.CreateSession(context.Background(), CreateSessionInput{
|
||||
UserID: "user-1",
|
||||
TargetRole: "junior backend developer",
|
||||
Stack: []string{"go"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession error: %v", err)
|
||||
}
|
||||
|
||||
for _, question := range session.Questions {
|
||||
if _, err := service.SubmitAnswer(context.Background(), SubmitAnswerInput{
|
||||
SessionID: session.ID,
|
||||
QuestionID: question.ID,
|
||||
AnswerText: "This answer gives a concrete backend tradeoff with an operational example for the interview.",
|
||||
}); err != nil {
|
||||
t.Fatalf("SubmitAnswer(%s) error: %v", question.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
loaded, err := service.GetSession(session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession error: %v", err)
|
||||
}
|
||||
if loaded.Status != SessionComplete {
|
||||
t.Fatalf("Status = %q, want %q", loaded.Status, SessionComplete)
|
||||
}
|
||||
}
|
||||
62
internal/interview/store.go
Normal file
62
internal/interview/store.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package interview
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var ErrSessionNotFound = errors.New("diagnostic session not found")
|
||||
|
||||
type Store interface {
|
||||
Create(Session) (Session, error)
|
||||
Get(string) (Session, error)
|
||||
Update(Session) (Session, error)
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]Session
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
sessions: make(map[string]Session),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Create(session Session) (Session, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.sessions[session.ID] = cloneSession(session)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Get(id string) (Session, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
session, ok := s.sessions[id]
|
||||
if !ok {
|
||||
return Session{}, ErrSessionNotFound
|
||||
}
|
||||
return cloneSession(session), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Update(session Session) (Session, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.sessions[session.ID]; !ok {
|
||||
return Session{}, ErrSessionNotFound
|
||||
}
|
||||
s.sessions[session.ID] = cloneSession(session)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func cloneSession(session Session) Session {
|
||||
session.Stack = append([]string(nil), session.Stack...)
|
||||
session.Questions = append([]Question(nil), session.Questions...)
|
||||
session.Answers = append([]Answer(nil), session.Answers...)
|
||||
return session
|
||||
}
|
||||
56
internal/interview/types.go
Normal file
56
internal/interview/types.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package interview
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"tutor/internal/workflows"
|
||||
)
|
||||
|
||||
const BackendDeveloperTrack = "backend-developer"
|
||||
|
||||
type SessionStatus string
|
||||
|
||||
const (
|
||||
SessionInProgress SessionStatus = "in_progress"
|
||||
SessionComplete SessionStatus = "complete"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Track string `json:"track"`
|
||||
Status SessionStatus `json:"status"`
|
||||
TargetRole string `json:"target_role"`
|
||||
Stack []string `json:"stack"`
|
||||
InterviewTimeline string `json:"interview_timeline,omitempty"`
|
||||
Questions []Question `json:"questions"`
|
||||
Answers []Answer `json:"answers"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Question struct {
|
||||
ID string `json:"id"`
|
||||
Prompt string `json:"prompt"`
|
||||
Concepts []workflows.ConceptRef `json:"concepts"`
|
||||
}
|
||||
|
||||
type Answer struct {
|
||||
ID string `json:"id"`
|
||||
QuestionID string `json:"question_id"`
|
||||
Text string `json:"text"`
|
||||
Grade workflows.GradedAnswer `json:"grade"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreateSessionInput struct {
|
||||
UserID string
|
||||
TargetRole string
|
||||
Stack []string
|
||||
InterviewTimeline string
|
||||
}
|
||||
|
||||
type SubmitAnswerInput struct {
|
||||
SessionID string
|
||||
QuestionID string
|
||||
AnswerText string
|
||||
}
|
||||
Reference in New Issue
Block a user