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

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

View File

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

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