diff --git a/.gitignore b/.gitignore index c334e92..a452966 100644 --- a/.gitignore +++ b/.gitignore @@ -49,7 +49,7 @@ env/ *.sqlite *.sqlite3 data/config.json -data/llm_models.json +llm_models.json data/whisper-models/* !data/whisper-models/.gitkeep data/piper-voices/* diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3f17f9c..55f8943 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,7 +6,7 @@ GrillKit is an AI-powered technical interview trainer. The stack is **FastAPI** **Session orchestration** lives in `interview/`: setup, dashboard, session shell (`Interview`), page composition, phase order, completion, results hub, and `selection_spec` v2 (`session_mode`). **Theory flow** lives in `theory/`: questions, tasks, timer, WebSocket/audio submit, AI evaluation, and post-session review. **Coding flow** lives in `coding/`: YAML task banks, Monaco UI, Judge0 Run attempts, WebSocket submit, AI evaluation, and post-session review. The interview shell does not own section tasks; `InterviewRead` composes theory task rows at read time via `theory_sections` + `answers`, and coding context from `coding_sections` + `coding_tasks`. -Within each feature: transport in `api/`, orchestration in `services/`, Pydantic read models in `schemas/` (where present), persistence in `repositories/`. Domain layers use frozen aggregates and value objects separate from ORM and DTOs. Transactions use `InterviewUnitOfWork` / `TheoryUnitOfWork` extending `shared/infrastructure/uow.py`. APIs do not expose SQLAlchemy models on the wire. +Transactions use a single app-wide `InterviewUnitOfWork` (interviews, theory/coding sections, code run attempts) extending `shared/infrastructure/uow.py`. Workflow services receive the UoW in their constructor; HTTP/WebSocket handlers create the UoW scope via FastAPI `Depends`. APIs do not expose SQLAlchemy models on the wire. ## Terminology @@ -34,7 +34,7 @@ grillkit/ │ │ ├── task_timer.py # Per-round timer helpers │ │ ├── infrastructure/ │ │ │ ├── database.py # engine, SessionLocal, DATABASE_URL env, run_migrations() -│ │ │ ├── models.py # Interview, TheorySection, Answer, CodingSection, CodingTask, CodeRunAttempt +│ │ │ ├── models.py # Interview, TheorySection, Answer, CodingSection, CodingTask, CodeRunAttempt, KnownQuestion │ │ │ ├── audio_wav.py # Canonical mono 16 kHz WAV validation │ │ │ ├── hf_hub_runtime.py, hf_download_progress.py, artifact_* │ │ │ └── uow.py # Base UnitOfWork: session, commit, rollback @@ -45,11 +45,11 @@ grillkit/ │ │ ├── speech_transcriber.py # SpeechTranscriber protocol (offline dictation) │ │ ├── audio_probe.py # Minimal WAV bytes for connectivity / audio tests │ │ ├── factory.py # ProviderFactory.from_config() -│ │ ├── llm_models.py # Catalog entry types (incl. accepts_audio_input) +│ │ ├── llm_models.py # Catalog types; slugify_model_id(), generate_model_id() │ │ ├── openai_compatible.py │ │ └── faster_whisper_transcriber.py │ ├── platform/ -│ │ ├── schemas.py # Config page read models, NewLLMModel, mappers +│ │ ├── schemas.py # Config page read models, NewLLMModel (display_name only), mappers │ │ ├── api/ │ │ │ ├── config.py # GET/POST /config │ │ │ └── deps.py @@ -61,12 +61,13 @@ grillkit/ │ │ └── ai_context.py # ai_provider_from_config() async context manager │ ├── 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 +│ │ ├── schemas/ # InterviewRead, dashboard/page, known_questions +│ │ ├── services/rules/ # selection_spec v2, display titles, bank_selection │ │ ├── repositories/ │ │ │ ├── interview.py # shell get/save, list_recent read models +│ │ │ ├── known_questions.py │ │ │ ├── mappers.py # ORM ↔ shell ↔ InterviewRead (+ theory compose) -│ │ │ └── uow.py # InterviewUnitOfWork (interviews + theory_sections) +│ │ │ └── uow.py # InterviewUnitOfWork (app-wide repositories) │ │ ├── services/ │ │ │ ├── creation.py # SessionCreationService │ │ │ ├── page.py # SessionPageService @@ -74,6 +75,7 @@ grillkit/ │ │ │ ├── dashboard.py │ │ │ ├── query.py │ │ │ ├── phases.py # multi-section phase order + prefetch hooks +│ │ │ ├── known_questions.py, bank_text.py │ │ │ ├── sections.py # Section registry and shared section DTOs │ │ │ ├── evaluation_aggregator.py │ │ │ ├── session_evaluator.py @@ -87,10 +89,11 @@ grillkit/ │ │ ├── setup_form.py │ │ ├── routes.py # GET /interview/{id}, question-audio │ │ ├── results.py # GET /results, /theory, /coding (completed sessions) +│ │ ├── known_questions.py # GET/POST/DELETE /known-questions, GET /manage │ │ └── errors.py │ ├── coding/ # Coding section (tasks, Judge0 runner, WS/API, evaluator) │ │ ├── domain/ # CodingSection, CodingTask, CodeRunAttempt aggregates -│ │ ├── repositories/ # coding_section repo, mappers, CodingUnitOfWork +│ │ ├── repositories/ # coding_section repo, mappers │ │ ├── services/ │ │ │ ├── planning.py # YAML task plan from data/coding/ │ │ │ ├── creation.py # CodingSectionCreationService @@ -107,7 +110,7 @@ grillkit/ │ ├── theory/ # Theory section (tasks, timer, WS, evaluator) │ │ ├── domain/ # TheorySection, TheoryTask aggregates │ │ ├── schemas/ # TheoryTaskRead, TheoryPageContext, WS messages -│ │ ├── repositories/ # theory_section repo, mappers, TheoryUnitOfWork +│ │ ├── repositories/ # theory_section repo, mappers │ │ ├── services/ │ │ │ ├── planning.py # YAML question plan (excludes type=coding) │ │ │ ├── creation.py # TheorySectionCreationService @@ -169,6 +172,10 @@ grillkit/ | GET | `/setup/options` | `interview/api/setup.py` | Cascaded JSON: theory tracks → levels → categories | | GET | `/setup/coding-options` | `interview/api/setup.py` | Cascaded JSON: coding tracks → levels → categories | | GET | `/setup/coding-available` | `interview/api/setup.py` | JSON: whether coding modes are offered (Judge0 health) | +| GET | `/known-questions` | `interview/api/known_questions.py` | JSON: known bank item IDs grouped by `theory` / `coding` | +| POST | `/known-questions` | `interview/api/known_questions.py` | Mark a bank item as known (`{branch, item_id}`) | +| DELETE | `/known-questions` | `interview/api/known_questions.py` | Unmark a known bank item | +| GET | `/known-questions/manage` | `interview/api/known_questions.py` | HTML manage page (resolved question text) | | GET | `/config` | `platform/api/config.py` | AI provider configuration form | | POST | `/config` | `platform/api/config.py` | Test connection (via form dependency), then save | | POST | `/config/test` | `platform/api/config.py` | Test connection without saving | @@ -197,7 +204,7 @@ grillkit/ | Package / layer | Responsibility | |-----------------|----------------| | `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) | +| `*/api/deps.py` | Inject request-scoped services and `InterviewUnitOfWork` via FastAPI `Depends` | | `interview/domain/` | Interview session shell aggregate, `SessionSelection`, serialization, domain exceptions | | `theory/domain/` | `TheorySection` / `TheoryTask` aggregates and theory-specific exceptions | | `coding/domain/` | `CodingSection` / `CodingTask` / `CodeRunAttempt` aggregates and coding exceptions | @@ -214,16 +221,14 @@ 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`, `uow.theory_sections` | -| `theory/repositories/uow.py` | `TheoryUnitOfWork`: theory section persistence | -| `coding/repositories/uow.py` | `CodingUnitOfWork`: coding section + run attempts | +| `interview/repositories/uow.py` | `InterviewUnitOfWork`: interviews, theory/coding sections, code run attempts | | `interview/services/results_page.py` | Completed session hub context (`SessionResultsPageService`) | | `theory/services/review.py`, `coding/services/review.py` | Post-session section review page builders | | `shared/infrastructure/models.py` | ORM models | | `ai/` | Provider adapters (`AIProvider`, `SpeechTranscriber`) | | `shared/questions.py` | Read-only YAML question bank access | -Application services are **stateless classes with `@staticmethod`**. FastAPI dependencies in each feature's `deps.py` return the class (e.g. `InterviewQuery`), not instances. +Workflow services (submit, create, complete, section creation, evaluation persistence, navigation) are **instance classes** constructed with `InterviewUnitOfWork`. FastAPI dependencies in `interview/api/deps.py` and `shared/application/uow_deps.py` yield scoped UoW instances. Read-only helpers may remain static until migrated. ## Module Dependency Graph @@ -396,10 +401,10 @@ flowchart TB | Theory task read DTO | `app.theory.schemas.theory.TheoryTaskRead` | | Route / WS path param | `interview_id` (same value as `Interview.id`) | | Create flow | `SessionCreationService.create_session()` + section creation services when enabled | -| Read flow | `InterviewQuery.get_interview()`, `DashboardBuilder.list_rows()` | +| Read flow | `InterviewQuery.load()` / `InterviewQuery(uow).get_interview()`, `DashboardBuilder.list_rows()` | | Complete flow | `SessionCompletionService.complete_session()` | | Results hub | `SessionResultsPageService.prepare_page()` | -| UoW repositories | `uow.interviews`, `uow.theory_sections`, `uow.coding_sections` (per feature UoW) | +| UoW repositories | `uow.interviews`, `uow.theory_sections`, `uow.coding_sections`, `uow.code_run_attempts`, `uow.known_questions` (single `InterviewUnitOfWork`) | | Theory submit | `TheorySubmissionService` (WS + audio + timeouts) | | Coding submit | `CodingSubmissionService` (WS submit after Run history) | | SQLAlchemy session | `uow.session` | @@ -412,7 +417,7 @@ flowchart TB |-------|------|-------| | `id` | `str` | UUID v4 primary key | | `locale` | `str` | AI feedback language (`en`, `ru`, …) | -| `selection_spec` | `str` | JSON v2: `session_mode`, `theory` / `coding` branches | +| `selection_spec` | `str` | JSON v2: `session_mode`, `exclude_known`, `theory` / `coding` branches | | `session_mode` | `str` | `theory_only`, `coding_only`, `theory_then_coding`, `coding_then_theory` | | `status` | `str` | `active` or `completed` | | `overall_feedback` | `str \| None` | JSON final evaluation with `score_breakdown.{theory,coding}` | @@ -476,6 +481,16 @@ Initial task rows are created with the theory section; follow-ups append via `Th `CodeRunAttempt` rows store each **Run** snapshot (code, stderr, public test results) for AI context on submit. +### KnownQuestion (`known_questions`) + +| Field | Type | Notes | +|-------|------|-------| +| `branch` | `str` | `theory` or `coding` (composite PK with `bank_item_id`) | +| `bank_item_id` | `str` | ID from the YAML bank for that branch | +| `created_at` | `datetime` | When the item was marked as known | + +Instance-wide list (no user accounts). When setup sends `exclude_known: true`, `SessionCreationService` loads IDs per branch and `plan_questions(..., excluded_ids=...)` removes them from pools before selection. Mark/unmark via `POST`/`DELETE /known-questions` with `{branch, item_id}`, **I know this** buttons during active interviews, or `/known-questions/manage`. Display text for the manage page is resolved from YAML banks via `interview/services/bank_text.py` (full-bank `id → text` indexes cached per process with `@lru_cache`). + ## Data Flow: Configure Provider ``` @@ -484,8 +499,9 @@ User → POST /config/test → test selected catalog model (no save) User → POST /config → merge form into config.json + catalog selection → ConfigService.test_connection(resolve_effective_config()) → AI provider ping → on success: save config.json and llm_models.json -User → POST /config/llm-models (add catalog entry, optional accepts_audio_input) - → LLMCatalogService → data/llm_models.json +User → POST /config/llm-models (display_name, base_url, model, optional api_key, accepts_audio_input) + → NewLLMModel validated (no manual model id — slug generated by generate_model_id()) + → LLMCatalogService.add_user_model() → data/llm_models.json (sets selected id) → when accepts_audio_input: test text + audio capability + Whisper readiness ``` @@ -512,7 +528,10 @@ User → POST /setup (selection_json v2: session_mode, theory/coding branches, c ``` Client → WS /interview/{id}/theory/ws {"type":"answer",...} → TheorySubmissionService (timer, navigation, TheoryEvaluatorService) - → On section complete: SessionPhaseOrchestrator.notify_section_complete → prefetch + → Commits the saved answer row before long-running AI evaluation (releases SQLite write lock) + → On section complete: SessionPhaseOrchestrator(uow).notify_section_complete + → on_phase_complete (may schedule background section-feedback prefetch) + → activate_if_pending("coding") on the same UoW (no second SQLite connection) → Session complete: SessionCompletionService via WS "complete" message Client → WS {"type":"timeout",...} → TheorySubmissionService timeout path (score 0) @@ -561,7 +580,7 @@ Separate from answer/evaluation WS. Requires active interview and loaded transcr ``` Client → WS connect /interview/{id}/dictation - → InterviewQuery.get_interview() + require_active() + → InterviewQuery.load() + require_active() → reject if model missing (download via /config → /speech/model/download) Client → {"type":"start"} @@ -758,6 +777,7 @@ Follow-up rounds use the same pipeline (cache key from localized `question_text` |---------|----------| | Catalog file | `data/llm_models.json` (gitignored) — models added via **Add model to catalog** on `/config` (`POST /config/llm-models`) | | Loader | `app/platform/services/llm_catalog.py` | +| Model id | Auto-generated slug from **display name** (`slugify_model_id` + `generate_model_id` in `app/ai/llm_models.py`); collisions get `-2`, `-3`, … suffixes | | Selection | `selected` id in catalog JSON; `llm_preset_id` on resolved `AppConfig` | | Audio flag | `accepts_audio_input` on `LLMModelEntry` — enables interview audio-answer UI and config audio probe | | Effective config | `ConfigService.resolve_effective_config()` applies catalog `base_url`, `model`, and `api_key` | @@ -769,7 +789,7 @@ Pytest discovers modules under `tests/` (`pyproject.toml` → `testpaths = ["tes | `app/` package | `tests/` mirror | Typical modules | |----------------|-----------------|-----------------| | `ai/` | `tests/ai/` | `test_base.py`, `test_factory.py`, `test_openai_compatible.py` | -| `interview/` | `tests/interview/{api,repositories,services}/` | `test_creation.py`, `test_phases.py`, `test_results.py` | +| `interview/` | `tests/interview/{api,repositories,services}/` | `test_creation.py`, `test_phases.py`, `test_known_questions.py`, `test_results.py` | | `theory/` | `tests/theory/{api,services,repositories,integration}/` | `test_submission.py`, `test_ws_routes.py`, `test_review.py` | | `coding/` | `tests/coding/{api,services,repositories}/` | `test_runner.py`, `test_evaluator.py`, `test_review.py` | | `speech/`, `question_voice/` | `tests/speech/`, `tests/question_voice/` | API + service tests | @@ -804,3 +824,21 @@ uv run pytest tests/theory/services/test_submission.py # single module - Question bank localization is partial: many YAML entries still fall back to `en` for non-English locales - Question TTS: Piper voice must be downloaded on `/config` before synthesis; first load is per process (not shared across multiple uvicorn workers) - Piper synthesis uses CPU ONNX; plan extra RAM on the host when question voice is enabled + +## SQLite concurrency + +File-backed SQLite databases enable **WAL journal mode** and a **busy timeout** at connection time (`shared/infrastructure/database.py`). Long-running work must not hold a write transaction open across unrelated writers. + +| Pattern | Where | Why | +|---------|-------|-----| +| Commit answer before AI eval | `TheorySubmissionService._release_submission_write_lock()` | Theory submit persists the task row, then calls the LLM on an uncommitted-free connection | +| Phase transition on caller UoW | `SessionPhaseOrchestrator(uow).notify_section_complete(…)` | Activating a pending coding section during theory navigation uses the orchestrator's unit of work — avoids `database is locked` from a nested `InterviewUnitOfWork` | +| Background prefetch | `SectionFeedbackPrefetch._run_in_background` | Section narrative feedback opens a dedicated UoW after the foreground workflow commits | +| Audio last-round eval | `_evaluate_last_follow_up_in_background` | Score-only persistence for the final follow-up runs in `InterviewUnitOfWork(auto_commit=True)` after navigation events are sent | + +## Performance notes + +| Area | Approach | +|------|----------| +| Known questions manage page | `bank_text._theory_text_index()` and `_coding_text_index()` cache full YAML bank `id → text` maps per process (`@lru_cache`) — banks ship with the image and do not change at runtime | +| Dashboard history | Batched section lookups when loading recent sessions | diff --git a/CHANGELOG.md b/CHANGELOG.md index 747577c..59fa1d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,29 @@ Work in progress is accumulated under `[Unreleased]`; on release, that section b ### Removed +## 2026.6.16 + +### Added + +- **Known questions** — mark theory or coding bank items as known during an interview (**I know this**) or on review pages; optionally exclude them when starting a new session; manage the list from **Known Questions** in the navigation bar + +### Changed + +- **Add model to catalog** — the catalog model id is generated automatically from the display name; removed the **Model id** field on `/config` +- **UI** — refreshed dark theme with clearer hierarchy, IDE-style coding editor, terminal-style run output, and updated status badges on the dashboard + +### Fixed + +- **Theory then coding sessions** — fixed errors when advancing from theory to coding in a combined session +- **Coding follow-ups** — explanation rounds now submit your typed explanation instead of the code in the editor +- **Coding timers** — expired rounds score 0 and the session advances automatically +- **Setup review** — known-questions option shows the correct hint for the checkbox +- **Early session end** — partial theory/coding scores are kept when you end a session before finishing every task +- **Theory answers** — more reliable submit flow for text and audio answers during AI evaluation +- **Dashboard** — faster interview history on the home page + +### Removed + ## 2026.6.12 ### Added diff --git a/README.md b/README.md index ed0f2f3..f8879cd 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A general chat assistant is flexible, but it does not run an **interview** for y | Interview flow | Free-form thread | Fixed session: theory Q&A and/or coding tasks, up to **2 AI follow-ups** per item, **1–5 scoring**, session summary | | Live coding practice | Paste code in chat | **Monaco editor**, **Run** against public tests, **Submit** for hidden tests + AI review (needs Judge0) | | Practice history | Scattered chats | **Dashboard** with past sessions; open **results** and per-section **review** pages after completion | +| Skip what you already know | You repeat the same prompts | **Known questions** — mark bank items during practice; optionally exclude them when starting a new session | | Time pressure | None | Optional **per-round timer** on theory and coding (expired round → 0, move on) | | Voice practice | Depends on product | Offline **Whisper** dictation; optional **Piper** question audio; **audio answers** when your model supports it | | Where data lives | Vendor cloud | **Self-hosted**: SQLite + `data/` on your machine; use **Ollama**, vLLM, or any OpenAI-compatible API | @@ -31,7 +32,10 @@ A general chat assistant is flexible, but it does not run an **interview** for y **Demo video** — full flow from setup to scored feedback

- Demo video +

**Dashboard** — recent sessions and quick start @@ -82,6 +86,7 @@ Coding modes need a running [Judge0](https://github.com/judge0/judge0) instance - **Voice** — offline Whisper dictation; optional Piper TTS to read theory questions aloud - **Audio answers** — record a WAV theory answer when your model supports audio input and Whisper is ready - **Results hub** — after you finish, `/interview/{id}/results` shows overall evaluation and links to **theory** and **coding** review pages with full chat/code history +- **Known questions** — mark theory or coding bank items as **I know this** during an interview or on review pages; optionally exclude them on **New interview** setup; manage the list at `/known-questions/manage` - **Dashboard** — recent sessions on the home page (completed sessions link to results) - **Setup** — model catalog on `/config`, interview locale, Whisper/Piper downloads from the UI - **Deployment** — Docker Compose on port 8000 with `./data` volume for config, DB, and models @@ -131,7 +136,7 @@ On some Linux hosts Judge0 needs **cgroup v1** (`systemd.unified_cgroup_hierarch ### First-time flow 1. **Configuration** (`/config`) — add one or more OpenAI-compatible models to the catalog, select an interview model, set interview locale; test connection, then save. Download Whisper (and optionally a Piper voice) from the same page if you want voice features. -2. **New interview** (`/setup`) — pick a **session mode** (theory only, coding only, or combined). Choose tracks, levels, topics, how many questions/tasks, and optional per-round timers. Coding modes require Judge0 (see **Coding sessions** above). +2. **New interview** (`/setup`) — pick a **session mode** (theory only, coding only, or combined). Choose tracks, levels, topics, how many questions/tasks, optional per-round timers, and whether to **exclude known questions**. Coding modes require Judge0 (see **Coding sessions** above). 3. **Practice** (`/interview/{id}`) — answer theory questions in the chat (type, dictate, or record audio). On coding phases, use the editor: **Run** to check public tests, **Submit** when ready. Combined sessions switch panels automatically when a section ends (or use **Continue to Coding**). End the interview from the sidebar at any time. 4. **Review** (`/interview/{id}/results`) — after completion, read the overall evaluation, then open **Theory** or **Coding** review for full conversation history, scores, and feedback. @@ -160,7 +165,7 @@ Any **OpenAI-compatible** HTTP API works: On `/config`: -- **Add model to catalog** — base URL, model name, optional API key; enable **Accepts audio input** only if the model supports multimodal audio (and download Whisper for transcription). +- **Add model to catalog** — display name, base URL, model name, optional API key (a stable catalog id is generated automatically from the display name); enable **Accepts audio input** only if the model supports multimodal audio (and download Whisper for transcription). - **Interview model** — pick from the catalog, **Test Connection**, save. - **Locale** — language for AI feedback and speech (stored in `data/config.json`, gitignored). - **Whisper** — choose size (`small`, `medium`, `large`), download from the UI for dictation and audio answers. diff --git a/alembic/versions/20260612_0010_answers_expected_points.py b/alembic/versions/20260612_0010_answers_expected_points.py new file mode 100644 index 0000000..0c178a2 --- /dev/null +++ b/alembic/versions/20260612_0010_answers_expected_points.py @@ -0,0 +1,26 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Add expected_points rubric snapshot column to answers.""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260612_0010" +down_revision: str | None = "20260610_0009" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Store rubric bullets on each theory answer row.""" + with op.batch_alter_table("answers") as batch_op: + batch_op.add_column(sa.Column("expected_points", sa.Text(), nullable=True)) + + +def downgrade() -> None: + """Remove expected_points from answers.""" + with op.batch_alter_table("answers") as batch_op: + batch_op.drop_column("expected_points") diff --git a/alembic/versions/20260615_0011_known_questions.py b/alembic/versions/20260615_0011_known_questions.py new file mode 100644 index 0000000..0eacddf --- /dev/null +++ b/alembic/versions/20260615_0011_known_questions.py @@ -0,0 +1,35 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Add known_questions table for excluding marked questions from planning.""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "20260615_0011" +down_revision: str | None = "20260612_0010" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create known_questions table with composite primary key.""" + op.create_table( + "known_questions", + sa.Column("branch", sa.Text(), nullable=False), + sa.Column("bank_item_id", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.PrimaryKeyConstraint("branch", "bank_item_id"), + ) + + +def downgrade() -> None: + """Drop known_questions table.""" + op.drop_table("known_questions") diff --git a/app/ai/llm_models.py b/app/ai/llm_models.py index 15e2631..c180896 100644 --- a/app/ai/llm_models.py +++ b/app/ai/llm_models.py @@ -2,14 +2,13 @@ # SPDX-License-Identifier: Apache-2.0 """LLM model catalog types.""" +from collections.abc import Collection from dataclasses import dataclass import re from typing import Final CUSTOM_PRESET_ID: Final[str] = "custom" -MODEL_ID_PATTERN: Final[re.Pattern[str]] = re.compile( - r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" -) +_FALLBACK_MODEL_ID: Final[str] = "model" @dataclass(frozen=True) @@ -71,25 +70,39 @@ def normalize_model_id(model_id: str, catalog: LLMCatalog) -> str: return value -def validate_new_model_id(model_id: str) -> str: - """Normalize and validate a user-supplied catalog model id. +def slugify_model_id(text: str) -> str: + """Convert arbitrary text into a catalog-safe id slug. + + Lowercases the text and replaces every run of non-alphanumeric characters + with a single hyphen, trimming hyphens from both ends. Args: - model_id: Proposed model id from the add-model form. + text: Source text, typically a model display name. Returns: - Normalized lowercase id. + A slug using lowercase letters, digits, and hyphens, or an empty + string when no usable characters remain. + """ + return re.sub(r"[^a-z0-9]+", "-", text.strip().lower()).strip("-") - Raises: - ValueError: If the id is invalid or reserved. + +def generate_model_id(display_name: str, existing_ids: Collection[str]) -> str: + """Derive a unique catalog id from a model display name. + + Args: + display_name: Human-readable model name from the add-model form. + existing_ids: Catalog ids already in use. + + Returns: + A unique slug; falls back to ``model`` when the name has no usable + characters and appends a numeric suffix to avoid collisions. """ - value = model_id.strip().lower() - if not value: - raise ValueError("Model id is required") - if value == CUSTOM_PRESET_ID: - raise ValueError(f"Model id '{CUSTOM_PRESET_ID}' is reserved") - if not MODEL_ID_PATTERN.fullmatch(value): - raise ValueError( - "Model id must use lowercase letters, digits, and hyphens only" - ) - return value + base = slugify_model_id(display_name) or _FALLBACK_MODEL_ID + if base == CUSTOM_PRESET_ID: + base = f"{CUSTOM_PRESET_ID}-{_FALLBACK_MODEL_ID}" + candidate = base + suffix = 2 + while candidate in existing_ids: + candidate = f"{base}-{suffix}" + suffix += 1 + return candidate diff --git a/app/coding/api/errors.py b/app/coding/api/errors.py index d707918..7ee4b3c 100644 --- a/app/coding/api/errors.py +++ b/app/coding/api/errors.py @@ -12,7 +12,11 @@ CodingTaskNotCurrentError, CodingTaskNotFoundError, ) -from app.interview.domain.exceptions import InterviewDomainError +from app.interview.domain.exceptions import ( + InterviewDomainError, + InterviewNotActiveError, + InterviewNotFoundError, +) def coding_ws_error_payload( @@ -42,14 +46,16 @@ def http_exception_from_coding_error( """ if isinstance( exc, - CodingSectionNotFoundError | CodingTaskNotFoundError, + InterviewNotFoundError | 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, + InterviewNotActiveError + | 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 index 3f5b4cb..cc8bb12 100644 --- a/app/coding/api/routes.py +++ b/app/coding/api/routes.py @@ -19,8 +19,11 @@ 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.api.deps import ( + AIProviderDep, + CodingStateServiceDep, + CodingSubmissionServiceDep, +) from app.interview.domain.exceptions import InterviewDomainError router = APIRouter(prefix="/interview", tags=["coding"]) @@ -74,7 +77,10 @@ async def coding_run( @router.get("/{interview_id}/coding/state") -async def coding_state(interview_id: str) -> JSONResponse: +async def coding_state( + interview_id: str, + service: CodingStateServiceDep, +) -> JSONResponse: """Return coding session progress and Run history for the active task. Args: @@ -87,7 +93,7 @@ async def coding_state(interview_id: str) -> JSONResponse: HTTPException: When the coding section does not exist. """ try: - state = CodingStateService.get_state(interview_id) + state = service.get_state(interview_id) except CodingDomainError as exc: raise http_exception_from_coding_error(exc) from exc return JSONResponse(coding_state_to_dict(state)) @@ -98,6 +104,7 @@ async def coding_ws( websocket: WebSocket, interview_id: str, provider: AIProviderDep, + submission_service: CodingSubmissionServiceDep, ) -> None: """WebSocket endpoint for coding task submit and feedback. @@ -118,6 +125,7 @@ async def coding_ws( raw, interview_id=interview_id, provider=provider, + submission_service=submission_service, ): if not await _safe_send_json(websocket, message): break diff --git a/app/coding/api/ws_session.py b/app/coding/api/ws_session.py index 704454d..f884389 100644 --- a/app/coding/api/ws_session.py +++ b/app/coding/api/ws_session.py @@ -26,7 +26,7 @@ async def iter_responses( *, interview_id: str, provider: AIProvider, - submission_service: type[CodingSubmissionService] = CodingSubmissionService, + submission_service: CodingSubmissionService, ) -> AsyncIterator[dict[str, Any]]: """Handle one client message and yield JSON payloads for the socket. @@ -34,7 +34,7 @@ async def iter_responses( raw: Parsed client JSON message. interview_id: Interview session UUID. provider: AI provider for coding evaluation. - submission_service: Coding submission service class. + submission_service: Request-scoped coding submission service. Yields: WebSocket message dicts to send to the client. @@ -50,6 +50,15 @@ async def iter_responses( yield message return + if msg_type == "timeout": + async for message in CodingWebSocketService._handle_timeout( + raw, + interview_id=interview_id, + submission_service=submission_service, + ): + yield message + return + yield { "type": "error", "message": f"Unknown message type: {msg_type}", @@ -61,7 +70,7 @@ async def _handle_submit( *, interview_id: str, provider: AIProvider, - submission_service: type[CodingSubmissionService], + submission_service: CodingSubmissionService, ) -> AsyncIterator[dict[str, Any]]: task_id = str(raw.get("task_id", "")).strip() source_code = str(raw.get("source_code", "")) @@ -88,3 +97,32 @@ async def _handle_submit( "type": "error", "message": ai_error_message_for_client(exc), } + + @staticmethod + async def _handle_timeout( + raw: dict[str, Any], + *, + interview_id: str, + submission_service: CodingSubmissionService, + ) -> AsyncIterator[dict[str, Any]]: + task_id = str(raw.get("task_id") or raw.get("question_id") or "").strip() + round_num = raw.get("round") + if not task_id or round_num is None: + yield {"type": "error", "message": "Both task_id and round are required"} + return + + try: + async for event in submission_service.stream_timeout_submission( + interview_id=interview_id, + task_id=task_id, + round_num=int(round_num), + ): + yield coding_event_to_message(event) + except (InterviewDomainError, CodingDomainError) as exc: + yield coding_ws_error_payload(exc) + except Exception as exc: + logger.exception("Coding timeout failed for interview %s", interview_id) + yield { + "type": "error", + "message": ai_error_message_for_client(exc), + } diff --git a/app/coding/domain/entities.py b/app/coding/domain/entities.py index 566f8c0..4ddc6ae 100644 --- a/app/coding/domain/entities.py +++ b/app/coding/domain/entities.py @@ -52,7 +52,7 @@ class CodingTask: created_at: When this task row was created. """ - TIME_EXPIRED_CODE = "[Time expired]" + TIME_EXPIRED_SOURCE_CODE = "[Time expired]" TIMEOUT_GRACE_SECONDS = DEFAULT_TIMEOUT_GRACE_SECONDS NEW_ID = 0 @@ -131,6 +131,19 @@ def remaining_seconds( """ 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.""" + 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 CodingSection: @@ -370,6 +383,22 @@ def with_submit_test_summary( ) return replace(self, tasks=tasks) + def with_timed_out_round(self, task_row_id: int, feedback: str) -> CodingSection: + """Return aggregate with a coding round marked as timed out.""" + tasks = tuple( + replace( + task, + submitted_code=CodingTask.TIME_EXPIRED_SOURCE_CODE, + submit_test_summary={"status": "timeout"}, + score=0, + feedback=feedback, + ) + 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, diff --git a/app/coding/domain/exceptions.py b/app/coding/domain/exceptions.py index f6cdcb3..423f3c8 100644 --- a/app/coding/domain/exceptions.py +++ b/app/coding/domain/exceptions.py @@ -65,12 +65,12 @@ def __init__(self, task_id: str, limit: int) -> None: 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.""" +class CodingTaskTimerError(CodingDomainError): + """Raised when a coding task timer request is invalid.""" - def __init__(self) -> None: - """Initialize with a stable client-facing message.""" - super().__init__("Coding AI evaluation is not available yet") + def __init__(self, message: str) -> None: + """Initialize with a client-safe timer error message.""" + super().__init__(message) class CodingTaskNotFoundError(CodingDomainError): diff --git a/app/coding/repositories/coding_section.py b/app/coding/repositories/coding_section.py index 1161e84..788e809 100644 --- a/app/coding/repositories/coding_section.py +++ b/app/coding/repositories/coding_section.py @@ -66,6 +66,27 @@ def get_aggregate(self, interview_id: str) -> DomainCodingSection | None: return None return coding_section_from_orm(orm_section) + def get_aggregates_by_interview_ids( + self, interview_ids: list[str] + ) -> dict[str, DomainCodingSection]: + """Load coding section aggregates for several interviews at once. + + Args: + interview_ids: Parent interview UUIDs. + + Returns: + Mapping of interview ID to domain aggregate for sections that exist. + """ + if not interview_ids: + return {} + rows = ( + self._session.query(CodingSection) + .options(selectinload(CodingSection.tasks)) + .filter(CodingSection.interview_id.in_(interview_ids)) + .all() + ) + return {row.interview_id: coding_section_from_orm(row) for row in rows} + def create_aggregate(self, section: DomainCodingSection) -> DomainCodingSection: """Insert a coding section and its task rows. diff --git a/app/coding/repositories/mappers.py b/app/coding/repositories/mappers.py index d8e93f8..0f63b4e 100644 --- a/app/coding/repositories/mappers.py +++ b/app/coding/repositories/mappers.py @@ -12,7 +12,7 @@ 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.coding.schemas.coding import CodingTaskRead from app.interview.domain.serialization import ( parse_coding_selection_spec, parse_overall_feedback, @@ -328,26 +328,3 @@ def coding_task_read_from_domain(task: DomainCodingTask) -> CodingTaskRead: 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 deleted file mode 100644 index 86950d6..0000000 --- a/app/coding/repositories/uow.py +++ /dev/null @@ -1,44 +0,0 @@ -# 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/coding.py b/app/coding/schemas/coding.py index 80b7c02..b3c941a 100644 --- a/app/coding/schemas/coding.py +++ b/app/coding/schemas/coding.py @@ -43,35 +43,6 @@ class CodingTaskRead: 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. diff --git a/app/coding/services/availability.py b/app/coding/services/availability.py index aa02446..9b603ff 100644 --- a/app/coding/services/availability.py +++ b/app/coding/services/availability.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 """Coding feature availability checks for setup and session creation.""" -import asyncio import os import httpx @@ -72,17 +71,3 @@ async def is_coding_available_async() -> bool: 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 index e963acf..5a03cbb 100644 --- a/app/coding/services/creation.py +++ b/app/coding/services/creation.py @@ -11,8 +11,16 @@ class CodingSectionCreationService: """Service for creating coding sections within an interview session.""" - @staticmethod + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this workflow. + """ + self._uow = uow + def create( + self, interview_id: str, *, selection: InterviewSelection, @@ -20,7 +28,6 @@ def create( planned_tasks: tuple[PlannedCodingTask, ...], task_time_limit_seconds: int | None, status: CodingSectionStatus = "active", - uow: InterviewUnitOfWork, ) -> CodingSection: """Persist a coding section with initial task rows. @@ -31,7 +38,6 @@ def create( 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. @@ -47,4 +53,4 @@ def create( task_time_limit_seconds=task_time_limit_seconds, status=status, ) - return uow.coding_sections.create_aggregate(section) + return self._uow.coding_sections.create_aggregate(section) diff --git a/app/coding/services/evaluation_persistence.py b/app/coding/services/evaluation_persistence.py index 71a7068..9183003 100644 --- a/app/coding/services/evaluation_persistence.py +++ b/app/coding/services/evaluation_persistence.py @@ -8,18 +8,33 @@ 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 +from app.interview.repositories.uow import InterviewUnitOfWork class CodingEvaluationPersistenceService: """Save coding evaluation scores and advance timed task rounds.""" + def __init__( + self, + uow: InterviewUnitOfWork, + *, + navigation: CodingNavigationService | None = None, + ) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this workflow. + navigation: Optional navigation collaborator sharing the same uow. + """ + self._uow = uow + self._navigation = navigation or CodingNavigationService(uow) + @staticmethod def _apply_hidden_test_cap( evaluation: CodingAnswerEvaluation | CodingFollowUpEvaluation, @@ -53,8 +68,8 @@ def _apply_hidden_test_cap( follow_up_mode=evaluation.follow_up_mode or "code", ) - @staticmethod def persist( + self, *, interview_id: str, task_id: str, @@ -84,10 +99,7 @@ def persist( Returns: Feedback event for the coding WebSocket client. """ - evaluation = CodingEvaluationPersistenceService._apply_hidden_test_cap( - evaluation, - submit_test_summary, - ) + evaluation = self._apply_hidden_test_cap(evaluation, submit_test_summary) if ( isinstance(evaluation, CodingAnswerEvaluation) and evaluation.follow_up_needed @@ -104,56 +116,50 @@ def persist( 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() + section = self._uow.coding_sections.get_aggregate(interview_id) + if section is None: + raise CodingSectionNotFoundError(interview_id) + section.ensure_active() - updated = section.with_evaluation( + 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, - round_num, - evaluation.score, - evaluation.feedback, + follow_up_text or "", + starter_code=starter_code, ) - 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, - ) + follow_up_round = pending.round + + self._uow.coding_sections.save_aggregate(updated) + + if follow_up_needed and follow_up_round is not None: + self._uow.flush() + reloaded = self._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) + self._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 = ( + self._navigation.advance_to_next_unsubmitted( + interview_id, + task_id=task_id, + round_num=round_num, ) + ) return CodingFeedbackEvent( task_id=task_id, diff --git a/app/coding/services/judge0_client.py b/app/coding/services/judge0_client.py index 49e5f70..18a9bc5 100644 --- a/app/coding/services/judge0_client.py +++ b/app/coding/services/judge0_client.py @@ -16,7 +16,6 @@ ) JUDGE0_STATUS_ACCEPTED = 3 -JUDGE0_STATUS_WRONG_ANSWER = 4 JUDGE0_STATUS_TIME_LIMIT = 5 JUDGE0_STATUS_COMPILATION_ERROR = 6 JUDGE0_STATUS_RUNTIME_ERROR = 11 diff --git a/app/coding/services/navigation.py b/app/coding/services/navigation.py index e9b0761..2bf651f 100644 --- a/app/coding/services/navigation.py +++ b/app/coding/services/navigation.py @@ -7,7 +7,7 @@ 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.repositories.uow import InterviewUnitOfWork from app.interview.services.phases import SessionPhaseOrchestrator @@ -32,9 +32,16 @@ def next_task_payload(task: CodingTask) -> dict[str, Any]: class CodingNavigationService: """Shared navigation after a coding task round is completed.""" - @staticmethod + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this workflow. + """ + self._uow = uow + def advance_to_next_unsubmitted( - uow: CodingUnitOfWork, + self, interview_id: str, *, task_id: str, @@ -43,7 +50,6 @@ def advance_to_next_unsubmitted( """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. @@ -55,7 +61,7 @@ def advance_to_next_unsubmitted( CodingSectionNotFoundError: If the coding section does not exist. CodingSectionNotActiveError: If the section is not active. """ - section = uow.coding_sections.get_aggregate(interview_id) + section = self._uow.coding_sections.get_aggregate(interview_id) if section is None: raise CodingSectionNotFoundError(interview_id) @@ -67,19 +73,17 @@ def advance_to_next_unsubmitted( ) next_task = section.find_next_unsubmitted_after(current_index) if next_task is None: - CodingNavigationService._notify_phase_complete_if_needed( - interview_id, section - ) + self._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) + self._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( + self, interview_id: str, section: CodingSection, ) -> None: @@ -90,4 +94,7 @@ def _notify_phase_complete_if_needed( section: Coding section after the latest navigation update. """ if section.is_complete(): - SessionPhaseOrchestrator.notify_section_complete(interview_id, "coding") + SessionPhaseOrchestrator(self._uow).notify_section_complete( + interview_id, + "coding", + ) diff --git a/app/coding/services/page.py b/app/coding/services/page.py index 1a6d070..c6137eb 100644 --- a/app/coding/services/page.py +++ b/app/coding/services/page.py @@ -2,35 +2,47 @@ # 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 +from app.interview.repositories.uow import InterviewUnitOfWork class CodingPageService: """Build coding-specific page context for session rendering.""" - @staticmethod - def activate_timer(interview_id: str) -> None: + def __init__( + self, + uow: InterviewUnitOfWork, + *, + section: CodingSectionService | None = None, + ) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this page scope. + section: Optional coding section service sharing the same unit of work. + """ + self._uow = uow + self._section = section or CodingSectionService(uow) + + def activate_timer(self, 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) + self._section.activate_pending(interview_id) + section = self._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) + self._uow.coding_sections.save_aggregate(updated) - @staticmethod - def build_context(interview_id: str) -> CodingPageContext | None: + def build_context(self, interview_id: str) -> CodingPageContext | None: """Assemble coding panel context for the interview page. Args: @@ -39,34 +51,45 @@ def build_context(interview_id: str) -> CodingPageContext | None: 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 + section = self._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 - and section.status == "active" - ) - 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, - ) + 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 and section.status == "active" + ) + 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, + ) + + @staticmethod + def build_context_for(interview_id: str) -> CodingPageContext | None: + """Build coding page context using a short-lived unit of work. + + Args: + interview_id: Parent interview UUID. + + Returns: + Coding page context, or None when the session has no coding section. + """ + with InterviewUnitOfWork() as uow: + return CodingPageService(uow).build_context(interview_id) diff --git a/app/coding/services/planning.py b/app/coding/services/planning.py index 89a2768..ffb6a6b 100644 --- a/app/coding/services/planning.py +++ b/app/coding/services/planning.py @@ -9,6 +9,12 @@ PlannedQuestion, TrackQuestionPools, ) +from app.interview.services.rules.bank_selection import ( + BankCatalog, + BankSelectionMessages, + track_label, + validate_bank_selection, +) from app.shared.coding import ( CodingTask, list_categories, @@ -19,6 +25,25 @@ ) from app.shared.locales import normalize_locale +_CODING_BANK_CATALOG = BankCatalog( + list_tracks=list_tracks, + list_levels=list_levels, + list_categories=list_categories, +) +_CODING_BANK_MESSAGES = BankSelectionMessages( + empty_sources="Select at least one coding track and topic", + unknown_track=lambda track: f"Unknown coding track: {track}", + unknown_level=lambda level, track: ( + f"Unknown level '{level}' for coding track '{track}'" + ), + empty_categories=lambda track: ( + f"Select at least one coding topic for {track_label(track)}" + ), + unknown_category=lambda category, track, level: ( + f"Unknown coding topic '{category}' for {track}/{level}" + ), +) + def _to_planned_question(task: CodingTask) -> PlannedQuestion: """Map a coding bank row to a generic planned question for selection. @@ -41,31 +66,11 @@ def validate_selection(selection: InterviewSelection) -> None: 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}" - ) + validate_bank_selection( + selection, + _CODING_BANK_CATALOG, + _CODING_BANK_MESSAGES, + ) def validate_task_count(selection: InterviewSelection, task_count: int) -> None: @@ -156,6 +161,8 @@ def build_coding_task_plan( selection: InterviewSelection, task_count: int, locale: str = "en", + *, + excluded_ids: frozenset[str] = frozenset(), ) -> tuple[PlannedCodingTask, ...]: """Build ordered coding task list for a multi-source section. @@ -163,6 +170,7 @@ def build_coding_task_plan( selection: Validated coding selection. task_count: Target number of tasks (>= topic count). locale: Locale for task prompt text. + excluded_ids: Task IDs to remove from pools before planning. Returns: Ordered planned coding tasks. @@ -170,12 +178,17 @@ def build_coding_task_plan( 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) + from app.interview.services.rules.selection import plan_questions + + planned = plan_questions( + selection, + task_count, + track_pools, + excluded_ids=excluded_ids, + ) tasks_by_id = _task_by_id(selection, locale) result: list[PlannedCodingTask] = [] for question in planned: diff --git a/app/coding/services/query.py b/app/coding/services/query.py index da527cb..c89116b 100644 --- a/app/coding/services/query.py +++ b/app/coding/services/query.py @@ -5,7 +5,7 @@ from typing import Any from app.coding.domain.entities import CodingSection -from app.coding.repositories.uow import CodingUnitOfWork +from app.interview.repositories.uow import InterviewUnitOfWork 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 @@ -14,6 +14,14 @@ class CodingQueryService: """Read-only queries for coding section aggregates.""" + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this read scope. + """ + self._uow = uow + @staticmethod def _items_from_section(section: CodingSection) -> tuple[dict[str, Any], ...]: """Build task rows from a coding section aggregate. @@ -37,8 +45,10 @@ def _items_from_section(section: CodingSection) -> tuple[dict[str, Any], ...]: if task.submitted_code is not None ) - @staticmethod - def get_evaluation_summary(interview_id: str) -> SectionEvaluationSummary | None: + def get_evaluation_summary( + self, + interview_id: str, + ) -> SectionEvaluationSummary | None: """Return coding section evaluation data for session completion. Uses cached ``section_feedback`` when present. @@ -49,22 +59,20 @@ def get_evaluation_summary(interview_id: str) -> SectionEvaluationSummary | None 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, - ) + section = self._uow.coding_sections.get_aggregate(interview_id) + if section is None: + return None - @staticmethod - def sources_text_for_section(interview_id: str) -> str: + return build_section_evaluation_summary( + "coding", + section_status=section.status, + items=self._items_from_section(section), + total_score=section.total_score(), + max_score=section.max_score(), + cached_narrative=section.section_feedback, + ) + + def sources_text_for_section(self, interview_id: str) -> str: """Build selection summary text for coding evaluation prompts. Args: @@ -73,8 +81,7 @@ def sources_text_for_section(interview_id: str) -> str: 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) + section = self._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 index 33a904a..52307a1 100644 --- a/app/coding/services/review.py +++ b/app/coding/services/review.py @@ -13,6 +13,7 @@ from app.coding.services.query import CodingQueryService from app.interview.repositories.uow import InterviewUnitOfWork from app.interview.services.section_review_support import ( + CompletedInterviewSnapshot, load_completed_interview, resolved_section_feedback, review_score_fields, @@ -23,6 +24,15 @@ class CodingReviewService: """Build read-only coding review context for completed sessions.""" + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this review scope. + """ + self._uow = uow + self._query = CodingQueryService(uow) + @staticmethod def _group_tasks(section: CodingSection) -> list[CodingTaskReviewRead]: """Group submitted coding task rows by display order. @@ -78,26 +88,25 @@ def _group_tasks(section: CodingSection) -> list[CodingTaskReviewRead]: return grouped - @staticmethod - def build_context(interview_id: str) -> CodingReviewContext | None: + def build_context( + self, + interview_id: str, + snapshot: CompletedInterviewSnapshot, + ) -> CodingReviewContext | None: """Assemble coding review template context for a completed session. Args: interview_id: Parent session UUID. + snapshot: Loaded completed interview snapshot. Returns: - Review context, or None when the session or coding section is missing. + Review context, or None when the coding section is missing. """ - snapshot = load_completed_interview(interview_id) - if snapshot is None: + section = self._uow.coding_sections.get_aggregate(interview_id) + if section 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) + summary = self._query.get_evaluation_summary(interview_id) if summary is None: return None @@ -117,6 +126,20 @@ def build_context(interview_id: str) -> CodingReviewContext | None: **shared_review_fields(interview_id, snapshot), **scores, "section_feedback": section_feedback, - "tasks": CodingReviewService._group_tasks(section), + "tasks": self._group_tasks(section), } ) + + def build_context_for(self, interview_id: str) -> CodingReviewContext | None: + """Build coding review 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(self._uow, interview_id) + if snapshot is None: + return None + return self.build_context(interview_id, snapshot) diff --git a/app/coding/services/run_execution.py b/app/coding/services/run_execution.py index 26cf2ed..861f9da 100644 --- a/app/coding/services/run_execution.py +++ b/app/coding/services/run_execution.py @@ -14,7 +14,6 @@ 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 @@ -105,7 +104,7 @@ class CodingRunExecutionService: def _load_run_context( interview_id: str, task_id: str, - ) -> tuple[dict[str, Any], int, int, str]: + ) -> dict[str, Any]: """Validate the active task and return execution context. Args: @@ -113,7 +112,7 @@ def _load_run_context( task_id: YAML task ID for the active coding round. Returns: - Tuple of task spec, coding task row ID, next attempt number, language. + Task spec used for public test execution. Raises: CodingSectionNotFoundError: If no coding section exists. @@ -121,7 +120,7 @@ def _load_run_context( CodingTaskNotCurrentError: If ``task_id`` is not the active task. CodingRunLimitExceededError: If the per-task Run limit is reached. """ - with CodingUnitOfWork() as uow: + with InterviewUnitOfWork() as uow: section = uow.coding_sections.get_aggregate(interview_id) if section is None: raise CodingSectionNotFoundError(interview_id) @@ -131,13 +130,7 @@ def _load_run_context( 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, - ) + return dict(current_task.task_spec) @staticmethod async def run_and_persist( @@ -165,21 +158,32 @@ async def run_and_persist( 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) - ) + task_spec = 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: + 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() + 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) + attempt = CodingRunExecutionService._build_attempt( + coding_task_id=current_task.id, + attempt_no=attempt_count + 1, + source_code=source_code, + language=str(current_task.task_spec.get("language", "python")), + run_result=run_result, + ) return uow.code_run_attempts.create(attempt) @staticmethod diff --git a/app/coding/services/section.py b/app/coding/services/section.py index d55b30b..b80c997 100644 --- a/app/coding/services/section.py +++ b/app/coding/services/section.py @@ -6,14 +6,10 @@ 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.repositories.uow import InterviewUnitOfWork +from app.interview.services.section_service_support import SectionFeedbackPrefetch from app.interview.services.sections import ( SectionEvaluationSummary, SectionPageContext, @@ -21,13 +17,82 @@ ) +async def _evaluate_coding_section_feedback( + provider: object, + summary: SectionEvaluationSummary, + sources_text: str, + locale: str, +) -> tuple[dict[str, object], int] | None: + """Run the coding section LLM evaluation for prefetch. + + Args: + provider: Configured AI provider instance. + summary: Section evaluation summary with per-task rows. + sources_text: Human-readable selection summary. + locale: Section locale for prompts. + + Returns: + Feedback payload and section score. + """ + section_eval = await CodingEvaluatorService.evaluate_section( + provider=provider, # type: ignore[arg-type] + task_submissions=list(summary.items), + sources_text=sources_text, + locale=locale, + ) + return section_eval.model_dump(), summary.score + + +def _build_coding_feedback_prefetch( + uow: InterviewUnitOfWork, + query: CodingQueryService | None = None, +) -> SectionFeedbackPrefetch: + """Build coding section feedback prefetch helpers for a unit of work. + + Args: + uow: Active application unit of work. + query: Optional query helper sharing the same unit of work. + + Returns: + Configured prefetch helper for coding sections. + """ + resolved_query = query or CodingQueryService(uow) + return SectionFeedbackPrefetch( + uow, + section_name="coding", + build=lambda scoped_uow: _build_coding_feedback_prefetch(scoped_uow), + query=resolved_query, + get_section=lambda scoped_uow, interview_id: ( + scoped_uow.coding_sections.get_aggregate(interview_id) + ), + save_section=lambda scoped_uow, section: ( + scoped_uow.coding_sections.save_aggregate(section) + ), + evaluate_section=_evaluate_coding_section_feedback, + ) + + class CodingSectionService: """Coding section lifecycle hooks and read helpers.""" section_kind: ClassVar[Literal["coding"]] = "coding" - @staticmethod - def is_complete(interview_id: str) -> bool: + def __init__( + self, + uow: InterviewUnitOfWork, + query: CodingQueryService | None = None, + ) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this section scope. + query: Optional coding query helper sharing the same unit of work. + """ + self._uow = uow + self._query = query or CodingQueryService(uow) + self._feedback = _build_coding_feedback_prefetch(uow, self._query) + + def is_complete(self, interview_id: str) -> bool: """Return whether all coding tasks in the section are submitted. Args: @@ -36,16 +101,14 @@ def is_complete(interview_id: str) -> bool: 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: + section = self._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() + + def is_user_facing(self, interview_id: str) -> bool: """Return whether the user should interact with the coding section now. Args: @@ -54,17 +117,13 @@ def is_user_facing(interview_id: str) -> bool: 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: + section = self._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 + + def activate_if_pending(self, 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. @@ -75,10 +134,9 @@ def activate_if_pending(interview_id: str) -> bool: Returns: True when the section was activated in this call. """ - return CodingSectionService.activate_pending(interview_id) + return self.activate_pending(interview_id) - @staticmethod - def get_page_context(interview_id: str) -> SectionPageContext | None: + def get_page_context(self, interview_id: str) -> SectionPageContext | None: """Return coding section page metadata for session composition. Args: @@ -87,22 +145,19 @@ def get_page_context(interview_id: str) -> SectionPageContext | None: 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 + section = self._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, + ) + def get_evaluation_summary( + self, interview_id: str, ) -> SectionEvaluationSummary | None: """Return coding evaluation summary for session completion. @@ -113,10 +168,9 @@ def get_evaluation_summary( Returns: Section summary, or None when no coding section exists. """ - return CodingQueryService.get_evaluation_summary(interview_id) + return self._query.get_evaluation_summary(interview_id) - @staticmethod - def activate_pending(interview_id: str) -> bool: + def activate_pending(self, 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. @@ -127,25 +181,23 @@ def activate_pending(interview_id: str) -> bool: Returns: True when the section was activated in this call. """ - if not prior_sections_complete_for(interview_id, "coding"): + if not prior_sections_complete_for(interview_id, "coding", self._uow): 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: + section = self._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) + self._uow.coding_sections.save_aggregate(updated) + return True + + def on_phase_complete(self, interview_id: str) -> None: """Schedule background prefetch of coding section narrative feedback. Idempotent: skips when feedback is already cached. @@ -153,14 +205,9 @@ def on_phase_complete(interview_id: str) -> None: 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) - ) + self._feedback.on_phase_complete(interview_id) - @staticmethod - async def ensure_section_feedback(interview_id: str) -> None: + async def ensure_section_feedback(self, interview_id: str) -> None: """Synchronously prefetch section feedback before session completion. Idempotent: skips when feedback is already cached or the section is @@ -169,108 +216,4 @@ async def ensure_section_feedback(interview_id: str) -> None: 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 + await self._feedback.ensure_section_feedback(interview_id) diff --git a/app/coding/services/state.py b/app/coding/services/state.py index 7aeee10..ab5e331 100644 --- a/app/coding/services/state.py +++ b/app/coding/services/state.py @@ -7,12 +7,12 @@ 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, ) +from app.interview.repositories.uow import InterviewUnitOfWork def _task_state_from_domain(task: CodingTask) -> CodingTaskStateRead: @@ -65,8 +65,11 @@ def _attempt_state_from_domain(attempt: CodeRunAttempt) -> CodeRunAttemptRead: class CodingStateService: """Read-only builder for ``GET /coding/state`` responses.""" - @staticmethod - def get_state(interview_id: str) -> CodingSessionStateRead: + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work.""" + self._uow = uow + + def get_state(self, interview_id: str) -> CodingSessionStateRead: """Return coding section progress and recent Run attempts. Args: @@ -78,34 +81,31 @@ def get_state(interview_id: str) -> CodingSessionStateRead: 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), + section = self._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 = self._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 index 5de5a69..2d14b86 100644 --- a/app/coding/services/submission.py +++ b/app/coding/services/submission.py @@ -7,26 +7,31 @@ import asyncio from collections.abc import AsyncIterator from dataclasses import dataclass +from datetime import UTC, datetime 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.domain.exceptions import ( + CodingSectionNotFoundError, + CodingTaskNotCurrentError, + CodingTaskTimerError, +) 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.navigation import CodingNavigationService +from app.coding.services.run_execution import coding_run_result_to_summary from app.coding.services.runner import CodingRunnerService +from app.interview.domain.exceptions import InterviewNotFoundError +from app.interview.repositories.uow import InterviewUnitOfWork from app.interview.services.events import ( AnswerSavedEvent, EvaluatingEvent, InterviewEvent, ) +from app.interview.services.rules.feedback import timeout_feedback_for_locale @dataclass(frozen=True) @@ -63,8 +68,50 @@ class CodingSubmissionContext: class CodingSubmissionService: """Handle coding submit messages and stream server events.""" - @staticmethod + def __init__( + self, + uow: InterviewUnitOfWork, + *, + persistence: CodingEvaluationPersistenceService | None = None, + ) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this submission workflow. + persistence: Optional evaluation persistence collaborator. + """ + self._uow = uow + navigation = CodingNavigationService(uow) + self._navigation = navigation + self._persistence = persistence or CodingEvaluationPersistenceService( + uow, navigation=navigation + ) + + def _commit_submission_workflow(self) -> None: + """Commit durable submission changes for the current request scope.""" + self._uow.commit() + + def _rollback_submission_workflow(self) -> None: + """Discard uncommitted submission changes after a workflow failure.""" + self._uow.rollback() + + def _ensure_interview_active(self, 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. + """ + aggregate = self._uow.interviews.get_aggregate(interview_id) + if aggregate is None: + raise InterviewNotFoundError(interview_id) + aggregate.ensure_active() + async def _prepare_submission( + self, *, interview_id: str, task_id: str, @@ -85,31 +132,30 @@ async def _prepare_submission( 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) + section = self._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 = self._serialize_run_attempts( + self._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 ) - 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 "" + initial_prompt_text = initial.prompt_text + initial_source_code = initial.submitted_code or "" hidden_result = await CodingRunnerService.run_hidden_tests( source_code=source_code, @@ -117,17 +163,17 @@ async def _prepare_submission( ) 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) + section = self._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, + ) + self._uow.coding_sections.save_aggregate(updated) + self._uow.flush() return CodingSubmissionContext( task_row_id=task_row_id, @@ -169,8 +215,8 @@ def _serialize_run_attempts( for attempt in attempts ) - @staticmethod async def stream_submit( + self, *, interview_id: str, task_id: str, @@ -195,8 +241,98 @@ async def stream_submit( 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( + try: + async for event in self._iter_submit( + interview_id=interview_id, + task_id=task_id, + source_code=source_code, + provider=provider, + ): + yield event + self._commit_submission_workflow() + except Exception: + self._rollback_submission_workflow() + raise + + async def stream_timeout_submission( + self, + *, + interview_id: str, + task_id: str, + round_num: int, + ) -> AsyncIterator[CodingFeedbackEvent]: + """Record an expired coding round with zero score and advance.""" + try: + yield self._persist_timed_out_round( + interview_id=interview_id, + task_id=task_id, + round_num=round_num, + ) + self._commit_submission_workflow() + except Exception: + self._rollback_submission_workflow() + raise + + def _persist_timed_out_round( + self, + *, + interview_id: str, + task_id: str, + round_num: int, + ) -> CodingFeedbackEvent: + self._ensure_interview_active(interview_id) + section = self._uow.coding_sections.get_aggregate(interview_id) + if section is None: + raise CodingSectionNotFoundError(interview_id) + section.ensure_active() + limit = section.task_time_limit_seconds + if not limit: + raise CodingTaskTimerError("Coding task timer is not enabled") + current = section.require_current_task(task_id) + if current.round != round_num: + raise CodingTaskNotCurrentError(interview_id, task_id) + if current.submitted_code is not None: + return CodingFeedbackEvent( + task_id=task_id, + order=current.order, + round=round_num, + follow_up_needed=False, + follow_up_text=None, + follow_up_mode=None, + next_task=None, + ) + if not current.client_timeout_due(limit, datetime.now(UTC)): + raise CodingTaskTimerError("Coding task timer has not expired") + feedback = timeout_feedback_for_locale(section.locale) + updated = section.with_timed_out_round(current.id, feedback) + self._uow.coding_sections.save_aggregate(updated) + next_task, timer_remaining = self._navigation.advance_to_next_unsubmitted( + interview_id, + task_id=task_id, + round_num=round_num, + ) + return CodingFeedbackEvent( + task_id=task_id, + order=current.order, + round=round_num, + follow_up_needed=False, + follow_up_text=None, + follow_up_mode=None, + next_task=next_task, + feedback=feedback, + timer_remaining_seconds=timer_remaining, + ) + + async def _iter_submit( + self, + *, + interview_id: str, + task_id: str, + source_code: str, + provider: AIProvider, + ) -> AsyncIterator[InterviewEvent | CodingFeedbackEvent]: + self._ensure_interview_active(interview_id) + ctx = await self._prepare_submission( interview_id=interview_id, task_id=task_id, source_code=source_code, @@ -225,7 +361,7 @@ async def stream_submit( ) ) - yield CodingEvaluationPersistenceService.persist( + yield self._persistence.persist( interview_id=interview_id, task_id=ctx.task_id, round_num=ctx.round_num, diff --git a/app/interview/api/dashboard.py b/app/interview/api/dashboard.py index 395a7ff..62ac51f 100644 --- a/app/interview/api/dashboard.py +++ b/app/interview/api/dashboard.py @@ -8,14 +8,17 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse -from app.interview.services.dashboard import DashboardBuilder +from app.interview.api.deps import DashboardBuilderDep from app.templating import templates router = APIRouter(tags=["dashboard"]) @router.get("/", response_class=HTMLResponse) -async def dashboard_page(request: Request) -> HTMLResponse: +async def dashboard_page( + request: Request, + dashboard: DashboardBuilderDep, +) -> HTMLResponse: """Render the dashboard with recent interview history. Args: @@ -28,6 +31,6 @@ async def dashboard_page(request: Request) -> HTMLResponse: request, "dashboard.html", { - "interview_history": DashboardBuilder.list_rows(limit=20), + "interview_history": dashboard.list_rows(limit=20), }, ) diff --git a/app/interview/api/deps.py b/app/interview/api/deps.py index 30bef4f..bea4ad1 100644 --- a/app/interview/api/deps.py +++ b/app/interview/api/deps.py @@ -9,15 +9,25 @@ from app.ai.base import AIProvider from app.ai.speech_transcriber import SpeechTranscriber +from app.coding.services.review import CodingReviewService +from app.coding.services.state import CodingStateService +from app.coding.services.submission import CodingSubmissionService from app.interview.services.completion import SessionCompletionService from app.interview.services.creation import SessionCreationService +from app.interview.services.dashboard import DashboardBuilder +from app.interview.services.known_questions import KnownQuestionsService +from app.interview.services.page import SessionPageService from app.interview.services.query import InterviewQuery +from app.interview.services.results_page import SessionResultsPageService from app.platform.api.deps import ConfigServiceDep from app.platform.services.ai_context import ai_provider_from_config +from app.shared.application.uow_deps import UoWAutoCommitDep, UoWDep from app.speech.services.transcriber_resolver import ( resolve_speech_transcriber, speech_transcriber_unavailable_message, ) +from app.theory.services.review import TheoryReviewService +from app.theory.services.submission import TheorySubmissionService async def get_ai_provider() -> AsyncIterator[AIProvider]: @@ -33,30 +43,179 @@ async def get_ai_provider() -> AsyncIterator[AIProvider]: yield provider -def get_interview_query() -> type[InterviewQuery]: - """Return the interview query service class used by API handlers.""" - return InterviewQuery +def get_interview_query(uow: UoWDep) -> InterviewQuery: + """Build an interview query service bound to the request unit of work. + Args: + uow: Application unit of work for the request scope. + + Returns: + Interview query service instance. + """ + return InterviewQuery(uow) + + +def get_dashboard_builder(uow: UoWDep) -> DashboardBuilder: + """Build a dashboard builder bound to the request UoW.""" + return DashboardBuilder(uow) + + +def get_session_page_service(uow: UoWAutoCommitDep) -> SessionPageService: + """Build a session page service bound to an auto-commit UoW.""" + return SessionPageService(uow) + + +def get_session_creation_service( + uow: UoWAutoCommitDep, +) -> SessionCreationService: + """Build a session creation service bound to an auto-commit UoW. + + Args: + uow: Application unit of work for the request scope. + + Returns: + Session creation service instance. + """ + return SessionCreationService(uow) + + +def get_session_completion_service( + uow: UoWDep, +) -> SessionCompletionService: + """Build a session completion service bound to the request UoW. + + Args: + uow: Application unit of work for the request scope. + + Returns: + Session completion service instance. + """ + return SessionCompletionService(uow) + + +def get_theory_submission_service(uow: UoWDep) -> TheorySubmissionService: + """Build a theory submission service bound to the request UoW. + + Args: + uow: Application unit of work for the request scope. + + Returns: + Theory submission service instance. + """ + return TheorySubmissionService(uow) + + +def get_coding_submission_service(uow: UoWDep) -> CodingSubmissionService: + """Build a coding submission service bound to the request UoW. + + Args: + uow: Application unit of work for the request scope. + + Returns: + Coding submission service instance. + """ + return CodingSubmissionService(uow) -def get_session_creation_service() -> type[SessionCreationService]: - """Return the session creation service class used by API handlers.""" - return SessionCreationService +def get_coding_state_service(uow: UoWDep) -> CodingStateService: + """Build a coding state service bound to the request UoW.""" + return CodingStateService(uow) -def get_session_completion_service() -> type[SessionCompletionService]: - """Return the session completion service class used by API handlers.""" - return SessionCompletionService +def get_known_questions_service( + uow: UoWAutoCommitDep, +) -> KnownQuestionsService: + """Build a known questions service bound to the request UoW. -InterviewQueryDep = Annotated[type[InterviewQuery], Depends(get_interview_query)] + Args: + uow: Application unit of work for the request scope. + + Returns: + Known questions service instance. + """ + return KnownQuestionsService(uow) + + +def get_session_results_page_service( + uow: UoWDep, +) -> SessionResultsPageService: + """Build a session results page service bound to the request UoW. + + Args: + uow: Application unit of work for the request scope. + + Returns: + Session results page service instance. + """ + return SessionResultsPageService(uow) + + +def get_theory_review_service(uow: UoWDep) -> TheoryReviewService: + """Build a theory review service bound to the request UoW. + + Args: + uow: Application unit of work for the request scope. + + Returns: + Theory review service instance. + """ + return TheoryReviewService(uow) + + +def get_coding_review_service(uow: UoWDep) -> CodingReviewService: + """Build a coding review service bound to the request UoW. + + Args: + uow: Application unit of work for the request scope. + + Returns: + Coding review service instance. + """ + return CodingReviewService(uow) + + +InterviewQueryDep = Annotated[InterviewQuery, Depends(get_interview_query)] +DashboardBuilderDep = Annotated[DashboardBuilder, Depends(get_dashboard_builder)] +SessionPageServiceDep = Annotated[ + SessionPageService, + Depends(get_session_page_service), +] SessionCreationServiceDep = Annotated[ - type[SessionCreationService], + SessionCreationService, Depends(get_session_creation_service), ] SessionCompletionServiceDep = Annotated[ - type[SessionCompletionService], + SessionCompletionService, Depends(get_session_completion_service), ] +TheorySubmissionServiceDep = Annotated[ + TheorySubmissionService, + Depends(get_theory_submission_service), +] +CodingSubmissionServiceDep = Annotated[ + CodingSubmissionService, + Depends(get_coding_submission_service), +] +CodingStateServiceDep = Annotated[ + CodingStateService, + Depends(get_coding_state_service), +] +KnownQuestionsServiceDep = Annotated[ + KnownQuestionsService, + Depends(get_known_questions_service), +] +SessionResultsPageServiceDep = Annotated[ + SessionResultsPageService, + Depends(get_session_results_page_service), +] +TheoryReviewServiceDep = Annotated[ + TheoryReviewService, + Depends(get_theory_review_service), +] +CodingReviewServiceDep = Annotated[ + CodingReviewService, + Depends(get_coding_review_service), +] AIProviderDep = Annotated[AIProvider, Depends(get_ai_provider)] diff --git a/app/interview/api/errors.py b/app/interview/api/errors.py index de0deae..9c71bf4 100644 --- a/app/interview/api/errors.py +++ b/app/interview/api/errors.py @@ -5,15 +5,10 @@ from fastapi import HTTPException from app.interview.domain.exceptions import ( - AnswerNotFoundError, InterviewDomainError, InterviewNotActiveError, InterviewNotFoundError, - QuestionTimerNotEnabledError, - QuestionTimerNotExpiredError, - UnansweredAnswerNotFoundError, ) -from app.theory.api.ws_protocol import domain_error_to_wire from app.theory.domain.exceptions import ( TaskTimerNotEnabledError, TaskTimerNotExpiredError, @@ -25,18 +20,6 @@ ) -def ws_error_payload(exc: InterviewDomainError | TheoryDomainError) -> dict[str, str]: - """Build a WebSocket error message from a domain exception. - - Args: - exc: Domain error raised by the service layer. - - Returns: - JSON-serializable error dict for the client. - """ - return domain_error_to_wire(exc) - - def http_exception_from_domain_error( exc: InterviewDomainError | TheoryDomainError, ) -> HTTPException: @@ -50,22 +33,16 @@ def http_exception_from_domain_error( """ if isinstance( exc, - InterviewNotFoundError - | AnswerNotFoundError - | TheorySectionNotFoundError - | TheoryTaskNotFoundError, + InterviewNotFoundError | TheorySectionNotFoundError | TheoryTaskNotFoundError, ): return HTTPException(status_code=404, detail=str(exc)) if isinstance( exc, InterviewNotActiveError - | UnansweredAnswerNotFoundError - | QuestionTimerNotEnabledError - | QuestionTimerNotExpiredError - | TheorySectionNotActiveError | UnansweredTaskNotFoundError | TaskTimerNotEnabledError - | TaskTimerNotExpiredError, + | TaskTimerNotExpiredError + | TheorySectionNotActiveError, ): return HTTPException(status_code=400, detail=str(exc)) return HTTPException(status_code=400, detail=str(exc)) diff --git a/app/interview/api/known_questions.py b/app/interview/api/known_questions.py new file mode 100644 index 0000000..9411b94 --- /dev/null +++ b/app/interview/api/known_questions.py @@ -0,0 +1,101 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""HTTP API for known bank-item exclusions.""" + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, Response + +from app.interview.api.deps import KnownQuestionsServiceDep +from app.interview.schemas.known_questions import KnownItemMutation, KnownItemsResponse +from app.templating import templates + +router = APIRouter(prefix="/known-questions", tags=["known-questions"]) + + +@router.get("") +def list_known_questions( + service: KnownQuestionsServiceDep, +) -> KnownItemsResponse: + """Return all known bank item IDs grouped by branch. + + Args: + service: Known questions service for the request scope. + + Returns: + Theory and coding ID lists. + """ + grouped = service.list_all() + return KnownItemsResponse( + theory=grouped.get("theory", []), + coding=grouped.get("coding", []), + ) + + +@router.post("") +def mark_known_item( + body: KnownItemMutation, + service: KnownQuestionsServiceDep, +) -> KnownItemsResponse: + """Mark a bank item as known for future session exclusion. + + Args: + body: Branch and bank item ID to mark. + service: Known questions service for the request scope. + + Returns: + Updated known item lists. + """ + service.mark_known(body.branch, body.item_id) + grouped = service.list_all() + return KnownItemsResponse( + theory=grouped.get("theory", []), + coding=grouped.get("coding", []), + ) + + +@router.delete("") +def unmark_known_item( + body: KnownItemMutation, + service: KnownQuestionsServiceDep, +) -> KnownItemsResponse: + """Remove a bank item from the known list. + + Args: + body: Branch and bank item ID to unmark. + service: Known questions service for the request scope. + + Returns: + Updated known item lists. + """ + service.unmark(body.branch, body.item_id) + grouped = service.list_all() + return KnownItemsResponse( + theory=grouped.get("theory", []), + coding=grouped.get("coding", []), + ) + + +@router.get("/manage", response_class=HTMLResponse) +async def manage_known_questions_page( + request: Request, + service: KnownQuestionsServiceDep, +) -> Response: + """Render the known bank items management page. + + Args: + request: FastAPI request object. + service: Known questions service for the request scope. + + Returns: + HTML page listing known items with unmark actions. + """ + known = service.list_all_with_text() + return templates.TemplateResponse( + request, + "known_questions.html", + { + "theory_items": known.get("theory", []), + "coding_items": known.get("coding", []), + "total_count": service.count(), + }, + ) diff --git a/app/interview/api/results.py b/app/interview/api/results.py index 5e180e7..0384201 100644 --- a/app/interview/api/results.py +++ b/app/interview/api/results.py @@ -5,10 +5,12 @@ 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.interview.api.deps import ( + CodingReviewServiceDep, + SessionResultsPageServiceDep, + TheoryReviewServiceDep, +) from app.templating import templates -from app.theory.services.review import TheoryReviewService router = APIRouter(prefix="/interview", tags=["interview-results"]) @@ -17,17 +19,19 @@ async def session_results_page( request: Request, interview_id: str, + service: SessionResultsPageServiceDep, ) -> Response: """Render the completed session results hub. Args: request: FastAPI request object. interview_id: Session UUID. + service: Session results page service for the request scope. Returns: HTML response with session results, or redirect when unavailable. """ - page = SessionResultsPageService.prepare_page(interview_id) + page = service.prepare_page(interview_id) if page.redirect_url is not None: return RedirectResponse(url=page.redirect_url, status_code=303) return templates.TemplateResponse( @@ -41,17 +45,19 @@ async def session_results_page( async def theory_review_page( request: Request, interview_id: str, + service: TheoryReviewServiceDep, ) -> Response: """Render the completed theory section review with chat history. Args: request: FastAPI request object. interview_id: Session UUID. + service: Theory review service for the request scope. Returns: HTML response with theory review, or redirect when unavailable. """ - context = TheoryReviewService.build_context(interview_id) + context = service.build_context_for(interview_id) if context is None: return RedirectResponse( url=f"/interview/{interview_id}/results", status_code=303 @@ -67,17 +73,19 @@ async def theory_review_page( async def coding_review_page( request: Request, interview_id: str, + service: CodingReviewServiceDep, ) -> Response: """Render the completed coding section review with per-task feedback. Args: request: FastAPI request object. interview_id: Session UUID. + service: Coding review service for the request scope. Returns: HTML response with coding review, or redirect when unavailable. """ - context = CodingReviewService.build_context(interview_id) + context = service.build_context_for(interview_id) if context is None: return RedirectResponse( url=f"/interview/{interview_id}/results", status_code=303 diff --git a/app/interview/api/routes.py b/app/interview/api/routes.py index b72047e..3e4ee48 100644 --- a/app/interview/api/routes.py +++ b/app/interview/api/routes.py @@ -9,9 +9,9 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response +from app.interview.api.deps import SessionPageServiceDep from app.interview.api.errors import http_exception_from_domain_error from app.interview.domain.exceptions import InterviewDomainError -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 @@ -45,6 +45,7 @@ async def interview_page( interview_id: str, config_service: ConfigServiceDep, whisper_model_service: WhisperModelServiceDep, + page_service: SessionPageServiceDep, ) -> Response: """View an interview session. @@ -61,7 +62,7 @@ async def interview_page( HTML response with interview view, or redirect if not found. """ config = config_service.get_config() - page = await SessionPageService.prepare_page( + page = await page_service.prepare_page( interview_id, config=config, whisper_model_service=whisper_model_service, diff --git a/app/interview/api/setup_form.py b/app/interview/api/setup_form.py index 61846f1..196cf3d 100644 --- a/app/interview/api/setup_form.py +++ b/app/interview/api/setup_form.py @@ -5,7 +5,7 @@ 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.interview.services.rules.bank_selection import track_label 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 diff --git a/app/interview/domain/__init__.py b/app/interview/domain/__init__.py index 3302341..f049de9 100644 --- a/app/interview/domain/__init__.py +++ b/app/interview/domain/__init__.py @@ -4,19 +4,16 @@ from app.interview.domain.entities import Interview from app.interview.domain.exceptions import ( - AnswerNotFoundError, InterviewDomainError, InterviewNotActiveError, InterviewNotFoundError, - QuestionTimerNotEnabledError, - QuestionTimerNotExpiredError, - UnansweredAnswerNotFoundError, ) from app.interview.domain.value_objects import ( InterviewSelection, InterviewSelectionHolder, PlannedQuestion, SectionBranchSpec, + SectionKind, SessionMode, SessionSelection, TrackQuestionPools, @@ -24,7 +21,6 @@ ) __all__ = [ - "AnswerNotFoundError", "Interview", "InterviewDomainError", "InterviewNotActiveError", @@ -33,11 +29,9 @@ "InterviewSelectionHolder", "PlannedQuestion", "SectionBranchSpec", + "SectionKind", "SessionMode", "SessionSelection", - "QuestionTimerNotEnabledError", - "QuestionTimerNotExpiredError", "TrackQuestionPools", "TrackSelection", - "UnansweredAnswerNotFoundError", ] diff --git a/app/interview/domain/exceptions.py b/app/interview/domain/exceptions.py index 985afbe..284735c 100644 --- a/app/interview/domain/exceptions.py +++ b/app/interview/domain/exceptions.py @@ -31,72 +31,3 @@ def __init__(self, interview_id: str | None = None) -> None: """ self.interview_id = interview_id super().__init__("Cannot submit answer to a completed interview") - - -class UnansweredAnswerNotFoundError(InterviewDomainError): - """Raised when no open answer 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 answer not found: interview={interview_id}, " - f"question={question_id}" - ) - - -class QuestionTimerNotEnabledError(InterviewDomainError): - """Raised when a timer operation is requested but the session has no limit.""" - - def __init__(self, interview_id: str) -> None: - """Initialize with the interview ID. - - Args: - interview_id: Interview UUID. - """ - self.interview_id = interview_id - super().__init__(f"Question timer is not enabled for interview: {interview_id}") - - -class QuestionTimerNotExpiredError(InterviewDomainError): - """Raised when a timeout is submitted before the round 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"Question timer has not expired: interview={interview_id}, " - f"question={question_id}" - ) - - -class AnswerNotFoundError(InterviewDomainError): - """Raised when a specific answer 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: Answer round (0 = initial). - """ - self.interview_id = interview_id - self.question_id = question_id - self.round_num = round_num - super().__init__( - f"Answer not found: interview={interview_id}, " - f"question={question_id}, round={round_num}" - ) diff --git a/app/interview/domain/serialization.py b/app/interview/domain/serialization.py index cdba8b8..77d2480 100644 --- a/app/interview/domain/serialization.py +++ b/app/interview/domain/serialization.py @@ -88,6 +88,7 @@ def session_to_spec(session: SessionSelection) -> str: payload = { "version": SESSION_SPEC_VERSION, "session_mode": session.session_mode, + "exclude_known": session.exclude_known, "theory": _branch_to_payload(session.theory), "coding": _branch_to_payload(session.coding), } @@ -225,6 +226,7 @@ def _normalize_session_selection(session: SessionSelection) -> SessionSelection: return session return SessionSelection( session_mode=session.session_mode, + exclude_known=session.exclude_known, theory=SectionBranchSpec( enabled=theory_enabled, question_count=session.theory.question_count, @@ -267,12 +269,16 @@ def session_from_payload( 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") + exclude_known = data.get("exclude_known", True) + if not isinstance(exclude_known, bool): + raise ValueError("Invalid selection_spec: exclude_known must be boolean") 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] + exclude_known=exclude_known, theory=_parse_branch_payload( theory_raw, branch_name="theory", diff --git a/app/interview/domain/value_objects.py b/app/interview/domain/value_objects.py index 3dade4d..26d1a1a 100644 --- a/app/interview/domain/value_objects.py +++ b/app/interview/domain/value_objects.py @@ -14,6 +14,8 @@ "coding_then_theory", ] +SectionKind = Literal["theory", "coding"] + SESSION_MODE_LABELS: dict[SessionMode, str] = { "theory_only": "Theory", "coding_only": "Coding", @@ -110,11 +112,13 @@ class SessionSelection: 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). + exclude_known: When True, omit known question IDs during planning. """ session_mode: SessionMode theory: SectionBranchSpec coding: SectionBranchSpec + exclude_known: bool = True @classmethod def theory_only( @@ -123,6 +127,7 @@ def theory_only( sources: tuple[TrackSelection, ...], question_count: int = 5, task_time_limit_seconds: int | None = None, + exclude_known: bool = True, ) -> SessionSelection: """Build a theory-only session selection. @@ -130,6 +135,7 @@ def theory_only( 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. + exclude_known: Whether to omit known question IDs during planning. Returns: Session selection with coding disabled. @@ -148,6 +154,7 @@ def theory_only( task_time_limit_seconds=None, sources=(), ), + exclude_known=exclude_known, ) @property diff --git a/app/interview/repositories/interview.py b/app/interview/repositories/interview.py index 3c22f38..3f96006 100644 --- a/app/interview/repositories/interview.py +++ b/app/interview/repositories/interview.py @@ -8,16 +8,13 @@ from sqlalchemy import func from sqlalchemy.orm import Session, selectinload -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 ( interview_from_orm, - interview_read_from_orm, interview_shell_to_orm, interview_to_orm_fields, ) -from app.interview.schemas.interview import InterviewRead from app.shared.infrastructure.models import Interview, TheorySection from app.shared.repositories.base import SqlAlchemyRepository @@ -73,20 +70,22 @@ def get_aggregate(self, entity_id: str) -> DomainInterview | None: return None return interview_from_orm(orm_interview) - def get_read_model(self, entity_id: str) -> InterviewRead | None: - """Load a composed interview read model with theory tasks. + def list_recent_aggregates(self, limit: int = 20) -> list[DomainInterview]: + """Return recent interview shell aggregates, newest first. + + Sort key is ``completed_at`` when set, otherwise ``started_at``. Args: - entity_id: The session UUID. + limit: Maximum number of rows to return. Returns: - Interview read model, or None when the session does not exist. + Domain shell aggregates in dashboard display order. """ - 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) + sort_key = func.coalesce(Interview.completed_at, Interview.started_at) + rows = ( + self._session.query(Interview).order_by(sort_key.desc()).limit(limit).all() + ) + return [interview_from_orm(row) for row in rows] def create_shell(self, interview: DomainInterview) -> DomainInterview: """Insert a new interview shell row. @@ -123,35 +122,3 @@ def save_aggregate(self, interview: DomainInterview) -> None: for field, value in interview_to_orm_fields(interview).items(): setattr(orm_interview, field, value) - - 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``. - - Args: - limit: Maximum number of rows to return. - - Returns: - 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.theory_section).selectinload( - TheorySection.tasks - ), - ) - .order_by(sort_key.desc()) - .limit(limit) - .all() - ) - 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/known_questions.py b/app/interview/repositories/known_questions.py new file mode 100644 index 0000000..0d8e1f9 --- /dev/null +++ b/app/interview/repositories/known_questions.py @@ -0,0 +1,101 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Repository for known bank-item exclusions.""" + +from __future__ import annotations + +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session + +from app.interview.domain.value_objects import SectionKind +from app.shared.infrastructure.models import KnownQuestion +from app.shared.repositories.base import SqlAlchemyRepository + + +class KnownQuestionsRepository(SqlAlchemyRepository[KnownQuestion]): + """Repository for ``known_questions`` rows. + + Attributes: + _session: Active SQLAlchemy Session (inherited). + """ + + _model = KnownQuestion + + def __init__(self, session: Session) -> None: + """Initialize the repository. + + Args: + session: Active SQLAlchemy Session. + """ + super().__init__(session) + + def list_ids(self, branch: SectionKind) -> frozenset[str]: + """Return bank item IDs marked as known for a branch. + + Args: + branch: ``theory`` or ``coding``. + + Returns: + Frozenset of excluded bank item IDs. + """ + rows = self._session.scalars( + select(KnownQuestion.bank_item_id).where(KnownQuestion.branch == branch) + ).all() + return frozenset(rows) + + def list_all_grouped(self) -> dict[str, list[str]]: + """Return all known bank item IDs grouped by branch. + + Returns: + Dict with ``theory`` and ``coding`` keys mapping to sorted ID lists. + """ + rows = self._session.scalars( + select(KnownQuestion).order_by( + KnownQuestion.branch, KnownQuestion.bank_item_id + ) + ).all() + grouped: dict[str, list[str]] = {"theory": [], "coding": []} + for row in rows: + grouped.setdefault(row.branch, []).append(row.bank_item_id) + return grouped + + def count(self) -> int: + """Return total number of known bank item rows. + + Returns: + Row count across both branches. + """ + return ( + self._session.scalar(select(func.count()).select_from(KnownQuestion)) or 0 + ) + + def mark(self, branch: SectionKind, bank_item_id: str) -> None: + """Insert a known bank item row if it does not exist. + + Args: + branch: ``theory`` or ``coding``. + bank_item_id: ID from the YAML bank for that branch. + """ + existing = self._session.scalar( + select(KnownQuestion).where( + KnownQuestion.branch == branch, + KnownQuestion.bank_item_id == bank_item_id, + ) + ) + if existing is None: + self._session.add(KnownQuestion(branch=branch, bank_item_id=bank_item_id)) + self._session.flush() + + def unmark(self, branch: SectionKind, bank_item_id: str) -> None: + """Remove a known bank item row if present. + + Args: + branch: ``theory`` or ``coding``. + bank_item_id: ID from the YAML bank for that branch. + """ + self._session.execute( + delete(KnownQuestion).where( + KnownQuestion.branch == branch, + KnownQuestion.bank_item_id == bank_item_id, + ) + ) diff --git a/app/interview/repositories/mappers.py b/app/interview/repositories/mappers.py index 2ab5dc6..bd30244 100644 --- a/app/interview/repositories/mappers.py +++ b/app/interview/repositories/mappers.py @@ -17,10 +17,6 @@ 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.shared.infrastructure.models import Interview as OrmInterview from app.theory.domain.entities import TheorySection as DomainTheorySection from app.theory.repositories.mappers import ( @@ -43,29 +39,6 @@ def _question_ids_to_json(question_ids: tuple[str, ...]) -> str: return json.dumps(list(question_ids), separators=(",", ":")) -def _resolve_completed_score( - shell: DomainInterview, - theory: DomainTheorySection | None, - coding: DomainCodingSection | None, -) -> int | None: - """Resolve display score for a completed session read model. - - Args: - shell: Interview shell aggregate. - theory: Theory section aggregate, if present. - coding: Coding section aggregate, if present. - - Returns: - Display score from feedback or section totals, or None while active. - """ - 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 interview_shell_from_orm(interview: OrmInterview) -> DomainInterview: """Map an ORM interview row to a shell domain aggregate. @@ -103,10 +76,8 @@ def compose_interview_read( coding: Coding section aggregate, used for coding-only score fallback. Returns: - Immutable InterviewRead for services, API, and templates. + Immutable InterviewRead without a resolved display score. """ - score = _resolve_completed_score(shell, theory, coding) - if theory is None: return InterviewRead( id=shell.id, @@ -117,7 +88,6 @@ def compose_interview_read( 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, @@ -134,7 +104,6 @@ def compose_interview_read( 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, @@ -176,20 +145,6 @@ def interview_read_from_orm( return compose_interview_read(shell, theory, coding) -def interview_to_read(interview: DomainInterview) -> InterviewRead: - """Map a shell aggregate to a minimal read model without theory tasks. - - Prefer ``compose_interview_read`` when section data is available. - - Args: - interview: Interview shell aggregate. - - Returns: - Interview read model without answers. - """ - return compose_interview_read(interview, None) - - def interview_shell_to_orm(interview: DomainInterview) -> OrmInterview: """Map a new interview shell to a detached ORM row. diff --git a/app/interview/repositories/uow.py b/app/interview/repositories/uow.py index 40be0ae..2cc92d6 100644 --- a/app/interview/repositories/uow.py +++ b/app/interview/repositories/uow.py @@ -1,17 +1,19 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Interview feature unit of work with repository accessors.""" +"""Application-wide 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.interview.repositories.interview import InterviewRepository +from app.interview.repositories.known_questions import KnownQuestionsRepository from app.shared.infrastructure.uow import UnitOfWork from app.theory.repositories.theory_section import TheorySectionRepository class InterviewUnitOfWork(UnitOfWork): - """Unit of Work exposing the interview repository. + """Unit of Work for interview shell and all section persistence. Usage:: @@ -23,7 +25,7 @@ class InterviewUnitOfWork(UnitOfWork): """ def __init__(self, auto_commit: bool = False) -> None: - """Initialize the interview unit of work. + """Initialize the application unit of work. Args: auto_commit: If True, commit on successful context exit. @@ -32,6 +34,8 @@ def __init__(self, auto_commit: bool = False) -> None: self._interviews_repo: InterviewRepository | None = None self._theory_sections_repo: TheorySectionRepository | None = None self._coding_sections_repo: CodingSectionRepository | None = None + self._code_run_attempts_repo: CodeRunAttemptRepository | None = None + self._known_questions_repo: KnownQuestionsRepository | None = None @property def interviews(self) -> InterviewRepository: @@ -53,3 +57,17 @@ def coding_sections(self) -> CodingSectionRepository: 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 + + @property + def known_questions(self) -> KnownQuestionsRepository: + """Access the ``KnownQuestionsRepository`` bound to this UoW.""" + if self._known_questions_repo is None: + self._known_questions_repo = KnownQuestionsRepository(self.session) + return self._known_questions_repo diff --git a/app/interview/schemas/known_questions.py b/app/interview/schemas/known_questions.py new file mode 100644 index 0000000..3df9b2d --- /dev/null +++ b/app/interview/schemas/known_questions.py @@ -0,0 +1,31 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Wire models for known bank-item exclusion API.""" + +from pydantic import BaseModel, Field + +from app.interview.domain.value_objects import SectionKind + + +class KnownItemMutation(BaseModel): + """Request body for marking or unmarking a known bank item. + + Attributes: + branch: Section branch (``theory`` or ``coding``). + item_id: ID from the YAML bank for that branch. + """ + + branch: SectionKind + item_id: str = Field(min_length=1) + + +class KnownItemsResponse(BaseModel): + """Known bank item IDs grouped by section branch. + + Attributes: + theory: Theory question IDs marked as known. + coding: Coding task IDs marked as known. + """ + + theory: list[str] + coding: list[str] diff --git a/app/interview/services/bank_text.py b/app/interview/services/bank_text.py new file mode 100644 index 0000000..347a0ee --- /dev/null +++ b/app/interview/services/bank_text.py @@ -0,0 +1,97 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Resolve known bank item IDs to their question text from the YAML banks.""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache + +from app.shared import coding as coding_bank +from app.shared import questions as theory_bank + + +@dataclass(frozen=True, slots=True) +class KnownQuestionView: + """Display row for a known bank item on the management page. + + Attributes: + id: Bank item ID from the YAML bank. + text: Resolved question or task text, or the ID when unresolved. + """ + + id: str + text: str + + +@lru_cache(maxsize=1) +def _theory_text_index() -> dict[str, str]: + """Build an ``id -> text`` map across the whole theory bank. + + The result is cached for the process lifetime: the YAML banks ship with the + image and do not change at runtime, so parsing every file on each request is + unnecessary. The returned mapping is read-only for callers. + + Returns: + Mapping of theory question IDs to their text (first occurrence wins). + """ + index: dict[str, str] = {} + for track in theory_bank.list_tracks(): + for level in theory_bank.list_levels(track): + for category in theory_bank.list_categories(track, level): + for question in theory_bank.load_category(track, level, category): + index.setdefault(question.id, question.text) + return index + + +@lru_cache(maxsize=1) +def _coding_text_index() -> dict[str, str]: + """Build an ``id -> text`` map across the whole coding bank. + + The result is cached for the process lifetime: the YAML banks ship with the + image and do not change at runtime, so parsing every file on each request is + unnecessary. The returned mapping is read-only for callers. + + Returns: + Mapping of coding task IDs to their text (first occurrence wins). + """ + index: dict[str, str] = {} + for track in coding_bank.list_tracks(): + for level in coding_bank.list_levels(track): + for category in coding_bank.list_categories(track, level): + for task in coding_bank.load_category(track, level, category): + index.setdefault(task.id, task.text) + return index + + +def _to_views(item_ids: list[str], index: dict[str, str]) -> list[KnownQuestionView]: + """Map bank item IDs to display rows using a text index. + + Args: + item_ids: Bank item IDs marked as known. + index: ``id -> text`` map for the matching bank. + + Returns: + Display rows; the ID is used as text when it is missing from the bank. + """ + return [ + KnownQuestionView(id=item_id, text=index.get(item_id, item_id)) + for item_id in item_ids + ] + + +def resolve_known_views( + grouped: dict[str, list[str]], +) -> dict[str, list[KnownQuestionView]]: + """Enrich grouped known item IDs with their bank text. + + Args: + grouped: Known item IDs grouped by ``theory`` and ``coding`` branches. + + Returns: + Display rows grouped by the same branches. + """ + return { + "theory": _to_views(grouped.get("theory", []), _theory_text_index()), + "coding": _to_views(grouped.get("coding", []), _coding_text_index()), + } diff --git a/app/interview/services/completion.py b/app/interview/services/completion.py index 7416f0a..94b23df 100644 --- a/app/interview/services/completion.py +++ b/app/interview/services/completion.py @@ -24,6 +24,7 @@ InterviewCompletedEvent, InterviewEvent, ) +from app.interview.services.read_model import load_interview_read 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 @@ -35,8 +36,16 @@ class SessionCompletionService: """Service for completing interview sessions.""" - @staticmethod + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this workflow. + """ + self._uow = uow + async def complete_session( + self, interview_id: str, provider: AIProvider, ) -> list[InterviewEvent]: @@ -57,24 +66,25 @@ async def complete_session( Raises: InterviewNotFoundError: If the interview does not exist. """ - with InterviewUnitOfWork() as uow: - aggregate = uow.interviews.get_aggregate(interview_id) - if aggregate is None: - raise InterviewNotFoundError(interview_id) + aggregate = self._uow.interviews.get_aggregate(interview_id) + if aggregate is None: + raise InterviewNotFoundError(interview_id) + try: locale = aggregate.locale session = aggregate.selection + theory_query = TheoryQueryService(self._uow) + coding_query = CodingQueryService(self._uow) + theory_section = TheorySectionService(self._uow, query=theory_query) + coding_section = CodingSectionService(self._uow, query=coding_query) + sources_parts: list[str] = [] if session.theory.enabled: - theory_sources = TheoryQueryService.sources_text_for_section( - interview_id - ) + theory_sources = theory_query.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 - ) + coding_sources = coding_query.sources_text_for_section(interview_id) if coding_sources: sources_parts.append(coding_sources) sources_text = ( @@ -83,68 +93,63 @@ async def complete_session( 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, + await theory_section.ensure_section_feedback(interview_id) + await coding_section.ensure_section_feedback(interview_id) + + theory_summary = theory_query.get_evaluation_summary(interview_id) + if ( + theory_summary is not None + and session.theory.enabled + and not theory_section.is_complete(interview_id) + ): + theory_summary = replace(theory_summary, skipped=True) + + coding_summary = coding_query.get_evaluation_summary(interview_id) + if ( + coding_summary is not None + and session.coding.enabled + and not coding_section.is_complete(interview_id) + ): + coding_summary = replace(coding_summary, skipped=True) + + merged = SessionEvaluationAggregator.merge(theory_summary, coding_summary) + + events: list[InterviewEvent] = [EvaluatingEvent()] + + session_eval = await SessionEvaluatorService.evaluate_session( + merged, + provider=provider, + locale=locale, + sources_text=sources_text, ) + interview_eval = attach_session_score_breakdown(session_eval, merged) - merged = SessionEvaluationAggregator.merge(theory_summary, coding_summary) - - events: list[InterviewEvent] = [EvaluatingEvent()] - - session_eval = await SessionEvaluatorService.evaluate_session( - merged, - provider=provider, - locale=locale, - sources_text=sources_text, - ) - interview_eval = attach_session_score_breakdown(session_eval, merged) - - with InterviewUnitOfWork(auto_commit=True) as uow: - aggregate = uow.interviews.get_aggregate(interview_id) + aggregate = self._uow.interviews.get_aggregate(interview_id) if aggregate is None: raise InterviewNotFoundError(interview_id) completed = aggregate.with_session_completed(interview_eval.model_dump()) - uow.interviews.save_aggregate(completed) - interview_read = uow.interviews.get_read_model(interview_id) + self._uow.interviews.save_aggregate(completed) + interview_read = load_interview_read(self._uow, 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 - ) - events.append( - InterviewCompletedEvent( - overall_feedback=interview_eval.model_dump(), - score=score, - max_score=max_score, + max_score = DashboardBuilder.compute_max_score( + interview_read, + interview_eval.score_breakdown or None, + uow=self._uow, + ) + events.append( + InterviewCompletedEvent( + overall_feedback=interview_eval.model_dump(), + score=score, + max_score=max_score, + ) ) - ) - return events + self._uow.commit() + return events + except Exception: + self._uow.rollback() + raise diff --git a/app/interview/services/creation.py b/app/interview/services/creation.py index 07d0c62..f254576 100644 --- a/app/interview/services/creation.py +++ b/app/interview/services/creation.py @@ -13,6 +13,8 @@ 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.known_questions import KnownQuestionsService +from app.interview.services.read_model import load_interview_read from app.interview.services.sections import ( is_first_user_facing_section, phase_order_for_mode, @@ -39,8 +41,16 @@ def _initial_coding_status(session_mode: SessionMode) -> CodingSectionStatus: class SessionCreationService: """Orchestrates interview shell and section creation.""" - @staticmethod + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this workflow. + """ + self._uow = uow + def create_session( + self, session: SessionSelection, locale: str = "en", ) -> InterviewRead: @@ -68,37 +78,48 @@ def create_session( locale=locale, ) - with InterviewUnitOfWork(auto_commit=True) as uow: - 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, - start_first_task_timer=is_first_user_facing_section( - session.session_mode, - "theory", - ), - 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 + known_service = KnownQuestionsService(self._uow) + theory_excluded = ( + known_service.list_ids("theory") + if session.exclude_known and session.theory.enabled + else frozenset() + ) + coding_excluded = ( + known_service.list_ids("coding") + if session.exclude_known and session.coding.enabled + else frozenset() + ) + + self._uow.interviews.create_shell(shell) + if session.theory.enabled: + TheorySectionCreationService(self._uow).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, + start_first_task_timer=is_first_user_facing_section( + session.session_mode, + "theory", + ), + excluded_ids=theory_excluded, + ) + if session.coding.enabled: + planned_tasks = build_coding_task_plan( + session.coding_selection, + session.coding.question_count, + locale=locale, + excluded_ids=coding_excluded, + ) + CodingSectionCreationService(self._uow).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), + ) + read_model = load_interview_read(self._uow, 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 9ca83d4..ef754a1 100644 --- a/app/interview/services/dashboard.py +++ b/app/interview/services/dashboard.py @@ -5,12 +5,13 @@ from datetime import UTC, datetime from typing import Any -from app.coding.repositories.uow import CodingUnitOfWork +from app.coding.domain.entities import CodingSection 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.read_model import load_recent_interview_reads from app.interview.services.rules.selection import ( selection_summary_lines, session_display_title, @@ -21,6 +22,10 @@ class DashboardBuilder: """Build dashboard rows and display helpers for interview history.""" + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work.""" + self._uow = uow + @staticmethod def format_local_datetime(dt: datetime | None) -> str: """Format a timezone-aware datetime in the local timezone. @@ -78,6 +83,9 @@ def _max_score_from_breakdown(score_breakdown: dict[str, Any]) -> int: def compute_max_score( interview: InterviewRead, score_breakdown: dict[str, Any] | None = None, + *, + uow: InterviewUnitOfWork | None = None, + coding_section: CodingSection | None = None, ) -> int: """Compute maximum achievable score for a session. @@ -87,6 +95,9 @@ def compute_max_score( Args: interview: Interview read model with answers loaded. score_breakdown: Optional per-question breakdown from session evaluation. + uow: Unit of work used to load the coding section on demand. + coding_section: Pre-loaded coding section that takes precedence over + ``uow`` (used to avoid per-row queries in batched contexts). Returns: Maximum possible score for the session. @@ -117,10 +128,11 @@ def compute_max_score( return theory_max if session.coding.enabled: - with CodingUnitOfWork() as uow: + section = coding_section + if section is None and uow is not None: section = uow.coding_sections.get_aggregate(interview.id) - if section is not None: - return section.max_score() + if section is not None: + return section.max_score() if interview.question_count > 0: return interview.question_count * TheorySection.MAX_SCORE_PER_ROUND @@ -138,8 +150,7 @@ def selection_summary_lines(selection: InterviewSelection) -> list[str]: """ return selection_summary_lines(selection) - @staticmethod - def list_rows(limit: int = 20) -> list[DashboardRowRead]: + def list_rows(self, limit: int = 20) -> list[DashboardRowRead]: """Load recent interviews for the dashboard history table. Args: @@ -148,15 +159,21 @@ def list_rows(limit: int = 20) -> list[DashboardRowRead]: Returns: Rows sorted newest-first (completed or started time). """ - with InterviewUnitOfWork() as uow: - interviews = uow.interviews.list_recent_read_models(limit=limit) + interviews = load_recent_interview_reads(self._uow, limit=limit) + coding_sections = self._uow.coding_sections.get_aggregates_by_interview_ids( + [interview.id for interview in interviews] + ) rows: list[DashboardRowRead] = [] for interview in interviews: if interview.status == "completed": feedback = interview.overall_feedback breakdown = feedback.get("score_breakdown") if feedback else None - max_score = DashboardBuilder.compute_max_score(interview, breakdown) + max_score = DashboardBuilder.compute_max_score( + interview, + breakdown, + coding_section=coding_sections.get(interview.id), + ) score = interview.score if interview.score is not None else 0 score_display = f"{score} / {max_score}" status_label = "Completed" diff --git a/app/interview/services/known_questions.py b/app/interview/services/known_questions.py new file mode 100644 index 0000000..ec18c46 --- /dev/null +++ b/app/interview/services/known_questions.py @@ -0,0 +1,79 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Service for managing known bank-item exclusions.""" + +from __future__ import annotations + +from app.interview.domain.value_objects import SectionKind +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.services.bank_text import KnownQuestionView, resolve_known_views + + +class KnownQuestionsService: + """Read and update the instance-wide known bank items list. + + Attributes: + _uow: Application unit of work for persistence. + """ + + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this workflow. + """ + self._uow = uow + + def list_ids(self, branch: SectionKind) -> frozenset[str]: + """Return bank item IDs marked as known for a branch. + + Args: + branch: ``theory`` or ``coding``. + + Returns: + Frozenset of excluded bank item IDs. + """ + return self._uow.known_questions.list_ids(branch) + + def mark_known(self, branch: SectionKind, item_id: str) -> None: + """Mark a bank item as known for future session exclusion. + + Args: + branch: ``theory`` or ``coding``. + item_id: ID from the YAML bank for that branch. + """ + self._uow.known_questions.mark(branch, item_id) + + def unmark(self, branch: SectionKind, item_id: str) -> None: + """Remove a bank item from the known list. + + Args: + branch: ``theory`` or ``coding``. + item_id: ID from the YAML bank for that branch. + """ + self._uow.known_questions.unmark(branch, item_id) + + def list_all(self) -> dict[str, list[str]]: + """Return all known bank item IDs grouped by branch. + + Returns: + Dict with ``theory`` and ``coding`` keys mapping to ID lists. + """ + return self._uow.known_questions.list_all_grouped() + + def list_all_with_text(self) -> dict[str, list[KnownQuestionView]]: + """Return all known bank items grouped by branch, enriched with text. + + Returns: + Dict with ``theory`` and ``coding`` keys mapping to display rows + that pair each bank item ID with its resolved question text. + """ + return resolve_known_views(self._uow.known_questions.list_all_grouped()) + + def count(self) -> int: + """Return total number of known bank item rows. + + Returns: + Row count across both branches. + """ + return self._uow.known_questions.count() diff --git a/app/interview/services/page.py b/app/interview/services/page.py index a1cd9f9..9c117a3 100644 --- a/app/interview/services/page.py +++ b/app/interview/services/page.py @@ -8,6 +8,7 @@ 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.repositories.uow import InterviewUnitOfWork from app.interview.schemas.interview import InterviewPageContext, InterviewRead from app.interview.services.dashboard import DashboardBuilder from app.interview.services.phases import SessionPhaseOrchestrator @@ -27,6 +28,7 @@ ) from app.speech.services.page import SpeechModelPageService from app.speech.services.whisper_model import WhisperModelService +from app.theory.schemas.page import TheoryPageContext from app.theory.services.page import TheoryPageService @@ -48,8 +50,11 @@ class SessionPageRender: class SessionPageService: """Compose session shell and section contexts for the interview page.""" - @staticmethod - def load_interview(interview_id: str) -> InterviewRead | None: + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work.""" + self._uow = uow + + def load_interview(self, interview_id: str) -> InterviewRead | None: """Load a session and start the active section timer when applicable. Args: @@ -58,15 +63,16 @@ def load_interview(interview_id: str) -> InterviewRead | None: Returns: Interview read model, or None when not found. """ - active = SessionPhaseOrchestrator.active_phase(interview_id) + orchestrator = SessionPhaseOrchestrator(self._uow) + active = orchestrator.active_phase(interview_id) if active == "theory": - TheoryPageService.activate_timer(interview_id) + TheoryPageService(self._uow).activate_timer(interview_id) elif active == "coding": - CodingPageService.activate_timer(interview_id) - return InterviewQuery.get_interview(interview_id) + CodingPageService(self._uow).activate_timer(interview_id) + return InterviewQuery(self._uow).get_interview(interview_id) - @staticmethod async def prepare_page( + self, interview_id: str, *, config: AppConfig | None, @@ -82,11 +88,11 @@ async def prepare_page( Returns: Redirect URL or template context for ``interview.html``. """ - interview = SessionPageService.load_interview(interview_id) + interview = self.load_interview(interview_id) if interview is None: return SessionPageRender(redirect_url="/", template_context=None) - template_context = await SessionPageService.build_full_template_context( + template_context = await self.build_full_template_context( interview, config=config, whisper_model_service=whisper_model_service, @@ -103,6 +109,8 @@ def build_page_context( *, config: AppConfig | None, question_voice_enabled: bool, + theory: TheoryPageContext | None = None, + uow: InterviewUnitOfWork | None = None, ) -> InterviewPageContext: """Assemble shell template context for ``interview.html``. @@ -113,11 +121,14 @@ def build_page_context( interview: Loaded interview read model. config: Application config, if configured. question_voice_enabled: Whether Piper TTS is enabled in config. + theory: Optional pre-built theory page context. + uow: Optional active unit of work for coding score fallback. Returns: Frozen page context for the interview template. """ - theory = TheoryPageService.build_context(interview) + if theory is None: + theory = TheoryPageService.build_context_for(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 @@ -137,6 +148,7 @@ def build_page_context( max_score = DashboardBuilder.compute_max_score( interview, score_breakdown if isinstance(score_breakdown, dict) else None, + uow=uow, ) session = parse_session_spec( interview.selection_spec, @@ -172,8 +184,8 @@ def build_page_context( interview_model_accepts_audio=interview_model_accepts_audio, ) - @staticmethod async def build_full_template_context( + self, interview: InterviewRead, *, config: AppConfig | None, @@ -194,12 +206,18 @@ async def build_full_template_context( 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) + theory_service = TheoryPageService(self._uow) + coding_service = CodingPageService(self._uow) + orchestrator = SessionPhaseOrchestrator(self._uow) + theory = theory_service.build_context(interview) + coding = coding_service.build_context(interview.id) + active_phase = orchestrator.active_phase(interview.id) base = SessionPageService.build_page_context( interview, config=config, question_voice_enabled=bool(config and config.question_voice_enabled), + theory=theory, + uow=self._uow, ).model_dump() speech = SpeechModelPageService.build_page_context( config, @@ -217,5 +235,5 @@ async def build_full_template_context( session.session_mode, session.session_mode ), "phase_order": list(phase_order_for_mode(session.session_mode)), - "active_phase": SessionPhaseOrchestrator.active_phase(interview.id), + "active_phase": active_phase, } diff --git a/app/interview/services/phases.py b/app/interview/services/phases.py index 1c6b180..2dc6189 100644 --- a/app/interview/services/phases.py +++ b/app/interview/services/phases.py @@ -2,10 +2,9 @@ # SPDX-License-Identifier: Apache-2.0 """Session phase transitions between interview sections.""" -from app.interview.domain.serialization import parse_session_spec +from app.interview.domain.value_objects import SectionKind from app.interview.repositories.uow import InterviewUnitOfWork from app.interview.services.sections import ( - SectionKind, phase_order_for_mode, section_services, ) @@ -14,8 +13,15 @@ class SessionPhaseOrchestrator: """Coordinate phase completion hooks across interview sections.""" - @staticmethod - def active_phase(interview_id: str) -> SectionKind | None: + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this phase scope. + """ + self._uow = uow + + def active_phase(self, interview_id: str) -> SectionKind | None: """Return the section kind the user should interact with now. Args: @@ -24,13 +30,12 @@ def active_phase(interview_id: str) -> SectionKind | None: 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) + aggregate = self._uow.interviews.get_aggregate(interview_id) + if aggregate is None: + return None + order = phase_order_for_mode(aggregate.session_mode) - services = section_services() + services = section_services(self._uow) for kind in order: services[kind].activate_if_pending(interview_id) if services[kind].is_user_facing(interview_id): @@ -39,30 +44,20 @@ def active_phase(interview_id: str) -> SectionKind | None: return kind return order[-1] if order else None - @staticmethod - def notify_section_complete(interview_id: str, section_kind: SectionKind) -> None: + def notify_section_complete( + self, + interview_id: str, + section_kind: SectionKind, + ) -> None: """Invoke section prefetch hooks when a phase finishes. + Runs on the orchestrator's unit of work so phase transitions do not open + a second SQLite connection while the caller still holds a write lock. + Args: interview_id: Parent interview UUID. section_kind: Section that the user just completed. """ - services = section_services() + services = section_services(self._uow) 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 91f9f09..f45ddfa 100644 --- a/app/interview/services/query.py +++ b/app/interview/services/query.py @@ -8,14 +8,21 @@ from app.interview.domain.exceptions import InterviewNotFoundError from app.interview.repositories.uow import InterviewUnitOfWork from app.interview.schemas.interview import AnswerRead, InterviewRead -from app.theory.repositories.uow import TheoryUnitOfWork +from app.interview.services.read_model import load_interview_read class InterviewQuery: """Read-only queries and view-model helpers for interview sessions.""" - @staticmethod - def get_interview(interview_id: str) -> InterviewRead | None: + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this read scope. + """ + self._uow = uow + + def get_interview(self, interview_id: str) -> InterviewRead | None: """Retrieve an interview session by ID with theory tasks loaded. Args: @@ -24,42 +31,46 @@ def get_interview(interview_id: str) -> InterviewRead | None: Returns: Interview read model with answers loaded, or None if not found. """ - with InterviewUnitOfWork() as uow: - return uow.interviews.get_read_model(interview_id) + return load_interview_read(self._uow, interview_id) @staticmethod - def get_interview_or_raise( - interview_id: str, - *, - uow: InterviewUnitOfWork | None = None, - ) -> InterviewRead: - """Load an interview read model or raise ``InterviewNotFoundError``. + def load(interview_id: str) -> InterviewRead | None: + """Load an interview read model using a short-lived unit of work. + + Args: + interview_id: The session UUID. + + Returns: + Interview read model with answers loaded, or None if not found. + """ + with InterviewUnitOfWork() as uow: + return InterviewQuery(uow).get_interview(interview_id) - When ``uow`` is provided, loads from that unit of work (same DB session). - Otherwise opens a short-lived read-only ``InterviewUnitOfWork``. + def get_active_or_raise(self, interview_id: str) -> InterviewRead: + """Load an active interview read model or raise a domain error. Args: interview_id: The session UUID. - uow: Optional active unit of work for transactional loads. Returns: Interview read model with answers loaded. Raises: InterviewNotFoundError: If the interview does not exist. + InterviewNotActiveError: If the interview is not active. """ - if uow is not None: - interview = uow.interviews.get_read_model(interview_id) - else: - with InterviewUnitOfWork() as read_uow: - interview = read_uow.interviews.get_read_model(interview_id) + shell = self._uow.interviews.get_aggregate(interview_id) + if shell is None: + raise InterviewNotFoundError(interview_id) + shell.ensure_active() + interview = self.get_interview(interview_id) if interview is None: raise InterviewNotFoundError(interview_id) return interview @staticmethod def get_active_interview_or_raise(interview_id: str) -> InterviewRead: - """Load an active interview read model or raise a domain error. + """Load an active interview using a short-lived unit of work. Args: interview_id: The session UUID. @@ -72,33 +83,7 @@ def get_active_interview_or_raise(interview_id: str) -> InterviewRead: InterviewNotActiveError: If the interview is not active. """ with InterviewUnitOfWork() as uow: - 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) - return interview - - @staticmethod - def timer_remaining_seconds(interview_id: str) -> int | None: - """Return seconds left on the current round timer for templates. - - Args: - interview_id: The session 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) + return InterviewQuery(uow).get_active_or_raise(interview_id) @staticmethod def get_current_unanswered(interview: InterviewRead) -> AnswerRead | None: diff --git a/app/interview/services/read_model.py b/app/interview/services/read_model.py new file mode 100644 index 0000000..c0306fb --- /dev/null +++ b/app/interview/services/read_model.py @@ -0,0 +1,86 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Assemble interview read models from domain aggregates.""" + +from __future__ import annotations + +from app.coding.domain.entities import CodingSection as DomainCodingSection +from app.interview.domain.entities import Interview as DomainInterview +from app.interview.repositories.mappers import compose_interview_read +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.schemas.interview import InterviewRead +from app.interview.services.scoring import resolve_completed_read_score +from app.theory.domain.entities import TheorySection as DomainTheorySection + + +def assemble_interview_read( + shell: DomainInterview, + theory: DomainTheorySection | None, + coding: DomainCodingSection | None = None, +) -> InterviewRead: + """Compose an interview read model with a resolved display score. + + Args: + shell: Interview shell aggregate. + theory: Theory section aggregate with tasks, if present. + coding: Coding section aggregate, if present. + + Returns: + Immutable interview read model for services, API, and templates. + """ + read_model = compose_interview_read(shell, theory, coding) + score = resolve_completed_read_score(shell, theory, coding) + if score is None: + return read_model + return read_model.model_copy(update={"score": score}) + + +def load_interview_read( + uow: InterviewUnitOfWork, + interview_id: str, +) -> InterviewRead | None: + """Load a composed interview read model for one session. + + Args: + uow: Active application unit of work. + interview_id: Parent session UUID. + + Returns: + Interview read model, or None when the session does not exist. + """ + shell = uow.interviews.get_aggregate(interview_id) + if shell is None: + return None + theory = uow.theory_sections.get_aggregate(interview_id) + coding = uow.coding_sections.get_aggregate(interview_id) + return assemble_interview_read(shell, theory, coding) + + +def load_recent_interview_reads( + uow: InterviewUnitOfWork, + *, + limit: int = 20, +) -> list[InterviewRead]: + """Load recent interview read models, newest first. + + Args: + uow: Active application unit of work. + limit: Maximum number of sessions to return. + + Returns: + Composed interview read models with theory tasks when present. + """ + shells = uow.interviews.list_recent_aggregates(limit=limit) + if not shells: + return [] + interview_ids = [shell.id for shell in shells] + theory_by_id = uow.theory_sections.get_aggregates_by_interview_ids(interview_ids) + coding_by_id = uow.coding_sections.get_aggregates_by_interview_ids(interview_ids) + return [ + assemble_interview_read( + shell, + theory_by_id.get(shell.id), + coding_by_id.get(shell.id), + ) + for shell in shells + ] diff --git a/app/interview/services/results_page.py b/app/interview/services/results_page.py index 114d105..bc36e1f 100644 --- a/app/interview/services/results_page.py +++ b/app/interview/services/results_page.py @@ -8,11 +8,12 @@ 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.domain.value_objects import SectionKind, 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.read_model import load_interview_read from app.interview.services.rules.selection import session_selection_summary_lines from app.interview.services.section_review_support import ( item_id_key_for, @@ -20,7 +21,6 @@ ) from app.interview.services.sections import ( SectionEvaluationSummary, - SectionKind, phase_order_for_mode, section_services, ) @@ -48,6 +48,14 @@ class SessionResultsRender: class SessionResultsPageService: """Compose the completed session results hub template context.""" + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this page scope. + """ + self._uow = uow + @staticmethod def _section_summary_text( section_key: SectionKind, @@ -116,8 +124,7 @@ def _section_card( detail_url=f"/interview/{interview_id}/{kind}", ) - @staticmethod - def build_context(interview: InterviewRead) -> SessionResultsContext | None: + def build_context(self, interview: InterviewRead) -> SessionResultsContext | None: """Assemble results hub context for a completed session. Args: @@ -134,9 +141,10 @@ def build_context(interview: InterviewRead) -> SessionResultsContext | None: max_score = DashboardBuilder.compute_max_score( interview, breakdown if isinstance(breakdown, dict) else None, + uow=self._uow, ) - services = section_services() + services = section_services(self._uow) section_cards: list[SectionResultCard] = [] theory_review_url: str | None = None coding_review_url: str | None = None @@ -145,7 +153,7 @@ def build_context(interview: InterviewRead) -> SessionResultsContext | None: summary = services[kind].get_evaluation_summary(interview.id) if summary is None: continue - card = SessionResultsPageService._section_card( + card = self._section_card( interview.id, kind, summary, @@ -170,8 +178,7 @@ def build_context(interview: InterviewRead) -> SessionResultsContext | None: coding_review_url=coding_review_url, ) - @staticmethod - def prepare_page(interview_id: str) -> SessionResultsRender: + def prepare_page(self, interview_id: str) -> SessionResultsRender: """Load a completed session and build the results hub context. Args: @@ -180,8 +187,7 @@ def prepare_page(interview_id: str) -> SessionResultsRender: Returns: Redirect URL or template context for ``session_results.html``. """ - with InterviewUnitOfWork() as uow: - interview = uow.interviews.get_read_model(interview_id) + interview = load_interview_read(self._uow, interview_id) if interview is None: return SessionResultsRender(redirect_url="/", template_context=None) @@ -191,7 +197,7 @@ def prepare_page(interview_id: str) -> SessionResultsRender: template_context=None, ) - context = SessionResultsPageService.build_context(interview) + context = self.build_context(interview) if context is None: return SessionResultsRender( redirect_url=f"/interview/{interview_id}", diff --git a/app/interview/services/rules/bank_selection.py b/app/interview/services/rules/bank_selection.py new file mode 100644 index 0000000..4602211 --- /dev/null +++ b/app/interview/services/rules/bank_selection.py @@ -0,0 +1,101 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Shared validation for theory and coding YAML bank selections.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from app.interview.domain.value_objects import InterviewSelection + +_TRACK_LABELS: dict[str, str] = { + "python": "Python", + "database": "Database / SQL", + "system-design": "System Design", +} + + +def track_label(slug: str) -> str: + """Return a human-readable label for a question-bank track slug. + + Args: + slug: Track directory name under ``data/questions/``. + + Returns: + Display label for templates and UI. + """ + return _TRACK_LABELS.get(slug, slug.replace("-", " ").replace("_", " ").title()) + + +@dataclass(frozen=True, slots=True) +class BankCatalog: + """Filesystem accessors for a YAML question or coding task bank. + + Attributes: + list_tracks: Return top-level track slugs. + list_levels: Return level slugs for a track. + list_categories: Return category slugs for a track and level. + """ + + list_tracks: Callable[[], list[str]] + list_levels: Callable[[str], list[str]] + list_categories: Callable[[str, str], list[str]] + + +@dataclass(frozen=True, slots=True) +class BankSelectionMessages: + """User-facing validation messages for a bank selection. + + Attributes: + empty_sources: Raised when no track sources are selected. + unknown_track: Format an unknown track slug. + unknown_level: Format an unknown level for a track. + empty_categories: Format a missing topic selection for a track. + unknown_category: Format an unknown topic for a track and level. + """ + + empty_sources: str + unknown_track: Callable[[str], str] + unknown_level: Callable[[str, str], str] + empty_categories: Callable[[str], str] + unknown_category: Callable[[str, str, str], str] + + +def validate_bank_selection( + selection: InterviewSelection, + catalog: BankCatalog, + messages: BankSelectionMessages, +) -> None: + """Validate selection against an on-disk YAML bank layout. + + Args: + selection: Parsed interview branch selection. + catalog: Bank filesystem accessors. + messages: User-facing error message templates. + + Raises: + ValueError: If selection is empty or references unknown bank paths. + """ + if not selection.sources: + raise ValueError(messages.empty_sources) + + tracks = set(catalog.list_tracks()) + for source in selection.sources: + if source.track not in tracks: + raise ValueError(messages.unknown_track(source.track)) + levels = set(catalog.list_levels(source.track)) + if source.level not in levels: + raise ValueError(messages.unknown_level(source.level, source.track)) + if not source.categories: + raise ValueError(messages.empty_categories(source.track)) + available = set(catalog.list_categories(source.track, source.level)) + for category in source.categories: + if category not in available: + raise ValueError( + messages.unknown_category( + category, + source.track, + source.level, + ) + ) diff --git a/app/interview/services/rules/selection.py b/app/interview/services/rules/selection.py index 39acfe7..87376bc 100644 --- a/app/interview/services/rules/selection.py +++ b/app/interview/services/rules/selection.py @@ -23,24 +23,7 @@ TrackQuestionPools, TrackSelection, ) - -_TRACK_LABELS: dict[str, str] = { - "python": "Python", - "database": "Database / SQL", - "system-design": "System Design", -} - - -def track_label(slug: str) -> str: - """Return a human-readable label for a question-bank track slug. - - Args: - slug: Track directory name under ``data/questions/``. - - Returns: - Display label for templates and UI. - """ - return _TRACK_LABELS.get(slug, slug.replace("-", " ").replace("_", " ").title()) +from app.interview.services.rules.bank_selection import track_label def validate_question_count(selection: InterviewSelection, question_count: int) -> None: @@ -221,19 +204,73 @@ def parse_session_json(raw_json: str) -> SessionSelection: return session -def parse_selection_json(raw_json: str) -> InterviewSelection: - """Parse setup form JSON and return theory sources only. +def _filter_track_pools( + track_pools: list[TrackQuestionPools], + excluded_ids: frozenset[str], +) -> list[TrackQuestionPools]: + """Remove excluded question IDs from loaded track pools. Args: - raw_json: JSON string from POST body. + track_pools: Loaded pools in selection source order. + excluded_ids: Question IDs to exclude from planning. Returns: - Validated theory interview selection. + Pools with excluded IDs removed from full and category pools. + """ + if not excluded_ids: + return track_pools + filtered: list[TrackQuestionPools] = [] + for pools in track_pools: + filtered.append( + TrackQuestionPools( + source=pools.source, + full_pool=tuple( + question + for question in pools.full_pool + if question.id not in excluded_ids + ), + category_pools={ + category: tuple( + question for question in pool if question.id not in excluded_ids + ) + for category, pool in pools.category_pools.items() + }, + ) + ) + return filtered + + +def _validate_filtered_pools( + track_pools: list[TrackQuestionPools], + question_count: int, +) -> None: + """Ensure filtered pools can satisfy the requested question count. + + Args: + track_pools: Pools after excluded-ID filtering. + question_count: Target number of questions for the session. Raises: - ValueError: If JSON is invalid or selection fails validation. + ValueError: If a category is empty or too few questions remain. """ - return parse_session_json(raw_json).theory_selection + available_ids: set[str] = set() + for pools in track_pools: + source = pools.source + for category in source.categories: + category_pool = pools.category_pools.get(category, ()) + if not category_pool: + raise ValueError( + f"All questions in {source.track}/{source.level}/{category} " + "are marked as known" + ) + if not pools.full_pool: + raise ValueError(f"No questions found for {source.track}/{source.level}") + available_ids.update(question.id for question in pools.full_pool) + if len(available_ids) < question_count: + raise ValueError( + f"Not enough unfamiliar questions: {len(available_ids)} available, " + f"{question_count} requested" + ) def _allocate_proportional(sizes: list[int], total: int) -> list[int]: @@ -266,6 +303,8 @@ def plan_questions( selection: InterviewSelection, question_count: int, track_pools: list[TrackQuestionPools], + *, + excluded_ids: frozenset[str] = frozenset(), ) -> list[PlannedQuestion]: """Build ordered question list from pre-loaded pools. @@ -277,6 +316,7 @@ def plan_questions( selection: Validated interview selection. question_count: Target number of questions (>= topic count). track_pools: Loaded pools in the same order as ``selection.sources``. + excluded_ids: Question IDs to remove from pools before planning. Returns: Ordered list of Question instances. @@ -287,6 +327,10 @@ def plan_questions( if len(track_pools) != len(selection.sources): raise ValueError("track_pools must match selection.sources") + filtered_pools = _filter_track_pools(track_pools, excluded_ids) + _validate_filtered_pools(filtered_pools, question_count) + track_pools = filtered_pools + picked: list[PlannedQuestion] = [] picked_ids: set[str] = set() question_track: dict[str, str] = {} diff --git a/app/interview/services/scoring.py b/app/interview/services/scoring.py index 545ea35..b522823 100644 --- a/app/interview/services/scoring.py +++ b/app/interview/services/scoring.py @@ -69,3 +69,26 @@ def completed_score_fallback( found = True total += _section_display_score(section) return total if found else None + + +def resolve_completed_read_score( + shell: DomainInterview, + theory: TheorySection | None, + coding: CodingSection | None, +) -> int | None: + """Resolve the display score for a completed session read model. + + Args: + shell: Interview shell aggregate. + theory: Theory section aggregate, if present. + coding: Coding section aggregate, if present. + + Returns: + Display score from feedback or section totals, or None while active. + """ + 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) diff --git a/app/interview/services/section_evaluation.py b/app/interview/services/section_evaluation.py index 57cf160..82b1e9b 100644 --- a/app/interview/services/section_evaluation.py +++ b/app/interview/services/section_evaluation.py @@ -6,7 +6,8 @@ from typing import Any -from app.interview.services.sections import SectionEvaluationSummary, SectionKind +from app.interview.domain.value_objects import SectionKind +from app.interview.services.sections import SectionEvaluationSummary def build_section_evaluation_summary( diff --git a/app/interview/services/section_review_support.py b/app/interview/services/section_review_support.py index 5e2c229..c68cd63 100644 --- a/app/interview/services/section_review_support.py +++ b/app/interview/services/section_review_support.py @@ -8,13 +8,14 @@ from typing import Any from app.interview.domain.serialization import parse_session_spec -from app.interview.domain.value_objects import SessionSelection +from app.interview.domain.value_objects import SectionKind, 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.read_model import load_interview_read 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.interview.services.sections import SectionEvaluationSummary from app.shared.locales import SUPPORTED_LOCALES @@ -31,21 +32,24 @@ class CompletedInterviewSnapshot: session: SessionSelection -def load_completed_interview(interview_id: str) -> CompletedInterviewSnapshot | None: - """Load a completed interview read model in one unit-of-work. +def load_completed_interview( + uow: InterviewUnitOfWork, + interview_id: str, +) -> CompletedInterviewSnapshot | None: + """Load a completed interview read model within an existing unit of work. Args: + uow: Active application unit of work. 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) + interview = load_interview_read(uow, 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( @@ -64,7 +68,7 @@ def section_score_bounds( Returns: Tuple of display score and max score. """ - if skipped: + if skipped and total_score == 0 and max_score == 0: return 0, 0 return total_score, max_score diff --git a/app/interview/services/section_service_support.py b/app/interview/services/section_service_support.py index b5ffdc5..0fbe9fb 100644 --- a/app/interview/services/section_service_support.py +++ b/app/interview/services/section_service_support.py @@ -6,15 +6,34 @@ import asyncio from collections.abc import Awaitable, Callable, Coroutine -from typing import Any +from typing import Any, Protocol from app.ai.base import AIProvider +from app.interview.domain.value_objects import SectionKind +from app.interview.repositories.uow import InterviewUnitOfWork from app.interview.services.section_prefetch import prefetch_section_feedback -from app.interview.services.sections import SectionKind +from app.interview.services.sections import SectionEvaluationSummary PersistFn = Callable[[dict[str, Any], int], None] EvaluateFn = Callable[[AIProvider], Awaitable[tuple[dict[str, object], int] | None]] ShouldPrefetchFn = Callable[[], bool] +EvaluateSectionFn = Callable[ + [object, SectionEvaluationSummary, str, str], + Awaitable[tuple[dict[str, object], int] | None], +] +GetSectionFn = Callable[[InterviewUnitOfWork, str], Any] +SaveSectionFn = Callable[[InterviewUnitOfWork, Any], None] + + +class SectionFeedbackQuery(Protocol): + """Minimal query surface needed for section feedback prefetch.""" + + def get_evaluation_summary( + self, + interview_id: str, + ) -> SectionEvaluationSummary | None: ... + + def sources_text_for_section(self, interview_id: str) -> str: ... def should_prefetch_feedback(section: object | None) -> bool: @@ -71,3 +90,177 @@ async def run_feedback_prefetch( evaluate=evaluate, persist=persist, ) + + +class SectionFeedbackPrefetch: + """Shared narrative feedback prefetch workflow for a section kind.""" + + def __init__( + self, + uow: InterviewUnitOfWork, + *, + section_name: SectionKind, + build: Callable[[InterviewUnitOfWork], SectionFeedbackPrefetch], + query: SectionFeedbackQuery, + get_section: GetSectionFn, + save_section: SaveSectionFn, + evaluate_section: EvaluateSectionFn, + ) -> None: + """Initialize prefetch helpers bound to one unit of work. + + Args: + uow: Active application unit of work. + section_name: Section kind label for log messages. + build: Factory that rebuilds this helper for background scopes. + query: Section query service with evaluation summary helpers. + get_section: Load a section aggregate for an interview. + save_section: Persist an updated section aggregate. + evaluate_section: Run the section LLM evaluation workflow. + """ + self._uow = uow + self._section_name = section_name + self._build = build + self._query = query + self._get_section = get_section + self._save_section = save_section + self._evaluate_section = evaluate_section + + def should_prefetch(self, interview_id: str) -> bool: + """Return whether section feedback should be generated. + + Args: + interview_id: Parent interview UUID. + + Returns: + True when the section exists, is complete, and lacks feedback. + """ + return should_prefetch_feedback(self._get_section(self._uow, interview_id)) + + def on_phase_complete(self, interview_id: str) -> None: + """Schedule background prefetch when prerequisites are met. + + Args: + interview_id: Parent interview UUID. + """ + if not self.should_prefetch(interview_id): + return + schedule_feedback_prefetch( + lambda: SectionFeedbackPrefetch._run_in_background( + interview_id, + build=self._build, + ) + ) + + async def ensure_section_feedback(self, interview_id: str) -> None: + """Synchronously prefetch section feedback before session completion. + + Args: + interview_id: Parent interview UUID. + """ + await self.prefetch(interview_id) + + async def prefetch(self, interview_id: str) -> None: + """Generate and persist cached section feedback when prerequisites pass. + + Args: + interview_id: Parent interview UUID. + """ + await run_feedback_prefetch( + interview_id, + section_name=self._section_name, + should_prefetch=lambda: self.should_prefetch(interview_id), + evaluate=lambda provider: self._evaluate(interview_id, provider), + persist=lambda payload, score: self._persist_in_background( + interview_id, + payload, + score, + ), + ) + + async def _evaluate( + self, + interview_id: str, + provider: object, + ) -> tuple[dict[str, object], int] | None: + """Run section LLM evaluation for prefetch. + + Args: + interview_id: Parent interview UUID. + provider: Configured AI provider instance. + + Returns: + Feedback payload and section score, or None when skipped. + """ + summary = self._query.get_evaluation_summary(interview_id) + if summary is None or not summary.items: + return None + return await self._evaluate_section( + provider, + summary, + self._query.sources_text_for_section(interview_id), + self._section_locale(interview_id), + ) + + def persist( + self, interview_id: str, payload: dict[str, object], score: int + ) -> None: + """Persist prefetched section feedback when still absent. + + Args: + interview_id: Parent interview UUID. + payload: Section evaluation payload from the LLM. + score: Earned section score. + """ + section = self._get_section(self._uow, interview_id) + if section is None or section.section_feedback is not None: + return + updated = section.with_cached_section_feedback( + payload, + section_score=score, + ) + self._save_section(self._uow, updated) + + def _section_locale(self, interview_id: str) -> str: + """Load the section locale for evaluation prompts. + + Args: + interview_id: Parent interview UUID. + + Returns: + Locale code, defaulting to ``en`` when the section is missing. + """ + section = self._get_section(self._uow, interview_id) + if section is None: + return "en" + return str(section.locale) + + def _persist_in_background( + self, + interview_id: str, + payload: dict[str, object], + score: int, + ) -> None: + """Persist prefetched feedback in a dedicated auto-commit unit of work. + + Args: + interview_id: Parent interview UUID. + payload: Section evaluation payload from the LLM. + score: Earned section score. + """ + with InterviewUnitOfWork(auto_commit=True) as uow: + self._build(uow).persist(interview_id, payload, score) + + @staticmethod + async def _run_in_background( + interview_id: str, + *, + build: Callable[[InterviewUnitOfWork], SectionFeedbackPrefetch], + ) -> None: + """Run section feedback prefetch in a dedicated unit of work. + + Args: + interview_id: Parent interview UUID. + build: Factory that rebuilds the prefetch helper for the scope. + """ + with InterviewUnitOfWork() as uow: + await build(uow).prefetch(interview_id) diff --git a/app/interview/services/sections.py b/app/interview/services/sections.py index 2579cdf..96982f9 100644 --- a/app/interview/services/sections.py +++ b/app/interview/services/sections.py @@ -5,11 +5,12 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, ClassVar, Literal, Protocol, cast +from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast -from app.interview.domain.value_objects import SessionMode +from app.interview.domain.value_objects import SectionKind, SessionMode -SectionKind = Literal["theory", "coding"] +if TYPE_CHECKING: + from app.interview.repositories.uow import InterviewUnitOfWork @dataclass(frozen=True, slots=True) @@ -53,34 +54,28 @@ class SectionService(Protocol): section_kind: ClassVar[SectionKind] - @staticmethod - def is_complete(interview_id: str) -> bool: + def is_complete(self, interview_id: str) -> bool: """Return whether the section has no remaining user tasks.""" - @staticmethod - def is_user_facing(interview_id: str) -> bool: + def is_user_facing(self, interview_id: str) -> bool: """Return whether the user should interact with this section now.""" - @staticmethod - def activate_if_pending(interview_id: str) -> bool: + def activate_if_pending(self, interview_id: str) -> bool: """Promote a pending section to active when prerequisites are met.""" - @staticmethod - def get_page_context(interview_id: str) -> SectionPageContext | None: + def get_page_context(self, interview_id: str) -> SectionPageContext | None: """Return section page metadata for session composition.""" - @staticmethod def get_evaluation_summary( + self, interview_id: str, ) -> SectionEvaluationSummary | None: """Return section evaluation data for session completion.""" - @staticmethod - def on_phase_complete(interview_id: str) -> None: + def on_phase_complete(self, interview_id: str) -> None: """Schedule background prefetch when a phase finishes.""" - @staticmethod - async def ensure_section_feedback(interview_id: str) -> None: + async def ensure_section_feedback(self, interview_id: str) -> None: """Synchronously prefetch section feedback before session completion.""" @@ -118,42 +113,53 @@ def is_first_user_facing_section( return bool(order) and order[0] == section -def section_services() -> dict[SectionKind, SectionService]: - """Return section service classes keyed by section kind. +def section_services( + uow: InterviewUnitOfWork, +) -> dict[SectionKind, SectionService]: + """Return section service instances keyed by section kind. + + Args: + uow: Shared application unit of work for the caller scope. Returns: - Mapping from section kind to the corresponding section service class. + Mapping from section kind to the corresponding section service instance. """ + from app.coding.services.query import CodingQueryService from app.coding.services.section import CodingSectionService + from app.theory.services.query import TheoryQueryService from app.theory.services.section import TheorySectionService + theory_query = TheoryQueryService(uow) + coding_query = CodingQueryService(uow) return cast( dict[SectionKind, SectionService], { - "theory": TheorySectionService, - "coding": CodingSectionService, + "theory": TheorySectionService(uow, query=theory_query), + "coding": CodingSectionService(uow, query=coding_query), }, ) -def prior_sections_complete_for(interview_id: str, section: SectionKind) -> bool: +def prior_sections_complete_for( + interview_id: str, + section: SectionKind, + uow: InterviewUnitOfWork, +) -> 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. + uow: Shared application unit of work for the caller scope. 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) + services = section_services(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) diff --git a/app/main.py b/app/main.py index 4bf1597..0ea67a1 100644 --- a/app/main.py +++ b/app/main.py @@ -15,6 +15,7 @@ from app.coding.api import routes as coding_router from app.interview.api import dashboard as dashboard_router +from app.interview.api import known_questions as known_questions_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 @@ -56,6 +57,7 @@ def create_app() -> FastAPI: app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.include_router(dashboard_router.router) app.include_router(setup_router.router) + app.include_router(known_questions_router.router) app.include_router(config_router.router) app.include_router(interview_router.router) app.include_router(results_router.router) diff --git a/app/platform/api/config.py b/app/platform/api/config.py index 2d680f4..8771ba4 100644 --- a/app/platform/api/config.py +++ b/app/platform/api/config.py @@ -193,7 +193,6 @@ async def add_llm_model( request: Request, config_service: ConfigServiceDep, whisper_model_service: WhisperModelServiceDep, - model_id: str = Form(...), display_name: str = Form(...), base_url: str = Form(...), model: str = Form(...), @@ -207,7 +206,6 @@ async def add_llm_model( request: FastAPI request object. config_service: Provider configuration service. whisper_model_service: Whisper model download service. - model_id: Stable lowercase catalog id. display_name: Label shown in the interview model selector. base_url: OpenAI-compatible API base URL. model: Provider model name. @@ -224,7 +222,6 @@ async def add_llm_model( error: str | None = None try: payload = NewLLMModel( - model_id=model_id, display_name=display_name, base_url=base_url, model=model, diff --git a/app/platform/schemas.py b/app/platform/schemas.py index 239152f..4125130 100644 --- a/app/platform/schemas.py +++ b/app/platform/schemas.py @@ -6,7 +6,6 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator -from app.ai.llm_models import validate_new_model_id from app.question_voice.schemas import PiperVoiceStatusRead from app.shared.speech_models import ( SPEECH_MODEL_BY_SIZE, @@ -133,8 +132,10 @@ class ConfigPageContext(BaseModel): class NewLLMModel(BaseModel): """User input for adding a catalog model. + The stable catalog id is derived from ``display_name`` when the model is + persisted, so it is not part of the user-supplied input. + Attributes: - model_id: Stable lowercase id for the catalog entry. display_name: Human-readable label shown in the UI. base_url: OpenAI-compatible base URL. model: Provider model name. @@ -145,7 +146,6 @@ class NewLLMModel(BaseModel): model_config = ConfigDict(str_strip_whitespace=True, frozen=True) - model_id: str display_name: str base_url: str model: str @@ -153,11 +153,6 @@ class NewLLMModel(BaseModel): api_key: str | None = None accepts_audio_input: bool = False - @field_validator("model_id") - @classmethod - def _validate_model_id(cls, value: str) -> str: - return validate_new_model_id(value) - @field_validator("display_name") @classmethod def _validate_display_name(cls, value: str) -> str: diff --git a/app/platform/services/llm_catalog.py b/app/platform/services/llm_catalog.py index 03ecd47..a8a3228 100644 --- a/app/platform/services/llm_catalog.py +++ b/app/platform/services/llm_catalog.py @@ -5,7 +5,7 @@ import json from typing import Any -from app.ai.llm_models import LLMCatalog, LLMModelEntry +from app.ai.llm_models import LLMCatalog, LLMModelEntry, generate_model_id from app.platform.schemas import NewLLMModel from app.shared.paths import LLM_MODELS_PATH @@ -84,21 +84,18 @@ def list_models() -> list[LLMModelEntry]: def add_user_model(payload: NewLLMModel) -> LLMModelEntry: """Append a model to ``data/llm_models.json``. + The catalog id is derived from the display name and made unique against + existing entries, so adding a model never fails on id collisions. + Args: payload: Validated add-model form values. Returns: Persisted catalog entry. - - Raises: - ValueError: If the id is invalid or already exists. """ - model_id = payload.model_id - data = _read_catalog_file() models = dict(data["models"]) - if model_id in models: - raise ValueError(f"Model id '{model_id}' already exists") + model_id = generate_model_id(payload.display_name, models) api_key = payload.api_key api_key_required = payload.api_key_required or bool(api_key) diff --git a/app/shared/application/__init__.py b/app/shared/application/__init__.py new file mode 100644 index 0000000..e74b3fe --- /dev/null +++ b/app/shared/application/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Shared application-layer helpers.""" diff --git a/app/shared/application/uow_deps.py b/app/shared/application/uow_deps.py new file mode 100644 index 0000000..871fbbc --- /dev/null +++ b/app/shared/application/uow_deps.py @@ -0,0 +1,34 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""FastAPI dependencies for application Unit of Work lifecycle.""" + +from collections.abc import Iterator +from typing import Annotated + +from fastapi import Depends + +from app.interview.repositories.uow import InterviewUnitOfWork + + +def get_uow() -> Iterator[InterviewUnitOfWork]: + """Yield an application Unit of Work for the request scope. + + Yields: + Active ``InterviewUnitOfWork``; commits are explicit unless auto-commit. + """ + with InterviewUnitOfWork() as uow: + yield uow + + +def get_uow_auto_commit() -> Iterator[InterviewUnitOfWork]: + """Yield an application Unit of Work that commits on successful exit. + + Yields: + Active ``InterviewUnitOfWork`` with ``auto_commit=True``. + """ + with InterviewUnitOfWork(auto_commit=True) as uow: + yield uow + + +UoWDep = Annotated[InterviewUnitOfWork, Depends(get_uow)] +UoWAutoCommitDep = Annotated[InterviewUnitOfWork, Depends(get_uow_auto_commit)] diff --git a/app/shared/infrastructure/audio_wav.py b/app/shared/infrastructure/audio_wav.py index 7592bda..6546fba 100644 --- a/app/shared/infrastructure/audio_wav.py +++ b/app/shared/infrastructure/audio_wav.py @@ -28,25 +28,6 @@ def pcm16le_to_float32(pcm: bytes) -> npt.NDArray[np.float32]: return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0 -def wav_duration_sec(wav_bytes: bytes) -> float: - """Return the duration of a WAV payload in seconds. - - Args: - wav_bytes: Raw WAV file bytes. - - Returns: - Duration in seconds, or ``0.0`` when the header cannot be parsed. - """ - try: - with wave.open(io.BytesIO(wav_bytes), "rb") as wav_file: - rate = wav_file.getframerate() - if rate <= 0: - return 0.0 - return wav_file.getnframes() / float(rate) - except (wave.Error, struct.error, EOFError): - return 0.0 - - def validate_wav_bytes(wav_bytes: bytes) -> None: """Validate canonical WAV format for audio answers and transcription. diff --git a/app/shared/infrastructure/database.py b/app/shared/infrastructure/database.py index cdb61f7..8bbf9fc 100644 --- a/app/shared/infrastructure/database.py +++ b/app/shared/infrastructure/database.py @@ -9,7 +9,7 @@ import os from alembic.config import Config -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import DeclarativeBase, sessionmaker from alembic import command @@ -21,7 +21,34 @@ "DATABASE_URL", f"sqlite:///{DB_DIR}/grillkit.db", ) -engine = create_engine(DATABASE_URL, echo=False) + + +def _configure_sqlite_connection( + dbapi_connection: object, + _connection_record: object, +) -> None: + """Enable WAL mode and a busy timeout for concurrent SQLite access. + + Args: + dbapi_connection: DB-API connection from the SQLAlchemy pool. + _connection_record: SQLAlchemy connection record (unused). + """ + cursor = dbapi_connection.cursor() # type: ignore[attr-defined] + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA busy_timeout=30000") + cursor.close() + + +if DATABASE_URL.startswith("sqlite"): + engine = create_engine( + DATABASE_URL, + echo=False, + connect_args={"check_same_thread": False, "timeout": 30.0}, + ) + event.listen(engine, "connect", _configure_sqlite_connection) +else: + engine = create_engine(DATABASE_URL, echo=False) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/app/shared/infrastructure/models.py b/app/shared/infrastructure/models.py index a5225ca..0bb810f 100644 --- a/app/shared/infrastructure/models.py +++ b/app/shared/infrastructure/models.py @@ -313,3 +313,22 @@ class CodeRunAttempt(Base): coding_task: Mapped["CodingTask"] = relationship( "CodingTask", back_populates="run_attempts" ) + + +class KnownQuestion(Base): + """Bank item ID marked as known and excluded from future session planning. + + Attributes: + branch: Section branch (``theory`` or ``coding``). + bank_item_id: ID from the YAML bank for that branch. + created_at: Timestamp when the item was marked as known. + """ + + __tablename__ = "known_questions" + + branch: Mapped[str] = mapped_column(String, primary_key=True) + bank_item_id: Mapped[str] = mapped_column(String, primary_key=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) diff --git a/app/speech/api/dictation.py b/app/speech/api/dictation.py index c14d3c0..45b7fd5 100644 --- a/app/speech/api/dictation.py +++ b/app/speech/api/dictation.py @@ -69,7 +69,7 @@ async def interview_dictation_ws( interview_id: Interview session UUID. config_service: Provider configuration service. """ - interview = InterviewQuery.get_interview(interview_id) + interview = InterviewQuery.load(interview_id) if interview is None: await _reject_dictation(websocket, "Interview not found") return diff --git a/app/theory/api/audio_answer.py b/app/theory/api/audio_answer.py index b298329..1a00b93 100644 --- a/app/theory/api/audio_answer.py +++ b/app/theory/api/audio_answer.py @@ -52,6 +52,7 @@ async def stream_ndjson_lines( wav_bytes: bytes, provider: AIProvider, transcriber: SpeechTranscriber, + submission_service: TheorySubmissionService, ) -> AsyncIterator[str]: """Map audio answer service events to NDJSON response lines. @@ -61,12 +62,13 @@ async def stream_ndjson_lines( wav_bytes: Validated WAV payload. provider: Configured AI provider. transcriber: Loaded speech transcriber. + submission_service: Request-scoped theory submission service. Yields: One JSON object per line for ``StreamingResponse``. """ try: - async for event in TheorySubmissionService.stream_audio_answer_submission( + async for event in submission_service.stream_audio_answer_submission( interview_id=interview_id, question_id=question_id, wav_bytes=wav_bytes, diff --git a/app/theory/api/routes.py b/app/theory/api/routes.py index c65ffde..dfff477 100644 --- a/app/theory/api/routes.py +++ b/app/theory/api/routes.py @@ -21,6 +21,7 @@ InterviewQueryDep, SessionCompletionServiceDep, SpeechTranscriberDep, + TheorySubmissionServiceDep, ) from app.theory.api.audio_answer import TheoryAudioAnswerAdapter from app.theory.api.ws_session import TheoryWebSocketService @@ -52,6 +53,7 @@ async def submit_theory_audio_answer( interview_id: str, provider: AIProviderDep, transcriber: SpeechTranscriberDep, + submission_service: TheorySubmissionServiceDep, question_id: Annotated[str, Form()], file: Annotated[UploadFile, File()], ) -> StreamingResponse: @@ -86,6 +88,7 @@ async def submit_theory_audio_answer( wav_bytes=wav_bytes, provider=provider, transcriber=transcriber, + submission_service=submission_service, ), media_type="application/x-ndjson", ) @@ -94,17 +97,16 @@ async def submit_theory_audio_answer( async def handle_theory_websocket( websocket: WebSocket, interview_id: str, - interview_query: InterviewQueryDep, - session_completion: SessionCompletionServiceDep, provider: AIProviderDep, + submission_service: TheorySubmissionServiceDep, + session_completion: SessionCompletionServiceDep, + interview_query: InterviewQueryDep, ) -> 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() @@ -120,6 +122,7 @@ async def handle_theory_websocket( raw, interview_id=interview_id, provider=provider, + submission_service=submission_service, session_completion=session_completion, interview_query=interview_query, ): @@ -135,23 +138,23 @@ async def handle_theory_websocket( async def theory_ws( websocket: WebSocket, interview_id: str, - interview_query: InterviewQueryDep, - session_completion: SessionCompletionServiceDep, provider: AIProviderDep, + submission_service: TheorySubmissionServiceDep, + session_completion: SessionCompletionServiceDep, + interview_query: InterviewQueryDep, ) -> 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, + submission_service, + session_completion, + interview_query, ) diff --git a/app/theory/api/ws_protocol.py b/app/theory/api/ws_protocol.py index 35d4385..36280ff 100644 --- a/app/theory/api/ws_protocol.py +++ b/app/theory/api/ws_protocol.py @@ -5,6 +5,14 @@ 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, @@ -14,14 +22,6 @@ 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", diff --git a/app/theory/api/ws_session.py b/app/theory/api/ws_session.py index 56f510f..dbb8e95 100644 --- a/app/theory/api/ws_session.py +++ b/app/theory/api/ws_session.py @@ -31,9 +31,9 @@ async def iter_responses( *, interview_id: str, provider: AIProvider, - submission_service: type[TheorySubmissionService] = TheorySubmissionService, - session_completion: type[SessionCompletionService] = SessionCompletionService, - interview_query: type[InterviewQuery] = InterviewQuery, + submission_service: TheorySubmissionService, + session_completion: SessionCompletionService, + interview_query: InterviewQuery, ) -> AsyncIterator[dict[str, Any]]: """Handle one client message and yield JSON payloads for the socket. @@ -41,9 +41,9 @@ async def iter_responses( raw: Parsed client JSON message. interview_id: Interview session UUID. provider: AI provider for answer and session evaluation. - submission_service: Theory submission service class. - session_completion: Session completion service class. - interview_query: Interview read service class. + submission_service: Request-scoped theory submission service. + session_completion: Request-scoped session completion service. + interview_query: Request-scoped interview read helper. Yields: WebSocket message dicts to send to the client. @@ -96,7 +96,7 @@ async def _handle_answer( *, interview_id: str, provider: AIProvider, - submission_service: type[TheorySubmissionService], + submission_service: TheorySubmissionService, ) -> AsyncIterator[dict[str, Any]]: question_id = raw.get("question_id", "") answer_text = raw.get("answer_text", "") @@ -132,7 +132,7 @@ async def _handle_timeout( raw: dict[str, Any], *, interview_id: str, - submission_service: type[TheorySubmissionService], + submission_service: TheorySubmissionService, ) -> AsyncIterator[dict[str, Any]]: question_id = raw.get("question_id", "") round_num = raw.get("round") @@ -166,7 +166,7 @@ async def _handle_timeout( def _handle_ping( interview_id: str, *, - interview_query: type[InterviewQuery], + interview_query: InterviewQuery, ) -> dict[str, Any]: try: interview = interview_query.get_interview(interview_id) @@ -181,7 +181,7 @@ async def _handle_complete( *, interview_id: str, provider: AIProvider, - session_completion: type[SessionCompletionService], + session_completion: SessionCompletionService, ) -> AsyncIterator[dict[str, Any]]: try: events = await session_completion.complete_session( diff --git a/app/theory/repositories/mappers.py b/app/theory/repositories/mappers.py index 5146b97..98feda9 100644 --- a/app/theory/repositories/mappers.py +++ b/app/theory/repositories/mappers.py @@ -17,7 +17,7 @@ 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 +from app.theory.schemas.theory import TheoryTaskRead def _expected_points_to_json(points: tuple[str, ...]) -> str | None: @@ -240,26 +240,3 @@ def theory_task_read_from_domain(task: DomainTheoryTask) -> TheoryTaskRead: 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 index e855ea9..7d38f2d 100644 --- a/app/theory/repositories/theory_section.py +++ b/app/theory/repositories/theory_section.py @@ -67,25 +67,26 @@ def get_aggregate(self, interview_id: str) -> DomainTheorySection | 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. + def get_aggregates_by_interview_ids( + self, interview_ids: list[str] + ) -> dict[str, DomainTheorySection]: + """Load theory section aggregates for several interviews at once. Args: - section: Domain section from ``TheorySection.start``. + interview_ids: Parent interview UUIDs. Returns: - Reloaded domain aggregate with assigned section ID. - - Raises: - TheorySectionNotFoundError: If reload fails after flush. + Mapping of interview ID to domain aggregate for sections that exist. """ - 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) + if not interview_ids: + return {} + rows = ( + self._session.query(TheorySection) + .options(selectinload(TheorySection.tasks)) + .filter(TheorySection.interview_id.in_(interview_ids)) + .all() + ) + return {row.interview_id: theory_section_from_orm(row) for row in rows} def create_aggregate(self, section: DomainTheorySection) -> DomainTheorySection: """Insert a theory section and its task rows. @@ -112,22 +113,6 @@ def create_aggregate(self, section: DomainTheorySection) -> DomainTheorySection: 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. diff --git a/app/theory/repositories/uow.py b/app/theory/repositories/uow.py deleted file mode 100644 index 758683f..0000000 --- a/app/theory/repositories/uow.py +++ /dev/null @@ -1,35 +0,0 @@ -# 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/theory.py b/app/theory/schemas/theory.py index 1f15655..f4fc883 100644 --- a/app/theory/schemas/theory.py +++ b/app/theory/schemas/theory.py @@ -3,12 +3,9 @@ """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. @@ -38,33 +35,3 @@ class TheoryTaskRead(BaseModel): 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 deleted file mode 100644 index 89c977e..0000000 --- a/app/theory/schemas/ws.py +++ /dev/null @@ -1,24 +0,0 @@ -# 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/creation.py b/app/theory/services/creation.py index 61c9c43..2550750 100644 --- a/app/theory/services/creation.py +++ b/app/theory/services/creation.py @@ -12,8 +12,16 @@ class TheorySectionCreationService: """Service for creating theory sections within an interview session.""" - @staticmethod + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this workflow. + """ + self._uow = uow + def create( + self, interview_id: str, *, selection: InterviewSelection, @@ -21,7 +29,7 @@ def create( question_count: int, task_time_limit_seconds: int | None, start_first_task_timer: bool = True, - uow: InterviewUnitOfWork, + excluded_ids: frozenset[str] = frozenset(), ) -> TheorySection: """Plan questions and persist a theory section with initial tasks. @@ -32,7 +40,7 @@ def create( question_count: Number of questions for this section. task_time_limit_seconds: Per-round time limit, or None to disable. start_first_task_timer: Whether to start the timer on the first task now. - uow: Active interview unit of work sharing the persistence session. + excluded_ids: Question IDs to omit during planning. Returns: Persisted theory section aggregate with assigned task IDs. @@ -42,7 +50,10 @@ def create( """ validate_question_count(selection, question_count) theory_planned = build_theory_question_plan( - selection, question_count, locale=locale + selection, + question_count, + locale=locale, + excluded_ids=excluded_ids, ) section = TheorySection.start( interview_id, @@ -52,4 +63,4 @@ def create( task_time_limit_seconds=task_time_limit_seconds, start_first_task_timer=start_first_task_timer, ) - return uow.theory_sections.create_aggregate(section) + return self._uow.theory_sections.create_aggregate(section) diff --git a/app/theory/services/evaluation_persistence.py b/app/theory/services/evaluation_persistence.py index 4813514..d2ac8fa 100644 --- a/app/theory/services/evaluation_persistence.py +++ b/app/theory/services/evaluation_persistence.py @@ -4,9 +4,9 @@ from typing import Any +from app.interview.repositories.uow import InterviewUnitOfWork 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, @@ -17,8 +17,23 @@ class TheoryEvaluationPersistenceService: """Save evaluation scores and advance timed theory task rounds.""" - @staticmethod + def __init__( + self, + uow: InterviewUnitOfWork, + *, + navigation: TheoryNavigationService | None = None, + ) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this workflow. + navigation: Optional navigation collaborator sharing the same uow. + """ + self._uow = uow + self._navigation = navigation or TheoryNavigationService(uow) + def advance_without_evaluation( + self, *, interview_id: str, question_id: str, @@ -36,18 +51,13 @@ def advance_without_evaluation( Returns: Feedback event for the client with the next question, if any. """ - next_question_data: dict[str, Any] | None = None - timer_remaining: int | None = None - - with TheoryUnitOfWork(auto_commit=True) as uow: - next_question_data, timer_remaining = ( - TheoryNavigationService.advance_to_next_unanswered( - uow, - interview_id, - question_id=question_id, - round_num=round_num, - ) + next_question_data, timer_remaining = ( + self._navigation.advance_to_next_unanswered( + interview_id, + question_id=question_id, + round_num=round_num, ) + ) return AnswerFeedbackEvent( question_id=question_id, @@ -59,8 +69,8 @@ def advance_without_evaluation( timer_remaining_seconds=timer_remaining, ) - @staticmethod def persist_evaluation_only( + self, *, interview_id: str, question_id: str, @@ -75,20 +85,19 @@ def persist_evaluation_only( round_num: Follow-up round that was evaluated. evaluation: Parsed AI 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.theory_sections.save_aggregate(updated) + section = self._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, + ) + self._uow.theory_sections.save_aggregate(updated) - @staticmethod def persist( + self, *, interview_id: str, question_id: str, @@ -116,51 +125,43 @@ def persist( timer_remaining: int | None = None follow_up_answer_id: int | None = None - with TheoryUnitOfWork(auto_commit=True) as uow: - section = uow.theory_sections.get_aggregate(interview_id) - if section is None: - raise TheorySectionNotFoundError(interview_id) - section.ensure_active() + section = self._uow.theory_sections.get_aggregate(interview_id) + if section is None: + raise TheorySectionNotFoundError(interview_id) + section.ensure_active() - updated = section.with_evaluation( - question_id, - round_num, - evaluation.score, - evaluation.feedback, - ) - follow_up_round: int | None = None - if follow_up_needed: - updated, pending = updated.with_follow_up( - question_id, follow_up_text or "" - ) - follow_up_round = pending.round - - uow.theory_sections.save_aggregate(updated) - - if follow_up_needed and follow_up_round is not None: - uow.flush() - reloaded = uow.theory_sections.get_aggregate(interview_id) - if reloaded is None: - raise TheorySectionNotFoundError(interview_id) - follow_up = reloaded.find_task(question_id, follow_up_round) - follow_up_answer_id = follow_up.id - timed = reloaded.start_timer_for_task(follow_up.id) - uow.theory_sections.save_aggregate(timed) - activated = next( - task for task in timed.tasks if task.id == follow_up.id - ) - timer_remaining = activated.remaining_seconds( - timed.task_time_limit_seconds - ) - else: - next_question_data, timer_remaining = ( - TheoryNavigationService.advance_to_next_unanswered( - uow, - interview_id, - question_id=question_id, - round_num=round_num, - ) + updated = section.with_evaluation( + question_id, + round_num, + evaluation.score, + evaluation.feedback, + ) + follow_up_round: int | None = None + if follow_up_needed: + updated, pending = updated.with_follow_up(question_id, follow_up_text or "") + follow_up_round = pending.round + + self._uow.theory_sections.save_aggregate(updated) + + if follow_up_needed and follow_up_round is not None: + self._uow.flush() + reloaded = self._uow.theory_sections.get_aggregate(interview_id) + if reloaded is None: + raise TheorySectionNotFoundError(interview_id) + follow_up = reloaded.find_task(question_id, follow_up_round) + follow_up_answer_id = follow_up.id + timed = reloaded.start_timer_for_task(follow_up.id) + self._uow.theory_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) + else: + next_question_data, timer_remaining = ( + self._navigation.advance_to_next_unanswered( + interview_id, + question_id=question_id, + round_num=round_num, ) + ) return AnswerFeedbackEvent( question_id=question_id, diff --git a/app/theory/services/navigation.py b/app/theory/services/navigation.py index 59570e5..dca0a86 100644 --- a/app/theory/services/navigation.py +++ b/app/theory/services/navigation.py @@ -4,10 +4,10 @@ from typing import Any +from app.interview.repositories.uow import InterviewUnitOfWork 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]: @@ -32,9 +32,16 @@ def next_task_payload(task: TheoryTask) -> dict[str, Any]: class TheoryNavigationService: """Shared navigation after a theory task round is completed or timed out.""" - @staticmethod + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this workflow. + """ + self._uow = uow + def advance_to_next_unanswered( - uow: TheoryUnitOfWork, + self, interview_id: str, *, question_id: str, @@ -43,7 +50,6 @@ def advance_to_next_unanswered( """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. @@ -55,7 +61,7 @@ def advance_to_next_unanswered( TheorySectionNotFoundError: If the theory section does not exist. TheorySectionNotActiveError: If the section is not active. """ - section = uow.theory_sections.get_aggregate(interview_id) + section = self._uow.theory_sections.get_aggregate(interview_id) if section is None: raise TheorySectionNotFoundError(interview_id) @@ -68,20 +74,18 @@ def advance_to_next_unanswered( ) next_task = section.find_next_unanswered_after(current_index) if next_task is None: - TheoryNavigationService._notify_phase_complete_if_needed( - interview_id, section - ) + self._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) + self._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( + self, interview_id: str, section: TheorySection, ) -> None: @@ -92,4 +96,7 @@ def _notify_phase_complete_if_needed( section: Theory section after the latest navigation update. """ if section.is_complete(): - SessionPhaseOrchestrator.notify_section_complete(interview_id, "theory") + SessionPhaseOrchestrator(self._uow).notify_section_complete( + interview_id, + "theory", + ) diff --git a/app/theory/services/page.py b/app/theory/services/page.py index 55e7d4c..27fc9e7 100644 --- a/app/theory/services/page.py +++ b/app/theory/services/page.py @@ -3,53 +3,39 @@ """Theory section page context builder.""" from app.interview.domain.serialization import parse_session_spec +from app.interview.repositories.uow import InterviewUnitOfWork 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. + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. Args: - interview_id: Parent interview UUID. + uow: Shared application unit of work for this page scope. """ - 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) + self._uow = uow - @staticmethod - def _timer_remaining_seconds(interview_id: str) -> int | None: - """Return seconds left on the current theory task timer. + def activate_timer(self, interview_id: str) -> None: + """Start the per-round timer on the current unanswered theory task. 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: + section = self._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) + self._uow.theory_sections.save_aggregate(updated) + + def build_context(self, interview: InterviewRead) -> TheoryPageContext | None: """Assemble theory panel context from a loaded interview read model. Args: @@ -66,19 +52,19 @@ def build_context(interview: InterviewRead) -> TheoryPageContext | None: 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 + section = self._uow.theory_sections.get_aggregate(interview.id) + if section is None and not interview.answers: + 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 - ) + timer_remaining_seconds = None + if question_timer_enabled and section is not None: + current = section.find_first_unanswered() + if current is not None: + timer_remaining_seconds = current.remaining_seconds( + section.task_time_limit_seconds + ) current_round = current_question.round if current_question else 0 complete = current_question is None and bool(interview.answers) @@ -94,14 +80,20 @@ def build_context(interview: InterviewRead) -> TheoryPageContext | None: ) @staticmethod - def load_interview(interview_id: str) -> InterviewRead | None: - """Load interview read model and activate the theory timer when needed. + def build_context_for( + interview: InterviewRead, + uow: InterviewUnitOfWork | None = None, + ) -> TheoryPageContext | None: + """Build theory page context using a short-lived unit of work. Args: - interview_id: Parent interview UUID. + interview: Interview read model with theory tasks mirrored as answers. + uow: Optional unit of work (useful in tests to use an isolated DB). Returns: - Interview read model, or None when not found. + Theory page context, or None when the session has no theory tasks. """ - TheoryPageService.activate_timer(interview_id) - return InterviewQuery.get_interview(interview_id) + if uow is not None: + return TheoryPageService(uow).build_context(interview) + with InterviewUnitOfWork() as new_uow: + return TheoryPageService(new_uow).build_context(interview) diff --git a/app/theory/services/planning.py b/app/theory/services/planning.py index 891a51e..6539e81 100644 --- a/app/theory/services/planning.py +++ b/app/theory/services/planning.py @@ -7,7 +7,13 @@ PlannedQuestion, TrackQuestionPools, ) -from app.interview.services.rules.selection import plan_questions, track_label +from app.interview.services.rules.bank_selection import ( + BankCatalog, + BankSelectionMessages, + track_label, + validate_bank_selection, +) +from app.interview.services.rules.selection import plan_questions from app.shared.locales import normalize_locale from app.shared.questions import ( Question, @@ -19,6 +25,23 @@ ) from app.theory.domain.value_objects import PlannedTheoryQuestion +_THEORY_BANK_CATALOG = BankCatalog( + list_tracks=list_tracks, + list_levels=list_levels, + list_categories=list_categories, +) +_THEORY_BANK_MESSAGES = BankSelectionMessages( + empty_sources="Select at least one track and topic", + unknown_track=lambda track: f"Unknown track: {track}", + unknown_level=lambda level, track: f"Unknown level '{level}' for track '{track}'", + empty_categories=lambda track: ( + f"Select at least one topic for {track_label(track)}" + ), + unknown_category=lambda category, track, level: ( + f"Unknown topic '{category}' for {track}/{level}" + ), +) + def _theory_questions_only(questions: list[Question]) -> list[Question]: """Drop coding-bank rows that may still appear in legacy theory YAML files. @@ -58,28 +81,11 @@ def validate_selection(selection: InterviewSelection) -> None: Raises: ValueError: If selection is empty or references unknown bank paths. """ - if not selection.sources: - raise ValueError("Select at least one track and topic") - - tracks = set(list_tracks()) - for source in selection.sources: - if source.track not in tracks: - raise ValueError(f"Unknown track: {source.track}") - levels = set(list_levels(source.track)) - if source.level not in levels: - raise ValueError( - f"Unknown level '{source.level}' for track '{source.track}'" - ) - if not source.categories: - raise ValueError( - f"Select at least one 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 topic '{category}' for {source.track}/{source.level}" - ) + validate_bank_selection( + selection, + _THEORY_BANK_CATALOG, + _THEORY_BANK_MESSAGES, + ) def load_track_pools( @@ -129,6 +135,8 @@ def build_theory_question_plan( selection: InterviewSelection, question_count: int, locale: str = "en", + *, + excluded_ids: frozenset[str] = frozenset(), ) -> tuple[PlannedTheoryQuestion, ...]: """Build ordered theory question list for a multi-source section. @@ -136,6 +144,7 @@ def build_theory_question_plan( selection: Validated interview selection. question_count: Target number of questions (>= topic count). locale: Locale for question text. + excluded_ids: Question IDs to remove from pools before planning. Returns: Ordered planned theory questions. @@ -145,7 +154,12 @@ def build_theory_question_plan( """ validate_selection(selection) track_pools = load_track_pools(selection, locale) - planned = plan_questions(selection, question_count, track_pools) + planned = plan_questions( + selection, + question_count, + track_pools, + excluded_ids=excluded_ids, + ) return tuple( PlannedTheoryQuestion( id=question.id, diff --git a/app/theory/services/query.py b/app/theory/services/query.py index a92e6e8..77a6cbf 100644 --- a/app/theory/services/query.py +++ b/app/theory/services/query.py @@ -4,16 +4,24 @@ from typing import Any +from app.interview.repositories.uow import InterviewUnitOfWork 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.""" + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this read scope. + """ + self._uow = uow + @staticmethod def _qa_items_from_section( section: TheorySection, @@ -39,8 +47,10 @@ def _qa_items_from_section( if task.answer_text is not None ) - @staticmethod - def get_evaluation_summary(interview_id: str) -> SectionEvaluationSummary | None: + def get_evaluation_summary( + self, + interview_id: str, + ) -> SectionEvaluationSummary | None: """Return theory section evaluation data for session completion. Uses cached ``section_feedback`` when present. @@ -51,22 +61,20 @@ def get_evaluation_summary(interview_id: str) -> SectionEvaluationSummary | None 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, - ) + section = self._uow.theory_sections.get_aggregate(interview_id) + if section is None: + return None - @staticmethod - def sources_text_for_section(interview_id: str) -> str: + return build_section_evaluation_summary( + "theory", + section_status=section.status, + items=self._qa_items_from_section(section), + total_score=section.total_score(), + max_score=section.max_score(), + cached_narrative=section.section_feedback, + ) + + def sources_text_for_section(self, interview_id: str) -> str: """Build selection summary text for theory evaluation prompts. Args: @@ -75,8 +83,7 @@ def sources_text_for_section(interview_id: str) -> str: 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) + section = self._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 index 30b00ba..2edfcf5 100644 --- a/app/theory/services/review.py +++ b/app/theory/services/review.py @@ -6,6 +6,7 @@ from app.interview.repositories.uow import InterviewUnitOfWork from app.interview.services.section_review_support import ( + CompletedInterviewSnapshot, load_completed_interview, resolved_section_feedback, review_score_fields, @@ -19,26 +20,34 @@ class TheoryReviewService: """Build read-only theory review context for completed sessions.""" - @staticmethod - def build_context(interview_id: str) -> TheoryReviewContext | None: + def __init__(self, uow: InterviewUnitOfWork) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this review scope. + """ + self._uow = uow + self._query = TheoryQueryService(uow) + + def build_context( + self, + interview_id: str, + snapshot: CompletedInterviewSnapshot, + ) -> TheoryReviewContext | None: """Assemble theory review template context for a completed session. Args: interview_id: Parent session UUID. + snapshot: Loaded completed interview snapshot. Returns: - Review context, or None when the session or theory section is missing. + Review context, or None when the theory section is missing. """ - snapshot = load_completed_interview(interview_id) - if snapshot is None: + section = self._uow.theory_sections.get_aggregate(interview_id) + if section 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) + summary = self._query.get_evaluation_summary(interview_id) if summary is None: return None @@ -66,3 +75,17 @@ def build_context(interview_id: str) -> TheoryReviewContext | None: "answers": answers, } ) + + def build_context_for(self, interview_id: str) -> TheoryReviewContext | None: + """Build theory review 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(self._uow, interview_id) + if snapshot is None: + return None + return self.build_context(interview_id, snapshot) diff --git a/app/theory/services/section.py b/app/theory/services/section.py index 50a5e4a..a27ccd0 100644 --- a/app/theory/services/section.py +++ b/app/theory/services/section.py @@ -6,27 +6,92 @@ 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.repositories.uow import InterviewUnitOfWork +from app.interview.services.section_service_support import SectionFeedbackPrefetch 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 +async def _evaluate_theory_section_feedback( + provider: object, + summary: SectionEvaluationSummary, + sources_text: str, + locale: str, +) -> tuple[dict[str, object], int] | None: + """Run the theory section LLM evaluation for prefetch. + + Args: + provider: Configured AI provider instance. + summary: Section evaluation summary with per-task rows. + sources_text: Human-readable selection summary. + locale: Section locale for prompts. + + Returns: + Feedback payload and section score. + """ + section_eval = await TheoryEvaluatorService.evaluate_section( + provider=provider, # type: ignore[arg-type] + questions_answers=list(summary.items), + sources_text=sources_text, + locale=locale, + ) + return section_eval.model_dump(), summary.score + + +def _build_theory_feedback_prefetch( + uow: InterviewUnitOfWork, + query: TheoryQueryService | None = None, +) -> SectionFeedbackPrefetch: + """Build theory section feedback prefetch helpers for a unit of work. + + Args: + uow: Active application unit of work. + query: Optional query helper sharing the same unit of work. + + Returns: + Configured prefetch helper for theory sections. + """ + resolved_query = query or TheoryQueryService(uow) + return SectionFeedbackPrefetch( + uow, + section_name="theory", + build=lambda scoped_uow: _build_theory_feedback_prefetch(scoped_uow), + query=resolved_query, + get_section=lambda scoped_uow, interview_id: ( + scoped_uow.theory_sections.get_aggregate(interview_id) + ), + save_section=lambda scoped_uow, section: ( + scoped_uow.theory_sections.save_aggregate(section) + ), + evaluate_section=_evaluate_theory_section_feedback, + ) + + class TheorySectionService: """Theory section lifecycle hooks and read helpers.""" section_kind: ClassVar[Literal["theory"]] = "theory" - @staticmethod - def is_complete(interview_id: str) -> bool: + def __init__( + self, + uow: InterviewUnitOfWork, + query: TheoryQueryService | None = None, + ) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this section scope. + query: Optional theory query helper sharing the same unit of work. + """ + self._uow = uow + self._query = query or TheoryQueryService(uow) + self._feedback = _build_theory_feedback_prefetch(uow, self._query) + + def is_complete(self, interview_id: str) -> bool: """Return whether all theory tasks in the section are answered. Args: @@ -35,14 +100,12 @@ def is_complete(interview_id: str) -> bool: 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: + section = self._uow.theory_sections.get_aggregate(interview_id) + if section is None: + return False + return section.is_complete() + + def is_user_facing(self, interview_id: str) -> bool: """Return whether the user should interact with the theory section now. Args: @@ -51,14 +114,12 @@ def is_user_facing(interview_id: str) -> bool: 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: + section = self._uow.theory_sections.get_aggregate(interview_id) + if section is None: + return False + return not section.is_complete() + + def activate_if_pending(self, interview_id: str) -> bool: """Theory sections are created active; nothing to promote. Args: @@ -70,8 +131,7 @@ def activate_if_pending(interview_id: str) -> bool: del interview_id return False - @staticmethod - def get_page_context(interview_id: str) -> SectionPageContext | None: + def get_page_context(self, interview_id: str) -> SectionPageContext | None: """Return theory section page metadata for session composition. Args: @@ -80,18 +140,17 @@ def get_page_context(interview_id: str) -> SectionPageContext | None: 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 + section = self._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(), + ) + def get_evaluation_summary( + self, interview_id: str, ) -> SectionEvaluationSummary | None: """Return theory evaluation summary for session completion. @@ -102,10 +161,9 @@ def get_evaluation_summary( Returns: Section summary, or None when no theory section exists. """ - return TheoryQueryService.get_evaluation_summary(interview_id) + return self._query.get_evaluation_summary(interview_id) - @staticmethod - def on_phase_complete(interview_id: str) -> None: + def on_phase_complete(self, interview_id: str) -> None: """Schedule background prefetch of theory section narrative feedback. Idempotent: skips when feedback is already cached. @@ -113,14 +171,9 @@ def on_phase_complete(interview_id: str) -> None: 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) - ) + self._feedback.on_phase_complete(interview_id) - @staticmethod - async def ensure_section_feedback(interview_id: str) -> None: + async def ensure_section_feedback(self, interview_id: str) -> None: """Synchronously prefetch section feedback before session completion. Idempotent: skips when feedback is already cached or the section is @@ -129,108 +182,4 @@ async def ensure_section_feedback(interview_id: str) -> None: 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 + await self._feedback.ensure_section_feedback(interview_id) diff --git a/app/theory/services/submission.py b/app/theory/services/submission.py index d6dd402..994255a 100644 --- a/app/theory/services/submission.py +++ b/app/theory/services/submission.py @@ -32,11 +32,11 @@ 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.navigation import TheoryNavigationService from app.theory.services.timer import TheoryTimerService logger = logging.getLogger(__name__) @@ -71,23 +71,6 @@ class TheorySubmissionContext: 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, @@ -105,6 +88,10 @@ async def _evaluate_last_follow_up_in_background( ) -> None: """Run AI evaluation for the last follow-up round and persist score only. + Uses a dedicated auto-commit unit of work because the request-scoped UoW may + already have committed navigation changes while this evaluation runs after the + client received the next question. + Args: interview_id: The session UUID. question_id: The question ID. @@ -144,12 +131,13 @@ async def _evaluate_last_follow_up_in_background( expected_points=expected_points, answer_text=answer_text, ) - TheoryEvaluationPersistenceService.persist_evaluation_only( - interview_id=interview_id, - question_id=question_id, - round_num=round_num, - evaluation=evaluation, - ) + with InterviewUnitOfWork(auto_commit=True) as uow: + TheoryEvaluationPersistenceService(uow).persist_evaluation_only( + interview_id=interview_id, + question_id=question_id, + round_num=round_num, + evaluation=evaluation, + ) except Exception: logger.exception( "Background evaluation failed for interview=%s question=%s round=%s", @@ -162,6 +150,29 @@ async def _evaluate_last_follow_up_in_background( class TheorySubmissionService: """Orchestrates theory task submission, timeout handling, and event streaming.""" + def __init__( + self, + uow: InterviewUnitOfWork, + *, + persistence: TheoryEvaluationPersistenceService | None = None, + navigation: TheoryNavigationService | None = None, + timer: TheoryTimerService | None = None, + ) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this submission workflow. + persistence: Optional evaluation persistence collaborator. + navigation: Optional navigation collaborator. + timer: Optional timer collaborator. + """ + self._uow = uow + self._navigation = navigation or TheoryNavigationService(uow) + self._persistence = persistence or TheoryEvaluationPersistenceService( + uow, navigation=self._navigation + ) + self._timer = timer or TheoryTimerService(uow, navigation=self._navigation) + @staticmethod def require_audio_answer_enabled() -> None: """Ensure the selected catalog model accepts audio input. @@ -176,8 +187,35 @@ def require_audio_answer_enabled() -> None: if entry is None or not entry.accepts_audio_input: raise ValueError("Selected interview model does not accept audio input") - @staticmethod + def _ensure_interview_active(self, 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. + """ + aggregate = self._uow.interviews.get_aggregate(interview_id) + if aggregate is None: + raise InterviewNotFoundError(interview_id) + aggregate.ensure_active() + + def _commit_submission_workflow(self) -> None: + """Commit durable submission changes for the current request scope.""" + self._uow.commit() + + def _rollback_submission_workflow(self) -> None: + """Discard uncommitted submission changes after a workflow failure.""" + self._uow.rollback() + + def _release_submission_write_lock(self) -> None: + """Commit the opened answer row so long-running AI work does not hold SQLite.""" + self._commit_submission_workflow() + async def _open_submission( + self, interview_id: str, question_id: str, answer_text: str, @@ -198,51 +236,51 @@ async def _open_submission( timed_out_round: int | None = None submission: TheorySubmissionContext | None = None - _ensure_interview_active(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) - - section.ensure_active() - current = section.find_unanswered_for_question(question_id) - round_num = current.round - limit = section.task_time_limit_seconds - - if limit and current.is_timer_expired(limit, grace_seconds=0): - timed_out_round = round_num - else: - 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( - 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 = TheorySubmissionContext( - question_id=question_id, - round_num=round_num, - order=saved.order, - question_text=saved.question_text, - question_code=saved.question_code, - initial_question_text=initial_question_text, - initial_answer_text=initial_answer_text, - expected_points=saved.expected_points, - locale=updated.locale, - answer_text=answer_text, + self._ensure_interview_active(interview_id) + + section = self._uow.theory_sections.get_aggregate(interview_id) + if section is None: + raise TheorySectionNotFoundError(interview_id) + + section.ensure_active() + current = section.find_unanswered_for_question(question_id) + round_num = current.round + limit = section.task_time_limit_seconds + + if limit and current.is_timer_expired(limit, grace_seconds=0): + timed_out_round = round_num + else: + updated = section.with_task_text(current.id, answer_text) + self._uow.theory_sections.save_aggregate(updated) + self._uow.flush() + 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( + 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 = TheorySubmissionContext( + question_id=question_id, + round_num=round_num, + order=saved.order, + question_text=saved.question_text, + question_code=saved.question_code, + initial_question_text=initial_question_text, + initial_answer_text=initial_answer_text, + expected_points=saved.expected_points, + locale=updated.locale, + answer_text=answer_text, + ) if timed_out_round is not None: - async for event in TheorySubmissionService.stream_timeout_submission( + async for event in self.stream_timeout_submission( interview_id=interview_id, question_id=question_id, round_num=timed_out_round, @@ -253,8 +291,8 @@ async def _open_submission( if submission is not None: yield submission - @staticmethod async def _transcribe_and_persist( + self, *, interview_id: str, question_id: str, @@ -278,17 +316,17 @@ async def _transcribe_and_persist( """ samples = wav_bytes_to_float32(wav_bytes) transcript = await transcriber.transcribe(samples, locale) - 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) + section = self._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) + self._uow.theory_sections.save_aggregate(updated) + self._uow.flush() return transcript - @staticmethod def _schedule_last_follow_up_evaluation( + self, *, interview_id: str, ctx: TheorySubmissionContext, @@ -297,6 +335,9 @@ def _schedule_last_follow_up_evaluation( ) -> None: """Run last-round AI evaluation in the background. + The foreground workflow commits navigation first, then schedules this task + so score persistence does not race with the request-scoped unit of work. + Args: interview_id: Interview UUID. ctx: Open submission context. @@ -324,8 +365,8 @@ def _schedule_last_follow_up_evaluation( ), ) - @staticmethod async def stream_timeout_submission( + self, interview_id: str, question_id: str, round_num: int, @@ -348,32 +389,49 @@ async def stream_timeout_submission( TaskTimerNotExpiredError: If the deadline has not passed yet. UnansweredTaskNotFoundError: If the round is not open. """ - _ensure_interview_active(interview_id) + try: + async for event in self._iter_timeout_submission( + interview_id=interview_id, + question_id=question_id, + round_num=round_num, + ): + yield event + self._commit_submission_workflow() + except Exception: + self._rollback_submission_workflow() + raise - with TheoryUnitOfWork() as uow: - section = uow.theory_sections.get_aggregate(interview_id) - if section is None: - raise TheorySectionNotFoundError(interview_id) + async def _iter_timeout_submission( + self, + interview_id: str, + question_id: str, + round_num: int, + ) -> AsyncIterator[InterviewEvent]: + self._ensure_interview_active(interview_id) + + section = self._uow.theory_sections.get_aggregate(interview_id) + if section is None: + raise TheorySectionNotFoundError(interview_id) - section.ensure_active() + section.ensure_active() - limit = section.task_time_limit_seconds - if not limit: - raise TaskTimerNotEnabledError(interview_id) + limit = section.task_time_limit_seconds + if not limit: + raise TaskTimerNotEnabledError(interview_id) - current = section.find_task(question_id, round_num) - now = datetime.now(UTC) + current = section.find_task(question_id, round_num) + now = datetime.now(UTC) - if current.answer_text is not None: - return + if current.answer_text is not None: + return - if not current.client_timeout_due(limit, now): - raise TaskTimerNotExpiredError(interview_id, question_id) + if not current.client_timeout_due(limit, now): + raise TaskTimerNotExpiredError(interview_id, question_id) - order = current.order - locale = section.locale + order = current.order + locale = section.locale - yield TheoryTimerService.persist_timed_out_round( + yield self._timer.persist_timed_out_round( interview_id=interview_id, question_id=question_id, round_num=round_num, @@ -381,8 +439,8 @@ async def stream_timeout_submission( locale=locale, ) - @staticmethod async def stream_answer_submission( + self, interview_id: str, question_id: str, answer_text: str, @@ -399,10 +457,28 @@ async def stream_answer_submission( Yields: Semantic events for WebSocket delivery in order. """ + try: + async for event in self._iter_answer_submission( + interview_id=interview_id, + question_id=question_id, + answer_text=answer_text, + provider=provider, + ): + yield event + self._commit_submission_workflow() + except Exception: + self._rollback_submission_workflow() + raise + + async def _iter_answer_submission( + self, + interview_id: str, + question_id: str, + answer_text: str, + provider: AIProvider, + ) -> AsyncIterator[InterviewEvent]: ctx: TheorySubmissionContext | None = None - async for item in TheorySubmissionService._open_submission( - interview_id, question_id, answer_text - ): + async for item in self._open_submission(interview_id, question_id, answer_text): if isinstance(item, TheorySubmissionContext): ctx = item break @@ -410,15 +486,18 @@ async def stream_answer_submission( if ctx is None: return + self._release_submission_write_lock() + if ctx.round_num >= TheoryEvaluatorService.MAX_FOLLOW_UP_DEPTH: yield AnswerSavedEvent() - yield TheoryEvaluationPersistenceService.advance_without_evaluation( + yield self._persistence.advance_without_evaluation( interview_id=interview_id, question_id=ctx.question_id, round_num=ctx.round_num, order=ctx.order, ) - TheorySubmissionService._schedule_last_follow_up_evaluation( + self._release_submission_write_lock() + self._schedule_last_follow_up_evaluation( interview_id=interview_id, ctx=ctx, provider=provider, @@ -446,7 +525,7 @@ async def stream_answer_submission( ) ) - yield TheoryEvaluationPersistenceService.persist( + yield self._persistence.persist( interview_id=interview_id, question_id=ctx.question_id, round_num=ctx.round_num, @@ -456,8 +535,8 @@ async def stream_answer_submission( follow_up_text=follow_up_text, ) - @staticmethod async def stream_audio_answer_submission( + self, interview_id: str, question_id: str, wav_bytes: bytes, @@ -476,13 +555,33 @@ async def stream_audio_answer_submission( Yields: Semantic events for HTTP NDJSON or WebSocket delivery. """ - TheorySubmissionService.require_audio_answer_enabled() + try: + async for event in self._iter_audio_answer_submission( + interview_id=interview_id, + question_id=question_id, + wav_bytes=wav_bytes, + provider=provider, + transcriber=transcriber, + ): + yield event + self._commit_submission_workflow() + except Exception: + self._rollback_submission_workflow() + raise + + async def _iter_audio_answer_submission( + self, + interview_id: str, + question_id: str, + wav_bytes: bytes, + provider: AIProvider, + transcriber: SpeechTranscriber, + ) -> AsyncIterator[InterviewEvent]: + self.require_audio_answer_enabled() validate_wav_bytes(wav_bytes) ctx: TheorySubmissionContext | None = None - async for item in TheorySubmissionService._open_submission( - interview_id, question_id, "" - ): + async for item in self._open_submission(interview_id, question_id, ""): if isinstance(item, TheorySubmissionContext): ctx = item break @@ -490,17 +589,20 @@ async def stream_audio_answer_submission( if ctx is None: return + self._release_submission_write_lock() + yield AnswerSavedEvent() if ctx.round_num >= TheoryEvaluatorService.MAX_FOLLOW_UP_DEPTH: - yield TheoryEvaluationPersistenceService.advance_without_evaluation( + yield self._persistence.advance_without_evaluation( interview_id=interview_id, question_id=ctx.question_id, round_num=ctx.round_num, order=ctx.order, ) + self._release_submission_write_lock() transcript_task = asyncio.create_task( - TheorySubmissionService._transcribe_and_persist( + self._transcribe_and_persist( interview_id=interview_id, question_id=ctx.question_id, round_num=ctx.round_num, @@ -510,7 +612,7 @@ async def stream_audio_answer_submission( ), name=f"audio-transcript-{interview_id}-{ctx.question_id}-r{ctx.round_num}", ) - TheorySubmissionService._schedule_last_follow_up_evaluation( + self._schedule_last_follow_up_evaluation( interview_id=interview_id, ctx=ctx, provider=provider, @@ -527,7 +629,7 @@ async def stream_audio_answer_submission( yield EvaluatingEvent() transcript_task = asyncio.create_task( - TheorySubmissionService._transcribe_and_persist( + self._transcribe_and_persist( interview_id=interview_id, question_id=ctx.question_id, round_num=ctx.round_num, @@ -565,7 +667,7 @@ async def stream_audio_answer_submission( follow_up_text, ) = await asyncio.shield(evaluation_task) - yield TheoryEvaluationPersistenceService.persist( + yield self._persistence.persist( interview_id=interview_id, question_id=ctx.question_id, round_num=ctx.round_num, @@ -575,8 +677,8 @@ async def stream_audio_answer_submission( follow_up_text=follow_up_text, ) - @staticmethod async def process_answer_submission( + self, interview_id: str, question_id: str, answer_text: str, @@ -595,7 +697,7 @@ async def process_answer_submission( """ return [ event - async for event in TheorySubmissionService.stream_answer_submission( + async for event in self.stream_answer_submission( interview_id=interview_id, question_id=question_id, answer_text=answer_text, @@ -603,8 +705,8 @@ async def process_answer_submission( ) ] - @staticmethod async def process_audio_answer_submission( + self, interview_id: str, question_id: str, wav_bytes: bytes, @@ -625,7 +727,7 @@ async def process_audio_answer_submission( """ return [ event - async for event in TheorySubmissionService.stream_audio_answer_submission( + async for event in self.stream_audio_answer_submission( interview_id=interview_id, question_id=question_id, wav_bytes=wav_bytes, @@ -634,8 +736,8 @@ async def process_audio_answer_submission( ) ] - @staticmethod async def process_timeout_submission( + self, interview_id: str, question_id: str, round_num: int, @@ -652,7 +754,7 @@ async def process_timeout_submission( """ return [ event - async for event in TheorySubmissionService.stream_timeout_submission( + async for event in self.stream_timeout_submission( interview_id=interview_id, question_id=question_id, round_num=round_num, diff --git a/app/theory/services/timer.py b/app/theory/services/timer.py index 51d09c5..7dad7f3 100644 --- a/app/theory/services/timer.py +++ b/app/theory/services/timer.py @@ -4,18 +4,33 @@ from typing import Any +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.theory.domain.exceptions import TheorySectionNotFoundError -from app.theory.repositories.uow import TheoryUnitOfWork from app.theory.services.navigation import TheoryNavigationService class TheoryTimerService: """Timeout persistence for timed theory task rounds.""" - @staticmethod + def __init__( + self, + uow: InterviewUnitOfWork, + *, + navigation: TheoryNavigationService | None = None, + ) -> None: + """Initialize with the active unit of work. + + Args: + uow: Shared application unit of work for this workflow. + navigation: Optional navigation collaborator sharing the same uow. + """ + self._uow = uow + self._navigation = navigation or TheoryNavigationService(uow) + def persist_timed_out_round( + self, *, interview_id: str, question_id: str, @@ -39,22 +54,20 @@ def persist_timed_out_round( feedback_text = timeout_feedback_for_locale(locale) timer_remaining: int | None = None - 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) + section = self._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) + self._uow.theory_sections.save_aggregate(updated) - next_question_data, timer_remaining = ( - TheoryNavigationService.advance_to_next_unanswered( - uow, - interview_id, - question_id=question_id, - round_num=round_num, - ) + next_question_data, timer_remaining = ( + self._navigation.advance_to_next_unanswered( + interview_id, + question_id=question_id, + round_num=round_num, ) + ) return AnswerFeedbackEvent( question_id=question_id, diff --git a/assets/coding.png b/assets/coding.png index 5eb48fb..a13dfff 100644 Binary files a/assets/coding.png and b/assets/coding.png differ diff --git a/assets/dashboard.png b/assets/dashboard.png index 80524cd..bd09ec9 100644 Binary files a/assets/dashboard.png and b/assets/dashboard.png differ diff --git a/assets/demo-video.mp4 b/assets/demo-video.mp4 new file mode 100644 index 0000000..7eb33ac Binary files /dev/null and b/assets/demo-video.mp4 differ diff --git a/assets/demo_cut.gif b/assets/demo_cut.gif deleted file mode 100644 index 880c392..0000000 Binary files a/assets/demo_cut.gif and /dev/null differ diff --git a/assets/interview-session.png b/assets/interview-session.png index a1d5eb3..6cab251 100644 Binary files a/assets/interview-session.png and b/assets/interview-session.png differ diff --git a/assets/interview-setup.png b/assets/interview-setup.png index be5523d..67123dd 100644 Binary files a/assets/interview-setup.png and b/assets/interview-setup.png differ diff --git a/pyproject.toml b/pyproject.toml index e0f2456..2e98da8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ module = ["tests.*", "tests"] ignore_errors = true [[tool.mypy.overrides]] -module = ["faster_whisper", "faster_whisper.*"] +module = ["faster_whisper"] ignore_missing_imports = true [[tool.mypy.overrides]] diff --git a/static/css/styles.css b/static/css/styles.css index ce2dd10..70d1989 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1,43 +1,75 @@ /* GrillKit - AI Interview Trainer Styles */ :root { - /* Light theme - default */ - --bg-primary: #F8FAFC; - --bg-surface: #FFFFFF; - --text-primary: #0F172A; - --text-secondary: #64748B; - --text-muted: #94A3B8; - --border-color: #E2E8F0; - --border-light: #F1F5F9; - - --accent-primary: #2563EB; - --accent-hover: #1D4ED8; - --accent-light: #DBEAFE; - --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; - --error-bg: #FEF2F2; - --error-text: #DC2626; - --error-border: #EF4444; - --warning-bg: #FFFBEB; - --warning-text: #D97706; - --warning-border: #F59E0B; - - --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); - --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); - --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + /* Backgrounds (depth levels 0 -> 2) */ + --bg-primary: #0B0C0F; + --bg-secondary: #111318; + --bg-tertiary: #151821; + + --surface: #171B26; + --surface-hover: #1D2230; + + --border: #2B3140; + --border-subtle: #202633; + + /* Text */ + --text-primary: #F3F4F6; + --text-secondary: #94A3B8; + --text-muted: #64748B; + + /* Brand / accent */ + --primary: #5E6AD2; + --primary-hover: #7180FF; + --secondary: #8B5CF6; + --primary-soft: rgb(94 106 210 / 0.12); + --primary-soft-strong: rgb(94 106 210 / 0.2); + + /* Status */ + --success: #22C55E; + --warning: #F59E0B; + --danger: #EF4444; + --info: #38BDF8; + + /* Editor / terminal surfaces */ + --editor-bg: #0D1117; + --editor-border: #30363D; + --editor-line: #6E7681; + + /* Legacy aliases mapped onto the new system */ + --bg-surface: var(--surface); + --border-color: var(--border); + --border-light: var(--border-subtle); + + --accent-primary: var(--primary); + --accent-hover: var(--primary-hover); + --accent-light: var(--primary-soft); + --accent-secondary: var(--secondary); + --accent-secondary-hover: #7C3AED; + + --content-panel-bg: var(--surface); + --content-panel-border: var(--border); + --content-panel-text: var(--text-primary); + --content-panel-text-muted: var(--text-secondary); + --content-panel-label: var(--text-secondary); + --content-panel-inset-bg: var(--editor-bg); + --content-panel-shadow: 0 1px 2px rgb(0 0 0 / 0.4); + + --success-bg: rgb(34 197 94 / 0.15); + --success-text: #4ADE80; + --success-border: rgb(34 197 94 / 0.4); + --error-bg: rgb(239 68 68 / 0.15); + --error-text: #F87171; + --error-border: rgb(239 68 68 / 0.4); + --warning-bg: rgb(245 158 11 / 0.15); + --warning-text: #FBBF24; + --warning-border: rgb(245 158 11 / 0.4); + + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.4); + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.5), 0 1px 2px -1px rgb(0 0 0 / 0.5); + --shadow-md: 0 4px 12px -2px rgb(0 0 0 / 0.45); + --shadow-lg: 0 12px 32px -8px rgb(0 0 0 / 0.55); + + --glow-focus: 0 0 0 1px rgb(94 106 210 / 0.4), 0 0 20px rgb(94 106 210 / 0.15); --radius-sm: 0.375rem; --radius: 0.5rem; @@ -49,47 +81,9 @@ --transition-fast: 150ms ease; --transition-base: 200ms ease; -} + --transition-ui: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease, transform 0.15s ease; -@media (prefers-color-scheme: dark) { - :root { - --bg-primary: #0F172A; - --bg-surface: #1E293B; - --text-primary: #E2E8F0; - --text-secondary: #94A3B8; - --text-muted: #64748B; - --border-color: #334155; - --border-light: #1E293B; - - --accent-primary: #38BDF8; - --accent-hover: #0EA5E9; - --accent-light: #0C4A6E; - --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; - --error-bg: #450A0A; - --error-text: #FCA5A5; - --error-border: #EF4444; - --warning-bg: #451A03; - --warning-text: #FCD34D; - --warning-border: #F59E0B; - - --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3); - --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.4), 0 1px 2px -1px rgb(0 0 0 / 0.4); - --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.4); - --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.4); - } + color-scheme: dark; } * { @@ -126,9 +120,9 @@ body { } .navbar { - background-color: var(--bg-surface); - border-bottom: 1px solid var(--border-color); - padding: 0 1.5rem; + background-color: var(--bg-secondary); + border-bottom: 1px solid var(--border-subtle); + padding: 0 2rem; height: 4rem; display: flex; align-items: center; @@ -163,21 +157,21 @@ body { .nav-link { color: var(--text-secondary); text-decoration: none; - padding: 0.5rem 0.75rem; + padding: 0.5rem 0.875rem; border-radius: var(--radius); font-weight: 500; font-size: 0.875rem; - transition: all var(--transition-fast); + transition: var(--transition-ui); } .nav-link:hover { color: var(--text-primary); - background-color: var(--border-light); + background-color: var(--surface-hover); } .nav-link.active { - color: var(--accent-primary); - background-color: var(--accent-light); + color: var(--text-primary); + background-color: var(--primary-soft); } .main-content { @@ -239,18 +233,19 @@ a:hover { /* Cards */ .card { - background-color: var(--bg-surface); - border-radius: var(--radius-lg); - border: 1px solid var(--border-color); + background-color: var(--surface); + border-radius: var(--radius-md); + border: 1px solid var(--border); box-shadow: var(--shadow); padding: 1.5rem; margin-bottom: 1.5rem; + transition: var(--transition-ui); } .card-header { margin-bottom: 1.25rem; padding-bottom: 1rem; - border-bottom: 1px solid var(--border-light); + border-bottom: 1px solid var(--border-subtle); } .card-title { @@ -286,7 +281,7 @@ a:hover { border: 1px solid transparent; cursor: pointer; text-decoration: none; - transition: all var(--transition-fast); + transition: var(--transition-ui); white-space: nowrap; } @@ -294,32 +289,33 @@ a:hover { display: none !important; } -.btn:focus { - outline: 2px solid var(--accent-primary); - outline-offset: 2px; +.btn:focus-visible { + outline: none; + box-shadow: var(--glow-focus); } .btn-primary { - background-color: var(--accent-primary); - color: white; - border-color: var(--accent-primary); + background-color: var(--primary); + color: #fff; + border-color: var(--primary); } .btn-primary:hover { - background-color: var(--accent-hover); - border-color: var(--accent-hover); + background-color: var(--primary-hover); + border-color: var(--primary-hover); text-decoration: none; + transform: translateY(-1px); } .btn-secondary { - background-color: var(--bg-surface); + background-color: transparent; color: var(--text-primary); - border-color: var(--border-color); + border-color: var(--border); } .btn-secondary:hover { - background-color: var(--border-light); - border-color: var(--border-color); + background-color: var(--surface-hover); + border-color: var(--border); text-decoration: none; } @@ -330,19 +326,20 @@ a:hover { } .btn-danger:hover { - background-color: var(--error-text); - color: white; + background-color: rgb(239 68 68 / 0.25); + border-color: var(--danger); + color: #fecaca; text-decoration: none; } .btn-outline { background-color: transparent; color: var(--text-primary); - border-color: var(--border-color); + border-color: var(--border); } .btn-outline:hover { - background-color: var(--border-light); + background-color: var(--surface-hover); text-decoration: none; } @@ -399,8 +396,8 @@ a:hover { .form-control:focus { outline: none; - border-color: var(--accent-primary); - box-shadow: 0 0 0 3px var(--accent-light); + border-color: var(--primary); + box-shadow: var(--glow-focus); } .form-control::placeholder { @@ -506,8 +503,8 @@ textarea.form-control { } .speech-model-size-option:has(input:checked) { - border-color: var(--primary-color, #2563eb); - background: var(--primary-bg-subtle, #eff6ff); + border-color: var(--accent-primary); + background: var(--accent-light); } .speech-model-size-option-title { @@ -692,8 +689,8 @@ textarea.form-control { flex-direction: column; gap: 1.25rem; padding: 1.5rem; - background-color: var(--bg-surface); - border-right: 1px solid var(--border-color); + background-color: var(--bg-secondary); + border-right: 1px solid var(--border-subtle); overflow-y: auto; } @@ -969,7 +966,8 @@ textarea.form-control { } .answer-bubble { - background-color: var(--accent-light); + background-color: var(--primary-soft); + border: 1px solid rgb(94 106 210 / 0.3); color: var(--text-primary); margin-left: auto; margin-right: 0; @@ -1187,13 +1185,15 @@ textarea.form-control { } .history-status--active { - background-color: var(--accent-light); - color: var(--accent-primary); + background-color: rgb(56 189 248 / 0.15); + color: var(--info); + border: 1px solid rgb(56 189 248 / 0.3); } .history-status--completed { background-color: var(--success-bg); color: var(--success-text); + border: 1px solid var(--success-border); } .history-date { @@ -1226,12 +1226,12 @@ textarea.form-control { } .setup-wizard-stepper-item-active { - color: var(--text-color); + color: var(--text-primary); font-weight: 600; } .setup-wizard-stepper-item-complete { - color: var(--text-color); + color: var(--text-primary); } .setup-wizard-stepper-marker { @@ -1247,13 +1247,13 @@ textarea.form-control { } .setup-wizard-stepper-item-active .setup-wizard-stepper-marker { - background: var(--primary-color); - border-color: var(--primary-color); + background: var(--accent-primary); + border-color: var(--accent-primary); color: #fff; } .setup-wizard-stepper-item-complete .setup-wizard-stepper-marker { - background: var(--bg-muted); + background: var(--border-light); } .setup-wizard-nav { @@ -1412,8 +1412,8 @@ textarea.form-control { gap: 0.5rem 1rem; flex-shrink: 0; padding: 0.45rem 1rem; - border-bottom: 1px solid var(--border-color); - background-color: var(--bg-surface); + border-bottom: 1px solid var(--border-subtle); + background-color: var(--bg-secondary); } .coding-session__heading { @@ -1509,6 +1509,19 @@ textarea.form-control { box-shadow: var(--content-panel-shadow); } +.coding-session__brief-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.coding-session__brief-header .coding-session__task-label { + margin: 0; + flex: 1 1 auto; +} + .coding-session__brief--has-runs .coding-session__brief-card { flex: 1 1 45%; max-height: 55%; @@ -1526,8 +1539,13 @@ textarea.form-control { .coding-session__assignment { white-space: pre-wrap; font-size: 0.9375rem; - line-height: 1.6; - color: var(--content-panel-text); + line-height: 1.7; + color: var(--text-secondary); +} + +.coding-session__assignment strong, +.coding-session__assignment b { + color: var(--text-primary); } .coding-session__workspace { @@ -1573,9 +1591,16 @@ textarea.form-control { .coding-editor { flex: 1; min-height: 0; - border: 1px solid var(--border-color); + background-color: var(--editor-bg); + border: 1px solid var(--editor-border); border-radius: var(--radius-md); overflow: hidden; + transition: var(--transition-ui); +} + +.coding-editor:focus-within { + border-color: var(--primary); + box-shadow: var(--glow-focus); } .coding-explanation-input { @@ -1585,6 +1610,9 @@ textarea.form-control { margin-top: 0.5rem; resize: vertical; font-size: 0.875rem; + background-color: var(--bg-secondary); + border-color: var(--border-subtle); + color: var(--text-secondary); } .coding-session__editor-shell--explanation .coding-editor { @@ -1600,8 +1628,8 @@ textarea.form-control { 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); + background-color: var(--editor-bg); + border: 1px solid var(--editor-border); box-shadow: var(--content-panel-shadow); overflow: hidden; } @@ -1609,12 +1637,12 @@ textarea.form-control { .coding-session__runs-header { flex-shrink: 0; padding: 0.75rem 1rem; - border-bottom: 1px solid var(--content-panel-border); + border-bottom: 1px solid var(--editor-border); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; - color: var(--content-panel-label); + color: var(--text-secondary); } .coding-session__output { @@ -1622,8 +1650,10 @@ textarea.form-control { min-height: 0; overflow-y: auto; padding: 0.75rem 1rem; - font-size: 0.875rem; - color: var(--content-panel-text); + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.6; + color: var(--text-secondary); } .coding-session__footer { @@ -1634,8 +1664,8 @@ textarea.form-control { gap: 0.5rem; flex-shrink: 0; padding: 0.45rem 0.75rem; - border-top: 1px solid var(--border-color); - background-color: var(--bg-surface); + border-top: 1px solid var(--border-subtle); + background-color: var(--bg-secondary); } .coding-session__footer-actions { @@ -1714,9 +1744,9 @@ textarea.form-control { 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); + background-color: var(--bg-tertiary); + border: 1px solid var(--editor-border); + color: var(--text-secondary); font-size: 0.8125rem; overflow-x: auto; white-space: pre-wrap; @@ -1784,9 +1814,16 @@ textarea.form-control { .section-result-card { padding: 1rem; - border: 1px solid var(--border-color); + border: 1px solid var(--border); border-radius: var(--radius-md); - background: var(--bg-surface); + background: var(--surface); + transition: var(--transition-ui); +} + +.section-result-card:hover { + border-color: var(--primary); + box-shadow: var(--shadow-md); + transform: translateY(-1px); } .section-result-card__head { @@ -1862,8 +1899,8 @@ textarea.form-control { } .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)); + background: color-mix(in srgb, var(--accent-primary) 12%, var(--bg-surface)); + border: 1px solid color-mix(in srgb, var(--accent-primary) 25%, var(--border-color)); } .coding-task-accordion { @@ -1903,6 +1940,12 @@ textarea.form-control { border-top: 1px solid var(--border-color); } +.coding-task-panel__actions { + display: flex; + justify-content: flex-end; + padding-top: 0.75rem; +} + .coding-task-round { padding-top: 1rem; } diff --git a/static/js/coding_editor.js b/static/js/coding_editor.js index cbd1f82..ecfba7d 100644 --- a/static/js/coding_editor.js +++ b/static/js/coding_editor.js @@ -136,13 +136,13 @@ }, getValue: function () { - if (editor) { - return editor.getValue(); - } const textarea = document.getElementById("coding-explanation-input"); if (textarea && !textarea.hidden) { return textarea.value; } + if (editor) { + return editor.getValue(); + } return ""; }, diff --git a/static/js/coding_session.js b/static/js/coding_session.js index 3bc127e..0ebb54b 100644 --- a/static/js/coding_session.js +++ b/static/js/coding_session.js @@ -18,6 +18,7 @@ : null; const llmRequestTimeoutSeconds = Number(panel.dataset.llmTimeout || 60); let isSubmitting = false; + window.isSubmitting = false; let ws = null; let reconnectTimer = null; let evaluationWatchdogTimer = null; @@ -104,6 +105,11 @@ if (prompt) { prompt.textContent = task.prompt_text || ""; } + if (window.KnownQuestions && window.KnownQuestions.updateCodingKnownItem) { + const itemId = task.task_id || taskId; + const round = task.round != null ? Number(task.round) : 0; + window.KnownQuestions.updateCodingKnownItem(itemId, round); + } } function applyTask(task) { @@ -184,7 +190,7 @@ questionId: taskId, round: currentRound, getWs: function () { - return null; + return ws; }, }); } @@ -213,6 +219,7 @@ } showEvaluating(false); isSubmitting = false; + window.isSubmitting = false; setComposerEnabled(true); showError( "AI evaluation is taking too long. Check /config, then try again." @@ -242,6 +249,7 @@ clearEvaluationWatchdog(); showEvaluating(false); isSubmitting = false; + window.isSubmitting = false; setComposerEnabled(true); showError( "Connection lost during evaluation. Refresh the page to continue." @@ -266,6 +274,7 @@ clearEvaluationWatchdog(); showEvaluating(false); isSubmitting = false; + window.isSubmitting = false; setComposerEnabled(true); const output = getOutput(); @@ -356,6 +365,7 @@ clearEvaluationWatchdog(); showEvaluating(false); isSubmitting = false; + window.isSubmitting = false; setComposerEnabled(true); showError(data.message || "Coding submit failed."); break; @@ -439,6 +449,7 @@ return; } isSubmitting = true; + window.isSubmitting = true; setComposerEnabled(false); stopTaskTimer(); ws.send( @@ -450,6 +461,15 @@ ); } + window.grillkitOnTimerExpired = function () { + if (isSubmitting) { + return; + } + isSubmitting = true; + window.isSubmitting = true; + setComposerEnabled(false); + }; + function bindActions() { const runBtn = getRunBtn(); const submitBtn = getSubmitBtn(); diff --git a/static/js/known_questions.js b/static/js/known_questions.js new file mode 100644 index 0000000..ac77d7b --- /dev/null +++ b/static/js/known_questions.js @@ -0,0 +1,209 @@ +(function () { + "use strict"; + + async function fetchKnown() { + const response = await fetch("/known-questions"); + if (!response.ok) { + throw new Error("Failed to load known questions"); + } + return response.json(); + } + + async function markKnown(branch, itemId) { + const response = await fetch("/known-questions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ branch: branch, item_id: itemId }), + }); + if (!response.ok) { + throw new Error("Failed to mark question as known"); + } + return response.json(); + } + + async function unmarkKnown(branch, itemId) { + const response = await fetch("/known-questions", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ branch: branch, item_id: itemId }), + }); + if (!response.ok) { + throw new Error("Failed to unmark question"); + } + return response.json(); + } + + function isKnown(known, branch, itemId) { + const ids = known[branch] || []; + return ids.indexOf(itemId) !== -1; + } + + function markLabel(button) { + return button.classList.contains("known-question-toggle--interview") + ? "I know this" + : "Mark as known"; + } + + function updateButton(button, known) { + const branch = button.dataset.branch; + const itemId = button.dataset.itemId; + const marked = isKnown(known, branch, itemId); + button.textContent = marked ? "Unmark" : markLabel(button); + button.classList.toggle("btn-primary", marked); + button.classList.toggle("btn-outline", !marked); + button.dataset.known = marked ? "true" : "false"; + } + + function bindKnownToggleButton(button, known) { + if (!button || button.dataset.knownBound === "true") { + return; + } + const branch = button.dataset.branch; + if (!branch || !button.dataset.itemId) { + return; + } + if (known) { + updateButton(button, known); + } + button.dataset.knownBound = "true"; + button.addEventListener("click", function () { + const itemId = button.dataset.itemId; + const action = button.dataset.known === "true" + ? unmarkKnown(branch, itemId) + : markKnown(branch, itemId); + action + .then(function (updated) { + updateButton(button, updated); + }) + .catch(function () { + window.alert("Could not update known status. Try again."); + }); + }); + } + + function bindKnownToggleButtons(scope, branch, known) { + const root = scope || document; + root.querySelectorAll(".known-question-toggle").forEach(function (button) { + if (branch && button.dataset.branch !== branch) { + return; + } + bindKnownToggleButton(button, known); + }); + } + + function initKnownToggleButtons(branch) { + const buttons = document.querySelectorAll(".known-question-toggle"); + if (!buttons.length) { + return; + } + fetchKnown() + .then(function (known) { + bindKnownToggleButtons(document, branch, known); + }) + .catch(function () { + buttons.forEach(function (button) { + if (!branch || button.dataset.branch === branch) { + button.disabled = true; + } + }); + }); + } + + function knownToggleButtonHtml(branch, itemId, interviewMode) { + const classes = "btn btn-outline btn-sm known-question-toggle" + + (interviewMode ? " known-question-toggle--interview" : ""); + const label = interviewMode ? "I know this" : "Mark as known"; + return '"; + } + + function bindKnownToggleIn(root) { + fetchKnown() + .then(function (known) { + bindKnownToggleButtons(root, null, known); + }) + .catch(function () { + if (!root) { + return; + } + root.querySelectorAll(".known-question-toggle").forEach(function (button) { + button.disabled = true; + }); + }); + } + + function updateCodingKnownItem(itemId, round) { + const button = document.getElementById("coding-known-item-btn"); + if (!button) { + return; + } + const roundNum = Number(round || 0); + button.hidden = roundNum > 0; + if (roundNum > 0) { + return; + } + button.dataset.itemId = itemId; + delete button.dataset.knownBound; + fetchKnown() + .then(function (known) { + bindKnownToggleButton(button, known); + }) + .catch(function () { + button.disabled = true; + }); + } + + function initManagePage() { + document.querySelectorAll(".known-questions-unmark").forEach(function (button) { + button.addEventListener("click", function () { + const branch = button.dataset.branch; + const itemId = button.dataset.itemId; + unmarkKnown(branch, itemId) + .then(function () { + const item = button.closest(".known-questions-item"); + if (item) { + item.remove(); + } + const list = document.querySelector( + '.known-questions-list[data-branch="' + branch + '"]' + ); + if (list && !list.querySelector(".known-questions-item")) { + const section = list.closest(".known-questions-section"); + if (section) { + const empty = document.createElement("p"); + empty.className = "known-questions-empty"; + empty.textContent = branch === "theory" + ? "No theory questions marked as known." + : "No coding tasks marked as known."; + list.replaceWith(empty); + } + } + }) + .catch(function () { + window.alert("Could not unmark question. Try again."); + }); + }); + }); + } + + window.KnownQuestions = { + fetchKnown: fetchKnown, + markKnown: markKnown, + unmarkKnown: unmarkKnown, + knownToggleButtonHtml: knownToggleButtonHtml, + bindKnownToggleIn: bindKnownToggleIn, + initReviewButtons: initKnownToggleButtons, + initInterviewTheory: function () { + initKnownToggleButtons("theory"); + }, + initInterviewCoding: function () { + initKnownToggleButtons("coding"); + }, + updateCodingKnownItem: updateCodingKnownItem, + initManagePage: initManagePage, + }; + + if (document.body.classList.contains("page-known-questions")) { + initManagePage(); + } +})(); diff --git a/static/js/setup_wizard.js b/static/js/setup_wizard.js index ae488a7..11c6bf6 100644 --- a/static/js/setup_wizard.js +++ b/static/js/setup_wizard.js @@ -32,6 +32,8 @@ const timerMinutesGroup = document.getElementById("question-timer-minutes-group"); const codingTimerCheckbox = document.getElementById("enable_coding_timer"); const codingTimerMinutesGroup = document.getElementById("coding-timer-minutes-group"); + const excludeKnownCheckbox = document.getElementById("exclude_known"); + const excludeKnownHint = document.getElementById("exclude-known-hint"); const localeLabel = wizard.dataset.localeLabel || ""; const initialStep = wizard.dataset.initialStep || "mode"; @@ -196,9 +198,11 @@ : null; const theoryEnabled = branchEnabled(sessionMode, "theory"); const codingEnabled = branchEnabled(sessionMode, "coding"); + const excludeKnown = !excludeKnownCheckbox || excludeKnownCheckbox.checked; return { version: 2, session_mode: sessionMode, + exclude_known: excludeKnown, theory: { enabled: theoryEnabled, question_count: theoryEnabled ? theoryCount : 0, @@ -359,6 +363,43 @@ + ""; } + function renderKnownExclusionNote() { + if (!excludeKnownHint || !excludeKnownCheckbox || !excludeKnownCheckbox.checked) { + if (excludeKnownHint) { + const link = excludeKnownHint.querySelector("a"); + const linkHtml = link ? link.outerHTML : ""; + excludeKnownHint.innerHTML = + "Known questions will be included in this session. " + + linkHtml; + } + return Promise.resolve(); + } + return fetch("/known-questions") + .then(function (response) { + if (!response.ok) { + throw new Error("failed"); + } + return response.json(); + }) + .then(function (known) { + const count = (known.theory || []).length + (known.coding || []).length; + const link = excludeKnownHint.querySelector("a"); + const linkHtml = link ? link.outerHTML : ""; + const note = count === 0 + ? "No known questions saved yet. " + : count + " known question" + (count === 1 ? "" : "s") + + " will be excluded. "; + excludeKnownHint.innerHTML = note + linkHtml; + }) + .catch(function () { + const link = excludeKnownHint.querySelector("a"); + const linkHtml = link ? link.outerHTML : ""; + excludeKnownHint.innerHTML = + "Known questions are skipped when building a new session. " + + linkHtml; + }); + } + function renderReviewSummary() { const selection = buildSelection(); const mode = selection.session_mode; @@ -399,7 +440,16 @@ + "

Change in Configuration.

", false ); + if (selection.exclude_known) { + html += renderReviewCard( + "review", + "Known questions", + "

Exclude questions marked as known.

", + false + ); + } reviewSummary.innerHTML = html; + renderKnownExclusionNote(); reviewSummary.querySelectorAll(".setup-review-edit").forEach(function (button) { button.addEventListener("click", function () { showStep(button.dataset.editStep); @@ -758,6 +808,14 @@ } document.getElementById("question_time_minutes").addEventListener("input", onFormChange); document.getElementById("coding_time_minutes").addEventListener("input", onFormChange); + if (excludeKnownCheckbox) { + excludeKnownCheckbox.addEventListener("change", function () { + if (currentStepId === "review") { + renderReviewSummary(); + } + onFormChange(); + }); + } nextBtn.addEventListener("click", goNext); backBtn.addEventListener("click", goBack); diff --git a/templates/base.html b/templates/base.html index 2455b52..a90f24b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -14,6 +14,7 @@ diff --git a/templates/coding_interview.html b/templates/coding_interview.html index 23058b6..65ed720 100644 --- a/templates/coding_interview.html +++ b/templates/coding_interview.html @@ -53,9 +53,19 @@

{{ interview_title }}

-
- - -

Lowercase letters, digits, and hyphens. Stored in data/llm_models.json.

-
+

A stable id is generated from this name and stored in data/llm_models.json.

diff --git a/templates/interview.html b/templates/interview.html index a812277..5cd95b1 100644 --- a/templates/interview.html +++ b/templates/interview.html @@ -104,6 +104,14 @@

{{ interview_title }}

aria-label="Play question" title="Play question" aria-pressed="false">Play {% endif %} + {% if interview.status == "active" and answer.round == 0 %} + + {% endif %}
{{ answer.question_text }} @@ -204,6 +212,7 @@

{{ interview_title }}

{% if (question_timer_enabled or (coding is defined and coding is not none and coding.task_timer_enabled)) and interview.status == "active" %} {% endif %} + +{% endblock %} diff --git a/templates/session_results.html b/templates/session_results.html index 2f4afff..5d6cc38 100644 --- a/templates/session_results.html +++ b/templates/session_results.html @@ -70,8 +70,10 @@

Sections

{{ card.label }}

- {% if card.skipped %} + {% if card.skipped and card.max_score == 0 %} Skipped + {% elif card.skipped %} + {{ card.score }} / {{ card.max_score }} (incomplete) {% else %} {{ card.score }} / {{ card.max_score }} {% endif %} diff --git a/templates/setup.html b/templates/setup.html index 5f22216..c4748cc 100644 --- a/templates/setup.html +++ b/templates/setup.html @@ -199,6 +199,16 @@

Interview Setup