feat: add diagnostic web app shell

This commit is contained in:
user
2026-04-26 18:39:09 +09:00
parent 3493f8b5a5
commit ce38189f33
15 changed files with 763 additions and 9 deletions

View File

@@ -70,10 +70,10 @@ interview-ready after each short practice loop.
### Frontend MVP ### Frontend MVP
- [ ] **WEB-01**: User can open a web app served by the Go service. - [x] **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-02**: User can create a diagnostic interview session from the web
app. 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 - [ ] **WEB-04**: User can see learner memory, readiness, and next challenge
after answering. after answering.
- [ ] **WEB-05**: Operator can ingest source material from the web app. - [ ] **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 | | PROG-01..PROG-05 | Phase 4 | Complete |
| ONTO-01..ONTO-04 | Phase 5 | Complete | | ONTO-01..ONTO-04 | Phase 5 | Complete |
| ASSET-01..ASSET-03 | Phase 6 | 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-04 | Phase 8 | Pending |
| WEB-05..WEB-08 | Phase 9 | Pending | | WEB-05..WEB-08 | Phase 9 | Pending |
@@ -128,4 +128,4 @@ interview-ready after each short practice loop.
--- ---
*Requirements defined: 2026-04-26* *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.*

View File

@@ -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 **Core value:** The user should feel and prove that they are becoming more
interview-ready after each short practice loop. 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 ## Current Decisions
@@ -37,10 +37,11 @@ interview-ready after each short practice loop.
tech-debt items recorded in `.planning/v1-MILESTONE-AUDIT.md`. tech-debt items recorded in `.planning/v1-MILESTONE-AUDIT.md`.
- v2 Frontend MVP milestone selected to turn the backend learning loop into a - v2 Frontend MVP milestone selected to turn the backend learning loop into a
usable web service. usable web service.
- Phase 7 web app shell and diagnostic start UI is implemented and verified.
## Next Actions ## 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 2. Verify the production OpenAI image model identifier before real image
generation calls. generation calls.
3. Add standardized SUMMARY frontmatter or Nyquist validation files if future 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. real image generation, and Nyquist validation artifacts remain deferred.
- 2026-04-26: v2 Frontend MVP milestone started with WEB-01..WEB-08 mapped to - 2026-04-26: v2 Frontend MVP milestone started with WEB-01..WEB-08 mapped to
phases 7 through 9. 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.* *State initialized: 2026-04-26.*

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -10,6 +10,7 @@ import (
"tutor/internal/ontology" "tutor/internal/ontology"
"tutor/internal/progression" "tutor/internal/progression"
"tutor/internal/teachingassets" "tutor/internal/teachingassets"
"tutor/internal/webapp"
) )
type Handler struct { type Handler struct {
@@ -52,6 +53,7 @@ func (h Handler) Routes() http.Handler {
mux.HandleFunc("GET /api/v1/ontology", h.getOntology) mux.HandleFunc("GET /api/v1/ontology", h.getOntology)
mux.HandleFunc("POST /api/v1/teaching-assets/prompts", h.generateTeachingAssetPrompt) mux.HandleFunc("POST /api/v1/teaching-assets/prompts", h.generateTeachingAssetPrompt)
mux.HandleFunc("GET /api/v1/teaching-assets", h.getTeachingAssets) mux.HandleFunc("GET /api/v1/teaching-assets", h.getTeachingAssets)
mux.Handle("GET /", webapp.Handler())
return mux return mux
} }

View File

@@ -50,3 +50,28 @@ func TestHealth(t *testing.T) {
t.Fatalf("body.ModelKey = %q", body.ModelKey) 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"))
}
}

41
internal/webapp/assets.go Normal file
View File

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

View File

@@ -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")
}
}

View File

@@ -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 = `<span class="question-id">${question.id}</span>${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 = `
<div>
<div class="grade">${escapeHTML(grade.overall)}</div>
<p class="status-line">${escapeHTML(grade.strengths?.[0] || "Answer was graded.")}</p>
</div>
${scoreRows(grade.scores)}
${listBlock("Gaps", grade.gaps)}
${followUpBlock(grade.follow_up)}
${evidenceBlock(grade.evidence)}
`;
}
function scoreRows(scores) {
return Object.entries(scores || {})
.map(([label, score]) => `
<div class="metric-row">
<span>${escapeHTML(label.replaceAll("_", " "))}</span>
<strong>${score}/4</strong>
</div>
`)
.join("");
}
function listBlock(title, items = []) {
if (!items.length) return "";
return `<section><h2>${title}</h2><ul class="small-list">${items.map((item) => `<li>${escapeHTML(item)}</li>`).join("")}</ul></section>`;
}
function followUpBlock(followUp) {
if (!followUp?.needed) return "";
return `<section><h2>Follow-up</h2><p class="status-line">${escapeHTML(followUp.question)}</p></section>`;
}
function evidenceBlock(evidence = []) {
if (!evidence.length) return "";
return `<section><h2>Evidence</h2><ul class="small-list">${evidence.map((item) => `<li>${escapeHTML(item.quote || item.id)}</li>`).join("")}</ul></section>`;
}
async function request(url, options = {}) {
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
...options,
});
const body = await response.json();
if (!response.ok) {
throw new Error(body.error || `Request failed: ${response.status}`);
}
return body;
}
function value(selector) {
return document.querySelector(selector).value.trim();
}
function setStatus(message) {
els.status.textContent = message;
}
function showError(message) {
els.error.textContent = message;
}
function clearError() {
els.error.textContent = "";
}
function escapeHTML(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}

View File

@@ -0,0 +1,68 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tutor Platform</title>
<link rel="stylesheet" href="/assets/styles.css" />
</head>
<body>
<main class="workspace">
<aside class="setup-pane" aria-label="Diagnostic setup">
<p class="eyebrow">Tutor Platform</p>
<h1>Interview practice</h1>
<p class="lede">Start a focused backend interview loop and turn one answer into evidence.</p>
<form id="session-form" class="stacked-form">
<label>
User ID
<input id="user-id" name="user_id" value="demo-user" autocomplete="off" />
</label>
<label>
Target role
<input id="target-role" name="target_role" value="junior backend developer" />
</label>
<label>
Stack
<input id="stack" name="stack" value="go, postgres" />
</label>
<label>
Timeline
<input id="timeline" name="interview_timeline" value="30 days" />
</label>
<button id="start-button" type="submit">Start diagnostic</button>
</form>
<p id="status-line" class="status-line" role="status">Ready</p>
<p id="error-line" class="error-line" role="alert"></p>
</aside>
<section class="practice-pane" aria-label="Diagnostic practice">
<div class="section-heading">
<p class="eyebrow">Diagnostic</p>
<h2 id="session-title">No active session</h2>
</div>
<div id="questions" class="question-list empty-state">
Start a diagnostic session to load interview questions.
</div>
<form id="answer-form" class="answer-form">
<label for="answer-text">Answer</label>
<textarea id="answer-text" rows="7" placeholder="Select a question, then answer with concrete production reasoning."></textarea>
<button id="answer-button" type="submit" disabled>Submit answer</button>
</form>
</section>
<aside class="feedback-pane" aria-label="Feedback">
<div class="section-heading">
<p class="eyebrow">Feedback</p>
<h2>Rubric result</h2>
</div>
<div id="feedback" class="feedback empty-state">
Submit an answer to see grade, evidence, and follow-up.
</div>
</aside>
</main>
<script src="/assets/app.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,232 @@
:root {
color-scheme: light;
--bg: #f5f7f4;
--surface: #ffffff;
--surface-muted: #eef2ec;
--text: #18201b;
--muted: #5b665f;
--line: #d8dfd5;
--accent: #19764b;
--accent-dark: #105c39;
--danger: #a93a2f;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
button,
input,
textarea {
font: inherit;
}
.workspace {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(360px, 1fr) minmax(280px, 360px);
gap: 1px;
min-height: 100vh;
background: var(--line);
}
.setup-pane,
.practice-pane,
.feedback-pane {
background: var(--surface);
padding: 28px;
}
.practice-pane {
display: flex;
flex-direction: column;
gap: 22px;
}
.eyebrow {
margin: 0 0 10px;
color: var(--accent);
font-size: 12px;
font-weight: 750;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
line-height: 1.08;
}
h1 {
max-width: 9ch;
font-size: clamp(42px, 6vw, 74px);
}
h2 {
font-size: 22px;
}
.lede {
max-width: 28ch;
margin: 18px 0 30px;
color: var(--muted);
line-height: 1.5;
}
.stacked-form,
.answer-form {
display: grid;
gap: 14px;
}
label {
display: grid;
gap: 7px;
color: var(--muted);
font-size: 13px;
font-weight: 650;
}
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
background: #fbfcfa;
color: var(--text);
padding: 12px;
outline: none;
}
textarea {
min-height: 160px;
resize: vertical;
line-height: 1.45;
}
input:focus,
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(25, 118, 75, 0.12);
}
button {
min-height: 44px;
border: 0;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
font-weight: 750;
}
button:hover:not(:disabled) {
background: var(--accent-dark);
}
button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.status-line,
.error-line {
min-height: 20px;
margin: 18px 0 0;
font-size: 13px;
}
.status-line {
color: var(--muted);
}
.error-line {
color: var(--danger);
}
.section-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
}
.question-list {
display: grid;
gap: 10px;
}
.question-button {
border: 1px solid var(--line);
background: #fbfcfa;
color: var(--text);
padding: 16px;
min-height: 72px;
text-align: left;
}
.question-button[aria-pressed="true"] {
border-color: var(--accent);
background: var(--surface-muted);
}
.question-id {
display: block;
margin-bottom: 5px;
color: var(--accent);
font-size: 12px;
font-weight: 750;
}
.empty-state {
border: 1px dashed var(--line);
border-radius: 6px;
color: var(--muted);
padding: 18px;
}
.feedback {
display: grid;
gap: 16px;
margin-top: 22px;
}
.metric-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.grade {
color: var(--accent);
font-size: 38px;
font-weight: 800;
}
.small-list {
margin: 0;
padding-left: 18px;
color: var(--muted);
line-height: 1.45;
}
@media (max-width: 980px) {
.workspace {
grid-template-columns: 1fr;
}
h1 {
max-width: 100%;
font-size: 42px;
}
}

View File

@@ -1,7 +1,7 @@
# Tasks # Tasks
- [ ] 1. Implement web app shell served by the Go backend. - [x] 1. Implement web app shell served by the Go backend.
- [ ] 2. Implement diagnostic session start and answer submission UI. - [x] 2. Implement diagnostic session start and answer submission UI.
- [ ] 3. Implement learner memory, readiness, and next challenge UI. - [ ] 3. Implement learner memory, readiness, and next challenge UI.
- [ ] 4. Implement material ingestion and ontology inspection UI. - [ ] 4. Implement material ingestion and ontology inspection UI.
- [ ] 5. Implement teaching asset prompt candidate UI. - [ ] 5. Implement teaching asset prompt candidate UI.