diff --git a/app/coding/domain/entities.py b/app/coding/domain/entities.py index b4c9be0..566f8c0 100644 --- a/app/coding/domain/entities.py +++ b/app/coding/domain/entities.py @@ -199,7 +199,9 @@ 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/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/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..2579cdf 100644 --- a/app/interview/services/sections.py +++ b/app/interview/services/sections.py @@ -102,6 +102,22 @@ 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 6767ed2..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; } @@ -1152,6 +1208,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/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 new file mode 100644 index 0000000..ae488a7 --- /dev/null +++ b/static/js/setup_wizard.js @@ -0,0 +1,813 @@ +(function () { + "use strict"; + + const STORAGE_KEY = "grillkit_setup_wizard"; + const SUBMITTED_KEY = "grillkit_setup_wizard_submitted"; + + 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 */ + } + } + + 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; + 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(); + markWizardSubmitted(); + }); + } + + async function init() { + bindTrackBlocks(); + bindControls(); + syncTimerFields(); + syncCodingTimerFields(); + + 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"; + } 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/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/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 %} 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..1623db5 100644 --- a/tests/test_theory_section.py +++ b/tests/test_theory_section.py @@ -95,6 +95,18 @@ 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( + "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."""