feat: add diagnostic interview loop

This commit is contained in:
user
2026-04-26 16:24:35 +09:00
parent 0e232ff405
commit 4a4240fea2
21 changed files with 926 additions and 23 deletions

View 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},
},
},
}
}

View 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
}

View 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)
}
}

View 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
}

View 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
}