Diagnostic
+diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 2deabb3..1cbd119 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -70,10 +70,10 @@ interview-ready after each short practice loop. ### Frontend MVP -- [ ] **WEB-01**: User can open a web app served by the Go service. -- [ ] **WEB-02**: User can create a diagnostic interview session from the web +- [x] **WEB-01**: User can open a web app served by the Go service. +- [x] **WEB-02**: User can create a diagnostic interview session from the web app. -- [ ] **WEB-03**: User can answer a diagnostic question and see rubric feedback. +- [x] **WEB-03**: User can answer a diagnostic question and see rubric feedback. - [ ] **WEB-04**: User can see learner memory, readiness, and next challenge after answering. - [ ] **WEB-05**: Operator can ingest source material from the web app. @@ -116,7 +116,7 @@ interview-ready after each short practice loop. | PROG-01..PROG-05 | Phase 4 | Complete | | ONTO-01..ONTO-04 | Phase 5 | Complete | | ASSET-01..ASSET-03 | Phase 6 | Complete | -| WEB-01..WEB-03 | Phase 7 | Pending | +| WEB-01..WEB-03 | Phase 7 | Complete | | WEB-04 | Phase 8 | Pending | | WEB-05..WEB-08 | Phase 9 | Pending | @@ -128,4 +128,4 @@ interview-ready after each short practice loop. --- *Requirements defined: 2026-04-26* -*Last updated: 2026-04-26 after v2 Frontend MVP milestone start.* +*Last updated: 2026-04-26 after Phase 7 execution.* diff --git a/.planning/STATE.md b/.planning/STATE.md index 0aa9924..3e2a0d7 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:** v2 Frontend MVP milestone started; ready for Phase 7. +**Current focus:** Phase 8 planning: Learning Progress View. ## Current Decisions @@ -37,10 +37,11 @@ interview-ready after each short practice loop. tech-debt items recorded in `.planning/v1-MILESTONE-AUDIT.md`. - v2 Frontend MVP milestone selected to turn the backend learning loop into a usable web service. +- Phase 7 web app shell and diagnostic start UI is implemented and verified. ## Next Actions -1. Plan and execute Phase 7: Web App Shell and Diagnostic Start. +1. Plan and execute Phase 8: Learning Progress View. 2. Verify the production OpenAI image model identifier before real image generation calls. 3. Add standardized SUMMARY frontmatter or Nyquist validation files if future @@ -76,6 +77,9 @@ interview-ready after each short practice loop. real image generation, and Nyquist validation artifacts remain deferred. - 2026-04-26: v2 Frontend MVP milestone started with WEB-01..WEB-08 mapped to phases 7 through 9. +- 2026-04-26: Phase 7 implementation verified with `go test ./...`, OpenSpec + validation, root/asset HTTP smoke, and diagnostic API smoke through the + server used by the web app. --- *State initialized: 2026-04-26.* diff --git a/.planning/phases/007-web-app-shell-diagnostic-start/007-CONTEXT.md b/.planning/phases/007-web-app-shell-diagnostic-start/007-CONTEXT.md new file mode 100644 index 0000000..54c701a --- /dev/null +++ b/.planning/phases/007-web-app-shell-diagnostic-start/007-CONTEXT.md @@ -0,0 +1,38 @@ +# Phase 7 Context: Web App Shell and Diagnostic Start + +**Status:** Ready for execution +**Started:** 2026-04-26 + +## Goal + +Serve the first browser UI from the Go backend and let a job seeker start +diagnostic practice without API tooling. + +## Requirements + +- WEB-01: User can open a web app served by the Go service. +- WEB-02: User can create a diagnostic interview session from the web app. +- WEB-03: User can answer a diagnostic question and see rubric feedback. + +## UX Direction + +Visual thesis: quiet interview coaching workspace, dense but readable, with one +clear green accent for action and readiness. + +Content plan: + +- left rail: learner setup +- main workspace: active diagnostic questions and answer input +- right context: grading feedback and evidence + +Interaction thesis: + +- loading states on API actions +- inline error region +- selected question state and answer result refresh + +## Out of Scope + +- Full progress view. +- Material/ontology workspace. +- Authentication. diff --git a/.planning/phases/007-web-app-shell-diagnostic-start/007-PLAN.md b/.planning/phases/007-web-app-shell-diagnostic-start/007-PLAN.md new file mode 100644 index 0000000..b0c90a6 --- /dev/null +++ b/.planning/phases/007-web-app-shell-diagnostic-start/007-PLAN.md @@ -0,0 +1,36 @@ +# Phase 7 Plan: Web App Shell and Diagnostic Start + +**Status:** Ready for execution +**Phase Goal:** Create a working diagnostic web app shell. + +## Tasks + +### 1. Serve embedded web app + +- Add `internal/webapp`. +- Embed static app assets. +- Register root and asset routes through the existing Go server. + +### 2. Build diagnostic UI + +- Add setup form for user id, target role, stack, and interview timeline. +- Create diagnostic session through the real API. +- Show returned questions. + +### 3. Build answer and grading UI + +- Let user select a question. +- Submit answer through the real API. +- Show overall grade, scores, follow-up, and evidence. + +### 4. Verify + +- Add HTTP tests for root web app and asset serving. +- Run Go tests, OpenSpec validation, line-count check. +- Smoke the rendered app endpoint and diagnostic API flow. + +## Out of Scope + +- Frontend build tooling. +- Authentication. +- Phase 8 progress panels. diff --git a/.planning/phases/007-web-app-shell-diagnostic-start/007-RESEARCH.md b/.planning/phases/007-web-app-shell-diagnostic-start/007-RESEARCH.md new file mode 100644 index 0000000..4933cc0 --- /dev/null +++ b/.planning/phases/007-web-app-shell-diagnostic-start/007-RESEARCH.md @@ -0,0 +1,27 @@ +# Phase 7 Research: Web App Shell and Diagnostic Start + +## Findings + +The existing Go backend can serve static assets without adding frontend build +tooling. For the first MVP UI, plain HTML/CSS/JavaScript is enough and keeps +the repo dependency-light. + +The diagnostic APIs already provide all Phase 7 data: + +- `POST /api/v1/diagnostic-sessions` +- `POST /api/v1/diagnostic-sessions/{id}/answers` +- `GET /api/v1/diagnostic-sessions/{id}` + +## Recommended Shape + +- Add `internal/webapp` with embedded static assets. +- Register web app routes from `httpapi.Handler`. +- Keep frontend files small and focused. +- Use fetch calls directly against existing API routes. + +## Risks + +- A mock UI would not prove the backend loop. The UI must call real APIs. +- A marketing-style landing page would distract from the core product surface. +- Overbuilding a frontend stack before interaction validation would violate + YAGNI. diff --git a/.planning/phases/007-web-app-shell-diagnostic-start/007-SUMMARY.md b/.planning/phases/007-web-app-shell-diagnostic-start/007-SUMMARY.md new file mode 100644 index 0000000..01b2275 --- /dev/null +++ b/.planning/phases/007-web-app-shell-diagnostic-start/007-SUMMARY.md @@ -0,0 +1,37 @@ +# Phase 7 Summary + +**Status:** Complete +**Completed:** 2026-04-26 + +## Delivered + +- Added embedded web app serving from the Go backend. +- Added `GET /` app shell and `/assets/*` static asset handling. +- Built dependency-light HTML/CSS/JavaScript UI for diagnostic practice. +- Added setup form for user id, target role, stack, and timeline. +- Added real API-backed diagnostic session creation. +- Added question selection, answer submission, and rubric feedback rendering. +- Added loading, error, empty, and selected-question states. +- Added web app route and asset tests. + +## Verification + +```powershell +gofmt -w cmd internal +go test ./... +openspec validate frontend-mvp --strict +openspec validate bootstrap-job-tutor-platform --strict +``` + +Additional smoke check: + +- `GET /` returned the app shell. +- `GET /assets/app.js` returned the browser script. +- Diagnostic session creation and answer grading succeeded through the same + server used by the app. + +## Deferred + +- Progress panels for memory/readiness/next challenge. +- Material and asset workspace. +- Browser screenshot audit. diff --git a/.planning/phases/007-web-app-shell-diagnostic-start/007-VERIFICATION.md b/.planning/phases/007-web-app-shell-diagnostic-start/007-VERIFICATION.md new file mode 100644 index 0000000..e012a5b --- /dev/null +++ b/.planning/phases/007-web-app-shell-diagnostic-start/007-VERIFICATION.md @@ -0,0 +1,27 @@ +# Phase 7 Verification + +## Verdict + +PASS + +## Requirement Coverage + +- WEB-01: PASS. The Go service serves the web app at `/`. +- WEB-02: PASS. The browser app can create diagnostic sessions through the real + backend API. +- WEB-03: PASS. The browser app can submit answers and render typed rubric + feedback, scores, follow-up, and evidence. + +## Evidence + +- `go test ./...` passed. +- `openspec validate frontend-mvp --strict` passed. +- `openspec validate bootstrap-job-tutor-platform --strict` passed. +- Live root/asset HTTP smoke passed. +- Live diagnostic create/answer smoke passed through the same server. + +## Residual Risk + +The UI was verified with HTTP/API smoke but not yet with a browser screenshot +audit. Phase 8 should add browser-backed checks once the progress view is +included. diff --git a/internal/httpapi/handler.go b/internal/httpapi/handler.go index cc57251..ebdf9ba 100644 --- a/internal/httpapi/handler.go +++ b/internal/httpapi/handler.go @@ -10,6 +10,7 @@ import ( "tutor/internal/ontology" "tutor/internal/progression" "tutor/internal/teachingassets" + "tutor/internal/webapp" ) type Handler struct { @@ -52,6 +53,7 @@ func (h Handler) Routes() http.Handler { mux.HandleFunc("GET /api/v1/ontology", h.getOntology) mux.HandleFunc("POST /api/v1/teaching-assets/prompts", h.generateTeachingAssetPrompt) mux.HandleFunc("GET /api/v1/teaching-assets", h.getTeachingAssets) + mux.Handle("GET /", webapp.Handler()) return mux } diff --git a/internal/httpapi/handler_test.go b/internal/httpapi/handler_test.go index 026b42b..dcb12f5 100644 --- a/internal/httpapi/handler_test.go +++ b/internal/httpapi/handler_test.go @@ -50,3 +50,28 @@ func TestHealth(t *testing.T) { t.Fatalf("body.ModelKey = %q", body.ModelKey) } } + +func TestWebAppRoute(t *testing.T) { + cfg := config.Config{ + Environment: "test", + ModelKey: "deepseek-v4-flash", + } + memory := learnermemory.NewService(learnermemory.NewMemoryStore()) + service := interview.NewService(interview.NewMemoryStore(), workflows.NewStubRunner(), memory) + progress := progression.NewService(memory) + onto := ontology.NewService(ontology.NewMemoryStore()) + assets := teachingassets.NewService(teachingassets.NewMemoryStore(), onto, cfg.ImageModelKey) + handler := NewHandler(cfg, service, memory, progress, onto, assets) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + handler.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if rec.Header().Get("Content-Type") != "text/html; charset=utf-8" { + t.Fatalf("content-type = %q", rec.Header().Get("Content-Type")) + } +} diff --git a/internal/webapp/assets.go b/internal/webapp/assets.go new file mode 100644 index 0000000..4ed7947 --- /dev/null +++ b/internal/webapp/assets.go @@ -0,0 +1,41 @@ +package webapp + +import ( + "embed" + "io/fs" + "net/http" + "strings" +) + +//go:embed static/* +var assets embed.FS + +func Handler() http.Handler { + static, err := fs.Sub(assets, "static") + if err != nil { + panic(err) + } + files := http.FileServer(http.FS(static)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + serveIndex(w, r, static) + return + } + if strings.HasPrefix(r.URL.Path, "/assets/") { + http.StripPrefix("/assets/", files).ServeHTTP(w, r) + return + } + http.NotFound(w, r) + }) +} + +func serveIndex(w http.ResponseWriter, r *http.Request, static fs.FS) { + content, err := fs.ReadFile(static, "index.html") + if err != nil { + http.Error(w, "web app unavailable", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(content) +} diff --git a/internal/webapp/assets_test.go b/internal/webapp/assets_test.go new file mode 100644 index 0000000..f653ed5 --- /dev/null +++ b/internal/webapp/assets_test.go @@ -0,0 +1,36 @@ +package webapp + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHandlerServesIndex(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Interview practice") { + t.Fatal("expected app shell content") + } +} + +func TestHandlerServesAsset(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil) + rec := httptest.NewRecorder() + + Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "diagnostic-sessions") { + t.Fatal("expected app script content") + } +} diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js new file mode 100644 index 0000000..f466e8e --- /dev/null +++ b/internal/webapp/static/app.js @@ -0,0 +1,181 @@ +const state = { + session: null, + selectedQuestion: null, + lastAnswer: null, +}; + +const els = { + sessionForm: document.querySelector("#session-form"), + answerForm: document.querySelector("#answer-form"), + answerText: document.querySelector("#answer-text"), + answerButton: document.querySelector("#answer-button"), + questions: document.querySelector("#questions"), + feedback: document.querySelector("#feedback"), + status: document.querySelector("#status-line"), + error: document.querySelector("#error-line"), + title: document.querySelector("#session-title"), +}; + +els.sessionForm.addEventListener("submit", async (event) => { + event.preventDefault(); + clearError(); + setStatus("Creating diagnostic session..."); + + const payload = { + user_id: value("#user-id"), + target_role: value("#target-role"), + stack: value("#stack").split(",").map((item) => item.trim()).filter(Boolean), + interview_timeline: value("#timeline"), + }; + + try { + const session = await request("/api/v1/diagnostic-sessions", { + method: "POST", + body: JSON.stringify(payload), + }); + state.session = session; + state.selectedQuestion = session.questions[0] || null; + state.lastAnswer = null; + renderSession(); + renderFeedback(); + setStatus(`Session ${session.id} ready`); + } catch (error) { + showError(error.message); + setStatus("Ready"); + } +}); + +els.answerForm.addEventListener("submit", async (event) => { + event.preventDefault(); + clearError(); + if (!state.session || !state.selectedQuestion) return; + + setStatus("Submitting answer..."); + els.answerButton.disabled = true; + + try { + const answer = await request(`/api/v1/diagnostic-sessions/${state.session.id}/answers`, { + method: "POST", + body: JSON.stringify({ + question_id: state.selectedQuestion.id, + answer_text: els.answerText.value, + }), + }); + state.lastAnswer = answer; + renderFeedback(); + setStatus(`Answer graded as ${answer.grade.overall}`); + } catch (error) { + showError(error.message); + setStatus("Session ready"); + } finally { + els.answerButton.disabled = !state.selectedQuestion; + } +}); + +function renderSession() { + if (!state.session) return; + els.title.textContent = `${state.session.target_role} ยท ${state.session.questions.length} questions`; + els.questions.className = "question-list"; + els.questions.innerHTML = ""; + + state.session.questions.forEach((question) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "question-button"; + button.setAttribute("aria-pressed", String(state.selectedQuestion?.id === question.id)); + button.innerHTML = `${question.id}${escapeHTML(question.prompt)}`; + button.addEventListener("click", () => { + state.selectedQuestion = question; + els.answerText.value = ""; + renderSession(); + setStatus(`Selected ${question.id}`); + }); + els.questions.append(button); + }); + + els.answerButton.disabled = !state.selectedQuestion; +} + +function renderFeedback() { + if (!state.lastAnswer) { + els.feedback.className = "feedback empty-state"; + els.feedback.textContent = "Submit an answer to see grade, evidence, and follow-up."; + return; + } + + const grade = state.lastAnswer.grade; + els.feedback.className = "feedback"; + els.feedback.innerHTML = ` +
${escapeHTML(grade.strengths?.[0] || "Answer was graded.")}
+${escapeHTML(followUp.question)}
Diagnostic
+