2026-04-26 18:39:09 +09:00
|
|
|
const state = {
|
|
|
|
|
session: null,
|
|
|
|
|
selectedQuestion: null,
|
|
|
|
|
lastAnswer: null,
|
2026-04-26 18:41:13 +09:00
|
|
|
progress: null,
|
2026-04-26 18:52:16 +09:00
|
|
|
ontology: null,
|
|
|
|
|
assetPrompt: null,
|
2026-04-26 18:39:09 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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"),
|
2026-04-26 18:41:13 +09:00
|
|
|
progress: document.querySelector("#progress"),
|
|
|
|
|
refreshProgress: document.querySelector("#refresh-progress"),
|
2026-04-26 18:52:16 +09:00
|
|
|
materialForm: document.querySelector("#material-form"),
|
|
|
|
|
assetForm: document.querySelector("#asset-form"),
|
|
|
|
|
ontology: document.querySelector("#ontology"),
|
|
|
|
|
assetOutput: document.querySelector("#asset-output"),
|
|
|
|
|
assetConcept: document.querySelector("#asset-concept"),
|
|
|
|
|
assetButton: document.querySelector("#asset-button"),
|
2026-04-26 18:39:09 +09:00
|
|
|
status: document.querySelector("#status-line"),
|
|
|
|
|
error: document.querySelector("#error-line"),
|
|
|
|
|
title: document.querySelector("#session-title"),
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-27 11:33:20 +09:00
|
|
|
const readinessClassMap = {
|
|
|
|
|
unknown: "pill-neutral",
|
|
|
|
|
fragile: "pill-weak",
|
|
|
|
|
improving: "pill-warn",
|
|
|
|
|
interview_ready: "pill-good",
|
|
|
|
|
strong_signal: "pill-strong",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const reviewClassMap = {
|
|
|
|
|
candidate: "pill-neutral",
|
|
|
|
|
reviewed: "pill-good",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const gradeClassMap = {
|
|
|
|
|
miss: "grade-miss",
|
|
|
|
|
partial: "grade-partial",
|
|
|
|
|
solid: "grade-solid",
|
|
|
|
|
strong: "grade-strong",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function setButtonLoading(button, loadingText) {
|
|
|
|
|
button.disabled = true;
|
|
|
|
|
button.classList.add("is-loading");
|
|
|
|
|
const textEl = button.querySelector(".btn-text");
|
|
|
|
|
if (textEl && loadingText) {
|
|
|
|
|
textEl.dataset.originalText = textEl.textContent;
|
|
|
|
|
textEl.textContent = loadingText;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearButtonLoading(button) {
|
|
|
|
|
button.disabled = false;
|
|
|
|
|
button.classList.remove("is-loading");
|
|
|
|
|
const textEl = button.querySelector(".btn-text");
|
|
|
|
|
if (textEl && textEl.dataset.originalText) {
|
|
|
|
|
textEl.textContent = textEl.dataset.originalText;
|
|
|
|
|
delete textEl.dataset.originalText;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 18:39:09 +09:00
|
|
|
els.sessionForm.addEventListener("submit", async (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
clearError();
|
2026-04-27 11:33:20 +09:00
|
|
|
setStatus("Creating diagnostic session...", true);
|
|
|
|
|
setButtonLoading(event.submitter || document.querySelector("#start-button"), "Starting...");
|
2026-04-26 18:39:09 +09:00
|
|
|
|
|
|
|
|
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();
|
2026-04-26 18:52:16 +09:00
|
|
|
renderProgress();
|
2026-04-26 18:39:09 +09:00
|
|
|
setStatus(`Session ${session.id} ready`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
showError(error.message);
|
|
|
|
|
setStatus("Ready");
|
2026-04-27 11:33:20 +09:00
|
|
|
} finally {
|
|
|
|
|
clearButtonLoading(document.querySelector("#start-button"));
|
2026-04-26 18:39:09 +09:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-26 18:41:13 +09:00
|
|
|
els.refreshProgress.addEventListener("click", async () => {
|
|
|
|
|
clearError();
|
|
|
|
|
await refreshProgress();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-26 18:39:09 +09:00
|
|
|
els.answerForm.addEventListener("submit", async (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
clearError();
|
|
|
|
|
if (!state.session || !state.selectedQuestion) return;
|
|
|
|
|
|
2026-04-27 11:33:20 +09:00
|
|
|
setStatus("Submitting answer...", true);
|
|
|
|
|
setButtonLoading(event.submitter || els.answerButton, "Grading...");
|
2026-04-26 18:39:09 +09:00
|
|
|
|
|
|
|
|
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();
|
2026-04-26 18:41:13 +09:00
|
|
|
await refreshProgress();
|
2026-04-26 18:39:09 +09:00
|
|
|
setStatus(`Answer graded as ${answer.grade.overall}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
showError(error.message);
|
|
|
|
|
setStatus("Session ready");
|
|
|
|
|
} finally {
|
2026-04-27 11:33:20 +09:00
|
|
|
clearButtonLoading(els.answerButton);
|
2026-04-26 18:39:09 +09:00
|
|
|
els.answerButton.disabled = !state.selectedQuestion;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-26 18:52:16 +09:00
|
|
|
els.materialForm.addEventListener("submit", async (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
clearError();
|
2026-04-27 11:33:20 +09:00
|
|
|
setStatus("Ingesting material...", true);
|
|
|
|
|
setButtonLoading(event.submitter || document.querySelector("#material-button"), "Ingesting...");
|
2026-04-26 18:52:16 +09:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await request("/api/v1/materials", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
title: value("#material-title"),
|
|
|
|
|
source_type: value("#material-source"),
|
|
|
|
|
body: value("#material-body"),
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
state.ontology = result.snapshot;
|
|
|
|
|
renderOntology();
|
|
|
|
|
setStatus(`Material ${result.material.id} ingested`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
showError(error.message);
|
|
|
|
|
setStatus("Content workspace ready");
|
2026-04-27 11:33:20 +09:00
|
|
|
} finally {
|
|
|
|
|
clearButtonLoading(document.querySelector("#material-button"));
|
2026-04-26 18:52:16 +09:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
els.assetForm.addEventListener("submit", async (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
clearError();
|
2026-04-27 11:33:20 +09:00
|
|
|
setStatus("Generating prompt candidate...", true);
|
|
|
|
|
setButtonLoading(event.submitter || els.assetButton, "Generating...");
|
2026-04-26 18:52:16 +09:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const prompt = await request("/api/v1/teaching-assets/prompts", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
concept_id: els.assetConcept.value,
|
|
|
|
|
asset_type: value("#asset-type"),
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
state.assetPrompt = prompt;
|
|
|
|
|
renderAssetPrompt();
|
|
|
|
|
setStatus(`Prompt ${prompt.id} generated`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
showError(error.message);
|
|
|
|
|
setStatus("Content workspace ready");
|
2026-04-27 11:33:20 +09:00
|
|
|
} finally {
|
|
|
|
|
clearButtonLoading(els.assetButton);
|
2026-04-26 18:52:16 +09:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-26 18:39:09 +09:00
|
|
|
function renderSession() {
|
|
|
|
|
if (!state.session) return;
|
2026-04-27 11:33:20 +09:00
|
|
|
els.title.textContent = `${state.session.target_role} — ${state.session.questions.length} questions`;
|
2026-04-26 18:39:09 +09:00
|
|
|
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));
|
2026-04-27 11:33:20 +09:00
|
|
|
button.innerHTML = `<span class="question-id">${escapeHTML(question.id)}</span>${escapeHTML(question.prompt)}`;
|
2026-04-26 18:39:09 +09:00
|
|
|
button.addEventListener("click", () => {
|
|
|
|
|
state.selectedQuestion = question;
|
|
|
|
|
els.answerText.value = "";
|
|
|
|
|
renderSession();
|
|
|
|
|
setStatus(`Selected ${question.id}`);
|
|
|
|
|
});
|
|
|
|
|
els.questions.append(button);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
els.answerButton.disabled = !state.selectedQuestion;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 18:41:13 +09:00
|
|
|
async function refreshProgress() {
|
|
|
|
|
if (!state.session) return;
|
2026-04-27 11:33:20 +09:00
|
|
|
setStatus("Refreshing learning progress...", true);
|
2026-04-26 18:41:13 +09:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const userID = encodeURIComponent(state.session.user_id);
|
|
|
|
|
const [memory, readiness, challenge] = await Promise.all([
|
|
|
|
|
request(`/api/v1/learners/${userID}/memory`),
|
|
|
|
|
request(`/api/v1/learners/${userID}/readiness-map`),
|
|
|
|
|
request(`/api/v1/learners/${userID}/next-challenge`),
|
|
|
|
|
]);
|
|
|
|
|
state.progress = { memory, readiness, challenge };
|
|
|
|
|
renderProgress();
|
|
|
|
|
setStatus("Learning progress updated");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
showError(error.message);
|
|
|
|
|
renderProgress();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderProgress() {
|
|
|
|
|
els.refreshProgress.disabled = !state.session;
|
|
|
|
|
if (!state.progress) {
|
|
|
|
|
els.progress.className = "feedback empty-state";
|
2026-04-27 11:33:20 +09:00
|
|
|
els.progress.innerHTML = `<span class="empty-hint">Answer once to update learner memory and readiness.</span>`;
|
2026-04-26 18:41:13 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { memory, readiness, challenge } = state.progress;
|
|
|
|
|
const mastery = memory.mastery || [];
|
|
|
|
|
els.progress.className = "feedback";
|
|
|
|
|
els.progress.innerHTML = `
|
|
|
|
|
<section>
|
|
|
|
|
<div class="readiness-value">${readiness.readiness_percentage}%</div>
|
|
|
|
|
<p class="status-line">${escapeHTML(memory.profile.target_role)} readiness</p>
|
|
|
|
|
</section>
|
|
|
|
|
<section>
|
|
|
|
|
<h2>Concept memory</h2>
|
2026-04-27 11:33:20 +09:00
|
|
|
<div>${mastery.map((item) => {
|
|
|
|
|
const cls = readinessClassMap[item.state] || "pill-neutral";
|
|
|
|
|
return `<span class="concept-pill ${cls}">${escapeHTML(item.concept.label)} — ${escapeHTML(item.state)}</span>`;
|
|
|
|
|
}).join("")}</div>
|
2026-04-26 18:41:13 +09:00
|
|
|
</section>
|
|
|
|
|
<section>
|
|
|
|
|
<h2>Next challenge</h2>
|
2026-04-27 11:33:20 +09:00
|
|
|
<p class="status-line">${escapeHTML(challenge.concept.label)} — ${escapeHTML(challenge.ladder_level)}</p>
|
2026-04-26 18:41:13 +09:00
|
|
|
<p>${escapeHTML(challenge.question)}</p>
|
|
|
|
|
</section>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 18:52:16 +09:00
|
|
|
function renderOntology() {
|
|
|
|
|
if (!state.ontology) {
|
|
|
|
|
els.ontology.className = "ontology-view empty-state";
|
2026-04-27 11:33:20 +09:00
|
|
|
els.ontology.innerHTML = `<span class="empty-hint">Ingest material to inspect ontology candidates.</span>`;
|
2026-04-26 18:52:16 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const concepts = state.ontology.concepts || [];
|
|
|
|
|
els.ontology.className = "ontology-view";
|
|
|
|
|
els.ontology.innerHTML = `
|
|
|
|
|
<div class="summary-strip">
|
|
|
|
|
<span class="summary-chip">${concepts.length} concepts</span>
|
|
|
|
|
<span class="summary-chip">${(state.ontology.edges || []).length} edges</span>
|
|
|
|
|
<span class="summary-chip">${(state.ontology.gaps || []).length} gaps</span>
|
|
|
|
|
</div>
|
|
|
|
|
<section>
|
|
|
|
|
<h2>Candidate concepts</h2>
|
2026-04-27 11:33:20 +09:00
|
|
|
<div>${concepts.map((item) => {
|
|
|
|
|
const cls = reviewClassMap[item.review_state] || "pill-neutral";
|
|
|
|
|
return `<span class="concept-pill ${cls}">${escapeHTML(item.concept.label)} — ${escapeHTML(item.review_state)}</span>`;
|
|
|
|
|
}).join("") || "No candidates yet."}</div>
|
2026-04-26 18:52:16 +09:00
|
|
|
</section>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
els.assetConcept.innerHTML = concepts
|
|
|
|
|
.map((item) => `<option value="${escapeHTML(item.concept.id)}">${escapeHTML(item.concept.label)}</option>`)
|
|
|
|
|
.join("");
|
|
|
|
|
els.assetConcept.disabled = concepts.length === 0;
|
|
|
|
|
els.assetButton.disabled = concepts.length === 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderAssetPrompt() {
|
|
|
|
|
if (!state.assetPrompt) {
|
|
|
|
|
els.assetOutput.className = "ontology-view empty-state";
|
2026-04-27 11:33:20 +09:00
|
|
|
els.assetOutput.innerHTML = `<span class="empty-hint">Generate a prompt to inspect model key, review state, and evidence.</span>`;
|
2026-04-26 18:52:16 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const prompt = state.assetPrompt;
|
|
|
|
|
els.assetOutput.className = "ontology-view";
|
|
|
|
|
els.assetOutput.innerHTML = `
|
|
|
|
|
<div class="summary-strip">
|
|
|
|
|
<span class="summary-chip">${escapeHTML(prompt.model_key)}</span>
|
|
|
|
|
<span class="summary-chip">${escapeHTML(prompt.review_state)}</span>
|
|
|
|
|
<span class="summary-chip">verify model id: ${prompt.requires_model_id_verification ? "yes" : "no"}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<pre class="prompt-text">${escapeHTML(prompt.prompt)}</pre>
|
|
|
|
|
${evidenceBlock(prompt.source_evidence)}
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 18:39:09 +09:00
|
|
|
function renderFeedback() {
|
|
|
|
|
if (!state.lastAnswer) {
|
|
|
|
|
els.feedback.className = "feedback empty-state";
|
2026-04-27 11:33:20 +09:00
|
|
|
els.feedback.innerHTML = `<span class="empty-hint">Submit an answer to see grade, evidence, and follow-up.</span>`;
|
2026-04-26 18:39:09 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const grade = state.lastAnswer.grade;
|
2026-04-27 11:33:20 +09:00
|
|
|
const gradeClass = gradeClassMap[grade.overall] || "";
|
2026-04-26 18:39:09 +09:00
|
|
|
els.feedback.className = "feedback";
|
|
|
|
|
els.feedback.innerHTML = `
|
|
|
|
|
<div>
|
2026-04-27 11:33:20 +09:00
|
|
|
<div class="grade ${gradeClass}">${escapeHTML(grade.overall)}</div>
|
2026-04-26 18:39:09 +09:00
|
|
|
<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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 11:33:20 +09:00
|
|
|
function setStatus(message, busy = false) {
|
|
|
|
|
const textEl = els.status.querySelector(".status-text");
|
|
|
|
|
if (textEl) textEl.textContent = message;
|
|
|
|
|
else els.status.textContent = message;
|
|
|
|
|
els.status.classList.toggle("is-busy", busy);
|
2026-04-26 18:39:09 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showError(message) {
|
|
|
|
|
els.error.textContent = message;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearError() {
|
|
|
|
|
els.error.textContent = "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeHTML(value) {
|
|
|
|
|
return String(value)
|
|
|
|
|
.replaceAll("&", "&")
|
|
|
|
|
.replaceAll("<", "<")
|
|
|
|
|
.replaceAll(">", ">")
|
|
|
|
|
.replaceAll('"', """)
|
|
|
|
|
.replaceAll("'", "'");
|
|
|
|
|
}
|