From e498d66ea8302fda9d872a00cd4afcf9f6a8b49a Mon Sep 17 00:00:00 2001 From: vitchenkokir Date: Thu, 11 Jun 2026 11:03:39 +0300 Subject: [PATCH 1/4] fix ui --- app/interview/api/setup.py | 1 + app/interview/api/setup_form.py | 4 + static/css/styles.css | 90 ++++ static/js/setup_wizard.js | 770 ++++++++++++++++++++++++++++++++ templates/setup.html | 618 +++++-------------------- 5 files changed, 966 insertions(+), 517 deletions(-) create mode 100644 static/js/setup_wizard.js diff --git a/app/interview/api/setup.py b/app/interview/api/setup.py index d1a9032..1d1b129 100644 --- a/app/interview/api/setup.py +++ b/app/interview/api/setup.py @@ -256,6 +256,7 @@ async def create_interview( error=str(e), min_question_count=min_theory, min_coding_task_count=min_coding, + initial_wizard_step="review", ), **SpeechModelPageService.build_page_context( config, diff --git a/app/interview/api/setup_form.py b/app/interview/api/setup_form.py index b5bff0f..61846f1 100644 --- a/app/interview/api/setup_form.py +++ b/app/interview/api/setup_form.py @@ -52,6 +52,7 @@ def setup_form_context( error: str | None = None, min_question_count: int = 1, min_coding_task_count: int = 1, + initial_wizard_step: str = "mode", ) -> dict[str, object]: """Build template context for the multi-track setup form. @@ -60,6 +61,7 @@ def setup_form_context( error: Optional error message to display. min_question_count: Minimum allowed theory question count. min_coding_task_count: Minimum allowed coding task count. + initial_wizard_step: Wizard step id to open on load (``mode``, ``review``, etc.). Returns: Context dict for ``setup.html``. @@ -80,6 +82,7 @@ def setup_form_context( "error": error or "No question banks found.", "min_question_count": min_question_count, "min_coding_task_count": min_coding_task_count, + "initial_wizard_step": initial_wizard_step, } track_sections = _build_track_sections( @@ -135,4 +138,5 @@ def setup_form_context( "error": error, "min_question_count": min_question_count, "min_coding_task_count": min_coding_task_count, + "initial_wizard_step": initial_wizard_step, } diff --git a/static/css/styles.css b/static/css/styles.css index 6767ed2..9fb8b15 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1152,6 +1152,96 @@ textarea.form-control { } } +.setup-wizard-stepper { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + list-style: none; + margin: 0 0 1.5rem; + padding: 0; +} + +.setup-wizard-stepper-item { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--text-muted); + font-size: 0.9rem; +} + +.setup-wizard-stepper-item-active { + color: var(--text-color); + font-weight: 600; +} + +.setup-wizard-stepper-item-complete { + color: var(--text-color); +} + +.setup-wizard-stepper-marker { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + border: 1px solid var(--border-color); + font-size: 0.75rem; + font-weight: 600; +} + +.setup-wizard-stepper-item-active .setup-wizard-stepper-marker { + background: var(--primary-color); + border-color: var(--primary-color); + color: #fff; +} + +.setup-wizard-stepper-item-complete .setup-wizard-stepper-marker { + background: var(--bg-muted); +} + +.setup-wizard-nav { + align-items: center; +} + +.setup-wizard-nav-review #setup-wizard-next { + display: none; +} + +.setup-review-summary { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.setup-review-card { + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 0.75rem 1rem; +} + +.setup-review-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.setup-review-card-title { + margin: 0; + font-size: 1rem; +} + +.setup-review-card-body p { + margin: 0.25rem 0; +} + +.setup-review-empty { + color: var(--text-muted); + font-style: italic; +} + .setup-session-modes { display: flex; flex-direction: column; diff --git a/static/js/setup_wizard.js b/static/js/setup_wizard.js new file mode 100644 index 0000000..f61d51b --- /dev/null +++ b/static/js/setup_wizard.js @@ -0,0 +1,770 @@ +(function () { + "use strict"; + + const STORAGE_KEY = "grillkit_setup_wizard"; + + const SESSION_MODE_LABELS = { + theory_only: "Theory only", + coding_only: "Coding only", + theory_then_coding: "Theory, then coding", + coding_then_theory: "Coding, then theory", + }; + + const form = document.getElementById("setup-form"); + const wizard = document.getElementById("setup-wizard"); + if (!form || !wizard) { + return; + } + + const selectionInput = document.getElementById("selection_json"); + const submitBtn = document.getElementById("setup-submit"); + const nextBtn = document.getElementById("setup-wizard-next"); + const backBtn = document.getElementById("setup-wizard-back"); + const wizardNav = document.querySelector(".setup-wizard-nav"); + const stepper = document.getElementById("setup-stepper"); + const reviewSummary = document.getElementById("setup-review-summary"); + const questionCountInput = document.getElementById("question_count"); + const questionCountHint = document.getElementById("question-count-hint"); + const codingCountInput = document.getElementById("coding_question_count"); + const codingCountHint = document.getElementById("coding-count-hint"); + const timerCheckbox = document.getElementById("enable_question_timer"); + const timerMinutesGroup = document.getElementById("question-timer-minutes-group"); + const codingTimerCheckbox = document.getElementById("enable_coding_timer"); + const codingTimerMinutesGroup = document.getElementById("coding-timer-minutes-group"); + + const localeLabel = wizard.dataset.localeLabel || ""; + const initialStep = wizard.dataset.initialStep || "mode"; + const hasServerError = wizard.dataset.hasError === "true"; + + let currentStepId = "mode"; + + function formatTopic(slug) { + return slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, " "); + } + + function formatLevel(level) { + return level.charAt(0).toUpperCase() + level.slice(1); + } + + function selectedSessionMode() { + const checked = document.querySelector(".session-mode-radio:checked"); + return checked ? checked.value : "theory_only"; + } + + function branchEnabled(mode, branch) { + if (mode === "theory_only") { + return branch === "theory"; + } + if (mode === "coding_only") { + return branch === "coding"; + } + return true; + } + + function getStepSequence() { + const mode = selectedSessionMode(); + const sections = []; + if (mode === "theory_only") { + sections.push("theory"); + } else if (mode === "coding_only") { + sections.push("coding"); + } else if (mode === "theory_then_coding") { + sections.push("theory", "coding"); + } else { + sections.push("coding", "theory"); + } + return ["mode", ...sections, "review"]; + } + + function stepLabel(stepId) { + if (stepId === "mode") { + return "Mode"; + } + if (stepId === "theory") { + return "Theory"; + } + if (stepId === "coding") { + return "Coding"; + } + return "Review"; + } + + function selectedTheoryTopicCount() { + let count = 0; + document.querySelectorAll(".theory-track-block").forEach(function (block) { + const enable = block.querySelector(".track-enable"); + if (enable && enable.checked) { + block.querySelectorAll(".topic-checkbox:checked").forEach(function () { + count += 1; + }); + } + }); + return count; + } + + function selectedCodingTopicCount() { + let count = 0; + document.querySelectorAll(".coding-track-block").forEach(function (block) { + const enable = block.querySelector(".coding-track-enable"); + if (enable && enable.checked) { + block.querySelectorAll(".coding-topic-checkbox:checked").forEach(function () { + count += 1; + }); + } + }); + return count; + } + + function syncTheoryCountMin() { + const topics = selectedTheoryTopicCount(); + const minVal = Math.max(1, topics); + questionCountInput.min = String(minVal); + if (Number(questionCountInput.value) < minVal) { + questionCountInput.value = String(minVal); + } + questionCountHint.textContent = + "How many questions in this session (1–20). Must be at least " + + minVal + " (one per selected topic)."; + } + + function syncCodingCountMin() { + const topics = selectedCodingTopicCount(); + const minVal = Math.max(1, topics); + codingCountInput.min = String(minVal); + if (Number(codingCountInput.value) < minVal) { + codingCountInput.value = String(minVal); + } + codingCountHint.textContent = + "How many coding tasks in this session (1–20). Must be at least " + + minVal + " (one per selected topic)."; + } + + function buildTheorySources() { + const sources = []; + document.querySelectorAll(".theory-track-block").forEach(function (block) { + const track = block.dataset.track; + const enabled = block.querySelector(".track-enable"); + if (!enabled || !enabled.checked) { + return; + } + const levelSelect = block.querySelector(".track-level"); + const level = levelSelect ? levelSelect.value : ""; + const categories = []; + block.querySelectorAll(".topic-checkbox:checked").forEach(function (cb) { + categories.push(cb.value); + }); + if (categories.length > 0) { + sources.push({ track: track, level: level, categories: categories }); + } + }); + return sources; + } + + function buildCodingSources() { + const sources = []; + document.querySelectorAll(".coding-track-block").forEach(function (block) { + const track = block.dataset.track; + const enabled = block.querySelector(".coding-track-enable"); + if (!enabled || !enabled.checked) { + return; + } + const levelSelect = block.querySelector(".coding-track-level"); + const level = levelSelect ? levelSelect.value : ""; + const categories = []; + block.querySelectorAll(".coding-topic-checkbox:checked").forEach(function (cb) { + categories.push(cb.value); + }); + if (categories.length > 0) { + sources.push({ track: track, level: level, categories: categories }); + } + }); + return sources; + } + + function buildSelection() { + const sessionMode = selectedSessionMode(); + const theorySources = buildTheorySources(); + const codingSources = buildCodingSources(); + const theoryCount = Number(questionCountInput.value); + const codingCount = Number(codingCountInput.value); + const theoryTimerSeconds = timerCheckbox && timerCheckbox.checked + ? Math.max(1, Number(document.getElementById("question_time_minutes").value)) * 60 + : null; + const codingTimerSeconds = codingTimerCheckbox && codingTimerCheckbox.checked + ? Math.max(1, Number(document.getElementById("coding_time_minutes").value)) * 60 + : null; + const theoryEnabled = branchEnabled(sessionMode, "theory"); + const codingEnabled = branchEnabled(sessionMode, "coding"); + return { + version: 2, + session_mode: sessionMode, + theory: { + enabled: theoryEnabled, + question_count: theoryEnabled ? theoryCount : 0, + task_time_limit_seconds: theoryEnabled ? theoryTimerSeconds : null, + sources: theoryEnabled ? theorySources : [], + }, + coding: { + enabled: codingEnabled, + question_count: codingEnabled ? codingCount : 0, + task_time_limit_seconds: codingEnabled ? codingTimerSeconds : null, + sources: codingEnabled ? codingSources : [], + }, + }; + } + + function theoryValid(selection) { + if (!selection.theory.enabled) { + return true; + } + const topics = selectedTheoryTopicCount(); + if (topics === 0 || selection.theory.sources.length === 0) { + return false; + } + const count = Number(questionCountInput.value); + return count >= topics && count >= 1 && count <= 20; + } + + function codingValid(selection) { + if (!selection.coding.enabled) { + return true; + } + const topics = selectedCodingTopicCount(); + if (topics === 0 || selection.coding.sources.length === 0) { + return false; + } + const count = Number(codingCountInput.value); + return count >= topics && count >= 1 && count <= 20; + } + + function validateStep(stepId) { + if (stepId === "mode") { + return Boolean(document.querySelector(".session-mode-radio:checked")); + } + const selection = buildSelection(); + if (stepId === "theory") { + return theoryValid(selection); + } + if (stepId === "coding") { + return codingValid(selection); + } + return theoryValid(selection) && codingValid(selection); + } + + function syncTimerFields() { + if (!timerCheckbox || !timerMinutesGroup) { + return; + } + timerMinutesGroup.hidden = !timerCheckbox.checked; + } + + function syncCodingTimerFields() { + if (!codingTimerCheckbox || !codingTimerMinutesGroup) { + return; + } + codingTimerMinutesGroup.hidden = !codingTimerCheckbox.checked; + } + + function renderStepper() { + const sequence = getStepSequence(); + stepper.innerHTML = ""; + sequence.forEach(function (stepId, index) { + const item = document.createElement("li"); + item.className = "setup-wizard-stepper-item"; + item.dataset.step = stepId; + const sequenceIndex = sequence.indexOf(currentStepId); + if (stepId === currentStepId) { + item.classList.add("setup-wizard-stepper-item-active"); + } else if (index < sequenceIndex) { + item.classList.add("setup-wizard-stepper-item-complete"); + } + const marker = document.createElement("span"); + marker.className = "setup-wizard-stepper-marker"; + marker.textContent = String(index + 1); + const label = document.createElement("span"); + label.className = "setup-wizard-stepper-label"; + label.textContent = stepLabel(stepId); + item.appendChild(marker); + item.appendChild(label); + stepper.appendChild(item); + }); + } + + function showStep(stepId) { + const sequence = getStepSequence(); + if (sequence.indexOf(stepId) === -1) { + stepId = sequence[0]; + } + currentStepId = stepId; + document.querySelectorAll(".setup-wizard-step").forEach(function (panel) { + panel.hidden = panel.dataset.wizardStep !== stepId; + }); + renderStepper(); + updateNavButtons(); + if (stepId === "review") { + renderReviewSummary(); + } + saveWizardState(); + } + + function updateNavButtons() { + const sequence = getStepSequence(); + const index = sequence.indexOf(currentStepId); + backBtn.hidden = index <= 0; + const onReview = currentStepId === "review"; + if (wizardNav) { + wizardNav.classList.toggle("setup-wizard-nav-review", onReview); + } + nextBtn.hidden = onReview; + submitBtn.hidden = !onReview; + if (!onReview) { + nextBtn.disabled = !validateStep(currentStepId); + } else { + submitBtn.disabled = !validateStep("review"); + } + } + + function formatTimer(seconds) { + if (seconds === null || seconds === undefined) { + return "Disabled"; + } + const minutes = Math.round(seconds / 60); + return minutes + " min per round"; + } + + function renderSectionSources(sources) { + if (!sources.length) { + return "

No topics selected.

"; + } + const items = sources.map(function (source) { + const topics = source.categories.map(formatTopic).join(", "); + return "
  • " + formatTopic(source.track) + " (" + + formatLevel(source.level) + "): " + topics + "
  • "; + }); + return ""; + } + + function renderReviewCard(stepId, title, bodyHtml, editable) { + const editButton = editable + ? "" + : ""; + return "
    " + + "
    " + + "

    " + title + "

    " + + editButton + + "
    " + + "
    " + bodyHtml + "
    " + + "
    "; + } + + function renderReviewSummary() { + const selection = buildSelection(); + const mode = selection.session_mode; + let html = renderReviewCard( + "mode", + "Session mode", + "

    " + (SESSION_MODE_LABELS[mode] || mode) + "

    ", + true + ); + if (selection.theory.enabled) { + html += renderReviewCard( + "theory", + "Theory", + "

    Questions: " + selection.theory.question_count + "

    " + + "

    Timer: " + + formatTimer(selection.theory.task_time_limit_seconds) + "

    " + + "

    Topics:

    " + + renderSectionSources(selection.theory.sources), + true + ); + } + if (selection.coding.enabled) { + html += renderReviewCard( + "coding", + "Coding", + "

    Tasks: " + selection.coding.question_count + "

    " + + "

    Timer: " + + formatTimer(selection.coding.task_time_limit_seconds) + "

    " + + "

    Topics:

    " + + renderSectionSources(selection.coding.sources), + true + ); + } + html += renderReviewCard( + "mode", + "Interview language", + "

    " + localeLabel + "

    " + + "

    Change in Configuration.

    ", + false + ); + reviewSummary.innerHTML = html; + reviewSummary.querySelectorAll(".setup-review-edit").forEach(function (button) { + button.addEventListener("click", function () { + showStep(button.dataset.editStep); + }); + }); + submitBtn.disabled = !validateStep("review"); + } + + function goNext() { + const sequence = getStepSequence(); + const index = sequence.indexOf(currentStepId); + if (index === -1 || index >= sequence.length - 1) { + return; + } + if (!validateStep(currentStepId)) { + return; + } + showStep(sequence[index + 1]); + } + + function goBack() { + const sequence = getStepSequence(); + const index = sequence.indexOf(currentStepId); + if (index <= 0) { + return; + } + showStep(sequence[index - 1]); + } + + function collectBranchState(prefix, enableClass, levelClass, topicClass) { + const tracks = {}; + document.querySelectorAll("." + prefix + "-track-block").forEach(function (block) { + const track = block.dataset.track; + const enable = block.querySelector("." + enableClass); + const levelSelect = block.querySelector("." + levelClass); + const categories = []; + block.querySelectorAll("." + topicClass + ":checked").forEach(function (cb) { + categories.push(cb.value); + }); + tracks[track] = { + enabled: Boolean(enable && enable.checked), + level: levelSelect ? levelSelect.value : "", + categories: categories, + }; + }); + return tracks; + } + + function saveWizardState() { + const state = { + sessionMode: selectedSessionMode(), + theoryTracks: collectBranchState("theory", "track-enable", "track-level", "topic-checkbox"), + codingTracks: collectBranchState( + "coding", + "coding-track-enable", + "coding-track-level", + "coding-topic-checkbox" + ), + questionCount: questionCountInput.value, + codingQuestionCount: codingCountInput.value, + theoryTimerEnabled: Boolean(timerCheckbox && timerCheckbox.checked), + theoryTimerMinutes: document.getElementById("question_time_minutes").value, + codingTimerEnabled: Boolean(codingTimerCheckbox && codingTimerCheckbox.checked), + codingTimerMinutes: document.getElementById("coding_time_minutes").value, + currentStepId: currentStepId, + }; + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (_error) { + /* ignore quota errors */ + } + } + + async function restoreBranchState(prefix, tracks, enableClass, levelClass, topicClass, loadTopics) { + for (const block of document.querySelectorAll("." + prefix + "-track-block")) { + const track = block.dataset.track; + const saved = tracks[track]; + if (!saved) { + continue; + } + const enable = block.querySelector("." + enableClass); + const body = block.querySelector(".setup-track-body"); + const levelSelect = block.querySelector("." + levelClass); + if (enable) { + enable.checked = saved.enabled; + } + if (body) { + body.hidden = !saved.enabled; + } + if (levelSelect && saved.level) { + levelSelect.value = saved.level; + } + if (saved.enabled && typeof loadTopics === "function") { + await loadTopics(block); + } + const wanted = new Set(saved.categories || []); + block.querySelectorAll("." + topicClass).forEach(function (cb) { + cb.checked = wanted.has(cb.value); + }); + } + } + + async function restoreWizardState() { + let raw; + try { + raw = sessionStorage.getItem(STORAGE_KEY); + } catch (_error) { + return false; + } + if (!raw) { + return false; + } + let state; + try { + state = JSON.parse(raw); + } catch (_error) { + return false; + } + const modeRadio = document.querySelector( + ".session-mode-radio[value=\"" + state.sessionMode + "\"]" + ); + if (modeRadio && !modeRadio.disabled) { + modeRadio.checked = true; + } + await restoreBranchState( + "theory", + state.theoryTracks || {}, + "track-enable", + "track-level", + "topic-checkbox", + loadTheoryTopics + ); + await restoreBranchState( + "coding", + state.codingTracks || {}, + "coding-track-enable", + "coding-track-level", + "coding-topic-checkbox", + loadCodingTopics + ); + if (state.questionCount) { + questionCountInput.value = state.questionCount; + } + if (state.codingQuestionCount) { + codingCountInput.value = state.codingQuestionCount; + } + if (timerCheckbox) { + timerCheckbox.checked = Boolean(state.theoryTimerEnabled); + } + if (state.theoryTimerMinutes) { + document.getElementById("question_time_minutes").value = state.theoryTimerMinutes; + } + if (codingTimerCheckbox) { + codingTimerCheckbox.checked = Boolean(state.codingTimerEnabled); + } + if (state.codingTimerMinutes) { + document.getElementById("coding_time_minutes").value = state.codingTimerMinutes; + } + syncTimerFields(); + syncCodingTimerFields(); + syncTheoryCountMin(); + syncCodingCountMin(); + return true; + } + + async function loadTheoryTopics(block) { + const track = block.dataset.track; + const levelSelect = block.querySelector(".track-level"); + const topicList = block.querySelector(".setup-topic-list"); + if (!levelSelect || !topicList) { + return; + } + const level = levelSelect.value; + const url = "/setup/options?track=" + encodeURIComponent(track) + + "&level=" + encodeURIComponent(level); + const response = await fetch(url); + if (!response.ok) { + return; + } + const data = await response.json(); + const categories = data.categories || []; + const checked = new Set(); + topicList.querySelectorAll(".topic-checkbox:checked").forEach(function (cb) { + checked.add(cb.value); + }); + topicList.innerHTML = ""; + categories.forEach(function (cat) { + const label = document.createElement("label"); + label.className = "setup-topic-item"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.className = "topic-checkbox"; + input.value = cat; + input.dataset.track = track; + if (checked.has(cat)) { + input.checked = true; + } + input.addEventListener("change", onFormChange); + label.appendChild(input); + label.appendChild(document.createTextNode(" " + formatTopic(cat))); + topicList.appendChild(label); + }); + } + + async function loadCodingTopics(block) { + const track = block.dataset.track; + const levelSelect = block.querySelector(".coding-track-level"); + const topicList = block.querySelector(".coding-topic-list"); + if (!levelSelect || !topicList) { + return; + } + const level = levelSelect.value; + const url = "/setup/coding-options?track=" + encodeURIComponent(track) + + "&level=" + encodeURIComponent(level); + const response = await fetch(url); + if (!response.ok) { + return; + } + const data = await response.json(); + const categories = data.categories || []; + const checked = new Set(); + topicList.querySelectorAll(".coding-topic-checkbox:checked").forEach(function (cb) { + checked.add(cb.value); + }); + topicList.innerHTML = ""; + categories.forEach(function (cat) { + const label = document.createElement("label"); + label.className = "setup-topic-item"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.className = "coding-topic-checkbox"; + input.value = cat; + input.dataset.track = track; + if (checked.has(cat)) { + input.checked = true; + } + input.addEventListener("change", onFormChange); + label.appendChild(input); + label.appendChild(document.createTextNode(" " + formatTopic(cat))); + topicList.appendChild(label); + }); + } + + function onFormChange() { + syncTheoryCountMin(); + syncCodingCountMin(); + updateNavButtons(); + if (currentStepId === "review") { + renderReviewSummary(); + } else { + saveWizardState(); + } + } + + function bindTrackBlocks() { + document.querySelectorAll(".theory-track-block").forEach(function (block) { + const enable = block.querySelector(".track-enable"); + const body = block.querySelector(".setup-track-body"); + const levelSelect = block.querySelector(".track-level"); + if (enable && body) { + enable.addEventListener("change", function () { + body.hidden = !enable.checked; + onFormChange(); + }); + } + if (levelSelect) { + levelSelect.addEventListener("change", function () { + loadTheoryTopics(block).then(onFormChange); + }); + } + block.querySelectorAll(".topic-checkbox").forEach(function (cb) { + cb.addEventListener("change", onFormChange); + }); + }); + + document.querySelectorAll(".coding-track-block").forEach(function (block) { + const enable = block.querySelector(".coding-track-enable"); + const body = block.querySelector(".setup-track-body"); + const levelSelect = block.querySelector(".coding-track-level"); + if (enable && body) { + enable.addEventListener("change", function () { + body.hidden = !enable.checked; + onFormChange(); + }); + } + if (levelSelect) { + levelSelect.addEventListener("change", function () { + loadCodingTopics(block).then(onFormChange); + }); + } + block.querySelectorAll(".coding-topic-checkbox").forEach(function (cb) { + cb.addEventListener("change", onFormChange); + }); + }); + + document.querySelectorAll(".session-mode-radio").forEach(function (radio) { + radio.addEventListener("change", function () { + const sequence = getStepSequence(); + if (sequence.indexOf(currentStepId) === -1) { + showStep("mode"); + } else { + renderStepper(); + updateNavButtons(); + } + onFormChange(); + }); + }); + } + + function bindControls() { + questionCountInput.addEventListener("input", onFormChange); + codingCountInput.addEventListener("input", onFormChange); + if (timerCheckbox) { + timerCheckbox.addEventListener("change", function () { + syncTimerFields(); + onFormChange(); + }); + } + if (codingTimerCheckbox) { + codingTimerCheckbox.addEventListener("change", function () { + syncCodingTimerFields(); + onFormChange(); + }); + } + document.getElementById("question_time_minutes").addEventListener("input", onFormChange); + document.getElementById("coding_time_minutes").addEventListener("input", onFormChange); + + nextBtn.addEventListener("click", goNext); + backBtn.addEventListener("click", goBack); + + form.addEventListener("submit", function (event) { + const selection = buildSelection(); + if (!theoryValid(selection) || !codingValid(selection)) { + event.preventDefault(); + showStep("review"); + renderReviewSummary(); + return; + } + selectionInput.value = JSON.stringify(selection); + saveWizardState(); + }); + } + + async function init() { + bindTrackBlocks(); + bindControls(); + syncTimerFields(); + syncCodingTimerFields(); + + const restored = await restoreWizardState(); + let startStep = initialStep; + if (restored && hasServerError) { + startStep = "review"; + } else if (restored) { + try { + const state = JSON.parse(sessionStorage.getItem(STORAGE_KEY)); + if (state.currentStepId && getStepSequence().includes(state.currentStepId)) { + startStep = state.currentStepId; + } + } catch (_error) { + /* use initialStep */ + } + } + showStep(startStep); + onFormChange(); + } + + init(); +})(); diff --git a/templates/setup.html b/templates/setup.html index 45a30ae..5f22216 100644 --- a/templates/setup.html +++ b/templates/setup.html @@ -16,54 +16,42 @@

    Interview Setup

    -

    Choose session mode, question banks, levels, and topics

    +

    Configure your session step by step

    -
    - +
    +
      -
      -

      Session mode

      -

      Choose which sections to include and in what order.

      - {% if not coding_available %} -

      Coding modes require Judge0 (set CODING_ENABLED and run the coding profile).

      - {% endif %} -
      - {% for mode in session_modes %} - - {% endfor %} -
      -
      - -
      -

      Theory: question banks & topics

      -

      Enable one or more tracks, pick a level per track, then select topics.

      - - {% if not track_sections %} -

      No question banks found.

      - {% endif %} + + -
      - {% for section in track_sections %} -
      - -
      - -
      - - -

      - How many questions in this session (1–20). Must be at least the number of selected topics. -

      -
      -
      - -

      When time runs out, the current round scores 0 and the interview moves on.

      -
      - - +
      - +
      + + +

      + How many coding tasks in this session (1–20). Must be at least the number of selected topics. +

      +
      - +
      + +

      When time runs out, the current coding task scores 0 and the session moves on.

      +
      - + +
      -
      - - -

      Language for AI feedback and voice dictation. Change in Configuration.

      -
      + -
      +
      @@ -255,386 +220,5 @@

      Interview Setup

      {% endblock %} {% block scripts %} - + {% endblock %} From 14bf74e62a7482316c9d337c29520e30d152eaef Mon Sep 17 00:00:00 2001 From: vitchenkokir Date: Fri, 12 Jun 2026 08:50:48 +0300 Subject: [PATCH 2/4] fixed some bugs --- app/coding/domain/entities.py | 6 +- app/coding/services/page.py | 5 +- app/interview/schemas/ws.py | 3 + app/interview/services/creation.py | 9 +- app/interview/services/events.py | 2 + app/interview/services/page.py | 9 +- app/interview/services/sections.py | 14 ++ app/shared/structured_evaluation.py | 171 +++++++++++++++--- app/theory/api/ws_protocol.py | 1 + app/theory/domain/entities.py | 8 +- app/theory/services/creation.py | 3 + app/theory/services/evaluation_persistence.py | 3 + app/theory/services/evaluator/prompts.py | 2 + app/theory/services/evaluator/service.py | 19 +- app/theory/services/navigation.py | 1 + static/css/styles.css | 56 ++++++ static/js/interview_voice.js | 119 +++++++----- static/js/setup_wizard.js | 45 ++++- templates/interview.html | 119 ++++++++---- tests/test_answer_processing.py | 6 +- tests/test_interview_ws_integration.py | 6 +- tests/test_session_phases.py | 67 +++++++ tests/test_theory_section.py | 13 ++ 23 files changed, 556 insertions(+), 131 deletions(-) diff --git a/app/coding/domain/entities.py b/app/coding/domain/entities.py index b4c9be0..83701e1 100644 --- a/app/coding/domain/entities.py +++ b/app/coding/domain/entities.py @@ -199,7 +199,11 @@ def start( when = datetime.now(UTC) task_ids = tuple(task.id for task in planned_tasks) - timer_start = when if task_time_limit_seconds is not None else None + timer_start = ( + when + if task_time_limit_seconds is not None and status == "active" + else None + ) tasks: list[CodingTask] = [] for order, planned in enumerate(planned_tasks, start=1): tasks.append( diff --git a/app/coding/services/page.py b/app/coding/services/page.py index 8077e61..1a6d070 100644 --- a/app/coding/services/page.py +++ b/app/coding/services/page.py @@ -48,7 +48,10 @@ def build_context(interview_id: str) -> CodingPageContext | None: completed_tasks = sum( 1 for task in section.tasks if task.submitted_code is not None ) - task_timer_enabled = section.task_time_limit_seconds is not None + task_timer_enabled = ( + section.task_time_limit_seconds is not None + and section.status == "active" + ) timer_remaining = ( current.remaining_seconds(section.task_time_limit_seconds) if task_timer_enabled and current is not None diff --git a/app/interview/schemas/ws.py b/app/interview/schemas/ws.py index d6c3b13..368a83f 100644 --- a/app/interview/schemas/ws.py +++ b/app/interview/schemas/ws.py @@ -48,6 +48,7 @@ class AnswerFeedbackMessage(BaseModel): timed_out: bool = False feedback: str | None = None timer_remaining_seconds: int | None = None + follow_up_answer_id: int | None = None class InterviewCompletedMessage(BaseModel): @@ -78,5 +79,7 @@ def server_message_to_dict(message: BaseModel) -> dict[str, Any]: payload.pop("feedback", None) if payload.get("timer_remaining_seconds") is None: payload.pop("timer_remaining_seconds", None) + if payload.get("follow_up_answer_id") is None: + payload.pop("follow_up_answer_id", None) return payload return message.model_dump(mode="json") diff --git a/app/interview/services/creation.py b/app/interview/services/creation.py index 9b90134..07d0c62 100644 --- a/app/interview/services/creation.py +++ b/app/interview/services/creation.py @@ -13,7 +13,10 @@ from app.interview.domain.value_objects import SessionMode, SessionSelection from app.interview.repositories.uow import InterviewUnitOfWork from app.interview.schemas.interview import InterviewRead -from app.interview.services.sections import phase_order_for_mode +from app.interview.services.sections import ( + is_first_user_facing_section, + phase_order_for_mode, +) from app.shared.locales import normalize_locale from app.theory.services.creation import TheorySectionCreationService @@ -74,6 +77,10 @@ def create_session( locale=locale, question_count=session.theory.question_count, task_time_limit_seconds=session.theory.task_time_limit_seconds, + start_first_task_timer=is_first_user_facing_section( + session.session_mode, + "theory", + ), uow=uow, ) if session.coding.enabled: diff --git a/app/interview/services/events.py b/app/interview/services/events.py index 2862f33..d74daa2 100644 --- a/app/interview/services/events.py +++ b/app/interview/services/events.py @@ -30,6 +30,7 @@ class AnswerFeedbackEvent: timed_out: Whether this round ended due to timer expiry. feedback: Short feedback for the client (e.g. timeout message). timer_remaining_seconds: Seconds left on the next round timer, if any. + follow_up_answer_id: Task row id for a newly created follow-up round. """ question_id: str @@ -41,6 +42,7 @@ class AnswerFeedbackEvent: timed_out: bool = False feedback: str | None = None timer_remaining_seconds: int | None = None + follow_up_answer_id: int | None = None @dataclass(frozen=True) diff --git a/app/interview/services/page.py b/app/interview/services/page.py index e16c42f..a1cd9f9 100644 --- a/app/interview/services/page.py +++ b/app/interview/services/page.py @@ -50,7 +50,7 @@ class SessionPageService: @staticmethod def load_interview(interview_id: str) -> InterviewRead | None: - """Load a session and start the theory timer on the current task when active. + """Load a session and start the active section timer when applicable. Args: interview_id: The session UUID. @@ -58,8 +58,11 @@ def load_interview(interview_id: str) -> InterviewRead | None: Returns: Interview read model, or None when not found. """ - TheoryPageService.activate_timer(interview_id) - CodingPageService.activate_timer(interview_id) + active = SessionPhaseOrchestrator.active_phase(interview_id) + if active == "theory": + TheoryPageService.activate_timer(interview_id) + elif active == "coding": + CodingPageService.activate_timer(interview_id) return InterviewQuery.get_interview(interview_id) @staticmethod diff --git a/app/interview/services/sections.py b/app/interview/services/sections.py index 2f2be2a..876af43 100644 --- a/app/interview/services/sections.py +++ b/app/interview/services/sections.py @@ -102,6 +102,20 @@ def phase_order_for_mode(session_mode: SessionMode) -> tuple[SectionKind, ...]: return ("coding", "theory") +def is_first_user_facing_section(session_mode: SessionMode, section: SectionKind) -> bool: + """Return whether ``section`` is the first interactive phase for a session mode. + + Args: + session_mode: Session mode from setup. + section: Section kind to check. + + Returns: + True when ``section`` is the first entry in the mode phase order. + """ + order = phase_order_for_mode(session_mode) + return bool(order) and order[0] == section + + def section_services() -> dict[SectionKind, SectionService]: """Return section service classes keyed by section kind. diff --git a/app/shared/structured_evaluation.py b/app/shared/structured_evaluation.py index fabafe3..d39fa01 100644 --- a/app/shared/structured_evaluation.py +++ b/app/shared/structured_evaluation.py @@ -6,7 +6,115 @@ from pydantic import BaseModel -from app.ai.base import AIProvider, Message +from app.ai.base import AIProvider, GenerationResult, Message + +_MAX_RETRY_TOKENS = 4096 +_COMPACT_JSON_RETRY_NOTE = ( + "\n\nYour previous response was truncated or invalid JSON. " + "Keep all string fields brief (feedback at most 4 sentences, " + "follow-up questions one sentence). " + "Return ONLY one complete valid JSON object, no markdown fences." +) + + +def _should_retry_structured_parse( + exc: ValueError, + finish_reason: str | None, +) -> bool: + """Return True when a structured JSON parse failure may succeed on retry. + + Args: + exc: Parse or validation error from the model response. + finish_reason: Provider completion reason, when available. + + Returns: + True if the caller should retry with a higher token budget. + """ + if finish_reason == "length": + return True + return "invalid JSON" in str(exc) + + +async def _parse_generation_result[T: BaseModel]( + result: GenerationResult, + response_model: type[T], +) -> T: + """Parse one provider result into a validated structured model. + + Args: + result: Raw provider generation result. + response_model: Pydantic model for parsed JSON output. + + Returns: + Parsed evaluation model instance. + + Raises: + ValueError: If the response body is empty or invalid JSON. + """ + from app.theory.services.evaluator.prompts import parse_json_response + + content = result.content.strip() + if not content: + raise ValueError("AI returned empty response") + return parse_json_response(content, response_model) + + +async def generate_and_parse_json_response[T: BaseModel]( + provider: AIProvider, + *, + messages: list[Message], + response_model: type[T], + max_tokens: int = 2000, + temperature: float = 0.3, +) -> T: + """Generate JSON from chat messages and parse it with retry on truncation. + + Args: + provider: Configured AI provider instance. + messages: Full chat messages for the provider request. + response_model: Pydantic model for parsed JSON output. + max_tokens: Initial maximum tokens for the model response. + temperature: Sampling temperature for generation. + + Returns: + Parsed evaluation model instance. + + Raises: + ValueError: If AI response is invalid or connection fails after retries. + """ + token_budgets = [max_tokens, min(max_tokens * 2, _MAX_RETRY_TOKENS)] + last_error: ValueError | None = None + base_system_prompt = ( + messages[0].content if messages and messages[0].role == "system" else None + ) + + for attempt, budget in enumerate(token_budgets): + attempt_messages = list(messages) + if attempt > 0 and base_system_prompt is not None: + attempt_messages[0] = Message( + role="system", + content=base_system_prompt + _COMPACT_JSON_RETRY_NOTE, + ) + + result = await provider.generate( + messages=attempt_messages, + temperature=temperature, + max_tokens=budget, + ) + + try: + return await _parse_generation_result(result, response_model) + except ValueError as exc: + last_error = exc + if attempt < len(token_budgets) - 1 and _should_retry_structured_parse( + exc, result.finish_reason + ): + continue + raise + + if last_error is not None: + raise last_error + raise ValueError("AI returned empty response") async def evaluate_with_schema[T: BaseModel]( @@ -17,7 +125,7 @@ async def evaluate_with_schema[T: BaseModel]( response_model: type[T], user_text: str, audio_wav: bytes | None = None, - max_tokens: int = 1000, + max_tokens: int = 2000, ) -> T: """Run a structured evaluation via text or multimodal generation. @@ -39,30 +147,47 @@ async def evaluate_with_schema[T: BaseModel]( from app.theory.services.evaluator.prompts import ( build_evaluator_instructions, build_prompt_with_schema, - parse_json_response, ) system_prompt = build_prompt_with_schema( build_evaluator_instructions(locale, instructions), response_model, ) - messages = [Message(role="system", content=system_prompt)] - if audio_wav is not None: - result = await provider.generate_with_audio( - messages=messages, - audio_wav=audio_wav, - user_text=user_text, - temperature=0.3, - max_tokens=max_tokens, - ) - else: - messages.append(Message(role="user", content=user_text)) - result = await provider.generate( - messages=messages, - temperature=0.3, - max_tokens=max_tokens, - ) - content = result.content.strip() - if not content: - raise ValueError("AI returned empty response") - return parse_json_response(content, response_model) + token_budgets = [max_tokens, min(max_tokens * 2, _MAX_RETRY_TOKENS)] + last_error: ValueError | None = None + + for attempt, budget in enumerate(token_budgets): + prompt = system_prompt + if attempt > 0: + prompt = system_prompt + _COMPACT_JSON_RETRY_NOTE + messages = [Message(role="system", content=prompt)] + + if audio_wav is not None: + result = await provider.generate_with_audio( + messages=messages, + audio_wav=audio_wav, + user_text=user_text, + temperature=0.3, + max_tokens=budget, + ) + else: + messages.append(Message(role="user", content=user_text)) + result = await provider.generate( + messages=messages, + temperature=0.3, + max_tokens=budget, + ) + + try: + return await _parse_generation_result(result, response_model) + except ValueError as exc: + last_error = exc + if attempt < len(token_budgets) - 1 and _should_retry_structured_parse( + exc, result.finish_reason + ): + continue + raise + + if last_error is not None: + raise last_error + raise ValueError("AI returned empty response") diff --git a/app/theory/api/ws_protocol.py b/app/theory/api/ws_protocol.py index 048bc7f..35d4385 100644 --- a/app/theory/api/ws_protocol.py +++ b/app/theory/api/ws_protocol.py @@ -85,6 +85,7 @@ def server_message_from_event( timed_out=event.timed_out, feedback=event.feedback, timer_remaining_seconds=event.timer_remaining_seconds, + follow_up_answer_id=event.follow_up_answer_id, ) if isinstance(event, InterviewCompletedEvent): return InterviewCompletedMessage( diff --git a/app/theory/domain/entities.py b/app/theory/domain/entities.py index 466d9e2..360e470 100644 --- a/app/theory/domain/entities.py +++ b/app/theory/domain/entities.py @@ -194,6 +194,7 @@ def start( planned_questions: tuple[PlannedTheoryQuestion, ...], task_time_limit_seconds: int | None = None, theory_section_id: int = NEW_ID, + start_first_task_timer: bool = True, ) -> TheorySection: """Build a new active theory section from a question plan. @@ -204,6 +205,7 @@ def start( planned_questions: Ordered questions for this section (non-empty). task_time_limit_seconds: Per-task time limit, or None to disable. theory_section_id: Existing section ID, or ``NEW_ID`` before insert. + start_first_task_timer: Whether to start the timer on the first task now. Returns: Active section with initial task rows (``TheoryTask.NEW_ID``). @@ -216,7 +218,11 @@ def start( when = datetime.now(UTC) question_ids = tuple(question.id for question in planned_questions) - timer_start = when if task_time_limit_seconds is not None else None + timer_start = ( + when + if task_time_limit_seconds is not None and start_first_task_timer + else None + ) tasks: list[TheoryTask] = [] for order, question in enumerate(planned_questions, start=1): tasks.append( diff --git a/app/theory/services/creation.py b/app/theory/services/creation.py index 2e02ba9..61c9c43 100644 --- a/app/theory/services/creation.py +++ b/app/theory/services/creation.py @@ -20,6 +20,7 @@ def create( locale: str, question_count: int, task_time_limit_seconds: int | None, + start_first_task_timer: bool = True, uow: InterviewUnitOfWork, ) -> TheorySection: """Plan questions and persist a theory section with initial tasks. @@ -30,6 +31,7 @@ def create( locale: Locale for AI feedback and follow-ups. question_count: Number of questions for this section. task_time_limit_seconds: Per-round time limit, or None to disable. + start_first_task_timer: Whether to start the timer on the first task now. uow: Active interview unit of work sharing the persistence session. Returns: @@ -48,5 +50,6 @@ def create( locale=locale, planned_questions=theory_planned, task_time_limit_seconds=task_time_limit_seconds, + start_first_task_timer=start_first_task_timer, ) return uow.theory_sections.create_aggregate(section) diff --git a/app/theory/services/evaluation_persistence.py b/app/theory/services/evaluation_persistence.py index 6e91baf..4813514 100644 --- a/app/theory/services/evaluation_persistence.py +++ b/app/theory/services/evaluation_persistence.py @@ -114,6 +114,7 @@ def persist( """ next_question_data: dict[str, Any] | None = None timer_remaining: int | None = None + follow_up_answer_id: int | None = None with TheoryUnitOfWork(auto_commit=True) as uow: section = uow.theory_sections.get_aggregate(interview_id) @@ -142,6 +143,7 @@ def persist( if reloaded is None: raise TheorySectionNotFoundError(interview_id) follow_up = reloaded.find_task(question_id, follow_up_round) + follow_up_answer_id = follow_up.id timed = reloaded.start_timer_for_task(follow_up.id) uow.theory_sections.save_aggregate(timed) activated = next( @@ -168,4 +170,5 @@ def persist( follow_up_text=follow_up_text, next_question=next_question_data, timer_remaining_seconds=timer_remaining, + follow_up_answer_id=follow_up_answer_id, ) diff --git a/app/theory/services/evaluator/prompts.py b/app/theory/services/evaluator/prompts.py index 6b92038..6fe511f 100644 --- a/app/theory/services/evaluator/prompts.py +++ b/app/theory/services/evaluator/prompts.py @@ -185,6 +185,8 @@ def build_prompt_with_schema(instructions: str, model_class: type[BaseModel]) -> '"required", "description", "$schema", or property-definition objects.\n\n' f"Required response shape (for reference — fill with data, do not echo):\n" f"{schema_str}\n\n" + "Keep string fields concise so the JSON fits in one response " + "(feedback at most 4 sentences; follow-up questions one sentence).\n" "Return ONLY one valid JSON object, no markdown fences, no extra text." ) diff --git a/app/theory/services/evaluator/service.py b/app/theory/services/evaluator/service.py index 6a8b20b..34198fe 100644 --- a/app/theory/services/evaluator/service.py +++ b/app/theory/services/evaluator/service.py @@ -9,7 +9,10 @@ from app.ai.base import AIProvider, Message from app.shared.evaluation_models import InterviewEvaluation, SectionEvaluation from app.shared.locales import DEFAULT_LOCALE -from app.shared.structured_evaluation import evaluate_with_schema +from app.shared.structured_evaluation import ( + evaluate_with_schema, + generate_and_parse_json_response, +) from app.theory.services.evaluator.models import ( AnswerEvaluation, FollowUpEvaluation, @@ -21,7 +24,6 @@ SESSION_EVALUATION_INSTRUCTIONS, build_evaluator_instructions, looks_like_json_schema_fragment, - parse_json_response, ) __all__ = [ @@ -69,7 +71,7 @@ async def _evaluate_with_schema( response_model: type[T], user_text: str, audio_wav: bytes | None = None, - max_tokens: int = 1000, + max_tokens: int = 2000, ) -> T: """Run a structured evaluation via text or multimodal generation. @@ -400,7 +402,7 @@ async def evaluate_section( instructions=SECTION_EVALUATION_INSTRUCTIONS, response_model=SectionEvaluation, user_text=user_text, - max_tokens=1200, + max_tokens=2000, ) @staticmethod @@ -454,12 +456,9 @@ async def evaluate_interview( Message(role="system", content=system_prompt), Message(role="user", content=user_text), ] - result = await provider.generate( + return await generate_and_parse_json_response( + provider, messages=messages, - temperature=0.3, + response_model=InterviewEvaluation, max_tokens=2000, ) - content = result.content.strip() - if not content: - raise ValueError("AI returned empty response") - return parse_json_response(content, InterviewEvaluation) diff --git a/app/theory/services/navigation.py b/app/theory/services/navigation.py index 3b8c996..59570e5 100644 --- a/app/theory/services/navigation.py +++ b/app/theory/services/navigation.py @@ -20,6 +20,7 @@ def next_task_payload(task: TheoryTask) -> dict[str, Any]: Dict with question fields for the client. """ return { + "id": task.id, "question_id": task.question_id, "order": task.order, "question_text": task.question_text, diff --git a/static/css/styles.css b/static/css/styles.css index 9fb8b15..ce2dd10 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -290,6 +290,10 @@ a:hover { white-space: nowrap; } +.btn[hidden] { + display: none !important; +} + .btn:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; @@ -732,6 +736,26 @@ textarea.form-control { font-style: italic; } +.meta-list .interview-timer { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.interview-timer-label { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.interview-timer-display { + font-variant-numeric: tabular-nums; + font-size: 0.9375rem; + font-weight: 600; +} + .interview-timer-display.timer-warning { color: var(--warning, #b45309); font-weight: 600; @@ -845,6 +869,38 @@ textarea.form-control { flex-shrink: 0; } +.question-bubble-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.35rem; +} + +.question-bubble-label { + display: inline-flex; + align-items: baseline; + gap: 0.35rem; + flex: 1; + min-width: 0; +} + +.question-bubble-header strong { + display: inline; + margin-bottom: 0; +} + +.btn-question-play { + flex-shrink: 0; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + line-height: 1.2; +} + +.btn-question-play.is-playing { + border-color: var(--accent-primary); + color: var(--accent-primary); +} + .composer-footer .btn-primary { min-width: 9rem; } diff --git a/static/js/interview_voice.js b/static/js/interview_voice.js index a04856b..a165743 100644 --- a/static/js/interview_voice.js +++ b/static/js/interview_voice.js @@ -1,19 +1,21 @@ (function () { - const composer = document.getElementById("answer-section"); - if (!composer || composer.dataset.questionVoiceEnabled !== "true") { + "use strict"; + + const panel = document.getElementById("theory-panel"); + if (!panel || panel.dataset.questionVoiceEnabled !== "true") { return; } - const interviewId = composer.dataset.interviewId; + const interviewId = panel.dataset.interviewId; if (!interviewId) { return; } const alertsHost = document.getElementById("interview-alerts"); let prepareHint = null; - let playButton = null; let audioElement = null; let loadToken = 0; + let activeAnswerId = null; function showPrepareHint() { if (!alertsHost || prepareHint) { @@ -43,35 +45,13 @@ alertsHost.appendChild(alertDiv); } - function ensurePlayButton(onClick) { - if (playButton) { - playButton.onclick = onClick; - return; - } - const footer = composer.querySelector(".composer-footer"); - if (!footer) { - return; - } - playButton = document.createElement("button"); - playButton.type = "button"; - playButton.className = "btn btn-secondary btn-question-voice"; - playButton.textContent = "Play question"; - playButton.setAttribute("aria-label", "Play question audio"); - playButton.hidden = true; - playButton.addEventListener("click", onClick); - footer.insertBefore(playButton, footer.firstChild); - } - - function hidePlayButton() { - if (playButton) { - playButton.hidden = true; - } - } - - function showPlayButton() { - if (playButton) { - playButton.hidden = false; - } + function setPlayingState(answerId, isPlaying) { + panel.querySelectorAll(".btn-question-play").forEach(function (button) { + const buttonAnswerId = button.dataset.answerId || ""; + const active = isPlaying && buttonAnswerId === String(answerId); + button.classList.toggle("is-playing", active); + button.setAttribute("aria-pressed", active ? "true" : "false"); + }); } function disposeAudio() { @@ -82,12 +62,13 @@ audioElement.remove(); audioElement = null; } + setPlayingState(activeAnswerId, false); + activeAnswerId = null; } function playQuestionAudio(answerId) { const token = ++loadToken; disposeAudio(); - hidePlayButton(); showPrepareHint(); let url = "/interview/" + encodeURIComponent(interviewId) + "/question-audio"; @@ -128,32 +109,36 @@ clearPrepareHint(); const objectUrl = URL.createObjectURL(blob); audioElement = new Audio(objectUrl); + activeAnswerId = answerId != null ? String(answerId) : null; audioElement.addEventListener("ended", function () { URL.revokeObjectURL(objectUrl); + setPlayingState(activeAnswerId, false); + activeAnswerId = null; + audioElement = null; }); - const tryAutoplay = function () { + const startPlayback = function () { const playPromise = audioElement.play(); if (!playPromise || typeof playPromise.then !== "function") { + setPlayingState(activeAnswerId, true); return; } - playPromise.then(function () { - hidePlayButton(); - }).catch(function () { - ensurePlayButton(function () { - audioElement.play().catch(function () { - showVoiceAlert("Could not play question audio.", "warning"); - }); + playPromise + .then(function () { + setPlayingState(activeAnswerId, true); + }) + .catch(function () { + setPlayingState(activeAnswerId, false); + showVoiceAlert("Could not play question audio.", "warning"); }); - showPlayButton(); - }); }; - ensurePlayButton(tryAutoplay); if (audioElement.readyState >= 2) { - tryAutoplay(); + startPlayback(); } else { - audioElement.addEventListener("canplay", tryAutoplay, { once: true }); + audioElement.addEventListener("canplay", startPlayback, { + once: true, + }); } audioElement.load(); }) @@ -169,10 +154,46 @@ }); } + function bindQuestionPlayButtons(root) { + const scope = root || panel; + scope.querySelectorAll(".btn-question-play").forEach(function (button) { + if (button.dataset.voiceBound === "true") { + return; + } + button.dataset.voiceBound = "true"; + button.addEventListener("click", function () { + const answerId = button.dataset.answerId; + if (!answerId) { + return; + } + playQuestionAudio(answerId); + }); + }); + } + window.grillkitPlayQuestionAudio = function (answerId) { playQuestionAudio(answerId || null); }; - const initialAnswerId = composer.dataset.currentAnswerId || null; - playQuestionAudio(initialAnswerId); + window.grillkitBindQuestionPlayButtons = bindQuestionPlayButtons; + + window.grillkitQuestionPlayButtonHtml = function (answerId) { + if (!answerId) { + return ""; + } + return ( + '' + ); + }; + + bindQuestionPlayButtons(panel); + + const composer = document.getElementById("answer-section"); + const initialAnswerId = composer ? composer.dataset.currentAnswerId || null : null; + if (initialAnswerId) { + playQuestionAudio(initialAnswerId); + } })(); diff --git a/static/js/setup_wizard.js b/static/js/setup_wizard.js index f61d51b..ae488a7 100644 --- a/static/js/setup_wizard.js +++ b/static/js/setup_wizard.js @@ -2,6 +2,7 @@ "use strict"; const STORAGE_KEY = "grillkit_setup_wizard"; + const SUBMITTED_KEY = "grillkit_setup_wizard_submitted"; const SESSION_MODE_LABELS = { theory_only: "Theory only", @@ -472,6 +473,38 @@ } } + function clearWizardState() { + try { + sessionStorage.removeItem(STORAGE_KEY); + } catch (_error) { + /* ignore quota errors */ + } + } + + function markWizardSubmitted() { + try { + sessionStorage.setItem(SUBMITTED_KEY, "1"); + } catch (_error) { + /* ignore quota errors */ + } + } + + function clearWizardSubmitted() { + try { + sessionStorage.removeItem(SUBMITTED_KEY); + } catch (_error) { + /* ignore quota errors */ + } + } + + function wasWizardSubmitted() { + try { + return sessionStorage.getItem(SUBMITTED_KEY) === "1"; + } catch (_error) { + return false; + } + } + async function restoreBranchState(prefix, tracks, enableClass, levelClass, topicClass, loadTopics) { for (const block of document.querySelectorAll("." + prefix + "-track-block")) { const track = block.dataset.track; @@ -739,6 +772,7 @@ } selectionInput.value = JSON.stringify(selection); saveWizardState(); + markWizardSubmitted(); }); } @@ -748,7 +782,16 @@ syncTimerFields(); syncCodingTimerFields(); - const restored = await restoreWizardState(); + let restored = false; + if (hasServerError) { + clearWizardSubmitted(); + restored = await restoreWizardState(); + } else if (wasWizardSubmitted()) { + clearWizardSubmitted(); + clearWizardState(); + } else { + restored = await restoreWizardState(); + } let startStep = initialStep; if (restored && hasServerError) { startStep = "review"; diff --git a/templates/interview.html b/templates/interview.html index 86fb1be..a812277 100644 --- a/templates/interview.html +++ b/templates/interview.html @@ -70,8 +70,8 @@

      {{ interview_title }}

      {% endif %}
      Status
      {{ interview.status | capitalize }}
      -
      Timer
      -
      +
      + Timer
      @@ -85,18 +85,32 @@

      {{ interview_title }}

      -
      +
      {% for answer in answers %} {% if answer.answer_text is not none or (current_question and answer.id == current_question.id) %} -
      +
      - Q{{ answer.order }}: - {% if answer.round > 0 %}(follow-up){% endif %} - {{ answer.question_text }} - {% if answer.question_code %} -
      {{ answer.question_code }}
      - {% endif %} +
      + + Q{{ answer.order }}: + {% if answer.round > 0 %}(follow-up){% endif %} + + {% if question_voice_enabled %} + + {% endif %} +
      +
      + {{ answer.question_text }} + {% if answer.question_code %} +
      {{ answer.question_code }}
      + {% endif %} +
      @@ -214,13 +228,9 @@

      {{ interview_title }}

      window.grillkitQuestionTimer.stop(); } const timerRow = document.getElementById("interview-timer-row"); - const timerLabel = document.getElementById("interview-timer-label"); if (timerRow) { timerRow.hidden = true; } - if (timerLabel) { - timerLabel.hidden = true; - } } window.grillkitOnTimerExpired = function () { @@ -467,16 +477,62 @@

      {{ interview_title }}

      } } + function buildQuestionBubbleHtml(options) { + const order = options.order; + const round = options.round != null ? Number(options.round) : 0; + const questionText = options.question_text || ""; + const questionCode = options.question_code; + const answerId = options.id; + let html = '
      '; + html += '
      '; + html += 'Q' + order + ':'; + if (round > 0) { + html += ' (follow-up)'; + } + html += ''; + if (window.grillkitQuestionPlayButtonHtml && answerId) { + html += window.grillkitQuestionPlayButtonHtml(answerId); + } + html += '
      '; + html += escapeHtml(questionText); + if (questionCode) { + html += '
      ' + escapeHtml(questionCode) + '
      '; + } + html += '
      '; + return html; + } + + function appendQuestionMessage(options) { + const chatContainer = document.getElementById('chat-container'); + if (!chatContainer) { + return null; + } + const messageDiv = document.createElement('div'); + messageDiv.className = 'chat-message question'; + messageDiv.setAttribute('data-question-id', options.question_id); + messageDiv.setAttribute('data-round', String(options.round != null ? options.round : 0)); + if (options.id) { + messageDiv.setAttribute('data-answer-id', String(options.id)); + } + messageDiv.innerHTML = buildQuestionBubbleHtml(options); + chatContainer.appendChild(messageDiv); + if (window.grillkitBindQuestionPlayButtons) { + window.grillkitBindQuestionPlayButtons(messageDiv); + } + return messageDiv; + } + function showNextAfterFeedback(data) { const chatContainer = document.getElementById('chat-container'); if (data.follow_up_question) { - const followUpDiv = document.createElement('div'); - followUpDiv.className = 'chat-message question'; - followUpDiv.setAttribute('data-question-id', data.question_id); - followUpDiv.setAttribute('data-round', String(data.round + 1)); - followUpDiv.innerHTML = '
      Q' + data.order + ': (follow-up) ' + escapeHtml(data.follow_up_question) + '
      '; - chatContainer.appendChild(followUpDiv); + appendQuestionMessage({ + question_id: data.question_id, + order: data.order, + round: data.round + 1, + question_text: data.follow_up_question, + id: data.follow_up_answer_id, + }); currentQuestionId = data.question_id; currentRound = data.round + 1; @@ -484,24 +540,13 @@

      {{ interview_title }}

      window.isSubmitting = false; enableForm(true); restartQuestionTimer(data.timer_remaining_seconds); - if (window.grillkitPlayQuestionAudio) { - window.grillkitPlayQuestionAudio(); + if (window.grillkitPlayQuestionAudio && data.follow_up_answer_id) { + window.grillkitPlayQuestionAudio(data.follow_up_answer_id); } } else if (data.next_question) { const nq = data.next_question; - const nextQDiv = document.createElement('div'); - nextQDiv.className = 'chat-message question'; - nextQDiv.setAttribute('data-question-id', nq.question_id); - nextQDiv.setAttribute('data-round', String(nq.round != null ? nq.round : 0)); - let qHtml = '
      '; - qHtml += 'Q' + nq.order + ': ' + escapeHtml(nq.question_text); - if (nq.question_code) { - qHtml += '
      ' + escapeHtml(nq.question_code) + '
      '; - } - qHtml += '
      '; - nextQDiv.innerHTML = qHtml; - chatContainer.appendChild(nextQDiv); + appendQuestionMessage(nq); currentQuestionId = nq.question_id; currentRound = nq.round != null ? nq.round : 0; @@ -510,8 +555,8 @@

      {{ interview_title }}

      window.isSubmitting = false; enableForm(true); restartQuestionTimer(data.timer_remaining_seconds); - if (window.grillkitPlayQuestionAudio) { - window.grillkitPlayQuestionAudio(); + if (window.grillkitPlayQuestionAudio && nq.id) { + window.grillkitPlayQuestionAudio(nq.id); } } else { updateProgressDisplay(null); @@ -632,7 +677,7 @@

      {{ interview_title }}

      {% if audio_answer_enabled %} {% endif %} -{% if question_voice_enabled and interview.status == "active" and current_question and show_theory_panel %} +{% if question_voice_enabled and interview.status == "active" and show_theory_panel %} {% endif %} {% endblock %} diff --git a/tests/test_answer_processing.py b/tests/test_answer_processing.py index 3492928..6fa28c4 100644 --- a/tests/test_answer_processing.py +++ b/tests/test_answer_processing.py @@ -52,7 +52,11 @@ async def test_process_answer_persists_score_and_next_question( feedback = events[2] assert isinstance(feedback, AnswerFeedbackEvent) assert feedback.follow_up_needed is False + reloaded = InterviewQuery.get_interview(interview_id) + assert reloaded is not None + q2 = next(a for a in reloaded.answers if a.question_id == "q2" and a.round == 0) assert feedback.next_question == { + "id": q2.id, "question_id": "q2", "order": 2, "question_text": "Question two?", @@ -60,8 +64,6 @@ async def test_process_answer_persists_score_and_next_question( "round": 0, } - reloaded = InterviewQuery.get_interview(interview_id) - assert reloaded is not None q1 = next(a for a in reloaded.answers if a.question_id == "q1" and a.round == 0) assert q1.answer_text == "Lists are mutable." assert q1.score == 5 diff --git a/tests/test_interview_ws_integration.py b/tests/test_interview_ws_integration.py index e04f50e..ab905dc 100644 --- a/tests/test_interview_ws_integration.py +++ b/tests/test_interview_ws_integration.py @@ -91,7 +91,11 @@ def test_websocket_answer_runs_full_processing_pipeline( assert feedback["round"] == 0 assert feedback["timed_out"] is False assert feedback["follow_up_question"] is None + reloaded = InterviewQuery.get_interview(interview_id) + assert reloaded is not None + answer2 = next(a for a in reloaded.answers if a.question_id == "q2") assert feedback["next_question"] == { + "id": answer2.id, "question_id": "q2", "order": 2, "question_text": "Question two?", @@ -99,8 +103,6 @@ def test_websocket_answer_runs_full_processing_pipeline( "round": 0, } - reloaded = InterviewQuery.get_interview(interview_id) - assert reloaded is not None answer = next(a for a in reloaded.answers if a.question_id == "q1") assert answer.answer_text == "My structured answer." assert answer.score == 4 diff --git a/tests/test_session_phases.py b/tests/test_session_phases.py index c68220e..a3d8712 100644 --- a/tests/test_session_phases.py +++ b/tests/test_session_phases.py @@ -62,6 +62,73 @@ def _complete_theory_section(interview_id: str) -> None: uow.theory_sections.save_aggregate(replace(section, tasks=tasks)) +def test_pending_coding_section_does_not_start_timer_at_creation( + isolated_db, temp_questions_dir, monkeypatch +) -> None: + """Pending coding sections defer the task timer until the coding phase begins.""" + del temp_questions_dir + monkeypatch.setattr("random.shuffle", lambda items: None) + + interview = SessionCreationService.create_session( + _theory_then_coding_session(), + locale="en", + ) + + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview.id) + assert section is not None + assert section.status == "pending" + assert section.tasks[0].started_at is None + + +def test_coding_then_theory_defers_theory_timer_until_theory_phase( + isolated_db, temp_questions_dir, monkeypatch +) -> None: + """Theory timers start only after the coding phase when theory comes second.""" + del temp_questions_dir + monkeypatch.setattr("random.shuffle", lambda items: None) + + session = SessionSelection( + session_mode="coding_then_theory", + theory=SectionBranchSpec( + enabled=True, + question_count=1, + task_time_limit_seconds=120, + sources=( + TrackSelection( + track="python", + level="junior", + categories=("data-structures",), + ), + ), + ), + coding=SectionBranchSpec( + enabled=True, + question_count=1, + task_time_limit_seconds=600, + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + ), + ) + interview = SessionCreationService.create_session(session, locale="en") + + with TheoryUnitOfWork() as uow: + theory = uow.theory_sections.get_aggregate(interview.id) + assert theory is not None + assert theory.tasks[0].started_at is None + + with CodingUnitOfWork() as uow: + coding = uow.coding_sections.get_aggregate(interview.id) + assert coding is not None + assert coding.status == "active" + assert coding.tasks[0].started_at is not None + + def test_activate_pending_promotes_coding_after_theory_complete( isolated_db, temp_questions_dir, monkeypatch ) -> None: diff --git a/tests/test_theory_section.py b/tests/test_theory_section.py index ee967d7..16fc73d 100644 --- a/tests/test_theory_section.py +++ b/tests/test_theory_section.py @@ -96,6 +96,19 @@ def test_start_builds_tasks_with_timer_on_first(self) -> None: assert section.tasks[1].started_at is None + def test_start_defers_timer_when_not_first_task(self) -> None: + """First task timer stays unset when the section is not active yet.""" + section = DomainTheorySection.start( + "iv-1", + selection=_sample_selection(), + locale="en", + planned_questions=_sample_planned(), + task_time_limit_seconds=90, + start_first_task_timer=False, + ) + assert section.tasks[0].started_at is None + + class TestTheorySectionRepository: """Theory section persistence.""" From dd3ad1010ff286f976530b8fda7f0d1f907f70bc Mon Sep 17 00:00:00 2001 From: vitchenkokir Date: Fri, 12 Jun 2026 08:53:03 +0300 Subject: [PATCH 3/4] ruff format --- app/coding/domain/entities.py | 4 +--- app/interview/services/sections.py | 4 +++- tests/test_theory_section.py | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/coding/domain/entities.py b/app/coding/domain/entities.py index 83701e1..566f8c0 100644 --- a/app/coding/domain/entities.py +++ b/app/coding/domain/entities.py @@ -200,9 +200,7 @@ def start( when = datetime.now(UTC) task_ids = tuple(task.id for task in planned_tasks) timer_start = ( - when - if task_time_limit_seconds is not None and status == "active" - else None + when if task_time_limit_seconds is not None and status == "active" else None ) tasks: list[CodingTask] = [] for order, planned in enumerate(planned_tasks, start=1): diff --git a/app/interview/services/sections.py b/app/interview/services/sections.py index 876af43..2579cdf 100644 --- a/app/interview/services/sections.py +++ b/app/interview/services/sections.py @@ -102,7 +102,9 @@ def phase_order_for_mode(session_mode: SessionMode) -> tuple[SectionKind, ...]: return ("coding", "theory") -def is_first_user_facing_section(session_mode: SessionMode, section: SectionKind) -> bool: +def is_first_user_facing_section( + session_mode: SessionMode, section: SectionKind +) -> bool: """Return whether ``section`` is the first interactive phase for a session mode. Args: diff --git a/tests/test_theory_section.py b/tests/test_theory_section.py index 16fc73d..1623db5 100644 --- a/tests/test_theory_section.py +++ b/tests/test_theory_section.py @@ -95,7 +95,6 @@ def test_start_builds_tasks_with_timer_on_first(self) -> None: assert section.tasks[0].started_at is not None assert section.tasks[1].started_at is None - def test_start_defers_timer_when_not_first_task(self) -> None: """First task timer stays unset when the section is not active yet.""" section = DomainTheorySection.start( From b2369e7b03a23b79a86d917e5436471bda22c48e Mon Sep 17 00:00:00 2001 From: vitchenkokir Date: Fri, 12 Jun 2026 09:38:45 +0300 Subject: [PATCH 4/4] 2026.6.12 --- ARCHITECTURE.md | 185 +++++- CHANGELOG.md | 64 +-- README.md | 61 +- app/main.py | 2 +- assets/coding.png | Bin 0 -> 76034 bytes data/coding/python/junior/basics.yaml | 102 ++++ data/coding/python/junior/control-flow.yaml | 63 ++ data/coding/python/junior/exceptions.yaml | 60 ++ data/coding/python/junior/functions.yaml | 93 +++ data/coding/python/junior/strings.yaml | 66 +++ data/coding/python/middle/bug-hunt.yaml | 66 +++ data/coding/python/middle/complete-code.yaml | 80 +++ data/coding/python/middle/implement.yaml | 41 ++ data/coding/python/middle/refactor.yaml | 66 +++ tests/{ => ai}/test_audio_probe.py | 0 tests/{test_ai_base.py => ai/test_base.py} | 0 .../test_factory.py} | 0 tests/{ => ai}/test_openai_compatible.py | 0 tests/{ => app}/test_main.py | 0 .../api/test_routes.py} | 0 .../repositories/test_coding_section.py} | 0 .../services/test_availability.py} | 0 .../services/test_evaluator.py} | 25 + .../services/test_harness.py} | 0 .../services}/test_judge0_client.py | 0 .../services/test_page.py} | 0 .../services/test_planning.py} | 2 +- .../services/test_runner.py} | 0 .../services/test_section.py} | 0 tests/conftest.py | 2 +- .../api/test_errors.py} | 0 .../api/test_setup.py} | 0 .../repositories/test_interview.py} | 0 .../services/rules/test_feedback.py} | 0 .../services/test_completion.py} | 0 .../services/test_creation.py} | 0 .../services/test_dashboard.py} | 0 .../services/test_page.py} | 0 .../services/test_phases.py} | 0 .../services}/test_section_feedback.py | 0 .../services/test_selection.py} | 0 .../services}/test_session_evaluation.py | 0 .../services/test_config.py} | 0 .../services}/test_llm_catalog.py | 0 .../api/test_tts.py} | 0 .../services}/test_piper_storage.py | 0 .../services}/test_tts_cache.py | 0 .../test_alembic_migrations.py | 0 .../infrastructure}/test_artifact_download.py | 0 .../infrastructure}/test_artifact_status.py | 0 .../infrastructure}/test_audio_wav.py | 0 .../infrastructure}/test_database.py | 0 .../test_hf_download_progress.py | 0 .../infrastructure}/test_hf_hub_runtime.py | 0 tests/{ => shared/infrastructure}/test_uow.py | 0 .../test_coding.py} | 15 +- tests/{ => shared}/test_locales.py | 0 tests/{ => shared}/test_questions.py | 0 tests/{ => shared}/test_speech_models.py | 0 tests/{ => speech/api}/test_dictation_ws.py | 0 .../api/test_routes.py} | 0 .../services/test_dictation.py} | 0 .../services}/test_whisper_runtime.py | 0 tests/test_answer_processing.py | 490 ---------------- tests/test_api_routers.py | 540 ------------------ tests/test_audio_answer_processing.py | 203 ------- tests/test_session_results.py | 241 -------- .../api/test_audio_answer.py} | 0 .../api/test_routes.py} | 0 tests/{ => theory/api}/test_ws_protocol.py | 0 .../integration/test_ws.py} | 0 .../repositories}/test_theory_section.py | 0 .../services/test_evaluator.py} | 0 .../services/test_evaluator_parsing.py} | 0 .../services/test_planning.py} | 0 75 files changed, 892 insertions(+), 1575 deletions(-) create mode 100644 assets/coding.png rename tests/{ => ai}/test_audio_probe.py (100%) rename tests/{test_ai_base.py => ai/test_base.py} (100%) rename tests/{test_ai_factory.py => ai/test_factory.py} (100%) rename tests/{ => ai}/test_openai_compatible.py (100%) rename tests/{ => app}/test_main.py (100%) rename tests/{test_coding_api.py => coding/api/test_routes.py} (100%) rename tests/{test_coding_repository.py => coding/repositories/test_coding_section.py} (100%) rename tests/{test_coding_availability.py => coding/services/test_availability.py} (100%) rename tests/{test_coding_evaluator.py => coding/services/test_evaluator.py} (66%) rename tests/{test_coding_harness.py => coding/services/test_harness.py} (100%) rename tests/{ => coding/services}/test_judge0_client.py (100%) rename tests/{test_coding_page.py => coding/services/test_page.py} (100%) rename tests/{test_coding_planning.py => coding/services/test_planning.py} (98%) rename tests/{test_coding_runner.py => coding/services/test_runner.py} (100%) rename tests/{test_coding_section_service.py => coding/services/test_section.py} (100%) rename tests/{test_interview_errors.py => interview/api/test_errors.py} (100%) rename tests/{test_setup_api.py => interview/api/test_setup.py} (100%) rename tests/{test_repositories.py => interview/repositories/test_interview.py} (100%) rename tests/{test_interview_timer.py => interview/services/rules/test_feedback.py} (100%) rename tests/{test_interview_completion.py => interview/services/test_completion.py} (100%) rename tests/{test_interview_creation.py => interview/services/test_creation.py} (100%) rename tests/{test_dashboard_query.py => interview/services/test_dashboard.py} (100%) rename tests/{test_interview_page.py => interview/services/test_page.py} (100%) rename tests/{test_session_phases.py => interview/services/test_phases.py} (100%) rename tests/{ => interview/services}/test_section_feedback.py (100%) rename tests/{test_interview_selection.py => interview/services/test_selection.py} (100%) rename tests/{ => interview/services}/test_session_evaluation.py (100%) rename tests/{test_config_service.py => platform/services/test_config.py} (100%) rename tests/{ => platform/services}/test_llm_catalog.py (100%) rename tests/{test_tts_api.py => question_voice/api/test_tts.py} (100%) rename tests/{ => question_voice/services}/test_piper_storage.py (100%) rename tests/{ => question_voice/services}/test_tts_cache.py (100%) rename tests/{ => shared/infrastructure}/test_alembic_migrations.py (100%) rename tests/{ => shared/infrastructure}/test_artifact_download.py (100%) rename tests/{ => shared/infrastructure}/test_artifact_status.py (100%) rename tests/{ => shared/infrastructure}/test_audio_wav.py (100%) rename tests/{ => shared/infrastructure}/test_database.py (100%) rename tests/{ => shared/infrastructure}/test_hf_download_progress.py (100%) rename tests/{ => shared/infrastructure}/test_hf_hub_runtime.py (100%) rename tests/{ => shared/infrastructure}/test_uow.py (100%) rename tests/{test_coding_tasks.py => shared/test_coding.py} (93%) rename tests/{ => shared}/test_locales.py (100%) rename tests/{ => shared}/test_questions.py (100%) rename tests/{ => shared}/test_speech_models.py (100%) rename tests/{ => speech/api}/test_dictation_ws.py (100%) rename tests/{test_speech_api.py => speech/api/test_routes.py} (100%) rename tests/{test_speech_recognition.py => speech/services/test_dictation.py} (100%) rename tests/{ => speech/services}/test_whisper_runtime.py (100%) delete mode 100644 tests/test_answer_processing.py delete mode 100644 tests/test_api_routers.py delete mode 100644 tests/test_audio_answer_processing.py delete mode 100644 tests/test_session_results.py rename tests/{test_audio_answer_api.py => theory/api/test_audio_answer.py} (100%) rename tests/{test_theory_api.py => theory/api/test_routes.py} (100%) rename tests/{ => theory/api}/test_ws_protocol.py (100%) rename tests/{test_interview_ws_integration.py => theory/integration/test_ws.py} (100%) rename tests/{ => theory/repositories}/test_theory_section.py (100%) rename tests/{test_answer_ai_evaluation.py => theory/services/test_evaluator.py} (100%) rename tests/{test_theory_evaluator_parsing.py => theory/services/test_evaluator_parsing.py} (100%) rename tests/{test_theory_planning.py => theory/services/test_planning.py} (100%) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 02c9240..3f17f9c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,7 +4,7 @@ User-facing overview, screenshots, and quick start: [README.md](README.md). GrillKit is an AI-powered technical interview trainer. The stack is **FastAPI** (HTTP + WebSocket), **SQLAlchemy** (SQLite), **Alembic** (schema and data migrations), **Jinja2** templates, and **OpenAI-compatible** plus **faster-whisper** adapters in `ai/`. Code is organized **by feature** (`interview/`, `theory/`, `coding/`, `speech/`, `question_voice/`, `platform/`) with cross-cutting code in `shared/`. -**Session orchestration** lives in `interview/`: setup, dashboard, session shell (`Interview`), page composition, phase order, completion, and `selection_spec` v2 (`session_mode`). **Theory flow** lives in `theory/`: questions, tasks, timer, WebSocket/audio submit, and AI evaluation. **Coding flow** lives in `coding/`: YAML task banks, Monaco UI, Judge0 Run attempts, WebSocket submit, and AI evaluation. The interview shell does not own section tasks; `InterviewRead` composes theory task rows at read time via `theory_sections` + `answers`. +**Session orchestration** lives in `interview/`: setup, dashboard, session shell (`Interview`), page composition, phase order, completion, results hub, and `selection_spec` v2 (`session_mode`). **Theory flow** lives in `theory/`: questions, tasks, timer, WebSocket/audio submit, AI evaluation, and post-session review. **Coding flow** lives in `coding/`: YAML task banks, Monaco UI, Judge0 Run attempts, WebSocket submit, AI evaluation, and post-session review. The interview shell does not own section tasks; `InterviewRead` composes theory task rows at read time via `theory_sections` + `answers`, and coding context from `coding_sections` + `coding_tasks`. Within each feature: transport in `api/`, orchestration in `services/`, Pydantic read models in `schemas/` (where present), persistence in `repositories/`. Domain layers use frozen aggregates and value objects separate from ORM and DTOs. Transactions use `InterviewUnitOfWork` / `TheoryUnitOfWork` extending `shared/infrastructure/uow.py`. APIs do not expose SQLAlchemy models on the wire. @@ -29,10 +29,14 @@ grillkit/ │ │ ├── questions.py # YAML theory question loader (data/questions/) │ │ ├── coding.py # YAML coding task loader (data/coding/) │ │ ├── locales.py # SUPPORTED_LOCALES, normalize_locale() +│ │ ├── structured_evaluation.py # Shared LLM JSON parse helpers +│ │ ├── evaluation_models.py # Section/session evaluation DTOs +│ │ ├── task_timer.py # Per-round timer helpers │ │ ├── infrastructure/ │ │ │ ├── database.py # engine, SessionLocal, DATABASE_URL env, run_migrations() -│ │ │ ├── models.py # Interview, TheorySection, Answer (theory tasks) ORM +│ │ │ ├── models.py # Interview, TheorySection, Answer, CodingSection, CodingTask, CodeRunAttempt │ │ │ ├── audio_wav.py # Canonical mono 16 kHz WAV validation +│ │ │ ├── hf_hub_runtime.py, hf_download_progress.py, artifact_* │ │ │ └── uow.py # Base UnitOfWork: session, commit, rollback │ │ └── repositories/ │ │ └── base.py # Repository[T], SqlAlchemyRepository[T] @@ -73,13 +77,16 @@ grillkit/ │ │ │ ├── sections.py # Section registry and shared section DTOs │ │ │ ├── evaluation_aggregator.py │ │ │ ├── session_evaluator.py -│ │ │ └── events.py +│ │ │ ├── results_page.py # SessionResultsPageService (completed hub) +│ │ │ ├── section_feedback.py, section_evaluation.py, scoring.py +│ │ │ └── events.py # Shared WS/NDJSON event types (theory + coding) │ │ └── api/ │ │ ├── deps.py │ │ ├── dashboard.py # GET / -│ │ ├── setup.py # GET/POST /setup +│ │ ├── setup.py # GET/POST /setup, cascaded options │ │ ├── setup_form.py │ │ ├── routes.py # GET /interview/{id}, question-audio +│ │ ├── results.py # GET /results, /theory, /coding (completed sessions) │ │ └── errors.py │ ├── coding/ # Coding section (tasks, Judge0 runner, WS/API, evaluator) │ │ ├── domain/ # CodingSection, CodingTask, CodeRunAttempt aggregates @@ -91,7 +98,7 @@ grillkit/ │ │ │ ├── runner.py # CodingRunnerService (public/hidden tests, compile-only) │ │ │ ├── run_execution.py, submission.py, navigation.py, state.py, page.py │ │ │ ├── judge0_client.py, judge0_config.py, harness.py -│ │ │ ├── section.py, query.py +│ │ │ ├── section.py, query.py, review.py │ │ │ └── evaluator/ # CodingEvaluatorService │ │ ├── api/ │ │ │ ├── routes.py # POST /coding/run, GET /coding/state, WS /coding/ws @@ -106,7 +113,7 @@ grillkit/ │ │ │ ├── creation.py # TheorySectionCreationService │ │ │ ├── submission.py # answer/timeout/audio orchestration │ │ │ ├── navigation.py, timer.py, evaluation_persistence.py -│ │ │ ├── page.py, query.py, section.py +│ │ │ ├── page.py, query.py, section.py, review.py │ │ │ └── evaluator/ # TheoryEvaluatorService │ │ └── api/ │ │ ├── routes.py # WS /theory/ws, POST /theory/audio-answer @@ -136,10 +143,20 @@ grillkit/ │ └── questions/ # YAML banks: {track}/{level}/{category}.yaml ├── alembic/ # Schema and data migrations ├── alembic.ini -├── docker-compose.yml # app service only +├── docker-compose.yml # app (+ optional Judge0 profile `coding`) ├── docker-entrypoint.sh # PUID/PGID, ensures data/db writable ├── Dockerfile # Multi-stage uv build → uvicorn -└── tests/ +└── tests/ # Mirrors app/ layout (see Tests) + ├── conftest.py, fakes.py + ├── helpers/ # Flat shared seeds (interview_seed, coding_seed, …) + ├── ai/, app/ + ├── interview/{api,repositories,services/rules,services}/ + ├── theory/{api,services,repositories,integration}/ + ├── coding/{api,services,repositories}/ + ├── speech/{api,services}/ + ├── question_voice/{api,services}/ + ├── platform/{api,services}/ + └── shared/{infrastructure}/ ``` ## HTTP Routes @@ -149,7 +166,9 @@ grillkit/ | GET | `/` | `interview/api/dashboard.py` | Interview history (last 20) | | GET | `/setup` | `interview/api/setup.py` | New interview form (redirects to `/config` if unset) | | POST | `/setup` | `interview/api/setup.py` | Create interview → redirect `/interview/{id}` | -| GET | `/setup/options` | `interview/api/setup.py` | Cascaded JSON: tracks → levels → categories | +| GET | `/setup/options` | `interview/api/setup.py` | Cascaded JSON: theory tracks → levels → categories | +| GET | `/setup/coding-options` | `interview/api/setup.py` | Cascaded JSON: coding tracks → levels → categories | +| GET | `/setup/coding-available` | `interview/api/setup.py` | JSON: whether coding modes are offered (Judge0 health) | | GET | `/config` | `platform/api/config.py` | AI provider configuration form | | POST | `/config` | `platform/api/config.py` | Test connection (via form dependency), then save | | POST | `/config/test` | `platform/api/config.py` | Test connection without saving | @@ -160,10 +179,16 @@ grillkit/ | GET | `/speech/model/options` | `speech/api/routes.py` | JSON size trade-off metadata | | GET | `/speech/tts/status` | `question_voice/api/routes.py` | Piper voice status (HTML fragment or JSON) when question voice is enabled | | POST | `/speech/tts/voice/download` | `question_voice/api/routes.py` | Start Piper voice download for configured `tts_voice_id` | -| GET | `/interview/{interview_id}` | `interview/api/routes.py` | Session page (composed shell + theory context) | +| GET | `/interview/{interview_id}` | `interview/api/routes.py` | Active session page (theory and/or coding by phase); completed → redirect `/results` | +| GET | `/interview/{interview_id}/results` | `interview/api/results.py` | Completed session hub: overall evaluation + section cards | +| GET | `/interview/{interview_id}/theory` | `interview/api/results.py` | Theory review: chat history and section feedback (completed only) | +| GET | `/interview/{interview_id}/coding` | `interview/api/results.py` | Coding review: per-task accordion with submits and feedback (completed only) | | GET | `/interview/{interview_id}/question-audio` | `interview/api/routes.py` | WAV for current theory task (`answer_id` query param) | | POST | `/interview/{interview_id}/theory/audio-answer` | `theory/api/routes.py` | Multipart WAV theory answer → NDJSON | | WS | `/interview/{interview_id}/theory/ws` | `theory/api/routes.py` | Real-time theory task submit, timeout, session complete | +| POST | `/interview/{interview_id}/coding/run` | `coding/api/routes.py` | Run public tests via Judge0; persist `CodeRunAttempt` | +| GET | `/interview/{interview_id}/coding/state` | `coding/api/routes.py` | Current coding task, progress, run history | +| WS | `/interview/{interview_id}/coding/ws` | `coding/api/routes.py` | Coding submit, hidden tests, AI evaluation stream | | WS | `/interview/{interview_id}/dictation` | `speech/api/dictation.py` | PCM dictation: `start` → `ready`, audio chunks, `stop` → `final` | | — | `/static/*` | `main.py` | CSS, JS, and assets | @@ -175,6 +200,7 @@ grillkit/ | `*/api/deps.py` | Inject service **classes** via `Depends` (handlers call static methods) | | `interview/domain/` | Interview session shell aggregate, `SessionSelection`, serialization, domain exceptions | | `theory/domain/` | `TheorySection` / `TheoryTask` aggregates and theory-specific exceptions | +| `coding/domain/` | `CodingSection` / `CodingTask` / `CodeRunAttempt` aggregates and coding exceptions | | `interview/schemas/` | Session read models (`InterviewRead`, dashboard/page context) | | `theory/schemas/` | Theory read models and WebSocket wire message types | | `interview/repositories/mappers.py` | Shell ORM ↔ domain; composes `InterviewRead` with theory tasks | @@ -190,6 +216,9 @@ grillkit/ | `shared/infrastructure/uow.py` | Base transaction boundary (session lifecycle) | | `interview/repositories/uow.py` | `InterviewUnitOfWork`: `uow.interviews`, `uow.theory_sections` | | `theory/repositories/uow.py` | `TheoryUnitOfWork`: theory section persistence | +| `coding/repositories/uow.py` | `CodingUnitOfWork`: coding section + run attempts | +| `interview/services/results_page.py` | Completed session hub context (`SessionResultsPageService`) | +| `theory/services/review.py`, `coding/services/review.py` | Post-session section review page builders | | `shared/infrastructure/models.py` | ORM models | | `ai/` | Provider adapters (`AIProvider`, `SpeechTranscriber`) | | `shared/questions.py` | Read-only YAML question bank access | @@ -221,18 +250,24 @@ question_voice/services/ └── tts_cache.py ──► data/tts-cache/v2/{locale}/ interview/services/ - ├── creation.py ──► SessionCreationService, TheorySectionCreationService - ├── page.py ──► SessionPageService, TheoryPageService + ├── creation.py ──► SessionCreationService + section creation services + ├── page.py ──► SessionPageService, TheoryPageService, CodingPageService ├── completion.py ──► SessionCompletionService, SessionEvaluationAggregator + ├── results_page.py ──► completed hub; review links via section registry ├── query.py, dashboard.py, phases.py, sections.py - └── session_evaluator.py ──► session-level narrative (delegates section eval to theory) + └── session_evaluator.py ──► session-level narrative (theory + coding sections) theory/services/ ├── planning.py ──► app/shared/questions.py (filters type=coding) - ├── creation.py, submission.py, navigation.py, timer.py + ├── creation.py, submission.py, navigation.py, timer.py, review.py ├── section.py ──► section registry hooks + prefetch └── evaluator/ ──► TheoryEvaluatorService (per-task + section narrative) +coding/services/ + ├── planning.py ──► app/shared/coding.py + ├── runner.py, submission.py, section.py, review.py + └── evaluator/ ──► CodingEvaluatorService (per-task + section narrative) + interview/api/deps.py ──► platform/services/ai_context (yields AIProvider for WS/routes) platform/services/config.py ──► ai/factory, speech/schemas, data/config.json @@ -243,7 +278,7 @@ speech/services/ └── dictation.py ──► ai/speech_transcriber shared/infrastructure/uow.py - └── interview/repositories/, theory/repositories/ ──► shared/repositories/base, models + └── interview/, theory/, coding/ repositories ──► shared/repositories/base, models ``` On GitHub, the same graph is also available as Mermaid (rendered on github.com only): @@ -284,8 +319,20 @@ flowchart TB interview_creation[creation] interview_query[query] interview_completion[completion] - answer_processing - interview_evaluator[evaluator] + interview_phases[phases] + session_evaluator[session_evaluator] + results_page[results_page] + end + subgraph theory_svc [theory/services] + theory_submission[submission] + theory_evaluator[evaluator] + theory_review[review] + end + subgraph coding_svc [coding/services] + coding_submission[submission] + coding_runner[runner] + coding_evaluator[evaluator] + coding_review[review] end subgraph platform_svc [platform/services] config_service[config] @@ -304,8 +351,12 @@ flowchart TB interview_svc --> uow interview_svc --> questions_mod[questions] interview_creation --> questions_mod - interview_completion --> interview_evaluator - answer_processing --> interview_evaluator + interview_completion --> session_evaluator + theory_submission --> theory_evaluator + coding_submission --> coding_runner + coding_submission --> coding_evaluator + results_page --> theory_review + results_page --> coding_review ai_context --> config_service ai_context --> ai_layer subgraph ai_layer [ai] @@ -316,9 +367,14 @@ flowchart TB uow --> repos subgraph interview_repos [interview/repositories] interview_repo[interview] - answer_repo[answer] repo_mappers[mappers] end + subgraph theory_repos [theory/repositories] + theory_section_repo[theory_section] + end + subgraph coding_repos [coding/repositories] + coding_section_repo[coding_section] + end interview_repos --> models repo_mappers --> interview_domain ``` @@ -331,16 +387,21 @@ flowchart TB |---------|----------------| | Session shell aggregate | `app.interview.domain.entities.Interview` | | Theory section aggregate | `app.theory.domain.entities.TheorySection` | +| Coding section aggregate | `app.coding.domain.entities.CodingSection` | | Interview ORM model | `shared.infrastructure.models.Interview` (table `interviews`) | | Theory task ORM | `shared.infrastructure.models.Answer` (table `answers`, FK `theory_section_id`) | +| Coding task ORM | `shared.infrastructure.models.CodingTask` (table `coding_tasks`) | +| Coding run snapshot ORM | `shared.infrastructure.models.CodeRunAttempt` | | Session read DTO | `app.interview.schemas.interview.InterviewRead` (composes theory tasks) | | Theory task read DTO | `app.theory.schemas.theory.TheoryTaskRead` | | Route / WS path param | `interview_id` (same value as `Interview.id`) | -| Create flow | `SessionCreationService.create_session()` + `TheorySectionCreationService.create()` | +| Create flow | `SessionCreationService.create_session()` + section creation services when enabled | | Read flow | `InterviewQuery.get_interview()`, `DashboardBuilder.list_rows()` | -| Theory submit | `TheorySubmissionService` (WS + audio) | | Complete flow | `SessionCompletionService.complete_session()` | -| UoW repositories | `uow.interviews`, `uow.theory_sections` | +| Results hub | `SessionResultsPageService.prepare_page()` | +| UoW repositories | `uow.interviews`, `uow.theory_sections`, `uow.coding_sections` (per feature UoW) | +| Theory submit | `TheorySubmissionService` (WS + audio + timeouts) | +| Coding submit | `CodingSubmissionService` (WS submit after Run history) | | SQLAlchemy session | `uow.session` | ## Key Models @@ -386,6 +447,35 @@ flowchart TB Initial task rows are created with the theory section; follow-ups append via `TheorySectionRepository.save_aggregate`. +### CodingSection (`coding_sections`) + +| Field | Type | Notes | +|-------|------|-------| +| `id` | `int` | Auto-increment PK | +| `interview_id` | `str` | FK to `interviews.id` (1:0..1) | +| `selection_spec` | `str` | Coding branch selection JSON | +| `task_count` | `int` | Number of coding tasks in section | +| `task_time_limit_seconds` | `int \| None` | Per-task timer (`None` = off) | +| `status` | `str` | `pending`, `active`, `completed`, or `skipped` | +| `section_score`, `section_feedback` | | Section narrative (prefetched after phase complete) | +| `locale` | `str` | Section locale snapshot | + +### CodingTask (`coding_tasks`) + +| Field | Type | Notes | +|-------|------|-------| +| `id` | `int` | Auto-increment PK | +| `coding_section_id` | `int` | FK to `coding_sections.id` | +| `task_id` | `str` | ID from coding YAML bank | +| `order` | `int` | 1-based display order | +| `round` | `int` | `0` = initial; `1+` = AI follow-up (code or explanation) | +| `prompt_text`, `task_spec` | `str` | Snapshot at ask time (`task_spec` is JSON) | +| `submitted_code` | `str \| None` | Final code for the round | +| `submit_test_summary` | `str \| None` | JSON hidden-test outcome on submit | +| `score`, `feedback` | | After AI evaluation (1–5) | + +`CodeRunAttempt` rows store each **Run** snapshot (code, stderr, public test results) for AI context on submit. + ## Data Flow: Configure Provider ``` @@ -516,6 +606,29 @@ Client → WS /interview/{id}/theory/ws {"type":"complete"} Display score sums `score_breakdown.theory.score` and `score_breakdown.coding.score` when both sections exist. Ending early marks an incomplete enabled section as skipped (score 0 for that section). +## Data Flow: Results and Review Pages + +``` +GET /interview/{id} on completed session + → SessionPageService redirects 303 → /interview/{id}/results + +GET /interview/{id}/results + → SessionResultsPageService.prepare_page() + → load completed InterviewRead + overall_feedback JSON + → section registry builds cards (theory/coding) with review URLs + → session_results.html + +GET /interview/{id}/theory + → TheoryReviewService.build_context() — answered rounds + section_feedback + → theory_review.html (redirect to /results if section missing) + +GET /interview/{id}/coding + → CodingReviewService.build_context() — tasks grouped by task_id with rounds + → coding_review.html +``` + +Dashboard history links to `/interview/{id}/results` for completed sessions. + ## Data Access Pattern ```python @@ -649,6 +762,32 @@ Follow-up rounds use the same pipeline (cache key from localized `question_text` | Audio flag | `accepts_audio_input` on `LLMModelEntry` — enables interview audio-answer UI and config audio probe | | Effective config | `ConfigService.resolve_effective_config()` applies catalog `base_url`, `model`, and `api_key` | +## Tests + +Pytest discovers modules under `tests/` (`pyproject.toml` → `testpaths = ["tests"]`). Layout **mirrors `app/`** so each feature owns its tests: + +| `app/` package | `tests/` mirror | Typical modules | +|----------------|-----------------|-----------------| +| `ai/` | `tests/ai/` | `test_base.py`, `test_factory.py`, `test_openai_compatible.py` | +| `interview/` | `tests/interview/{api,repositories,services}/` | `test_creation.py`, `test_phases.py`, `test_results.py` | +| `theory/` | `tests/theory/{api,services,repositories,integration}/` | `test_submission.py`, `test_ws_routes.py`, `test_review.py` | +| `coding/` | `tests/coding/{api,services,repositories}/` | `test_runner.py`, `test_evaluator.py`, `test_review.py` | +| `speech/`, `question_voice/` | `tests/speech/`, `tests/question_voice/` | API + service tests | +| `platform/` | `tests/platform/{api,services}/` | `test_config.py`, `test_llm_catalog.py` | +| `shared/` | `tests/shared/` (+ `infrastructure/`) | `test_questions.py`, `test_coding.py`, `test_uow.py` | +| `main.py` | `tests/app/` | `test_main.py` | + +Shared fixtures live in `tests/conftest.py` (`client`, `isolated_db`, `fake_ai_provider`, `override_ws_ai_provider`). Cross-feature seeds stay **flat** in `tests/helpers/` (`interview_seed.py`, `coding_seed.py`, `completed_session_seed.py`, …). `tests/fakes.py` provides `FakeProvider` and canned evaluation JSON. + +`tests/shared/test_questions.py` is loaded via `pytest_plugins` in `conftest.py` for the `temp_questions_dir` fixture used by creation tests. + +Run the suite: + +```bash +uv run pytest +uv run pytest tests/theory/services/test_submission.py # single module +``` + ## Current Limitations - Only one AI adapter type is implemented: `openai-compatible` (`ProviderFactory`) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc1119..af26e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,62 +8,26 @@ Work in progress is accumulated under `[Unreleased]`; on release, that section b ### Added -- **Session results hub** — completed interviews redirect to `/interview/{id}/results` with overall evaluation and per-section summary cards linking to dedicated review pages -- **Theory review page** — `/interview/{id}/theory` shows section feedback and full Q&A chat history with per-round scores after session completion -- **Coding review page** — `/interview/{id}/coding` shows section feedback and an accordion of coding tasks with final submit, test summary, and per-round feedback on one page -- **Coding section evaluator** — `CodingEvaluatorService.evaluate_section()` prefetches `coding_sections.section_feedback` when the coding phase completes and before session completion -- **Coding interview UI** — separate coding panel with Monaco editor (CDN), Run (`POST /coding/run`), Submit (`WS /coding/ws`), run output with test progress, `sessionStorage` drafts, and phase switch between theory and coding by `session_mode` -- **CodingEvaluatorService** — AI scoring for coding submit with run history and hidden test context in prompts; `follow_up_mode: code | explanation`; hidden test failures cap score at 3 -- **Coding Run API** — `POST /interview/{id}/coding/run` executes public tests via Judge0 and persists `CodeRunAttempt`; `GET /interview/{id}/coding/state` returns current task, progress, and run history; `WS /interview/{id}/coding/ws` accepts submit and streams `feedback` -- **Judge0 coding runner** — `CodingRunnerService` executes public tests and compile-only checks via `Judge0Client`; Python harness wraps candidate code for entrypoint tasks; setup blocks coding when Judge0 is unhealthy (`CODING_ENABLED` + health probe) -- **Judge0 Docker profile** — `docker compose --profile coding up` starts Judge0 CE (server, worker, Postgres, Redis); `deploy/judge0.conf` and env vars `JUDGE0_URL`, `JUDGE0_AUTH_TOKEN` -- **Coding setup and planning** — all four `session_mode` options on setup when coding is available; `GET /setup/coding-options` and `GET /setup/coding-available`; `app/coding/services/planning.py` picks tasks from `data/coding/`; `SessionCreationService` creates coding sections via `CodingSectionCreationService` -- **Dashboard session mode badge** — history rows show Theory, Coding, or Theory+Coding from `session_mode` -- **`app/theory/` module scaffold** — domain (`TheorySection`, `TheoryTask`), repositories, read schemas, and `theory_sections` table with backfill from existing interviews -- **Theory section tasks** — `answers.theory_section_id` links tasks to sections; theory repository loads full aggregate; interview creation dual-writes theory section rows -- **Theory submission services** — answer processing, navigation, timer, and evaluation persistence moved to `app/theory/services/`; WebSocket and audio API use `TheorySubmissionService` -- **Theory API routes** — canonical `POST /interview/{id}/theory/audio-answer` and `WS /interview/{id}/theory/ws`; legacy `/audio-answer` and `/ws` delegate with deprecation log; interview page uses new paths -- **Theory evaluator** — `app/theory/services/evaluator/` with `TheoryEvaluatorService`; per-task evaluation used by theory submission; `InterviewEvaluatorService` remains a compat alias -- **Session creation split** — `SessionCreationService` persists an interview shell plus `TheorySectionCreationService`; `Interview.start_shell` and theory-aware `interview_from_orm` reads -- **Selection spec v2** — `SessionSelection` with `session_mode`, theory/coding branches; setup form session-mode picker (coding modes shown as coming soon); Alembic backfill for legacy rows -- **Session page composition** — `SessionPageService` merges shell + `TheoryPageContext`; phase order from `session_mode` -- **Session evaluation pipeline** — `SessionEvaluationAggregator`, `SessionEvaluatorService`, and `InterviewSection` protocol with theory prefetch via `on_phase_complete` - ### Changed -- **Section orchestration consolidation** — typed `SectionService` protocol with `is_user_facing` / `activate_if_pending`, shared section evaluation/review helpers, session evaluation models moved to `app/shared/evaluation_models.py`, multi-section score fallback sums both sections, unified results hub card builder via section registry, `score_breakdown` attached only at session completion via `attach_session_score_breakdown` -- **Session orchestration refactor** — unified `SESSION_MODE_LABELS`, section service registry instead of unused `InterviewSection` protocol, single `InterviewUnitOfWork` for cross-section phase reads, shared section-feedback prefetch and task timer helpers, score resolution moved out of mappers -- **Completed session navigation** — dashboard history links to `/interview/{id}/results`; active interview pages no longer embed final evaluation in the sidebar -- **Session completion scoring** — `SessionCompletionService` merges theory and coding section summaries; `score_breakdown` exposes separate `theory` and `coding` totals; display score sums both sections -- **Theory question planning** — excludes legacy `type: coding` rows still present in theory YAML banks -- **Documentation** — `ARCHITECTURE.md` coding data flows and scoring; `README.md` setup/coding env vars; `CONTRIBUTING.md` coding task YAML format -- **Coding naming** — domain/ORM fields use `task_count`, `task_id`, and `prompt_text` instead of legacy `question_*` names; `CodingSectionCreationService` requires shared `InterviewUnitOfWork` like theory -- **Shared paths and questions** — `app/paths.py` and `app/questions.py` moved to `app/shared/paths.py` and `app/shared/questions.py` -- **Theory question planning** — moved to `app/theory/services/planning.py`; excludes YAML `type: coding` rows -- **Session read models** — `AnswerRead` is an alias of `TheoryTaskRead`; interview domain no longer defines an `Answer` entity -- **Interview aggregate** — `Interview` is a session shell only; answers and theory config are composed at read time from `theory_sections` -- **Interview completion** — `SessionCompletionService` loads read models and scores from merged section breakdown -- **Interview creation** — setup uses `SessionCreationService.create_session` with shell + theory section persistence -- **Setup form** — posts v2 `selection_json`; theory question count and timer stored on the theory branch - ### Fixed -- **Coding session UI** — dedicated `coding_interview.html` layout (assignment panel + editor); evaluating spinner no longer visible on load (`[hidden]` vs `display:flex` clash) -- **Coding task bank** — tasks use `coding.assignment` (technical brief) instead of theory-style `question.text` prompts -- **Coding-only session pages** — dashboard and interview page no longer 500 when theory sources are empty; titles and selection summary use coding branch data -- **Coding phase activation** — `theory_then_coding` sessions promote coding sections from `pending` to `active` when theory finishes (`SessionPhaseOrchestrator`, `CodingPageService.activate_timer`) -- **Theory-to-coding handoff** — completing the theory section auto-reloads into the coding page via shared `session_phases.js`; theory-complete state shows a **Continue to Coding** button as fallback -- Configuration speech model panel tracks the selected Whisper size and locale in the form (status, download, and save now refer to the same model) -- Piper and Whisper downloads in Docker no longer fail with ``Permission denied: '/.cache'`` (Hub cache uses ``data/.cache/huggingface``) -- Per-question timer stops when the interview is ended or completed (including during final evaluation) -- Configuration question voice panel tracks the selected interview language in the form (status and download now refer to the matching Piper voice) -- Whisper and Piper voices can be downloaded from Configuration before any LLM model is saved; adding an audio-capable catalog entry no longer requires Whisper to be installed first - ### Removed -- **Legacy interview columns** — `question_count`, `question_ids`, `question_time_limit_seconds`, and `score` dropped from `interviews`; `answers.interview_id` removed (Alembic `20260608_0007`) -- **Deprecated interview API paths** — `POST /interview/{id}/audio-answer` and `WS /interview/{id}/ws`; use `/theory/audio-answer` and `/theory/ws` -- **Interview compat re-exports** — `AnswerProcessingService`, `InterviewPageService`, `InterviewCreationService`, `InterviewCompletionService`, and `app/interview/services/evaluator/` +## 2026.6.12 + +### Added + +- **Coding interviews** — practice live coding in the browser: editor, Run on public tests, Submit for evaluation, and a review page after the session; use `docker compose --profile coding` for code execution +- **Coding question bank** — 33 Python language-focused tasks (junior: basics, strings, functions, control flow, exceptions, OOP, collections; middle: refactor, bug hunt, complete code, implement) + +### Changed + +- **New interview setup** — choose session mode (theory only, coding only, or both in sequence) and configure theory and coding topics separately on one screen + +### Fixed + +- **First-time configuration** — saving provider settings and downloading Whisper or Piper models works on a fresh install, including in Docker ## 2026.5.31 diff --git a/README.md b/README.md index adb90de..ed0f2f3 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-yellow.svg)](https://opensource.org/licenses/Apache-2.0) -[![Version](https://img.shields.io/badge/version-2026.5.31-blue.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-2026.6.12-blue.svg)](CHANGELOG.md) -Open-source AI technical interview trainer. Practice from curated YAML question banks, get structured scoring and follow-ups, and optionally use voice — with your own LLM (cloud or local). +Open-source AI technical interview trainer. Practice **theory Q&A**, **live coding**, or **both in one session** from curated YAML banks — with structured scoring, follow-ups, optional voice, and a local results history. Bring your own LLM (cloud or local). [Why GrillKit](#why-grillkit-not-just-chatgpt) · [Quick start](#quick-start) · [Changelog](CHANGELOG.md) · [Architecture](ARCHITECTURE.md) @@ -15,9 +15,10 @@ A general chat assistant is flexible, but it does not run an **interview** for y | What you need | ChatGPT-style chat | GrillKit | |---------------|-------------------|----------| | Curated technical questions | You prompt each time | Built-in **tracks** (Python, Kafka, System Design, …), **levels**, and **topics** | -| Interview flow | Free-form thread | Fixed session: N questions, up to **2 AI follow-ups** per question, **1–5 scoring**, session summary | -| Practice history | Scattered chats | **Dashboard** with past sessions stored locally | -| Time pressure | None | Optional **per-round timer** (expired round → 0, move on) | +| Interview flow | Free-form thread | Fixed session: theory Q&A and/or coding tasks, up to **2 AI follow-ups** per item, **1–5 scoring**, session summary | +| Live coding practice | Paste code in chat | **Monaco editor**, **Run** against public tests, **Submit** for hidden tests + AI review (needs Judge0) | +| Practice history | Scattered chats | **Dashboard** with past sessions; open **results** and per-section **review** pages after completion | +| Time pressure | None | Optional **per-round timer** on theory and coding (expired round → 0, move on) | | Voice practice | Depends on product | Offline **Whisper** dictation; optional **Piper** question audio; **audio answers** when your model supports it | | Where data lives | Vendor cloud | **Self-hosted**: SQLite + `data/` on your machine; use **Ollama**, vLLM, or any OpenAI-compatible API | @@ -45,7 +46,13 @@ A general chat assistant is flexible, but it does not run an **interview** for y Interview setup

      -**Interview session** — real-time Q&A with AI scoring and final evaluation +**Coding section** — Monaco editor, Run on public tests, Submit for AI evaluation + +

      + Coding interview session +

      + +**Theory section** — real-time Q&A with AI scoring and final evaluation

      Completed interview with evaluation @@ -53,13 +60,30 @@ A general chat assistant is flexible, but it does not run an **interview** for y ## Features -- **Interviews** — multi-track setup, several topics per session, WebSocket Q&A, AI scoring 1–5, up to 2 follow-ups per question -- **Question banks** — Python, Database/SQL, System Design, Kafka, RabbitMQ, Docker, Kubernetes, Observability, Airflow, and more under `data/questions/{track}/` (junior / middle / senior where applicable) -- **Timer** — optional per-round time limit; expired rounds score 0 and the session moves on -- **Voice** — offline Whisper dictation for typed answers; optional Piper TTS to read questions aloud -- **Audio answers** — when the configured model supports audio input and Whisper is ready, record and send a WAV answer from the interview page -- **Setup** — model catalog on `/config`, interview locale (AI feedback language), Whisper/Piper downloads from the UI -- **Dashboard** — recent interview history on the home page +### Session modes + +Pick one mode on **New interview** (`/setup`): + +| Mode | What you practice | +|------|-------------------| +| **Theory only** | Technical Q&A from `data/questions/` — type, dictate, or record answers | +| **Coding only** | Programming tasks from `data/coding/` — edit, Run, Submit | +| **Theory then coding** | Q&A first, then coding panel when theory finishes | +| **Coding then theory** | Coding first, then theory | + +Coding modes need a running [Judge0](https://github.com/judge0/judge0) instance (see **Coding sessions** below). + +### Practice tools + +- **Theory** — WebSocket Q&A, AI scoring 1–5, up to 2 follow-ups per question +- **Coding** — Monaco editor, Run (`POST /coding/run`) on public tests, Submit (`WS /coding/ws`) with hidden tests and AI feedback +- **Question banks** — Python, Database/SQL, System Design, Kafka, RabbitMQ, Docker, Kubernetes, Observability, Airflow, and more (junior / middle / senior where applicable) +- **Timer** — optional per-round limit on theory and coding; expired rounds score 0 and the session moves on +- **Voice** — offline Whisper dictation; optional Piper TTS to read theory questions aloud +- **Audio answers** — record a WAV theory answer when your model supports audio input and Whisper is ready +- **Results hub** — after you finish, `/interview/{id}/results` shows overall evaluation and links to **theory** and **coding** review pages with full chat/code history +- **Dashboard** — recent sessions on the home page (completed sessions link to results) +- **Setup** — model catalog on `/config`, interview locale, Whisper/Piper downloads from the UI - **Deployment** — Docker Compose on port 8000 with `./data` volume for config, DB, and models ## Quick start @@ -106,9 +130,10 @@ On some Linux hosts Judge0 needs **cgroup v1** (`systemd.unified_cgroup_hierarch ### First-time flow -1. **Configuration** (`/config`) — add one or more OpenAI-compatible models to the catalog, select an interview model, set interview locale; test connection, then save. -2. **New interview** (`/setup`) — pick a **session mode** (theory only, coding only, or combined). Configure theory and/or coding tracks, topics, task counts, and per-task timers. Coding modes require Judge0 (see **Coding sessions** above). -3. **Interview** (`/interview/{id}`) — theory answers over `WS /theory/ws`; coding uses Monaco + Run (`POST /coding/run`) and Submit (`WS /coding/ws`). End interview from the sidebar at any time. +1. **Configuration** (`/config`) — add one or more OpenAI-compatible models to the catalog, select an interview model, set interview locale; test connection, then save. Download Whisper (and optionally a Piper voice) from the same page if you want voice features. +2. **New interview** (`/setup`) — pick a **session mode** (theory only, coding only, or combined). Choose tracks, levels, topics, how many questions/tasks, and optional per-round timers. Coding modes require Judge0 (see **Coding sessions** above). +3. **Practice** (`/interview/{id}`) — answer theory questions in the chat (type, dictate, or record audio). On coding phases, use the editor: **Run** to check public tests, **Submit** when ready. Combined sessions switch panels automatically when a section ends (or use **Continue to Coding**). End the interview from the sidebar at any time. +4. **Review** (`/interview/{id}/results`) — after completion, read the overall evaluation, then open **Theory** or **Coding** review for full conversation history, scores, and feedback. Without saved provider config, `/setup` redirects to `/config`. @@ -168,8 +193,8 @@ Optional environment variables (full list in [ARCHITECTURE.md](ARCHITECTURE.md#p | Document | Contents | |----------|----------| -| [ARCHITECTURE.md](ARCHITECTURE.md) | Layers, HTTP/WebSocket routes, data flows, persistence, question banks | -| [CONTRIBUTING.md](CONTRIBUTING.md) | Dev setup, tests, ruff/mypy/pytest, contribution workflow | +| [ARCHITECTURE.md](ARCHITECTURE.md) | Feature modules, routes, data flows, persistence, test layout | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Dev setup, quality checks, question/coding YAML guidelines | | [CHANGELOG.md](CHANGELOG.md) | Release history | ## Security diff --git a/app/main.py b/app/main.py index 8be4d66..4bf1597 100644 --- a/app/main.py +++ b/app/main.py @@ -49,7 +49,7 @@ def create_app() -> FastAPI: app = FastAPI( title="GrillKit", description="AI Interview Trainer", - version="2026.5.31", + version="2026.6.12", lifespan=lifespan, ) diff --git a/assets/coding.png b/assets/coding.png new file mode 100644 index 0000000000000000000000000000000000000000..5eb48fbc08a0a851ea98fe9062a33fbbccfa00cf GIT binary patch literal 76034 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 H>~H@A$(`w0 literal 0 HcmV?d00001 diff --git a/data/coding/python/junior/basics.yaml b/data/coding/python/junior/basics.yaml index 0445ae0..032024c 100644 --- a/data/coding/python/junior/basics.yaml +++ b/data/coding/python/junior/basics.yaml @@ -5,6 +5,108 @@ level: "junior" description: "Core Python fundamentals: types, variables, operators, and language essentials" tasks: + - id: "bas-001" + difficulty: 1 + tags: ["f-strings", "formatting"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + The greeting uses old-style `%` formatting. Modern Python code prefers f-strings + for readability. + + Your task: + Rewrite the `greeting` assignment to use an f-string. Keep the same output. + ru: | + Контекст: + Приветствие собирается через `%`-форматирование. + + Задача: + Перепишите присваивание `greeting` на f-string с тем же результатом. + starter_code: | + name = "Alice" + score = 95 + + greeting = "Hello, %s! Your score is %d." % (name, score) + print(greeting) + expected_points: + - "Uses f-string with name and score interpolated" + - "Same printed output as original" + + - id: "bas-002" + difficulty: 1 + tags: ["none", "identity", "comparison"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `find_user` checks for a missing user with `== None`. In Python, singletons + like `None` should be compared with `is` / `is not`. + + Your task: + Fix the None check. Do not change behavior for valid users. + ru: | + Контекст: + `find_user` сравнивает результат с `None` через `==`. + + Задача: + Исправьте проверку на `None` через `is` / `is not`. Поведение для найденных пользователей не меняйте. + starter_code: | + users = {"alice": "Alice", "bob": "Bob"} + + + def find_user(user_id): + return users.get(user_id) + + + result = find_user("charlie") + if result == None: + print("User not found") + else: + print(f"Found: {result}") + expected_points: + - "Uses `is None` or `is not None` instead of == None" + - "Same output for missing and existing users" + + - id: "bas-003" + difficulty: 2 + tags: ["truthiness", "conditionals"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `is_valid` treats any truthy value as valid, so non-empty strings like `"0"` + pass even when they should not. + + Your task: + Rewrite `is_valid` so only actual boolean `True` is accepted. + Use an explicit identity check against `True`. + ru: | + Контекст: + `is_valid` принимает любое truthy-значение, включая строку `"0"`. + + Задача: + Перепишите `is_valid`: валидным считается только булев `True` (явная проверка идентичности). + starter_code: | + def is_valid(flag): + if flag: + return "ok" + return "invalid" + + + print(is_valid(True)) + print(is_valid("0")) + print(is_valid(1)) + expected_points: + - "Checks `flag is True` (or equivalent explicit boolean check)" + - "String \"0\" and integer 1 return invalid" + - id: "bas-004" difficulty: 2 tags: ["type-conversion", "type-hints"] diff --git a/data/coding/python/junior/control-flow.yaml b/data/coding/python/junior/control-flow.yaml index f968988..f441750 100644 --- a/data/coding/python/junior/control-flow.yaml +++ b/data/coding/python/junior/control-flow.yaml @@ -5,6 +5,69 @@ level: "junior" description: "Python control flow constructs: conditionals, loops, iterators, and context managers" tasks: + - id: "cf-001" + difficulty: 1 + tags: ["break", "loops"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `find_first_even` scans the entire list even after the first even number is found. + + Your task: + Stop the loop early with `break` once the first even number is found. + Return `None` if no even number exists. + ru: | + Контекст: + `find_first_even` проходит весь список, хотя первое чётное уже найдено. + + Задача: + Остановите цикл через `break` после первого чётного. Если чётных нет — верните `None`. + starter_code: | + def find_first_even(numbers): + for n in numbers: + if n % 2 == 0: + return n + return None + + + print(find_first_even([1, 3, 4, 6, 8])) + print(find_first_even([1, 3, 5])) + expected_points: + - "Uses break when even number found (or equivalent early exit)" + - "Returns first even or None" + + - id: "cf-002" + difficulty: 1 + tags: ["dict", "items", "iteration"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + The loop prints scores by indexing into `scores` with each key from `scores.keys()`. + That pattern is verbose and non-idiomatic. + + Your task: + Refactor the loop to iterate with `.items()` while keeping the same output. + ru: | + Контекст: + Баллы выводятся через индексацию по ключам из `scores.keys()`. + + Задача: + Перепишите цикл на `.items()` с тем же выводом. + starter_code: | + scores = {"Alice": 85, "Bob": 92, "Charlie": 78} + + for name in scores.keys(): + print(name, scores[name]) + expected_points: + - "Uses for name, score in scores.items()" + - "Same print output as original" + - id: "cf-003" difficulty: 2 tags: ["range", "enumerate", "iteration"] diff --git a/data/coding/python/junior/exceptions.yaml b/data/coding/python/junior/exceptions.yaml index 200885b..b4df1e6 100644 --- a/data/coding/python/junior/exceptions.yaml +++ b/data/coding/python/junior/exceptions.yaml @@ -5,6 +5,66 @@ level: "junior" description: "Python exception handling: try/except/finally, raising exceptions, and exception hierarchy" tasks: + - id: "exc-001" + difficulty: 1 + tags: ["try-except", "value-error"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `to_int` crashes on invalid input because `int()` raises `ValueError`. + + Your task: + Wrap the conversion in try/except. Return `None` when conversion fails. + ru: | + Контекст: + `to_int` падает на невалидном вводе. + + Задача: + Оберните преобразование в try/except. При ошибке возвращайте `None`. + starter_code: | + def to_int(value): + return int(value) + + + print(to_int("42")) + print(to_int("abc")) + print(to_int("")) + expected_points: + - "Catches ValueError (or broader Exception) around int()" + - "Returns None on invalid input" + - "Returns int for valid numeric strings" + + - id: "exc-002" + difficulty: 2 + tags: ["finally", "cleanup"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `read_lines` opens a file but never closes it if an error occurs while reading. + + Your task: + Ensure the file is always closed using a `finally` block (do not switch to `with` here). + ru: | + Контекст: + `read_lines` не закрывает файл при ошибке чтения. + + Задача: + Гарантируйте закрытие файла через `finally` (без перехода на `with`). + starter_code: | + def read_lines(path): + f = open(path, "r") + lines = f.readlines() + return [line.strip() for line in lines] + expected_points: + - "Uses try/finally to close the file handle" + - "File closed even when readlines raises" + - id: "exc-005" difficulty: 1 tags: ["assert", "debugging"] diff --git a/data/coding/python/junior/functions.yaml b/data/coding/python/junior/functions.yaml index 26af8f2..28eb8af 100644 --- a/data/coding/python/junior/functions.yaml +++ b/data/coding/python/junior/functions.yaml @@ -5,6 +5,99 @@ level: "junior" description: "Python functions: parameters, return values, scoping, and advanced function concepts" tasks: + - id: "func-001" + difficulty: 1 + tags: ["default-arguments"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `greet` always requires a prefix argument. Callers want a sensible default. + + Your task: + Add a default value `"Hello"` to the `prefix` parameter. Keep the function body unchanged. + ru: | + Контекст: + `greet` всегда требует аргумент `prefix`. + + Задача: + Задайте значение по умолчанию `"Hello"` для `prefix`. Тело функции не меняйте. + starter_code: | + def greet(name, prefix): + return f"{prefix}, {name}!" + + + print(greet("Alice")) + print(greet("Bob", "Hi")) + expected_points: + - "prefix has default value \"Hello\"" + - "greet(\"Alice\") works without second argument" + + - id: "func-002" + difficulty: 2 + tags: ["args", "variadic"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `total` should accept any number of numeric arguments and return their sum. + + Your task: + Implement `total` using `*args`. Return `0` when called with no arguments. + ru: | + Контекст: + `total` должна суммировать произвольное число аргументов. + + Задача: + Реализуйте `total` через `*args`. Без аргументов возвращайте `0`. + starter_code: | + def total(*args): + pass + + + print(total(1, 2, 3)) + print(total()) + print(total(10, -5, 2.5)) + expected_points: + - "Uses *args in signature" + - "Returns sum of all arguments" + - "Empty call returns 0" + + - id: "func-003" + difficulty: 2 + tags: ["keyword-only", "parameters"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `connect` accepts host and port, but callers sometimes pass the port positionally + by mistake. The port should be keyword-only. + + Your task: + Make `port` a keyword-only parameter (use `*` in the signature). + Keep the return format unchanged. + ru: | + Контекст: + В `connect` порт иногда передают позиционно по ошибке. + + Задача: + Сделайте `port` keyword-only (через `*` в сигнатуре). Формат возврата не меняйте. + starter_code: | + def connect(host, port): + return f"{host}:{port}" + + + print(connect("localhost", port=5432)) + expected_points: + - "port is keyword-only after bare *" + - "connect(\"localhost\", port=5432) still works" + - id: "func-006" difficulty: 2 tags: ["docstrings", "annotations"] diff --git a/data/coding/python/junior/strings.yaml b/data/coding/python/junior/strings.yaml index 7880e6c..77030dd 100644 --- a/data/coding/python/junior/strings.yaml +++ b/data/coding/python/junior/strings.yaml @@ -5,6 +5,72 @@ level: "junior" description: "Python string operations, formatting, and manipulation" tasks: + - id: "str-001" + difficulty: 1 + tags: ["split", "strip", "parsing"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + A log line stores key-value pairs separated by commas (`key=value`). + The parser must extract the value for a given key. + + Your task: + Complete `parse_value` so it splits the line, strips whitespace, and returns + the value for `key`, or `None` if the key is absent. + ru: | + Контекст: + Строка лога содержит пары `key=value` через запятую. + + Задача: + Допишите `parse_value`: разбейте строку, уберите пробелы, верните значение для `key` + или `None`, если ключа нет. + starter_code: | + def parse_value(line, key): + # split by comma, then by '=', strip parts + pass + + + line = "user=alice, role=admin, active=true" + print(parse_value(line, "role")) + print(parse_value(line, "missing")) + expected_points: + - "Splits on comma and equals with strip" + - "Returns correct value for existing key" + - "Returns None when key is missing" + + - id: "str-002" + difficulty: 1 + tags: ["case", "normalization"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + User emails are compared case-sensitively, so `"User@Mail.com"` and + `"user@mail.com"` are treated as different accounts. + + Your task: + Normalize both emails with `.lower()` before comparison in `emails_match`. + ru: | + Контекст: + Email сравниваются с учётом регистра — дубликаты не находятся. + + Задача: + Нормализуйте оба email через `.lower()` в `emails_match` перед сравнением. + starter_code: | + def emails_match(a, b): + return a == b + + + print(emails_match("User@Mail.com", "user@mail.com")) + expected_points: + - "Calls .lower() on both operands before ==" + - "Returns True for case-insensitive match" + - id: "str-004" difficulty: 2 tags: ["join", "split", "concatenation"] diff --git a/data/coding/python/middle/bug-hunt.yaml b/data/coding/python/middle/bug-hunt.yaml index 1a2d9e5..48bb451 100644 --- a/data/coding/python/middle/bug-hunt.yaml +++ b/data/coding/python/middle/bug-hunt.yaml @@ -47,3 +47,69 @@ tasks: - "Non-numeric lines cause ValueError without handling" - "Fix skips blank lines and catches ValueError per line" - "Negative numbers are ignored as required" + + - id: "bh-mutable-default-002" + difficulty: 2 + tags: ["mutable-default", "functions"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `add_item` uses a mutable list as a default argument. Repeated calls share + the same list, which surprises callers. + + Your task: + 1. Explain the bug (in a comment at the top of the file). + 2. Fix `add_item` so each call without `items` gets a fresh empty list. + ru: | + Контекст: + `add_item` использует изменяемый список по умолчанию — вызовы делят один список. + + Задача: + 1. Опишите баг в комментарии в начале файла. + 2. Исправьте `add_item`: без `items` каждый вызов получает новый пустой список. + starter_code: | + def add_item(value, items=[]): + items.append(value) + return items + + + print(add_item("a")) + print(add_item("b")) + expected_points: + - "Comment describes shared mutable default" + - "Uses None sentinel and items = items or [] (or equivalent)" + - "Second call without items does not contain first call's value" + + - id: "bh-string-is-003" + difficulty: 2 + tags: ["identity", "strings", "comparison"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `is_admin_role` compares role strings with `is`. String content should be + compared with `==`, not identity. + + Your task: + Fix the comparison so `"admin"` matches regardless of how the string was created. + ru: | + Контекст: + `is_admin_role` сравнивает строки через `is` вместо сравнения значений. + + Задача: + Исправьте сравнение: роль `"admin"` должна определяться по содержимому. + starter_code: | + def is_admin_role(role): + return role is "admin" + + + user_input = "admin" + print(is_admin_role(user_input)) + expected_points: + - "Uses == for string equality" + - "Returns True for role equal to admin" diff --git a/data/coding/python/middle/complete-code.yaml b/data/coding/python/middle/complete-code.yaml index 00faa64..1744216 100644 --- a/data/coding/python/middle/complete-code.yaml +++ b/data/coding/python/middle/complete-code.yaml @@ -55,3 +55,83 @@ tasks: - "set removes old queue entry before re-appending on update" - "eviction uses popleft on order and deletes key from data" - "FIFO semantics preserved after updates and inserts" + + - id: "cc-freq-002" + difficulty: 2 + tags: ["dict", "counting", "collections"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `top_n` should return the `n` most frequent items from a list as `(item, count)` pairs, + sorted by count descending. + + Your task: + Complete `top_n`: build frequencies, then return the top `n` pairs. + You may use `sorted` with a key; ties can be broken arbitrarily. + ru: | + Контекст: + `top_n` возвращает `n` самых частых элементов как пары `(элемент, счётчик)`. + + Задача: + Допишите `top_n`: посчитайте частоты, верните топ-`n` по убыванию счётчика. + starter_code: | + def top_n(items, n): + counts = {} + for item in items: + counts[item] = counts.get(item, 0) + 1 + # return n most common (item, count) pairs + pass + + + print(top_n(["a", "b", "a", "c", "a", "b"], 2)) + expected_points: + - "Builds frequency dict correctly" + - "Returns up to n pairs sorted by count descending" + + - id: "cc-context-003" + difficulty: 3 + tags: ["context-managers", "dunder"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `Timer` is a context manager skeleton. It should record elapsed wall time + between entering and exiting the block. + + Your task: + Implement `__enter__` and `__exit__` so that after the `with` block, + `timer.elapsed` holds the duration in seconds (float). + ru: | + Контекст: + `Timer` — заготовка контекстного менеджера для замера времени блока. + + Задача: + Реализуйте `__enter__` и `__exit__`: после `with` в `timer.elapsed` — длительность в секундах. + starter_code: | + import time + + + class Timer: + def __init__(self): + self.elapsed = 0.0 + + def __enter__(self): + pass + + def __exit__(self, exc_type, exc, tb): + pass + + + with Timer() as timer: + time.sleep(0.01) + + print(timer.elapsed > 0) + expected_points: + - "__enter__ records start time" + - "__exit__ sets elapsed from monotonic or perf counter" + - "elapsed is positive after block" diff --git a/data/coding/python/middle/implement.yaml b/data/coding/python/middle/implement.yaml index 81c52b4..fe4f2ea 100644 --- a/data/coding/python/middle/implement.yaml +++ b/data/coding/python/middle/implement.yaml @@ -57,3 +57,44 @@ tasks: - "Only lists are flattened; scalars appended in order" - "Handles empty input and deeply nested single value" - "Includes runnable tests covering examples and edge cases" + + - id: "im-config-002" + difficulty: 2 + tags: ["dict", "validation", "types"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `parse_config` receives a plain dict from JSON. Required keys are `host` (str) + and `port` (int). Optional `debug` defaults to `False`. + + Your task: + Implement validation: + - raise `ValueError` with a clear message if `host` or `port` is missing + - raise `TypeError` if `port` is not an int + - return a new dict with `host`, `port`, and `debug` (default False) + ru: | + Контекст: + `parse_config` валидирует словарь конфигурации из JSON. + + Задача: + - `ValueError`, если нет `host` или `port` + - `TypeError`, если `port` не int + - вернуть dict с `host`, `port`, `debug` (по умолчанию False) + starter_code: | + def parse_config(raw): + """Validate and normalize application config from a JSON dict.""" + raise NotImplementedError + + + cfg = parse_config({"host": "localhost", "port": 8080}) + print(cfg) + + cfg_debug = parse_config({"host": "api", "port": 443, "debug": True}) + print(cfg_debug) + expected_points: + - "Raises ValueError on missing host or port" + - "Raises TypeError when port is not int" + - "Returns dict with debug defaulting to False" diff --git a/data/coding/python/middle/refactor.yaml b/data/coding/python/middle/refactor.yaml index 9b253d2..43c9a1b 100644 --- a/data/coding/python/middle/refactor.yaml +++ b/data/coding/python/middle/refactor.yaml @@ -78,3 +78,69 @@ tasks: - "Type hints on public methods" - "Docstrings describe return semantics" - "PEP 8 spacing after commas and around operators" + + - id: "rf-list-comp-002" + difficulty: 2 + tags: ["list-comprehension", "idioms"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `square_evens` builds a result list with append in a loop. A list comprehension + is shorter and idiomatic for simple filters and transforms. + + Your task: + Rewrite the function body as a single list comprehension. Keep the same behavior: + return squares of even numbers only. + ru: | + Контекст: + `square_evens` собирает результат через append в цикле. + + Задача: + Перепишите тело функции одним list comprehension. Квадраты только чётных чисел. + starter_code: | + def square_evens(numbers): + result = [] + for n in numbers: + if n % 2 == 0: + result.append(n * n) + return result + + + print(square_evens([1, 2, 3, 4, 5, 6])) + expected_points: + - "Single list comprehension with filter for even n" + - "Same output as loop version" + + - id: "rf-with-open-003" + difficulty: 2 + tags: ["context-managers", "files"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `read_config` opens a file and closes it manually. If `read()` raises, + the handle may leak. + + Your task: + Refactor to use `with open(...) as f`. Preserve the return value (file contents). + ru: | + Контекст: + `read_config` закрывает файл вручную — при ошибке чтения возможна утечка дескриптора. + + Задача: + Перепишите на `with open(...) as f`. Возвращайте содержимое файла как раньше. + starter_code: | + def read_config(path): + f = open(path, "r") + data = f.read() + f.close() + return data + expected_points: + - "Uses with open for reading" + - "Returns full file contents" + - "No manual close after refactor" diff --git a/tests/test_audio_probe.py b/tests/ai/test_audio_probe.py similarity index 100% rename from tests/test_audio_probe.py rename to tests/ai/test_audio_probe.py diff --git a/tests/test_ai_base.py b/tests/ai/test_base.py similarity index 100% rename from tests/test_ai_base.py rename to tests/ai/test_base.py diff --git a/tests/test_ai_factory.py b/tests/ai/test_factory.py similarity index 100% rename from tests/test_ai_factory.py rename to tests/ai/test_factory.py diff --git a/tests/test_openai_compatible.py b/tests/ai/test_openai_compatible.py similarity index 100% rename from tests/test_openai_compatible.py rename to tests/ai/test_openai_compatible.py diff --git a/tests/test_main.py b/tests/app/test_main.py similarity index 100% rename from tests/test_main.py rename to tests/app/test_main.py diff --git a/tests/test_coding_api.py b/tests/coding/api/test_routes.py similarity index 100% rename from tests/test_coding_api.py rename to tests/coding/api/test_routes.py diff --git a/tests/test_coding_repository.py b/tests/coding/repositories/test_coding_section.py similarity index 100% rename from tests/test_coding_repository.py rename to tests/coding/repositories/test_coding_section.py diff --git a/tests/test_coding_availability.py b/tests/coding/services/test_availability.py similarity index 100% rename from tests/test_coding_availability.py rename to tests/coding/services/test_availability.py diff --git a/tests/test_coding_evaluator.py b/tests/coding/services/test_evaluator.py similarity index 66% rename from tests/test_coding_evaluator.py rename to tests/coding/services/test_evaluator.py index 61ee2be..5db3d57 100644 --- a/tests/test_coding_evaluator.py +++ b/tests/coding/services/test_evaluator.py @@ -48,3 +48,28 @@ async def test_evaluate_submission_uses_run_history_context() -> None: assert follow_up_needed is True assert follow_up_text == "Add type hints." assert follow_up_mode == "code" + + +@pytest.mark.asyncio +async def test_coding_evaluator_evaluate_section() -> None: + """Coding section evaluation returns parsed section narrative.""" + from tests.fakes import FakeProvider, section_evaluation_json + + provider = FakeProvider( + replies=[section_evaluation_json(section_feedback="Strong coding section.")] + ) + result = await CodingEvaluatorService.evaluate_section( + provider=provider, + task_submissions=[ + { + "task_id": "cod-001", + "round": 0, + "prompt_text": "Solve it.", + "submitted_code": "return 1", + "score": 4, + } + ], + sources_text="Python / junior: basics", + locale="en", + ) + assert result.section_feedback == "Strong coding section." diff --git a/tests/test_coding_harness.py b/tests/coding/services/test_harness.py similarity index 100% rename from tests/test_coding_harness.py rename to tests/coding/services/test_harness.py diff --git a/tests/test_judge0_client.py b/tests/coding/services/test_judge0_client.py similarity index 100% rename from tests/test_judge0_client.py rename to tests/coding/services/test_judge0_client.py diff --git a/tests/test_coding_page.py b/tests/coding/services/test_page.py similarity index 100% rename from tests/test_coding_page.py rename to tests/coding/services/test_page.py diff --git a/tests/test_coding_planning.py b/tests/coding/services/test_planning.py similarity index 98% rename from tests/test_coding_planning.py rename to tests/coding/services/test_planning.py index c41c1f4..34a6ed6 100644 --- a/tests/test_coding_planning.py +++ b/tests/coding/services/test_planning.py @@ -65,7 +65,7 @@ def test_build_coding_task_plan_from_bank() -> None: ) planned = build_coding_task_plan(selection, task_count=1, locale="en") assert len(planned) == 1 - assert planned[0].id == "bas-004" + assert planned[0].id.startswith("bas-") assert planned[0].task_spec["language"] == "python" diff --git a/tests/test_coding_runner.py b/tests/coding/services/test_runner.py similarity index 100% rename from tests/test_coding_runner.py rename to tests/coding/services/test_runner.py diff --git a/tests/test_coding_section_service.py b/tests/coding/services/test_section.py similarity index 100% rename from tests/test_coding_section_service.py rename to tests/coding/services/test_section.py diff --git a/tests/conftest.py b/tests/conftest.py index 8077652..9e523c0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -116,4 +116,4 @@ def uow(isolated_db): yield work -pytest_plugins = ["tests.test_questions"] +pytest_plugins = ["tests.shared.test_questions"] diff --git a/tests/test_interview_errors.py b/tests/interview/api/test_errors.py similarity index 100% rename from tests/test_interview_errors.py rename to tests/interview/api/test_errors.py diff --git a/tests/test_setup_api.py b/tests/interview/api/test_setup.py similarity index 100% rename from tests/test_setup_api.py rename to tests/interview/api/test_setup.py diff --git a/tests/test_repositories.py b/tests/interview/repositories/test_interview.py similarity index 100% rename from tests/test_repositories.py rename to tests/interview/repositories/test_interview.py diff --git a/tests/test_interview_timer.py b/tests/interview/services/rules/test_feedback.py similarity index 100% rename from tests/test_interview_timer.py rename to tests/interview/services/rules/test_feedback.py diff --git a/tests/test_interview_completion.py b/tests/interview/services/test_completion.py similarity index 100% rename from tests/test_interview_completion.py rename to tests/interview/services/test_completion.py diff --git a/tests/test_interview_creation.py b/tests/interview/services/test_creation.py similarity index 100% rename from tests/test_interview_creation.py rename to tests/interview/services/test_creation.py diff --git a/tests/test_dashboard_query.py b/tests/interview/services/test_dashboard.py similarity index 100% rename from tests/test_dashboard_query.py rename to tests/interview/services/test_dashboard.py diff --git a/tests/test_interview_page.py b/tests/interview/services/test_page.py similarity index 100% rename from tests/test_interview_page.py rename to tests/interview/services/test_page.py diff --git a/tests/test_session_phases.py b/tests/interview/services/test_phases.py similarity index 100% rename from tests/test_session_phases.py rename to tests/interview/services/test_phases.py diff --git a/tests/test_section_feedback.py b/tests/interview/services/test_section_feedback.py similarity index 100% rename from tests/test_section_feedback.py rename to tests/interview/services/test_section_feedback.py diff --git a/tests/test_interview_selection.py b/tests/interview/services/test_selection.py similarity index 100% rename from tests/test_interview_selection.py rename to tests/interview/services/test_selection.py diff --git a/tests/test_session_evaluation.py b/tests/interview/services/test_session_evaluation.py similarity index 100% rename from tests/test_session_evaluation.py rename to tests/interview/services/test_session_evaluation.py diff --git a/tests/test_config_service.py b/tests/platform/services/test_config.py similarity index 100% rename from tests/test_config_service.py rename to tests/platform/services/test_config.py diff --git a/tests/test_llm_catalog.py b/tests/platform/services/test_llm_catalog.py similarity index 100% rename from tests/test_llm_catalog.py rename to tests/platform/services/test_llm_catalog.py diff --git a/tests/test_tts_api.py b/tests/question_voice/api/test_tts.py similarity index 100% rename from tests/test_tts_api.py rename to tests/question_voice/api/test_tts.py diff --git a/tests/test_piper_storage.py b/tests/question_voice/services/test_piper_storage.py similarity index 100% rename from tests/test_piper_storage.py rename to tests/question_voice/services/test_piper_storage.py diff --git a/tests/test_tts_cache.py b/tests/question_voice/services/test_tts_cache.py similarity index 100% rename from tests/test_tts_cache.py rename to tests/question_voice/services/test_tts_cache.py diff --git a/tests/test_alembic_migrations.py b/tests/shared/infrastructure/test_alembic_migrations.py similarity index 100% rename from tests/test_alembic_migrations.py rename to tests/shared/infrastructure/test_alembic_migrations.py diff --git a/tests/test_artifact_download.py b/tests/shared/infrastructure/test_artifact_download.py similarity index 100% rename from tests/test_artifact_download.py rename to tests/shared/infrastructure/test_artifact_download.py diff --git a/tests/test_artifact_status.py b/tests/shared/infrastructure/test_artifact_status.py similarity index 100% rename from tests/test_artifact_status.py rename to tests/shared/infrastructure/test_artifact_status.py diff --git a/tests/test_audio_wav.py b/tests/shared/infrastructure/test_audio_wav.py similarity index 100% rename from tests/test_audio_wav.py rename to tests/shared/infrastructure/test_audio_wav.py diff --git a/tests/test_database.py b/tests/shared/infrastructure/test_database.py similarity index 100% rename from tests/test_database.py rename to tests/shared/infrastructure/test_database.py diff --git a/tests/test_hf_download_progress.py b/tests/shared/infrastructure/test_hf_download_progress.py similarity index 100% rename from tests/test_hf_download_progress.py rename to tests/shared/infrastructure/test_hf_download_progress.py diff --git a/tests/test_hf_hub_runtime.py b/tests/shared/infrastructure/test_hf_hub_runtime.py similarity index 100% rename from tests/test_hf_hub_runtime.py rename to tests/shared/infrastructure/test_hf_hub_runtime.py diff --git a/tests/test_uow.py b/tests/shared/infrastructure/test_uow.py similarity index 100% rename from tests/test_uow.py rename to tests/shared/infrastructure/test_uow.py diff --git a/tests/test_coding_tasks.py b/tests/shared/test_coding.py similarity index 93% rename from tests/test_coding_tasks.py rename to tests/shared/test_coding.py index 52afa9a..fe1f9ae 100644 --- a/tests/test_coding_tasks.py +++ b/tests/shared/test_coding.py @@ -168,11 +168,12 @@ def test_load_categories_merges_and_dedupes(self, temp_coding_dir) -> None: assert [task.id for task in tasks] == ["algo-001", "algo-002", "algo-003"] def test_load_real_python_junior_basics(self) -> None: - """Load migrated production task bank entry.""" + """Load production basics category including type-hints task.""" tasks = load_category("python", "junior", "basics", locale="en") - assert len(tasks) == 1 - assert tasks[0].id == "bas-004" - assert tasks[0].coding.evaluation_mode == "ai" - assert tasks[0].coding.starter_code is not None - assert "def process" in tasks[0].coding.starter_code - assert "type hints" in tasks[0].text.lower() + by_id = {task.id: task for task in tasks} + assert "bas-004" in by_id + task = by_id["bas-004"] + assert task.coding.evaluation_mode == "ai" + assert task.coding.starter_code is not None + assert "def process" in task.coding.starter_code + assert "type hints" in task.text.lower() diff --git a/tests/test_locales.py b/tests/shared/test_locales.py similarity index 100% rename from tests/test_locales.py rename to tests/shared/test_locales.py diff --git a/tests/test_questions.py b/tests/shared/test_questions.py similarity index 100% rename from tests/test_questions.py rename to tests/shared/test_questions.py diff --git a/tests/test_speech_models.py b/tests/shared/test_speech_models.py similarity index 100% rename from tests/test_speech_models.py rename to tests/shared/test_speech_models.py diff --git a/tests/test_dictation_ws.py b/tests/speech/api/test_dictation_ws.py similarity index 100% rename from tests/test_dictation_ws.py rename to tests/speech/api/test_dictation_ws.py diff --git a/tests/test_speech_api.py b/tests/speech/api/test_routes.py similarity index 100% rename from tests/test_speech_api.py rename to tests/speech/api/test_routes.py diff --git a/tests/test_speech_recognition.py b/tests/speech/services/test_dictation.py similarity index 100% rename from tests/test_speech_recognition.py rename to tests/speech/services/test_dictation.py diff --git a/tests/test_whisper_runtime.py b/tests/speech/services/test_whisper_runtime.py similarity index 100% rename from tests/test_whisper_runtime.py rename to tests/speech/services/test_whisper_runtime.py diff --git a/tests/test_answer_processing.py b/tests/test_answer_processing.py deleted file mode 100644 index 6fa28c4..0000000 --- a/tests/test_answer_processing.py +++ /dev/null @@ -1,490 +0,0 @@ -# Copyright 2026 GrillKit Contributors -# SPDX-License-Identifier: Apache-2.0 -"""Tests for answer processing with a deterministic fake AI provider.""" - -import asyncio -from datetime import UTC, datetime, timedelta - -import pytest - -from app.interview.domain.exceptions import InterviewNotActiveError -from app.interview.services.events import ( - AnswerFeedbackEvent, - AnswerSavedEvent, - EvaluatingEvent, -) -from app.interview.services.query import InterviewQuery -from app.shared.infrastructure.models import Answer, Interview -from app.theory.domain.entities import TheoryTask -from app.theory.repositories.uow import TheoryUnitOfWork -from app.theory.services.evaluator.service import TheoryEvaluatorService -from app.theory.services.submission import TheorySubmissionService -from tests.fakes import answer_evaluation_json, follow_up_evaluation_json -from tests.helpers.interview_seed import ( - persist_interview_with_answers, - seed_two_question_interview, -) -from tests.helpers.selection import minimal_selection_spec - - -@pytest.mark.asyncio -async def test_process_answer_persists_score_and_next_question( - isolated_db, fake_ai_provider -): - """Initial answer is scored and the client receives the next question.""" - interview_id = seed_two_question_interview() - provider = fake_ai_provider( - [answer_evaluation_json(score=5, follow_up_needed=False)] - ) - - events = await TheorySubmissionService.process_answer_submission( - interview_id=interview_id, - question_id="q1", - answer_text="Lists are mutable.", - provider=provider, - ) - - assert [type(e) for e in events] == [ - AnswerSavedEvent, - EvaluatingEvent, - AnswerFeedbackEvent, - ] - feedback = events[2] - assert isinstance(feedback, AnswerFeedbackEvent) - assert feedback.follow_up_needed is False - reloaded = InterviewQuery.get_interview(interview_id) - assert reloaded is not None - q2 = next(a for a in reloaded.answers if a.question_id == "q2" and a.round == 0) - assert feedback.next_question == { - "id": q2.id, - "question_id": "q2", - "order": 2, - "question_text": "Question two?", - "question_code": None, - "round": 0, - } - - q1 = next(a for a in reloaded.answers if a.question_id == "q1" and a.round == 0) - assert q1.answer_text == "Lists are mutable." - assert q1.score == 5 - assert q1.feedback is not None - assert len(reloaded.answers) == 2 - - -@pytest.mark.asyncio -async def test_process_answer_creates_follow_up_round(isolated_db, fake_ai_provider): - """When AI requests a follow-up, a new unanswered round row is created.""" - interview_id = seed_two_question_interview("ap-test-2") - provider = fake_ai_provider( - [ - answer_evaluation_json( - score=3, - follow_up_needed=True, - follow_up_question="Explain big-O of append.", - ) - ] - ) - - events = await TheorySubmissionService.process_answer_submission( - interview_id=interview_id, - question_id="q1", - answer_text="Partial answer.", - provider=provider, - ) - - feedback = events[2] - assert isinstance(feedback, AnswerFeedbackEvent) - assert feedback.follow_up_needed is True - assert feedback.follow_up_text == "Explain big-O of append." - assert feedback.next_question is None - - reloaded = InterviewQuery.get_interview(interview_id) - assert reloaded is not None - rounds = [a for a in reloaded.answers if a.question_id == "q1"] - assert len(rounds) == 2 - follow_up = next(a for a in rounds if a.round == 1) - assert follow_up.question_text == "Explain big-O of append." - assert follow_up.answer_text is None - - -@pytest.mark.asyncio -async def test_process_follow_up_answer_without_another_follow_up( - isolated_db, fake_ai_provider -): - """Answering a follow-up round persists score and advances to the next question.""" - interview_id = "ap-test-3" - initial = Answer( - question_id="q1", - order=1, - round=0, - question_text="Original question?", - ) - initial.answer_text = "First answer" - initial.score = 3 - initial.feedback = "OK" - first_follow_up = Answer( - question_id="q1", - order=1, - round=1, - question_text="Follow-up question?", - ) - persist_interview_with_answers( - Interview( - id=interview_id, - locale="en", - selection_spec=minimal_selection_spec(categories=["basics"]), - status="active", - ), - [ - initial, - first_follow_up, - Answer( - question_id="q2", - order=2, - round=0, - question_text="Question two?", - ), - ], - question_count=2, - ) - - provider = fake_ai_provider( - [follow_up_evaluation_json(score=4, needs_further_follow_up=False)] - ) - - events = await TheorySubmissionService.process_answer_submission( - interview_id=interview_id, - question_id="q1", - answer_text="Follow-up answer text.", - provider=provider, - ) - - feedback = events[2] - assert isinstance(feedback, AnswerFeedbackEvent) - assert feedback.round == 1 - assert feedback.follow_up_needed is False - assert feedback.next_question is not None - assert feedback.next_question["question_id"] == "q2" - - reloaded = InterviewQuery.get_interview(interview_id) - assert reloaded is not None - follow_up = next( - a for a in reloaded.answers if a.question_id == "q1" and a.round == 1 - ) - assert follow_up.answer_text == "Follow-up answer text." - assert follow_up.score == 4 - - -@pytest.mark.asyncio -async def test_last_follow_up_advances_immediately_and_evaluates_in_background( - isolated_db, fake_ai_provider -): - """The last follow-up round advances without waiting for AI evaluation.""" - interview_id = "ap-test-last-follow-up" - initial = Answer( - question_id="q1", - order=1, - round=0, - question_text="Original question?", - ) - initial.answer_text = "First answer" - initial.score = 3 - initial.feedback = "OK" - first_follow_up = Answer( - question_id="q1", - order=1, - round=1, - question_text="First follow-up?", - ) - first_follow_up.answer_text = "First follow-up answer" - first_follow_up.score = 3 - first_follow_up.feedback = "OK" - persist_interview_with_answers( - Interview( - id=interview_id, - locale="en", - selection_spec=minimal_selection_spec(categories=["basics"]), - status="active", - ), - [ - initial, - first_follow_up, - Answer( - question_id="q1", - order=1, - round=2, - question_text="Second follow-up?", - ), - Answer( - question_id="q2", - order=2, - round=0, - question_text="Question two?", - ), - ], - question_count=2, - ) - - provider = fake_ai_provider( - [follow_up_evaluation_json(score=4, needs_further_follow_up=False)] - ) - - events = await TheorySubmissionService.process_answer_submission( - interview_id=interview_id, - question_id="q1", - answer_text="Second follow-up answer.", - provider=provider, - ) - - assert len(events) == 2 - assert isinstance(events[0], AnswerSavedEvent) - feedback = events[1] - assert isinstance(feedback, AnswerFeedbackEvent) - assert not any(isinstance(event, EvaluatingEvent) for event in events) - assert feedback.round == 2 - assert feedback.next_question is not None - assert feedback.next_question["question_id"] == "q2" - - reloaded = InterviewQuery.get_interview(interview_id) - assert reloaded is not None - last_follow_up = next( - a for a in reloaded.answers if a.question_id == "q1" and a.round == 2 - ) - assert last_follow_up.answer_text == "Second follow-up answer." - assert last_follow_up.score is None - - await asyncio.sleep(0.05) - - reloaded = InterviewQuery.get_interview(interview_id) - assert reloaded is not None - last_follow_up = next( - a for a in reloaded.answers if a.question_id == "q1" and a.round == 2 - ) - assert last_follow_up.score == 4 - assert last_follow_up.feedback is not None - - -@pytest.mark.asyncio -async def test_process_answer_rejects_completed_interview( - isolated_db, fake_ai_provider -): - """Completed interviews cannot accept new answers.""" - interview_id = "ap-test-4" - persist_interview_with_answers( - Interview( - id=interview_id, - selection_spec=minimal_selection_spec(categories=["basics"]), - status="completed", - ), - [ - Answer( - question_id="q1", - order=1, - round=0, - question_text="Done?", - ) - ], - question_count=1, - ) - - provider = fake_ai_provider([answer_evaluation_json()]) - - with pytest.raises(InterviewNotActiveError): - await TheorySubmissionService.process_answer_submission( - interview_id=interview_id, - question_id="q1", - answer_text="Too late.", - provider=provider, - ) - - -def _seed_timed_interview( - interview_id: str = "ap-timer-default", - *, - started_at: datetime, - limit_seconds: int = 60, -) -> str: - """Persist an active interview with an expired timer on question one. - - Args: - interview_id: Interview primary key. - started_at: When the current round timer started. - limit_seconds: Per-round limit in seconds. - - Returns: - The interview id. - """ - return persist_interview_with_answers( - Interview( - id=interview_id, - locale="en", - selection_spec=minimal_selection_spec(categories=["basics"]), - status="active", - ), - [ - Answer( - question_id="q1", - order=1, - round=0, - question_text="Question one?", - started_at=started_at, - ), - Answer( - question_id="q2", - order=2, - round=0, - question_text="Question two?", - ), - ], - question_count=2, - task_time_limit_seconds=limit_seconds, - ) - - -@pytest.mark.asyncio -async def test_process_timeout_when_display_shows_zero(isolated_db): - """WS timeout is accepted when UI remaining is 0 but deadline is sub-second away.""" - started = datetime.now(UTC) - timedelta(seconds=59, milliseconds=500) - interview_id = _seed_timed_interview(started_at=started, limit_seconds=60) - - events = await TheorySubmissionService.process_timeout_submission( - interview_id=interview_id, - question_id="q1", - round_num=0, - ) - - assert len(events) == 1 - assert events[0].timed_out is True - - -@pytest.mark.asyncio -async def test_process_timeout_scores_zero_and_advances(isolated_db): - """Expired round is recorded with zero score without calling AI.""" - started = datetime.now(UTC) - timedelta(seconds=120) - interview_id = _seed_timed_interview(started_at=started) - - events = await TheorySubmissionService.process_timeout_submission( - interview_id=interview_id, - question_id="q1", - round_num=0, - ) - - assert len(events) == 1 - feedback = events[0] - assert isinstance(feedback, AnswerFeedbackEvent) - assert feedback.timed_out is True - assert feedback.next_question is not None - assert feedback.next_question["question_id"] == "q2" - - reloaded = InterviewQuery.get_interview(interview_id) - assert reloaded is not None - q1 = next(a for a in reloaded.answers if a.question_id == "q1" and a.round == 0) - assert q1.answer_text == TheoryTask.TIME_EXPIRED_ANSWER_TEXT - assert q1.score == 0 - q2 = next(a for a in reloaded.answers if a.question_id == "q2") - assert q2.started_at is not None - - -@pytest.mark.asyncio -async def test_timeout_ignored_while_answer_pending_evaluation( - isolated_db, -): - """Timeout during AI evaluation must not overwrite a submitted answer.""" - started = datetime.now(UTC) - timedelta(seconds=30) - interview_id = _seed_timed_interview(started_at=started) - with TheoryUnitOfWork(auto_commit=True) as uow: - section = uow.theory_sections.get_aggregate(interview_id) - assert section is not None - current = section.find_task("q1", 0) - updated = section.with_task_text(current.id, "Answer in progress.") - uow.theory_sections.save_aggregate(updated) - - events = await TheorySubmissionService.process_timeout_submission( - interview_id=interview_id, - question_id="q1", - round_num=0, - ) - - assert events == [] - reloaded = InterviewQuery.get_interview(interview_id) - assert reloaded is not None - q1 = next(a for a in reloaded.answers if a.question_id == "q1" and a.round == 0) - assert q1.answer_text == "Answer in progress." - assert q1.score is None - - -@pytest.mark.asyncio -async def test_timeout_during_ai_evaluation_preserves_score( - isolated_db, fake_ai_provider, monkeypatch -): - """Timeout sent while AI runs does not block persisting the real score.""" - import asyncio - - started = datetime.now(UTC) - timedelta(seconds=30) - interview_id = _seed_timed_interview(started_at=started) - provider = fake_ai_provider( - [answer_evaluation_json(score=5, follow_up_needed=False)] - ) - - orig_eval = TheoryEvaluatorService.evaluate_submission - - async def slow_eval(**kwargs): - await asyncio.sleep(0.05) - return await orig_eval(**kwargs) - - monkeypatch.setattr( - TheoryEvaluatorService, - "evaluate_submission", - staticmethod(slow_eval), - ) - - events: list = [] - gen = TheorySubmissionService.stream_answer_submission( - interview_id=interview_id, - question_id="q1", - answer_text="Valid on-time answer.", - provider=provider, - ) - async for event in gen: - events.append(event) - if type(event).__name__ == "EvaluatingEvent": - timeout_events = await TheorySubmissionService.process_timeout_submission( - interview_id=interview_id, - question_id="q1", - round_num=0, - ) - assert timeout_events == [] - - assert any(type(e).__name__ == "AnswerFeedbackEvent" for e in events) - q1 = next( - a - for a in InterviewQuery.get_interview(interview_id).answers - if a.question_id == "q1" - ) - assert q1.answer_text == "Valid on-time answer." - assert q1.score == 5 - - -@pytest.mark.asyncio -async def test_late_answer_submission_treated_as_timeout(isolated_db, fake_ai_provider): - """Submitting after the deadline skips AI and scores zero.""" - started = datetime.now(UTC) - timedelta(seconds=120) - interview_id = _seed_timed_interview(started_at=started) - provider = fake_ai_provider([answer_evaluation_json(score=5)]) - - events = await TheorySubmissionService.process_answer_submission( - interview_id=interview_id, - question_id="q1", - answer_text="Too late but trying anyway.", - provider=provider, - ) - - assert len(events) == 1 - assert isinstance(events[0], AnswerFeedbackEvent) - assert events[0].timed_out is True - - reloaded = InterviewQuery.get_interview(interview_id) - assert reloaded is not None - q1 = next(a for a in reloaded.answers if a.question_id == "q1" and a.round == 0) - assert q1.score == 0 - assert q1.answer_text == TheoryTask.TIME_EXPIRED_ANSWER_TEXT diff --git a/tests/test_api_routers.py b/tests/test_api_routers.py deleted file mode 100644 index 463c9c6..0000000 --- a/tests/test_api_routers.py +++ /dev/null @@ -1,540 +0,0 @@ -# Copyright 2026 GrillKit Contributors -# SPDX-License-Identifier: Apache-2.0 -"""Tests for API routers.""" - -import time -from typing import Any -from unittest.mock import ANY, AsyncMock, patch - -from fastapi.testclient import TestClient -import pytest - -from app.ai.llm_models import LLMModelEntry -from app.interview.domain.exceptions import ( - InterviewNotActiveError, - InterviewNotFoundError, -) -from app.main import create_app -from app.platform.services.config import AppConfig - - -async def _raising_answer_stream( - exc: Exception, - interview_id: str, - question_id: str, - answer_text: str, - **kwargs: Any, -) -> None: - raise exc - yield # type: ignore[misc, unreachable] - - -@pytest.fixture -def client(): - """Create a test client with mocked database.""" - from app.interview.api.deps import get_ai_provider - from tests.fakes import FakeProvider - - async def _fake_ai_provider(): - yield FakeProvider([]) - - with ( - patch("app.main.run_migrations"), - patch( - "app.platform.services.speech_runtime.SpeechRuntimeCoordinator.startup", - new=AsyncMock(), - ), - patch( - "app.platform.services.speech_runtime.SpeechRuntimeCoordinator.unload_all", - ), - ): - app = create_app() - app.dependency_overrides[get_ai_provider] = _fake_ai_provider - with TestClient(app) as test_client: - yield test_client - app.dependency_overrides.clear() - - -class MockInterview: - """Minimal mock of Interview for WebSocket tests.""" - - def __init__(self, status: str = "active"): - self.id = "test-session-id" - self.status = status - self.answers = [] - self.question_count = 5 - self.locale = "en" - self.selection_spec = ( - '{"sources":[{"track":"python","level":"junior",' - '"categories":["data-structures"]}]}' - ) - self.score = None - self.overall_feedback = None - - -class TestDashboardRouter: - """Tests for the dashboard home page.""" - - def test_dashboard_includes_interview_history(self, client): - """Dashboard passes interview history to the template.""" - mock_rows = [ - type( - "Row", - (), - { - "id": "id-1", - "title": "Python Interview", - "question_count": 5, - "score_display": "10 / 15", - "status": "completed", - "status_label": "Completed", - "datetime_display": "18 May 2026, 14:30", - "url": "/interview/id-1", - }, - )(), - ] - with patch( - "app.interview.services.dashboard.DashboardBuilder.list_rows", - return_value=mock_rows, - ): - response = client.get("/") - assert response.status_code == 200 - assert "Interview history" in response.text - assert "Python Interview" in response.text - - def test_dashboard_returns_html(self, client): - """Dashboard always returns HTML, even without provider config.""" - with patch( - "app.interview.services.dashboard.DashboardBuilder.list_rows", - return_value=[], - ): - response = client.get("/") - assert response.status_code == 200 - assert "text/html" in response.headers.get("content-type", "") - assert "Dashboard" in response.text - - -class TestConfigRouter: - """Tests for config router endpoints.""" - - _catalog_entry = LLMModelEntry( - id="cloud", - display_name="Cloud", - provider_type="openai-compatible", - model="gpt-4", - base_url="https://api.openai.com", - api_key_required=True, - api_key="stored-secret", - ) - - def _config_form_data(self, **overrides): - """Build a valid config form payload.""" - data = { - "llm_preset_id": "cloud", - "api_key": "test-key", - "timeout": 60.0, - "locale": "en", - } - data.update(overrides) - return data - - def test_config_page_get(self, client): - """Test GET /config endpoint returns HTML.""" - mock_config = AppConfig( - provider_type="openai-compatible", - base_url="https://api.openai.com", - model="gpt-4", - api_key="test-key", - ) - - with ( - patch( - "app.platform.services.config.ConfigService.get_config", - return_value=mock_config, - ), - ): - response = client.get("/config") - assert response.status_code == 200 - assert "text/html" in response.headers.get("content-type", "") - assert "Interview model" in response.text - assert "Add model to catalog" in response.text - - def test_config_page_get_no_config(self, client): - """Test GET /config without existing config.""" - with ( - patch( - "app.platform.services.config.ConfigService.get_config", - return_value=None, - ), - ): - response = client.get("/config") - assert response.status_code == 200 - assert "Interview model" in response.text - assert "Speech recognition model" in response.text - assert "Question voice (TTS)" in response.text - - async def test_save_config_preserves_api_key_when_field_empty(self, client): - """POST /config keeps the stored key when the password field is left blank.""" - existing = AppConfig( - provider_type="openai-compatible", - base_url="https://api.openai.com", - model="gpt-4", - api_key="stored-secret", - llm_preset_id="cloud", - ) - with ( - patch( - "app.platform.services.config.ConfigService.get_config", - return_value=existing, - ), - patch( - "app.platform.services.config_form.normalize_model_id", - return_value="cloud", - ), - patch( - "app.platform.api.config.LLMCatalogService.get_model", - return_value=self._catalog_entry, - ), - patch( - "app.platform.services.config.LLMCatalogService.get_model", - return_value=self._catalog_entry, - ), - patch( - "app.platform.services.config.ConfigService.test_connection", - return_value=(True, "OK"), - ), - patch( - "app.platform.services.config.ConfigService.save_config" - ) as mock_save, - ): - response = client.post( - "/config", - data=self._config_form_data(api_key=""), - ) - - assert response.status_code == 200 - saved = mock_save.call_args[0][0] - assert saved.api_key == "stored-secret" - - @pytest.mark.asyncio - async def test_save_config_success(self, client): - """Test POST /config with successful connection test.""" - with ( - patch( - "app.platform.services.config_form.normalize_model_id", - return_value="cloud", - ), - patch( - "app.platform.api.config.LLMCatalogService.get_model", - return_value=self._catalog_entry, - ), - patch( - "app.platform.services.config.LLMCatalogService.get_model", - return_value=self._catalog_entry, - ), - patch( - "app.platform.services.config.ConfigService.test_connection", - return_value=(True, "OK"), - ), - patch( - "app.platform.services.config.ConfigService.save_config" - ) as mock_save, - ): - response = client.post( - "/config", - data=self._config_form_data(), - ) - - assert response.status_code == 200 - mock_save.assert_called_once() - - @pytest.mark.asyncio - async def test_save_config_failure(self, client): - """Test POST /config with failed connection test.""" - with ( - patch( - "app.platform.services.config_form.normalize_model_id", - return_value="cloud", - ), - patch( - "app.platform.api.config.LLMCatalogService.get_model", - return_value=self._catalog_entry, - ), - patch( - "app.platform.services.config.LLMCatalogService.get_model", - return_value=self._catalog_entry, - ), - patch( - "app.platform.services.config.ConfigService.test_connection", - return_value=(False, "Connection failed"), - ), - ): - response = client.post( - "/config", - data=self._config_form_data(), - ) - - assert response.status_code == 200 - - def test_delete_config(self, client): - """Test DELETE /config endpoint.""" - with ( - patch( - "app.platform.services.config.ConfigService.delete_config" - ) as mock_delete, - ): - response = client.delete("/config") - - assert response.status_code == 200 - mock_delete.assert_called_once() - - @pytest.mark.asyncio - async def test_test_config_success(self, client): - """Test POST /config/test with successful connection.""" - with ( - patch( - "app.platform.services.config_form.normalize_model_id", - return_value="cloud", - ), - patch( - "app.platform.api.config.LLMCatalogService.get_model", - return_value=self._catalog_entry, - ), - patch( - "app.platform.services.config.LLMCatalogService.get_model", - return_value=self._catalog_entry, - ), - patch( - "app.platform.services.config.ConfigService.test_connection", - return_value=(True, "Connection successful"), - ), - ): - response = client.post( - "/config/test", - data=self._config_form_data(), - ) - - assert response.status_code == 200 - - @pytest.mark.asyncio - async def test_test_config_failure(self, client): - """Test POST /config/test with failed connection.""" - with ( - patch( - "app.platform.services.config_form.normalize_model_id", - return_value="cloud", - ), - patch( - "app.platform.api.config.LLMCatalogService.get_model", - return_value=self._catalog_entry, - ), - patch( - "app.platform.services.config.LLMCatalogService.get_model", - return_value=self._catalog_entry, - ), - patch( - "app.platform.services.config.ConfigService.test_connection", - return_value=(False, "Invalid API key"), - ), - ): - response = client.post( - "/config/test", - data=self._config_form_data(api_key="invalid-key"), - ) - - assert response.status_code == 200 - - -class TestInterviewHttpRoutes: - """Tests for interview HTTP surface (page only; interaction is WebSocket).""" - - def test_legacy_post_answer_removed(self, client): - """Legacy form POST answer endpoint is no longer registered.""" - response = client.post( - "/interview/test-id/answer", - data={"question_id": "q1", "answer_text": "text"}, - ) - assert response.status_code == 404 - - def test_legacy_post_complete_removed(self, client): - """Legacy form POST complete endpoint is no longer registered.""" - response = client.post("/interview/test-id/complete") - assert response.status_code == 404 - - -class TestInterviewWebSocket: - """Tests for WebSocket interview endpoint.""" - - def test_websocket_unknown_message(self, client): - """Test WebSocket returns error for unknown message type.""" - with ( - patch("app.interview.services.query.InterviewQuery.get_interview"), - client.websocket_connect("/interview/test-id/theory/ws") as ws, - ): - ws.send_json({"type": "unknown_command"}) - response = ws.receive_json() - assert response["type"] == "error" - assert "Unknown message type" in response["message"] - - def test_websocket_answer_success(self, client): - """Test WebSocket answer submission invokes stream_answer_submission.""" - stream_calls: list[tuple[str, str, str]] = [] - - async def mock_stream( - interview_id: str, - question_id: str, - answer_text: str, - **kwargs: Any, - ) -> None: - stream_calls.append((interview_id, question_id, answer_text)) - return - yield # type: ignore[misc, unreachable] - - with ( - patch( - "app.theory.services.submission.TheorySubmissionService.stream_answer_submission", - side_effect=mock_stream, - ), - client.websocket_connect("/interview/test-id/theory/ws") as ws, - ): - ws.send_json( - { - "type": "answer", - "question_id": "ds-001", - "answer_text": "My answer", - } - ) - for _ in range(100): - if stream_calls: - break - time.sleep(0.01) - assert stream_calls == [("test-id", "ds-001", "My answer")] - - def test_websocket_answer_missing_fields(self, client): - """Test WebSocket returns error when question_id or answer_text is missing.""" - with ( - patch("app.interview.services.query.InterviewQuery.get_interview"), - client.websocket_connect("/interview/test-id/theory/ws") as ws, - ): - ws.send_json({"type": "answer", "question_id": ""}) - response = ws.receive_json() - assert response["type"] == "error" - assert "Both" in response["message"] - - def test_websocket_answer_completed_session(self, client): - """Test WebSocket rejects answer on completed session.""" - with ( - patch( - "app.theory.services.submission.TheorySubmissionService.stream_answer_submission", - side_effect=lambda *args, **kwargs: _raising_answer_stream( - InterviewNotActiveError("test-id"), *args, **kwargs - ), - ), - client.websocket_connect("/interview/test-id/theory/ws") as ws, - ): - ws.send_json( - { - "type": "answer", - "question_id": "ds-001", - "answer_text": "My answer", - } - ) - response = ws.receive_json() - assert response["type"] == "error" - assert "completed" in response["message"].lower() - - def test_websocket_answer_session_not_found(self, client): - """Test WebSocket returns error when session is not found.""" - with ( - patch( - "app.theory.services.submission.TheorySubmissionService.stream_answer_submission", - side_effect=lambda *args, **kwargs: _raising_answer_stream( - InterviewNotFoundError("test-id"), *args, **kwargs - ), - ), - client.websocket_connect("/interview/test-id/theory/ws") as ws, - ): - ws.send_json( - { - "type": "answer", - "question_id": "ds-001", - "answer_text": "My answer", - } - ) - response = ws.receive_json() - assert response["type"] == "error" - assert "not found" in response["message"].lower() - - def test_websocket_ping_pong(self, client): - """Test WebSocket ping/pong returns session status.""" - mock_session = MockInterview(status="active") - - with ( - patch( - "app.interview.services.query.InterviewQuery.get_interview", - return_value=mock_session, - ), - client.websocket_connect("/interview/test-id/theory/ws") as ws, - ): - ws.send_json({"type": "ping"}) - response = ws.receive_json() - assert response["type"] == "pong" - assert response["status"] == "active" - - def test_websocket_ping_completed_session(self, client): - """Test ping returns completed status.""" - mock_session = MockInterview(status="completed") - - with ( - patch( - "app.interview.services.query.InterviewQuery.get_interview", - return_value=mock_session, - ), - client.websocket_connect("/interview/test-id/theory/ws") as ws, - ): - ws.send_json({"type": "ping"}) - response = ws.receive_json() - assert response["type"] == "pong" - assert response["status"] == "completed" - - def test_websocket_complete_success(self, client): - """Test WebSocket complete message triggers session completion.""" - with ( - patch( - "app.interview.services.completion.SessionCompletionService.complete_session", - new_callable=AsyncMock, - return_value=[], - ) as mock_complete, - client.websocket_connect("/interview/test-id/theory/ws") as ws, - ): - ws.send_json({"type": "complete"}) - for _ in range(100): - if mock_complete.await_count: - break - time.sleep(0.01) - mock_complete.assert_awaited_once_with( - interview_id="test-id", - provider=ANY, - ) - - def test_websocket_answer_service_error(self, client): - """Test WebSocket handles ValueError from service layer.""" - with ( - patch( - "app.theory.services.submission.TheorySubmissionService.stream_answer_submission", - side_effect=lambda *args, **kwargs: _raising_answer_stream( - ValueError("Invalid question"), *args, **kwargs - ), - ), - client.websocket_connect("/interview/test-id/theory/ws") as ws, - ): - ws.send_json( - { - "type": "answer", - "question_id": "ds-001", - "answer_text": "My answer", - } - ) - response = ws.receive_json() - assert response["type"] == "error" - assert "Invalid question" in response["message"] diff --git a/tests/test_audio_answer_processing.py b/tests/test_audio_answer_processing.py deleted file mode 100644 index a20f4e3..0000000 --- a/tests/test_audio_answer_processing.py +++ /dev/null @@ -1,203 +0,0 @@ -# Copyright 2026 GrillKit Contributors -# SPDX-License-Identifier: Apache-2.0 -"""Tests for audio answer submission orchestration.""" - -import asyncio - -import pytest - -from app.ai.audio_probe import minimal_wav_bytes -from app.interview.services.events import ( - AnswerFeedbackEvent, - AnswerSavedEvent, - EvaluatingEvent, - TranscriptEvent, -) -from app.interview.services.query import InterviewQuery -from app.shared.infrastructure.models import Answer, Interview -from app.theory.services.evaluator.service import TheoryEvaluatorService -from app.theory.services.submission import TheorySubmissionService -from tests.fakes import answer_evaluation_json, follow_up_evaluation_json -from tests.helpers.interview_seed import ( - persist_interview_with_answers, - seed_two_question_interview, -) -from tests.helpers.selection import minimal_selection_spec -from tests.helpers.transcription import FakeTranscriber - - -@pytest.mark.asyncio -async def test_process_audio_answer_runs_transcription_and_evaluation( - isolated_db, fake_ai_provider, monkeypatch -): - """Audio answers yield saved, evaluating, transcript, and feedback events.""" - monkeypatch.setattr( - TheorySubmissionService, - "require_audio_answer_enabled", - staticmethod(lambda: None), - ) - interview_id = seed_two_question_interview("audio-ap-1") - provider = fake_ai_provider( - [answer_evaluation_json(score=5, follow_up_needed=False)] - ) - transcriber = FakeTranscriber("spoken answer text") - wav_bytes = minimal_wav_bytes(duration_sec=0.2) - - events = await TheorySubmissionService.process_audio_answer_submission( - interview_id=interview_id, - question_id="q1", - wav_bytes=wav_bytes, - provider=provider, - transcriber=transcriber, - ) - - assert [type(event) for event in events] == [ - AnswerSavedEvent, - EvaluatingEvent, - TranscriptEvent, - AnswerFeedbackEvent, - ] - transcript = events[2] - assert isinstance(transcript, TranscriptEvent) - assert transcript.text == "spoken answer text" - assert transcriber.last_audio is not None - - reloaded = InterviewQuery.get_interview(interview_id) - assert reloaded is not None - answer = next(a for a in reloaded.answers if a.question_id == "q1" and a.round == 0) - assert answer.answer_text == "spoken answer text" - assert answer.score == 5 - - -@pytest.mark.asyncio -async def test_process_audio_answer_rejects_invalid_wav( - isolated_db, fake_ai_provider, monkeypatch -): - """Invalid WAV payloads fail before any events are emitted.""" - monkeypatch.setattr( - TheorySubmissionService, - "require_audio_answer_enabled", - staticmethod(lambda: None), - ) - interview_id = seed_two_question_interview("audio-ap-1") - provider = fake_ai_provider([answer_evaluation_json()]) - transcriber = FakeTranscriber() - - with pytest.raises(ValueError, match="valid WAV"): - await TheorySubmissionService.process_audio_answer_submission( - interview_id=interview_id, - question_id="q1", - wav_bytes=b"not-wav", - provider=provider, - transcriber=transcriber, - ) - - -@pytest.mark.asyncio -async def test_process_audio_answer_last_follow_up_fast_path( - isolated_db, fake_ai_provider, monkeypatch -): - """Last follow-up round advances immediately and transcribes in-band.""" - monkeypatch.setattr( - TheorySubmissionService, - "require_audio_answer_enabled", - staticmethod(lambda: None), - ) - interview_id = "audio-ap-last-follow-up" - initial = Answer( - question_id="q1", - order=1, - round=0, - question_text="Original question?", - ) - initial.answer_text = "First answer" - initial.score = 3 - initial.feedback = "OK" - first_follow_up = Answer( - question_id="q1", - order=1, - round=1, - question_text="First follow-up?", - ) - first_follow_up.answer_text = "First follow-up answer" - first_follow_up.score = 3 - first_follow_up.feedback = "OK" - persist_interview_with_answers( - Interview( - id=interview_id, - locale="en", - selection_spec=minimal_selection_spec(categories=["basics"]), - status="active", - ), - [ - initial, - first_follow_up, - Answer( - question_id="q1", - order=1, - round=2, - question_text="Second follow-up?", - ), - Answer( - question_id="q2", - order=2, - round=0, - question_text="Question two?", - ), - ], - question_count=2, - ) - - provider = fake_ai_provider( - [ - follow_up_evaluation_json( - score=4, - needs_further_follow_up=False, - ) - ] - ) - transcriber = FakeTranscriber("second follow-up spoken") - wav_bytes = minimal_wav_bytes() - - orig_eval = TheoryEvaluatorService.evaluate_submission - - async def slow_audio_eval(**kwargs): - await asyncio.sleep(0.05) - return await orig_eval(**kwargs) - - monkeypatch.setattr( - TheoryEvaluatorService, - "evaluate_submission", - staticmethod(slow_audio_eval), - ) - - events = await TheorySubmissionService.process_audio_answer_submission( - interview_id=interview_id, - question_id="q1", - wav_bytes=wav_bytes, - provider=provider, - transcriber=transcriber, - ) - - assert len(events) == 3 - assert isinstance(events[0], AnswerSavedEvent) - assert isinstance(events[1], AnswerFeedbackEvent) - assert isinstance(events[2], TranscriptEvent) - assert not any(isinstance(event, EvaluatingEvent) for event in events) - - reloaded = InterviewQuery.get_interview(interview_id) - assert reloaded is not None - last_follow_up = next( - a for a in reloaded.answers if a.question_id == "q1" and a.round == 2 - ) - assert last_follow_up.answer_text == "second follow-up spoken" - assert last_follow_up.score is None - - await asyncio.sleep(0.05) - - reloaded = InterviewQuery.get_interview(interview_id) - assert reloaded is not None - last_follow_up = next( - a for a in reloaded.answers if a.question_id == "q1" and a.round == 2 - ) - assert last_follow_up.score == 4 diff --git a/tests/test_session_results.py b/tests/test_session_results.py deleted file mode 100644 index 171122d..0000000 --- a/tests/test_session_results.py +++ /dev/null @@ -1,241 +0,0 @@ -# Copyright 2026 GrillKit Contributors -# SPDX-License-Identifier: Apache-2.0 -"""Tests for completed session results and section review pages.""" - -import json - -import pytest - -from app.coding.services.evaluator.service import CodingEvaluatorService -from app.coding.services.review import CodingReviewService -from app.interview.repositories.uow import InterviewUnitOfWork -from app.interview.services.results_page import SessionResultsPageService -from app.shared.infrastructure.models import Answer, CodingTask, Interview -from app.theory.services.review import TheoryReviewService -from tests.fakes import FakeProvider, section_evaluation_json -from tests.helpers.coding_seed import ( - attach_coding_tasks, - create_coding_section_for_interview, -) -from tests.helpers.interview_seed import persist_interview_with_answers -from tests.helpers.selection import minimal_selection_spec - - -def _seed_completed_theory_interview(interview_id: str = "results-theory-1") -> str: - """Persist a completed theory interview with one answered question. - - Args: - interview_id: Interview primary key. - - Returns: - Interview UUID. - """ - persist_interview_with_answers( - Interview( - id=interview_id, - locale="en", - selection_spec=minimal_selection_spec(categories=["basics"]), - status="active", - ), - [ - Answer( - question_id="q1", - order=1, - round=0, - question_text="What is Python?", - answer_text="A programming language", - score=4, - feedback="Clear and concise.", - ) - ], - ) - overall_feedback = { - "overall_feedback": "Good theory performance.", - "strengths_summary": ["basics"], - "topics_to_review": [], - "score_breakdown": { - "theory": { - "score": 4, - "max": 5, - "skipped": False, - "questions": {"q1": {"score": 4, "max": 5}}, - } - }, - } - with InterviewUnitOfWork(auto_commit=True) as uow: - aggregate = uow.interviews.get_aggregate(interview_id) - assert aggregate is not None - completed = aggregate.with_session_completed(overall_feedback) - uow.interviews.save_aggregate(completed) - return interview_id - - -def _seed_completed_coding_interview(interview_id: str = "results-coding-1") -> str: - """Persist a completed coding-only interview with one submitted task. - - Args: - interview_id: Interview primary key. - - Returns: - Interview UUID. - """ - with InterviewUnitOfWork(auto_commit=True) as uow: - interview = Interview( - id=interview_id, - locale="en", - selection_spec=json.dumps( - { - "version": 2, - "session_mode": "coding_only", - "theory": {"enabled": False}, - "coding": {"enabled": True}, - } - ), - session_mode="coding_only", - status="active", - ) - uow.interviews.add(interview) - uow.flush() - section = create_coding_section_for_interview( - uow.session, - interview, - task_count=1, - status="completed", - ) - tasks = attach_coding_tasks(uow.session, section, task_ids=["cod-001"]) - task = tasks[0] - task.submitted_code = "def solve():\n return 1" - task.score = 4 - task.feedback = "Works for the sample case." - task.submit_test_summary = json.dumps( - {"status": "success", "tests_passed": 2, "tests_total": 2} - ) - uow.session.add(task) - overall_feedback = { - "overall_feedback": "Good coding performance.", - "strengths_summary": ["problem solving"], - "topics_to_review": [], - "score_breakdown": { - "coding": { - "score": 4, - "max": 5, - "skipped": False, - "questions": {"cod-001": {"score": 4, "max": 5}}, - } - }, - } - aggregate = uow.interviews.get_aggregate(interview_id) - assert aggregate is not None - completed = aggregate.with_session_completed(overall_feedback) - uow.interviews.save_aggregate(completed) - return interview_id - - -@pytest.mark.asyncio -async def test_coding_evaluator_evaluate_section() -> None: - """Coding section evaluation returns parsed section narrative.""" - provider = FakeProvider( - replies=[section_evaluation_json(section_feedback="Strong coding section.")] - ) - result = await CodingEvaluatorService.evaluate_section( - provider=provider, - task_submissions=[ - { - "task_id": "cod-001", - "round": 0, - "prompt_text": "Solve it.", - "submitted_code": "return 1", - "score": 4, - } - ], - sources_text="Python / junior: basics", - locale="en", - ) - assert result.section_feedback == "Strong coding section." - - -def test_theory_review_service_builds_chat_history(isolated_db) -> None: - """Theory review exposes answered rounds and fallback section feedback.""" - interview_id = _seed_completed_theory_interview() - context = TheoryReviewService.build_context(interview_id) - assert context is not None - assert len(context.answers) == 1 - assert context.answers[0].feedback == "Clear and concise." - assert "Clear and concise." in context.section_feedback["section_feedback"] - - -def test_coding_review_service_groups_task_rounds(isolated_db) -> None: - """Coding review groups submitted rounds on one page.""" - interview_id = _seed_completed_coding_interview() - with InterviewUnitOfWork(auto_commit=True) as uow: - section = uow.coding_sections.get_aggregate(interview_id) - assert section is not None - follow_up = CodingTask( - coding_section_id=section.id, - task_id="cod-001", - order=1, - round=1, - prompt_text="Explain your approach.", - task_spec=json.dumps({"language": "python"}), - submitted_code="I used a direct return.", - score=3, - feedback="Explanation was brief.", - ) - uow.session.add(follow_up) - - context = CodingReviewService.build_context(interview_id) - assert context is not None - assert len(context.tasks) == 1 - assert len(context.tasks[0].rounds) == 2 - assert context.tasks[0].total_score == 7 - - -def test_session_results_page_service_builds_section_cards(isolated_db) -> None: - """Results hub includes enabled section cards with review links.""" - interview_id = _seed_completed_theory_interview("results-hub-1") - with InterviewUnitOfWork() as uow: - interview = uow.interviews.get_read_model(interview_id) - assert interview is not None - context = SessionResultsPageService.build_context(interview) - assert context is not None - assert context.theory_review_url == f"/interview/{interview_id}/theory" - assert len(context.section_cards) == 1 - assert context.section_cards[0].section == "theory" - - -def test_completed_interview_page_redirects_to_results(client, isolated_db) -> None: - """Completed sessions no longer render the active interview page.""" - interview_id = _seed_completed_theory_interview("results-redirect-1") - response = client.get(f"/interview/{interview_id}", follow_redirects=False) - assert response.status_code == 303 - assert response.headers["location"] == f"/interview/{interview_id}/results" - - -def test_results_page_renders_for_completed_session(client, isolated_db) -> None: - """Results hub renders overall feedback and section cards.""" - interview_id = _seed_completed_theory_interview("results-page-1") - response = client.get(f"/interview/{interview_id}/results") - assert response.status_code == 200 - assert "Overall Evaluation" in response.text - assert "View details" in response.text - assert "Good theory performance." in response.text - - -def test_theory_review_page_renders_history(client, isolated_db) -> None: - """Theory review page renders chat history and section feedback.""" - interview_id = _seed_completed_theory_interview("results-theory-page-1") - response = client.get(f"/interview/{interview_id}/theory") - assert response.status_code == 200 - assert "Conversation History" in response.text - assert "A programming language" in response.text - assert "Clear and concise." in response.text - - -def test_coding_review_page_renders_task_accordion(client, isolated_db) -> None: - """Coding review page renders per-task accordion with final submit.""" - interview_id = _seed_completed_coding_interview("results-coding-page-1") - response = client.get(f"/interview/{interview_id}/coding") - assert response.status_code == 200 - assert "Coding Tasks" in response.text - assert "cod-001" in response.text - assert "Works for the sample case." in response.text diff --git a/tests/test_audio_answer_api.py b/tests/theory/api/test_audio_answer.py similarity index 100% rename from tests/test_audio_answer_api.py rename to tests/theory/api/test_audio_answer.py diff --git a/tests/test_theory_api.py b/tests/theory/api/test_routes.py similarity index 100% rename from tests/test_theory_api.py rename to tests/theory/api/test_routes.py diff --git a/tests/test_ws_protocol.py b/tests/theory/api/test_ws_protocol.py similarity index 100% rename from tests/test_ws_protocol.py rename to tests/theory/api/test_ws_protocol.py diff --git a/tests/test_interview_ws_integration.py b/tests/theory/integration/test_ws.py similarity index 100% rename from tests/test_interview_ws_integration.py rename to tests/theory/integration/test_ws.py diff --git a/tests/test_theory_section.py b/tests/theory/repositories/test_theory_section.py similarity index 100% rename from tests/test_theory_section.py rename to tests/theory/repositories/test_theory_section.py diff --git a/tests/test_answer_ai_evaluation.py b/tests/theory/services/test_evaluator.py similarity index 100% rename from tests/test_answer_ai_evaluation.py rename to tests/theory/services/test_evaluator.py diff --git a/tests/test_theory_evaluator_parsing.py b/tests/theory/services/test_evaluator_parsing.py similarity index 100% rename from tests/test_theory_evaluator_parsing.py rename to tests/theory/services/test_evaluator_parsing.py diff --git a/tests/test_theory_planning.py b/tests/theory/services/test_planning.py similarity index 100% rename from tests/test_theory_planning.py rename to tests/theory/services/test_planning.py