diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js
index b215fcc..839506c 100644
--- a/internal/webapp/static/app.js
+++ b/internal/webapp/static/app.js
@@ -75,13 +75,19 @@ function clearButtonLoading(button) {
els.sessionForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
- setStatus("Creating diagnostic session...", true);
- setButtonLoading(event.submitter || document.querySelector("#start-button"), "Starting...");
+ setStatus(t("creatingSession"), true);
+ setButtonLoading(
+ event.submitter || document.querySelector("#start-button"),
+ t("starting")
+ );
const payload = {
user_id: value("#user-id"),
target_role: value("#target-role"),
- stack: value("#stack").split(",").map((item) => item.trim()).filter(Boolean),
+ stack: value("#stack")
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean),
interview_timeline: value("#timeline"),
};
@@ -96,10 +102,10 @@ els.sessionForm.addEventListener("submit", async (event) => {
renderSession();
renderFeedback();
renderProgress();
- setStatus(`Session ${session.id} ready`);
+ setStatus(t("sessionReady", session.id));
} catch (error) {
showError(error.message);
- setStatus("Ready");
+ setStatus(t("ready"));
} finally {
clearButtonLoading(document.querySelector("#start-button"));
}
@@ -115,24 +121,27 @@ els.answerForm.addEventListener("submit", async (event) => {
clearError();
if (!state.session || !state.selectedQuestion) return;
- setStatus("Submitting answer...", true);
- setButtonLoading(event.submitter || els.answerButton, "Grading...");
+ setStatus(t("submittingAnswer"), true);
+ setButtonLoading(event.submitter || els.answerButton, t("grading"));
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,
- }),
- });
+ 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();
await refreshProgress();
- setStatus(`Answer graded as ${answer.grade.overall}`);
+ setStatus(t("answerGraded", answer.grade.overall));
} catch (error) {
showError(error.message);
- setStatus("Session ready");
+ setStatus(t("sessionReadyShort"));
} finally {
clearButtonLoading(els.answerButton);
els.answerButton.disabled = !state.selectedQuestion;
@@ -142,8 +151,11 @@ els.answerForm.addEventListener("submit", async (event) => {
els.materialForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
- setStatus("Ingesting material...", true);
- setButtonLoading(event.submitter || document.querySelector("#material-button"), "Ingesting...");
+ setStatus(t("ingestingMaterial"), true);
+ setButtonLoading(
+ event.submitter || document.querySelector("#material-button"),
+ t("ingesting")
+ );
try {
const result = await request("/api/v1/materials", {
@@ -156,10 +168,10 @@ els.materialForm.addEventListener("submit", async (event) => {
});
state.ontology = result.snapshot;
renderOntology();
- setStatus(`Material ${result.material.id} ingested`);
+ setStatus(t("materialIngested", result.material.id));
} catch (error) {
showError(error.message);
- setStatus("Content workspace ready");
+ setStatus(t("contentReady"));
} finally {
clearButtonLoading(document.querySelector("#material-button"));
}
@@ -168,8 +180,8 @@ els.materialForm.addEventListener("submit", async (event) => {
els.assetForm.addEventListener("submit", async (event) => {
event.preventDefault();
clearError();
- setStatus("Generating prompt candidate...", true);
- setButtonLoading(event.submitter || els.assetButton, "Generating...");
+ setStatus(t("generatingPrompt"), true);
+ setButtonLoading(event.submitter || els.assetButton, t("generating"));
try {
const prompt = await request("/api/v1/teaching-assets/prompts", {
@@ -181,10 +193,10 @@ els.assetForm.addEventListener("submit", async (event) => {
});
state.assetPrompt = prompt;
renderAssetPrompt();
- setStatus(`Prompt ${prompt.id} generated`);
+ setStatus(t("promptGenerated", prompt.id));
} catch (error) {
showError(error.message);
- setStatus("Content workspace ready");
+ setStatus(t("contentReady"));
} finally {
clearButtonLoading(els.assetButton);
}
@@ -192,7 +204,7 @@ els.assetForm.addEventListener("submit", async (event) => {
function renderSession() {
if (!state.session) return;
- els.title.textContent = `${state.session.target_role} — ${state.session.questions.length} questions`;
+ els.title.textContent = `${state.session.target_role} — ${state.session.questions.length} ${t("questionsSuffix")}`;
els.questions.className = "question-list";
els.questions.innerHTML = "";
@@ -200,13 +212,16 @@ function renderSession() {
const button = document.createElement("button");
button.type = "button";
button.className = "question-button";
- button.setAttribute("aria-pressed", String(state.selectedQuestion?.id === question.id));
+ button.setAttribute(
+ "aria-pressed",
+ String(state.selectedQuestion?.id === question.id)
+ );
button.innerHTML = `${escapeHTML(question.id)}${escapeHTML(question.prompt)}`;
button.addEventListener("click", () => {
state.selectedQuestion = question;
els.answerText.value = "";
renderSession();
- setStatus(`Selected ${question.id}`);
+ setStatus(t("selected", question.id));
});
els.questions.append(button);
});
@@ -216,7 +231,7 @@ function renderSession() {
async function refreshProgress() {
if (!state.session) return;
- setStatus("Refreshing learning progress...", true);
+ setStatus(t("refreshingProgress"), true);
try {
const userID = encodeURIComponent(state.session.user_id);
@@ -227,7 +242,7 @@ async function refreshProgress() {
]);
state.progress = { memory, readiness, challenge };
renderProgress();
- setStatus("Learning progress updated");
+ setStatus(t("progressUpdated"));
} catch (error) {
showError(error.message);
renderProgress();
@@ -238,7 +253,7 @@ function renderProgress() {
els.refreshProgress.disabled = !state.session;
if (!state.progress) {
els.progress.className = "feedback empty-state";
- els.progress.innerHTML = `Answer once to update learner memory and readiness.`;
+ els.progress.innerHTML = `${t("emptyProgress")}`;
return;
}
@@ -248,17 +263,19 @@ function renderProgress() {
els.progress.innerHTML = `
${readiness.readiness_percentage}%
- ${escapeHTML(memory.profile.target_role)} readiness
+ ${escapeHTML(memory.profile.target_role)} ${t("readiness")}
- Concept memory
- ${mastery.map((item) => {
- const cls = readinessClassMap[item.state] || "pill-neutral";
- return `${escapeHTML(item.concept.label)} — ${escapeHTML(item.state)}`;
- }).join("")}
+ ${t("conceptMemory")}
+ ${mastery
+ .map((item) => {
+ const cls = readinessClassMap[item.state] || "pill-neutral";
+ return `${escapeHTML(item.concept.label)} — ${escapeHTML(item.state)}`;
+ })
+ .join("")}
- Next challenge
+ ${t("nextChallenge")}
${escapeHTML(challenge.concept.label)} — ${escapeHTML(challenge.ladder_level)}
${escapeHTML(challenge.question)}
@@ -268,7 +285,7 @@ function renderProgress() {
function renderOntology() {
if (!state.ontology) {
els.ontology.className = "ontology-view empty-state";
- els.ontology.innerHTML = `Ingest material to inspect ontology candidates.`;
+ els.ontology.innerHTML = `${t("emptyOntology")}`;
return;
}
@@ -276,21 +293,26 @@ function renderOntology() {
els.ontology.className = "ontology-view";
els.ontology.innerHTML = `
- ${concepts.length} concepts
- ${(state.ontology.edges || []).length} edges
- ${(state.ontology.gaps || []).length} gaps
+ ${concepts.length} ${t("conceptsSuffix")}
+ ${(state.ontology.edges || []).length} ${t("edgesSuffix")}
+ ${(state.ontology.gaps || []).length} ${t("gapsSuffix")}
- Candidate concepts
- ${concepts.map((item) => {
- const cls = reviewClassMap[item.review_state] || "pill-neutral";
- return `${escapeHTML(item.concept.label)} — ${escapeHTML(item.review_state)}`;
- }).join("") || "No candidates yet."}
+ ${t("candidateConcepts")}
+ ${concepts
+ .map((item) => {
+ const cls = reviewClassMap[item.review_state] || "pill-neutral";
+ return `${escapeHTML(item.concept.label)} — ${escapeHTML(item.review_state)}`;
+ })
+ .join("") || t("noCandidates")}
`;
els.assetConcept.innerHTML = concepts
- .map((item) => ``)
+ .map(
+ (item) =>
+ ``
+ )
.join("");
els.assetConcept.disabled = concepts.length === 0;
els.assetButton.disabled = concepts.length === 0;
@@ -299,7 +321,7 @@ function renderOntology() {
function renderAssetPrompt() {
if (!state.assetPrompt) {
els.assetOutput.className = "ontology-view empty-state";
- els.assetOutput.innerHTML = `Generate a prompt to inspect model key, review state, and evidence.`;
+ els.assetOutput.innerHTML = `${t("emptyAsset")}`;
return;
}
@@ -309,7 +331,7 @@ function renderAssetPrompt() {
${escapeHTML(prompt.model_key)}
${escapeHTML(prompt.review_state)}
- verify model id: ${prompt.requires_model_id_verification ? "yes" : "no"}
+ ${t("verifyModelId")}: ${prompt.requires_model_id_verification ? t("yes") : t("no")}
${escapeHTML(prompt.prompt)}
${evidenceBlock(prompt.source_evidence)}
@@ -319,7 +341,7 @@ function renderAssetPrompt() {
function renderFeedback() {
if (!state.lastAnswer) {
els.feedback.className = "feedback empty-state";
- els.feedback.innerHTML = `Submit an answer to see grade, evidence, and follow-up.`;
+ els.feedback.innerHTML = `${t("emptyFeedback")}`;
return;
}
@@ -329,10 +351,10 @@ function renderFeedback() {
els.feedback.innerHTML = `
${escapeHTML(grade.overall)}
-
${escapeHTML(grade.strengths?.[0] || "Answer was graded.")}
+
${escapeHTML(grade.strengths?.[0] || t("answerWasGraded"))}
${scoreRows(grade.scores)}
- ${listBlock("Gaps", grade.gaps)}
+ ${listBlock(t("gaps"), grade.gaps)}
${followUpBlock(grade.follow_up)}
${evidenceBlock(grade.evidence)}
`;
@@ -340,12 +362,14 @@ function renderFeedback() {
function scoreRows(scores) {
return Object.entries(scores || {})
- .map(([label, score]) => `
+ .map(
+ ([label, score]) => `
${escapeHTML(label.replaceAll("_", " "))}
${score}/4
- `)
+ `
+ )
.join("");
}
@@ -356,12 +380,12 @@ function listBlock(title, items = []) {
function followUpBlock(followUp) {
if (!followUp?.needed) return "";
- return `Follow-up
${escapeHTML(followUp.question)}
`;
+ return `${t("followUp")}
${escapeHTML(followUp.question)}
`;
}
function evidenceBlock(evidence = []) {
if (!evidence.length) return "";
- return `Evidence
${evidence.map((item) => `- ${escapeHTML(item.quote || item.id)}
`).join("")}
`;
+ return `${t("evidence")}
${evidence.map((item) => `- ${escapeHTML(item.quote || item.id)}
`).join("")}
`;
}
window._tutorGoogleCallback = async (response) => {
@@ -401,7 +425,9 @@ function renderAuth() {
els.loginView.style.display = "none";
els.workspaceView.style.display = "grid";
els.userInfo.textContent = user.email || user.name || "User";
- setStatus(`Signed in as ${user.email || user.name}`);
+ setStatus(t("signedInAs", user.email || user.name));
+ const userIdInput = document.querySelector("#user-id");
+ if (userIdInput) userIdInput.value = user.email || user.id || "";
if (els.loginError) els.loginError.classList.remove("visible");
} else {
els.loginView.style.display = "flex";
@@ -413,7 +439,7 @@ els.logoutButton.addEventListener("click", () => {
localStorage.removeItem("tutor_token");
localStorage.removeItem("tutor_user");
renderAuth();
- setStatus("Signed out");
+ setStatus(t("signedOut"));
});
async function request(url, options = {}) {
@@ -456,4 +482,19 @@ function escapeHTML(value) {
.replaceAll("'", "'");
}
+document.querySelectorAll(".lang-switch").forEach((group) => {
+ group.addEventListener("click", (e) => {
+ if (!e.target.dataset.lang) return;
+ const lang = e.target.dataset.lang;
+ localStorage.setItem("tutor_lang", lang);
+ document.documentElement.lang = lang;
+ updateStaticText();
+ document.querySelectorAll(".lang-btn").forEach((btn) => {
+ btn.classList.toggle("is-active", btn.dataset.lang === lang);
+ });
+ });
+});
+
+updateStaticText();
+window.renderAuth = renderAuth;
renderAuth();
diff --git a/internal/webapp/static/i18n.js b/internal/webapp/static/i18n.js
new file mode 100644
index 0000000..d00b5d4
--- /dev/null
+++ b/internal/webapp/static/i18n.js
@@ -0,0 +1,204 @@
+const i18n = {
+ ko: {
+ eyebrow: "튜터 플랫폼",
+ titleLogin: "면접 연습",
+ subtitleLogin: "짧은 연습 루프를 반복하며 면접 준비도를 높여보세요.",
+ accountDivider: "계정",
+ legalLogin:
+ '로그인하면 이용약관 및 개인정보처리방침에 동의하는 것입니다.',
+ titleWorkspace: "면접 연습",
+ subtitleWorkspace:
+ "백엔드 면접 연습을 시작하고, 하나의 답변을 증거로 만드세요.",
+ userId: "사용자 ID",
+ targetRole: "목표 직무",
+ stack: "기술 스택",
+ timeline: "준비 기간",
+ startDiagnostic: "진단 시작",
+ signOut: "로그아웃",
+ diagnosticEyebrow: "진단",
+ noActiveSession: "활성 세션 없음",
+ emptyQuestions: "면접 질문을 불러오려면 진단 세션을 시작하세요.",
+ answerLabel: "답변",
+ answerPlaceholder:
+ "질문을 선택한 후, 구체적인 프로덕션 관점에서 답변하세요.",
+ submitAnswer: "답변 제출",
+ contentEyebrow: "콘텐츠 작업",
+ contentTitle: "소스 → 에셋 프롬프트",
+ materialTitle: "자료 제목",
+ sourceType: "소스 유형",
+ sourceMaterial: "소스 자료",
+ ingestMaterial: "자료 수집",
+ emptyOntology: "자료를 수집하면 개념 후보를 확인할 수 있습니다.",
+ concept: "개념",
+ assetType: "에셋 유형",
+ generatePrompt: "프롬프트 생성",
+ emptyAsset:
+ "프롬프트를 생성하면 모델 키, 검토 상태, 근거를 확인할 수 있습니다.",
+ feedbackEyebrow: "피드백",
+ rubricResult: "채점 결과",
+ emptyFeedback:
+ "답변을 제출하면 등급, 근거, 후속 질문을 확인할 수 있습니다.",
+ progressEyebrow: "진행 상황",
+ learningState: "학습 상태",
+ emptyProgress:
+ "답변을 제출하면 학습자 메모리와 준비도가 업데이트됩니다.",
+ refresh: "새로고침",
+ ready: "준비 완료",
+ creatingSession: "진단 세션 생성 중…",
+ sessionReady: (id) => `세션 ${id} 준비 완료`,
+ submittingAnswer: "답변 제출 중…",
+ answerGraded: (grade) => `답변 등급: ${grade}`,
+ ingestingMaterial: "자료 수집 중…",
+ materialIngested: (id) => `자료 ${id} 수집 완료`,
+ generatingPrompt: "프롬프트 생성 중…",
+ promptGenerated: (id) => `프롬프트 ${id} 생성 완료`,
+ refreshingProgress: "학습 진행 상황 새로고침 중…",
+ progressUpdated: "학습 진행 상황 업데이트 완료",
+ selected: (id) => `${id} 선택됨`,
+ contentReady: "콘텐츠 작업 공간 준비 완료",
+ sessionReadyShort: "세션 준비 완료",
+ signedInAs: (email) => `${email}님으로 로그인됨`,
+ signedOut: "로그아웃됨",
+ followUp: "후속 질문",
+ evidence: "근거",
+ gaps: "부족한 점",
+ conceptMemory: "개념 메모리",
+ nextChallenge: "다음 도전",
+ readiness: "준비도",
+ modelKey: "모델 키",
+ reviewState: "검토 상태",
+ verifyModelId: "모델 ID 확인 필요",
+ yes: "예",
+ no: "아니오",
+ questionId: "질문 ID",
+ starting: "시작 중…",
+ grading: "채점 중…",
+ ingesting: "수집 중…",
+ generating: "생성 중…",
+ questionsSuffix: "개 질문",
+ conceptsSuffix: "개 개념",
+ edgesSuffix: "개 엣지",
+ gapsSuffix: "개 갭",
+ candidateConcepts: "후보 개념",
+ noCandidates: "아직 후보가 없습니다.",
+ answerWasGraded: "답변이 채점되었습니다.",
+ score: (label, val) => `${label} — ${val}/4`,
+ },
+ en: {
+ eyebrow: "Tutor Platform",
+ titleLogin: "Interview practice",
+ subtitleLogin:
+ "Prove you are becoming more interview-ready after each short practice loop.",
+ accountDivider: "Account",
+ legalLogin:
+ 'By signing in, you agree to our Terms and Privacy Policy.',
+ titleWorkspace: "Interview practice",
+ subtitleWorkspace:
+ "Start a focused backend interview loop and turn one answer into evidence.",
+ userId: "User ID",
+ targetRole: "Target role",
+ stack: "Stack",
+ timeline: "Timeline",
+ startDiagnostic: "Start diagnostic",
+ signOut: "Sign out",
+ diagnosticEyebrow: "Diagnostic",
+ noActiveSession: "No active session",
+ emptyQuestions: "Start a diagnostic session to load interview questions.",
+ answerLabel: "Answer",
+ answerPlaceholder:
+ "Select a question, then answer with concrete production reasoning.",
+ submitAnswer: "Submit answer",
+ contentEyebrow: "Content operations",
+ contentTitle: "Source to asset prompt",
+ materialTitle: "Material title",
+ sourceType: "Source type",
+ sourceMaterial: "Source material",
+ ingestMaterial: "Ingest material",
+ emptyOntology: "Ingest material to inspect ontology candidates.",
+ concept: "Concept",
+ assetType: "Asset type",
+ generatePrompt: "Generate prompt",
+ emptyAsset:
+ "Generate a prompt to inspect model key, review state, and evidence.",
+ feedbackEyebrow: "Feedback",
+ rubricResult: "Rubric result",
+ emptyFeedback:
+ "Submit an answer to see grade, evidence, and follow-up.",
+ progressEyebrow: "Progress",
+ learningState: "Learning state",
+ emptyProgress:
+ "Answer once to update learner memory and readiness.",
+ refresh: "Refresh",
+ ready: "Ready",
+ creatingSession: "Creating diagnostic session…",
+ sessionReady: (id) => `Session ${id} ready`,
+ submittingAnswer: "Submitting answer…",
+ answerGraded: (grade) => `Answer graded as ${grade}`,
+ ingestingMaterial: "Ingesting material…",
+ materialIngested: (id) => `Material ${id} ingested`,
+ generatingPrompt: "Generating prompt candidate…",
+ promptGenerated: (id) => `Prompt ${id} generated`,
+ refreshingProgress: "Refreshing learning progress…",
+ progressUpdated: "Learning progress updated",
+ selected: (id) => `Selected ${id}`,
+ contentReady: "Content workspace ready",
+ sessionReadyShort: "Session ready",
+ signedInAs: (email) => `Signed in as ${email}`,
+ signedOut: "Signed out",
+ followUp: "Follow-up",
+ evidence: "Evidence",
+ gaps: "Gaps",
+ conceptMemory: "Concept memory",
+ nextChallenge: "Next challenge",
+ readiness: "readiness",
+ modelKey: "Model key",
+ reviewState: "Review state",
+ verifyModelId: "verify model id",
+ yes: "yes",
+ no: "no",
+ questionId: "question id",
+ starting: "Starting…",
+ grading: "Grading…",
+ ingesting: "Ingesting…",
+ generating: "Generating…",
+ questionsSuffix: "questions",
+ conceptsSuffix: "concepts",
+ edgesSuffix: "edges",
+ gapsSuffix: "gaps",
+ candidateConcepts: "Candidate concepts",
+ noCandidates: "No candidates yet.",
+ answerWasGraded: "Answer was graded.",
+ score: (label, val) => `${label} — ${val}/4`,
+ },
+};
+
+function t(key, ...args) {
+ const lang =
+ localStorage.getItem("tutor_lang") ||
+ document.documentElement.lang ||
+ "ko";
+ const text = i18n[lang]?.[key] ?? i18n["en"]?.[key] ?? key;
+ return typeof text === "function" ? text(...args) : text;
+}
+
+function updateStaticText() {
+ const lang =
+ localStorage.getItem("tutor_lang") ||
+ document.documentElement.lang ||
+ "ko";
+ document.documentElement.lang = lang;
+ document.querySelectorAll("[data-i18n]").forEach((el) => {
+ const k = el.dataset.i18n;
+ const v = i18n[lang]?.[k] ?? i18n["en"]?.[k] ?? "";
+ const text = typeof v === "function" ? v() : v;
+ if (el.classList.contains("login-divider")) {
+ el.setAttribute("data-label", text);
+ } else if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
+ if (el.placeholder !== undefined) el.placeholder = text;
+ } else if (text.includes("<")) {
+ el.innerHTML = text;
+ } else {
+ el.textContent = text;
+ }
+ });
+}
diff --git a/internal/webapp/static/index.html b/internal/webapp/static/index.html
index d322860..4a4dbe9 100644
--- a/internal/webapp/static/index.html
+++ b/internal/webapp/static/index.html
@@ -10,7 +10,7 @@