From 4a4240fea23ae9aea131a481893f354c2fd074e8 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 26 Apr 2026 16:24:35 +0900 Subject: [PATCH] feat: add diagnostic interview loop --- .planning/REQUIREMENTS.md | 16 +-- .planning/STATE.md | 13 +- .../002-CONTEXT.md | 93 ++++++++++++++ .../002-diagnostic-interview-loop/002-PLAN.md | 77 ++++++++++++ .../002-RESEARCH.md | 43 +++++++ .../002-SUMMARY.md | 50 ++++++++ .../002-VERIFICATION.md | 32 +++++ docs/planning/WORKFLOW_CONTRACTS.md | 1 + internal/app/server.go | 5 +- internal/httpapi/diagnostic.go | 80 ++++++++++++ internal/httpapi/diagnostic_test.go | 76 ++++++++++++ internal/httpapi/handler.go | 23 +++- internal/httpapi/handler_test.go | 4 +- internal/interview/catalog.go | 29 +++++ internal/interview/service.go | 117 ++++++++++++++++++ internal/interview/service_test.go | 87 +++++++++++++ internal/interview/store.go | 62 ++++++++++ internal/interview/types.go | 56 +++++++++ internal/workflows/contracts.go | 1 + internal/workflows/runner.go | 57 ++++++++- internal/workflows/runner_test.go | 27 +++- 21 files changed, 926 insertions(+), 23 deletions(-) create mode 100644 .planning/phases/002-diagnostic-interview-loop/002-CONTEXT.md create mode 100644 .planning/phases/002-diagnostic-interview-loop/002-PLAN.md create mode 100644 .planning/phases/002-diagnostic-interview-loop/002-RESEARCH.md create mode 100644 .planning/phases/002-diagnostic-interview-loop/002-SUMMARY.md create mode 100644 .planning/phases/002-diagnostic-interview-loop/002-VERIFICATION.md create mode 100644 internal/httpapi/diagnostic.go create mode 100644 internal/httpapi/diagnostic_test.go create mode 100644 internal/interview/catalog.go create mode 100644 internal/interview/service.go create mode 100644 internal/interview/service_test.go create mode 100644 internal/interview/store.go create mode 100644 internal/interview/types.go diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index b574230..c28d2bd 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -18,12 +18,12 @@ interview-ready after each short practice loop. ### Interview Practice -- [ ] **INT-01**: User can select target role, stack, and interview timeline. -- [ ] **INT-02**: User can complete a diagnostic technical interview. -- [ ] **INT-03**: System can generate role-specific interview questions. -- [ ] **INT-04**: System can grade user answers against explicit rubrics. -- [ ] **INT-05**: System can ask targeted follow-up questions for weak answers. -- [ ] **INT-06**: System preserves original answers and grading evidence. +- [x] **INT-01**: User can select target role, stack, and interview timeline. +- [x] **INT-02**: User can complete a diagnostic technical interview. +- [x] **INT-03**: System can generate role-specific interview questions. +- [x] **INT-04**: System can grade user answers against explicit rubrics. +- [x] **INT-05**: System can ask targeted follow-up questions for weak answers. +- [x] **INT-06**: System preserves original answers and grading evidence. ### Learner Memory @@ -95,7 +95,7 @@ interview-ready after each short practice loop. | Requirement | Phase | Status | |-------------|-------|--------| | BACK-01..BACK-05 | Phase 1 | Complete | -| INT-01..INT-06 | Phase 2 | Pending | +| INT-01..INT-06 | Phase 2 | Complete | | MEM-01..MEM-05 | Phase 3 | Pending | | PROG-01..PROG-05 | Phase 4 | Pending | | ONTO-01..ONTO-04 | Phase 5 | Pending | @@ -108,4 +108,4 @@ interview-ready after each short practice loop. --- *Requirements defined: 2026-04-26* -*Last updated: 2026-04-26 after Phase 1 execution.* +*Last updated: 2026-04-26 after Phase 2 execution.* diff --git a/.planning/STATE.md b/.planning/STATE.md index eacf7d1..b44cb21 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -7,7 +7,7 @@ See: `.planning/PROJECT.md` (updated 2026-04-26) **Core value:** The user should feel and prove that they are becoming more interview-ready after each short practice loop. -**Current focus:** Phase 2 planning: Diagnostic Interview Loop. +**Current focus:** Phase 3 planning: Learner Memory. ## Current Decisions @@ -22,14 +22,16 @@ interview-ready after each short practice loop. - First interview track is Backend Developer Interview. - Phase 1 has context, research, and plan artifacts. - Phase 1 Go backend scaffold is implemented and verified. +- Phase 2 diagnostic interview loop is implemented and verified with in-memory + sessions. ## Next Actions -1. Plan Phase 2 diagnostic interview loop with GSD. +1. Plan Phase 3 learner memory with GSD. 2. Keep `docs/planning/WORKFLOW_CONTRACTS.md` aligned with Go structs during future workflow implementation. -3. Decide whether Phase 2 starts with in-memory diagnostic sessions or a small - persistence boundary. +3. Decide whether Phase 3 learner memory remains in-memory for MVP proof or + introduces a small persistence boundary. ## Validation Log @@ -40,6 +42,9 @@ interview-ready after each short practice loop. - 2026-04-26: Phase 1 implementation verified with `go test ./...`, `openspec validate bootstrap-job-tutor-platform --strict`, and Go source line-count check. +- 2026-04-26: Phase 2 implementation verified with `go test ./...`, live + `/healthz` smoke, live diagnostic create/answer/get smoke, OpenSpec, and Go + source line-count check. --- *State initialized: 2026-04-26.* diff --git a/.planning/phases/002-diagnostic-interview-loop/002-CONTEXT.md b/.planning/phases/002-diagnostic-interview-loop/002-CONTEXT.md new file mode 100644 index 0000000..0b10b1f --- /dev/null +++ b/.planning/phases/002-diagnostic-interview-loop/002-CONTEXT.md @@ -0,0 +1,93 @@ +# Phase 2: Diagnostic Interview Loop - Context + +**Gathered:** 2026-04-26 +**Status:** Ready for planning +**Source:** GSD continuation after Phase 1 completion + + +## Phase Boundary + +Phase 2 proves the first job-seeker loop from target role/stack selection +through a graded diagnostic answer. It should create a thin backend product +surface for diagnostic sessions while avoiding persistent storage and real LLM +calls until later phases require them. + + + + +## Implementation Decisions + +### Persistence + +- Use an in-memory session store in Phase 2. +- Do not add a database or migrations yet. +- Persisting original answers and grading evidence means preserving them inside + the in-memory diagnostic session record for this phase. + +### Interview Track + +- Use the Backend Developer Interview track from + `docs/planning/INTERVIEW_TRACKS.md`. +- Seed questions should cover the canonical concept clusters, starting with a + small representative set. + +### Workflow Boundary + +- Grading must go through the typed `workflows.Runner` interface. +- The default implementation can remain deterministic/stubbed, but it must + return typed `GradedAnswer` data rather than freeform prose. +- HTTP handlers must not shell out or parse arbitrary assistant text. + + + + +## Canonical References + +Downstream agents MUST read these before planning or implementing. + +### Product and Track + +- `docs/planning/PRD.md` - diagnostic interview product flow. +- `docs/planning/INTERVIEW_TRACKS.md` - first track and concept seed list. +- `docs/planning/WORKFLOW_CONTRACTS.md` - typed workflow result shape. + +### Engineering and Requirements + +- `docs/planning/ENGINEERING.md` - 600-line, SOLID, KISS, YAGNI constraints. +- `.planning/REQUIREMENTS.md` - INT-01 through INT-06 requirements. +- `.planning/ROADMAP.md` - Phase 2 goal and success criteria. +- `openspec/changes/bootstrap-job-tutor-platform/specs/job-seeker-tutor/spec.md` + - diagnostic and first-track requirements. +- `openspec/changes/bootstrap-job-tutor-platform/specs/tutor-workflows/spec.md` + - typed workflow requirements. + + + + +## Specific Ideas + +- Add `internal/interview` for sessions, questions, answers, and in-memory store. +- Add endpoints: + - `POST /api/v1/diagnostic-sessions` + - `GET /api/v1/diagnostic-sessions/{id}` + - `POST /api/v1/diagnostic-sessions/{id}/answers` +- Keep routing simple with standard library `http.ServeMux`. +- Add deterministic grading in the workflow stub so tests can prove typed + evidence is recorded. + + + + +## Deferred Ideas + +- Real `third-one` grading execution. +- Database persistence. +- Authentication and user identity provider integration. +- Memory extraction and durable learner memory. +- Frontend UI. + + + +--- +*Phase: 002-diagnostic-interview-loop* +*Context gathered: 2026-04-26* diff --git a/.planning/phases/002-diagnostic-interview-loop/002-PLAN.md b/.planning/phases/002-diagnostic-interview-loop/002-PLAN.md new file mode 100644 index 0000000..60b8625 --- /dev/null +++ b/.planning/phases/002-diagnostic-interview-loop/002-PLAN.md @@ -0,0 +1,77 @@ +# Phase 2 Plan: Diagnostic Interview Loop + +**Status:** Ready for execution +**Phase Goal:** Prove the first job-seeker loop from role selection through +graded diagnostic interview. + +## Requirements Covered + +- INT-01: User can select target role, stack, and interview timeline. +- INT-02: User can complete a diagnostic technical interview. +- INT-03: System can generate role-specific interview questions. +- INT-04: System can grade user answers against explicit rubrics. +- INT-05: System can ask targeted follow-up questions for weak answers. +- INT-06: System preserves original answers and grading evidence. + +## Tasks + +### 1. Add interview domain package + +- Create `internal/interview`. +- Define session, question, answer, and store types. +- Add in-memory store implementation. +- Add backend developer diagnostic question catalog. + +### 2. Add diagnostic service + +- Create session from target role, stack, and optional interview timeline. +- Select first diagnostic questions from the backend developer track. +- Record submitted answers. +- Invoke `workflows.Runner.GradeInterviewAnswer`. +- Attach typed grading result and evidence to the answer record. + +### 3. Add HTTP endpoints + +- `POST /api/v1/diagnostic-sessions` +- `GET /api/v1/diagnostic-sessions/{id}` +- `POST /api/v1/diagnostic-sessions/{id}/answers` + +### 4. Extend workflow stub + +- Return deterministic typed grades. +- Include follow-up recommendation for weak answers. +- Include evidence references. + +### 5. Add tests + +- Domain tests for session creation and answer grading. +- HTTP tests for create/get/answer flow. +- Existing config/workflow tests remain passing. + +### 6. Update GSD/OpenSpec state + +- Mark INT-01 through INT-06 complete if all success criteria pass. +- Add Phase 2 summary and verification artifacts. + +## Verification + +```powershell +gofmt -w cmd internal +go test ./... +openspec validate bootstrap-job-tutor-platform --strict +``` + +Run Go line-count check and confirm every manually authored Go file is at or +below 600 lines. + +## Out of Scope + +- Database persistence. +- Authentication. +- Real LLM grading. +- Durable learner memory. +- Progression map. +- Frontend. + +--- +*Plan created: 2026-04-26* diff --git a/.planning/phases/002-diagnostic-interview-loop/002-RESEARCH.md b/.planning/phases/002-diagnostic-interview-loop/002-RESEARCH.md new file mode 100644 index 0000000..3233c7f --- /dev/null +++ b/.planning/phases/002-diagnostic-interview-loop/002-RESEARCH.md @@ -0,0 +1,43 @@ +# Phase 2 Research + +## Question + +How should the diagnostic interview loop be implemented while preserving the +Phase 1 typed workflow boundary and avoiding premature infrastructure? + +## Findings + +### In-memory persistence is enough for Phase 2 + +The goal is to prove request/response flow and evidence preservation. A database +would add migration, repository, and lifecycle complexity before the product loop +is proven. An in-memory store with clear interface boundaries keeps the future +database replacement straightforward. + +### Standard library routing remains sufficient + +The current backend already uses `http.ServeMux`. Phase 2 can add route patterns +with path variables using Go 1.23's standard mux support. No router dependency +is needed. + +### Deterministic grading is acceptable as a workflow stub + +Phase 2 requires typed grading through the workflow boundary. It does not require +live LLM grading. A deterministic stub can grade on answer length and preserve +evidence. This proves the product state flow and keeps live model integration +for a later phase. + +### Keep interview domain separate from HTTP + +`internal/interview` should own session creation, question catalog selection, +answer recording, and grade attachment. HTTP handlers should translate requests +and responses only. + +## Recommendation + +1. Add `internal/interview` domain service and in-memory store. +2. Add a small backend developer question catalog. +3. Add typed endpoints for creating/getting sessions and submitting answers. +4. Extend workflow stub to return deterministic `GradedAnswer`. +5. Add tests at domain and HTTP layers. +6. Verify with `go test ./...`, OpenSpec, and line-count checks. diff --git a/.planning/phases/002-diagnostic-interview-loop/002-SUMMARY.md b/.planning/phases/002-diagnostic-interview-loop/002-SUMMARY.md new file mode 100644 index 0000000..6068214 --- /dev/null +++ b/.planning/phases/002-diagnostic-interview-loop/002-SUMMARY.md @@ -0,0 +1,50 @@ +# Phase 2 Summary + +**Status:** Complete +**Completed:** 2026-04-26 + +## Delivered + +- Added in-memory diagnostic interview domain package. +- Added Backend Developer Interview seed question catalog. +- Added diagnostic session create/get/answer service. +- Added session status that becomes `complete` after all diagnostic questions + have answers. +- Added HTTP endpoints: + - `POST /api/v1/diagnostic-sessions` + - `GET /api/v1/diagnostic-sessions/{id}` + - `POST /api/v1/diagnostic-sessions/{id}/answers` +- Extended workflow stub to return deterministic typed grading results. +- Added grading evidence to `GradedAnswer`. +- Added domain, workflow, and HTTP flow tests. + +## Files Added + +- `internal/httpapi/diagnostic.go` +- `internal/httpapi/diagnostic_test.go` +- `internal/interview/catalog.go` +- `internal/interview/service.go` +- `internal/interview/service_test.go` +- `internal/interview/store.go` +- `internal/interview/types.go` + +## Verification + +```powershell +gofmt -w cmd internal +go test ./... +openspec validate bootstrap-job-tutor-platform --strict +``` + +Additional live smoke checks: + +- `GET /healthz` returned status `ok`. +- Diagnostic create/answer/get flow returned a session id, 3 questions, a + `solid` typed grade, 1 evidence item, and 1 stored answer. + +## Deferred + +- Durable database persistence. +- Authentication. +- Real `third-one` grading calls. +- Learner memory extraction and readiness progression. diff --git a/.planning/phases/002-diagnostic-interview-loop/002-VERIFICATION.md b/.planning/phases/002-diagnostic-interview-loop/002-VERIFICATION.md new file mode 100644 index 0000000..563c044 --- /dev/null +++ b/.planning/phases/002-diagnostic-interview-loop/002-VERIFICATION.md @@ -0,0 +1,32 @@ +# Phase 2 Verification + +## Verdict + +PASS + +## Requirement Coverage + +- INT-01: PASS. Diagnostic session request accepts target role, stack, and + interview timeline. +- INT-02: PASS. Diagnostic sessions can progress to `complete` after all seed + questions are answered. +- INT-03: PASS. Backend Developer Interview questions are generated from the + role-specific seed catalog. +- INT-04: PASS. Answers are graded through the typed workflow runner boundary. +- INT-05: PASS. Weak or partial answers receive typed follow-up recommendations. +- INT-06: PASS. Original answer text and grading evidence are preserved in the + in-memory session record. + +## Evidence + +- `go test ./...` passed. +- `openspec validate bootstrap-job-tutor-platform --strict` passed. +- Live `GET /healthz` smoke passed. +- Live diagnostic create/answer/get smoke passed. +- Go source line-count check passed. + +## Residual Risk + +Persistence is intentionally in-memory. Data is lost on process restart. Phase 3 +should decide whether learner memory remains in-memory for proof or introduces a +small persistent boundary. diff --git a/docs/planning/WORKFLOW_CONTRACTS.md b/docs/planning/WORKFLOW_CONTRACTS.md index 98877ac..8bd695e 100644 --- a/docs/planning/WORKFLOW_CONTRACTS.md +++ b/docs/planning/WORKFLOW_CONTRACTS.md @@ -73,6 +73,7 @@ Produced by `grade_interview_answer`. "overall": "miss|partial|solid|strong", "strengths": ["string"], "gaps": ["string"], + "evidence": ["evidence_ref"], "misconception_candidates": [ { "label": "string", diff --git a/internal/app/server.go b/internal/app/server.go index 524aa36..71a29d7 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -5,12 +5,15 @@ import ( "tutor/internal/config" "tutor/internal/httpapi" + "tutor/internal/interview" "tutor/internal/workflows" ) func NewServer(cfg config.Config) *http.Server { runner := workflows.NewStubRunner() - handler := httpapi.NewHandler(cfg, runner) + store := interview.NewMemoryStore() + service := interview.NewService(store, runner) + handler := httpapi.NewHandler(cfg, service) return &http.Server{ Addr: cfg.HTTPAddr, diff --git a/internal/httpapi/diagnostic.go b/internal/httpapi/diagnostic.go new file mode 100644 index 0000000..e1e5820 --- /dev/null +++ b/internal/httpapi/diagnostic.go @@ -0,0 +1,80 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + + "tutor/internal/interview" +) + +type createDiagnosticSessionRequest struct { + UserID string `json:"user_id"` + TargetRole string `json:"target_role"` + Stack []string `json:"stack"` + InterviewTimeline string `json:"interview_timeline"` +} + +type submitDiagnosticAnswerRequest struct { + QuestionID string `json:"question_id"` + AnswerText string `json:"answer_text"` +} + +func (h Handler) createDiagnosticSession(w http.ResponseWriter, r *http.Request) { + var req createDiagnosticSessionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + session, err := h.diagnostic.CreateSession(r.Context(), interview.CreateSessionInput{ + UserID: req.UserID, + TargetRole: req.TargetRole, + Stack: req.Stack, + InterviewTimeline: req.InterviewTimeline, + }) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, session) +} + +func (h Handler) getDiagnosticSession(w http.ResponseWriter, r *http.Request) { + session, err := h.diagnostic.GetSession(r.PathValue("id")) + if errors.Is(err, interview.ErrSessionNotFound) { + writeError(w, http.StatusNotFound, "diagnostic session not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "could not load diagnostic session") + return + } + + writeJSON(w, http.StatusOK, session) +} + +func (h Handler) submitDiagnosticAnswer(w http.ResponseWriter, r *http.Request) { + var req submitDiagnosticAnswerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + answer, err := h.diagnostic.SubmitAnswer(r.Context(), interview.SubmitAnswerInput{ + SessionID: r.PathValue("id"), + QuestionID: req.QuestionID, + AnswerText: req.AnswerText, + }) + if errors.Is(err, interview.ErrSessionNotFound) || errors.Is(err, interview.ErrQuestionNotFound) { + writeError(w, http.StatusNotFound, err.Error()) + return + } + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, answer) +} diff --git a/internal/httpapi/diagnostic_test.go b/internal/httpapi/diagnostic_test.go new file mode 100644 index 0000000..794325f --- /dev/null +++ b/internal/httpapi/diagnostic_test.go @@ -0,0 +1,76 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "tutor/internal/config" + "tutor/internal/interview" + "tutor/internal/workflows" +) + +func TestDiagnosticHTTPFlow(t *testing.T) { + service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner()) + handler := NewHandler(config.Config{Environment: "test", ModelKey: "deepseek-v4-flash"}, service) + routes := handler.Routes() + + createBody := bytes.NewBufferString(`{ + "user_id":"user-1", + "target_role":"junior backend developer", + "stack":["go","postgres"], + "interview_timeline":"30 days" + }`) + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostic-sessions", createBody) + createRec := httptest.NewRecorder() + routes.ServeHTTP(createRec, createReq) + + if createRec.Code != http.StatusCreated { + t.Fatalf("create status = %d, body = %s", createRec.Code, createRec.Body.String()) + } + + var session interview.Session + if err := json.NewDecoder(createRec.Body).Decode(&session); err != nil { + t.Fatalf("decode create response: %v", err) + } + if len(session.Questions) == 0 { + t.Fatal("expected questions") + } + + answerBody := bytes.NewBufferString(`{ + "question_id":"` + session.Questions[0].ID + `", + "answer_text":"Idempotent requests can be retried safely because repeated calls have the same intended effect." + }`) + answerReq := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostic-sessions/"+session.ID+"/answers", answerBody) + answerRec := httptest.NewRecorder() + routes.ServeHTTP(answerRec, answerReq) + + if answerRec.Code != http.StatusCreated { + t.Fatalf("answer status = %d, body = %s", answerRec.Code, answerRec.Body.String()) + } + + var answer interview.Answer + if err := json.NewDecoder(answerRec.Body).Decode(&answer); err != nil { + t.Fatalf("decode answer response: %v", err) + } + if len(answer.Grade.Evidence) == 0 { + t.Fatal("expected grade evidence") + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/v1/diagnostic-sessions/"+session.ID, nil) + getRec := httptest.NewRecorder() + routes.ServeHTTP(getRec, getReq) + + if getRec.Code != http.StatusOK { + t.Fatalf("get status = %d, body = %s", getRec.Code, getRec.Body.String()) + } + var loaded interview.Session + if err := json.NewDecoder(getRec.Body).Decode(&loaded); err != nil { + t.Fatalf("decode get response: %v", err) + } + if len(loaded.Answers) != 1 { + t.Fatalf("answers = %d, want 1", len(loaded.Answers)) + } +} diff --git a/internal/httpapi/handler.go b/internal/httpapi/handler.go index 67a41e9..0dd2776 100644 --- a/internal/httpapi/handler.go +++ b/internal/httpapi/handler.go @@ -5,24 +5,27 @@ import ( "net/http" "tutor/internal/config" - "tutor/internal/workflows" + "tutor/internal/interview" ) type Handler struct { - cfg config.Config - runner workflows.Runner + cfg config.Config + diagnostic *interview.Service } -func NewHandler(cfg config.Config, runner workflows.Runner) Handler { +func NewHandler(cfg config.Config, diagnostic *interview.Service) Handler { return Handler{ - cfg: cfg, - runner: runner, + cfg: cfg, + diagnostic: diagnostic, } } func (h Handler) Routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", h.health) + mux.HandleFunc("POST /api/v1/diagnostic-sessions", h.createDiagnosticSession) + mux.HandleFunc("GET /api/v1/diagnostic-sessions/{id}", h.getDiagnosticSession) + mux.HandleFunc("POST /api/v1/diagnostic-sessions/{id}/answers", h.submitDiagnosticAnswer) return mux } @@ -45,3 +48,11 @@ func writeJSON(w http.ResponseWriter, status int, value any) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, errorResponse{Error: message}) +} + +type errorResponse struct { + Error string `json:"error"` +} diff --git a/internal/httpapi/handler_test.go b/internal/httpapi/handler_test.go index 17b3d05..4829460 100644 --- a/internal/httpapi/handler_test.go +++ b/internal/httpapi/handler_test.go @@ -7,6 +7,7 @@ import ( "testing" "tutor/internal/config" + "tutor/internal/interview" "tutor/internal/workflows" ) @@ -15,7 +16,8 @@ func TestHealth(t *testing.T) { Environment: "test", ModelKey: "deepseek-v4-flash", } - handler := NewHandler(cfg, workflows.NewStubRunner()) + service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner()) + handler := NewHandler(cfg, service) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) rec := httptest.NewRecorder() diff --git a/internal/interview/catalog.go b/internal/interview/catalog.go new file mode 100644 index 0000000..8475184 --- /dev/null +++ b/internal/interview/catalog.go @@ -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}, + }, + }, + } +} diff --git a/internal/interview/service.go b/internal/interview/service.go new file mode 100644 index 0000000..8e92cd4 --- /dev/null +++ b/internal/interview/service.go @@ -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 +} diff --git a/internal/interview/service_test.go b/internal/interview/service_test.go new file mode 100644 index 0000000..15ff97e --- /dev/null +++ b/internal/interview/service_test.go @@ -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) + } +} diff --git a/internal/interview/store.go b/internal/interview/store.go new file mode 100644 index 0000000..2746b03 --- /dev/null +++ b/internal/interview/store.go @@ -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 +} diff --git a/internal/interview/types.go b/internal/interview/types.go new file mode 100644 index 0000000..fe16d5a --- /dev/null +++ b/internal/interview/types.go @@ -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 +} diff --git a/internal/workflows/contracts.go b/internal/workflows/contracts.go index 54fccf0..87c991a 100644 --- a/internal/workflows/contracts.go +++ b/internal/workflows/contracts.go @@ -58,6 +58,7 @@ type GradedAnswer struct { Overall AnswerOverall `json:"overall"` Strengths []string `json:"strengths"` Gaps []string `json:"gaps"` + Evidence []EvidenceRef `json:"evidence"` MisconceptionCandidates []MisconceptionCandidate `json:"misconception_candidates"` FollowUp FollowUpRecommendation `json:"follow_up"` } diff --git a/internal/workflows/runner.go b/internal/workflows/runner.go index d9b4f43..da2e7af 100644 --- a/internal/workflows/runner.go +++ b/internal/workflows/runner.go @@ -3,6 +3,7 @@ package workflows import ( "context" "errors" + "strings" ) var ErrNotImplemented = errors.New("workflow runner not implemented") @@ -27,6 +28,7 @@ type GradeAnswerInput struct { QuestionID string AnswerID string AnswerText string + Concepts []ConceptRef } type NextChallengeInput struct { @@ -49,8 +51,49 @@ func (StubRunner) DiagnoseJobSeeker(context.Context, DiagnosticInput) (Diagnosti return DiagnosticResult{}, ErrNotImplemented } -func (StubRunner) GradeInterviewAnswer(context.Context, GradeAnswerInput) (GradedAnswer, error) { - return GradedAnswer{}, ErrNotImplemented +func (StubRunner) GradeInterviewAnswer(_ context.Context, input GradeAnswerInput) (GradedAnswer, error) { + wordCount := len(strings.Fields(input.AnswerText)) + overall := AnswerPartial + if wordCount >= 18 { + overall = AnswerSolid + } + if wordCount < 8 { + overall = AnswerMiss + } + + grade := GradedAnswer{ + AnswerID: input.AnswerID, + QuestionID: input.QuestionID, + Concepts: append([]ConceptRef(nil), input.Concepts...), + Scores: AnswerScores{ + Correctness: scoreFromWords(wordCount, 8), + Depth: scoreFromWords(wordCount, 14), + Communication: scoreFromWords(wordCount, 10), + ProductionJudgment: scoreFromWords(wordCount, 20), + }, + Overall: overall, + Strengths: []string{"Answer was captured and evaluated through the typed workflow boundary."}, + Gaps: []string{}, + Evidence: []EvidenceRef{ + { + Kind: EvidenceAnswer, + ID: input.AnswerID, + Quote: input.AnswerText, + Confidence: 1, + }, + }, + FollowUp: FollowUpRecommendation{}, + } + + if overall == AnswerMiss || overall == AnswerPartial { + grade.Gaps = []string{"Answer needs more concrete reasoning and tradeoff discussion."} + grade.FollowUp = FollowUpRecommendation{ + Needed: true, + Question: "Can you give a concrete production example and explain the tradeoff?", + Purpose: FollowUpRepair, + } + } + return grade, nil } func (StubRunner) ExtractLearningMemory(context.Context, GradedAnswer) (MemoryUpdateCandidate, error) { @@ -64,3 +107,13 @@ func (StubRunner) SelectNextChallenge(context.Context, NextChallengeInput) (Next func (StubRunner) UpdateReadinessMap(context.Context, ReadinessUpdateInput) (ReadinessUpdate, error) { return ReadinessUpdate{}, ErrNotImplemented } + +func scoreFromWords(wordCount int, target int) int { + if wordCount >= target { + return 4 + } + if wordCount >= target/2 { + return 2 + } + return 1 +} diff --git a/internal/workflows/runner_test.go b/internal/workflows/runner_test.go index b6082b5..c599349 100644 --- a/internal/workflows/runner_test.go +++ b/internal/workflows/runner_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -func TestStubRunnerReturnsTypedNotImplemented(t *testing.T) { +func TestStubRunnerDiagnoseReturnsTypedNotImplemented(t *testing.T) { runner := NewStubRunner() _, err := runner.DiagnoseJobSeeker(context.Background(), DiagnosticInput{ @@ -19,3 +19,28 @@ func TestStubRunnerReturnsTypedNotImplemented(t *testing.T) { t.Fatalf("err = %v, want %v", err, ErrNotImplemented) } } + +func TestStubRunnerGradesAnswer(t *testing.T) { + runner := NewStubRunner() + + grade, err := runner.GradeInterviewAnswer(context.Background(), GradeAnswerInput{ + QuestionID: "q-1", + AnswerID: "a-1", + AnswerText: "Indexes can speed reads by helping the database find rows, but they add write overhead.", + Concepts: []ConceptRef{ + {ID: "database-indexes", Label: "Database indexes", Track: "backend-developer"}, + }, + }) + if err != nil { + t.Fatalf("GradeInterviewAnswer error: %v", err) + } + if grade.AnswerID != "a-1" { + t.Fatalf("AnswerID = %q", grade.AnswerID) + } + if len(grade.Concepts) != 1 { + t.Fatalf("concepts = %d, want 1", len(grade.Concepts)) + } + if len(grade.Evidence) != 1 { + t.Fatalf("evidence = %d, want 1", len(grade.Evidence)) + } +}