feat: add diagnostic web app shell
This commit is contained in:
@@ -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.*
|
||||||
|
|||||||
@@ -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.*
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
41
internal/webapp/assets.go
Normal 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)
|
||||||
|
}
|
||||||
36
internal/webapp/assets_test.go
Normal file
36
internal/webapp/assets_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
181
internal/webapp/static/app.js
Normal file
181
internal/webapp/static/app.js
Normal 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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
68
internal/webapp/static/index.html
Normal file
68
internal/webapp/static/index.html
Normal 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>
|
||||||
232
internal/webapp/static/styles.css
Normal file
232
internal/webapp/static/styles.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user