diff --git a/.env.example b/.env.example index dce7577..b8cf67e 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,8 @@ # Linux bind-mount ownership for ./data (optional). # PUID=1000 # PGID=1000 + +# Coding section (optional). Requires Judge0 when enabled. +# CODING_ENABLED=true +# JUDGE0_URL=http://localhost:2358 +# JUDGE0_AUTH_TOKEN= diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c12479a..02c9240 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,7 +2,11 @@ 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/`, `speech/`, `question_voice/`, `platform/`) with cross-cutting code in `shared/`. Within each feature: transport in `api/`, orchestration in `services/`, Pydantic read models in `schemas/` (where present), persistence in `repositories/` (interview only). The **interview** feature uses a dedicated **domain** layer (`interview/domain/`: frozen aggregates, value objects, exceptions, behavior on entities) separate from ORM write models and Pydantic DTOs; `interview/repositories/mappers.py` maps ORM ↔ domain ↔ `InterviewRead`. Interview services load and mutate sessions via `get_aggregate` / `save_aggregate` and return `InterviewRead` at boundaries; they do not import SQLAlchemy models. Interview transactions use `InterviewUnitOfWork` (`interview/repositories/uow.py`), extending base `UnitOfWork` in `shared/infrastructure/`. The interview API does not expose SQLAlchemy models on the wire. +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`. + +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. ## Terminology @@ -19,14 +23,15 @@ GrillKit is an AI-powered technical interview trainer. The stack is **FastAPI** grillkit/ ├── app/ │ ├── main.py # create_app(), router registration, lifespan → run_migrations() -│ ├── paths.py # PROJECT_ROOT, DATA_DIR, CONFIG_PATH, whisper/questions/db paths -│ ├── questions.py # YAML question loader (data/questions/) │ ├── templating.py # Shared Jinja2Templates + static_version() │ ├── shared/ +│ │ ├── paths.py # PROJECT_ROOT, DATA_DIR, CONFIG_PATH, whisper/questions/db paths +│ │ ├── questions.py # YAML theory question loader (data/questions/) +│ │ ├── coding.py # YAML coding task loader (data/coding/) │ │ ├── locales.py # SUPPORTED_LOCALES, normalize_locale() │ │ ├── infrastructure/ │ │ │ ├── database.py # engine, SessionLocal, DATABASE_URL env, run_migrations() -│ │ │ ├── models.py # Interview, Answer ORM models +│ │ │ ├── models.py # Interview, TheorySection, Answer (theory tasks) ORM │ │ │ ├── audio_wav.py # Canonical mono 16 kHz WAV validation │ │ │ └── uow.py # Base UnitOfWork: session, commit, rollback │ │ └── repositories/ @@ -50,38 +55,62 @@ grillkit/ │ │ ├── speech_runtime.py # SpeechRuntimeCoordinator (Whisper + Piper lifecycle) │ │ ├── speech_settings.py │ │ └── ai_context.py # ai_provider_from_config() async context manager -│ ├── interview/ -│ │ ├── domain/ # Interview/Answer aggregates, VO, serialization, exceptions -│ │ ├── schemas/ # InterviewRead, page context, WebSocket message models -│ │ ├── services/rules/ # selection, feedback parsing (display titles, spec JSON) +│ ├── interview/ # Session orchestrator (shell, setup, dashboard, completion) +│ │ ├── domain/ # Interview shell aggregate, SessionSelection, serialization +│ │ ├── schemas/ # InterviewRead, dashboard/page context +│ │ ├── services/rules/ # selection_spec v2, display titles │ │ ├── repositories/ -│ │ │ ├── interview.py # get_aggregate, save_aggregate, list_recent -│ │ │ ├── answer.py -│ │ │ ├── mappers.py # ORM ↔ domain ↔ InterviewRead -│ │ │ └── uow.py # InterviewUnitOfWork +│ │ │ ├── interview.py # shell get/save, list_recent read models +│ │ │ ├── mappers.py # ORM ↔ shell ↔ InterviewRead (+ theory compose) +│ │ │ └── uow.py # InterviewUnitOfWork (interviews + theory_sections) │ │ ├── services/ -│ │ │ ├── creation.py -│ │ │ ├── question_planning.py # YAML plan + validation -│ │ │ ├── query.py -│ │ │ ├── page.py # Interview page context + speech/TTS template keys +│ │ │ ├── creation.py # SessionCreationService +│ │ │ ├── page.py # SessionPageService +│ │ │ ├── completion.py # SessionCompletionService │ │ │ ├── dashboard.py -│ │ │ ├── completion.py -│ │ │ ├── answer_processing.py # WS orchestration (submit + timeout) -│ │ │ ├── answer_timer.py -│ │ │ ├── answer_evaluation_persistence.py -│ │ │ ├── session_navigation.py -│ │ │ ├── events.py -│ │ │ └── evaluator/ # service.py, models.py, prompts.py +│ │ │ ├── query.py +│ │ │ ├── phases.py # multi-section phase order + prefetch hooks +│ │ │ ├── sections.py # Section registry and shared section DTOs +│ │ │ ├── evaluation_aggregator.py +│ │ │ ├── session_evaluator.py +│ │ │ └── events.py │ │ └── api/ -│ │ ├── deps.py # Services + AIProvider + SpeechTranscriber for routes +│ │ ├── deps.py │ │ ├── dashboard.py # GET / -│ │ ├── setup.py # GET/POST /setup, GET /setup/options +│ │ ├── setup.py # GET/POST /setup │ │ ├── setup_form.py -│ │ ├── routes.py # GET /interview/{id}, question-audio, audio-answer, WS -│ │ ├── ws_session.py # WebSocket message handling (transport) -│ │ ├── audio_answer.py # NDJSON audio-answer transport adapter -│ │ ├── ws_protocol.py # InterviewEvent → wire JSON +│ │ ├── routes.py # GET /interview/{id}, question-audio │ │ └── errors.py +│ ├── coding/ # Coding section (tasks, Judge0 runner, WS/API, evaluator) +│ │ ├── domain/ # CodingSection, CodingTask, CodeRunAttempt aggregates +│ │ ├── repositories/ # coding_section repo, mappers, CodingUnitOfWork +│ │ ├── services/ +│ │ │ ├── planning.py # YAML task plan from data/coding/ +│ │ │ ├── creation.py # CodingSectionCreationService +│ │ │ ├── availability.py # CODING_ENABLED + Judge0 health gate +│ │ │ ├── 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 +│ │ │ └── evaluator/ # CodingEvaluatorService +│ │ ├── api/ +│ │ │ ├── routes.py # POST /coding/run, GET /coding/state, WS /coding/ws +│ │ │ └── ws_session.py, ws_protocol.py +│ │ └── schemas/ # coding read models + WS messages +│ ├── theory/ # Theory section (tasks, timer, WS, evaluator) +│ │ ├── domain/ # TheorySection, TheoryTask aggregates +│ │ ├── schemas/ # TheoryTaskRead, TheoryPageContext, WS messages +│ │ ├── repositories/ # theory_section repo, mappers, TheoryUnitOfWork +│ │ ├── services/ +│ │ │ ├── planning.py # YAML question plan (excludes type=coding) +│ │ │ ├── creation.py # TheorySectionCreationService +│ │ │ ├── submission.py # answer/timeout/audio orchestration +│ │ │ ├── navigation.py, timer.py, evaluation_persistence.py +│ │ │ ├── page.py, query.py, section.py +│ │ │ └── evaluator/ # TheoryEvaluatorService +│ │ └── api/ +│ │ ├── routes.py # WS /theory/ws, POST /theory/audio-answer +│ │ ├── ws_session.py, ws_protocol.py, audio_answer.py │ ├── question_voice/ │ │ ├── api/ │ │ │ └── routes.py # GET /speech/tts/status, POST /speech/tts/voice/download @@ -96,7 +125,7 @@ grillkit/ ├── templates/ # Jinja2 HTML (dashboard, setup, config, interview, speech_model_*) ├── static/ │ ├── css/styles.css -│ └── js/ # dictation, interview_voice, interview_timer, interview_audio_answer, ... +│ └── js/ # dictation, interview_voice, interview_timer, coding_editor, coding_session, ... ├── data/ │ ├── config.json # Locale, speech/TTS flags (gitignored) │ ├── llm_models.json # User LLM catalog + selected model (gitignored) @@ -131,10 +160,10 @@ 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` | Interview page (active or completed) | -| GET | `/interview/{interview_id}/question-audio` | `interview/api/routes.py` | WAV for current question text (`question_id`, `round` query params) | -| POST | `/interview/{interview_id}/audio-answer` | `interview/api/routes.py` | Multipart WAV answer → NDJSON (`saved`, `transcript`, `feedback`, …) | -| WS | `/interview/{interview_id}/ws` | `interview/api/routes.py` | Real-time text answers and completion | +| GET | `/interview/{interview_id}` | `interview/api/routes.py` | Session page (composed shell + theory context) | +| 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 | | WS | `/interview/{interview_id}/dictation` | `speech/api/dictation.py` | PCM dictation: `start` → `ready`, audio chunks, `stop` → `final` | | — | `/static/*` | `main.py` | CSS, JS, and assets | @@ -144,12 +173,14 @@ grillkit/ |-----------------|----------------| | `interview/api/`, `speech/api/`, `platform/api/`, `question_voice/api/` | HTTP/WebSocket transport, forms, template rendering | | `*/api/deps.py` | Inject service **classes** via `Depends` (handlers call static methods) | -| `interview/domain/` | Interview aggregate, `Answer`, value objects, persistence serialization, domain exceptions; no I/O or framework imports | -| `interview/schemas/` | Pydantic read models (`InterviewRead`, page context, WS server messages) | -| `interview/repositories/mappers.py` | ORM ↔ domain ↔ `InterviewRead` (persistence and read-model mapping) | -| `interview/api/ws_protocol.py` | Map `InterviewEvent` dataclasses → interview WebSocket/NDJSON JSON (`interview/schemas/ws.py`) | -| `interview/api/ws_session.py` | Parse client WebSocket messages, call use cases, emit wire JSON | -| `interview/api/audio_answer.py` | Validate multipart input and stream NDJSON from `InterviewEvent` | +| `interview/domain/` | Interview session shell aggregate, `SessionSelection`, serialization, domain exceptions | +| `theory/domain/` | `TheorySection` / `TheoryTask` aggregates and theory-specific 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 | +| `theory/api/ws_protocol.py` | Map service events → theory WebSocket/NDJSON JSON | +| `theory/api/ws_session.py` | Parse client WebSocket messages, call `TheorySubmissionService` | +| `theory/api/audio_answer.py` | Validate multipart input and stream NDJSON from theory events | | `speech/api/dictation_protocol.py` | Dictation WebSocket message types (`start`, `stop`, `ready`, `final`, `error`) | | `interview/api/errors.py` | Map `InterviewDomainError` → error payloads | | `*/services/` | Use-case orchestration (static methods on service classes) | @@ -157,10 +188,11 @@ grillkit/ | `shared/locales.py` | Locale normalization and localized UI strings | | `interview/repositories/` | Interview persistence: ORM access, `get_aggregate` / `save_aggregate`, mappers | | `shared/infrastructure/uow.py` | Base transaction boundary (session lifecycle) | -| `interview/repositories/uow.py` | `InterviewUnitOfWork`: `uow.interviews` only | +| `interview/repositories/uow.py` | `InterviewUnitOfWork`: `uow.interviews`, `uow.theory_sections` | +| `theory/repositories/uow.py` | `TheoryUnitOfWork`: theory section persistence | | `shared/infrastructure/models.py` | ORM models | | `ai/` | Provider adapters (`AIProvider`, `SpeechTranscriber`) | -| `questions.py` | Read-only YAML question bank access | +| `shared/questions.py` | Read-only YAML question bank access | Application services are **stateless classes with `@staticmethod`**. FastAPI dependencies in each feature's `deps.py` return the class (e.g. `InterviewQuery`), not instances. @@ -189,15 +221,17 @@ question_voice/services/ └── tts_cache.py ──► data/tts-cache/v2/{locale}/ interview/services/ - ├── creation.py ──► domain, mappers, question_planning, InterviewUnitOfWork - ├── question_planning.py ──► app/questions.py, services/rules/selection - ├── session_navigation.py ──► domain, InterviewUnitOfWork (timer start on aggregate) - ├── query.py ──► domain, mappers, InterviewUnitOfWork - ├── dashboard.py ──► domain, mappers, InterviewUnitOfWork - ├── completion.py ──► domain, mappers, evaluator, uow (AIProvider via interview/api/deps) - ├── answer_processing.py ──► answer_timer, answer_evaluation_persistence, evaluator - ├── answer_timer.py ──► domain, session_navigation, InterviewUnitOfWork - └── evaluator/ ──► service.py (evaluate_submission, session evaluation), models.py, prompts.py + ├── creation.py ──► SessionCreationService, TheorySectionCreationService + ├── page.py ──► SessionPageService, TheoryPageService + ├── completion.py ──► SessionCompletionService, SessionEvaluationAggregator + ├── query.py, dashboard.py, phases.py, sections.py + └── session_evaluator.py ──► session-level narrative (delegates section eval to theory) + +theory/services/ + ├── planning.py ──► app/shared/questions.py (filters type=coding) + ├── creation.py, submission.py, navigation.py, timer.py + ├── section.py ──► section registry hooks + prefetch + └── evaluator/ ──► TheoryEvaluatorService (per-task + section narrative) interview/api/deps.py ──► platform/services/ai_context (yields AIProvider for WS/routes) @@ -209,7 +243,7 @@ speech/services/ └── dictation.py ──► ai/speech_transcriber shared/infrastructure/uow.py - └── interview/repositories/ (interview, answer) ──► shared/repositories/base, models + └── interview/repositories/, theory/repositories/ ──► shared/repositories/base, models ``` On GitHub, the same graph is also available as Mermaid (rendered on github.com only): @@ -295,52 +329,62 @@ flowchart TB | Concept | Name in code | |---------|----------------| -| Interview domain aggregate | `app.interview.domain.entities.Interview` | +| Session shell aggregate | `app.interview.domain.entities.Interview` | +| Theory section aggregate | `app.theory.domain.entities.TheorySection` | | Interview ORM model | `shared.infrastructure.models.Interview` (table `interviews`) | -| Interview read DTO | `app.interview.schemas.interview.InterviewRead` | -| Primary key column | `Interview.id` (UUID string) | +| Theory task ORM | `shared.infrastructure.models.Answer` (table `answers`, FK `theory_section_id`) | +| 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`) | -| Answer FK | `Answer.interview_id` → `interviews.id` | -| Create flow | `interview.services.creation.InterviewCreationService.create_interview()` | -| Read flow | `interview.services.query.InterviewQuery.get_interview()`, `dashboard.DashboardBuilder.list_rows()` | -| Answer flow | `AnswerProcessingService` (orchestrates timer + `AnswerAiEvaluationService` + persistence) | -| Timeout flow | `AnswerProcessingService.stream_timeout_submission()` + `RoundTimerService` | -| Complete flow | `interview.services.completion.InterviewCompletionService.complete_interview()` | -| UoW repositories | `uow.interviews` | +| Create flow | `SessionCreationService.create_session()` + `TheorySectionCreationService.create()` | +| 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` | | SQLAlchemy session | `uow.session` | ## Key Models -### Interview (`interviews`) +### Interview (`interviews`) — session shell | Field | Type | Notes | |-------|------|-------| | `id` | `str` | UUID v4 primary key | | `locale` | `str` | AI feedback language (`en`, `ru`, …) | -| `selection_spec` | `str` | JSON `{sources: [{track, level, categories[]}]}` (required) | -| `question_count` | `int` | Number of questions in session | -| `question_ids` | `str` | JSON list of question IDs in display order | -| `question_time_limit_seconds` | `int \| None` | Per-round limit (`None` = timer off) | +| `selection_spec` | `str` | JSON v2: `session_mode`, `theory` / `coding` branches | +| `session_mode` | `str` | `theory_only`, `coding_only`, `theory_then_coding`, `coding_then_theory` | | `status` | `str` | `active` or `completed` | -| `score` | `int \| None` | Total score when completed | -| `overall_feedback` | `str \| None` | JSON string from final AI evaluation | +| `overall_feedback` | `str \| None` | JSON final evaluation with `score_breakdown.{theory,coding}` | | `started_at`, `completed_at` | `datetime` | Session timestamps | -### Answer (`answers`) +### TheorySection (`theory_sections`) + +| Field | Type | Notes | +|-------|------|-------| +| `id` | `int` | Auto-increment PK | +| `interview_id` | `str` | FK to `interviews.id` (1:0..1) | +| `selection_spec` | `str` | Theory branch selection JSON | +| `question_count` | `int` | Number of theory tasks in section | +| `task_time_limit_seconds` | `int \| None` | Per-task timer (`None` = off) | +| `status` | `str` | `active`, `completed`, or `skipped` | +| `section_score`, `section_feedback` | | Section narrative (may be prefetched after phase complete) | +| `locale` | `str` | Section locale snapshot | + +### Answer (`answers`) — theory task rows | Field | Type | Notes | |-------|------|-------| | `id` | `int` | Auto-increment PK | -| `interview_id` | `str` | FK to `interviews.id` (CASCADE delete) | +| `theory_section_id` | `int` | FK to `theory_sections.id` | | `question_id` | `str` | ID from YAML bank | -| `order` | `int` | 1-based display order within session | -| `round` | `int` | `0` = initial question; `1+` = AI follow-up | +| `order` | `int` | 1-based display order within section | +| `round` | `int` | `0` = initial; `1+` = AI follow-up | | `question_text`, `question_code` | `str` | Snapshot at ask time | | `answer_text` | `str \| None` | User answer (`None` until submitted) | | `started_at` | `datetime \| None` | When this round became active (timed sessions) | | `score`, `feedback` | | After AI evaluation (1–5) or `0` on timeout | -Rows are created up front at interview creation (one per question, `round=0`). Follow-up rounds are appended via `InterviewRepository.save_aggregate`. +Initial task rows are created with the theory section; follow-ups append via `TheorySectionRepository.save_aggregate`. ## Data Flow: Configure Provider @@ -360,55 +404,67 @@ User → POST /config/llm-models (add catalog entry, optional accepts_audio_inpu ## Data Flow: Create Interview ``` -User → POST /setup (selection_json, question_count, optional timer) - → parse InterviewSelection (tracks, per-track level, topic categories) - → validate question_count ≥ number of selected topics - → locale from ConfigService.get_config() → Interview.locale snapshot - → InterviewCreationService.create_interview(selection, …) - → build_question_plan(): one question per topic, then proportional fill - → questions grouped by track (form order), shuffled within each block - → UnitOfWork(auto_commit=True): persist Interview + selection_spec + Answer rows +User → POST /setup (selection_json v2: session_mode, theory/coding branches, counts, timers) + → parse SessionSelection; gate coding modes on CODING_ENABLED + Judge0 health + → locale from ConfigService.get_config() + → SessionCreationService.create_session(selection, locale) + → Interview.start_shell() + → TheorySectionCreationService.create() when theory.enabled + → CodingSectionCreationService.create() when coding.enabled + → build_theory_question_plan() (excludes YAML type=coding) + → build_coding_task_plan() from data/coding/ + → InterviewUnitOfWork(auto_commit=True): shell + section rows + tasks → Redirect GET /interview/{id} ``` -## Data Flow: WebSocket Answer +## Data Flow: WebSocket Theory Answer ``` -Client → WS {"type":"answer","question_id":"...","answer_text":"..."} - → AnswerProcessingService.process_answer_submission(interview_id, ...) - → UoW #1: validate active, save answer_text, load context - → ai_provider_from_config() → InterviewEvaluatorService (no DB transaction) - → UoW #2: save score/feedback; optional follow-up Answer row or advance - → stream_answer_submission() yields saved/evaluating, then feedback after AI - → On the **last follow-up** of a question: advance to next question immediately; - AI score/feedback for that round may persist in a background task (UI not blocked) - → event_to_message() per event → client (not batched after evaluation) - -Client → WS {"type":"timeout","question_id":"...","round":N} - → AnswerProcessingService.stream_timeout_submission() when deadline passed - → score 0, no AI, advance (same feedback shape with `timed_out: true`) - -Client → WS {"type":"ping"} - → InterviewQuery.get_interview() → {"type":"pong","status":"active"|"completed"|...} +Client → WS /interview/{id}/theory/ws {"type":"answer",...} + → TheorySubmissionService (timer, navigation, TheoryEvaluatorService) + → On section complete: SessionPhaseOrchestrator.notify_section_complete → prefetch + → Session complete: SessionCompletionService via WS "complete" message + +Client → WS {"type":"timeout",...} → TheorySubmissionService timeout path (score 0) +Client → WS {"type":"ping"} → pong with session status ``` **Server → client message types:** `saved`, `evaluating`, `transcript` (audio path), `feedback`, `interview_completed`, `error`, `pong`. ## Data Flow: Audio Answer (HTTP) -Requires active interview, catalog model with `accepts_audio_input`, and loaded Whisper (`app.state.speech_transcriber`). - ``` -Client → POST /interview/{id}/audio-answer (multipart: question_id, file=WAV) - → validate mono 16 kHz PCM WAV (shared/infrastructure/audio_wav.py) - → AnswerProcessingService.require_audio_answer_enabled() - → transcribe via SpeechTranscriber → stream NDJSON (same event shapes as WS) - → saved → transcript → evaluating → feedback (multimodal LLM when supported) +Client → POST /interview/{id}/theory/audio-answer (multipart: question_id, file=WAV) + → TheoryAudioAnswerAdapter → TheorySubmissionService stream (NDJSON) → Client: static/js/interview_audio_answer.js ``` Gated on the interview page when dictation is available **and** `interview_model_accepts_audio` (`InterviewPageService` + catalog `accepts_audio_input`). Configuration save / add-model tests audio capability with `app/ai/audio_probe.py` when the flag is enabled. +## Data Flow: Coding Run and Submit + +Interview page shows a separate **coding panel** (Monaco via CDN) when `session_mode` places the user on the coding phase. Theory and coding are not mixed in one chat stream. + +``` +Client → POST /interview/{id}/coding/run {"task_id","source_code"} + → CodingRunExecutionService → CodingRunnerService (public tests via Judge0) + → persist CodeRunAttempt (snapshot: code, stderr, test_results, attempt_no) + → JSON mirror of the attempt + +Client → WS /interview/{id}/coding/ws {"type":"submit","task_id","source_code"} + → CodingSubmissionService + → hidden tests (Judge0) → submit_test_summary on CodingTask + → load code_run_attempts for the task + → CodingEvaluatorService (run history + tests + code in prompt) + → persist score/feedback; optional follow-up round (code | explanation) + → saved → evaluating → feedback (next_task or phase switch) + +Client → GET /interview/{id}/coding/state + → current task, progress, run history for the active task +``` + +Run attempts are rate-limited per task (`CODING_MAX_RUNS_PER_TASK`, default 20). Judge0 runs only on the server; hidden test expectations are never sent to the browser. + ## Data Flow: Dictation WebSocket Separate from answer/evaluation WS. Requires active interview and loaded transcriber (`app.state.speech_transcriber`). @@ -447,14 +503,19 @@ Configured size and locale live in `data/config.json` (`AppConfig`). Transcripti ## Data Flow: Complete Interview ``` -Client → WS {"type":"complete"} - → InterviewCompletionService.complete_interview(interview_id) - → build Q&A summary → AI overall evaluation - → UnitOfWork: save overall_feedback, mark completed, set score +Client → WS /interview/{id}/theory/ws {"type":"complete"} + → SessionCompletionService.complete_session(interview_id) + → TheoryQueryService.get_evaluation_summary() + → CodingQueryService.get_evaluation_summary() + → SessionEvaluationAggregator.merge() → nested score_breakdown + → SessionEvaluatorService (cached section narratives or one LLM call) + → UnitOfWork: save overall_feedback, mark completed → returns [EvaluatingEvent, InterviewCompletedEvent] → events_to_messages() → client ``` +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 Access Pattern ```python @@ -482,10 +543,11 @@ with InterviewUnitOfWork(auto_commit=True) as uow: ## Scoring -- Each answered round (initial or follow-up) is scored **1–5** by the AI. -- Maximum points per round: `Interview.MAX_SCORE_PER_ROUND` (5) in `app/interview/domain/entities.py`. -- Session total: `compute_interview_score()` sums all non-null answer scores. -- Per-question breakdown: `build_per_question_score_breakdown()` for completion feedback. +- Each theory answer round and each coding submit round is scored **1–5** by the AI. +- Maximum points per round: `TheorySection.MAX_SCORE_PER_ROUND` / `CodingSection.MAX_SCORE_PER_ROUND` (5). +- Section totals: `theory_sections` and `coding_sections` aggregate per-round scores on their task rows. +- Session completion: `SessionEvaluationAggregator` builds `score_breakdown` with separate `theory` and `coding` entries (`score`, `max`, `skipped`, per-item rows). +- Display score on the dashboard and completed interview: sum of section scores from `score_breakdown` (not a single blended total). ## Persistence & Configuration @@ -499,6 +561,7 @@ data/ ├── whisper-models// # faster-whisper snapshots (gitignored content) ├── piper-voices// # Piper ONNX voices (gitignored content) ├── tts-cache/v2/{locale}/ # Cached question WAVs (gitignored content) +├── coding/ # YAML coding task banks (Judge0 / AI tasks) └── questions/ # YAML banks: {track}/{level}/{category}.yaml ``` @@ -510,7 +573,8 @@ data/ | `data/whisper-models//` | Offline faster-whisper snapshots (`WhisperModelService`) | | `data/piper-voices//` | Piper ONNX voice files (`PiperVoiceService`) | | `data/tts-cache/v2/{locale}/` | Cached question WAVs (`TtsCacheService`; SHA-256 of normalized text) | -| `data/questions/{track}/{level}/{category}.yaml` | Question banks | +| `data/questions/{track}/{level}/{category}.yaml` | Theory question banks | +| `data/coding/{track}/{level}/{category}.yaml` | Coding task banks | ### Environment variables @@ -520,6 +584,10 @@ data/ | `HF_TOKEN` | Hugging Face read token for Whisper/Piper downloads | | `WHISPER_DEVICE` | `cpu` or `cuda` (default `cpu`) | | `WHISPER_COMPUTE_TYPE` | `int8` or `float16` (default `int8` on CPU) | +| `CODING_ENABLED` | Enable coding session modes on setup (default `true`; requires healthy Judge0) | +| `JUDGE0_URL` | Judge0 CE API base URL (default `http://localhost:2358`; Docker profile uses `http://judge0-server:2358`) | +| `JUDGE0_AUTH_TOKEN` | Optional `X-Auth-Token` for self-hosted Judge0 | +| `CODING_MAX_RUNS_PER_TASK` | Max Run attempts per coding task (default `20`) | Docker Compose mounts `./data:/app/data` so DB and config survive container restarts. `run_migrations()` runs on app startup (`lifespan` in `main.py`) via **Alembic** (`alembic upgrade head`). For a clean dev DB, remove `data/db/grillkit.db` and restart, or run `uv run alembic upgrade head` manually. @@ -539,7 +607,7 @@ Current top-level **tracks** under `data/questions/` (each has `junior` / `middl | **observability** | Prometheus, Grafana, Loki | | **airflow** | Scheduling, executors, TaskFlow, operations | -`questions.py` discovers tracks and categories from the filesystem (`questions_map.yaml` is metadata only). Setup uses `GET /setup/options?track=…` for cascaded form updates. +`shared/questions.py` discovers theory tracks and categories from the filesystem (`questions_map.yaml` is metadata only). `shared/coding.py` mirrors the layout under `data/coding/`. Setup uses `GET /setup/options?track=…` and `GET /setup/coding-options?track=…` for cascaded form updates. ### Localization (YAML) @@ -585,7 +653,8 @@ Follow-up rounds use the same pipeline (cache key from localized `question_text` - Only one AI adapter type is implemented: `openai-compatible` (`ProviderFactory`) - Preset provider names in UI/docs may list OpenAI, Anthropic, Ollama, etc., but all use the same HTTP client shape -- Text answers use WebSocket (`WS /interview/{id}/ws`); spoken **audio answers** use `POST /interview/{id}/audio-answer` (NDJSON) +- Text theory answers use `WS /interview/{id}/theory/ws`; coding submit uses `WS /interview/{id}/coding/ws`; spoken **audio answers** use `POST /interview/{id}/theory/audio-answer` (NDJSON) +- Coding requires a healthy Judge0 instance when `CODING_ENABLED=true`; use `docker compose --profile coding up` or set `CODING_ENABLED=false` to disable coding modes - Per-round scores and feedback are stored during the interview but shown in the UI only after completion (WebSocket `feedback` advances questions without live score bubbles) - On the **last follow-up** of a question, navigation is immediate; that round’s score may finish persisting in the background - AI follow-ups: up to `InterviewEvaluatorService.MAX_FOLLOW_UP_DEPTH` (2) extra rounds per question diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a075fc..1dc1119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,51 @@ 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) @@ -20,6 +61,10 @@ Work in progress is accumulated under `[Unreleased]`; on release, that section b ### 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.5.31 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 52ef6d5..26975ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,6 +74,54 @@ Guidelines: Optional legacy keys `follow_ups` and `expected_points` may appear in older banks; the loader ignores them. Follow-ups in interviews are generated by the AI. +### Adding Coding Tasks + +Coding tasks live in YAML under `data/coding/{track}/{level}/{category}.yaml` (same track/level layout as theory banks). Loader: `app/shared/coding.py`. Do **not** add `type: coding` rows to `data/questions/` — use the coding bank instead. + +Example (`data/coding/python/junior/functions.yaml`): + +```yaml +category: "Functions" +track: "python" +level: "junior" + +tasks: + - id: "func-001" + difficulty: 2 + tags: ["docstrings"] + question: + text: + en: "Add type hints and a docstring to the starter function." + coding: + language: python + evaluation_mode: ai # or tests for Judge0 public/hidden cases + starter_code: | + def divide(a, b): + return a / b + entrypoint: divide # required when evaluation_mode is tests + public_tests: + - name: normal + stdin: "6\n2\n" + expected_stdout: "3.0\n" + hidden_tests: + - name: zero_division + stdin: "1\n0\n" + expected_stdout: "None\n" + time_limit_seconds: 5 + memory_limit_kb: 128000 + expected_points: + - "Docstring describes parameters and return value" +``` + +Guidelines: + +- Use `tasks` (not `questions`); `id` must be unique within the file +- `evaluation_mode: ai` — Run checks compile/sanity; AI scores on Submit using `expected_points` and run history +- `evaluation_mode: tests` — Run uses `public_tests`; Submit runs `hidden_tests` before AI evaluation +- `entrypoint` names the function Judge0 harness calls for stdin/stdout tests +- Localize `question.text` with locale maps (`en` required); `starter_code` is not localized +- Validate YAML before opening a PR; run `uv run pytest tests/test_coding_tasks.py` + ### Code Contributions 1. Add tests for new behavior diff --git a/README.md b/README.md index a9915f1..adb90de 100644 --- a/README.md +++ b/README.md @@ -94,11 +94,21 @@ If bind-mounted `data/` is not writable (Linux UID mismatch): PUID=$(id -u) PGID=$(id -g) docker compose up --build ``` +**Coding sessions** (Monaco + code execution) require [Judge0 CE](https://github.com/judge0/judge0). Start the optional `coding` profile: + +```bash +docker compose --profile coding up --build +``` + +Judge0 listens on port `2358` inside the Compose network (`JUDGE0_URL=http://judge0-server:2358` for the `app` service). For local development without Docker, run Judge0 separately and point `JUDGE0_URL` at `http://localhost:2358`. + +On some Linux hosts Judge0 needs **cgroup v1** (`systemd.unified_cgroup_hierarchy=0` in GRUB). Set `CODING_ENABLED=false` to hide coding modes when Judge0 is unavailable. + ### 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`) — enable one or more question-bank tracks (level per track), select multiple topics, set total question count (at least one per selected topic; interview locale is read-only from config). -3. **Interview** (`/interview/{id}`) — page loads history; text answers and completion go over WebSocket. +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. Without saved provider config, `/setup` redirects to `/config`. @@ -141,6 +151,10 @@ Optional environment variables (full list in [ARCHITECTURE.md](ARCHITECTURE.md#p | `HF_TOKEN` | Hugging Face token for faster Whisper/Piper downloads | | `WHISPER_DEVICE` | `cpu` or `cuda` | | `WHISPER_COMPUTE_TYPE` | `int8` or `float16` | +| `CODING_ENABLED` | Enable coding session modes (default `true`; requires healthy Judge0) | +| `JUDGE0_URL` | Judge0 API base URL (default `http://localhost:2358`) | +| `JUDGE0_AUTH_TOKEN` | Optional Judge0 `X-Auth-Token` header | +| `CODING_MAX_RUNS_PER_TASK` | Max Run attempts per coding task (default `20`) | ## Roadmap @@ -148,7 +162,6 @@ Optional environment variables (full list in [ARCHITECTURE.md](ARCHITECTURE.md#p - Session-wide time limit (total interview duration) - More question banks and categories -- Code editor in the interview UI - Custom question banks, PWA / standalone frontend ## For developers diff --git a/alembic/versions/20260608_0004_theory_sections.py b/alembic/versions/20260608_0004_theory_sections.py new file mode 100644 index 0000000..0898f42 --- /dev/null +++ b/alembic/versions/20260608_0004_theory_sections.py @@ -0,0 +1,82 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Create theory_sections table and backfill from interviews.""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260608_0004" +down_revision: str | None = "20260526_0003" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create ``theory_sections`` and backfill one row per existing interview.""" + bind = op.get_bind() + inspector = sa.inspect(bind) + existing = set(inspector.get_table_names()) + + if "theory_sections" not in existing: + op.create_table( + "theory_sections", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("interview_id", sa.String(), nullable=False), + sa.Column("selection_spec", sa.Text(), nullable=False), + sa.Column("question_count", sa.Integer(), nullable=False), + sa.Column("task_time_limit_seconds", sa.Integer(), nullable=True), + sa.Column( + "status", + sa.String(), + server_default="active", + nullable=False, + ), + sa.Column("section_score", sa.Integer(), nullable=True), + sa.Column("section_feedback", sa.Text(), nullable=True), + sa.Column( + "locale", + sa.String(), + server_default="en", + nullable=False, + ), + sa.ForeignKeyConstraint( + ["interview_id"], + ["interviews.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("interview_id"), + ) + + conn = op.get_bind() + conn.execute( + sa.text( + """ + INSERT INTO theory_sections ( + interview_id, + selection_spec, + question_count, + task_time_limit_seconds, + status, + locale + ) + SELECT + id, + selection_spec, + question_count, + question_time_limit_seconds, + CASE WHEN status = 'completed' THEN 'completed' ELSE 'active' END, + COALESCE(locale, 'en') + FROM interviews + WHERE id NOT IN (SELECT interview_id FROM theory_sections) + """ + ) + ) + + +def downgrade() -> None: + """Drop ``theory_sections``.""" + op.drop_table("theory_sections") diff --git a/alembic/versions/20260608_0005_answers_theory_section_id.py b/alembic/versions/20260608_0005_answers_theory_section_id.py new file mode 100644 index 0000000..ec4fe5a --- /dev/null +++ b/alembic/versions/20260608_0005_answers_theory_section_id.py @@ -0,0 +1,59 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Add theory_section_id to answers and backfill from theory_sections.""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260608_0005" +down_revision: str | None = "20260608_0004" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Link each answer row to its parent theory section.""" + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("answers")} + + if "theory_section_id" not in columns: + with op.batch_alter_table("answers") as batch_op: + batch_op.add_column( + sa.Column("theory_section_id", sa.Integer(), nullable=True) + ) + batch_op.create_foreign_key( + "fk_answers_theory_section_id", + "theory_sections", + ["theory_section_id"], + ["id"], + ondelete="CASCADE", + ) + + conn = op.get_bind() + conn.execute( + sa.text( + """ + UPDATE answers + SET theory_section_id = ( + SELECT ts.id + FROM theory_sections ts + WHERE ts.interview_id = answers.interview_id + ) + WHERE theory_section_id IS NULL + """ + ) + ) + + with op.batch_alter_table("answers") as batch_op: + batch_op.alter_column("theory_section_id", nullable=False) + + +def downgrade() -> None: + """Remove theory_section_id from answers.""" + with op.batch_alter_table("answers") as batch_op: + batch_op.drop_constraint("fk_answers_theory_section_id", type_="foreignkey") + batch_op.drop_column("theory_section_id") diff --git a/alembic/versions/20260608_0006_session_mode_selection_v2.py b/alembic/versions/20260608_0006_session_mode_selection_v2.py new file mode 100644 index 0000000..6cd0d63 --- /dev/null +++ b/alembic/versions/20260608_0006_session_mode_selection_v2.py @@ -0,0 +1,126 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Add session_mode and migrate selection_spec to v2.""" + +from collections.abc import Sequence +import json + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260608_0006" +down_revision: str | None = "20260608_0005" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +SESSION_SPEC_VERSION = 2 + + +def _is_v2(data: dict[str, object]) -> bool: + """Return whether a parsed selection_spec payload is already v2.""" + return data.get("version") == SESSION_SPEC_VERSION or ( + "session_mode" in data and "theory" in data + ) + + +def _v1_to_v2( + data: dict[str, object], + *, + question_count: int, + task_time_limit_seconds: int | None, +) -> dict[str, object]: + """Convert a legacy v1 selection_spec payload to v2.""" + sources = data.get("sources", []) + if not isinstance(sources, list): + sources = [] + return { + "version": SESSION_SPEC_VERSION, + "session_mode": "theory_only", + "theory": { + "enabled": True, + "question_count": question_count, + "task_time_limit_seconds": task_time_limit_seconds, + "sources": sources, + }, + "coding": { + "enabled": False, + "question_count": 0, + "task_time_limit_seconds": None, + "sources": [], + }, + } + + +def upgrade() -> None: + """Add session_mode and backfill selection_spec v2 for existing interviews.""" + op.add_column( + "interviews", + sa.Column( + "session_mode", + sa.String(), + nullable=False, + server_default="theory_only", + ), + ) + + conn = op.get_bind() + rows = conn.execute( + sa.text( + "SELECT id, selection_spec, question_count, question_time_limit_seconds " + "FROM interviews" + ) + ).fetchall() + + for interview_id, raw, question_count, timer in rows: + if not raw: + continue + data = json.loads(raw) + if not isinstance(data, dict): + continue + if _is_v2(data): + session_mode = data.get("session_mode", "theory_only") + spec = raw + else: + session_mode = "theory_only" + spec = json.dumps( + _v1_to_v2( + data, + question_count=int(question_count or 5), + task_time_limit_seconds=timer, + ), + separators=(",", ":"), + ) + conn.execute( + sa.text( + "UPDATE interviews SET selection_spec = :spec, session_mode = :mode " + "WHERE id = :id" + ), + {"spec": spec, "mode": session_mode, "id": interview_id}, + ) + + +def downgrade() -> None: + """Drop session_mode and flatten v2 selection_spec rows to v1 sources.""" + conn = op.get_bind() + rows = conn.execute(sa.text("SELECT id, selection_spec FROM interviews")).fetchall() + + for interview_id, raw in rows: + if not raw: + continue + data = json.loads(raw) + if not isinstance(data, dict) or not _is_v2(data): + continue + theory = data.get("theory") + if not isinstance(theory, dict): + continue + sources = theory.get("sources", []) + if not isinstance(sources, list): + sources = [] + spec = json.dumps({"sources": sources}, separators=(",", ":")) + conn.execute( + sa.text("UPDATE interviews SET selection_spec = :spec WHERE id = :id"), + {"spec": spec, "id": interview_id}, + ) + + op.drop_column("interviews", "session_mode") diff --git a/alembic/versions/20260608_0007_drop_interview_legacy_columns.py b/alembic/versions/20260608_0007_drop_interview_legacy_columns.py new file mode 100644 index 0000000..c40accc --- /dev/null +++ b/alembic/versions/20260608_0007_drop_interview_legacy_columns.py @@ -0,0 +1,67 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Drop legacy question/score columns from interviews and interview_id from answers.""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260608_0007" +down_revision: str | None = "20260608_0006" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_INTERVIEW_LEGACY_COLUMNS = ( + "question_count", + "question_ids", + "question_time_limit_seconds", + "score", +) + + +def upgrade() -> None: + """Remove duplicated interview columns and answers.interview_id.""" + bind = op.get_bind() + inspector = sa.inspect(bind) + interview_columns = {col["name"] for col in inspector.get_columns("interviews")} + answer_columns = {col["name"] for col in inspector.get_columns("answers")} + + for column in _INTERVIEW_LEGACY_COLUMNS: + if column in interview_columns: + with op.batch_alter_table("interviews") as batch_op: + batch_op.drop_column(column) + + if "interview_id" in answer_columns: + for fk in inspector.get_foreign_keys("answers"): + if "interview_id" in fk.get("constrained_columns", []): + fk_name = fk.get("name") + if fk_name: + with op.batch_alter_table("answers") as batch_op: + batch_op.drop_constraint(fk_name, type_="foreignkey") + with op.batch_alter_table("answers") as batch_op: + batch_op.drop_column("interview_id") + + +def downgrade() -> None: + """Restore legacy interview and answer columns.""" + with op.batch_alter_table("interviews") as batch_op: + batch_op.add_column(sa.Column("question_count", sa.Integer(), nullable=True)) + batch_op.add_column( + sa.Column("question_ids", sa.Text(), nullable=True, server_default="[]") + ) + batch_op.add_column( + sa.Column("question_time_limit_seconds", sa.Integer(), nullable=True) + ) + batch_op.add_column(sa.Column("score", sa.Integer(), nullable=True)) + + with op.batch_alter_table("answers") as batch_op: + batch_op.add_column(sa.Column("interview_id", sa.String(), nullable=True)) + batch_op.create_foreign_key( + "answers_interview_id_fkey", + "interviews", + ["interview_id"], + ["id"], + ondelete="CASCADE", + ) diff --git a/alembic/versions/20260609_0008_coding_sections.py b/alembic/versions/20260609_0008_coding_sections.py new file mode 100644 index 0000000..410fc88 --- /dev/null +++ b/alembic/versions/20260609_0008_coding_sections.py @@ -0,0 +1,108 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Create coding_sections, coding_tasks, and code_run_attempts tables.""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260609_0008" +down_revision: str | None = "20260608_0007" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create coding section tables.""" + bind = op.get_bind() + inspector = sa.inspect(bind) + existing = set(inspector.get_table_names()) + + if "coding_sections" not in existing: + op.create_table( + "coding_sections", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("interview_id", sa.String(), nullable=False), + sa.Column("selection_spec", sa.Text(), nullable=False), + sa.Column("question_count", sa.Integer(), nullable=False), + sa.Column("task_time_limit_seconds", sa.Integer(), nullable=True), + sa.Column( + "status", + sa.String(), + server_default="active", + nullable=False, + ), + sa.Column("section_score", sa.Integer(), nullable=True), + sa.Column("section_feedback", sa.Text(), nullable=True), + sa.Column( + "locale", + sa.String(), + server_default="en", + nullable=False, + ), + sa.ForeignKeyConstraint( + ["interview_id"], + ["interviews.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("interview_id"), + ) + + if "coding_tasks" not in existing: + op.create_table( + "coding_tasks", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("coding_section_id", sa.Integer(), nullable=False), + sa.Column("question_id", sa.String(), nullable=False), + sa.Column("order", sa.Integer(), nullable=False), + sa.Column("round", sa.Integer(), server_default="0", nullable=False), + sa.Column("question_text", sa.Text(), nullable=False), + sa.Column("task_spec", sa.Text(), nullable=False), + sa.Column("submitted_code", sa.Text(), nullable=True), + sa.Column("submit_test_summary", sa.Text(), nullable=True), + sa.Column("score", sa.Integer(), nullable=True), + sa.Column("feedback", sa.Text(), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["coding_section_id"], + ["coding_sections.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + ) + + if "code_run_attempts" not in existing: + op.create_table( + "code_run_attempts", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("coding_task_id", sa.Integer(), nullable=False), + sa.Column("attempt_no", sa.Integer(), nullable=True), + sa.Column("source_code", sa.Text(), nullable=False), + sa.Column("language", sa.String(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("stdout", sa.Text(), nullable=True), + sa.Column("stderr", sa.Text(), nullable=True), + sa.Column("compile_output", sa.Text(), nullable=True), + sa.Column("tests_passed", sa.Integer(), nullable=True), + sa.Column("tests_total", sa.Integer(), nullable=True), + sa.Column("test_results", sa.Text(), nullable=True), + sa.Column("duration_ms", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["coding_task_id"], + ["coding_tasks.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + """Drop coding section tables.""" + op.drop_table("code_run_attempts") + op.drop_table("coding_tasks") + op.drop_table("coding_sections") diff --git a/alembic/versions/20260610_0009_coding_task_naming.py b/alembic/versions/20260610_0009_coding_task_naming.py new file mode 100644 index 0000000..edf5817 --- /dev/null +++ b/alembic/versions/20260610_0009_coding_task_naming.py @@ -0,0 +1,32 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Rename coding section columns to task-oriented names.""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "20260610_0009" +down_revision: str | None = "20260609_0008" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Rename question_* columns on coding tables to task_* / prompt_text.""" + with op.batch_alter_table("coding_sections") as batch_op: + batch_op.alter_column("question_count", new_column_name="task_count") + + with op.batch_alter_table("coding_tasks") as batch_op: + batch_op.alter_column("question_id", new_column_name="task_id") + batch_op.alter_column("question_text", new_column_name="prompt_text") + + +def downgrade() -> None: + """Restore legacy question_* column names on coding tables.""" + with op.batch_alter_table("coding_tasks") as batch_op: + batch_op.alter_column("prompt_text", new_column_name="question_text") + batch_op.alter_column("task_id", new_column_name="question_id") + + with op.batch_alter_table("coding_sections") as batch_op: + batch_op.alter_column("task_count", new_column_name="question_count") diff --git a/app/coding/__init__.py b/app/coding/__init__.py new file mode 100644 index 0000000..6c4389e --- /dev/null +++ b/app/coding/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding interview section feature module.""" diff --git a/app/coding/api/__init__.py b/app/coding/api/__init__.py new file mode 100644 index 0000000..d1c6e9d --- /dev/null +++ b/app/coding/api/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding API transport layer.""" diff --git a/app/coding/api/errors.py b/app/coding/api/errors.py new file mode 100644 index 0000000..d707918 --- /dev/null +++ b/app/coding/api/errors.py @@ -0,0 +1,55 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Map coding domain errors to HTTP and WebSocket responses.""" + +from fastapi import HTTPException + +from app.coding.domain.exceptions import ( + CodingDomainError, + CodingRunLimitExceededError, + CodingSectionNotActiveError, + CodingSectionNotFoundError, + CodingTaskNotCurrentError, + CodingTaskNotFoundError, +) +from app.interview.domain.exceptions import InterviewDomainError + + +def coding_ws_error_payload( + exc: CodingDomainError | InterviewDomainError, +) -> dict[str, str]: + """Build a WebSocket error payload from a domain exception. + + Args: + exc: Domain error raised by the service layer. + + Returns: + JSON-serializable error dict for the client. + """ + return {"type": "error", "message": str(exc)} + + +def http_exception_from_coding_error( + exc: CodingDomainError | InterviewDomainError, +) -> HTTPException: + """Convert a domain exception to an HTTPException. + + Args: + exc: Domain error raised by the service layer. + + Returns: + HTTPException with an appropriate status code. + """ + if isinstance( + exc, + CodingSectionNotFoundError | CodingTaskNotFoundError, + ): + return HTTPException(status_code=404, detail=str(exc)) + if isinstance(exc, CodingRunLimitExceededError): + return HTTPException(status_code=429, detail=str(exc)) + if isinstance( + exc, + CodingSectionNotActiveError | CodingTaskNotCurrentError, + ): + return HTTPException(status_code=400, detail=str(exc)) + return HTTPException(status_code=400, detail=str(exc)) diff --git a/app/coding/api/routes.py b/app/coding/api/routes.py new file mode 100644 index 0000000..3f5b4cb --- /dev/null +++ b/app/coding/api/routes.py @@ -0,0 +1,127 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding section HTTP and WebSocket transport.""" + +import logging +from typing import Any + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from fastapi.responses import JSONResponse + +from app.coding.api.errors import http_exception_from_coding_error +from app.coding.api.ws_session import CodingWebSocketService +from app.coding.domain.exceptions import CodingDomainError +from app.coding.schemas.coding import ( + CodingRunRequest, + CodingRunResponse, + coding_state_to_dict, + domain_run_attempt_to_read, + run_attempt_to_response, +) +from app.coding.services.run_execution import CodingRunExecutionService +from app.coding.services.state import CodingStateService +from app.interview.api.deps import AIProviderDep +from app.interview.domain.exceptions import InterviewDomainError + +router = APIRouter(prefix="/interview", tags=["coding"]) + +logger = logging.getLogger(__name__) + + +async def _safe_send_json(websocket: WebSocket, message: dict[str, Any]) -> bool: + """Send a JSON message, returning False if the client already disconnected. + + Args: + websocket: Active coding WebSocket. + message: Payload to send. + + Returns: + True if the message was sent, False if the socket is closed. + """ + try: + await websocket.send_json(message) + return True + except (WebSocketDisconnect, RuntimeError): + return False + + +@router.post("/{interview_id}/coding/run", response_model=CodingRunResponse) +async def coding_run( + interview_id: str, + body: CodingRunRequest, +) -> CodingRunResponse: + """Execute public tests for the active coding task and persist the attempt. + + Args: + interview_id: Interview session UUID. + body: Task ID and current editor contents. + + Returns: + Mirror of the persisted Run attempt. + + Raises: + HTTPException: On validation, domain, or rate-limit errors. + """ + try: + attempt = await CodingRunExecutionService.run_and_persist( + interview_id=interview_id, + task_id=body.task_id, + source_code=body.source_code, + ) + except (InterviewDomainError, CodingDomainError) as exc: + raise http_exception_from_coding_error(exc) from exc + return run_attempt_to_response(domain_run_attempt_to_read(attempt)) + + +@router.get("/{interview_id}/coding/state") +async def coding_state(interview_id: str) -> JSONResponse: + """Return coding session progress and Run history for the active task. + + Args: + interview_id: Interview session UUID. + + Returns: + JSON read model for the coding panel. + + Raises: + HTTPException: When the coding section does not exist. + """ + try: + state = CodingStateService.get_state(interview_id) + except CodingDomainError as exc: + raise http_exception_from_coding_error(exc) from exc + return JSONResponse(coding_state_to_dict(state)) + + +@router.websocket("/{interview_id}/coding/ws") +async def coding_ws( + websocket: WebSocket, + interview_id: str, + provider: AIProviderDep, +) -> None: + """WebSocket endpoint for coding task submit and feedback. + + Args: + websocket: The WebSocket connection. + interview_id: The session UUID. + provider: AI provider for coding evaluation. + """ + await websocket.accept() + try: + while True: + try: + raw = await websocket.receive_json() + except RuntimeError: + break + + async for message in CodingWebSocketService.iter_responses( + raw, + interview_id=interview_id, + provider=provider, + ): + if not await _safe_send_json(websocket, message): + break + except WebSocketDisconnect: + logger.debug("Coding WebSocket disconnected for session %s", interview_id) + except RuntimeError: + logger.debug("Coding WebSocket closed for session %s", interview_id) diff --git a/app/coding/api/ws_protocol.py b/app/coding/api/ws_protocol.py new file mode 100644 index 0000000..f921791 --- /dev/null +++ b/app/coding/api/ws_protocol.py @@ -0,0 +1,55 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""WebSocket wire protocol mapping for coding sessions.""" + +from typing import Any + +from app.coding.schemas.ws import ( + CodingFeedbackMessage, + CodingSavedMessage, + EvaluatingMessage, + coding_server_message_to_dict, +) +from app.coding.services.events import CodingFeedbackEvent +from app.interview.services.events import ( + AnswerSavedEvent, + EvaluatingEvent, + InterviewEvent, +) + + +def coding_event_to_message( + event: InterviewEvent | CodingFeedbackEvent, +) -> dict[str, Any]: + """Convert a semantic service event to a coding WebSocket JSON message. + + Args: + event: Event from coding submission services. + + Returns: + JSON-serializable message dict for the client. + + Raises: + TypeError: If the event type is not supported. + """ + if isinstance(event, AnswerSavedEvent): + return coding_server_message_to_dict(CodingSavedMessage()) + if isinstance(event, EvaluatingEvent): + return coding_server_message_to_dict(EvaluatingMessage()) + if isinstance(event, CodingFeedbackEvent): + return coding_server_message_to_dict( + CodingFeedbackMessage( + task_id=event.task_id, + order=event.order, + round=event.round, + follow_up_question=event.follow_up_text + if event.follow_up_needed + else None, + follow_up_mode=event.follow_up_mode, + next_task=event.next_task, + feedback=event.feedback, + timer_remaining_seconds=event.timer_remaining_seconds, + ) + ) + msg = f"Unsupported coding event: {type(event)!r}" + raise TypeError(msg) diff --git a/app/coding/api/ws_session.py b/app/coding/api/ws_session.py new file mode 100644 index 0000000..704454d --- /dev/null +++ b/app/coding/api/ws_session.py @@ -0,0 +1,90 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""WebSocket message handling for coding sessions.""" + +from collections.abc import AsyncIterator +import logging +from typing import Any + +from app.ai.base import AIProvider +from app.coding.api.errors import coding_ws_error_payload +from app.coding.api.ws_protocol import coding_event_to_message +from app.coding.domain.exceptions import CodingDomainError +from app.coding.services.submission import CodingSubmissionService +from app.interview.domain.exceptions import InterviewDomainError +from app.interview.services.ai_errors import ai_error_message_for_client + +logger = logging.getLogger(__name__) + + +class CodingWebSocketService: + """Translate client coding WebSocket messages into server payloads.""" + + @staticmethod + async def iter_responses( + raw: dict[str, Any], + *, + interview_id: str, + provider: AIProvider, + submission_service: type[CodingSubmissionService] = CodingSubmissionService, + ) -> AsyncIterator[dict[str, Any]]: + """Handle one client message and yield JSON payloads for the socket. + + Args: + raw: Parsed client JSON message. + interview_id: Interview session UUID. + provider: AI provider for coding evaluation. + submission_service: Coding submission service class. + + Yields: + WebSocket message dicts to send to the client. + """ + msg_type = raw.get("type") + if msg_type == "submit": + async for message in CodingWebSocketService._handle_submit( + raw, + interview_id=interview_id, + provider=provider, + submission_service=submission_service, + ): + yield message + return + + yield { + "type": "error", + "message": f"Unknown message type: {msg_type}", + } + + @staticmethod + async def _handle_submit( + raw: dict[str, Any], + *, + interview_id: str, + provider: AIProvider, + submission_service: type[CodingSubmissionService], + ) -> AsyncIterator[dict[str, Any]]: + task_id = str(raw.get("task_id", "")).strip() + source_code = str(raw.get("source_code", "")) + if not task_id or not source_code: + yield { + "type": "error", + "message": "Both task_id and source_code are required", + } + return + + try: + async for event in submission_service.stream_submit( + interview_id=interview_id, + task_id=task_id, + source_code=source_code, + provider=provider, + ): + yield coding_event_to_message(event) + except (InterviewDomainError, CodingDomainError) as exc: + yield coding_ws_error_payload(exc) + except Exception as exc: + logger.exception("Coding submit failed for interview %s", interview_id) + yield { + "type": "error", + "message": ai_error_message_for_client(exc), + } diff --git a/app/coding/domain/__init__.py b/app/coding/domain/__init__.py new file mode 100644 index 0000000..8de4585 --- /dev/null +++ b/app/coding/domain/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding domain layer.""" diff --git a/app/coding/domain/entities.py b/app/coding/domain/entities.py new file mode 100644 index 0000000..b4c9be0 --- /dev/null +++ b/app/coding/domain/entities.py @@ -0,0 +1,537 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding section aggregate entities.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import UTC, datetime +from typing import Any, Literal + +from app.coding.domain.exceptions import ( + CodingSectionNotActiveError, + CodingTaskNotCurrentError, + CodingTaskNotFoundError, +) +from app.coding.domain.value_objects import PlannedCodingTask, RunOutcomeStatus +from app.interview.domain.value_objects import InterviewSelection +from app.shared.task_timer import ( + DEFAULT_TIMEOUT_GRACE_SECONDS, +) +from app.shared.task_timer import ( + is_timer_expired as shared_is_timer_expired, +) +from app.shared.task_timer import ( + remaining_seconds as shared_remaining_seconds, +) +from app.shared.task_timer import ( + timer_deadline as shared_timer_deadline, +) + +CodingSectionStatus = Literal["pending", "active", "completed", "skipped"] + + +@dataclass(frozen=True, slots=True) +class CodingTask: + """One coding task round within a coding section. + + Attributes: + id: Task row primary key. + coding_section_id: Parent coding section ID. + interview_id: Parent interview UUID (denormalized from the section). + task_id: YAML task ID from the coding bank. + order: Display order within the section (1-based). + round: Follow-up round number (0 = initial). + prompt_text: Task prompt snapshot. + task_spec: Client-safe task metadata JSON. + submitted_code: Final submitted source code, or None when pending. + submit_test_summary: Hidden test results after submit, or None. + score: AI score for the round, or None when not evaluated. + feedback: AI-generated feedback text, or None. + started_at: When the per-task timer started, or None. + created_at: When this task row was created. + """ + + TIME_EXPIRED_CODE = "[Time expired]" + TIMEOUT_GRACE_SECONDS = DEFAULT_TIMEOUT_GRACE_SECONDS + NEW_ID = 0 + + id: int + coding_section_id: int + interview_id: str + task_id: str + order: int + round: int + prompt_text: str + task_spec: dict[str, Any] + submitted_code: str | None + submit_test_summary: dict[str, Any] | None + score: int | None + feedback: str | None + started_at: datetime | None + created_at: datetime + + def timer_deadline(self, limit_seconds: int) -> datetime: + """Compute the absolute deadline for this timed task round. + + Args: + limit_seconds: Allowed duration in seconds. + + Returns: + Timezone-aware deadline timestamp. + + Raises: + ValueError: If the round has no ``started_at`` timestamp. + """ + if self.started_at is None: + raise ValueError("Coding task round has no started_at") + return shared_timer_deadline( + self.started_at, + limit_seconds, + label="Coding task", + ) + + def is_timer_expired( + self, + limit_seconds: int | None, + now: datetime | None = None, + *, + grace_seconds: int = TIMEOUT_GRACE_SECONDS, + ) -> bool: + """Return whether the per-task timer has elapsed. + + Args: + limit_seconds: Configured limit for the section (None disables timer). + now: Current time (defaults to UTC now). + grace_seconds: Extra seconds allowed for network delay on timeout submit. + + Returns: + True if the timer is enabled and the deadline plus grace has passed. + """ + return shared_is_timer_expired( + self.started_at, + limit_seconds, + now, + grace_seconds=grace_seconds, + ) + + def remaining_seconds( + self, + limit_seconds: int | None, + now: datetime | None = None, + ) -> int | None: + """Return whole seconds left on the timer, or None if disabled. + + Args: + limit_seconds: Configured limit for the section. + now: Current time (defaults to UTC now). + + Returns: + Non-negative seconds remaining, or None when the timer is off. + """ + return shared_remaining_seconds(self.started_at, limit_seconds, now) + + +@dataclass(frozen=True, slots=True) +class CodingSection: + """Coding section aggregate root. + + Attributes: + id: Coding section primary key. + interview_id: Parent interview UUID. + locale: Language code for feedback. + selection: Parsed coding-bank selection for this section. + task_count: Number of coding tasks in this section. + task_ids: Task IDs in display order. + task_time_limit_seconds: Per-task time limit, or None when disabled. + status: Section status. + section_score: Aggregated section score when evaluated. + section_feedback: Parsed section evaluation payload. + tasks: Coding tasks in display order (order, then round). + """ + + MAX_SCORE_PER_ROUND = 5 + NEW_ID = 0 + + id: int + interview_id: str + locale: str + selection: InterviewSelection + task_count: int + task_ids: tuple[str, ...] + task_time_limit_seconds: int | None + status: CodingSectionStatus + section_score: int | None + section_feedback: dict[str, object] | None + tasks: tuple[CodingTask, ...] + + @classmethod + def start( + cls, + interview_id: str, + *, + selection: InterviewSelection, + locale: str, + planned_tasks: tuple[PlannedCodingTask, ...], + task_time_limit_seconds: int | None = None, + coding_section_id: int = NEW_ID, + status: CodingSectionStatus = "active", + ) -> CodingSection: + """Build a new coding section from a planned task list. + + Args: + interview_id: Parent interview UUID. + selection: Track/level/topic selection from setup. + locale: Locale for AI feedback. + planned_tasks: Ordered tasks for this section (non-empty). + task_time_limit_seconds: Per-task time limit, or None to disable. + coding_section_id: Existing section ID, or ``NEW_ID`` before insert. + status: Initial section status (``pending`` or ``active``). + + Returns: + Section aggregate with initial task rows (``CodingTask.NEW_ID``). + + Raises: + ValueError: If ``planned_tasks`` is empty. + """ + if not planned_tasks: + raise ValueError("No coding tasks found for the selected topics") + + 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 + tasks: list[CodingTask] = [] + for order, planned in enumerate(planned_tasks, start=1): + tasks.append( + CodingTask( + id=CodingTask.NEW_ID, + coding_section_id=coding_section_id, + interview_id=interview_id, + task_id=planned.id, + order=order, + round=0, + prompt_text=planned.text, + task_spec=dict(planned.task_spec), + submitted_code=None, + submit_test_summary=None, + score=None, + feedback=None, + started_at=timer_start if order == 1 else None, + created_at=when, + ) + ) + return cls( + id=coding_section_id, + interview_id=interview_id, + locale=locale, + selection=selection, + task_count=len(planned_tasks), + task_ids=task_ids, + task_time_limit_seconds=task_time_limit_seconds, + status=status, + section_score=None, + section_feedback=None, + tasks=tuple(tasks), + ) + + def with_activated(self) -> CodingSection: + """Return aggregate with ``pending`` status promoted to ``active``. + + Returns: + Updated aggregate when status was ``pending``, otherwise ``self``. + """ + if self.status != "pending": + return self + return replace(self, status="active") + + def ensure_active(self) -> None: + """Ensure this coding section accepts submissions. + + Raises: + CodingSectionNotActiveError: If the section is not in ``active`` status. + """ + if self.status != "active": + raise CodingSectionNotActiveError(self.interview_id) + + def find_first_unsubmitted(self) -> CodingTask | None: + """Return the first task without a submitted solution. + + Returns: + The first task with ``submitted_code`` unset, or None when all are done. + """ + for task in self.tasks: + if task.submitted_code is None: + return task + return None + + def is_complete(self) -> bool: + """Return whether every task in this section has been submitted. + + Returns: + True when there is at least one task and none remain unsubmitted. + """ + return bool(self.tasks) and self.find_first_unsubmitted() is None + + def total_score(self) -> int: + """Sum scores from all submitted task rounds in this section. + + Returns: + Total earned points across submitted rounds. + """ + return sum( + (task.score or 0) for task in self.tasks if task.submitted_code is not None + ) + + def max_score(self) -> int: + """Compute maximum achievable score for submitted rounds. + + Returns: + Maximum possible points for rounds with submissions. + """ + submitted_rounds = sum( + 1 for task in self.tasks if task.submitted_code is not None + ) + return self.MAX_SCORE_PER_ROUND * submitted_rounds + + def with_cached_section_feedback( + self, + feedback: dict[str, object], + *, + section_score: int, + ) -> CodingSection: + """Return aggregate with prefetched section feedback when not cached. + + Args: + feedback: Parsed section evaluation payload. + section_score: Aggregated section score. + + Returns: + Updated aggregate, or ``self`` when feedback is already cached. + """ + if self.section_feedback is not None: + return self + return replace( + self, + section_feedback=feedback, + section_score=section_score, + ) + + def start_timer_for_task( + self, task_row_id: int, when: datetime | None = None + ) -> CodingSection: + """Start the per-task timer on a coding task when the section has a limit. + + Args: + task_row_id: Primary key of the task row to activate. + when: Timestamp to set (defaults to UTC now). + + Returns: + A new aggregate with ``started_at`` set on the target task when applicable. + """ + if self.task_time_limit_seconds is None: + return self + started_at = when or datetime.now(UTC) + tasks = tuple( + replace(task, started_at=started_at) + if task.id == task_row_id and task.started_at is None + else task + for task in self.tasks + ) + return replace(self, tasks=tasks) + + def with_submit_test_summary( + self, + task_row_id: int, + summary: dict[str, Any], + *, + source_code: str, + ) -> CodingSection: + """Return aggregate with submitted code and hidden test summary. + + Args: + task_row_id: Primary key of the task row to update. + summary: Hidden test execution summary from Judge0. + source_code: Final editor contents at submit time. + + Returns: + A new aggregate with submission fields set on the target task. + """ + tasks = tuple( + replace( + task, + submitted_code=source_code, + submit_test_summary=summary, + ) + if task.id == task_row_id + else task + for task in self.tasks + ) + return replace(self, tasks=tasks) + + def with_evaluation( + self, + task_id: str, + round_num: int, + score: int, + feedback: str, + ) -> CodingSection: + """Return aggregate with AI score and feedback on one task round. + + Args: + task_id: YAML task ID. + round_num: Follow-up round (0 = initial). + score: AI score for the round. + feedback: AI feedback text. + + Returns: + A new aggregate with evaluation fields set on the target task. + """ + target = self.find_task(task_id, round_num) + tasks = tuple( + replace(task, score=score, feedback=feedback) + if task.id == target.id + else task + for task in self.tasks + ) + return replace(self, tasks=tasks) + + def max_round_for_task(self, task_id: str) -> int: + """Return the highest follow-up round number for a bank task ID. + + Args: + task_id: YAML task ID. + + Returns: + Maximum ``round`` value among rows for the task, or 0 when none exist. + """ + rounds = [task.round for task in self.tasks if task.task_id == task_id] + return max(rounds) if rounds else 0 + + def with_follow_up( + self, + task_id: str, + prompt_text: str, + *, + starter_code: str | None, + ) -> tuple[CodingSection, CodingTask]: + """Return aggregate with a new unsubmitted follow-up task row. + + Args: + task_id: YAML task ID for the follow-up chain. + prompt_text: Follow-up prompt shown to the candidate. + starter_code: Monaco starter code for code-mode follow-ups. + + Returns: + Tuple of updated aggregate and the pending follow-up task. + """ + base = self.find_task(task_id, 0) + next_round = self.max_round_for_task(task_id) + 1 + follow_up_spec = dict(base.task_spec) + if starter_code is not None: + follow_up_spec["starter_code"] = starter_code + created_at = datetime.now(UTC) + follow_up = CodingTask( + id=CodingTask.NEW_ID, + coding_section_id=self.id, + interview_id=self.interview_id, + task_id=task_id, + order=base.order, + round=next_round, + prompt_text=prompt_text, + task_spec=follow_up_spec, + submitted_code=None, + submit_test_summary=None, + score=None, + feedback=None, + started_at=None, + created_at=created_at, + ) + return replace(self, tasks=self.tasks + (follow_up,)), follow_up + + def find_next_unsubmitted_after(self, current_index: int) -> CodingTask | None: + """Return the next unsubmitted task after a position in the task list. + + Args: + current_index: Index of the current task in ``tasks``. + + Returns: + The next unsubmitted task, or None if none remain. + """ + for task in self.tasks[current_index + 1 :]: + if task.submitted_code is None: + return task + return None + + def require_current_task(self, task_id: str) -> CodingTask: + """Return the active unsubmitted task when it matches ``task_id``. + + Args: + task_id: YAML task ID from a client Run/Submit request. + + Returns: + The current coding task row. + + Raises: + CodingTaskNotCurrentError: If no matching unsubmitted task is active. + """ + current = self.find_first_unsubmitted() + if current is None or current.task_id != task_id: + raise CodingTaskNotCurrentError(self.interview_id, task_id) + return current + + def find_task(self, task_id: str, round_num: int) -> CodingTask: + """Return the task row for a bank task and follow-up round. + + Args: + task_id: YAML task ID. + round_num: Follow-up round (0 = initial). + + Returns: + The matching task row. + + Raises: + CodingTaskNotFoundError: If no row matches the keys. + """ + for task in self.tasks: + if task.task_id == task_id and task.round == round_num: + return task + raise CodingTaskNotFoundError(self.interview_id, task_id, round_num) + + +@dataclass(frozen=True, slots=True) +class CodeRunAttempt: + """Immutable snapshot of one Run action on a coding task. + + Attributes: + id: Attempt row primary key. + coding_task_id: Parent coding task row ID. + attempt_no: Sequential attempt number for the task. + source_code: Editor contents at Run time. + language: Programming language slug. + status: Aggregated run outcome. + stdout: Captured standard output. + stderr: Captured standard error. + compile_output: Compiler output when applicable. + tests_passed: Number of public tests that passed. + tests_total: Number of public tests executed. + test_results: Serialized per-test result payloads. + duration_ms: Judge0 execution duration in milliseconds. + created_at: Timestamp when the attempt was recorded. + """ + + NEW_ID = 0 + + id: int + coding_task_id: int + attempt_no: int + source_code: str + language: str + status: RunOutcomeStatus + stdout: str | None + stderr: str | None + compile_output: str | None + tests_passed: int + tests_total: int + test_results: tuple[dict[str, Any], ...] + duration_ms: int | None + created_at: datetime diff --git a/app/coding/domain/exceptions.py b/app/coding/domain/exceptions.py new file mode 100644 index 0000000..f6cdcb3 --- /dev/null +++ b/app/coding/domain/exceptions.py @@ -0,0 +1,93 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding domain exceptions.""" + + +class CodingDomainError(Exception): + """Base class for coding-related domain errors.""" + + +class CodingSectionNotFoundError(CodingDomainError): + """Raised when a coding section does not exist for an interview.""" + + def __init__(self, interview_id: str) -> None: + """Initialize with the parent interview ID. + + Args: + interview_id: Parent interview UUID. + """ + self.interview_id = interview_id + super().__init__(f"Coding section not found for interview: {interview_id}") + + +class CodingSectionNotActiveError(CodingDomainError): + """Raised when an operation requires an active coding section.""" + + def __init__(self, interview_id: str | None = None) -> None: + """Initialize optionally with the interview ID. + + Args: + interview_id: Parent interview UUID, if known. + """ + self.interview_id = interview_id + super().__init__("Cannot submit code to a completed coding section") + + +class CodingTaskNotCurrentError(CodingDomainError): + """Raised when Run/Submit targets a task that is not the active round.""" + + def __init__(self, interview_id: str, task_id: str) -> None: + """Initialize with lookup keys. + + Args: + interview_id: Parent interview UUID. + task_id: YAML task ID from the client request. + """ + self.interview_id = interview_id + self.task_id = task_id + super().__init__( + f"Task {task_id} is not the current coding task for interview {interview_id}" + ) + + +class CodingRunLimitExceededError(CodingDomainError): + """Raised when a task exceeds the configured Run attempt limit.""" + + def __init__(self, task_id: str, limit: int) -> None: + """Initialize with the task ID and configured limit. + + Args: + task_id: YAML task ID. + limit: Maximum allowed Run attempts per task. + """ + self.task_id = task_id + self.limit = limit + super().__init__(f"Run limit exceeded for task {task_id} (max {limit})") + + +class CodingEvaluatorNotAvailableError(CodingDomainError): + """Raised when AI evaluation is requested before the evaluator is wired.""" + + def __init__(self) -> None: + """Initialize with a stable client-facing message.""" + super().__init__("Coding AI evaluation is not available yet") + + +class CodingTaskNotFoundError(CodingDomainError): + """Raised when a specific coding task row is missing.""" + + def __init__(self, interview_id: str, task_id: str, round_num: int) -> None: + """Initialize with lookup keys. + + Args: + interview_id: Parent interview UUID. + task_id: YAML task ID. + round_num: Task round (0 = initial). + """ + self.interview_id = interview_id + self.task_id = task_id + self.round_num = round_num + super().__init__( + f"Coding task not found: interview={interview_id}, " + f"task={task_id}, round={round_num}" + ) diff --git a/app/coding/domain/task_spec.py b/app/coding/domain/task_spec.py new file mode 100644 index 0000000..94433a0 --- /dev/null +++ b/app/coding/domain/task_spec.py @@ -0,0 +1,70 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Build client-safe coding task specs from YAML bank rows.""" + +from __future__ import annotations + +from typing import Any + +from app.shared.coding import CodingTask as BankCodingTask + + +def task_spec_from_bank_task(task: BankCodingTask) -> dict[str, Any]: + """Serialize a coding bank row for persistence and UI. + + Public tests include stdin and expected stdout for server-side Judge0 runs. + Hidden tests are never serialized here. + + Args: + task: Loaded row from ``app.shared.coding``. + + Returns: + JSON-serializable task specification for ``coding_tasks.task_spec``. + """ + spec = task.coding + return { + "language": spec.language, + "evaluation_mode": spec.evaluation_mode, + "starter_code": spec.starter_code, + "entrypoint": spec.entrypoint, + "public_tests": [ + { + "name": test_case.name, + "stdin": test_case.stdin, + "expected_stdout": test_case.expected_stdout, + } + for test_case in spec.public_tests + ], + "time_limit_seconds": spec.time_limit_seconds, + "memory_limit_kb": spec.memory_limit_kb, + "hidden_tests": [ + { + "name": test_case.name, + "stdin": test_case.stdin, + "expected_stdout": test_case.expected_stdout, + } + for test_case in spec.hidden_tests + ], + "expected_points": list(task.expected_points), + } + + +def client_task_spec_from_stored(spec: dict[str, Any]) -> dict[str, Any]: + """Strip server-only test expectations from a persisted task spec. + + Args: + spec: Task spec loaded from ``coding_tasks.task_spec``. + + Returns: + Client-safe task metadata for UI state responses. + """ + client_spec = dict(spec) + client_spec.pop("hidden_tests", None) + public_tests = spec.get("public_tests") + if isinstance(public_tests, list): + client_spec["public_tests"] = [ + {"name": test_case["name"]} + for test_case in public_tests + if isinstance(test_case, dict) and "name" in test_case + ] + return client_spec diff --git a/app/coding/domain/value_objects.py b/app/coding/domain/value_objects.py new file mode 100644 index 0000000..0b306f7 --- /dev/null +++ b/app/coding/domain/value_objects.py @@ -0,0 +1,81 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding domain value objects.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +RunOutcomeStatus = Literal[ + "success", + "compile_error", + "runtime_error", + "time_limit_exceeded", + "tests_failed", +] + + +@dataclass(frozen=True, slots=True) +class PlannedCodingTask: + """Task snapshot used when starting a coding section. + + Attributes: + id: Unique task identifier from the coding bank. + text: Localized task prompt shown to the candidate. + task_spec: Task metadata for persistence, UI, and Judge0 execution. + """ + + id: str + text: str + task_spec: dict[str, Any] + + +@dataclass(frozen=True, slots=True) +class TestCaseRunResult: + """Outcome of executing one public test case via Judge0. + + Attributes: + name: Test case identifier from the task bank. + passed: Whether actual stdout matched the expected output. + expected_stdout: Expected standard output for the test. + actual_stdout: Captured standard output from Judge0. + stderr: Captured standard error, if any. + compile_output: Compiler output when compilation failed. + judge0_status_id: Raw Judge0 status identifier. + judge0_status_description: Human-readable Judge0 status label. + """ + + name: str + passed: bool + expected_stdout: str + actual_stdout: str + stderr: str | None + compile_output: str | None + judge0_status_id: int | None + judge0_status_description: str | None + + +@dataclass(frozen=True, slots=True) +class CodingRunResult: + """Aggregated outcome of a Run action against public tests or compile-only. + + Attributes: + status: High-level run outcome for the API and persistence layer. + stdout: Stdout from the last executed case, if any. + stderr: Stderr from the last executed case, if any. + compile_output: Compile output from the last executed case, if any. + tests_passed: Number of public tests that passed. + tests_total: Number of public tests executed. + test_results: Per-test details in execution order. + duration_ms: Total Judge0 wall time across executed cases. + """ + + status: RunOutcomeStatus + stdout: str | None + stderr: str | None + compile_output: str | None + tests_passed: int + tests_total: int + test_results: tuple[TestCaseRunResult, ...] + duration_ms: int | None diff --git a/app/coding/repositories/__init__.py b/app/coding/repositories/__init__.py new file mode 100644 index 0000000..560ad58 --- /dev/null +++ b/app/coding/repositories/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding persistence layer.""" diff --git a/app/coding/repositories/code_run_attempt.py b/app/coding/repositories/code_run_attempt.py new file mode 100644 index 0000000..c4e87d9 --- /dev/null +++ b/app/coding/repositories/code_run_attempt.py @@ -0,0 +1,77 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Repository for immutable coding Run attempt rows.""" + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.coding.domain.entities import CodeRunAttempt as DomainCodeRunAttempt +from app.coding.repositories.mappers import ( + code_run_attempt_from_orm, + domain_code_run_attempt_to_orm, +) +from app.shared.infrastructure.models import CodeRunAttempt + + +class CodeRunAttemptRepository: + """Persistence access for ``code_run_attempts`` rows. + + Attributes: + _session: Active SQLAlchemy Session. + """ + + def __init__(self, session: Session) -> None: + """Initialize the repository. + + Args: + session: Active SQLAlchemy Session. + """ + self._session = session + + def count_for_task(self, coding_task_id: int) -> int: + """Return how many Run attempts exist for a coding task row. + + Args: + coding_task_id: Parent ``coding_tasks.id``. + + Returns: + Number of persisted attempts. + """ + return ( + self._session.query(func.count(CodeRunAttempt.id)) + .filter(CodeRunAttempt.coding_task_id == coding_task_id) + .scalar() + or 0 + ) + + def list_for_task(self, coding_task_id: int) -> tuple[DomainCodeRunAttempt, ...]: + """Load attempts for a coding task ordered by attempt number. + + Args: + coding_task_id: Parent ``coding_tasks.id``. + + Returns: + Immutable domain attempts in ascending ``attempt_no`` order. + """ + rows = ( + self._session.query(CodeRunAttempt) + .filter_by(coding_task_id=coding_task_id) + .order_by(CodeRunAttempt.attempt_no.asc()) + .all() + ) + return tuple(code_run_attempt_from_orm(row) for row in rows) + + def create(self, attempt: DomainCodeRunAttempt) -> DomainCodeRunAttempt: + """Insert one Run attempt row. + + Args: + attempt: Domain attempt with ``NEW_ID``. + + Returns: + Reloaded domain attempt including the assigned primary key. + """ + orm_row = domain_code_run_attempt_to_orm(attempt) + self._session.add(orm_row) + self._session.flush() + self._session.refresh(orm_row) + return code_run_attempt_from_orm(orm_row) diff --git a/app/coding/repositories/coding_section.py b/app/coding/repositories/coding_section.py new file mode 100644 index 0000000..1161e84 --- /dev/null +++ b/app/coding/repositories/coding_section.py @@ -0,0 +1,130 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding section repository.""" + +import json + +from sqlalchemy.orm import Session, selectinload + +from app.coding.domain.entities import CodingSection as DomainCodingSection +from app.coding.domain.entities import CodingTask as DomainCodingTask +from app.coding.domain.exceptions import CodingSectionNotFoundError +from app.coding.repositories.mappers import ( + coding_section_from_orm, + coding_section_to_orm, + coding_section_to_orm_fields, + domain_coding_task_to_orm, +) +from app.shared.infrastructure.models import CodingSection +from app.shared.repositories.base import SqlAlchemyRepository + + +class CodingSectionRepository(SqlAlchemyRepository[CodingSection]): + """Repository for ``CodingSection`` entities. + + Attributes: + _session: Active SQLAlchemy Session (inherited). + """ + + _model = CodingSection + + def __init__(self, session: Session) -> None: + """Initialize the repository. + + Args: + session: Active SQLAlchemy Session. + """ + super().__init__(session) + + def get_by_interview_id(self, interview_id: str) -> CodingSection | None: + """Retrieve a coding section by parent interview ID. + + Args: + interview_id: Parent interview UUID. + + Returns: + CodingSection ORM row with tasks loaded, or None. + """ + return ( + self._session.query(CodingSection) + .options(selectinload(CodingSection.tasks)) + .filter_by(interview_id=interview_id) + .first() + ) + + def get_aggregate(self, interview_id: str) -> DomainCodingSection | None: + """Load a domain coding section aggregate with tasks. + + Args: + interview_id: Parent interview UUID. + + Returns: + Domain aggregate, or None when the section does not exist. + """ + orm_section = self.get_by_interview_id(interview_id) + if orm_section is None: + return None + return coding_section_from_orm(orm_section) + + def create_aggregate(self, section: DomainCodingSection) -> DomainCodingSection: + """Insert a coding section and its task rows. + + Args: + section: Domain section with tasks to persist. + + Returns: + Reloaded domain aggregate with assigned IDs. + + Raises: + CodingSectionNotFoundError: If reload fails after flush. + """ + orm_section = coding_section_to_orm(section) + self._session.add(orm_section) + self._session.flush() + for task in section.tasks: + self._session.add( + domain_coding_task_to_orm(task, coding_section_id=orm_section.id) + ) + self._session.flush() + reloaded = self.get_by_interview_id(section.interview_id) + if reloaded is None: + raise CodingSectionNotFoundError(section.interview_id) + return coding_section_from_orm(reloaded) + + def save_aggregate(self, section: DomainCodingSection) -> None: + """Persist mutable section and task fields from a domain aggregate. + + Args: + section: Domain section previously loaded from this repository. + + Raises: + CodingSectionNotFoundError: If the section row no longer exists. + """ + orm_section = self.get_by_interview_id(section.interview_id) + if orm_section is None: + raise CodingSectionNotFoundError(section.interview_id) + + for field, value in coding_section_to_orm_fields(section).items(): + setattr(orm_section, field, value) + + orm_tasks_by_id = {task.id: task for task in orm_section.tasks} + for domain_task in section.tasks: + if domain_task.id == DomainCodingTask.NEW_ID: + orm_section.tasks.append( + domain_coding_task_to_orm( + domain_task, coding_section_id=orm_section.id + ) + ) + continue + orm_task = orm_tasks_by_id.get(domain_task.id) + if orm_task is None: + continue + orm_task.submitted_code = domain_task.submitted_code + orm_task.submit_test_summary = ( + json.dumps(domain_task.submit_test_summary, separators=(",", ":")) + if domain_task.submit_test_summary is not None + else None + ) + orm_task.score = domain_task.score + orm_task.feedback = domain_task.feedback + orm_task.started_at = domain_task.started_at diff --git a/app/coding/repositories/mappers.py b/app/coding/repositories/mappers.py new file mode 100644 index 0000000..d8e93f8 --- /dev/null +++ b/app/coding/repositories/mappers.py @@ -0,0 +1,353 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""ORM ↔ domain ↔ read-model mappers for coding persistence.""" + +from __future__ import annotations + +import json +from typing import Any + +from app.coding.domain.entities import CodeRunAttempt as DomainCodeRunAttempt +from app.coding.domain.entities import CodingSection as DomainCodingSection +from app.coding.domain.entities import CodingSectionStatus +from app.coding.domain.entities import CodingTask as DomainCodingTask +from app.coding.domain.value_objects import RunOutcomeStatus +from app.coding.schemas.coding import CodingSectionRead, CodingTaskRead +from app.interview.domain.serialization import ( + parse_coding_selection_spec, + parse_overall_feedback, + selection_to_spec, +) +from app.shared.infrastructure.models import CodeRunAttempt as OrmCodeRunAttempt +from app.shared.infrastructure.models import CodingSection as OrmCodingSection +from app.shared.infrastructure.models import CodingTask as OrmCodingTask + + +def _task_ids_from_tasks(tasks: tuple[DomainCodingTask, ...]) -> tuple[str, ...]: + """Derive ordered task IDs from initial task rounds. + + Args: + tasks: Coding tasks for a section. + + Returns: + Task IDs for round 0 rows sorted by display order. + """ + initial = sorted( + (task for task in tasks if task.round == 0), + key=lambda task: task.order, + ) + return tuple(task.task_id for task in initial) + + +def _parse_task_spec(raw: str) -> dict[str, Any]: + """Parse persisted task spec JSON. + + Args: + raw: JSON string from ``coding_tasks.task_spec``. + + Returns: + Parsed dict, or empty dict when invalid. + """ + try: + data = json.loads(raw) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def _parse_optional_json(raw: str | None) -> dict[str, Any] | None: + """Parse optional JSON column value. + + Args: + raw: JSON string or None. + + Returns: + Parsed dict, or None when unset or invalid. + """ + if raw is None: + return None + try: + data = json.loads(raw) + except json.JSONDecodeError: + return None + return data if isinstance(data, dict) else None + + +def coding_task_from_orm( + row: OrmCodingTask, + *, + interview_id: str, +) -> DomainCodingTask: + """Map an ORM coding task row to a domain entity. + + Args: + row: SQLAlchemy CodingTask linked to a coding section. + interview_id: Parent interview UUID from the coding section. + + Returns: + Immutable domain CodingTask. + """ + return DomainCodingTask( + id=row.id, + coding_section_id=row.coding_section_id, + interview_id=interview_id, + task_id=row.task_id, + order=row.order, + round=row.round, + prompt_text=row.prompt_text, + task_spec=_parse_task_spec(row.task_spec), + submitted_code=row.submitted_code, + submit_test_summary=_parse_optional_json(row.submit_test_summary), + score=row.score, + feedback=row.feedback, + started_at=row.started_at, + created_at=row.created_at, + ) + + +def domain_coding_task_to_orm( + task: DomainCodingTask, + *, + coding_section_id: int | None = None, +) -> OrmCodingTask: + """Map a domain coding task to a new ORM row. + + Args: + task: Domain coding task (typically ``id`` is ``CodingTask.NEW_ID``). + coding_section_id: Parent section ID override when inserting. + + Returns: + Detached ORM CodingTask ready to be added to a session. + """ + section_id = ( + coding_section_id if coding_section_id is not None else task.coding_section_id + ) + return OrmCodingTask( + coding_section_id=section_id, + task_id=task.task_id, + order=task.order, + round=task.round, + prompt_text=task.prompt_text, + task_spec=json.dumps(task.task_spec, separators=(",", ":")), + submitted_code=task.submitted_code, + submit_test_summary=( + json.dumps(task.submit_test_summary, separators=(",", ":")) + if task.submit_test_summary is not None + else None + ), + score=task.score, + feedback=task.feedback, + started_at=task.started_at, + created_at=task.created_at, + ) + + +def coding_section_from_orm(section: OrmCodingSection) -> DomainCodingSection: + """Map an ORM coding section row to a domain aggregate. + + Args: + section: SQLAlchemy CodingSection with tasks loaded. + + Returns: + Immutable domain CodingSection including tasks. + """ + status: CodingSectionStatus + if section.status == "completed": + status = "completed" + elif section.status == "skipped": + status = "skipped" + elif section.status == "pending": + status = "pending" + else: + status = "active" + tasks = tuple( + coding_task_from_orm(row, interview_id=section.interview_id) + for row in section.tasks + ) + return DomainCodingSection( + id=section.id, + interview_id=section.interview_id, + locale=section.locale or "en", + selection=parse_coding_selection_spec(section.selection_spec), + task_count=section.task_count or 0, + task_ids=_task_ids_from_tasks(tasks), + task_time_limit_seconds=section.task_time_limit_seconds, + status=status, + section_score=section.section_score, + section_feedback=parse_overall_feedback(section.section_feedback), + tasks=tasks, + ) + + +def coding_section_to_orm(section: DomainCodingSection) -> OrmCodingSection: + """Map a new domain coding section to a detached ORM row. + + Args: + section: Domain section from ``CodingSection.start``. + + Returns: + ORM CodingSection ready for ``session.add``. + """ + fields: dict[str, Any] = { + "interview_id": section.interview_id, + "selection_spec": selection_to_spec(section.selection), + "task_count": section.task_count, + "task_time_limit_seconds": section.task_time_limit_seconds, + "status": section.status, + "section_score": section.section_score, + "section_feedback": ( + json.dumps(section.section_feedback, separators=(",", ":")) + if section.section_feedback is not None + else None + ), + "locale": section.locale, + } + if section.id != DomainCodingSection.NEW_ID: + fields["id"] = section.id + return OrmCodingSection(**fields) + + +def coding_section_to_orm_fields(section: DomainCodingSection) -> dict[str, Any]: + """Extract ORM-mutable coding section fields from a domain aggregate. + + Args: + section: Domain coding section aggregate. + + Returns: + Dict of column names to values for partial ORM updates. + """ + return { + "selection_spec": selection_to_spec(section.selection), + "task_count": section.task_count, + "task_time_limit_seconds": section.task_time_limit_seconds, + "status": section.status, + "section_score": section.section_score, + "section_feedback": ( + json.dumps(section.section_feedback, separators=(",", ":")) + if section.section_feedback is not None + else None + ), + "locale": section.locale, + } + + +def _parse_test_results(raw: str | None) -> tuple[dict[str, Any], ...]: + """Parse persisted per-test JSON payloads. + + Args: + raw: JSON array stored on ``code_run_attempts.test_results``. + + Returns: + Tuple of test result dicts. + """ + if raw is None: + return () + try: + data = json.loads(raw) + except json.JSONDecodeError: + return () + if not isinstance(data, list): + return () + return tuple(item for item in data if isinstance(item, dict)) + + +def code_run_attempt_from_orm(row: OrmCodeRunAttempt) -> DomainCodeRunAttempt: + """Map an ORM run attempt row to a domain entity. + + Args: + row: SQLAlchemy CodeRunAttempt row. + + Returns: + Immutable domain CodeRunAttempt. + """ + status: RunOutcomeStatus = row.status # type: ignore[assignment] + return DomainCodeRunAttempt( + id=row.id, + coding_task_id=row.coding_task_id, + attempt_no=row.attempt_no or 0, + source_code=row.source_code, + language=row.language, + status=status, + stdout=row.stdout, + stderr=row.stderr, + compile_output=row.compile_output, + tests_passed=row.tests_passed or 0, + tests_total=row.tests_total or 0, + test_results=_parse_test_results(row.test_results), + duration_ms=row.duration_ms, + created_at=row.created_at, + ) + + +def domain_code_run_attempt_to_orm( + attempt: DomainCodeRunAttempt, +) -> OrmCodeRunAttempt: + """Map a domain run attempt to a new ORM row. + + Args: + attempt: Domain attempt to persist. + + Returns: + Detached ORM row ready for ``session.add``. + """ + return OrmCodeRunAttempt( + coding_task_id=attempt.coding_task_id, + attempt_no=attempt.attempt_no, + source_code=attempt.source_code, + language=attempt.language, + status=attempt.status, + stdout=attempt.stdout, + stderr=attempt.stderr, + compile_output=attempt.compile_output, + tests_passed=attempt.tests_passed, + tests_total=attempt.tests_total, + test_results=json.dumps(list(attempt.test_results), separators=(",", ":")), + duration_ms=attempt.duration_ms, + created_at=attempt.created_at, + ) + + +def coding_task_read_from_domain(task: DomainCodingTask) -> CodingTaskRead: + """Map a domain coding task to a read model. + + Args: + task: Domain coding task entity. + + Returns: + Immutable CodingTaskRead for services and API. + """ + return CodingTaskRead( + id=task.id, + task_id=task.task_id, + order=task.order, + round=task.round, + prompt_text=task.prompt_text, + task_spec=dict(task.task_spec), + submitted_code=task.submitted_code, + score=task.score, + feedback=task.feedback, + started_at=task.started_at, + ) + + +def coding_section_to_read(section: DomainCodingSection) -> CodingSectionRead: + """Map a domain coding section to a read model. + + Args: + section: Domain coding section aggregate. + + Returns: + Immutable CodingSectionRead for services and API. + """ + return CodingSectionRead( + id=section.id, + interview_id=section.interview_id, + status=section.status, + locale=section.locale, + selection_spec=selection_to_spec(section.selection), + task_count=section.task_count, + task_time_limit_seconds=section.task_time_limit_seconds, + tasks=[coding_task_read_from_domain(task) for task in section.tasks], + section_score=section.section_score, + section_feedback=section.section_feedback, + ) diff --git a/app/coding/repositories/uow.py b/app/coding/repositories/uow.py new file mode 100644 index 0000000..86950d6 --- /dev/null +++ b/app/coding/repositories/uow.py @@ -0,0 +1,44 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding feature unit of work with repository accessors.""" + +from __future__ import annotations + +from app.coding.repositories.code_run_attempt import CodeRunAttemptRepository +from app.coding.repositories.coding_section import CodingSectionRepository +from app.shared.infrastructure.uow import UnitOfWork + + +class CodingUnitOfWork(UnitOfWork): + """Unit of Work exposing the coding section repository. + + Usage:: + + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview_id) + uow.commit() + """ + + def __init__(self, auto_commit: bool = False) -> None: + """Initialize the coding unit of work. + + Args: + auto_commit: If True, commit on successful context exit. + """ + super().__init__(auto_commit=auto_commit) + self._coding_sections_repo: CodingSectionRepository | None = None + self._code_run_attempts_repo: CodeRunAttemptRepository | None = None + + @property + def coding_sections(self) -> CodingSectionRepository: + """Access the ``CodingSectionRepository`` bound to this UoW.""" + if self._coding_sections_repo is None: + self._coding_sections_repo = CodingSectionRepository(self.session) + return self._coding_sections_repo + + @property + def code_run_attempts(self) -> CodeRunAttemptRepository: + """Access the ``CodeRunAttemptRepository`` bound to this UoW.""" + if self._code_run_attempts_repo is None: + self._code_run_attempts_repo = CodeRunAttemptRepository(self.session) + return self._code_run_attempts_repo diff --git a/app/coding/schemas/__init__.py b/app/coding/schemas/__init__.py new file mode 100644 index 0000000..b60cfd2 --- /dev/null +++ b/app/coding/schemas/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding read schemas.""" diff --git a/app/coding/schemas/coding.py b/app/coding/schemas/coding.py new file mode 100644 index 0000000..80b7c02 --- /dev/null +++ b/app/coding/schemas/coding.py @@ -0,0 +1,260 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding section read models.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime +from typing import Any, cast + +from pydantic import BaseModel, ConfigDict, Field + +from app.coding.domain.entities import CodeRunAttempt, CodingSectionStatus +from app.coding.domain.value_objects import RunOutcomeStatus + + +@dataclass(frozen=True, slots=True) +class CodingTaskRead: + """Read model for one coding task row. + + Attributes: + id: Task primary key. + task_id: YAML task ID. + order: Display order within the section. + round: Follow-up round number. + prompt_text: Task prompt snapshot. + task_spec: Client-safe task metadata. + submitted_code: Submitted source code, if any. + score: AI score for the round. + feedback: AI feedback text. + started_at: Timer start timestamp. + """ + + id: int + task_id: str + order: int + round: int + prompt_text: str + task_spec: dict[str, Any] + submitted_code: str | None + score: int | None + feedback: str | None + started_at: datetime | None + + +@dataclass(frozen=True, slots=True) +class CodingSectionRead: + """Read model for a coding section aggregate. + + Attributes: + id: Section primary key. + interview_id: Parent interview UUID. + status: Section status. + locale: Locale for feedback. + selection_spec: JSON selection for this section. + task_count: Number of tasks in the section. + task_time_limit_seconds: Per-task timer, if enabled. + tasks: Coding tasks in display order. + section_score: Aggregated section score. + section_feedback: Cached section narrative feedback. + """ + + id: int + interview_id: str + status: CodingSectionStatus + locale: str + selection_spec: str + task_count: int + task_time_limit_seconds: int | None + tasks: list[CodingTaskRead] + section_score: int | None + section_feedback: dict[str, object] | None + + +@dataclass(frozen=True, slots=True) +class CodeRunAttemptRead: + """Read model for one persisted Run attempt. + + Attributes: + attempt_id: Attempt row primary key. + attempt_no: Sequential attempt number for the task. + status: Aggregated run outcome. + stdout: Captured standard output. + stderr: Captured standard error. + compile_output: Compiler output when applicable. + tests_passed: Number of public tests that passed. + tests_total: Number of public tests executed. + test_results: Per-test result payloads. + duration_ms: Judge0 execution duration in milliseconds. + created_at: Timestamp when the attempt was recorded. + """ + + attempt_id: int + attempt_no: int + status: RunOutcomeStatus + stdout: str | None + stderr: str | None + compile_output: str | None + tests_passed: int + tests_total: int + test_results: list[dict[str, Any]] + duration_ms: int | None + created_at: datetime + + +@dataclass(frozen=True, slots=True) +class CodingTaskStateRead: + """Client-safe coding task row for session state responses. + + Attributes: + id: Task row primary key. + task_id: YAML task ID. + order: Display order within the section. + round: Follow-up round number. + prompt_text: Task prompt snapshot. + task_spec: Client-safe task metadata. + submitted_code: Submitted source code, if any. + score: AI score for the round. + feedback: AI feedback text. + started_at: Timer start timestamp. + """ + + id: int + task_id: str + order: int + round: int + prompt_text: str + task_spec: dict[str, Any] + submitted_code: str | None + score: int | None + feedback: str | None + started_at: datetime | None + + +@dataclass(frozen=True, slots=True) +class CodingSessionStateRead: + """Read model for ``GET /interview/{id}/coding/state``. + + Attributes: + interview_id: Parent interview UUID. + section_status: Coding section status. + task_time_limit_seconds: Per-task timer, if enabled. + completed_tasks: Number of submitted tasks. + total_tasks: Total tasks in the section. + current_task: Active unsubmitted task, if any. + tasks: All task rows in display order. + run_attempts: Run history for the current task. + """ + + interview_id: str + section_status: CodingSectionStatus + task_time_limit_seconds: int | None + completed_tasks: int + total_tasks: int + current_task: CodingTaskStateRead | None + tasks: list[CodingTaskStateRead] + run_attempts: list[CodeRunAttemptRead] + + +class CodingRunRequest(BaseModel): + """Request body for ``POST /interview/{id}/coding/run``.""" + + model_config = ConfigDict(frozen=True) + + task_id: str = Field(min_length=1) + source_code: str + + +class CodingRunResponse(BaseModel): + """Response body mirroring a persisted Run attempt.""" + + model_config = ConfigDict(frozen=True) + + attempt_id: int + attempt_no: int + status: RunOutcomeStatus + stdout: str | None = None + stderr: str | None = None + compile_output: str | None = None + tests_passed: int + tests_total: int + test_results: list[dict[str, Any]] + duration_ms: int | None = None + + +def domain_run_attempt_to_read(attempt: CodeRunAttempt) -> CodeRunAttemptRead: + """Map a domain run attempt entity to a read model. + + Args: + attempt: Persisted domain attempt. + + Returns: + API read model for Run responses and state payloads. + """ + return CodeRunAttemptRead( + attempt_id=attempt.id, + attempt_no=attempt.attempt_no, + status=attempt.status, + stdout=attempt.stdout, + stderr=attempt.stderr, + compile_output=attempt.compile_output, + tests_passed=attempt.tests_passed, + tests_total=attempt.tests_total, + test_results=list(attempt.test_results), + duration_ms=attempt.duration_ms, + created_at=attempt.created_at, + ) + + +def run_attempt_to_response(attempt: CodeRunAttemptRead) -> CodingRunResponse: + """Convert a run attempt read model to an API response. + + Args: + attempt: Persisted attempt read model. + + Returns: + Pydantic response payload for the Run endpoint. + """ + return CodingRunResponse( + attempt_id=attempt.attempt_id, + attempt_no=attempt.attempt_no, + status=attempt.status, + stdout=attempt.stdout, + stderr=attempt.stderr, + compile_output=attempt.compile_output, + tests_passed=attempt.tests_passed, + tests_total=attempt.tests_total, + test_results=attempt.test_results, + duration_ms=attempt.duration_ms, + ) + + +def _json_safe(value: Any) -> Any: + """Recursively convert datetimes to ISO strings for JSON responses. + + Args: + value: Arbitrary nested value from a dataclass ``asdict`` payload. + + Returns: + JSON-serializable value. + """ + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, dict): + return {key: _json_safe(item) for key, item in value.items()} + if isinstance(value, list): + return [_json_safe(item) for item in value] + return value + + +def coding_state_to_dict(state: CodingSessionStateRead) -> dict[str, Any]: + """Serialize a coding session state read model for JSON responses. + + Args: + state: Session state read model. + + Returns: + JSON-serializable dict. + """ + return cast(dict[str, Any], _json_safe(asdict(state))) diff --git a/app/coding/schemas/page.py b/app/coding/schemas/page.py new file mode 100644 index 0000000..31c587d --- /dev/null +++ b/app/coding/schemas/page.py @@ -0,0 +1,37 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding section page read models.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class CodingPageContext(BaseModel): + """Template context for the coding section panel. + + Attributes: + task_count: Total coding tasks in the section. + completed_tasks: Number of submitted tasks. + current_task: Active unsubmitted task metadata for the editor. + current_task_row_id: Primary key of the active task row. + task_timer_enabled: Whether the per-task timer is active. + task_time_limit_seconds: Configured limit in seconds. + timer_remaining_seconds: Seconds left on the current task. + current_round: Follow-up round number for the active task. + complete: Whether all coding tasks have been submitted. + section_status: Coding section status slug. + """ + + model_config = ConfigDict(frozen=True) + + task_count: int + completed_tasks: int + current_task: dict[str, Any] | None + current_task_row_id: int | None + task_timer_enabled: bool + task_time_limit_seconds: int | None + timer_remaining_seconds: int | None + current_round: int = Field(default=0) + complete: bool = False + section_status: str = "active" diff --git a/app/coding/schemas/review.py b/app/coding/schemas/review.py new file mode 100644 index 0000000..1765598 --- /dev/null +++ b/app/coding/schemas/review.py @@ -0,0 +1,81 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding section review page read models.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class CodingTaskRoundRead(BaseModel): + """Read model for one submitted coding task round. + + Attributes: + round: Follow-up round number (0 = initial). + prompt_text: Prompt shown for this round. + submitted_code: Submitted source code or explanation text. + score: AI score for the round. + feedback: AI feedback text for the round. + submit_test_summary: Hidden test summary after the initial submit. + """ + + model_config = ConfigDict(frozen=True) + + round: int + prompt_text: str + submitted_code: str + score: int | None + feedback: str | None + submit_test_summary: dict[str, Any] | None = None + + +class CodingTaskReviewRead(BaseModel): + """Read model for one coding task grouped across follow-up rounds. + + Attributes: + order: Display order within the section (1-based). + task_id: YAML task ID. + initial_prompt: Original task prompt from round 0. + total_score: Sum of round scores for this task. + max_score: Maximum achievable score for this task. + rounds: Submitted rounds in ascending round order. + """ + + model_config = ConfigDict(frozen=True) + + order: int + task_id: str + initial_prompt: str + total_score: int + max_score: int + rounds: list[CodingTaskRoundRead] = Field(default_factory=list) + + +class CodingReviewContext(BaseModel): + """Template context for the completed coding section review page. + + Attributes: + interview_id: Parent session UUID. + interview_title: Display title derived from selection. + selection_lines: Human-readable selection summary lines. + locale_label: Localized language label. + section_score: Aggregated section score. + section_max_score: Maximum achievable section score. + section_feedback: Resolved section narrative payload. + tasks: Coding tasks grouped by order with round details. + results_url: Relative URL for the session results hub. + """ + + model_config = ConfigDict(frozen=True) + + interview_id: str + interview_title: str + selection_lines: list[str] + locale_label: str + section_score: int + section_max_score: int + section_feedback: dict[str, Any] + tasks: list[CodingTaskReviewRead] + results_url: str diff --git a/app/coding/schemas/ws.py b/app/coding/schemas/ws.py new file mode 100644 index 0000000..8483a0a --- /dev/null +++ b/app/coding/schemas/ws.py @@ -0,0 +1,64 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""WebSocket server message schemas for coding sessions.""" + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +from app.interview.schemas.ws import EvaluatingMessage, server_message_to_dict + + +class CodingSavedMessage(BaseModel): + """Server message emitted after coding submission text is persisted.""" + + model_config = ConfigDict(frozen=True) + + type: Literal["saved"] = "saved" + + +class CodingFeedbackMessage(BaseModel): + """Server message with evaluation feedback for one coding task round.""" + + model_config = ConfigDict(frozen=True) + + type: Literal["feedback"] = "feedback" + task_id: str + order: int + round: int + follow_up_question: str | None + follow_up_mode: Literal["code", "explanation"] | None = None + next_task: dict[str, Any] | None = None + feedback: str | None = None + timer_remaining_seconds: int | None = None + + +def coding_server_message_to_dict(message: BaseModel) -> dict[str, Any]: + """Serialize a coding WebSocket server message for ``send_json``. + + Args: + message: Pydantic server message model. + + Returns: + JSON-serializable dict. + """ + if isinstance(message, CodingFeedbackMessage): + payload = message.model_dump(mode="json") + if payload.get("feedback") is None: + payload.pop("feedback", None) + if payload.get("timer_remaining_seconds") is None: + payload.pop("timer_remaining_seconds", None) + if payload.get("follow_up_mode") is None: + payload.pop("follow_up_mode", None) + if payload.get("next_task") is None: + payload.pop("next_task", None) + return payload + return server_message_to_dict(message) + + +__all__ = [ + "CodingFeedbackMessage", + "CodingSavedMessage", + "EvaluatingMessage", + "coding_server_message_to_dict", +] diff --git a/app/coding/services/__init__.py b/app/coding/services/__init__.py new file mode 100644 index 0000000..78bea55 --- /dev/null +++ b/app/coding/services/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding orchestration services.""" diff --git a/app/coding/services/availability.py b/app/coding/services/availability.py new file mode 100644 index 0000000..aa02446 --- /dev/null +++ b/app/coding/services/availability.py @@ -0,0 +1,88 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding feature availability checks for setup and session creation.""" + +import asyncio +import os + +import httpx + +from app.coding.services.judge0_config import judge0_auth_token, judge0_url + +_TRUTHY = frozenset({"1", "true", "yes", "on"}) + + +def _coding_enabled_env() -> bool: + """Return whether coding is enabled via ``CODING_ENABLED``. + + Returns: + True when coding is enabled in the environment. + """ + return os.environ.get("CODING_ENABLED", "true").lower() in _TRUTHY + + +def is_judge0_healthy() -> bool: + """Probe Judge0 synchronously for setup and validation paths. + + Returns: + True when ``GET /about`` responds with HTTP 200. + """ + try: + headers: dict[str, str] = {} + token = judge0_auth_token() + if token: + headers["X-Auth-Token"] = token + with httpx.Client(timeout=2.0) as client: + response = client.get(f"{judge0_url()}/about", headers=headers) + return response.status_code == 200 + except httpx.HTTPError: + return False + + +async def is_judge0_healthy_async() -> bool: + """Probe Judge0 asynchronously for async API handlers. + + Returns: + True when ``GET /about`` responds with HTTP 200. + """ + from app.coding.services.judge0_client import Judge0Client + + return await Judge0Client.from_env().health_check() + + +def is_coding_available() -> bool: + """Return whether coding can be selected on setup and created. + + Requires ``CODING_ENABLED`` and a healthy Judge0 instance. + + Returns: + True when coding sessions may be started from setup. + """ + if not _coding_enabled_env(): + return False + return is_judge0_healthy() + + +async def is_coding_available_async() -> bool: + """Async variant of :func:`is_coding_available` for API handlers. + + Returns: + True when coding sessions may be started from setup. + """ + if not _coding_enabled_env(): + return False + return await is_judge0_healthy_async() + + +def run_is_coding_available_async() -> bool: + """Run the async availability probe from synchronous code. + + Returns: + True when coding sessions may be started from setup. + """ + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(is_coding_available_async()) + msg = "run_is_coding_available_async() cannot be used inside a running event loop" + raise RuntimeError(msg) diff --git a/app/coding/services/creation.py b/app/coding/services/creation.py new file mode 100644 index 0000000..e963acf --- /dev/null +++ b/app/coding/services/creation.py @@ -0,0 +1,50 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding section creation service.""" + +from app.coding.domain.entities import CodingSection, CodingSectionStatus +from app.coding.domain.value_objects import PlannedCodingTask +from app.interview.domain.value_objects import InterviewSelection +from app.interview.repositories.uow import InterviewUnitOfWork + + +class CodingSectionCreationService: + """Service for creating coding sections within an interview session.""" + + @staticmethod + def create( + interview_id: str, + *, + selection: InterviewSelection, + locale: str, + planned_tasks: tuple[PlannedCodingTask, ...], + task_time_limit_seconds: int | None, + status: CodingSectionStatus = "active", + uow: InterviewUnitOfWork, + ) -> CodingSection: + """Persist a coding section with initial task rows. + + Args: + interview_id: Parent interview UUID. + selection: Track/level/topic selection from setup. + locale: Locale for AI feedback. + planned_tasks: Ordered tasks for this section. + task_time_limit_seconds: Per-task time limit, or None to disable. + status: Initial section status (``pending`` until phase switch). + uow: Active interview unit of work sharing the persistence session. + + Returns: + Persisted coding section aggregate with assigned task IDs. + + Raises: + ValueError: If ``planned_tasks`` is empty. + """ + section = CodingSection.start( + interview_id, + selection=selection, + locale=locale, + planned_tasks=planned_tasks, + task_time_limit_seconds=task_time_limit_seconds, + status=status, + ) + return uow.coding_sections.create_aggregate(section) diff --git a/app/coding/services/evaluation_persistence.py b/app/coding/services/evaluation_persistence.py new file mode 100644 index 0000000..71a7068 --- /dev/null +++ b/app/coding/services/evaluation_persistence.py @@ -0,0 +1,168 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Persist coding AI evaluation results and advance sections.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any, Literal + +from app.coding.domain.exceptions import CodingSectionNotFoundError +from app.coding.repositories.uow import CodingUnitOfWork +from app.coding.services.evaluator.models import ( + CodingAnswerEvaluation, + CodingFollowUpEvaluation, +) +from app.coding.services.events import CodingFeedbackEvent +from app.coding.services.navigation import CodingNavigationService, next_task_payload + + +class CodingEvaluationPersistenceService: + """Save coding evaluation scores and advance timed task rounds.""" + + @staticmethod + def _apply_hidden_test_cap( + evaluation: CodingAnswerEvaluation | CodingFollowUpEvaluation, + submit_test_summary: dict[str, Any] | None, + ) -> CodingAnswerEvaluation | CodingFollowUpEvaluation: + """Cap score and force follow-up when hidden tests failed on submit. + + Args: + evaluation: Parsed AI evaluation. + submit_test_summary: Hidden test summary from submit. + + Returns: + Evaluation with score capped when hidden tests failed. + """ + if submit_test_summary is None: + return evaluation + if submit_test_summary.get("status") == "success": + return evaluation + capped_score = min(evaluation.score, 3) + if isinstance(evaluation, CodingAnswerEvaluation): + return replace( + evaluation, + score=capped_score, + follow_up_needed=True, + follow_up_mode=evaluation.follow_up_mode or "code", + ) + return replace( + evaluation, + score=capped_score, + needs_further_follow_up=True, + follow_up_mode=evaluation.follow_up_mode or "code", + ) + + @staticmethod + def persist( + *, + interview_id: str, + task_id: str, + round_num: int, + order: int, + evaluation: CodingAnswerEvaluation | CodingFollowUpEvaluation, + follow_up_needed: bool, + follow_up_text: str | None, + follow_up_mode: Literal["code", "explanation"] | None, + submit_test_summary: dict[str, Any] | None, + submitted_source_code: str, + ) -> CodingFeedbackEvent: + """Save AI evaluation results and build a feedback event. + + Args: + interview_id: Parent interview UUID. + task_id: YAML task ID for the evaluated round. + round_num: Follow-up round (0 = initial). + order: Display order of the task. + evaluation: Parsed AI evaluation. + follow_up_needed: Whether to create a follow-up row. + follow_up_text: Follow-up prompt when applicable. + follow_up_mode: Composer mode for the follow-up round. + submit_test_summary: Hidden test summary from submit. + submitted_source_code: Code submitted for the evaluated round. + + Returns: + Feedback event for the coding WebSocket client. + """ + evaluation = CodingEvaluationPersistenceService._apply_hidden_test_cap( + evaluation, + submit_test_summary, + ) + if ( + isinstance(evaluation, CodingAnswerEvaluation) + and evaluation.follow_up_needed + or ( + isinstance(evaluation, CodingFollowUpEvaluation) + and evaluation.needs_further_follow_up + ) + ): + follow_up_needed = True + follow_up_text = follow_up_text or evaluation.follow_up_question + follow_up_mode = follow_up_mode or evaluation.follow_up_mode + + next_task_data: dict[str, Any] | None = None + timer_remaining: int | None = None + resolved_follow_up_mode = follow_up_mode + + with CodingUnitOfWork(auto_commit=True) as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None: + raise CodingSectionNotFoundError(interview_id) + section.ensure_active() + + updated = section.with_evaluation( + task_id, + round_num, + evaluation.score, + evaluation.feedback, + ) + follow_up_round: int | None = None + if follow_up_needed: + starter_code = ( + submitted_source_code if resolved_follow_up_mode == "code" else None + ) + updated, pending = updated.with_follow_up( + task_id, + follow_up_text or "", + starter_code=starter_code, + ) + follow_up_round = pending.round + + uow.coding_sections.save_aggregate(updated) + + if follow_up_needed and follow_up_round is not None: + uow.flush() + reloaded = uow.coding_sections.get_aggregate(interview_id) + if reloaded is None: + raise CodingSectionNotFoundError(interview_id) + follow_up = reloaded.find_task(task_id, follow_up_round) + timed = reloaded.start_timer_for_task(follow_up.id) + uow.coding_sections.save_aggregate(timed) + activated = next( + task for task in timed.tasks if task.id == follow_up.id + ) + timer_remaining = activated.remaining_seconds( + timed.task_time_limit_seconds + ) + next_task_data = next_task_payload(activated) + elif not follow_up_needed: + next_task_data, timer_remaining = ( + CodingNavigationService.advance_to_next_unsubmitted( + uow, + interview_id, + task_id=task_id, + round_num=round_num, + ) + ) + + return CodingFeedbackEvent( + task_id=task_id, + order=order, + round=round_num, + follow_up_needed=follow_up_needed, + follow_up_text=follow_up_text, + follow_up_mode=resolved_follow_up_mode, + next_task=next_task_data, + feedback=evaluation.feedback, + timer_remaining_seconds=timer_remaining, + ) diff --git a/app/coding/services/evaluator/__init__.py b/app/coding/services/evaluator/__init__.py new file mode 100644 index 0000000..672171e --- /dev/null +++ b/app/coding/services/evaluator/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding AI evaluator package.""" + +from app.coding.services.evaluator.models import ( + CodingAnswerEvaluation, + CodingFollowUpEvaluation, +) +from app.coding.services.evaluator.service import CodingEvaluatorService + +__all__ = [ + "CodingAnswerEvaluation", + "CodingEvaluatorService", + "CodingFollowUpEvaluation", +] diff --git a/app/coding/services/evaluator/models.py b/app/coding/services/evaluator/models.py new file mode 100644 index 0000000..6c5c12f --- /dev/null +++ b/app/coding/services/evaluator/models.py @@ -0,0 +1,57 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Pydantic models for structured coding AI evaluation output.""" + +from typing import Literal + +from pydantic import BaseModel, Field + + +class CodingAnswerEvaluation(BaseModel): + """Evaluation of an initial coding submission (round=0). + + Attributes: + score: Rating 1-5. + feedback: Detailed feedback on the submission. + strengths: Key strengths demonstrated. + weaknesses: Areas for improvement. + follow_up_needed: Whether a follow-up round is needed. + follow_up_question: Follow-up prompt text when needed. + follow_up_mode: Composer mode for the follow-up round. + """ + + score: int = Field(..., ge=1, le=5, description="Rating 1-5") + feedback: str = Field(..., description="Detailed feedback") + strengths: list[str] = Field(default_factory=list) + weaknesses: list[str] = Field(default_factory=list) + follow_up_needed: bool = Field(..., description="Whether a follow-up is needed") + follow_up_question: str | None = Field(None, description="Follow-up prompt text") + follow_up_mode: Literal["code", "explanation"] | None = Field( + None, + description="Follow-up composer mode when follow_up_needed is true", + ) + + +class CodingFollowUpEvaluation(BaseModel): + """Evaluation of a coding follow-up round (round >= 1). + + Attributes: + score: Rating 1-5 for the follow-up. + feedback: Detailed feedback. + needs_further_follow_up: Whether another follow-up is needed. + follow_up_question: Next follow-up prompt text, if needed. + follow_up_mode: Composer mode for the next follow-up round. + """ + + score: int = Field(..., ge=1, le=5, description="Rating 1-5") + feedback: str = Field(..., description="Detailed feedback") + needs_further_follow_up: bool = Field( + ..., description="Whether another follow-up is needed" + ) + follow_up_question: str | None = Field( + None, description="Next follow-up prompt text" + ) + follow_up_mode: Literal["code", "explanation"] | None = Field( + None, + description="Follow-up composer mode when needs_further_follow_up is true", + ) diff --git a/app/coding/services/evaluator/prompts.py b/app/coding/services/evaluator/prompts.py new file mode 100644 index 0000000..a44c784 --- /dev/null +++ b/app/coding/services/evaluator/prompts.py @@ -0,0 +1,129 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Prompt templates for coding AI evaluation.""" + +import json +from typing import Any + +CODING_ANSWER_EVALUATION_INSTRUCTIONS = """You are a technical interviewer evaluating a coding submission. +Use the task prompt, submitted code, optional Run attempt history, hidden test results, +and expected rubric points. + +Scoring guide: +- 5: Excellent — correct, clean, demonstrates strong understanding +- 4: Good — solid solution with minor issues +- 3: Adequate — partially correct or shallow understanding +- 2: Weak — significant gaps or major issues +- 1: Poor — incorrect or empty submission + +If hidden tests failed, cap the score at 3 and set follow_up_needed to true. +If the candidate made no Run attempts, note that as a weakness but do not block scoring. +If Run attempts show repeated compile errors or failing tests, lower the score and prefer follow-up. + +When follow_up_needed is true, set follow_up_mode: +- "code" when the candidate should fix or extend code in the editor +- "explanation" when the candidate should explain their approach in text + +Do not set follow_up_needed when the answer is already strong (score 4-5) unless hidden tests failed.""" + +CODING_SECTION_EVALUATION_INSTRUCTIONS = """You are a technical interviewer providing a coding section evaluation. +Review all coding task submissions from this section and provide: +1. Section narrative feedback summarizing performance in this coding section only +2. Topics they should review based on this section +3. Key strengths demonstrated in this section +4. A per-task score breakdown for this section + +For score_breakdown, use task IDs as keys. Each value is an object +with "score" (sum of all rounds for that task) and "max" fields. + +Return a JSON data object with your evaluation content. Do NOT return JSON Schema +metadata or a schema description — only the evaluation data object itself.""" + +CODING_FOLLOW_UP_EVALUATION_INSTRUCTIONS = """You are a technical interviewer evaluating a coding follow-up round. +Review the original task, initial submission, follow-up prompt, and the follow-up response code. + +Score 1-5 using the same guide as the initial coding evaluation. +If the follow-up scores 2 or below and this is not the final allowed follow-up round, +you may request another follow-up with an appropriate follow_up_mode.""" + + +def format_run_attempts(run_attempts: tuple[dict[str, Any], ...]) -> str: + """Serialize Run attempt history for evaluator prompts. + + Args: + run_attempts: Persisted attempt payloads for the task. + + Returns: + Human-readable summary text. + """ + if not run_attempts: + return "No Run attempts were recorded before submit." + lines: list[str] = [] + for attempt in run_attempts: + lines.append( + f"Attempt #{attempt.get('attempt_no', '?')}: " + f"status={attempt.get('status')}, " + f"tests={attempt.get('tests_passed')}/{attempt.get('tests_total')}" + ) + if attempt.get("compile_output"): + lines.append(f" compile_output: {attempt['compile_output']}") + if attempt.get("stderr"): + lines.append(f" stderr: {attempt['stderr']}") + return "\n".join(lines) + + +def format_submit_test_summary(summary: dict[str, Any] | None) -> str: + """Serialize hidden test results for evaluator prompts. + + Args: + summary: Hidden test summary persisted on submit. + + Returns: + Human-readable summary text. + """ + if summary is None: + return "Hidden tests were not executed for this task." + return json.dumps(summary, indent=2, ensure_ascii=False) + + +def build_coding_evaluation_user_text( + *, + prompt_text: str, + source_code: str, + expected_points: list[str], + run_attempts: tuple[dict[str, Any], ...], + submit_test_summary: dict[str, Any] | None, + initial_prompt_text: str | None = None, + initial_source_code: str | None = None, + follow_up_prompt: str | None = None, +) -> str: + """Build the user message for a coding evaluation request. + + Args: + prompt_text: Task or follow-up prompt shown to the candidate. + source_code: Submitted source code for the evaluated round. + expected_points: Rubric bullets from the task bank. + run_attempts: Run attempt history before submit. + submit_test_summary: Hidden test summary from submit. + initial_prompt_text: Original task prompt for follow-up rounds. + initial_source_code: Initial submitted code for follow-up rounds. + follow_up_prompt: Follow-up prompt for follow-up round evaluation. + + Returns: + User message text for the LLM. + """ + rubric = "\n".join(f"- {point}" for point in expected_points) or "(none)" + sections = [ + f"Task prompt:\n{prompt_text}", + f"Submitted code:\n{source_code}", + f"Expected rubric points:\n{rubric}", + f"Run attempts:\n{format_run_attempts(run_attempts)}", + f"Hidden test summary:\n{format_submit_test_summary(submit_test_summary)}", + ] + if initial_prompt_text is not None: + sections.insert(0, f"Original task prompt:\n{initial_prompt_text}") + if initial_source_code is not None: + sections.insert(2, f"Initial submitted code:\n{initial_source_code}") + if follow_up_prompt is not None: + sections.insert(-2, f"Follow-up prompt:\n{follow_up_prompt}") + return "\n\n".join(sections) diff --git a/app/coding/services/evaluator/service.py b/app/coding/services/evaluator/service.py new file mode 100644 index 0000000..4dda7a1 --- /dev/null +++ b/app/coding/services/evaluator/service.py @@ -0,0 +1,244 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding AI evaluator service.""" + +from __future__ import annotations + +from typing import Any, TypeVar, cast + +from pydantic import BaseModel + +from app.ai.base import AIProvider +from app.coding.services.evaluator.models import ( + CodingAnswerEvaluation, + CodingFollowUpEvaluation, +) +from app.coding.services.evaluator.prompts import ( + CODING_ANSWER_EVALUATION_INSTRUCTIONS, + CODING_FOLLOW_UP_EVALUATION_INSTRUCTIONS, + CODING_SECTION_EVALUATION_INSTRUCTIONS, + build_coding_evaluation_user_text, +) +from app.shared.evaluation_models import SectionEvaluation +from app.shared.structured_evaluation import evaluate_with_schema + +T = TypeVar("T", bound=BaseModel) + + +class CodingEvaluatorService: + """Evaluate coding submissions with run history and hidden test context.""" + + MAX_FOLLOW_UP_DEPTH = 2 + + @staticmethod + async def _evaluate_with_schema( + provider: AIProvider, + *, + locale: str, + instructions: str, + response_model: type[T], + user_text: str, + max_tokens: int = 1200, + ) -> T: + """Run a structured coding evaluation via the configured provider. + + Args: + provider: Configured AI provider instance. + locale: Locale for AI feedback. + instructions: Evaluator instruction template constant. + response_model: Pydantic model for parsed JSON output. + user_text: User message with task and submission context. + max_tokens: Maximum tokens for the model response. + + Returns: + Parsed evaluation model instance. + + Raises: + ValueError: If AI response is invalid or connection fails. + """ + return await evaluate_with_schema( + provider, + locale=locale, + instructions=instructions, + response_model=response_model, + user_text=user_text, + max_tokens=max_tokens, + ) + + @staticmethod + def _expected_points(task_spec: dict[str, Any]) -> list[str]: + """Extract rubric bullets from a persisted task spec. + + Args: + task_spec: Task metadata JSON. + + Returns: + List of expected rubric point strings. + """ + raw_points = task_spec.get("expected_points") + if not isinstance(raw_points, list): + return [] + return [str(point) for point in raw_points] + + @staticmethod + def _follow_up_decision( + evaluation: CodingAnswerEvaluation | CodingFollowUpEvaluation, + answer_round: int, + ) -> tuple[bool, str | None, str | None]: + """Decide whether another follow-up round is needed. + + Args: + evaluation: Parsed AI evaluation for the submitted round. + answer_round: Follow-up round that was evaluated (0 = initial). + + Returns: + Tuple of follow_up_needed, follow_up_text, follow_up_mode. + """ + if answer_round == 0: + if not isinstance(evaluation, CodingAnswerEvaluation): + raise TypeError("Round 0 evaluation must be CodingAnswerEvaluation") + follow_up_needed = evaluation.follow_up_needed and bool( + evaluation.follow_up_question + ) + mode = evaluation.follow_up_mode if follow_up_needed else None + return follow_up_needed, evaluation.follow_up_question, mode + if not isinstance(evaluation, CodingFollowUpEvaluation): + raise TypeError( + "Follow-up round evaluation must be CodingFollowUpEvaluation" + ) + follow_up_needed = ( + evaluation.needs_further_follow_up + and bool(evaluation.follow_up_question) + and answer_round < CodingEvaluatorService.MAX_FOLLOW_UP_DEPTH + ) + mode = evaluation.follow_up_mode if follow_up_needed else None + return follow_up_needed, evaluation.follow_up_question, mode + + @staticmethod + async def evaluate_submission( + *, + provider: AIProvider, + locale: str, + answer_round: int, + prompt_text: str, + task_spec: dict[str, Any], + source_code: str, + run_attempts: tuple[dict[str, Any], ...], + submit_test_summary: dict[str, Any] | None, + initial_prompt_text: str = "", + initial_source_code: str = "", + ) -> tuple[ + CodingAnswerEvaluation | CodingFollowUpEvaluation, + bool, + str | None, + str | None, + ]: + """Evaluate one coding submission round and decide on follow-up. + + Args: + provider: Configured AI provider instance. + locale: Locale for AI feedback. + answer_round: Follow-up round (0 = initial). + prompt_text: Prompt for the evaluated round. + task_spec: Persisted task metadata for the round. + source_code: Submitted editor contents. + run_attempts: Serialized Run attempt history for the initial task row. + submit_test_summary: Hidden test summary from submit. + initial_prompt_text: Original task prompt for follow-up rounds. + initial_source_code: Initial submitted code for follow-up rounds. + + Returns: + Tuple of evaluation, follow_up_needed, follow_up_text, follow_up_mode. + """ + expected_points = CodingEvaluatorService._expected_points(task_spec) + evaluation: CodingAnswerEvaluation | CodingFollowUpEvaluation + if answer_round == 0: + user_text = build_coding_evaluation_user_text( + prompt_text=prompt_text, + source_code=source_code, + expected_points=expected_points, + run_attempts=run_attempts, + submit_test_summary=submit_test_summary, + ) + evaluation = await CodingEvaluatorService._evaluate_with_schema( + provider, + locale=locale, + instructions=CODING_ANSWER_EVALUATION_INSTRUCTIONS, + response_model=CodingAnswerEvaluation, + user_text=user_text, + ) + else: + user_text = build_coding_evaluation_user_text( + prompt_text=prompt_text, + source_code=source_code, + expected_points=expected_points, + run_attempts=run_attempts, + submit_test_summary=submit_test_summary, + initial_prompt_text=initial_prompt_text, + initial_source_code=initial_source_code, + follow_up_prompt=prompt_text, + ) + evaluation = cast( + CodingFollowUpEvaluation, + await CodingEvaluatorService._evaluate_with_schema( + provider, + locale=locale, + instructions=CODING_FOLLOW_UP_EVALUATION_INSTRUCTIONS, + response_model=CodingFollowUpEvaluation, + user_text=user_text, + ), + ) + + follow_up_needed, follow_up_text, follow_up_mode = ( + CodingEvaluatorService._follow_up_decision(evaluation, answer_round) + ) + return evaluation, follow_up_needed, follow_up_text, follow_up_mode + + @staticmethod + async def evaluate_section( + provider: AIProvider, + task_submissions: list[dict[str, Any]], + sources_text: str, + locale: str, + ) -> SectionEvaluation: + """Provide a narrative evaluation for one coding section. + + Args: + provider: Configured AI provider instance. + task_submissions: Per-round coding task rows for the section. + sources_text: Human-readable list of tracks, levels, and topics. + locale: Locale for the section evaluation narrative. + + Returns: + SectionEvaluation with narrative feedback and recommendations. + + Raises: + ValueError: If AI response is invalid or connection fails. + """ + summary_rows: list[str] = [] + for row in task_submissions: + task_id = row.get("task_id", "?") + task_round = row.get("round", 0) + prompt_text = row.get("prompt_text", "") + submitted_code = row.get("submitted_code", "(skipped)") + score = row.get("score", "N/A") + summary_rows.append( + f"Task {task_id} (round {task_round}):\n" + f"Prompt: {prompt_text}\n" + f"Submission: {submitted_code}\n" + f"Score: {score}" + ) + + summary_text = "\n\n".join(summary_rows) + user_text = ( + f"Sources:\n{sources_text}\n\n" + f"Section Coding Tasks and Submissions:\n{summary_text}" + ) + return await CodingEvaluatorService._evaluate_with_schema( + provider, + locale=locale, + instructions=CODING_SECTION_EVALUATION_INSTRUCTIONS, + response_model=SectionEvaluation, + user_text=user_text, + max_tokens=1200, + ) diff --git a/app/coding/services/events.py b/app/coding/services/events.py new file mode 100644 index 0000000..9b184c8 --- /dev/null +++ b/app/coding/services/events.py @@ -0,0 +1,33 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Semantic events emitted by coding application services.""" + +from dataclasses import dataclass +from typing import Any, Literal + + +@dataclass(frozen=True) +class CodingFeedbackEvent: + """AI evaluation finished for one coding task round. + + Attributes: + task_id: YAML task ID. + order: Display order within the section. + round: Task round number. + follow_up_needed: Whether a follow-up round was created. + follow_up_text: Follow-up prompt when applicable. + follow_up_mode: Composer mode for the follow-up round. + next_task: Next task payload for the client, if any. + feedback: Short feedback for the client. + timer_remaining_seconds: Seconds left on the next round timer, if any. + """ + + task_id: str + order: int + round: int + follow_up_needed: bool + follow_up_text: str | None + follow_up_mode: Literal["code", "explanation"] | None + next_task: dict[str, Any] | None + feedback: str | None = None + timer_remaining_seconds: int | None = None diff --git a/app/coding/services/harness.py b/app/coding/services/harness.py new file mode 100644 index 0000000..11f5906 --- /dev/null +++ b/app/coding/services/harness.py @@ -0,0 +1,65 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Generate single-file Python scripts for Judge0 execution.""" + +from __future__ import annotations + +import textwrap + + +def build_python_script( + source_code: str, + *, + entrypoint: str | None, + stdin: str = "", +) -> str: + """Wrap candidate code for Judge0 execution. + + When ``entrypoint`` is set, stdin lines are parsed with ``ast.literal_eval`` + when possible and passed as positional arguments to the callable. Otherwise + the candidate source is executed as a standalone script. + + Args: + source_code: Candidate editor contents. + entrypoint: Callable name to invoke, or None for script mode. + stdin: Standard input fed to the submission. + + Returns: + Full Python source sent to Judge0. + """ + body = textwrap.dedent(source_code).strip("\n") + if not entrypoint: + if "__main__" in body: + return f"{body}\n" + return f"{body}\n\nif __name__ == '__main__':\n pass\n" + + stdin_repr = repr(stdin) + entrypoint_repr = repr(entrypoint) + runner = f""" +import ast +import sys + +{body} + + +def __grillkit_invoke(): + raw = {stdin_repr} if {stdin_repr} else sys.stdin.read() + lines = [line for line in raw.splitlines() if line.strip()] + if not lines: + args = [] + else: + args = [] + for line in lines: + try: + args.append(ast.literal_eval(line)) + except (ValueError, SyntaxError): + args.append(line) + result = {entrypoint_repr}(*args) + if result is not None: + print(result) + + +if __name__ == "__main__": + __grillkit_invoke() +""" + return textwrap.dedent(runner).strip() + "\n" diff --git a/app/coding/services/judge0_client.py b/app/coding/services/judge0_client.py new file mode 100644 index 0000000..49e5f70 --- /dev/null +++ b/app/coding/services/judge0_client.py @@ -0,0 +1,166 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Async HTTP client for the Judge0 CE submissions API.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import httpx + +from app.coding.services.judge0_config import ( + _DEFAULT_CPU_TIME_LIMIT_SECONDS, + _DEFAULT_MEMORY_LIMIT_KB, + judge0_auth_token, + judge0_url, +) + +JUDGE0_STATUS_ACCEPTED = 3 +JUDGE0_STATUS_WRONG_ANSWER = 4 +JUDGE0_STATUS_TIME_LIMIT = 5 +JUDGE0_STATUS_COMPILATION_ERROR = 6 +JUDGE0_STATUS_RUNTIME_ERROR = 11 + + +@dataclass(frozen=True, slots=True) +class Judge0SubmissionResult: + """Normalized Judge0 submission response. + + Attributes: + status_id: Judge0 status identifier. + status_description: Human-readable status label. + stdout: Captured standard output. + stderr: Captured standard error. + compile_output: Compiler diagnostics when compilation fails. + time: CPU time reported by Judge0 (string seconds). + memory: Memory usage reported by Judge0 in kilobytes. + """ + + status_id: int | None + status_description: str | None + stdout: str | None + stderr: str | None + compile_output: str | None + time: str | None + memory: int | None + + @property + def duration_ms(self) -> int | None: + """Return CPU time converted to milliseconds when available.""" + if not self.time: + return None + try: + return int(float(self.time) * 1000) + except ValueError: + return None + + +class Judge0Client: + """Thin wrapper around Judge0 CE HTTP endpoints.""" + + def __init__( + self, + *, + base_url: str | None = None, + auth_token: str | None = None, + timeout_seconds: float = 30.0, + ) -> None: + """Initialize the client. + + Args: + base_url: Judge0 API base URL; defaults to ``JUDGE0_URL``. + auth_token: Optional ``X-Auth-Token`` value. + timeout_seconds: HTTP timeout for submission calls. + """ + self._base_url = (base_url or judge0_url()).rstrip("/") + self._auth_token = auth_token if auth_token is not None else judge0_auth_token() + self._timeout_seconds = timeout_seconds + + @classmethod + def from_env(cls) -> Judge0Client: + """Build a client from environment variables. + + Returns: + Configured ``Judge0Client`` instance. + """ + return cls() + + def _headers(self) -> dict[str, str]: + headers: dict[str, str] = {"Content-Type": "application/json"} + if self._auth_token: + headers["X-Auth-Token"] = self._auth_token + return headers + + async def health_check(self) -> bool: + """Return whether the Judge0 server responds to ``/about``. + + Returns: + True when the health endpoint returns HTTP 200. + """ + try: + async with httpx.AsyncClient(timeout=2.0) as client: + response = await client.get( + f"{self._base_url}/about", + headers=self._headers(), + ) + return response.status_code == 200 + except httpx.HTTPError: + return False + + async def submit( + self, + *, + source_code: str, + language_id: int, + stdin: str = "", + cpu_time_limit: float | None = None, + memory_limit_kb: int | None = None, + compile_only: bool = False, + ) -> Judge0SubmissionResult: + """Create a Judge0 submission and wait for the result. + + Args: + source_code: Program source to execute. + language_id: Judge0 language identifier. + stdin: Input passed to the program. + cpu_time_limit: CPU time limit in seconds. + memory_limit_kb: Memory limit in kilobytes. + compile_only: When True, compile without running the program. + + Returns: + Normalized submission result. + + Raises: + httpx.HTTPError: If the Judge0 API request fails. + ValueError: If the response body is invalid. + """ + payload = { + "source_code": source_code, + "language_id": language_id, + "stdin": stdin, + "cpu_time_limit": cpu_time_limit or _DEFAULT_CPU_TIME_LIMIT_SECONDS, + "memory_limit": memory_limit_kb or _DEFAULT_MEMORY_LIMIT_KB, + "compile_only": compile_only, + } + async with httpx.AsyncClient(timeout=self._timeout_seconds) as client: + response = await client.post( + f"{self._base_url}/submissions", + params={"base64_encoded": "false", "wait": "true"}, + headers=self._headers(), + json=payload, + ) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict): + msg = "Invalid Judge0 response: expected object" + raise ValueError(msg) + status = data.get("status") or {} + return Judge0SubmissionResult( + status_id=status.get("id"), + status_description=status.get("description"), + stdout=data.get("stdout"), + stderr=data.get("stderr"), + compile_output=data.get("compile_output"), + time=data.get("time"), + memory=data.get("memory"), + ) diff --git a/app/coding/services/judge0_config.py b/app/coding/services/judge0_config.py new file mode 100644 index 0000000..f19e6ea --- /dev/null +++ b/app/coding/services/judge0_config.py @@ -0,0 +1,51 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Judge0 connection settings and language identifiers.""" + +import os + +JUDGE0_LANGUAGE_IDS: dict[str, int] = { + "python": 71, +} + +_DEFAULT_JUDGE0_URL = "http://localhost:2358" +_DEFAULT_CPU_TIME_LIMIT_SECONDS = 5.0 +_DEFAULT_MEMORY_LIMIT_KB = 128_000 + + +def judge0_url() -> str: + """Return the Judge0 API base URL without a trailing slash. + + Returns: + Base URL from ``JUDGE0_URL`` or the local development default. + """ + return os.environ.get("JUDGE0_URL", _DEFAULT_JUDGE0_URL).rstrip("/") + + +def judge0_auth_token() -> str | None: + """Return the optional Judge0 auth token. + + Returns: + Token string, or None when unset. + """ + token = os.environ.get("JUDGE0_AUTH_TOKEN", "").strip() + return token or None + + +def judge0_language_id(language: str) -> int: + """Map a GrillKit language slug to a Judge0 language ID. + + Args: + language: Language slug from a coding task spec. + + Returns: + Judge0 language identifier. + + Raises: + ValueError: If the language is not supported in v1. + """ + language_id = JUDGE0_LANGUAGE_IDS.get(language) + if language_id is None: + msg = f"Unsupported Judge0 language: {language}" + raise ValueError(msg) + return language_id diff --git a/app/coding/services/navigation.py b/app/coding/services/navigation.py new file mode 100644 index 0000000..e9b0761 --- /dev/null +++ b/app/coding/services/navigation.py @@ -0,0 +1,93 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Advance coding sections to the next unsubmitted task.""" + +from typing import Any + +from app.coding.domain.entities import CodingSection, CodingTask +from app.coding.domain.exceptions import CodingSectionNotFoundError +from app.coding.domain.task_spec import client_task_spec_from_stored +from app.coding.repositories.uow import CodingUnitOfWork +from app.interview.services.phases import SessionPhaseOrchestrator + + +def next_task_payload(task: CodingTask) -> dict[str, Any]: + """Build WebSocket/API payload for the next unsubmitted coding task. + + Args: + task: Next unsubmitted coding task round. + + Returns: + Dict with task fields for the client. + """ + return { + "task_id": task.task_id, + "order": task.order, + "round": task.round, + "prompt_text": task.prompt_text, + "task_spec": client_task_spec_from_stored(task.task_spec), + } + + +class CodingNavigationService: + """Shared navigation after a coding task round is completed.""" + + @staticmethod + def advance_to_next_unsubmitted( + uow: CodingUnitOfWork, + interview_id: str, + *, + task_id: str, + round_num: int, + ) -> tuple[dict[str, Any] | None, int | None]: + """Activate the next unsubmitted task and build client payload. + + Args: + uow: Active unit of work. + interview_id: Parent interview UUID. + task_id: YAML task ID of the completed round. + round_num: Follow-up round that was just completed. + + Returns: + Tuple of (next_task dict or None, timer_remaining_seconds or None). + + Raises: + CodingSectionNotFoundError: If the coding section does not exist. + CodingSectionNotActiveError: If the section is not active. + """ + section = uow.coding_sections.get_aggregate(interview_id) + if section is None: + raise CodingSectionNotFoundError(interview_id) + + section.ensure_active() + current_index = next( + i + for i, task in enumerate(section.tasks) + if task.task_id == task_id and task.round == round_num + ) + next_task = section.find_next_unsubmitted_after(current_index) + if next_task is None: + CodingNavigationService._notify_phase_complete_if_needed( + interview_id, section + ) + return None, None + + updated = section.start_timer_for_task(next_task.id) + uow.coding_sections.save_aggregate(updated) + activated = next(task for task in updated.tasks if task.id == next_task.id) + timer_remaining = activated.remaining_seconds(updated.task_time_limit_seconds) + return next_task_payload(activated), timer_remaining + + @staticmethod + def _notify_phase_complete_if_needed( + interview_id: str, + section: CodingSection, + ) -> None: + """Trigger section prefetch when the coding phase has no remaining tasks. + + Args: + interview_id: Parent interview UUID. + section: Coding section after the latest navigation update. + """ + if section.is_complete(): + SessionPhaseOrchestrator.notify_section_complete(interview_id, "coding") diff --git a/app/coding/services/page.py b/app/coding/services/page.py new file mode 100644 index 0000000..8077e61 --- /dev/null +++ b/app/coding/services/page.py @@ -0,0 +1,69 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding section page context builder.""" + +from app.coding.repositories.uow import CodingUnitOfWork +from app.coding.schemas.page import CodingPageContext +from app.coding.services.navigation import next_task_payload +from app.coding.services.section import CodingSectionService + + +class CodingPageService: + """Build coding-specific page context for session rendering.""" + + @staticmethod + def activate_timer(interview_id: str) -> None: + """Start the per-task timer on the current unsubmitted coding task. + + Args: + interview_id: Parent interview UUID. + """ + CodingSectionService.activate_pending(interview_id) + with CodingUnitOfWork(auto_commit=True) as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None or section.task_time_limit_seconds is None: + return + current = section.find_first_unsubmitted() + if current is None or current.started_at is not None: + return + updated = section.start_timer_for_task(current.id) + uow.coding_sections.save_aggregate(updated) + + @staticmethod + def build_context(interview_id: str) -> CodingPageContext | None: + """Assemble coding panel context for the interview page. + + Args: + interview_id: Parent interview UUID. + + Returns: + Coding page context, or None when the session has no coding section. + """ + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None: + return None + + current = section.find_first_unsubmitted() + 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 + timer_remaining = ( + current.remaining_seconds(section.task_time_limit_seconds) + if task_timer_enabled and current is not None + else None + ) + current_task = next_task_payload(current) if current is not None else None + return CodingPageContext( + task_count=section.task_count, + completed_tasks=completed_tasks, + current_task=current_task, + current_task_row_id=current.id if current is not None else None, + task_timer_enabled=task_timer_enabled, + task_time_limit_seconds=section.task_time_limit_seconds, + timer_remaining_seconds=timer_remaining, + current_round=current.round if current is not None else 0, + complete=section.is_complete(), + section_status=section.status, + ) diff --git a/app/coding/services/planning.py b/app/coding/services/planning.py new file mode 100644 index 0000000..89a2768 --- /dev/null +++ b/app/coding/services/planning.py @@ -0,0 +1,192 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Load coding task banks and build coding section task plans.""" + +from app.coding.domain.task_spec import task_spec_from_bank_task +from app.coding.domain.value_objects import PlannedCodingTask +from app.interview.domain.value_objects import ( + InterviewSelection, + PlannedQuestion, + TrackQuestionPools, +) +from app.shared.coding import ( + CodingTask, + list_categories, + list_levels, + list_tracks, + load_categories, + load_category, +) +from app.shared.locales import normalize_locale + + +def _to_planned_question(task: CodingTask) -> PlannedQuestion: + """Map a coding bank row to a generic planned question for selection. + + Args: + task: Loaded coding task. + + Returns: + Planned question used by shared selection planning. + """ + return PlannedQuestion(id=task.id, text=task.text, code=None) + + +def validate_selection(selection: InterviewSelection) -> None: + """Validate selection against the on-disk coding task bank. + + Args: + selection: Parsed coding branch selection. + + Raises: + ValueError: If selection is empty or references unknown bank paths. + """ + from app.interview.services.rules.selection import track_label + + if not selection.sources: + raise ValueError("Select at least one coding track and topic") + + tracks = set(list_tracks()) + for source in selection.sources: + if source.track not in tracks: + raise ValueError(f"Unknown coding track: {source.track}") + levels = set(list_levels(source.track)) + if source.level not in levels: + raise ValueError( + f"Unknown level '{source.level}' for coding track '{source.track}'" + ) + if not source.categories: + raise ValueError( + f"Select at least one coding topic for {track_label(source.track)}" + ) + available = set(list_categories(source.track, source.level)) + for category in source.categories: + if category not in available: + raise ValueError( + f"Unknown coding topic '{category}' for " + f"{source.track}/{source.level}" + ) + + +def validate_task_count(selection: InterviewSelection, task_count: int) -> None: + """Ensure task count allows at least one task per selected topic. + + Args: + selection: Parsed coding branch selection. + task_count: Requested number of coding tasks. + + Raises: + ValueError: If ``task_count`` is below the number of selected topics. + """ + topics = selection.topic_count + if task_count < topics: + msg = ( + f"Number of coding tasks must be at least {topics} " + f"(one per selected topic), got {task_count}" + ) + raise ValueError(msg) + + +def load_track_pools( + selection: InterviewSelection, + locale: str, +) -> list[TrackQuestionPools]: + """Load YAML coding task pools for each track source in a selection. + + Args: + selection: Validated coding selection. + locale: Locale for task prompt text. + + Returns: + Loaded pools in the same order as ``selection.sources``. + + Raises: + ValueError: If a pool is empty or a category has no tasks. + """ + locale = normalize_locale(locale) + pools: list[TrackQuestionPools] = [] + for source in selection.sources: + full_pool = load_categories( + source.track, source.level, list(source.categories), locale=locale + ) + category_pools: dict[str, list[CodingTask]] = {} + for category in source.categories: + category_pool = load_category( + source.track, source.level, category, locale=locale + ) + category_pools[category] = category_pool + pools.append( + TrackQuestionPools( + source=source, + full_pool=tuple(_to_planned_question(task) for task in full_pool), + category_pools={ + category: tuple(_to_planned_question(task) for task in pool) + for category, pool in category_pools.items() + }, + ) + ) + return pools + + +def _task_by_id( + selection: InterviewSelection, + locale: str, +) -> dict[str, CodingTask]: + """Load all coding tasks for a selection keyed by task id. + + Args: + selection: Validated coding selection. + locale: Locale for task prompt text. + + Returns: + Mapping from task id to loaded coding task row. + """ + locale = normalize_locale(locale) + tasks: dict[str, CodingTask] = {} + for source in selection.sources: + for category in source.categories: + for task in load_category( + source.track, source.level, category, locale=locale + ): + tasks.setdefault(task.id, task) + return tasks + + +def build_coding_task_plan( + selection: InterviewSelection, + task_count: int, + locale: str = "en", +) -> tuple[PlannedCodingTask, ...]: + """Build ordered coding task list for a multi-source section. + + Args: + selection: Validated coding selection. + task_count: Target number of tasks (>= topic count). + locale: Locale for task prompt text. + + Returns: + Ordered planned coding tasks. + + Raises: + ValueError: If validation fails or pools are empty. + """ + from app.interview.services.rules.selection import plan_questions + + validate_selection(selection) + validate_task_count(selection, task_count) + track_pools = load_track_pools(selection, locale) + planned = plan_questions(selection, task_count, track_pools) + tasks_by_id = _task_by_id(selection, locale) + result: list[PlannedCodingTask] = [] + for question in planned: + task = tasks_by_id.get(question.id) + if task is None: + raise ValueError(f"Coding task {question.id} not found in bank") + result.append( + PlannedCodingTask( + id=task.id, + text=task.text, + task_spec=task_spec_from_bank_task(task), + ) + ) + return tuple(result) diff --git a/app/coding/services/query.py b/app/coding/services/query.py new file mode 100644 index 0000000..da527cb --- /dev/null +++ b/app/coding/services/query.py @@ -0,0 +1,80 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding section read-only query helpers.""" + +from typing import Any + +from app.coding.domain.entities import CodingSection +from app.coding.repositories.uow import CodingUnitOfWork +from app.interview.services.rules.selection import selection_sources_summary +from app.interview.services.section_evaluation import build_section_evaluation_summary +from app.interview.services.sections import SectionEvaluationSummary + + +class CodingQueryService: + """Read-only queries for coding section aggregates.""" + + @staticmethod + def _items_from_section(section: CodingSection) -> tuple[dict[str, Any], ...]: + """Build task rows from a coding section aggregate. + + Args: + section: Domain coding section with tasks loaded. + + Returns: + Tuple of dicts with task and submission fields for evaluation. + """ + return tuple( + { + "task_id": task.task_id, + "prompt_text": task.prompt_text, + "submitted_code": task.submitted_code, + "score": task.score, + "round": task.round, + "feedback": task.feedback, + } + for task in section.tasks + if task.submitted_code is not None + ) + + @staticmethod + def get_evaluation_summary(interview_id: str) -> SectionEvaluationSummary | None: + """Return coding section evaluation data for session completion. + + Uses cached ``section_feedback`` when present. + + Args: + interview_id: Parent interview UUID. + + Returns: + Section summary, or None when no coding section exists. + """ + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None: + return None + + return build_section_evaluation_summary( + "coding", + section_status=section.status, + items=CodingQueryService._items_from_section(section), + total_score=section.total_score(), + max_score=section.max_score(), + cached_narrative=section.section_feedback, + ) + + @staticmethod + def sources_text_for_section(interview_id: str) -> str: + """Build selection summary text for coding evaluation prompts. + + Args: + interview_id: Parent interview UUID. + + Returns: + Human-readable selection summary, or empty string when missing. + """ + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None: + return "" + return selection_sources_summary(section.selection) diff --git a/app/coding/services/review.py b/app/coding/services/review.py new file mode 100644 index 0000000..33a904a --- /dev/null +++ b/app/coding/services/review.py @@ -0,0 +1,122 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding section review page context builder.""" + +from __future__ import annotations + +from app.coding.domain.entities import CodingSection +from app.coding.schemas.review import ( + CodingReviewContext, + CodingTaskReviewRead, + CodingTaskRoundRead, +) +from app.coding.services.query import CodingQueryService +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.services.section_review_support import ( + load_completed_interview, + resolved_section_feedback, + review_score_fields, + shared_review_fields, +) + + +class CodingReviewService: + """Build read-only coding review context for completed sessions.""" + + @staticmethod + def _group_tasks(section: CodingSection) -> list[CodingTaskReviewRead]: + """Group submitted coding task rows by display order. + + Args: + section: Coding section aggregate with tasks loaded. + + Returns: + Task review rows sorted by display order. + """ + submitted = [task for task in section.tasks if task.submitted_code is not None] + orders = sorted({task.order for task in submitted}) + grouped: list[CodingTaskReviewRead] = [] + + for order in orders: + rounds = sorted( + (task for task in submitted if task.order == order), + key=lambda task: task.round, + ) + if not rounds: + continue + initial = next( + (task for task in rounds if task.round == 0), + rounds[0], + ) + round_reads = [ + CodingTaskRoundRead( + round=task.round, + prompt_text=task.prompt_text, + submitted_code=task.submitted_code or "", + score=task.score, + feedback=task.feedback, + submit_test_summary=( + task.submit_test_summary if task.round == 0 else None + ), + ) + for task in rounds + ] + total_score = sum( + task.score for task in rounds if isinstance(task.score, int) + ) + max_score = len(rounds) * CodingSection.MAX_SCORE_PER_ROUND + grouped.append( + CodingTaskReviewRead( + order=order, + task_id=initial.task_id, + initial_prompt=initial.prompt_text, + total_score=total_score, + max_score=max_score, + rounds=round_reads, + ) + ) + + return grouped + + @staticmethod + def build_context(interview_id: str) -> CodingReviewContext | None: + """Assemble coding review template context for a completed session. + + Args: + interview_id: Parent session UUID. + + Returns: + Review context, or None when the session or coding section is missing. + """ + snapshot = load_completed_interview(interview_id) + if snapshot is None: + return None + + with InterviewUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None: + return None + + summary = CodingQueryService.get_evaluation_summary(interview_id) + if summary is None: + return None + + section_feedback = resolved_section_feedback( + summary, + item_id_key="task_id", + cached_payload=section.section_feedback, + ) + scores = review_score_fields( + summary, + total_score=section.total_score(), + max_score=section.max_score(), + ) + + return CodingReviewContext( + **{ + **shared_review_fields(interview_id, snapshot), + **scores, + "section_feedback": section_feedback, + "tasks": CodingReviewService._group_tasks(section), + } + ) diff --git a/app/coding/services/run_execution.py b/app/coding/services/run_execution.py new file mode 100644 index 0000000..26cf2ed --- /dev/null +++ b/app/coding/services/run_execution.py @@ -0,0 +1,224 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Persist coding Run attempts after Judge0 execution.""" + +from __future__ import annotations + +from datetime import UTC, datetime +import os +from typing import Any + +from app.coding.domain.entities import CodeRunAttempt +from app.coding.domain.exceptions import ( + CodingRunLimitExceededError, + CodingSectionNotFoundError, +) +from app.coding.domain.value_objects import CodingRunResult, TestCaseRunResult +from app.coding.repositories.uow import CodingUnitOfWork +from app.coding.services.runner import CodingRunnerService +from app.interview.domain.exceptions import InterviewNotFoundError +from app.interview.repositories.uow import InterviewUnitOfWork + + +def _max_runs_per_task() -> int: + """Return the configured Run attempt limit per coding task. + + Returns: + Positive integer limit from ``CODING_MAX_RUNS_PER_TASK``. + """ + raw = os.environ.get("CODING_MAX_RUNS_PER_TASK", "20").strip() + try: + value = int(raw) + except ValueError: + return 20 + return max(1, value) + + +def _ensure_interview_active(interview_id: str) -> None: + """Ensure the parent interview session accepts coding actions. + + Args: + interview_id: Parent interview UUID. + + Raises: + InterviewNotFoundError: If the interview does not exist. + InterviewNotActiveError: If the interview is completed. + """ + with InterviewUnitOfWork() as uow: + aggregate = uow.interviews.get_aggregate(interview_id) + if aggregate is None: + raise InterviewNotFoundError(interview_id) + aggregate.ensure_active() + + +def _serialize_test_result(result: TestCaseRunResult) -> dict[str, Any]: + """Convert a domain test result into an API/persistence payload. + + Args: + result: One public test execution result. + + Returns: + JSON-serializable dict for clients and persistence. + """ + payload: dict[str, Any] = { + "name": result.name, + "passed": result.passed, + "expected_stdout": result.expected_stdout, + "actual_stdout": result.actual_stdout, + } + if result.stderr: + payload["stderr"] = result.stderr + if result.compile_output: + payload["compile_output"] = result.compile_output + if result.judge0_status_description: + payload["status"] = result.judge0_status_description + return payload + + +def coding_run_result_to_summary(result: CodingRunResult) -> dict[str, Any]: + """Serialize a Judge0 run result for submit_test_summary persistence. + + Args: + result: Aggregated run outcome from Judge0. + + Returns: + JSON-serializable hidden test summary payload. + """ + return { + "status": result.status, + "stdout": result.stdout, + "stderr": result.stderr, + "compile_output": result.compile_output, + "tests_passed": result.tests_passed, + "tests_total": result.tests_total, + "test_results": [ + _serialize_test_result(test_result) for test_result in result.test_results + ], + "duration_ms": result.duration_ms, + } + + +class CodingRunExecutionService: + """Validate, execute, and persist coding Run attempts.""" + + @staticmethod + def _load_run_context( + interview_id: str, + task_id: str, + ) -> tuple[dict[str, Any], int, int, str]: + """Validate the active task and return execution context. + + Args: + interview_id: Parent interview UUID. + task_id: YAML task ID for the active coding round. + + Returns: + Tuple of task spec, coding task row ID, next attempt number, language. + + Raises: + CodingSectionNotFoundError: If no coding section exists. + CodingSectionNotActiveError: If the coding section is not active. + CodingTaskNotCurrentError: If ``task_id`` is not the active task. + CodingRunLimitExceededError: If the per-task Run limit is reached. + """ + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None: + raise CodingSectionNotFoundError(interview_id) + section.ensure_active() + current_task = section.require_current_task(task_id) + limit = _max_runs_per_task() + attempt_count = uow.code_run_attempts.count_for_task(current_task.id) + if attempt_count >= limit: + raise CodingRunLimitExceededError(task_id, limit) + language = str(current_task.task_spec.get("language", "python")) + return ( + dict(current_task.task_spec), + current_task.id, + attempt_count + 1, + language, + ) + + @staticmethod + async def run_and_persist( + *, + interview_id: str, + task_id: str, + source_code: str, + ) -> CodeRunAttempt: + """Execute public tests and persist an immutable Run attempt. + + Args: + interview_id: Parent interview UUID. + task_id: YAML task ID for the active coding round. + source_code: Current Monaco editor contents. + + Returns: + Persisted domain run attempt. + + Raises: + InterviewNotFoundError: If the interview does not exist. + InterviewNotActiveError: If the interview is completed. + CodingSectionNotFoundError: If no coding section exists. + CodingSectionNotActiveError: If the coding section is not active. + CodingTaskNotCurrentError: If ``task_id`` is not the active task. + CodingRunLimitExceededError: If the per-task Run limit is reached. + """ + _ensure_interview_active(interview_id) + task_spec, coding_task_id, attempt_no, language = ( + CodingRunExecutionService._load_run_context(interview_id, task_id) + ) + run_result = await CodingRunnerService.run_public_tests( + source_code=source_code, + task_spec=task_spec, + ) + attempt = CodingRunExecutionService._build_attempt( + coding_task_id=coding_task_id, + attempt_no=attempt_no, + source_code=source_code, + language=language, + run_result=run_result, + ) + with CodingUnitOfWork(auto_commit=True) as uow: + return uow.code_run_attempts.create(attempt) + + @staticmethod + def _build_attempt( + *, + coding_task_id: int, + attempt_no: int, + source_code: str, + language: str, + run_result: CodingRunResult, + ) -> CodeRunAttempt: + """Build a domain run attempt from a Judge0 aggregate result. + + Args: + coding_task_id: Parent coding task row ID. + attempt_no: Sequential attempt number for the task. + source_code: Editor snapshot from the client. + language: Programming language slug. + run_result: Aggregated Judge0 outcome. + + Returns: + Unpersisted domain attempt. + """ + test_results = tuple( + _serialize_test_result(result) for result in run_result.test_results + ) + return CodeRunAttempt( + id=CodeRunAttempt.NEW_ID, + coding_task_id=coding_task_id, + attempt_no=attempt_no, + source_code=source_code, + language=language, + status=run_result.status, + stdout=run_result.stdout, + stderr=run_result.stderr, + compile_output=run_result.compile_output, + tests_passed=run_result.tests_passed, + tests_total=run_result.tests_total, + test_results=test_results, + duration_ms=run_result.duration_ms, + created_at=datetime.now(UTC), + ) diff --git a/app/coding/services/runner.py b/app/coding/services/runner.py new file mode 100644 index 0000000..83a04d6 --- /dev/null +++ b/app/coding/services/runner.py @@ -0,0 +1,301 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Execute coding task submissions through Judge0.""" + +from __future__ import annotations + +from typing import Any + +from app.coding.domain.value_objects import ( + CodingRunResult, + RunOutcomeStatus, + TestCaseRunResult, +) +from app.coding.services.harness import build_python_script +from app.coding.services.judge0_client import ( + JUDGE0_STATUS_ACCEPTED, + JUDGE0_STATUS_COMPILATION_ERROR, + JUDGE0_STATUS_RUNTIME_ERROR, + JUDGE0_STATUS_TIME_LIMIT, + Judge0Client, + Judge0SubmissionResult, +) +from app.coding.services.judge0_config import judge0_language_id + + +class CodingRunnerService: + """Run public tests and compile-only checks for coding tasks.""" + + @staticmethod + async def run_hidden_tests( + *, + source_code: str, + task_spec: dict[str, Any], + client: Judge0Client | None = None, + ) -> CodingRunResult: + """Execute hidden tests for a coding submission. + + Args: + source_code: Submitted editor contents. + task_spec: Persisted task metadata including ``hidden_tests``. + client: Optional Judge0 client for dependency injection in tests. + + Returns: + Aggregated hidden test outcome. + """ + hidden_tests = task_spec.get("hidden_tests") + if not isinstance(hidden_tests, list) or not hidden_tests: + return await CodingRunnerService.run_public_tests( + source_code=source_code, + task_spec={**task_spec, "evaluation_mode": "ai"}, + client=client, + ) + hidden_spec = { + **task_spec, + "evaluation_mode": "tests", + "public_tests": hidden_tests, + } + return await CodingRunnerService.run_public_tests( + source_code=source_code, + task_spec=hidden_spec, + client=client, + ) + + @staticmethod + async def run_public_tests( + *, + source_code: str, + task_spec: dict[str, Any], + client: Judge0Client | None = None, + ) -> CodingRunResult: + """Execute public tests for a coding task, or compile-only for AI tasks. + + Args: + source_code: Current editor contents from the candidate. + task_spec: Persisted task metadata with language, mode, and tests. + client: Optional Judge0 client for dependency injection in tests. + + Returns: + Aggregated run outcome with per-test details. + """ + judge0 = client or Judge0Client.from_env() + language = str(task_spec.get("language", "python")) + language_id = judge0_language_id(language) + evaluation_mode = str(task_spec.get("evaluation_mode", "tests")) + cpu_time_limit = task_spec.get("time_limit_seconds") + memory_limit_kb = task_spec.get("memory_limit_kb") + entrypoint = task_spec.get("entrypoint") + if not isinstance(entrypoint, str): + entrypoint = None + + if evaluation_mode == "ai": + return await CodingRunnerService._run_compile_only( + source_code=source_code, + language_id=language_id, + entrypoint=entrypoint, + cpu_time_limit=cpu_time_limit, + memory_limit_kb=memory_limit_kb, + client=judge0, + ) + + public_tests = task_spec.get("public_tests") or [] + if not isinstance(public_tests, list) or not public_tests: + return await CodingRunnerService._run_compile_only( + source_code=source_code, + language_id=language_id, + entrypoint=entrypoint, + cpu_time_limit=cpu_time_limit, + memory_limit_kb=memory_limit_kb, + client=judge0, + ) + + results: list[TestCaseRunResult] = [] + total_duration_ms = 0 + last_stdout: str | None = None + last_stderr: str | None = None + last_compile_output: str | None = None + + for raw_test in public_tests: + if not isinstance(raw_test, dict): + continue + name = str(raw_test.get("name", "test")) + stdin = str(raw_test.get("stdin", "")) + expected_stdout = str(raw_test.get("expected_stdout", "")) + script = build_python_script( + source_code, + entrypoint=entrypoint, + stdin=stdin, + ) + submission = await judge0.submit( + source_code=script, + language_id=language_id, + stdin=stdin, + cpu_time_limit=float(cpu_time_limit) + if isinstance(cpu_time_limit, (int, float)) + else None, + memory_limit_kb=memory_limit_kb + if isinstance(memory_limit_kb, int) + else None, + ) + case_result = CodingRunnerService._case_result_from_submission( + name=name, + expected_stdout=expected_stdout, + submission=submission, + ) + results.append(case_result) + if submission.duration_ms is not None: + total_duration_ms += submission.duration_ms + last_stdout = submission.stdout + last_stderr = submission.stderr + last_compile_output = submission.compile_output + if not case_result.passed: + break + + tests_passed = sum(1 for result in results if result.passed) + tests_total = len(results) + status = CodingRunnerService._aggregate_status(results) + return CodingRunResult( + status=status, + stdout=last_stdout, + stderr=last_stderr, + compile_output=last_compile_output, + tests_passed=tests_passed, + tests_total=tests_total, + test_results=tuple(results), + duration_ms=total_duration_ms or None, + ) + + @staticmethod + async def _run_compile_only( + *, + source_code: str, + language_id: int, + entrypoint: str | None, + cpu_time_limit: Any, + memory_limit_kb: Any, + client: Judge0Client, + ) -> CodingRunResult: + """Compile candidate code without executing public tests. + + Args: + source_code: Candidate editor contents. + language_id: Judge0 language identifier. + entrypoint: Optional callable used by the harness wrapper. + cpu_time_limit: Optional per-task CPU limit in seconds. + memory_limit_kb: Optional memory limit in kilobytes. + client: Judge0 client instance. + + Returns: + Compile-only run result. + """ + script = build_python_script(source_code, entrypoint=entrypoint) + submission = await client.submit( + source_code=script, + language_id=language_id, + cpu_time_limit=float(cpu_time_limit) + if isinstance(cpu_time_limit, (int, float)) + else None, + memory_limit_kb=memory_limit_kb + if isinstance(memory_limit_kb, int) + else None, + compile_only=True, + ) + status = CodingRunnerService._status_from_submission(submission, passed=True) + return CodingRunResult( + status=status, + stdout=submission.stdout, + stderr=submission.stderr, + compile_output=submission.compile_output, + tests_passed=0, + tests_total=0, + test_results=(), + duration_ms=submission.duration_ms, + ) + + @staticmethod + def _case_result_from_submission( + *, + name: str, + expected_stdout: str, + submission: Judge0SubmissionResult, + ) -> TestCaseRunResult: + """Map one Judge0 submission to a public test case result. + + Args: + name: Test case name. + expected_stdout: Expected stdout for the case. + submission: Judge0 response for the case. + + Returns: + Per-test run result. + """ + actual_stdout = submission.stdout or "" + passed = ( + submission.status_id == JUDGE0_STATUS_ACCEPTED + and actual_stdout == expected_stdout + ) + return TestCaseRunResult( + name=name, + passed=passed, + expected_stdout=expected_stdout, + actual_stdout=actual_stdout, + stderr=submission.stderr, + compile_output=submission.compile_output, + judge0_status_id=submission.status_id, + judge0_status_description=submission.status_description, + ) + + @staticmethod + def _status_from_submission( + submission: Judge0SubmissionResult, + *, + passed: bool, + ) -> RunOutcomeStatus: + """Derive a high-level run status from a Judge0 submission. + + Args: + submission: Judge0 response. + passed: Whether the caller considers the case successful. + + Returns: + Aggregated run status slug. + """ + status_id = submission.status_id + if status_id == JUDGE0_STATUS_COMPILATION_ERROR: + return "compile_error" + if status_id == JUDGE0_STATUS_TIME_LIMIT: + return "time_limit_exceeded" + if status_id == JUDGE0_STATUS_RUNTIME_ERROR: + return "runtime_error" + if passed: + return "success" + return "tests_failed" + + @staticmethod + def _aggregate_status(results: list[TestCaseRunResult]) -> RunOutcomeStatus: + """Derive the aggregate run status from per-test results. + + Args: + results: Executed public test results in order. + + Returns: + Aggregate run status slug. + """ + if not results: + return "success" + if any( + result.judge0_status_id == JUDGE0_STATUS_COMPILATION_ERROR + for result in results + ): + return "compile_error" + if any( + result.judge0_status_id == JUDGE0_STATUS_TIME_LIMIT for result in results + ): + return "time_limit_exceeded" + if any( + result.judge0_status_id == JUDGE0_STATUS_RUNTIME_ERROR for result in results + ): + return "runtime_error" + if all(result.passed for result in results): + return "success" + return "tests_failed" diff --git a/app/coding/services/section.py b/app/coding/services/section.py new file mode 100644 index 0000000..d55b30b --- /dev/null +++ b/app/coding/services/section.py @@ -0,0 +1,276 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding section orchestration service.""" + +from __future__ import annotations + +from typing import ClassVar, Literal + +from app.coding.repositories.uow import CodingUnitOfWork +from app.coding.services.evaluator.service import CodingEvaluatorService +from app.coding.services.query import CodingQueryService +from app.interview.services.section_service_support import ( + run_feedback_prefetch, + schedule_feedback_prefetch, + should_prefetch_feedback, +) +from app.interview.services.sections import ( + SectionEvaluationSummary, + SectionPageContext, + prior_sections_complete_for, +) + + +class CodingSectionService: + """Coding section lifecycle hooks and read helpers.""" + + section_kind: ClassVar[Literal["coding"]] = "coding" + + @staticmethod + def is_complete(interview_id: str) -> bool: + """Return whether all coding tasks in the section are submitted. + + Args: + interview_id: Parent interview UUID. + + Returns: + True when every task has submitted code or the section was skipped. + """ + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None: + return False + if section.status in {"skipped", "completed"}: + return True + return section.is_complete() + + @staticmethod + def is_user_facing(interview_id: str) -> bool: + """Return whether the user should interact with the coding section now. + + Args: + interview_id: Parent interview UUID. + + Returns: + True when the coding section is active and still has work remaining. + """ + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None: + return False + complete = ( + section.status in {"skipped", "completed"} or section.is_complete() + ) + return section.status == "active" and not complete + + @staticmethod + def activate_if_pending(interview_id: str) -> bool: + """Promote a pending coding section to active when prior phases finish. + + Starts the per-task timer on the first unsubmitted task when enabled. + + Args: + interview_id: Parent interview UUID. + + Returns: + True when the section was activated in this call. + """ + return CodingSectionService.activate_pending(interview_id) + + @staticmethod + def get_page_context(interview_id: str) -> SectionPageContext | None: + """Return coding section page metadata for session composition. + + Args: + interview_id: Parent interview UUID. + + Returns: + Section page context, or None when no coding section exists. + """ + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None: + return None + complete = ( + section.status in {"skipped", "completed"} or section.is_complete() + ) + active = section.status == "active" and not complete + return SectionPageContext( + section="coding", + active=active, + complete=complete, + ) + + @staticmethod + def get_evaluation_summary( + interview_id: str, + ) -> SectionEvaluationSummary | None: + """Return coding evaluation summary for session completion. + + Args: + interview_id: Parent interview UUID. + + Returns: + Section summary, or None when no coding section exists. + """ + return CodingQueryService.get_evaluation_summary(interview_id) + + @staticmethod + def activate_pending(interview_id: str) -> bool: + """Promote a pending coding section to active when prior phases finish. + + Starts the per-task timer on the first unsubmitted task when enabled. + + Args: + interview_id: Parent interview UUID. + + Returns: + True when the section was activated in this call. + """ + if not prior_sections_complete_for(interview_id, "coding"): + return False + with CodingUnitOfWork(auto_commit=True) as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None or section.status != "pending": + return False + updated = section.with_activated() + current = updated.find_first_unsubmitted() + if ( + current is not None + and updated.task_time_limit_seconds is not None + and current.started_at is None + ): + updated = updated.start_timer_for_task(current.id) + uow.coding_sections.save_aggregate(updated) + return True + + @staticmethod + def on_phase_complete(interview_id: str) -> None: + """Schedule background prefetch of coding section narrative feedback. + + Idempotent: skips when feedback is already cached. + + Args: + interview_id: Parent interview UUID. + """ + if not CodingSectionService._should_prefetch_section_feedback(interview_id): + return + schedule_feedback_prefetch( + lambda: CodingSectionService._prefetch_section_feedback(interview_id) + ) + + @staticmethod + async def ensure_section_feedback(interview_id: str) -> None: + """Synchronously prefetch section feedback before session completion. + + Idempotent: skips when feedback is already cached or the section is + incomplete. + + Args: + interview_id: Parent interview UUID. + """ + await CodingSectionService._prefetch_section_feedback(interview_id) + + @staticmethod + def _should_prefetch_section_feedback(interview_id: str) -> bool: + """Return whether section feedback should be generated for an interview. + + Args: + interview_id: Parent interview UUID. + + Returns: + True when the coding section exists, is complete, and lacks feedback. + """ + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview_id) + return should_prefetch_feedback(section) + + @staticmethod + async def _prefetch_section_feedback(interview_id: str) -> None: + """Generate and persist cached coding section feedback. + + Args: + interview_id: Parent interview UUID. + """ + await run_feedback_prefetch( + interview_id, + section_name="coding", + should_prefetch=lambda: ( + CodingSectionService._should_prefetch_section_feedback(interview_id) + ), + evaluate=lambda provider: CodingSectionService._evaluate_section_feedback( + interview_id, + provider, + ), + persist=lambda payload, score: ( + CodingSectionService._persist_section_feedback( + interview_id, + payload, + score, + ) + ), + ) + + @staticmethod + async def _evaluate_section_feedback( + interview_id: str, + provider: object, + ) -> tuple[dict[str, object], int] | None: + """Run the coding section LLM evaluation. + + Args: + interview_id: Parent interview UUID. + provider: Configured AI provider instance. + + Returns: + Feedback payload and section score, or None when evaluation is skipped. + """ + summary = CodingQueryService.get_evaluation_summary(interview_id) + if summary is None or not summary.items: + return None + section_eval = await CodingEvaluatorService.evaluate_section( + provider=provider, # type: ignore[arg-type] + task_submissions=list(summary.items), + sources_text=CodingQueryService.sources_text_for_section(interview_id), + locale=CodingSectionService._section_locale(interview_id), + ) + return section_eval.model_dump(), summary.score + + @staticmethod + def _persist_section_feedback( + interview_id: str, + payload: dict[str, object], + score: int, + ) -> None: + """Persist prefetched coding section feedback when still absent. + + Args: + interview_id: Parent interview UUID. + payload: Section evaluation payload from the LLM. + score: Earned section score. + """ + with CodingUnitOfWork(auto_commit=True) as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None or section.section_feedback is not None: + return + updated = section.with_cached_section_feedback( + payload, + section_score=score, + ) + uow.coding_sections.save_aggregate(updated) + + @staticmethod + def _section_locale(interview_id: str) -> str: + """Load the coding section locale for evaluation prompts. + + Args: + interview_id: Parent interview UUID. + + Returns: + Locale code, defaulting to ``en`` when the section is missing. + """ + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None: + return "en" + return section.locale diff --git a/app/coding/services/state.py b/app/coding/services/state.py new file mode 100644 index 0000000..7aeee10 --- /dev/null +++ b/app/coding/services/state.py @@ -0,0 +1,111 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Build coding session state read models for the interview UI.""" + +from __future__ import annotations + +from app.coding.domain.entities import CodeRunAttempt, CodingTask +from app.coding.domain.exceptions import CodingSectionNotFoundError +from app.coding.domain.task_spec import client_task_spec_from_stored +from app.coding.repositories.uow import CodingUnitOfWork +from app.coding.schemas.coding import ( + CodeRunAttemptRead, + CodingSessionStateRead, + CodingTaskStateRead, +) + + +def _task_state_from_domain(task: CodingTask) -> CodingTaskStateRead: + """Map a domain coding task to a client-safe state row. + + Args: + task: Domain coding task entity. + + Returns: + Task state read model for the coding panel. + """ + return CodingTaskStateRead( + id=task.id, + task_id=task.task_id, + order=task.order, + round=task.round, + prompt_text=task.prompt_text, + task_spec=client_task_spec_from_stored(task.task_spec), + submitted_code=task.submitted_code, + score=task.score, + feedback=task.feedback, + started_at=task.started_at, + ) + + +def _attempt_state_from_domain(attempt: CodeRunAttempt) -> CodeRunAttemptRead: + """Map a domain run attempt to an API read model. + + Args: + attempt: Persisted run attempt entity. + + Returns: + Run attempt read model for clients. + """ + return CodeRunAttemptRead( + attempt_id=attempt.id, + attempt_no=attempt.attempt_no, + status=attempt.status, + stdout=attempt.stdout, + stderr=attempt.stderr, + compile_output=attempt.compile_output, + tests_passed=attempt.tests_passed, + tests_total=attempt.tests_total, + test_results=list(attempt.test_results), + duration_ms=attempt.duration_ms, + created_at=attempt.created_at, + ) + + +class CodingStateService: + """Read-only builder for ``GET /coding/state`` responses.""" + + @staticmethod + def get_state(interview_id: str) -> CodingSessionStateRead: + """Return coding section progress and recent Run attempts. + + Args: + interview_id: Parent interview UUID. + + Returns: + Session state for the coding panel. + + Raises: + CodingSectionNotFoundError: If no coding section exists. + """ + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None: + raise CodingSectionNotFoundError(interview_id) + + current_task = section.find_first_unsubmitted() + current_task_state = ( + _task_state_from_domain(current_task) + if current_task is not None + else None + ) + run_attempts: tuple[CodeRunAttemptRead, ...] = () + if current_task is not None: + attempts = uow.code_run_attempts.list_for_task(current_task.id) + run_attempts = tuple( + _attempt_state_from_domain(attempt) for attempt in attempts + ) + + completed_tasks = sum( + 1 for task in section.tasks if task.submitted_code is not None + ) + return CodingSessionStateRead( + interview_id=interview_id, + section_status=section.status, + task_time_limit_seconds=section.task_time_limit_seconds, + completed_tasks=completed_tasks, + total_tasks=section.task_count, + current_task=current_task_state, + tasks=[_task_state_from_domain(task) for task in section.tasks], + run_attempts=list(run_attempts), + ) diff --git a/app/coding/services/submission.py b/app/coding/services/submission.py new file mode 100644 index 0000000..5de5a69 --- /dev/null +++ b/app/coding/services/submission.py @@ -0,0 +1,239 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding task submit orchestration for the coding WebSocket.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Any, Literal, cast + +from app.ai.base import AIProvider +from app.coding.domain.exceptions import CodingSectionNotFoundError +from app.coding.repositories.uow import CodingUnitOfWork +from app.coding.services.evaluation_persistence import ( + CodingEvaluationPersistenceService, +) +from app.coding.services.evaluator.service import CodingEvaluatorService +from app.coding.services.events import CodingFeedbackEvent +from app.coding.services.run_execution import ( + _ensure_interview_active, + coding_run_result_to_summary, +) +from app.coding.services.runner import CodingRunnerService +from app.interview.services.events import ( + AnswerSavedEvent, + EvaluatingEvent, + InterviewEvent, +) + + +@dataclass(frozen=True) +class CodingSubmissionContext: + """Shared state after a coding task row is prepared for submit. + + Attributes: + task_row_id: Primary key of the active coding task row. + task_id: YAML task ID. + round_num: Follow-up round (0 = initial). + order: Display order of the task. + prompt_text: Task prompt for the evaluated round. + task_spec: Persisted task metadata for the round. + locale: Section locale for AI feedback. + initial_prompt_text: Original task prompt for follow-up rounds. + initial_source_code: Initial submitted code for follow-up rounds. + submit_test_summary: Hidden test summary captured on submit. + run_attempts: Serialized Run attempt history for the task row. + """ + + task_row_id: int + task_id: str + round_num: int + order: int + prompt_text: str + task_spec: dict[str, Any] + locale: str + initial_prompt_text: str + initial_source_code: str + submit_test_summary: dict[str, Any] + run_attempts: tuple[dict[str, Any], ...] + + +class CodingSubmissionService: + """Handle coding submit messages and stream server events.""" + + @staticmethod + async def _prepare_submission( + *, + interview_id: str, + task_id: str, + source_code: str, + ) -> CodingSubmissionContext: + """Run hidden tests and persist the submitted code snapshot. + + Args: + interview_id: Parent interview UUID. + task_id: YAML task ID for the active coding round. + source_code: Monaco editor contents at submit time. + + Returns: + Submission context for AI evaluation. + + Raises: + CodingSectionNotFoundError: If no coding section exists. + CodingSectionNotActiveError: If the coding section is not active. + CodingTaskNotCurrentError: If ``task_id`` is not the active task. + """ + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None: + raise CodingSectionNotFoundError(interview_id) + section.ensure_active() + current_task = section.require_current_task(task_id) + run_attempts = CodingSubmissionService._serialize_run_attempts( + uow.code_run_attempts.list_for_task(current_task.id) + ) + round_num = current_task.round + order = current_task.order + prompt_text = current_task.prompt_text + task_spec = dict(current_task.task_spec) + locale = section.locale + task_row_id = current_task.id + initial_prompt_text = current_task.prompt_text + initial_source_code = source_code + if current_task.round > 0: + initial = next( + task + for task in section.tasks + if task.task_id == task_id and task.round == 0 + ) + initial_prompt_text = initial.prompt_text + initial_source_code = initial.submitted_code or "" + + hidden_result = await CodingRunnerService.run_hidden_tests( + source_code=source_code, + task_spec=task_spec, + ) + submit_test_summary = coding_run_result_to_summary(hidden_result) + + with CodingUnitOfWork(auto_commit=True) as uow: + section = uow.coding_sections.get_aggregate(interview_id) + if section is None: + raise CodingSectionNotFoundError(interview_id) + section.ensure_active() + updated = section.with_submit_test_summary( + task_row_id, + submit_test_summary, + source_code=source_code, + ) + uow.coding_sections.save_aggregate(updated) + + return CodingSubmissionContext( + task_row_id=task_row_id, + task_id=task_id, + round_num=round_num, + order=order, + prompt_text=prompt_text, + task_spec=task_spec, + locale=locale, + initial_prompt_text=initial_prompt_text, + initial_source_code=initial_source_code, + submit_test_summary=submit_test_summary, + run_attempts=run_attempts, + ) + + @staticmethod + def _serialize_run_attempts( + attempts: tuple[Any, ...], + ) -> tuple[dict[str, Any], ...]: + """Convert domain run attempts into evaluator prompt payloads. + + Args: + attempts: Persisted domain run attempts. + + Returns: + Tuple of serialized attempt dicts. + """ + return tuple( + { + "attempt_no": attempt.attempt_no, + "source_code": attempt.source_code, + "status": attempt.status, + "stderr": attempt.stderr, + "compile_output": attempt.compile_output, + "tests_passed": attempt.tests_passed, + "tests_total": attempt.tests_total, + "test_results": list(attempt.test_results), + } + for attempt in attempts + ) + + @staticmethod + async def stream_submit( + *, + interview_id: str, + task_id: str, + source_code: str, + provider: AIProvider, + ) -> AsyncIterator[InterviewEvent | CodingFeedbackEvent]: + """Persist a coding submission and stream evaluation events. + + Args: + interview_id: Parent interview UUID. + task_id: YAML task ID for the active coding round. + source_code: Monaco editor contents at submit time. + provider: AI provider for coding evaluation. + + Yields: + Semantic events mapped to WebSocket payloads by the API layer. + + Raises: + InterviewNotFoundError: If the interview does not exist. + InterviewNotActiveError: If the interview is completed. + CodingSectionNotFoundError: If no coding section exists. + CodingSectionNotActiveError: If the coding section is not active. + CodingTaskNotCurrentError: If ``task_id`` is not the active task. + """ + _ensure_interview_active(interview_id) + ctx = await CodingSubmissionService._prepare_submission( + interview_id=interview_id, + task_id=task_id, + source_code=source_code, + ) + + yield AnswerSavedEvent() + yield EvaluatingEvent() + + ( + evaluation, + follow_up_needed, + follow_up_text, + follow_up_mode, + ) = await asyncio.shield( + CodingEvaluatorService.evaluate_submission( + provider=provider, + locale=ctx.locale, + answer_round=ctx.round_num, + prompt_text=ctx.prompt_text, + task_spec=ctx.task_spec, + source_code=source_code, + run_attempts=ctx.run_attempts, + submit_test_summary=ctx.submit_test_summary, + initial_prompt_text=ctx.initial_prompt_text, + initial_source_code=ctx.initial_source_code, + ) + ) + + yield CodingEvaluationPersistenceService.persist( + interview_id=interview_id, + task_id=ctx.task_id, + round_num=ctx.round_num, + order=ctx.order, + evaluation=evaluation, + follow_up_needed=follow_up_needed, + follow_up_text=follow_up_text, + follow_up_mode=cast(Literal["code", "explanation"] | None, follow_up_mode), + submit_test_summary=ctx.submit_test_summary, + submitted_source_code=source_code, + ) diff --git a/app/interview/api/deps.py b/app/interview/api/deps.py index 4d1f8b7..30bef4f 100644 --- a/app/interview/api/deps.py +++ b/app/interview/api/deps.py @@ -9,8 +9,8 @@ from app.ai.base import AIProvider from app.ai.speech_transcriber import SpeechTranscriber -from app.interview.services.completion import InterviewCompletionService -from app.interview.services.creation import InterviewCreationService +from app.interview.services.completion import SessionCompletionService +from app.interview.services.creation import SessionCreationService from app.interview.services.query import InterviewQuery from app.platform.api.deps import ConfigServiceDep from app.platform.services.ai_context import ai_provider_from_config @@ -38,24 +38,24 @@ def get_interview_query() -> type[InterviewQuery]: return InterviewQuery -def get_interview_creation_service() -> type[InterviewCreationService]: - """Return the interview creation service class used by API handlers.""" - return InterviewCreationService +def get_session_creation_service() -> type[SessionCreationService]: + """Return the session creation service class used by API handlers.""" + return SessionCreationService -def get_interview_completion_service() -> type[InterviewCompletionService]: - """Return the interview completion service class used by API handlers.""" - return InterviewCompletionService +def get_session_completion_service() -> type[SessionCompletionService]: + """Return the session completion service class used by API handlers.""" + return SessionCompletionService InterviewQueryDep = Annotated[type[InterviewQuery], Depends(get_interview_query)] -InterviewCreationServiceDep = Annotated[ - type[InterviewCreationService], - Depends(get_interview_creation_service), +SessionCreationServiceDep = Annotated[ + type[SessionCreationService], + Depends(get_session_creation_service), ] -InterviewCompletionServiceDep = Annotated[ - type[InterviewCompletionService], - Depends(get_interview_completion_service), +SessionCompletionServiceDep = Annotated[ + type[SessionCompletionService], + Depends(get_session_completion_service), ] AIProviderDep = Annotated[AIProvider, Depends(get_ai_provider)] diff --git a/app/interview/api/errors.py b/app/interview/api/errors.py index 74c32c7..de0deae 100644 --- a/app/interview/api/errors.py +++ b/app/interview/api/errors.py @@ -4,7 +4,6 @@ from fastapi import HTTPException -from app.interview.api.ws_protocol import domain_error_to_wire from app.interview.domain.exceptions import ( AnswerNotFoundError, InterviewDomainError, @@ -14,9 +13,19 @@ QuestionTimerNotExpiredError, UnansweredAnswerNotFoundError, ) +from app.theory.api.ws_protocol import domain_error_to_wire +from app.theory.domain.exceptions import ( + TaskTimerNotEnabledError, + TaskTimerNotExpiredError, + TheoryDomainError, + TheorySectionNotActiveError, + TheorySectionNotFoundError, + TheoryTaskNotFoundError, + UnansweredTaskNotFoundError, +) -def ws_error_payload(exc: InterviewDomainError) -> dict[str, str]: +def ws_error_payload(exc: InterviewDomainError | TheoryDomainError) -> dict[str, str]: """Build a WebSocket error message from a domain exception. Args: @@ -28,7 +37,9 @@ def ws_error_payload(exc: InterviewDomainError) -> dict[str, str]: return domain_error_to_wire(exc) -def http_exception_from_domain_error(exc: InterviewDomainError) -> HTTPException: +def http_exception_from_domain_error( + exc: InterviewDomainError | TheoryDomainError, +) -> HTTPException: """Convert a domain exception to an HTTPException. Args: @@ -37,14 +48,24 @@ def http_exception_from_domain_error(exc: InterviewDomainError) -> HTTPException Returns: HTTPException with an appropriate status code. """ - if isinstance(exc, InterviewNotFoundError | AnswerNotFoundError): + if isinstance( + exc, + InterviewNotFoundError + | AnswerNotFoundError + | TheorySectionNotFoundError + | TheoryTaskNotFoundError, + ): return HTTPException(status_code=404, detail=str(exc)) if isinstance( exc, InterviewNotActiveError | UnansweredAnswerNotFoundError | QuestionTimerNotEnabledError - | QuestionTimerNotExpiredError, + | QuestionTimerNotExpiredError + | TheorySectionNotActiveError + | UnansweredTaskNotFoundError + | TaskTimerNotEnabledError + | TaskTimerNotExpiredError, ): return HTTPException(status_code=400, detail=str(exc)) return HTTPException(status_code=400, detail=str(exc)) diff --git a/app/interview/api/results.py b/app/interview/api/results.py new file mode 100644 index 0000000..5e180e7 --- /dev/null +++ b/app/interview/api/results.py @@ -0,0 +1,89 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Completed session results and section review pages.""" + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, RedirectResponse, Response + +from app.coding.services.review import CodingReviewService +from app.interview.services.results_page import SessionResultsPageService +from app.templating import templates +from app.theory.services.review import TheoryReviewService + +router = APIRouter(prefix="/interview", tags=["interview-results"]) + + +@router.get("/{interview_id}/results", response_class=HTMLResponse) +async def session_results_page( + request: Request, + interview_id: str, +) -> Response: + """Render the completed session results hub. + + Args: + request: FastAPI request object. + interview_id: Session UUID. + + Returns: + HTML response with session results, or redirect when unavailable. + """ + page = SessionResultsPageService.prepare_page(interview_id) + if page.redirect_url is not None: + return RedirectResponse(url=page.redirect_url, status_code=303) + return templates.TemplateResponse( + request, + "session_results.html", + page.template_context or {}, + ) + + +@router.get("/{interview_id}/theory", response_class=HTMLResponse) +async def theory_review_page( + request: Request, + interview_id: str, +) -> Response: + """Render the completed theory section review with chat history. + + Args: + request: FastAPI request object. + interview_id: Session UUID. + + Returns: + HTML response with theory review, or redirect when unavailable. + """ + context = TheoryReviewService.build_context(interview_id) + if context is None: + return RedirectResponse( + url=f"/interview/{interview_id}/results", status_code=303 + ) + return templates.TemplateResponse( + request, + "theory_review.html", + context.model_dump(), + ) + + +@router.get("/{interview_id}/coding", response_class=HTMLResponse) +async def coding_review_page( + request: Request, + interview_id: str, +) -> Response: + """Render the completed coding section review with per-task feedback. + + Args: + request: FastAPI request object. + interview_id: Session UUID. + + Returns: + HTML response with coding review, or redirect when unavailable. + """ + context = CodingReviewService.build_context(interview_id) + if context is None: + return RedirectResponse( + url=f"/interview/{interview_id}/results", status_code=303 + ) + return templates.TemplateResponse( + request, + "coding_review.html", + context.model_dump(), + ) diff --git a/app/interview/api/routes.py b/app/interview/api/routes.py index 23d7a42..b72047e 100644 --- a/app/interview/api/routes.py +++ b/app/interview/api/routes.py @@ -2,43 +2,16 @@ # SPDX-License-Identifier: Apache-2.0 """Interview session endpoints. -This module provides the interview page (HTTP GET) and a WebSocket -endpoint for real-time answers and completion. Business logic is -delegated to the service layer. +This module provides the interview page (HTTP GET). Business logic is +delegated to the service layer; theory transport lives under ``/theory/``. """ -import logging -from typing import Annotated, Any - -from fastapi import ( - APIRouter, - File, - Form, - HTTPException, - Request, - UploadFile, - WebSocket, - WebSocketDisconnect, -) -from fastapi.responses import ( - FileResponse, - HTMLResponse, - RedirectResponse, - Response, - StreamingResponse, -) +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response -from app.interview.api.audio_answer import InterviewAudioAnswerAdapter -from app.interview.api.deps import ( - AIProviderDep, - InterviewCompletionServiceDep, - InterviewQueryDep, - SpeechTranscriberDep, -) from app.interview.api.errors import http_exception_from_domain_error -from app.interview.api.ws_session import InterviewWebSocketService from app.interview.domain.exceptions import InterviewDomainError -from app.interview.services.page import InterviewPageService +from app.interview.services.page import SessionPageService from app.platform.api.deps import ConfigServiceDep from app.platform.services.speech_runtime import SpeechRuntimeCoordinator from app.question_voice.services.question_audio import get_question_audio_path @@ -51,24 +24,19 @@ router = APIRouter(prefix="/interview", tags=["interview"]) -logger = logging.getLogger(__name__) - -async def _safe_send_json(websocket: WebSocket, message: dict[str, Any]) -> bool: - """Send a JSON message, returning False if the client already disconnected. +def _interview_template_name(context: dict[str, object]) -> str: + """Pick the interview page template for the active session phase. Args: - websocket: Active interview WebSocket. - message: Payload to send. + context: Template context from ``SessionPageService``. Returns: - True if the message was sent, False if the socket is closed. + Template path for theory or coding-focused rendering. """ - try: - await websocket.send_json(message) - return True - except (WebSocketDisconnect, RuntimeError): - return False + if context.get("active_phase") == "coding": + return "coding_interview.html" + return "interview.html" @router.get("/{interview_id}", response_class=HTMLResponse) @@ -93,7 +61,7 @@ async def interview_page( HTML response with interview view, or redirect if not found. """ config = config_service.get_config() - page = await InterviewPageService.prepare_page( + page = await SessionPageService.prepare_page( interview_id, config=config, whisper_model_service=whisper_model_service, @@ -101,6 +69,13 @@ async def interview_page( if page.redirect_url is not None: return RedirectResponse(url=page.redirect_url, status_code=303) + context = page.template_context or {} + if context.get("interview", {}).get("status") == "completed": + return RedirectResponse( + url=f"/interview/{interview_id}/results", + status_code=303, + ) + await SpeechRuntimeCoordinator.preload_whisper_for_active_interview( request.app, config, @@ -108,8 +83,8 @@ async def interview_page( ) return templates.TemplateResponse( request, - "interview.html", - page.template_context or {}, + _interview_template_name(context), + context, ) @@ -142,109 +117,3 @@ async def question_audio( raise HTTPException(status_code=404, detail=str(exc)) from exc return FileResponse(path, media_type="audio/wav", filename="question.wav") - - -@router.post("/{interview_id}/audio-answer") -async def submit_audio_answer( - interview_id: str, - provider: AIProviderDep, - transcriber: SpeechTranscriberDep, - question_id: Annotated[str, Form()], - file: Annotated[UploadFile, File()], -) -> StreamingResponse: - """Submit a spoken answer and stream NDJSON evaluation events. - - Multipart form fields: - - ``question_id`` — question being answered - - ``file`` — canonical mono 16 kHz PCM WAV bytes - - Response lines use the same event shapes as the interview WebSocket - (``saved``, ``evaluating``, ``transcript``, ``feedback``, ``error``). - - Args: - interview_id: Interview session UUID. - provider: AI provider for multimodal evaluation. - transcriber: Loaded Whisper transcriber for audio transcription. - question_id: Question ID from the active answer row. - file: Uploaded WAV audio answer. - - Returns: - NDJSON stream of server events. - - Raises: - HTTPException: When required fields are missing or WAV validation fails. - """ - wav_bytes = await file.read() - try: - normalized_question_id = InterviewAudioAnswerAdapter.parse_submission( - question_id=question_id, - wav_bytes=wav_bytes, - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - return StreamingResponse( - InterviewAudioAnswerAdapter.stream_ndjson_lines( - interview_id=interview_id, - question_id=normalized_question_id, - wav_bytes=wav_bytes, - provider=provider, - transcriber=transcriber, - ), - media_type="application/x-ndjson", - ) - - -@router.websocket("/{interview_id}/ws") -async def interview_ws( - websocket: WebSocket, - interview_id: str, - interview_query: InterviewQueryDep, - interview_completion: InterviewCompletionServiceDep, - provider: AIProviderDep, -) -> None: - """WebSocket endpoint for real-time interview interaction. - - Protocol (JSON messages): - - **Client → Server:** - - ``{"type":"answer","question_id":"...","answer_text":"..."}`` - - ``{"type":"timeout","question_id":"...","round":N}`` - - ``{"type":"complete"}`` - - **Server → Client:** - - ``{"type":"saved"}`` — answer persisted - - ``{"type":"evaluating"}`` — AI is evaluating - - ``{"type":"feedback",...}`` — follow-up or next question navigation - - ``{"type":"interview_completed","overall_feedback":{...},"score":N}`` - - ``{"type":"error","message":"..."}`` - - Args: - websocket: The WebSocket connection. - interview_id: The session UUID. - interview_query: Interview read service. - interview_completion: Interview completion service. - provider: AI provider for answer and session evaluation. - """ - await websocket.accept() - - try: - while True: - try: - raw = await websocket.receive_json() - except RuntimeError: - break - - async for message in InterviewWebSocketService.iter_responses( - raw, - interview_id=interview_id, - provider=provider, - interview_completion=interview_completion, - interview_query=interview_query, - ): - if not await _safe_send_json(websocket, message): - break - except WebSocketDisconnect: - logger.debug("WebSocket disconnected for session %s", interview_id) - except RuntimeError: - logger.debug("WebSocket closed for session %s", interview_id) diff --git a/app/interview/api/setup.py b/app/interview/api/setup.py index 6de2009..d1a9032 100644 --- a/app/interview/api/setup.py +++ b/app/interview/api/setup.py @@ -6,17 +6,23 @@ and creating new interview sessions. """ +from dataclasses import replace + from fastapi import APIRouter, Form, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response -from app.interview.api.deps import InterviewCreationServiceDep +from app.coding.services.availability import ( + is_coding_available_async, +) +from app.interview.api.deps import SessionCreationServiceDep from app.interview.api.setup_form import setup_form_context from app.interview.services.rules.selection import ( - parse_selection_json, - validate_question_count, + parse_session_json, + validate_session_selection, ) from app.platform.api.deps import ConfigServiceDep -from app.questions import list_categories, list_levels, list_tracks +from app.shared import coding as coding_bank +from app.shared.questions import list_categories, list_levels, list_tracks from app.speech.api.deps import WhisperModelServiceDep from app.speech.services.page import SpeechModelPageService from app.templating import templates @@ -112,28 +118,77 @@ async def setup_options( return JSONResponse({"categories": sorted(list_categories(track, level))}) +@router.get("/coding-options") +async def setup_coding_options( + track: str | None = None, + level: str | None = None, +) -> JSONResponse: + """Return cascaded setup options for the coding task bank. + + Args: + track: When set, returns levels for that coding track. + level: When set with track, returns categories for that pair. + + Returns: + JSON with ``tracks``, ``levels``, or ``categories`` keys. + """ + if track is None: + return JSONResponse({"tracks": coding_bank.list_tracks()}) + + tracks = coding_bank.list_tracks() + if track not in tracks: + raise HTTPException(status_code=404, detail=f"Unknown coding track: {track}") + + if level is None: + return JSONResponse({"levels": coding_bank.list_levels(track)}) + + levels = coding_bank.list_levels(track) + if level not in levels: + raise HTTPException(status_code=404, detail=f"Unknown coding level: {level}") + + return JSONResponse( + {"categories": sorted(coding_bank.list_categories(track, level))} + ) + + +@router.get("/coding-available") +async def setup_coding_available() -> JSONResponse: + """Return whether coding sessions can be started from setup. + + Returns: + JSON with ``available`` boolean. + """ + return JSONResponse({"available": await is_coding_available_async()}) + + @router.post("", response_class=HTMLResponse) async def create_interview( request: Request, config_service: ConfigServiceDep, - interview_creation: InterviewCreationServiceDep, + session_creation: SessionCreationServiceDep, whisper_model_service: WhisperModelServiceDep, selection_json: str = Form(...), question_count: int = Form(5), + coding_question_count: int = Form(2), enable_question_timer: str | None = Form(None), question_time_minutes: int = Form(3), + enable_coding_timer: str | None = Form(None), + coding_time_minutes: int = Form(10), ) -> Response: """Create interview session from multi-track setup selection. Args: request: FastAPI request object. config_service: Provider configuration service. - interview_creation: Interview creation service. + session_creation: Session creation service. whisper_model_service: Whisper model download service. selection_json: JSON payload built by the setup form script. - question_count: Number of questions. - enable_question_timer: Present when the timer checkbox is checked. - question_time_minutes: Per-round limit in minutes when the timer is enabled. + question_count: Number of theory questions. + coding_question_count: Number of coding tasks. + enable_question_timer: Present when the theory timer checkbox is checked. + question_time_minutes: Per-round theory limit in minutes when enabled. + enable_coding_timer: Present when the coding timer checkbox is checked. + coding_time_minutes: Per-task coding limit in minutes when enabled. Returns: Redirect to interview session page, config, or back to setup on error. @@ -144,31 +199,52 @@ async def create_interview( if config is None: return _CONFIG_REDIRECT - timer_seconds: int | None = None + theory_timer_seconds: int | None = None if enable_question_timer: minutes = max(1, question_time_minutes) - timer_seconds = minutes * 60 + theory_timer_seconds = minutes * 60 + + coding_timer_seconds: int | None = None + if enable_coding_timer: + minutes = max(1, coding_time_minutes) + coding_timer_seconds = minutes * 60 - clamped_count = _clamp_question_count(question_count) + clamped_theory_count = _clamp_question_count(question_count) + clamped_coding_count = _clamp_question_count(coding_question_count) try: - selection = parse_selection_json(selection_json) - validate_question_count(selection, clamped_count) - interview = interview_creation.create_interview( - selection=selection, + session = parse_session_json(selection_json) + session = replace( + session, + theory=replace( + session.theory, + question_count=clamped_theory_count, + task_time_limit_seconds=theory_timer_seconds, + ), + coding=replace( + session.coding, + question_count=clamped_coding_count, + task_time_limit_seconds=coding_timer_seconds, + ), + ) + validate_session_selection(session) + interview = session_creation.create_session( + session, locale=config.locale, - question_count=clamped_count, - question_time_limit_seconds=timer_seconds, ) return RedirectResponse( url=f"/interview/{interview.id}", status_code=303, ) except ValueError as e: - min_count = _MIN_QUESTIONS + min_theory = _MIN_QUESTIONS + min_coding = _MIN_QUESTIONS try: - selection = parse_selection_json(selection_json) - min_count = max(_MIN_QUESTIONS, selection.topic_count) + parsed = parse_session_json(selection_json) + if parsed.theory.enabled: + min_theory = max(_MIN_QUESTIONS, parsed.theory_selection.topic_count) + if parsed.coding.enabled: + min_coding = max(_MIN_QUESTIONS, parsed.coding_selection.topic_count) except ValueError: pass return templates.TemplateResponse( @@ -178,7 +254,8 @@ async def create_interview( **setup_form_context( locale=config.locale, error=str(e), - min_question_count=min_count, + min_question_count=min_theory, + min_coding_task_count=min_coding, ), **SpeechModelPageService.build_page_context( config, diff --git a/app/interview/api/setup_form.py b/app/interview/api/setup_form.py index 7bdb178..b5bff0f 100644 --- a/app/interview/api/setup_form.py +++ b/app/interview/api/setup_form.py @@ -2,9 +2,48 @@ # SPDX-License-Identifier: Apache-2.0 """Setup form template context helpers.""" +from collections.abc import Callable + +from app.coding.services.availability import is_coding_available from app.interview.services.rules.selection import track_label -from app.questions import list_categories, list_levels, list_tracks +from app.shared import coding as coding_bank from app.shared.locales import SUPPORTED_LOCALES, normalize_locale +from app.shared.questions import list_categories, list_levels, list_tracks + + +def _build_track_sections( + tracks: list[str], + *, + list_levels_fn: Callable[[str], list[str]], + list_categories_fn: Callable[[str, str], list[str]], +) -> list[dict[str, object]]: + """Build setup track section metadata for one question bank. + + Args: + tracks: Track slugs from the bank. + list_levels_fn: Callable ``(track) -> levels``. + list_categories_fn: Callable ``(track, level) -> categories``. + + Returns: + Track section dicts for ``setup.html``. + """ + track_sections: list[dict[str, object]] = [] + for slug in tracks: + levels = list_levels_fn(slug) + default_level = levels[0] if levels else "" + categories = ( + sorted(list_categories_fn(slug, default_level)) if default_level else [] + ) + track_sections.append( + { + "slug": slug, + "label": track_label(slug), + "levels": levels, + "default_level": default_level, + "categories": categories, + } + ) + return track_sections def setup_form_context( @@ -12,52 +51,88 @@ def setup_form_context( locale: str, error: str | None = None, min_question_count: int = 1, + min_coding_task_count: int = 1, ) -> dict[str, object]: """Build template context for the multi-track setup form. Args: locale: Configured interview locale from provider config. error: Optional error message to display. - min_question_count: Minimum allowed question count (updated client-side). + min_question_count: Minimum allowed theory question count. + min_coding_task_count: Minimum allowed coding task count. Returns: Context dict for ``setup.html``. """ locale_code = normalize_locale(locale) locale_label = SUPPORTED_LOCALES[locale_code] + coding_available = is_coding_available() tracks = list_tracks() if not tracks: return { "tracks": [], "track_sections": [], + "coding_track_sections": [], + "session_modes": [], + "coding_available": coding_available, "locale": locale_code, "locale_label": locale_label, "error": error or "No question banks found.", "min_question_count": min_question_count, + "min_coding_task_count": min_coding_task_count, } - track_sections: list[dict[str, object]] = [] - for slug in tracks: - levels = list_levels(slug) - default_level = levels[0] if levels else "" - categories = ( - sorted(list_categories(slug, default_level)) if default_level else [] - ) - track_sections.append( - { - "slug": slug, - "label": track_label(slug), - "levels": levels, - "default_level": default_level, - "categories": categories, - } - ) + track_sections = _build_track_sections( + tracks, + list_levels_fn=list_levels, + list_categories_fn=list_categories, + ) + coding_tracks = coding_bank.list_tracks() + coding_track_sections = _build_track_sections( + coding_tracks, + list_levels_fn=coding_bank.list_levels, + list_categories_fn=coding_bank.list_categories, + ) + + session_modes = [ + { + "value": "theory_only", + "label": "Theory only", + "description": "Question-and-answer theory section", + "enabled": True, + }, + { + "value": "theory_then_coding", + "label": "Theory, then coding", + "description": "Theory section followed by coding challenges", + "enabled": coding_available, + "badge": None if coding_available else "Unavailable", + }, + { + "value": "coding_then_theory", + "label": "Coding, then theory", + "description": "Coding challenges followed by theory questions", + "enabled": coding_available, + "badge": None if coding_available else "Unavailable", + }, + { + "value": "coding_only", + "label": "Coding only", + "description": "Coding challenges without theory questions", + "enabled": coding_available, + "badge": None if coding_available else "Unavailable", + }, + ] return { "tracks": [(slug, track_label(slug)) for slug in tracks], "track_sections": track_sections, + "coding_track_sections": coding_track_sections, + "session_modes": session_modes, + "coding_available": coding_available, "locale": locale_code, "locale_label": locale_label, "error": error, "min_question_count": min_question_count, + "min_coding_task_count": min_coding_task_count, } diff --git a/app/interview/domain/__init__.py b/app/interview/domain/__init__.py index 79b7a87..3302341 100644 --- a/app/interview/domain/__init__.py +++ b/app/interview/domain/__init__.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 """Interview domain model: entities, value objects, and exceptions.""" -from app.interview.domain.entities import Answer, Interview +from app.interview.domain.entities import Interview from app.interview.domain.exceptions import ( AnswerNotFoundError, InterviewDomainError, @@ -16,12 +16,14 @@ InterviewSelection, InterviewSelectionHolder, PlannedQuestion, + SectionBranchSpec, + SessionMode, + SessionSelection, TrackQuestionPools, TrackSelection, ) __all__ = [ - "Answer", "AnswerNotFoundError", "Interview", "InterviewDomainError", @@ -30,6 +32,9 @@ "InterviewSelection", "InterviewSelectionHolder", "PlannedQuestion", + "SectionBranchSpec", + "SessionMode", + "SessionSelection", "QuestionTimerNotEnabledError", "QuestionTimerNotExpiredError", "TrackQuestionPools", diff --git a/app/interview/domain/entities.py b/app/interview/domain/entities.py index 6c7f590..9c26220 100644 --- a/app/interview/domain/entities.py +++ b/app/interview/domain/entities.py @@ -4,251 +4,74 @@ from __future__ import annotations -from collections import defaultdict from dataclasses import dataclass, replace -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from typing import Any, Literal -from app.interview.domain.exceptions import ( - AnswerNotFoundError, - InterviewNotActiveError, - UnansweredAnswerNotFoundError, -) -from app.interview.domain.value_objects import InterviewSelection, PlannedQuestion +from app.interview.domain.exceptions import InterviewNotActiveError +from app.interview.domain.value_objects import SessionMode, SessionSelection InterviewStatus = Literal["active", "completed"] -@dataclass(frozen=True, slots=True) -class Answer: - """One answer round within an interview session. - - Attributes: - id: Answer row primary key. - interview_id: Parent interview UUID. - question_id: YAML question ID. - order: Display order within the session (1-based). - round: Follow-up round number (0 = initial). - question_text: Question text shown to the user. - question_code: Optional code snippet for the question. - answer_text: User answer text, or None when unanswered. - score: AI score for the round, or None when not evaluated. - feedback: AI-generated feedback text, or None. - started_at: When the round timer started, or None. - created_at: When this answer row was created. - """ - - TIME_EXPIRED_ANSWER_TEXT = "[Time expired]" - TIMEOUT_GRACE_SECONDS = 2 - NEW_ID = 0 - - id: int - interview_id: str - question_id: str - order: int - round: int - question_text: str - question_code: str | None - answer_text: str | None - score: int | None - feedback: str | None - started_at: datetime | None - created_at: datetime - - def timer_deadline(self, limit_seconds: int) -> datetime: - """Compute the absolute deadline for this timed answer round. - - Args: - limit_seconds: Allowed duration in seconds. - - Returns: - Timezone-aware deadline timestamp. - - Raises: - ValueError: If the round has no ``started_at`` timestamp. - """ - if self.started_at is None: - raise ValueError("Answer round has no started_at") - started_at = self.started_at - if started_at.tzinfo is None: - started_at = started_at.replace(tzinfo=UTC) - return started_at + timedelta(seconds=limit_seconds) - - def is_timer_expired( - self, - limit_seconds: int | None, - now: datetime | None = None, - *, - grace_seconds: int = TIMEOUT_GRACE_SECONDS, - ) -> bool: - """Return whether the per-round timer has elapsed. - - Args: - limit_seconds: Configured limit for the session (None disables timer). - now: Current time (defaults to UTC now). - grace_seconds: Extra seconds allowed for network delay on timeout submit. - - Returns: - True if the timer is enabled and the deadline plus grace has passed. - """ - if limit_seconds is None or self.started_at is None: - return False - if now is None: - now = datetime.now(UTC) - if now.tzinfo is None: - now = now.replace(tzinfo=UTC) - return now >= self.timer_deadline(limit_seconds) + timedelta( - seconds=grace_seconds - ) - - def remaining_seconds( - self, - limit_seconds: int | None, - now: datetime | None = None, - ) -> int | None: - """Return whole seconds left on the timer, or None if disabled. - - Args: - limit_seconds: Configured limit for the session. - now: Current time (defaults to UTC now). - - Returns: - Non-negative seconds remaining, or None when the timer is off. - """ - if limit_seconds is None or self.started_at is None: - return None - if now is None: - now = datetime.now(UTC) - if now.tzinfo is None: - now = now.replace(tzinfo=UTC) - end = self.timer_deadline(limit_seconds) - delta = (end - now).total_seconds() - return max(0, int(delta)) - - def client_timeout_due( - self, - limit_seconds: int | None, - now: datetime | None = None, - ) -> bool: - """Return whether a client-sent timeout should be accepted. - - Args: - limit_seconds: Configured limit for the session. - now: Current time (defaults to UTC now). - - Returns: - True when the round timer has effectively expired for the client. - """ - if limit_seconds is None or self.started_at is None: - return False - rem = self.remaining_seconds(limit_seconds, now) - return self.is_timer_expired(limit_seconds, now, grace_seconds=0) or ( - rem is not None and rem <= 0 - ) - - @dataclass(frozen=True, slots=True) class Interview: - """Interview session aggregate root. + """Interview session shell aggregate root. Attributes: id: Interview UUID. locale: Language code for feedback and voice. - selection: Parsed question-bank selection. - question_count: Number of questions in this session. - question_ids: Question IDs in display order. - question_time_limit_seconds: Per-round time limit, or None when disabled. + session_mode: Session mode (theory-only or multi-section order). + selection: Parsed session selection (theory and coding branches). status: Session status (``active`` or ``completed``). - score: Final session score when completed. overall_feedback: Parsed overall evaluation payload when completed. started_at: When the session began. completed_at: When the session ended, or None while active. - answers: Answer rounds in display order (order, then round). """ - MAX_SCORE_PER_ROUND = 5 - id: str locale: str - selection: InterviewSelection - question_count: int - question_ids: tuple[str, ...] - question_time_limit_seconds: int | None + session_mode: SessionMode + selection: SessionSelection status: InterviewStatus - score: int | None overall_feedback: dict[str, Any] | None started_at: datetime completed_at: datetime | None - answers: tuple[Answer, ...] @classmethod - def start( + def start_shell( cls, interview_id: str, *, - selection: InterviewSelection, + selection: SessionSelection, locale: str, - planned_questions: tuple[PlannedQuestion, ...], - question_time_limit_seconds: int | None = None, started_at: datetime | None = None, ) -> Interview: - """Build a new active interview aggregate from a question plan. + """Build a new active interview shell without section tasks. Args: interview_id: New session UUID. - selection: Track/level/topic selection from setup. + selection: Full session selection from setup. locale: Locale for AI feedback and follow-ups. - planned_questions: Ordered questions for this session (non-empty). - question_time_limit_seconds: Per-round time limit, or None to disable. started_at: Session start time (defaults to UTC now). Returns: - Active aggregate with initial answer rows (``Answer.NEW_ID``). - - Raises: - ValueError: If ``planned_questions`` is empty. + Active shell aggregate. """ - if not planned_questions: - raise ValueError("No questions found for the selected topics") - when = started_at or datetime.now(UTC) - question_ids = tuple(question.id for question in planned_questions) - timer_start = when if question_time_limit_seconds is not None else None - answers: list[Answer] = [] - for order, question in enumerate(planned_questions, start=1): - answers.append( - Answer( - id=Answer.NEW_ID, - interview_id=interview_id, - question_id=question.id, - order=order, - round=0, - question_text=question.text, - question_code=question.code, - answer_text=None, - score=None, - feedback=None, - started_at=timer_start if order == 1 else None, - created_at=when, - ) - ) return cls( id=interview_id, locale=locale, + session_mode=selection.session_mode, selection=selection, - question_count=len(planned_questions), - question_ids=question_ids, - question_time_limit_seconds=question_time_limit_seconds, status="active", - score=None, overall_feedback=None, started_at=when, completed_at=None, - answers=tuple(answers), ) def ensure_active(self) -> None: - """Ensure this interview accepts new answers. + """Ensure this interview accepts new section activity. Raises: InterviewNotActiveError: If the interview is not in ``active`` status. @@ -256,244 +79,25 @@ def ensure_active(self) -> None: if self.status != "active": raise InterviewNotActiveError(self.id) - def start_timer_for_answer( - self, answer_id: int, when: datetime | None = None - ) -> Interview: - """Start the per-round timer on an answer when the session has a limit. - - Args: - answer_id: Primary key of the answer row to activate. - when: Timestamp to set (defaults to UTC now). - - Returns: - A new aggregate with ``started_at`` set on the target answer when applicable. - """ - if self.question_time_limit_seconds is None: - return self - started_at = when or datetime.now(UTC) - answers = tuple( - replace(answer, started_at=started_at) - if answer.id == answer_id and answer.started_at is None - else answer - for answer in self.answers - ) - return replace(self, answers=answers) - - def with_answer_text(self, answer_id: int, text: str) -> Interview: - """Return aggregate with user answer text on the given row. - - Args: - answer_id: Primary key of the answer row to update. - text: User answer text (maybe empty before transcription). - - Returns: - A new aggregate with ``answer_text`` set on the target answer. - """ - answers = tuple( - replace(answer, answer_text=text) if answer.id == answer_id else answer - for answer in self.answers - ) - return replace(self, answers=answers) - - def with_timed_out_round(self, answer_id: int, feedback: str) -> Interview: - """Return aggregate with a timed-out round scored zero. - - Args: - answer_id: Primary key of the answer row that expired. - feedback: User-facing timeout feedback text. - - Returns: - A new aggregate with timeout marker text, score 0, and feedback. - """ - answers = tuple( - replace( - answer, - answer_text=Answer.TIME_EXPIRED_ANSWER_TEXT, - score=0, - feedback=feedback, - ) - if answer.id == answer_id - else answer - for answer in self.answers - ) - return replace(self, answers=answers) - - def with_evaluation( - self, question_id: str, round_num: int, score: int, feedback: str - ) -> Interview: - """Return aggregate with AI score and feedback on one answer round. - - Args: - question_id: YAML question ID. - round_num: Follow-up round (0 = initial). - score: AI score for the round. - feedback: AI feedback text. - - Returns: - A new aggregate with evaluation fields set on the target answer. - """ - target = self.find_answer(question_id, round_num) - answers = tuple( - replace(answer, score=score, feedback=feedback) - if answer.id == target.id - else answer - for answer in self.answers - ) - return replace(self, answers=answers) - - def max_round_for_question(self, question_id: str) -> int: - """Return the highest follow-up round number for a question. - - Args: - question_id: YAML question ID. - - Returns: - Maximum ``round`` value among answers for the question, or 0 when none exist. - """ - rounds = [ - answer.round for answer in self.answers if answer.question_id == question_id - ] - return max(rounds) if rounds else 0 - - def with_follow_up( - self, question_id: str, question_text: str - ) -> tuple[Interview, Answer]: - """Return aggregate with a new unanswered follow-up answer row. - - Args: - question_id: YAML question ID for the follow-up chain. - question_text: Follow-up question text shown to the user. - - Returns: - Tuple of updated aggregate and the pending follow-up answer (``id`` is ``NEW_ID``). - """ - base = self.find_answer(question_id, 0) - next_round = self.max_round_for_question(question_id) + 1 - created_at = datetime.now(UTC) - follow_up = Answer( - id=Answer.NEW_ID, - interview_id=self.id, - question_id=question_id, - order=base.order, - round=next_round, - question_text=question_text, - question_code=base.question_code, - answer_text=None, - score=None, - feedback=None, - started_at=None, - created_at=created_at, - ) - return replace(self, answers=self.answers + (follow_up,)), follow_up - - def find_first_unanswered(self) -> Answer | None: - """Return the first unanswered answer in display order. - - Returns: - The first answer with no ``answer_text``, or None. - """ - for answer in self.answers: - if answer.answer_text is None: - return answer - return None - - def find_unanswered_for_question(self, question_id: str) -> Answer: - """Return the unanswered answer row for a question (any follow-up round). - - Args: - question_id: YAML question ID. - - Returns: - The first unanswered answer for that question. - - Raises: - UnansweredAnswerNotFoundError: If no unanswered answer exists for the question. - """ - for answer in self.answers: - if answer.question_id == question_id and answer.answer_text is None: - return answer - raise UnansweredAnswerNotFoundError(self.id, question_id) - - def find_answer(self, question_id: str, round_num: int) -> Answer: - """Return the answer row for a question and follow-up round. - - Args: - question_id: YAML question ID. - round_num: Follow-up round (0 = initial). - - Returns: - The matching answer row. - - Raises: - AnswerNotFoundError: If no row matches the keys. - """ - for answer in self.answers: - if answer.question_id == question_id and answer.round == round_num: - return answer - raise AnswerNotFoundError(self.id, question_id, round_num) - - def find_next_unanswered_after(self, current_index: int) -> Answer | None: - """Return the next unanswered answer after a position in the answer list. - - Args: - current_index: Index of the current answer in ``answers``. - - Returns: - The next unanswered answer, or None if none remain. - """ - for answer in self.answers[current_index + 1 :]: - if answer.answer_text is None: - return answer - return None - - def total_score(self) -> int: - """Sum scores from all answered rounds in this session. - - Returns: - Total score, or 0 if no scored answers exist. - """ - scores = [answer.score for answer in self.answers if answer.score is not None] - return sum(scores) if scores else 0 - - def per_question_score_breakdown(self) -> dict[str, Any]: - """Aggregate earned and maximum scores per question from persisted answers. - - Returns: - Mapping ``question_id`` → ``{"score": int, "max": int}`` for questions - with at least one answered round. - """ - rounds_by_question: defaultdict[str, list[Answer]] = defaultdict(list) - for answer in self.answers: - if answer.answer_text is not None: - rounds_by_question[answer.question_id].append(answer) - - breakdown: dict[str, Any] = {} - for question_id, rounds in rounds_by_question.items(): - earned = sum((r.score or 0) for r in rounds) - maximum = self.MAX_SCORE_PER_ROUND * len(rounds) - breakdown[question_id] = {"score": earned, "max": maximum} - return breakdown - def with_session_completed( self, overall_feedback: dict[str, Any], *, completed_at: datetime | None = None, ) -> Interview: - """Return aggregate marked completed with final evaluation payload. + """Return shell marked completed with final evaluation payload. Args: overall_feedback: Parsed overall evaluation dict for persistence. completed_at: Session end time (defaults to UTC now). Returns: - A new aggregate with ``status`` completed, total score, and feedback set. + A new shell with ``status`` completed and feedback set. """ when = completed_at or datetime.now(UTC) return replace( self, status="completed", - score=self.total_score(), overall_feedback=overall_feedback, completed_at=when, ) diff --git a/app/interview/domain/serialization.py b/app/interview/domain/serialization.py index 6405253..cdba8b8 100644 --- a/app/interview/domain/serialization.py +++ b/app/interview/domain/serialization.py @@ -9,28 +9,87 @@ from app.interview.domain.value_objects import ( InterviewSelection, + SectionBranchSpec, + SessionMode, + SessionSelection, TrackSelection, ) +SESSION_SPEC_VERSION = 2 + +_SESSION_MODES: frozenset[str] = frozenset( + { + "theory_only", + "coding_only", + "theory_then_coding", + "coding_then_theory", + } +) + + +def _sources_to_payload(sources: tuple[TrackSelection, ...]) -> list[dict[str, object]]: + """Serialize track selections for JSON persistence. + + Args: + sources: Ordered track selections. + + Returns: + List of JSON-compatible source dicts. + """ + return [ + { + "track": source.track, + "level": source.level, + "categories": list(source.categories), + } + for source in sources + ] + + +def _branch_to_payload(branch: SectionBranchSpec) -> dict[str, object]: + """Serialize one section branch for JSON persistence. + + Args: + branch: Theory or coding branch configuration. + + Returns: + JSON-compatible branch dict. + """ + return { + "enabled": branch.enabled, + "question_count": branch.question_count, + "task_time_limit_seconds": branch.task_time_limit_seconds, + "sources": _sources_to_payload(branch.sources), + } + def selection_to_spec(selection: InterviewSelection) -> str: - """Serialize selection to JSON for ``Interview.selection_spec``. + """Serialize theory sources to JSON for ``theory_sections.selection_spec``. Args: - selection: Interview selection. + selection: Theory-only interview selection. Returns: JSON string with a ``sources`` list. """ + payload = {"sources": _sources_to_payload(selection.sources)} + return json.dumps(payload, separators=(",", ":")) + + +def session_to_spec(session: SessionSelection) -> str: + """Serialize a session selection to JSON for ``Interview.selection_spec``. + + Args: + session: Full session selection including mode and branches. + + Returns: + JSON string in selection_spec v2 format. + """ payload = { - "sources": [ - { - "track": source.track, - "level": source.level, - "categories": list(source.categories), - } - for source in selection.sources - ], + "version": SESSION_SPEC_VERSION, + "session_mode": session.session_mode, + "theory": _branch_to_payload(session.theory), + "coding": _branch_to_payload(session.coding), } return json.dumps(payload, separators=(",", ":")) @@ -66,20 +125,183 @@ def selection_from_payload(data: dict[str, Any]) -> InterviewSelection: TrackSelection( track=track, level=level, - categories=tuple(str(c) for c in categories), + categories=tuple(str(category) for category in categories), ) ) return InterviewSelection(sources=tuple(sources)) +def _parse_branch_payload( + data: dict[str, Any], + *, + branch_name: str, + default_enabled: bool, +) -> SectionBranchSpec: + """Parse one section branch from a v2 selection payload. + + Args: + data: Branch object from JSON. + branch_name: Branch key for error messages. + default_enabled: Enabled flag when omitted from JSON. + + Returns: + Parsed section branch spec. + + Raises: + ValueError: If branch payload shape is invalid. + """ + if not isinstance(data, dict): + raise ValueError(f"Invalid selection_spec: {branch_name} must be an object") + + enabled = data.get("enabled", default_enabled) + if not isinstance(enabled, bool): + raise ValueError( + f"Invalid selection_spec: {branch_name}.enabled must be boolean" + ) + + question_count = data.get("question_count", 0) + if not isinstance(question_count, int): + raise ValueError( + f"Invalid selection_spec: {branch_name}.question_count must be integer" + ) + + timer = data.get("task_time_limit_seconds") + if timer is not None and not isinstance(timer, int): + raise ValueError( + f"Invalid selection_spec: {branch_name}.task_time_limit_seconds invalid" + ) + + sources_payload = data.get("sources", []) + if not isinstance(sources_payload, list): + raise ValueError( + f"Invalid selection_spec: {branch_name}.sources must be a list" + ) + + if sources_payload: + sources = selection_from_payload({"sources": sources_payload}).sources + else: + sources = () + + return SectionBranchSpec( + enabled=enabled, + question_count=question_count, + task_time_limit_seconds=timer, + sources=sources, + ) + + +def _branch_enabled_for_mode(mode: SessionMode, branch: str) -> bool: + """Derive whether a branch is enabled from the session mode. + + Args: + mode: Session mode from setup. + branch: ``"theory"`` or ``"coding"``. + + Returns: + True when the branch participates in the session mode. + """ + if mode == "theory_only": + return branch == "theory" + if mode == "coding_only": + return branch == "coding" + return True + + +def _normalize_session_selection(session: SessionSelection) -> SessionSelection: + """Align branch ``enabled`` flags with ``session_mode``. + + Args: + session: Parsed session selection. + + Returns: + Session selection with consistent enabled flags. + """ + theory_enabled = _branch_enabled_for_mode(session.session_mode, "theory") + coding_enabled = _branch_enabled_for_mode(session.session_mode, "coding") + if ( + session.theory.enabled == theory_enabled + and session.coding.enabled == coding_enabled + ): + return session + return SessionSelection( + session_mode=session.session_mode, + theory=SectionBranchSpec( + enabled=theory_enabled, + question_count=session.theory.question_count, + task_time_limit_seconds=session.theory.task_time_limit_seconds, + sources=session.theory.sources, + ), + coding=SectionBranchSpec( + enabled=coding_enabled, + question_count=session.coding.question_count, + task_time_limit_seconds=session.coding.task_time_limit_seconds, + sources=session.coding.sources, + ), + ) + + +def session_from_payload( + data: dict[str, Any], + *, + question_count: int = 5, + task_time_limit_seconds: int | None = None, +) -> SessionSelection: + """Build ``SessionSelection`` from a JSON-compatible dict. + + Supports v2 payloads and legacy v1 ``{sources: [...]}`` rows. + + Args: + data: Parsed selection_spec JSON object. + question_count: Fallback theory question count for legacy v1 rows. + task_time_limit_seconds: Fallback timer for legacy v1 rows. + + Returns: + Normalized session selection. + + Raises: + ValueError: If payload shape is invalid. + """ + if data.get("version") == SESSION_SPEC_VERSION or ( + "session_mode" in data and "theory" in data + ): + session_mode = data.get("session_mode") + if not isinstance(session_mode, str) or session_mode not in _SESSION_MODES: + raise ValueError("Invalid selection_spec: session_mode required") + theory_raw = data.get("theory") + coding_raw = data.get("coding") + if not isinstance(theory_raw, dict) or not isinstance(coding_raw, dict): + raise ValueError("Invalid selection_spec: theory and coding required") + session = SessionSelection( + session_mode=session_mode, # type: ignore[arg-type] + theory=_parse_branch_payload( + theory_raw, + branch_name="theory", + default_enabled=_branch_enabled_for_mode(session_mode, "theory"), # type: ignore[arg-type] + ), + coding=_parse_branch_payload( + coding_raw, + branch_name="coding", + default_enabled=_branch_enabled_for_mode(session_mode, "coding"), # type: ignore[arg-type] + ), + ) + return _normalize_session_selection(session) + + interview_selection = selection_from_payload(data) + return SessionSelection.theory_only( + sources=interview_selection.sources, + question_count=question_count, + task_time_limit_seconds=task_time_limit_seconds, + ) + + def parse_selection_spec(raw: str) -> InterviewSelection: - """Parse ``selection_spec`` JSON from the database. + """Parse theory sources from ``selection_spec`` JSON. Args: - raw: JSON string stored on ``Interview.selection_spec``. + raw: JSON string stored on a theory section or legacy interview row. Returns: - Parsed selection. + Parsed theory-only selection. Raises: ValueError: If ``raw`` is empty or invalid. @@ -89,9 +311,70 @@ def parse_selection_spec(raw: str) -> InterviewSelection: data = json.loads(raw) if not isinstance(data, dict): raise ValueError("selection_spec must be a JSON object") + if data.get("version") == SESSION_SPEC_VERSION or ( + "session_mode" in data and "theory" in data + ): + session = session_from_payload(data) + return session.theory_selection return selection_from_payload(data) +def parse_coding_selection_spec(raw: str) -> InterviewSelection: + """Parse coding sources from a section ``selection_spec`` JSON. + + Args: + raw: JSON string stored on a coding section row. + + Returns: + Parsed coding branch selection. + + Raises: + ValueError: If ``raw`` is empty or invalid. + """ + if not raw: + raise ValueError("selection_spec is empty") + data = json.loads(raw) + if not isinstance(data, dict): + raise ValueError("selection_spec must be a JSON object") + if data.get("version") == SESSION_SPEC_VERSION or ( + "session_mode" in data and "coding" in data + ): + session = session_from_payload(data) + return session.coding_selection + return selection_from_payload(data) + + +def parse_session_spec( + raw: str, + *, + question_count: int = 5, + task_time_limit_seconds: int | None = None, +) -> SessionSelection: + """Parse full session selection from ``selection_spec`` JSON. + + Args: + raw: JSON string stored on ``Interview.selection_spec``. + question_count: Fallback theory question count for legacy v1 rows. + task_time_limit_seconds: Fallback timer for legacy v1 rows. + + Returns: + Parsed session selection. + + Raises: + ValueError: If ``raw`` is empty or invalid. + """ + if not raw: + raise ValueError("selection_spec is empty") + data = json.loads(raw) + if not isinstance(data, dict): + raise ValueError("selection_spec must be a JSON object") + return session_from_payload( + data, + question_count=question_count, + task_time_limit_seconds=task_time_limit_seconds, + ) + + def parse_overall_feedback(raw: str | None) -> dict[str, Any] | None: """Parse ``overall_feedback`` JSON from the database. diff --git a/app/interview/domain/value_objects.py b/app/interview/domain/value_objects.py index 91d4645..99fb3a2 100644 --- a/app/interview/domain/value_objects.py +++ b/app/interview/domain/value_objects.py @@ -5,7 +5,40 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Protocol +from typing import Literal, Protocol + +SessionMode = Literal[ + "theory_only", + "coding_only", + "theory_then_coding", + "coding_then_theory", +] + +SESSION_MODE_LABELS: dict[SessionMode, str] = { + "theory_only": "Theory", + "coding_only": "Coding", + "theory_then_coding": "Theory → Coding", + "coding_then_theory": "Coding → Theory", +} + +SESSION_MODE_BADGE_LABELS: dict[SessionMode, str] = { + "theory_only": "Theory", + "coding_only": "Coding", + "theory_then_coding": "Theory+Coding", + "coding_then_theory": "Theory+Coding", +} + + +def session_mode_label(mode: SessionMode) -> str: + """Return a short dashboard badge label for a session mode. + + Args: + mode: Session mode from setup or persistence. + + Returns: + Compact human-readable mode badge text. + """ + return SESSION_MODE_BADGE_LABELS.get(mode, "Theory") @dataclass(frozen=True, slots=True) @@ -45,6 +78,87 @@ class TrackSelection: categories: tuple[str, ...] +@dataclass(frozen=True, slots=True) +class SectionBranchSpec: + """Configuration for one session section branch (theory or coding). + + Attributes: + enabled: Whether this branch is active for the session mode. + question_count: Number of questions or tasks in the branch. + task_time_limit_seconds: Per-round time limit, or None when disabled. + sources: Question-bank track selections for this branch. + """ + + enabled: bool + question_count: int + task_time_limit_seconds: int | None + sources: tuple[TrackSelection, ...] + + @property + def topic_count(self) -> int: + """Return total number of selected categories across all sources.""" + return sum(len(source.categories) for source in self.sources) + + +@dataclass(frozen=True, slots=True) +class SessionSelection: + """Full session setup including mode and per-section configuration. + + Attributes: + session_mode: How theory and coding sections are ordered and enabled. + theory: Theory section branch configuration. + coding: Coding section branch configuration (stub until coding plan). + """ + + session_mode: SessionMode + theory: SectionBranchSpec + coding: SectionBranchSpec + + @classmethod + def theory_only( + cls, + *, + sources: tuple[TrackSelection, ...], + question_count: int = 5, + task_time_limit_seconds: int | None = None, + ) -> SessionSelection: + """Build a theory-only session selection. + + Args: + sources: Track/level/topic selections for theory. + question_count: Number of theory questions. + task_time_limit_seconds: Per-round time limit, or None to disable. + + Returns: + Session selection with coding disabled. + """ + return cls( + session_mode="theory_only", + theory=SectionBranchSpec( + enabled=True, + question_count=question_count, + task_time_limit_seconds=task_time_limit_seconds, + sources=sources, + ), + coding=SectionBranchSpec( + enabled=False, + question_count=0, + task_time_limit_seconds=None, + sources=(), + ), + ) + + @property + def theory_selection(self) -> InterviewSelection: + """Return theory sources as a legacy interview selection.""" + return InterviewSelection(sources=self.theory.sources) + + @property + def coding_selection(self) -> InterviewSelection: + """Return coding sources as an interview selection.""" + return InterviewSelection(sources=self.coding.sources) + + @dataclass(frozen=True, slots=True) class InterviewSelection: """Full interview question-bank selection (one or more tracks). diff --git a/app/interview/repositories/interview.py b/app/interview/repositories/interview.py index 80e1acd..3c22f38 100644 --- a/app/interview/repositories/interview.py +++ b/app/interview/repositories/interview.py @@ -2,28 +2,28 @@ # SPDX-License-Identifier: Apache-2.0 """Interview repository. -Provides data access for ``Interview`` records, including -eager-loading of related answers and lookups by ID. +Provides data access for interview session shell rows. """ from sqlalchemy import func from sqlalchemy.orm import Session, selectinload -from app.interview.domain.entities import Answer as DomainAnswer +from app.coding.repositories.coding_section import CodingSectionRepository from app.interview.domain.entities import Interview as DomainInterview from app.interview.domain.exceptions import InterviewNotFoundError from app.interview.repositories.mappers import ( - domain_answer_to_orm, interview_from_orm, - interview_to_orm, + interview_read_from_orm, + interview_shell_to_orm, interview_to_orm_fields, ) -from app.shared.infrastructure.models import Interview +from app.interview.schemas.interview import InterviewRead +from app.shared.infrastructure.models import Interview, TheorySection from app.shared.repositories.base import SqlAlchemyRepository class InterviewRepository(SqlAlchemyRepository[Interview]): - """Repository for ``Interview`` entities. + """Repository for ``Interview`` shell entities. Attributes: _session: Active SQLAlchemy Session (inherited). @@ -40,62 +40,79 @@ def __init__(self, session: Session) -> None: super().__init__(session) def get(self, entity_id: str) -> Interview | None: - """Retrieve a session by ID with eagerly loaded answers. + """Retrieve a session shell by ID with theory section loaded. Args: entity_id: The session UUID. Returns: - Interview with answers loaded, or None. + Interview with theory section and tasks loaded, or None. """ return ( self._session.query(Interview) - .options(selectinload(Interview.answers)) + .options( + selectinload(Interview.theory_section).selectinload( + TheorySection.tasks + ), + ) .filter_by(id=entity_id) .first() ) def get_aggregate(self, entity_id: str) -> DomainInterview | None: - """Load a domain interview aggregate with answers. + """Load a domain interview shell aggregate. Args: entity_id: The session UUID. Returns: - Domain aggregate, or None when the session does not exist. + Domain shell aggregate, or None when the session does not exist. """ orm_interview = self.get(entity_id) if orm_interview is None: return None return interview_from_orm(orm_interview) - def create_aggregate(self, interview: DomainInterview) -> DomainInterview: - """Insert a new interview aggregate and return it with assigned answer IDs. + def get_read_model(self, entity_id: str) -> InterviewRead | None: + """Load a composed interview read model with theory tasks. Args: - interview: Domain aggregate from ``Interview.start``. + entity_id: The session UUID. Returns: - Reloaded domain aggregate after flush. + Interview read model, or None when the session does not exist. """ - orm_interview = interview_to_orm(interview) + orm_interview = self.get(entity_id) + if orm_interview is None: + return None + coding = CodingSectionRepository(self._session).get_aggregate(entity_id) + return interview_read_from_orm(orm_interview, coding=coding) + + def create_shell(self, interview: DomainInterview) -> DomainInterview: + """Insert a new interview shell row. + + Args: + interview: Domain shell from ``Interview.start_shell``. + + Returns: + Reloaded domain shell after flush. + + Raises: + InterviewNotFoundError: If reload fails after flush. + """ + orm_interview = interview_shell_to_orm(interview) self._session.add(orm_interview) self._session.flush() - self._session.refresh(orm_interview) reloaded = self.get(interview.id) if reloaded is None: raise InterviewNotFoundError(interview.id) return interview_from_orm(reloaded) def save_aggregate(self, interview: DomainInterview) -> None: - """Persist mutable fields from a domain aggregate onto ORM rows. - - Updates interview scalars and answer fields that may change during - navigation and answer submission (``answer_text``, ``score``, - ``feedback``, ``started_at``). + """Persist mutable shell fields from a domain aggregate onto the ORM row. Args: - interview: Domain aggregate previously loaded from this repository. + interview: Domain shell previously loaded from this repository. Raises: InterviewNotFoundError: If the session row no longer exists. @@ -107,21 +124,8 @@ def save_aggregate(self, interview: DomainInterview) -> None: for field, value in interview_to_orm_fields(interview).items(): setattr(orm_interview, field, value) - orm_answers_by_id = {answer.id: answer for answer in orm_interview.answers} - for domain_answer in interview.answers: - if domain_answer.id == DomainAnswer.NEW_ID: - orm_interview.answers.append(domain_answer_to_orm(domain_answer)) - continue - orm_answer = orm_answers_by_id.get(domain_answer.id) - if orm_answer is None: - continue - orm_answer.answer_text = domain_answer.answer_text - orm_answer.score = domain_answer.score - orm_answer.feedback = domain_answer.feedback - orm_answer.started_at = domain_answer.started_at - - def list_recent(self, limit: int = 20) -> list[DomainInterview]: - """Return recent domain aggregates (active and completed), newest first. + def list_recent_read_models(self, limit: int = 20) -> list[InterviewRead]: + """Return recent interview read models, newest first. Sort key is ``completed_at`` when set, otherwise ``started_at``. @@ -129,14 +133,25 @@ def list_recent(self, limit: int = 20) -> list[DomainInterview]: limit: Maximum number of rows to return. Returns: - Domain interviews with answers loaded. + Composed interview read models with theory tasks when present. """ sort_key = func.coalesce(Interview.completed_at, Interview.started_at) orm_rows = ( self._session.query(Interview) - .options(selectinload(Interview.answers)) + .options( + selectinload(Interview.theory_section).selectinload( + TheorySection.tasks + ), + ) .order_by(sort_key.desc()) .limit(limit) .all() ) - return [interview_from_orm(row) for row in orm_rows] + coding_repo = CodingSectionRepository(self._session) + return [ + interview_read_from_orm( + row, + coding=coding_repo.get_aggregate(row.id), + ) + for row in orm_rows + ] diff --git a/app/interview/repositories/mappers.py b/app/interview/repositories/mappers.py index 0fb75f7..2ab5dc6 100644 --- a/app/interview/repositories/mappers.py +++ b/app/interview/repositories/mappers.py @@ -8,39 +8,29 @@ import json from typing import Any -from app.interview.domain.entities import Answer as DomainAnswer +from app.coding.domain.entities import CodingSection as DomainCodingSection from app.interview.domain.entities import Interview as DomainInterview from app.interview.domain.entities import InterviewStatus from app.interview.domain.serialization import ( parse_overall_feedback, - parse_selection_spec, - selection_to_spec, + parse_session_spec, + session_to_spec, +) +from app.interview.schemas.interview import InterviewRead +from app.interview.services.scoring import ( + completed_score_fallback, + score_from_overall_feedback, ) -from app.interview.schemas.interview import AnswerRead, InterviewRead -from app.shared.infrastructure.models import Answer as OrmAnswer from app.shared.infrastructure.models import Interview as OrmInterview +from app.theory.domain.entities import TheorySection as DomainTheorySection +from app.theory.repositories.mappers import ( + theory_section_from_orm, + theory_task_read_from_domain, +) _EPOCH = datetime.min.replace(tzinfo=UTC) -def _question_ids_from_json(raw: str) -> tuple[str, ...]: - """Parse ``question_ids`` JSON into an ordered tuple. - - Args: - raw: JSON array string from persistence. - - Returns: - Question IDs in display order. - """ - try: - parsed = json.loads(raw or "[]") - except json.JSONDecodeError: - return () - if not isinstance(parsed, list): - return () - return tuple(str(item) for item in parsed) - - def _question_ids_to_json(question_ids: tuple[str, ...]) -> str: """Serialize question IDs for persistence. @@ -53,233 +43,192 @@ def _question_ids_to_json(question_ids: tuple[str, ...]) -> str: return json.dumps(list(question_ids), separators=(",", ":")) -def domain_answer_to_orm(answer: DomainAnswer) -> OrmAnswer: - """Map a domain answer to a new ORM row for insert. +def _resolve_completed_score( + shell: DomainInterview, + theory: DomainTheorySection | None, + coding: DomainCodingSection | None, +) -> int | None: + """Resolve display score for a completed session read model. Args: - answer: Domain answer (typically ``id`` is ``Answer.NEW_ID``). + shell: Interview shell aggregate. + theory: Theory section aggregate, if present. + coding: Coding section aggregate, if present. Returns: - Detached ORM Answer ready to be added to a session. + Display score from feedback or section totals, or None while active. """ - return OrmAnswer( - interview_id=answer.interview_id, - question_id=answer.question_id, - order=answer.order, - round=answer.round, - question_text=answer.question_text, - question_code=answer.question_code, - answer_text=answer.answer_text, - score=answer.score, - feedback=answer.feedback, - started_at=answer.started_at, - created_at=answer.created_at, - ) + if shell.status != "completed": + return None + score = score_from_overall_feedback(shell.overall_feedback) + if score is not None: + return score + return completed_score_fallback(shell, theory, coding) -def answer_from_orm(answer: OrmAnswer) -> DomainAnswer: - """Map an ORM answer row to a domain answer. +def interview_shell_from_orm(interview: OrmInterview) -> DomainInterview: + """Map an ORM interview row to a shell domain aggregate. Args: - answer: SQLAlchemy Answer instance. + interview: SQLAlchemy Interview row. Returns: - Immutable domain Answer. + Immutable interview shell without section tasks. """ - return DomainAnswer( - id=answer.id, - interview_id=answer.interview_id, - question_id=answer.question_id, - order=answer.order, - round=answer.round, - question_text=answer.question_text, - question_code=answer.question_code, - answer_text=answer.answer_text, - score=answer.score, - feedback=answer.feedback, - started_at=answer.started_at, - created_at=answer.created_at, + status: InterviewStatus = ( + "completed" if interview.status == "completed" else "active" ) - - -def answer_read_from_domain(answer: DomainAnswer) -> AnswerRead: - """Map a domain answer to a read model. - - Args: - answer: Domain answer entity. - - Returns: - Immutable AnswerRead for services and API. - """ - return AnswerRead( - id=answer.id, - question_id=answer.question_id, - order=answer.order, - round=answer.round, - question_text=answer.question_text, - question_code=answer.question_code, - answer_text=answer.answer_text, - score=answer.score, - feedback=answer.feedback, - started_at=answer.started_at, + return DomainInterview( + id=interview.id, + locale=interview.locale or "en", + session_mode=interview.session_mode, # type: ignore[arg-type] + selection=parse_session_spec(interview.selection_spec), + status=status, + overall_feedback=parse_overall_feedback(interview.overall_feedback), + started_at=interview.started_at, + completed_at=interview.completed_at, ) -def answer_read_to_domain(answer: AnswerRead, interview_id: str) -> DomainAnswer: - """Map a read-model answer into a domain answer for rule evaluation. +def compose_interview_read( + shell: DomainInterview, + theory: DomainTheorySection | None, + coding: DomainCodingSection | None = None, +) -> InterviewRead: + """Compose an interview read model from shell and optional section aggregates. Args: - answer: Answer read snapshot. - interview_id: Parent interview UUID. + shell: Interview shell aggregate. + theory: Theory section aggregate with tasks, if present. + coding: Coding section aggregate, used for coding-only score fallback. Returns: - Domain answer with a placeholder ``created_at`` when unknown. + Immutable InterviewRead for services, API, and templates. """ - created_at = answer.started_at if answer.started_at is not None else _EPOCH - return DomainAnswer( - id=answer.id, - interview_id=interview_id, - question_id=answer.question_id, - order=answer.order, - round=answer.round, - question_text=answer.question_text, - question_code=answer.question_code, - answer_text=answer.answer_text, - score=answer.score, - feedback=answer.feedback, - started_at=answer.started_at, - created_at=created_at, + score = _resolve_completed_score(shell, theory, coding) + + if theory is None: + return InterviewRead( + id=shell.id, + status=shell.status, + locale=shell.locale, + selection_spec=session_to_spec(shell.selection), + question_ids="[]", + question_count=0, + question_time_limit_seconds=None, + answers=[], + score=score, + overall_feedback=shell.overall_feedback, + started_at=shell.started_at, + completed_at=shell.completed_at, + ) + + answers = [theory_task_read_from_domain(task) for task in theory.tasks] + + return InterviewRead( + id=shell.id, + status=shell.status, + locale=theory.locale, + selection_spec=session_to_spec(shell.selection), + question_ids=_question_ids_to_json(theory.question_ids), + question_count=theory.question_count, + question_time_limit_seconds=theory.task_time_limit_seconds, + answers=answers, + score=score, + overall_feedback=shell.overall_feedback, + started_at=shell.started_at, + completed_at=shell.completed_at, ) def interview_from_orm(interview: OrmInterview) -> DomainInterview: - """Map an ORM interview row to a domain aggregate. + """Map an ORM interview row to a shell domain aggregate. Args: - interview: SQLAlchemy Interview with answers loaded. + interview: SQLAlchemy Interview row. Returns: - Immutable domain Interview. + Interview shell aggregate. """ - status: InterviewStatus = ( - "completed" if interview.status == "completed" else "active" - ) - return DomainInterview( - id=interview.id, - locale=interview.locale or "en", - selection=parse_selection_spec(interview.selection_spec), - question_count=interview.question_count or 0, - question_ids=_question_ids_from_json(interview.question_ids or "[]"), - question_time_limit_seconds=interview.question_time_limit_seconds, - status=status, - score=interview.score, - overall_feedback=parse_overall_feedback(interview.overall_feedback), - started_at=interview.started_at, - completed_at=interview.completed_at, - answers=tuple(answer_from_orm(a) for a in interview.answers), - ) + return interview_shell_from_orm(interview) -def interview_read_to_domain(interview: InterviewRead) -> DomainInterview: - """Map an interview read model to a domain aggregate for rules. +def interview_read_from_orm( + interview: OrmInterview, + *, + coding: DomainCodingSection | None = None, +) -> InterviewRead: + """Map an ORM interview row and section aggregates to a read model. Args: - interview: Interview read snapshot with answers. + interview: SQLAlchemy Interview with optional theory section loaded. + coding: Coding section aggregate for coding-only score fallback. Returns: - Domain interview aggregate. + Composed interview read model. """ - status: InterviewStatus = ( - "completed" if interview.status == "completed" else "active" - ) - return DomainInterview( - id=interview.id, - locale=interview.locale, - selection=parse_selection_spec(interview.selection_spec), - question_count=interview.question_count, - question_ids=_question_ids_from_json(interview.question_ids), - question_time_limit_seconds=interview.question_time_limit_seconds, - status=status, - score=interview.score, - overall_feedback=interview.overall_feedback, - started_at=interview.started_at or _EPOCH, - completed_at=interview.completed_at, - answers=tuple( - answer_read_to_domain(answer, interview.id) for answer in interview.answers - ), + shell = interview_shell_from_orm(interview) + theory = ( + theory_section_from_orm(interview.theory_section) + if interview.theory_section is not None + else None ) + return compose_interview_read(shell, theory, coding) def interview_to_read(interview: DomainInterview) -> InterviewRead: - """Map a domain aggregate to a read model. + """Map a shell aggregate to a minimal read model without theory tasks. + + Prefer ``compose_interview_read`` when section data is available. Args: - interview: Domain interview aggregate. + interview: Interview shell aggregate. Returns: - Immutable InterviewRead for services and API. + Interview read model without answers. """ - return InterviewRead( - id=interview.id, - status=interview.status, - locale=interview.locale, - selection_spec=selection_to_spec(interview.selection), - question_ids=_question_ids_to_json(interview.question_ids), - question_count=interview.question_count, - question_time_limit_seconds=interview.question_time_limit_seconds, - answers=[answer_read_from_domain(a) for a in interview.answers], - score=interview.score, - overall_feedback=interview.overall_feedback, - started_at=interview.started_at, - completed_at=interview.completed_at, - ) + return compose_interview_read(interview, None) -def interview_to_orm(interview: DomainInterview) -> OrmInterview: - """Map a new domain aggregate to a detached ORM interview row. +def interview_shell_to_orm(interview: DomainInterview) -> OrmInterview: + """Map a new interview shell to a detached ORM row. Args: - interview: Domain aggregate from ``Interview.start``. + interview: Domain shell from ``Interview.start_shell``. Returns: - ORM Interview with nested answer rows ready for ``session.add``. + ORM Interview without nested section rows. """ - orm_interview = OrmInterview( + return OrmInterview( id=interview.id, locale=interview.locale, - selection_spec=selection_to_spec(interview.selection), - question_count=interview.question_count, - question_ids=_question_ids_to_json(interview.question_ids), - question_time_limit_seconds=interview.question_time_limit_seconds, + selection_spec=session_to_spec(interview.selection), + session_mode=interview.session_mode, status=interview.status, - score=interview.score, - overall_feedback=None, + overall_feedback=( + json.dumps(interview.overall_feedback, separators=(",", ":")) + if interview.overall_feedback is not None + else None + ), started_at=interview.started_at, completed_at=interview.completed_at, ) - orm_interview.answers = [ - domain_answer_to_orm(answer) for answer in interview.answers - ] - return orm_interview def interview_to_orm_fields(interview: DomainInterview) -> dict[str, Any]: - """Extract ORM-mutable interview fields from a domain aggregate. + """Extract ORM-mutable interview shell fields from a domain aggregate. Args: - interview: Domain interview aggregate. + interview: Domain interview shell aggregate. Returns: Dict of column names to values for partial ORM updates. """ return { "locale": interview.locale, - "selection_spec": selection_to_spec(interview.selection), - "question_count": interview.question_count, - "question_ids": _question_ids_to_json(interview.question_ids), - "question_time_limit_seconds": interview.question_time_limit_seconds, + "selection_spec": session_to_spec(interview.selection), + "session_mode": interview.session_mode, "status": interview.status, - "score": interview.score, "overall_feedback": ( json.dumps(interview.overall_feedback, separators=(",", ":")) if interview.overall_feedback is not None diff --git a/app/interview/repositories/uow.py b/app/interview/repositories/uow.py index b0880ea..40be0ae 100644 --- a/app/interview/repositories/uow.py +++ b/app/interview/repositories/uow.py @@ -4,8 +4,10 @@ from __future__ import annotations +from app.coding.repositories.coding_section import CodingSectionRepository from app.interview.repositories.interview import InterviewRepository from app.shared.infrastructure.uow import UnitOfWork +from app.theory.repositories.theory_section import TheorySectionRepository class InterviewUnitOfWork(UnitOfWork): @@ -28,6 +30,8 @@ def __init__(self, auto_commit: bool = False) -> None: """ super().__init__(auto_commit=auto_commit) self._interviews_repo: InterviewRepository | None = None + self._theory_sections_repo: TheorySectionRepository | None = None + self._coding_sections_repo: CodingSectionRepository | None = None @property def interviews(self) -> InterviewRepository: @@ -35,3 +39,17 @@ def interviews(self) -> InterviewRepository: if self._interviews_repo is None: self._interviews_repo = InterviewRepository(self.session) return self._interviews_repo + + @property + def theory_sections(self) -> TheorySectionRepository: + """Access the ``TheorySectionRepository`` bound to this UoW.""" + if self._theory_sections_repo is None: + self._theory_sections_repo = TheorySectionRepository(self.session) + return self._theory_sections_repo + + @property + def coding_sections(self) -> CodingSectionRepository: + """Access the ``CodingSectionRepository`` bound to this UoW.""" + if self._coding_sections_repo is None: + self._coding_sections_repo = CodingSectionRepository(self.session) + return self._coding_sections_repo diff --git a/app/interview/schemas/dashboard.py b/app/interview/schemas/dashboard.py index 110146b..4db229c 100644 --- a/app/interview/schemas/dashboard.py +++ b/app/interview/schemas/dashboard.py @@ -12,6 +12,7 @@ class DashboardRowRead(BaseModel): id: Interview UUID. title: Display title (e.g. "Python Interview"). question_count: Number of questions in the session. + session_mode_label: Short badge for session mode (Theory, Coding, Theory+Coding). score_display: Formatted score or em dash when not finished. status: Raw status ("active" or "completed"). status_label: Human-readable status for the UI. @@ -24,6 +25,7 @@ class DashboardRowRead(BaseModel): id: str title: str question_count: int + session_mode_label: str score_display: str status: str status_label: str diff --git a/app/interview/schemas/interview.py b/app/interview/schemas/interview.py index 255944b..c1b9dbf 100644 --- a/app/interview/schemas/interview.py +++ b/app/interview/schemas/interview.py @@ -7,35 +7,10 @@ from pydantic import BaseModel, ConfigDict, Field +from app.theory.schemas.theory import TheoryTaskRead -class AnswerRead(BaseModel): - """Read-only snapshot of one answer round. - - Attributes: - id: Answer row primary key. - question_id: YAML question ID. - order: Display order within the session (1-based). - round: Follow-up round number (0 = initial). - question_text: Question text shown to the user. - question_code: Optional code snippet for the question. - answer_text: User answer text, or None when unanswered. - score: AI score for the round, or None when not evaluated. - feedback: AI-generated feedback text, or None. - started_at: When the round timer started, or None. - """ - - model_config = ConfigDict(frozen=True) - - id: int - question_id: str - order: int - round: int - question_text: str - question_code: str | None - answer_text: str | None - score: int | None - feedback: str | None = None - started_at: datetime | None +AnswerRead = TheoryTaskRead +"""Alias for theory task read models composed into session page context.""" class InterviewRead(BaseModel): diff --git a/app/interview/schemas/results.py b/app/interview/schemas/results.py new file mode 100644 index 0000000..b07eb75 --- /dev/null +++ b/app/interview/schemas/results.py @@ -0,0 +1,65 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Session results page read models.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +from app.interview.schemas.interview import InterviewRead + + +class SectionResultCard(BaseModel): + """Summary card for one completed interview section on the results hub. + + Attributes: + section: Section kind identifier. + label: Human-readable section title. + score: Earned points for the section. + max_score: Maximum achievable points for the section. + skipped: True when the user ended before finishing the section. + summary: Short narrative excerpt for the card. + detail_url: Relative URL for the section review page. + """ + + model_config = ConfigDict(frozen=True) + + section: Literal["theory", "coding"] + label: str + score: int + max_score: int + skipped: bool + summary: str + detail_url: str + + +class SessionResultsContext(BaseModel): + """Template context for the completed session results hub. + + Attributes: + interview: Completed session read model. + interview_title: Display title derived from selection. + selection_lines: Human-readable selection summary lines. + session_mode_label: Localized session mode label. + locale_label: Localized language label. + max_score: Maximum achievable score for the session. + overall_feedback: Parsed final evaluation payload. + section_cards: Per-section summary cards in phase order. + theory_review_url: Theory review URL when theory is enabled. + coding_review_url: Coding review URL when coding is enabled. + """ + + model_config = ConfigDict(frozen=True) + + interview: InterviewRead + interview_title: str + selection_lines: list[str] + session_mode_label: str + locale_label: str + max_score: int + overall_feedback: dict[str, Any] + section_cards: list[SectionResultCard] + theory_review_url: str | None = None + coding_review_url: str | None = None diff --git a/app/interview/services/completion.py b/app/interview/services/completion.py index e1fa610..7416f0a 100644 --- a/app/interview/services/completion.py +++ b/app/interview/services/completion.py @@ -3,42 +3,49 @@ """Session completion service. This module provides service for completing interview sessions and generating -final AI evaluations. +final AI evaluations from merged section summaries. """ +from dataclasses import replace import logging from app.ai.base import AIProvider +from app.coding.services.query import CodingQueryService +from app.coding.services.section import CodingSectionService from app.interview.domain.exceptions import InterviewNotFoundError -from app.interview.repositories.mappers import interview_to_read from app.interview.repositories.uow import InterviewUnitOfWork from app.interview.services.dashboard import DashboardBuilder -from app.interview.services.evaluator.service import InterviewEvaluatorService +from app.interview.services.evaluation_aggregator import ( + SessionEvaluationAggregator, + attach_session_score_breakdown, +) from app.interview.services.events import ( EvaluatingEvent, InterviewCompletedEvent, InterviewEvent, ) from app.interview.services.rules.selection import selection_sources_summary +from app.interview.services.session_evaluator import SessionEvaluatorService +from app.theory.services.query import TheoryQueryService +from app.theory.services.section import TheorySectionService logger = logging.getLogger(__name__) -class InterviewCompletionService: +class SessionCompletionService: """Service for completing interview sessions.""" @staticmethod - async def complete_interview( + async def complete_session( interview_id: str, provider: AIProvider, ) -> list[InterviewEvent]: """Complete a session and generate final AI evaluation. Orchestrates: - 1. Build Q&A summary from all answers - 2. Evaluate with AI - 3. Save overall_feedback - 4. Mark session as completed + 1. Load per-section evaluation summaries + 2. Merge summaries for session-level evaluation + 3. Save overall_feedback and mark session completed Args: interview_id: The session UUID. @@ -55,42 +62,80 @@ async def complete_interview( if aggregate is None: raise InterviewNotFoundError(interview_id) - questions_answers = [ - { - "question_id": answer.question_id, - "question_text": answer.question_text, - "answer_text": answer.answer_text, - "score": answer.score, - "round": answer.round, - } - for answer in aggregate.answers - if answer.answer_text is not None - ] - normalized_breakdown = aggregate.per_question_score_breakdown() locale = aggregate.locale - sources_text = selection_sources_summary(aggregate.selection) + session = aggregate.selection + sources_parts: list[str] = [] + if session.theory.enabled: + theory_sources = TheoryQueryService.sources_text_for_section( + interview_id + ) + if theory_sources: + sources_parts.append(theory_sources) + if session.coding.enabled: + coding_sources = CodingQueryService.sources_text_for_section( + interview_id + ) + if coding_sources: + sources_parts.append(coding_sources) + sources_text = ( + "; ".join(sources_parts) + if sources_parts + else selection_sources_summary(aggregate.selection.theory_selection) + ) + + await TheorySectionService.ensure_section_feedback(interview_id) + await CodingSectionService.ensure_section_feedback(interview_id) + + theory_summary = TheoryQueryService.get_evaluation_summary(interview_id) + if ( + theory_summary is not None + and session.theory.enabled + and not TheorySectionService.is_complete(interview_id) + ): + theory_summary = replace( + theory_summary, + skipped=True, + score=0, + max_score=0, + ) + + coding_summary = CodingQueryService.get_evaluation_summary(interview_id) + if ( + coding_summary is not None + and session.coding.enabled + and not CodingSectionService.is_complete(interview_id) + ): + coding_summary = replace( + coding_summary, + skipped=True, + score=0, + max_score=0, + ) + + merged = SessionEvaluationAggregator.merge(theory_summary, coding_summary) events: list[InterviewEvent] = [EvaluatingEvent()] - interview_eval = await InterviewEvaluatorService.evaluate_interview( + session_eval = await SessionEvaluatorService.evaluate_session( + merged, provider=provider, - questions_answers=questions_answers, - sources_text=sources_text, locale=locale, + sources_text=sources_text, ) - - interview_eval = interview_eval.model_copy( - update={"score_breakdown": normalized_breakdown} - ) + interview_eval = attach_session_score_breakdown(session_eval, merged) with InterviewUnitOfWork(auto_commit=True) as uow: aggregate = uow.interviews.get_aggregate(interview_id) if aggregate is None: raise InterviewNotFoundError(interview_id) completed = aggregate.with_session_completed(interview_eval.model_dump()) - score = completed.score or 0 uow.interviews.save_aggregate(completed) - interview_read = interview_to_read(completed) + interview_read = uow.interviews.get_read_model(interview_id) + if interview_read is None: + raise InterviewNotFoundError(interview_id) + score = SessionEvaluationAggregator.total_score_from_breakdown( + interview_eval.score_breakdown + ) max_score = DashboardBuilder.compute_max_score( interview_read, interview_eval.score_breakdown or None diff --git a/app/interview/services/creation.py b/app/interview/services/creation.py index 93a5d99..9b90134 100644 --- a/app/interview/services/creation.py +++ b/app/interview/services/creation.py @@ -1,61 +1,97 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Interview creation service.""" +"""Interview session creation service.""" import logging from uuid import uuid4 +from app.coding.domain.entities import CodingSectionStatus +from app.coding.services.creation import CodingSectionCreationService +from app.coding.services.planning import build_coding_task_plan from app.interview.domain.entities import Interview -from app.interview.domain.value_objects import InterviewSelection -from app.interview.repositories.mappers import interview_to_read +from app.interview.domain.exceptions import InterviewNotFoundError +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.question_planning import build_question_plan -from app.interview.services.rules.selection import validate_question_count +from app.interview.services.sections import phase_order_for_mode from app.shared.locales import normalize_locale +from app.theory.services.creation import TheorySectionCreationService logger = logging.getLogger(__name__) -class InterviewCreationService: - """Service for creating interview sessions.""" +def _initial_coding_status(session_mode: SessionMode) -> CodingSectionStatus: + """Return the initial coding section status for a session mode. + + Args: + session_mode: Session mode from setup. + + Returns: + ``active`` when coding is the first user-facing phase, else ``pending``. + """ + order = phase_order_for_mode(session_mode) + return "active" if order and order[0] == "coding" else "pending" + + +class SessionCreationService: + """Orchestrates interview shell and section creation.""" @staticmethod - def create_interview( - selection: InterviewSelection, + def create_session( + session: SessionSelection, locale: str = "en", - question_count: int = 5, - question_time_limit_seconds: int | None = None, ) -> InterviewRead: - """Create a new interview session with selected questions. + """Create a new interview session from a v2 session selection. - Loads questions from YAML banks per selection, builds a plan with at - least one question per topic, then persists the session atomically. + Persists an interview shell and enabled section rows atomically in one + transaction. Args: - selection: Track/level/topic selection from setup. + session: Full session selection from setup (v2). locale: Locale for AI feedback and follow-ups (default: "en"). - question_count: Number of questions for this session (default: 5). - question_time_limit_seconds: Per-round time limit, or None to disable. Returns: - Read model for the created interview with answers pre-populated. + Read model for the created session with answers pre-populated. Raises: ValueError: If validation fails or no questions are available. """ locale = normalize_locale(locale) - validate_question_count(selection, question_count) - selected = build_question_plan(selection, question_count, locale=locale) + interview_id = str(uuid4()) - aggregate = Interview.start( + shell = Interview.start_shell( interview_id, - selection=selection, + selection=session, locale=locale, - planned_questions=tuple(selected), - question_time_limit_seconds=question_time_limit_seconds, ) with InterviewUnitOfWork(auto_commit=True) as uow: - persisted = uow.interviews.create_aggregate(aggregate) - return interview_to_read(persisted) + uow.interviews.create_shell(shell) + if session.theory.enabled: + TheorySectionCreationService.create( + interview_id, + selection=session.theory_selection, + locale=locale, + question_count=session.theory.question_count, + task_time_limit_seconds=session.theory.task_time_limit_seconds, + uow=uow, + ) + if session.coding.enabled: + planned_tasks = build_coding_task_plan( + session.coding_selection, + session.coding.question_count, + locale=locale, + ) + CodingSectionCreationService.create( + interview_id, + selection=session.coding_selection, + locale=locale, + planned_tasks=planned_tasks, + task_time_limit_seconds=session.coding.task_time_limit_seconds, + status=_initial_coding_status(session.session_mode), + uow=uow, + ) + read_model = uow.interviews.get_read_model(interview_id) + if read_model is None: + raise InterviewNotFoundError(interview_id) + return read_model diff --git a/app/interview/services/dashboard.py b/app/interview/services/dashboard.py index f921eee..9ca83d4 100644 --- a/app/interview/services/dashboard.py +++ b/app/interview/services/dashboard.py @@ -5,17 +5,17 @@ from datetime import UTC, datetime from typing import Any -from app.interview.domain.entities import Interview -from app.interview.domain.value_objects import InterviewSelection -from app.interview.repositories.mappers import interview_to_read +from app.coding.repositories.uow import CodingUnitOfWork +from app.interview.domain.serialization import parse_session_spec +from app.interview.domain.value_objects import InterviewSelection, session_mode_label from app.interview.repositories.uow import InterviewUnitOfWork from app.interview.schemas.dashboard import DashboardRowRead from app.interview.schemas.interview import InterviewRead from app.interview.services.rules.selection import ( - get_interview_selection, - interview_display_title, - track_label, + selection_summary_lines, + session_display_title, ) +from app.theory.domain.entities import TheorySection class DashboardBuilder: @@ -47,8 +47,32 @@ def interview_display_title(interview: InterviewRead) -> str: Returns: Title such as ``Python Interview`` or ``Multi-topic Interview``. """ - selection = get_interview_selection(interview) - return interview_display_title(selection) + session = parse_session_spec(interview.selection_spec) + return session_display_title(session) + + @staticmethod + def _max_score_from_breakdown(score_breakdown: dict[str, Any]) -> int: + """Sum maximum points from nested or flat score breakdown payloads. + + Args: + score_breakdown: Session evaluation breakdown dict. + + Returns: + Maximum achievable score encoded in the breakdown. + """ + total = 0 + for key, entry in score_breakdown.items(): + if key == "total" or not isinstance(entry, dict): + continue + if key in ("theory", "coding"): + max_score = entry.get("max") + if isinstance(max_score, int): + total += max_score + continue + max_score = entry.get("max") + if isinstance(max_score, int): + total += max_score + return total @staticmethod def compute_max_score( @@ -58,7 +82,7 @@ def compute_max_score( """Compute maximum achievable score for a session. Uses AI ``score_breakdown`` when provided; otherwise estimates - five points per answered round (including follow-ups). + five points per answered theory round or submitted coding round. Args: interview: Interview read model with answers loaded. @@ -68,18 +92,40 @@ def compute_max_score( Maximum possible score for the session. """ if score_breakdown: - total = 0 - for qid, breakdown in score_breakdown.items(): - if qid != "total" and isinstance(breakdown, dict): - total += breakdown.get("max", Interview.MAX_SCORE_PER_ROUND) - return total - - return sum( - Interview.MAX_SCORE_PER_ROUND - for answer in interview.answers - if answer.answer_text is not None + breakdown_total = DashboardBuilder._max_score_from_breakdown( + score_breakdown + ) + if breakdown_total > 0: + return breakdown_total + + session = parse_session_spec( + interview.selection_spec, + question_count=interview.question_count, + task_time_limit_seconds=interview.question_time_limit_seconds, ) + if session.theory.enabled: + if interview.answers: + theory_max = sum( + TheorySection.MAX_SCORE_PER_ROUND for _answer in interview.answers + ) + else: + theory_max = ( + interview.question_count * TheorySection.MAX_SCORE_PER_ROUND + ) + if theory_max > 0: + return theory_max + + if session.coding.enabled: + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview.id) + if section is not None: + return section.max_score() + if interview.question_count > 0: + return interview.question_count * TheorySection.MAX_SCORE_PER_ROUND + + return 0 + @staticmethod def selection_summary_lines(selection: InterviewSelection) -> list[str]: """Build display lines for each track source in a selection. @@ -90,15 +136,7 @@ def selection_summary_lines(selection: InterviewSelection) -> list[str]: Returns: Lines such as ``Python / middle: basics, oop``. """ - lines: list[str] = [] - for source in selection.sources: - label = track_label(source.track) - topics = ", ".join( - cat.replace("-", " ").replace("_", " ").title() - for cat in source.categories - ) - lines.append(f"{label} / {source.level}: {topics}") - return lines + return selection_summary_lines(selection) @staticmethod def list_rows(limit: int = 20) -> list[DashboardRowRead]: @@ -111,11 +149,10 @@ def list_rows(limit: int = 20) -> list[DashboardRowRead]: Rows sorted newest-first (completed or started time). """ with InterviewUnitOfWork() as uow: - interviews = uow.interviews.list_recent(limit=limit) + interviews = uow.interviews.list_recent_read_models(limit=limit) rows: list[DashboardRowRead] = [] - for aggregate in interviews: - interview = interview_to_read(aggregate) + for interview in interviews: if interview.status == "completed": feedback = interview.overall_feedback breakdown = feedback.get("score_breakdown") if feedback else None @@ -129,16 +166,22 @@ def list_rows(limit: int = 20) -> list[DashboardRowRead]: status_label = "Active" when = interview.started_at + session = parse_session_spec(interview.selection_spec) rows.append( DashboardRowRead( id=interview.id, title=DashboardBuilder.interview_display_title(interview), question_count=interview.question_count, + session_mode_label=session_mode_label(session.session_mode), score_display=score_display, status=interview.status, status_label=status_label, datetime_display=DashboardBuilder.format_local_datetime(when), - url=f"/interview/{interview.id}", + url=( + f"/interview/{interview.id}/results" + if interview.status == "completed" + else f"/interview/{interview.id}" + ), ) ) return rows diff --git a/app/interview/services/evaluation_aggregator.py b/app/interview/services/evaluation_aggregator.py new file mode 100644 index 0000000..3fb7829 --- /dev/null +++ b/app/interview/services/evaluation_aggregator.py @@ -0,0 +1,127 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Merge per-section evaluation summaries for session completion.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from app.interview.services.sections import SectionEvaluationSummary +from app.shared.evaluation_models import InterviewEvaluation + + +@dataclass(frozen=True, slots=True) +class MergedSessionEvaluation: + """Combined evaluation inputs from all enabled interview sections. + + Attributes: + sections: Non-empty section summaries in phase order. + """ + + sections: tuple[SectionEvaluationSummary, ...] + + @property + def all_items(self) -> tuple[dict[str, Any], ...]: + """Return Q&A items from every section in order.""" + rows: list[dict[str, Any]] = [] + for section in self.sections: + rows.extend(section.items) + return tuple(rows) + + def has_cached_narratives(self) -> bool: + """Return whether every non-skipped section has cached narrative feedback.""" + actionable = [section for section in self.sections if not section.skipped] + if not actionable: + return False + return all(section.cached_narrative is not None for section in actionable) + + def to_score_breakdown(self) -> dict[str, Any]: + """Build nested score breakdown keyed by section kind. + + Returns: + Mapping ``theory`` / ``coding`` to score metadata and per-question rows. + """ + breakdown: dict[str, Any] = {} + for section in self.sections: + question_rows: dict[str, Any] = {} + for item in section.items: + item_id = item.get("question_id") or item.get("task_id") or "?" + question_id = str(item_id) + round_num = int(item.get("round", 0)) + key = question_id if round_num == 0 else f"{question_id}:r{round_num}" + score = item.get("score") + question_rows[key] = { + "score": score if isinstance(score, int) else 0, + "max": 5, + } + breakdown[section.section] = { + "score": section.score, + "max": section.max_score, + "skipped": section.skipped, + "questions": question_rows, + } + return breakdown + + +def attach_session_score_breakdown( + evaluation: InterviewEvaluation, + merged: MergedSessionEvaluation, +) -> InterviewEvaluation: + """Attach merged section score breakdown before session persistence. + + Args: + evaluation: Session narrative from the evaluator service. + merged: Combined section summaries from the aggregator. + + Returns: + Evaluation with ``score_breakdown`` populated from merged sections. + """ + return evaluation.model_copy( + update={"score_breakdown": merged.to_score_breakdown()} + ) + + +class SessionEvaluationAggregator: + """Merge section evaluation summaries for session-level completion.""" + + @staticmethod + def merge( + *summaries: SectionEvaluationSummary | None, + ) -> MergedSessionEvaluation: + """Combine section summaries, ignoring ``None`` placeholders. + + Args: + *summaries: Section summaries in phase order (``None`` when disabled). + + Returns: + Merged evaluation payload for session completion. + + Raises: + ValueError: If no section summaries are provided. + """ + present = tuple(summary for summary in summaries if summary is not None) + if not present: + raise ValueError("At least one section summary is required") + return MergedSessionEvaluation(sections=present) + + @staticmethod + def total_score_from_breakdown(score_breakdown: dict[str, Any] | None) -> int: + """Sum earned points across nested section breakdown entries. + + Args: + score_breakdown: Nested breakdown from ``to_score_breakdown``. + + Returns: + Total earned score across all sections. + """ + if not score_breakdown: + return 0 + total = 0 + for key, section in score_breakdown.items(): + if key == "total" or not isinstance(section, dict): + continue + score = section.get("score") + if isinstance(score, int): + total += score + return total diff --git a/app/interview/services/page.py b/app/interview/services/page.py index 7c95d0a..e16c42f 100644 --- a/app/interview/services/page.py +++ b/app/interview/services/page.py @@ -5,12 +5,18 @@ from dataclasses import dataclass from typing import Any -from app.interview.repositories.mappers import interview_to_read -from app.interview.repositories.uow import InterviewUnitOfWork +from app.coding.services.page import CodingPageService +from app.interview.domain.serialization import parse_session_spec +from app.interview.domain.value_objects import SESSION_MODE_LABELS from app.interview.schemas.interview import InterviewPageContext, InterviewRead from app.interview.services.dashboard import DashboardBuilder +from app.interview.services.phases import SessionPhaseOrchestrator from app.interview.services.query import InterviewQuery -from app.interview.services.rules.selection import get_interview_selection +from app.interview.services.rules.selection import ( + session_display_title, + session_selection_summary_lines, +) +from app.interview.services.sections import phase_order_for_mode from app.platform.services.config import AppConfig from app.platform.services.llm_catalog import LLMCatalogService from app.question_voice.services.page import QuestionVoicePageService @@ -21,10 +27,11 @@ ) from app.speech.services.page import SpeechModelPageService from app.speech.services.whisper_model import WhisperModelService +from app.theory.services.page import TheoryPageService @dataclass(frozen=True) -class InterviewPageRender: +class SessionPageRender: """Result of preparing the interview HTML page. Attributes: @@ -38,12 +45,12 @@ class InterviewPageRender: interview_active: bool = False -class InterviewPageService: - """Build read models and template context for the interview page.""" +class SessionPageService: + """Compose session shell and section contexts for the interview page.""" @staticmethod def load_interview(interview_id: str) -> InterviewRead | None: - """Load a session and start the timer on the current round when active. + """Load a session and start the theory timer on the current task when active. Args: interview_id: The session UUID. @@ -51,16 +58,9 @@ def load_interview(interview_id: str) -> InterviewRead | None: Returns: Interview read model, or None when not found. """ - with InterviewUnitOfWork(auto_commit=True) as uow: - aggregate = uow.interviews.get_aggregate(interview_id) - if aggregate is None: - return None - if aggregate.status == "active" and aggregate.question_time_limit_seconds: - current = aggregate.find_first_unanswered() - if current is not None and current.started_at is None: - aggregate = aggregate.start_timer_for_answer(current.id) - uow.interviews.save_aggregate(aggregate) - return interview_to_read(aggregate) + TheoryPageService.activate_timer(interview_id) + CodingPageService.activate_timer(interview_id) + return InterviewQuery.get_interview(interview_id) @staticmethod async def prepare_page( @@ -68,7 +68,7 @@ async def prepare_page( *, config: AppConfig | None, whisper_model_service: type[WhisperModelService] = WhisperModelService, - ) -> InterviewPageRender: + ) -> SessionPageRender: """Load a session and build template context for the interview page. Args: @@ -79,16 +79,16 @@ async def prepare_page( Returns: Redirect URL or template context for ``interview.html``. """ - interview = InterviewPageService.load_interview(interview_id) + interview = SessionPageService.load_interview(interview_id) if interview is None: - return InterviewPageRender(redirect_url="/", template_context=None) + return SessionPageRender(redirect_url="/", template_context=None) - template_context = await InterviewPageService.build_full_template_context( + template_context = await SessionPageService.build_full_template_context( interview, config=config, whisper_model_service=whisper_model_service, ) - return InterviewPageRender( + return SessionPageRender( redirect_url=None, template_context=template_context, interview_active=interview.status == "active", @@ -101,29 +101,47 @@ def build_page_context( config: AppConfig | None, question_voice_enabled: bool, ) -> InterviewPageContext: - """Assemble template context for ``interview.html``. + """Assemble shell template context for ``interview.html``. + + Theory-specific fields are merged from ``TheoryPageService`` for template + compatibility while ``theory`` exposes the structured section context. Args: interview: Loaded interview read model. config: Application config, if configured. - question_voice_enabled: Whether Piper TTS is enabled. + question_voice_enabled: Whether Piper TTS is enabled in config. Returns: Frozen page context for the interview template. """ - current_question = InterviewQuery.get_current_unanswered(interview) - question_timer_enabled = interview.question_time_limit_seconds is not None + theory = TheoryPageService.build_context(interview) + current_question = theory.current_question if theory is not None else None + question_timer_enabled = ( + theory.question_timer_enabled if theory is not None else False + ) timer_remaining_seconds = ( - InterviewQuery.timer_remaining_seconds(interview.id) - if question_timer_enabled - else None + theory.timer_remaining_seconds if theory is not None else None ) - current_round = current_question.round if current_question else 0 + current_round = theory.current_round if theory is not None else 0 + answers = theory.answers if theory is not None else interview.answers + overall_feedback_data = interview.overall_feedback - max_score = DashboardBuilder.compute_max_score(interview) - selection = get_interview_selection(interview) - selection_lines = DashboardBuilder.selection_summary_lines(selection) - interview_title = DashboardBuilder.interview_display_title(interview) + score_breakdown = ( + overall_feedback_data.get("score_breakdown") + if overall_feedback_data + else None + ) + max_score = DashboardBuilder.compute_max_score( + interview, + score_breakdown if isinstance(score_breakdown, dict) else None, + ) + session = parse_session_spec( + interview.selection_spec, + question_count=interview.question_count, + task_time_limit_seconds=interview.question_time_limit_seconds, + ) + selection_lines = session_selection_summary_lines(session) + interview_title = session_display_title(session) interview_model_accepts_audio = False if config is not None and config.llm_preset_id: entry = LLMCatalogService.get_model(config.llm_preset_id) @@ -135,7 +153,7 @@ def build_page_context( interview=interview, interview_title=interview_title, selection_lines=selection_lines, - answers=interview.answers, + answers=answers, current_question=current_question, current_answer_id=current_question.id if current_question else None, question_voice_enabled=question_voice_enabled, @@ -158,7 +176,7 @@ async def build_full_template_context( config: AppConfig | None, whisper_model_service: type[WhisperModelService] = WhisperModelService, ) -> dict[str, Any]: - """Merge interview, speech, and question-voice keys for ``interview.html``. + """Merge session shell, theory section, and audio keys for ``interview.html``. Args: interview: Loaded interview read model. @@ -168,7 +186,14 @@ async def build_full_template_context( Returns: Flat dict for Jinja template rendering. """ - base = InterviewPageService.build_page_context( + session = parse_session_spec( + interview.selection_spec, + question_count=interview.question_count, + task_time_limit_seconds=interview.question_time_limit_seconds, + ) + theory = TheoryPageService.build_context(interview) + coding = CodingPageService.build_context(interview.id) + base = SessionPageService.build_page_context( interview, config=config, question_voice_enabled=bool(config and config.question_voice_enabled), @@ -178,4 +203,16 @@ async def build_full_template_context( whisper_model_service=whisper_model_service, ).model_dump() voice = (await QuestionVoicePageService.build_page_context(config)).model_dump() - return {**base, **speech, **voice} + return { + **base, + **speech, + **voice, + "theory": theory.model_dump() if theory is not None else None, + "coding": coding.model_dump() if coding is not None else None, + "session_mode": session.session_mode, + "session_mode_label": SESSION_MODE_LABELS.get( + session.session_mode, session.session_mode + ), + "phase_order": list(phase_order_for_mode(session.session_mode)), + "active_phase": SessionPhaseOrchestrator.active_phase(interview.id), + } diff --git a/app/interview/services/phases.py b/app/interview/services/phases.py new file mode 100644 index 0000000..1c6b180 --- /dev/null +++ b/app/interview/services/phases.py @@ -0,0 +1,68 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Session phase transitions between interview sections.""" + +from app.interview.domain.serialization import parse_session_spec +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.services.sections import ( + SectionKind, + phase_order_for_mode, + section_services, +) + + +class SessionPhaseOrchestrator: + """Coordinate phase completion hooks across interview sections.""" + + @staticmethod + def active_phase(interview_id: str) -> SectionKind | None: + """Return the section kind the user should interact with now. + + Args: + interview_id: Parent interview UUID. + + Returns: + Active section kind, or None when the session row is missing. + """ + with InterviewUnitOfWork() as uow: + aggregate = uow.interviews.get_aggregate(interview_id) + if aggregate is None: + return None + order = phase_order_for_mode(aggregate.selection.session_mode) + + services = section_services() + for kind in order: + services[kind].activate_if_pending(interview_id) + if services[kind].is_user_facing(interview_id): + return kind + if not services[kind].is_complete(interview_id): + return kind + return order[-1] if order else None + + @staticmethod + def notify_section_complete(interview_id: str, section_kind: SectionKind) -> None: + """Invoke section prefetch hooks when a phase finishes. + + Args: + interview_id: Parent interview UUID. + section_kind: Section that the user just completed. + """ + services = section_services() + services[section_kind].on_phase_complete(interview_id) + services["coding"].activate_if_pending(interview_id) + + @staticmethod + def session_mode_for_interview(interview_id: str) -> str: + """Load the session mode for an interview row. + + Args: + interview_id: Parent interview UUID. + + Returns: + Session mode string, defaulting to ``theory_only`` when missing. + """ + with InterviewUnitOfWork() as uow: + row = uow.interviews.get(interview_id) + if row is None: + return "theory_only" + return parse_session_spec(row.selection_spec).session_mode diff --git a/app/interview/services/query.py b/app/interview/services/query.py index c791b7a..91f9f09 100644 --- a/app/interview/services/query.py +++ b/app/interview/services/query.py @@ -6,9 +6,9 @@ """ from app.interview.domain.exceptions import InterviewNotFoundError -from app.interview.repositories.mappers import interview_to_read from app.interview.repositories.uow import InterviewUnitOfWork from app.interview.schemas.interview import AnswerRead, InterviewRead +from app.theory.repositories.uow import TheoryUnitOfWork class InterviewQuery: @@ -16,7 +16,7 @@ class InterviewQuery: @staticmethod def get_interview(interview_id: str) -> InterviewRead | None: - """Retrieve an interview session by ID with answers loaded. + """Retrieve an interview session by ID with theory tasks loaded. Args: interview_id: The session UUID. @@ -25,10 +25,7 @@ def get_interview(interview_id: str) -> InterviewRead | None: Interview read model with answers loaded, or None if not found. """ with InterviewUnitOfWork() as uow: - aggregate = uow.interviews.get_aggregate(interview_id) - if aggregate is None: - return None - return interview_to_read(aggregate) + return uow.interviews.get_read_model(interview_id) @staticmethod def get_interview_or_raise( @@ -52,13 +49,13 @@ def get_interview_or_raise( InterviewNotFoundError: If the interview does not exist. """ if uow is not None: - aggregate = uow.interviews.get_aggregate(interview_id) + interview = uow.interviews.get_read_model(interview_id) else: with InterviewUnitOfWork() as read_uow: - aggregate = read_uow.interviews.get_aggregate(interview_id) - if aggregate is None: + interview = read_uow.interviews.get_read_model(interview_id) + if interview is None: raise InterviewNotFoundError(interview_id) - return interview_to_read(aggregate) + return interview @staticmethod def get_active_interview_or_raise(interview_id: str) -> InterviewRead: @@ -75,11 +72,14 @@ def get_active_interview_or_raise(interview_id: str) -> InterviewRead: InterviewNotActiveError: If the interview is not active. """ with InterviewUnitOfWork() as uow: - aggregate = uow.interviews.get_aggregate(interview_id) - if aggregate is None: + shell = uow.interviews.get_aggregate(interview_id) + if shell is None: + raise InterviewNotFoundError(interview_id) + shell.ensure_active() + interview = uow.interviews.get_read_model(interview_id) + if interview is None: raise InterviewNotFoundError(interview_id) - aggregate.ensure_active() - return interview_to_read(aggregate) + return interview @staticmethod def timer_remaining_seconds(interview_id: str) -> int | None: @@ -91,14 +91,14 @@ def timer_remaining_seconds(interview_id: str) -> int | None: Returns: Remaining seconds, or None when the timer is disabled or unavailable. """ - with InterviewUnitOfWork() as uow: - aggregate = uow.interviews.get_aggregate(interview_id) - if aggregate is None: + with TheoryUnitOfWork() as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: return None - current = aggregate.find_first_unanswered() + current = section.find_first_unanswered() if current is None: return None - return current.remaining_seconds(aggregate.question_time_limit_seconds) + return current.remaining_seconds(section.task_time_limit_seconds) @staticmethod def get_current_unanswered(interview: InterviewRead) -> AnswerRead | None: diff --git a/app/interview/services/results_page.py b/app/interview/services/results_page.py new file mode 100644 index 0000000..114d105 --- /dev/null +++ b/app/interview/services/results_page.py @@ -0,0 +1,204 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Completed session results hub page context builder.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from app.interview.domain.serialization import parse_session_spec +from app.interview.domain.value_objects import session_mode_label +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.schemas.interview import InterviewRead +from app.interview.schemas.results import SectionResultCard, SessionResultsContext +from app.interview.services.dashboard import DashboardBuilder +from app.interview.services.rules.selection import session_selection_summary_lines +from app.interview.services.section_review_support import ( + item_id_key_for, + resolved_section_feedback, +) +from app.interview.services.sections import ( + SectionEvaluationSummary, + SectionKind, + phase_order_for_mode, + section_services, +) +from app.shared.locales import SUPPORTED_LOCALES + +_SECTION_LABELS: dict[SectionKind, str] = { + "theory": "Theory", + "coding": "Coding", +} + + +@dataclass(frozen=True) +class SessionResultsRender: + """Result of preparing a session results HTML page. + + Attributes: + redirect_url: Redirect target when the session is missing or not completed. + template_context: Jinja context when the page should render. + """ + + redirect_url: str | None + template_context: dict[str, Any] | None + + +class SessionResultsPageService: + """Compose the completed session results hub template context.""" + + @staticmethod + def _section_summary_text( + section_key: SectionKind, + summary: SectionEvaluationSummary, + ) -> str: + """Build a short excerpt for a section results card. + + Args: + section_key: Section kind identifier. + summary: Section evaluation summary. + + Returns: + Short summary text for display on the results hub. + """ + feedback = resolved_section_feedback( + summary, + item_id_key=item_id_key_for(section_key), + cached_payload=summary.cached_narrative, + ) + narrative = str(feedback.get("section_feedback", "")).strip() + if narrative: + return narrative + return f"{section_key.capitalize()} section complete." + + @staticmethod + def _section_card( + interview_id: str, + kind: SectionKind, + summary: SectionEvaluationSummary, + breakdown: dict[str, Any] | None, + ) -> SectionResultCard: + """Build one section result card from summary and session breakdown. + + Args: + interview_id: Parent session UUID. + kind: Section kind identifier. + summary: Section evaluation summary. + breakdown: Nested session score breakdown, if present. + + Returns: + Section result card for the results hub template. + """ + section_data = breakdown.get(kind) if isinstance(breakdown, dict) else None + score = ( + int(section_data.get("score", 0)) + if isinstance(section_data, dict) + else summary.score + ) + section_max = ( + int(section_data.get("max", 0)) + if isinstance(section_data, dict) + else summary.max_score + ) + skipped = ( + bool(section_data.get("skipped")) + if isinstance(section_data, dict) + else summary.skipped + ) + return SectionResultCard( + section=kind, + label=_SECTION_LABELS[kind], + score=score, + max_score=section_max, + skipped=skipped, + summary=SessionResultsPageService._section_summary_text(kind, summary), + detail_url=f"/interview/{interview_id}/{kind}", + ) + + @staticmethod + def build_context(interview: InterviewRead) -> SessionResultsContext | None: + """Assemble results hub context for a completed session. + + Args: + interview: Completed interview read model. + + Returns: + Results context, or None when overall feedback is missing. + """ + if interview.status != "completed" or interview.overall_feedback is None: + return None + + session = parse_session_spec(interview.selection_spec) + breakdown = interview.overall_feedback.get("score_breakdown") + max_score = DashboardBuilder.compute_max_score( + interview, + breakdown if isinstance(breakdown, dict) else None, + ) + + services = section_services() + section_cards: list[SectionResultCard] = [] + theory_review_url: str | None = None + coding_review_url: str | None = None + + for kind in phase_order_for_mode(session.session_mode): + summary = services[kind].get_evaluation_summary(interview.id) + if summary is None: + continue + card = SessionResultsPageService._section_card( + interview.id, + kind, + summary, + breakdown if isinstance(breakdown, dict) else None, + ) + section_cards.append(card) + if kind == "theory": + theory_review_url = card.detail_url + elif kind == "coding": + coding_review_url = card.detail_url + + return SessionResultsContext( + interview=interview, + interview_title=DashboardBuilder.interview_display_title(interview), + selection_lines=session_selection_summary_lines(session), + session_mode_label=session_mode_label(session.session_mode), + locale_label=SUPPORTED_LOCALES.get(interview.locale, interview.locale), + max_score=max_score, + overall_feedback=interview.overall_feedback, + section_cards=section_cards, + theory_review_url=theory_review_url, + coding_review_url=coding_review_url, + ) + + @staticmethod + def prepare_page(interview_id: str) -> SessionResultsRender: + """Load a completed session and build the results hub context. + + Args: + interview_id: Session UUID. + + Returns: + Redirect URL or template context for ``session_results.html``. + """ + with InterviewUnitOfWork() as uow: + interview = uow.interviews.get_read_model(interview_id) + + if interview is None: + return SessionResultsRender(redirect_url="/", template_context=None) + if interview.status != "completed": + return SessionResultsRender( + redirect_url=f"/interview/{interview_id}", + template_context=None, + ) + + context = SessionResultsPageService.build_context(interview) + if context is None: + return SessionResultsRender( + redirect_url=f"/interview/{interview_id}", + template_context=None, + ) + + return SessionResultsRender( + redirect_url=None, + template_context=context.model_dump(), + ) diff --git a/app/interview/services/rules/selection.py b/app/interview/services/rules/selection.py index ca0d938..39acfe7 100644 --- a/app/interview/services/rules/selection.py +++ b/app/interview/services/rules/selection.py @@ -7,14 +7,19 @@ import json import random +from app.coding.services.availability import is_coding_available +from app.coding.services.planning import validate_selection as validate_coding_selection +from app.coding.services.planning import validate_task_count from app.interview.domain.serialization import ( - parse_selection_spec, - selection_from_payload, + parse_session_spec, + session_from_payload, ) from app.interview.domain.value_objects import ( + SESSION_MODE_LABELS, InterviewSelection, InterviewSelectionHolder, PlannedQuestion, + SessionSelection, TrackQuestionPools, TrackSelection, ) @@ -71,7 +76,7 @@ def get_interview_selection(interview: InterviewSelectionHolder) -> InterviewSel """ if not interview.selection_spec: raise ValueError(f"Interview {interview.id} has no selection_spec") - return parse_selection_spec(interview.selection_spec) + return parse_session_spec(interview.selection_spec).theory_selection def selection_sources_summary(selection: InterviewSelection) -> str: @@ -91,6 +96,25 @@ def selection_sources_summary(selection: InterviewSelection) -> str: return "\n".join(lines) +def selection_summary_lines(selection: InterviewSelection) -> list[str]: + """Build display lines for each track source in a selection. + + Args: + selection: Interview selection. + + Returns: + Lines such as ``Python / middle: basics, oop``. + """ + lines: list[str] = [] + for source in selection.sources: + label = track_label(source.track) + topics = ", ".join( + cat.replace("-", " ").replace("_", " ").title() for cat in source.categories + ) + lines.append(f"{label} / {source.level}: {topics}") + return lines + + def interview_display_title(selection: InterviewSelection) -> str: """Build page title from selection. @@ -100,23 +124,91 @@ def interview_display_title(selection: InterviewSelection) -> str: Returns: Title such as ``Python Interview`` or ``Multi-topic Interview``. """ + if not selection.sources: + return "Interview" if selection.is_multi(): return "Multi-topic Interview" source = selection.sources[0] return f"{track_label(source.track)} Interview" -def parse_selection_json(raw_json: str) -> InterviewSelection: - """Parse setup form ``selection_json`` field. +def session_display_title(session: SessionSelection) -> str: + """Build page title from a full session selection. + + Args: + session: Session selection including mode and branches. + + Returns: + Title based on the active branch sources, or a mode fallback. + """ + if session.session_mode == "coding_only": + selection = session.coding_selection + else: + selection = session.theory_selection + + if selection.sources: + return interview_display_title(selection) + + mode_label = SESSION_MODE_LABELS.get(session.session_mode, "Interview") + return f"{mode_label} Interview" + + +def session_selection_summary_lines(session: SessionSelection) -> list[str]: + """Build display lines for the active session branches. + + Args: + session: Session selection including mode and branches. + + Returns: + Summary lines for theory and/or coding sources. + """ + if session.session_mode == "coding_only": + return selection_summary_lines(session.coding_selection) + + lines = selection_summary_lines(session.theory_selection) + if session.session_mode in ("theory_then_coding", "coding_then_theory"): + lines.extend(selection_summary_lines(session.coding_selection)) + return lines + + +def validate_session_selection(session: SessionSelection) -> None: + """Validate a parsed session selection from setup. + + Args: + session: Session selection including mode and branch specs. + + Raises: + ValueError: If branches are inconsistent or banks reject the selection. + """ + if not session.theory.enabled and not session.coding.enabled: + raise ValueError("At least one section must be enabled") + if session.theory.enabled: + if not session.theory.sources: + raise ValueError("Select at least one theory track and topic") + validate_question_count(session.theory_selection, session.theory.question_count) + if session.coding.enabled: + if not is_coding_available(): + raise ValueError( + "Coding is not available. Enable CODING_ENABLED and ensure " + "Judge0 is running, or choose a theory-only session." + ) + if not session.coding.sources: + raise ValueError("Select at least one coding track and topic") + validate_coding_selection(session.coding_selection) + validate_task_count(session.coding_selection, session.coding.question_count) + + +def parse_session_json(raw_json: str) -> SessionSelection: + """Parse setup form ``selection_json`` field (v2 session selection). Args: raw_json: JSON string from POST body. Returns: - Validated InterviewSelection. + Validated session selection. Raises: - ValueError: If JSON is invalid or selection fails validation. + ValueError: If JSON is invalid or validation fails. """ try: data = json.loads(raw_json) @@ -124,7 +216,24 @@ def parse_selection_json(raw_json: str) -> InterviewSelection: raise ValueError("Invalid selection JSON") from exc if not isinstance(data, dict): raise ValueError("Invalid selection JSON: expected object") - return selection_from_payload(data) + session = session_from_payload(data) + validate_session_selection(session) + return session + + +def parse_selection_json(raw_json: str) -> InterviewSelection: + """Parse setup form JSON and return theory sources only. + + Args: + raw_json: JSON string from POST body. + + Returns: + Validated theory interview selection. + + Raises: + ValueError: If JSON is invalid or selection fails validation. + """ + return parse_session_json(raw_json).theory_selection def _allocate_proportional(sizes: list[int], total: int) -> list[int]: diff --git a/app/interview/services/scoring.py b/app/interview/services/scoring.py new file mode 100644 index 0000000..545ea35 --- /dev/null +++ b/app/interview/services/scoring.py @@ -0,0 +1,71 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Resolve display scores for completed interview read models.""" + +from __future__ import annotations + +from typing import Any + +from app.coding.domain.entities import CodingSection +from app.interview.domain.entities import Interview as DomainInterview +from app.interview.services.evaluation_aggregator import SessionEvaluationAggregator +from app.theory.domain.entities import TheorySection + + +def score_from_overall_feedback(overall_feedback: dict[str, Any] | None) -> int | None: + """Extract a display score from session evaluation feedback. + + Args: + overall_feedback: Parsed overall evaluation payload. + + Returns: + Combined score from nested ``score_breakdown``, or None. + """ + if overall_feedback is None: + return None + breakdown = overall_feedback.get("score_breakdown") + if not isinstance(breakdown, dict) or not breakdown: + return None + return SessionEvaluationAggregator.total_score_from_breakdown(breakdown) + + +def _section_display_score(section: TheorySection | CodingSection) -> int: + """Resolve earned points from one section aggregate. + + Args: + section: Theory or coding section aggregate. + + Returns: + Best-effort earned score for the section. + """ + if section.status == "skipped": + return 0 + if section.section_score is not None: + return section.section_score + return section.total_score() + + +def completed_score_fallback( + shell: DomainInterview, + theory: TheorySection | None, + coding: CodingSection | None, +) -> int | None: + """Resolve a completed session score from section aggregates when feedback lacks it. + + Args: + shell: Interview shell aggregate. + theory: Theory section aggregate, if present. + coding: Coding section aggregate, if present. + + Returns: + Combined best-effort score across present sections, or None. + """ + del shell + total = 0 + found = False + for section in (theory, coding): + if section is None: + continue + found = True + total += _section_display_score(section) + return total if found else None diff --git a/app/interview/services/section_evaluation.py b/app/interview/services/section_evaluation.py new file mode 100644 index 0000000..57cf160 --- /dev/null +++ b/app/interview/services/section_evaluation.py @@ -0,0 +1,42 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Shared helpers for building per-section evaluation summaries.""" + +from __future__ import annotations + +from typing import Any + +from app.interview.services.sections import SectionEvaluationSummary, SectionKind + + +def build_section_evaluation_summary( + section_kind: SectionKind, + *, + section_status: str, + items: tuple[dict[str, Any], ...], + total_score: int, + max_score: int, + cached_narrative: dict[str, Any] | None, +) -> SectionEvaluationSummary: + """Build a ``SectionEvaluationSummary`` from section aggregate fields. + + Args: + section_kind: Section kind identifier. + section_status: Persisted section status string. + items: Per-task evaluation rows for the section. + total_score: Earned points before skip normalization. + max_score: Maximum achievable points before skip normalization. + cached_narrative: Cached section feedback payload, if any. + + Returns: + Normalized section evaluation summary for session completion. + """ + skipped = section_status == "skipped" + return SectionEvaluationSummary( + section=section_kind, + score=0 if skipped else total_score, + max_score=0 if skipped else max_score, + items=items, + cached_narrative=cached_narrative, + skipped=skipped, + ) diff --git a/app/interview/services/section_feedback.py b/app/interview/services/section_feedback.py new file mode 100644 index 0000000..207f312 --- /dev/null +++ b/app/interview/services/section_feedback.py @@ -0,0 +1,52 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Resolve section narrative feedback from cache or per-task fallbacks.""" + +from __future__ import annotations + +from typing import Any + + +def resolve_section_feedback( + cached: dict[str, Any] | None, + items: tuple[dict[str, Any], ...], + *, + item_id_key: str, +) -> dict[str, Any]: + """Return cached section feedback or synthesize it from per-task rows. + + Args: + cached: Persisted section feedback payload, if any. + items: Per-task evaluation rows for the section. + item_id_key: Field name for the task or question identifier. + + Returns: + Section feedback dict with narrative fields and score breakdown. + """ + if cached: + return cached + + feedback_texts = [ + str(item["feedback"]).strip() for item in items if item.get("feedback") + ] + section_feedback = ( + " ".join(feedback_texts) if feedback_texts else "Section complete." + ) + + question_rows: dict[str, dict[str, int]] = {} + for item in items: + item_id = str(item.get(item_id_key, "?")) + round_num = int(item.get("round", 0)) + key = item_id if round_num == 0 else f"{item_id}:r{round_num}" + score = item.get("score") + question_rows[key] = { + "score": score if isinstance(score, int) else 0, + "max": 5, + } + + return { + "section_feedback": section_feedback, + "topics_to_review": [], + "strengths_summary": [], + "score_breakdown": question_rows, + } diff --git a/app/interview/services/section_prefetch.py b/app/interview/services/section_prefetch.py new file mode 100644 index 0000000..1fab94d --- /dev/null +++ b/app/interview/services/section_prefetch.py @@ -0,0 +1,63 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Background prefetch of section narrative feedback after phase completion.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +import logging +from typing import Any + +from app.ai.base import AIProvider +from app.platform.services.config import ConfigService + +logger = logging.getLogger(__name__) + +EvaluationPayload = tuple[dict[str, Any], int] + + +async def prefetch_section_feedback( + interview_id: str, + *, + section_name: str, + should_prefetch: Callable[[], bool], + evaluate: Callable[[AIProvider], Awaitable[EvaluationPayload | None]], + persist: Callable[[dict[str, Any], int], None], +) -> None: + """Generate and persist cached section feedback when prerequisites are met. + + Args: + interview_id: Parent interview UUID. + section_name: Section kind label for log messages (``theory`` or ``coding``). + should_prefetch: Returns True when feedback should be generated. + evaluate: Async LLM evaluation returning payload dict and section score. + persist: Saves feedback payload and section score when evaluation succeeds. + """ + if not should_prefetch(): + return + + try: + provider = ConfigService.create_provider_from_config() + except ValueError: + logger.warning( + "Skipping %s section prefetch for %s: provider not configured", + section_name, + interview_id, + ) + return + + try: + result = await evaluate(provider) + except Exception: + logger.exception( + "%s section prefetch failed for interview %s", + section_name.capitalize(), + interview_id, + ) + return + + if result is None: + return + + payload, score = result + persist(payload, score) diff --git a/app/interview/services/section_review_support.py b/app/interview/services/section_review_support.py new file mode 100644 index 0000000..5e2c229 --- /dev/null +++ b/app/interview/services/section_review_support.py @@ -0,0 +1,154 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Shared helpers for completed section review page context.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from app.interview.domain.serialization import parse_session_spec +from app.interview.domain.value_objects import SessionSelection +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.schemas.interview import InterviewRead +from app.interview.services.dashboard import DashboardBuilder +from app.interview.services.rules.selection import session_selection_summary_lines +from app.interview.services.section_feedback import resolve_section_feedback +from app.interview.services.sections import SectionEvaluationSummary, SectionKind +from app.shared.locales import SUPPORTED_LOCALES + + +@dataclass(frozen=True, slots=True) +class CompletedInterviewSnapshot: + """Loaded completed interview shell for section review pages. + + Attributes: + interview: Completed interview read model. + session: Parsed session selection from ``selection_spec``. + """ + + interview: InterviewRead + session: SessionSelection + + +def load_completed_interview(interview_id: str) -> CompletedInterviewSnapshot | None: + """Load a completed interview read model in one unit-of-work. + + Args: + interview_id: Parent session UUID. + + Returns: + Snapshot for review rendering, or None when missing or still active. + """ + with InterviewUnitOfWork() as uow: + interview = uow.interviews.get_read_model(interview_id) + if interview is None or interview.status != "completed": + return None + session = parse_session_spec(interview.selection_spec) + return CompletedInterviewSnapshot(interview=interview, session=session) + + +def section_score_bounds( + *, + skipped: bool, + total_score: int, + max_score: int, +) -> tuple[int, int]: + """Normalize section score bounds for skipped sections. + + Args: + skipped: Whether the section was skipped at session end. + total_score: Earned section points. + max_score: Maximum achievable section points. + + Returns: + Tuple of display score and max score. + """ + if skipped: + return 0, 0 + return total_score, max_score + + +def shared_review_fields( + interview_id: str, + snapshot: CompletedInterviewSnapshot, +) -> dict[str, Any]: + """Build review context fields shared by theory and coding pages. + + Args: + interview_id: Parent session UUID. + snapshot: Completed interview snapshot. + + Returns: + Dict with title, selection lines, locale label, and results URL. + """ + interview = snapshot.interview + return { + "interview_id": interview_id, + "interview_title": DashboardBuilder.interview_display_title(interview), + "selection_lines": session_selection_summary_lines(snapshot.session), + "locale_label": SUPPORTED_LOCALES.get(interview.locale, interview.locale), + "results_url": f"/interview/{interview_id}/results", + } + + +def resolved_section_feedback( + summary: SectionEvaluationSummary, + *, + item_id_key: str, + cached_payload: dict[str, Any] | None, +) -> dict[str, Any]: + """Resolve section narrative feedback from cache or per-task rows. + + Args: + summary: Section evaluation summary from query services. + item_id_key: Identifier field name in summary item rows. + cached_payload: Persisted section feedback payload, if any. + + Returns: + Section feedback dict for templates. + """ + return resolve_section_feedback( + cached_payload, + summary.items, + item_id_key=item_id_key, + ) + + +def review_score_fields( + summary: SectionEvaluationSummary, + *, + total_score: int, + max_score: int, +) -> dict[str, int]: + """Build normalized score fields for section review templates. + + Args: + summary: Section evaluation summary. + total_score: Earned points from the section aggregate. + max_score: Maximum achievable points from the section aggregate. + + Returns: + Dict with ``section_score`` and ``section_max_score``. + """ + score, section_max = section_score_bounds( + skipped=summary.skipped, + total_score=total_score, + max_score=max_score, + ) + return { + "section_score": score, + "section_max_score": section_max, + } + + +def item_id_key_for(section_kind: SectionKind) -> str: + """Return the item identifier field for a section kind. + + Args: + section_kind: Section kind identifier. + + Returns: + ``question_id`` for theory or ``task_id`` for coding. + """ + return "question_id" if section_kind == "theory" else "task_id" diff --git a/app/interview/services/section_service_support.py b/app/interview/services/section_service_support.py new file mode 100644 index 0000000..b5ffdc5 --- /dev/null +++ b/app/interview/services/section_service_support.py @@ -0,0 +1,73 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Shared helpers for theory and coding section service implementations.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Coroutine +from typing import Any + +from app.ai.base import AIProvider +from app.interview.services.section_prefetch import prefetch_section_feedback +from app.interview.services.sections import SectionKind + +PersistFn = Callable[[dict[str, Any], int], None] +EvaluateFn = Callable[[AIProvider], Awaitable[tuple[dict[str, object], int] | None]] +ShouldPrefetchFn = Callable[[], bool] + + +def should_prefetch_feedback(section: object | None) -> bool: + """Return whether section narrative feedback should be generated. + + Args: + section: Loaded section aggregate, if any. + + Returns: + True when the section is complete and feedback is not cached yet. + """ + if section is None: + return False + if getattr(section, "section_feedback", None) is not None: + return False + is_complete = getattr(section, "is_complete", None) + if not callable(is_complete): + return False + return bool(is_complete()) + + +def schedule_feedback_prefetch( + run_prefetch: Callable[[], Coroutine[Any, Any, None]], +) -> None: + """Schedule background section feedback prefetch when prerequisites pass. + + Args: + run_prefetch: Coroutine factory for the prefetch workflow. + """ + asyncio.create_task(run_prefetch()) + + +async def run_feedback_prefetch( + interview_id: str, + *, + section_name: SectionKind, + should_prefetch: ShouldPrefetchFn, + evaluate: EvaluateFn, + persist: PersistFn, +) -> None: + """Generate and persist cached section feedback when prerequisites are met. + + Args: + interview_id: Parent interview UUID. + section_name: Section kind label for log messages. + should_prefetch: Returns True when feedback should be generated. + evaluate: Async LLM evaluation returning payload dict and section score. + persist: Saves feedback payload and section score when evaluation succeeds. + """ + await prefetch_section_feedback( + interview_id, + section_name=section_name, + should_prefetch=should_prefetch, + evaluate=evaluate, + persist=persist, + ) diff --git a/app/interview/services/sections.py b/app/interview/services/sections.py new file mode 100644 index 0000000..2f2be2a --- /dev/null +++ b/app/interview/services/sections.py @@ -0,0 +1,147 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Interview section registry and shared section value objects.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, ClassVar, Literal, Protocol, cast + +from app.interview.domain.value_objects import SessionMode + +SectionKind = Literal["theory", "coding"] + + +@dataclass(frozen=True, slots=True) +class SectionEvaluationSummary: + """Evaluation snapshot for one interview section. + + Attributes: + section: Section kind (``theory`` or ``coding``). + score: Earned points for the section. + max_score: Maximum achievable points for the section. + items: Per-task Q&A rows used for evaluation prompts. + cached_narrative: Prefetched section feedback payload, if any. + skipped: True when the user ended the session before completing the section. + """ + + section: SectionKind + score: int + max_score: int + items: tuple[dict[str, Any], ...] + cached_narrative: dict[str, Any] | None = None + skipped: bool = False + + +@dataclass(frozen=True, slots=True) +class SectionPageContext: + """Minimal page context exposed by an interview section. + + Attributes: + section: Section kind identifier. + active: Whether this section is the current user-facing phase. + complete: Whether the user finished all tasks in this section. + """ + + section: SectionKind + active: bool + complete: bool + + +class SectionService(Protocol): + """Contract implemented by theory and coding section services.""" + + section_kind: ClassVar[SectionKind] + + @staticmethod + def is_complete(interview_id: str) -> bool: + """Return whether the section has no remaining user tasks.""" + + @staticmethod + def is_user_facing(interview_id: str) -> bool: + """Return whether the user should interact with this section now.""" + + @staticmethod + def activate_if_pending(interview_id: str) -> bool: + """Promote a pending section to active when prerequisites are met.""" + + @staticmethod + def get_page_context(interview_id: str) -> SectionPageContext | None: + """Return section page metadata for session composition.""" + + @staticmethod + def get_evaluation_summary( + interview_id: str, + ) -> SectionEvaluationSummary | None: + """Return section evaluation data for session completion.""" + + @staticmethod + def on_phase_complete(interview_id: str) -> None: + """Schedule background prefetch when a phase finishes.""" + + @staticmethod + async def ensure_section_feedback(interview_id: str) -> None: + """Synchronously prefetch section feedback before session completion.""" + + +def phase_order_for_mode(session_mode: SessionMode) -> tuple[SectionKind, ...]: + """Return ordered section kinds for a session mode. + + Args: + session_mode: Session mode from setup. + + Returns: + Tuple of section kinds in user-facing order. + """ + if session_mode == "theory_only": + return ("theory",) + if session_mode == "coding_only": + return ("coding",) + if session_mode == "theory_then_coding": + return ("theory", "coding") + return ("coding", "theory") + + +def section_services() -> dict[SectionKind, SectionService]: + """Return section service classes keyed by section kind. + + Returns: + Mapping from section kind to the corresponding section service class. + """ + from app.coding.services.section import CodingSectionService + from app.theory.services.section import TheorySectionService + + return cast( + dict[SectionKind, SectionService], + { + "theory": TheorySectionService, + "coding": CodingSectionService, + }, + ) + + +def prior_sections_complete_for(interview_id: str, section: SectionKind) -> bool: + """Return whether every section before ``section`` in phase order is complete. + + Args: + interview_id: Parent interview UUID. + section: Target section kind to check prerequisites for. + + Returns: + True when all prior sections are finished or absent from the phase order. + """ + from app.interview.repositories.uow import InterviewUnitOfWork + + services = section_services() + with InterviewUnitOfWork() as uow: + aggregate = uow.interviews.get_aggregate(interview_id) + if aggregate is None: + return False + order = phase_order_for_mode(aggregate.session_mode) + if section not in order: + return False + target_index = order.index(section) + for kind in order[:target_index]: + if not services[kind].is_complete(interview_id): + return False + return True diff --git a/app/interview/services/session_evaluator.py b/app/interview/services/session_evaluator.py new file mode 100644 index 0000000..4999271 --- /dev/null +++ b/app/interview/services/session_evaluator.py @@ -0,0 +1,243 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Session-level evaluation built from merged section summaries.""" + +import logging +from typing import Any + +from app.ai.base import AIProvider +from app.interview.services.evaluation_aggregator import MergedSessionEvaluation +from app.interview.services.sections import SectionEvaluationSummary +from app.shared.evaluation_models import InterviewEvaluation +from app.theory.services.evaluator.service import TheoryEvaluatorService + +logger = logging.getLogger(__name__) + + +class SessionEvaluatorService: + """Produce the final session evaluation narrative.""" + + @staticmethod + def _normalize_item_for_session_eval(item: dict[str, Any]) -> dict[str, Any]: + """Map coding task rows to the theory session-evaluator field names. + + Args: + item: Theory Q&A row or coding task submission row. + + Returns: + Dict with ``question_id``, ``question_text``, ``answer_text``, ``score``, + and ``round`` keys for prompt formatting. + """ + if "question_id" in item: + return item + if "task_id" in item: + normalized = { + "question_id": item["task_id"], + "question_text": item.get("prompt_text", ""), + "answer_text": item.get("submitted_code", "(skipped)"), + "score": item.get("score", "N/A"), + "round": item.get("round", 0), + } + feedback = item.get("feedback") + if feedback is not None: + normalized["feedback"] = feedback + return normalized + return item + + @staticmethod + def _build_from_cached_narratives( + merged: MergedSessionEvaluation, + ) -> InterviewEvaluation: + """Build a session evaluation from prefetched section narratives. + + Args: + merged: Combined section summaries with cached narratives. + + Returns: + Session evaluation narrative without ``score_breakdown``. + """ + feedback_parts: list[str] = [] + topics: list[str] = [] + strengths: list[str] = [] + + for section in merged.sections: + if section.skipped: + continue + SessionEvaluatorService._collect_section_narrative( + section, + feedback_parts=feedback_parts, + topics=topics, + strengths=strengths, + ) + + overall_feedback = " ".join( + part for part in feedback_parts if part.strip() + ).strip() + if not overall_feedback: + overall_feedback = "Session evaluation complete." + + return InterviewEvaluation( + overall_feedback=overall_feedback, + topics_to_review=topics, + strengths_summary=strengths, + score_breakdown={}, + ) + + @staticmethod + def _append_unique_strings(target: list[str], values: object) -> None: + """Append string values to a list when not already present. + + Args: + target: Destination list of unique strings. + values: Iterable or scalar value from a narrative payload. + """ + if isinstance(values, str): + if values and values not in target: + target.append(values) + return + if not isinstance(values, list): + return + for value in values: + if isinstance(value, str) and value and value not in target: + target.append(value) + + @staticmethod + def _synthesize_from_merged( + merged: MergedSessionEvaluation, + ) -> InterviewEvaluation: + """Build a session evaluation without calling the LLM. + + Args: + merged: Combined section summaries from the aggregator. + + Returns: + Synthesized session evaluation using per-task feedback when available. + """ + feedback_parts: list[str] = [] + topics: list[str] = [] + strengths: list[str] = [] + + for section in merged.sections: + if section.skipped: + feedback_parts.append( + f"The {section.section} section was not completed." + ) + continue + + SessionEvaluatorService._collect_section_narrative( + section, + feedback_parts=feedback_parts, + topics=topics, + strengths=strengths, + ) + SessionEvaluatorService._collect_item_feedback(section, feedback_parts) + + overall_feedback = "\n\n".join( + part for part in feedback_parts if part.strip() + ).strip() + if not overall_feedback: + overall_feedback = "Session evaluation complete." + + return InterviewEvaluation( + overall_feedback=overall_feedback, + topics_to_review=topics, + strengths_summary=strengths, + score_breakdown={}, + ) + + @staticmethod + def _collect_section_narrative( + section: SectionEvaluationSummary, + *, + feedback_parts: list[str], + topics: list[str], + strengths: list[str], + ) -> None: + """Merge cached section narrative fields into synthesis buffers. + + Args: + section: Section summary with optional cached narrative. + feedback_parts: Overall feedback fragments to append to. + topics: Topic list to extend uniquely. + strengths: Strength list to extend uniquely. + """ + if section.cached_narrative is None: + return + narrative = section.cached_narrative + section_feedback = str(narrative.get("section_feedback", "")).strip() + if section_feedback: + feedback_parts.append(section_feedback) + SessionEvaluatorService._append_unique_strings( + topics, narrative.get("topics_to_review", []) + ) + SessionEvaluatorService._append_unique_strings( + strengths, narrative.get("strengths_summary", []) + ) + + @staticmethod + def _collect_item_feedback( + section: SectionEvaluationSummary, + feedback_parts: list[str], + ) -> None: + """Append per-task feedback rows when no section narrative exists. + + Args: + section: Section summary with evaluated task rows. + feedback_parts: Overall feedback fragments to append to. + """ + if section.cached_narrative is not None: + return + for item in section.items: + feedback = item.get("feedback") + if isinstance(feedback, str) and feedback.strip(): + feedback_parts.append(feedback.strip()) + + @staticmethod + async def evaluate_session( + merged: MergedSessionEvaluation, + *, + provider: AIProvider, + locale: str, + sources_text: str, + ) -> InterviewEvaluation: + """Evaluate a session from merged section summaries. + + Reuses cached section narratives when every actionable section has + prefetched feedback; otherwise calls the LLM once with all Q&A rows. + Falls back to synthesized feedback when the LLM returns invalid output. + + Score breakdown is attached later by ``attach_session_score_breakdown`` + in the completion service before persistence. + + Args: + merged: Combined section summaries from the aggregator. + provider: AI provider for final evaluation. + locale: Locale for the session narrative. + sources_text: Human-readable selection summary for prompts. + + Returns: + Session evaluation narrative without ``score_breakdown``. + """ + if merged.has_cached_narratives(): + return SessionEvaluatorService._build_from_cached_narratives(merged) + + normalized_items = [ + SessionEvaluatorService._normalize_item_for_session_eval(item) + for item in merged.all_items + ] + try: + interview_eval = await TheoryEvaluatorService.evaluate_interview( + provider=provider, + questions_answers=normalized_items, + sources_text=sources_text, + locale=locale, + ) + if not interview_eval.overall_feedback.strip(): + raise ValueError("AI returned empty evaluation") + return interview_eval.model_copy(update={"score_breakdown": {}}) + except Exception as exc: + logger.warning( + "Session LLM evaluation failed; using synthesized feedback: %s", + exc, + ) + return SessionEvaluatorService._synthesize_from_merged(merged) diff --git a/app/interview/services/session_navigation.py b/app/interview/services/session_navigation.py deleted file mode 100644 index af68883..0000000 --- a/app/interview/services/session_navigation.py +++ /dev/null @@ -1,83 +0,0 @@ -# Copyright 2026 GrillKit Contributors -# SPDX-License-Identifier: Apache-2.0 -"""Advance interview sessions to the next unanswered question.""" - -from typing import Any - -from app.interview.domain.entities import Answer -from app.interview.domain.exceptions import InterviewNotFoundError -from app.interview.repositories.uow import InterviewUnitOfWork - - -def next_question_payload(answer: Answer) -> dict[str, Any]: - """Build WebSocket/API payload for the next unanswered question. - - Args: - answer: Next unanswered answer round. - - Returns: - Dict with question fields for the client. - """ - return { - "question_id": answer.question_id, - "order": answer.order, - "question_text": answer.question_text, - "question_code": answer.question_code, - "round": answer.round, - } - - -class SessionNavigationService: - """Shared navigation after a round is completed or timed out.""" - - @staticmethod - def advance_to_next_unanswered( - uow: InterviewUnitOfWork, - interview_id: str, - *, - question_id: str, - round_num: int, - ) -> tuple[dict[str, Any] | None, int | None]: - """Activate the next unanswered round and build client payload. - - Loads the domain aggregate, finds the next open answer, starts its timer - when configured, and persists via ``InterviewRepository.save_aggregate``. - - Args: - uow: Active unit of work. - interview_id: Parent interview UUID. - question_id: Question ID of the completed round. - round_num: Follow-up round that was just completed. - - Returns: - Tuple of (next_question dict or None, timer_remaining_seconds or None). - - Raises: - InterviewNotFoundError: If the session does not exist. - InterviewNotActiveError: If the session is not active. - """ - aggregate = uow.interviews.get_aggregate(interview_id) - if aggregate is None: - raise InterviewNotFoundError(interview_id) - - aggregate.ensure_active() - - current_index = next( - i - for i, answer in enumerate(aggregate.answers) - if answer.question_id == question_id and answer.round == round_num - ) - next_answer = aggregate.find_next_unanswered_after(current_index) - if next_answer is None: - return None, None - - updated = aggregate.start_timer_for_answer(next_answer.id) - uow.interviews.save_aggregate(updated) - - activated = next( - answer for answer in updated.answers if answer.id == next_answer.id - ) - timer_remaining = activated.remaining_seconds( - updated.question_time_limit_seconds - ) - return next_question_payload(activated), timer_remaining diff --git a/app/main.py b/app/main.py index 73a0017..8be4d66 100644 --- a/app/main.py +++ b/app/main.py @@ -13,16 +13,19 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles +from app.coding.api import routes as coding_router from app.interview.api import dashboard as dashboard_router +from app.interview.api import results as results_router from app.interview.api import routes as interview_router from app.interview.api import setup as setup_router -from app.paths import STATIC_DIR from app.platform.api import config as config_router from app.platform.services.speech_runtime import SpeechRuntimeCoordinator from app.question_voice.api import routes as question_voice_router from app.shared.infrastructure.database import run_migrations +from app.shared.paths import STATIC_DIR from app.speech.api import dictation as dictation_router from app.speech.api import routes as speech_router +from app.theory.api import routes as theory_router @asynccontextmanager @@ -55,6 +58,9 @@ def create_app() -> FastAPI: app.include_router(setup_router.router) app.include_router(config_router.router) app.include_router(interview_router.router) + app.include_router(results_router.router) + app.include_router(theory_router.router) + app.include_router(coding_router.router) app.include_router(dictation_router.router) app.include_router(speech_router.router) app.include_router(question_voice_router.router) diff --git a/app/platform/services/config.py b/app/platform/services/config.py index 8c726b7..ed0e982 100644 --- a/app/platform/services/config.py +++ b/app/platform/services/config.py @@ -13,9 +13,9 @@ from app.ai.audio_probe import minimal_wav_bytes from app.ai.base import AIProvider from app.ai.factory import ProviderFactory -from app.paths import CONFIG_PATH, DATA_DIR from app.platform.services.llm_catalog import LLMCatalogService from app.shared.locales import DEFAULT_LOCALE, normalize_locale +from app.shared.paths import CONFIG_PATH, DATA_DIR from app.shared.speech_models import ( DEFAULT_SPEECH_MODEL_SIZE, normalize_speech_model_size, diff --git a/app/platform/services/llm_catalog.py b/app/platform/services/llm_catalog.py index 180768d..03ecd47 100644 --- a/app/platform/services/llm_catalog.py +++ b/app/platform/services/llm_catalog.py @@ -6,8 +6,8 @@ from typing import Any from app.ai.llm_models import LLMCatalog, LLMModelEntry -from app.paths import LLM_MODELS_PATH from app.platform.schemas import NewLLMModel +from app.shared.paths import LLM_MODELS_PATH class LLMCatalogService: diff --git a/app/question_voice/services/piper_storage.py b/app/question_voice/services/piper_storage.py index 3440797..ee055d8 100644 --- a/app/question_voice/services/piper_storage.py +++ b/app/question_voice/services/piper_storage.py @@ -4,7 +4,7 @@ from pathlib import Path -from app.paths import PIPER_VOICES_ROOT +from app.shared.paths import PIPER_VOICES_ROOT from app.shared.tts_voices import normalize_tts_voice_id diff --git a/app/question_voice/services/piper_voice.py b/app/question_voice/services/piper_voice.py index 1e8152f..d03de05 100644 --- a/app/question_voice/services/piper_voice.py +++ b/app/question_voice/services/piper_voice.py @@ -9,7 +9,6 @@ from huggingface_hub import hf_hub_download -from app.paths import PIPER_VOICES_ROOT from app.question_voice.schemas import PiperVoiceStatusRead from app.question_voice.services.piper_runtime import PiperRuntime from app.question_voice.services.piper_storage import ( @@ -29,6 +28,7 @@ promote_staging_dir, ) from app.shared.locales import SUPPORTED_LOCALES, normalize_locale +from app.shared.paths import PIPER_VOICES_ROOT from app.shared.tts_voices import ( PiperVoiceSpec, normalize_tts_voice_id, diff --git a/app/question_voice/services/tts_cache.py b/app/question_voice/services/tts_cache.py index f763d52..40182b9 100644 --- a/app/question_voice/services/tts_cache.py +++ b/app/question_voice/services/tts_cache.py @@ -6,11 +6,11 @@ from pathlib import Path import re -from app.paths import TTS_CACHE_DIR from app.question_voice.services.piper_runtime import PiperRuntime from app.question_voice.services.piper_storage import is_voice_installed from app.question_voice.services.tts_exceptions import QuestionVoiceSynthesisError from app.shared.locales import normalize_locale +from app.shared.paths import TTS_CACHE_DIR _WHITESPACE_RE = re.compile(r"\s+") _CACHE_VERSION = "v2" diff --git a/app/shared/coding.py b/app/shared/coding.py new file mode 100644 index 0000000..87b3942 --- /dev/null +++ b/app/shared/coding.py @@ -0,0 +1,348 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""YAML coding task loader. + +This module loads coding interview tasks from YAML files organized by +track, level, and category under ``data/coding/``. +""" + +from dataclasses import dataclass +from typing import Any + +import yaml + +from app.shared.locales import DEFAULT_LOCALE +from app.shared.paths import CODING_DIR +from app.shared.questions import _resolve_localized_string + +EvaluationMode = str +CodingLanguage = str + + +@dataclass(frozen=True, slots=True) +class CodingTestCase: + """Single stdin/stdout test case for Judge0 execution. + + Attributes: + name: Human-readable test identifier. + stdin: Input passed to the program. + expected_stdout: Expected standard output. + """ + + name: str + stdin: str + expected_stdout: str + + +@dataclass(frozen=True, slots=True) +class CodingSpec: + """Execution and evaluation metadata for a coding task. + + Attributes: + language: Programming language slug (e.g. ``python``). + evaluation_mode: ``tests`` for algorithmic tasks or ``ai`` for open-ended. + starter_code: Initial editor contents shown to the candidate. + entrypoint: Callable name for the test harness (``tests`` mode). + public_tests: Visible test cases executed on Run. + hidden_tests: Hidden test cases executed on Submit. + time_limit_seconds: Per-submission CPU time limit. + memory_limit_kb: Per-submission memory limit. + """ + + language: CodingLanguage + evaluation_mode: EvaluationMode + starter_code: str | None = None + entrypoint: str | None = None + public_tests: tuple[CodingTestCase, ...] = () + hidden_tests: tuple[CodingTestCase, ...] = () + time_limit_seconds: int | None = None + memory_limit_kb: int | None = None + + +@dataclass(frozen=True, slots=True) +class CodingTask: + """Coding challenge loaded from the task bank. + + Attributes: + id: Unique task identifier. + difficulty: Difficulty level (1-5 scale). + tags: List of topic tags. + text: Coding assignment text shown to the candidate. + coding: Execution and evaluation specification. + expected_points: Rubric bullets for AI evaluation. + """ + + id: str + difficulty: int + tags: tuple[str, ...] + text: str + coding: CodingSpec + expected_points: tuple[str, ...] = () + + +def _parse_test_cases( + raw_cases: Any, *, task_id: str, field: str +) -> tuple[CodingTestCase, ...]: + """Parse a list of YAML test case dicts. + + Args: + raw_cases: YAML list value. + task_id: Task id for error messages. + field: Field name for error messages. + + Returns: + Parsed test cases. + + Raises: + ValueError: If the shape is invalid. + """ + if raw_cases is None: + return () + if not isinstance(raw_cases, list): + msg = f"Coding task {task_id}: invalid {field} (expected list)" + raise ValueError(msg) + cases: list[CodingTestCase] = [] + for index, item in enumerate(raw_cases): + if not isinstance(item, dict): + msg = f"Coding task {task_id}: invalid {field}[{index}]" + raise ValueError(msg) + name = item.get("name") + if not isinstance(name, str) or not name: + msg = f"Coding task {task_id}: {field}[{index}] missing name" + raise ValueError(msg) + stdin = item.get("stdin", "") + expected_stdout = item.get("expected_stdout", "") + if not isinstance(stdin, str) or not isinstance(expected_stdout, str): + msg = ( + f"Coding task {task_id}: {field}[{index}] invalid stdin/expected_stdout" + ) + raise ValueError(msg) + cases.append( + CodingTestCase( + name=name, + stdin=stdin, + expected_stdout=expected_stdout, + ) + ) + return tuple(cases) + + +def _parse_coding_spec(raw: dict[str, Any], *, task_id: str) -> CodingSpec: + """Parse the ``coding`` block from a YAML task row. + + Args: + raw: Task dict from YAML. + task_id: Task id for error messages. + + Returns: + Parsed coding specification. + + Raises: + ValueError: If required fields are missing or invalid. + """ + coding = raw.get("coding") + if not isinstance(coding, dict): + msg = f"Coding task {task_id}: missing coding block" + raise ValueError(msg) + language = coding.get("language") + evaluation_mode = coding.get("evaluation_mode") + if not isinstance(language, str) or not language: + msg = f"Coding task {task_id}: missing coding.language" + raise ValueError(msg) + if evaluation_mode not in {"tests", "ai"}: + msg = f"Coding task {task_id}: invalid coding.evaluation_mode" + raise ValueError(msg) + starter_code = coding.get("starter_code") + if starter_code is not None and not isinstance(starter_code, str): + msg = f"Coding task {task_id}: invalid coding.starter_code" + raise ValueError(msg) + entrypoint = coding.get("entrypoint") + if entrypoint is not None and not isinstance(entrypoint, str): + msg = f"Coding task {task_id}: invalid coding.entrypoint" + raise ValueError(msg) + time_limit = coding.get("time_limit_seconds") + if time_limit is not None and not isinstance(time_limit, int): + msg = f"Coding task {task_id}: invalid coding.time_limit_seconds" + raise ValueError(msg) + memory_limit = coding.get("memory_limit_kb") + if memory_limit is not None and not isinstance(memory_limit, int): + msg = f"Coding task {task_id}: invalid coding.memory_limit_kb" + raise ValueError(msg) + return CodingSpec( + language=language, + evaluation_mode=evaluation_mode, + starter_code=starter_code, + entrypoint=entrypoint, + public_tests=_parse_test_cases( + coding.get("public_tests"), task_id=task_id, field="coding.public_tests" + ), + hidden_tests=_parse_test_cases( + coding.get("hidden_tests"), task_id=task_id, field="coding.hidden_tests" + ), + time_limit_seconds=time_limit, + memory_limit_kb=memory_limit, + ) + + +def _parse_task(raw: dict[str, Any], *, locale: str) -> CodingTask: + """Parse one YAML task row. + + Args: + raw: Task dict from YAML. + locale: Locale for prompt text. + + Returns: + Parsed coding task. + + Raises: + ValueError: If required fields are missing or invalid. + """ + task_id = raw.get("id") + if not isinstance(task_id, str) or not task_id: + msg = "Coding task row missing id" + raise ValueError(msg) + difficulty = raw.get("difficulty") + if not isinstance(difficulty, int): + msg = f"Coding task {task_id}: missing difficulty" + raise ValueError(msg) + coding = raw.get("coding") + if not isinstance(coding, dict): + msg = f"Coding task {task_id}: missing coding block" + raise ValueError(msg) + assignment = coding.get("assignment") + if assignment is None: + msg = f"Coding task {task_id}: missing coding.assignment" + raise ValueError(msg) + tags_raw = raw.get("tags", []) + if not isinstance(tags_raw, list): + msg = f"Coding task {task_id}: invalid tags" + raise ValueError(msg) + points_raw = raw.get("expected_points", []) + if not isinstance(points_raw, list): + msg = f"Coding task {task_id}: invalid expected_points" + raise ValueError(msg) + return CodingTask( + id=task_id, + difficulty=difficulty, + tags=tuple(str(tag) for tag in tags_raw), + text=_resolve_localized_string( + assignment, + locale, + field="assignment", + question_id=task_id, + ), + coding=_parse_coding_spec(raw, task_id=task_id), + expected_points=tuple(str(point) for point in points_raw), + ) + + +def load_category( + track: str, + level: str, + category: str, + locale: str = DEFAULT_LOCALE, +) -> list[CodingTask]: + """Load coding tasks for a specific category. + + Args: + track: Task bank slug (e.g. ``python``). + level: Difficulty level (e.g. ``junior``). + category: Category YAML stem (e.g. ``basics``). + locale: Locale for task prompt text. + + Returns: + List of coding tasks. Empty list if the file does not exist. + """ + path = CODING_DIR / track / level / f"{category}.yaml" + if not path.exists(): + return [] + + with open(path) as f: + data = yaml.safe_load(f) + if data is None: + return [] + + tasks: list[CodingTask] = [] + for raw in data.get("tasks", []): + if not isinstance(raw, dict): + continue + tasks.append(_parse_task(raw, locale=locale)) + return tasks + + +def load_categories( + track: str, + level: str, + categories: list[str], + locale: str = DEFAULT_LOCALE, +) -> list[CodingTask]: + """Load and merge coding tasks from multiple categories. + + Args: + track: Task bank slug. + level: Difficulty level slug. + categories: Category YAML stems to load. + locale: Locale for task prompt text. + + Returns: + De-duplicated list of tasks (first occurrence wins by task id). + """ + seen: set[str] = set() + merged: list[CodingTask] = [] + for category in categories: + for task in load_category(track, level, category, locale=locale): + if task.id in seen: + continue + seen.add(task.id) + merged.append(task) + return merged + + +def list_tracks() -> list[str]: + """List task-bank tracks under ``data/coding/``. + + Returns: + Sorted directory names (e.g. ``python``). + """ + if not CODING_DIR.exists(): + return [] + return sorted( + path.name + for path in CODING_DIR.iterdir() + if path.is_dir() and not path.name.startswith(".") + ) + + +def list_levels(track: str) -> list[str]: + """List difficulty levels available for a coding track. + + Args: + track: Task bank slug. + + Returns: + Sorted level directory names. + """ + path = CODING_DIR / track + if not path.exists(): + return [] + return sorted( + level.name + for level in path.iterdir() + if level.is_dir() and not level.name.startswith(".") + ) + + +def list_categories(track: str, level: str) -> list[str]: + """List available categories for a track and level. + + Args: + track: Task bank slug. + level: Difficulty level slug. + + Returns: + Category YAML stems. Empty list if the directory does not exist. + """ + path = CODING_DIR / track / level + if not path.exists(): + return [] + return [f.stem for f in path.glob("*.yaml")] diff --git a/app/shared/evaluation_models.py b/app/shared/evaluation_models.py new file mode 100644 index 0000000..7ae9a6b --- /dev/null +++ b/app/shared/evaluation_models.py @@ -0,0 +1,39 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Shared Pydantic models for structured session and section AI evaluation.""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class SectionEvaluation(BaseModel): + """Narrative evaluation for a single theory or coding section. + + Attributes: + section_feedback: Section-level narrative feedback. + topics_to_review: Topics the candidate should study further. + strengths_summary: Key strengths demonstrated in the section. + score_breakdown: Per-task score breakdown for the section. + """ + + section_feedback: str = Field(..., description="Section narrative feedback") + topics_to_review: list[str] = Field(default_factory=list) + strengths_summary: list[str] = Field(default_factory=list) + score_breakdown: dict[str, Any] = Field(default_factory=dict) + + +class InterviewEvaluation(BaseModel): + """Final evaluation of an entire interview session. + + Attributes: + overall_feedback: Comprehensive narrative feedback on the session. + topics_to_review: Topics the candidate should study further. + strengths_summary: Key strengths demonstrated. + score_breakdown: Per-section and per-task score breakdown. + """ + + overall_feedback: str = Field(..., description="Comprehensive feedback") + topics_to_review: list[str] = Field(default_factory=list) + strengths_summary: list[str] = Field(default_factory=list) + score_breakdown: dict[str, Any] = Field(default_factory=dict) diff --git a/app/shared/infrastructure/database.py b/app/shared/infrastructure/database.py index 150880e..cdb61f7 100644 --- a/app/shared/infrastructure/database.py +++ b/app/shared/infrastructure/database.py @@ -13,7 +13,7 @@ from sqlalchemy.orm import DeclarativeBase, sessionmaker from alembic import command -from app.paths import ALEMBIC_INI, DB_DIR +from app.shared.paths import ALEMBIC_INI, DB_DIR DB_DIR.mkdir(parents=True, exist_ok=True) diff --git a/app/shared/infrastructure/hf_hub_runtime.py b/app/shared/infrastructure/hf_hub_runtime.py index 6d6746b..a73a6c1 100644 --- a/app/shared/infrastructure/hf_hub_runtime.py +++ b/app/shared/infrastructure/hf_hub_runtime.py @@ -4,7 +4,7 @@ import os -from app.paths import DATA_DIR +from app.shared.paths import DATA_DIR _CONFIGURED = False diff --git a/app/shared/infrastructure/models.py b/app/shared/infrastructure/models.py index a1f9db9..d01dd04 100644 --- a/app/shared/infrastructure/models.py +++ b/app/shared/infrastructure/models.py @@ -3,7 +3,7 @@ """SQLAlchemy models for GrillKit. This module defines all database models, including interview sessions, -answers, and future entities. +theory tasks, and future entities. """ from datetime import UTC, datetime @@ -15,23 +15,20 @@ class Interview(Base): - """Interview session record. + """Interview session shell record. - Stores the top-level metadata for an interview session. + Stores top-level session metadata. Theory tasks live on ``theory_sections``. Attributes: id: Unique interview identifier (UUID v4). locale: Language for AI feedback and follow-ups (e.g., "en", "ru"). - selection_spec: JSON describing tracks, levels, and topic categories. - question_count: Number of questions in this interview. - question_ids: JSON list of question IDs in display order. - question_time_limit_seconds: Per-round time limit in seconds (None if disabled). + selection_spec: JSON describing session mode and section branches (v2). + session_mode: Session mode (theory_only, coding_only, or combined order). status: Interview status ("active", "completed"). - score: Final total score (None if not graded yet). overall_feedback: JSON string with final evaluation feedback (None if not evaluated). started_at: Timestamp when interview began. completed_at: Timestamp when interview ended (None if active). - answers: Relationship to Answer records, ordered by (order, round). + theory_section: Optional theory section for this session. """ __tablename__ = "interviews" @@ -39,13 +36,10 @@ class Interview(Base): id: Mapped[str] = mapped_column(String, primary_key=True) locale: Mapped[str] = mapped_column(String, default="en", server_default="en") selection_spec: Mapped[str] = mapped_column(Text) - question_count: Mapped[int] = mapped_column(default=5) - question_ids: Mapped[str] = mapped_column(Text, default="[]") - question_time_limit_seconds: Mapped[int | None] = mapped_column( - Integer, nullable=True + session_mode: Mapped[str] = mapped_column( + String, default="theory_only", server_default="theory_only" ) status: Mapped[str] = mapped_column(String, default="active") - score: Mapped[int | None] = mapped_column(Integer, nullable=True) overall_feedback: Mapped[str | None] = mapped_column(Text, nullable=True) started_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() @@ -54,25 +48,78 @@ class Interview(Base): DateTime(timezone=True), nullable=True ) - answers: Mapped[list["Answer"]] = relationship( - "Answer", + theory_section: Mapped["TheorySection | None"] = relationship( + "TheorySection", back_populates="interview", - order_by="Answer.order, Answer.round", + uselist=False, + cascade="all, delete-orphan", + ) + coding_section: Mapped["CodingSection | None"] = relationship( + "CodingSection", + back_populates="interview", + uselist=False, cascade="all, delete-orphan", ) +class TheorySection(Base): + """Theory section record within an interview session. + + Stores theory-specific configuration, scoring, and section feedback. + One interview may have at most one theory section. + + Attributes: + id: Auto-increment primary key. + interview_id: Foreign key to the parent Interview. + selection_spec: JSON describing tracks, levels, and topic categories. + question_count: Number of theory questions in this section. + task_time_limit_seconds: Per-task time limit in seconds (None if disabled). + status: Section status (``active``, ``completed``, or ``skipped``). + section_score: Aggregated section score when evaluated. + section_feedback: JSON string with section narrative feedback. + locale: Language for AI feedback and follow-ups. + interview: Back-reference to the parent Interview. + tasks: Theory task rows linked to this section. + """ + + __tablename__ = "theory_sections" + + id: Mapped[int] = mapped_column(primary_key=True) + interview_id: Mapped[str] = mapped_column( + String, + ForeignKey("interviews.id", ondelete="CASCADE"), + unique=True, + ) + selection_spec: Mapped[str] = mapped_column(Text) + question_count: Mapped[int] = mapped_column(default=5) + task_time_limit_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) + status: Mapped[str] = mapped_column(String, default="active") + section_score: Mapped[int | None] = mapped_column(Integer, nullable=True) + section_feedback: Mapped[str | None] = mapped_column(Text, nullable=True) + locale: Mapped[str] = mapped_column(String, default="en", server_default="en") + + interview: Mapped["Interview"] = relationship( + "Interview", back_populates="theory_section" + ) + + tasks: Mapped[list["Answer"]] = relationship( + "Answer", + back_populates="theory_section", + order_by="Answer.order, Answer.round", + ) + + class Answer(Base): - """Individual answer record within an interview session. + """Theory task row persisted in the ``answers`` table. Each row represents one answer attempt: the initial answer (round=0) or a follow-up (round>0) when the AI digs deeper on a topic. Attributes: id: Auto-increment primary key. - interview_id: Foreign key to the parent Interview. + theory_section_id: Foreign key to the parent TheorySection. question_id: Question ID from YAML bank (e.g., "ds-001"). - order: Display order within the session (1-based). + order: Display order within the section (1-based). round: Follow-up round number (0 = initial, 1+ = follow-ups). question_text: Snapshot of the question text at time of asking. question_code: Snapshot of the optional code snippet. @@ -81,15 +128,15 @@ class Answer(Base): feedback: AI-generated feedback text. started_at: When this round became active for the user (None if timer off). created_at: Timestamp when this answer was recorded. - interview: Back-reference to the parent Interview. + theory_section: Back-reference to the parent TheorySection. """ __tablename__ = "answers" id: Mapped[int] = mapped_column(primary_key=True) - interview_id: Mapped[str] = mapped_column( - String, - ForeignKey("interviews.id", ondelete="CASCADE"), + theory_section_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("theory_sections.id", ondelete="CASCADE"), ) question_id: Mapped[str] = mapped_column(String) order: Mapped[int] = mapped_column() @@ -107,4 +154,160 @@ class Answer(Base): default=lambda: datetime.now(UTC), ) - interview: Mapped["Interview"] = relationship("Interview", back_populates="answers") + theory_section: Mapped["TheorySection"] = relationship( + "TheorySection", back_populates="tasks" + ) + + +class CodingSection(Base): + """Coding section record within an interview session. + + Stores coding-specific configuration, scoring, and section feedback. + One interview may have at most one coding section. + + Attributes: + id: Auto-increment primary key. + interview_id: Foreign key to the parent Interview. + selection_spec: JSON describing tracks, levels, and topic categories. + task_count: Number of coding tasks in this section. + task_time_limit_seconds: Per-task time limit in seconds (None if disabled). + status: Section status (``pending``, ``active``, ``completed``, or ``skipped``). + section_score: Aggregated section score when evaluated. + section_feedback: JSON string with section narrative feedback. + locale: Language for AI feedback. + interview: Back-reference to the parent Interview. + tasks: Coding task rows linked to this section. + """ + + __tablename__ = "coding_sections" + + id: Mapped[int] = mapped_column(primary_key=True) + interview_id: Mapped[str] = mapped_column( + String, + ForeignKey("interviews.id", ondelete="CASCADE"), + unique=True, + ) + selection_spec: Mapped[str] = mapped_column(Text) + task_count: Mapped[int] = mapped_column(default=1) + task_time_limit_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) + status: Mapped[str] = mapped_column(String, default="active") + section_score: Mapped[int | None] = mapped_column(Integer, nullable=True) + section_feedback: Mapped[str | None] = mapped_column(Text, nullable=True) + locale: Mapped[str] = mapped_column(String, default="en", server_default="en") + + interview: Mapped["Interview"] = relationship( + "Interview", back_populates="coding_section" + ) + + tasks: Mapped[list["CodingTask"]] = relationship( + "CodingTask", + back_populates="coding_section", + order_by="CodingTask.order, CodingTask.round", + cascade="all, delete-orphan", + ) + + +class CodingTask(Base): + """Coding task row within a coding section. + + Attributes: + id: Auto-increment primary key. + coding_section_id: Foreign key to the parent CodingSection. + task_id: Task ID from the coding bank. + order: Display order within the section (1-based). + round: Follow-up round number (0 = initial). + prompt_text: Snapshot of the task prompt. + task_spec: JSON with starter code and public test metadata. + submitted_code: Final submitted source code (None if pending). + submit_test_summary: JSON with hidden test results after submit. + score: AI-assigned score (1-5, or 0 on timeout). + feedback: AI-generated feedback text. + started_at: When this round became active for the user. + created_at: Timestamp when this task row was created. + coding_section: Back-reference to the parent CodingSection. + run_attempts: Code run attempts for this task. + """ + + __tablename__ = "coding_tasks" + + id: Mapped[int] = mapped_column(primary_key=True) + coding_section_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("coding_sections.id", ondelete="CASCADE"), + ) + task_id: Mapped[str] = mapped_column(String) + order: Mapped[int] = mapped_column() + round: Mapped[int] = mapped_column(Integer, default=0) + prompt_text: Mapped[str] = mapped_column(Text) + task_spec: Mapped[str] = mapped_column(Text) + submitted_code: Mapped[str | None] = mapped_column(Text, nullable=True) + submit_test_summary: Mapped[str | None] = mapped_column(Text, nullable=True) + score: Mapped[int | None] = mapped_column(Integer, nullable=True) + feedback: Mapped[str | None] = mapped_column(Text, nullable=True) + started_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + ) + + coding_section: Mapped["CodingSection"] = relationship( + "CodingSection", back_populates="tasks" + ) + + run_attempts: Mapped[list["CodeRunAttempt"]] = relationship( + "CodeRunAttempt", + back_populates="coding_task", + order_by="CodeRunAttempt.attempt_no", + cascade="all, delete-orphan", + ) + + +class CodeRunAttempt(Base): + """Immutable snapshot of one Run action on a coding task. + + Attributes: + id: Auto-increment primary key. + coding_task_id: Foreign key to the parent CodingTask. + attempt_no: Sequential attempt number for the task. + source_code: Editor contents at Run time. + language: Programming language slug. + status: Run outcome status. + stdout: Captured standard output. + stderr: Captured standard error. + compile_output: Compiler output when applicable. + tests_passed: Number of public tests passed. + tests_total: Total public tests executed. + test_results: JSON per-test result details. + duration_ms: Judge0 execution duration in milliseconds. + created_at: Timestamp when the attempt was recorded. + coding_task: Back-reference to the parent CodingTask. + """ + + __tablename__ = "code_run_attempts" + + id: Mapped[int] = mapped_column(primary_key=True) + coding_task_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("coding_tasks.id", ondelete="CASCADE"), + ) + attempt_no: Mapped[int | None] = mapped_column(Integer, nullable=True) + source_code: Mapped[str] = mapped_column(Text) + language: Mapped[str] = mapped_column(String) + status: Mapped[str] = mapped_column(String) + stdout: Mapped[str | None] = mapped_column(Text, nullable=True) + stderr: Mapped[str | None] = mapped_column(Text, nullable=True) + compile_output: Mapped[str | None] = mapped_column(Text, nullable=True) + tests_passed: Mapped[int | None] = mapped_column(Integer, nullable=True) + tests_total: Mapped[int | None] = mapped_column(Integer, nullable=True) + test_results: Mapped[str | None] = mapped_column(Text, nullable=True) + duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + ) + + coding_task: Mapped["CodingTask"] = relationship( + "CodingTask", back_populates="run_attempts" + ) diff --git a/app/paths.py b/app/shared/paths.py similarity index 86% rename from app/paths.py rename to app/shared/paths.py index ffb148e..dd4c1ab 100644 --- a/app/paths.py +++ b/app/shared/paths.py @@ -4,7 +4,7 @@ from pathlib import Path -PROJECT_ROOT = Path(__file__).resolve().parent.parent +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent DATA_DIR = PROJECT_ROOT / "data" CONFIG_PATH = DATA_DIR / "config.json" LLM_MODELS_PATH = DATA_DIR / "llm_models.json" @@ -12,6 +12,7 @@ PIPER_VOICES_ROOT = DATA_DIR / "piper-voices" TTS_CACHE_DIR = DATA_DIR / "tts-cache" QUESTIONS_DIR = DATA_DIR / "questions" +CODING_DIR = DATA_DIR / "coding" DB_DIR = DATA_DIR / "db" STATIC_DIR = PROJECT_ROOT / "static" TEMPLATES_DIR = PROJECT_ROOT / "templates" diff --git a/app/questions.py b/app/shared/questions.py similarity index 99% rename from app/questions.py rename to app/shared/questions.py index 337ca8e..0065067 100644 --- a/app/questions.py +++ b/app/shared/questions.py @@ -12,8 +12,8 @@ import yaml -from app.paths import QUESTIONS_DIR from app.shared.locales import DEFAULT_LOCALE, normalize_locale +from app.shared.paths import QUESTIONS_DIR logger = logging.getLogger(__name__) diff --git a/app/shared/structured_evaluation.py b/app/shared/structured_evaluation.py new file mode 100644 index 0000000..fabafe3 --- /dev/null +++ b/app/shared/structured_evaluation.py @@ -0,0 +1,68 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Shared structured LLM evaluation helpers.""" + +from __future__ import annotations + +from pydantic import BaseModel + +from app.ai.base import AIProvider, Message + + +async def evaluate_with_schema[T: BaseModel]( + provider: AIProvider, + *, + locale: str, + instructions: str, + response_model: type[T], + user_text: str, + audio_wav: bytes | None = None, + max_tokens: int = 1000, +) -> T: + """Run a structured evaluation via text or multimodal generation. + + Args: + provider: Configured AI provider instance. + locale: Locale for AI feedback. + instructions: Evaluator instruction template constant. + response_model: Pydantic model for parsed JSON output. + user_text: User message text (full content for text mode; context for audio). + audio_wav: Optional WAV bytes for multimodal evaluation. + max_tokens: Maximum tokens for the model response. + + Returns: + Parsed evaluation model instance. + + Raises: + ValueError: If AI response is invalid or connection fails. + """ + 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) diff --git a/app/shared/task_timer.py b/app/shared/task_timer.py new file mode 100644 index 0000000..a716f63 --- /dev/null +++ b/app/shared/task_timer.py @@ -0,0 +1,91 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Shared per-task timer helpers for theory and coding rounds.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +DEFAULT_TIMEOUT_GRACE_SECONDS = 2 + + +def timer_deadline( + started_at: datetime | None, + limit_seconds: int, + *, + label: str = "Task", +) -> datetime: + """Compute the absolute deadline for a timed task round. + + Args: + started_at: When the round timer started. + limit_seconds: Allowed duration in seconds. + label: Entity label for error messages. + + Returns: + Timezone-aware deadline timestamp. + + Raises: + ValueError: If ``started_at`` is missing. + """ + if started_at is None: + raise ValueError(f"{label} round has no started_at") + normalized = started_at + if normalized.tzinfo is None: + normalized = normalized.replace(tzinfo=UTC) + return normalized + timedelta(seconds=limit_seconds) + + +def is_timer_expired( + started_at: datetime | None, + limit_seconds: int | None, + now: datetime | None = None, + *, + grace_seconds: int = DEFAULT_TIMEOUT_GRACE_SECONDS, +) -> bool: + """Return whether a per-round timer has elapsed. + + Args: + started_at: When the round timer started. + limit_seconds: Configured limit (``None`` disables the timer). + now: Current time (defaults to UTC now). + grace_seconds: Extra seconds allowed for network delay on timeout submit. + + Returns: + True if the timer is enabled and the deadline plus grace has passed. + """ + if limit_seconds is None or started_at is None: + return False + if now is None: + now = datetime.now(UTC) + if now.tzinfo is None: + now = now.replace(tzinfo=UTC) + return now >= timer_deadline(started_at, limit_seconds) + timedelta( + seconds=grace_seconds + ) + + +def remaining_seconds( + started_at: datetime | None, + limit_seconds: int | None, + now: datetime | None = None, +) -> int | None: + """Return whole seconds left on the timer, or None if disabled. + + Args: + started_at: When the round timer started. + limit_seconds: Configured limit for the section. + now: Current time (defaults to UTC now). + + Returns: + Non-negative seconds remaining, or None when the timer is off. + """ + if limit_seconds is None or started_at is None: + return None + if now is None: + now = datetime.now(UTC) + if now.tzinfo is None: + now = now.replace(tzinfo=UTC) + end = timer_deadline(started_at, limit_seconds) + delta = (end - now).total_seconds() + return max(0, int(delta)) diff --git a/app/speech/services/whisper_model.py b/app/speech/services/whisper_model.py index 4493059..77f3c88 100644 --- a/app/speech/services/whisper_model.py +++ b/app/speech/services/whisper_model.py @@ -9,7 +9,6 @@ from huggingface_hub import snapshot_download -from app.paths import WHISPER_MODELS_ROOT from app.shared.infrastructure.artifact_download import ArtifactDownloadService from app.shared.infrastructure.artifact_status import ArtifactStatusBuilder from app.shared.infrastructure.hf_download_progress import hf_progress_tqdm_factory @@ -19,6 +18,7 @@ promote_staging_dir, ) from app.shared.locales import SUPPORTED_LOCALES, normalize_locale +from app.shared.paths import WHISPER_MODELS_ROOT from app.shared.speech_models import ( SpeechModelSpec, normalize_speech_model_size, diff --git a/app/speech/services/whisper_storage.py b/app/speech/services/whisper_storage.py index df9c5a1..12ff12b 100644 --- a/app/speech/services/whisper_storage.py +++ b/app/speech/services/whisper_storage.py @@ -4,7 +4,7 @@ from pathlib import Path -from app.paths import WHISPER_MODELS_ROOT +from app.shared.paths import WHISPER_MODELS_ROOT from app.shared.speech_models import normalize_speech_model_size diff --git a/app/templating.py b/app/templating.py index c5e398c..ba936ec 100644 --- a/app/templating.py +++ b/app/templating.py @@ -4,7 +4,7 @@ from fastapi.templating import Jinja2Templates -from app.paths import STATIC_DIR, TEMPLATES_DIR +from app.shared.paths import STATIC_DIR, TEMPLATES_DIR templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) diff --git a/app/theory/__init__.py b/app/theory/__init__.py new file mode 100644 index 0000000..db0337b --- /dev/null +++ b/app/theory/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory feature: questions, answers, timer, and evaluation within a session.""" diff --git a/app/theory/api/__init__.py b/app/theory/api/__init__.py new file mode 100644 index 0000000..c380a2a --- /dev/null +++ b/app/theory/api/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory HTTP and WebSocket transport (scaffold for Phase 4+).""" diff --git a/app/interview/api/audio_answer.py b/app/theory/api/audio_answer.py similarity index 83% rename from app/interview/api/audio_answer.py rename to app/theory/api/audio_answer.py index 047e394..b298329 100644 --- a/app/interview/api/audio_answer.py +++ b/app/theory/api/audio_answer.py @@ -1,6 +1,6 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""HTTP audio-answer transport adapter for interview sessions.""" +"""HTTP audio-answer transport adapter for theory sessions.""" from collections.abc import AsyncIterator import json @@ -8,16 +8,17 @@ from app.ai.base import AIProvider from app.ai.speech_transcriber import SpeechTranscriber -from app.interview.api.ws_protocol import domain_error_to_wire, event_to_message from app.interview.domain.exceptions import InterviewDomainError from app.interview.services.ai_errors import ai_error_message_for_client -from app.interview.services.answer_processing import AnswerProcessingService from app.shared.infrastructure.audio_wav import validate_wav_bytes +from app.theory.api.ws_protocol import domain_error_to_wire, event_to_message +from app.theory.domain.exceptions import TheoryDomainError +from app.theory.services.submission import TheorySubmissionService logger = logging.getLogger(__name__) -class InterviewAudioAnswerAdapter: +class TheoryAudioAnswerAdapter: """Validate multipart input and stream NDJSON wire payloads.""" @staticmethod @@ -25,7 +26,7 @@ def parse_submission(*, question_id: str, wav_bytes: bytes) -> str: """Validate form fields and WAV payload before submission. Args: - question_id: Question ID from the active answer row. + question_id: Question ID from the active task row. wav_bytes: Uploaded WAV audio bytes. Returns: @@ -39,7 +40,7 @@ def parse_submission(*, question_id: str, wav_bytes: bytes) -> str: raise ValueError("question_id is required") if not wav_bytes: raise ValueError("Audio file is required") - AnswerProcessingService.require_audio_answer_enabled() + TheorySubmissionService.require_audio_answer_enabled() validate_wav_bytes(wav_bytes) return normalized_question_id @@ -65,7 +66,7 @@ async def stream_ndjson_lines( One JSON object per line for ``StreamingResponse``. """ try: - async for event in AnswerProcessingService.stream_audio_answer_submission( + async for event in TheorySubmissionService.stream_audio_answer_submission( interview_id=interview_id, question_id=question_id, wav_bytes=wav_bytes, @@ -73,7 +74,7 @@ async def stream_ndjson_lines( transcriber=transcriber, ): yield json.dumps(event_to_message(event)) + "\n" - except InterviewDomainError as exc: + except (InterviewDomainError, TheoryDomainError) as exc: yield json.dumps(domain_error_to_wire(exc)) + "\n" except ValueError as exc: yield json.dumps({"type": "error", "message": str(exc)}) + "\n" diff --git a/app/theory/api/routes.py b/app/theory/api/routes.py new file mode 100644 index 0000000..c65ffde --- /dev/null +++ b/app/theory/api/routes.py @@ -0,0 +1,157 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory section HTTP and WebSocket transport.""" + +import logging +from typing import Annotated, Any + +from fastapi import ( + APIRouter, + File, + Form, + HTTPException, + UploadFile, + WebSocket, + WebSocketDisconnect, +) +from fastapi.responses import StreamingResponse + +from app.interview.api.deps import ( + AIProviderDep, + InterviewQueryDep, + SessionCompletionServiceDep, + SpeechTranscriberDep, +) +from app.theory.api.audio_answer import TheoryAudioAnswerAdapter +from app.theory.api.ws_session import TheoryWebSocketService + +router = APIRouter(prefix="/interview", tags=["theory"]) + +logger = logging.getLogger(__name__) + + +async def _safe_send_json(websocket: WebSocket, message: dict[str, Any]) -> bool: + """Send a JSON message, returning False if the client already disconnected. + + Args: + websocket: Active theory WebSocket. + message: Payload to send. + + Returns: + True if the message was sent, False if the socket is closed. + """ + try: + await websocket.send_json(message) + return True + except (WebSocketDisconnect, RuntimeError): + return False + + +@router.post("/{interview_id}/theory/audio-answer") +async def submit_theory_audio_answer( + interview_id: str, + provider: AIProviderDep, + transcriber: SpeechTranscriberDep, + question_id: Annotated[str, Form()], + file: Annotated[UploadFile, File()], +) -> StreamingResponse: + """Submit a spoken theory answer and stream NDJSON evaluation events. + + Args: + interview_id: Interview session UUID. + provider: AI provider for multimodal evaluation. + transcriber: Loaded Whisper transcriber for audio transcription. + question_id: Question ID from the active task row. + file: Uploaded WAV audio answer. + + Returns: + NDJSON stream of server events. + + Raises: + HTTPException: When required fields are missing or WAV validation fails. + """ + wav_bytes = await file.read() + try: + normalized_question_id = TheoryAudioAnswerAdapter.parse_submission( + question_id=question_id, + wav_bytes=wav_bytes, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return StreamingResponse( + TheoryAudioAnswerAdapter.stream_ndjson_lines( + interview_id=interview_id, + question_id=normalized_question_id, + wav_bytes=wav_bytes, + provider=provider, + transcriber=transcriber, + ), + media_type="application/x-ndjson", + ) + + +async def handle_theory_websocket( + websocket: WebSocket, + interview_id: str, + interview_query: InterviewQueryDep, + session_completion: SessionCompletionServiceDep, + provider: AIProviderDep, +) -> None: + """Run the theory WebSocket message loop until disconnect. + + Args: + websocket: The WebSocket connection. + interview_id: The session UUID. + interview_query: Interview read service. + session_completion: Session completion service. + provider: AI provider for answer and session evaluation. + """ + await websocket.accept() + + try: + while True: + try: + raw = await websocket.receive_json() + except RuntimeError: + break + + async for message in TheoryWebSocketService.iter_responses( + raw, + interview_id=interview_id, + provider=provider, + session_completion=session_completion, + interview_query=interview_query, + ): + if not await _safe_send_json(websocket, message): + break + except WebSocketDisconnect: + logger.debug("Theory WebSocket disconnected for session %s", interview_id) + except RuntimeError: + logger.debug("Theory WebSocket closed for session %s", interview_id) + + +@router.websocket("/{interview_id}/theory/ws") +async def theory_ws( + websocket: WebSocket, + interview_id: str, + interview_query: InterviewQueryDep, + session_completion: SessionCompletionServiceDep, + provider: AIProviderDep, +) -> None: + """WebSocket endpoint for real-time theory task interaction. + + Args: + websocket: The WebSocket connection. + interview_id: The session UUID. + interview_query: Interview read service. + session_completion: Session completion service. + provider: AI provider for answer and session evaluation. + """ + await handle_theory_websocket( + websocket, + interview_id, + interview_query, + session_completion, + provider, + ) diff --git a/app/interview/api/ws_protocol.py b/app/theory/api/ws_protocol.py similarity index 88% rename from app/interview/api/ws_protocol.py rename to app/theory/api/ws_protocol.py index 0fc0985..048bc7f 100644 --- a/app/interview/api/ws_protocol.py +++ b/app/theory/api/ws_protocol.py @@ -1,18 +1,10 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""WebSocket and NDJSON wire protocol mapping for interview sessions.""" +"""WebSocket and NDJSON wire protocol mapping for theory sessions.""" from typing import Any from app.interview.domain.exceptions import InterviewDomainError -from app.interview.schemas.ws import ( - AnswerFeedbackMessage, - AnswerSavedMessage, - EvaluatingMessage, - InterviewCompletedMessage, - TranscriptMessage, - server_message_to_dict, -) from app.interview.services.events import ( AnswerFeedbackEvent, AnswerSavedEvent, @@ -21,6 +13,15 @@ InterviewEvent, TranscriptEvent, ) +from app.theory.domain.exceptions import TheoryDomainError +from app.theory.schemas.ws import ( + AnswerFeedbackMessage, + AnswerSavedMessage, + EvaluatingMessage, + InterviewCompletedMessage, + TranscriptMessage, + server_message_to_dict, +) __all__ = [ "domain_error_to_wire", @@ -30,7 +31,9 @@ ] -def domain_error_to_wire(exc: InterviewDomainError) -> dict[str, str]: +def domain_error_to_wire( + exc: InterviewDomainError | TheoryDomainError, +) -> dict[str, str]: """Build a WebSocket error payload from a domain exception. Args: @@ -54,7 +57,7 @@ def server_message_from_event( """Map a semantic service event to a typed WebSocket message. Args: - event: Event from answer or completion services. + event: Event from theory submission or session completion services. Returns: Pydantic message model for JSON serialization. @@ -93,7 +96,7 @@ def server_message_from_event( def event_to_message(event: InterviewEvent) -> dict[str, Any]: - """Convert a semantic interview event to a WebSocket JSON message. + """Convert a semantic service event to a WebSocket JSON message. Args: event: Event from the application service layer. diff --git a/app/interview/api/ws_session.py b/app/theory/api/ws_session.py similarity index 77% rename from app/interview/api/ws_session.py rename to app/theory/api/ws_session.py index a487534..56f510f 100644 --- a/app/interview/api/ws_session.py +++ b/app/theory/api/ws_session.py @@ -1,27 +1,28 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""WebSocket message handling for interview sessions.""" +"""WebSocket message handling for theory sessions.""" from collections.abc import AsyncIterator import logging from typing import Any from app.ai.base import AIProvider -from app.interview.api.ws_protocol import ( +from app.interview.domain.exceptions import InterviewDomainError +from app.interview.services.ai_errors import ai_error_message_for_client +from app.interview.services.completion import SessionCompletionService +from app.interview.services.query import InterviewQuery +from app.theory.api.ws_protocol import ( domain_error_to_wire, event_to_message, events_to_messages, ) -from app.interview.domain.exceptions import InterviewDomainError -from app.interview.services.ai_errors import ai_error_message_for_client -from app.interview.services.answer_processing import AnswerProcessingService -from app.interview.services.completion import InterviewCompletionService -from app.interview.services.query import InterviewQuery +from app.theory.domain.exceptions import TheoryDomainError +from app.theory.services.submission import TheorySubmissionService logger = logging.getLogger(__name__) -class InterviewWebSocketService: +class TheoryWebSocketService: """Translate client WebSocket messages into server response payloads.""" @staticmethod @@ -30,10 +31,8 @@ async def iter_responses( *, interview_id: str, provider: AIProvider, - answer_processing: type[AnswerProcessingService] = AnswerProcessingService, - interview_completion: type[ - InterviewCompletionService - ] = InterviewCompletionService, + submission_service: type[TheorySubmissionService] = TheorySubmissionService, + session_completion: type[SessionCompletionService] = SessionCompletionService, interview_query: type[InterviewQuery] = InterviewQuery, ) -> AsyncIterator[dict[str, Any]]: """Handle one client message and yield JSON payloads for the socket. @@ -42,8 +41,8 @@ async def iter_responses( raw: Parsed client JSON message. interview_id: Interview session UUID. provider: AI provider for answer and session evaluation. - answer_processing: Answer processing service class. - interview_completion: Session completion service class. + submission_service: Theory submission service class. + session_completion: Session completion service class. interview_query: Interview read service class. Yields: @@ -52,36 +51,36 @@ async def iter_responses( msg_type = raw.get("type") if msg_type == "answer": - async for message in InterviewWebSocketService._handle_answer( + async for message in TheoryWebSocketService._handle_answer( raw, interview_id=interview_id, provider=provider, - answer_processing=answer_processing, + submission_service=submission_service, ): yield message return if msg_type == "timeout": - async for message in InterviewWebSocketService._handle_timeout( + async for message in TheoryWebSocketService._handle_timeout( raw, interview_id=interview_id, - answer_processing=answer_processing, + submission_service=submission_service, ): yield message return if msg_type == "ping": - yield InterviewWebSocketService._handle_ping( + yield TheoryWebSocketService._handle_ping( interview_id, interview_query=interview_query, ) return if msg_type == "complete": - async for message in InterviewWebSocketService._handle_complete( + async for message in TheoryWebSocketService._handle_complete( interview_id=interview_id, provider=provider, - interview_completion=interview_completion, + session_completion=session_completion, ): yield message return @@ -97,7 +96,7 @@ async def _handle_answer( *, interview_id: str, provider: AIProvider, - answer_processing: type[AnswerProcessingService], + submission_service: type[TheorySubmissionService], ) -> AsyncIterator[dict[str, Any]]: question_id = raw.get("question_id", "") answer_text = raw.get("answer_text", "") @@ -109,14 +108,14 @@ async def _handle_answer( return try: - async for event in answer_processing.stream_answer_submission( + async for event in submission_service.stream_answer_submission( interview_id=interview_id, question_id=question_id, answer_text=answer_text, provider=provider, ): yield event_to_message(event) - except InterviewDomainError as exc: + except (InterviewDomainError, TheoryDomainError) as exc: yield domain_error_to_wire(exc) except Exception as exc: logger.exception( @@ -133,7 +132,7 @@ async def _handle_timeout( raw: dict[str, Any], *, interview_id: str, - answer_processing: type[AnswerProcessingService], + submission_service: type[TheorySubmissionService], ) -> AsyncIterator[dict[str, Any]]: question_id = raw.get("question_id", "") round_num = raw.get("round") @@ -145,13 +144,13 @@ async def _handle_timeout( return try: - async for event in answer_processing.stream_timeout_submission( + async for event in submission_service.stream_timeout_submission( interview_id=interview_id, question_id=question_id, round_num=int(round_num), ): yield event_to_message(event) - except InterviewDomainError as exc: + except (InterviewDomainError, TheoryDomainError) as exc: yield domain_error_to_wire(exc) except Exception as exc: logger.exception( @@ -182,10 +181,10 @@ async def _handle_complete( *, interview_id: str, provider: AIProvider, - interview_completion: type[InterviewCompletionService], + session_completion: type[SessionCompletionService], ) -> AsyncIterator[dict[str, Any]]: try: - events = await interview_completion.complete_interview( + events = await session_completion.complete_session( interview_id=interview_id, provider=provider, ) diff --git a/app/theory/domain/__init__.py b/app/theory/domain/__init__.py new file mode 100644 index 0000000..9e3fd70 --- /dev/null +++ b/app/theory/domain/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory domain layer: aggregates, value objects, and exceptions.""" diff --git a/app/theory/domain/entities.py b/app/theory/domain/entities.py new file mode 100644 index 0000000..466d9e2 --- /dev/null +++ b/app/theory/domain/entities.py @@ -0,0 +1,499 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory section aggregate entities.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import UTC, datetime +from typing import Literal + +from app.interview.domain.value_objects import InterviewSelection +from app.shared.task_timer import ( + DEFAULT_TIMEOUT_GRACE_SECONDS, +) +from app.shared.task_timer import ( + is_timer_expired as shared_is_timer_expired, +) +from app.shared.task_timer import ( + remaining_seconds as shared_remaining_seconds, +) +from app.shared.task_timer import ( + timer_deadline as shared_timer_deadline, +) +from app.theory.domain.exceptions import ( + TheorySectionNotActiveError, + TheoryTaskNotFoundError, + UnansweredTaskNotFoundError, +) +from app.theory.domain.value_objects import PlannedTheoryQuestion + +TheorySectionStatus = Literal["active", "completed", "skipped"] + + +@dataclass(frozen=True, slots=True) +class TheoryTask: + """One answer round within a theory section. + + Attributes: + id: Task row primary key. + theory_section_id: Parent theory section ID. + interview_id: Parent interview UUID (denormalized from the theory section). + question_id: YAML question ID. + order: Display order within the section (1-based). + round: Follow-up round number (0 = initial). + question_text: Question text shown to the user. + question_code: Optional code snippet for the question. + answer_text: User answer text, or None when unanswered. + score: AI score for the round, or None when not evaluated. + feedback: AI-generated feedback text, or None. + started_at: When the round timer started, or None. + created_at: When this task row was created. + """ + + TIME_EXPIRED_ANSWER_TEXT = "[Time expired]" + TIMEOUT_GRACE_SECONDS = DEFAULT_TIMEOUT_GRACE_SECONDS + NEW_ID = 0 + + id: int + theory_section_id: int + interview_id: str + question_id: str + order: int + round: int + question_text: str + question_code: str | None + answer_text: str | None + score: int | None + feedback: str | None + started_at: datetime | None + created_at: datetime + + def timer_deadline(self, limit_seconds: int) -> datetime: + """Compute the absolute deadline for this timed task round. + + Args: + limit_seconds: Allowed duration in seconds. + + Returns: + Timezone-aware deadline timestamp. + + Raises: + ValueError: If the round has no ``started_at`` timestamp. + """ + if self.started_at is None: + raise ValueError("Theory task round has no started_at") + return shared_timer_deadline( + self.started_at, + limit_seconds, + label="Theory task", + ) + + def is_timer_expired( + self, + limit_seconds: int | None, + now: datetime | None = None, + *, + grace_seconds: int = TIMEOUT_GRACE_SECONDS, + ) -> bool: + """Return whether the per-round timer has elapsed. + + Args: + limit_seconds: Configured limit for the section (None disables timer). + now: Current time (defaults to UTC now). + grace_seconds: Extra seconds allowed for network delay on timeout submit. + + Returns: + True if the timer is enabled and the deadline plus grace has passed. + """ + return shared_is_timer_expired( + self.started_at, + limit_seconds, + now, + grace_seconds=grace_seconds, + ) + + def remaining_seconds( + self, + limit_seconds: int | None, + now: datetime | None = None, + ) -> int | None: + """Return whole seconds left on the timer, or None if disabled. + + Args: + limit_seconds: Configured limit for the section. + now: Current time (defaults to UTC now). + + Returns: + Non-negative seconds remaining, or None when the timer is off. + """ + return shared_remaining_seconds(self.started_at, limit_seconds, now) + + def client_timeout_due( + self, + limit_seconds: int | None, + now: datetime | None = None, + ) -> bool: + """Return whether a client-sent timeout should be accepted. + + Args: + limit_seconds: Configured limit for the section. + now: Current time (defaults to UTC now). + + Returns: + True when the round timer has effectively expired for the client. + """ + if limit_seconds is None or self.started_at is None: + return False + rem = self.remaining_seconds(limit_seconds, now) + return self.is_timer_expired(limit_seconds, now, grace_seconds=0) or ( + rem is not None and rem <= 0 + ) + + +@dataclass(frozen=True, slots=True) +class TheorySection: + MAX_SCORE_PER_ROUND = 5 + """Theory section aggregate root. + + Attributes: + id: Theory section primary key. + interview_id: Parent interview UUID. + locale: Language code for feedback and voice. + selection: Parsed question-bank selection for this section. + question_count: Number of questions in this section. + question_ids: Question IDs in display order. + task_time_limit_seconds: Per-task time limit, or None when disabled. + status: Section status (``active``, ``completed``, or ``skipped``). + section_score: Aggregated section score when evaluated. + section_feedback: Parsed section evaluation payload. + tasks: Theory tasks in display order (order, then round). + """ + + NEW_ID = 0 + + id: int + interview_id: str + locale: str + selection: InterviewSelection + question_count: int + question_ids: tuple[str, ...] + task_time_limit_seconds: int | None + status: TheorySectionStatus + section_score: int | None + section_feedback: dict[str, object] | None + tasks: tuple[TheoryTask, ...] + + @classmethod + def start( + cls, + interview_id: str, + *, + selection: InterviewSelection, + locale: str, + planned_questions: tuple[PlannedTheoryQuestion, ...], + task_time_limit_seconds: int | None = None, + theory_section_id: int = NEW_ID, + ) -> TheorySection: + """Build a new active theory section from a question plan. + + Args: + interview_id: Parent interview UUID. + selection: Track/level/topic selection from setup. + locale: Locale for AI feedback and follow-ups. + 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. + + Returns: + Active section with initial task rows (``TheoryTask.NEW_ID``). + + Raises: + ValueError: If ``planned_questions`` is empty. + """ + if not planned_questions: + raise ValueError("No questions found for the selected topics") + + 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 + tasks: list[TheoryTask] = [] + for order, question in enumerate(planned_questions, start=1): + tasks.append( + TheoryTask( + id=TheoryTask.NEW_ID, + theory_section_id=theory_section_id, + interview_id=interview_id, + question_id=question.id, + order=order, + round=0, + question_text=question.text, + question_code=question.code, + answer_text=None, + score=None, + feedback=None, + started_at=timer_start if order == 1 else None, + created_at=when, + ) + ) + return cls( + id=theory_section_id, + interview_id=interview_id, + locale=locale, + selection=selection, + question_count=len(planned_questions), + question_ids=question_ids, + task_time_limit_seconds=task_time_limit_seconds, + status="active", + section_score=None, + section_feedback=None, + tasks=tuple(tasks), + ) + + def ensure_active(self) -> None: + """Ensure this theory section accepts new task submissions. + + Raises: + TheorySectionNotActiveError: If the section is not in ``active`` status. + """ + if self.status != "active": + raise TheorySectionNotActiveError(self.interview_id) + + def find_first_unanswered(self) -> TheoryTask | None: + """Return the first unanswered task in display order. + + Returns: + The first task with ``answer_text`` unset, or None when all are answered. + """ + for task in self.tasks: + if task.answer_text is None: + return task + return None + + def is_complete(self) -> bool: + """Return whether every task in this section has been answered. + + Returns: + True when there is at least one task and none remain unanswered. + """ + return bool(self.tasks) and self.find_first_unanswered() is None + + def total_score(self) -> int: + """Sum scores from all answered task rounds in this section. + + Returns: + Total earned points across answered rounds. + """ + return sum( + (task.score or 0) for task in self.tasks if task.answer_text is not None + ) + + def max_score(self) -> int: + """Compute maximum achievable score for answered rounds in this section. + + Returns: + Maximum possible points for rounds with user answers. + """ + answered_rounds = sum(1 for task in self.tasks if task.answer_text is not None) + return self.MAX_SCORE_PER_ROUND * answered_rounds + + def with_cached_section_feedback( + self, + feedback: dict[str, object], + *, + section_score: int, + ) -> TheorySection: + """Return aggregate with prefetched section feedback when not already cached. + + Args: + feedback: Parsed section evaluation payload. + section_score: Aggregated section score. + + Returns: + Updated aggregate, or ``self`` when feedback is already cached. + """ + if self.section_feedback is not None: + return self + return replace( + self, + section_feedback=feedback, + section_score=section_score, + ) + + def start_timer_for_task( + self, task_id: int, when: datetime | None = None + ) -> TheorySection: + """Start the per-round timer on a task when the section has a limit. + + Args: + task_id: Primary key of the task row to activate. + when: Timestamp to set (defaults to UTC now). + + Returns: + A new aggregate with ``started_at`` set on the target task when applicable. + """ + if self.task_time_limit_seconds is None: + return self + started_at = when or datetime.now(UTC) + tasks = tuple( + replace(task, started_at=started_at) + if task.id == task_id and task.started_at is None + else task + for task in self.tasks + ) + return replace(self, tasks=tasks) + + def with_task_text(self, task_id: int, text: str) -> TheorySection: + """Return aggregate with user answer text on the given task. + + Args: + task_id: Primary key of the task row to update. + text: User answer text (maybe empty before transcription). + + Returns: + A new aggregate with ``answer_text`` set on the target task. + """ + tasks = tuple( + replace(task, answer_text=text) if task.id == task_id else task + for task in self.tasks + ) + return replace(self, tasks=tasks) + + def with_timed_out_round(self, task_id: int, feedback: str) -> TheorySection: + """Return aggregate with a timed-out round scored zero. + + Args: + task_id: Primary key of the task row that expired. + feedback: User-facing timeout feedback text. + + Returns: + A new aggregate with timeout marker text, score 0, and feedback. + """ + tasks = tuple( + replace( + task, + answer_text=TheoryTask.TIME_EXPIRED_ANSWER_TEXT, + score=0, + feedback=feedback, + ) + if task.id == task_id + else task + for task in self.tasks + ) + return replace(self, tasks=tasks) + + def with_evaluation( + self, question_id: str, round_num: int, score: int, feedback: str + ) -> TheorySection: + """Return aggregate with AI score and feedback on one task round. + + Args: + question_id: YAML question ID. + round_num: Follow-up round (0 = initial). + score: AI score for the round. + feedback: AI feedback text. + + Returns: + A new aggregate with evaluation fields set on the target task. + """ + target = self.find_task(question_id, round_num) + tasks = tuple( + replace(task, score=score, feedback=feedback) + if task.id == target.id + else task + for task in self.tasks + ) + return replace(self, tasks=tasks) + + def max_round_for_question(self, question_id: str) -> int: + """Return the highest follow-up round number for a question. + + Args: + question_id: YAML question ID. + + Returns: + Maximum ``round`` value among tasks for the question, or 0 when none exist. + """ + rounds = [task.round for task in self.tasks if task.question_id == question_id] + return max(rounds) if rounds else 0 + + def with_follow_up( + self, question_id: str, question_text: str + ) -> tuple[TheorySection, TheoryTask]: + """Return aggregate with a new unanswered follow-up task row. + + Args: + question_id: YAML question ID for the follow-up chain. + question_text: Follow-up question text shown to the user. + + Returns: + Tuple of updated aggregate and the pending follow-up task (``id`` is ``NEW_ID``). + """ + base = self.find_task(question_id, 0) + next_round = self.max_round_for_question(question_id) + 1 + created_at = datetime.now(UTC) + follow_up = TheoryTask( + id=TheoryTask.NEW_ID, + theory_section_id=self.id, + interview_id=self.interview_id, + question_id=question_id, + order=base.order, + round=next_round, + question_text=question_text, + question_code=base.question_code, + answer_text=None, + score=None, + feedback=None, + started_at=None, + created_at=created_at, + ) + return replace(self, tasks=self.tasks + (follow_up,)), follow_up + + def find_unanswered_for_question(self, question_id: str) -> TheoryTask: + """Return the unanswered task row for a question (any follow-up round). + + Args: + question_id: YAML question ID. + + Returns: + The first unanswered task for that question. + + Raises: + UnansweredTaskNotFoundError: If no unanswered task exists for the question. + """ + for task in self.tasks: + if task.question_id == question_id and task.answer_text is None: + return task + raise UnansweredTaskNotFoundError(self.interview_id, question_id) + + def find_task(self, question_id: str, round_num: int) -> TheoryTask: + """Return the task row for a question and follow-up round. + + Args: + question_id: YAML question ID. + round_num: Follow-up round (0 = initial). + + Returns: + The matching task row. + + Raises: + TheoryTaskNotFoundError: If no row matches the keys. + """ + for task in self.tasks: + if task.question_id == question_id and task.round == round_num: + return task + raise TheoryTaskNotFoundError(self.interview_id, question_id, round_num) + + def find_next_unanswered_after(self, current_index: int) -> TheoryTask | None: + """Return the next unanswered task after a position in the task list. + + Args: + current_index: Index of the current task in ``tasks``. + + Returns: + The next unanswered task, or None if none remain. + """ + for task in self.tasks[current_index + 1 :]: + if task.answer_text is None: + return task + return None diff --git a/app/theory/domain/exceptions.py b/app/theory/domain/exceptions.py new file mode 100644 index 0000000..3d4ce1c --- /dev/null +++ b/app/theory/domain/exceptions.py @@ -0,0 +1,104 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory domain exceptions.""" + + +class TheoryDomainError(Exception): + """Base class for theory-related domain errors.""" + + +class TheorySectionNotFoundError(TheoryDomainError): + """Raised when a theory section does not exist for an interview.""" + + def __init__(self, interview_id: str) -> None: + """Initialize with the parent interview ID. + + Args: + interview_id: Parent interview UUID. + """ + self.interview_id = interview_id + super().__init__(f"Theory section not found for interview: {interview_id}") + + +class TheorySectionNotActiveError(TheoryDomainError): + """Raised when an operation requires an active theory section.""" + + def __init__(self, interview_id: str | None = None) -> None: + """Initialize optionally with the interview ID. + + Args: + interview_id: Parent interview UUID, if known. + """ + self.interview_id = interview_id + super().__init__("Cannot submit answer to a completed theory section") + + +class UnansweredTaskNotFoundError(TheoryDomainError): + """Raised when no open task row exists for a question.""" + + def __init__(self, interview_id: str, question_id: str) -> None: + """Initialize with interview and question identifiers. + + Args: + interview_id: Parent interview UUID. + question_id: YAML question ID. + """ + self.interview_id = interview_id + self.question_id = question_id + super().__init__( + f"Unanswered theory task not found: interview={interview_id}, " + f"question={question_id}" + ) + + +class TaskTimerNotEnabledError(TheoryDomainError): + """Raised when a timer operation is requested but the section has no limit.""" + + def __init__(self, interview_id: str) -> None: + """Initialize with the interview ID. + + Args: + interview_id: Parent interview UUID. + """ + self.interview_id = interview_id + super().__init__( + f"Task timer is not enabled for theory section: {interview_id}" + ) + + +class TaskTimerNotExpiredError(TheoryDomainError): + """Raised when a timeout is submitted before the task deadline.""" + + def __init__(self, interview_id: str, question_id: str) -> None: + """Initialize with interview and question identifiers. + + Args: + interview_id: Parent interview UUID. + question_id: YAML question ID. + """ + self.interview_id = interview_id + self.question_id = question_id + super().__init__( + f"Task timer has not expired: interview={interview_id}, " + f"question={question_id}" + ) + + +class TheoryTaskNotFoundError(TheoryDomainError): + """Raised when a specific theory task row is missing.""" + + def __init__(self, interview_id: str, question_id: str, round_num: int) -> None: + """Initialize with lookup keys. + + Args: + interview_id: Parent interview UUID. + question_id: YAML question ID. + round_num: Task round (0 = initial). + """ + self.interview_id = interview_id + self.question_id = question_id + self.round_num = round_num + super().__init__( + f"Theory task not found: interview={interview_id}, " + f"question={question_id}, round={round_num}" + ) diff --git a/app/theory/domain/value_objects.py b/app/theory/domain/value_objects.py new file mode 100644 index 0000000..a83361b --- /dev/null +++ b/app/theory/domain/value_objects.py @@ -0,0 +1,22 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory domain value objects.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class PlannedTheoryQuestion: + """Question snapshot used when starting a theory section. + + Attributes: + id: Unique question identifier from the question bank. + text: Localized question text shown to the user. + code: Optional code snippet, or None when not applicable. + """ + + id: str + text: str + code: str | None diff --git a/app/theory/repositories/__init__.py b/app/theory/repositories/__init__.py new file mode 100644 index 0000000..f33f177 --- /dev/null +++ b/app/theory/repositories/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory persistence repositories.""" diff --git a/app/theory/repositories/mappers.py b/app/theory/repositories/mappers.py new file mode 100644 index 0000000..e899c9f --- /dev/null +++ b/app/theory/repositories/mappers.py @@ -0,0 +1,232 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""ORM ↔ domain ↔ read-model mappers for theory persistence.""" + +from __future__ import annotations + +import json +from typing import Any + +from app.interview.domain.serialization import ( + parse_overall_feedback, + parse_selection_spec, + selection_to_spec, +) +from app.shared.infrastructure.models import Answer as OrmAnswer +from app.shared.infrastructure.models import TheorySection as OrmTheorySection +from app.theory.domain.entities import TheorySection as DomainTheorySection +from app.theory.domain.entities import TheorySectionStatus +from app.theory.domain.entities import TheoryTask as DomainTheoryTask +from app.theory.schemas.theory import TheorySectionRead, TheoryTaskRead + + +def _question_ids_from_tasks(tasks: tuple[DomainTheoryTask, ...]) -> tuple[str, ...]: + """Derive ordered question IDs from initial task rounds. + + Args: + tasks: Theory tasks for a section. + + Returns: + Question IDs for round 0 tasks sorted by display order. + """ + initial = sorted( + (task for task in tasks if task.round == 0), + key=lambda task: task.order, + ) + return tuple(task.question_id for task in initial) + + +def theory_task_from_orm( + answer: OrmAnswer, + *, + interview_id: str, +) -> DomainTheoryTask: + """Map an ORM answer row to a domain theory task. + + Args: + answer: SQLAlchemy Answer linked to a theory section. + interview_id: Parent interview UUID from the theory section. + + Returns: + Immutable domain TheoryTask. + """ + return DomainTheoryTask( + id=answer.id, + theory_section_id=answer.theory_section_id, + interview_id=interview_id, + question_id=answer.question_id, + order=answer.order, + round=answer.round, + question_text=answer.question_text, + question_code=answer.question_code, + answer_text=answer.answer_text, + score=answer.score, + feedback=answer.feedback, + started_at=answer.started_at, + created_at=answer.created_at, + ) + + +def domain_theory_task_to_orm( + task: DomainTheoryTask, + *, + theory_section_id: int | None = None, +) -> OrmAnswer: + """Map a domain theory task to a new ORM answer row. + + Args: + task: Domain theory task (typically ``id`` is ``TheoryTask.NEW_ID``). + theory_section_id: Parent section ID override when inserting. + + Returns: + Detached ORM Answer ready to be added to a session. + """ + section_id = ( + theory_section_id if theory_section_id is not None else task.theory_section_id + ) + return OrmAnswer( + theory_section_id=section_id, + question_id=task.question_id, + order=task.order, + round=task.round, + question_text=task.question_text, + question_code=task.question_code, + answer_text=task.answer_text, + score=task.score, + feedback=task.feedback, + started_at=task.started_at, + created_at=task.created_at, + ) + + +def theory_section_from_orm(section: OrmTheorySection) -> DomainTheorySection: + """Map an ORM theory section row to a domain aggregate. + + Args: + section: SQLAlchemy TheorySection with tasks loaded. + + Returns: + Immutable domain TheorySection including tasks. + """ + status: TheorySectionStatus + if section.status == "completed": + status = "completed" + elif section.status == "skipped": + status = "skipped" + else: + status = "active" + tasks = tuple( + theory_task_from_orm(answer, interview_id=section.interview_id) + for answer in section.tasks + ) + return DomainTheorySection( + id=section.id, + interview_id=section.interview_id, + locale=section.locale or "en", + selection=parse_selection_spec(section.selection_spec), + question_count=section.question_count or 0, + question_ids=_question_ids_from_tasks(tasks), + task_time_limit_seconds=section.task_time_limit_seconds, + status=status, + section_score=section.section_score, + section_feedback=parse_overall_feedback(section.section_feedback), + tasks=tasks, + ) + + +def theory_section_to_orm(section: DomainTheorySection) -> OrmTheorySection: + """Map a new domain theory section to a detached ORM row. + + Args: + section: Domain section from ``TheorySection.start``. + + Returns: + ORM TheorySection ready for ``session.add``. + """ + fields: dict[str, Any] = { + "interview_id": section.interview_id, + "selection_spec": selection_to_spec(section.selection), + "question_count": section.question_count, + "task_time_limit_seconds": section.task_time_limit_seconds, + "status": section.status, + "section_score": section.section_score, + "section_feedback": ( + json.dumps(section.section_feedback, separators=(",", ":")) + if section.section_feedback is not None + else None + ), + "locale": section.locale, + } + if section.id != DomainTheorySection.NEW_ID: + fields["id"] = section.id + return OrmTheorySection(**fields) + + +def theory_section_to_orm_fields(section: DomainTheorySection) -> dict[str, Any]: + """Extract ORM-mutable theory section fields from a domain aggregate. + + Args: + section: Domain theory section aggregate. + + Returns: + Dict of column names to values for partial ORM updates. + """ + return { + "selection_spec": selection_to_spec(section.selection), + "question_count": section.question_count, + "task_time_limit_seconds": section.task_time_limit_seconds, + "status": section.status, + "section_score": section.section_score, + "section_feedback": ( + json.dumps(section.section_feedback, separators=(",", ":")) + if section.section_feedback is not None + else None + ), + "locale": section.locale, + } + + +def theory_task_read_from_domain(task: DomainTheoryTask) -> TheoryTaskRead: + """Map a domain theory task to a read model. + + Args: + task: Domain theory task entity. + + Returns: + Immutable TheoryTaskRead for services and API. + """ + return TheoryTaskRead( + id=task.id, + question_id=task.question_id, + order=task.order, + round=task.round, + question_text=task.question_text, + question_code=task.question_code, + answer_text=task.answer_text, + score=task.score, + feedback=task.feedback, + started_at=task.started_at, + ) + + +def theory_section_to_read(section: DomainTheorySection) -> TheorySectionRead: + """Map a domain theory section to a read model. + + Args: + section: Domain theory section aggregate. + + Returns: + Immutable TheorySectionRead for services and API. + """ + return TheorySectionRead( + id=section.id, + interview_id=section.interview_id, + status=section.status, + locale=section.locale, + selection_spec=selection_to_spec(section.selection), + question_count=section.question_count, + task_time_limit_seconds=section.task_time_limit_seconds, + tasks=[theory_task_read_from_domain(task) for task in section.tasks], + section_score=section.section_score, + section_feedback=section.section_feedback, + ) diff --git a/app/theory/repositories/theory_section.py b/app/theory/repositories/theory_section.py new file mode 100644 index 0000000..e855ea9 --- /dev/null +++ b/app/theory/repositories/theory_section.py @@ -0,0 +1,162 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory section repository. + +Provides data access for ``TheorySection`` records keyed by interview ID. +""" + +from sqlalchemy.orm import Session, selectinload + +from app.shared.infrastructure.models import TheorySection +from app.shared.repositories.base import SqlAlchemyRepository +from app.theory.domain.entities import TheorySection as DomainTheorySection +from app.theory.domain.entities import TheoryTask as DomainTheoryTask +from app.theory.domain.exceptions import TheorySectionNotFoundError +from app.theory.repositories.mappers import ( + domain_theory_task_to_orm, + theory_section_from_orm, + theory_section_to_orm, + theory_section_to_orm_fields, +) + + +class TheorySectionRepository(SqlAlchemyRepository[TheorySection]): + """Repository for ``TheorySection`` entities. + + Attributes: + _session: Active SQLAlchemy Session (inherited). + """ + + _model = TheorySection + + def __init__(self, session: Session) -> None: + """Initialize the repository. + + Args: + session: Active SQLAlchemy Session. + """ + super().__init__(session) + + def get_by_interview_id(self, interview_id: str) -> TheorySection | None: + """Retrieve a theory section by parent interview ID. + + Args: + interview_id: Parent interview UUID. + + Returns: + TheorySection ORM row with tasks loaded, or None. + """ + return ( + self._session.query(TheorySection) + .options(selectinload(TheorySection.tasks)) + .filter_by(interview_id=interview_id) + .first() + ) + + def get_aggregate(self, interview_id: str) -> DomainTheorySection | None: + """Load a domain theory section aggregate with tasks. + + Args: + interview_id: Parent interview UUID. + + Returns: + Domain aggregate, or None when the section does not exist. + """ + orm_section = self.get_by_interview_id(interview_id) + if orm_section is None: + return None + return theory_section_from_orm(orm_section) + + def create_section(self, section: DomainTheorySection) -> DomainTheorySection: + """Insert a new theory section row without tasks. + + Args: + section: Domain section from ``TheorySection.start``. + + Returns: + Reloaded domain aggregate with assigned section ID. + + Raises: + TheorySectionNotFoundError: If reload fails after flush. + """ + orm_section = theory_section_to_orm(section) + self._session.add(orm_section) + self._session.flush() + reloaded = self.get_by_interview_id(section.interview_id) + if reloaded is None: + raise TheorySectionNotFoundError(section.interview_id) + return theory_section_from_orm(reloaded) + + def create_aggregate(self, section: DomainTheorySection) -> DomainTheorySection: + """Insert a theory section and its task rows. + + Args: + section: Domain section with tasks to persist. + + Returns: + Reloaded domain aggregate with assigned IDs. + + Raises: + TheorySectionNotFoundError: If reload fails after flush. + """ + orm_section = theory_section_to_orm(section) + self._session.add(orm_section) + self._session.flush() + for task in section.tasks: + self._session.add( + domain_theory_task_to_orm(task, theory_section_id=orm_section.id) + ) + self._session.flush() + reloaded = self.get_by_interview_id(section.interview_id) + if reloaded is None: + raise TheorySectionNotFoundError(section.interview_id) + return theory_section_from_orm(reloaded) + + def save_section(self, section: DomainTheorySection) -> None: + """Persist mutable fields from a domain section onto the ORM row. + + Args: + section: Domain section previously loaded from this repository. + + Raises: + TheorySectionNotFoundError: If the section row no longer exists. + """ + orm_section = self.get_by_interview_id(section.interview_id) + if orm_section is None: + raise TheorySectionNotFoundError(section.interview_id) + + for field, value in theory_section_to_orm_fields(section).items(): + setattr(orm_section, field, value) + + def save_aggregate(self, section: DomainTheorySection) -> None: + """Persist mutable section and task fields from a domain aggregate. + + Args: + section: Domain section previously loaded from this repository. + + Raises: + TheorySectionNotFoundError: If the section row no longer exists. + """ + orm_section = self.get_by_interview_id(section.interview_id) + if orm_section is None: + raise TheorySectionNotFoundError(section.interview_id) + + for field, value in theory_section_to_orm_fields(section).items(): + setattr(orm_section, field, value) + + orm_tasks_by_id = {task.id: task for task in orm_section.tasks} + for domain_task in section.tasks: + if domain_task.id == DomainTheoryTask.NEW_ID: + orm_section.tasks.append( + domain_theory_task_to_orm( + domain_task, theory_section_id=orm_section.id + ) + ) + continue + orm_task = orm_tasks_by_id.get(domain_task.id) + if orm_task is None: + continue + orm_task.answer_text = domain_task.answer_text + orm_task.score = domain_task.score + orm_task.feedback = domain_task.feedback + orm_task.started_at = domain_task.started_at diff --git a/app/theory/repositories/uow.py b/app/theory/repositories/uow.py new file mode 100644 index 0000000..758683f --- /dev/null +++ b/app/theory/repositories/uow.py @@ -0,0 +1,35 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory feature unit of work with repository accessors.""" + +from __future__ import annotations + +from app.shared.infrastructure.uow import UnitOfWork +from app.theory.repositories.theory_section import TheorySectionRepository + + +class TheoryUnitOfWork(UnitOfWork): + """Unit of Work exposing the theory section repository. + + Usage:: + + with TheoryUnitOfWork() as uow: + section = uow.theory_sections.get_aggregate(interview_id) + uow.commit() + """ + + def __init__(self, auto_commit: bool = False) -> None: + """Initialize the theory unit of work. + + Args: + auto_commit: If True, commit on successful context exit. + """ + super().__init__(auto_commit=auto_commit) + self._theory_sections_repo: TheorySectionRepository | None = None + + @property + def theory_sections(self) -> TheorySectionRepository: + """Access the ``TheorySectionRepository`` bound to this UoW.""" + if self._theory_sections_repo is None: + self._theory_sections_repo = TheorySectionRepository(self.session) + return self._theory_sections_repo diff --git a/app/theory/schemas/__init__.py b/app/theory/schemas/__init__.py new file mode 100644 index 0000000..ad03aa1 --- /dev/null +++ b/app/theory/schemas/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory read models and wire schemas.""" diff --git a/app/theory/schemas/page.py b/app/theory/schemas/page.py new file mode 100644 index 0000000..802c4ea --- /dev/null +++ b/app/theory/schemas/page.py @@ -0,0 +1,33 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory section page read models.""" + +from pydantic import BaseModel, ConfigDict, Field + +from app.theory.schemas.theory import TheoryTaskRead + + +class TheoryPageContext(BaseModel): + """Template context for the theory section panel. + + Attributes: + answers: All theory task rounds for the chat history. + current_question: First unanswered task, if any. + current_answer_id: Primary key of the current task row. + question_timer_enabled: Whether per-round timer is active. + question_time_limit_seconds: Configured limit in seconds. + timer_remaining_seconds: Seconds left on the current round. + current_round: Follow-up round number for the active task. + complete: Whether all theory tasks have been answered. + """ + + model_config = ConfigDict(frozen=True) + + answers: list[TheoryTaskRead] + current_question: TheoryTaskRead | None + current_answer_id: int | None + question_timer_enabled: bool + question_time_limit_seconds: int | None + timer_remaining_seconds: int | None + current_round: int = Field(default=0) + complete: bool = False diff --git a/app/theory/schemas/review.py b/app/theory/schemas/review.py new file mode 100644 index 0000000..52a06e7 --- /dev/null +++ b/app/theory/schemas/review.py @@ -0,0 +1,39 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory section review page read models.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from app.theory.schemas.theory import TheoryTaskRead + + +class TheoryReviewContext(BaseModel): + """Template context for the completed theory section review page. + + Attributes: + interview_id: Parent session UUID. + interview_title: Display title derived from selection. + selection_lines: Human-readable selection summary lines. + locale_label: Localized language label. + section_score: Aggregated section score. + section_max_score: Maximum achievable section score. + section_feedback: Resolved section narrative payload. + answers: All answered theory task rounds for chat history. + results_url: Relative URL for the session results hub. + """ + + model_config = ConfigDict(frozen=True) + + interview_id: str + interview_title: str + selection_lines: list[str] + locale_label: str + section_score: int + section_max_score: int + section_feedback: dict[str, Any] + answers: list[TheoryTaskRead] + results_url: str diff --git a/app/theory/schemas/theory.py b/app/theory/schemas/theory.py new file mode 100644 index 0000000..1f15655 --- /dev/null +++ b/app/theory/schemas/theory.py @@ -0,0 +1,70 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory section read models for services and API.""" + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +TheorySectionStatusRead = Literal["active", "completed", "skipped"] + + +class TheoryTaskRead(BaseModel): + """Read-only snapshot of one theory task round. + + Attributes: + id: Task row primary key. + question_id: YAML question ID. + order: Display order within the section (1-based). + round: Follow-up round number (0 = initial). + question_text: Question text shown to the user. + question_code: Optional code snippet for the question. + answer_text: User answer text, or None when unanswered. + score: AI score for the round, or None when not evaluated. + feedback: AI-generated feedback text, or None. + started_at: When the round timer started, or None. + """ + + model_config = ConfigDict(frozen=True) + + id: int + question_id: str + order: int + round: int + question_text: str + question_code: str | None + answer_text: str | None + score: int | None + feedback: str | None = None + started_at: datetime | None + + +class TheorySectionRead(BaseModel): + """Read-only snapshot of a theory section. + + Attributes: + id: Theory section primary key. + interview_id: Parent interview UUID. + status: Section status. + locale: Language code for feedback and voice. + selection_spec: JSON describing tracks, levels, and topic categories. + question_count: Number of questions in this section. + task_time_limit_seconds: Per-task time limit, or None when disabled. + tasks: Theory tasks in display order. + section_score: Aggregated section score when evaluated. + section_feedback: Parsed section evaluation payload. + """ + + model_config = ConfigDict(frozen=True) + + id: int + interview_id: str + status: TheorySectionStatusRead + locale: str + selection_spec: str + question_count: int + task_time_limit_seconds: int | None + tasks: list[TheoryTaskRead] + section_score: int | None = None + section_feedback: dict[str, Any] | None = None diff --git a/app/theory/schemas/ws.py b/app/theory/schemas/ws.py new file mode 100644 index 0000000..89c977e --- /dev/null +++ b/app/theory/schemas/ws.py @@ -0,0 +1,24 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""WebSocket server message schemas for theory sessions. + +Re-exports interview wire models until Phase 4 moves canonical handlers here. +""" + +from app.interview.schemas.ws import ( + AnswerFeedbackMessage, + AnswerSavedMessage, + EvaluatingMessage, + InterviewCompletedMessage, + TranscriptMessage, + server_message_to_dict, +) + +__all__ = [ + "AnswerFeedbackMessage", + "AnswerSavedMessage", + "EvaluatingMessage", + "InterviewCompletedMessage", + "TranscriptMessage", + "server_message_to_dict", +] diff --git a/app/theory/services/__init__.py b/app/theory/services/__init__.py new file mode 100644 index 0000000..7ec3ce5 --- /dev/null +++ b/app/theory/services/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory orchestration services (scaffold for Phase 3+).""" diff --git a/app/theory/services/creation.py b/app/theory/services/creation.py new file mode 100644 index 0000000..2e02ba9 --- /dev/null +++ b/app/theory/services/creation.py @@ -0,0 +1,52 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory section creation service.""" + +from app.interview.domain.value_objects import InterviewSelection +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.services.rules.selection import validate_question_count +from app.theory.domain.entities import TheorySection +from app.theory.services.planning import build_theory_question_plan + + +class TheorySectionCreationService: + """Service for creating theory sections within an interview session.""" + + @staticmethod + def create( + interview_id: str, + *, + selection: InterviewSelection, + locale: str, + question_count: int, + task_time_limit_seconds: int | None, + uow: InterviewUnitOfWork, + ) -> TheorySection: + """Plan questions and persist a theory section with initial tasks. + + Args: + interview_id: Parent interview UUID. + selection: Track/level/topic selection from setup. + 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. + uow: Active interview unit of work sharing the persistence session. + + Returns: + Persisted theory section aggregate with assigned task IDs. + + Raises: + ValueError: If validation fails or no questions are available. + """ + validate_question_count(selection, question_count) + theory_planned = build_theory_question_plan( + selection, question_count, locale=locale + ) + section = TheorySection.start( + interview_id, + selection=selection, + locale=locale, + planned_questions=theory_planned, + task_time_limit_seconds=task_time_limit_seconds, + ) + return uow.theory_sections.create_aggregate(section) diff --git a/app/interview/services/answer_evaluation_persistence.py b/app/theory/services/evaluation_persistence.py similarity index 60% rename from app/interview/services/answer_evaluation_persistence.py rename to app/theory/services/evaluation_persistence.py index 47e05e1..6e91baf 100644 --- a/app/interview/services/answer_evaluation_persistence.py +++ b/app/theory/services/evaluation_persistence.py @@ -1,21 +1,21 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Persist AI evaluation results and advance interview sessions.""" +"""Persist AI evaluation results and advance theory sections.""" from typing import Any -from app.interview.domain.exceptions import InterviewNotFoundError -from app.interview.repositories.uow import InterviewUnitOfWork -from app.interview.services.evaluator.service import ( +from app.interview.services.events import AnswerFeedbackEvent +from app.theory.domain.exceptions import TheorySectionNotFoundError +from app.theory.repositories.uow import TheoryUnitOfWork +from app.theory.services.evaluator.models import ( AnswerEvaluation, FollowUpEvaluation, ) -from app.interview.services.events import AnswerFeedbackEvent -from app.interview.services.session_navigation import SessionNavigationService +from app.theory.services.navigation import TheoryNavigationService -class AnswerEvaluationPersistenceService: - """Save evaluation scores and advance timed interview rounds.""" +class TheoryEvaluationPersistenceService: + """Save evaluation scores and advance timed theory task rounds.""" @staticmethod def advance_without_evaluation( @@ -25,16 +25,13 @@ def advance_without_evaluation( round_num: int, order: int, ) -> AnswerFeedbackEvent: - """Advance the session without waiting for AI evaluation. - - Used when the user submits the last allowed follow-up round: navigation - happens immediately while score and feedback are persisted separately. + """Advance the section without waiting for AI evaluation. Args: - interview_id: Interview UUID. - question_id: Question ID from the answer row. + interview_id: Parent interview UUID. + question_id: Question ID from the task row. round_num: Follow-up round that was just answered. - order: Display order of the answer. + order: Display order of the task. Returns: Feedback event for the client with the next question, if any. @@ -42,9 +39,9 @@ def advance_without_evaluation( next_question_data: dict[str, Any] | None = None timer_remaining: int | None = None - with InterviewUnitOfWork(auto_commit=True) as uow: + with TheoryUnitOfWork(auto_commit=True) as uow: next_question_data, timer_remaining = ( - SessionNavigationService.advance_to_next_unanswered( + TheoryNavigationService.advance_to_next_unanswered( uow, interview_id, question_id=question_id, @@ -70,25 +67,25 @@ def persist_evaluation_only( round_num: int, evaluation: AnswerEvaluation | FollowUpEvaluation, ) -> None: - """Persist AI score and feedback for one answer round. + """Persist AI score and feedback for one task round. Args: - interview_id: Interview UUID. - question_id: Question ID from the answer row. + interview_id: Parent interview UUID. + question_id: Question ID from the task row. round_num: Follow-up round that was evaluated. evaluation: Parsed AI evaluation. """ - with InterviewUnitOfWork(auto_commit=True) as uow: - aggregate = uow.interviews.get_aggregate(interview_id) - if aggregate is None: - raise InterviewNotFoundError(interview_id) - updated = aggregate.with_evaluation( + with TheoryUnitOfWork(auto_commit=True) as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + raise TheorySectionNotFoundError(interview_id) + updated = section.with_evaluation( question_id, round_num, evaluation.score, evaluation.feedback, ) - uow.interviews.save_aggregate(updated) + uow.theory_sections.save_aggregate(updated) @staticmethod def persist( @@ -104,10 +101,10 @@ def persist( """Save AI evaluation results and build a feedback event. Args: - interview_id: Interview UUID. - question_id: Question ID from the answer row. + interview_id: Parent interview UUID. + question_id: Question ID from the task row. round_num: Follow-up round (0 = initial). - order: Display order of the answer. + order: Display order of the task. evaluation: Parsed AI evaluation. follow_up_needed: Whether to create a follow-up row. follow_up_text: Follow-up question text when applicable. @@ -118,13 +115,13 @@ def persist( next_question_data: dict[str, Any] | None = None timer_remaining: int | None = None - with InterviewUnitOfWork(auto_commit=True) as uow: - aggregate = uow.interviews.get_aggregate(interview_id) - if aggregate is None: - raise InterviewNotFoundError(interview_id) - aggregate.ensure_active() + with TheoryUnitOfWork(auto_commit=True) as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + raise TheorySectionNotFoundError(interview_id) + section.ensure_active() - updated = aggregate.with_evaluation( + updated = section.with_evaluation( question_id, round_num, evaluation.score, @@ -137,25 +134,25 @@ def persist( ) follow_up_round = pending.round - uow.interviews.save_aggregate(updated) + uow.theory_sections.save_aggregate(updated) if follow_up_needed and follow_up_round is not None: uow.flush() - reloaded = uow.interviews.get_aggregate(interview_id) + reloaded = uow.theory_sections.get_aggregate(interview_id) if reloaded is None: - raise InterviewNotFoundError(interview_id) - follow_up = reloaded.find_answer(question_id, follow_up_round) - timed = reloaded.start_timer_for_answer(follow_up.id) - uow.interviews.save_aggregate(timed) + raise TheorySectionNotFoundError(interview_id) + follow_up = reloaded.find_task(question_id, follow_up_round) + timed = reloaded.start_timer_for_task(follow_up.id) + uow.theory_sections.save_aggregate(timed) activated = next( - answer for answer in timed.answers if answer.id == follow_up.id + task for task in timed.tasks if task.id == follow_up.id ) timer_remaining = activated.remaining_seconds( - timed.question_time_limit_seconds + timed.task_time_limit_seconds ) else: next_question_data, timer_remaining = ( - SessionNavigationService.advance_to_next_unanswered( + TheoryNavigationService.advance_to_next_unanswered( uow, interview_id, question_id=question_id, diff --git a/app/interview/services/evaluator/__init__.py b/app/theory/services/evaluator/__init__.py similarity index 51% rename from app/interview/services/evaluator/__init__.py rename to app/theory/services/evaluator/__init__.py index b138cab..939b225 100644 --- a/app/interview/services/evaluator/__init__.py +++ b/app/theory/services/evaluator/__init__.py @@ -1,17 +1,17 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""AI interview evaluation service and supporting types.""" +"""AI theory evaluation service and supporting types.""" -from app.interview.services.evaluator.models import ( +from app.theory.services.evaluator.models import ( AnswerEvaluation, FollowUpEvaluation, InterviewEvaluation, ) -from app.interview.services.evaluator.service import InterviewEvaluatorService +from app.theory.services.evaluator.service import TheoryEvaluatorService __all__ = [ "AnswerEvaluation", "FollowUpEvaluation", "InterviewEvaluation", - "InterviewEvaluatorService", + "TheoryEvaluatorService", ] diff --git a/app/interview/services/evaluator/models.py b/app/theory/services/evaluator/models.py similarity index 69% rename from app/interview/services/evaluator/models.py rename to app/theory/services/evaluator/models.py index df8f476..f90a3de 100644 --- a/app/interview/services/evaluator/models.py +++ b/app/theory/services/evaluator/models.py @@ -1,11 +1,18 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Pydantic models for structured AI evaluation output.""" - -from typing import Any +"""Pydantic models for structured theory AI evaluation output.""" from pydantic import BaseModel, Field +from app.shared.evaluation_models import InterviewEvaluation, SectionEvaluation + +__all__ = [ + "AnswerEvaluation", + "FollowUpEvaluation", + "InterviewEvaluation", + "SectionEvaluation", +] + class AnswerEvaluation(BaseModel): """Evaluation of a single initial answer (round=0). @@ -45,19 +52,3 @@ class FollowUpEvaluation(BaseModel): follow_up_question: str | None = Field( None, description="Next follow-up question text" ) - - -class InterviewEvaluation(BaseModel): - """Final evaluation of an entire interview. - - Attributes: - overall_feedback: Comprehensive narrative feedback on the session. - topics_to_review: Topics the candidate should study further. - strengths_summary: Key strengths demonstrated. - score_breakdown: Per-question score breakdown. - """ - - overall_feedback: str = Field(..., description="Comprehensive feedback") - topics_to_review: list[str] = Field(default_factory=list) - strengths_summary: list[str] = Field(default_factory=list) - score_breakdown: dict[str, Any] = Field(default_factory=dict) diff --git a/app/interview/services/evaluator/prompts.py b/app/theory/services/evaluator/prompts.py similarity index 90% rename from app/interview/services/evaluator/prompts.py rename to app/theory/services/evaluator/prompts.py index 0b2fb76..6b92038 100644 --- a/app/interview/services/evaluator/prompts.py +++ b/app/theory/services/evaluator/prompts.py @@ -1,6 +1,6 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Prompt templates and JSON-schema helpers for interview AI evaluation.""" +"""Prompt templates and JSON-schema helpers for theory AI evaluation.""" import json from typing import Any @@ -67,6 +67,19 @@ you may set needs_further_follow_up to true with another question. Otherwise set it to false.""" +SECTION_EVALUATION_INSTRUCTIONS = """You are a technical interviewer providing a section-level evaluation. +Review all question-answer pairs from this interview section and provide: +1. Section narrative feedback summarizing performance in this section only +2. Topics they should review based on this section +3. Key strengths demonstrated in this section +4. A per-question score breakdown for this section + +For score_breakdown, use question IDs as keys. Each value is an object +with "score" (sum of all rounds for that question) and "max" fields. + +Return a JSON data object with your evaluation content. Do NOT return JSON Schema +metadata or a schema description — only the evaluation data object itself.""" + SESSION_EVALUATION_INSTRUCTIONS = """You are a technical interviewer providing a final evaluation. Review all the question-answer pairs from the interview and provide: 1. Overall narrative feedback summarizing the candidate's performance diff --git a/app/interview/services/evaluator/service.py b/app/theory/services/evaluator/service.py similarity index 75% rename from app/interview/services/evaluator/service.py rename to app/theory/services/evaluator/service.py index 73bd29e..6a8b20b 100644 --- a/app/interview/services/evaluator/service.py +++ b/app/theory/services/evaluator/service.py @@ -1,41 +1,43 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""AI-powered interview evaluation service.""" +"""AI-powered theory task evaluation service.""" from typing import Any, TypeVar from pydantic import BaseModel from app.ai.base import AIProvider, Message -from app.interview.services.evaluator.models import ( +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.theory.services.evaluator.models import ( AnswerEvaluation, FollowUpEvaluation, - InterviewEvaluation, ) -from app.interview.services.evaluator.prompts import ( +from app.theory.services.evaluator.prompts import ( ANSWER_EVALUATION_INSTRUCTIONS, FOLLOW_UP_EVALUATION_INSTRUCTIONS, + SECTION_EVALUATION_INSTRUCTIONS, SESSION_EVALUATION_INSTRUCTIONS, build_evaluator_instructions, - build_prompt_with_schema, looks_like_json_schema_fragment, parse_json_response, ) -from app.shared.locales import DEFAULT_LOCALE __all__ = [ "AnswerEvaluation", "FollowUpEvaluation", "InterviewEvaluation", - "InterviewEvaluatorService", + "SectionEvaluation", + "TheoryEvaluatorService", "looks_like_json_schema_fragment", ] T = TypeVar("T", bound=BaseModel) -class InterviewEvaluatorService: - """Service for AI-powered evaluation of interview answers. +class TheoryEvaluatorService: + """Service for AI-powered evaluation of theory task answers. Uses the configured AI provider to evaluate answers, generate follow-up questions, and produce final session evaluations. @@ -86,25 +88,15 @@ async def _evaluate_with_schema( Raises: ValueError: If AI response is invalid or connection fails. """ - system_prompt = build_prompt_with_schema( - build_evaluator_instructions(locale, instructions), - response_model, + return await evaluate_with_schema( + provider, + locale=locale, + instructions=instructions, + response_model=response_model, + user_text=user_text, + audio_wav=audio_wav, + max_tokens=max_tokens, ) - 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 - ) - return parse_json_response(result.content, response_model) @staticmethod async def evaluate_answer( @@ -129,11 +121,9 @@ async def evaluate_answer( Raises: ValueError: If AI response is invalid or connection fails. """ - question = InterviewEvaluatorService._format_question( - question_text, question_code - ) + question = TheoryEvaluatorService._format_question(question_text, question_code) user_text = f"Question:\n{question}\n\nAnswer:\n{answer_text}" - return await InterviewEvaluatorService._evaluate_with_schema( + return await TheoryEvaluatorService._evaluate_with_schema( provider, locale=locale, instructions=ANSWER_EVALUATION_INSTRUCTIONS, @@ -164,10 +154,8 @@ async def evaluate_answer_with_audio( Raises: ValueError: If AI response is invalid or connection fails. """ - question = InterviewEvaluatorService._format_question( - question_text, question_code - ) - return await InterviewEvaluatorService._evaluate_with_schema( + question = TheoryEvaluatorService._format_question(question_text, question_code) + return await TheoryEvaluatorService._evaluate_with_schema( provider, locale=locale, instructions=ANSWER_EVALUATION_INSTRUCTIONS, @@ -203,16 +191,14 @@ async def evaluate_follow_up( Raises: ValueError: If AI response is invalid or connection fails. """ - question = InterviewEvaluatorService._format_question( - question_text, question_code - ) + question = TheoryEvaluatorService._format_question(question_text, question_code) user_text = ( f"Original Question:\n{question}\n\n" f"Initial Answer:\n{initial_answer}\n\n" f"Follow-up Question:\n{follow_up_question}\n\n" f"Follow-up Answer:\n{follow_up_answer}" ) - return await InterviewEvaluatorService._evaluate_with_schema( + return await TheoryEvaluatorService._evaluate_with_schema( provider, locale=locale, instructions=FOLLOW_UP_EVALUATION_INSTRUCTIONS, @@ -247,15 +233,13 @@ async def evaluate_follow_up_with_audio( Raises: ValueError: If AI response is invalid or connection fails. """ - question = InterviewEvaluatorService._format_question( - question_text, question_code - ) + question = TheoryEvaluatorService._format_question(question_text, question_code) user_text = ( f"Original Question:\n{question}\n\n" f"Initial Answer:\n{initial_answer}\n\n" f"Follow-up Question:\n{follow_up_question}" ) - return await InterviewEvaluatorService._evaluate_with_schema( + return await TheoryEvaluatorService._evaluate_with_schema( provider, locale=locale, instructions=FOLLOW_UP_EVALUATION_INSTRUCTIONS, @@ -290,7 +274,7 @@ def _follow_up_decision( follow_up_needed = ( evaluation.needs_further_follow_up and bool(evaluation.follow_up_question) - and answer_round < InterviewEvaluatorService.MAX_FOLLOW_UP_DEPTH + and answer_round < TheoryEvaluatorService.MAX_FOLLOW_UP_DEPTH ) return follow_up_needed, evaluation.follow_up_question @@ -332,7 +316,7 @@ async def evaluate_submission( evaluation: AnswerEvaluation | FollowUpEvaluation if answer_round == 0: if audio_wav is not None: - evaluation = await InterviewEvaluatorService.evaluate_answer_with_audio( + evaluation = await TheoryEvaluatorService.evaluate_answer_with_audio( provider=provider, question_text=question_text, audio_wav=audio_wav, @@ -340,7 +324,7 @@ async def evaluate_submission( locale=locale, ) else: - evaluation = await InterviewEvaluatorService.evaluate_answer( + evaluation = await TheoryEvaluatorService.evaluate_answer( provider=provider, question_text=question_text, answer_text=answer_text or "", @@ -348,7 +332,7 @@ async def evaluate_submission( locale=locale, ) elif audio_wav is not None: - evaluation = await InterviewEvaluatorService.evaluate_follow_up_with_audio( + evaluation = await TheoryEvaluatorService.evaluate_follow_up_with_audio( provider=provider, question_text=initial_question_text, initial_answer=initial_answer_text, @@ -358,7 +342,7 @@ async def evaluate_submission( locale=locale, ) else: - evaluation = await InterviewEvaluatorService.evaluate_follow_up( + evaluation = await TheoryEvaluatorService.evaluate_follow_up( provider=provider, question_text=initial_question_text, initial_answer=initial_answer_text, @@ -368,11 +352,57 @@ async def evaluate_submission( locale=locale, ) - follow_up_needed, follow_up_text = ( - InterviewEvaluatorService._follow_up_decision(evaluation, answer_round) + follow_up_needed, follow_up_text = TheoryEvaluatorService._follow_up_decision( + evaluation, answer_round ) return evaluation, follow_up_needed, follow_up_text + @staticmethod + async def evaluate_section( + provider: AIProvider, + questions_answers: list[dict[str, Any]], + sources_text: str, + locale: str = DEFAULT_LOCALE, + ) -> SectionEvaluation: + """Provide a narrative evaluation for one interview section. + + Args: + provider: Configured AI provider instance. + questions_answers: Per-task Q&A rows for the section. + sources_text: Human-readable list of tracks, levels, and topics. + locale: Locale for the section evaluation narrative. + + Returns: + SectionEvaluation with narrative feedback and recommendations. + + Raises: + ValueError: If AI response is invalid or connection fails. + """ + qa_summary: list[str] = [] + for qa in questions_answers: + qa_id = qa.get("question_id", "?") + qa_round = qa.get("round", 0) + q_text = qa.get("question_text", "") + a_text = qa.get("answer_text", "(skipped)") + score = qa.get("score", "N/A") + qa_summary.append( + f"Question {qa_id} (round {qa_round}):\n" + f"Q: {q_text}\n" + f"A: {a_text}\n" + f"Score: {score}" + ) + + summary_text = "\n\n".join(qa_summary) + user_text = f"Sources:\n{sources_text}\n\nSection Questions and Answers:\n{summary_text}" + return await TheoryEvaluatorService._evaluate_with_schema( + provider, + locale=locale, + instructions=SECTION_EVALUATION_INSTRUCTIONS, + response_model=SectionEvaluation, + user_text=user_text, + max_tokens=1200, + ) + @staticmethod async def evaluate_interview( provider: AIProvider, @@ -413,11 +443,23 @@ async def evaluate_interview( user_text = ( f"Sources:\n{sources_text}\n\nQuestions and Answers:\n{summary_text}" ) - return await InterviewEvaluatorService._evaluate_with_schema( - provider, - locale=locale, - instructions=SESSION_EVALUATION_INSTRUCTIONS, - response_model=InterviewEvaluation, - user_text=user_text, - max_tokens=1500, + system_prompt = ( + f"{build_evaluator_instructions(locale, SESSION_EVALUATION_INSTRUCTIONS)}\n\n" + "Return ONLY one valid JSON object with overall_feedback, " + "topics_to_review, and strengths_summary. " + "Do not include score_breakdown. " + "No markdown fences, no extra text." + ) + messages = [ + Message(role="system", content=system_prompt), + Message(role="user", content=user_text), + ] + result = await provider.generate( + messages=messages, + temperature=0.3, + 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 new file mode 100644 index 0000000..3b8c996 --- /dev/null +++ b/app/theory/services/navigation.py @@ -0,0 +1,94 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Advance theory sections to the next unanswered task.""" + +from typing import Any + +from app.interview.services.phases import SessionPhaseOrchestrator +from app.theory.domain.entities import TheorySection, TheoryTask +from app.theory.domain.exceptions import TheorySectionNotFoundError +from app.theory.repositories.uow import TheoryUnitOfWork + + +def next_task_payload(task: TheoryTask) -> dict[str, Any]: + """Build WebSocket/API payload for the next unanswered task. + + Args: + task: Next unanswered theory task round. + + Returns: + Dict with question fields for the client. + """ + return { + "question_id": task.question_id, + "order": task.order, + "question_text": task.question_text, + "question_code": task.question_code, + "round": task.round, + } + + +class TheoryNavigationService: + """Shared navigation after a theory task round is completed or timed out.""" + + @staticmethod + def advance_to_next_unanswered( + uow: TheoryUnitOfWork, + interview_id: str, + *, + question_id: str, + round_num: int, + ) -> tuple[dict[str, Any] | None, int | None]: + """Activate the next unanswered task and build client payload. + + Args: + uow: Active unit of work. + interview_id: Parent interview UUID. + question_id: Question ID of the completed round. + round_num: Follow-up round that was just completed. + + Returns: + Tuple of (next_question dict or None, timer_remaining_seconds or None). + + Raises: + TheorySectionNotFoundError: If the theory section does not exist. + TheorySectionNotActiveError: If the section is not active. + """ + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + raise TheorySectionNotFoundError(interview_id) + + section.ensure_active() + + current_index = next( + i + for i, task in enumerate(section.tasks) + if task.question_id == question_id and task.round == round_num + ) + next_task = section.find_next_unanswered_after(current_index) + if next_task is None: + TheoryNavigationService._notify_phase_complete_if_needed( + interview_id, section + ) + return None, None + + updated = section.start_timer_for_task(next_task.id) + uow.theory_sections.save_aggregate(updated) + + activated = next(task for task in updated.tasks if task.id == next_task.id) + timer_remaining = activated.remaining_seconds(updated.task_time_limit_seconds) + return next_task_payload(activated), timer_remaining + + @staticmethod + def _notify_phase_complete_if_needed( + interview_id: str, + section: TheorySection, + ) -> None: + """Trigger section prefetch when the theory phase has no remaining tasks. + + Args: + interview_id: Parent interview UUID. + section: Theory section after the latest navigation update. + """ + if section.is_complete(): + SessionPhaseOrchestrator.notify_section_complete(interview_id, "theory") diff --git a/app/theory/services/page.py b/app/theory/services/page.py new file mode 100644 index 0000000..55e7d4c --- /dev/null +++ b/app/theory/services/page.py @@ -0,0 +1,107 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory section page context builder.""" + +from app.interview.domain.serialization import parse_session_spec +from app.interview.schemas.interview import InterviewRead +from app.interview.services.query import InterviewQuery +from app.theory.repositories.uow import TheoryUnitOfWork +from app.theory.schemas.page import TheoryPageContext + + +class TheoryPageService: + """Build theory-specific page context for session rendering.""" + + @staticmethod + def activate_timer(interview_id: str) -> None: + """Start the per-round timer on the current unanswered theory task. + + Args: + interview_id: Parent interview UUID. + """ + with TheoryUnitOfWork(auto_commit=True) as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None or section.task_time_limit_seconds is None: + return + current = section.find_first_unanswered() + if current is None or current.started_at is not None: + return + updated = section.start_timer_for_task(current.id) + uow.theory_sections.save_aggregate(updated) + + @staticmethod + def _timer_remaining_seconds(interview_id: str) -> int | None: + """Return seconds left on the current theory task timer. + + Args: + interview_id: Parent interview UUID. + + Returns: + Remaining seconds, or None when the timer is disabled or unavailable. + """ + with TheoryUnitOfWork() as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + return None + current = section.find_first_unanswered() + if current is None: + return None + return current.remaining_seconds(section.task_time_limit_seconds) + + @staticmethod + def build_context(interview: InterviewRead) -> TheoryPageContext | None: + """Assemble theory panel context from a loaded interview read model. + + Args: + interview: Interview read model with theory tasks mirrored as answers. + + Returns: + Theory page context, or None when the session has no theory tasks. + """ + session = parse_session_spec( + interview.selection_spec, + question_count=interview.question_count, + task_time_limit_seconds=interview.question_time_limit_seconds, + ) + if not session.theory.enabled: + return None + + if not interview.answers: + with TheoryUnitOfWork() as uow: + section = uow.theory_sections.get_aggregate(interview.id) + if section is None: + return None + + current_question = InterviewQuery.get_current_unanswered(interview) + question_timer_enabled = interview.question_time_limit_seconds is not None + timer_remaining_seconds = ( + TheoryPageService._timer_remaining_seconds(interview.id) + if question_timer_enabled + else None + ) + current_round = current_question.round if current_question else 0 + complete = current_question is None and bool(interview.answers) + + return TheoryPageContext( + answers=interview.answers, + current_question=current_question, + current_answer_id=current_question.id if current_question else None, + question_timer_enabled=question_timer_enabled, + question_time_limit_seconds=interview.question_time_limit_seconds, + timer_remaining_seconds=timer_remaining_seconds, + current_round=current_round, + complete=complete, + ) + + @staticmethod + def load_interview(interview_id: str) -> InterviewRead | None: + """Load interview read model and activate the theory timer when needed. + + Args: + interview_id: Parent interview UUID. + + Returns: + Interview read model, or None when not found. + """ + TheoryPageService.activate_timer(interview_id) + return InterviewQuery.get_interview(interview_id) diff --git a/app/interview/services/question_planning.py b/app/theory/services/planning.py similarity index 70% rename from app/interview/services/question_planning.py rename to app/theory/services/planning.py index d466824..fe1d7bc 100644 --- a/app/interview/services/question_planning.py +++ b/app/theory/services/planning.py @@ -1,6 +1,6 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Load question banks and build interview question plans.""" +"""Load question banks and build theory section question plans.""" from app.interview.domain.value_objects import ( InterviewSelection, @@ -8,7 +8,8 @@ TrackQuestionPools, ) from app.interview.services.rules.selection import plan_questions, track_label -from app.questions import ( +from app.shared.locales import normalize_locale +from app.shared.questions import ( Question, list_categories, list_levels, @@ -16,17 +17,29 @@ load_categories, load_category, ) -from app.shared.locales import normalize_locale +from app.theory.domain.value_objects import PlannedTheoryQuestion + + +def _theory_questions_only(questions: list[Question]) -> list[Question]: + """Drop coding-bank rows that may still appear in legacy theory YAML files. + + Args: + questions: Loaded question rows from the theory bank. + + Returns: + Questions eligible for theory section planning. + """ + return [question for question in questions if question.type != "coding"] def _to_planned(question: Question) -> PlannedQuestion: """Map a YAML question bank row to a domain planned question. Args: - question: Loaded question from ``app.questions``. + question: Loaded question from ``app.shared.questions``. Returns: - Domain value object for interview creation. + Domain value object for theory section creation. """ return PlannedQuestion(id=question.id, text=question.text, code=question.code) @@ -68,7 +81,7 @@ def load_track_pools( selection: InterviewSelection, locale: str, ) -> list[TrackQuestionPools]: - """Load YAML question pools for each track source in a selection. + """Load YAML theory question pools for each track source in a selection. Args: selection: Validated interview selection. @@ -83,13 +96,15 @@ def load_track_pools( locale = normalize_locale(locale) pools: list[TrackQuestionPools] = [] for source in selection.sources: - full_pool = load_categories( - source.track, source.level, list(source.categories), locale=locale + full_pool = _theory_questions_only( + load_categories( + source.track, source.level, list(source.categories), locale=locale + ) ) category_pools: dict[str, list[Question]] = {} for category in source.categories: - category_pool = load_category( - source.track, source.level, category, locale=locale + category_pool = _theory_questions_only( + load_category(source.track, source.level, category, locale=locale) ) category_pools[category] = category_pool pools.append( @@ -105,12 +120,12 @@ def load_track_pools( return pools -def build_question_plan( +def build_theory_question_plan( selection: InterviewSelection, question_count: int, locale: str = "en", -) -> list[PlannedQuestion]: - """Build ordered question list for a multi-source interview. +) -> tuple[PlannedTheoryQuestion, ...]: + """Build ordered theory question list for a multi-source section. Args: selection: Validated interview selection. @@ -118,11 +133,15 @@ def build_question_plan( locale: Locale for question text. Returns: - Ordered list of planned domain questions. + Ordered planned theory questions. Raises: ValueError: If validation fails or pools are empty. """ validate_selection(selection) track_pools = load_track_pools(selection, locale) - return plan_questions(selection, question_count, track_pools) + planned = plan_questions(selection, question_count, track_pools) + return tuple( + PlannedTheoryQuestion(id=question.id, text=question.text, code=question.code) + for question in planned + ) diff --git a/app/theory/services/query.py b/app/theory/services/query.py new file mode 100644 index 0000000..a92e6e8 --- /dev/null +++ b/app/theory/services/query.py @@ -0,0 +1,82 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory section read-only query helpers.""" + +from typing import Any + +from app.interview.services.rules.selection import selection_sources_summary +from app.interview.services.section_evaluation import build_section_evaluation_summary +from app.interview.services.sections import SectionEvaluationSummary +from app.theory.domain.entities import TheorySection +from app.theory.repositories.uow import TheoryUnitOfWork + + +class TheoryQueryService: + """Read-only queries for theory section aggregates.""" + + @staticmethod + def _qa_items_from_section( + section: TheorySection, + ) -> tuple[dict[str, Any], ...]: + """Build Q&A rows from a theory section aggregate. + + Args: + section: Domain theory section with tasks loaded. + + Returns: + Tuple of dicts with question and answer fields for evaluation. + """ + return tuple( + { + "question_id": task.question_id, + "question_text": task.question_text, + "answer_text": task.answer_text, + "score": task.score, + "round": task.round, + "feedback": task.feedback, + } + for task in section.tasks + if task.answer_text is not None + ) + + @staticmethod + def get_evaluation_summary(interview_id: str) -> SectionEvaluationSummary | None: + """Return theory section evaluation data for session completion. + + Uses cached ``section_feedback`` when present. + + Args: + interview_id: Parent interview UUID. + + Returns: + Section summary, or None when no theory section exists. + """ + with TheoryUnitOfWork() as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + return None + + return build_section_evaluation_summary( + "theory", + section_status=section.status, + items=TheoryQueryService._qa_items_from_section(section), + total_score=section.total_score(), + max_score=section.max_score(), + cached_narrative=section.section_feedback, + ) + + @staticmethod + def sources_text_for_section(interview_id: str) -> str: + """Build selection summary text for theory evaluation prompts. + + Args: + interview_id: Parent interview UUID. + + Returns: + Human-readable selection summary, or empty string when missing. + """ + with TheoryUnitOfWork() as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + return "" + return selection_sources_summary(section.selection) diff --git a/app/theory/services/review.py b/app/theory/services/review.py new file mode 100644 index 0000000..30b00ba --- /dev/null +++ b/app/theory/services/review.py @@ -0,0 +1,68 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory section review page context builder.""" + +from __future__ import annotations + +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.services.section_review_support import ( + load_completed_interview, + resolved_section_feedback, + review_score_fields, + shared_review_fields, +) +from app.theory.schemas.review import TheoryReviewContext +from app.theory.schemas.theory import TheoryTaskRead +from app.theory.services.query import TheoryQueryService + + +class TheoryReviewService: + """Build read-only theory review context for completed sessions.""" + + @staticmethod + def build_context(interview_id: str) -> TheoryReviewContext | None: + """Assemble theory review template context for a completed session. + + Args: + interview_id: Parent session UUID. + + Returns: + Review context, or None when the session or theory section is missing. + """ + snapshot = load_completed_interview(interview_id) + if snapshot is None: + return None + + with InterviewUnitOfWork() as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + return None + + summary = TheoryQueryService.get_evaluation_summary(interview_id) + if summary is None: + return None + + section_feedback = resolved_section_feedback( + summary, + item_id_key="question_id", + cached_payload=section.section_feedback, + ) + answers = [ + TheoryTaskRead.model_validate(task) + for task in snapshot.interview.answers + if task.answer_text is not None + ] + scores = review_score_fields( + summary, + total_score=section.total_score(), + max_score=section.max_score(), + ) + + return TheoryReviewContext( + **{ + **shared_review_fields(interview_id, snapshot), + **scores, + "section_feedback": section_feedback, + "answers": answers, + } + ) diff --git a/app/theory/services/section.py b/app/theory/services/section.py new file mode 100644 index 0000000..50a5e4a --- /dev/null +++ b/app/theory/services/section.py @@ -0,0 +1,236 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Theory section orchestration service.""" + +from __future__ import annotations + +from typing import ClassVar, Literal + +from app.interview.services.section_service_support import ( + run_feedback_prefetch, + schedule_feedback_prefetch, + should_prefetch_feedback, +) +from app.interview.services.sections import ( + SectionEvaluationSummary, + SectionPageContext, +) +from app.theory.repositories.uow import TheoryUnitOfWork +from app.theory.services.evaluator.service import TheoryEvaluatorService +from app.theory.services.query import TheoryQueryService + + +class TheorySectionService: + """Theory section lifecycle hooks and read helpers.""" + + section_kind: ClassVar[Literal["theory"]] = "theory" + + @staticmethod + def is_complete(interview_id: str) -> bool: + """Return whether all theory tasks in the section are answered. + + Args: + interview_id: Parent interview UUID. + + Returns: + True when every task has answer text. + """ + with TheoryUnitOfWork() as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + return False + return section.is_complete() + + @staticmethod + def is_user_facing(interview_id: str) -> bool: + """Return whether the user should interact with the theory section now. + + Args: + interview_id: Parent interview UUID. + + Returns: + True when unanswered theory tasks remain. + """ + with TheoryUnitOfWork() as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + return False + return not section.is_complete() + + @staticmethod + def activate_if_pending(interview_id: str) -> bool: + """Theory sections are created active; nothing to promote. + + Args: + interview_id: Parent interview UUID. + + Returns: + Always False. + """ + del interview_id + return False + + @staticmethod + def get_page_context(interview_id: str) -> SectionPageContext | None: + """Return theory section page metadata for session composition. + + Args: + interview_id: Parent interview UUID. + + Returns: + Section page context, or None when no theory section exists. + """ + with TheoryUnitOfWork() as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + return None + return SectionPageContext( + section="theory", + active=not section.is_complete(), + complete=section.is_complete(), + ) + + @staticmethod + def get_evaluation_summary( + interview_id: str, + ) -> SectionEvaluationSummary | None: + """Return theory evaluation summary for session completion. + + Args: + interview_id: Parent interview UUID. + + Returns: + Section summary, or None when no theory section exists. + """ + return TheoryQueryService.get_evaluation_summary(interview_id) + + @staticmethod + def on_phase_complete(interview_id: str) -> None: + """Schedule background prefetch of theory section narrative feedback. + + Idempotent: skips when feedback is already cached. + + Args: + interview_id: Parent interview UUID. + """ + if not TheorySectionService._should_prefetch_section_feedback(interview_id): + return + schedule_feedback_prefetch( + lambda: TheorySectionService._prefetch_section_feedback(interview_id) + ) + + @staticmethod + async def ensure_section_feedback(interview_id: str) -> None: + """Synchronously prefetch section feedback before session completion. + + Idempotent: skips when feedback is already cached or the section is + incomplete. + + Args: + interview_id: Parent interview UUID. + """ + await TheorySectionService._prefetch_section_feedback(interview_id) + + @staticmethod + def _should_prefetch_section_feedback(interview_id: str) -> bool: + """Return whether section feedback should be generated for an interview. + + Args: + interview_id: Parent interview UUID. + + Returns: + True when the theory section exists, is complete, and lacks feedback. + """ + with TheoryUnitOfWork() as uow: + section = uow.theory_sections.get_aggregate(interview_id) + return should_prefetch_feedback(section) + + @staticmethod + async def _prefetch_section_feedback(interview_id: str) -> None: + """Generate and persist cached theory section feedback. + + Args: + interview_id: Parent interview UUID. + """ + await run_feedback_prefetch( + interview_id, + section_name="theory", + should_prefetch=lambda: ( + TheorySectionService._should_prefetch_section_feedback(interview_id) + ), + evaluate=lambda provider: TheorySectionService._evaluate_section_feedback( + interview_id, + provider, + ), + persist=lambda payload, score: ( + TheorySectionService._persist_section_feedback( + interview_id, + payload, + score, + ) + ), + ) + + @staticmethod + async def _evaluate_section_feedback( + interview_id: str, + provider: object, + ) -> tuple[dict[str, object], int] | None: + """Run the theory section LLM evaluation. + + Args: + interview_id: Parent interview UUID. + provider: Configured AI provider instance. + + Returns: + Feedback payload and section score, or None when evaluation is skipped. + """ + summary = TheoryQueryService.get_evaluation_summary(interview_id) + if summary is None or not summary.items: + return None + section_eval = await TheoryEvaluatorService.evaluate_section( + provider=provider, # type: ignore[arg-type] + questions_answers=list(summary.items), + sources_text=TheoryQueryService.sources_text_for_section(interview_id), + locale=TheorySectionService._section_locale(interview_id), + ) + return section_eval.model_dump(), summary.score + + @staticmethod + def _persist_section_feedback( + interview_id: str, + payload: dict[str, object], + score: int, + ) -> None: + """Persist prefetched theory section feedback when still absent. + + Args: + interview_id: Parent interview UUID. + payload: Section evaluation payload from the LLM. + score: Earned section score. + """ + with TheoryUnitOfWork(auto_commit=True) as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None or section.section_feedback is not None: + return + updated = section.with_cached_section_feedback( + payload, + section_score=score, + ) + uow.theory_sections.save_aggregate(updated) + + @staticmethod + def _section_locale(interview_id: str) -> str: + """Load the theory section locale for evaluation prompts. + + Args: + interview_id: Parent interview UUID. + + Returns: + Locale code, defaulting to ``en`` when the section is missing. + """ + with TheoryUnitOfWork() as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + return "en" + return section.locale diff --git a/app/interview/services/answer_processing.py b/app/theory/services/submission.py similarity index 73% rename from app/interview/services/answer_processing.py rename to app/theory/services/submission.py index 5d5b261..92d700c 100644 --- a/app/interview/services/answer_processing.py +++ b/app/theory/services/submission.py @@ -1,8 +1,8 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Answer processing service. +"""Theory task submission orchestration. -Orchestrates text and audio answer submission, timeout flows, and event streaming. +Handles text and audio answer submission, timeout flows, and event streaming. """ import asyncio @@ -13,17 +13,8 @@ from app.ai.base import AIProvider from app.ai.speech_transcriber import SpeechTranscriber -from app.interview.domain.exceptions import ( - InterviewNotFoundError, - QuestionTimerNotEnabledError, - QuestionTimerNotExpiredError, -) +from app.interview.domain.exceptions import InterviewNotFoundError from app.interview.repositories.uow import InterviewUnitOfWork -from app.interview.services.answer_evaluation_persistence import ( - AnswerEvaluationPersistenceService, -) -from app.interview.services.answer_timer import RoundTimerService -from app.interview.services.evaluator.service import InterviewEvaluatorService from app.interview.services.events import ( AnswerSavedEvent, EvaluatingEvent, @@ -36,24 +27,35 @@ validate_wav_bytes, wav_bytes_to_float32, ) +from app.theory.domain.exceptions import ( + TaskTimerNotEnabledError, + TaskTimerNotExpiredError, + TheorySectionNotFoundError, +) +from app.theory.repositories.uow import TheoryUnitOfWork +from app.theory.services.evaluation_persistence import ( + TheoryEvaluationPersistenceService, +) +from app.theory.services.evaluator.service import TheoryEvaluatorService +from app.theory.services.timer import TheoryTimerService logger = logging.getLogger(__name__) @dataclass(frozen=True) -class AnswerSubmissionContext: - """Shared state after an answer row is opened for submission. +class TheorySubmissionContext: + """Shared state after a theory task row is opened for submission. Attributes: question_id: YAML question ID. round_num: Follow-up round (0 = initial). - order: Display order of the answer. + order: Display order of the task. question_text: Text of the question being answered. question_code: Optional code snippet for the question. initial_question_text: Original question text (round 0). initial_answer_text: User's initial answer text (round 0). - locale: Interview locale for AI and speech. - answer_text: Text persisted on the answer row (may be empty for audio). + locale: Section locale for AI and speech. + answer_text: Text persisted on the task row (may be empty for audio). """ question_id: str @@ -67,6 +69,23 @@ class AnswerSubmissionContext: answer_text: str +def _ensure_interview_active(interview_id: str) -> None: + """Ensure the parent interview session accepts submissions. + + Args: + interview_id: Parent interview UUID. + + Raises: + InterviewNotFoundError: If the interview does not exist. + InterviewNotActiveError: If the interview is completed. + """ + with InterviewUnitOfWork() as uow: + aggregate = uow.interviews.get_aggregate(interview_id) + if aggregate is None: + raise InterviewNotFoundError(interview_id) + aggregate.ensure_active() + + async def _evaluate_last_follow_up_in_background( *, interview_id: str, @@ -98,7 +117,7 @@ async def _evaluate_last_follow_up_in_background( """ try: if audio_wav is not None: - evaluation, _, _ = await InterviewEvaluatorService.evaluate_submission( + evaluation, _, _ = await TheoryEvaluatorService.evaluate_submission( provider=provider, locale=locale, answer_round=round_num, @@ -109,7 +128,7 @@ async def _evaluate_last_follow_up_in_background( audio_wav=audio_wav, ) else: - evaluation, _, _ = await InterviewEvaluatorService.evaluate_submission( + evaluation, _, _ = await TheoryEvaluatorService.evaluate_submission( provider=provider, locale=locale, answer_round=round_num, @@ -119,7 +138,7 @@ async def _evaluate_last_follow_up_in_background( initial_answer_text=initial_answer_text, answer_text=answer_text, ) - AnswerEvaluationPersistenceService.persist_evaluation_only( + TheoryEvaluationPersistenceService.persist_evaluation_only( interview_id=interview_id, question_id=question_id, round_num=round_num, @@ -134,8 +153,8 @@ async def _evaluate_last_follow_up_in_background( ) -class AnswerProcessingService: - """Orchestrates answer submission, timeout handling, and event streaming.""" +class TheorySubmissionService: + """Orchestrates theory task submission, timeout handling, and event streaming.""" @staticmethod def require_audio_answer_enabled() -> None: @@ -156,52 +175,54 @@ async def _open_submission( interview_id: str, question_id: str, answer_text: str, - ) -> AsyncIterator[AnswerSubmissionContext | InterviewEvent]: - """Validate the session and persist answer text before evaluation. + ) -> AsyncIterator[TheorySubmissionContext | InterviewEvent]: + """Validate the section and persist task text before evaluation. Yields timeout events when the round expired, otherwise one - :class:`AnswerSubmissionContext`. + :class:`TheorySubmissionContext`. Args: interview_id: The session UUID. question_id: The question ID. - answer_text: Text to store on the answer row (empty for audio). + answer_text: Text to store on the task row (empty for audio). Yields: Timeout feedback events or a single submission context. """ timed_out_round: int | None = None - submission: AnswerSubmissionContext | None = None + submission: TheorySubmissionContext | None = None + + _ensure_interview_active(interview_id) - with InterviewUnitOfWork(auto_commit=True) as uow: - aggregate = uow.interviews.get_aggregate(interview_id) - if aggregate is None: - raise InterviewNotFoundError(interview_id) + with TheoryUnitOfWork(auto_commit=True) as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + raise TheorySectionNotFoundError(interview_id) - aggregate.ensure_active() - current = aggregate.find_unanswered_for_question(question_id) + section.ensure_active() + current = section.find_unanswered_for_question(question_id) round_num = current.round - limit = aggregate.question_time_limit_seconds + limit = section.task_time_limit_seconds if limit and current.is_timer_expired(limit, grace_seconds=0): timed_out_round = round_num else: - updated = aggregate.with_answer_text(current.id, answer_text) - uow.interviews.save_aggregate(updated) - saved = next(a for a in updated.answers if a.id == current.id) + updated = section.with_task_text(current.id, answer_text) + uow.theory_sections.save_aggregate(updated) + saved = next(task for task in updated.tasks if task.id == current.id) initial_question_text = saved.question_text initial_answer_text = "" if round_num > 0: initial = next( - a - for a in updated.answers - if a.question_id == question_id and a.round == 0 + task + for task in updated.tasks + if task.question_id == question_id and task.round == 0 ) initial_question_text = initial.question_text initial_answer_text = initial.answer_text or "" - submission = AnswerSubmissionContext( + submission = TheorySubmissionContext( question_id=question_id, round_num=round_num, order=saved.order, @@ -214,7 +235,7 @@ async def _open_submission( ) if timed_out_round is not None: - async for event in AnswerProcessingService.stream_timeout_submission( + async for event in TheorySubmissionService.stream_timeout_submission( interview_id=interview_id, question_id=question_id, round_num=timed_out_round, @@ -235,35 +256,35 @@ async def _transcribe_and_persist( transcriber: SpeechTranscriber, locale: str, ) -> str: - """Transcribe WAV audio and persist the answer text. + """Transcribe WAV audio and persist the task answer text. Args: interview_id: Interview UUID. - question_id: Question ID from the answer row. + question_id: Question ID from the task row. round_num: Follow-up round being answered. wav_bytes: Canonical WAV payload. transcriber: Loaded speech transcriber. - locale: Interview locale for recognition. + locale: Section locale for recognition. Returns: Final transcript text (may be empty). """ samples = wav_bytes_to_float32(wav_bytes) transcript = await transcriber.transcribe(samples, locale) - with InterviewUnitOfWork(auto_commit=True) as uow: - aggregate = uow.interviews.get_aggregate(interview_id) - if aggregate is None: - raise InterviewNotFoundError(interview_id) - current = aggregate.find_answer(question_id, round_num) - updated = aggregate.with_answer_text(current.id, transcript) - uow.interviews.save_aggregate(updated) + with TheoryUnitOfWork(auto_commit=True) as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + raise TheorySectionNotFoundError(interview_id) + current = section.find_task(question_id, round_num) + updated = section.with_task_text(current.id, transcript) + uow.theory_sections.save_aggregate(updated) return transcript @staticmethod def _schedule_last_follow_up_evaluation( *, interview_id: str, - ctx: AnswerSubmissionContext, + ctx: TheorySubmissionContext, provider: AIProvider, audio_wav: bytes | None = None, ) -> None: @@ -301,12 +322,12 @@ async def stream_timeout_submission( question_id: str, round_num: int, ) -> AsyncIterator[InterviewEvent]: - """Record a timed-out round with zero score and advance the interview. + """Record a timed-out round with zero score and advance the section. Args: interview_id: The interview UUID. question_id: The question ID. - round_num: The answer round that expired. + round_num: The task round that expired. Yields: ``AnswerFeedbackEvent`` when the timeout is accepted. @@ -314,34 +335,37 @@ async def stream_timeout_submission( Raises: InterviewNotFoundError: If the interview does not exist. InterviewNotActiveError: If the interview is already completed. - QuestionTimerNotEnabledError: If the interview has no time limit. - QuestionTimerNotExpiredError: If the deadline has not passed yet. - UnansweredAnswerNotFoundError: If the round is not open. + TheorySectionNotFoundError: If the theory section does not exist. + TaskTimerNotEnabledError: If the section has no time limit. + TaskTimerNotExpiredError: If the deadline has not passed yet. + UnansweredTaskNotFoundError: If the round is not open. """ - with InterviewUnitOfWork() as uow: - aggregate = uow.interviews.get_aggregate(interview_id) - if aggregate is None: - raise InterviewNotFoundError(interview_id) + _ensure_interview_active(interview_id) - aggregate.ensure_active() + with TheoryUnitOfWork() as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + raise TheorySectionNotFoundError(interview_id) - limit = aggregate.question_time_limit_seconds + section.ensure_active() + + limit = section.task_time_limit_seconds if not limit: - raise QuestionTimerNotEnabledError(interview_id) + raise TaskTimerNotEnabledError(interview_id) - current = aggregate.find_answer(question_id, round_num) + current = section.find_task(question_id, round_num) now = datetime.now(UTC) if current.answer_text is not None: return if not current.client_timeout_due(limit, now): - raise QuestionTimerNotExpiredError(interview_id, question_id) + raise TaskTimerNotExpiredError(interview_id, question_id) order = current.order - locale = aggregate.locale + locale = section.locale - yield RoundTimerService.persist_timed_out_round( + yield TheoryTimerService.persist_timed_out_round( interview_id=interview_id, question_id=question_id, round_num=round_num, @@ -366,33 +390,27 @@ async def stream_answer_submission( Yields: Semantic events for WebSocket delivery in order. - - Raises: - InterviewNotFoundError: If the interview does not exist. - InterviewNotActiveError: If the interview is already completed. - UnansweredAnswerNotFoundError: If the question has no open answer row. - AnswerNotFoundError: If the answer row is missing in the database. """ - ctx: AnswerSubmissionContext | None = None - async for item in AnswerProcessingService._open_submission( + ctx: TheorySubmissionContext | None = None + async for item in TheorySubmissionService._open_submission( interview_id, question_id, answer_text ): - if isinstance(item, AnswerSubmissionContext): + if isinstance(item, TheorySubmissionContext): ctx = item break yield item if ctx is None: return - if ctx.round_num >= InterviewEvaluatorService.MAX_FOLLOW_UP_DEPTH: + if ctx.round_num >= TheoryEvaluatorService.MAX_FOLLOW_UP_DEPTH: yield AnswerSavedEvent() - yield AnswerEvaluationPersistenceService.advance_without_evaluation( + yield TheoryEvaluationPersistenceService.advance_without_evaluation( interview_id=interview_id, question_id=ctx.question_id, round_num=ctx.round_num, order=ctx.order, ) - AnswerProcessingService._schedule_last_follow_up_evaluation( + TheorySubmissionService._schedule_last_follow_up_evaluation( interview_id=interview_id, ctx=ctx, provider=provider, @@ -407,7 +425,7 @@ async def stream_answer_submission( follow_up_needed, follow_up_text, ) = await asyncio.shield( - InterviewEvaluatorService.evaluate_submission( + TheoryEvaluatorService.evaluate_submission( provider=provider, locale=ctx.locale, answer_round=ctx.round_num, @@ -419,7 +437,7 @@ async def stream_answer_submission( ) ) - yield AnswerEvaluationPersistenceService.persist( + yield TheoryEvaluationPersistenceService.persist( interview_id=interview_id, question_id=ctx.question_id, round_num=ctx.round_num, @@ -439,10 +457,6 @@ async def stream_audio_answer_submission( ) -> AsyncIterator[InterviewEvent]: """Submit an audio answer and yield NDJSON-compatible service events. - Whisper transcription and LLM audio evaluation run in parallel after the - answer row is saved. On the last allowed follow-up round, navigation - happens immediately and LLM evaluation continues in the background. - Args: interview_id: The session UUID. question_id: The question ID. @@ -452,22 +466,15 @@ async def stream_audio_answer_submission( Yields: Semantic events for HTTP NDJSON or WebSocket delivery. - - Raises: - InterviewNotFoundError: If the interview does not exist. - InterviewNotActiveError: If the interview is already completed. - UnansweredAnswerNotFoundError: If the question has no open answer row. - AnswerNotFoundError: If the answer row is missing in the database. - ValueError: If WAV validation or audio capability checks fail. """ - AnswerProcessingService.require_audio_answer_enabled() + TheorySubmissionService.require_audio_answer_enabled() validate_wav_bytes(wav_bytes) - ctx: AnswerSubmissionContext | None = None - async for item in AnswerProcessingService._open_submission( + ctx: TheorySubmissionContext | None = None + async for item in TheorySubmissionService._open_submission( interview_id, question_id, "" ): - if isinstance(item, AnswerSubmissionContext): + if isinstance(item, TheorySubmissionContext): ctx = item break yield item @@ -476,15 +483,15 @@ async def stream_audio_answer_submission( yield AnswerSavedEvent() - if ctx.round_num >= InterviewEvaluatorService.MAX_FOLLOW_UP_DEPTH: - yield AnswerEvaluationPersistenceService.advance_without_evaluation( + if ctx.round_num >= TheoryEvaluatorService.MAX_FOLLOW_UP_DEPTH: + yield TheoryEvaluationPersistenceService.advance_without_evaluation( interview_id=interview_id, question_id=ctx.question_id, round_num=ctx.round_num, order=ctx.order, ) transcript_task = asyncio.create_task( - AnswerProcessingService._transcribe_and_persist( + TheorySubmissionService._transcribe_and_persist( interview_id=interview_id, question_id=ctx.question_id, round_num=ctx.round_num, @@ -494,7 +501,7 @@ async def stream_audio_answer_submission( ), name=f"audio-transcript-{interview_id}-{ctx.question_id}-r{ctx.round_num}", ) - AnswerProcessingService._schedule_last_follow_up_evaluation( + TheorySubmissionService._schedule_last_follow_up_evaluation( interview_id=interview_id, ctx=ctx, provider=provider, @@ -511,7 +518,7 @@ async def stream_audio_answer_submission( yield EvaluatingEvent() transcript_task = asyncio.create_task( - AnswerProcessingService._transcribe_and_persist( + TheorySubmissionService._transcribe_and_persist( interview_id=interview_id, question_id=ctx.question_id, round_num=ctx.round_num, @@ -522,7 +529,7 @@ async def stream_audio_answer_submission( name=f"audio-transcript-{interview_id}-{ctx.question_id}-r{ctx.round_num}", ) evaluation_task = asyncio.create_task( - InterviewEvaluatorService.evaluate_submission( + TheoryEvaluatorService.evaluate_submission( provider=provider, locale=ctx.locale, answer_round=ctx.round_num, @@ -548,7 +555,7 @@ async def stream_audio_answer_submission( follow_up_text, ) = await asyncio.shield(evaluation_task) - yield AnswerEvaluationPersistenceService.persist( + yield TheoryEvaluationPersistenceService.persist( interview_id=interview_id, question_id=ctx.question_id, round_num=ctx.round_num, @@ -578,7 +585,7 @@ async def process_answer_submission( """ return [ event - async for event in AnswerProcessingService.stream_answer_submission( + async for event in TheorySubmissionService.stream_answer_submission( interview_id=interview_id, question_id=question_id, answer_text=answer_text, @@ -608,7 +615,7 @@ async def process_audio_answer_submission( """ return [ event - async for event in AnswerProcessingService.stream_audio_answer_submission( + async for event in TheorySubmissionService.stream_audio_answer_submission( interview_id=interview_id, question_id=question_id, wav_bytes=wav_bytes, @@ -628,14 +635,14 @@ async def process_timeout_submission( Args: interview_id: The session UUID. question_id: The question ID. - round_num: The answer round that expired. + round_num: The task round that expired. Returns: Semantic events in delivery order. """ return [ event - async for event in AnswerProcessingService.stream_timeout_submission( + async for event in TheorySubmissionService.stream_timeout_submission( interview_id=interview_id, question_id=question_id, round_num=round_num, diff --git a/app/interview/services/answer_timer.py b/app/theory/services/timer.py similarity index 59% rename from app/interview/services/answer_timer.py rename to app/theory/services/timer.py index 474bd67..51d09c5 100644 --- a/app/interview/services/answer_timer.py +++ b/app/theory/services/timer.py @@ -1,18 +1,18 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Per-round timer side effects for interview answers.""" +"""Per-round timer side effects for theory tasks.""" from typing import Any -from app.interview.domain.exceptions import InterviewNotFoundError -from app.interview.repositories.uow import InterviewUnitOfWork from app.interview.services.events import AnswerFeedbackEvent from app.interview.services.rules.feedback import timeout_feedback_for_locale -from app.interview.services.session_navigation import SessionNavigationService +from app.theory.domain.exceptions import TheorySectionNotFoundError +from app.theory.repositories.uow import TheoryUnitOfWork +from app.theory.services.navigation import TheoryNavigationService -class RoundTimerService: - """Timeout persistence for timed answer rounds.""" +class TheoryTimerService: + """Timeout persistence for timed theory task rounds.""" @staticmethod def persist_timed_out_round( @@ -23,13 +23,13 @@ def persist_timed_out_round( order: int, locale: str, ) -> AnswerFeedbackEvent: - """Save a timed-out round with zero score and advance the session. + """Save a timed-out round with zero score and advance the section. Args: - interview_id: Interview UUID. - question_id: Question ID from the answer row. + interview_id: Parent interview UUID. + question_id: Question ID from the task row. round_num: Follow-up round (0 = initial). - order: Display order of the answer. + order: Display order of the task. locale: Locale for timeout feedback. Returns: @@ -39,16 +39,16 @@ def persist_timed_out_round( feedback_text = timeout_feedback_for_locale(locale) timer_remaining: int | None = None - with InterviewUnitOfWork(auto_commit=True) as uow: - aggregate = uow.interviews.get_aggregate(interview_id) - if aggregate is None: - raise InterviewNotFoundError(interview_id) - current = aggregate.find_answer(question_id, round_num) - updated = aggregate.with_timed_out_round(current.id, feedback_text) - uow.interviews.save_aggregate(updated) + with TheoryUnitOfWork(auto_commit=True) as uow: + section = uow.theory_sections.get_aggregate(interview_id) + if section is None: + raise TheorySectionNotFoundError(interview_id) + current = section.find_task(question_id, round_num) + updated = section.with_timed_out_round(current.id, feedback_text) + uow.theory_sections.save_aggregate(updated) next_question_data, timer_remaining = ( - SessionNavigationService.advance_to_next_unanswered( + TheoryNavigationService.advance_to_next_unanswered( uow, interview_id, question_id=question_id, diff --git a/data/coding/python/junior/basics.yaml b/data/coding/python/junior/basics.yaml new file mode 100644 index 0000000..0445ae0 --- /dev/null +++ b/data/coding/python/junior/basics.yaml @@ -0,0 +1,39 @@ +category: "Basics" +track: "python" +level: "junior" + +description: "Core Python fundamentals: types, variables, operators, and language essentials" + +tasks: + - id: "bas-004" + difficulty: 2 + tags: ["type-conversion", "type-hints"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + A data-processing helper doubles each numeric item in a list. The implementation works, + but the function has no type information for reviewers or static checkers. + + Your task: + Add Python 3.10+ type hints to `process` for all parameters and the return value. + Do not change runtime behavior. + ru: | + Контекст: + Функция `process` удваивает числа в списке. Код работает, но без аннотаций типов. + + Задача: + Добавьте аннотации Python 3.10+ для всех параметров и возвращаемого значения. + Поведение функции не меняйте. + starter_code: | + def process(data, options=None): + results = [] + for item in data: + results.append(item * 2) + return results + expected_points: + - "Uses list[int] or equivalent 3.10+ syntax for data and return type" + - "options typed as optional mapping or None" + - "Runtime behavior unchanged" diff --git a/data/coding/python/junior/control-flow.yaml b/data/coding/python/junior/control-flow.yaml new file mode 100644 index 0000000..f968988 --- /dev/null +++ b/data/coding/python/junior/control-flow.yaml @@ -0,0 +1,64 @@ +category: "Control Flow" +track: "python" +level: "junior" + +description: "Python control flow constructs: conditionals, loops, iterators, and context managers" + +tasks: + - id: "cf-003" + difficulty: 2 + tags: ["range", "enumerate", "iteration"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + The script prints index and value for each item using `range(len(...))`. + That pattern is error-prone and unidiomatic in Python. + + Your task: + Refactor the loop to use `enumerate()` while keeping the same printed output. + ru: | + Контекст: + Индекс и значение выводятся через `range(len(...))`. + + Задача: + Перепишите цикл на `enumerate()` с тем же выводом. + starter_code: | + items = ["a", "b", "c"] + + for i in range(len(items)): + print(i, items[i]) + expected_points: + - "Uses enumerate(items) instead of manual indexing" + - "Same print output as original" + + - id: "cf-007" + difficulty: 2 + tags: ["zip", "iteration"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + Two parallel lists hold student names and scores. They must be printed in pairs. + + Your task: + Complete the script so each line shows name and score using `zip()`. + Example line format: `Alice 85` + ru: | + Контекст: + Два списка — имена и баллы. Нужно вывести пары. + + Задача: + Допишите код с `zip()`. Формат строки: `Alice 85` + starter_code: | + names = ["Alice", "Bob", "Charlie"] + scores = [85, 92, 78] + + # print each name with its score + expected_points: + - "Iterates with zip(names, scores)" + - "Prints all three pairs correctly" diff --git a/data/coding/python/junior/exceptions.yaml b/data/coding/python/junior/exceptions.yaml new file mode 100644 index 0000000..200885b --- /dev/null +++ b/data/coding/python/junior/exceptions.yaml @@ -0,0 +1,36 @@ +category: "Exceptions" +track: "python" +level: "junior" + +description: "Python exception handling: try/except/finally, raising exceptions, and exception hierarchy" + +tasks: + - id: "exc-005" + difficulty: 1 + tags: ["assert", "debugging"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `divide` uses `assert` to guard against division by zero. Assertions can be disabled + with `python -O` and are not meant for validating user input. + + Your task: + Replace the assertion with explicit validation. Raise a suitable exception when `b` + is zero. Keep normal division behavior otherwise. + ru: | + Контекст: + `divide` проверяет делитель через `assert`, что не подходит для production. + + Задача: + Замените assert явной проверкой и выбросом подходящего исключения при `b == 0`. + starter_code: | + def divide(a, b): + assert b != 0, "Division by zero!" + return a / b + expected_points: + - "No assert for input validation" + - "Raises ZeroDivisionError or ValueError on b == 0" + - "Returns quotient for valid inputs" diff --git a/data/coding/python/junior/functions.yaml b/data/coding/python/junior/functions.yaml new file mode 100644 index 0000000..26af8f2 --- /dev/null +++ b/data/coding/python/junior/functions.yaml @@ -0,0 +1,38 @@ +category: "Functions" +track: "python" +level: "junior" + +description: "Python functions: parameters, return values, scoping, and advanced function concepts" + +tasks: + - id: "func-006" + difficulty: 2 + tags: ["docstrings", "annotations"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + `divide` performs safe division and returns None when the divisor is zero. + The logic is correct but undocumented and untyped. + + Your task: + Add type hints and a short docstring that explains parameters, return value, + and the zero-divisor case. Keep the existing behavior. + ru: | + Контекст: + `divide` делит числа и возвращает None при делении на ноль. + + Задача: + Добавьте type hints и короткий docstring (параметры, возврат, случай b == 0). + Поведение не меняйте. + starter_code: | + def divide(a, b): + if b == 0: + return None + return a / b + expected_points: + - "Numeric types on parameters and float | None return" + - "Docstring describes zero-divisor behavior" + - "Behavior preserved" diff --git a/data/coding/python/junior/strings.yaml b/data/coding/python/junior/strings.yaml new file mode 100644 index 0000000..7880e6c --- /dev/null +++ b/data/coding/python/junior/strings.yaml @@ -0,0 +1,38 @@ +category: "Strings" +track: "python" +level: "junior" + +description: "Python string operations, formatting, and manipulation" + +tasks: + - id: "str-004" + difficulty: 2 + tags: ["join", "split", "concatenation"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + The snippet builds a string by concatenating items in a loop. That pattern is slow + for large lists because it creates many intermediate string objects. + + Your task: + Refactor the loop to build the same result with `''.join(...)`. + Leave the final value in `result`. + ru: | + Контекст: + Строка собирается конкатенацией в цикле — неэффективно для длинных списков. + + Задача: + Перепишите сборку через `''.join(...)`. Итог сохраните в `result`. + starter_code: | + items = ["a", "b", "c"] + + result = "" + for item in items: + result += item + expected_points: + - "Uses str.join on iterable of strings" + - "Same output as concatenation loop" + - "No += in loop after refactor" diff --git a/data/coding/python/middle/bug-hunt.yaml b/data/coding/python/middle/bug-hunt.yaml new file mode 100644 index 0000000..1a2d9e5 --- /dev/null +++ b/data/coding/python/middle/bug-hunt.yaml @@ -0,0 +1,49 @@ +category: "Bug hunting" +track: "python" +level: "middle" + +description: "Find defects, edge cases, and fragile assumptions in existing code" + +tasks: + - id: "bh-sum-pos-001" + difficulty: 2 + tags: ["files", "exceptions", "edge-cases"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + The function below should read numbers from a file (one integer per line) and return + the sum of all positive numbers. + + Your task: + 1. Find what is wrong with this code (edge cases, crashes, wrong results). + 2. Fix the implementation in the editor so it handles empty lines and invalid input safely. + 3. Keep the function signature and overall behavior: sum only strictly positive integers. + ru: | + Контекст: + Функция должна читать числа из файла (по одному в строке) и возвращать сумму положительных. + + Задача: + 1. Найдите ошибки (краевые случаи, падения, неверный результат). + 2. Исправьте код: пустые строки и невалидный ввод не должны ломать программу. + 3. Сохраните контракт: суммируем только строго положительные целые. + starter_code: | + def sum_positive_numbers(filename): + total = 0 + with open(filename, "r") as f: + for line in f: + number = int(line.strip()) + if number > 0: + total += number + return total + + + result = sum_positive_numbers("numbers.txt") + print(f"Sum of positive numbers: {result}") + expected_points: + - "Empty lines cause ValueError on int(line.strip())" + - "Non-numeric lines cause ValueError without handling" + - "Fix skips blank lines and catches ValueError per line" + - "Negative numbers are ignored as required" diff --git a/data/coding/python/middle/complete-code.yaml b/data/coding/python/middle/complete-code.yaml new file mode 100644 index 0000000..00faa64 --- /dev/null +++ b/data/coding/python/middle/complete-code.yaml @@ -0,0 +1,57 @@ +category: "Complete the code" +track: "python" +level: "middle" + +description: "Finish partial implementations: data structures, algorithms, and API design" + +tasks: + - id: "cc-cache-001" + difficulty: 3 + tags: ["cache", "fifo", "deque", "data-structures"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + You are given a Cache class that stores key-value pairs with a maximum size (maxsize). + When a new item is added and the cache is full, the oldest item must be removed (FIFO policy). + Methods __init__, get, and set are started but incomplete. + + Your task: + Implement get and set so that: + - get returns the value for an existing key, or None if the key is missing. + - set inserts or updates a key; on update, move the key to the end of the eviction order. + - when inserting a new key exceeds maxsize, remove the oldest key from both the queue and data. + ru: | + Контекст: + Дан класс Cache с ограничением maxsize и политикой вытеснения FIFO. + Методы __init__, get и set начаты, но не завершены. + + Задача: + Реализуйте get и set: + - get возвращает значение или None, если ключа нет. + - set добавляет или обновляет ключ; при обновлении ключ уходит в конец очереди. + - при переполнении удаляется самый старый ключ из очереди и словаря. + starter_code: | + from collections import deque + + + class Cache: + def __init__(self, maxsize=10): + self.maxsize = maxsize + self.data = {} + self.order = deque() + + def get(self, key): + # TODO: return value from data if key exists, else None + pass + + def set(self, key, value): + # TODO: add or update key; maintain FIFO order; evict oldest when over maxsize + pass + expected_points: + - "get uses dict lookup and returns None for missing keys" + - "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" diff --git a/data/coding/python/middle/implement.yaml b/data/coding/python/middle/implement.yaml new file mode 100644 index 0000000..81c52b4 --- /dev/null +++ b/data/coding/python/middle/implement.yaml @@ -0,0 +1,59 @@ +category: "Implement from scratch" +track: "python" +level: "middle" + +description: "Design and implement small modules with clear contracts and tests" + +tasks: + - id: "im-flatten-001" + difficulty: 3 + tags: ["recursion", "iterators", "lists", "algorithms"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + Implement flatten(nested_list) that accepts a list of arbitrary nesting depth. + Elements may be numbers, strings, or nested lists. Output must be a flat list + preserving left-to-right order. + + Examples: + flatten([1, [2, [3, 4], 5], 6]) -> [1, 2, 3, 4, 5, 6] + flatten([[1], 2, [[3]]]) -> [1, 2, 3] + flatten([1, "hello", [2, "world"]]) -> [1, "hello", 2, "world"] + + Your task: + - implement flatten in the editor (iterative stack preferred over deep recursion) + - add a small test_flatten() with the examples above plus at least one edge case + - you may run the file locally; Submit sends your final code for review + ru: | + Контекст: + Реализуйте flatten(nested_list) для списка произвольной вложенности. + Числа, строки и вложенные списки сохраняют порядок обхода слева направо. + + Задача: + - реализуйте flatten (лучше итеративно через стек) + - добавьте test_flatten() с примерами и одним краевым случаем + starter_code: | + def flatten(nested_list): + """Return a flat list preserving order.""" + raise NotImplementedError + + + def test_flatten(): + assert flatten([1, [2, [3, 4], 5], 6]) == [1, 2, 3, 4, 5, 6] + assert flatten([[1], 2, [[3]]]) == [1, 2, 3] + assert flatten([1, "hello", [2, "world"]]) == [1, "hello", 2, "world"] + assert flatten([]) == [] + assert flatten([[[[[42]]]]]) == [42] + + + if __name__ == "__main__": + test_flatten() + print("All tests passed!") + expected_points: + - "Iterative stack-of-iterators or equivalent avoids recursion depth limits" + - "Only lists are flattened; scalars appended in order" + - "Handles empty input and deeply nested single value" + - "Includes runnable tests covering examples and edge cases" diff --git a/data/coding/python/middle/refactor.yaml b/data/coding/python/middle/refactor.yaml new file mode 100644 index 0000000..9b253d2 --- /dev/null +++ b/data/coding/python/middle/refactor.yaml @@ -0,0 +1,80 @@ +category: "Refactoring" +track: "python" +level: "middle" + +description: "Improve readability, structure, and Python idioms without changing behavior" + +tasks: + - id: "rf-user-mgr-001" + difficulty: 3 + tags: ["oop", "pep8", "typing", "refactoring"] + coding: + language: python + evaluation_mode: ai + assignment: + en: | + Context: + UserManager stores users in a dict and supports add, remove, find, and list-all. + The code works but is hard to read: redundant None checks, inconsistent style, no types. + + Your task: + Refactor the class in the editor: + - preserve behavior and public method names + - simplify control flow (early returns, dict.get) + - add type hints and short docstrings + - follow PEP 8 spacing and naming + ru: | + Контекст: + UserManager хранит пользователей в словаре. Код работает, но плохо читается. + + Задача: + Отрефакторите класс: + - сохраните поведение и имена методов + - упростите логику (early return, dict.get) + - добавьте type hints и короткие docstrings + - соблюдайте PEP 8 + starter_code: | + class UserManager: + def __init__(self): + self.users = {} + + def add_user(self, uid, name, age): + if uid != None and name != None and age != None: + if uid not in self.users: + self.users[uid] = {"name": name, "age": age} + return True + else: + return False + else: + return False + + def remove_user(self, uid): + if uid in self.users: + del self.users[uid] + return True + else: + return False + + def find_user(self, uid): + if uid in self.users: + return self.users[uid] + else: + return None + + def get_all_users(self): + result = [] + for uid in self.users: + result.append( + { + "uid": uid, + "name": self.users[uid]["name"], + "age": self.users[uid]["age"], + } + ) + return result + expected_points: + - "Uses dict.get or early return instead of nested if/else" + - "List comprehension or clear loop for get_all_users" + - "Type hints on public methods" + - "Docstrings describe return semantics" + - "PEP 8 spacing after commas and around operators" diff --git a/data/questions/python/junior/basics.yaml b/data/questions/python/junior/basics.yaml index d25cb7d..358d97d 100644 --- a/data/questions/python/junior/basics.yaml +++ b/data/questions/python/junior/basics.yaml @@ -84,39 +84,6 @@ questions: - "'None' specifically means absence of value, while other falsy values are legitimate values" - "Use 'is None' to check for None specifically" - - id: "bas-004" - type: "coding" - difficulty: 2 - tags: ["type-conversion", "type-hints"] - - question: - text: - en: "What is type hinting in Python 3.10+? Why is it useful? Describe the typing features added in Python 3.10." - ru: "Что такое аннотации типов (type hints) в Python 3.10+? Зачем они нужны? Опишите возможности typing, добавленные в Python 3.10." - code: | - # Rewrite this function with proper type hints (3.10+ syntax) - def process(data, options=None): - results = [] - for item in data: - results.append(item * 2) - return results - - follow_ups: - en: - - "What is the difference between List[int] and list[int]?" - - "What are TypeAlias and Union types in Python 3.10+?" - - "How does '|' syntax work for unions?" - ru: - - "В чём разница между List[int] и list[int]?" - - "Что такое TypeAlias и Union типы в Python 3.10+?" - - "Как работает синтаксис '|' для объединений?" - - expected_points: - - "Type hints improve code readability and enable static type checking" - - "Python 3.10+: list[int] instead of typing.List[int], dict[str, int]" - - "Union: int | str instead of Union[int, str]" - - "Optional[x] is equivalent to x | None" - - id: "bas-005" type: "knowledge" difficulty: 2 diff --git a/data/questions/python/junior/control-flow.yaml b/data/questions/python/junior/control-flow.yaml index 7cd02cb..22abdd7 100644 --- a/data/questions/python/junior/control-flow.yaml +++ b/data/questions/python/junior/control-flow.yaml @@ -49,33 +49,6 @@ questions: - "Both support 'break' (exit loop) and 'continue' (skip to next iteration)" - "Loop 'else' runs when loop completes normally without break" - - id: "cf-003" - type: "coding" - difficulty: 2 - tags: ["range", "enumerate", "iteration"] - question: - text: - en: "How does 'range()' work in Python? What is 'enumerate()' and why is it useful?" - ru: "Как работает 'range()' в Python? Что такое 'enumerate()' и почему он полезен?" - code: | - items = ['a', 'b', 'c'] - for i in range(len(items)): - print(i, items[i]) - # Refactor using enumerate() - follow_ups: - en: - - "What does range(5) vs range(2, 8) vs range(1, 10, 2) produce?" - - "How memory-efficient is range() in Python 3?" - - "What does enumerate(items, start=1) do?" - ru: - - "Что возвращают range(5), range(2, 8) и range(1, 10, 2)?" - - "Насколько эффективен range() по памяти в Python 3?" - - "Что делает enumerate(items, start=1)?" - expected_points: - - "range(start, stop, step) is a lazy sequence, not a list" - - "enumerate(iterable, start=0) yields (index, value) tuples" - - "Using enumerate avoids manual indexing and is idiomatic Python" - - id: "cf-004" type: "knowledge" difficulty: 2 @@ -152,33 +125,6 @@ questions: - "Guards: case pattern if condition:" - "Wildcard '_' matches anything" - - id: "cf-007" - type: "coding" - difficulty: 2 - tags: ["zip", "iteration"] - question: - text: - en: "How does 'zip()' work in Python? What happens when iterables have different lengths?" - ru: "Как работает 'zip()' в Python? Что происходит, когда итерабельные объекты имеют разную длину?" - code: | - names = ['Alice', 'Bob', 'Charlie'] - scores = [85, 92, 78] - # Use zip to iterate over both lists simultaneously - follow_ups: - en: - - "What does zip(*pairs) do?" - - "What is itertools.zip_longest?" - - "What is the 'strict' parameter in zip in Python 3.10+?" - ru: - - "Что делает zip(*pairs)?" - - "Что такое itertools.zip_longest?" - - "Что такое параметр 'strict' в zip в Python 3.10+?" - expected_points: - - "zip(*iterables) returns iterator of tuples pairing elements by position" - - "Stops at the shortest iterable by default" - - "zip_longest from itertools fills missing values" - - "Python 3.10+: strict=True raises ValueError on length mismatch" - - id: "cf-008" type: "knowledge" difficulty: 3 diff --git a/data/questions/python/junior/exceptions.yaml b/data/questions/python/junior/exceptions.yaml index 701b908..2768c9a 100644 --- a/data/questions/python/junior/exceptions.yaml +++ b/data/questions/python/junior/exceptions.yaml @@ -113,27 +113,3 @@ questions: - "If __exit__ returns True, exception is suppressed" - "If __exit__ returns False (default), exception propagates" - "Useful for implementing retry logic or ignoring specific errors" - - - id: "exc-005" - type: "coding" - difficulty: 1 - tags: ["assert", "debugging"] - question: - text: - en: "What does the 'assert' statement do in Python? When should you use it vs raising exceptions?" - ru: "Что делает оператор 'assert' в Python? Когда его использовать вместо выбрасывания исключений?" - code: | - def divide(a, b): - assert b != 0, "Division by zero!" - return a / b - follow_ups: - en: - - "How can assertions be disabled?" - - "Why shouldn't assertions be used for input validation in production?" - ru: - - "Как можно отключить утверждения (assertions)?" - - "Почему не стоит использовать assert для проверки входных данных в продакшене?" - expected_points: - - "'assert condition, message' raises AssertionError if condition is False" - - "Assertions can be disabled with -O flag" - - "Use assertions for internal invariants, not input validation" diff --git a/data/questions/python/junior/functions.yaml b/data/questions/python/junior/functions.yaml index 0356176..c3a67d1 100644 --- a/data/questions/python/junior/functions.yaml +++ b/data/questions/python/junior/functions.yaml @@ -131,32 +131,6 @@ questions: - "Multiple values are returned as a tuple: return a, b" - "automatic packing" - - id: "func-006" - type: "coding" - difficulty: 2 - tags: ["docstrings", "annotations"] - question: - text: - en: "What are function annotations (type hints) in Python? How do you document a function properly?" - ru: "Что такое аннотации функций (type hints) в Python? Как правильно документировать функцию?" - code: | - # Add type hints and a docstring to this function - def divide(a, b): - if b == 0: - return None - return a / b - follow_ups: - en: - - "What is the difference between type hints and docstrings?" - - "Are type hints enforced at runtime?" - ru: - - "В чём разница между аннотациями типов и докстрингами?" - - "Проверяются ли аннотации типов во время выполнения?" - expected_points: - - "Type hints indicate expected types but are not enforced at runtime" - - "Docstrings describe what the function does in natural language" - - "Use Google or NumPy style for docstrings" - - id: "func-007" type: "knowledge" difficulty: 2 diff --git a/data/questions/python/junior/strings.yaml b/data/questions/python/junior/strings.yaml index bc793bc..aba340d 100644 --- a/data/questions/python/junior/strings.yaml +++ b/data/questions/python/junior/strings.yaml @@ -83,34 +83,6 @@ questions: - "s[::-1] reverses the string" - "Slicing handles out-of-range gracefully; indexing raises IndexError" - - id: "str-004" - type: "coding" - difficulty: 2 - tags: ["join", "split", "concatenation"] - question: - text: - en: "Why is ''.join(list) preferred over string concatenation in a loop?" - ru: "Почему ''.join(list) предпочтительнее конкатенации строк в цикле?" - code: | - items = ["a", "b", "c"] - # Bad approach - result = "" - for item in items: - result += item - # Good approach - result = "".join(items) - follow_ups: - en: - - "How does .join() work internally?" - - "What is the opposite of .join()?" - ru: - - "Как .join() работает внутри?" - - "Что является противоположностью .join()?" - expected_points: - - "String concatenation in loop creates many intermediate objects (O(n²))" - - ".join() allocates once, computes final size" - - "Opposite: .split() splits string into list" - - id: "str-005" type: "knowledge" difficulty: 2 diff --git a/deploy/judge0.conf b/deploy/judge0.conf new file mode 100644 index 0000000..0021604 --- /dev/null +++ b/deploy/judge0.conf @@ -0,0 +1,15 @@ +# Judge0 CE configuration for GrillKit docker-compose profile `coding`. +# Replace passwords before production use. + +REDIS_HOST=judge0-redis +REDIS_PORT=6379 +REDIS_PASSWORD=judge0_redis_dev + +POSTGRES_HOST=judge0-db +POSTGRES_PORT=5432 +POSTGRES_DB=judge0 +POSTGRES_USER=judge0 +POSTGRES_PASSWORD=judge0_postgres_dev + +RAILS_ENV=production +RAILS_LOG_TO_STDOUT=true diff --git a/docker-compose.yml b/docker-compose.yml index cd564a0..d606a68 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,4 +13,65 @@ services: PUID: ${PUID:-1000} PGID: ${PGID:-1000} HF_TOKEN: ${HF_TOKEN:-} + CODING_ENABLED: ${CODING_ENABLED:-true} + JUDGE0_URL: ${JUDGE0_URL:-http://judge0-server:2358} + JUDGE0_AUTH_TOKEN: ${JUDGE0_AUTH_TOKEN:-} restart: unless-stopped + + judge0-db: + image: postgres:16-alpine + profiles: [coding] + environment: + POSTGRES_USER: judge0 + POSTGRES_PASSWORD: judge0_postgres_dev + POSTGRES_DB: judge0 + volumes: + - judge0-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U judge0 -d judge0"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + judge0-redis: + image: redis:7-alpine + profiles: [coding] + command: redis-server --requirepass judge0_redis_dev + healthcheck: + test: ["CMD", "redis-cli", "-a", "judge0_redis_dev", "ping"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + judge0-server: + image: judge0/judge0:1.13.1 + profiles: [coding] + command: ["./scripts/server"] + volumes: + - ./deploy/judge0.conf:/judge0.conf:ro + privileged: true + depends_on: + judge0-db: + condition: service_healthy + judge0-redis: + condition: service_healthy + restart: unless-stopped + + judge0-worker: + image: judge0/judge0:1.13.1 + profiles: [coding] + command: ["./scripts/workers"] + volumes: + - ./deploy/judge0.conf:/judge0.conf:ro + privileged: true + depends_on: + judge0-db: + condition: service_healthy + judge0-redis: + condition: service_healthy + restart: unless-stopped + +volumes: + judge0-postgres-data: diff --git a/static/css/styles.css b/static/css/styles.css index 29960ec..6767ed2 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -16,6 +16,14 @@ --accent-secondary: #0EA5E9; --accent-secondary-hover: #0284C7; + --content-panel-bg: #1B2432; + --content-panel-border: #2F3D4F; + --content-panel-text: #EAEFF4; + --content-panel-text-muted: #8B9AAD; + --content-panel-label: #B4BEC9; + --content-panel-inset-bg: #121A24; + --content-panel-shadow: 0 2px 8px rgb(15 23 42 / 0.22); + --success-bg: #ECFDF5; --success-text: #059669; --success-border: #10B981; @@ -59,6 +67,14 @@ --accent-secondary: #818CF8; --accent-secondary-hover: #6366F1; + --content-panel-bg: #252F3D; + --content-panel-border: #384454; + --content-panel-text: #E2E8F0; + --content-panel-text-muted: #8896A8; + --content-panel-label: #A8B4C0; + --content-panel-inset-bg: #1A222D; + --content-panel-shadow: 0 2px 8px rgb(0 0 0 / 0.35); + --success-bg: #064E3B; --success-text: #34D399; --success-border: #10B981; @@ -702,7 +718,7 @@ textarea.form-control { font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; - color: var(--text-muted); + color: var(--text-secondary); margin: 0; } @@ -859,12 +875,41 @@ textarea.form-control { margin-bottom: 0.25rem; } +.interview-chat-panel .chat-bubble.question-bubble { + background-color: #1B2432; + background-color: var(--content-panel-bg); + border: 1px solid #2F3D4F; + border: 1px solid var(--content-panel-border); + color: #EAEFF4; + color: var(--content-panel-text); + box-shadow: var(--content-panel-shadow); +} + .question-bubble { - background-color: var(--bg-surface); - border: 1px solid var(--border-color); - color: var(--text-primary); + background-color: #1B2432; + background-color: var(--content-panel-bg); + border: 1px solid #2F3D4F; + border: 1px solid var(--content-panel-border); + color: #EAEFF4; + color: var(--content-panel-text); margin-right: auto; margin-left: 0; + box-shadow: var(--content-panel-shadow); +} + +.question-bubble strong { + color: #B4BEC9; + color: var(--content-panel-label); +} + +.question-bubble em { + color: var(--content-panel-text-muted); +} + +.question-bubble .code-block { + background-color: var(--content-panel-inset-bg); + border-color: var(--content-panel-border); + color: var(--content-panel-text); } .answer-bubble { @@ -969,6 +1014,10 @@ textarea.form-control { font-size: 0.875rem; } +.evaluating-indicator[hidden] { + display: none !important; +} + .spinner { width: 1.25rem; height: 1.25rem; @@ -1059,6 +1108,20 @@ textarea.form-control { text-decoration: none; } +.history-mode-badge { + display: inline-block; + margin-left: 0.5rem; + padding: 0.15rem 0.45rem; + border-radius: var(--radius-sm); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--text-secondary); + background-color: var(--border-light); + vertical-align: middle; +} + .history-status { display: inline-block; padding: 0.2rem 0.5rem; @@ -1089,6 +1152,52 @@ textarea.form-control { } } +.setup-session-modes { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.setup-session-mode { + display: flex; + align-items: flex-start; + gap: 0.75rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 0.75rem 1rem; + cursor: pointer; +} + +.setup-session-mode-disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.setup-session-mode-body { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.setup-session-mode-title { + font-weight: 600; +} + +.setup-session-mode-description { + color: var(--text-muted); + font-size: 0.9rem; +} + +.setup-session-mode-badge { + display: inline-block; + margin-top: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-muted); +} + .setup-track-block { border: 1px solid var(--border-color); border-radius: var(--radius-md); @@ -1128,3 +1237,558 @@ textarea.form-control { .selection-sources-list li { margin-bottom: 0.25rem; } + +.session-mode-badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: var(--radius-sm); + background-color: var(--border-light); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-size: 0.8125rem; + font-weight: 600; +} + +.coding-session { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + width: 100%; + background-color: var(--bg-primary); +} + +.coding-session__header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; + flex-shrink: 0; + padding: 0.45rem 1rem; + border-bottom: 1px solid var(--border-color); + background-color: var(--bg-surface); +} + +.coding-session__heading { + min-width: 0; +} + +.coding-session__eyebrow { + display: inline; + margin: 0 0.5rem 0 0; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-muted); +} + +.coding-session__title { + display: inline; + margin: 0; + font-size: 1.0625rem; + line-height: 1.2; + font-weight: 600; +} + +.coding-session__meta { + margin: 0.15rem 0 0; + font-size: 0.8125rem; + line-height: 1.35; + color: var(--text-muted); +} + +.coding-session__dot { + margin: 0 0.35rem; +} + +.coding-session__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.coding-session__timer { + display: flex; + flex-direction: column; + align-items: flex-end; + margin-right: 0.5rem; + padding-right: 0.75rem; + border-right: 1px solid var(--border-color); +} + +.coding-session__timer-label { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.coding-session__timer-value { + font-variant-numeric: tabular-nums; + font-size: 0.9375rem; + font-weight: 600; +} + +.coding-session__body { + display: grid; + grid-template-columns: minmax(16rem, 34%) minmax(0, 1fr); + flex: 1; + min-height: 0; + gap: 0; +} + +.coding-session__brief { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + border-right: 1px solid var(--border-color); + background-color: var(--bg-primary); +} + +.coding-session__brief-card { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + margin: 0.75rem 0.75rem 0.375rem; + padding: 1rem; + border-radius: var(--radius-md); + background-color: var(--content-panel-bg); + border: 1px solid var(--content-panel-border); + color: var(--content-panel-text); + box-shadow: var(--content-panel-shadow); +} + +.coding-session__brief--has-runs .coding-session__brief-card { + flex: 1 1 45%; + max-height: 55%; +} + +.coding-session__task-label { + margin: 0 0 0.75rem; + font-size: 0.8125rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--content-panel-label); +} + +.coding-session__assignment { + white-space: pre-wrap; + font-size: 0.9375rem; + line-height: 1.6; + color: var(--content-panel-text); +} + +.coding-session__workspace { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.coding-session__editor-shell { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: 0.5rem 0.75rem; +} + +.coding-session__editor-bar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; + font-size: 0.8125rem; +} + +.coding-session__editor-label { + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.coding-session__language { + padding: 0.15rem 0.5rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + background-color: var(--border-light); + color: var(--text-secondary); + font-weight: 600; + text-transform: lowercase; +} + +.coding-editor { + flex: 1; + min-height: 0; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; +} + +.coding-explanation-input { + flex: 0 0 auto; + min-height: 4.5rem; + max-height: 7rem; + margin-top: 0.5rem; + resize: vertical; + font-size: 0.875rem; +} + +.coding-session__editor-shell--explanation .coding-editor { + flex: 1; + min-height: 0; +} + +.coding-session__runs { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + margin: 0.375rem 0.75rem 0.75rem; + border-radius: var(--radius-md); + background-color: var(--content-panel-bg); + border: 1px solid var(--content-panel-border); + box-shadow: var(--content-panel-shadow); + overflow: hidden; +} + +.coding-session__runs-header { + flex-shrink: 0; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--content-panel-border); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--content-panel-label); +} + +.coding-session__output { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0.75rem 1rem; + font-size: 0.875rem; + color: var(--content-panel-text); +} + +.coding-session__footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + flex-shrink: 0; + padding: 0.45rem 0.75rem; + border-top: 1px solid var(--border-color); + background-color: var(--bg-surface); +} + +.coding-session__footer-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-left: auto; +} + +.coding-session__evaluating { + margin-top: 0; + flex: 1 1 auto; +} + +.coding-session__done, +.coding-session__final { + padding: 2rem 1.5rem; +} + +.coding-session__done h2 { + margin: 0 0 0.5rem; + font-size: 1.125rem; +} + +@media (max-width: 960px) { + .coding-session__body { + grid-template-columns: 1fr; + } + + .coding-session__brief { + border-right: none; + border-bottom: 1px solid var(--border-color); + max-height: 40vh; + } + + .coding-session__brief--has-runs .coding-session__brief-card { + max-height: 22vh; + } +} + +.coding-run-result + .coding-run-result { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px dashed var(--content-panel-border); +} + +.coding-run-header { + font-weight: 600; + margin-bottom: 0.35rem; + color: var(--content-panel-label); +} + +.coding-run-tests { + margin-bottom: 0.35rem; + color: var(--content-panel-text-muted); +} + +.coding-test-list { + margin: 0.35rem 0 0; + padding-left: 1.1rem; +} + +.coding-test-list li { + margin-bottom: 0.25rem; +} + +.test-pass { + color: var(--success-text, #15803d); +} + +.test-fail { + color: var(--error-text, #b91c1c); +} + +.coding-run-pre { + margin: 0.35rem 0 0; + padding: 0.5rem 0.65rem; + border-radius: var(--radius-sm); + background-color: var(--content-panel-inset-bg); + border: 1px solid var(--content-panel-border); + color: var(--content-panel-text); + font-size: 0.8125rem; + overflow-x: auto; + white-space: pre-wrap; +} + +.coding-run-error { + color: var(--error-text, #b91c1c); + background-color: color-mix(in srgb, var(--error-bg, #fef2f2) 35%, var(--content-panel-inset-bg)); + border-color: color-mix(in srgb, var(--error-border, #ef4444) 50%, var(--content-panel-border)); +} + +.coding-feedback-block { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border-color); +} + +.main-content--coding, +.app-container--coding { + min-height: 0; +} + +.app-container--coding { + height: 100vh; + overflow: hidden; +} + +.page-coding-session .main-content--coding { + display: flex; + flex-direction: column; + max-width: none; + margin: 0; + height: calc(100vh - 4rem); + padding: 0; + overflow: hidden; +} + +.session-results__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.session-results__eyebrow, +.section-review__eyebrow { + margin: 0 0 0.25rem; + font-size: 0.8125rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.session-results__meta, +.session-results__overall, +.session-results__sections { + margin-top: 1.5rem; +} + +.section-result-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: 1rem; +} + +.section-result-card { + padding: 1rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-surface); +} + +.section-result-card__head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.section-result-card__head h3 { + margin: 0; + font-size: 1rem; +} + +.section-result-card__score { + margin: 0; + font-weight: 600; +} + +.section-result-card__summary { + margin: 0 0 1rem; + color: var(--text-muted); + font-size: 0.9375rem; +} + +.section-review__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.section-review__title { + margin: 0 0 0.25rem; + font-size: 1.5rem; +} + +.section-review__meta { + margin: 0; + color: var(--text-muted); +} + +.section-review__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.section-review__layout { + display: grid; + grid-template-columns: minmax(14rem, 20rem) minmax(0, 1fr); + gap: 1.5rem; + align-items: start; +} + +.section-review__sidebar { + padding: 1rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-surface); +} + +.theory-review-chat { + min-height: 20rem; + max-height: 70vh; + overflow-y: auto; + padding: 1rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-primary); +} + +.chat-bubble.feedback-bubble { + background: color-mix(in srgb, var(--accent-color) 12%, var(--bg-surface)); + border: 1px solid color-mix(in srgb, var(--accent-color) 25%, var(--border-color)); +} + +.coding-task-accordion { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.coding-task-panel { + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-surface); +} + +.coding-task-panel__summary { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.875rem 1rem; + cursor: pointer; + list-style: none; + font-weight: 600; +} + +.coding-task-panel__summary::-webkit-details-marker { + display: none; +} + +.coding-task-panel__score { + color: var(--text-muted); + font-weight: 500; +} + +.coding-task-panel__body { + padding: 0 1rem 1rem; + border-top: 1px solid var(--border-color); +} + +.coding-task-round { + padding-top: 1rem; +} + +.coding-task-round + .coding-task-round { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px dashed var(--border-color); +} + +.coding-task-round__header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.coding-task-round__header h4, +.coding-task-round__code h5, +.coding-task-round__tests h5, +.coding-task-round__feedback h5 { + margin: 0 0 0.5rem; + font-size: 0.9375rem; +} + +.code-block--compact { + font-size: 0.8125rem; +} + +@media (max-width: 900px) { + .section-review__layout { + grid-template-columns: 1fr; + } +} diff --git a/static/js/coding_editor.js b/static/js/coding_editor.js new file mode 100644 index 0000000..cbd1f82 --- /dev/null +++ b/static/js/coding_editor.js @@ -0,0 +1,299 @@ +(function () { + "use strict"; + + let editor = null; + let monacoReady = false; + let monacoInitPromise = null; + + function loadMonaco() { + if (monacoInitPromise) { + return monacoInitPromise; + } + monacoInitPromise = new Promise(function (resolve, reject) { + if (window.monaco && window.monaco.editor) { + monacoReady = true; + resolve(window.monaco); + return; + } + const script = document.createElement("script"); + script.src = + "https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"; + script.onload = function () { + window.require.config({ + paths: { + vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs", + }, + }); + window.require(["vs/editor/editor.main"], function () { + monacoReady = true; + resolve(window.monaco); + }, reject); + }; + script.onerror = function () { + reject(new Error("Failed to load Monaco editor")); + }; + document.head.appendChild(script); + }); + return monacoInitPromise; + } + + function escapeHtml(text) { + if (!text) { + return ""; + } + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + function getRunsPanel() { + return document.getElementById("coding-runs-panel"); + } + + function getBrief() { + return document.querySelector(".coding-session__brief"); + } + + function syncRunsPanel(container) { + const output = container || document.getElementById("coding-output"); + const hasContent = output && output.childElementCount > 0; + const panel = getRunsPanel(); + const brief = getBrief(); + if (panel) { + panel.hidden = !hasContent; + } + if (brief) { + brief.classList.toggle("coding-session__brief--has-runs", hasContent); + } + } + + function draftKey(interviewId, taskId, round) { + return ( + "grillkit-coding-draft:" + + interviewId + + ":" + + taskId + + ":" + + String(round != null ? round : 0) + ); + } + + function readDraft(key) { + try { + return sessionStorage.getItem(key); + } catch (_err) { + return null; + } + } + + function writeDraft(key, value) { + try { + sessionStorage.setItem(key, value); + } catch (_err) { + /* ignore quota errors */ + } + } + + function clearDraft(key) { + try { + sessionStorage.removeItem(key); + } catch (_err) { + /* ignore */ + } + } + + window.grillkitCodingEditor = { + init: function (container, options) { + const opts = options || {}; + const interviewId = opts.interviewId || ""; + const taskId = opts.taskId || ""; + const round = opts.round != null ? opts.round : 0; + const starterCode = opts.starterCode || ""; + const language = opts.language || "python"; + const key = draftKey(interviewId, taskId, round); + const saved = readDraft(key); + const initialValue = saved != null ? saved : starterCode; + + return loadMonaco().then(function (monaco) { + if (editor) { + editor.dispose(); + editor = null; + } + editor = monaco.editor.create(container, { + value: initialValue, + language: language, + theme: "vs-dark", + automaticLayout: true, + minimap: { enabled: false }, + fontSize: 14, + scrollBeyondLastLine: false, + }); + editor.onDidChangeModelContent(function () { + writeDraft(key, editor.getValue()); + }); + return editor; + }); + }, + + getValue: function () { + if (editor) { + return editor.getValue(); + } + const textarea = document.getElementById("coding-explanation-input"); + if (textarea && !textarea.hidden) { + return textarea.value; + } + return ""; + }, + + setValue: function (value) { + if (editor) { + editor.setValue(value || ""); + } + }, + + setFollowUpMode: function (mode, text) { + const wrap = document.getElementById("coding-editor-wrap"); + const monacoEl = document.getElementById("coding-editor"); + const textarea = document.getElementById("coding-explanation-input"); + if (!wrap || !monacoEl || !textarea) { + return; + } + if (mode === "explanation") { + monacoEl.hidden = false; + textarea.hidden = false; + wrap.classList.add("coding-session__editor-shell--explanation"); + textarea.value = text || ""; + textarea.focus(); + if (editor) { + editor.layout(); + } + } else { + monacoEl.hidden = false; + textarea.hidden = true; + wrap.classList.remove("coding-session__editor-shell--explanation"); + if (editor && text) { + editor.setValue(text); + } + if (editor) { + editor.layout(); + } + } + }, + + isMonacoVisible: function () { + const monacoEl = document.getElementById("coding-editor"); + return Boolean(monacoEl && !monacoEl.hidden); + }, + + layout: function () { + if (editor) { + editor.layout(); + } + }, + + clearDraftForTask: function (interviewId, taskId, round) { + clearDraft(draftKey(interviewId, taskId, round)); + }, + + renderRunResult: function (container, result) { + if (!container || !result) { + return; + } + let html = '
'; + html += + '
Attempt #' + + escapeHtml(String(result.attempt_no)) + + " — " + + escapeHtml(result.status) + + "
"; + if (result.tests_total > 0) { + html += + '
Tests: ' + + escapeHtml(String(result.tests_passed)) + + " / " + + escapeHtml(String(result.tests_total)) + + "
"; + } + if (Array.isArray(result.test_results)) { + html += '
    '; + result.test_results.forEach(function (testCase) { + const css = testCase.passed ? "test-pass" : "test-fail"; + html += + '
  • ' + + escapeHtml(testCase.name) + + ""; + if (testCase.passed) { + html += " passed"; + } else { + html += " failed"; + if (testCase.actual_stdout != null) { + html += + '
    got: ' +
    +                                escapeHtml(testCase.actual_stdout) +
    +                                "
    "; + } + if (testCase.expected_stdout != null) { + html += + '
    expected: ' +
    +                                escapeHtml(testCase.expected_stdout) +
    +                                "
    "; + } + } + html += "
  • "; + }); + html += "
"; + } + if (result.compile_output) { + html += + '
' +
+                    escapeHtml(result.compile_output) +
+                    "
"; + } + if (result.stderr) { + html += + '
' +
+                    escapeHtml(result.stderr) +
+                    "
"; + } + if (result.stdout) { + html += + '
' +
+                    escapeHtml(result.stdout) +
+                    "
"; + } + html += "
"; + container.insertAdjacentHTML("beforeend", html); + container.scrollTop = container.scrollHeight; + syncRunsPanel(container); + }, + + renderRunHistory: function (container, attempts) { + if (!container || !Array.isArray(attempts)) { + return; + } + container.innerHTML = ""; + attempts.forEach(function (attempt) { + window.grillkitCodingEditor.renderRunResult(container, attempt); + }); + syncRunsPanel(container); + }, + + renderFeedback: function (container, feedbackText) { + if (!container || !feedbackText) { + return; + } + const block = document.createElement("div"); + block.className = "coding-feedback-block"; + block.innerHTML = + "Feedback: " + escapeHtml(feedbackText); + container.appendChild(block); + container.scrollTop = container.scrollHeight; + syncRunsPanel(container); + }, + + syncRunsPanel: syncRunsPanel, + }; +})(); diff --git a/static/js/coding_session.js b/static/js/coding_session.js new file mode 100644 index 0000000..3bc127e --- /dev/null +++ b/static/js/coding_session.js @@ -0,0 +1,506 @@ +(function () { + "use strict"; + + const panel = document.getElementById("coding-panel"); + if (!panel) { + return; + } + + const interviewId = panel.dataset.interviewId || ""; + let taskId = panel.dataset.taskId || ""; + let currentRound = Number(panel.dataset.round || 0); + const taskTimerEnabled = panel.dataset.taskTimerEnabled === "true"; + const taskTimeLimitSeconds = panel.dataset.taskTimeLimit + ? Number(panel.dataset.taskTimeLimit) + : null; + let timerRemainingSeconds = panel.dataset.timerRemaining + ? Number(panel.dataset.timerRemaining) + : null; + const llmRequestTimeoutSeconds = Number(panel.dataset.llmTimeout || 60); + let isSubmitting = false; + let ws = null; + let reconnectTimer = null; + let evaluationWatchdogTimer = null; + let followUpMode = "code"; + let starterCode = ""; + + const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = + wsProtocol + "//" + window.location.host + "/interview/" + interviewId + "/coding/ws"; + + function escapeHtml(text) { + if (!text) { + return ""; + } + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + function showError(message) { + if (typeof window.showError === "function") { + window.showError(message); + return; + } + alert(message); + } + + function getRunBtn() { + return document.getElementById("coding-run-btn"); + } + + function getSubmitBtn() { + return document.getElementById("coding-submit-btn"); + } + + function getOutput() { + return document.getElementById("coding-output"); + } + + function setComposerEnabled(enabled) { + const runBtn = getRunBtn(); + const submitBtn = getSubmitBtn(); + if (runBtn) { + runBtn.disabled = !enabled; + } + if (submitBtn) { + submitBtn.disabled = !enabled; + } + } + + function showEvaluating(visible) { + const indicator = document.getElementById("coding-evaluating-indicator"); + if (indicator) { + indicator.hidden = !visible; + } + } + + function updateProgressDisplay(completed, total) { + const progressEl = document.getElementById("coding-progress"); + if (!progressEl) { + return; + } + if (completed >= total) { + progressEl.textContent = "All coding tasks submitted"; + } else { + progressEl.textContent = + "Task " + String(completed + 1) + " of " + String(total); + } + } + + function updateTaskHeader(task) { + const title = document.getElementById("coding-task-title"); + const prompt = document.getElementById("coding-task-prompt"); + if (!task) { + return; + } + if (title) { + let label = "Task " + String(task.order); + if (task.round > 0) { + label += " · follow-up"; + } + title.textContent = label; + } + if (prompt) { + prompt.textContent = task.prompt_text || ""; + } + } + + function applyTask(task) { + if (!task) { + return; + } + taskId = task.task_id; + currentRound = task.round != null ? Number(task.round) : 0; + panel.dataset.taskId = taskId; + panel.dataset.round = String(currentRound); + updateTaskHeader(task); + const spec = task.task_spec || {}; + starterCode = spec.starter_code || ""; + followUpMode = "code"; + const editorContainer = document.getElementById("coding-editor"); + if (editorContainer && window.grillkitCodingEditor) { + window.grillkitCodingEditor.init(editorContainer, { + interviewId: interviewId, + taskId: taskId, + round: currentRound, + starterCode: starterCode, + language: spec.language || "python", + }).then(function () { + window.grillkitCodingEditor.setFollowUpMode("code"); + window.grillkitCodingEditor.layout(); + }).catch(function (err) { + showError(err.message || "Failed to initialize code editor."); + }); + } + const output = getOutput(); + if (output) { + output.innerHTML = ""; + if (window.grillkitCodingEditor) { + window.grillkitCodingEditor.syncRunsPanel(output); + } + } + loadRunHistory(); + restartTaskTimer(timerRemainingSeconds); + } + + function loadRunHistory() { + if (!taskId) { + return; + } + fetch("/interview/" + encodeURIComponent(interviewId) + "/coding/state") + .then(function (response) { + if (!response.ok) { + throw new Error("Failed to load coding state"); + } + return response.json(); + }) + .then(function (state) { + const output = getOutput(); + if (!output) { + return; + } + const attempts = state.run_attempts || []; + window.grillkitCodingEditor.renderRunHistory(output, attempts); + updateProgressDisplay( + state.completed_tasks || 0, + state.total_tasks || 0 + ); + }) + .catch(function () { + /* history is optional on load */ + }); + } + + function restartTaskTimer(remainingSeconds) { + if (!taskTimerEnabled || !window.grillkitQuestionTimer) { + return; + } + const seconds = + remainingSeconds != null ? remainingSeconds : taskTimeLimitSeconds; + window.grillkitQuestionTimer.start({ + enabled: true, + remainingSeconds: seconds, + questionId: taskId, + round: currentRound, + getWs: function () { + return null; + }, + }); + } + + function stopTaskTimer() { + if (window.grillkitQuestionTimer) { + window.grillkitQuestionTimer.stop(); + } + } + + function clearEvaluationWatchdog() { + if (evaluationWatchdogTimer) { + clearTimeout(evaluationWatchdogTimer); + evaluationWatchdogTimer = null; + } + } + + function startEvaluationWatchdog() { + clearEvaluationWatchdog(); + const graceMs = 15000; + const timeoutMs = llmRequestTimeoutSeconds * 1000 + graceMs; + evaluationWatchdogTimer = setTimeout(function () { + evaluationWatchdogTimer = null; + if (!isSubmitting) { + return; + } + showEvaluating(false); + isSubmitting = false; + setComposerEnabled(true); + showError( + "AI evaluation is taking too long. Check /config, then try again." + ); + }, timeoutMs); + } + + function connectWebSocket() { + ws = new WebSocket(wsUrl); + + ws.onopen = function () { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + }; + + ws.onmessage = function (event) { + const data = JSON.parse(event.data); + handleWsMessage(data); + }; + + ws.onclose = function () { + const indicator = document.getElementById("coding-evaluating-indicator"); + const wasEvaluating = indicator && !indicator.hidden; + if (wasEvaluating || isSubmitting) { + clearEvaluationWatchdog(); + showEvaluating(false); + isSubmitting = false; + setComposerEnabled(true); + showError( + "Connection lost during evaluation. Refresh the page to continue." + ); + } + reconnectTimer = setTimeout(connectWebSocket, 3000); + }; + + ws.onerror = function (err) { + console.error("Coding WebSocket error:", err); + }; + } + + function shouldSwitchToTheoryPhase() { + return ( + window.grillkitSessionPhaseNav && + window.grillkitSessionPhaseNav.hasPendingTheoryPhase() + ); + } + + function handleFeedback(data) { + clearEvaluationWatchdog(); + showEvaluating(false); + isSubmitting = false; + setComposerEnabled(true); + + const output = getOutput(); + if (output && data.feedback) { + window.grillkitCodingEditor.renderFeedback(output, data.feedback); + } + + if (data.follow_up_question) { + const mode = data.follow_up_mode || "code"; + followUpMode = mode; + updateTaskHeader({ + order: data.order, + round: data.round + 1, + prompt_text: data.follow_up_question, + }); + currentRound = data.round + 1; + panel.dataset.round = String(currentRound); + if (mode === "explanation") { + window.grillkitCodingEditor.setFollowUpMode( + "explanation", + "" + ); + } else { + window.grillkitCodingEditor.setFollowUpMode("code"); + } + restartTaskTimer(data.timer_remaining_seconds); + return; + } + + if (data.next_task) { + window.grillkitCodingEditor.clearDraftForTask( + interviewId, + taskId, + currentRound + ); + timerRemainingSeconds = data.timer_remaining_seconds; + applyTask(data.next_task); + return; + } + + stopTaskTimer(); + window.grillkitCodingEditor.clearDraftForTask( + interviewId, + taskId, + currentRound + ); + updateProgressDisplay( + Number(panel.dataset.taskCount || 0), + Number(panel.dataset.taskCount || 0) + ); + + if (shouldSwitchToTheoryPhase()) { + window.location.reload(); + return; + } + + const footer = document.querySelector(".coding-session__footer"); + const body = document.querySelector(".coding-session__body"); + if (footer) { + footer.hidden = true; + } + if (body) { + body.hidden = true; + } + let doneSection = document.querySelector(".coding-session__done"); + if (!doneSection && panel) { + doneSection = document.createElement("section"); + doneSection.className = "coding-session__done"; + doneSection.innerHTML = + "

All coding tasks submitted

" + + '

Use End Interview for your final evaluation.

'; + panel.appendChild(doneSection); + } + } + + function handleWsMessage(data) { + switch (data.type) { + case "saved": + break; + case "evaluating": + showEvaluating(true); + startEvaluationWatchdog(); + break; + case "feedback": + handleFeedback(data); + break; + case "error": + clearEvaluationWatchdog(); + showEvaluating(false); + isSubmitting = false; + setComposerEnabled(true); + showError(data.message || "Coding submit failed."); + break; + default: + break; + } + } + + function runCode() { + if (!taskId || isSubmitting) { + return; + } + const sourceCode = window.grillkitCodingEditor.getValue().trim(); + if (!sourceCode) { + alert("Please enter some code before running."); + return; + } + const runBtn = getRunBtn(); + if (runBtn) { + runBtn.disabled = true; + } + fetch( + "/interview/" + encodeURIComponent(interviewId) + "/coding/run", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + task_id: taskId, + source_code: sourceCode, + }), + } + ) + .then(function (response) { + return response.json().then(function (body) { + if (!response.ok) { + const message = + body.detail || + body.message || + "Run failed (" + String(response.status) + ")."; + throw new Error( + typeof message === "string" + ? message + : JSON.stringify(message) + ); + } + return body; + }); + }) + .then(function (result) { + const output = getOutput(); + if (output) { + window.grillkitCodingEditor.renderRunResult(output, result); + } + }) + .catch(function (err) { + showError(err.message || "Run failed."); + }) + .finally(function () { + if (runBtn) { + runBtn.disabled = isSubmitting; + } + }); + } + + function submitCode() { + if (!taskId || isSubmitting) { + return; + } + const sourceCode = window.grillkitCodingEditor.getValue().trim(); + if (!sourceCode) { + alert( + followUpMode === "explanation" + ? "Please enter your explanation." + : "Please enter your code before submitting." + ); + return; + } + if (!ws || ws.readyState !== WebSocket.OPEN) { + showError("Not connected. Reconnecting…"); + connectWebSocket(); + return; + } + isSubmitting = true; + setComposerEnabled(false); + stopTaskTimer(); + ws.send( + JSON.stringify({ + type: "submit", + task_id: taskId, + source_code: sourceCode, + }) + ); + } + + function bindActions() { + const runBtn = getRunBtn(); + const submitBtn = getSubmitBtn(); + if (runBtn) { + runBtn.addEventListener("click", runCode); + } + if (submitBtn) { + submitBtn.addEventListener("click", submitCode); + } + } + + window.grillkitCodingSession = { + hasPendingCodingPhase: function () { + return ( + window.grillkitSessionPhaseNav && + window.grillkitSessionPhaseNav.hasPendingCodingPhase() + ); + }, + switchToCodingPhase: function () { + if (window.grillkitSessionPhaseNav) { + window.grillkitSessionPhaseNav.continueToNextPhase(); + } else { + window.location.reload(); + } + }, + }; + + document.addEventListener("DOMContentLoaded", function () { + if (panel.dataset.complete === "true" || !taskId) { + return; + } + bindActions(); + connectWebSocket(); + const initialTask = { + task_id: taskId, + order: Number(panel.dataset.completedTasks || 0) + 1, + round: currentRound, + prompt_text: + document.getElementById("coding-task-prompt")?.textContent || "", + task_spec: { + starter_code: starterCode, + language: "python", + }, + }; + const bootstrap = window.grillkitCodingBootstrap; + if (bootstrap && bootstrap.currentTask) { + initialTask.prompt_text = bootstrap.currentTask.prompt_text; + initialTask.order = bootstrap.currentTask.order; + initialTask.round = bootstrap.currentTask.round; + initialTask.task_spec = bootstrap.currentTask.task_spec || {}; + } + applyTask(initialTask); + }); +})(); diff --git a/static/js/interview_audio_answer.js b/static/js/interview_audio_answer.js index d922144..5172c5f 100644 --- a/static/js/interview_audio_answer.js +++ b/static/js/interview_audio_answer.js @@ -181,7 +181,7 @@ const url = "/interview/" + encodeURIComponent(interviewId) + - "/audio-answer"; + "/theory/audio-answer"; const response = await fetch(url, { method: "POST", diff --git a/static/js/session_phases.js b/static/js/session_phases.js new file mode 100644 index 0000000..9bec319 --- /dev/null +++ b/static/js/session_phases.js @@ -0,0 +1,31 @@ +(function () { + "use strict"; + + function getPhases() { + return window.grillkitSessionPhases || null; + } + + window.grillkitSessionPhaseNav = { + hasPendingCodingPhase: function () { + const phases = getPhases(); + return ( + phases && + phases.hasCoding && + !phases.codingComplete && + phases.sessionMode === "theory_then_coding" + ); + }, + hasPendingTheoryPhase: function () { + const phases = getPhases(); + return ( + phases && + phases.hasTheory && + !phases.theoryComplete && + phases.sessionMode === "coding_then_theory" + ); + }, + continueToNextPhase: function () { + window.location.reload(); + }, + }; +})(); diff --git a/templates/_section_feedback.html b/templates/_section_feedback.html new file mode 100644 index 0000000..e498fbb --- /dev/null +++ b/templates/_section_feedback.html @@ -0,0 +1,32 @@ +{% if section_feedback %} +
+ {% if section_feedback.section_feedback %} + + {% endif %} + + {% if section_feedback.strengths_summary %} + + {% endif %} + + {% if section_feedback.topics_to_review %} + + {% endif %} +
+{% endif %} diff --git a/templates/coding_interview.html b/templates/coding_interview.html new file mode 100644 index 0000000..23058b6 --- /dev/null +++ b/templates/coding_interview.html @@ -0,0 +1,236 @@ +{% extends "base.html" %} + +{% block title %}Coding Session - GrillKit{% endblock %} +{% block body_class %}page-coding-session{% endblock %} +{% block app_class %} app-container--coding{% endblock %} +{% block main_class %} main-content--coding{% endblock %} + +{% block content %} +
+ +
+
+

{{ session_mode_label }} session

+

{{ interview_title }}

+

+ + {% if coding.complete %} + All tasks submitted + {% else %} + Task {{ coding.completed_tasks + 1 }} of {{ coding.task_count }} + {% endif %} + + {% if selection_lines %} + + {{ selection_lines | join(' · ') }} + {% endif %} +

+
+
+
+ Time left + +
+ Dashboard + {% if interview.status == "active" %} + + {% endif %} +
+
+ + {% if interview.status == "active" and coding.current_task and not coding.complete %} +
+ + +
+
+
+ Solution + {{ coding.current_task.task_spec.language | default('python') }} +
+
+ +
+
+
+ +
+ + +
+ + {% elif coding.complete %} +
+

All coding tasks submitted

+

Use End Interview when you are ready for the final evaluation.

+
+ {% endif %} +
+{% endblock %} + +{% block scripts %} +{% if session_mode %} + + +{% endif %} +{% if coding.current_task %} + +{% endif %} +{% if coding.task_timer_enabled and interview.status == "active" %} + +{% endif %} +{% if interview.status == "active" %} + +{% endif %} + +{% if interview.status == "active" and coding.current_task and not coding.complete %} + +{% endif %} +{% endblock %} diff --git a/templates/coding_review.html b/templates/coding_review.html new file mode 100644 index 0000000..910907a --- /dev/null +++ b/templates/coding_review.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} + +{% block title %}Coding Review - GrillKit{% endblock %} +{% block body_class %}page-coding-review{% endblock %} + +{% block content %} +
+
+
+

Coding section

+

{{ interview_title }}

+

+ Score {{ section_score }} / {{ section_max_score }} · {{ locale_label }} +

+
+ +
+ +
+ + +
+

Coding Tasks

+
+ {% for task in tasks %} +
+ + Task {{ task.order }} · {{ task.task_id }} + {{ task.total_score }} / {{ task.max_score }} + + +
+
+

Assignment

+

{{ task.initial_prompt }}

+
+ + {% for round in task.rounds %} +
+
+

+ {% if round.round == 0 %} + Initial submission + {% else %} + Follow-up round {{ round.round }} + {% endif %} +

+ {% if round.score is not none %} + {{ round.score }} / 5 + {% endif %} +
+ + {% if round.round > 0 %} +
+ Prompt: {{ round.prompt_text }} +
+ {% endif %} + +
+
Submission
+
{{ round.submitted_code }}
+
+ + {% if round.round == 0 and round.submit_test_summary %} +
+
Test results
+
{{ round.submit_test_summary | tojson(indent=2) }}
+
+ {% endif %} + + {% if round.feedback %} + + {% endif %} +
+ {% endfor %} +
+
+ {% endfor %} +
+
+
+
+{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html index 76643ba..3abdf43 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -43,6 +43,7 @@

Interview history

{{ row.title }} + {{ row.session_mode_label }} {{ row.question_count }} {{ row.score_display }} diff --git a/templates/interview.html b/templates/interview.html index c784bd1..86fb1be 100644 --- a/templates/interview.html +++ b/templates/interview.html @@ -2,6 +2,8 @@ {% set dictation_enabled = interview.status == "active" and current_question and speech_model_status and speech_model_status.state == "ready" %} {% set audio_answer_enabled = dictation_enabled and interview_model_accepts_audio %} +{% set show_theory_panel = (theory is not defined or theory is not none) and (active_phase is not defined or active_phase == 'theory') %} +{% set show_coding_panel = coding is defined and coding is not none and active_phase is defined and active_phase == 'coding' %} {% block title %}Interview - GrillKit{% endblock %} {% block body_class %}page-interview{% endblock %} @@ -23,11 +25,6 @@ Open Configuration to retry loading the speech model. {% endif %} - {% if interview.status == "completed" %} -
- Interview completed! Final score: {{ interview.score }} / {{ max_score }} -
- {% endif %}
@@ -43,14 +40,34 @@

{{ interview_title }}

AI language
{{ locale_label }}
-
Progress
+ {% if session_mode %} +
Session
+
{{ session_mode_label }}
+ {% endif %} + {% if theory is defined and theory is not none %} +
Theory
- {% if current_question %} - Question {{ current_question.order }} of {{ interview.question_count }} + {% if theory.complete %} + All questions answered + {% elif theory.current_question %} + Question {{ theory.current_question.order }} of {{ interview.question_count }} {% else %} All questions answered {% endif %}
+ {% endif %} + {% if coding is defined and coding is not none %} +
Coding
+
+ {% if coding.complete %} + All coding tasks submitted + {% elif coding.current_task %} + Task {{ coding.completed_tasks + 1 }} of {{ coding.task_count }} + {% else %} + Task {{ coding.completed_tasks }} of {{ coding.task_count }} + {% endif %} +
+ {% endif %}
Status
{{ interview.status | capitalize }}
Timer
@@ -60,65 +77,6 @@

{{ interview_title }}

- {% if interview.status == "completed" and overall_feedback %} -
-

Final Evaluation

- - - - {% if overall_feedback.strengths_summary %} - - {% endif %} - - {% if overall_feedback.topics_to_review %} - - {% endif %} - - {% if overall_feedback.score_breakdown %} - - {% endif %} -
- {% endif %} - -
+
{% for answer in answers %} {% if answer.answer_text is not none or (current_question and answer.id == current_question.id) %} @@ -154,7 +112,12 @@

Score Breakdown

{% endfor %}
- {% if interview.status == "active" and current_question %} + {% if interview.status == "active" and theory is defined and theory is not none and theory.complete and session_mode == "theory_then_coding" and coding is defined and coding is not none and not coding.complete %} +
+

Theory section complete. Continue to the coding practice.

+ +
+ {% elif interview.status == "active" and current_question %}
Score Breakdown
{% endif %}
+ {% endblock %} {% block scripts %} -{% if question_timer_enabled and interview.status == "active" and current_question %} +{% if session_mode %} + + +{% endif %} +{% if coding is defined and coding is not none and coding.current_task %} + +{% endif %} +{% if (question_timer_enabled or (coding is defined and coding is not none and coding.task_timer_enabled)) and interview.status == "active" %} {% endif %} @@ -704,7 +632,7 @@

Score Breakdown

{% if audio_answer_enabled %} {% endif %} -{% if question_voice_enabled and interview.status == "active" and current_question %} +{% if question_voice_enabled and interview.status == "active" and current_question and show_theory_panel %} {% endif %} {% endblock %} diff --git a/templates/session_results.html b/templates/session_results.html new file mode 100644 index 0000000..2f4afff --- /dev/null +++ b/templates/session_results.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} + +{% block title %}Session Results - GrillKit{% endblock %} +{% block body_class %}page-session-results{% endblock %} + +{% block content %} +
+
+
+

{{ session_mode_label }} session

+

{{ interview_title }}

+

+ Completed · Score {{ interview.score }} / {{ max_score }} · {{ locale_label }} +

+
+ Dashboard +
+ +
+
+
Sources
+
+
    + {% for line in selection_lines %} +
  • {{ line }}
  • + {% endfor %} +
+
+
+
+ +
+

Overall Evaluation

+ {% if overall_feedback.overall_feedback %} + + {% endif %} + + {% if overall_feedback.strengths_summary %} + + {% endif %} + + {% if overall_feedback.topics_to_review %} + + {% endif %} +
+ + {% if section_cards %} +
+

Sections

+
+ {% for card in section_cards %} +
+
+

{{ card.label }}

+

+ {% if card.skipped %} + Skipped + {% else %} + {{ card.score }} / {{ card.max_score }} + {% endif %} +

+
+

{{ card.summary }}

+ View details +
+ {% endfor %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/setup.html b/templates/setup.html index eb79a08..27283c9 100644 --- a/templates/setup.html +++ b/templates/setup.html @@ -16,14 +16,41 @@

Interview Setup

-

Choose question banks, levels, and topics for your session

+

Choose session mode, question banks, levels, and topics

-

Question banks & topics

+

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 %} @@ -32,7 +59,7 @@

Interview Setup

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

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

-
- -
- +
+

@@ -80,7 +101,7 @@

Interview Setup

-
+
+ + + + + + + + +
+ + +

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

+
+
@@ -114,14 +209,22 @@

Interview Setup

const submitBtn = document.getElementById("setup-submit"); 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 theorySetupPanel = document.getElementById("theory-setup-panel"); + const theoryCountPanel = document.getElementById("theory-count-panel"); + const theoryTimerPanel = document.getElementById("theory-timer-panel"); + const codingSetupPanel = document.getElementById("coding-setup-panel"); + const codingCountPanel = document.getElementById("coding-count-panel"); + const codingTimerPanel = document.getElementById("coding-timer-panel"); function formatTopic(slug) { return slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, " "); } - function selectedTopicCount() { + function selectedTheoryTopicCount() { let count = 0; - document.querySelectorAll(".setup-track-block").forEach(function (block) { + 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 () { @@ -132,8 +235,21 @@

Interview Setup

return count; } - function syncQuestionCountMin() { - const topics = selectedTopicCount(); + 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) { @@ -144,9 +260,36 @@

Interview Setup

+ minVal + " (one per selected topic)."; } - function buildSelection() { + 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 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 buildTheorySources() { const sources = []; - document.querySelectorAll(".setup-track-block").forEach(function (block) { + document.querySelectorAll(".theory-track-block").forEach(function (block) { const track = block.dataset.track; const enabled = block.querySelector(".track-enable"); if (!enabled || !enabled.checked) { @@ -162,28 +305,121 @@

Interview Setup

sources.push({ track: track, level: level, categories: categories }); } }); - return { sources: sources }; + return sources; } - function canSubmit() { - const selection = buildSelection(); - if (selection.sources.length === 0) { - return false; + 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 = selectedTopicCount(); - if (topics === 0) { + 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 canSubmit() { + const selection = buildSelection(); + return theoryValid(selection) && codingValid(selection); + } + + function syncPanelVisibility() { + const mode = selectedSessionMode(); + const showTheory = branchEnabled(mode, "theory"); + const showCoding = branchEnabled(mode, "coding"); + theorySetupPanel.hidden = !showTheory; + theoryCountPanel.hidden = !showTheory; + theoryTimerPanel.hidden = !showTheory; + if (!showTheory && timerMinutesGroup) { + timerMinutesGroup.hidden = true; + } else { + syncTimerFields(); + } + codingSetupPanel.hidden = !showCoding; + codingCountPanel.hidden = !showCoding; + codingTimerPanel.hidden = !showCoding; + if (!showCoding && codingTimerMinutesGroup) { + codingTimerMinutesGroup.hidden = true; + } else { + syncCodingTimerFields(); + } + } + function syncSubmitState() { + syncPanelVisibility(); submitBtn.disabled = !canSubmit(); - syncQuestionCountMin(); + syncTheoryCountMin(); + syncCodingCountMin(); } - async function loadTopics(block) { + async function loadTheoryTopics(block) { const track = block.dataset.track; const levelSelect = block.querySelector(".track-level"); const topicList = block.querySelector(".setup-topic-list"); @@ -223,7 +459,47 @@

Interview Setup

syncSubmitState(); } - document.querySelectorAll(".setup-track-block").forEach(function (block) { + 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", syncSubmitState); + label.appendChild(input); + label.appendChild(document.createTextNode(" " + formatTopic(cat))); + topicList.appendChild(label); + }); + syncSubmitState(); + } + + 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"); @@ -236,7 +512,7 @@

Interview Setup

} if (levelSelect) { levelSelect.addEventListener("change", function () { - loadTopics(block); + loadTheoryTopics(block); }); } block.querySelectorAll(".topic-checkbox").forEach(function (cb) { @@ -244,24 +520,61 @@

Interview Setup

}); }); + 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; + syncSubmitState(); + }); + } + if (levelSelect) { + levelSelect.addEventListener("change", function () { + loadCodingTopics(block); + }); + } + block.querySelectorAll(".coding-topic-checkbox").forEach(function (cb) { + cb.addEventListener("change", syncSubmitState); + }); + }); + + document.querySelectorAll(".session-mode-radio").forEach(function (radio) { + radio.addEventListener("change", syncSubmitState); + }); + questionCountInput.addEventListener("input", syncSubmitState); + codingCountInput.addEventListener("input", syncSubmitState); 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"); function syncTimerFields() { if (!timerCheckbox || !timerMinutesGroup) return; timerMinutesGroup.hidden = !timerCheckbox.checked; } + function syncCodingTimerFields() { + if (!codingTimerCheckbox || !codingTimerMinutesGroup) return; + codingTimerMinutesGroup.hidden = !codingTimerCheckbox.checked; + } + if (timerCheckbox) { timerCheckbox.addEventListener("change", syncTimerFields); syncTimerFields(); } + if (codingTimerCheckbox) { + codingTimerCheckbox.addEventListener("change", syncCodingTimerFields); + syncCodingTimerFields(); + } form.addEventListener("submit", function (event) { const selection = buildSelection(); - if (selection.sources.length === 0) { + if (!theoryValid(selection) || !codingValid(selection)) { event.preventDefault(); return; } diff --git a/templates/theory_review.html b/templates/theory_review.html new file mode 100644 index 0000000..977363e --- /dev/null +++ b/templates/theory_review.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} + +{% block title %}Theory Review - GrillKit{% endblock %} +{% block body_class %}page-theory-review{% endblock %} + +{% block content %} +
+
+
+

Theory section

+

{{ interview_title }}

+

+ Score {{ section_score }} / {{ section_max_score }} · {{ locale_label }} +

+
+ +
+ +
+ + +
+

Conversation History

+
+ {% for answer in answers %} +
+
+ Q{{ answer.order }}: + {% if answer.round > 0 %}(follow-up){% endif %} + {{ answer.question_text }} + {% if answer.question_code %} +
{{ answer.question_code }}
+ {% endif %} +
+
+ +
+
+ You: {{ answer.answer_text }} +
+
+ + {% if answer.score is not none or answer.feedback %} + + {% endif %} + {% endfor %} +
+
+
+
+{% endblock %} diff --git a/tests/fakes.py b/tests/fakes.py index 3db5ebc..75bb69d 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -5,7 +5,14 @@ from collections.abc import AsyncIterator from app.ai.base import AIProvider, GenerationResult, Message -from app.interview.services.evaluator.models import AnswerEvaluation, FollowUpEvaluation +from app.coding.services.evaluator.models import ( + CodingAnswerEvaluation, +) +from app.shared.evaluation_models import SectionEvaluation +from app.theory.services.evaluator.models import ( + AnswerEvaluation, + FollowUpEvaluation, +) def answer_evaluation_json( @@ -35,6 +42,36 @@ def answer_evaluation_json( return payload.model_dump_json() +def coding_answer_evaluation_json( + *, + score: int = 4, + feedback: str = "Solid code.", + follow_up_needed: bool = False, + follow_up_question: str | None = None, + follow_up_mode: str | None = None, +) -> str: + """Build JSON text matching ``CodingAnswerEvaluation`` for a fake provider. + + Args: + score: Rating 1-5. + feedback: Evaluation feedback text. + follow_up_needed: Whether a follow-up is requested. + follow_up_question: Follow-up prompt when needed. + follow_up_mode: Composer mode for the follow-up round. + + Returns: + Serialized JSON string. + """ + payload = CodingAnswerEvaluation( + score=score, + feedback=feedback, + follow_up_needed=follow_up_needed, + follow_up_question=follow_up_question, + follow_up_mode=follow_up_mode, # type: ignore[arg-type] + ) + return payload.model_dump_json() + + def follow_up_evaluation_json( *, score: int = 3, @@ -62,6 +99,30 @@ def follow_up_evaluation_json( return payload.model_dump_json() +def section_evaluation_json( + *, + section_feedback: str = "Solid section performance.", + topics_to_review: list[str] | None = None, + strengths_summary: list[str] | None = None, +) -> str: + """Build JSON text matching ``SectionEvaluation`` for a fake provider. + + Args: + section_feedback: Section narrative feedback text. + topics_to_review: Topics to review for the section. + strengths_summary: Strength bullets for the section. + + Returns: + Serialized JSON string. + """ + payload = SectionEvaluation( + section_feedback=section_feedback, + topics_to_review=topics_to_review or [], + strengths_summary=strengths_summary or ["clear answers"], + ) + return payload.model_dump_json() + + class FakeProvider(AIProvider): """Deterministic AI provider that returns queued JSON responses. diff --git a/tests/helpers/coding_seed.py b/tests/helpers/coding_seed.py new file mode 100644 index 0000000..6b62e57 --- /dev/null +++ b/tests/helpers/coding_seed.py @@ -0,0 +1,146 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Test helpers for seeding coding sections linked to interview rows.""" + +import json + +from sqlalchemy.orm import Session + +from app.interview.domain.serialization import selection_to_spec, session_to_spec +from app.interview.domain.value_objects import ( + SectionBranchSpec, + SessionSelection, + TrackSelection, +) +from app.interview.repositories.uow import InterviewUnitOfWork +from app.shared.infrastructure.models import CodingSection, CodingTask, Interview + + +def create_coding_section_for_interview( + db_session: Session, + interview: Interview, + *, + task_count: int = 2, + task_time_limit_seconds: int | None = None, + status: str = "active", +) -> CodingSection: + """Insert a coding section row matching an interview shell. + + Args: + db_session: Active SQLAlchemy session. + interview: Parent interview ORM row (must be flushed). + task_count: Number of coding tasks in the section. + task_time_limit_seconds: Optional per-task timer in seconds. + status: Section status to persist. + + Returns: + Persisted coding section with assigned primary key. + """ + section = CodingSection( + interview_id=interview.id, + selection_spec=interview.selection_spec, + task_count=task_count, + task_time_limit_seconds=task_time_limit_seconds, + locale=interview.locale or "en", + status=status, + ) + db_session.add(section) + db_session.flush() + return section + + +def attach_coding_tasks( + db_session: Session, + section: CodingSection, + *, + task_ids: list[str] | None = None, +) -> list[CodingTask]: + """Create coding task rows for a section. + + Args: + db_session: Active SQLAlchemy session. + section: Parent coding section ORM row (must be flushed). + task_ids: Task IDs to create; defaults to two placeholder tasks. + + Returns: + Persisted coding task rows. + """ + ids = task_ids or ["cod-001", "cod-002"] + tasks: list[CodingTask] = [] + for order, task_id in enumerate(ids, start=1): + task = CodingTask( + coding_section_id=section.id, + task_id=task_id, + order=order, + round=0, + prompt_text=f"Task {task_id}", + task_spec=json.dumps( + { + "language": "python", + "evaluation_mode": "ai", + "starter_code": "pass", + } + ), + ) + db_session.add(task) + tasks.append(task) + db_session.flush() + return tasks + + +def seed_active_coding_interview( + interview_id: str = "coding-api-1", + *, + task_ids: list[str] | None = None, +) -> tuple[str, str]: + """Persist an active interview shell with one coding section and tasks. + + Args: + interview_id: Interview primary key. + task_ids: Coding bank task IDs to attach. + + Returns: + Tuple of interview id and the first task's YAML task id. + """ + ids = task_ids or ["cod-001"] + session = SessionSelection( + session_mode="coding_only", + theory=SectionBranchSpec( + enabled=False, + question_count=0, + task_time_limit_seconds=None, + sources=(), + ), + coding=SectionBranchSpec( + enabled=True, + question_count=len(ids), + task_time_limit_seconds=None, + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + ), + ) + with InterviewUnitOfWork(auto_commit=True) as uow: + interview = Interview( + id=interview_id, + locale="en", + selection_spec=session_to_spec(session), + session_mode="coding_only", + status="active", + ) + uow.interviews.add(interview) + uow.flush() + section = create_coding_section_for_interview( + uow.session, + interview, + task_count=len(ids), + status="active", + ) + section.selection_spec = selection_to_spec(session.coding_selection) + attach_coding_tasks(uow.session, section, task_ids=ids) + first_task_id = ids[0] + return interview_id, first_task_id diff --git a/tests/helpers/interview_seed.py b/tests/helpers/interview_seed.py index 7923bb3..0a81003 100644 --- a/tests/helpers/interview_seed.py +++ b/tests/helpers/interview_seed.py @@ -2,30 +2,41 @@ # SPDX-License-Identifier: Apache-2.0 """Test helpers for seeding interview sessions in the database.""" -import json - from app.interview.repositories.uow import InterviewUnitOfWork from app.shared.infrastructure.models import Answer, Interview from tests.helpers.selection import minimal_selection_spec +from tests.helpers.theory_seed import attach_theory_section_to_answers def persist_interview_with_answers( interview: Interview, answers: list[Answer], + *, + question_count: int | None = None, + task_time_limit_seconds: int | None = None, ) -> str: """Persist an interview row and nested answer rows in one transaction. Args: interview: ORM interview instance to insert. - answers: Answer rows attached via ``Interview.answers``. + answers: Theory task rows to link through a theory section. + question_count: Section question count; defaults to answer list length. + task_time_limit_seconds: Optional per-task timer in seconds. Returns: The interview id. """ interview_id = interview.id - interview.answers = answers with InterviewUnitOfWork(auto_commit=True) as uow: uow.interviews.add(interview) + uow.flush() + attach_theory_section_to_answers( + uow.session, + interview, + answers, + question_count=question_count, + task_time_limit_seconds=task_time_limit_seconds, + ) return interview_id @@ -43,24 +54,22 @@ def seed_two_question_interview(interview_id: str = "ap-test-1") -> str: id=interview_id, locale="en", selection_spec=minimal_selection_spec(categories=["basics"]), - question_count=2, - question_ids=json.dumps(["q1", "q2"]), status="active", ), [ Answer( - interview_id=interview_id, question_id="q1", order=1, round=0, question_text="Question one?", ), Answer( - interview_id=interview_id, question_id="q2", order=2, round=0, question_text="Question two?", ), ], + question_count=2, + task_time_limit_seconds=None, ) diff --git a/tests/helpers/legacy_interview.py b/tests/helpers/legacy_interview.py new file mode 100644 index 0000000..86fa05c --- /dev/null +++ b/tests/helpers/legacy_interview.py @@ -0,0 +1,58 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Helpers for inserting interview rows before session_mode migration.""" + +from sqlalchemy import text +from sqlalchemy.orm import Session + + +def insert_pre_session_mode_interview( + session: Session, + *, + interview_id: str, + selection_spec: str, + locale: str = "en", + question_count: int = 5, + question_time_limit_seconds: int | None = None, +) -> None: + """Insert an interview row using the pre-0006 interviews schema. + + Args: + session: Active SQLAlchemy session bound to a pre-0006 database. + interview_id: Interview UUID primary key. + selection_spec: Raw selection_spec JSON string. + locale: Interview locale. + question_count: Legacy question count column value. + question_time_limit_seconds: Legacy per-round timer column value. + """ + session.execute( + text( + """ + INSERT INTO interviews ( + id, + locale, + selection_spec, + question_count, + question_ids, + question_time_limit_seconds, + status + ) VALUES ( + :id, + :locale, + :spec, + :question_count, + '[]', + :timer, + 'active' + ) + """ + ), + { + "id": interview_id, + "locale": locale, + "spec": selection_spec, + "question_count": question_count, + "timer": question_time_limit_seconds, + }, + ) + session.commit() diff --git a/tests/helpers/selection.py b/tests/helpers/selection.py index 7ccdc6f..a64fdcd 100644 --- a/tests/helpers/selection.py +++ b/tests/helpers/selection.py @@ -2,8 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 """Helpers for building interview selection JSON in tests.""" -from app.interview.domain.serialization import selection_to_spec -from app.interview.domain.value_objects import InterviewSelection, TrackSelection +from app.interview.domain.serialization import session_to_spec +from app.interview.domain.value_objects import SessionSelection, TrackSelection def minimal_selection_spec( @@ -11,25 +11,31 @@ def minimal_selection_spec( track: str = "python", level: str = "junior", categories: tuple[str, ...] | None = None, + question_count: int = 5, + task_time_limit_seconds: int | None = None, ) -> str: - """Build a minimal ``selection_spec`` JSON string for test interviews. + """Build a minimal v2 ``selection_spec`` JSON string for test interviews. Args: track: Question bank slug. level: Difficulty level slug. categories: Topic slugs (default: ``("basics",)``). + question_count: Theory question count stored in the spec. + task_time_limit_seconds: Optional per-round timer for theory. Returns: JSON string suitable for ``Interview.selection_spec``. """ - return selection_to_spec( - InterviewSelection( + return session_to_spec( + SessionSelection.theory_only( sources=( TrackSelection( track=track, level=level, categories=categories if categories is not None else ("basics",), ), - ) + ), + question_count=question_count, + task_time_limit_seconds=task_time_limit_seconds, ) ) diff --git a/tests/helpers/theory_seed.py b/tests/helpers/theory_seed.py new file mode 100644 index 0000000..cbdd325 --- /dev/null +++ b/tests/helpers/theory_seed.py @@ -0,0 +1,77 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Test helpers for seeding theory sections linked to interview rows.""" + +from sqlalchemy.orm import Session + +from app.shared.infrastructure.models import Answer, Interview, TheorySection + + +def create_theory_section_for_interview( + db_session: Session, + interview: Interview, + *, + question_count: int = 5, + task_time_limit_seconds: int | None = None, + status: str = "active", +) -> TheorySection: + """Insert a theory section row matching an interview shell. + + Args: + db_session: Active SQLAlchemy session. + interview: Parent interview ORM row (must be flushed). + question_count: Number of theory questions in the section. + task_time_limit_seconds: Optional per-task timer in seconds. + status: Section status to persist. + + Returns: + Persisted theory section with assigned primary key. + """ + section = TheorySection( + interview_id=interview.id, + selection_spec=interview.selection_spec, + question_count=question_count, + task_time_limit_seconds=task_time_limit_seconds, + locale=interview.locale or "en", + status=status, + ) + db_session.add(section) + db_session.flush() + return section + + +def attach_theory_section_to_answers( + db_session: Session, + interview: Interview, + answers: list[Answer], + *, + question_count: int | None = None, + task_time_limit_seconds: int | None = None, + status: str = "active", +) -> TheorySection: + """Create a theory section and link answer rows to it. + + Args: + db_session: Active SQLAlchemy session. + interview: Parent interview ORM row (must be flushed). + answers: Answer rows to link via ``theory_section_id``. + question_count: Section question count; defaults to answer list length. + task_time_limit_seconds: Optional per-task timer in seconds. + status: Section status to persist. + + Returns: + Persisted theory section with answers linked. + """ + resolved_count = question_count if question_count is not None else len(answers) + section = create_theory_section_for_interview( + db_session, + interview, + question_count=resolved_count, + task_time_limit_seconds=task_time_limit_seconds, + status=status, + ) + for answer in answers: + answer.theory_section_id = section.id + db_session.add(answer) + db_session.flush() + return section diff --git a/tests/test_alembic_migrations.py b/tests/test_alembic_migrations.py index e9766d8..b73025a 100644 --- a/tests/test_alembic_migrations.py +++ b/tests/test_alembic_migrations.py @@ -11,9 +11,9 @@ from sqlalchemy.orm import sessionmaker from alembic import command -from app.paths import ALEMBIC_INI from app.shared.infrastructure.database import Base -from app.shared.infrastructure.models import Interview +from app.shared.paths import ALEMBIC_INI +from tests.helpers.legacy_interview import insert_pre_session_mode_interview @pytest.fixture @@ -46,7 +46,7 @@ class TestSelectionSpecAlembicMigration: """Tests for selection_spec data migrations.""" def test_migrates_legacy_selection_spec_rows(self, alembic_engine): - """Legacy language keys become track; version field is removed at head.""" + """Legacy language keys become track and selection_spec ends at v2 at head.""" legacy_spec = json.dumps( { "version": 1, @@ -62,42 +62,44 @@ def test_migrates_legacy_selection_spec_rows(self, alembic_engine): ) session_factory = sessionmaker(bind=alembic_engine) session = session_factory() - session.add( - Interview( - id="legacy-interview", - selection_spec=legacy_spec, - ) + insert_pre_session_mode_interview( + session, + interview_id="legacy-interview", + selection_spec=legacy_spec, ) - session.commit() session.close() alembic_cfg = Config(str(ALEMBIC_INI)) command.upgrade(alembic_cfg, "head") session = session_factory() - row = session.get(Interview, "legacy-interview") - assert row is not None + row = session.execute( + text("SELECT selection_spec, session_mode FROM interviews WHERE id = :id"), + {"id": "legacy-interview"}, + ).one() data = json.loads(row.selection_spec) - assert "version" not in data - assert data["sources"][0]["track"] == "python" - assert "language" not in data["sources"][0] + assert data["version"] == 2 + assert data["session_mode"] == "theory_only" + assert data["theory"]["sources"][0]["track"] == "python" + assert "language" not in data["theory"]["sources"][0] + assert row.session_mode == "theory_only" session.close() def test_track_migration_is_idempotent(self, alembic_engine): - """Running migrations twice leaves modern rows unchanged.""" + """Running migrations twice leaves v2 rows unchanged.""" track_spec = ( '{"sources":[{"track":"database","level":"junior",' '"categories":["sql-basics"]}]}' ) session_factory = sessionmaker(bind=alembic_engine) session = session_factory() - session.add( - Interview( - id="track-interview", - selection_spec=track_spec, - ) + insert_pre_session_mode_interview( + session, + interview_id="track-interview", + selection_spec=track_spec, + question_count=3, + question_time_limit_seconds=90, ) - session.commit() session.close() alembic_cfg = Config(str(ALEMBIC_INI)) @@ -106,7 +108,14 @@ def test_track_migration_is_idempotent(self, alembic_engine): with alembic_engine.connect() as conn: stored = conn.execute( - text("SELECT selection_spec FROM interviews WHERE id = :id"), + text( + "SELECT selection_spec, session_mode FROM interviews WHERE id = :id" + ), {"id": "track-interview"}, - ).scalar_one() - assert stored == track_spec + ).one() + data = json.loads(stored.selection_spec) + assert data["version"] == 2 + assert data["session_mode"] == "theory_only" + assert data["theory"]["question_count"] == 3 + assert data["theory"]["task_time_limit_seconds"] == 90 + assert stored.session_mode == "theory_only" diff --git a/tests/test_answer_ai_evaluation.py b/tests/test_answer_ai_evaluation.py index 4b5bf1b..1288305 100644 --- a/tests/test_answer_ai_evaluation.py +++ b/tests/test_answer_ai_evaluation.py @@ -5,7 +5,7 @@ import pytest from app.ai.audio_probe import minimal_wav_bytes -from app.interview.services.evaluator.service import InterviewEvaluatorService +from app.theory.services.evaluator.service import TheoryEvaluatorService from tests.fakes import FakeProvider, answer_evaluation_json, follow_up_evaluation_json @@ -28,7 +28,7 @@ async def test_evaluate_with_audio_initial_round() -> None: evaluation, follow_up_needed, follow_up_text, - ) = await InterviewEvaluatorService.evaluate_submission( + ) = await TheoryEvaluatorService.evaluate_submission( provider=provider, locale="en", answer_round=0, @@ -64,10 +64,10 @@ async def test_evaluate_with_audio_follow_up_round() -> None: evaluation, follow_up_needed, follow_up_text, - ) = await InterviewEvaluatorService.evaluate_submission( + ) = await TheoryEvaluatorService.evaluate_submission( provider=provider, locale="en", - answer_round=InterviewEvaluatorService.MAX_FOLLOW_UP_DEPTH, + answer_round=TheoryEvaluatorService.MAX_FOLLOW_UP_DEPTH, question_text="Can you give an example?", question_code=None, initial_question_text="What is a generator?", diff --git a/tests/test_answer_processing.py b/tests/test_answer_processing.py index 6e3d2a7..3492928 100644 --- a/tests/test_answer_processing.py +++ b/tests/test_answer_processing.py @@ -4,15 +4,10 @@ import asyncio from datetime import UTC, datetime, timedelta -import json import pytest -from app.interview.domain.entities import Answer as DomainAnswer from app.interview.domain.exceptions import InterviewNotActiveError -from app.interview.repositories.uow import InterviewUnitOfWork -from app.interview.services.answer_processing import AnswerProcessingService -from app.interview.services.evaluator.service import InterviewEvaluatorService from app.interview.services.events import ( AnswerFeedbackEvent, AnswerSavedEvent, @@ -20,6 +15,10 @@ ) 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, @@ -38,7 +37,7 @@ async def test_process_answer_persists_score_and_next_question( [answer_evaluation_json(score=5, follow_up_needed=False)] ) - events = await AnswerProcessingService.process_answer_submission( + events = await TheorySubmissionService.process_answer_submission( interview_id=interview_id, question_id="q1", answer_text="Lists are mutable.", @@ -84,7 +83,7 @@ async def test_process_answer_creates_follow_up_round(isolated_db, fake_ai_provi ] ) - events = await AnswerProcessingService.process_answer_submission( + events = await TheorySubmissionService.process_answer_submission( interview_id=interview_id, question_id="q1", answer_text="Partial answer.", @@ -113,7 +112,6 @@ async def test_process_follow_up_answer_without_another_follow_up( """Answering a follow-up round persists score and advances to the next question.""" interview_id = "ap-test-3" initial = Answer( - interview_id=interview_id, question_id="q1", order=1, round=0, @@ -123,7 +121,6 @@ async def test_process_follow_up_answer_without_another_follow_up( initial.score = 3 initial.feedback = "OK" first_follow_up = Answer( - interview_id=interview_id, question_id="q1", order=1, round=1, @@ -134,28 +131,26 @@ async def test_process_follow_up_answer_without_another_follow_up( id=interview_id, locale="en", selection_spec=minimal_selection_spec(categories=["basics"]), - question_count=2, - question_ids=json.dumps(["q1", "q2"]), status="active", ), [ initial, first_follow_up, Answer( - interview_id=interview_id, 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 AnswerProcessingService.process_answer_submission( + events = await TheorySubmissionService.process_answer_submission( interview_id=interview_id, question_id="q1", answer_text="Follow-up answer text.", @@ -185,7 +180,6 @@ async def test_last_follow_up_advances_immediately_and_evaluates_in_background( """The last follow-up round advances without waiting for AI evaluation.""" interview_id = "ap-test-last-follow-up" initial = Answer( - interview_id=interview_id, question_id="q1", order=1, round=0, @@ -195,7 +189,6 @@ async def test_last_follow_up_advances_immediately_and_evaluates_in_background( initial.score = 3 initial.feedback = "OK" first_follow_up = Answer( - interview_id=interview_id, question_id="q1", order=1, round=1, @@ -209,35 +202,32 @@ async def test_last_follow_up_advances_immediately_and_evaluates_in_background( id=interview_id, locale="en", selection_spec=minimal_selection_spec(categories=["basics"]), - question_count=2, - question_ids=json.dumps(["q1", "q2"]), status="active", ), [ initial, first_follow_up, Answer( - interview_id=interview_id, question_id="q1", order=1, round=2, question_text="Second follow-up?", ), Answer( - interview_id=interview_id, 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 AnswerProcessingService.process_answer_submission( + events = await TheorySubmissionService.process_answer_submission( interview_id=interview_id, question_id="q1", answer_text="Second follow-up answer.", @@ -282,25 +272,23 @@ async def test_process_answer_rejects_completed_interview( Interview( id=interview_id, selection_spec=minimal_selection_spec(categories=["basics"]), - question_count=1, - question_ids=json.dumps(["q1"]), status="completed", ), [ Answer( - interview_id=interview_id, 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 AnswerProcessingService.process_answer_submission( + await TheorySubmissionService.process_answer_submission( interview_id=interview_id, question_id="q1", answer_text="Too late.", @@ -329,14 +317,10 @@ def _seed_timed_interview( id=interview_id, locale="en", selection_spec=minimal_selection_spec(categories=["basics"]), - question_count=2, - question_ids=json.dumps(["q1", "q2"]), - question_time_limit_seconds=limit_seconds, status="active", ), [ Answer( - interview_id=interview_id, question_id="q1", order=1, round=0, @@ -344,13 +328,14 @@ def _seed_timed_interview( started_at=started_at, ), Answer( - interview_id=interview_id, question_id="q2", order=2, round=0, question_text="Question two?", ), ], + question_count=2, + task_time_limit_seconds=limit_seconds, ) @@ -360,7 +345,7 @@ async def test_process_timeout_when_display_shows_zero(isolated_db): started = datetime.now(UTC) - timedelta(seconds=59, milliseconds=500) interview_id = _seed_timed_interview(started_at=started, limit_seconds=60) - events = await AnswerProcessingService.process_timeout_submission( + events = await TheorySubmissionService.process_timeout_submission( interview_id=interview_id, question_id="q1", round_num=0, @@ -376,7 +361,7 @@ async def test_process_timeout_scores_zero_and_advances(isolated_db): started = datetime.now(UTC) - timedelta(seconds=120) interview_id = _seed_timed_interview(started_at=started) - events = await AnswerProcessingService.process_timeout_submission( + events = await TheorySubmissionService.process_timeout_submission( interview_id=interview_id, question_id="q1", round_num=0, @@ -392,7 +377,7 @@ async def test_process_timeout_scores_zero_and_advances(isolated_db): 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 == DomainAnswer.TIME_EXPIRED_ANSWER_TEXT + 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 @@ -405,14 +390,14 @@ async def test_timeout_ignored_while_answer_pending_evaluation( """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 InterviewUnitOfWork(auto_commit=True) as uow: - aggregate = uow.interviews.get_aggregate(interview_id) - assert aggregate is not None - current = aggregate.find_answer("q1", 0) - updated = aggregate.with_answer_text(current.id, "Answer in progress.") - uow.interviews.save_aggregate(updated) - - events = await AnswerProcessingService.process_timeout_submission( + 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, @@ -439,20 +424,20 @@ async def test_timeout_during_ai_evaluation_preserves_score( [answer_evaluation_json(score=5, follow_up_needed=False)] ) - orig_eval = InterviewEvaluatorService.evaluate_submission + orig_eval = TheoryEvaluatorService.evaluate_submission async def slow_eval(**kwargs): await asyncio.sleep(0.05) return await orig_eval(**kwargs) monkeypatch.setattr( - InterviewEvaluatorService, + TheoryEvaluatorService, "evaluate_submission", staticmethod(slow_eval), ) events: list = [] - gen = AnswerProcessingService.stream_answer_submission( + gen = TheorySubmissionService.stream_answer_submission( interview_id=interview_id, question_id="q1", answer_text="Valid on-time answer.", @@ -461,7 +446,7 @@ async def slow_eval(**kwargs): async for event in gen: events.append(event) if type(event).__name__ == "EvaluatingEvent": - timeout_events = await AnswerProcessingService.process_timeout_submission( + timeout_events = await TheorySubmissionService.process_timeout_submission( interview_id=interview_id, question_id="q1", round_num=0, @@ -485,7 +470,7 @@ async def test_late_answer_submission_treated_as_timeout(isolated_db, fake_ai_pr interview_id = _seed_timed_interview(started_at=started) provider = fake_ai_provider([answer_evaluation_json(score=5)]) - events = await AnswerProcessingService.process_answer_submission( + events = await TheorySubmissionService.process_answer_submission( interview_id=interview_id, question_id="q1", answer_text="Too late but trying anyway.", @@ -500,4 +485,4 @@ async def test_late_answer_submission_treated_as_timeout(isolated_db, fake_ai_pr 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 == DomainAnswer.TIME_EXPIRED_ANSWER_TEXT + assert q1.answer_text == TheoryTask.TIME_EXPIRED_ANSWER_TEXT diff --git a/tests/test_api_routers.py b/tests/test_api_routers.py index f77398e..463c9c6 100644 --- a/tests/test_api_routers.py +++ b/tests/test_api_routers.py @@ -369,7 +369,7 @@ 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/ws") as ws, + client.websocket_connect("/interview/test-id/theory/ws") as ws, ): ws.send_json({"type": "unknown_command"}) response = ws.receive_json() @@ -392,10 +392,10 @@ async def mock_stream( with ( patch( - "app.interview.services.answer_processing.AnswerProcessingService.stream_answer_submission", + "app.theory.services.submission.TheorySubmissionService.stream_answer_submission", side_effect=mock_stream, ), - client.websocket_connect("/interview/test-id/ws") as ws, + client.websocket_connect("/interview/test-id/theory/ws") as ws, ): ws.send_json( { @@ -414,7 +414,7 @@ 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/ws") as ws, + client.websocket_connect("/interview/test-id/theory/ws") as ws, ): ws.send_json({"type": "answer", "question_id": ""}) response = ws.receive_json() @@ -425,12 +425,12 @@ def test_websocket_answer_completed_session(self, client): """Test WebSocket rejects answer on completed session.""" with ( patch( - "app.interview.services.answer_processing.AnswerProcessingService.stream_answer_submission", + "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/ws") as ws, + client.websocket_connect("/interview/test-id/theory/ws") as ws, ): ws.send_json( { @@ -447,12 +447,12 @@ def test_websocket_answer_session_not_found(self, client): """Test WebSocket returns error when session is not found.""" with ( patch( - "app.interview.services.answer_processing.AnswerProcessingService.stream_answer_submission", + "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/ws") as ws, + client.websocket_connect("/interview/test-id/theory/ws") as ws, ): ws.send_json( { @@ -474,7 +474,7 @@ def test_websocket_ping_pong(self, client): "app.interview.services.query.InterviewQuery.get_interview", return_value=mock_session, ), - client.websocket_connect("/interview/test-id/ws") as ws, + client.websocket_connect("/interview/test-id/theory/ws") as ws, ): ws.send_json({"type": "ping"}) response = ws.receive_json() @@ -490,7 +490,7 @@ def test_websocket_ping_completed_session(self, client): "app.interview.services.query.InterviewQuery.get_interview", return_value=mock_session, ), - client.websocket_connect("/interview/test-id/ws") as ws, + client.websocket_connect("/interview/test-id/theory/ws") as ws, ): ws.send_json({"type": "ping"}) response = ws.receive_json() @@ -501,11 +501,11 @@ def test_websocket_complete_success(self, client): """Test WebSocket complete message triggers session completion.""" with ( patch( - "app.interview.services.completion.InterviewCompletionService.complete_interview", + "app.interview.services.completion.SessionCompletionService.complete_session", new_callable=AsyncMock, return_value=[], ) as mock_complete, - client.websocket_connect("/interview/test-id/ws") as ws, + client.websocket_connect("/interview/test-id/theory/ws") as ws, ): ws.send_json({"type": "complete"}) for _ in range(100): @@ -521,12 +521,12 @@ def test_websocket_answer_service_error(self, client): """Test WebSocket handles ValueError from service layer.""" with ( patch( - "app.interview.services.answer_processing.AnswerProcessingService.stream_answer_submission", + "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/ws") as ws, + client.websocket_connect("/interview/test-id/theory/ws") as ws, ): ws.send_json( { diff --git a/tests/test_audio_answer_api.py b/tests/test_audio_answer_api.py index cfc7d9e..e41a32c 100644 --- a/tests/test_audio_answer_api.py +++ b/tests/test_audio_answer_api.py @@ -1,6 +1,6 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Tests for POST /interview/{id}/audio-answer and related interview UI gating.""" +"""Tests for POST /interview/{id}/theory/audio-answer and related interview UI gating.""" import json from unittest.mock import AsyncMock, patch @@ -10,7 +10,7 @@ from app.ai.audio_probe import minimal_wav_bytes from app.ai.llm_models import LLMModelEntry from app.interview.schemas.interview import AnswerRead, InterviewRead -from app.interview.services.page import InterviewPageRender, InterviewPageService +from app.interview.services.page import SessionPageRender, SessionPageService from app.platform.services.config import AppConfig from app.question_voice.schemas import QuestionVoicePageContext from app.speech.schemas.page import SpeechModelPageContext @@ -103,14 +103,14 @@ def audio_api_client(client, override_ws_ai_provider): class TestAudioAnswerApi: - """Tests for POST /interview/{id}/audio-answer.""" + """Tests for POST /interview/{id}/theory/audio-answer.""" def test_audio_answer_streams_ndjson_events( self, audio_api_client, isolated_db, override_ws_ai_provider, monkeypatch ): """Successful upload returns saved, evaluating, transcript, and feedback lines.""" monkeypatch.setattr( - "app.interview.services.answer_processing.AnswerProcessingService.require_audio_answer_enabled", + "app.theory.services.submission.TheorySubmissionService.require_audio_answer_enabled", staticmethod(lambda: None), ) interview_id = seed_two_question_interview("audio-api-1") @@ -121,7 +121,7 @@ def test_audio_answer_streams_ndjson_events( wav_bytes = minimal_wav_bytes(duration_sec=0.2) response = audio_api_client.post( - f"/interview/{interview_id}/audio-answer", + f"/interview/{interview_id}/theory/audio-answer", data={"question_id": "q1"}, files={"file": ("answer.wav", wav_bytes, "audio/wav")}, ) @@ -143,13 +143,13 @@ def test_audio_answer_rejects_invalid_wav( ): """Invalid WAV payloads return HTTP 400 before streaming.""" monkeypatch.setattr( - "app.interview.services.answer_processing.AnswerProcessingService.require_audio_answer_enabled", + "app.theory.services.submission.TheorySubmissionService.require_audio_answer_enabled", staticmethod(lambda: None), ) interview_id = seed_two_question_interview("audio-api-invalid") response = audio_api_client.post( - f"/interview/{interview_id}/audio-answer", + f"/interview/{interview_id}/theory/audio-answer", data={"question_id": "q1"}, files={"file": ("answer.wav", b"not-wav", "audio/wav")}, ) @@ -186,7 +186,7 @@ def test_audio_answer_rejects_when_model_disallows_audio( wav_bytes = minimal_wav_bytes() response = audio_api_client.post( - f"/interview/{interview_id}/audio-answer", + f"/interview/{interview_id}/theory/audio-answer", data={"question_id": "q1"}, files={"file": ("answer.wav", wav_bytes, "audio/wav")}, ) @@ -199,7 +199,7 @@ def test_audio_answer_rejects_when_whisper_unavailable( ): """HTTP 503 when Whisper is not loaded on the application.""" monkeypatch.setattr( - "app.interview.services.answer_processing.AnswerProcessingService.require_audio_answer_enabled", + "app.theory.services.submission.TheorySubmissionService.require_audio_answer_enabled", staticmethod(lambda: None), ) override_ws_ai_provider(client, []) @@ -212,7 +212,7 @@ def test_audio_answer_rejects_when_whisper_unavailable( return_value=False, ): response = client.post( - f"/interview/{interview_id}/audio-answer", + f"/interview/{interview_id}/theory/audio-answer", data={"question_id": "q1"}, files={"file": ("answer.wav", wav_bytes, "audio/wav")}, ) @@ -241,7 +241,7 @@ def test_interview_page_shows_audio_controls_when_enabled( accepts_audio_input=True, ), ) - page_context = InterviewPageService.build_page_context( + page_context = SessionPageService.build_page_context( interview, config=AppConfig( provider_type="openai-compatible", @@ -254,9 +254,9 @@ def test_interview_page_shows_audio_controls_when_enabled( with ( patch( - "app.interview.api.routes.InterviewPageService.prepare_page", + "app.interview.api.routes.SessionPageService.prepare_page", new=AsyncMock( - return_value=InterviewPageRender( + return_value=SessionPageRender( redirect_url=None, template_context=_full_template_context(page_context), ) @@ -273,7 +273,7 @@ def test_interview_page_shows_audio_controls_when_enabled( def test_interview_page_hides_audio_controls_without_catalog_flag(self, client): """Audio controls stay hidden when the configured model is text-only.""" interview = _active_interview_read("audio-ui-2") - page_context = InterviewPageService.build_page_context( + page_context = SessionPageService.build_page_context( interview, config=AppConfig( provider_type="openai-compatible", @@ -286,9 +286,9 @@ def test_interview_page_hides_audio_controls_without_catalog_flag(self, client): with ( patch( - "app.interview.api.routes.InterviewPageService.prepare_page", + "app.interview.api.routes.SessionPageService.prepare_page", new=AsyncMock( - return_value=InterviewPageRender( + return_value=SessionPageRender( redirect_url=None, template_context=_full_template_context(page_context), ) diff --git a/tests/test_audio_answer_processing.py b/tests/test_audio_answer_processing.py index 9efb14b..a20f4e3 100644 --- a/tests/test_audio_answer_processing.py +++ b/tests/test_audio_answer_processing.py @@ -3,13 +3,10 @@ """Tests for audio answer submission orchestration.""" import asyncio -import json import pytest from app.ai.audio_probe import minimal_wav_bytes -from app.interview.services.answer_processing import AnswerProcessingService -from app.interview.services.evaluator.service import InterviewEvaluatorService from app.interview.services.events import ( AnswerFeedbackEvent, AnswerSavedEvent, @@ -18,6 +15,8 @@ ) 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, @@ -33,7 +32,7 @@ async def test_process_audio_answer_runs_transcription_and_evaluation( ): """Audio answers yield saved, evaluating, transcript, and feedback events.""" monkeypatch.setattr( - AnswerProcessingService, + TheorySubmissionService, "require_audio_answer_enabled", staticmethod(lambda: None), ) @@ -44,7 +43,7 @@ async def test_process_audio_answer_runs_transcription_and_evaluation( transcriber = FakeTranscriber("spoken answer text") wav_bytes = minimal_wav_bytes(duration_sec=0.2) - events = await AnswerProcessingService.process_audio_answer_submission( + events = await TheorySubmissionService.process_audio_answer_submission( interview_id=interview_id, question_id="q1", wav_bytes=wav_bytes, @@ -76,7 +75,7 @@ async def test_process_audio_answer_rejects_invalid_wav( ): """Invalid WAV payloads fail before any events are emitted.""" monkeypatch.setattr( - AnswerProcessingService, + TheorySubmissionService, "require_audio_answer_enabled", staticmethod(lambda: None), ) @@ -85,7 +84,7 @@ async def test_process_audio_answer_rejects_invalid_wav( transcriber = FakeTranscriber() with pytest.raises(ValueError, match="valid WAV"): - await AnswerProcessingService.process_audio_answer_submission( + await TheorySubmissionService.process_audio_answer_submission( interview_id=interview_id, question_id="q1", wav_bytes=b"not-wav", @@ -100,13 +99,12 @@ async def test_process_audio_answer_last_follow_up_fast_path( ): """Last follow-up round advances immediately and transcribes in-band.""" monkeypatch.setattr( - AnswerProcessingService, + TheorySubmissionService, "require_audio_answer_enabled", staticmethod(lambda: None), ) interview_id = "audio-ap-last-follow-up" initial = Answer( - interview_id=interview_id, question_id="q1", order=1, round=0, @@ -116,7 +114,6 @@ async def test_process_audio_answer_last_follow_up_fast_path( initial.score = 3 initial.feedback = "OK" first_follow_up = Answer( - interview_id=interview_id, question_id="q1", order=1, round=1, @@ -130,28 +127,25 @@ async def test_process_audio_answer_last_follow_up_fast_path( id=interview_id, locale="en", selection_spec=minimal_selection_spec(categories=["basics"]), - question_count=2, - question_ids=json.dumps(["q1", "q2"]), status="active", ), [ initial, first_follow_up, Answer( - interview_id=interview_id, question_id="q1", order=1, round=2, question_text="Second follow-up?", ), Answer( - interview_id=interview_id, question_id="q2", order=2, round=0, question_text="Question two?", ), ], + question_count=2, ) provider = fake_ai_provider( @@ -165,19 +159,19 @@ async def test_process_audio_answer_last_follow_up_fast_path( transcriber = FakeTranscriber("second follow-up spoken") wav_bytes = minimal_wav_bytes() - orig_eval = InterviewEvaluatorService.evaluate_submission + orig_eval = TheoryEvaluatorService.evaluate_submission async def slow_audio_eval(**kwargs): await asyncio.sleep(0.05) return await orig_eval(**kwargs) monkeypatch.setattr( - InterviewEvaluatorService, + TheoryEvaluatorService, "evaluate_submission", staticmethod(slow_audio_eval), ) - events = await AnswerProcessingService.process_audio_answer_submission( + events = await TheorySubmissionService.process_audio_answer_submission( interview_id=interview_id, question_id="q1", wav_bytes=wav_bytes, diff --git a/tests/test_coding_api.py b/tests/test_coding_api.py new file mode 100644 index 0000000..d573a99 --- /dev/null +++ b/tests/test_coding_api.py @@ -0,0 +1,154 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for coding HTTP and WebSocket routes.""" + +from unittest.mock import AsyncMock, patch + +from app.coding.domain.value_objects import CodingRunResult +from app.coding.services.evaluator.models import CodingAnswerEvaluation +from tests.helpers.coding_seed import seed_active_coding_interview + + +def _success_run_result() -> CodingRunResult: + return CodingRunResult( + status="success", + stdout=None, + stderr=None, + compile_output=None, + tests_passed=0, + tests_total=0, + test_results=(), + duration_ms=12, + ) + + +class TestCodingRunApi: + """Tests for POST /interview/{id}/coding/run.""" + + def test_run_persists_attempt(self, client, isolated_db): + """Run endpoint stores an attempt and returns the mirror payload.""" + interview_id, task_id = seed_active_coding_interview("coding-run-1") + with patch( + "app.coding.services.run_execution.CodingRunnerService.run_public_tests", + new=AsyncMock(return_value=_success_run_result()), + ): + response = client.post( + f"/interview/{interview_id}/coding/run", + json={"task_id": task_id, "source_code": "def solve():\n return 1"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["attempt_no"] == 1 + assert payload["status"] == "success" + assert payload["tests_passed"] == 0 + assert payload["attempt_id"] > 0 + + def test_run_rejects_wrong_task(self, client, isolated_db): + """Run fails when the client targets a non-current task.""" + interview_id, _task_id = seed_active_coding_interview("coding-run-2") + response = client.post( + f"/interview/{interview_id}/coding/run", + json={"task_id": "other-task", "source_code": "pass"}, + ) + assert response.status_code == 400 + + def test_run_rate_limit(self, client, isolated_db, monkeypatch): + """Run returns 429 when the per-task attempt limit is exceeded.""" + interview_id, task_id = seed_active_coding_interview("coding-run-3") + monkeypatch.setenv("CODING_MAX_RUNS_PER_TASK", "1") + with patch( + "app.coding.services.run_execution.CodingRunnerService.run_public_tests", + new=AsyncMock(return_value=_success_run_result()), + ): + first = client.post( + f"/interview/{interview_id}/coding/run", + json={"task_id": task_id, "source_code": "pass"}, + ) + second = client.post( + f"/interview/{interview_id}/coding/run", + json={"task_id": task_id, "source_code": "pass"}, + ) + assert first.status_code == 200 + assert second.status_code == 429 + + +class TestCodingStateApi: + """Tests for GET /interview/{id}/coding/state.""" + + def test_state_returns_current_task_and_attempts(self, client, isolated_db): + """State endpoint exposes progress and persisted Run history.""" + interview_id, task_id = seed_active_coding_interview("coding-state-1") + with patch( + "app.coding.services.run_execution.CodingRunnerService.run_public_tests", + new=AsyncMock(return_value=_success_run_result()), + ): + client.post( + f"/interview/{interview_id}/coding/run", + json={"task_id": task_id, "source_code": "print(1)"}, + ) + + response = client.get(f"/interview/{interview_id}/coding/state") + assert response.status_code == 200 + payload = response.json() + assert payload["section_status"] == "active" + assert payload["current_task"]["task_id"] == task_id + assert payload["run_attempts"][0]["attempt_no"] == 1 + assert "expected_stdout" not in str(payload["current_task"]["task_spec"]) + + def test_state_missing_section_returns_404(self, client, isolated_db): + """State returns 404 when the interview has no coding section.""" + del isolated_db + response = client.get("/interview/missing-session/coding/state") + assert response.status_code == 404 + + +class TestCodingWebSocket: + """Tests for WS /interview/{id}/coding/ws.""" + + def test_submit_streams_saved_evaluating_and_feedback( + self, client, isolated_db, override_ws_ai_provider + ): + """Submit persists code, evaluates with AI, and returns feedback.""" + interview_id, task_id = seed_active_coding_interview("coding-ws-1") + override_ws_ai_provider(client, []) + evaluation = CodingAnswerEvaluation( + score=4, + feedback="Nice work.", + follow_up_needed=False, + follow_up_question=None, + follow_up_mode=None, + ) + with ( + patch( + "app.coding.services.submission.CodingRunnerService.run_hidden_tests", + new=AsyncMock(return_value=_success_run_result()), + ), + patch( + "app.coding.services.submission.CodingEvaluatorService.evaluate_submission", + new=AsyncMock(return_value=(evaluation, False, None, None)), + ), + client.websocket_connect(f"/interview/{interview_id}/coding/ws") as ws, + ): + ws.send_json( + { + "type": "submit", + "task_id": task_id, + "source_code": "def process(data):\n return data", + } + ) + assert ws.receive_json() == {"type": "saved"} + assert ws.receive_json() == {"type": "evaluating"} + feedback = ws.receive_json() + assert feedback["type"] == "feedback" + assert feedback["task_id"] == task_id + assert feedback["feedback"] == "Nice work." + + def test_submit_requires_fields(self, client, isolated_db, override_ws_ai_provider): + """Submit rejects messages without task_id or source_code.""" + interview_id, _task_id = seed_active_coding_interview("coding-ws-2") + override_ws_ai_provider(client, []) + with client.websocket_connect(f"/interview/{interview_id}/coding/ws") as ws: + ws.send_json({"type": "submit", "task_id": "cod-001"}) + error = ws.receive_json() + assert error["type"] == "error" diff --git a/tests/test_coding_availability.py b/tests/test_coding_availability.py new file mode 100644 index 0000000..836ab3b --- /dev/null +++ b/tests/test_coding_availability.py @@ -0,0 +1,53 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for coding availability checks.""" + +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from app.coding.services.availability import ( + is_coding_available, + is_coding_available_async, + is_judge0_healthy, +) + + +def test_is_coding_available_false_when_disabled(monkeypatch) -> None: + """CODING_ENABLED=false disables coding regardless of Judge0 health.""" + monkeypatch.setenv("CODING_ENABLED", "false") + with patch("app.coding.services.availability.is_judge0_healthy", return_value=True): + assert is_coding_available() is False + + +def test_is_coding_available_requires_judge0(monkeypatch) -> None: + """Coding is unavailable when Judge0 health check fails.""" + monkeypatch.setenv("CODING_ENABLED", "true") + with patch( + "app.coding.services.availability.is_judge0_healthy", return_value=False + ): + assert is_coding_available() is False + with patch("app.coding.services.availability.is_judge0_healthy", return_value=True): + assert is_coding_available() is True + + +@pytest.mark.asyncio +async def test_is_coding_available_async_uses_client(monkeypatch) -> None: + """Async availability delegates to Judge0Client.health_check.""" + monkeypatch.setenv("CODING_ENABLED", "true") + mock_client = AsyncMock() + mock_client.health_check.return_value = True + with patch( + "app.coding.services.judge0_client.Judge0Client.from_env", + return_value=mock_client, + ): + assert await is_coding_available_async() is True + + +def test_is_judge0_healthy_handles_connection_errors() -> None: + """Sync health probe returns False when Judge0 is unreachable.""" + with patch("app.coding.services.availability.httpx.Client") as client_cls: + client = client_cls.return_value.__enter__.return_value + client.get.side_effect = httpx.ConnectError("connection refused") + assert is_judge0_healthy() is False diff --git a/tests/test_coding_evaluator.py b/tests/test_coding_evaluator.py new file mode 100644 index 0000000..61ee2be --- /dev/null +++ b/tests/test_coding_evaluator.py @@ -0,0 +1,50 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for CodingEvaluatorService.""" + +import pytest + +from app.coding.services.evaluator.service import CodingEvaluatorService +from tests.fakes import FakeProvider, coding_answer_evaluation_json + + +@pytest.mark.asyncio +async def test_evaluate_submission_uses_run_history_context() -> None: + """Initial coding evaluation returns parsed score and follow-up decision.""" + provider = FakeProvider( + replies=[ + coding_answer_evaluation_json( + score=3, + feedback="Needs better typing.", + follow_up_needed=True, + follow_up_question="Add type hints.", + follow_up_mode="code", + ) + ] + ) + ( + evaluation, + follow_up_needed, + follow_up_text, + follow_up_mode, + ) = await CodingEvaluatorService.evaluate_submission( + provider=provider, + locale="en", + answer_round=0, + prompt_text="Add type hints to this function.", + task_spec={"expected_points": ["Use list[int] syntax"]}, + source_code="def process(data):\n return data", + run_attempts=( + { + "attempt_no": 1, + "status": "compile_error", + "tests_passed": 0, + "tests_total": 0, + }, + ), + submit_test_summary={"status": "success", "tests_passed": 0, "tests_total": 0}, + ) + assert evaluation.score == 3 + assert follow_up_needed is True + assert follow_up_text == "Add type hints." + assert follow_up_mode == "code" diff --git a/tests/test_coding_harness.py b/tests/test_coding_harness.py new file mode 100644 index 0000000..e7c6886 --- /dev/null +++ b/tests/test_coding_harness.py @@ -0,0 +1,25 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for Python Judge0 harness generation.""" + +from app.coding.services.harness import build_python_script + + +def test_build_python_script_script_mode() -> None: + """Script mode wraps bare code with a main guard when needed.""" + script = build_python_script("x = 1\nprint(x)", entrypoint=None) + assert "print(x)" in script + assert "__main__" in script + + +def test_build_python_script_entrypoint_invokes_callable() -> None: + """Entrypoint mode parses stdin lines and calls the named function.""" + source = "def add(a, b):\n return a + b" + script = build_python_script( + source, + entrypoint="add", + stdin="1\n2\n", + ) + assert "def add(a, b):" in script + assert "__grillkit_invoke" in script + assert "'add'(*args)" in script diff --git a/tests/test_coding_page.py b/tests/test_coding_page.py new file mode 100644 index 0000000..8901849 --- /dev/null +++ b/tests/test_coding_page.py @@ -0,0 +1,57 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for coding section page context.""" + +import asyncio +import json + +from app.coding.services.page import CodingPageService +from app.interview.services.page import SessionPageService +from tests.helpers.coding_seed import seed_active_coding_interview + + +def test_build_context_returns_none_without_section(isolated_db): + """Coding page context is absent when the session has no coding section.""" + del isolated_db + assert CodingPageService.build_context("missing-session") is None + + +def test_build_context_exposes_current_task(isolated_db): + """Coding page context includes the active task and progress fields.""" + interview_id, task_id = seed_active_coding_interview("coding-page-1") + context = CodingPageService.build_context(interview_id) + assert context is not None + assert context.task_count == 1 + assert context.completed_tasks == 0 + assert context.current_task is not None + assert context.current_task["task_id"] == task_id + assert context.current_task_row_id is not None + assert "hidden_tests" not in json.dumps(context.current_task["task_spec"]) + + +def test_full_template_context_includes_coding_key(isolated_db): + """Session page merges coding context for interview.html.""" + interview_id, _task_id = seed_active_coding_interview("coding-page-2") + from app.interview.services.query import InterviewQuery + + interview = InterviewQuery.get_interview(interview_id) + assert interview is not None + template_context = asyncio.run( + SessionPageService.build_full_template_context( + interview, + config=None, + ) + ) + assert template_context["coding"] is not None + assert template_context["coding"]["task_count"] == 1 + assert template_context["session_mode_label"] + + +def test_interview_route_uses_coding_template(client, isolated_db): + """Active coding phase renders the dedicated coding interview template.""" + interview_id, _task_id = seed_active_coding_interview("coding-template-1") + response = client.get(f"/interview/{interview_id}") + assert response.status_code == 200 + assert "coding-session" in response.text + assert "coding-session__assignment" in response.text + assert "interview-chat-panel" not in response.text diff --git a/tests/test_coding_planning.py b/tests/test_coding_planning.py new file mode 100644 index 0000000..c41c1f4 --- /dev/null +++ b/tests/test_coding_planning.py @@ -0,0 +1,99 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for coding task planning.""" + +import pytest + +from app.coding.domain.task_spec import ( + client_task_spec_from_stored, + task_spec_from_bank_task, +) +from app.coding.services.planning import ( + build_coding_task_plan, + validate_selection, + validate_task_count, +) +from app.interview.domain.value_objects import InterviewSelection, TrackSelection +from app.shared.coding import CodingSpec, CodingTask + + +def test_task_spec_from_bank_task_omits_hidden_tests() -> None: + """Persisted task spec includes public tests but never hidden tests.""" + from app.shared.coding import CodingTestCase + + task = CodingTask( + id="algo-001", + difficulty=2, + tags=("sorting",), + text="Sort numbers", + coding=CodingSpec( + language="python", + evaluation_mode="tests", + starter_code="pass", + entrypoint="solve", + public_tests=( + CodingTestCase( + name="normal", + stdin="1\n", + expected_stdout="1\n", + ), + ), + hidden_tests=(), + ), + ) + spec = task_spec_from_bank_task(task) + assert spec["language"] == "python" + assert spec["evaluation_mode"] == "tests" + assert spec["public_tests"] == [ + {"name": "normal", "stdin": "1\n", "expected_stdout": "1\n"} + ] + assert spec["hidden_tests"] == [] + client_spec = client_task_spec_from_stored(spec) + assert "hidden_tests" not in client_spec + + +def test_build_coding_task_plan_from_bank() -> None: + """Coding planning loads tasks from the coding bank only.""" + selection = InterviewSelection( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ) + ) + planned = build_coding_task_plan(selection, task_count=1, locale="en") + assert len(planned) == 1 + assert planned[0].id == "bas-004" + assert planned[0].task_spec["language"] == "python" + + +def test_validate_task_count_requires_one_per_topic() -> None: + """Task count must cover every selected topic.""" + selection = InterviewSelection( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics", "oop"), + ), + ) + ) + with pytest.raises(ValueError, match="at least 2"): + validate_task_count(selection, 1) + + +def test_validate_selection_rejects_unknown_track() -> None: + """Unknown coding track slug raises ValueError.""" + selection = InterviewSelection( + sources=( + TrackSelection( + track="unknown", + level="junior", + categories=("basics",), + ), + ) + ) + with pytest.raises(ValueError, match="Unknown coding track"): + validate_selection(selection) diff --git a/tests/test_coding_repository.py b/tests/test_coding_repository.py new file mode 100644 index 0000000..d45bc47 --- /dev/null +++ b/tests/test_coding_repository.py @@ -0,0 +1,186 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for coding section repository and domain aggregate.""" + +from dataclasses import replace +from pathlib import Path + +from alembic.config import Config +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +from alembic import command +from app.coding.domain.entities import CodingSection +from app.coding.domain.value_objects import PlannedCodingTask +from app.coding.repositories.coding_section import CodingSectionRepository +from app.interview.domain.value_objects import InterviewSelection, TrackSelection +from app.shared.infrastructure.database import Base +from app.shared.infrastructure.models import Interview +from app.shared.paths import ALEMBIC_INI +from tests.helpers.selection import minimal_selection_spec + + +@pytest.fixture +def engine(): + """Create an in-memory SQLite engine for coding repository tests.""" + engine = create_engine("sqlite:///:memory:", echo=False) + Base.metadata.create_all(bind=engine) + yield engine + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture +def db_session(engine): + """Create a test database session.""" + _Session = sessionmaker(bind=engine) # noqa: N806 + session = _Session() + yield session + session.close() + + +def _planned_tasks() -> tuple[PlannedCodingTask, ...]: + return ( + PlannedCodingTask( + id="bas-004", + text="Add type hints", + task_spec={ + "language": "python", + "evaluation_mode": "ai", + "starter_code": "def process(data): pass", + }, + ), + PlannedCodingTask( + id="func-006", + text="Document divide()", + task_spec={ + "language": "python", + "evaluation_mode": "ai", + "starter_code": "def divide(a, b): pass", + }, + ), + ) + + +def test_create_and_load_coding_aggregate(db_session) -> None: + """Repository persists section and tasks and reloads domain aggregate.""" + interview = Interview( + id="coding-session-1", + selection_spec=minimal_selection_spec(), + status="active", + ) + db_session.add(interview) + db_session.commit() + + selection = InterviewSelection( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics", "functions"), + ), + ) + ) + section = CodingSection.start( + interview.id, + selection=selection, + locale="en", + planned_tasks=_planned_tasks(), + task_time_limit_seconds=600, + ) + + repo = CodingSectionRepository(db_session) + created = repo.create_aggregate(section) + db_session.commit() + + assert created.id > 0 + assert created.task_count == 2 + assert len(created.tasks) == 2 + assert created.tasks[0].id > 0 + assert created.tasks[0].task_spec["language"] == "python" + + loaded = repo.get_aggregate(interview.id) + assert loaded is not None + assert loaded.task_ids == ("bas-004", "func-006") + assert loaded.tasks[1].prompt_text == "Document divide()" + + +def test_coding_section_is_complete_when_all_submitted() -> None: + """Domain aggregate reports completion only after every task is submitted.""" + selection = InterviewSelection( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ) + ) + section = CodingSection.start( + "session-1", + selection=selection, + locale="en", + planned_tasks=_planned_tasks()[:1], + ) + assert section.is_complete() is False + + task = section.tasks[0] + updated = replace( + section, + tasks=( + replace( + task, + submitted_code="def process(data: list[int]) -> list[int]: ...", + score=4, + ), + ), + ) + assert updated.is_complete() is True + assert updated.total_score() == 4 + assert updated.max_score() == 5 + + +class TestCodingSectionsAlembicMigration: + """Tests for coding section schema migration.""" + + @pytest.fixture + def alembic_engine(self, tmp_path: Path, monkeypatch): + """SQLite file DB upgraded through the coding tables migration.""" + db_path = tmp_path / "grillkit.db" + db_url = f"sqlite:///{db_path}" + + import app.shared.infrastructure.database as database_module + + engine = create_engine(db_url, echo=False) + monkeypatch.setattr(database_module, "DATABASE_URL", db_url) + monkeypatch.setattr(database_module, "engine", engine) + monkeypatch.setattr( + database_module, + "SessionLocal", + sessionmaker(autocommit=False, autoflush=False, bind=engine), + ) + + alembic_cfg = Config(str(ALEMBIC_INI)) + command.upgrade(alembic_cfg, "20260609_0008") + + yield engine + + Base.metadata.drop_all(bind=engine) + engine.dispose() + + def test_coding_tables_exist_at_revision(self, alembic_engine) -> None: + """Migration creates coding_sections, coding_tasks, and code_run_attempts.""" + with alembic_engine.connect() as conn: + tables = { + row[0] + for row in conn.execute( + text( + "SELECT name FROM sqlite_master " + "WHERE type='table' AND name LIKE 'coding%' " + "OR name = 'code_run_attempts'" + ) + ) + } + assert "coding_sections" in tables + assert "coding_tasks" in tables + assert "code_run_attempts" in tables diff --git a/tests/test_coding_runner.py b/tests/test_coding_runner.py new file mode 100644 index 0000000..12c2bb7 --- /dev/null +++ b/tests/test_coding_runner.py @@ -0,0 +1,144 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for CodingRunnerService.""" + +from unittest.mock import AsyncMock + +import pytest + +from app.coding.services.judge0_client import ( + JUDGE0_STATUS_ACCEPTED, + JUDGE0_STATUS_COMPILATION_ERROR, + Judge0SubmissionResult, +) +from app.coding.services.runner import CodingRunnerService + + +def _submission( + *, + status_id: int, + stdout: str | None = None, + stderr: str | None = None, + compile_output: str | None = None, +) -> Judge0SubmissionResult: + return Judge0SubmissionResult( + status_id=status_id, + status_description="status", + stdout=stdout, + stderr=stderr, + compile_output=compile_output, + time="0.01", + memory=1024, + ) + + +@pytest.mark.asyncio +async def test_run_public_tests_success() -> None: + """All matching public tests produce a success aggregate status.""" + client = AsyncMock() + client.submit.return_value = _submission( + status_id=JUDGE0_STATUS_ACCEPTED, + stdout="3\n", + ) + task_spec = { + "language": "python", + "evaluation_mode": "tests", + "entrypoint": "solve", + "public_tests": [ + { + "name": "normal", + "stdin": "1\n2\n", + "expected_stdout": "3\n", + } + ], + } + + result = await CodingRunnerService.run_public_tests( + source_code="def solve(a, b):\n return a + b", + task_spec=task_spec, + client=client, + ) + + assert result.status == "success" + assert result.tests_passed == 1 + assert result.tests_total == 1 + assert result.test_results[0].passed is True + + +@pytest.mark.asyncio +async def test_run_public_tests_stops_on_first_failure() -> None: + """Runner short-circuits after the first failing public test.""" + client = AsyncMock() + client.submit.side_effect = [ + _submission(status_id=JUDGE0_STATUS_ACCEPTED, stdout="1\n"), + _submission(status_id=JUDGE0_STATUS_ACCEPTED, stdout="wrong\n"), + ] + task_spec = { + "language": "python", + "evaluation_mode": "tests", + "entrypoint": "solve", + "public_tests": [ + {"name": "first", "stdin": "", "expected_stdout": "1\n"}, + {"name": "second", "stdin": "", "expected_stdout": "2\n"}, + ], + } + + result = await CodingRunnerService.run_public_tests( + source_code="def solve():\n return 1", + task_spec=task_spec, + client=client, + ) + + assert result.status == "tests_failed" + assert result.tests_passed == 1 + assert result.tests_total == 2 + assert client.submit.await_count == 2 + + +@pytest.mark.asyncio +async def test_run_compile_only_for_ai_tasks() -> None: + """AI tasks use compile-only Judge0 submissions.""" + client = AsyncMock() + client.submit.return_value = _submission(status_id=JUDGE0_STATUS_ACCEPTED) + task_spec = { + "language": "python", + "evaluation_mode": "ai", + "starter_code": "pass", + } + + result = await CodingRunnerService.run_public_tests( + source_code="def process(data):\n return data", + task_spec=task_spec, + client=client, + ) + + assert result.status == "success" + assert result.tests_total == 0 + client.submit.assert_awaited_once() + assert client.submit.await_args.kwargs["compile_only"] is True + + +@pytest.mark.asyncio +async def test_run_public_tests_compile_error() -> None: + """Compilation errors map to compile_error aggregate status.""" + client = AsyncMock() + client.submit.return_value = _submission( + status_id=JUDGE0_STATUS_COMPILATION_ERROR, + compile_output="SyntaxError", + ) + task_spec = { + "language": "python", + "evaluation_mode": "tests", + "public_tests": [ + {"name": "only", "stdin": "", "expected_stdout": "1\n"}, + ], + } + + result = await CodingRunnerService.run_public_tests( + source_code="def broken(:\n pass", + task_spec=task_spec, + client=client, + ) + + assert result.status == "compile_error" + assert result.tests_passed == 0 diff --git a/tests/test_coding_section_service.py b/tests/test_coding_section_service.py new file mode 100644 index 0000000..a773125 --- /dev/null +++ b/tests/test_coding_section_service.py @@ -0,0 +1,39 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for CodingSectionService interview section protocol.""" + +from app.coding.services.section import CodingSectionService +from app.shared.infrastructure.models import Interview +from tests.helpers.coding_seed import ( + attach_coding_tasks, + create_coding_section_for_interview, +) +from tests.helpers.selection import minimal_selection_spec + + +def test_coding_section_service_reports_incomplete_until_submitted( + isolated_db, +) -> None: + """Section protocol marks coding incomplete while tasks lack submissions.""" + del isolated_db + interview_id = "coding-svc-1" + interview = Interview( + id=interview_id, + selection_spec=minimal_selection_spec(), + status="active", + ) + from app.coding.repositories.uow import CodingUnitOfWork + + with CodingUnitOfWork() as uow: + uow.session.add(interview) + uow.commit() + section = create_coding_section_for_interview(uow.session, interview) + attach_coding_tasks(uow.session, section) + uow.commit() + + assert CodingSectionService.is_complete(interview_id) is False + context = CodingSectionService.get_page_context(interview_id) + assert context is not None + assert context.section == "coding" + assert context.active is True + assert context.complete is False diff --git a/tests/test_coding_tasks.py b/tests/test_coding_tasks.py new file mode 100644 index 0000000..52afa9a --- /dev/null +++ b/tests/test_coding_tasks.py @@ -0,0 +1,178 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for coding task loading.""" + +from pathlib import Path + +import pytest +import yaml + +from app.shared.coding import ( + CodingTask, + list_categories, + list_levels, + list_tracks, + load_categories, + load_category, +) + + +def _write_category_yaml(path: Path, tasks: list[dict]) -> None: + """Write a minimal coding category YAML file for loader tests. + + Args: + path: Destination ``.yaml`` file path. + tasks: Task dicts under the ``tasks`` key. + """ + path.parent.mkdir(parents=True, exist_ok=True) + content = { + "category": "Test", + "track": "python", + "level": "junior", + "tasks": tasks, + } + with open(path, "w") as f: + yaml.dump(content, f) + + +@pytest.fixture +def temp_coding_dir(tmp_path, monkeypatch): + """Create a temporary coding task bank for loader tests. + + Returns: + Path to the temporary ``data/coding`` root. + """ + coding_root = tmp_path / "data" / "coding" + python_junior_dir = coding_root / "python" / "junior" + python_junior_dir.mkdir(parents=True) + + algorithms_content = { + "category": "Algorithms", + "track": "python", + "level": "junior", + "tasks": [ + { + "id": "algo-001", + "difficulty": 2, + "tags": ["sorting"], + "coding": { + "language": "python", + "evaluation_mode": "tests", + "assignment": "Implement bubble sort for the given input format.", + "starter_code": "def bubble_sort(arr):\n pass", + "entrypoint": "bubble_sort", + "public_tests": [ + { + "name": "sorted", + "stdin": "[3, 1, 2]\n", + "expected_stdout": "[1, 2, 3]\n", + } + ], + }, + "expected_points": ["Correct sorting"], + }, + { + "id": "algo-002", + "difficulty": 2, + "tags": ["complexity"], + "coding": { + "language": "python", + "evaluation_mode": "ai", + "assignment": "Refactor this loop to use enumerate().", + "starter_code": "for i in range(len(items)):\n pass", + }, + }, + ], + } + with open(python_junior_dir / "algorithms.yaml", "w") as f: + yaml.dump(algorithms_content, f) + + monkeypatch.setattr("app.shared.coding.CODING_DIR", coding_root) + yield coding_root + + +class TestCodingTasks: + """Tests for coding task loading functions.""" + + def test_load_category_exists(self, temp_coding_dir) -> None: + """Load an existing coding category.""" + del temp_coding_dir + tasks = load_category("python", "junior", "algorithms") + + assert len(tasks) == 2 + task = tasks[0] + assert isinstance(task, CodingTask) + assert task.id == "algo-001" + assert task.difficulty == 2 + assert task.tags == ("sorting",) + assert task.text == "Implement bubble sort for the given input format." + assert task.coding.language == "python" + assert task.coding.evaluation_mode == "tests" + assert task.coding.entrypoint == "bubble_sort" + assert len(task.coding.public_tests) == 1 + assert task.coding.public_tests[0].name == "sorted" + assert task.expected_points == ("Correct sorting",) + + def test_load_category_non_existent(self, temp_coding_dir) -> None: + """Missing category files return an empty list.""" + del temp_coding_dir + assert load_category("python", "junior", "missing") == [] + + def test_list_tracks(self, temp_coding_dir) -> None: + """List tracks from the coding bank root.""" + del temp_coding_dir + assert list_tracks() == ["python"] + + def test_list_levels(self, temp_coding_dir) -> None: + """List levels for a coding track.""" + del temp_coding_dir + assert list_levels("python") == ["junior"] + + def test_list_categories(self, temp_coding_dir) -> None: + """List categories for a track and level.""" + del temp_coding_dir + assert list_categories("python", "junior") == ["algorithms"] + + def test_load_categories_merges_and_dedupes(self, temp_coding_dir) -> None: + """load_categories merges YAML files and de-duplicates by id.""" + root = temp_coding_dir + duplicate = { + "category": "Dup", + "track": "python", + "level": "junior", + "tasks": [ + { + "id": "algo-001", + "difficulty": 1, + "coding": { + "language": "python", + "evaluation_mode": "ai", + "assignment": "Duplicate task.", + }, + }, + { + "id": "algo-003", + "difficulty": 1, + "coding": { + "language": "python", + "evaluation_mode": "ai", + "assignment": "Unique task.", + }, + }, + ], + } + with open(root / "python" / "junior" / "dup.yaml", "w") as f: + yaml.dump(duplicate, f) + + tasks = load_categories("python", "junior", ["algorithms", "dup"]) + 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.""" + 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() diff --git a/tests/test_dashboard_query.py b/tests/test_dashboard_query.py index 7d74697..ba56239 100644 --- a/tests/test_dashboard_query.py +++ b/tests/test_dashboard_query.py @@ -8,13 +8,21 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from app.interview.domain.serialization import session_to_spec +from app.interview.domain.value_objects import ( + SectionBranchSpec, + SessionSelection, + TrackSelection, +) from app.interview.repositories.interview import InterviewRepository -from app.interview.repositories.mappers import interview_from_orm, interview_to_read +from app.interview.repositories.mappers import interview_read_from_orm from app.interview.schemas.dashboard import DashboardRowRead +from app.interview.schemas.interview import InterviewRead from app.interview.services.dashboard import DashboardBuilder from app.shared.infrastructure.database import Base from app.shared.infrastructure.models import Interview from tests.helpers.selection import minimal_selection_spec +from tests.helpers.theory_seed import create_theory_section_for_interview @pytest.fixture @@ -49,66 +57,144 @@ def test_interview_display_title(): id="x", selection_spec=minimal_selection_spec(categories=["data-structures"]), ) - read_model = interview_to_read(interview_from_orm(interview)) + read_model = interview_read_from_orm(interview) + assert DashboardBuilder.interview_display_title(read_model) == "Python Interview" + + +def test_interview_display_title_coding_only(): + """Coding-only sessions use coding sources for the dashboard title.""" + spec = session_to_spec( + SessionSelection( + session_mode="coding_only", + theory=SectionBranchSpec( + enabled=False, + question_count=0, + task_time_limit_seconds=None, + sources=(), + ), + coding=SectionBranchSpec( + enabled=True, + question_count=1, + task_time_limit_seconds=None, + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + ), + ) + ) + interview = Interview(id="coding-1", selection_spec=spec) + read_model = interview_read_from_orm(interview) assert DashboardBuilder.interview_display_title(read_model) == "Python Interview" def test_list_recent_ordering(db_session): - """list_recent returns completed before older active when completed is newer.""" + """list_recent_read_models returns completed before older active when newer.""" now = datetime.now(UTC) active = Interview( id="active-1", selection_spec=minimal_selection_spec(categories=["basics"]), - question_count=5, status="active", started_at=now - timedelta(hours=2), ) completed = Interview( id="done-1", selection_spec=minimal_selection_spec(categories=["algorithms"]), - question_count=3, status="completed", - score=10, started_at=now - timedelta(hours=3), completed_at=now, ) db_session.add(active) db_session.add(completed) + db_session.flush() + create_theory_section_for_interview( + db_session, + active, + question_count=5, + ) + create_theory_section_for_interview( + db_session, + completed, + question_count=3, + ) db_session.commit() repo = InterviewRepository(db_session) - recent = repo.list_recent(limit=20) + recent = repo.list_recent_read_models(limit=20) assert [i.id for i in recent] == ["done-1", "active-1"] +def test_compute_max_score_uses_nested_breakdown_for_coding_only(): + """Coding-only completed sessions read max score from nested breakdown.""" + interview = InterviewRead( + id="coding-done", + status="completed", + locale="en", + selection_spec=minimal_selection_spec(categories=["basics"]), + question_ids="[]", + question_count=0, + question_time_limit_seconds=None, + answers=[], + score=4, + overall_feedback={ + "score_breakdown": { + "coding": { + "score": 4, + "max": 5, + "skipped": False, + "questions": {"cod-001": {"score": 4, "max": 5}}, + } + } + }, + ) + assert ( + DashboardBuilder.compute_max_score( + interview, + interview.overall_feedback["score_breakdown"], + ) + == 5 + ) + + def test_list_dashboard_rows(monkeypatch): """Dashboard rows map interview fields for the template.""" now = datetime.now(UTC) - completed = Interview( + completed = InterviewRead( id="done-1", + status="completed", + locale="en", selection_spec=minimal_selection_spec(categories=["algorithms"]), + question_ids='["q1"]', question_count=3, - status="completed", + question_time_limit_seconds=None, + answers=[], score=8, + overall_feedback={ + "score_breakdown": { + "theory": {"score": 8, "max": 15, "skipped": False}, + } + }, completed_at=now, ) - completed.answers = [] - active = Interview( + active = InterviewRead( id="active-1", + status="active", + locale="en", selection_spec=minimal_selection_spec(categories=["basics"]), + question_ids='["q1"]', question_count=5, - status="active", + question_time_limit_seconds=None, + answers=[], started_at=now, ) - active.answers = [] class FakeInterviews: @staticmethod - def list_recent(limit=20): - return [ - interview_from_orm(completed), - interview_from_orm(active), - ] + def list_recent_read_models(limit=20): + return [completed, active] class FakeUow: def __init__(self, auto_commit=False): @@ -128,9 +214,10 @@ def __exit__(self, *args): rows = DashboardBuilder.list_rows(limit=20) assert len(rows) == 2 assert rows[0].title == "Python Interview" - assert rows[0].score_display == "8 / 0" + assert rows[0].session_mode_label == "Theory" + assert rows[0].score_display == "8 / 15" assert rows[0].status_label == "Completed" assert rows[1].score_display == "—" assert rows[1].status_label == "Active" - assert rows[0].url == "/interview/done-1" + assert rows[0].url == "/interview/done-1/results" assert isinstance(rows[0], DashboardRowRead) diff --git a/tests/test_database.py b/tests/test_database.py index 2d35b72..3b372f4 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -51,24 +51,6 @@ def test_interview_creation(self, test_session): assert result.selection_spec == spec assert result.status == "active" - def test_session_with_score(self, test_session): - """Test creating an Interview with a score.""" - session = Interview( - id="test-session-002", - selection_spec=minimal_selection_spec( - level="senior", categories=["system-design"] - ), - status="completed", - score=85, - question_ids='["ds-001","ds-002"]', - ) - test_session.add(session) - test_session.commit() - - result = test_session.query(Interview).filter_by(id="test-session-002").first() - assert result.score == 85 - assert result.question_ids == '["ds-001","ds-002"]' - def test_session_default_status(self, test_session): """Test that status defaults to 'active'.""" session = Interview( @@ -81,18 +63,6 @@ def test_session_default_status(self, test_session): result = test_session.query(Interview).filter_by(id="test-session-003").first() assert result.status == "active" - def test_session_default_question_ids(self, test_session): - """Test that question_ids defaults to '[]'.""" - session = Interview( - id="test-session-004", - selection_spec=minimal_selection_spec(categories=["sql"]), - ) - test_session.add(session) - test_session.commit() - - result = test_session.query(Interview).filter_by(id="test-session-004").first() - assert result.question_ids == "[]" - def test_interview_completed_at_nullable(self, test_session): """Test that completed_at can be null.""" session = Interview( @@ -106,19 +76,6 @@ def test_interview_completed_at_nullable(self, test_session): result = test_session.query(Interview).filter_by(id="test-session-005").first() assert result.completed_at is None - def test_session_score_nullable(self, test_session): - """Test that score can be null.""" - session = Interview( - id="test-session-006", - selection_spec=minimal_selection_spec(), - score=None, - ) - test_session.add(session) - test_session.commit() - - result = test_session.query(Interview).filter_by(id="test-session-006").first() - assert result.score is None - def test_session_started_at_auto(self, test_session): """Test that started_at is set automatically.""" session = Interview( diff --git a/tests/test_interview_completion.py b/tests/test_interview_completion.py index 169dd07..b41c207 100644 --- a/tests/test_interview_completion.py +++ b/tests/test_interview_completion.py @@ -2,16 +2,17 @@ # SPDX-License-Identifier: Apache-2.0 """Tests for interview completion persistence.""" -import json from unittest.mock import AsyncMock, patch import pytest -from app.interview.services.completion import InterviewCompletionService -from app.interview.services.evaluator.service import InterviewEvaluation +from app.coding.repositories.uow import CodingUnitOfWork +from app.interview.services.completion import SessionCompletionService from app.interview.services.query import InterviewQuery from app.shared.infrastructure.models import Answer, Interview +from app.theory.services.evaluator.models import InterviewEvaluation from tests.fakes import FakeProvider +from tests.helpers.coding_seed import seed_active_coding_interview from tests.helpers.interview_seed import persist_interview_with_answers from tests.helpers.selection import minimal_selection_spec @@ -26,13 +27,10 @@ async def test_complete_interview_persists_completed_status(isolated_db): id=interview_id, locale="en", selection_spec=minimal_selection_spec(categories=["basics"]), - question_count=1, - question_ids=json.dumps(["q1"]), status="active", ), [ Answer( - interview_id=interview_id, question_id="q1", order=1, round=0, @@ -47,15 +45,22 @@ async def test_complete_interview_persists_completed_status(isolated_db): overall_feedback="Good work", strengths_summary=["Clear answer"], topics_to_review=[], - score_breakdown={"q1": {"score": 99, "max": 999}}, + score_breakdown={ + "theory": { + "score": 99, + "max": 999, + "skipped": False, + "questions": {"q1": {"score": 99, "max": 999}}, + } + }, ) with patch( - "app.interview.services.completion.InterviewEvaluatorService.evaluate_interview", + "app.interview.services.completion.SessionEvaluatorService.evaluate_session", new_callable=AsyncMock, return_value=mock_eval, ): - events = await InterviewCompletionService.complete_interview( + events = await SessionCompletionService.complete_session( interview_id, provider=FakeProvider([]), ) @@ -68,5 +73,55 @@ async def test_complete_interview_persists_completed_status(isolated_db): assert reloaded.score == 5 assert reloaded.completed_at is not None assert reloaded.overall_feedback is not None - assert reloaded.overall_feedback["score_breakdown"]["q1"]["score"] == 5 - assert reloaded.overall_feedback["score_breakdown"]["q1"]["max"] == 5 + theory_breakdown = reloaded.overall_feedback["score_breakdown"]["theory"] + assert theory_breakdown["questions"]["q1"]["score"] == 5 + assert theory_breakdown["questions"]["q1"]["max"] == 5 + + +@pytest.mark.asyncio +async def test_complete_coding_only_session_includes_coding_breakdown(isolated_db): + """Completion merges coding section scores into the session breakdown.""" + interview_id, _task_id = seed_active_coding_interview("coding-completion-1") + with CodingUnitOfWork(auto_commit=True) as uow: + section = uow.coding_sections.get_aggregate(interview_id) + assert section is not None + task = section.find_first_unsubmitted() + assert task is not None + submitted = section.with_submit_test_summary( + task.id, + {"status": "success"}, + source_code="def solve():\n return 1", + ) + evaluated = submitted.with_evaluation( + task.task_id, + task.round, + score=4, + feedback="Good solution.", + ) + uow.coding_sections.save_aggregate(evaluated) + + mock_eval = InterviewEvaluation( + overall_feedback="Solid coding work", + strengths_summary=["Clean code"], + topics_to_review=[], + score_breakdown={}, + ) + + with patch( + "app.interview.services.completion.SessionEvaluatorService.evaluate_session", + new_callable=AsyncMock, + return_value=mock_eval, + ): + events = await SessionCompletionService.complete_session( + interview_id, + provider=FakeProvider([]), + ) + + assert len(events) == 2 + reloaded = InterviewQuery.get_interview(interview_id) + assert reloaded is not None + assert reloaded.status == "completed" + assert reloaded.score == 4 + coding_breakdown = reloaded.overall_feedback["score_breakdown"]["coding"] + assert coding_breakdown["score"] == 4 + assert coding_breakdown["questions"]["cod-001"]["score"] == 4 diff --git a/tests/test_interview_creation.py b/tests/test_interview_creation.py index 21e078c..a191570 100644 --- a/tests/test_interview_creation.py +++ b/tests/test_interview_creation.py @@ -6,13 +6,33 @@ import pytest -from app.interview.domain.value_objects import InterviewSelection, TrackSelection +from app.coding.repositories.uow import CodingUnitOfWork +from app.interview.domain.value_objects import ( + InterviewSelection, + SectionBranchSpec, + SessionSelection, + TrackSelection, +) from app.interview.repositories.uow import InterviewUnitOfWork -from app.interview.services.creation import InterviewCreationService +from app.interview.services.creation import SessionCreationService from app.interview.services.query import InterviewQuery from app.interview.services.rules.selection import get_interview_selection +def _session_from_selection( + selection: InterviewSelection, + *, + question_count: int = 5, + task_time_limit_seconds: int | None = None, +) -> SessionSelection: + """Wrap a legacy theory selection in a v2 session selection.""" + return SessionSelection.theory_only( + sources=selection.sources, + question_count=question_count, + task_time_limit_seconds=task_time_limit_seconds, + ) + + def _single_selection( *, track: str = "python", @@ -38,10 +58,9 @@ def test_create_interview_persists_questions( del temp_questions_dir monkeypatch.setattr("random.shuffle", lambda items: None) - interview = InterviewCreationService.create_interview( - selection=_single_selection(), + interview = SessionCreationService.create_session( + _session_from_selection(_single_selection(), question_count=1), locale="en", - question_count=1, ) assert interview.id @@ -74,11 +93,13 @@ def test_create_interview_with_timer_starts_first_round( del temp_questions_dir monkeypatch.setattr("random.shuffle", lambda items: None) - interview = InterviewCreationService.create_interview( - selection=_single_selection(), + interview = SessionCreationService.create_session( + _session_from_selection( + _single_selection(), + question_count=1, + task_time_limit_seconds=180, + ), locale="en", - question_count=1, - question_time_limit_seconds=180, ) assert interview.question_time_limit_seconds == 180 @@ -93,22 +114,28 @@ def test_create_interview_unknown_category_raises(isolated_db, temp_questions_di """Missing category in the bank raises ValueError.""" del temp_questions_dir with pytest.raises(ValueError, match="Unknown topic"): - InterviewCreationService.create_interview( - selection=_single_selection(categories=("nonexistent",)), - question_count=1, + SessionCreationService.create_session( + _session_from_selection( + _single_selection(categories=("nonexistent",)), + question_count=1, + ), ) def test_create_interview_expunged_instance_is_usable(isolated_db, temp_questions_dir): """Returned interview is detached but id and fields remain readable.""" del temp_questions_dir - interview = InterviewCreationService.create_interview( - selection=_single_selection(categories=("algorithms",)), - question_count=1, + interview = SessionCreationService.create_session( + _session_from_selection( + _single_selection(categories=("algorithms",)), + question_count=1, + ), ) assert interview.id assert "algorithms" in interview.selection_spec + question_ids = json.loads(interview.question_ids) + assert question_ids == ["algo-002"] with InterviewUnitOfWork() as uow: stored = uow.interviews.get(interview.id) @@ -130,9 +157,8 @@ def test_create_multi_topic_interview(isolated_db, temp_questions_dir, monkeypat ), ) ) - interview = InterviewCreationService.create_interview( - selection=selection, - question_count=2, + interview = SessionCreationService.create_session( + _session_from_selection(selection, question_count=2), ) assert interview.question_count == 2 @@ -144,3 +170,86 @@ def test_create_multi_topic_interview(isolated_db, temp_questions_dir, monkeypat assert reloaded is not None parsed = get_interview_selection(reloaded) assert len(parsed.sources[0].categories) == 2 + + +def test_create_coding_only_session(isolated_db, monkeypatch) -> None: + """Coding-only sessions persist a coding section with planned tasks.""" + monkeypatch.setattr("random.shuffle", lambda items: None) + + session = SessionSelection( + session_mode="coding_only", + theory=SectionBranchSpec( + enabled=False, + question_count=0, + task_time_limit_seconds=None, + sources=(), + ), + 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") + + assert interview.id + assert interview.status == "active" + assert "coding_only" in interview.selection_spec + + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview.id) + assert section is not None + assert section.status == "active" + assert section.task_count == 1 + assert len(section.tasks) == 1 + assert section.tasks[0].task_id == "bas-004" + assert section.task_time_limit_seconds == 600 + + +def test_create_theory_then_coding_session_pending_coding( + isolated_db, temp_questions_dir, monkeypatch +) -> None: + """Theory-first mixed sessions keep coding pending until the theory phase ends.""" + del temp_questions_dir + monkeypatch.setattr("random.shuffle", lambda items: None) + + session = SessionSelection( + session_mode="theory_then_coding", + theory=SectionBranchSpec( + enabled=True, + question_count=1, + task_time_limit_seconds=None, + sources=( + TrackSelection( + track="python", + level="junior", + categories=("data-structures",), + ), + ), + ), + coding=SectionBranchSpec( + enabled=True, + question_count=1, + task_time_limit_seconds=None, + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + ), + ) + interview = SessionCreationService.create_session(session, locale="en") + + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview.id) + assert section is not None + assert section.status == "pending" diff --git a/tests/test_interview_lifecycle.py b/tests/test_interview_lifecycle.py deleted file mode 100644 index 5a3b540..0000000 --- a/tests/test_interview_lifecycle.py +++ /dev/null @@ -1,370 +0,0 @@ -# Copyright 2026 GrillKit Contributors -# SPDX-License-Identifier: Apache-2.0 -"""Tests for interview aggregate behavior.""" - -from datetime import UTC, datetime - -import pytest - -from app.interview.domain.entities import Answer, Interview -from app.interview.domain.exceptions import ( - AnswerNotFoundError, - InterviewNotActiveError, - UnansweredAnswerNotFoundError, -) -from app.interview.domain.serialization import parse_selection_spec -from app.interview.domain.value_objects import ( - InterviewSelection, - PlannedQuestion, - TrackSelection, -) -from tests.helpers.selection import minimal_selection_spec - -_SPEC = minimal_selection_spec() -_EPOCH = datetime.min.replace(tzinfo=UTC) - - -def _answer(**kwargs) -> Answer: - """Build a domain answer with defaults for aggregate tests.""" - defaults = { - "id": 1, - "interview_id": "s1", - "question_id": "q1", - "order": 1, - "round": 0, - "question_text": "Q1", - "question_code": None, - "answer_text": None, - "score": None, - "feedback": None, - "started_at": None, - "created_at": _EPOCH, - } - defaults.update(kwargs) - return Answer(**defaults) - - -def _session(*, status: str = "active", answers: list[Answer]) -> Interview: - """Build a domain interview for aggregate behavior tests.""" - return Interview( - id="s1", - locale="en", - selection=parse_selection_spec(_SPEC), - question_count=len(answers), - question_ids=tuple(answer.question_id for answer in answers), - question_time_limit_seconds=None, - status=status, - score=None, - overall_feedback=None, - started_at=datetime(2026, 5, 23, 12, 0, 0, tzinfo=UTC), - completed_at=None, - answers=tuple(answers), - ) - - -def test_total_score(): - """total_score sums scored answers.""" - session = _session( - answers=[ - _answer(question_id="q1", score=4), - _answer(id=2, question_id="q2", order=2, question_text="Q2", score=3), - ] - ) - assert session.total_score() == 7 - - -def test_find_first_unanswered(): - """find_first_unanswered returns the first row without answer text.""" - session = _session( - answers=[ - _answer(answer_text="done"), - _answer(id=2, question_id="q2", order=2, question_text="Q2"), - ] - ) - current = session.find_first_unanswered() - assert current is not None - assert current.question_id == "q2" - - -def test_find_unanswered_for_question(): - """find_unanswered_for_question returns the open row for a question.""" - session = _session( - answers=[ - _answer(answer_text="done"), - _answer(id=2, question_id="q2", order=2, question_text="Q2"), - ] - ) - current = session.find_unanswered_for_question("q2") - assert current.question_id == "q2" - - -def test_find_unanswered_for_question_raises(): - """find_unanswered_for_question raises when no open row exists.""" - session = _session(answers=[_answer(answer_text="done")]) - with pytest.raises(UnansweredAnswerNotFoundError): - session.find_unanswered_for_question("q1") - - -def test_find_next_unanswered_after(): - """find_next_unanswered_after skips answered rows.""" - session = _session( - answers=[ - _answer(answer_text="done"), - _answer( - id=2, - question_id="q2", - order=2, - question_text="Q2", - answer_text="done", - ), - _answer(id=3, question_id="q3", order=3, question_text="Q3"), - ] - ) - nxt = session.find_next_unanswered_after(1) - assert nxt is not None - assert nxt.question_id == "q3" - - -def test_ensure_active(): - """ensure_active raises when the session is completed.""" - session = _session(status="completed", answers=[]) - with pytest.raises(InterviewNotActiveError): - session.ensure_active() - - -def test_find_answer_returns_matching_round(): - """find_answer locates a row by question_id and round.""" - session = _session( - answers=[ - _answer(id=1, question_id="q1", round=0), - _answer(id=2, question_id="q1", round=1, question_text="Follow-up"), - ] - ) - found = session.find_answer("q1", 1) - assert found.id == 2 - assert found.round == 1 - - -def test_find_answer_raises_when_missing(): - """find_answer raises AnswerNotFoundError for unknown keys.""" - session = _session(answers=[_answer(question_id="q1")]) - with pytest.raises(AnswerNotFoundError): - session.find_answer("q99", 0) - - -def test_with_timed_out_round_sets_marker_score_and_feedback(): - """with_timed_out_round records timeout fields on one row.""" - session = _session( - answers=[ - _answer(id=1, question_id="q1"), - _answer(id=2, question_id="q2", order=2, question_text="Q2"), - ] - ) - updated = session.with_timed_out_round(1, "Time is up.") - timed = updated.answers[0] - assert timed.answer_text == Answer.TIME_EXPIRED_ANSWER_TEXT - assert timed.score == 0 - assert timed.feedback == "Time is up." - assert updated.answers[1].answer_text is None - - -def test_with_evaluation_sets_score_and_feedback(): - """with_evaluation updates score and feedback on one round.""" - session = _session( - answers=[ - _answer(id=1, question_id="q1", answer_text="a"), - _answer(id=2, question_id="q2", order=2, question_text="Q2"), - ] - ) - updated = session.with_evaluation("q1", 0, 4, "Solid answer.") - evaluated = updated.answers[0] - assert evaluated.score == 4 - assert evaluated.feedback == "Solid answer." - assert updated.answers[1].score is None - - -def test_with_follow_up_appends_new_round(): - """with_follow_up adds an unanswered follow-up row with NEW_ID.""" - session = _session( - answers=[ - _answer(id=1, question_id="q1", answer_text="a", score=3), - ] - ) - updated, pending = session.with_follow_up("q1", "Can you elaborate?") - assert pending.id == Answer.NEW_ID - assert pending.round == 1 - assert pending.question_text == "Can you elaborate?" - assert pending.answer_text is None - assert len(updated.answers) == 2 - - -def test_max_round_for_question(): - """max_round_for_question returns the highest round for a question.""" - session = _session( - answers=[ - _answer(id=1, question_id="q1", round=0), - _answer(id=2, question_id="q1", round=2, question_text="R2"), - ] - ) - assert session.max_round_for_question("q1") == 2 - - -def test_with_answer_text_updates_single_row(): - """with_answer_text sets answer_text on one answer without touching others.""" - session = _session( - answers=[ - _answer(id=1, question_id="q1"), - _answer(id=2, question_id="q2", order=2, question_text="Q2"), - ] - ) - updated = session.with_answer_text(1, "my answer") - assert updated.answers[0].answer_text == "my answer" - assert updated.answers[1].answer_text is None - - -def test_start_timer_for_answer_sets_started_at(): - """start_timer_for_answer activates the timer on the target row only.""" - when = datetime(2026, 6, 1, 10, 0, 0, tzinfo=UTC) - session = Interview( - id="s1", - locale="en", - selection=parse_selection_spec(_SPEC), - question_count=2, - question_ids=("q1", "q2"), - question_time_limit_seconds=90, - status="active", - score=None, - overall_feedback=None, - started_at=datetime(2026, 5, 23, 12, 0, 0, tzinfo=UTC), - completed_at=None, - answers=( - _answer(id=1, question_id="q1"), - _answer(id=2, question_id="q2", order=2, question_text="Q2"), - ), - ) - timed = session.start_timer_for_answer(2, when=when) - assert timed.answers[0].started_at is None - assert timed.answers[1].started_at == when - - -def test_per_question_score_breakdown(): - """per_question_score_breakdown aggregates per question.""" - session = _session( - answers=[ - _answer(question_id="q1", answer_text="a", score=4), - _answer(question_id="q1", round=1, answer_text="b", score=3), - _answer( - question_id="q2", order=2, question_text="Q2", answer_text="c", score=5 - ), - ] - ) - breakdown = session.per_question_score_breakdown() - assert breakdown["q1"] == { - "score": 7, - "max": Interview.MAX_SCORE_PER_ROUND * 2, - } - assert breakdown["q2"] == {"score": 5, "max": Interview.MAX_SCORE_PER_ROUND} - - -def _planned_question( - question_id: str, *, text: str = "What is a list?" -) -> PlannedQuestion: - return PlannedQuestion(id=question_id, text=text, code=None) - - -def test_interview_start_builds_active_aggregate(): - """Interview.start creates answer rows and question_ids from the plan.""" - selection = InterviewSelection( - sources=( - TrackSelection( - track="python", - level="junior", - categories=("data-structures",), - ), - ) - ) - session = Interview.start( - "new-session", - selection=selection, - locale="ru", - planned_questions=( - _planned_question("ds-001"), - _planned_question("ds-002", text="Second?"), - ), - ) - - assert session.status == "active" - assert session.locale == "ru" - assert session.question_count == 2 - assert session.question_ids == ("ds-001", "ds-002") - assert len(session.answers) == 2 - assert session.answers[0].id == Answer.NEW_ID - assert session.answers[0].order == 1 - assert session.answers[1].question_id == "ds-002" - assert session.answers[0].started_at is None - - -def test_interview_start_with_timer_starts_first_round(): - """Interview.start sets started_at on the first answer when a limit is set.""" - selection = InterviewSelection( - sources=( - TrackSelection( - track="python", - level="junior", - categories=("basics",), - ), - ) - ) - session = Interview.start( - "timed-session", - selection=selection, - locale="en", - planned_questions=(_planned_question("q1"),), - question_time_limit_seconds=120, - ) - - assert session.question_time_limit_seconds == 120 - assert session.answers[0].started_at is not None - assert session.answers[0].started_at == session.started_at - - -def test_with_session_completed_sets_final_state(): - """with_session_completed marks the session completed with total score.""" - when = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) - session = _session( - answers=[ - _answer(question_id="q1", answer_text="a", score=4), - _answer( - question_id="q2", order=2, question_text="Q2", answer_text="b", score=3 - ), - ] - ) - completed = session.with_session_completed( - {"summary": "Solid performance."}, - completed_at=when, - ) - - assert completed.status == "completed" - assert completed.score == 7 - assert completed.completed_at == when - assert completed.overall_feedback == {"summary": "Solid performance."} - - -def test_interview_start_empty_plan_raises(): - """Interview.start rejects an empty question plan.""" - selection = InterviewSelection( - sources=( - TrackSelection( - track="python", - level="junior", - categories=("basics",), - ), - ) - ) - with pytest.raises(ValueError, match="No questions found"): - Interview.start( - "empty", - selection=selection, - locale="en", - planned_questions=(), - ) diff --git a/tests/test_interview_page.py b/tests/test_interview_page.py index e7ee1fe..b27672b 100644 --- a/tests/test_interview_page.py +++ b/tests/test_interview_page.py @@ -2,8 +2,14 @@ # SPDX-License-Identifier: Apache-2.0 """Tests for interview page context building.""" +from app.interview.domain.serialization import session_to_spec +from app.interview.domain.value_objects import ( + SectionBranchSpec, + SessionSelection, + TrackSelection, +) from app.interview.schemas.interview import AnswerRead, InterviewRead -from app.interview.services.page import InterviewPageService +from app.interview.services.page import SessionPageService from app.platform.services.config import AppConfig from tests.helpers.selection import minimal_selection_spec @@ -47,7 +53,7 @@ def test_build_page_context_sets_audio_flag_from_catalog(monkeypatch): )(), ) - enabled = InterviewPageService.build_page_context( + enabled = SessionPageService.build_page_context( _session(), config=AppConfig( llm_preset_id="audio-model", @@ -58,7 +64,7 @@ def test_build_page_context_sets_audio_flag_from_catalog(monkeypatch): ), question_voice_enabled=False, ) - disabled = InterviewPageService.build_page_context( + disabled = SessionPageService.build_page_context( _session(), config=AppConfig( llm_preset_id="text-model", @@ -72,3 +78,50 @@ def test_build_page_context_sets_audio_flag_from_catalog(monkeypatch): assert enabled.interview_model_accepts_audio is True assert disabled.interview_model_accepts_audio is False + + +def test_build_page_context_coding_only_session(isolated_db): + """Coding-only sessions build page context without theory sources.""" + del isolated_db + spec = session_to_spec( + SessionSelection( + session_mode="coding_only", + theory=SectionBranchSpec( + enabled=False, + question_count=0, + task_time_limit_seconds=None, + sources=(), + ), + coding=SectionBranchSpec( + enabled=True, + question_count=1, + task_time_limit_seconds=None, + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + ), + ) + ) + interview = InterviewRead( + id="coding-1", + status="active", + locale="en", + selection_spec=spec, + question_ids="[]", + question_count=0, + question_time_limit_seconds=None, + answers=[], + ) + + context = SessionPageService.build_page_context( + interview, + config=None, + question_voice_enabled=False, + ) + + assert context.interview_title == "Python Interview" + assert context.selection_lines == ["Python / junior: Basics"] diff --git a/tests/test_interview_selection.py b/tests/test_interview_selection.py index 34ef1fc..56eceaa 100644 --- a/tests/test_interview_selection.py +++ b/tests/test_interview_selection.py @@ -6,11 +6,13 @@ from app.interview.domain.serialization import ( parse_selection_spec, - selection_to_spec, + parse_session_spec, + session_to_spec, ) from app.interview.domain.value_objects import ( InterviewSelection, PlannedQuestion, + SessionSelection, TrackQuestionPools, TrackSelection, ) @@ -121,8 +123,30 @@ def test_orders_by_track_blocks(self, monkeypatch): class TestSelectionSpec: """Tests for selection_spec JSON round-trip.""" - def test_round_trip(self): - """selection_to_spec and parse_selection_spec preserve data.""" + def test_v2_session_round_trip(self): + """session_to_spec and parse_session_spec preserve v2 session data.""" + session = SessionSelection.theory_only( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + question_count=5, + task_time_limit_seconds=120, + ) + raw = session_to_spec(session) + parsed = parse_session_spec(raw) + assert parsed.session_mode == "theory_only" + assert parsed.theory.question_count == 5 + assert parsed.theory.task_time_limit_seconds == 120 + assert parsed.theory_selection.sources[0].track == "python" + assert '"version":2' in raw + assert '"session_mode":"theory_only"' in raw + + def test_v1_theory_sources_compat(self): + """parse_selection_spec extracts theory sources from legacy v1 JSON.""" selection = InterviewSelection( sources=( TrackSelection( @@ -132,9 +156,9 @@ def test_round_trip(self): ), ) ) - raw = selection_to_spec(selection) + raw = ( + '{"sources":[{"track":"python","level":"junior","categories":["basics"]}]}' + ) parsed = parse_selection_spec(raw) - assert parsed.sources[0].track == "python" - assert parsed.sources[0].categories == ("basics",) - assert '"track":"python"' in raw - assert '"language"' not in raw + assert parsed.sources[0].track == selection.sources[0].track + assert parsed.sources[0].categories == selection.sources[0].categories diff --git a/tests/test_interview_ws_integration.py b/tests/test_interview_ws_integration.py index bb878d2..e04f50e 100644 --- a/tests/test_interview_ws_integration.py +++ b/tests/test_interview_ws_integration.py @@ -3,13 +3,12 @@ """WebSocket integration tests using real answer processing and fake AI.""" from datetime import UTC, datetime, timedelta -import json from app.ai.base import GenerationResult, Message from app.interview.api.deps import get_ai_provider -from app.interview.domain.entities import Answer as DomainAnswer from app.interview.services.query import InterviewQuery from app.shared.infrastructure.models import Answer, Interview +from app.theory.domain.entities import TheoryTask from tests.fakes import FakeProvider, answer_evaluation_json from tests.helpers.interview_seed import persist_interview_with_answers from tests.helpers.selection import minimal_selection_spec @@ -46,39 +45,36 @@ def _seed_interview(interview_id: str = "ws-int-1") -> str: id=interview_id, locale="en", selection_spec=minimal_selection_spec(categories=["basics"]), - question_count=2, - question_ids=json.dumps(["q1", "q2"]), status="active", ), [ Answer( - interview_id=interview_id, question_id="q1", order=1, round=0, question_text="Question one?", ), Answer( - interview_id=interview_id, question_id="q2", order=2, round=0, question_text="Question two?", ), ], + question_count=2, ) def test_websocket_answer_runs_full_processing_pipeline( client, isolated_db, override_ws_ai_provider ): - """WS answer uses AnswerProcessingService; only the AI provider is faked.""" + """WS answer uses TheorySubmissionService; only the AI provider is faked.""" interview_id = _seed_interview() override_ws_ai_provider( client, [answer_evaluation_json(score=4, follow_up_needed=False)] ) - with client.websocket_connect(f"/interview/{interview_id}/ws") as ws: + with client.websocket_connect(f"/interview/{interview_id}/theory/ws") as ws: ws.send_json( { "type": "answer", @@ -120,7 +116,7 @@ async def _dep(): client.app.dependency_overrides[get_ai_provider] = _dep try: - with client.websocket_connect(f"/interview/{interview_id}/ws") as ws: + with client.websocket_connect(f"/interview/{interview_id}/theory/ws") as ws: ws.send_json( { "type": "answer", @@ -147,14 +143,10 @@ def test_websocket_timeout_scores_zero(client, isolated_db, override_ws_ai_provi id=interview_id, locale="en", selection_spec=minimal_selection_spec(categories=["basics"]), - question_count=2, - question_ids=json.dumps(["q1", "q2"]), - question_time_limit_seconds=60, status="active", ), [ Answer( - interview_id=interview_id, question_id="q1", order=1, round=0, @@ -162,18 +154,19 @@ def test_websocket_timeout_scores_zero(client, isolated_db, override_ws_ai_provi started_at=started, ), Answer( - interview_id=interview_id, question_id="q2", order=2, round=0, question_text="Question two?", ), ], + question_count=2, + task_time_limit_seconds=60, ) override_ws_ai_provider(client, []) - with client.websocket_connect(f"/interview/{interview_id}/ws") as ws: + with client.websocket_connect(f"/interview/{interview_id}/theory/ws") as ws: ws.send_json({"type": "timeout", "question_id": "q1", "round": 0}) feedback = ws.receive_json() @@ -184,5 +177,5 @@ def test_websocket_timeout_scores_zero(client, isolated_db, override_ws_ai_provi reloaded = InterviewQuery.get_interview(interview_id) assert reloaded is not None q1 = next(a for a in reloaded.answers if a.question_id == "q1") - assert q1.answer_text == DomainAnswer.TIME_EXPIRED_ANSWER_TEXT + assert q1.answer_text == TheoryTask.TIME_EXPIRED_ANSWER_TEXT assert q1.score == 0 diff --git a/tests/test_judge0_client.py b/tests/test_judge0_client.py new file mode 100644 index 0000000..7b3001c --- /dev/null +++ b/tests/test_judge0_client.py @@ -0,0 +1,81 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the Judge0 HTTP client.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from app.coding.services.judge0_client import Judge0Client, Judge0SubmissionResult + + +@pytest.mark.asyncio +async def test_health_check_returns_true_on_200() -> None: + """Health check succeeds when /about returns HTTP 200.""" + client = Judge0Client(base_url="http://judge0.test") + response = MagicMock() + response.status_code = 200 + mock_http = AsyncMock() + mock_http.get.return_value = response + mock_http.__aenter__.return_value = mock_http + mock_http.__aexit__.return_value = None + + with patch( + "app.coding.services.judge0_client.httpx.AsyncClient", return_value=mock_http + ): + assert await client.health_check() is True + + +@pytest.mark.asyncio +async def test_health_check_returns_false_on_network_error() -> None: + """Health check fails closed on transport errors.""" + client = Judge0Client(base_url="http://judge0.test") + mock_http = AsyncMock() + mock_http.get.side_effect = httpx.ConnectError("down") + mock_http.__aenter__.return_value = mock_http + mock_http.__aexit__.return_value = None + + with patch( + "app.coding.services.judge0_client.httpx.AsyncClient", return_value=mock_http + ): + assert await client.health_check() is False + + +@pytest.mark.asyncio +async def test_submit_parses_wait_response() -> None: + """Submit normalizes a synchronous wait=true submission payload.""" + client = Judge0Client(base_url="http://judge0.test") + response = MagicMock() + response.raise_for_status.return_value = None + response.json.return_value = { + "status": {"id": 3, "description": "Accepted"}, + "stdout": "3.0\n", + "stderr": None, + "compile_output": None, + "time": "0.01", + "memory": 4096, + } + mock_http = AsyncMock() + mock_http.post.return_value = response + mock_http.__aenter__.return_value = mock_http + mock_http.__aexit__.return_value = None + + with patch( + "app.coding.services.judge0_client.httpx.AsyncClient", return_value=mock_http + ): + result = await client.submit(source_code="print(1)", language_id=71) + + assert result == Judge0SubmissionResult( + status_id=3, + status_description="Accepted", + stdout="3.0\n", + stderr=None, + compile_output=None, + time="0.01", + memory=4096, + ) + mock_http.post.assert_awaited_once() + call_kwargs = mock_http.post.await_args.kwargs + assert call_kwargs["json"]["language_id"] == 71 + assert call_kwargs["params"]["wait"] == "true" diff --git a/tests/test_questions.py b/tests/test_questions.py index bb5dc2e..a3503e4 100644 --- a/tests/test_questions.py +++ b/tests/test_questions.py @@ -7,7 +7,7 @@ import pytest import yaml -from app.questions import ( +from app.shared.questions import ( Question, list_categories, list_levels, @@ -82,13 +82,13 @@ def temp_questions_dir(tmp_path, monkeypatch): "description": "Basic algorithms and their implementation", "questions": [ { - "id": "algo-001", - "type": "coding", + "id": "algo-002", + "type": "knowledge", "difficulty": 2, - "tags": ["sorting", "array"], + "tags": ["complexity"], "question": { - "text": "Implement bubble sort.", - "code": "def bubble_sort(arr):\n pass", + "text": "What is Big-O notation?", + "code": None, }, }, ], @@ -134,7 +134,7 @@ def temp_questions_dir(tmp_path, monkeypatch): with open(javascript_junior_dir / "basics.yaml", "w") as f: yaml.dump(js_basics_content, f) - monkeypatch.setattr("app.questions.QUESTIONS_DIR", questions_root) + monkeypatch.setattr("app.shared.questions.QUESTIONS_DIR", questions_root) yield questions_root @@ -198,11 +198,23 @@ def test_list_categories_non_existent_track(self, temp_questions_dir): def test_load_category_with_code(self, temp_questions_dir): """Test loading a question with a code snippet.""" - questions = load_category("python", "junior", "algorithms") - assert len(questions) == 1 - q = questions[0] - assert q.id == "algo-001" - assert q.code == "def bubble_sort(arr):\n pass" + path = temp_questions_dir / "python" / "junior" / "with-code.yaml" + _write_category_yaml( + path, + [ + { + "id": "wc-001", + "type": "knowledge", + "difficulty": 1, + "question": { + "text": "Explain this snippet.", + "code": "x = [1, 2, 3]", + }, + } + ], + ) + questions = load_category("python", "junior", "with-code") + assert questions[0].code == "x = [1, 2, 3]" def test_load_category_multiple_questions(self, temp_questions_dir): """Test loading a category file with multiple questions.""" @@ -241,7 +253,7 @@ def test_load_categories_merges_and_dedupes(self, temp_questions_dir): ) ids = {q.id for q in questions} assert "ds-001" in ids - assert "algo-001" in ids + assert "algo-002" in ids assert len(questions) == len(ids) @@ -290,7 +302,7 @@ def i18n_questions_dir(self, tmp_path, monkeypatch): }, ], ) - monkeypatch.setattr("app.questions.QUESTIONS_DIR", questions_root) + monkeypatch.setattr("app.shared.questions.QUESTIONS_DIR", questions_root) return questions_root def test_load_resolves_requested_locale(self, i18n_questions_dir): diff --git a/tests/test_repositories.py b/tests/test_repositories.py index dbc6eb9..b973790 100644 --- a/tests/test_repositories.py +++ b/tests/test_repositories.py @@ -8,18 +8,14 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from app.interview.domain.entities import Answer as DomainAnswer from app.interview.domain.entities import Interview as DomainInterview -from app.interview.domain.value_objects import ( - InterviewSelection, - PlannedQuestion, - TrackSelection, -) +from app.interview.domain.value_objects import SessionSelection, TrackSelection from app.interview.repositories.interview import InterviewRepository from app.shared.infrastructure.database import Base from app.shared.infrastructure.models import Answer, Interview from app.shared.repositories.base import SqlAlchemyRepository from tests.helpers.selection import minimal_selection_spec +from tests.helpers.theory_seed import attach_theory_section_to_answers @pytest.fixture @@ -45,29 +41,43 @@ def db_session(engine): # --------------------------------------------------------------------------- -def _create_test_interview(db_session, interview_id="session-1") -> Interview: +def _create_test_interview( + db_session, + interview_id: str = "session-1", + *, + question_count: int = 3, +) -> Interview: sess = Interview( id=interview_id, selection_spec=minimal_selection_spec(), - question_count=3, - question_ids='["q1","q2","q3"]', status="active", ) db_session.add(sess) + db_session.flush() + attach_theory_section_to_answers( + db_session, + sess, + [], + question_count=question_count, + ) db_session.commit() return sess def _create_test_answer( db_session, - interview_id="session-1", - question_id="q1", - order=1, - round_num=0, - question_text="What is Python?", + interview_id: str = "session-1", + question_id: str = "q1", + order: int = 1, + round_num: int = 0, + question_text: str = "What is Python?", ) -> Answer: + interview = db_session.get(Interview, interview_id) + assert interview is not None + section = interview.theory_section + assert section is not None ans = Answer( - interview_id=interview_id, + theory_section_id=section.id, question_id=question_id, order=order, round=round_num, @@ -142,29 +152,24 @@ def test_list_all(self, db_session): class TestInterviewRepository: - """Tests for InterviewRepository.""" + """Tests for InterviewRepository shell persistence.""" - def test_get_eager_loads_answers(self, db_session): - """Test get() eagerly loads the answers relationship.""" + def test_get_eager_loads_theory_tasks(self, db_session): + """Test get() eagerly loads theory section tasks.""" _create_test_interview(db_session) _create_test_answer(db_session) repo = InterviewRepository(db_session) session = repo.get("session-1") assert session is not None - # answers should be loaded (no lazy-load error) - assert len(session.answers) == 1 + assert session.theory_section is not None + assert len(session.theory_section.tasks) == 1 def test_save_aggregate_persists_completed_session(self, db_session): - """save_aggregate writes completed status, score, and overall feedback.""" + """save_aggregate writes completed status and overall feedback.""" _create_test_interview(db_session, interview_id="session-1") - a1 = _create_test_answer(db_session, question_id="q1", order=1) - a1.answer_text = "a" - a1.score = 4 - a2 = _create_test_answer(db_session, question_id="q2", order=2) - a2.answer_text = "b" - a2.score = 3 - db_session.commit() + _create_test_answer(db_session, question_id="q1", order=1) + _create_test_answer(db_session, question_id="q2", order=2) repo = InterviewRepository(db_session) aggregate = repo.get_aggregate("session-1") @@ -176,7 +181,6 @@ def test_save_aggregate_persists_completed_session(self, db_session): reloaded = repo.get_aggregate("session-1") assert reloaded is not None assert reloaded.status == "completed" - assert reloaded.score == 7 assert reloaded.completed_at is not None assert isinstance(reloaded.completed_at, datetime) assert reloaded.overall_feedback == {"summary": "done"} @@ -186,46 +190,37 @@ def test_get_not_found(self, db_session): repo = InterviewRepository(db_session) assert repo.get("nope") is None - def test_create_aggregate_inserts_interview_and_answers(self, db_session): - """create_aggregate persists a new domain aggregate with real answer IDs.""" - selection = InterviewSelection( + def test_create_shell_inserts_interview(self, db_session): + """create_shell persists a new interview shell row.""" + selection = SessionSelection.theory_only( sources=( TrackSelection( track="python", level="junior", categories=("basics",), ), - ) - ) - planned = ( - PlannedQuestion( - id="q1", - text="Question one", - code=None, ), + question_count=1, + task_time_limit_seconds=60, ) - aggregate = DomainInterview.start( + shell = DomainInterview.start_shell( "new-session", selection=selection, locale="en", - planned_questions=planned, - question_time_limit_seconds=60, ) repo = InterviewRepository(db_session) - persisted = repo.create_aggregate(aggregate) + persisted = repo.create_shell(shell) db_session.commit() assert persisted.id == "new-session" - assert persisted.question_count == 1 - assert persisted.answers[0].id != DomainAnswer.NEW_ID - assert persisted.answers[0].started_at is not None + assert persisted.status == "active" reloaded = repo.get_aggregate("new-session") assert reloaded is not None - assert reloaded.answers[0].question_text == "Question one" + assert reloaded.locale == "en" - def test_get_aggregate_maps_domain_interview(self, db_session): - """get_aggregate returns a domain interview with answers.""" + def test_get_aggregate_maps_domain_shell(self, db_session): + """get_aggregate returns a domain interview shell without tasks.""" _create_test_interview(db_session) _create_test_answer(db_session, question_id="q1", order=1) _create_test_answer(db_session, question_id="q2", order=2) @@ -236,105 +231,16 @@ def test_get_aggregate_maps_domain_interview(self, db_session): assert aggregate is not None assert aggregate.id == "session-1" assert aggregate.status == "active" - assert len(aggregate.answers) == 2 - assert aggregate.answers[0].question_id == "q1" - - def test_save_aggregate_persists_answer_started_at(self, db_session): - """save_aggregate writes answer timer state from the domain model.""" - interview = _create_test_interview(db_session, interview_id="session-1") - interview.question_time_limit_seconds = 120 - db_session.commit() - a1 = _create_test_answer(db_session, question_id="q1", order=1) - _create_test_answer(db_session, question_id="q2", order=2) - - repo = InterviewRepository(db_session) - aggregate = repo.get_aggregate("session-1") - assert aggregate is not None - updated = aggregate.start_timer_for_answer(a1.id) - repo.save_aggregate(updated) - db_session.commit() - - reloaded = repo.get("session-1") - assert reloaded is not None - started = next(ans for ans in reloaded.answers if ans.id == a1.id) - assert started.started_at is not None - - def test_save_aggregate_persists_answer_text(self, db_session): - """save_aggregate writes answer_text from the domain model.""" - _create_test_interview(db_session, interview_id="session-1") - a1 = _create_test_answer(db_session, question_id="q1", order=1) - - repo = InterviewRepository(db_session) - aggregate = repo.get_aggregate("session-1") - assert aggregate is not None - updated = aggregate.with_answer_text(a1.id, "Lists are mutable.") - repo.save_aggregate(updated) - db_session.commit() - - reloaded = repo.get_aggregate("session-1") - assert reloaded is not None - saved = next(ans for ans in reloaded.answers if ans.id == a1.id) - assert saved.answer_text == "Lists are mutable." - - def test_save_aggregate_persists_timed_out_round(self, db_session): - """save_aggregate writes timeout marker, score, and feedback.""" - _create_test_interview(db_session, interview_id="session-1") - a1 = _create_test_answer(db_session, question_id="q1", order=1) - repo = InterviewRepository(db_session) - aggregate = repo.get_aggregate("session-1") - assert aggregate is not None - updated = aggregate.with_timed_out_round(a1.id, "Time expired.") - repo.save_aggregate(updated) - db_session.commit() - - reloaded = repo.get_aggregate("session-1") - assert reloaded is not None - saved = next(ans for ans in reloaded.answers if ans.id == a1.id) - assert saved.answer_text == DomainAnswer.TIME_EXPIRED_ANSWER_TEXT - assert saved.score == 0 - assert saved.feedback - - def test_save_aggregate_persists_evaluation(self, db_session): - """save_aggregate writes AI score and feedback from the domain model.""" - _create_test_interview(db_session, interview_id="session-1") - a1 = _create_test_answer(db_session, question_id="q1", order=1) - a1.answer_text = "my answer" - db_session.commit() - - repo = InterviewRepository(db_session) - aggregate = repo.get_aggregate("session-1") - assert aggregate is not None - updated = aggregate.with_evaluation("q1", 0, 5, "Excellent.") - repo.save_aggregate(updated) - db_session.commit() - - reloaded = repo.get_aggregate("session-1") - assert reloaded is not None - saved = reloaded.find_answer("q1", 0) - assert saved.score == 5 - assert saved.feedback == "Excellent." - - def test_save_aggregate_inserts_follow_up(self, db_session): - """save_aggregate inserts a new follow-up answer row.""" - _create_test_interview(db_session, interview_id="session-1") - a1 = _create_test_answer(db_session, question_id="q1", order=1) - a1.answer_text = "done" - a1.score = 4 - db_session.commit() + def test_get_read_model_composes_theory_tasks(self, db_session): + """get_read_model composes answers from the linked theory section.""" + _create_test_interview(db_session) + _create_test_answer(db_session, question_id="q1", order=1) repo = InterviewRepository(db_session) - aggregate = repo.get_aggregate("session-1") - assert aggregate is not None - evaluated = aggregate.with_evaluation("q1", 0, 4, "Good.") - updated, _ = evaluated.with_follow_up("q1", "Tell me more.") - repo.save_aggregate(updated) - db_session.commit() + read_model = repo.get_read_model("session-1") - reloaded = repo.get_aggregate("session-1") - assert reloaded is not None - assert len(reloaded.answers) == 2 - follow_up = reloaded.find_answer("q1", 1) - assert follow_up.id != DomainAnswer.NEW_ID - assert follow_up.question_text == "Tell me more." - assert follow_up.answer_text is None + assert read_model is not None + assert read_model.question_count == 3 + assert len(read_model.answers) == 1 + assert read_model.answers[0].question_id == "q1" diff --git a/tests/test_section_feedback.py b/tests/test_section_feedback.py new file mode 100644 index 0000000..9d7b346 --- /dev/null +++ b/tests/test_section_feedback.py @@ -0,0 +1,46 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for section feedback fallback resolution.""" + +from app.interview.services.section_feedback import resolve_section_feedback + + +def test_resolve_section_feedback_returns_cached_payload() -> None: + """Cached section feedback is returned unchanged.""" + cached = { + "section_feedback": "Cached narrative.", + "topics_to_review": ["loops"], + "strengths_summary": ["clarity"], + } + result = resolve_section_feedback( + cached, + (), + item_id_key="question_id", + ) + assert result == cached + + +def test_resolve_section_feedback_builds_fallback_from_task_rows() -> None: + """Missing cached feedback is synthesized from per-task feedback rows.""" + result = resolve_section_feedback( + None, + ( + { + "task_id": "cod-001", + "round": 0, + "score": 4, + "feedback": "Good solution.", + }, + { + "task_id": "cod-001", + "round": 1, + "score": 3, + "feedback": "Follow-up was weak.", + }, + ), + item_id_key="task_id", + ) + assert "Good solution." in result["section_feedback"] + assert "Follow-up was weak." in result["section_feedback"] + assert result["score_breakdown"]["cod-001"]["score"] == 4 + assert result["score_breakdown"]["cod-001:r1"]["score"] == 3 diff --git a/tests/test_session_evaluation.py b/tests/test_session_evaluation.py new file mode 100644 index 0000000..8c135a1 --- /dev/null +++ b/tests/test_session_evaluation.py @@ -0,0 +1,117 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for session evaluation aggregation and completion.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from app.interview.services.evaluation_aggregator import ( + SessionEvaluationAggregator, + attach_session_score_breakdown, +) +from app.interview.services.sections import SectionEvaluationSummary +from app.interview.services.session_evaluator import SessionEvaluatorService +from tests.fakes import FakeProvider + + +def test_aggregator_builds_nested_section_breakdown() -> None: + """Merged evaluation exposes per-section score breakdown.""" + theory = SectionEvaluationSummary( + section="theory", + score=4, + max_score=5, + items=( + { + "question_id": "q1", + "question_text": "Question?", + "answer_text": "Answer", + "score": 4, + "round": 0, + }, + ), + ) + merged = SessionEvaluationAggregator.merge(theory, None) + breakdown = merged.to_score_breakdown() + assert breakdown["theory"]["score"] == 4 + assert breakdown["theory"]["questions"]["q1"]["score"] == 4 + assert SessionEvaluationAggregator.total_score_from_breakdown(breakdown) == 4 + + +def test_aggregator_merges_theory_and_coding_breakdown() -> None: + """Merged evaluation exposes separate theory and coding section scores.""" + theory = SectionEvaluationSummary( + section="theory", + score=4, + max_score=5, + items=( + { + "question_id": "q1", + "question_text": "Question?", + "answer_text": "Answer", + "score": 4, + "round": 0, + }, + ), + ) + coding = SectionEvaluationSummary( + section="coding", + score=3, + max_score=5, + items=( + { + "task_id": "cod-001", + "prompt_text": "Write a function", + "submitted_code": "def solve():\n return 1", + "score": 3, + "round": 0, + }, + ), + ) + merged = SessionEvaluationAggregator.merge(theory, coding) + breakdown = merged.to_score_breakdown() + assert breakdown["theory"]["score"] == 4 + assert breakdown["coding"]["score"] == 3 + assert breakdown["coding"]["questions"]["cod-001"]["score"] == 3 + assert SessionEvaluationAggregator.total_score_from_breakdown(breakdown) == 7 + + +@pytest.mark.asyncio +async def test_evaluate_session_synthesizes_when_llm_returns_empty_json() -> None: + """Session completion falls back to per-answer feedback when LLM output is empty.""" + theory = SectionEvaluationSummary( + section="theory", + score=4, + max_score=5, + items=( + { + "question_id": "q1", + "question_text": "Question?", + "answer_text": "Answer", + "score": 4, + "round": 0, + "feedback": "Good explanation of basics.", + }, + ), + ) + merged = SessionEvaluationAggregator.merge(theory, None) + + with patch( + "app.interview.services.session_evaluator.TheoryEvaluatorService.evaluate_interview", + new_callable=AsyncMock, + side_effect=ValueError( + "AI returned invalid JSON: Expecting value: line 1 column 1" + ), + ): + result = await SessionEvaluatorService.evaluate_session( + merged, + provider=FakeProvider([]), + locale="en", + sources_text="Python / junior / basics", + ) + + assert "Good explanation of basics." in result.overall_feedback + assert result.score_breakdown == {} + + completed = attach_session_score_breakdown(result, merged) + assert completed.score_breakdown["theory"]["score"] == 4 diff --git a/tests/test_session_phases.py b/tests/test_session_phases.py new file mode 100644 index 0000000..c68220e --- /dev/null +++ b/tests/test_session_phases.py @@ -0,0 +1,175 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for multi-section session phase transitions.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, patch + +from app.coding.domain.value_objects import CodingRunResult +from app.coding.repositories.uow import CodingUnitOfWork +from app.coding.services.page import CodingPageService +from app.coding.services.section import CodingSectionService +from app.interview.domain.value_objects import ( + SectionBranchSpec, + SessionSelection, + TrackSelection, +) +from app.interview.services.creation import SessionCreationService +from app.interview.services.phases import SessionPhaseOrchestrator +from app.theory.repositories.uow import TheoryUnitOfWork +from app.theory.services.section import TheorySectionService + + +def _theory_then_coding_session() -> SessionSelection: + """Build a minimal theory-then-coding session selection for tests.""" + return SessionSelection( + session_mode="theory_then_coding", + 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",), + ), + ), + ), + ) + + +def _complete_theory_section(interview_id: str) -> None: + """Mark every theory task in a section as answered.""" + with TheoryUnitOfWork(auto_commit=True) as uow: + section = uow.theory_sections.get_aggregate(interview_id) + assert section is not None + tasks = tuple( + replace(task, answer_text="done", score=5) for task in section.tasks + ) + uow.theory_sections.save_aggregate(replace(section, tasks=tasks)) + + +def test_activate_pending_promotes_coding_after_theory_complete( + isolated_db, temp_questions_dir, monkeypatch +) -> None: + """Pending coding sections become active once theory is finished.""" + 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 CodingSectionService.activate_pending(interview.id) is False + + _complete_theory_section(interview.id) + assert TheorySectionService.is_complete(interview.id) is True + + assert CodingSectionService.activate_pending(interview.id) is True + + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview.id) + assert section is not None + assert section.status == "active" + assert section.tasks[0].started_at is not None + + +def test_notify_theory_complete_activates_pending_coding( + isolated_db, temp_questions_dir, monkeypatch +) -> None: + """Theory phase completion hook activates the next coding section.""" + del temp_questions_dir + monkeypatch.setattr("random.shuffle", lambda items: None) + + interview = SessionCreationService.create_session( + _theory_then_coding_session(), + locale="en", + ) + _complete_theory_section(interview.id) + + with patch.object(TheorySectionService, "on_phase_complete"): + SessionPhaseOrchestrator.notify_section_complete(interview.id, "theory") + + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview.id) + assert section is not None + assert section.status == "active" + + +def test_coding_run_works_after_theory_phase_activation( + client, isolated_db, temp_questions_dir, monkeypatch +) -> None: + """Run API accepts requests once the coding section is activated.""" + del temp_questions_dir + monkeypatch.setattr("random.shuffle", lambda items: None) + + interview = SessionCreationService.create_session( + _theory_then_coding_session(), + locale="en", + ) + _complete_theory_section(interview.id) + CodingPageService.activate_timer(interview.id) + + with CodingUnitOfWork() as uow: + section = uow.coding_sections.get_aggregate(interview.id) + assert section is not None + task_id = section.tasks[0].task_id + + run_result = CodingRunResult( + status="success", + stdout=None, + stderr=None, + compile_output=None, + tests_passed=0, + tests_total=0, + test_results=(), + duration_ms=5, + ) + with patch( + "app.coding.services.run_execution.CodingRunnerService.run_public_tests", + new=AsyncMock(return_value=run_result), + ): + response = client.post( + f"/interview/{interview.id}/coding/run", + json={"task_id": task_id, "source_code": "def solve():\n return 1"}, + ) + + assert response.status_code == 200 + assert response.json()["attempt_no"] == 1 + + +def test_interview_page_switches_to_coding_template_after_theory( + client, isolated_db, temp_questions_dir, monkeypatch +) -> None: + """Completed theory phase serves the coding interview page on reload.""" + del temp_questions_dir + monkeypatch.setattr("random.shuffle", lambda items: None) + + interview = SessionCreationService.create_session( + _theory_then_coding_session(), + locale="en", + ) + _complete_theory_section(interview.id) + + response = client.get(f"/interview/{interview.id}") + assert response.status_code == 200 + assert 'id="coding-panel"' in response.text + assert "coding-session" in response.text diff --git a/tests/test_session_results.py b/tests/test_session_results.py new file mode 100644 index 0000000..171122d --- /dev/null +++ b/tests/test_session_results.py @@ -0,0 +1,241 @@ +# 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_setup_api.py b/tests/test_setup_api.py index 9363209..912b57f 100644 --- a/tests/test_setup_api.py +++ b/tests/test_setup_api.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 """Tests for setup API cascaded options.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from app.platform.services.config import AppConfig @@ -69,6 +69,30 @@ def test_unknown_track_returns_404(self, client): assert response.status_code == 404 +class TestSetupCodingOptions: + """Tests for GET /setup/coding-options and /setup/coding-available.""" + + def test_lists_coding_tracks(self, client): + """Coding options without params returns available coding tracks.""" + with patch( + "app.interview.api.setup.coding_bank.list_tracks", + return_value=["python"], + ): + response = client.get("/setup/coding-options") + assert response.status_code == 200 + assert response.json() == {"tracks": ["python"]} + + def test_coding_available_endpoint(self, client): + """Coding availability endpoint reflects service flag.""" + with patch( + "app.interview.api.setup.is_coding_available_async", + new=AsyncMock(return_value=True), + ): + response = client.get("/setup/coding-available") + assert response.status_code == 200 + assert response.json() == {"available": True} + + class TestSetupConfigRedirect: """Setup requires provider configuration before starting an interview.""" @@ -141,8 +165,9 @@ def test_setup_post_passes_timer_limit_when_enabled(self, client): ) captured: dict[str, object] = {} - def fake_create(**kwargs: object) -> MagicMock: - captured.update(kwargs) + def fake_create(session, locale: str = "en") -> MagicMock: + captured["session"] = session + captured["locale"] = locale interview = MagicMock() interview.id = "timer-setup-id" return interview @@ -153,7 +178,7 @@ def fake_create(**kwargs: object) -> MagicMock: return_value=mock_config, ), patch( - "app.interview.services.creation.InterviewCreationService.create_interview", + "app.interview.services.creation.SessionCreationService.create_session", side_effect=fake_create, ), ): @@ -173,8 +198,9 @@ def fake_create(**kwargs: object) -> MagicMock: assert response.status_code == 303 assert response.headers["location"] == "/interview/timer-setup-id" - assert captured.get("question_time_limit_seconds") == 240 - assert "selection" in captured + session = captured.get("session") + assert session is not None + assert session.theory.task_time_limit_seconds == 240 def test_setup_post_rejects_question_count_below_topics(self, client): """POST /setup rejects when question count is below selected topic count.""" diff --git a/tests/test_theory_api.py b/tests/test_theory_api.py new file mode 100644 index 0000000..dcffa8c --- /dev/null +++ b/tests/test_theory_api.py @@ -0,0 +1,39 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for canonical theory HTTP and WebSocket routes.""" + +from unittest.mock import patch + +from tests.helpers.interview_seed import seed_two_question_interview + + +class TestTheoryCanonicalRoutes: + """Theory section transport under /interview/{id}/theory/.""" + + def test_theory_websocket_answer_success( + self, client, isolated_db, override_ws_ai_provider + ): + """Canonical theory WebSocket path accepts answers.""" + interview_id = seed_two_question_interview("theory-ws-1") + override_ws_ai_provider(client, []) + with patch( + "app.theory.services.submission.TheorySubmissionService.stream_answer_submission", + ) as mock_stream: + from app.interview.services.events import AnswerSavedEvent, EvaluatingEvent + + async def _events(*_args, **_kwargs): + yield AnswerSavedEvent() + yield EvaluatingEvent() + + mock_stream.side_effect = _events + + with client.websocket_connect(f"/interview/{interview_id}/theory/ws") as ws: + ws.send_json( + { + "type": "answer", + "question_id": "q1", + "answer_text": "My answer.", + } + ) + assert ws.receive_json() == {"type": "saved"} + assert ws.receive_json() == {"type": "evaluating"} diff --git a/tests/test_interview_evaluator.py b/tests/test_theory_evaluator_parsing.py similarity index 84% rename from tests/test_interview_evaluator.py rename to tests/test_theory_evaluator_parsing.py index 3b3969f..1cb7788 100644 --- a/tests/test_interview_evaluator.py +++ b/tests/test_theory_evaluator_parsing.py @@ -1,18 +1,16 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Tests for interview evaluator JSON parsing helpers.""" +"""Tests for theory evaluator JSON parsing helpers.""" import json import pytest -from app.interview.services.evaluator.prompts import ( +from app.theory.services.evaluator.models import InterviewEvaluation +from app.theory.services.evaluator.prompts import ( looks_like_json_schema_fragment, parse_json_response, ) -from app.interview.services.evaluator.service import ( - InterviewEvaluation, -) @pytest.mark.parametrize( @@ -52,6 +50,12 @@ def test_looks_like_json_schema_fragment_allows_data(payload: dict) -> None: assert looks_like_json_schema_fragment(payload) is False +def test_parse_json_response_rejects_empty_content() -> None: + """Parsing raises a clear error when the model returns an empty body.""" + with pytest.raises(ValueError, match="invalid JSON"): + parse_json_response("", InterviewEvaluation) + + def test_parse_json_response_rejects_schema_fragment() -> None: """Parsing raises a clear error when the model returns schema metadata.""" content = json.dumps( diff --git a/tests/test_theory_planning.py b/tests/test_theory_planning.py new file mode 100644 index 0000000..38c6d54 --- /dev/null +++ b/tests/test_theory_planning.py @@ -0,0 +1,71 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for theory question planning.""" + +from pathlib import Path + +import yaml + +from app.interview.domain.value_objects import InterviewSelection, TrackSelection +from app.theory.services.planning import build_theory_question_plan + + +def test_build_theory_question_plan_from_theory_bank(temp_questions_dir) -> None: + """Theory planning loads questions from the theory bank only.""" + del temp_questions_dir + selection = InterviewSelection( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("data-structures",), + ), + ) + ) + planned = build_theory_question_plan(selection, question_count=1, locale="en") + assert len(planned) == 1 + assert planned[0].id == "ds-001" + + +def test_build_theory_question_plan_skips_coding_type_rows( + temp_questions_dir: Path, +) -> None: + """Theory planning ignores legacy ``type: coding`` rows in the theory bank.""" + category_path = temp_questions_dir / "python" / "junior" / "mixed.yaml" + category_path.parent.mkdir(parents=True, exist_ok=True) + with open(category_path, "w") as f: + yaml.dump( + { + "category": "Mixed", + "track": "python", + "level": "junior", + "questions": [ + { + "id": "theory-001", + "type": "knowledge", + "difficulty": 1, + "question": {"text": "Theory question"}, + }, + { + "id": "coding-001", + "type": "coding", + "difficulty": 2, + "question": {"text": "Coding question"}, + }, + ], + }, + f, + ) + + selection = InterviewSelection( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("mixed",), + ), + ) + ) + planned = build_theory_question_plan(selection, question_count=1, locale="en") + assert len(planned) == 1 + assert planned[0].id == "theory-001" diff --git a/tests/test_theory_section.py b/tests/test_theory_section.py new file mode 100644 index 0000000..ee967d7 --- /dev/null +++ b/tests/test_theory_section.py @@ -0,0 +1,281 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for theory section domain, repository, and migration.""" + +from datetime import UTC, datetime, timedelta +import json +from pathlib import Path + +from alembic.config import Config +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +from alembic import command +from app.interview.domain.value_objects import InterviewSelection, TrackSelection +from app.shared.infrastructure.database import Base +from app.shared.infrastructure.models import Interview, TheorySection +from app.shared.paths import ALEMBIC_INI +from app.theory.domain.entities import TheorySection as DomainTheorySection +from app.theory.domain.entities import TheoryTask +from app.theory.domain.value_objects import PlannedTheoryQuestion +from app.theory.repositories.uow import TheoryUnitOfWork +from tests.helpers.legacy_interview import insert_pre_session_mode_interview + + +def _sample_selection() -> InterviewSelection: + return InterviewSelection( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ) + ) + + +def _sample_planned() -> tuple[PlannedTheoryQuestion, ...]: + return ( + PlannedTheoryQuestion( + id="py-001", + text="What is a list?", + code=None, + ), + PlannedTheoryQuestion( + id="py-002", + text="Explain dict.", + code="d = {}", + ), + ) + + +class TestTheoryTaskTimer: + """Theory task timer behavior mirrors legacy answer rounds.""" + + def test_timer_deadline_and_expiry(self) -> None: + """Timer helpers respect grace and remaining seconds.""" + started = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) + task = TheoryTask( + id=1, + theory_section_id=10, + interview_id="iv-1", + question_id="py-001", + order=1, + round=0, + question_text="Q", + question_code=None, + answer_text=None, + score=None, + feedback=None, + started_at=started, + created_at=started, + ) + deadline = task.timer_deadline(120) + assert deadline == started + timedelta(seconds=120) + assert not task.is_timer_expired(120, now=started + timedelta(seconds=119)) + assert task.is_timer_expired(120, now=started + timedelta(seconds=122)) + assert task.remaining_seconds(120, now=started + timedelta(seconds=30)) == 90 + + +class TestTheorySectionDomain: + """Theory section aggregate factory.""" + + def test_start_builds_tasks_with_timer_on_first(self) -> None: + """First task gets started_at when a time limit is configured.""" + section = DomainTheorySection.start( + "iv-1", + selection=_sample_selection(), + locale="en", + planned_questions=_sample_planned(), + task_time_limit_seconds=90, + ) + assert section.question_count == 2 + assert section.status == "active" + assert section.tasks[0].started_at is not None + assert section.tasks[1].started_at is None + + +class TestTheorySectionRepository: + """Theory section persistence.""" + + def test_create_aggregate_persists_tasks(self, isolated_db) -> None: + """Repository round-trips a theory section with linked answer rows.""" + with TheoryUnitOfWork() as uow: + uow.session.add( + Interview( + id="iv-theory", + selection_spec='{"sources":[{"track":"python","level":"junior","categories":["basics"]}]}', + ) + ) + uow.commit() + + section = DomainTheorySection.start( + "iv-theory", + selection=_sample_selection(), + locale="en", + planned_questions=_sample_planned(), + task_time_limit_seconds=120, + ) + with TheoryUnitOfWork() as uow: + created = uow.theory_sections.create_aggregate(section) + uow.commit() + + assert created.id > 0 + assert created.interview_id == "iv-theory" + assert created.task_time_limit_seconds == 120 + assert len(created.tasks) == 2 + assert created.tasks[0].id != TheoryTask.NEW_ID + assert created.question_ids == ("py-001", "py-002") + + with TheoryUnitOfWork() as uow: + loaded = uow.theory_sections.get_aggregate("iv-theory") + + assert loaded is not None + assert loaded.id == created.id + assert loaded.question_count == 2 + assert len(loaded.tasks) == 2 + assert loaded.tasks[0].theory_section_id == loaded.id + + +@pytest.fixture +def alembic_engine(tmp_path: Path, monkeypatch): + """SQLite file DB with Alembic migrations applied through revision 0003.""" + db_path = tmp_path / "grillkit.db" + db_url = f"sqlite:///{db_path}" + + import app.shared.infrastructure.database as database_module + + engine = create_engine(db_url, echo=False) + monkeypatch.setattr(database_module, "DATABASE_URL", db_url) + monkeypatch.setattr(database_module, "engine", engine) + monkeypatch.setattr( + database_module, + "SessionLocal", + sessionmaker(autocommit=False, autoflush=False, bind=engine), + ) + + alembic_cfg = Config(str(ALEMBIC_INI)) + command.upgrade(alembic_cfg, "20260526_0003") + + yield engine + + Base.metadata.drop_all(bind=engine) + engine.dispose() + + +class TestTheorySectionsAlembicMigration: + """Tests for theory_sections schema migration.""" + + def test_backfills_theory_section_per_interview(self, alembic_engine) -> None: + """Each interview row gets a matching theory_sections row at head.""" + session_factory = sessionmaker(bind=alembic_engine) + session = session_factory() + insert_pre_session_mode_interview( + session, + interview_id="legacy-interview", + selection_spec='{"sources":[{"track":"python","level":"junior","categories":["basics"]}]}', + question_count=4, + question_time_limit_seconds=60, + locale="ru", + ) + session.close() + + alembic_cfg = Config(str(ALEMBIC_INI)) + command.upgrade(alembic_cfg, "head") + + session = session_factory() + section = ( + session.query(TheorySection) + .filter_by(interview_id="legacy-interview") + .one() + ) + assert section.question_count == 4 + assert section.task_time_limit_seconds == 60 + assert section.locale == "ru" + assert section.status == "active" + data = json.loads(section.selection_spec) + assert data["sources"][0]["track"] == "python" + session.close() + + def test_backfill_is_idempotent(self, alembic_engine) -> None: + """Running head migration twice does not duplicate theory sections.""" + session_factory = sessionmaker(bind=alembic_engine) + session = session_factory() + insert_pre_session_mode_interview( + session, + interview_id="dup-interview", + selection_spec='{"sources":[{"track":"database","level":"junior","categories":["sql-basics"]}]}', + ) + session.close() + + alembic_cfg = Config(str(ALEMBIC_INI)) + command.upgrade(alembic_cfg, "head") + command.upgrade(alembic_cfg, "head") + + with alembic_engine.connect() as conn: + count = conn.execute( + text("SELECT COUNT(*) FROM theory_sections WHERE interview_id = :id"), + {"id": "dup-interview"}, + ).scalar_one() + assert count == 1 + + def test_backfills_theory_section_id_on_answers(self, alembic_engine) -> None: + """Answers migration links rows to their parent theory section.""" + session_factory = sessionmaker(bind=alembic_engine) + session = session_factory() + insert_pre_session_mode_interview( + session, + interview_id="answers-link", + selection_spec='{"sources":[{"track":"python","level":"junior","categories":["basics"]}]}', + question_count=1, + ) + session.close() + + alembic_cfg = Config(str(ALEMBIC_INI)) + command.upgrade(alembic_cfg, "20260608_0004") + + session = session_factory() + section_id = ( + session.query(TheorySection.id) + .filter_by(interview_id="answers-link") + .scalar() + ) + session.execute( + text( + """ + INSERT INTO answers ( + interview_id, question_id, "order", round, question_text, created_at + ) + VALUES ( + :interview_id, :question_id, :order, :round, :question_text, + CURRENT_TIMESTAMP + ) + """ + ), + { + "interview_id": "answers-link", + "question_id": "q1", + "order": 1, + "round": 0, + "question_text": "Question?", + }, + ) + session.commit() + session.close() + + command.upgrade(alembic_cfg, "20260608_0005") + + session = session_factory() + answer = session.execute( + text( + """ + SELECT theory_section_id + FROM answers + WHERE question_id = :question_id + """ + ), + {"question_id": "q1"}, + ).one() + assert answer.theory_section_id == section_id + session.close() diff --git a/tests/test_tts_api.py b/tests/test_tts_api.py index 8d4a0b9..1eaa37e 100644 --- a/tests/test_tts_api.py +++ b/tests/test_tts_api.py @@ -8,7 +8,7 @@ import pytest -from app.interview.services.creation import InterviewCreationService +from app.interview.services.creation import SessionCreationService from app.interview.services.query import InterviewQuery from app.platform.services.config import AppConfig from app.question_voice.schemas import PiperVoiceStatusRead @@ -204,22 +204,22 @@ def test_question_audio_requires_voice_enabled( question_voice_enabled=False, ) from app.interview.domain.value_objects import ( - InterviewSelection, + SessionSelection, TrackSelection, ) - interview = InterviewCreationService.create_interview( - selection=InterviewSelection( + interview = SessionCreationService.create_session( + SessionSelection.theory_only( sources=( TrackSelection( track="python", level="junior", categories=("data-structures",), ), - ) + ), + question_count=1, ), locale="en", - question_count=1, ) with patch( "app.platform.services.config.ConfigService.get_config", @@ -241,22 +241,22 @@ def test_question_audio_streams_cached_wav( del temp_questions_dir monkeypatch.setattr("random.shuffle", lambda items: None) from app.interview.domain.value_objects import ( - InterviewSelection, + SessionSelection, TrackSelection, ) - interview = InterviewCreationService.create_interview( - selection=InterviewSelection( + interview = SessionCreationService.create_session( + SessionSelection.theory_only( sources=( TrackSelection( track="python", level="junior", categories=("data-structures",), ), - ) + ), + question_count=1, ), locale="en", - question_count=1, ) reloaded = InterviewQuery.get_interview(interview.id) assert reloaded is not None diff --git a/tests/test_uow.py b/tests/test_uow.py index 1fe330d..1a26ec7 100644 --- a/tests/test_uow.py +++ b/tests/test_uow.py @@ -115,28 +115,30 @@ def test_interview_repository_accessor(self, patch_session_local): assert isinstance(uow.interviews, InterviewRepository) - def test_nested_answers_persist_with_interview(self, patch_session_local): - """Answer rows added via ``Interview.answers`` are loaded with the session.""" + def test_nested_tasks_persist_with_theory_section(self, patch_session_local): + """Theory task rows linked via theory section are loaded with the session.""" with InterviewUnitOfWork(auto_commit=True) as uow: interview = Interview( id="uow-test-6", selection_spec=minimal_selection_spec() ) - interview.answers = [ - Answer( - interview_id="uow-test-6", - question_id="q1", - order=1, - round=0, - question_text="What?", - ) - ] uow.interviews.add(interview) + uow.flush() + from tests.helpers.theory_seed import attach_theory_section_to_answers + + answer = Answer( + question_id="q1", + order=1, + round=0, + question_text="What?", + ) + attach_theory_section_to_answers(uow.session, interview, [answer]) with InterviewUnitOfWork() as uow: loaded_session = uow.interviews.get("uow-test-6") assert loaded_session is not None - assert len(loaded_session.answers) == 1 - assert loaded_session.answers[0].question_id == "q1" + assert loaded_session.theory_section is not None + assert len(loaded_session.theory_section.tasks) == 1 + assert loaded_session.theory_section.tasks[0].question_id == "q1" def test_flush(self, patch_session_local): """Test flush() sends changes to DB without committing.""" diff --git a/tests/test_ws_protocol.py b/tests/test_ws_protocol.py index ff3e952..aa2ae03 100644 --- a/tests/test_ws_protocol.py +++ b/tests/test_ws_protocol.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 """Tests for WebSocket protocol mapping.""" -from app.interview.api.ws_protocol import event_to_message, events_to_messages from app.interview.services.events import ( AnswerFeedbackEvent, AnswerSavedEvent, @@ -10,6 +9,7 @@ InterviewCompletedEvent, TranscriptEvent, ) +from app.theory.api.ws_protocol import event_to_message, events_to_messages def test_event_to_message_saved():