diff --git a/.gitignore b/.gitignore
index 1790630..b5eac8b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,6 +46,8 @@ env/
# Database
*.db
+*.db-shm
+*.db-wal
*.sqlite
*.sqlite3
data/config.json
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 55f8943..ed37edb 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -53,8 +53,13 @@ grillkit/
│ │ ├── api/
│ │ │ ├── config.py # GET/POST /config
│ │ │ └── deps.py
-│ │ └── services/
-│ │ ├── config.py # AppConfig, ConfigService (data/config.json)
+│ │ ├── use_cases/
+│ │ │ └── config.py # AppConfig, ConfigService (data/config.json)
+│ │ ├── queries/
+│ │ │ ├── config_form.py # Config page read model
+│ │ │ ├── llm_page.py # LLM catalog page context
+│ │ │ └── platform_page.py
+│ │ └── support/
│ │ ├── llm_catalog.py # data/llm_models.json load/save/select
│ │ ├── speech_runtime.py # SpeechRuntimeCoordinator (Whisper + Piper lifecycle)
│ │ ├── speech_settings.py
@@ -62,26 +67,27 @@ grillkit/
│ ├── interview/ # Session orchestrator (shell, setup, dashboard, completion)
│ │ ├── domain/ # Interview shell aggregate, SessionSelection, serialization
│ │ ├── schemas/ # InterviewRead, dashboard/page, known_questions
-│ │ ├── services/rules/ # selection_spec v2, display titles, bank_selection
+│ │ ├── domain/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)
+│ │ │ ├── mappers.py # ORM ↔ shell ↔ InterviewRead (+ theory/coding compose)
│ │ │ └── uow.py # InterviewUnitOfWork (app-wide repositories)
-│ │ ├── services/
-│ │ │ ├── creation.py # SessionCreationService
-│ │ │ ├── page.py # SessionPageService
-│ │ │ ├── completion.py # SessionCompletionService
-│ │ │ ├── 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
-│ │ │ ├── results_page.py # SessionResultsPageService (completed hub)
-│ │ │ ├── section_feedback.py, section_evaluation.py, scoring.py
-│ │ │ └── events.py # Shared WS/NDJSON event types (theory + coding)
+│ │ ├── use_cases/
+│ │ │ ├── create_session.py # CreateInterviewSession
+│ │ │ ├── complete_session.py # CompleteInterviewSession
+│ │ │ └── advance_phase.py # SessionPhaseOrchestrator
+│ │ ├── queries/
+│ │ │ ├── loader.py # InterviewLoader (was InterviewQuery)
+│ │ │ ├── session_page.py # ActiveSessionPage
+│ │ │ ├── dashboard.py # InterviewDashboard
+│ │ │ ├── results_page.py # CompletedSessionResults
+│ │ │ ├── review_context.py
+│ │ │ └── projection.py
+│ │ └── support/
+│ │ ├── known_questions.py, bank_text.py
+│ │ ├── feedback_prefetch.py
+│ │ └── ai_errors.py
│ │ └── api/
│ │ ├── deps.py
│ │ ├── dashboard.py # GET /
@@ -93,41 +99,61 @@ grillkit/
│ │ └── errors.py
│ ├── coding/ # Coding section (tasks, Judge0 runner, WS/API, evaluator)
│ │ ├── domain/ # CodingSection, CodingTask, CodeRunAttempt aggregates
+│ │ ├── schemas/ # coding read models + WS messages
│ │ ├── repositories/ # coding_section repo, mappers
-│ │ ├── services/
-│ │ │ ├── planning.py # YAML task plan from data/coding/
-│ │ │ ├── creation.py # CodingSectionCreationService
-│ │ │ ├── availability.py # CODING_ENABLED + Judge0 health gate
-│ │ │ ├── runner.py # CodingRunnerService (public/hidden tests, compile-only)
-│ │ │ ├── run_execution.py, submission.py, navigation.py, state.py, page.py
-│ │ │ ├── judge0_client.py, judge0_config.py, harness.py
-│ │ │ ├── section.py, query.py, review.py
-│ │ │ └── evaluator/ # CodingEvaluatorService
-│ │ ├── api/
-│ │ │ ├── routes.py # POST /coding/run, GET /coding/state, WS /coding/ws
-│ │ │ └── ws_session.py, ws_protocol.py
-│ │ └── schemas/ # coding read models + WS messages
+│ │ ├── use_cases/
+│ │ │ ├── create_section.py # CreateCodingSection
+│ │ │ ├── navigate_tasks.py
+│ │ │ ├── run_tests.py # CodingRunExecutionService
+│ │ │ └── submit_solution.py # CodingSubmissionService
+│ │ ├── queries/
+│ │ │ ├── loader.py
+│ │ │ ├── task_page.py # ActiveCodingTaskPage
+│ │ │ ├── section_state.py
+│ │ │ ├── section_summary.py
+│ │ │ └── review_page.py
+│ │ ├── support/
+│ │ │ ├── coding_availability.py
+│ │ │ ├── evaluation_commit.py
+│ │ │ ├── events.py
+│ │ │ └── run_result_mapper.py
+│ │ └── api/
+│ │ ├── routes.py # POST /coding/run, GET /coding/state, WS /coding/ws
+│ │ ├── ws_session.py, ws_protocol.py
+│ │ └── errors.py
│ ├── theory/ # Theory section (tasks, timer, WS, evaluator)
│ │ ├── domain/ # TheorySection, TheoryTask aggregates
│ │ ├── schemas/ # TheoryTaskRead, TheoryPageContext, WS messages
│ │ ├── repositories/ # theory_section repo, mappers
-│ │ ├── services/
-│ │ │ ├── planning.py # YAML question plan (excludes type=coding)
-│ │ │ ├── creation.py # TheorySectionCreationService
-│ │ │ ├── submission.py # answer/timeout/audio orchestration
-│ │ │ ├── navigation.py, timer.py, evaluation_persistence.py
-│ │ │ ├── page.py, query.py, section.py, review.py
-│ │ │ └── evaluator/ # TheoryEvaluatorService
+│ │ ├── use_cases/
+│ │ │ └── create_section.py # CreateTheorySection
+│ │ ├── queries/
+│ │ │ ├── loader.py
+│ │ │ ├── task_page.py # ActiveTheoryTaskPage
+│ │ │ ├── section_state.py
+│ │ │ ├── section_summary.py
+│ │ │ └── review_page.py
+│ │ ├── support/
+│ │ │ ├── events.py
+│ │ │ └── feedback_prefetch.py
│ │ └── api/
│ │ ├── routes.py # WS /theory/ws, POST /theory/audio-answer
-│ │ ├── ws_session.py, ws_protocol.py, audio_answer.py
+│ │ ├── ws_session.py, ws_protocol.py
+│ │ └── errors.py
│ ├── question_voice/
│ │ ├── api/
│ │ │ └── routes.py # GET /speech/tts/status, POST /speech/tts/voice/download
-│ │ └── services/ # piper_*, tts_cache, question_audio, rules (voices)
+│ │ ├── queries/
+│ │ │ ├── voice_page.py
+│ │ │ └── voice_status.py
+│ │ └── use_cases/
+│ │ └── generate_question_audio.py
│ ├── speech/
│ │ ├── schemas/ # Pydantic status/page context read models
-│ │ ├── services/ # whisper_*, dictation, transcriber_resolver
+│ │ ├── queries/
+│ │ │ └── speech_page.py
+│ │ ├── use_cases/
+│ │ │ └── (reserved for future speech workflows)
│ │ └── api/
│ │ ├── routes.py # GET/POST /speech/model/*
│ │ ├── dictation.py # WS /interview/{id}/dictation
@@ -153,12 +179,12 @@ grillkit/
├── conftest.py, fakes.py
├── helpers/ # Flat shared seeds (interview_seed, coding_seed, …)
├── ai/, app/
- ├── interview/{api,repositories,services/rules,services}/
- ├── theory/{api,services,repositories,integration}/
- ├── coding/{api,services,repositories}/
- ├── speech/{api,services}/
- ├── question_voice/{api,services}/
- ├── platform/{api,services}/
+ ├── interview/{api,repositories,domain/rules,use_cases,queries,support}/
+ ├── theory/{api,use_cases,queries,repositories,integration}/
+ ├── coding/{api,use_cases,queries,repositories}/
+ ├── speech/{api,queries}/
+ ├── question_voice/{api,use_cases}/
+ ├── platform/{api,use_cases,queries}/
└── shared/{infrastructure}/
```
@@ -204,31 +230,33 @@ grillkit/
| Package / layer | Responsibility |
|-----------------|----------------|
| `interview/api/`, `speech/api/`, `platform/api/`, `question_voice/api/` | HTTP/WebSocket transport, forms, template rendering |
-| `*/api/deps.py` | Inject request-scoped services and `InterviewUnitOfWork` via FastAPI `Depends` |
+| `*/api/deps.py` | Inject request-scoped use cases 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 |
| `interview/schemas/` | Session read models (`InterviewRead`, dashboard/page context) |
| `theory/schemas/` | Theory read models and WebSocket wire message types |
-| `interview/repositories/mappers.py` | Shell ORM ↔ domain; composes `InterviewRead` with theory tasks |
-| `theory/api/ws_protocol.py` | Map service events → theory WebSocket/NDJSON JSON |
-| `theory/api/ws_session.py` | Parse client WebSocket messages, call `TheorySubmissionService` |
+| `interview/repositories/mappers.py` | Shell ORM ↔ domain; composes `InterviewRead` with theory and coding tasks |
+| `theory/api/ws_protocol.py` | Map domain events → theory WebSocket/NDJSON JSON |
+| `theory/api/ws_session.py` | Parse client WebSocket messages, call theory use cases |
| `theory/api/audio_answer.py` | Validate multipart input and stream NDJSON from theory events |
| `speech/api/dictation_protocol.py` | Dictation WebSocket message types (`start`, `stop`, `ready`, `final`, `error`) |
| `interview/api/errors.py` | Map `InterviewDomainError` → error payloads |
-| `*/services/` | Use-case orchestration (static methods on service classes) |
-| `*/services/rules/` | Pure helpers (no I/O) for a feature (selection display, voices, etc.) |
+| `*/use_cases/` | Use-case orchestration (instance classes receiving UoW) |
+| `*/queries/` | Read-only view-model builders and loaders |
+| `*/support/` | Pure helpers, event mappers, and cross-cutting utilities |
+| `*/domain/rules/` | Pure helpers (no I/O) for a feature (selection display, voices, etc.) |
| `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`: 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 |
+| `interview/queries/results_page.py` | Completed session hub context (`CompletedSessionResults`) |
+| `theory/queries/review_page.py`, `coding/queries/review_page.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 |
-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.
+Workflow use cases (submit, create, complete, section creation, evaluation persistence, navigation) are **instance classes** constructed with `InterviewUnitOfWork`. FastAPI dependencies in `interview/api/deps.py` yield scoped UoW instances. Read-only helpers live in `*/queries/` and may remain static until migrated.
## Module Dependency Graph
@@ -238,49 +266,60 @@ Dependencies flow **downward** (caller → callee). Plain-text diagram for edito
main.py ──► lifespan: init_db(), SpeechRuntimeCoordinator.startup() (Whisper + Piper when configured)
├── interview/api/ (dashboard, setup, routes)
│ ├── routes.py ──► ws_protocol, errors, page (full template context)
- │ └── deps.py ──► interview/services/*
- ├── platform/api/config.py ──► platform/services/config, platform/services/page
- ├── question_voice/api/routes.py ──► piper_voice, tts_cache
+ │ └── deps.py ──► interview/use_cases/*, interview/queries/*
+ ├── platform/api/config.py ──► platform/use_cases/config, platform/queries/*
+ ├── question_voice/api/routes.py ──► question_voice/use_cases/*
└── speech/api/ (routes, dictation)
├── dictation.py ──► dictation_protocol, transcriber_resolver, dictation session
- └── routes.py ──► speech/services/whisper_model
-
-interview/api/routes.py ──► question_voice/services/question_audio, interview/api/deps (AIProvider)
-interview/services/query.py ──► cross-feature read helpers (`get_active_interview_or_raise`)
-
-question_voice/services/
- ├── question_audio.py ──► interview/services/query, speech_settings, tts_cache
- ├── piper_voice.py ──► Hugging Face download into data/piper-voices/
- ├── piper_runtime.py ──► in-process PiperVoice load and synthesis
- └── tts_cache.py ──► data/tts-cache/v2/{locale}/
-
-interview/services/
- ├── creation.py ──► SessionCreationService + section creation services
- ├── page.py ──► SessionPageService, TheoryPageService, CodingPageService
- ├── completion.py ──► SessionCompletionService, SessionEvaluationAggregator
- ├── results_page.py ──► completed hub; review links via section registry
- ├── query.py, dashboard.py, phases.py, sections.py
- └── session_evaluator.py ──► session-level narrative (theory + coding sections)
-
-theory/services/
- ├── planning.py ──► app/shared/questions.py (filters type=coding)
- ├── creation.py, submission.py, navigation.py, timer.py, review.py
- ├── section.py ──► section registry hooks + prefetch
- └── evaluator/ ──► TheoryEvaluatorService (per-task + section narrative)
-
-coding/services/
- ├── planning.py ──► app/shared/coding.py
- ├── runner.py, submission.py, section.py, review.py
- └── evaluator/ ──► CodingEvaluatorService (per-task + section narrative)
-
-interview/api/deps.py ──► platform/services/ai_context (yields AIProvider for WS/routes)
-
-platform/services/config.py ──► ai/factory, speech/schemas, data/config.json
-speech/services/
- ├── whisper_model.py ──► whisper_runtime, whisper_storage, Hugging Face hub
- ├── whisper_runtime.py ──► ai/faster_whisper_transcriber, whisper_storage
- ├── transcriber_resolver.py ──► whisper_runtime, ConfigService
- └── dictation.py ──► ai/speech_transcriber
+ └── routes.py ──► speech/queries/speech_page
+
+interview/api/routes.py ──► question_voice/use_cases/generate_question_audio, interview/api/deps (AIProvider)
+interview/queries/loader.py ──► cross-feature read helpers (`get_active_interview_or_raise`)
+
+question_voice/use_cases/
+ └── generate_question_audio.py ──► interview/queries/loader, speech_settings, tts_cache
+
+interview/use_cases/
+ ├── create_session.py ──► CreateInterviewSession + section creation use cases
+ ├── complete_session.py ──► CompleteInterviewSession, SessionEvaluationAggregator
+ └── advance_phase.py ──► SessionPhaseOrchestrator
+
+interview/queries/
+ ├── session_page.py ──► ActiveSessionPage, TheoryPageService, CodingPageService
+ ├── results_page.py ──► CompletedSessionResults; review links via section registry
+ ├── loader.py ──► InterviewLoader (was InterviewQuery)
+ ├── dashboard.py ──► InterviewDashboard
+ └── review_context.py
+
+theory/use_cases/
+ └── create_section.py ──► CreateTheorySection
+
+theory/queries/
+ ├── task_page.py ──► ActiveTheoryTaskPage
+ ├── section_state.py, section_summary.py
+ └── review_page.py ──► theory review context builder
+
+coding/use_cases/
+ ├── create_section.py ──► CreateCodingSection
+ ├── navigate_tasks.py
+ ├── run_tests.py ──► CodingRunExecutionService
+ └── submit_solution.py ──► CodingSubmissionService
+
+coding/queries/
+ ├── task_page.py ──► ActiveCodingTaskPage
+ ├── section_state.py, section_summary.py
+ └── review_page.py ──► coding review context builder
+
+interview/api/deps.py ──► platform/support/ai_context (yields AIProvider for WS/routes)
+
+platform/use_cases/config.py ──► ai/factory, speech/schemas, data/config.json
+platform/support/
+ ├── llm_catalog.py ──► data/llm_models.json
+ ├── speech_runtime.py ──► whisper_runtime, piper_runtime
+ └── speech_settings.py
+
+speech/queries/
+ └── speech_page.py ──► speech status page context
shared/infrastructure/uow.py
└── interview/, theory/, coding/ repositories ──► shared/repositories/base, models
@@ -320,48 +359,60 @@ flowchart TB
dictation_svc --> speech_transcriber_proto[speech_transcriber]
speech_router --> whisper_model
whisper_runtime --> faster_whisper_transcriber
- subgraph interview_svc [interview/services]
- interview_creation[creation]
- interview_query[query]
- interview_completion[completion]
- interview_phases[phases]
- session_evaluator[session_evaluator]
- results_page[results_page]
+ subgraph interview_uc [interview/use_cases]
+ interview_creation[create_session]
+ interview_completion[complete_session]
+ interview_phases[advance_phase]
end
- subgraph theory_svc [theory/services]
- theory_submission[submission]
- theory_evaluator[evaluator]
- theory_review[review]
+ subgraph interview_q [interview/queries]
+ interview_query[loader]
+ interview_session_page[session_page]
+ interview_results_page[results_page]
+ interview_dashboard[dashboard]
end
- subgraph coding_svc [coding/services]
- coding_submission[submission]
- coding_runner[runner]
- coding_evaluator[evaluator]
- coding_review[review]
+ subgraph theory_uc [theory/use_cases]
+ theory_creation[create_section]
end
- subgraph platform_svc [platform/services]
+ subgraph theory_q [theory/queries]
+ theory_task_page[task_page]
+ theory_review[review_page]
+ end
+ subgraph coding_uc [coding/use_cases]
+ coding_creation[create_section]
+ coding_navigate[navigate_tasks]
+ coding_run[run_tests]
+ coding_submit[submit_solution]
+ end
+ subgraph coding_q [coding/queries]
+ coding_task_page[task_page]
+ coding_review[review_page]
+ end
+ subgraph platform_uc [platform/use_cases]
config_service[config]
+ end
+ subgraph platform_sup [platform/support]
ai_context
+ llm_catalog
+ speech_runtime
end
- subgraph speech_svc [speech/services]
- whisper_model
- dictation_svc
+ subgraph speech_q [speech/queries]
+ speech_page[speech_page]
end
faster_whisper_transcriber --> speech_transcriber_proto
whisper_model --> whisper_runtime
whisper_model --> whisper_storage
whisper_runtime --> whisper_storage
- interview_svc --> interview_domain[domain]
- interview_svc --> interview_rules[services/rules]
- interview_svc --> uow
- interview_svc --> questions_mod[questions]
- interview_creation --> questions_mod
+ interview_uc --> interview_domain[domain]
+ interview_uc --> interview_rules[domain/rules]
+ interview_uc --> uow
+ interview_q --> interview_domain[domain]
+ interview_q --> uow
+ interview_creation --> questions_mod[questions]
interview_completion --> session_evaluator
- theory_submission --> theory_evaluator
- coding_submission --> coding_runner
- coding_submission --> coding_evaluator
- results_page --> theory_review
- results_page --> coding_review
+ coding_submit --> coding_runner
+ coding_submit --> coding_evaluator
+ interview_results_page --> theory_review
+ interview_results_page --> coding_review
ai_context --> config_service
ai_context --> ai_layer
subgraph ai_layer [ai]
@@ -397,16 +448,17 @@ flowchart TB
| Theory task ORM | `shared.infrastructure.models.Answer` (table `answers`, FK `theory_section_id`) |
| Coding task ORM | `shared.infrastructure.models.CodingTask` (table `coding_tasks`) |
| Coding run snapshot ORM | `shared.infrastructure.models.CodeRunAttempt` |
-| Session read DTO | `app.interview.schemas.interview.InterviewRead` (composes theory tasks) |
+| Session read DTO | `app.interview.schemas.interview.InterviewRead` (composes theory and coding tasks) |
| Theory task read DTO | `app.theory.schemas.theory.TheoryTaskRead` |
+| Coding task read DTO | `app.coding.schemas.coding.CodingTaskRead` |
| Route / WS path param | `interview_id` (same value as `Interview.id`) |
-| Create flow | `SessionCreationService.create_session()` + section creation services when enabled |
-| Read flow | `InterviewQuery.load()` / `InterviewQuery(uow).get_interview()`, `DashboardBuilder.list_rows()` |
-| Complete flow | `SessionCompletionService.complete_session()` |
-| Results hub | `SessionResultsPageService.prepare_page()` |
+| Create flow | `CreateInterviewSession.create_session()` + section creation use cases when enabled |
+| Read flow | `InterviewLoader.load()` / `InterviewLoader(uow).get_interview()`, `InterviewDashboard.list_rows()` |
+| Complete flow | `CompleteInterviewSession.complete_session()` |
+| Results hub | `CompletedSessionResults.prepare_page()` |
| 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) |
+| Theory submit | Theory use cases in `theory/api/routes.py` + `theory/api/ws_session.py` (WS + audio + timeouts) |
+| Coding submit | `CodingSubmissionService` via `coding/use_cases/submit_solution.py` (WS submit after Run history) |
| SQLAlchemy session | `uow.session` |
## Key Models
@@ -489,7 +541,7 @@ Initial task rows are created with the theory section; follow-ups append via `Th
| `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`).
+Instance-wide list (no user accounts). When setup sends `exclude_known: true`, `CreateInterviewSession` 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/support/bank_text.py` (full-bank `id → text` indexes cached per process with `@lru_cache`).
## Data Flow: Configure Provider
@@ -513,10 +565,10 @@ User → POST /config/llm-models (display_name, base_url, model, optional api_ke
User → POST /setup (selection_json v2: session_mode, theory/coding branches, counts, timers)
→ parse SessionSelection; gate coding modes on CODING_ENABLED + Judge0 health
→ locale from ConfigService.get_config()
- → SessionCreationService.create_session(selection, locale)
+ → CreateInterviewSession.create_session(selection, locale)
→ Interview.start_shell()
- → TheorySectionCreationService.create() when theory.enabled
- → CodingSectionCreationService.create() when coding.enabled
+ → CreateTheorySection.create() when theory.enabled
+ → CreateCodingSection.create() when coding.enabled
→ build_theory_question_plan() (excludes YAML type=coding)
→ build_coding_task_plan() from data/coding/
→ InterviewUnitOfWork(auto_commit=True): shell + section rows + tasks
@@ -527,14 +579,14 @@ User → POST /setup (selection_json v2: session_mode, theory/coding branches, c
```
Client → WS /interview/{id}/theory/ws {"type":"answer",...}
- → TheorySubmissionService (timer, navigation, TheoryEvaluatorService)
+ → theory/api/ws_session.py (timer, navigation, theory evaluation)
→ 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
+ → Session complete: CompleteInterviewSession via WS "complete" message
-Client → WS {"type":"timeout",...} → TheorySubmissionService timeout path (score 0)
+Client → WS {"type":"timeout",...} → theory timeout path (score 0)
Client → WS {"type":"ping"} → pong with session status
```
@@ -548,7 +600,7 @@ Client → POST /interview/{id}/theory/audio-answer (multipart: question_id, fil
→ Client: static/js/interview_audio_answer.js
```
-Gated on the interview page when dictation is available **and** `interview_model_accepts_audio` (`InterviewPageService` + catalog `accepts_audio_input`). Configuration save / add-model tests audio capability with `app/ai/audio_probe.py` when the flag is enabled.
+Gated on the interview page when dictation is available **and** `interview_model_accepts_audio` (`ActiveSessionPage` + catalog `accepts_audio_input`). Configuration save / add-model tests audio capability with `app/ai/audio_probe.py` when the flag is enabled.
## Data Flow: Coding Run and Submit
@@ -556,12 +608,12 @@ Interview page shows a separate **coding panel** (Monaco via CDN) when `session_
```
Client → POST /interview/{id}/coding/run {"task_id","source_code"}
- → CodingRunExecutionService → CodingRunnerService (public tests via Judge0)
+ → coding/use_cases/run_tests.py → CodingRunnerService (public tests via Judge0)
→ persist CodeRunAttempt (snapshot: code, stderr, test_results, attempt_no)
→ JSON mirror of the attempt
Client → WS /interview/{id}/coding/ws {"type":"submit","task_id","source_code"}
- → CodingSubmissionService
+ → coding/use_cases/submit_solution.py
→ hidden tests (Judge0) → submit_test_summary on CodingTask
→ load code_run_attempts for the task
→ CodingEvaluatorService (run history + tests + code in prompt)
@@ -580,7 +632,7 @@ Separate from answer/evaluation WS. Requires active interview and loaded transcr
```
Client → WS connect /interview/{id}/dictation
- → InterviewQuery.load() + require_active()
+ → InterviewLoader.load() + require_active()
→ reject if model missing (download via /config → /speech/model/download)
Client → {"type":"start"}
@@ -613,9 +665,9 @@ Configured size and locale live in `data/config.json` (`AppConfig`). Transcripti
```
Client → WS /interview/{id}/theory/ws {"type":"complete"}
- → SessionCompletionService.complete_session(interview_id)
- → TheoryQueryService.get_evaluation_summary()
- → CodingQueryService.get_evaluation_summary()
+ → CompleteInterviewSession.complete_session(interview_id)
+ → TheorySectionLoader.get_evaluation_summary()
+ → CodingSectionSummary.get_evaluation_summary()
→ SessionEvaluationAggregator.merge() → nested score_breakdown
→ SessionEvaluatorService (cached section narratives or one LLM call)
→ UnitOfWork: save overall_feedback, mark completed
@@ -629,20 +681,20 @@ Display score sums `score_breakdown.theory.score` and `score_breakdown.coding.sc
```
GET /interview/{id} on completed session
- → SessionPageService redirects 303 → /interview/{id}/results
+ → ActiveSessionPage redirects 303 → /interview/{id}/results
GET /interview/{id}/results
- → SessionResultsPageService.prepare_page()
+ → CompletedSessionResults.prepare_page()
→ load completed InterviewRead + overall_feedback JSON
→ section registry builds cards (theory/coding) with review URLs
→ session_results.html
GET /interview/{id}/theory
- → TheoryReviewService.build_context() — answered rounds + section_feedback
+ → theory/queries/review_page.py build_context() — answered rounds + section_feedback
→ theory_review.html (redirect to /results if section missing)
GET /interview/{id}/coding
- → CodingReviewService.build_context() — tasks grouped by task_id with rounds
+ → coding/queries/review_page.py build_context() — tasks grouped by task_id with rounds
→ coding_review.html
```
@@ -671,7 +723,7 @@ with InterviewUnitOfWork(auto_commit=True) as uow:
uow.interviews.save_aggregate(updated)
```
-`InterviewRepository.get()` eagerly loads `answers` via `selectinload`. Prefer `InterviewUnitOfWork` in interview services for all transactional work.
+`InterviewRepository.get()` eagerly loads `answers` via `selectinload`. Prefer `InterviewUnitOfWork` in interview use cases for all transactional work.
## Scoring
@@ -776,7 +828,7 @@ Follow-up rounds use the same pipeline (cache key from localized `question_text`
| Concern | Location |
|---------|----------|
| 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` |
+| Loader | `app/platform/support/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 |
@@ -789,11 +841,11 @@ 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_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 |
-| `platform/` | `tests/platform/{api,services}/` | `test_config.py`, `test_llm_catalog.py` |
+| `interview/` | `tests/interview/{api,repositories,use_cases,queries,support}/` | `test_creation.py`, `test_phases.py`, `test_known_questions.py`, `test_results.py` |
+| `theory/` | `tests/theory/{api,use_cases,queries,repositories,integration}/` | `test_submission.py`, `test_ws_routes.py`, `test_review.py` |
+| `coding/` | `tests/coding/{api,use_cases,queries,repositories}/` | `test_runner.py`, `test_evaluator.py`, `test_review.py` |
+| `speech/`, `question_voice/` | `tests/speech/`, `tests/question_voice/` | API + query tests |
+| `platform/` | `tests/platform/{api,use_cases,queries}/` | `test_config.py`, `test_llm_catalog.py` |
| `shared/` | `tests/shared/` (+ `infrastructure/`) | `test_questions.py`, `test_coding.py`, `test_uow.py` |
| `main.py` | `tests/app/` | `test_main.py` |
@@ -805,7 +857,7 @@ Run the suite:
```bash
uv run pytest
-uv run pytest tests/theory/services/test_submission.py # single module
+uv run pytest tests/theory/use_cases/test_submission.py # single module
```
## Current Limitations
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 497cb42..9655911 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,10 +10,24 @@ Work in progress is accumulated under `[Unreleased]`; on release, that section b
### Changed
+### Fixed
+
+### Removed
+
+## 2026.8.9
+
+### Added
+
+### Changed
+
+- **Updated UI** — light theme is now the default, with a **dark theme toggle** (sun/moon) in the navigation bar. Your choice is remembered and falls back to your system preference on first visit. The whole palette moved to a warm, restrained **«ember»** (orange/red) accent with better contrast throughout
- **Theory answer evaluation** — load `expected_points` rubric bullets from question banks, pass them through evaluation prompts with explicit candidate-only scoring rules, and use temperature 0 for structured LLM evaluation
### Fixed
+- **Coding timer** — when a coding round timer expires, the round now submits automatically and the session advances even if you refresh the page
+- **Whisper transcription** — more robust audio transcription (voice-activity detection disabled) with clearer audio-answer logging
+
### Removed
## 2026.7.14
diff --git a/README.md b/README.md
index 59c6000..28ba679 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](https://www.python.org/downloads/)
[](https://opensource.org/licenses/Apache-2.0)
-[](CHANGELOG.md)
+[](CHANGELOG.md)
Open-source AI technical interview trainer. Practice **theory Q&A**, **live coding**, or **both in one session** from curated YAML banks — with structured scoring, follow-ups, optional voice, and a local results history. Bring your own LLM (cloud or local).
@@ -34,30 +34,6 @@ A general chat assistant is flexible, but it does not run an **interview** for y
https://github.com/user-attachments/assets/25655f1e-89d3-472f-8c1f-3f154df622b2
-**Dashboard** — recent sessions and quick start
-
-
-
-
-
-**Interview setup** — question-bank tracks, levels, topics, and session options
-
-
-
-
-
-**Coding section** — Monaco editor, Run on public tests, Submit for AI evaluation
-
-
-
-
-
-**Theory section** — real-time Q&A with AI scoring and final evaluation
-
-
-
-
-
## Features
### Session modes
@@ -85,6 +61,7 @@ Coding modes need a running [Judge0](https://github.com/judge0/judge0) instance
- **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
+- **Theme** — light theme by default with a **dark mode toggle** (sun/moon) in the navbar; your choice is remembered and follows your system preference on first visit
- **Deployment** — Docker Compose on port 8000 with `./data` volume for config, DB, and models
## Quick start
diff --git a/app/ai/__init__.py b/app/ai/__init__.py
index b9608b0..fbdb2cf 100644
--- a/app/ai/__init__.py
+++ b/app/ai/__init__.py
@@ -6,14 +6,22 @@
including base classes, factory methods, and concrete implementations.
"""
-from app.ai.base import AIProvider, GenerationResult, Message
+from app.ai.base import (
+ AIProvider,
+ AudioCapableProvider,
+ GenerationResult,
+ Message,
+ StreamingProvider,
+)
from app.ai.factory import ProviderFactory
from app.ai.openai_compatible import OpenAICompatibleProvider
__all__ = [
"AIProvider",
+ "AudioCapableProvider",
"GenerationResult",
"Message",
"OpenAICompatibleProvider",
"ProviderFactory",
+ "StreamingProvider",
]
diff --git a/app/ai/audio_probe.py b/app/ai/audio_probe.py
index 81ef1ee..dd3b981 100644
--- a/app/ai/audio_probe.py
+++ b/app/ai/audio_probe.py
@@ -3,6 +3,8 @@
"""Minimal WAV payloads for audio capability probes."""
import io
+import math
+import struct
import wave
from app.shared.infrastructure.audio_wav import CANONICAL_AUDIO_SAMPLE_RATE_HZ
@@ -13,23 +15,32 @@
def minimal_wav_bytes(
*,
sample_rate: int = CANONICAL_AUDIO_SAMPLE_RATE_HZ,
- duration_sec: float = 0.1,
+ duration_sec: float = 0.5,
+ tone_freq_hz: float = 440.0,
) -> bytes:
- """Build a short silent mono PCM WAV for connection testing.
+ """Build a short mono PCM WAV beep for connection testing.
+
+ A pure-silence clip is rejected by most multimodal audio models (they
+ require audible speech), so the probe sends a short audible tone instead.
Args:
sample_rate: Sample rate in Hz.
- duration_sec: Duration of silence in seconds.
+ duration_sec: Duration of the tone in seconds.
+ tone_freq_hz: Frequency of the probe tone in Hz.
Returns:
WAV file bytes suitable for provider audio probes.
"""
frame_count = max(1, int(sample_rate * duration_sec))
- pcm = b"\x00\x00" * frame_count
+ amplitude = 0.2 # moderate volume, well below clipping
+ pcm = bytearray()
+ for i in range(frame_count):
+ sample = amplitude * math.sin(2 * math.pi * tone_freq_hz * i / sample_rate)
+ pcm += struct.pack(" None:
@@ -64,29 +61,10 @@ def __init__(self, model: str, **kwargs: object) -> None:
@abstractmethod
def name(self) -> str:
"""Provider display name."""
- pass
-
- @abstractmethod
- def supports_streaming(self) -> bool:
- """Check if provider supports streaming."""
- pass
@abstractmethod
async def validate(self) -> bool:
"""Validate API key and connection."""
- pass
-
- @abstractmethod
- async def probe_audio_input(self, audio_wav: bytes) -> bool:
- """Probe whether the endpoint accepts multimodal audio input.
-
- Args:
- audio_wav: Canonical WAV bytes (mono PCM).
-
- Returns:
- True when the provider accepts the audio probe request.
- """
- pass
@abstractmethod
async def generate(
@@ -95,71 +73,56 @@ async def generate(
temperature: float = 0.7,
max_tokens: int = 2000,
) -> GenerationResult:
- """Generate a single response.
+ """Generate a single response."""
- Args:
- messages: List of conversation messages.
- temperature: Sampling temperature (0.0 to 2.0).
- max_tokens: Maximum tokens to generate.
+ @abstractmethod
+ async def close(self) -> None:
+ """Close the provider and release resources."""
- Returns:
- The generation result with content and metadata.
- Raises:
- ValueError: If the request fails or parameters are invalid.
- """
- pass
+class StreamingProvider(ABC):
+ """Capability protocol for providers that support token streaming."""
@abstractmethod
- async def generate_with_audio(
+ def supports_streaming(self) -> bool:
+ """Check if provider supports streaming."""
+
+ @abstractmethod
+ def generate_stream(
self,
messages: list[Message],
- audio_wav: bytes,
- *,
- user_text: str,
temperature: float = 0.7,
max_tokens: int = 2000,
- ) -> GenerationResult:
- """Generate a response from system messages, user text, and audio.
-
- Args:
- messages: System (and optional assistant) messages without user audio.
- audio_wav: Canonical WAV bytes representing the user's spoken answer.
- user_text: Text context for the user turn (question prompt, no answer text).
- temperature: Sampling temperature (0.0 to 2.0).
- max_tokens: Maximum tokens to generate.
-
- Returns:
- The generation result with content and metadata.
+ ) -> AsyncIterator[str]:
+ """Stream response tokens.
- Raises:
- ValueError: If the request fails or parameters are invalid.
+ Yields:
+ Chunks of generated text as they become available.
"""
- pass
- @abstractmethod
- async def close(self) -> None:
- """Close the provider and release resources."""
- pass
+
+class AudioCapableProvider(ABC):
+ """Capability protocol for providers that accept multimodal audio input."""
@abstractmethod
- def generate_stream(
+ async def generate_with_audio(
self,
messages: list[Message],
+ audio_wav: bytes,
+ *,
+ user_text: str,
temperature: float = 0.7,
max_tokens: int = 2000,
- ) -> AsyncIterator[str]:
- """Stream response tokens.
+ ) -> GenerationResult:
+ """Generate a response from system messages, user text, and audio."""
- Args:
- messages: List of conversation messages.
- temperature: Sampling temperature (0.0 to 2.0).
- max_tokens: Maximum tokens to generate.
+ @abstractmethod
+ async def probe_audio_input(self, audio_wav: bytes) -> bool:
+ """Probe whether the endpoint accepts multimodal audio input.
- Yields:
- Chunks of generated text as they become available.
+ Args:
+ audio_wav: Canonical WAV bytes (mono PCM).
- Raises:
- ValueError: If the request fails or parameters are invalid.
+ Returns:
+ True when the provider accepts the audio probe request.
"""
- pass
diff --git a/app/ai/faster_whisper_transcriber.py b/app/ai/faster_whisper_transcriber.py
index bfcf377..c95b526 100644
--- a/app/ai/faster_whisper_transcriber.py
+++ b/app/ai/faster_whisper_transcriber.py
@@ -52,7 +52,7 @@ def _transcribe() -> str:
result = "".join((segment.text or "") for segment in segment_list).strip()
logger.info(
"Whisper transcript: language=%s segments=%d result=%r",
- info.language,
+ getattr(info, "language", language) if info is not None else language,
len(segment_list),
result,
)
diff --git a/app/ai/openai_compatible.py b/app/ai/openai_compatible.py
index 73b3584..d4463e6 100644
--- a/app/ai/openai_compatible.py
+++ b/app/ai/openai_compatible.py
@@ -7,15 +7,21 @@
"""
import base64
-from collections.abc import AsyncIterator
+from collections.abc import AsyncIterator, Callable, Coroutine
from openai import AsyncOpenAI, AuthenticationError, OpenAIError, RateLimitError
from openai.types.chat import ChatCompletionMessageParam
-from app.ai.base import AIProvider, GenerationResult, Message
+from app.ai.base import (
+ AIProvider,
+ AudioCapableProvider,
+ GenerationResult,
+ Message,
+ StreamingProvider,
+)
-class OpenAICompatibleProvider(AIProvider):
+class OpenAICompatibleProvider(AIProvider, StreamingProvider, AudioCapableProvider):
"""Provider for any OpenAI-compatible API.
Covers: OpenAI, Grok, Ollama, vLLM, and other OpenAI-compatible endpoints.
@@ -59,6 +65,30 @@ def supports_streaming(self) -> bool:
"""Check if provider supports streaming."""
return True
+ async def _call_api[T](
+ self,
+ operation: Callable[[], Coroutine[None, None, T]],
+ ) -> T:
+ """Execute an OpenAI call and map SDK errors to ``ValueError``.
+
+ Args:
+ operation: Coroutine factory performing the SDK call.
+
+ Returns:
+ The SDK call result.
+
+ Raises:
+ ValueError: On auth failure, rate limit, or other API errors.
+ """
+ try:
+ return await operation()
+ except AuthenticationError as e:
+ raise ValueError("Invalid API key") from e
+ except RateLimitError as e:
+ raise ValueError("Rate limit exceeded") from e
+ except OpenAIError as e:
+ raise ValueError(f"API error: {e}") from e
+
def _format_messages(
self, messages: list[Message]
) -> list[ChatCompletionMessageParam]:
@@ -91,20 +121,15 @@ async def generate(
Raises:
ValueError: If authentication fails, rate limit exceeded, or API error occurs.
"""
- try:
- response = await self.client.chat.completions.create(
+ response = await self._call_api(
+ lambda: self.client.chat.completions.create(
model=self.model,
messages=self._format_messages(messages),
temperature=temperature,
max_tokens=max_tokens,
stream=False,
)
- except AuthenticationError as e:
- raise ValueError("Invalid API key") from e
- except RateLimitError as e:
- raise ValueError("Rate limit exceeded") from e
- except OpenAIError as e:
- raise ValueError(f"API error: {e}") from e
+ )
choice = response.choices[0]
content = choice.message.content or ""
@@ -158,20 +183,16 @@ async def generate_with_audio(
],
}
)
- try:
- response = await self.client.chat.completions.create(
+
+ response = await self._call_api(
+ lambda: self.client.chat.completions.create(
model=self.model,
messages=api_messages,
temperature=temperature,
max_tokens=max_tokens,
stream=False,
)
- except AuthenticationError as e:
- raise ValueError("Invalid API key") from e
- except RateLimitError as e:
- raise ValueError("Rate limit exceeded") from e
- except OpenAIError as e:
- raise ValueError(f"API error: {e}") from e
+ )
choice = response.choices[0]
content = choice.message.content or ""
@@ -203,20 +224,15 @@ async def generate_stream(
Raises:
ValueError: If authentication fails, rate limit exceeded, or API error occurs.
"""
- try:
- stream = await self.client.chat.completions.create(
+ stream = await self._call_api(
+ lambda: self.client.chat.completions.create(
model=self.model,
messages=self._format_messages(messages),
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
- except AuthenticationError as e:
- raise ValueError("Invalid API key") from e
- except RateLimitError as e:
- raise ValueError("Rate limit exceeded") from e
- except OpenAIError as e:
- raise ValueError(f"API error: {e}") from e
+ )
async for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
diff --git a/app/coding/api/routes.py b/app/coding/api/routes.py
index cc8bb12..cc30ca2 100644
--- a/app/coding/api/routes.py
+++ b/app/coding/api/routes.py
@@ -3,7 +3,6 @@
"""Coding section HTTP and WebSocket transport."""
import logging
-from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
@@ -11,6 +10,7 @@
from app.coding.api.errors import http_exception_from_coding_error
from app.coding.api.ws_session import CodingWebSocketService
from app.coding.domain.exceptions import CodingDomainError
+from app.coding.domain.run_result import RunResultBuilder as CodingRunExecutionService
from app.coding.schemas.coding import (
CodingRunRequest,
CodingRunResponse,
@@ -18,36 +18,19 @@
domain_run_attempt_to_read,
run_attempt_to_response,
)
-from app.coding.services.run_execution import CodingRunExecutionService
from app.interview.api.deps import (
AIProviderDep,
CodingStateServiceDep,
CodingSubmissionServiceDep,
)
from app.interview.domain.exceptions import InterviewDomainError
+from app.shared.api.negotiated_response import safe_send_json
router = APIRouter(prefix="/interview", tags=["coding"])
logger = logging.getLogger(__name__)
-async def _safe_send_json(websocket: WebSocket, message: dict[str, Any]) -> bool:
- """Send a JSON message, returning False if the client already disconnected.
-
- Args:
- websocket: Active coding WebSocket.
- message: Payload to send.
-
- Returns:
- True if the message was sent, False if the socket is closed.
- """
- try:
- await websocket.send_json(message)
- return True
- except (WebSocketDisconnect, RuntimeError):
- return False
-
-
@router.post("/{interview_id}/coding/run", response_model=CodingRunResponse)
async def coding_run(
interview_id: str,
@@ -127,7 +110,7 @@ async def coding_ws(
provider=provider,
submission_service=submission_service,
):
- if not await _safe_send_json(websocket, message):
+ if not await safe_send_json(websocket, message):
break
except WebSocketDisconnect:
logger.debug("Coding WebSocket disconnected for session %s", interview_id)
diff --git a/app/coding/api/ws_protocol.py b/app/coding/api/ws_protocol.py
index f921791..d48a21c 100644
--- a/app/coding/api/ws_protocol.py
+++ b/app/coding/api/ws_protocol.py
@@ -10,8 +10,8 @@
EvaluatingMessage,
coding_server_message_to_dict,
)
-from app.coding.services.events import CodingFeedbackEvent
-from app.interview.services.events import (
+from app.coding.support.events import CodingFeedbackEvent
+from app.interview.domain.events import (
AnswerSavedEvent,
EvaluatingEvent,
InterviewEvent,
diff --git a/app/coding/api/ws_session.py b/app/coding/api/ws_session.py
index f884389..051cf26 100644
--- a/app/coding/api/ws_session.py
+++ b/app/coding/api/ws_session.py
@@ -10,9 +10,11 @@
from app.coding.api.errors import coding_ws_error_payload
from app.coding.api.ws_protocol import coding_event_to_message
from app.coding.domain.exceptions import CodingDomainError
-from app.coding.services.submission import CodingSubmissionService
+from app.coding.use_cases.submit_solution import (
+ SubmitCodingSolution as CodingSubmissionService,
+)
from app.interview.domain.exceptions import InterviewDomainError
-from app.interview.services.ai_errors import ai_error_message_for_client
+from app.interview.support.ai_errors import ai_error_message_for_client
logger = logging.getLogger(__name__)
diff --git a/app/coding/domain/entities.py b/app/coding/domain/entities.py
index 16a334a..aca75d2 100644
--- a/app/coding/domain/entities.py
+++ b/app/coding/domain/entities.py
@@ -15,24 +15,14 @@
)
from app.coding.domain.value_objects import PlannedCodingTask, RunOutcomeStatus
from app.interview.domain.value_objects import InterviewSelection
-from app.shared.task_timer import (
- DEFAULT_TIMEOUT_GRACE_SECONDS,
-)
-from app.shared.task_timer import (
- is_timer_expired as shared_is_timer_expired,
-)
-from app.shared.task_timer import (
- remaining_seconds as shared_remaining_seconds,
-)
-from app.shared.task_timer import (
- timer_deadline as shared_timer_deadline,
-)
+from app.shared.section import Section
+from app.shared.timed_task import TimedTask
CodingSectionStatus = Literal["pending", "active", "completed", "skipped"]
@dataclass(frozen=True, slots=True)
-class CodingTask:
+class CodingTask(TimedTask):
"""One coding task round within a coding section.
Attributes:
@@ -53,7 +43,6 @@ class CodingTask:
"""
TIME_EXPIRED_SOURCE_CODE = "[Time expired]"
- TIMEOUT_GRACE_SECONDS = DEFAULT_TIMEOUT_GRACE_SECONDS
NEW_ID = 0
id: int
@@ -71,82 +60,9 @@ class CodingTask:
started_at: datetime | None
created_at: datetime
- def timer_deadline(self, limit_seconds: int) -> datetime:
- """Compute the absolute deadline for this timed task round.
-
- Args:
- limit_seconds: Allowed duration in seconds.
-
- Returns:
- Timezone-aware deadline timestamp.
-
- Raises:
- ValueError: If the round has no ``started_at`` timestamp.
- """
- if self.started_at is None:
- raise ValueError("Coding task round has no started_at")
- return shared_timer_deadline(
- self.started_at,
- limit_seconds,
- label="Coding task",
- )
-
- def is_timer_expired(
- self,
- limit_seconds: int | None,
- now: datetime | None = None,
- *,
- grace_seconds: int = TIMEOUT_GRACE_SECONDS,
- ) -> bool:
- """Return whether the per-task timer has elapsed.
-
- Args:
- limit_seconds: Configured limit for the section (None disables timer).
- now: Current time (defaults to UTC now).
- grace_seconds: Extra seconds allowed for network delay on timeout submit.
-
- Returns:
- True if the timer is enabled and the deadline plus grace has passed.
- """
- return shared_is_timer_expired(
- self.started_at,
- limit_seconds,
- now,
- grace_seconds=grace_seconds,
- )
-
- def remaining_seconds(
- self,
- limit_seconds: int | None,
- now: datetime | None = None,
- ) -> int | None:
- """Return whole seconds left on the timer, or None if disabled.
-
- Args:
- limit_seconds: Configured limit for the section.
- now: Current time (defaults to UTC now).
-
- Returns:
- Non-negative seconds remaining, or None when the timer is off.
- """
- return shared_remaining_seconds(self.started_at, limit_seconds, now)
-
- 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:
+class CodingSection(Section[CodingTask]):
"""Coding section aggregate root.
Attributes:
@@ -163,7 +79,7 @@ class CodingSection:
tasks: Coding tasks in display order (order, then round).
"""
- MAX_SCORE_PER_ROUND = 5
+ MAX_SCORE_PER_ROUND = 5 # pyright: ignore
NEW_ID = 0
id: int
@@ -178,6 +94,62 @@ class CodingSection:
section_feedback: dict[str, object] | None
tasks: tuple[CodingTask, ...]
+ # ------------------------------------------------------------------
+ # Section abstract hooks
+ # ------------------------------------------------------------------
+ @property
+ def _completion_field_name(self) -> str:
+ return "submitted_code"
+
+ @property
+ def _task_id_field(self) -> str:
+ return "task_id"
+
+ def _task_not_found_error(self, task_id: str, round_num: int) -> Exception:
+ return CodingTaskNotFoundError(self.interview_id, task_id, round_num)
+
+ def _timeout_replacement_fields(
+ self, task: CodingTask, feedback: str
+ ) -> dict[str, Any]:
+ return {
+ "submitted_code": CodingTask.TIME_EXPIRED_SOURCE_CODE,
+ "submit_test_summary": {"status": "timeout"},
+ "score": 0,
+ "feedback": feedback,
+ }
+
+ def _create_follow_up(
+ self,
+ base: CodingTask,
+ next_round: int,
+ prompt_text: str,
+ *,
+ starter_code: str | None = None,
+ **kwargs: Any,
+ ) -> CodingTask:
+ follow_up_spec = dict(base.task_spec)
+ if starter_code is not None:
+ follow_up_spec["starter_code"] = starter_code
+ return CodingTask(
+ id=CodingTask.NEW_ID,
+ coding_section_id=self.id,
+ interview_id=self.interview_id,
+ task_id=base.task_id,
+ order=base.order,
+ round=next_round,
+ prompt_text=prompt_text,
+ task_spec=follow_up_spec,
+ submitted_code=None,
+ submit_test_summary=None,
+ score=None,
+ feedback=None,
+ started_at=None,
+ created_at=datetime.now(UTC),
+ )
+
+ # ------------------------------------------------------------------
+ # Factory
+ # ------------------------------------------------------------------
@classmethod
def start(
cls,
@@ -249,16 +221,9 @@ def start(
tasks=tuple(tasks),
)
- def with_activated(self) -> CodingSection:
- """Return aggregate with ``pending`` status promoted to ``active``.
-
- Returns:
- Updated aggregate when status was ``pending``, otherwise ``self``.
- """
- if self.status != "pending":
- return self
- return replace(self, status="active")
-
+ # ------------------------------------------------------------------
+ # Domain-specific behaviour
+ # ------------------------------------------------------------------
def ensure_active(self) -> None:
"""Ensure this coding section accepts submissions.
@@ -274,85 +239,7 @@ def find_first_unsubmitted(self) -> CodingTask | None:
Returns:
The first task with ``submitted_code`` unset, or None when all are done.
"""
- for task in self.tasks:
- if task.submitted_code is None:
- return task
- return None
-
- def is_complete(self) -> bool:
- """Return whether every task in this section has been submitted.
-
- Returns:
- True when there is at least one task and none remain unsubmitted.
- """
- return bool(self.tasks) and self.find_first_unsubmitted() is None
-
- def total_score(self) -> int:
- """Sum scores from all submitted task rounds in this section.
-
- Returns:
- Total earned points across submitted rounds.
- """
- return sum(
- (task.score or 0) for task in self.tasks if task.submitted_code is not None
- )
-
- def max_score(self) -> int:
- """Compute maximum achievable score for submitted rounds.
-
- Returns:
- Maximum possible points for rounds with submissions.
- """
- submitted_rounds = sum(
- 1 for task in self.tasks if task.submitted_code is not None
- )
- return self.MAX_SCORE_PER_ROUND * submitted_rounds
-
- def with_cached_section_feedback(
- self,
- feedback: dict[str, object],
- *,
- section_score: int,
- ) -> CodingSection:
- """Return aggregate with prefetched section feedback when not cached.
-
- Args:
- feedback: Parsed section evaluation payload.
- section_score: Aggregated section score.
-
- Returns:
- Updated aggregate, or ``self`` when feedback is already cached.
- """
- if self.section_feedback is not None:
- return self
- return replace(
- self,
- section_feedback=feedback,
- section_score=section_score,
- )
-
- def start_timer_for_task(
- self, task_row_id: int, when: datetime | None = None
- ) -> CodingSection:
- """Start the per-task timer on a coding task when the section has a limit.
-
- Args:
- task_row_id: Primary key of the task row to activate.
- when: Timestamp to set (defaults to UTC now).
-
- Returns:
- A new aggregate with ``started_at`` set on the target task when applicable.
- """
- if self.task_time_limit_seconds is None:
- return self
- started_at = when or datetime.now(UTC)
- tasks = tuple(
- replace(task, started_at=started_at)
- if task.id == task_row_id and task.started_at is None
- else task
- for task in self.tasks
- )
- return replace(self, tasks=tasks)
+ return self.find_first_pending()
def with_submit_test_summary(
self,
@@ -383,116 +270,6 @@ 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,
- round_num: int,
- score: int,
- feedback: str,
- ) -> CodingSection:
- """Return aggregate with AI score and feedback on one task round.
-
- Args:
- task_id: YAML task ID.
- round_num: Follow-up round (0 = initial).
- score: AI score for the round.
- feedback: AI feedback text.
-
- Returns:
- A new aggregate with evaluation fields set on the target task.
- """
- target = self.find_task(task_id, round_num)
- tasks = tuple(
- replace(task, score=score, feedback=feedback)
- if task.id == target.id
- else task
- for task in self.tasks
- )
- return replace(self, tasks=tasks)
-
- def max_round_for_task(self, task_id: str) -> int:
- """Return the highest follow-up round number for a bank task ID.
-
- Args:
- task_id: YAML task ID.
-
- Returns:
- Maximum ``round`` value among rows for the task, or 0 when none exist.
- """
- rounds = [task.round for task in self.tasks if task.task_id == task_id]
- return max(rounds) if rounds else 0
-
- def with_follow_up(
- self,
- task_id: str,
- prompt_text: str,
- *,
- starter_code: str | None,
- ) -> tuple[CodingSection, CodingTask]:
- """Return aggregate with a new unsubmitted follow-up task row.
-
- Args:
- task_id: YAML task ID for the follow-up chain.
- prompt_text: Follow-up prompt shown to the candidate.
- starter_code: Monaco starter code for code-mode follow-ups.
-
- Returns:
- Tuple of updated aggregate and the pending follow-up task.
- """
- base = self.find_task(task_id, 0)
- next_round = self.max_round_for_task(task_id) + 1
- follow_up_spec = dict(base.task_spec)
- if starter_code is not None:
- follow_up_spec["starter_code"] = starter_code
- created_at = datetime.now(UTC)
- follow_up = CodingTask(
- id=CodingTask.NEW_ID,
- coding_section_id=self.id,
- interview_id=self.interview_id,
- task_id=task_id,
- order=base.order,
- round=next_round,
- prompt_text=prompt_text,
- task_spec=follow_up_spec,
- submitted_code=None,
- submit_test_summary=None,
- score=None,
- feedback=None,
- started_at=None,
- created_at=created_at,
- )
- return replace(self, tasks=self.tasks + (follow_up,)), follow_up
-
- def find_next_unsubmitted_after(self, current_index: int) -> CodingTask | None:
- """Return the next unsubmitted task after a position in the task list.
-
- Args:
- current_index: Index of the current task in ``tasks``.
-
- Returns:
- The next unsubmitted task, or None if none remain.
- """
- for task in self.tasks[current_index + 1 :]:
- if task.submitted_code is None:
- return task
- return None
-
def require_current_task(self, task_id: str) -> CodingTask:
"""Return the active unsubmitted task when it matches ``task_id``.
@@ -510,23 +287,16 @@ def require_current_task(self, task_id: str) -> CodingTask:
raise CodingTaskNotCurrentError(self.interview_id, task_id)
return current
- def find_task(self, task_id: str, round_num: int) -> CodingTask:
- """Return the task row for a bank task and follow-up round.
+ def find_next_unsubmitted_after(self, current_index: int) -> CodingTask | None:
+ """Return the next unsubmitted task after a position in the task list.
Args:
- task_id: YAML task ID.
- round_num: Follow-up round (0 = initial).
+ current_index: Index of the current task in ``tasks``.
Returns:
- The matching task row.
-
- Raises:
- CodingTaskNotFoundError: If no row matches the keys.
+ The next unsubmitted task, or None if none remain.
"""
- for task in self.tasks:
- if task.task_id == task_id and task.round == round_num:
- return task
- raise CodingTaskNotFoundError(self.interview_id, task_id, round_num)
+ return self.find_next_pending_after(current_index)
@dataclass(frozen=True, slots=True)
diff --git a/app/coding/services/evaluator/service.py b/app/coding/domain/evaluator.py
similarity index 91%
rename from app/coding/services/evaluator/service.py
rename to app/coding/domain/evaluator.py
index 4dda7a1..b4dabee 100644
--- a/app/coding/services/evaluator/service.py
+++ b/app/coding/domain/evaluator.py
@@ -9,23 +9,24 @@
from pydantic import BaseModel
from app.ai.base import AIProvider
-from app.coding.services.evaluator.models import (
+from app.coding.domain.evaluator_models import (
CodingAnswerEvaluation,
CodingFollowUpEvaluation,
)
-from app.coding.services.evaluator.prompts import (
+from app.coding.domain.evaluator_prompts import (
CODING_ANSWER_EVALUATION_INSTRUCTIONS,
CODING_FOLLOW_UP_EVALUATION_INSTRUCTIONS,
CODING_SECTION_EVALUATION_INSTRUCTIONS,
build_coding_evaluation_user_text,
)
from app.shared.evaluation_models import SectionEvaluation
+from app.shared.json_parser import build_prompt_with_schema
from app.shared.structured_evaluation import evaluate_with_schema
T = TypeVar("T", bound=BaseModel)
-class CodingEvaluatorService:
+class CodingEvaluator:
"""Evaluate coding submissions with run history and hidden test context."""
MAX_FOLLOW_UP_DEPTH = 2
@@ -44,7 +45,7 @@ async def _evaluate_with_schema(
Args:
provider: Configured AI provider instance.
- locale: Locale for AI feedback.
+ locale: Locale for AI feedback (passed for API consistency).
instructions: Evaluator instruction template constant.
response_model: Pydantic model for parsed JSON output.
user_text: User message with task and submission context.
@@ -56,10 +57,10 @@ async def _evaluate_with_schema(
Raises:
ValueError: If AI response is invalid or connection fails.
"""
+ system_prompt = build_prompt_with_schema(instructions, response_model)
return await evaluate_with_schema(
provider,
- locale=locale,
- instructions=instructions,
+ system_prompt=system_prompt,
response_model=response_model,
user_text=user_text,
max_tokens=max_tokens,
@@ -109,7 +110,7 @@ def _follow_up_decision(
follow_up_needed = (
evaluation.needs_further_follow_up
and bool(evaluation.follow_up_question)
- and answer_round < CodingEvaluatorService.MAX_FOLLOW_UP_DEPTH
+ and answer_round < CodingEvaluator.MAX_FOLLOW_UP_DEPTH
)
mode = evaluation.follow_up_mode if follow_up_needed else None
return follow_up_needed, evaluation.follow_up_question, mode
@@ -150,7 +151,7 @@ async def evaluate_submission(
Returns:
Tuple of evaluation, follow_up_needed, follow_up_text, follow_up_mode.
"""
- expected_points = CodingEvaluatorService._expected_points(task_spec)
+ expected_points = CodingEvaluator._expected_points(task_spec)
evaluation: CodingAnswerEvaluation | CodingFollowUpEvaluation
if answer_round == 0:
user_text = build_coding_evaluation_user_text(
@@ -160,7 +161,7 @@ async def evaluate_submission(
run_attempts=run_attempts,
submit_test_summary=submit_test_summary,
)
- evaluation = await CodingEvaluatorService._evaluate_with_schema(
+ evaluation = await CodingEvaluator._evaluate_with_schema(
provider,
locale=locale,
instructions=CODING_ANSWER_EVALUATION_INSTRUCTIONS,
@@ -180,7 +181,7 @@ async def evaluate_submission(
)
evaluation = cast(
CodingFollowUpEvaluation,
- await CodingEvaluatorService._evaluate_with_schema(
+ await CodingEvaluator._evaluate_with_schema(
provider,
locale=locale,
instructions=CODING_FOLLOW_UP_EVALUATION_INSTRUCTIONS,
@@ -190,7 +191,7 @@ async def evaluate_submission(
)
follow_up_needed, follow_up_text, follow_up_mode = (
- CodingEvaluatorService._follow_up_decision(evaluation, answer_round)
+ CodingEvaluator._follow_up_decision(evaluation, answer_round)
)
return evaluation, follow_up_needed, follow_up_text, follow_up_mode
@@ -234,7 +235,7 @@ async def evaluate_section(
f"Sources:\n{sources_text}\n\n"
f"Section Coding Tasks and Submissions:\n{summary_text}"
)
- return await CodingEvaluatorService._evaluate_with_schema(
+ return await CodingEvaluator._evaluate_with_schema(
provider,
locale=locale,
instructions=CODING_SECTION_EVALUATION_INSTRUCTIONS,
diff --git a/app/coding/services/evaluator/models.py b/app/coding/domain/evaluator_models.py
similarity index 100%
rename from app/coding/services/evaluator/models.py
rename to app/coding/domain/evaluator_models.py
diff --git a/app/coding/services/evaluator/prompts.py b/app/coding/domain/evaluator_prompts.py
similarity index 100%
rename from app/coding/services/evaluator/prompts.py
rename to app/coding/domain/evaluator_prompts.py
diff --git a/app/coding/domain/exceptions.py b/app/coding/domain/exceptions.py
index 423f3c8..ddfcc64 100644
--- a/app/coding/domain/exceptions.py
+++ b/app/coding/domain/exceptions.py
@@ -2,8 +2,10 @@
# SPDX-License-Identifier: Apache-2.0
"""Coding domain exceptions."""
+from app.interview.domain.exceptions import InterviewDomainError
-class CodingDomainError(Exception):
+
+class CodingDomainError(InterviewDomainError):
"""Base class for coding-related domain errors."""
diff --git a/tests/coding/services/__init__.py b/app/coding/domain/rules/__init__.py
similarity index 100%
rename from tests/coding/services/__init__.py
rename to app/coding/domain/rules/__init__.py
diff --git a/app/coding/services/run_execution.py b/app/coding/domain/run_result.py
similarity index 57%
rename from app/coding/services/run_execution.py
rename to app/coding/domain/run_result.py
index 5db035a..3406e89 100644
--- a/app/coding/services/run_execution.py
+++ b/app/coding/domain/run_result.py
@@ -14,7 +14,7 @@
CodingSectionNotFoundError,
)
from app.coding.domain.value_objects import CaseRunResult, CodingRunResult
-from app.coding.services.runner import CodingRunnerService
+from app.coding.use_cases.run_tests import CodingRunnerService
from app.interview.domain.exceptions import InterviewNotFoundError
from app.interview.repositories.uow import InterviewUnitOfWork
@@ -33,104 +33,45 @@ def _max_runs_per_task() -> int:
return max(1, value)
-def _ensure_interview_active(interview_id: str) -> None:
- """Ensure the parent interview session accepts coding actions.
-
- Args:
- interview_id: Parent interview UUID.
-
- Raises:
- InterviewNotFoundError: If the interview does not exist.
- InterviewNotActiveError: If the interview is completed.
- """
- with InterviewUnitOfWork() as uow:
- aggregate = uow.interviews.get_aggregate(interview_id)
- if aggregate is None:
- raise InterviewNotFoundError(interview_id)
- aggregate.ensure_active()
-
-
-def _serialize_test_result(result: CaseRunResult) -> dict[str, Any]:
- """Convert a domain test result into an API/persistence payload.
-
- Args:
- result: One public test execution result.
-
- Returns:
- JSON-serializable dict for clients and persistence.
- """
- payload: dict[str, Any] = {
- "name": result.name,
- "passed": result.passed,
- "expected_stdout": result.expected_stdout,
- "actual_stdout": result.actual_stdout,
- }
- if result.stderr:
- payload["stderr"] = result.stderr
- if result.compile_output:
- payload["compile_output"] = result.compile_output
- if result.judge0_status_description:
- payload["status"] = result.judge0_status_description
- return payload
-
-
-def coding_run_result_to_summary(result: CodingRunResult) -> dict[str, Any]:
- """Serialize a Judge0 run result for submit_test_summary persistence.
-
- Args:
- result: Aggregated run outcome from Judge0.
-
- Returns:
- JSON-serializable hidden test summary payload.
- """
- return {
- "status": result.status,
- "stdout": result.stdout,
- "stderr": result.stderr,
- "compile_output": result.compile_output,
- "tests_passed": result.tests_passed,
- "tests_total": result.tests_total,
- "test_results": [
- _serialize_test_result(test_result) for test_result in result.test_results
- ],
- "duration_ms": result.duration_ms,
- }
-
-
-class CodingRunExecutionService:
+class RunResultBuilder:
"""Validate, execute, and persist coding Run attempts."""
@staticmethod
def _load_run_context(
+ uow: InterviewUnitOfWork,
interview_id: str,
task_id: str,
- ) -> dict[str, Any]:
- """Validate the active task and return execution context.
+ ) -> tuple[Any, int, int, dict[str, Any]]:
+ """Validate the active interview and task, returning execution context.
Args:
+ uow: Active unit of work bound to a session.
interview_id: Parent interview UUID.
task_id: YAML task ID for the active coding round.
Returns:
- Task spec used for public test execution.
+ Tuple of ``(current_task, attempt_count, limit, task_spec)`` used
+ for public test execution and attempt persistence.
Raises:
+ InterviewNotFoundError: If the interview does not exist.
+ InterviewNotActiveError: If the interview is completed.
CodingSectionNotFoundError: If no coding section exists.
CodingSectionNotActiveError: If the coding section is not active.
CodingTaskNotCurrentError: If ``task_id`` is not the active task.
- CodingRunLimitExceededError: If the per-task Run limit is reached.
"""
- with InterviewUnitOfWork() as uow:
- section = uow.coding_sections.get_aggregate(interview_id)
- if section is None:
- raise CodingSectionNotFoundError(interview_id)
- section.ensure_active()
- current_task = section.require_current_task(task_id)
- limit = _max_runs_per_task()
- attempt_count = uow.code_run_attempts.count_for_task(current_task.id)
- if attempt_count >= limit:
- raise CodingRunLimitExceededError(task_id, limit)
- return dict(current_task.task_spec)
+ 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)
+ return current_task, attempt_count, limit, dict(current_task.task_spec)
@staticmethod
async def run_and_persist(
@@ -141,6 +82,11 @@ async def run_and_persist(
) -> CodeRunAttempt:
"""Execute public tests and persist an immutable Run attempt.
+ Validates the session and task, executes public tests via Judge0, then
+ re-validates and persists the attempt. The session is not held across
+ the external Judge0 call to avoid pinning a SQLite write lock on a
+ long network request; the limit is therefore re-checked at persist time.
+
Args:
interview_id: Parent interview UUID.
task_id: YAML task ID for the active coding round.
@@ -157,27 +103,25 @@ async def run_and_persist(
CodingTaskNotCurrentError: If ``task_id`` is not the active task.
CodingRunLimitExceededError: If the per-task Run limit is reached.
"""
- _ensure_interview_active(interview_id)
- task_spec = CodingRunExecutionService._load_run_context(interview_id, task_id)
+ with InterviewUnitOfWork() as uow:
+ current_task, attempt_count, limit, task_spec = (
+ RunResultBuilder._load_run_context(uow, interview_id, task_id)
+ )
+ if attempt_count >= limit:
+ raise CodingRunLimitExceededError(task_id, limit)
+
run_result = await CodingRunnerService.run_public_tests(
source_code=source_code,
task_spec=task_spec,
)
+
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)
+ current_task, attempt_count, limit, _ = RunResultBuilder._load_run_context(
+ uow, interview_id, task_id
+ )
if attempt_count >= limit:
raise CodingRunLimitExceededError(task_id, limit)
- attempt = CodingRunExecutionService._build_attempt(
+ attempt = RunResultBuilder._build_attempt(
coding_task_id=current_task.id,
attempt_no=attempt_count + 1,
source_code=source_code,
@@ -208,7 +152,8 @@ def _build_attempt(
Unpersisted domain attempt.
"""
test_results = tuple(
- _serialize_test_result(result) for result in run_result.test_results
+ RunResultBuilder._serialize_test_result(result)
+ for result in run_result.test_results
)
return CodeRunAttempt(
id=CodeRunAttempt.NEW_ID,
@@ -226,3 +171,27 @@ def _build_attempt(
duration_ms=run_result.duration_ms,
created_at=datetime.now(UTC),
)
+
+ @staticmethod
+ def _serialize_test_result(result: CaseRunResult) -> dict[str, Any]:
+ """Convert a domain test result into an API/persistence payload.
+
+ Args:
+ result: One public test execution result.
+
+ Returns:
+ JSON-serializable dict for clients and persistence.
+ """
+ payload: dict[str, Any] = {
+ "name": result.name,
+ "passed": result.passed,
+ "expected_stdout": result.expected_stdout,
+ "actual_stdout": result.actual_stdout,
+ }
+ if result.stderr:
+ payload["stderr"] = result.stderr
+ if result.compile_output:
+ payload["compile_output"] = result.compile_output
+ if result.judge0_status_description:
+ payload["status"] = result.judge0_status_description
+ return payload
diff --git a/app/coding/services/planning.py b/app/coding/domain/task_planner.py
similarity index 97%
rename from app/coding/services/planning.py
rename to app/coding/domain/task_planner.py
index ffb6a6b..9a5e57a 100644
--- a/app/coding/services/planning.py
+++ b/app/coding/domain/task_planner.py
@@ -4,17 +4,17 @@
from app.coding.domain.task_spec import task_spec_from_bank_task
from app.coding.domain.value_objects import PlannedCodingTask
-from app.interview.domain.value_objects import (
- InterviewSelection,
- PlannedQuestion,
- TrackQuestionPools,
-)
-from app.interview.services.rules.bank_selection import (
+from app.interview.domain.rules.bank_selection import (
BankCatalog,
BankSelectionMessages,
track_label,
validate_bank_selection,
)
+from app.interview.domain.value_objects import (
+ InterviewSelection,
+ PlannedQuestion,
+ TrackQuestionPools,
+)
from app.shared.coding import (
CodingTask,
list_categories,
@@ -181,7 +181,7 @@ def build_coding_task_plan(
validate_selection(selection)
validate_task_count(selection, task_count)
track_pools = load_track_pools(selection, locale)
- from app.interview.services.rules.selection import plan_questions
+ from app.interview.domain.rules.selection import plan_questions
planned = plan_questions(
selection,
diff --git a/app/coding/services/harness.py b/app/coding/domain/test_harness.py
similarity index 100%
rename from app/coding/services/harness.py
rename to app/coding/domain/test_harness.py
diff --git a/tests/interview/services/__init__.py b/app/coding/queries/__init__.py
similarity index 100%
rename from tests/interview/services/__init__.py
rename to app/coding/queries/__init__.py
diff --git a/app/coding/services/state.py b/app/coding/queries/loader.py
similarity index 99%
rename from app/coding/services/state.py
rename to app/coding/queries/loader.py
index ab5e331..672c204 100644
--- a/app/coding/services/state.py
+++ b/app/coding/queries/loader.py
@@ -62,7 +62,7 @@ def _attempt_state_from_domain(attempt: CodeRunAttempt) -> CodeRunAttemptRead:
)
-class CodingStateService:
+class CodingTaskLoader:
"""Read-only builder for ``GET /coding/state`` responses."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
diff --git a/app/coding/services/review.py b/app/coding/queries/review_page.py
similarity index 96%
rename from app/coding/services/review.py
rename to app/coding/queries/review_page.py
index 52307a1..7123b40 100644
--- a/app/coding/services/review.py
+++ b/app/coding/queries/review_page.py
@@ -5,23 +5,23 @@
from __future__ import annotations
from app.coding.domain.entities import CodingSection
+from app.coding.queries.section_summary import CodingSectionSummary
from app.coding.schemas.review import (
CodingReviewContext,
CodingTaskReviewRead,
CodingTaskRoundRead,
)
-from app.coding.services.query import CodingQueryService
-from app.interview.repositories.uow import InterviewUnitOfWork
-from app.interview.services.section_review_support import (
+from app.interview.queries.review_context import (
CompletedInterviewSnapshot,
load_completed_interview,
resolved_section_feedback,
review_score_fields,
shared_review_fields,
)
+from app.interview.repositories.uow import InterviewUnitOfWork
-class CodingReviewService:
+class CodingReviewPage:
"""Build read-only coding review context for completed sessions."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
@@ -31,7 +31,7 @@ def __init__(self, uow: InterviewUnitOfWork) -> None:
uow: Shared application unit of work for this review scope.
"""
self._uow = uow
- self._query = CodingQueryService(uow)
+ self._query = CodingSectionSummary(uow)
@staticmethod
def _group_tasks(section: CodingSection) -> list[CodingTaskReviewRead]:
diff --git a/app/coding/services/section.py b/app/coding/queries/section_state.py
similarity index 92%
rename from app/coding/services/section.py
rename to app/coding/queries/section_state.py
index b80c997..a92a01e 100644
--- a/app/coding/services/section.py
+++ b/app/coding/queries/section_state.py
@@ -6,15 +6,15 @@
from typing import ClassVar, Literal
-from app.coding.services.evaluator.service import CodingEvaluatorService
-from app.coding.services.query import CodingQueryService
-from app.interview.repositories.uow import InterviewUnitOfWork
-from app.interview.services.section_service_support import SectionFeedbackPrefetch
-from app.interview.services.sections import (
+from app.coding.domain.evaluator import CodingEvaluator
+from app.coding.queries.section_summary import CodingSectionSummary
+from app.interview.domain.session_phases import (
SectionEvaluationSummary,
SectionPageContext,
prior_sections_complete_for,
)
+from app.interview.repositories.uow import InterviewUnitOfWork
+from app.interview.support.feedback_prefetch import SectionFeedbackPrefetch
async def _evaluate_coding_section_feedback(
@@ -34,7 +34,7 @@ async def _evaluate_coding_section_feedback(
Returns:
Feedback payload and section score.
"""
- section_eval = await CodingEvaluatorService.evaluate_section(
+ section_eval = await CodingEvaluator.evaluate_section(
provider=provider, # type: ignore[arg-type]
task_submissions=list(summary.items),
sources_text=sources_text,
@@ -45,7 +45,7 @@ async def _evaluate_coding_section_feedback(
def _build_coding_feedback_prefetch(
uow: InterviewUnitOfWork,
- query: CodingQueryService | None = None,
+ query: CodingSectionSummary | None = None,
) -> SectionFeedbackPrefetch:
"""Build coding section feedback prefetch helpers for a unit of work.
@@ -56,7 +56,7 @@ def _build_coding_feedback_prefetch(
Returns:
Configured prefetch helper for coding sections.
"""
- resolved_query = query or CodingQueryService(uow)
+ resolved_query = query or CodingSectionSummary(uow)
return SectionFeedbackPrefetch(
uow,
section_name="coding",
@@ -72,7 +72,7 @@ def _build_coding_feedback_prefetch(
)
-class CodingSectionService:
+class CodingSectionState:
"""Coding section lifecycle hooks and read helpers."""
section_kind: ClassVar[Literal["coding"]] = "coding"
@@ -80,7 +80,7 @@ class CodingSectionService:
def __init__(
self,
uow: InterviewUnitOfWork,
- query: CodingQueryService | None = None,
+ query: CodingSectionSummary | None = None,
) -> None:
"""Initialize with the active unit of work.
@@ -89,7 +89,7 @@ def __init__(
query: Optional coding query helper sharing the same unit of work.
"""
self._uow = uow
- self._query = query or CodingQueryService(uow)
+ self._query = query or CodingSectionSummary(uow)
self._feedback = _build_coding_feedback_prefetch(uow, self._query)
def is_complete(self, interview_id: str) -> bool:
diff --git a/app/coding/services/query.py b/app/coding/queries/section_summary.py
similarity index 91%
rename from app/coding/services/query.py
rename to app/coding/queries/section_summary.py
index c89116b..2567334 100644
--- a/app/coding/services/query.py
+++ b/app/coding/queries/section_summary.py
@@ -5,13 +5,13 @@
from typing import Any
from app.coding.domain.entities import CodingSection
+from app.interview.domain.rules.selection import selection_sources_summary
+from app.interview.domain.section_evaluation import build_section_evaluation_summary
+from app.interview.domain.session_phases import SectionEvaluationSummary
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
-class CodingQueryService:
+class CodingSectionSummary:
"""Read-only queries for coding section aggregates."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
diff --git a/app/coding/services/page.py b/app/coding/queries/task_page.py
similarity index 90%
rename from app/coding/services/page.py
rename to app/coding/queries/task_page.py
index c6137eb..ba6813d 100644
--- a/app/coding/services/page.py
+++ b/app/coding/queries/task_page.py
@@ -2,20 +2,20 @@
# SPDX-License-Identifier: Apache-2.0
"""Coding section page context builder."""
+from app.coding.queries.section_state import CodingSectionState
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.coding.use_cases.navigate_tasks import next_task_payload
from app.interview.repositories.uow import InterviewUnitOfWork
-class CodingPageService:
+class ActiveCodingTaskPage:
"""Build coding-specific page context for session rendering."""
def __init__(
self,
uow: InterviewUnitOfWork,
*,
- section: CodingSectionService | None = None,
+ section: CodingSectionState | None = None,
) -> None:
"""Initialize with the active unit of work.
@@ -24,7 +24,7 @@ def __init__(
section: Optional coding section service sharing the same unit of work.
"""
self._uow = uow
- self._section = section or CodingSectionService(uow)
+ self._section = section or CodingSectionState(uow)
def activate_timer(self, interview_id: str) -> None:
"""Start the per-task timer on the current unsubmitted coding task.
@@ -92,4 +92,4 @@ def build_context_for(interview_id: str) -> CodingPageContext | None:
Coding page context, or None when the session has no coding section.
"""
with InterviewUnitOfWork() as uow:
- return CodingPageService(uow).build_context(interview_id)
+ return ActiveCodingTaskPage(uow).build_context(interview_id)
diff --git a/app/coding/repositories/mappers.py b/app/coding/repositories/mappers.py
index 0f63b4e..bef6d12 100644
--- a/app/coding/repositories/mappers.py
+++ b/app/coding/repositories/mappers.py
@@ -264,7 +264,7 @@ def code_run_attempt_from_orm(row: OrmCodeRunAttempt) -> DomainCodeRunAttempt:
return DomainCodeRunAttempt(
id=row.id,
coding_task_id=row.coding_task_id,
- attempt_no=row.attempt_no or 0,
+ attempt_no=row.attempt_no,
source_code=row.source_code,
language=row.language,
status=status,
diff --git a/app/coding/services/__init__.py b/app/coding/services/__init__.py
deleted file mode 100644
index 78bea55..0000000
--- a/app/coding/services/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# Copyright 2026 GrillKit Contributors
-# SPDX-License-Identifier: Apache-2.0
-"""Coding orchestration services."""
diff --git a/app/coding/services/evaluator/__init__.py b/app/coding/services/evaluator/__init__.py
deleted file mode 100644
index 672171e..0000000
--- a/app/coding/services/evaluator/__init__.py
+++ /dev/null
@@ -1,15 +0,0 @@
-# Copyright 2026 GrillKit Contributors
-# SPDX-License-Identifier: Apache-2.0
-"""Coding AI evaluator package."""
-
-from app.coding.services.evaluator.models import (
- CodingAnswerEvaluation,
- CodingFollowUpEvaluation,
-)
-from app.coding.services.evaluator.service import CodingEvaluatorService
-
-__all__ = [
- "CodingAnswerEvaluation",
- "CodingEvaluatorService",
- "CodingFollowUpEvaluation",
-]
diff --git a/tests/interview/services/rules/__init__.py b/app/coding/support/__init__.py
similarity index 100%
rename from tests/interview/services/rules/__init__.py
rename to app/coding/support/__init__.py
diff --git a/app/coding/services/availability.py b/app/coding/support/coding_availability.py
similarity index 88%
rename from app/coding/services/availability.py
rename to app/coding/support/coding_availability.py
index 9b603ff..b197b05 100644
--- a/app/coding/services/availability.py
+++ b/app/coding/support/coding_availability.py
@@ -6,7 +6,10 @@
import httpx
-from app.coding.services.judge0_config import judge0_auth_token, judge0_url
+from app.shared.infrastructure.gateways.judge0_config import (
+ judge0_auth_token,
+ judge0_url,
+)
_TRUTHY = frozenset({"1", "true", "yes", "on"})
@@ -44,9 +47,9 @@ async def is_judge0_healthy_async() -> bool:
Returns:
True when ``GET /about`` responds with HTTP 200.
"""
- from app.coding.services.judge0_client import Judge0Client
+ from app.shared.infrastructure.gateways.judge0 import Judge0Gateway
- return await Judge0Client.from_env().health_check()
+ return await Judge0Gateway.from_env().health_check()
def is_coding_available() -> bool:
diff --git a/app/coding/services/evaluation_persistence.py b/app/coding/support/evaluation_commit.py
similarity index 96%
rename from app/coding/services/evaluation_persistence.py
rename to app/coding/support/evaluation_commit.py
index dad6790..122499f 100644
--- a/app/coding/services/evaluation_persistence.py
+++ b/app/coding/support/evaluation_commit.py
@@ -7,17 +7,20 @@
from dataclasses import replace
from typing import Any, Literal
-from app.coding.domain.exceptions import CodingSectionNotFoundError
-from app.coding.services.evaluator.models import (
+from app.coding.domain.evaluator_models import (
CodingAnswerEvaluation,
CodingFollowUpEvaluation,
)
-from app.coding.services.events import CodingFeedbackEvent
-from app.coding.services.navigation import CodingNavigationService, next_task_payload
+from app.coding.domain.exceptions import CodingSectionNotFoundError
+from app.coding.support.events import CodingFeedbackEvent
+from app.coding.use_cases.navigate_tasks import (
+ CodingNavigationService,
+ next_task_payload,
+)
from app.interview.repositories.uow import InterviewUnitOfWork
-class CodingEvaluationPersistenceService:
+class CommitEvaluationResult:
"""Save coding evaluation scores and advance timed task rounds."""
def __init__(
diff --git a/app/coding/services/events.py b/app/coding/support/events.py
similarity index 100%
rename from app/coding/services/events.py
rename to app/coding/support/events.py
diff --git a/app/coding/support/run_result_mapper.py b/app/coding/support/run_result_mapper.py
new file mode 100644
index 0000000..0d479c0
--- /dev/null
+++ b/app/coding/support/run_result_mapper.py
@@ -0,0 +1,61 @@
+# Copyright 2026 GrillKit Contributors
+# SPDX-License-Identifier: Apache-2.0
+"""Map Judge0 run results to persistence-friendly summaries."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from app.coding.domain.value_objects import CaseRunResult, CodingRunResult
+
+
+class Judge0ResultMapper:
+ """Serialize Judge0 run results for persistence."""
+
+ @staticmethod
+ def _serialize_test_result(result: CaseRunResult) -> dict[str, Any]:
+ """Convert a domain test result into an API/persistence payload.
+
+ Args:
+ result: One public test execution result.
+
+ Returns:
+ JSON-serializable dict for clients and persistence.
+ """
+ payload: dict[str, Any] = {
+ "name": result.name,
+ "passed": result.passed,
+ "expected_stdout": result.expected_stdout,
+ "actual_stdout": result.actual_stdout,
+ }
+ if result.stderr:
+ payload["stderr"] = result.stderr
+ if result.compile_output:
+ payload["compile_output"] = result.compile_output
+ if result.judge0_status_description:
+ payload["status"] = result.judge0_status_description
+ return payload
+
+ @staticmethod
+ def to_summary(result: CodingRunResult) -> dict[str, Any]:
+ """Serialize a Judge0 run result for submit_test_summary persistence.
+
+ Args:
+ result: Aggregated run outcome from Judge0.
+
+ Returns:
+ JSON-serializable hidden test summary payload.
+ """
+ return {
+ "status": result.status,
+ "stdout": result.stdout,
+ "stderr": result.stderr,
+ "compile_output": result.compile_output,
+ "tests_passed": result.tests_passed,
+ "tests_total": result.tests_total,
+ "test_results": [
+ Judge0ResultMapper._serialize_test_result(test_result)
+ for test_result in result.test_results
+ ],
+ "duration_ms": result.duration_ms,
+ }
diff --git a/tests/platform/services/__init__.py b/app/coding/use_cases/__init__.py
similarity index 100%
rename from tests/platform/services/__init__.py
rename to app/coding/use_cases/__init__.py
diff --git a/app/coding/services/creation.py b/app/coding/use_cases/create_section.py
similarity index 98%
rename from app/coding/services/creation.py
rename to app/coding/use_cases/create_section.py
index 5a03cbb..60ee254 100644
--- a/app/coding/services/creation.py
+++ b/app/coding/use_cases/create_section.py
@@ -8,7 +8,7 @@
from app.interview.repositories.uow import InterviewUnitOfWork
-class CodingSectionCreationService:
+class CreateCodingSection:
"""Service for creating coding sections within an interview session."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
diff --git a/app/coding/services/navigation.py b/app/coding/use_cases/navigate_tasks.py
similarity index 68%
rename from app/coding/services/navigation.py
rename to app/coding/use_cases/navigate_tasks.py
index 2bf651f..93a13b5 100644
--- a/app/coding/services/navigation.py
+++ b/app/coding/use_cases/navigate_tasks.py
@@ -8,18 +8,11 @@
from app.coding.domain.exceptions import CodingSectionNotFoundError
from app.coding.domain.task_spec import client_task_spec_from_stored
from app.interview.repositories.uow import InterviewUnitOfWork
-from app.interview.services.phases import SessionPhaseOrchestrator
+from app.interview.use_cases.advance_phase import AdvanceSessionPhase
def next_task_payload(task: CodingTask) -> dict[str, Any]:
- """Build WebSocket/API payload for the next unsubmitted coding task.
-
- Args:
- task: Next unsubmitted coding task round.
-
- Returns:
- Dict with task fields for the client.
- """
+ """Build WebSocket/API payload for the next unsubmitted coding task."""
return {
"task_id": task.task_id,
"order": task.order,
@@ -29,15 +22,10 @@ def next_task_payload(task: CodingTask) -> dict[str, Any]:
}
-class CodingNavigationService:
+class CodingTaskNavigation:
"""Shared navigation after a coding task round is completed."""
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(
@@ -47,20 +35,7 @@ def advance_to_next_unsubmitted(
task_id: str,
round_num: int,
) -> tuple[dict[str, Any] | None, int | None]:
- """Activate the next unsubmitted task and build client payload.
-
- Args:
- interview_id: Parent interview UUID.
- task_id: YAML task ID of the completed round.
- round_num: Follow-up round that was just completed.
-
- Returns:
- Tuple of (next_task dict or None, timer_remaining_seconds or None).
-
- Raises:
- CodingSectionNotFoundError: If the coding section does not exist.
- CodingSectionNotActiveError: If the section is not active.
- """
+ """Activate the next unsubmitted task and build client payload."""
section = self._uow.coding_sections.get_aggregate(interview_id)
if section is None:
raise CodingSectionNotFoundError(interview_id)
@@ -87,14 +62,12 @@ def _notify_phase_complete_if_needed(
interview_id: str,
section: CodingSection,
) -> None:
- """Trigger section prefetch when the coding phase has no remaining tasks.
-
- Args:
- interview_id: Parent interview UUID.
- section: Coding section after the latest navigation update.
- """
+ """Trigger section prefetch when the coding phase has no remaining tasks."""
if section.is_complete():
- SessionPhaseOrchestrator(self._uow).notify_section_complete(
+ AdvanceSessionPhase(self._uow).notify_section_complete(
interview_id,
"coding",
)
+
+
+CodingNavigationService = CodingTaskNavigation
diff --git a/app/coding/services/runner.py b/app/coding/use_cases/run_tests.py
similarity index 95%
rename from app/coding/services/runner.py
rename to app/coding/use_cases/run_tests.py
index bed44ff..07a05d8 100644
--- a/app/coding/services/runner.py
+++ b/app/coding/use_cases/run_tests.py
@@ -6,21 +6,21 @@
from typing import Any
+from app.coding.domain.test_harness import build_python_script
from app.coding.domain.value_objects import (
CaseRunResult,
CodingRunResult,
RunOutcomeStatus,
)
-from app.coding.services.harness import build_python_script
-from app.coding.services.judge0_client import (
+from app.shared.infrastructure.gateways.judge0 import (
JUDGE0_STATUS_ACCEPTED,
JUDGE0_STATUS_COMPILATION_ERROR,
JUDGE0_STATUS_RUNTIME_ERROR,
JUDGE0_STATUS_TIME_LIMIT,
- Judge0Client,
+ Judge0Gateway,
Judge0SubmissionResult,
)
-from app.coding.services.judge0_config import judge0_language_id
+from app.shared.infrastructure.gateways.judge0_config import judge0_language_id
class CodingRunnerService:
@@ -31,9 +31,9 @@ async def run_hidden_tests(
*,
source_code: str,
task_spec: dict[str, Any],
- client: Judge0Client | None = None,
+ client: Judge0Gateway | None = None,
) -> CodingRunResult:
- """Execute hidden tests for a coding submission.
+ """Execute hidden tests for a coding task.
Args:
source_code: Submitted editor contents.
@@ -66,7 +66,7 @@ async def run_public_tests(
*,
source_code: str,
task_spec: dict[str, Any],
- client: Judge0Client | None = None,
+ client: Judge0Gateway | None = None,
) -> CodingRunResult:
"""Execute public tests for a coding task, or compile-only for AI tasks.
@@ -78,7 +78,7 @@ async def run_public_tests(
Returns:
Aggregated run outcome with per-test details.
"""
- judge0 = client or Judge0Client.from_env()
+ judge0 = client or Judge0Gateway.from_env()
language = str(task_spec.get("language", "python"))
language_id = judge0_language_id(language)
evaluation_mode = str(task_spec.get("evaluation_mode", "tests"))
@@ -173,7 +173,7 @@ async def _run_compile_only(
entrypoint: str | None,
cpu_time_limit: Any,
memory_limit_kb: Any,
- client: Judge0Client,
+ client: Judge0Gateway,
) -> CodingRunResult:
"""Compile candidate code without executing public tests.
diff --git a/app/coding/services/submission.py b/app/coding/use_cases/submit_solution.py
similarity index 93%
rename from app/coding/services/submission.py
rename to app/coding/use_cases/submit_solution.py
index 2d14b86..3a252e8 100644
--- a/app/coding/services/submission.py
+++ b/app/coding/use_cases/submit_solution.py
@@ -11,27 +11,27 @@
from typing import Any, Literal, cast
from app.ai.base import AIProvider
+from app.coding.domain.evaluator import CodingEvaluator
from app.coding.domain.exceptions import (
CodingSectionNotFoundError,
CodingTaskNotCurrentError,
CodingTaskTimerError,
)
-from app.coding.services.evaluation_persistence import (
- CodingEvaluationPersistenceService,
+from app.coding.support.evaluation_commit import (
+ CommitEvaluationResult,
)
-from app.coding.services.evaluator.service import CodingEvaluatorService
-from app.coding.services.events import CodingFeedbackEvent
-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 (
+from app.coding.support.events import CodingFeedbackEvent
+from app.coding.support.run_result_mapper import Judge0ResultMapper
+from app.coding.use_cases.navigate_tasks import CodingNavigationService
+from app.coding.use_cases.run_tests import CodingRunnerService
+from app.interview.domain.events import (
AnswerSavedEvent,
EvaluatingEvent,
InterviewEvent,
)
-from app.interview.services.rules.feedback import timeout_feedback_for_locale
+from app.interview.domain.exceptions import InterviewNotFoundError
+from app.interview.domain.rules.feedback import timeout_feedback_for_locale
+from app.interview.repositories.uow import InterviewUnitOfWork
@dataclass(frozen=True)
@@ -65,14 +65,14 @@ class CodingSubmissionContext:
run_attempts: tuple[dict[str, Any], ...]
-class CodingSubmissionService:
+class SubmitCodingSolution:
"""Handle coding submit messages and stream server events."""
def __init__(
self,
uow: InterviewUnitOfWork,
*,
- persistence: CodingEvaluationPersistenceService | None = None,
+ persistence: CommitEvaluationResult | None = None,
) -> None:
"""Initialize with the active unit of work.
@@ -83,7 +83,7 @@ def __init__(
self._uow = uow
navigation = CodingNavigationService(uow)
self._navigation = navigation
- self._persistence = persistence or CodingEvaluationPersistenceService(
+ self._persistence = persistence or CommitEvaluationResult(
uow, navigation=navigation
)
@@ -161,7 +161,7 @@ async def _prepare_submission(
source_code=source_code,
task_spec=task_spec,
)
- submit_test_summary = coding_run_result_to_summary(hidden_result)
+ submit_test_summary = Judge0ResultMapper.to_summary(hidden_result)
section = self._uow.coding_sections.get_aggregate(interview_id)
if section is None:
@@ -347,7 +347,7 @@ async def _iter_submit(
follow_up_text,
follow_up_mode,
) = await asyncio.shield(
- CodingEvaluatorService.evaluate_submission(
+ CodingEvaluator.evaluate_submission(
provider=provider,
locale=ctx.locale,
answer_round=ctx.round_num,
diff --git a/app/interview/api/dashboard.py b/app/interview/api/dashboard.py
index 62ac51f..9797039 100644
--- a/app/interview/api/dashboard.py
+++ b/app/interview/api/dashboard.py
@@ -8,7 +8,7 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
-from app.interview.api.deps import DashboardBuilderDep
+from app.interview.api.deps import InterviewDashboardDep
from app.templating import templates
router = APIRouter(tags=["dashboard"])
@@ -17,7 +17,7 @@
@router.get("/", response_class=HTMLResponse)
async def dashboard_page(
request: Request,
- dashboard: DashboardBuilderDep,
+ dashboard: InterviewDashboardDep,
) -> HTMLResponse:
"""Render the dashboard with recent interview history.
diff --git a/app/interview/api/deps.py b/app/interview/api/deps.py
index bea4ad1..2beb038 100644
--- a/app/interview/api/deps.py
+++ b/app/interview/api/deps.py
@@ -9,25 +9,30 @@
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.coding.queries.loader import CodingTaskLoader as CodingStateService
+from app.coding.queries.review_page import CodingReviewPage as CodingReviewService
+from app.coding.use_cases.create_section import CreateCodingSection
+from app.coding.use_cases.submit_solution import (
+ SubmitCodingSolution as CodingSubmissionService,
+)
+from app.interview.queries.dashboard import InterviewDashboard
+from app.interview.queries.loader import InterviewLoader
+from app.interview.queries.results_page import CompletedSessionResults
+from app.interview.queries.session_page import ActiveSessionPage
+from app.interview.use_cases.complete_session import CompleteInterviewSession
+from app.interview.use_cases.create_session import CreateInterviewSession
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 (
+from app.shared.infrastructure.gateways.ai_context import ai_provider_from_config
+from app.speech.domain.transcriber_resolver import (
resolve_speech_transcriber,
speech_transcriber_unavailable_message,
)
-from app.theory.services.review import TheoryReviewService
-from app.theory.services.submission import TheorySubmissionService
+from app.theory.queries.review_page import TheoryReviewPage as TheoryReviewService
+from app.theory.use_cases.create_section import CreateTheorySection
+from app.theory.use_cases.submit_answer import (
+ SubmitTheoryAnswer as TheorySubmissionService,
+)
async def get_ai_provider() -> AsyncIterator[AIProvider]:
@@ -43,54 +48,62 @@ async def get_ai_provider() -> AsyncIterator[AIProvider]:
yield provider
-def get_interview_query(uow: UoWDep) -> InterviewQuery:
- """Build an interview query service bound to the request unit of work.
+def get_interview_query(uow: UoWDep) -> InterviewLoader:
+ """Build an interview query service bound to the request unit of work."""
+ return InterviewLoader(uow)
- 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:
+def get_dashboard_builder(uow: UoWDep) -> InterviewDashboard:
"""Build a dashboard builder bound to the request UoW."""
- return DashboardBuilder(uow)
+ return InterviewDashboard(uow)
-def get_session_page_service(uow: UoWAutoCommitDep) -> SessionPageService:
+def get_session_page_service(uow: UoWAutoCommitDep) -> ActiveSessionPage:
"""Build a session page service bound to an auto-commit UoW."""
- return SessionPageService(uow)
+ return ActiveSessionPage(uow)
+
+
+def get_create_theory_section(
+ uow: UoWAutoCommitDep,
+) -> CreateTheorySection:
+ """Build a theory section creation use case."""
+ return CreateTheorySection(uow)
+
+
+def get_create_coding_section(
+ uow: UoWAutoCommitDep,
+) -> CreateCodingSection:
+ """Build a coding section creation use case."""
+ return CreateCodingSection(uow)
def get_session_creation_service(
uow: UoWAutoCommitDep,
-) -> SessionCreationService:
- """Build a session creation service bound to an auto-commit UoW.
+ create_theory: Annotated[CreateTheorySection, Depends(get_create_theory_section)],
+ create_coding: Annotated[CreateCodingSection, Depends(get_create_coding_section)],
+) -> CreateInterviewSession:
+ """Build a session creation use case composed from section creators.
Args:
uow: Application unit of work for the request scope.
+ create_theory: Theory section creation use case.
+ create_coding: Coding section creation use case.
Returns:
- Session creation service instance.
+ Composed ``CreateInterviewSession`` use case instance.
"""
- return SessionCreationService(uow)
+ return CreateInterviewSession(
+ uow,
+ create_theory_section=create_theory,
+ create_coding_section=create_coding,
+ )
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)
+) -> CompleteInterviewSession:
+ """Build a session completion use case bound to the request UoW."""
+ return CompleteInterviewSession(uow)
def get_theory_submission_service(uow: UoWDep) -> TheorySubmissionService:
@@ -122,32 +135,11 @@ def get_coding_state_service(uow: UoWDep) -> CodingStateService:
return CodingStateService(uow)
-def get_known_questions_service(
- uow: UoWAutoCommitDep,
-) -> KnownQuestionsService:
- """Build a known questions service bound to the request UoW.
-
- 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)
+) -> CompletedSessionResults:
+ """Build a session results page query bound to the request UoW."""
+ return CompletedSessionResults(uow)
def get_theory_review_service(uow: UoWDep) -> TheoryReviewService:
@@ -174,18 +166,18 @@ def get_coding_review_service(uow: UoWDep) -> CodingReviewService:
return CodingReviewService(uow)
-InterviewQueryDep = Annotated[InterviewQuery, Depends(get_interview_query)]
-DashboardBuilderDep = Annotated[DashboardBuilder, Depends(get_dashboard_builder)]
-SessionPageServiceDep = Annotated[
- SessionPageService,
+InterviewLoaderDep = Annotated[InterviewLoader, Depends(get_interview_query)]
+InterviewDashboardDep = Annotated[InterviewDashboard, Depends(get_dashboard_builder)]
+ActiveSessionPageDep = Annotated[
+ ActiveSessionPage,
Depends(get_session_page_service),
]
-SessionCreationServiceDep = Annotated[
- SessionCreationService,
+CreateSessionDep = Annotated[
+ CreateInterviewSession,
Depends(get_session_creation_service),
]
-SessionCompletionServiceDep = Annotated[
- SessionCompletionService,
+CompleteSessionDep = Annotated[
+ CompleteInterviewSession,
Depends(get_session_completion_service),
]
TheorySubmissionServiceDep = Annotated[
@@ -200,12 +192,16 @@ def get_coding_review_service(uow: UoWDep) -> CodingReviewService:
CodingStateService,
Depends(get_coding_state_service),
]
-KnownQuestionsServiceDep = Annotated[
- KnownQuestionsService,
- Depends(get_known_questions_service),
+CreateTheorySectionDep = Annotated[
+ CreateTheorySection,
+ Depends(get_create_theory_section),
+]
+CreateCodingSectionDep = Annotated[
+ CreateCodingSection,
+ Depends(get_create_coding_section),
]
-SessionResultsPageServiceDep = Annotated[
- SessionResultsPageService,
+CompletedSessionResultsDep = Annotated[
+ CompletedSessionResults,
Depends(get_session_results_page_service),
]
TheoryReviewServiceDep = Annotated[
diff --git a/app/interview/api/known_questions.py b/app/interview/api/known_questions.py
index 9411b94..8e1cf5a 100644
--- a/app/interview/api/known_questions.py
+++ b/app/interview/api/known_questions.py
@@ -5,8 +5,9 @@
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.interview.support.bank_text import resolve_known_views
+from app.shared.application.uow_deps import UoWAutoCommitDep
from app.templating import templates
router = APIRouter(prefix="/known-questions", tags=["known-questions"])
@@ -14,17 +15,10 @@
@router.get("")
def list_known_questions(
- service: KnownQuestionsServiceDep,
+ uow: UoWAutoCommitDep,
) -> 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 all known bank item IDs grouped by branch."""
+ grouped = uow.known_questions.list_all_grouped()
return KnownItemsResponse(
theory=grouped.get("theory", []),
coding=grouped.get("coding", []),
@@ -34,19 +28,11 @@ def list_known_questions(
@router.post("")
def mark_known_item(
body: KnownItemMutation,
- service: KnownQuestionsServiceDep,
+ uow: UoWAutoCommitDep,
) -> 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()
+ """Mark a bank item as known for future session exclusion."""
+ uow.known_questions.mark(body.branch, body.item_id)
+ grouped = uow.known_questions.list_all_grouped()
return KnownItemsResponse(
theory=grouped.get("theory", []),
coding=grouped.get("coding", []),
@@ -56,19 +42,11 @@ def mark_known_item(
@router.delete("")
def unmark_known_item(
body: KnownItemMutation,
- service: KnownQuestionsServiceDep,
+ uow: UoWAutoCommitDep,
) -> 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()
+ """Remove a bank item from the known list."""
+ uow.known_questions.unmark(body.branch, body.item_id)
+ grouped = uow.known_questions.list_all_grouped()
return KnownItemsResponse(
theory=grouped.get("theory", []),
coding=grouped.get("coding", []),
@@ -78,24 +56,16 @@ def unmark_known_item(
@router.get("/manage", response_class=HTMLResponse)
async def manage_known_questions_page(
request: Request,
- service: KnownQuestionsServiceDep,
+ uow: UoWAutoCommitDep,
) -> 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()
+ """Render the known bank items management page."""
+ known = resolve_known_views(uow.known_questions.list_all_grouped())
return templates.TemplateResponse(
request,
"known_questions.html",
{
"theory_items": known.get("theory", []),
"coding_items": known.get("coding", []),
- "total_count": service.count(),
+ "total_count": uow.known_questions.count(),
},
)
diff --git a/app/interview/api/results.py b/app/interview/api/results.py
index 0384201..54788dc 100644
--- a/app/interview/api/results.py
+++ b/app/interview/api/results.py
@@ -7,7 +7,7 @@
from app.interview.api.deps import (
CodingReviewServiceDep,
- SessionResultsPageServiceDep,
+ CompletedSessionResultsDep,
TheoryReviewServiceDep,
)
from app.templating import templates
@@ -19,7 +19,7 @@
async def session_results_page(
request: Request,
interview_id: str,
- service: SessionResultsPageServiceDep,
+ service: CompletedSessionResultsDep,
) -> Response:
"""Render the completed session results hub.
diff --git a/app/interview/api/routes.py b/app/interview/api/routes.py
index 3e4ee48..39af7ca 100644
--- a/app/interview/api/routes.py
+++ b/app/interview/api/routes.py
@@ -9,13 +9,13 @@
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.deps import ActiveSessionPageDep
from app.interview.api.errors import http_exception_from_domain_error
from app.interview.domain.exceptions import InterviewDomainError
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
-from app.question_voice.services.tts_exceptions import (
+from app.platform.domain.speech_runtime import SpeechRuntimeCoordinator
+from app.question_voice.use_cases.generate_question_audio import GenerateQuestionAudio
+from app.shared.infrastructure.gateways.tts_exceptions import (
QuestionVoiceDisabledError,
QuestionVoiceSynthesisError,
)
@@ -45,7 +45,7 @@ async def interview_page(
interview_id: str,
config_service: ConfigServiceDep,
whisper_model_service: WhisperModelServiceDep,
- page_service: SessionPageServiceDep,
+ page_service: ActiveSessionPageDep,
) -> Response:
"""View an interview session.
@@ -107,7 +107,7 @@ async def question_audio(
HTTPException: When voice is disabled, the session is invalid, or TTS fails.
"""
try:
- path = await get_question_audio_path(interview_id, answer_id)
+ path = await GenerateQuestionAudio.execute(interview_id, answer_id)
except QuestionVoiceDisabledError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except QuestionVoiceSynthesisError as exc:
diff --git a/app/interview/api/setup.py b/app/interview/api/setup.py
index 1d1b129..08edc0b 100644
--- a/app/interview/api/setup.py
+++ b/app/interview/api/setup.py
@@ -11,12 +11,12 @@
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
-from app.coding.services.availability import (
+from app.coding.support.coding_availability import (
is_coding_available_async,
)
-from app.interview.api.deps import SessionCreationServiceDep
+from app.interview.api.deps import CreateSessionDep
from app.interview.api.setup_form import setup_form_context
-from app.interview.services.rules.selection import (
+from app.interview.domain.rules.selection import (
parse_session_json,
validate_session_selection,
)
@@ -24,7 +24,7 @@
from app.shared import coding as coding_bank
from app.shared.questions import list_categories, list_levels, list_tracks
from app.speech.api.deps import WhisperModelServiceDep
-from app.speech.services.page import SpeechModelPageService
+from app.speech.queries.speech_page import SpeechModelPageService
from app.templating import templates
router = APIRouter(prefix="/setup", tags=["setup"])
@@ -165,7 +165,7 @@ async def setup_coding_available() -> JSONResponse:
async def create_interview(
request: Request,
config_service: ConfigServiceDep,
- session_creation: SessionCreationServiceDep,
+ session_creation: CreateSessionDep,
whisper_model_service: WhisperModelServiceDep,
selection_json: str = Form(...),
question_count: int = Form(5),
diff --git a/app/interview/api/setup_form.py b/app/interview/api/setup_form.py
index 196cf3d..5563340 100644
--- a/app/interview/api/setup_form.py
+++ b/app/interview/api/setup_form.py
@@ -4,8 +4,8 @@
from collections.abc import Callable
-from app.coding.services.availability import is_coding_available
-from app.interview.services.rules.bank_selection import track_label
+from app.coding.support.coding_availability import is_coding_available
+from app.interview.domain.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/services/evaluation_aggregator.py b/app/interview/domain/evaluation_aggregator.py
similarity index 98%
rename from app/interview/services/evaluation_aggregator.py
rename to app/interview/domain/evaluation_aggregator.py
index 3fb7829..27e2a1c 100644
--- a/app/interview/services/evaluation_aggregator.py
+++ b/app/interview/domain/evaluation_aggregator.py
@@ -7,7 +7,7 @@
from dataclasses import dataclass
from typing import Any
-from app.interview.services.sections import SectionEvaluationSummary
+from app.interview.domain.session_phases import SectionEvaluationSummary
from app.shared.evaluation_models import InterviewEvaluation
diff --git a/app/interview/services/events.py b/app/interview/domain/events.py
similarity index 100%
rename from app/interview/services/events.py
rename to app/interview/domain/events.py
diff --git a/tests/question_voice/services/__init__.py b/app/interview/domain/rules/__init__.py
similarity index 100%
rename from tests/question_voice/services/__init__.py
rename to app/interview/domain/rules/__init__.py
diff --git a/app/interview/services/rules/bank_selection.py b/app/interview/domain/rules/bank_selection.py
similarity index 100%
rename from app/interview/services/rules/bank_selection.py
rename to app/interview/domain/rules/bank_selection.py
diff --git a/app/interview/services/rules/feedback.py b/app/interview/domain/rules/feedback.py
similarity index 100%
rename from app/interview/services/rules/feedback.py
rename to app/interview/domain/rules/feedback.py
diff --git a/app/interview/services/rules/selection.py b/app/interview/domain/rules/selection.py
similarity index 97%
rename from app/interview/services/rules/selection.py
rename to app/interview/domain/rules/selection.py
index 87376bc..2a5d6d8 100644
--- a/app/interview/services/rules/selection.py
+++ b/app/interview/domain/rules/selection.py
@@ -7,9 +7,12 @@
import json
import random
-from app.coding.services.availability import is_coding_available
-from app.coding.services.planning import validate_selection as validate_coding_selection
-from app.coding.services.planning import validate_task_count
+from app.coding.domain.task_planner import (
+ validate_selection as validate_coding_selection,
+)
+from app.coding.domain.task_planner import validate_task_count
+from app.coding.support.coding_availability import is_coding_available
+from app.interview.domain.rules.bank_selection import track_label
from app.interview.domain.serialization import (
parse_session_spec,
session_from_payload,
@@ -23,7 +26,6 @@
TrackQuestionPools,
TrackSelection,
)
-from app.interview.services.rules.bank_selection import track_label
def validate_question_count(selection: InterviewSelection, question_count: int) -> None:
diff --git a/app/interview/services/scoring.py b/app/interview/domain/scoring.py
similarity index 97%
rename from app/interview/services/scoring.py
rename to app/interview/domain/scoring.py
index b522823..1767aa0 100644
--- a/app/interview/services/scoring.py
+++ b/app/interview/domain/scoring.py
@@ -8,7 +8,7 @@
from app.coding.domain.entities import CodingSection
from app.interview.domain.entities import Interview as DomainInterview
-from app.interview.services.evaluation_aggregator import SessionEvaluationAggregator
+from app.interview.domain.evaluation_aggregator import SessionEvaluationAggregator
from app.theory.domain.entities import TheorySection
diff --git a/app/interview/services/section_evaluation.py b/app/interview/domain/section_evaluation.py
similarity index 94%
rename from app/interview/services/section_evaluation.py
rename to app/interview/domain/section_evaluation.py
index 82b1e9b..51a5731 100644
--- a/app/interview/services/section_evaluation.py
+++ b/app/interview/domain/section_evaluation.py
@@ -6,8 +6,8 @@
from typing import Any
+from app.interview.domain.session_phases import SectionEvaluationSummary
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_feedback.py b/app/interview/domain/section_feedback.py
similarity index 100%
rename from app/interview/services/section_feedback.py
rename to app/interview/domain/section_feedback.py
diff --git a/app/interview/domain/serialization.py b/app/interview/domain/serialization.py
index 77d2480..ca36d4c 100644
--- a/app/interview/domain/serialization.py
+++ b/app/interview/domain/serialization.py
@@ -5,7 +5,7 @@
from __future__ import annotations
import json
-from typing import Any
+from typing import Any, cast
from app.interview.domain.value_objects import (
InterviewSelection,
@@ -269,6 +269,7 @@ 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")
+ validated_mode: SessionMode = cast(SessionMode, session_mode)
exclude_known = data.get("exclude_known", True)
if not isinstance(exclude_known, bool):
raise ValueError("Invalid selection_spec: exclude_known must be boolean")
@@ -277,17 +278,17 @@ def session_from_payload(
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]
+ session_mode=validated_mode,
exclude_known=exclude_known,
theory=_parse_branch_payload(
theory_raw,
branch_name="theory",
- default_enabled=_branch_enabled_for_mode(session_mode, "theory"), # type: ignore[arg-type]
+ default_enabled=_branch_enabled_for_mode(validated_mode, "theory"),
),
coding=_parse_branch_payload(
coding_raw,
branch_name="coding",
- default_enabled=_branch_enabled_for_mode(session_mode, "coding"), # type: ignore[arg-type]
+ default_enabled=_branch_enabled_for_mode(validated_mode, "coding"),
),
)
return _normalize_session_selection(session)
diff --git a/app/interview/services/session_evaluator.py b/app/interview/domain/session_evaluation.py
similarity index 89%
rename from app/interview/services/session_evaluator.py
rename to app/interview/domain/session_evaluation.py
index 4999271..e7b2d70 100644
--- a/app/interview/services/session_evaluator.py
+++ b/app/interview/domain/session_evaluation.py
@@ -6,15 +6,15 @@
from typing import Any
from app.ai.base import AIProvider
-from app.interview.services.evaluation_aggregator import MergedSessionEvaluation
-from app.interview.services.sections import SectionEvaluationSummary
+from app.interview.domain.evaluation_aggregator import MergedSessionEvaluation
+from app.interview.domain.session_phases import SectionEvaluationSummary
from app.shared.evaluation_models import InterviewEvaluation
-from app.theory.services.evaluator.service import TheoryEvaluatorService
+from app.theory.domain.evaluator import TheoryEvaluator
logger = logging.getLogger(__name__)
-class SessionEvaluatorService:
+class SessionEvaluator:
"""Produce the final session evaluation narrative."""
@staticmethod
@@ -63,7 +63,7 @@ def _build_from_cached_narratives(
for section in merged.sections:
if section.skipped:
continue
- SessionEvaluatorService._collect_section_narrative(
+ SessionEvaluator._collect_section_narrative(
section,
feedback_parts=feedback_parts,
topics=topics,
@@ -124,13 +124,13 @@ def _synthesize_from_merged(
)
continue
- SessionEvaluatorService._collect_section_narrative(
+ SessionEvaluator._collect_section_narrative(
section,
feedback_parts=feedback_parts,
topics=topics,
strengths=strengths,
)
- SessionEvaluatorService._collect_item_feedback(section, feedback_parts)
+ SessionEvaluator._collect_item_feedback(section, feedback_parts)
overall_feedback = "\n\n".join(
part for part in feedback_parts if part.strip()
@@ -167,10 +167,10 @@ def _collect_section_narrative(
section_feedback = str(narrative.get("section_feedback", "")).strip()
if section_feedback:
feedback_parts.append(section_feedback)
- SessionEvaluatorService._append_unique_strings(
+ SessionEvaluator._append_unique_strings(
topics, narrative.get("topics_to_review", [])
)
- SessionEvaluatorService._append_unique_strings(
+ SessionEvaluator._append_unique_strings(
strengths, narrative.get("strengths_summary", [])
)
@@ -219,14 +219,14 @@ async def evaluate_session(
Session evaluation narrative without ``score_breakdown``.
"""
if merged.has_cached_narratives():
- return SessionEvaluatorService._build_from_cached_narratives(merged)
+ return SessionEvaluator._build_from_cached_narratives(merged)
normalized_items = [
- SessionEvaluatorService._normalize_item_for_session_eval(item)
+ SessionEvaluator._normalize_item_for_session_eval(item)
for item in merged.all_items
]
try:
- interview_eval = await TheoryEvaluatorService.evaluate_interview(
+ interview_eval = await TheoryEvaluator.evaluate_interview(
provider=provider,
questions_answers=normalized_items,
sources_text=sources_text,
@@ -240,4 +240,7 @@ async def evaluate_session(
"Session LLM evaluation failed; using synthesized feedback: %s",
exc,
)
- return SessionEvaluatorService._synthesize_from_merged(merged)
+ return SessionEvaluator._synthesize_from_merged(merged)
+
+
+SessionEvaluatorService = SessionEvaluator
diff --git a/app/interview/services/sections.py b/app/interview/domain/session_phases.py
similarity index 92%
rename from app/interview/services/sections.py
rename to app/interview/domain/session_phases.py
index 96982f9..d978aaf 100644
--- a/app/interview/services/sections.py
+++ b/app/interview/domain/session_phases.py
@@ -124,10 +124,16 @@ def section_services(
Returns:
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
+ from app.coding.queries.section_state import (
+ CodingSectionState as CodingSectionService,
+ )
+ from app.coding.queries.section_summary import (
+ CodingSectionSummary as CodingQueryService,
+ )
+ from app.theory.queries.loader import TheorySectionLoader as TheoryQueryService
+ from app.theory.queries.section_state import (
+ TheorySectionState as TheorySectionService,
+ )
theory_query = TheoryQueryService(uow)
coding_query = CodingQueryService(uow)
diff --git a/tests/speech/services/__init__.py b/app/interview/queries/__init__.py
similarity index 100%
rename from tests/speech/services/__init__.py
rename to app/interview/queries/__init__.py
diff --git a/app/interview/services/dashboard.py b/app/interview/queries/dashboard.py
similarity index 93%
rename from app/interview/services/dashboard.py
rename to app/interview/queries/dashboard.py
index ef754a1..8b36d60 100644
--- a/app/interview/services/dashboard.py
+++ b/app/interview/queries/dashboard.py
@@ -6,20 +6,22 @@
from typing import Any
from app.coding.domain.entities import CodingSection
+from app.interview.domain.rules.selection import (
+ selection_summary_lines,
+ session_display_title,
+)
from app.interview.domain.serialization import parse_session_spec
from app.interview.domain.value_objects import InterviewSelection, session_mode_label
+from app.interview.queries.projection import (
+ load_recent_interview_reads,
+)
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,
-)
from app.theory.domain.entities import TheorySection
-class DashboardBuilder:
+class InterviewDashboard:
"""Build dashboard rows and display helpers for interview history."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
@@ -103,7 +105,7 @@ def compute_max_score(
Maximum possible score for the session.
"""
if score_breakdown:
- breakdown_total = DashboardBuilder._max_score_from_breakdown(
+ breakdown_total = InterviewDashboard._max_score_from_breakdown(
score_breakdown
)
if breakdown_total > 0:
@@ -169,7 +171,7 @@ def list_rows(self, limit: int = 20) -> list[DashboardRowRead]:
if interview.status == "completed":
feedback = interview.overall_feedback
breakdown = feedback.get("score_breakdown") if feedback else None
- max_score = DashboardBuilder.compute_max_score(
+ max_score = InterviewDashboard.compute_max_score(
interview,
breakdown,
coding_section=coding_sections.get(interview.id),
@@ -187,13 +189,13 @@ def list_rows(self, limit: int = 20) -> list[DashboardRowRead]:
rows.append(
DashboardRowRead(
id=interview.id,
- title=DashboardBuilder.interview_display_title(interview),
+ title=InterviewDashboard.interview_display_title(interview),
question_count=interview.question_count,
session_mode_label=session_mode_label(session.session_mode),
score_display=score_display,
status=interview.status,
status_label=status_label,
- datetime_display=DashboardBuilder.format_local_datetime(when),
+ datetime_display=InterviewDashboard.format_local_datetime(when),
url=(
f"/interview/{interview.id}/results"
if interview.status == "completed"
@@ -202,3 +204,6 @@ def list_rows(self, limit: int = 20) -> list[DashboardRowRead]:
)
)
return rows
+
+
+DashboardBuilder = InterviewDashboard
diff --git a/app/interview/services/query.py b/app/interview/queries/loader.py
similarity index 92%
rename from app/interview/services/query.py
rename to app/interview/queries/loader.py
index f45ddfa..13008aa 100644
--- a/app/interview/services/query.py
+++ b/app/interview/queries/loader.py
@@ -6,12 +6,12 @@
"""
from app.interview.domain.exceptions import InterviewNotFoundError
+from app.interview.queries.projection import load_interview_read
from app.interview.repositories.uow import InterviewUnitOfWork
from app.interview.schemas.interview import AnswerRead, InterviewRead
-from app.interview.services.read_model import load_interview_read
-class InterviewQuery:
+class InterviewLoader:
"""Read-only queries and view-model helpers for interview sessions."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
@@ -44,7 +44,7 @@ def load(interview_id: str) -> InterviewRead | None:
Interview read model with answers loaded, or None if not found.
"""
with InterviewUnitOfWork() as uow:
- return InterviewQuery(uow).get_interview(interview_id)
+ return InterviewLoader(uow).get_interview(interview_id)
def get_active_or_raise(self, interview_id: str) -> InterviewRead:
"""Load an active interview read model or raise a domain error.
@@ -83,7 +83,7 @@ def get_active_interview_or_raise(interview_id: str) -> InterviewRead:
InterviewNotActiveError: If the interview is not active.
"""
with InterviewUnitOfWork() as uow:
- return InterviewQuery(uow).get_active_or_raise(interview_id)
+ return InterviewLoader(uow).get_active_or_raise(interview_id)
@staticmethod
def get_current_unanswered(interview: InterviewRead) -> AnswerRead | None:
@@ -99,3 +99,6 @@ def get_current_unanswered(interview: InterviewRead) -> AnswerRead | None:
if answer.answer_text is None:
return answer
return None
+
+
+InterviewQuery = InterviewLoader
diff --git a/app/interview/queries/projection.py b/app/interview/queries/projection.py
new file mode 100644
index 0000000..e21dea7
--- /dev/null
+++ b/app/interview/queries/projection.py
@@ -0,0 +1,182 @@
+# 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.domain.scoring import resolve_completed_read_score
+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.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 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
+ ]
+
+
+class InterviewProjection:
+ """Build interview read-model projections from domain 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
+
+ def load(self, interview_id: str) -> InterviewRead | None:
+ """Load a composed interview read model for one session.
+
+ Args:
+ interview_id: Parent session UUID.
+
+ Returns:
+ Interview read model, or None when the session does not exist.
+ """
+ shell = self._uow.interviews.get_aggregate(interview_id)
+ if shell is None:
+ return None
+ theory = self._uow.theory_sections.get_aggregate(interview_id)
+ coding = self._uow.coding_sections.get_aggregate(interview_id)
+ return _assemble_interview_read(shell, theory, coding)
+
+ def load_recent(self, *, limit: int = 20) -> list[InterviewRead]:
+ """Load recent interview read models, newest first.
+
+ Args:
+ limit: Maximum number of sessions to return.
+
+ Returns:
+ Composed interview read models with theory tasks when present.
+ """
+ shells = self._uow.interviews.list_recent_aggregates(limit=limit)
+ if not shells:
+ return []
+ interview_ids = [shell.id for shell in shells]
+ theory_by_id = self._uow.theory_sections.get_aggregates_by_interview_ids(
+ interview_ids
+ )
+ coding_by_id = self._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
+ ]
+
+ @staticmethod
+ def assemble(
+ 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.
+ """
+ return _assemble_interview_read(shell, theory, coding)
diff --git a/app/interview/services/results_page.py b/app/interview/queries/results_page.py
similarity index 93%
rename from app/interview/services/results_page.py
rename to app/interview/queries/results_page.py
index bc36e1f..169e6c4 100644
--- a/app/interview/services/results_page.py
+++ b/app/interview/queries/results_page.py
@@ -7,23 +7,23 @@
from dataclasses import dataclass
from typing import Any
+from app.interview.domain.rules.selection import session_selection_summary_lines
from app.interview.domain.serialization import parse_session_spec
-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,
- resolved_section_feedback,
-)
-from app.interview.services.sections import (
+from app.interview.domain.session_phases import (
SectionEvaluationSummary,
phase_order_for_mode,
section_services,
)
+from app.interview.domain.value_objects import SectionKind, session_mode_label
+from app.interview.queries.dashboard import InterviewDashboard as DashboardBuilder
+from app.interview.queries.projection import load_interview_read
+from app.interview.queries.review_context import (
+ item_id_key_for,
+ resolved_section_feedback,
+)
+from app.interview.repositories.uow import InterviewUnitOfWork
+from app.interview.schemas.interview import InterviewRead
+from app.interview.schemas.results import SectionResultCard, SessionResultsContext
from app.shared.locales import SUPPORTED_LOCALES
_SECTION_LABELS: dict[SectionKind, str] = {
@@ -45,7 +45,7 @@ class SessionResultsRender:
template_context: dict[str, Any] | None
-class SessionResultsPageService:
+class CompletedSessionResults:
"""Compose the completed session results hub template context."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
@@ -120,7 +120,7 @@ def _section_card(
score=score,
max_score=section_max,
skipped=skipped,
- summary=SessionResultsPageService._section_summary_text(kind, summary),
+ summary=CompletedSessionResults._section_summary_text(kind, summary),
detail_url=f"/interview/{interview_id}/{kind}",
)
diff --git a/app/interview/services/section_review_support.py b/app/interview/queries/review_context.py
similarity index 87%
rename from app/interview/services/section_review_support.py
rename to app/interview/queries/review_context.py
index c68cd63..aa063a2 100644
--- a/app/interview/services/section_review_support.py
+++ b/app/interview/queries/review_context.py
@@ -7,20 +7,20 @@
from dataclasses import dataclass
from typing import Any
+from app.interview.domain.rules.selection import session_selection_summary_lines
+from app.interview.domain.section_feedback import resolve_section_feedback
from app.interview.domain.serialization import parse_session_spec
+from app.interview.domain.session_phases import SectionEvaluationSummary
from app.interview.domain.value_objects import SectionKind, SessionSelection
+from app.interview.queries.dashboard import InterviewDashboard as DashboardBuilder
+from app.interview.queries.projection import load_interview_read
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
from app.shared.locales import SUPPORTED_LOCALES
@dataclass(frozen=True, slots=True)
-class CompletedInterviewSnapshot:
+class CompletedInterviewContext:
"""Loaded completed interview shell for section review pages.
Attributes:
@@ -32,10 +32,13 @@ class CompletedInterviewSnapshot:
session: SessionSelection
+CompletedInterviewSnapshot = CompletedInterviewContext
+
+
def load_completed_interview(
uow: InterviewUnitOfWork,
interview_id: str,
-) -> CompletedInterviewSnapshot | None:
+) -> CompletedInterviewContext | None:
"""Load a completed interview read model within an existing unit of work.
Args:
@@ -49,7 +52,7 @@ def load_completed_interview(
if interview is None or interview.status != "completed":
return None
session = parse_session_spec(interview.selection_spec)
- return CompletedInterviewSnapshot(interview=interview, session=session)
+ return CompletedInterviewContext(interview=interview, session=session)
def section_score_bounds(
@@ -75,7 +78,7 @@ def section_score_bounds(
def shared_review_fields(
interview_id: str,
- snapshot: CompletedInterviewSnapshot,
+ snapshot: CompletedInterviewContext,
) -> dict[str, Any]:
"""Build review context fields shared by theory and coding pages.
diff --git a/app/interview/services/page.py b/app/interview/queries/session_page.py
similarity index 90%
rename from app/interview/services/page.py
rename to app/interview/queries/session_page.py
index 9c117a3..4498334 100644
--- a/app/interview/services/page.py
+++ b/app/interview/queries/session_page.py
@@ -5,31 +5,33 @@
from dataclasses import dataclass
from typing import Any
-from app.coding.services.page import CodingPageService
+from app.coding.queries.task_page import ActiveCodingTaskPage as CodingPageService
+from app.interview.domain.rules.selection import (
+ session_display_title,
+ session_selection_summary_lines,
+)
from app.interview.domain.serialization import parse_session_spec
+from app.interview.domain.session_phases import phase_order_for_mode
from app.interview.domain.value_objects import SESSION_MODE_LABELS
+from app.interview.queries.dashboard import InterviewDashboard as DashboardBuilder
+from app.interview.queries.loader import InterviewLoader as InterviewQuery
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
-from app.interview.services.query import InterviewQuery
-from app.interview.services.rules.selection import (
- session_display_title,
- session_selection_summary_lines,
+from app.interview.use_cases.advance_phase import (
+ AdvanceSessionPhase as SessionPhaseOrchestrator,
)
-from app.interview.services.sections import phase_order_for_mode
-from app.platform.services.config import AppConfig
-from app.platform.services.llm_catalog import LLMCatalogService
-from app.question_voice.services.page import QuestionVoicePageService
+from app.platform.domain.config import AppConfig
+from app.platform.domain.llm_catalog import LLMCatalogService
+from app.question_voice.queries.voice_page import VoicePage as QuestionVoicePageService
+from app.shared.infrastructure.gateways.whisper_model import WhisperModelService
from app.shared.locales import (
SUPPORTED_LOCALES,
TIMEOUT_CHAT_LABELS,
localized_string,
)
-from app.speech.services.page import SpeechModelPageService
-from app.speech.services.whisper_model import WhisperModelService
+from app.speech.queries.speech_page import SpeechModelPageService
+from app.theory.queries.task_page import ActiveTheoryTaskPage as TheoryPageService
from app.theory.schemas.page import TheoryPageContext
-from app.theory.services.page import TheoryPageService
@dataclass(frozen=True)
@@ -47,7 +49,7 @@ class SessionPageRender:
interview_active: bool = False
-class SessionPageService:
+class ActiveSessionPage:
"""Compose session shell and section contexts for the interview page."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
@@ -212,7 +214,7 @@ async def build_full_template_context(
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(
+ base = ActiveSessionPage.build_page_context(
interview,
config=config,
question_voice_enabled=bool(config and config.question_voice_enabled),
diff --git a/app/interview/repositories/interview.py b/app/interview/repositories/interview.py
index 3f96006..7aff256 100644
--- a/app/interview/repositories/interview.py
+++ b/app/interview/repositories/interview.py
@@ -5,7 +5,7 @@
Provides data access for interview session shell rows.
"""
-from sqlalchemy import func
+from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.interview.domain.entities import Interview as DomainInterview
@@ -45,16 +45,16 @@ def get(self, entity_id: str) -> Interview | None:
Returns:
Interview with theory section and tasks loaded, or None.
"""
- return (
- self._session.query(Interview)
+ stmt = (
+ select(Interview)
.options(
selectinload(Interview.theory_section).selectinload(
TheorySection.tasks
),
)
.filter_by(id=entity_id)
- .first()
)
+ return self._session.execute(stmt).scalar_one_or_none()
def get_aggregate(self, entity_id: str) -> DomainInterview | None:
"""Load a domain interview shell aggregate.
@@ -83,7 +83,11 @@ def list_recent_aggregates(self, limit: int = 20) -> list[DomainInterview]:
"""
sort_key = func.coalesce(Interview.completed_at, Interview.started_at)
rows = (
- self._session.query(Interview).order_by(sort_key.desc()).limit(limit).all()
+ self._session.execute(
+ select(Interview).order_by(sort_key.desc()).limit(limit)
+ )
+ .scalars()
+ .all()
)
return [interview_from_orm(row) for row in rows]
@@ -116,7 +120,7 @@ def save_aggregate(self, interview: DomainInterview) -> None:
Raises:
InterviewNotFoundError: If the session row no longer exists.
"""
- orm_interview = self.get(interview.id)
+ orm_interview = self._session.get(Interview, interview.id)
if orm_interview is None:
raise InterviewNotFoundError(interview.id)
diff --git a/app/interview/repositories/mappers.py b/app/interview/repositories/mappers.py
index bd30244..2b277ec 100644
--- a/app/interview/repositories/mappers.py
+++ b/app/interview/repositories/mappers.py
@@ -6,9 +6,10 @@
from datetime import UTC, datetime
import json
-from typing import Any
+from typing import Any, cast
from app.coding.domain.entities import CodingSection as DomainCodingSection
+from app.coding.repositories.mappers import coding_task_read_from_domain
from app.interview.domain.entities import Interview as DomainInterview
from app.interview.domain.entities import InterviewStatus
from app.interview.domain.serialization import (
@@ -16,6 +17,7 @@
parse_session_spec,
session_to_spec,
)
+from app.interview.domain.value_objects import SessionMode
from app.interview.schemas.interview import InterviewRead
from app.shared.infrastructure.models import Interview as OrmInterview
from app.theory.domain.entities import TheorySection as DomainTheorySection
@@ -39,6 +41,18 @@ def _question_ids_to_json(question_ids: tuple[str, ...]) -> str:
return json.dumps(list(question_ids), separators=(",", ":"))
+def _task_ids_to_json(task_ids: tuple[str, ...]) -> str:
+ """Serialize task IDs for persistence.
+
+ Args:
+ task_ids: Task IDs in display order.
+
+ Returns:
+ JSON array string.
+ """
+ return json.dumps(list(task_ids), separators=(",", ":"))
+
+
def interview_shell_from_orm(interview: OrmInterview) -> DomainInterview:
"""Map an ORM interview row to a shell domain aggregate.
@@ -54,7 +68,7 @@ def interview_shell_from_orm(interview: OrmInterview) -> DomainInterview:
return DomainInterview(
id=interview.id,
locale=interview.locale or "en",
- session_mode=interview.session_mode, # type: ignore[arg-type]
+ session_mode=cast(SessionMode, interview.session_mode),
selection=parse_session_spec(interview.selection_spec),
status=status,
overall_feedback=parse_overall_feedback(interview.overall_feedback),
@@ -73,37 +87,54 @@ def compose_interview_read(
Args:
shell: Interview shell aggregate.
theory: Theory section aggregate with tasks, if present.
- coding: Coding section aggregate, used for coding-only score fallback.
+ coding: Coding section aggregate with tasks, if present.
Returns:
Immutable InterviewRead without a resolved display score.
"""
- if theory is None:
- return InterviewRead(
- id=shell.id,
- status=shell.status,
- locale=shell.locale,
- selection_spec=session_to_spec(shell.selection),
- question_ids="[]",
- question_count=0,
- question_time_limit_seconds=None,
- answers=[],
- overall_feedback=shell.overall_feedback,
- started_at=shell.started_at,
- completed_at=shell.completed_at,
- )
-
- answers = [theory_task_read_from_domain(task) for task in theory.tasks]
+ answers = (
+ [theory_task_read_from_domain(task) for task in theory.tasks]
+ if theory is not None
+ else []
+ )
+ coding_tasks = (
+ [coding_task_read_from_domain(task) for task in coding.tasks]
+ if coding is not None
+ else []
+ )
+
+ # Prefer theory section metadata; fall back to coding when theory is absent.
+ locale = (
+ theory.locale
+ if theory is not None
+ else (coding.locale if coding is not None else shell.locale)
+ )
+ question_ids = (
+ _question_ids_to_json(theory.question_ids)
+ if theory is not None
+ else (_task_ids_to_json(coding.task_ids) if coding is not None else "[]")
+ )
+ question_count = (
+ theory.question_count
+ if theory is not None
+ else (coding.task_count if coding is not None else 0)
+ )
+ question_time_limit_seconds = (
+ theory.task_time_limit_seconds
+ if theory is not None
+ else (coding.task_time_limit_seconds if coding is not None else None)
+ )
return InterviewRead(
id=shell.id,
status=shell.status,
- locale=theory.locale,
+ locale=locale,
selection_spec=session_to_spec(shell.selection),
- question_ids=_question_ids_to_json(theory.question_ids),
- question_count=theory.question_count,
- question_time_limit_seconds=theory.task_time_limit_seconds,
+ question_ids=question_ids,
+ question_count=question_count,
+ question_time_limit_seconds=question_time_limit_seconds,
answers=answers,
+ coding_tasks=coding_tasks,
overall_feedback=shell.overall_feedback,
started_at=shell.started_at,
completed_at=shell.completed_at,
@@ -131,7 +162,7 @@ def interview_read_from_orm(
Args:
interview: SQLAlchemy Interview with optional theory section loaded.
- coding: Coding section aggregate for coding-only score fallback.
+ coding: Coding section aggregate for coding-only sessions.
Returns:
Composed interview read model.
diff --git a/app/interview/schemas/interview.py b/app/interview/schemas/interview.py
index c1b9dbf..f463899 100644
--- a/app/interview/schemas/interview.py
+++ b/app/interview/schemas/interview.py
@@ -7,6 +7,7 @@
from pydantic import BaseModel, ConfigDict, Field
+from app.coding.schemas.coding import CodingTaskRead
from app.theory.schemas.theory import TheoryTaskRead
AnswerRead = TheoryTaskRead
@@ -24,7 +25,8 @@ class InterviewRead(BaseModel):
question_ids: JSON list of question IDs in display order.
question_count: Number of questions in this interview.
question_time_limit_seconds: Per-round time limit, or None when disabled.
- answers: Answer rounds in display order.
+ answers: Theory answer rounds in display order.
+ coding_tasks: Coding task rounds in display order.
score: Final session score when completed.
overall_feedback: Parsed overall evaluation payload when completed.
started_at: When the session began.
@@ -41,6 +43,7 @@ class InterviewRead(BaseModel):
question_count: int
question_time_limit_seconds: int | None
answers: list[AnswerRead]
+ coding_tasks: list[CodingTaskRead] = []
score: int | None = None
overall_feedback: dict[str, Any] | None = None
started_at: datetime | None = None
diff --git a/app/interview/services/__init__.py b/app/interview/services/__init__.py
deleted file mode 100644
index 7aed1dc..0000000
--- a/app/interview/services/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# Copyright 2026 GrillKit Contributors
-# SPDX-License-Identifier: Apache-2.0
-"""Interview application services."""
diff --git a/app/interview/services/known_questions.py b/app/interview/services/known_questions.py
deleted file mode 100644
index ec18c46..0000000
--- a/app/interview/services/known_questions.py
+++ /dev/null
@@ -1,79 +0,0 @@
-# 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/read_model.py b/app/interview/services/read_model.py
deleted file mode 100644
index c0306fb..0000000
--- a/app/interview/services/read_model.py
+++ /dev/null
@@ -1,86 +0,0 @@
-# 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/section_prefetch.py b/app/interview/services/section_prefetch.py
deleted file mode 100644
index 83a701b..0000000
--- a/app/interview/services/section_prefetch.py
+++ /dev/null
@@ -1,63 +0,0 @@
-# Copyright 2026 GrillKit Contributors
-# SPDX-License-Identifier: Apache-2.0
-"""Background prefetch of section narrative feedback after phase completion."""
-
-from __future__ import annotations
-
-from collections.abc import Awaitable, Callable
-import logging
-from typing import Any
-
-from app.ai.base import AIProvider
-from app.platform.services.config import ConfigService
-
-logger = logging.getLogger(__name__)
-
-EvaluationPayload = tuple[dict[str, Any], int]
-
-
-async def prefetch_section_feedback(
- interview_id: str,
- *,
- section_name: str,
- should_prefetch: Callable[[], bool],
- evaluate: Callable[[AIProvider], Awaitable[EvaluationPayload | None]],
- persist: Callable[[dict[str, Any], int], None],
-) -> None:
- """Generate and persist cached section feedback when prerequisites are met.
-
- Args:
- interview_id: Parent interview UUID.
- section_name: Section kind label for log messages (``theory`` or ``coding``).
- should_prefetch: Returns True when feedback should be generated.
- evaluate: Async LLM evaluation returning payload dict and section score.
- persist: Saves feedback payload and section score when evaluation succeeds.
- """
- if not should_prefetch():
- return
-
- try:
- provider = ConfigService.create_provider_from_config()
- except Exception:
- logger.warning(
- "Skipping %s section prefetch for %s: provider not configured",
- section_name,
- interview_id,
- )
- return
-
- try:
- result = await evaluate(provider)
- except Exception:
- logger.exception(
- "%s section prefetch failed for interview %s",
- section_name.capitalize(),
- interview_id,
- )
- return
-
- if result is None:
- return
-
- payload, score = result
- persist(payload, score)
diff --git a/tests/theory/services/__init__.py b/app/interview/support/__init__.py
similarity index 100%
rename from tests/theory/services/__init__.py
rename to app/interview/support/__init__.py
diff --git a/app/interview/services/ai_errors.py b/app/interview/support/ai_errors.py
similarity index 100%
rename from app/interview/services/ai_errors.py
rename to app/interview/support/ai_errors.py
diff --git a/app/interview/services/bank_text.py b/app/interview/support/bank_text.py
similarity index 100%
rename from app/interview/services/bank_text.py
rename to app/interview/support/bank_text.py
diff --git a/app/interview/services/section_service_support.py b/app/interview/support/feedback_prefetch.py
similarity index 65%
rename from app/interview/services/section_service_support.py
rename to app/interview/support/feedback_prefetch.py
index 0fbe9fb..b4507b7 100644
--- a/app/interview/services/section_service_support.py
+++ b/app/interview/support/feedback_prefetch.py
@@ -1,21 +1,25 @@
# Copyright 2026 GrillKit Contributors
# SPDX-License-Identifier: Apache-2.0
-"""Shared helpers for theory and coding section service implementations."""
+"""Shared narrative feedback prefetch workflow for interview sections."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Coroutine
+import logging
from typing import Any, Protocol
from app.ai.base import AIProvider
+from app.interview.domain.session_phases import SectionEvaluationSummary
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 SectionEvaluationSummary
+from app.platform.domain.config import ConfigService
+
+logger = logging.getLogger(__name__)
+EvaluationPayload = tuple[dict[str, Any], int]
PersistFn = Callable[[dict[str, Any], int], None]
-EvaluateFn = Callable[[AIProvider], Awaitable[tuple[dict[str, object], int] | None]]
+EvaluateFn = Callable[[AIProvider], Awaitable[EvaluationPayload | None]]
ShouldPrefetchFn = Callable[[], bool]
EvaluateSectionFn = Callable[
[object, SectionEvaluationSummary, str, str],
@@ -25,6 +29,45 @@
SaveSectionFn = Callable[[InterviewUnitOfWork, Any], None]
+async def prefetch_section_feedback(
+ interview_id: str,
+ *,
+ section_name: str,
+ should_prefetch: Callable[[], bool],
+ evaluate: Callable[[AIProvider], Awaitable[EvaluationPayload | None]],
+ persist: Callable[[dict[str, Any], int], None],
+) -> None:
+ """Generate and persist cached section feedback when prerequisites are met."""
+ if not should_prefetch():
+ return
+
+ try:
+ provider = ConfigService.create_provider_from_config()
+ except Exception:
+ logger.warning(
+ "Skipping %s section prefetch for %s: provider not configured",
+ section_name,
+ interview_id,
+ )
+ return
+
+ try:
+ result = await evaluate(provider)
+ except Exception:
+ logger.exception(
+ "%s section prefetch failed for interview %s",
+ section_name.capitalize(),
+ interview_id,
+ )
+ return
+
+ if result is None:
+ return
+
+ payload, score = result
+ persist(payload, score)
+
+
class SectionFeedbackQuery(Protocol):
"""Minimal query surface needed for section feedback prefetch."""
@@ -37,14 +80,7 @@ def sources_text_for_section(self, interview_id: str) -> str: ...
def should_prefetch_feedback(section: object | None) -> bool:
- """Return whether section narrative feedback should be generated.
-
- Args:
- section: Loaded section aggregate, if any.
-
- Returns:
- True when the section is complete and feedback is not cached yet.
- """
+ """Return whether section narrative feedback should be generated."""
if section is None:
return False
if getattr(section, "section_feedback", None) is not None:
@@ -58,11 +94,7 @@ def should_prefetch_feedback(section: object | None) -> bool:
def schedule_feedback_prefetch(
run_prefetch: Callable[[], Coroutine[Any, Any, None]],
) -> None:
- """Schedule background section feedback prefetch when prerequisites pass.
-
- Args:
- run_prefetch: Coroutine factory for the prefetch workflow.
- """
+ """Schedule background section feedback prefetch when prerequisites pass."""
asyncio.create_task(run_prefetch())
@@ -74,15 +106,7 @@ async def run_feedback_prefetch(
evaluate: EvaluateFn,
persist: PersistFn,
) -> None:
- """Generate and persist cached section feedback when prerequisites are met.
-
- Args:
- interview_id: Parent interview UUID.
- section_name: Section kind label for log messages.
- should_prefetch: Returns True when feedback should be generated.
- evaluate: Async LLM evaluation returning payload dict and section score.
- persist: Saves feedback payload and section score when evaluation succeeds.
- """
+ """Generate and persist cached section feedback when prerequisites are met."""
await prefetch_section_feedback(
interview_id,
section_name=section_name,
@@ -106,17 +130,6 @@ def __init__(
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
@@ -126,45 +139,26 @@ def __init__(
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 whether section feedback should be generated."""
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.
- """
+ """Schedule background prefetch when prerequisites are met."""
if not self.should_prefetch(interview_id):
return
schedule_feedback_prefetch(
- lambda: SectionFeedbackPrefetch._run_in_background(
+ lambda: self._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.
- """
+ """Synchronously prefetch section feedback before session completion."""
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.
- """
+ """Generate and persist cached section feedback when prerequisites pass."""
await run_feedback_prefetch(
interview_id,
section_name=self._section_name,
@@ -182,15 +176,7 @@ async def _evaluate(
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.
- """
+ """Run section LLM evaluation for prefetch."""
summary = self._query.get_evaluation_summary(interview_id)
if summary is None or not summary.items:
return None
@@ -204,13 +190,7 @@ async def _evaluate(
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.
- """
+ """Persist prefetched section feedback when still absent."""
section = self._get_section(self._uow, interview_id)
if section is None or section.section_feedback is not None:
return
@@ -221,14 +201,7 @@ def persist(
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.
- """
+ """Load the section locale for evaluation prompts."""
section = self._get_section(self._uow, interview_id)
if section is None:
return "en"
@@ -240,13 +213,7 @@ def _persist_in_background(
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.
- """
+ """Persist prefetched feedback in a dedicated auto-commit unit of work."""
with InterviewUnitOfWork(auto_commit=True) as uow:
self._build(uow).persist(interview_id, payload, score)
@@ -256,11 +223,6 @@ async def _run_in_background(
*,
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.
- """
+ """Run section feedback prefetch in a dedicated unit of work."""
with InterviewUnitOfWork() as uow:
await build(uow).prefetch(interview_id)
diff --git a/app/interview/use_cases/__init__.py b/app/interview/use_cases/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/interview/services/phases.py b/app/interview/use_cases/advance_phase.py
similarity index 92%
rename from app/interview/services/phases.py
rename to app/interview/use_cases/advance_phase.py
index 2dc6189..01b0d68 100644
--- a/app/interview/services/phases.py
+++ b/app/interview/use_cases/advance_phase.py
@@ -2,15 +2,15 @@
# SPDX-License-Identifier: Apache-2.0
"""Session phase transitions between interview sections."""
-from app.interview.domain.value_objects import SectionKind
-from app.interview.repositories.uow import InterviewUnitOfWork
-from app.interview.services.sections import (
+from app.interview.domain.session_phases import (
phase_order_for_mode,
section_services,
)
+from app.interview.domain.value_objects import SectionKind
+from app.interview.repositories.uow import InterviewUnitOfWork
-class SessionPhaseOrchestrator:
+class AdvanceSessionPhase:
"""Coordinate phase completion hooks across interview sections."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
@@ -61,3 +61,7 @@ def notify_section_complete(
services = section_services(self._uow)
services[section_kind].on_phase_complete(interview_id)
services["coding"].activate_if_pending(interview_id)
+
+
+# Alias for backward compatibility
+SessionPhaseOrchestrator = AdvanceSessionPhase
diff --git a/app/interview/services/completion.py b/app/interview/use_cases/complete_session.py
similarity index 87%
rename from app/interview/services/completion.py
rename to app/interview/use_cases/complete_session.py
index 94b23df..d5be5fc 100644
--- a/app/interview/services/completion.py
+++ b/app/interview/use_cases/complete_session.py
@@ -10,30 +10,32 @@
import logging
from app.ai.base import AIProvider
-from app.coding.services.query import CodingQueryService
-from app.coding.services.section import CodingSectionService
-from app.interview.domain.exceptions import InterviewNotFoundError
-from app.interview.repositories.uow import InterviewUnitOfWork
-from app.interview.services.dashboard import DashboardBuilder
-from app.interview.services.evaluation_aggregator import (
+from app.coding.queries.section_state import CodingSectionState as CodingSectionService
+from app.coding.queries.section_summary import (
+ CodingSectionSummary as CodingQueryService,
+)
+from app.interview.domain.evaluation_aggregator import (
SessionEvaluationAggregator,
attach_session_score_breakdown,
)
-from app.interview.services.events import (
+from app.interview.domain.events import (
EvaluatingEvent,
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
-from app.theory.services.section import TheorySectionService
+from app.interview.domain.exceptions import InterviewNotFoundError
+from app.interview.domain.rules.selection import selection_sources_summary
+from app.interview.domain.session_evaluation import SessionEvaluatorService
+from app.interview.queries.dashboard import DashboardBuilder
+from app.interview.queries.projection import load_interview_read
+from app.interview.repositories.uow import InterviewUnitOfWork
+from app.theory.queries.loader import TheorySectionLoader as TheoryQueryService
+from app.theory.queries.section_state import TheorySectionState as TheorySectionService
logger = logging.getLogger(__name__)
-class SessionCompletionService:
+class CompleteInterviewSession:
"""Service for completing interview sessions."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
diff --git a/app/interview/services/creation.py b/app/interview/use_cases/create_session.py
similarity index 76%
rename from app/interview/services/creation.py
rename to app/interview/use_cases/create_session.py
index f254576..6e4819b 100644
--- a/app/interview/services/creation.py
+++ b/app/interview/use_cases/create_session.py
@@ -6,21 +6,24 @@
from uuid import uuid4
from app.coding.domain.entities import CodingSectionStatus
-from app.coding.services.creation import CodingSectionCreationService
-from app.coding.services.planning import build_coding_task_plan
+from app.coding.domain.task_planner import build_coding_task_plan
+from app.coding.use_cases.create_section import (
+ CreateCodingSection as CodingSectionCreationService,
+)
from app.interview.domain.entities import Interview
from app.interview.domain.exceptions import InterviewNotFoundError
-from app.interview.domain.value_objects import SessionMode, SessionSelection
-from app.interview.repositories.uow import InterviewUnitOfWork
-from app.interview.schemas.interview import InterviewRead
-from app.interview.services.known_questions import KnownQuestionsService
-from app.interview.services.read_model import load_interview_read
-from app.interview.services.sections import (
+from app.interview.domain.session_phases import (
is_first_user_facing_section,
phase_order_for_mode,
)
+from app.interview.domain.value_objects import SessionMode, SessionSelection
+from app.interview.queries.projection import load_interview_read
+from app.interview.repositories.uow import InterviewUnitOfWork
+from app.interview.schemas.interview import InterviewRead
from app.shared.locales import normalize_locale
-from app.theory.services.creation import TheorySectionCreationService
+from app.theory.use_cases.create_section import (
+ CreateTheorySection as TheorySectionCreationService,
+)
logger = logging.getLogger(__name__)
@@ -38,16 +41,25 @@ def _initial_coding_status(session_mode: SessionMode) -> CodingSectionStatus:
return "active" if order and order[0] == "coding" else "pending"
-class SessionCreationService:
+class CreateInterviewSession:
"""Orchestrates interview shell and section creation."""
- def __init__(self, uow: InterviewUnitOfWork) -> None:
- """Initialize with the active unit of work.
+ def __init__(
+ self,
+ uow: InterviewUnitOfWork,
+ create_theory_section: TheorySectionCreationService,
+ create_coding_section: CodingSectionCreationService,
+ ) -> None:
+ """Initialize with the active unit of work and section creators.
Args:
uow: Shared application unit of work for this workflow.
+ create_theory_section: DI-injected theory section creation service.
+ create_coding_section: DI-injected coding section creation service.
"""
self._uow = uow
+ self._create_theory = create_theory_section
+ self._create_coding = create_coding_section
def create_session(
self,
@@ -78,21 +90,20 @@ def create_session(
locale=locale,
)
- known_service = KnownQuestionsService(self._uow)
theory_excluded = (
- known_service.list_ids("theory")
+ self._uow.known_questions.list_ids("theory")
if session.exclude_known and session.theory.enabled
else frozenset()
)
coding_excluded = (
- known_service.list_ids("coding")
+ self._uow.known_questions.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(
+ self._create_theory.create(
interview_id,
selection=session.theory_selection,
locale=locale,
@@ -111,7 +122,7 @@ def create_session(
locale=locale,
excluded_ids=coding_excluded,
)
- CodingSectionCreationService(self._uow).create(
+ self._create_coding.create(
interview_id,
selection=session.coding_selection,
locale=locale,
diff --git a/app/main.py b/app/main.py
index 0ea67a1..4526fa5 100644
--- a/app/main.py
+++ b/app/main.py
@@ -20,7 +20,7 @@
from app.interview.api import routes as interview_router
from app.interview.api import setup as setup_router
from app.platform.api import config as config_router
-from app.platform.services.speech_runtime import SpeechRuntimeCoordinator
+from app.platform.domain.speech_runtime import SpeechRuntimeCoordinator
from app.question_voice.api import routes as question_voice_router
from app.shared.infrastructure.database import run_migrations
from app.shared.paths import STATIC_DIR
@@ -29,6 +29,23 @@
from app.theory.api import routes as theory_router
+def _get_app_version() -> str:
+ """Return the application version from package metadata.
+
+ Falls back to a hardcoded value when the package is not installed
+ (e.g., during development).
+
+ Returns:
+ Semantic version string.
+ """
+ try:
+ from importlib.metadata import version
+
+ return version("grillkit")
+ except Exception:
+ return "2026.6.12"
+
+
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler.
@@ -50,7 +67,7 @@ def create_app() -> FastAPI:
app = FastAPI(
title="GrillKit",
description="AI Interview Trainer",
- version="2026.6.12",
+ version=_get_app_version(),
lifespan=lifespan,
)
diff --git a/app/platform/api/config.py b/app/platform/api/config.py
index 8771ba4..32ae076 100644
--- a/app/platform/api/config.py
+++ b/app/platform/api/config.py
@@ -9,16 +9,16 @@
from pydantic import ValidationError
from app.platform.api.deps import ConfigServiceDep
+from app.platform.domain.config import AppConfig, ConfigService
+from app.platform.domain.llm_catalog import LLMCatalogService
+from app.platform.domain.speech_runtime import SpeechRuntimeCoordinator
+from app.platform.queries.config_form import ConfigFormService
+from app.platform.queries.platform_page import ConfigPageService
from app.platform.schemas import NewLLMModel
-from app.platform.services.config import AppConfig, ConfigService
-from app.platform.services.config_form import ConfigFormService
-from app.platform.services.llm_catalog import LLMCatalogService
-from app.platform.services.page import ConfigPageService
-from app.platform.services.speech_runtime import SpeechRuntimeCoordinator
+from app.shared.infrastructure.gateways.whisper_model import WhisperModelService
from app.shared.locales import DEFAULT_LOCALE
from app.shared.speech_models import DEFAULT_SPEECH_MODEL_SIZE
from app.speech.api.deps import WhisperModelServiceDep
-from app.speech.services.whisper_model import WhisperModelService
from app.templating import templates
router = APIRouter(prefix="/config", tags=["config"])
diff --git a/app/platform/api/deps.py b/app/platform/api/deps.py
index b7fa77c..d4997be 100644
--- a/app/platform/api/deps.py
+++ b/app/platform/api/deps.py
@@ -6,7 +6,7 @@
from fastapi import Depends
-from app.platform.services.config import ConfigService
+from app.platform.domain.config import ConfigService
def get_config_service() -> type[ConfigService]:
diff --git a/app/platform/domain/__init__.py b/app/platform/domain/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/platform/services/config.py b/app/platform/domain/config.py
similarity index 90%
rename from app/platform/services/config.py
rename to app/platform/domain/config.py
index ed0e982..d42b07f 100644
--- a/app/platform/services/config.py
+++ b/app/platform/domain/config.py
@@ -11,9 +11,10 @@
from typing import Any
from app.ai.audio_probe import minimal_wav_bytes
-from app.ai.base import AIProvider
+from app.ai.base import AIProvider, AudioCapableProvider
from app.ai.factory import ProviderFactory
-from app.platform.services.llm_catalog import LLMCatalogService
+from app.platform.domain.llm_catalog import LLMCatalogService
+from app.platform.schemas import AppConfigRead
from app.shared.locales import DEFAULT_LOCALE, normalize_locale
from app.shared.paths import CONFIG_PATH, DATA_DIR
from app.shared.speech_models import (
@@ -25,7 +26,7 @@
default_voice_for_locale,
normalize_tts_voice_id,
)
-from app.speech.services.readiness import WhisperReadinessService
+from app.speech.queries.readiness import WhisperReadinessService
MASKED_API_KEY_PLACEHOLDER = "***"
@@ -154,14 +155,6 @@ def resolve_api_key_from_form(
return entry.api_key
return None
- def effective(self) -> "AppConfig":
- """Return configuration with catalog defaults and runtime overrides applied.
-
- Returns:
- Copy of this config ready for provider creation and connection tests.
- """
- return ConfigService.resolve_effective_config(self)
-
class ConfigService:
"""Service for managing provider configuration."""
@@ -315,6 +308,11 @@ async def test_audio_connection(config: AppConfig) -> tuple[bool, str]:
api_key=probe.api_key,
timeout=probe.timeout,
)
+ if not isinstance(provider, AudioCapableProvider):
+ return (
+ False,
+ "Provider does not support audio input.",
+ )
is_valid = await provider.probe_audio_input(minimal_wav_bytes())
if is_valid:
return True, "Audio connection successful"
@@ -395,7 +393,7 @@ def create_provider_from_config() -> AIProvider:
config = ConfigService.get_config()
if not config:
raise ValueError("No configuration found")
- effective = config.effective()
+ effective = ConfigService.resolve_effective_config(config)
return ProviderFactory.from_config(
api_type=effective.provider_type,
base_url=effective.base_url,
@@ -403,3 +401,32 @@ def create_provider_from_config() -> AIProvider:
api_key=effective.api_key,
timeout=effective.timeout,
)
+
+
+def app_config_read_from(
+ config: AppConfig,
+ *,
+ mask_secret: bool = False,
+) -> AppConfigRead:
+ """Map a ``AppConfig`` service entity to a template read model.
+
+ Args:
+ config: Loaded or submitted provider configuration.
+ mask_secret: Whether to mask the API key for display.
+
+ Returns:
+ Immutable read model for templates.
+ """
+ data = config.to_dict(mask_secret=mask_secret)
+ return AppConfigRead(
+ provider_type=str(data["provider_type"]),
+ base_url=str(data["base_url"]),
+ model=str(data["model"]),
+ api_key=data.get("api_key"),
+ timeout=float(data["timeout"]),
+ locale=str(data["locale"]),
+ speech_model_size=str(data["speech_model_size"]),
+ question_voice_enabled=bool(data["question_voice_enabled"]),
+ tts_voice_id=str(data["tts_voice_id"]),
+ llm_preset_id=data.get("llm_preset_id"),
+ )
diff --git a/app/platform/services/llm_catalog.py b/app/platform/domain/llm_catalog.py
similarity index 98%
rename from app/platform/services/llm_catalog.py
rename to app/platform/domain/llm_catalog.py
index a8a3228..61e3265 100644
--- a/app/platform/services/llm_catalog.py
+++ b/app/platform/domain/llm_catalog.py
@@ -2,13 +2,17 @@
# SPDX-License-Identifier: Apache-2.0
"""Load and persist the interview LLM model catalog from ``data/llm_models.json``."""
+from __future__ import annotations
+
import json
-from typing import Any
+from typing import TYPE_CHECKING, Any
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
+if TYPE_CHECKING:
+ from app.platform.schemas import NewLLMModel
+
class LLMCatalogService:
"""Read and update the user LLM model catalog."""
diff --git a/app/platform/domain/rules/__init__.py b/app/platform/domain/rules/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/platform/services/speech_runtime.py b/app/platform/domain/speech_runtime.py
similarity index 88%
rename from app/platform/services/speech_runtime.py
rename to app/platform/domain/speech_runtime.py
index 2984afe..82bbbe2 100644
--- a/app/platform/services/speech_runtime.py
+++ b/app/platform/domain/speech_runtime.py
@@ -4,15 +4,15 @@
from fastapi import FastAPI
-from app.platform.services.config import AppConfig, ConfigService
-from app.platform.services.speech_settings import (
+from app.platform.domain.config import AppConfig, ConfigService
+from app.platform.domain.speech_settings import (
question_voice_settings_from_config,
speech_settings_from_config,
)
-from app.question_voice.services.piper_runtime import PiperRuntime
-from app.question_voice.services.piper_storage import is_voice_installed
-from app.speech.services.whisper_runtime import WhisperRuntime
-from app.speech.services.whisper_storage import is_installed
+from app.shared.infrastructure.gateways.piper import PiperGateway as PiperRuntime
+from app.shared.infrastructure.gateways.piper_storage import is_voice_installed
+from app.shared.infrastructure.gateways.whisper import WhisperGateway as WhisperRuntime
+from app.shared.infrastructure.gateways.whisper_storage import is_installed
class SpeechRuntimeCoordinator:
diff --git a/app/platform/services/speech_settings.py b/app/platform/domain/speech_settings.py
similarity index 96%
rename from app/platform/services/speech_settings.py
rename to app/platform/domain/speech_settings.py
index 0c93464..d1b6991 100644
--- a/app/platform/services/speech_settings.py
+++ b/app/platform/domain/speech_settings.py
@@ -6,7 +6,7 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
- from app.platform.services.config import AppConfig
+ from app.platform.domain.config import AppConfig
@dataclass(frozen=True)
diff --git a/app/platform/queries/__init__.py b/app/platform/queries/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/platform/services/config_form.py b/app/platform/queries/config_form.py
similarity index 96%
rename from app/platform/services/config_form.py
rename to app/platform/queries/config_form.py
index 24482ea..9fbc057 100644
--- a/app/platform/services/config_form.py
+++ b/app/platform/queries/config_form.py
@@ -3,8 +3,8 @@
"""Configuration form parsing and connection testing."""
from app.ai.llm_models import normalize_model_id
-from app.platform.services.config import AppConfig, ConfigService
-from app.platform.services.llm_catalog import LLMCatalogService
+from app.platform.domain.config import AppConfig, ConfigService
+from app.platform.domain.llm_catalog import LLMCatalogService
from app.shared.locales import normalize_locale
from app.shared.speech_models import normalize_speech_model_size
from app.shared.tts_voices import default_voice_for_locale
diff --git a/app/platform/services/llm_page.py b/app/platform/queries/llm_page.py
similarity index 92%
rename from app/platform/services/llm_page.py
rename to app/platform/queries/llm_page.py
index 716068c..32788cf 100644
--- a/app/platform/services/llm_page.py
+++ b/app/platform/queries/llm_page.py
@@ -2,9 +2,9 @@
# SPDX-License-Identifier: Apache-2.0
"""LLM catalog read models for configuration templates."""
+from app.platform.domain.config import AppConfig
+from app.platform.domain.llm_catalog import LLMCatalogService
from app.platform.schemas import LLMPresetOptionRead
-from app.platform.services.config import AppConfig
-from app.platform.services.llm_catalog import LLMCatalogService
class LLMPageService:
diff --git a/app/platform/services/page.py b/app/platform/queries/platform_page.py
similarity index 87%
rename from app/platform/services/page.py
rename to app/platform/queries/platform_page.py
index e55f37a..1334d52 100644
--- a/app/platform/services/page.py
+++ b/app/platform/queries/platform_page.py
@@ -2,17 +2,16 @@
# SPDX-License-Identifier: Apache-2.0
"""Configuration page context builder."""
+from app.platform.domain.config import AppConfig, app_config_read_from
+from app.platform.queries.llm_page import LLMPageService
from app.platform.schemas import (
ConfigPageContext,
- app_config_read_from,
speech_model_specs_for_config,
)
-from app.platform.services.config import AppConfig
-from app.platform.services.llm_page import LLMPageService
-from app.question_voice.services.page import QuestionVoicePageService
+from app.question_voice.queries.voice_page import QuestionVoicePageService
+from app.shared.infrastructure.gateways.whisper_model import WhisperModelService
from app.shared.locales import SUPPORTED_LOCALES
-from app.speech.services.page import SpeechModelPageService
-from app.speech.services.whisper_model import WhisperModelService
+from app.speech.queries.speech_page import SpeechModelPageService
class ConfigPageService:
diff --git a/app/platform/schemas.py b/app/platform/schemas.py
index 4125130..03885ab 100644
--- a/app/platform/schemas.py
+++ b/app/platform/schemas.py
@@ -2,8 +2,6 @@
# SPDX-License-Identifier: Apache-2.0
"""Pydantic models and mappers for the platform feature."""
-from typing import TYPE_CHECKING
-
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.question_voice.schemas import PiperVoiceStatusRead
@@ -13,9 +11,6 @@
)
from app.speech.schemas.status import WhisperModelStatusRead
-if TYPE_CHECKING:
- from app.platform.services.config import AppConfig
-
class AppConfigRead(BaseModel):
"""Saved provider settings for configuration UI templates.
@@ -186,35 +181,6 @@ def _normalize_api_key(cls, value: object) -> str | None:
return str(value).strip() or None
-def app_config_read_from(
- config: "AppConfig",
- *,
- mask_secret: bool = False,
-) -> AppConfigRead:
- """Map a ``AppConfig`` service entity to a template read model.
-
- Args:
- config: Loaded or submitted provider configuration.
- mask_secret: Whether to mask the API key for display.
-
- Returns:
- Immutable read model for templates.
- """
- data = config.to_dict(mask_secret=mask_secret)
- return AppConfigRead(
- provider_type=str(data["provider_type"]),
- base_url=str(data["base_url"]),
- model=str(data["model"]),
- api_key=data.get("api_key"),
- timeout=float(data["timeout"]),
- locale=str(data["locale"]),
- speech_model_size=str(data["speech_model_size"]),
- question_voice_enabled=bool(data["question_voice_enabled"]),
- tts_voice_id=str(data["tts_voice_id"]),
- llm_preset_id=data.get("llm_preset_id"),
- )
-
-
def speech_model_specs_for_config() -> dict[str, SpeechModelSpecRead]:
"""Build speech model spec read models keyed by size for ``config.html``.
diff --git a/app/platform/services/__init__.py b/app/platform/services/__init__.py
deleted file mode 100644
index 22cd84a..0000000
--- a/app/platform/services/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# Copyright 2026 GrillKit Contributors
-# SPDX-License-Identifier: Apache-2.0
-"""Platform application services."""
diff --git a/app/platform/support/__init__.py b/app/platform/support/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/platform/use_cases/__init__.py b/app/platform/use_cases/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/question_voice/api/deps.py b/app/question_voice/api/deps.py
index 4f86521..11c7a92 100644
--- a/app/question_voice/api/deps.py
+++ b/app/question_voice/api/deps.py
@@ -6,7 +6,7 @@
from fastapi import Depends
-from app.question_voice.services.piper_voice import PiperVoiceService
+from app.shared.infrastructure.gateways.piper_voice import PiperVoiceService
def get_piper_voice_service() -> type[PiperVoiceService]:
diff --git a/app/question_voice/api/routes.py b/app/question_voice/api/routes.py
index 4bd643c..62c5440 100644
--- a/app/question_voice/api/routes.py
+++ b/app/question_voice/api/routes.py
@@ -7,7 +7,9 @@
from app.platform.api.deps import ConfigServiceDep
from app.question_voice.api.deps import PiperVoiceServiceDep
-from app.question_voice.services.status import QuestionVoiceStatusService
+from app.question_voice.queries.voice_status import (
+ VoiceStatus as QuestionVoiceStatusService,
+)
from app.shared.api.negotiated_response import negotiated_response
router = APIRouter(prefix="/speech", tags=["question-voice"])
diff --git a/app/question_voice/queries/__init__.py b/app/question_voice/queries/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/question_voice/services/page.py b/app/question_voice/queries/voice_page.py
similarity index 74%
rename from app/question_voice/services/page.py
rename to app/question_voice/queries/voice_page.py
index d385d21..6d0b40d 100644
--- a/app/question_voice/services/page.py
+++ b/app/question_voice/queries/voice_page.py
@@ -2,13 +2,13 @@
# SPDX-License-Identifier: Apache-2.0
"""Question-voice page context builder for HTML templates."""
-from app.platform.services.config import AppConfig
+from app.platform.domain.config import AppConfig
+from app.question_voice.queries.voice_status import VoiceStatus
from app.question_voice.schemas import PiperVoiceStatusRead, QuestionVoicePageContext
-from app.question_voice.services.piper_voice import PiperVoiceService
-from app.question_voice.services.status import QuestionVoiceStatusService
+from app.shared.infrastructure.gateways.piper_voice import PiperVoiceService
-class QuestionVoicePageService:
+class VoicePage:
"""Build template context for Piper question-voice status."""
@staticmethod
@@ -30,11 +30,13 @@ async def build_page_context(
Returns:
Frozen page context for templates.
"""
- voice_id, locale = QuestionVoiceStatusService.resolve_tts_target(config)
+ voice_id, locale = VoiceStatus.resolve_tts_target(config)
status = PiperVoiceService.get_status(voice_id, locale)
show_banner = config is not None and config.question_voice_enabled
return QuestionVoicePageContext(
tts_voice_status=status,
- tts_voice_banner=show_banner
- and QuestionVoicePageService._show_voice_banner(status),
+ tts_voice_banner=show_banner and VoicePage._show_voice_banner(status),
)
+
+
+QuestionVoicePageService = VoicePage
diff --git a/app/question_voice/services/status.py b/app/question_voice/queries/voice_status.py
similarity index 92%
rename from app/question_voice/services/status.py
rename to app/question_voice/queries/voice_status.py
index 3a2b1ed..bf96c75 100644
--- a/app/question_voice/services/status.py
+++ b/app/question_voice/queries/voice_status.py
@@ -2,9 +2,9 @@
# SPDX-License-Identifier: Apache-2.0
"""Piper voice status resolution for API and templates."""
-from app.platform.services.config import AppConfig
+from app.platform.domain.config import AppConfig
from app.question_voice.schemas import PiperVoiceStatusRead
-from app.question_voice.services.piper_voice import PiperVoiceService
+from app.shared.infrastructure.gateways.piper_voice import PiperVoiceService
from app.shared.locales import DEFAULT_LOCALE, normalize_locale
from app.shared.tts_voices import (
default_voice_for_locale,
@@ -12,7 +12,7 @@
)
-class QuestionVoiceStatusService:
+class VoiceStatus:
"""Resolve Piper voice status from provider configuration."""
@staticmethod
@@ -67,7 +67,7 @@ def resolve_for_config(
Returns:
Voice status read model and enabled flag for API consumers.
"""
- resolved_voice, resolved_locale = QuestionVoiceStatusService.resolve_tts_target(
+ resolved_voice, resolved_locale = VoiceStatus.resolve_tts_target(
config,
locale=locale,
voice_id=voice_id,
diff --git a/app/question_voice/services/__init__.py b/app/question_voice/services/__init__.py
deleted file mode 100644
index 0421926..0000000
--- a/app/question_voice/services/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# Copyright 2026 GrillKit Contributors
-# SPDX-License-Identifier: Apache-2.0
-"""Question-voice use-case services."""
diff --git a/app/question_voice/services/question_audio.py b/app/question_voice/services/question_audio.py
deleted file mode 100644
index d078fea..0000000
--- a/app/question_voice/services/question_audio.py
+++ /dev/null
@@ -1,82 +0,0 @@
-# Copyright 2026 GrillKit Contributors
-# SPDX-License-Identifier: Apache-2.0
-"""Question-audio orchestration for interview sessions."""
-
-from pathlib import Path
-
-from app.interview.schemas.interview import AnswerRead, InterviewRead
-from app.interview.services.query import InterviewQuery
-from app.platform.services.config import ConfigService
-from app.platform.services.speech_settings import question_voice_settings_from_config
-from app.question_voice.services.tts_cache import TtsCacheService
-from app.question_voice.services.tts_exceptions import QuestionVoiceDisabledError
-
-
-async def get_question_audio_path(
- interview_id: str,
- answer_id: int | None = None,
-) -> Path:
- """Return a WAV path for interview question audio.
-
- Args:
- interview_id: Interview session UUID.
- answer_id: Optional answer row id; defaults to the current question.
-
- Returns:
- Path to a cached or newly synthesized WAV file.
-
- Raises:
- QuestionVoiceDisabledError: When voice is disabled in config.
- InterviewNotFoundError: When the interview does not exist.
- InterviewNotActiveError: When the session is not active.
- ValueError: When no suitable unanswered answer exists.
- QuestionVoiceSynthesisError: When synthesis cannot complete.
- """
- config = ConfigService.get_config()
- if config is None:
- raise QuestionVoiceDisabledError()
- voice_settings = question_voice_settings_from_config(config)
- if not voice_settings.enabled:
- raise QuestionVoiceDisabledError()
-
- interview = InterviewQuery.get_active_interview_or_raise(interview_id)
- answer = _resolve_answer(interview, answer_id)
- return await TtsCacheService.get_or_fetch(
- voice_settings.voice_id,
- interview.locale,
- answer.question_text,
- )
-
-
-def _resolve_answer(
- interview: InterviewRead,
- answer_id: int | None,
-) -> AnswerRead:
- """Pick the target answer row for audio synthesis.
-
- Args:
- interview: Interview read model with answers.
- answer_id: Optional answer primary key; defaults to first unanswered.
-
- Returns:
- Answer read model with ``question_text`` to synthesize.
-
- Raises:
- ValueError: If no suitable unanswered answer exists.
- """
- if answer_id is not None:
- for answer in interview.answers:
- if answer.id == answer_id:
- if answer.answer_text is not None:
- raise ValueError("Answer is already submitted")
- if not answer.question_text.strip():
- raise ValueError("Question text is empty")
- return answer
- raise ValueError(f"Answer not found in interview: {answer_id}")
-
- current = InterviewQuery.get_current_unanswered(interview)
- if current is None:
- raise ValueError("No unanswered question in this interview")
- if not current.question_text.strip():
- raise ValueError("Question text is empty")
- return current
diff --git a/app/question_voice/support/__init__.py b/app/question_voice/support/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/question_voice/use_cases/__init__.py b/app/question_voice/use_cases/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/question_voice/use_cases/generate_question_audio.py b/app/question_voice/use_cases/generate_question_audio.py
new file mode 100644
index 0000000..2978f13
--- /dev/null
+++ b/app/question_voice/use_cases/generate_question_audio.py
@@ -0,0 +1,94 @@
+# Copyright 2026 GrillKit Contributors
+# SPDX-License-Identifier: Apache-2.0
+"""Generate question audio for interview sessions."""
+
+from pathlib import Path
+
+from app.interview.queries.loader import InterviewQuery
+from app.interview.schemas.interview import AnswerRead, InterviewRead
+from app.platform.domain.config import ConfigService
+from app.platform.domain.speech_settings import question_voice_settings_from_config
+from app.shared.infrastructure.gateways.tts_cache import TtsCacheService
+from app.shared.infrastructure.gateways.tts_exceptions import QuestionVoiceDisabledError
+
+
+async def get_question_audio_path(
+ interview_id: str,
+ answer_id: int | None = None,
+) -> Path:
+ """Convenience wrapper for :meth:`GenerateQuestionAudio.execute`."""
+ return await GenerateQuestionAudio.execute(interview_id, answer_id)
+
+
+class GenerateQuestionAudio:
+ """Orchestrate question-audio synthesis for interview sessions."""
+
+ @staticmethod
+ async def execute(
+ interview_id: str,
+ answer_id: int | None = None,
+ ) -> Path:
+ """Return a WAV path for interview question audio.
+
+ Args:
+ interview_id: Interview session UUID.
+ answer_id: Optional answer row id; defaults to the current question.
+
+ Returns:
+ Path to a cached or newly synthesized WAV file.
+
+ Raises:
+ QuestionVoiceDisabledError: When voice is disabled in config.
+ InterviewNotFoundError: When the interview does not exist.
+ InterviewNotActiveError: When the session is not active.
+ ValueError: When no suitable unanswered answer exists.
+ QuestionVoiceSynthesisError: When synthesis cannot complete.
+ """
+ config = ConfigService.get_config()
+ if config is None:
+ raise QuestionVoiceDisabledError()
+ voice_settings = question_voice_settings_from_config(config)
+ if not voice_settings.enabled:
+ raise QuestionVoiceDisabledError()
+
+ interview = InterviewQuery.get_active_interview_or_raise(interview_id)
+ answer = _resolve_answer(interview, answer_id)
+ return await TtsCacheService.get_or_fetch(
+ voice_settings.voice_id,
+ interview.locale,
+ answer.question_text,
+ )
+
+
+def _resolve_answer(
+ interview: InterviewRead,
+ answer_id: int | None,
+) -> AnswerRead:
+ """Pick the target answer row for audio synthesis.
+
+ Args:
+ interview: Interview read model with answers.
+ answer_id: Optional answer primary key; defaults to first unanswered.
+
+ Returns:
+ Answer read model with ``question_text`` to synthesize.
+
+ Raises:
+ ValueError: If no suitable unanswered answer exists.
+ """
+ if answer_id is not None:
+ for answer in interview.answers:
+ if answer.id == answer_id:
+ if answer.answer_text is not None:
+ raise ValueError("Answer is already submitted")
+ if not answer.question_text.strip():
+ raise ValueError("Question text is empty")
+ return answer
+ raise ValueError(f"Answer not found in interview: {answer_id}")
+
+ current = InterviewQuery.get_current_unanswered(interview)
+ if current is None:
+ raise ValueError("No unanswered question in this interview")
+ if not current.question_text.strip():
+ raise ValueError("Question text is empty")
+ return current
diff --git a/app/shared/api/negotiated_response.py b/app/shared/api/negotiated_response.py
index 4a87795..d022131 100644
--- a/app/shared/api/negotiated_response.py
+++ b/app/shared/api/negotiated_response.py
@@ -1,10 +1,10 @@
# Copyright 2026 GrillKit Contributors
# SPDX-License-Identifier: Apache-2.0
-"""Content negotiation helpers for status and download endpoints."""
+"""Content negotiation and WebSocket transport helpers."""
from typing import Any
-from fastapi import Request
+from fastapi import Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse, Response
from app.templating import templates
@@ -39,3 +39,20 @@ def negotiated_response(
template_name,
template_context,
)
+
+
+async def safe_send_json(websocket: WebSocket, message: dict[str, Any]) -> bool:
+ """Send a JSON message, returning False if the client already disconnected.
+
+ Args:
+ websocket: Active WebSocket connection.
+ message: Payload to send.
+
+ Returns:
+ True if the message was sent, False if the socket is closed.
+ """
+ try:
+ await websocket.send_json(message)
+ return True
+ except (WebSocketDisconnect, RuntimeError):
+ return False
diff --git a/app/shared/application/uow_deps.py b/app/shared/application/uow_deps.py
index 871fbbc..aa569e8 100644
--- a/app/shared/application/uow_deps.py
+++ b/app/shared/application/uow_deps.py
@@ -9,6 +9,8 @@
from app.interview.repositories.uow import InterviewUnitOfWork
+__all__ = ["UoWDep", "UoWAutoCommitDep", "get_uow", "get_uow_auto_commit"]
+
def get_uow() -> Iterator[InterviewUnitOfWork]:
"""Yield an application Unit of Work for the request scope.
diff --git a/app/shared/infrastructure/database.py b/app/shared/infrastructure/database.py
index 8bbf9fc..1ca34bb 100644
--- a/app/shared/infrastructure/database.py
+++ b/app/shared/infrastructure/database.py
@@ -7,6 +7,7 @@
"""
import os
+import sqlite3
from alembic.config import Config
from sqlalchemy import create_engine, event
@@ -24,7 +25,7 @@
def _configure_sqlite_connection(
- dbapi_connection: object,
+ dbapi_connection: sqlite3.Connection,
_connection_record: object,
) -> None:
"""Enable WAL mode and a busy timeout for concurrent SQLite access.
@@ -33,7 +34,7 @@ def _configure_sqlite_connection(
dbapi_connection: DB-API connection from the SQLAlchemy pool.
_connection_record: SQLAlchemy connection record (unused).
"""
- cursor = dbapi_connection.cursor() # type: ignore[attr-defined]
+ cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=30000")
cursor.close()
@@ -43,6 +44,11 @@ def _configure_sqlite_connection(
engine = create_engine(
DATABASE_URL,
echo=False,
+ # ``check_same_thread=False`` is safe here because:
+ # 1. SQLAlchemy's ``SessionLocal`` manages a single-threaded session pool.
+ # 2. The ``UnitOfWork`` context manager scopes sessions to one request.
+ # 3. FastAPI's async endpoints run in a thread pool, so each request
+ # gets its own thread and session.
connect_args={"check_same_thread": False, "timeout": 30.0},
)
event.listen(engine, "connect", _configure_sqlite_connection)
diff --git a/app/shared/infrastructure/gateways/__init__.py b/app/shared/infrastructure/gateways/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/platform/services/ai_context.py b/app/shared/infrastructure/gateways/ai_context.py
similarity index 93%
rename from app/platform/services/ai_context.py
rename to app/shared/infrastructure/gateways/ai_context.py
index 7a4223e..a3910f5 100644
--- a/app/platform/services/ai_context.py
+++ b/app/shared/infrastructure/gateways/ai_context.py
@@ -7,7 +7,7 @@
import logging
from app.ai.base import AIProvider
-from app.platform.services.config import ConfigService
+from app.platform.domain.config import ConfigService
logger = logging.getLogger(__name__)
diff --git a/app/coding/services/judge0_client.py b/app/shared/infrastructure/gateways/judge0.py
similarity index 96%
rename from app/coding/services/judge0_client.py
rename to app/shared/infrastructure/gateways/judge0.py
index 18a9bc5..a3d9876 100644
--- a/app/coding/services/judge0_client.py
+++ b/app/shared/infrastructure/gateways/judge0.py
@@ -8,7 +8,7 @@
import httpx
-from app.coding.services.judge0_config import (
+from app.shared.infrastructure.gateways.judge0_config import (
_DEFAULT_CPU_TIME_LIMIT_SECONDS,
_DEFAULT_MEMORY_LIMIT_KB,
judge0_auth_token,
@@ -54,7 +54,7 @@ def duration_ms(self) -> int | None:
return None
-class Judge0Client:
+class Judge0Gateway:
"""Thin wrapper around Judge0 CE HTTP endpoints."""
def __init__(
@@ -76,11 +76,11 @@ def __init__(
self._timeout_seconds = timeout_seconds
@classmethod
- def from_env(cls) -> Judge0Client:
+ def from_env(cls) -> Judge0Gateway:
"""Build a client from environment variables.
Returns:
- Configured ``Judge0Client`` instance.
+ Configured ``Judge0Gateway`` instance.
"""
return cls()
diff --git a/app/coding/services/judge0_config.py b/app/shared/infrastructure/gateways/judge0_config.py
similarity index 100%
rename from app/coding/services/judge0_config.py
rename to app/shared/infrastructure/gateways/judge0_config.py
diff --git a/app/question_voice/services/piper_runtime.py b/app/shared/infrastructure/gateways/piper.py
similarity index 95%
rename from app/question_voice/services/piper_runtime.py
rename to app/shared/infrastructure/gateways/piper.py
index c9bb8e5..14354a1 100644
--- a/app/question_voice/services/piper_runtime.py
+++ b/app/shared/infrastructure/gateways/piper.py
@@ -8,7 +8,10 @@
from typing import TYPE_CHECKING
import wave
-from app.question_voice.services.piper_storage import is_voice_installed, voice_dir
+from app.shared.infrastructure.gateways.piper_storage import (
+ is_voice_installed,
+ voice_dir,
+)
from app.shared.infrastructure.in_process_runtime import InProcessArtifactRuntime
from app.shared.tts_voices import normalize_tts_voice_id
@@ -18,7 +21,7 @@
logger = logging.getLogger(__name__)
-class PiperRuntime(InProcessArtifactRuntime):
+class PiperGateway(InProcessArtifactRuntime):
"""Hold the loaded :class:`PiperVoice` for the configured question voice."""
@classmethod
diff --git a/app/question_voice/services/piper_storage.py b/app/shared/infrastructure/gateways/piper_storage.py
similarity index 100%
rename from app/question_voice/services/piper_storage.py
rename to app/shared/infrastructure/gateways/piper_storage.py
diff --git a/app/question_voice/services/piper_voice.py b/app/shared/infrastructure/gateways/piper_voice.py
similarity index 97%
rename from app/question_voice/services/piper_voice.py
rename to app/shared/infrastructure/gateways/piper_voice.py
index d03de05..4a0333e 100644
--- a/app/question_voice/services/piper_voice.py
+++ b/app/shared/infrastructure/gateways/piper_voice.py
@@ -10,14 +10,14 @@
from huggingface_hub import hf_hub_download
from app.question_voice.schemas import PiperVoiceStatusRead
-from app.question_voice.services.piper_runtime import PiperRuntime
-from app.question_voice.services.piper_storage import (
+from app.shared.infrastructure.artifact_download import ArtifactDownloadService
+from app.shared.infrastructure.artifact_status import ArtifactStatusBuilder
+from app.shared.infrastructure.gateways.piper import PiperGateway as PiperRuntime
+from app.shared.infrastructure.gateways.piper_storage import (
is_valid_voice_dir,
is_voice_installed,
voice_dir,
)
-from app.shared.infrastructure.artifact_download import ArtifactDownloadService
-from app.shared.infrastructure.artifact_status import ArtifactStatusBuilder
from app.shared.infrastructure.hf_download_progress import (
copy_file_with_progress,
hf_progress_tqdm_factory,
diff --git a/app/question_voice/services/tts_cache.py b/app/shared/infrastructure/gateways/tts_cache.py
similarity index 91%
rename from app/question_voice/services/tts_cache.py
rename to app/shared/infrastructure/gateways/tts_cache.py
index 40182b9..72e3824 100644
--- a/app/question_voice/services/tts_cache.py
+++ b/app/shared/infrastructure/gateways/tts_cache.py
@@ -6,9 +6,11 @@
from pathlib import Path
import re
-from app.question_voice.services.piper_runtime import PiperRuntime
-from app.question_voice.services.piper_storage import is_voice_installed
-from app.question_voice.services.tts_exceptions import QuestionVoiceSynthesisError
+from app.shared.infrastructure.gateways.piper import PiperGateway as PiperRuntime
+from app.shared.infrastructure.gateways.piper_storage import is_voice_installed
+from app.shared.infrastructure.gateways.tts_exceptions import (
+ QuestionVoiceSynthesisError,
+)
from app.shared.locales import normalize_locale
from app.shared.paths import TTS_CACHE_DIR
diff --git a/app/question_voice/services/tts_exceptions.py b/app/shared/infrastructure/gateways/tts_exceptions.py
similarity index 100%
rename from app/question_voice/services/tts_exceptions.py
rename to app/shared/infrastructure/gateways/tts_exceptions.py
diff --git a/app/speech/services/whisper_runtime.py b/app/shared/infrastructure/gateways/whisper.py
similarity index 94%
rename from app/speech/services/whisper_runtime.py
rename to app/shared/infrastructure/gateways/whisper.py
index 5a7dd6d..da3fd39 100644
--- a/app/speech/services/whisper_runtime.py
+++ b/app/shared/infrastructure/gateways/whisper.py
@@ -11,9 +11,9 @@
from app.ai.faster_whisper_transcriber import FasterWhisperTranscriber
from app.ai.speech_transcriber import SpeechTranscriber
+from app.shared.infrastructure.gateways.whisper_storage import is_installed, model_dir
from app.shared.infrastructure.in_process_runtime import InProcessArtifactRuntime
from app.shared.speech_models import normalize_speech_model_size
-from app.speech.services.whisper_storage import is_installed, model_dir
logger = logging.getLogger(__name__)
@@ -21,7 +21,7 @@
WHISPER_COMPUTE_TYPE = os.environ.get("WHISPER_COMPUTE_TYPE", "int8")
-class WhisperRuntime(InProcessArtifactRuntime):
+class WhisperGateway(InProcessArtifactRuntime):
"""Hold the loaded :class:`SpeechTranscriber` and sync it to ``app.state``."""
_app: ClassVar[FastAPI | None] = None
@@ -94,3 +94,6 @@ def _sync_app_state(cls) -> None:
if app is None:
return
app.state.speech_transcriber = cls._artifact
+
+
+WhisperRuntime = WhisperGateway
diff --git a/app/speech/services/whisper_model.py b/app/shared/infrastructure/gateways/whisper_model.py
similarity index 97%
rename from app/speech/services/whisper_model.py
rename to app/shared/infrastructure/gateways/whisper_model.py
index 77f3c88..06c1a63 100644
--- a/app/speech/services/whisper_model.py
+++ b/app/shared/infrastructure/gateways/whisper_model.py
@@ -11,6 +11,12 @@
from app.shared.infrastructure.artifact_download import ArtifactDownloadService
from app.shared.infrastructure.artifact_status import ArtifactStatusBuilder
+from app.shared.infrastructure.gateways.whisper import WhisperGateway as WhisperRuntime
+from app.shared.infrastructure.gateways.whisper_storage import (
+ is_installed,
+ is_valid_model_dir,
+ model_dir,
+)
from app.shared.infrastructure.hf_download_progress import hf_progress_tqdm_factory
from app.shared.infrastructure.model_download import (
cleanup_staging_dir,
@@ -25,12 +31,6 @@
speech_model_spec_for_size,
)
from app.speech.schemas.status import WhisperModelStatusRead
-from app.speech.services.whisper_runtime import WhisperRuntime
-from app.speech.services.whisper_storage import (
- is_installed,
- is_valid_model_dir,
- model_dir,
-)
class WhisperModelService(ArtifactDownloadService):
diff --git a/app/speech/services/whisper_storage.py b/app/shared/infrastructure/gateways/whisper_storage.py
similarity index 100%
rename from app/speech/services/whisper_storage.py
rename to app/shared/infrastructure/gateways/whisper_storage.py
diff --git a/app/shared/infrastructure/models.py b/app/shared/infrastructure/models.py
index 0bb810f..69f3ad4 100644
--- a/app/shared/infrastructure/models.py
+++ b/app/shared/infrastructure/models.py
@@ -294,7 +294,7 @@ class CodeRunAttempt(Base):
Integer,
ForeignKey("coding_tasks.id", ondelete="CASCADE"),
)
- attempt_no: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ attempt_no: Mapped[int] = mapped_column(Integer, nullable=False)
source_code: Mapped[str] = mapped_column(Text)
language: Mapped[str] = mapped_column(String)
status: Mapped[str] = mapped_column(String)
diff --git a/app/shared/infrastructure/uow.py b/app/shared/infrastructure/uow.py
index 7fee9c2..96fba63 100644
--- a/app/shared/infrastructure/uow.py
+++ b/app/shared/infrastructure/uow.py
@@ -9,6 +9,7 @@
from __future__ import annotations
+from types import TracebackType
from typing import Self
from sqlalchemy.orm import Session
@@ -19,6 +20,8 @@
class UnitOfWork:
"""Base unit of work — session and transaction lifecycle only.
+ Subclasses expose lazy repository accessors bound to ``self.session``.
+
Usage::
with UnitOfWork() as uow:
@@ -72,7 +75,7 @@ def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
- exc_tb: object,
+ exc_tb: TracebackType | None,
) -> None:
try:
if exc_type is None and self._auto_commit:
diff --git a/app/shared/json_parser.py b/app/shared/json_parser.py
new file mode 100644
index 0000000..ccf0d8f
--- /dev/null
+++ b/app/shared/json_parser.py
@@ -0,0 +1,145 @@
+# Copyright 2026 GrillKit Contributors
+# SPDX-License-Identifier: Apache-2.0
+"""Shared JSON parsing and schema helpers for AI structured evaluation."""
+
+import json
+from typing import Any
+
+from pydantic import BaseModel, ValidationError
+
+_JSON_SCHEMA_TYPE_NAMES = frozenset(
+ {"object", "string", "array", "integer", "number", "boolean", "null"}
+)
+
+_JSON_SCHEMA_METADATA_KEYS = frozenset(
+ {
+ "$schema",
+ "$ref",
+ "additionalProperties",
+ "allOf",
+ "anyOf",
+ "definitions",
+ "description",
+ "enum",
+ "format",
+ "items",
+ "oneOf",
+ "properties",
+ "required",
+ "title",
+ "type",
+ }
+)
+
+
+def looks_like_json_schema_fragment(data: Any) -> bool:
+ """Return True if parsed JSON looks like schema metadata, not instance data.
+
+ Some models echo JSON Schema fragments (e.g. ``{"type": "object",
+ "description": "..."}``) instead of filling the schema with values.
+
+ Args:
+ data: Parsed JSON value from the model response.
+
+ Returns:
+ True when the payload is likely a schema description, not data.
+ """
+ if not isinstance(data, dict):
+ return False
+
+ keys = frozenset(data.keys())
+ if not keys:
+ return False
+
+ schema_markers = keys & {
+ "$schema",
+ "$ref",
+ "properties",
+ "required",
+ "additionalProperties",
+ "allOf",
+ "anyOf",
+ "oneOf",
+ "items",
+ "definitions",
+ }
+ if schema_markers:
+ return True
+
+ type_value = data.get("type")
+ return (
+ isinstance(type_value, str)
+ and type_value in _JSON_SCHEMA_TYPE_NAMES
+ and keys <= _JSON_SCHEMA_METADATA_KEYS
+ )
+
+
+def build_prompt_with_schema(instructions: str, model_class: type[BaseModel]) -> str:
+ """Build a system prompt with the Pydantic model's JSON schema embedded.
+
+ Args:
+ instructions: Natural language instructions for the AI.
+ model_class: Pydantic model class whose schema to embed.
+
+ Returns:
+ Complete system prompt string.
+ """
+ schema = model_class.model_json_schema()
+ schema_str = json.dumps(schema, indent=2)
+ return (
+ f"{instructions}\n\n"
+ "The JSON block below describes the REQUIRED SHAPE of your response only. "
+ "Return a single JSON object with real field VALUES (scores, feedback text, "
+ "lists, nested objects). "
+ 'Do NOT return JSON Schema metadata: no top-level "type", "properties", '
+ '"required", "description", "$schema", or property-definition objects.\n\n'
+ f"Required response shape (for reference — fill with data, do not echo):\n"
+ f"{schema_str}\n\n"
+ "Keep string fields concise so the JSON fits in one response "
+ "(feedback at most 4 sentences; follow-up questions one sentence).\n"
+ "Return ONLY one valid JSON object, no markdown fences, no extra text."
+ )
+
+
+def parse_json_response[T: BaseModel](content: str, model: type[T]) -> T:
+ """Parse AI JSON response and validate against a Pydantic model.
+
+ Strips optional markdown code fences before parsing.
+
+ Args:
+ content: Raw JSON string from the AI.
+ model: Pydantic model class to validate against.
+
+ Returns:
+ Validated Pydantic model instance.
+
+ Raises:
+ ValueError: If JSON is invalid or doesn't match the model.
+ """
+ content = content.strip()
+
+ if content.startswith("```"):
+ lines = content.splitlines()
+ if lines[0].startswith("```"):
+ lines = lines[1:]
+ if lines and lines[-1].strip() == "```":
+ lines = lines[:-1]
+ content = "\n".join(lines).strip()
+
+ try:
+ data = json.loads(content)
+ except json.JSONDecodeError as e:
+ raise ValueError(f"AI returned invalid JSON: {e}") from e
+
+ if looks_like_json_schema_fragment(data):
+ raise ValueError(
+ "AI returned JSON Schema metadata instead of evaluation data "
+ "(e.g. an object with only 'type' and 'description'). "
+ "Return a data object with field values such as overall_feedback, "
+ "not a schema definition."
+ )
+
+ try:
+ return model.model_validate(data)
+ except ValidationError as e:
+ raise ValueError(f"AI response validation failed: {e}") from e
diff --git a/app/shared/repositories/base.py b/app/shared/repositories/base.py
index e6c8de0..77b25eb 100644
--- a/app/shared/repositories/base.py
+++ b/app/shared/repositories/base.py
@@ -10,6 +10,7 @@ class that eliminates boilerplate for common CRUD operations.
from abc import ABC, abstractmethod
from collections.abc import Sequence
+from sqlalchemy import select
from sqlalchemy.orm import Session
# ---------------------------------------------------------------------------
@@ -102,4 +103,4 @@ def list_all(self) -> Sequence[T]:
Returns:
A list of model instances.
"""
- return self._session.query(self._model).all()
+ return self._session.execute(select(self._model)).scalars().all()
diff --git a/app/shared/section.py b/app/shared/section.py
new file mode 100644
index 0000000..32e92d8
--- /dev/null
+++ b/app/shared/section.py
@@ -0,0 +1,222 @@
+# Copyright 2026 GrillKit Contributors
+# SPDX-License-Identifier: Apache-2.0
+"""Shared base aggregate for theory and coding sections."""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import replace
+from datetime import UTC, datetime
+from typing import Any, Protocol, Self, TypeVar
+
+
+class _TaskLike(Protocol):
+ """Protocol for task rows consumed by ``Section``."""
+
+ @property
+ def id(self) -> int: ...
+
+ @property
+ def round(self) -> int: ...
+
+ @property
+ def score(self) -> int | None: ...
+
+ @property
+ def started_at(self) -> datetime | None: ...
+
+
+TTask = TypeVar("TTask", bound=_TaskLike)
+
+
+class Section[TTask: _TaskLike](ABC):
+ """Base aggregate for theory and coding sections.
+
+ Encapsulates the ~12 identical methods that were previously duplicated
+ between ``TheorySection`` and ``CodingSection``.
+
+ Concrete subclasses MUST provide (via ``@dataclass`` fields or class vars):
+ - ``status`` — section status literal (``str``)
+ - ``tasks`` — ordered task rows (``tuple[TTask, ...]``)
+ - ``task_time_limit_seconds`` — per-task time limit (``int | None``)
+ - ``interview_id`` — parent interview UUID (``str``)
+ - ``section_feedback`` — cached section evaluation (``dict[str, object] | None``)
+ - ``section_score`` — cached aggregated score (``int | None``)
+ - ``MAX_SCORE_PER_ROUND`` — max points per round (``int``)
+ """
+
+ # ------------------------------------------------------------------
+ # Abstract hooks expected from concrete subclasses
+ # ------------------------------------------------------------------
+ @property
+ @abstractmethod
+ def MAX_SCORE_PER_ROUND(self) -> int: # noqa: N802
+ """Maximum points achievable per round."""
+
+ @property
+ @abstractmethod
+ def _completion_field_name(self) -> str:
+ """Attribute name that indicates a task is done (e.g. ``answer_text``)."""
+
+ @property
+ @abstractmethod
+ def _task_id_field(self) -> str:
+ """Attribute name that holds the bank task/question ID."""
+
+ @abstractmethod
+ def _task_not_found_error(self, task_id: str, round_num: int) -> Exception:
+ """Build the domain-specific exception for a missing task."""
+
+ @abstractmethod
+ def _timeout_replacement_fields(self, task: TTask, feedback: str) -> dict[str, Any]:
+ """Return the ``replace()`` kwargs for a timed-out task."""
+
+ @abstractmethod
+ def _create_follow_up(
+ self,
+ base: TTask,
+ next_round: int,
+ prompt_text: str,
+ **kwargs: Any,
+ ) -> TTask:
+ """Instantiate a new follow-up task row."""
+
+ # ------------------------------------------------------------------
+ # Shared behaviour — relies on subclass attributes listed above
+ # ------------------------------------------------------------------
+ def with_activated(self: Self) -> Self:
+ """Promote ``pending`` status to ``active``.
+
+ Returns:
+ Updated aggregate when status was ``pending``, otherwise ``self``.
+ """
+ if self.status != "pending": # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ return self
+ return replace(self, status="active") # type: ignore[type-var] # pyright: ignore[reportArgumentType]
+
+ def find_first_pending(self) -> TTask | None:
+ """Return the first task whose completion field is still ``None``."""
+ for task in self.tasks: # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ if getattr(task, self._completion_field_name) is None:
+ return task # type: ignore[no-any-return]
+ return None
+
+ def is_complete(self) -> bool:
+ """Return whether every task in this section has been completed."""
+ return bool(self.tasks) and self.find_first_pending() is None # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+
+ def total_score(self) -> int:
+ """Sum scores from all completed task rounds."""
+ return sum(
+ (task.score or 0) # type: ignore[misc]
+ for task in self.tasks # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ if getattr(task, self._completion_field_name) is not None
+ )
+
+ def max_score(self) -> int:
+ """Compute maximum achievable score for completed rounds."""
+ completed = sum(
+ 1 # type: ignore[misc]
+ for task in self.tasks # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ if getattr(task, self._completion_field_name) is not None
+ )
+ return self.MAX_SCORE_PER_ROUND * completed
+
+ def with_cached_section_feedback(
+ self: Self,
+ feedback: dict[str, object],
+ *,
+ section_score: int,
+ ) -> Self:
+ """Return aggregate with prefetched section feedback when not cached."""
+ if self.section_feedback is not None: # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ return self
+ return replace( # type: ignore[type-var]
+ self, # pyright: ignore[reportArgumentType]
+ section_feedback=feedback,
+ section_score=section_score,
+ )
+
+ def start_timer_for_task(
+ self: Self, task_row_id: int, when: datetime | None = None
+ ) -> Self:
+ """Start the per-task timer on a task when the section has a limit."""
+ if self.task_time_limit_seconds is None: # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ return self
+ started_at = when or datetime.now(UTC)
+ tasks = tuple(
+ replace(task, started_at=started_at)
+ if task.id == task_row_id and task.started_at is None
+ else task
+ for task in self.tasks # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ )
+ return replace(self, tasks=tasks) # type: ignore[type-var] # pyright: ignore[reportArgumentType]
+
+ def with_evaluation(
+ self: Self,
+ task_id: str,
+ round_num: int,
+ score: int,
+ feedback: str,
+ ) -> Self:
+ """Return aggregate with AI score and feedback on one task round."""
+ target = self.find_task(task_id, round_num)
+ tasks = tuple(
+ replace(task, score=score, feedback=feedback)
+ if task.id == target.id
+ else task
+ for task in self.tasks # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ )
+ return replace(self, tasks=tasks) # type: ignore[type-var] # pyright: ignore[reportArgumentType]
+
+ def max_round_for_task(self, task_id: str) -> int:
+ """Return the highest follow-up round number for a bank task ID."""
+ rounds = [
+ task.round
+ for task in self.tasks # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ if getattr(task, self._task_id_field) == task_id
+ ]
+ return max(rounds) if rounds else 0
+
+ def with_follow_up(
+ self: Self,
+ task_id: str,
+ prompt_text: str,
+ **kwargs: Any,
+ ) -> tuple[Self, TTask]:
+ """Return aggregate with a new pending follow-up task row."""
+ base = self.find_task(task_id, 0)
+ next_round = self.max_round_for_task(task_id) + 1
+ follow_up = self._create_follow_up(base, next_round, prompt_text, **kwargs)
+ return replace(self, tasks=self.tasks + (follow_up,)), follow_up # type: ignore[attr-defined,type-var] # pyright: ignore[reportAttributeAccessIssue,reportArgumentType]
+
+ def find_next_pending_after(self, current_index: int) -> TTask | None:
+ """Return the next pending task after a position in the task list."""
+ for task in self.tasks[current_index + 1 :]: # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ if getattr(task, self._completion_field_name) is None:
+ return task # type: ignore[no-any-return]
+ return None
+
+ def find_task(self, task_id: str, round_num: int) -> TTask:
+ """Return the task row for a bank task and follow-up round.
+
+ Raises:
+ Exception: Domain-specific ``*TaskNotFoundError`` when no row matches.
+ """
+ for task in self.tasks: # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ if (
+ getattr(task, self._task_id_field) == task_id
+ and task.round == round_num
+ ):
+ return task # type: ignore[no-any-return]
+ raise self._task_not_found_error(task_id, round_num)
+
+ def with_timed_out_round(self: Self, task_row_id: int, feedback: str) -> Self:
+ """Return aggregate with a task round marked as timed out."""
+ tasks = tuple(
+ replace(task, **self._timeout_replacement_fields(task, feedback))
+ if task.id == task_row_id
+ else task
+ for task in self.tasks # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ )
+ return replace(self, tasks=tasks) # type: ignore[type-var] # pyright: ignore[reportArgumentType]
diff --git a/app/shared/structured_evaluation.py b/app/shared/structured_evaluation.py
index 66b7e66..8203e13 100644
--- a/app/shared/structured_evaluation.py
+++ b/app/shared/structured_evaluation.py
@@ -4,9 +4,12 @@
from __future__ import annotations
+from collections.abc import Awaitable, Callable
+
from pydantic import BaseModel
-from app.ai.base import AIProvider, GenerationResult, Message
+from app.ai.base import AIProvider, AudioCapableProvider, GenerationResult, Message
+from app.shared.json_parser import parse_json_response
_MAX_RETRY_TOKENS = 4096
_COMPACT_JSON_RETRY_NOTE = (
@@ -35,10 +38,10 @@ def _should_retry_structured_parse(
return "invalid JSON" in str(exc)
-async def _parse_generation_result[T: BaseModel](
+async def _parse_generation_result[TModel: BaseModel](
result: GenerationResult,
- response_model: type[T],
-) -> T:
+ response_model: type[TModel],
+) -> TModel:
"""Parse one provider result into a validated structured model.
Args:
@@ -51,22 +54,63 @@ async def _parse_generation_result[T: BaseModel](
Raises:
ValueError: If the response body is empty or invalid JSON.
"""
- from app.theory.services.evaluator.prompts import parse_json_response
-
content = result.content.strip()
if not content:
raise ValueError("AI returned empty response")
return parse_json_response(content, response_model)
-async def generate_and_parse_json_response[T: BaseModel](
+async def _generate_with_retry[TModel: BaseModel](
+ response_model: type[TModel],
+ max_tokens: int,
+ generate: Callable[[int, int], Awaitable[GenerationResult]],
+) -> TModel:
+ """Generate and parse with a retry on truncation or invalid JSON.
+
+ Runs the ``generate`` callable up to two times: first with the initial
+ budget, then once more with a doubled budget capped at ``_MAX_RETRY_TOKENS``
+ when the model response was truncated or returned invalid JSON.
+
+ Args:
+ response_model: Pydantic model for parsed JSON output.
+ max_tokens: Initial maximum tokens for the model response.
+ generate: Async callable accepting ``(attempt, budget)`` and returning
+ the raw provider result. ``attempt`` is 0 for the initial call and
+ 1 for the retry.
+
+ Returns:
+ Parsed evaluation model instance.
+
+ Raises:
+ ValueError: If AI response is invalid or connection fails after retries.
+ """
+ token_budgets = [max_tokens, min(max_tokens * 2, _MAX_RETRY_TOKENS)]
+ last_error: ValueError | None = None
+
+ for attempt, budget in enumerate(token_budgets):
+ result = await generate(attempt, budget)
+ try:
+ return await _parse_generation_result(result, response_model)
+ except ValueError as exc:
+ last_error = exc
+ if attempt < len(token_budgets) - 1 and _should_retry_structured_parse(
+ exc, result.finish_reason
+ ):
+ continue
+ raise
+ # last_error always set here because token_budgets has at least one entry
+ assert last_error is not None
+ raise last_error
+
+
+async def generate_and_parse_json_response[TModel: BaseModel](
provider: AIProvider,
*,
messages: list[Message],
- response_model: type[T],
+ response_model: type[TModel],
max_tokens: int = 2000,
temperature: float = 0.1,
-) -> T:
+) -> TModel:
"""Generate JSON from chat messages and parse it with retry on truncation.
Args:
@@ -82,57 +126,40 @@ async def generate_and_parse_json_response[T: BaseModel](
Raises:
ValueError: If AI response is invalid or connection fails after retries.
"""
- token_budgets = [max_tokens, min(max_tokens * 2, _MAX_RETRY_TOKENS)]
- last_error: ValueError | None = None
base_system_prompt = (
messages[0].content if messages and messages[0].role == "system" else None
)
- for attempt, budget in enumerate(token_budgets):
+ async def _generate(attempt: int, budget: int) -> GenerationResult:
attempt_messages = list(messages)
if attempt > 0 and base_system_prompt is not None:
attempt_messages[0] = Message(
role="system",
content=base_system_prompt + _COMPACT_JSON_RETRY_NOTE,
)
-
- result = await provider.generate(
+ return await provider.generate(
messages=attempt_messages,
temperature=temperature,
max_tokens=budget,
)
- try:
- return await _parse_generation_result(result, response_model)
- except ValueError as exc:
- last_error = exc
- if attempt < len(token_budgets) - 1 and _should_retry_structured_parse(
- exc, result.finish_reason
- ):
- continue
- raise
-
- if last_error is not None:
- raise last_error
- raise ValueError("AI returned empty response")
+ return await _generate_with_retry(response_model, max_tokens, _generate)
-async def evaluate_with_schema[T: BaseModel](
+async def evaluate_with_schema[TModel: BaseModel](
provider: AIProvider,
*,
- locale: str,
- instructions: str,
- response_model: type[T],
+ system_prompt: str,
+ response_model: type[TModel],
user_text: str,
audio_wav: bytes | None = None,
max_tokens: int = 2000,
-) -> T:
+) -> TModel:
"""Run a structured evaluation via text or multimodal generation.
Args:
provider: Configured AI provider instance.
- locale: Locale for AI feedback.
- instructions: Evaluator instruction template constant.
+ system_prompt: Ready-to-use system prompt (already includes schema hint).
response_model: Pydantic model for parsed JSON output.
user_text: User message text (full content for text mode; context for audio).
audio_wav: Optional WAV bytes for multimodal evaluation.
@@ -144,50 +171,28 @@ async def evaluate_with_schema[T: BaseModel](
Raises:
ValueError: If AI response is invalid or connection fails.
"""
- from app.theory.services.evaluator.prompts import (
- build_evaluator_instructions,
- build_prompt_with_schema,
- )
-
- system_prompt = build_prompt_with_schema(
- build_evaluator_instructions(locale, instructions),
- response_model,
- )
- token_budgets = [max_tokens, min(max_tokens * 2, _MAX_RETRY_TOKENS)]
- last_error: ValueError | None = None
- for attempt, budget in enumerate(token_budgets):
+ async def _generate(attempt: int, budget: int) -> GenerationResult:
prompt = system_prompt
if attempt > 0:
prompt = system_prompt + _COMPACT_JSON_RETRY_NOTE
messages = [Message(role="system", content=prompt)]
if audio_wav is not None:
- result = await provider.generate_with_audio(
+ if not isinstance(provider, AudioCapableProvider):
+ raise ValueError("Provider does not support audio input")
+ return await provider.generate_with_audio(
messages=messages,
audio_wav=audio_wav,
user_text=user_text,
temperature=0.0,
max_tokens=budget,
)
- else:
- messages.append(Message(role="user", content=user_text))
- result = await provider.generate(
- messages=messages,
- temperature=0.0,
- max_tokens=budget,
- )
-
- try:
- return await _parse_generation_result(result, response_model)
- except ValueError as exc:
- last_error = exc
- if attempt < len(token_budgets) - 1 and _should_retry_structured_parse(
- exc, result.finish_reason
- ):
- continue
- raise
+ messages.append(Message(role="user", content=user_text))
+ return await provider.generate(
+ messages=messages,
+ temperature=0.0,
+ max_tokens=budget,
+ )
- if last_error is not None:
- raise last_error
- raise ValueError("AI returned empty response")
+ return await _generate_with_retry(response_model, max_tokens, _generate)
diff --git a/app/shared/timed_task.py b/app/shared/timed_task.py
new file mode 100644
index 0000000..fc8cc78
--- /dev/null
+++ b/app/shared/timed_task.py
@@ -0,0 +1,107 @@
+# Copyright 2026 GrillKit Contributors
+# SPDX-License-Identifier: Apache-2.0
+"""Shared timed-task mixin for theory and coding rounds."""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from app.shared.task_timer import (
+ DEFAULT_TIMEOUT_GRACE_SECONDS,
+)
+from app.shared.task_timer import (
+ is_timer_expired as shared_is_timer_expired,
+)
+from app.shared.task_timer import (
+ remaining_seconds as shared_remaining_seconds,
+)
+from app.shared.task_timer import (
+ timer_deadline as shared_timer_deadline,
+)
+
+
+class TimedTask:
+ """Mixin base for a task round that supports a per-task timer.
+
+ Subclasses must declare ``id``, ``started_at``, and ``created_at``
+ fields with compatible types, and may override ``TIMEOUT_GRACE_SECONDS``.
+ """
+
+ TIMEOUT_GRACE_SECONDS: int = DEFAULT_TIMEOUT_GRACE_SECONDS
+
+ def timer_deadline(self, limit_seconds: int) -> datetime:
+ """Compute the absolute deadline for this timed task round.
+
+ Args:
+ limit_seconds: Allowed duration in seconds.
+
+ Returns:
+ Timezone-aware deadline timestamp.
+
+ Raises:
+ ValueError: If the round has no ``started_at`` timestamp.
+ """
+ if self.started_at is None: # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ raise ValueError(f"{self.__class__.__name__} round has no started_at")
+ return shared_timer_deadline(
+ self.started_at, # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ limit_seconds,
+ label=self.__class__.__name__,
+ )
+
+ def is_timer_expired(
+ self,
+ limit_seconds: int | None,
+ now: datetime | None = None,
+ *,
+ grace_seconds: int = TIMEOUT_GRACE_SECONDS,
+ ) -> bool:
+ """Return whether the per-task timer has elapsed.
+
+ Args:
+ limit_seconds: Configured limit for the section (None disables timer).
+ now: Current time (defaults to UTC now).
+ grace_seconds: Extra seconds allowed for network delay on timeout submit.
+
+ Returns:
+ True if the timer is enabled and the deadline plus grace has passed.
+ """
+ return shared_is_timer_expired(
+ self.started_at, # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ limit_seconds,
+ now,
+ grace_seconds=grace_seconds,
+ )
+
+ def remaining_seconds(
+ self,
+ limit_seconds: int | None,
+ now: datetime | None = None,
+ ) -> int | None:
+ """Return whole seconds left on the timer, or None if disabled.
+
+ Args:
+ limit_seconds: Configured limit for the section.
+ now: Current time (defaults to UTC now).
+
+ Returns:
+ Non-negative seconds remaining, or None when the timer is off.
+ """
+ return shared_remaining_seconds(
+ self.started_at, # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ 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: # type: ignore[attr-defined] # pyright: ignore[reportAttributeAccessIssue]
+ 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
+ )
diff --git a/app/speech/api/deps.py b/app/speech/api/deps.py
index e793271..a4b798b 100644
--- a/app/speech/api/deps.py
+++ b/app/speech/api/deps.py
@@ -6,7 +6,7 @@
from fastapi import Depends
-from app.speech.services.whisper_model import WhisperModelService
+from app.shared.infrastructure.gateways.whisper_model import WhisperModelService
def get_whisper_model_service() -> type[WhisperModelService]:
diff --git a/app/speech/api/dictation.py b/app/speech/api/dictation.py
index 45b7fd5..21c49af 100644
--- a/app/speech/api/dictation.py
+++ b/app/speech/api/dictation.py
@@ -8,7 +8,7 @@
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
-from app.interview.services.query import InterviewQuery
+from app.interview.queries.loader import InterviewQuery
from app.platform.api.deps import ConfigServiceDep
from app.speech.api.dictation_protocol import (
DICTATION_CLIENT_START,
@@ -18,11 +18,11 @@
DICTATION_SERVER_READY,
dictation_message,
)
-from app.speech.services.dictation import DictationSession
-from app.speech.services.transcriber_resolver import (
+from app.speech.domain.transcriber_resolver import (
resolve_speech_transcriber,
speech_transcriber_unavailable_message,
)
+from app.speech.use_cases.dictation import DictationSession
logger = logging.getLogger(__name__)
diff --git a/app/speech/api/routes.py b/app/speech/api/routes.py
index 3268855..100e305 100644
--- a/app/speech/api/routes.py
+++ b/app/speech/api/routes.py
@@ -6,7 +6,7 @@
from fastapi.responses import JSONResponse, Response
from app.platform.api.deps import ConfigServiceDep
-from app.platform.services.config import AppConfig
+from app.platform.domain.config import AppConfig
from app.shared.api.negotiated_response import negotiated_response
from app.shared.locales import DEFAULT_LOCALE, normalize_locale
from app.shared.speech_models import (
diff --git a/app/speech/services/__init__.py b/app/speech/domain/__init__.py
similarity index 68%
rename from app/speech/services/__init__.py
rename to app/speech/domain/__init__.py
index 5e079ef..ba70adf 100644
--- a/app/speech/services/__init__.py
+++ b/app/speech/domain/__init__.py
@@ -1,3 +1,3 @@
# Copyright 2026 GrillKit Contributors
# SPDX-License-Identifier: Apache-2.0
-"""Speech application services."""
+"""Speech domain layer."""
diff --git a/app/speech/domain/rules/__init__.py b/app/speech/domain/rules/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/speech/services/transcriber_resolver.py b/app/speech/domain/transcriber_resolver.py
similarity index 89%
rename from app/speech/services/transcriber_resolver.py
rename to app/speech/domain/transcriber_resolver.py
index 3faff6d..43f2778 100644
--- a/app/speech/services/transcriber_resolver.py
+++ b/app/speech/domain/transcriber_resolver.py
@@ -7,9 +7,9 @@
from starlette.applications import Starlette
from app.ai.speech_transcriber import SpeechTranscriber
-from app.platform.services.config import ConfigService
-from app.speech.services.whisper_runtime import WhisperRuntime
-from app.speech.services.whisper_storage import is_installed
+from app.platform.domain.config import ConfigService
+from app.shared.infrastructure.gateways.whisper import WhisperRuntime
+from app.shared.infrastructure.gateways.whisper_storage import is_installed
_UNLOADED_MESSAGE = "Speech model is not loaded. Download it in Configuration."
diff --git a/app/speech/queries/__init__.py b/app/speech/queries/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/speech/services/readiness.py b/app/speech/queries/readiness.py
similarity index 82%
rename from app/speech/services/readiness.py
rename to app/speech/queries/readiness.py
index 6467906..10696e9 100644
--- a/app/speech/services/readiness.py
+++ b/app/speech/queries/readiness.py
@@ -2,11 +2,11 @@
# SPDX-License-Identifier: Apache-2.0
"""Speech runtime readiness checks for cross-feature orchestration."""
+from app.shared.infrastructure.gateways.whisper_storage import is_installed
from app.shared.speech_models import normalize_speech_model_size
-from app.speech.services.whisper_storage import is_installed
-class WhisperReadinessService:
+class SpeechModelReadiness:
"""Check whether configured Whisper artifacts are available on disk."""
@staticmethod
@@ -21,3 +21,6 @@ def is_model_installed(speech_model_size: str) -> bool:
"""
size = normalize_speech_model_size(speech_model_size)
return is_installed(size)
+
+
+WhisperReadinessService = SpeechModelReadiness
diff --git a/app/speech/services/page.py b/app/speech/queries/speech_page.py
similarity index 90%
rename from app/speech/services/page.py
rename to app/speech/queries/speech_page.py
index 9056132..a2e52cc 100644
--- a/app/speech/services/page.py
+++ b/app/speech/queries/speech_page.py
@@ -2,11 +2,11 @@
# SPDX-License-Identifier: Apache-2.0
"""Speech model page context builder for HTML templates."""
-from app.platform.services.config import AppConfig
+from app.platform.domain.config import AppConfig
+from app.shared.infrastructure.gateways.whisper_model import WhisperModelService
from app.shared.locales import DEFAULT_LOCALE
from app.shared.speech_models import DEFAULT_SPEECH_MODEL_SIZE
from app.speech.schemas.page import SpeechModelPageContext
-from app.speech.services.whisper_model import WhisperModelService
class SpeechModelPageService:
diff --git a/app/speech/support/__init__.py b/app/speech/support/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/speech/use_cases/__init__.py b/app/speech/use_cases/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/speech/services/dictation.py b/app/speech/use_cases/dictation.py
similarity index 100%
rename from app/speech/services/dictation.py
rename to app/speech/use_cases/dictation.py
diff --git a/app/theory/api/audio_answer.py b/app/theory/api/audio_answer.py
index 1a00b93..02509de 100644
--- a/app/theory/api/audio_answer.py
+++ b/app/theory/api/audio_answer.py
@@ -9,11 +9,13 @@
from app.ai.base import AIProvider
from app.ai.speech_transcriber import SpeechTranscriber
from app.interview.domain.exceptions import InterviewDomainError
-from app.interview.services.ai_errors import ai_error_message_for_client
+from app.interview.support.ai_errors import ai_error_message_for_client
from app.shared.infrastructure.audio_wav import validate_wav_bytes
from app.theory.api.ws_protocol import domain_error_to_wire, event_to_message
from app.theory.domain.exceptions import TheoryDomainError
-from app.theory.services.submission import TheorySubmissionService
+from app.theory.use_cases.submit_answer import (
+ SubmitTheoryAnswer as TheorySubmissionService,
+)
logger = logging.getLogger(__name__)
diff --git a/app/theory/api/routes.py b/app/theory/api/routes.py
index dfff477..505bd46 100644
--- a/app/theory/api/routes.py
+++ b/app/theory/api/routes.py
@@ -3,7 +3,7 @@
"""Theory section HTTP and WebSocket transport."""
import logging
-from typing import Annotated, Any
+from typing import Annotated
from fastapi import (
APIRouter,
@@ -18,11 +18,12 @@
from app.interview.api.deps import (
AIProviderDep,
- InterviewQueryDep,
- SessionCompletionServiceDep,
+ CompleteSessionDep,
+ InterviewLoaderDep,
SpeechTranscriberDep,
TheorySubmissionServiceDep,
)
+from app.shared.api.negotiated_response import safe_send_json
from app.theory.api.audio_answer import TheoryAudioAnswerAdapter
from app.theory.api.ws_session import TheoryWebSocketService
@@ -31,23 +32,6 @@
logger = logging.getLogger(__name__)
-async def _safe_send_json(websocket: WebSocket, message: dict[str, Any]) -> bool:
- """Send a JSON message, returning False if the client already disconnected.
-
- Args:
- websocket: Active theory WebSocket.
- message: Payload to send.
-
- Returns:
- True if the message was sent, False if the socket is closed.
- """
- try:
- await websocket.send_json(message)
- return True
- except (WebSocketDisconnect, RuntimeError):
- return False
-
-
@router.post("/{interview_id}/theory/audio-answer")
async def submit_theory_audio_answer(
interview_id: str,
@@ -99,8 +83,8 @@ async def handle_theory_websocket(
interview_id: str,
provider: AIProviderDep,
submission_service: TheorySubmissionServiceDep,
- session_completion: SessionCompletionServiceDep,
- interview_query: InterviewQueryDep,
+ session_completion: CompleteSessionDep,
+ interview_query: InterviewLoaderDep,
) -> None:
"""Run the theory WebSocket message loop until disconnect.
@@ -126,7 +110,7 @@ async def handle_theory_websocket(
session_completion=session_completion,
interview_query=interview_query,
):
- if not await _safe_send_json(websocket, message):
+ if not await safe_send_json(websocket, message):
break
except WebSocketDisconnect:
logger.debug("Theory WebSocket disconnected for session %s", interview_id)
@@ -140,8 +124,8 @@ async def theory_ws(
interview_id: str,
provider: AIProviderDep,
submission_service: TheorySubmissionServiceDep,
- session_completion: SessionCompletionServiceDep,
- interview_query: InterviewQueryDep,
+ session_completion: CompleteSessionDep,
+ interview_query: InterviewLoaderDep,
) -> None:
"""WebSocket endpoint for real-time theory task interaction.
diff --git a/app/theory/api/ws_protocol.py b/app/theory/api/ws_protocol.py
index 36280ff..8eaba13 100644
--- a/app/theory/api/ws_protocol.py
+++ b/app/theory/api/ws_protocol.py
@@ -4,6 +4,14 @@
from typing import Any
+from app.interview.domain.events import (
+ AnswerFeedbackEvent,
+ AnswerSavedEvent,
+ EvaluatingEvent,
+ InterviewCompletedEvent,
+ InterviewEvent,
+ TranscriptEvent,
+)
from app.interview.domain.exceptions import InterviewDomainError
from app.interview.schemas.ws import (
AnswerFeedbackMessage,
@@ -13,14 +21,6 @@
TranscriptMessage,
server_message_to_dict,
)
-from app.interview.services.events import (
- AnswerFeedbackEvent,
- AnswerSavedEvent,
- EvaluatingEvent,
- InterviewCompletedEvent,
- InterviewEvent,
- TranscriptEvent,
-)
from app.theory.domain.exceptions import TheoryDomainError
__all__ = [
diff --git a/app/theory/api/ws_session.py b/app/theory/api/ws_session.py
index dbb8e95..2a65726 100644
--- a/app/theory/api/ws_session.py
+++ b/app/theory/api/ws_session.py
@@ -8,16 +8,20 @@
from app.ai.base import AIProvider
from app.interview.domain.exceptions import InterviewDomainError
-from app.interview.services.ai_errors import ai_error_message_for_client
-from app.interview.services.completion import SessionCompletionService
-from app.interview.services.query import InterviewQuery
+from app.interview.queries.loader import InterviewLoader as InterviewQuery
+from app.interview.support.ai_errors import ai_error_message_for_client
+from app.interview.use_cases.complete_session import (
+ CompleteInterviewSession as SessionCompletionService,
+)
from app.theory.api.ws_protocol import (
domain_error_to_wire,
event_to_message,
events_to_messages,
)
from app.theory.domain.exceptions import TheoryDomainError
-from app.theory.services.submission import TheorySubmissionService
+from app.theory.use_cases.submit_answer import (
+ SubmitTheoryAnswer as TheorySubmissionService,
+)
logger = logging.getLogger(__name__)
diff --git a/app/theory/domain/entities.py b/app/theory/domain/entities.py
index 7772e75..c531e63 100644
--- a/app/theory/domain/entities.py
+++ b/app/theory/domain/entities.py
@@ -6,21 +6,11 @@
from dataclasses import dataclass, replace
from datetime import UTC, datetime
-from typing import Literal
+from typing import Any, Literal
from app.interview.domain.value_objects import InterviewSelection
-from app.shared.task_timer import (
- DEFAULT_TIMEOUT_GRACE_SECONDS,
-)
-from app.shared.task_timer import (
- is_timer_expired as shared_is_timer_expired,
-)
-from app.shared.task_timer import (
- remaining_seconds as shared_remaining_seconds,
-)
-from app.shared.task_timer import (
- timer_deadline as shared_timer_deadline,
-)
+from app.shared.section import Section
+from app.shared.timed_task import TimedTask
from app.theory.domain.exceptions import (
TheorySectionNotActiveError,
TheoryTaskNotFoundError,
@@ -32,7 +22,7 @@
@dataclass(frozen=True, slots=True)
-class TheoryTask:
+class TheoryTask(TimedTask):
"""One answer round within a theory section.
Attributes:
@@ -53,7 +43,6 @@ class TheoryTask:
"""
TIME_EXPIRED_ANSWER_TEXT = "[Time expired]"
- TIMEOUT_GRACE_SECONDS = DEFAULT_TIMEOUT_GRACE_SECONDS
NEW_ID = 0
id: int
@@ -71,91 +60,9 @@ class TheoryTask:
created_at: datetime
expected_points: tuple[str, ...] = ()
- def timer_deadline(self, limit_seconds: int) -> datetime:
- """Compute the absolute deadline for this timed task round.
-
- Args:
- limit_seconds: Allowed duration in seconds.
-
- Returns:
- Timezone-aware deadline timestamp.
-
- Raises:
- ValueError: If the round has no ``started_at`` timestamp.
- """
- if self.started_at is None:
- raise ValueError("Theory task round has no started_at")
- return shared_timer_deadline(
- self.started_at,
- limit_seconds,
- label="Theory task",
- )
-
- def is_timer_expired(
- self,
- limit_seconds: int | None,
- now: datetime | None = None,
- *,
- grace_seconds: int = TIMEOUT_GRACE_SECONDS,
- ) -> bool:
- """Return whether the per-round timer has elapsed.
-
- Args:
- limit_seconds: Configured limit for the section (None disables timer).
- now: Current time (defaults to UTC now).
- grace_seconds: Extra seconds allowed for network delay on timeout submit.
-
- Returns:
- True if the timer is enabled and the deadline plus grace has passed.
- """
- return shared_is_timer_expired(
- self.started_at,
- limit_seconds,
- now,
- grace_seconds=grace_seconds,
- )
-
- def remaining_seconds(
- self,
- limit_seconds: int | None,
- now: datetime | None = None,
- ) -> int | None:
- """Return whole seconds left on the timer, or None if disabled.
-
- Args:
- limit_seconds: Configured limit for the section.
- now: Current time (defaults to UTC now).
-
- Returns:
- Non-negative seconds remaining, or None when the timer is off.
- """
- return shared_remaining_seconds(self.started_at, limit_seconds, now)
-
- def client_timeout_due(
- self,
- limit_seconds: int | None,
- now: datetime | None = None,
- ) -> bool:
- """Return whether a client-sent timeout should be accepted.
-
- Args:
- limit_seconds: Configured limit for the section.
- now: Current time (defaults to UTC now).
-
- Returns:
- True when the round timer has effectively expired for the client.
- """
- if limit_seconds is None or self.started_at is None:
- return False
- rem = self.remaining_seconds(limit_seconds, now)
- return self.is_timer_expired(limit_seconds, now, grace_seconds=0) or (
- rem is not None and rem <= 0
- )
-
@dataclass(frozen=True, slots=True)
-class TheorySection:
- MAX_SCORE_PER_ROUND = 5
+class TheorySection(Section[TheoryTask]):
"""Theory section aggregate root.
Attributes:
@@ -172,6 +79,7 @@ class TheorySection:
tasks: Theory tasks in display order (order, then round).
"""
+ MAX_SCORE_PER_ROUND = 5 # pyright: ignore
NEW_ID = 0
id: int
@@ -186,6 +94,56 @@ class TheorySection:
section_feedback: dict[str, object] | None
tasks: tuple[TheoryTask, ...]
+ # ------------------------------------------------------------------
+ # Section abstract hooks
+ # ------------------------------------------------------------------
+ @property
+ def _completion_field_name(self) -> str:
+ return "answer_text"
+
+ @property
+ def _task_id_field(self) -> str:
+ return "question_id"
+
+ def _task_not_found_error(self, task_id: str, round_num: int) -> Exception:
+ return TheoryTaskNotFoundError(self.interview_id, task_id, round_num)
+
+ def _timeout_replacement_fields(
+ self, task: TheoryTask, feedback: str
+ ) -> dict[str, Any]:
+ return {
+ "answer_text": TheoryTask.TIME_EXPIRED_ANSWER_TEXT,
+ "score": 0,
+ "feedback": feedback,
+ }
+
+ def _create_follow_up(
+ self,
+ base: TheoryTask,
+ next_round: int,
+ prompt_text: str,
+ **kwargs: Any,
+ ) -> TheoryTask:
+ return TheoryTask(
+ id=TheoryTask.NEW_ID,
+ theory_section_id=self.id,
+ interview_id=self.interview_id,
+ question_id=base.question_id,
+ order=base.order,
+ round=next_round,
+ question_text=prompt_text,
+ question_code=base.question_code,
+ answer_text=None,
+ score=None,
+ feedback=None,
+ started_at=None,
+ created_at=datetime.now(UTC),
+ expected_points=base.expected_points,
+ )
+
+ # ------------------------------------------------------------------
+ # Factory
+ # ------------------------------------------------------------------
@classmethod
def start(
cls,
@@ -259,6 +217,9 @@ def start(
tasks=tuple(tasks),
)
+ # ------------------------------------------------------------------
+ # Domain-specific behaviour
+ # ------------------------------------------------------------------
def ensure_active(self) -> None:
"""Ensure this theory section accepts new task submissions.
@@ -268,90 +229,6 @@ def ensure_active(self) -> None:
if self.status != "active":
raise TheorySectionNotActiveError(self.interview_id)
- def find_first_unanswered(self) -> TheoryTask | None:
- """Return the first unanswered task in display order.
-
- Returns:
- The first task with ``answer_text`` unset, or None when all are answered.
- """
- for task in self.tasks:
- if task.answer_text is None:
- return task
- return None
-
- def is_complete(self) -> bool:
- """Return whether every task in this section has been answered.
-
- Returns:
- True when there is at least one task and none remain unanswered.
- """
- return bool(self.tasks) and self.find_first_unanswered() is None
-
- def total_score(self) -> int:
- """Sum scores from all answered task rounds in this section.
-
- Returns:
- Total earned points across answered rounds.
- """
- return sum(
- (task.score or 0) for task in self.tasks if task.answer_text is not None
- )
-
- def max_score(self) -> int:
- """Compute maximum achievable score for answered rounds in this section.
-
- Returns:
- Maximum possible points for rounds with user answers.
- """
- answered_rounds = sum(1 for task in self.tasks if task.answer_text is not None)
- return self.MAX_SCORE_PER_ROUND * answered_rounds
-
- def with_cached_section_feedback(
- self,
- feedback: dict[str, object],
- *,
- section_score: int,
- ) -> TheorySection:
- """Return aggregate with prefetched section feedback when not already cached.
-
- Args:
- feedback: Parsed section evaluation payload.
- section_score: Aggregated section score.
-
- Returns:
- Updated aggregate, or ``self`` when feedback is already cached.
- """
- if self.section_feedback is not None:
- return self
- return replace(
- self,
- section_feedback=feedback,
- section_score=section_score,
- )
-
- def start_timer_for_task(
- self, task_id: int, when: datetime | None = None
- ) -> TheorySection:
- """Start the per-round timer on a task when the section has a limit.
-
- Args:
- task_id: Primary key of the task row to activate.
- when: Timestamp to set (defaults to UTC now).
-
- Returns:
- A new aggregate with ``started_at`` set on the target task when applicable.
- """
- if self.task_time_limit_seconds is None:
- return self
- started_at = when or datetime.now(UTC)
- tasks = tuple(
- replace(task, started_at=started_at)
- if task.id == task_id and task.started_at is None
- else task
- for task in self.tasks
- )
- return replace(self, tasks=tasks)
-
def with_task_text(self, task_id: int, text: str) -> TheorySection:
"""Return aggregate with user answer text on the given task.
@@ -368,97 +245,6 @@ def with_task_text(self, task_id: int, text: str) -> TheorySection:
)
return replace(self, tasks=tasks)
- def with_timed_out_round(self, task_id: int, feedback: str) -> TheorySection:
- """Return aggregate with a timed-out round scored zero.
-
- Args:
- task_id: Primary key of the task row that expired.
- feedback: User-facing timeout feedback text.
-
- Returns:
- A new aggregate with timeout marker text, score 0, and feedback.
- """
- tasks = tuple(
- replace(
- task,
- answer_text=TheoryTask.TIME_EXPIRED_ANSWER_TEXT,
- score=0,
- feedback=feedback,
- )
- if task.id == task_id
- else task
- for task in self.tasks
- )
- return replace(self, tasks=tasks)
-
- def with_evaluation(
- self, question_id: str, round_num: int, score: int, feedback: str
- ) -> TheorySection:
- """Return aggregate with AI score and feedback on one task round.
-
- Args:
- question_id: YAML question ID.
- round_num: Follow-up round (0 = initial).
- score: AI score for the round.
- feedback: AI feedback text.
-
- Returns:
- A new aggregate with evaluation fields set on the target task.
- """
- target = self.find_task(question_id, round_num)
- tasks = tuple(
- replace(task, score=score, feedback=feedback)
- if task.id == target.id
- else task
- for task in self.tasks
- )
- return replace(self, tasks=tasks)
-
- def max_round_for_question(self, question_id: str) -> int:
- """Return the highest follow-up round number for a question.
-
- Args:
- question_id: YAML question ID.
-
- Returns:
- Maximum ``round`` value among tasks for the question, or 0 when none exist.
- """
- rounds = [task.round for task in self.tasks if task.question_id == question_id]
- return max(rounds) if rounds else 0
-
- def with_follow_up(
- self, question_id: str, question_text: str
- ) -> tuple[TheorySection, TheoryTask]:
- """Return aggregate with a new unanswered follow-up task row.
-
- Args:
- question_id: YAML question ID for the follow-up chain.
- question_text: Follow-up question text shown to the user.
-
- Returns:
- Tuple of updated aggregate and the pending follow-up task (``id`` is ``NEW_ID``).
- """
- base = self.find_task(question_id, 0)
- next_round = self.max_round_for_question(question_id) + 1
- created_at = datetime.now(UTC)
- follow_up = TheoryTask(
- id=TheoryTask.NEW_ID,
- theory_section_id=self.id,
- interview_id=self.interview_id,
- question_id=question_id,
- order=base.order,
- round=next_round,
- question_text=question_text,
- question_code=base.question_code,
- answer_text=None,
- score=None,
- feedback=None,
- started_at=None,
- created_at=created_at,
- expected_points=base.expected_points,
- )
- return replace(self, tasks=self.tasks + (follow_up,)), follow_up
-
def find_unanswered_for_question(self, question_id: str) -> TheoryTask:
"""Return the unanswered task row for a question (any follow-up round).
@@ -475,35 +261,3 @@ def find_unanswered_for_question(self, question_id: str) -> TheoryTask:
if task.question_id == question_id and task.answer_text is None:
return task
raise UnansweredTaskNotFoundError(self.interview_id, question_id)
-
- def find_task(self, question_id: str, round_num: int) -> TheoryTask:
- """Return the task row for a question and follow-up round.
-
- Args:
- question_id: YAML question ID.
- round_num: Follow-up round (0 = initial).
-
- Returns:
- The matching task row.
-
- Raises:
- TheoryTaskNotFoundError: If no row matches the keys.
- """
- for task in self.tasks:
- if task.question_id == question_id and task.round == round_num:
- return task
- raise TheoryTaskNotFoundError(self.interview_id, question_id, round_num)
-
- def find_next_unanswered_after(self, current_index: int) -> TheoryTask | None:
- """Return the next unanswered task after a position in the task list.
-
- Args:
- current_index: Index of the current task in ``tasks``.
-
- Returns:
- The next unanswered task, or None if none remain.
- """
- for task in self.tasks[current_index + 1 :]:
- if task.answer_text is None:
- return task
- return None
diff --git a/app/theory/services/evaluator/service.py b/app/theory/domain/evaluator.py
similarity index 89%
rename from app/theory/services/evaluator/service.py
rename to app/theory/domain/evaluator.py
index b3eec0b..6b96a52 100644
--- a/app/theory/services/evaluator/service.py
+++ b/app/theory/domain/evaluator.py
@@ -8,23 +8,23 @@
from app.ai.base import AIProvider, Message
from app.shared.evaluation_models import InterviewEvaluation, SectionEvaluation
+from app.shared.json_parser import build_prompt_with_schema
from app.shared.locales import DEFAULT_LOCALE
from app.shared.structured_evaluation import (
evaluate_with_schema,
generate_and_parse_json_response,
)
-from app.theory.services.evaluator.models import (
+from app.theory.domain.evaluator_models import (
AnswerEvaluation,
FollowUpEvaluation,
)
-from app.theory.services.evaluator.prompts import (
+from app.theory.domain.evaluator_prompts import (
ANSWER_EVALUATION_INSTRUCTIONS,
FOLLOW_UP_EVALUATION_INSTRUCTIONS,
SECTION_EVALUATION_INSTRUCTIONS,
SESSION_EVALUATION_INSTRUCTIONS,
build_evaluator_instructions,
format_expected_rubric,
- looks_like_json_schema_fragment,
)
__all__ = [
@@ -32,14 +32,13 @@
"FollowUpEvaluation",
"InterviewEvaluation",
"SectionEvaluation",
- "TheoryEvaluatorService",
- "looks_like_json_schema_fragment",
+ "TheoryEvaluator",
]
T = TypeVar("T", bound=BaseModel)
-class TheoryEvaluatorService:
+class TheoryEvaluator:
"""Service for AI-powered evaluation of theory task answers.
Uses the configured AI provider to evaluate answers, generate follow-up
@@ -81,7 +80,7 @@ def _format_answer_evaluation_user_text(
Returns:
Labeled prompt text separating context from candidate content.
"""
- question = TheoryEvaluatorService._format_question(question_text, question_code)
+ question = TheoryEvaluator._format_question(question_text, question_code)
rubric = format_expected_rubric(expected_points)
parts = [
f"Question (for context only, NOT part of the answer):\n{question}",
@@ -96,6 +95,27 @@ def _format_answer_evaluation_user_text(
)
return "\n\n".join(parts)
+ @staticmethod
+ def _build_system_prompt(
+ locale: str,
+ instructions: str,
+ response_model: type[BaseModel],
+ ) -> str:
+ """Build evaluator system prompt with schema and locale.
+
+ Args:
+ locale: Interview locale code.
+ instructions: Task-specific evaluation instructions.
+ response_model: Pydantic model for response validation.
+
+ Returns:
+ Complete system prompt ready for the AI provider.
+ """
+ return build_prompt_with_schema(
+ build_evaluator_instructions(locale, instructions),
+ response_model,
+ )
+
@staticmethod
async def _evaluate_with_schema(
provider: AIProvider,
@@ -124,10 +144,12 @@ async def _evaluate_with_schema(
Raises:
ValueError: If AI response is invalid or connection fails.
"""
+ system_prompt = TheoryEvaluator._build_system_prompt(
+ locale, instructions, response_model
+ )
return await evaluate_with_schema(
provider,
- locale=locale,
- instructions=instructions,
+ system_prompt=system_prompt,
response_model=response_model,
user_text=user_text,
audio_wav=audio_wav,
@@ -159,13 +181,13 @@ async def evaluate_answer(
Raises:
ValueError: If AI response is invalid or connection fails.
"""
- user_text = TheoryEvaluatorService._format_answer_evaluation_user_text(
+ user_text = TheoryEvaluator._format_answer_evaluation_user_text(
question_text=question_text,
question_code=question_code,
answer_text=answer_text,
expected_points=expected_points,
)
- return await TheoryEvaluatorService._evaluate_with_schema(
+ return await TheoryEvaluator._evaluate_with_schema(
provider,
locale=locale,
instructions=ANSWER_EVALUATION_INSTRUCTIONS,
@@ -198,12 +220,12 @@ async def evaluate_answer_with_audio(
Raises:
ValueError: If AI response is invalid or connection fails.
"""
- user_text = TheoryEvaluatorService._format_answer_evaluation_user_text(
+ user_text = TheoryEvaluator._format_answer_evaluation_user_text(
question_text=question_text,
question_code=question_code,
expected_points=expected_points,
)
- return await TheoryEvaluatorService._evaluate_with_schema(
+ return await TheoryEvaluator._evaluate_with_schema(
provider,
locale=locale,
instructions=ANSWER_EVALUATION_INSTRUCTIONS,
@@ -239,14 +261,14 @@ async def evaluate_follow_up(
Raises:
ValueError: If AI response is invalid or connection fails.
"""
- question = TheoryEvaluatorService._format_question(question_text, question_code)
+ question = TheoryEvaluator._format_question(question_text, question_code)
user_text = (
f"Original Question:\n{question}\n\n"
f"Initial Answer:\n{initial_answer}\n\n"
f"Follow-up Question:\n{follow_up_question}\n\n"
f"Follow-up Answer:\n{follow_up_answer}"
)
- return await TheoryEvaluatorService._evaluate_with_schema(
+ return await TheoryEvaluator._evaluate_with_schema(
provider,
locale=locale,
instructions=FOLLOW_UP_EVALUATION_INSTRUCTIONS,
@@ -281,13 +303,13 @@ async def evaluate_follow_up_with_audio(
Raises:
ValueError: If AI response is invalid or connection fails.
"""
- question = TheoryEvaluatorService._format_question(question_text, question_code)
+ question = TheoryEvaluator._format_question(question_text, question_code)
user_text = (
f"Original Question:\n{question}\n\n"
f"Initial Answer:\n{initial_answer}\n\n"
f"Follow-up Question:\n{follow_up_question}"
)
- return await TheoryEvaluatorService._evaluate_with_schema(
+ return await TheoryEvaluator._evaluate_with_schema(
provider,
locale=locale,
instructions=FOLLOW_UP_EVALUATION_INSTRUCTIONS,
@@ -322,7 +344,7 @@ def _follow_up_decision(
follow_up_needed = (
evaluation.needs_further_follow_up
and bool(evaluation.follow_up_question)
- and answer_round < TheoryEvaluatorService.MAX_FOLLOW_UP_DEPTH
+ and answer_round < TheoryEvaluator.MAX_FOLLOW_UP_DEPTH
)
return follow_up_needed, evaluation.follow_up_question
@@ -366,7 +388,7 @@ async def evaluate_submission(
evaluation: AnswerEvaluation | FollowUpEvaluation
if answer_round == 0:
if audio_wav is not None:
- evaluation = await TheoryEvaluatorService.evaluate_answer_with_audio(
+ evaluation = await TheoryEvaluator.evaluate_answer_with_audio(
provider=provider,
question_text=question_text,
audio_wav=audio_wav,
@@ -375,7 +397,7 @@ async def evaluate_submission(
locale=locale,
)
else:
- evaluation = await TheoryEvaluatorService.evaluate_answer(
+ evaluation = await TheoryEvaluator.evaluate_answer(
provider=provider,
question_text=question_text,
answer_text=answer_text or "",
@@ -384,7 +406,7 @@ async def evaluate_submission(
locale=locale,
)
elif audio_wav is not None:
- evaluation = await TheoryEvaluatorService.evaluate_follow_up_with_audio(
+ evaluation = await TheoryEvaluator.evaluate_follow_up_with_audio(
provider=provider,
question_text=initial_question_text,
initial_answer=initial_answer_text,
@@ -394,7 +416,7 @@ async def evaluate_submission(
locale=locale,
)
else:
- evaluation = await TheoryEvaluatorService.evaluate_follow_up(
+ evaluation = await TheoryEvaluator.evaluate_follow_up(
provider=provider,
question_text=initial_question_text,
initial_answer=initial_answer_text,
@@ -404,7 +426,7 @@ async def evaluate_submission(
locale=locale,
)
- follow_up_needed, follow_up_text = TheoryEvaluatorService._follow_up_decision(
+ follow_up_needed, follow_up_text = TheoryEvaluator._follow_up_decision(
evaluation, answer_round
)
return evaluation, follow_up_needed, follow_up_text
@@ -446,7 +468,7 @@ async def evaluate_section(
summary_text = "\n\n".join(qa_summary)
user_text = f"Sources:\n{sources_text}\n\nSection Questions and Answers:\n{summary_text}"
- return await TheoryEvaluatorService._evaluate_with_schema(
+ return await TheoryEvaluator._evaluate_with_schema(
provider,
locale=locale,
instructions=SECTION_EVALUATION_INSTRUCTIONS,
diff --git a/app/theory/services/evaluator/models.py b/app/theory/domain/evaluator_models.py
similarity index 100%
rename from app/theory/services/evaluator/models.py
rename to app/theory/domain/evaluator_models.py
diff --git a/app/theory/services/evaluator/prompts.py b/app/theory/domain/evaluator_prompts.py
similarity index 100%
rename from app/theory/services/evaluator/prompts.py
rename to app/theory/domain/evaluator_prompts.py
diff --git a/app/theory/domain/exceptions.py b/app/theory/domain/exceptions.py
index 3d4ce1c..edabbc7 100644
--- a/app/theory/domain/exceptions.py
+++ b/app/theory/domain/exceptions.py
@@ -2,8 +2,10 @@
# SPDX-License-Identifier: Apache-2.0
"""Theory domain exceptions."""
+from app.interview.domain.exceptions import InterviewDomainError
-class TheoryDomainError(Exception):
+
+class TheoryDomainError(InterviewDomainError):
"""Base class for theory-related domain errors."""
diff --git a/app/theory/services/planning.py b/app/theory/domain/question_planner.py
similarity index 97%
rename from app/theory/services/planning.py
rename to app/theory/domain/question_planner.py
index 6539e81..a7c00b2 100644
--- a/app/theory/services/planning.py
+++ b/app/theory/domain/question_planner.py
@@ -2,18 +2,18 @@
# SPDX-License-Identifier: Apache-2.0
"""Load question banks and build theory section question plans."""
-from app.interview.domain.value_objects import (
- InterviewSelection,
- PlannedQuestion,
- TrackQuestionPools,
-)
-from app.interview.services.rules.bank_selection import (
+from app.interview.domain.rules.bank_selection import (
BankCatalog,
BankSelectionMessages,
track_label,
validate_bank_selection,
)
-from app.interview.services.rules.selection import plan_questions
+from app.interview.domain.rules.selection import plan_questions
+from app.interview.domain.value_objects import (
+ InterviewSelection,
+ PlannedQuestion,
+ TrackQuestionPools,
+)
from app.shared.locales import normalize_locale
from app.shared.questions import (
Question,
diff --git a/app/theory/domain/rules/__init__.py b/app/theory/domain/rules/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/theory/services/timer.py b/app/theory/domain/timer.py
similarity index 75%
rename from app/theory/services/timer.py
rename to app/theory/domain/timer.py
index 7dad7f3..3779cff 100644
--- a/app/theory/services/timer.py
+++ b/app/theory/domain/timer.py
@@ -2,23 +2,27 @@
# SPDX-License-Identifier: Apache-2.0
"""Per-round timer side effects for theory tasks."""
-from typing import Any
+from __future__ import annotations
+from typing import TYPE_CHECKING, Any
+
+from app.interview.domain.events import AnswerFeedbackEvent
+from app.interview.domain.rules.feedback import timeout_feedback_for_locale
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.services.navigation import TheoryNavigationService
+
+if TYPE_CHECKING:
+ from app.theory.use_cases.navigate_tasks import TheoryTaskNavigation
-class TheoryTimerService:
+class TaskTimer:
"""Timeout persistence for timed theory task rounds."""
def __init__(
self,
uow: InterviewUnitOfWork,
*,
- navigation: TheoryNavigationService | None = None,
+ navigation: TheoryTaskNavigation | None = None,
) -> None:
"""Initialize with the active unit of work.
@@ -27,7 +31,7 @@ def __init__(
navigation: Optional navigation collaborator sharing the same uow.
"""
self._uow = uow
- self._navigation = navigation or TheoryNavigationService(uow)
+ self._navigation = navigation
def persist_timed_out_round(
self,
@@ -61,13 +65,14 @@ def persist_timed_out_round(
updated = section.with_timed_out_round(current.id, feedback_text)
self._uow.theory_sections.save_aggregate(updated)
- next_question_data, timer_remaining = (
- self._navigation.advance_to_next_unanswered(
- interview_id,
- question_id=question_id,
- round_num=round_num,
+ if self._navigation is not None:
+ 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/queries/__init__.py b/app/theory/queries/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/theory/services/query.py b/app/theory/queries/loader.py
similarity index 91%
rename from app/theory/services/query.py
rename to app/theory/queries/loader.py
index 77a6cbf..8825f43 100644
--- a/app/theory/services/query.py
+++ b/app/theory/queries/loader.py
@@ -4,14 +4,14 @@
from typing import Any
+from app.interview.domain.rules.selection import selection_sources_summary
+from app.interview.domain.section_evaluation import build_section_evaluation_summary
+from app.interview.domain.session_phases import SectionEvaluationSummary
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
-class TheoryQueryService:
+class TheorySectionLoader:
"""Read-only queries for theory section aggregates."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
diff --git a/app/theory/services/review.py b/app/theory/queries/review_page.py
similarity index 93%
rename from app/theory/services/review.py
rename to app/theory/queries/review_page.py
index 2edfcf5..8e3a8f6 100644
--- a/app/theory/services/review.py
+++ b/app/theory/queries/review_page.py
@@ -4,20 +4,20 @@
from __future__ import annotations
-from app.interview.repositories.uow import InterviewUnitOfWork
-from app.interview.services.section_review_support import (
+from app.interview.queries.review_context import (
CompletedInterviewSnapshot,
load_completed_interview,
resolved_section_feedback,
review_score_fields,
shared_review_fields,
)
+from app.interview.repositories.uow import InterviewUnitOfWork
+from app.theory.queries.loader import TheorySectionLoader
from app.theory.schemas.review import TheoryReviewContext
from app.theory.schemas.theory import TheoryTaskRead
-from app.theory.services.query import TheoryQueryService
-class TheoryReviewService:
+class TheoryReviewPage:
"""Build read-only theory review context for completed sessions."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
@@ -27,7 +27,7 @@ def __init__(self, uow: InterviewUnitOfWork) -> None:
uow: Shared application unit of work for this review scope.
"""
self._uow = uow
- self._query = TheoryQueryService(uow)
+ self._query = TheorySectionLoader(uow)
def build_context(
self,
diff --git a/app/theory/services/section.py b/app/theory/queries/section_state.py
similarity index 90%
rename from app/theory/services/section.py
rename to app/theory/queries/section_state.py
index a27ccd0..ee5cbfc 100644
--- a/app/theory/services/section.py
+++ b/app/theory/queries/section_state.py
@@ -6,14 +6,14 @@
from typing import ClassVar, Literal
-from app.interview.repositories.uow import InterviewUnitOfWork
-from app.interview.services.section_service_support import SectionFeedbackPrefetch
-from app.interview.services.sections import (
+from app.interview.domain.session_phases import (
SectionEvaluationSummary,
SectionPageContext,
)
-from app.theory.services.evaluator.service import TheoryEvaluatorService
-from app.theory.services.query import TheoryQueryService
+from app.interview.repositories.uow import InterviewUnitOfWork
+from app.interview.support.feedback_prefetch import SectionFeedbackPrefetch
+from app.theory.domain.evaluator import TheoryEvaluator
+from app.theory.queries.loader import TheorySectionLoader
async def _evaluate_theory_section_feedback(
@@ -33,7 +33,7 @@ async def _evaluate_theory_section_feedback(
Returns:
Feedback payload and section score.
"""
- section_eval = await TheoryEvaluatorService.evaluate_section(
+ section_eval = await TheoryEvaluator.evaluate_section(
provider=provider, # type: ignore[arg-type]
questions_answers=list(summary.items),
sources_text=sources_text,
@@ -44,7 +44,7 @@ async def _evaluate_theory_section_feedback(
def _build_theory_feedback_prefetch(
uow: InterviewUnitOfWork,
- query: TheoryQueryService | None = None,
+ query: TheorySectionLoader | None = None,
) -> SectionFeedbackPrefetch:
"""Build theory section feedback prefetch helpers for a unit of work.
@@ -55,7 +55,7 @@ def _build_theory_feedback_prefetch(
Returns:
Configured prefetch helper for theory sections.
"""
- resolved_query = query or TheoryQueryService(uow)
+ resolved_query = query or TheorySectionLoader(uow)
return SectionFeedbackPrefetch(
uow,
section_name="theory",
@@ -71,7 +71,7 @@ def _build_theory_feedback_prefetch(
)
-class TheorySectionService:
+class TheorySectionState:
"""Theory section lifecycle hooks and read helpers."""
section_kind: ClassVar[Literal["theory"]] = "theory"
@@ -79,7 +79,7 @@ class TheorySectionService:
def __init__(
self,
uow: InterviewUnitOfWork,
- query: TheoryQueryService | None = None,
+ query: TheorySectionLoader | None = None,
) -> None:
"""Initialize with the active unit of work.
@@ -88,7 +88,7 @@ def __init__(
query: Optional theory query helper sharing the same unit of work.
"""
self._uow = uow
- self._query = query or TheoryQueryService(uow)
+ self._query = query or TheorySectionLoader(uow)
self._feedback = _build_theory_feedback_prefetch(uow, self._query)
def is_complete(self, interview_id: str) -> bool:
diff --git a/app/theory/services/page.py b/app/theory/queries/task_page.py
similarity index 91%
rename from app/theory/services/page.py
rename to app/theory/queries/task_page.py
index 27fc9e7..3ced289 100644
--- a/app/theory/services/page.py
+++ b/app/theory/queries/task_page.py
@@ -3,13 +3,13 @@
"""Theory section page context builder."""
from app.interview.domain.serialization import parse_session_spec
+from app.interview.queries.loader import InterviewQuery
from app.interview.repositories.uow import InterviewUnitOfWork
from app.interview.schemas.interview import InterviewRead
-from app.interview.services.query import InterviewQuery
from app.theory.schemas.page import TheoryPageContext
-class TheoryPageService:
+class ActiveTheoryTaskPage:
"""Build theory-specific page context for session rendering."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
@@ -29,7 +29,7 @@ def activate_timer(self, interview_id: str) -> 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()
+ current = section.find_first_pending()
if current is None or current.started_at is not None:
return
updated = section.start_timer_for_task(current.id)
@@ -60,7 +60,7 @@ def build_context(self, interview: InterviewRead) -> TheoryPageContext | None:
question_timer_enabled = interview.question_time_limit_seconds is not None
timer_remaining_seconds = None
if question_timer_enabled and section is not None:
- current = section.find_first_unanswered()
+ current = section.find_first_pending()
if current is not None:
timer_remaining_seconds = current.remaining_seconds(
section.task_time_limit_seconds
@@ -94,6 +94,6 @@ def build_context_for(
Theory page context, or None when the session has no theory tasks.
"""
if uow is not None:
- return TheoryPageService(uow).build_context(interview)
+ return ActiveTheoryTaskPage(uow).build_context(interview)
with InterviewUnitOfWork() as new_uow:
- return TheoryPageService(new_uow).build_context(interview)
+ return ActiveTheoryTaskPage(new_uow).build_context(interview)
diff --git a/app/theory/services/__init__.py b/app/theory/services/__init__.py
deleted file mode 100644
index 7ec3ce5..0000000
--- a/app/theory/services/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# Copyright 2026 GrillKit Contributors
-# SPDX-License-Identifier: Apache-2.0
-"""Theory orchestration services (scaffold for Phase 3+)."""
diff --git a/app/theory/services/evaluator/__init__.py b/app/theory/services/evaluator/__init__.py
deleted file mode 100644
index 939b225..0000000
--- a/app/theory/services/evaluator/__init__.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# Copyright 2026 GrillKit Contributors
-# SPDX-License-Identifier: Apache-2.0
-"""AI theory evaluation service and supporting types."""
-
-from app.theory.services.evaluator.models import (
- AnswerEvaluation,
- FollowUpEvaluation,
- InterviewEvaluation,
-)
-from app.theory.services.evaluator.service import TheoryEvaluatorService
-
-__all__ = [
- "AnswerEvaluation",
- "FollowUpEvaluation",
- "InterviewEvaluation",
- "TheoryEvaluatorService",
-]
diff --git a/app/theory/support/__init__.py b/app/theory/support/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/theory/services/evaluation_persistence.py b/app/theory/support/answer_commit.py
similarity index 86%
rename from app/theory/services/evaluation_persistence.py
rename to app/theory/support/answer_commit.py
index d2ac8fa..35d1d80 100644
--- a/app/theory/services/evaluation_persistence.py
+++ b/app/theory/support/answer_commit.py
@@ -2,26 +2,30 @@
# SPDX-License-Identifier: Apache-2.0
"""Persist AI evaluation results and advance theory sections."""
-from typing import Any
+from __future__ import annotations
+from typing import TYPE_CHECKING, Any
+
+from app.interview.domain.events import AnswerFeedbackEvent
from app.interview.repositories.uow import InterviewUnitOfWork
-from app.interview.services.events import AnswerFeedbackEvent
-from app.theory.domain.exceptions import TheorySectionNotFoundError
-from app.theory.services.evaluator.models import (
+from app.theory.domain.evaluator_models import (
AnswerEvaluation,
FollowUpEvaluation,
)
-from app.theory.services.navigation import TheoryNavigationService
+from app.theory.domain.exceptions import TheorySectionNotFoundError
+if TYPE_CHECKING:
+ from app.theory.use_cases.navigate_tasks import TheoryTaskNavigation
-class TheoryEvaluationPersistenceService:
+
+class CommitAnswerResult:
"""Save evaluation scores and advance timed theory task rounds."""
def __init__(
self,
uow: InterviewUnitOfWork,
*,
- navigation: TheoryNavigationService | None = None,
+ navigation: TheoryTaskNavigation | None = None,
) -> None:
"""Initialize with the active unit of work.
@@ -30,7 +34,7 @@ def __init__(
navigation: Optional navigation collaborator sharing the same uow.
"""
self._uow = uow
- self._navigation = navigation or TheoryNavigationService(uow)
+ self._navigation = navigation
def advance_without_evaluation(
self,
@@ -51,13 +55,16 @@ def advance_without_evaluation(
Returns:
Feedback event for the client with the next question, if any.
"""
- next_question_data, timer_remaining = (
- self._navigation.advance_to_next_unanswered(
- interview_id,
- question_id=question_id,
- round_num=round_num,
+ next_question_data = None
+ timer_remaining = None
+ if self._navigation is not None:
+ 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,
@@ -155,13 +162,14 @@ def persist(
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,
+ if self._navigation is not None:
+ 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/use_cases/__init__.py b/app/theory/use_cases/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/theory/services/creation.py b/app/theory/use_cases/create_section.py
similarity index 92%
rename from app/theory/services/creation.py
rename to app/theory/use_cases/create_section.py
index 2550750..74dfe8b 100644
--- a/app/theory/services/creation.py
+++ b/app/theory/use_cases/create_section.py
@@ -2,14 +2,14 @@
# SPDX-License-Identifier: Apache-2.0
"""Theory section creation service."""
+from app.interview.domain.rules.selection import validate_question_count
from app.interview.domain.value_objects import InterviewSelection
from app.interview.repositories.uow import InterviewUnitOfWork
-from app.interview.services.rules.selection import validate_question_count
from app.theory.domain.entities import TheorySection
-from app.theory.services.planning import build_theory_question_plan
+from app.theory.domain.question_planner import build_theory_question_plan
-class TheorySectionCreationService:
+class CreateTheorySection:
"""Service for creating theory sections within an interview session."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
diff --git a/app/theory/services/navigation.py b/app/theory/use_cases/navigate_tasks.py
similarity index 94%
rename from app/theory/services/navigation.py
rename to app/theory/use_cases/navigate_tasks.py
index dca0a86..f820865 100644
--- a/app/theory/services/navigation.py
+++ b/app/theory/use_cases/navigate_tasks.py
@@ -2,10 +2,12 @@
# SPDX-License-Identifier: Apache-2.0
"""Advance theory sections to the next unanswered task."""
+from __future__ import annotations
+
from typing import Any
from app.interview.repositories.uow import InterviewUnitOfWork
-from app.interview.services.phases import SessionPhaseOrchestrator
+from app.interview.use_cases.advance_phase import SessionPhaseOrchestrator
from app.theory.domain.entities import TheorySection, TheoryTask
from app.theory.domain.exceptions import TheorySectionNotFoundError
@@ -29,7 +31,7 @@ def next_task_payload(task: TheoryTask) -> dict[str, Any]:
}
-class TheoryNavigationService:
+class TheoryTaskNavigation:
"""Shared navigation after a theory task round is completed or timed out."""
def __init__(self, uow: InterviewUnitOfWork) -> None:
@@ -72,7 +74,7 @@ def advance_to_next_unanswered(
for i, task in enumerate(section.tasks)
if task.question_id == question_id and task.round == round_num
)
- next_task = section.find_next_unanswered_after(current_index)
+ next_task = section.find_next_pending_after(current_index)
if next_task is None:
self._notify_phase_complete_if_needed(interview_id, section)
return None, None
diff --git a/app/theory/services/submission.py b/app/theory/use_cases/submit_answer.py
similarity index 94%
rename from app/theory/services/submission.py
rename to app/theory/use_cases/submit_answer.py
index 750b450..c01cafe 100644
--- a/app/theory/services/submission.py
+++ b/app/theory/use_cases/submit_answer.py
@@ -13,31 +13,29 @@
from app.ai.base import AIProvider
from app.ai.speech_transcriber import SpeechTranscriber
-from app.interview.domain.exceptions import InterviewNotFoundError
-from app.interview.repositories.uow import InterviewUnitOfWork
-from app.interview.services.events import (
+from app.interview.domain.events import (
AnswerSavedEvent,
EvaluatingEvent,
InterviewEvent,
TranscriptEvent,
)
-from app.platform.services.config import ConfigService
-from app.platform.services.llm_catalog import LLMCatalogService
+from app.interview.domain.exceptions import InterviewNotFoundError
+from app.interview.repositories.uow import InterviewUnitOfWork
+from app.platform.domain.config import ConfigService
+from app.platform.domain.llm_catalog import LLMCatalogService
from app.shared.infrastructure.audio_wav import (
validate_wav_bytes,
wav_bytes_to_float32,
)
+from app.theory.domain.evaluator import TheoryEvaluator
from app.theory.domain.exceptions import (
TaskTimerNotEnabledError,
TaskTimerNotExpiredError,
TheorySectionNotFoundError,
)
-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
+from app.theory.domain.timer import TaskTimer
+from app.theory.support.answer_commit import CommitAnswerResult
+from app.theory.use_cases.navigate_tasks import TheoryTaskNavigation
logger = logging.getLogger(__name__)
@@ -108,7 +106,7 @@ async def _evaluate_last_follow_up_in_background(
"""
try:
if audio_wav is not None:
- evaluation, _, _ = await TheoryEvaluatorService.evaluate_submission(
+ evaluation, _, _ = await TheoryEvaluator.evaluate_submission(
provider=provider,
locale=locale,
answer_round=round_num,
@@ -120,7 +118,7 @@ async def _evaluate_last_follow_up_in_background(
audio_wav=audio_wav,
)
else:
- evaluation, _, _ = await TheoryEvaluatorService.evaluate_submission(
+ evaluation, _, _ = await TheoryEvaluator.evaluate_submission(
provider=provider,
locale=locale,
answer_round=round_num,
@@ -132,7 +130,7 @@ async def _evaluate_last_follow_up_in_background(
answer_text=answer_text,
)
with InterviewUnitOfWork(auto_commit=True) as uow:
- TheoryEvaluationPersistenceService(uow).persist_evaluation_only(
+ CommitAnswerResult(uow).persist_evaluation_only(
interview_id=interview_id,
question_id=question_id,
round_num=round_num,
@@ -147,16 +145,16 @@ async def _evaluate_last_follow_up_in_background(
)
-class TheorySubmissionService:
+class SubmitTheoryAnswer:
"""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,
+ persistence: CommitAnswerResult | None = None,
+ navigation: TheoryTaskNavigation | None = None,
+ timer: TaskTimer | None = None,
) -> None:
"""Initialize with the active unit of work.
@@ -167,11 +165,11 @@ def __init__(
timer: Optional timer collaborator.
"""
self._uow = uow
- self._navigation = navigation or TheoryNavigationService(uow)
- self._persistence = persistence or TheoryEvaluationPersistenceService(
+ self._navigation = navigation or TheoryTaskNavigation(uow)
+ self._persistence = persistence or CommitAnswerResult(
uow, navigation=self._navigation
)
- self._timer = timer or TheoryTimerService(uow, navigation=self._navigation)
+ self._timer = timer or TaskTimer(uow, navigation=self._navigation)
@staticmethod
def require_audio_answer_enabled() -> None:
@@ -496,7 +494,7 @@ async def _iter_answer_submission(
self._release_submission_write_lock()
- if ctx.round_num >= TheoryEvaluatorService.MAX_FOLLOW_UP_DEPTH:
+ if ctx.round_num >= TheoryEvaluator.MAX_FOLLOW_UP_DEPTH:
yield AnswerSavedEvent()
yield self._persistence.advance_without_evaluation(
interview_id=interview_id,
@@ -520,7 +518,7 @@ async def _iter_answer_submission(
follow_up_needed,
follow_up_text,
) = await asyncio.shield(
- TheoryEvaluatorService.evaluate_submission(
+ TheoryEvaluator.evaluate_submission(
provider=provider,
locale=ctx.locale,
answer_round=ctx.round_num,
@@ -610,7 +608,7 @@ async def _iter_audio_answer_submission(
logger.info("[AudioAnswer] Context opened, round=%d", ctx.round_num)
yield AnswerSavedEvent()
- if ctx.round_num >= TheoryEvaluatorService.MAX_FOLLOW_UP_DEPTH:
+ if ctx.round_num >= TheoryEvaluator.MAX_FOLLOW_UP_DEPTH:
yield self._persistence.advance_without_evaluation(
interview_id=interview_id,
question_id=ctx.question_id,
@@ -657,7 +655,7 @@ async def _iter_audio_answer_submission(
name=f"audio-transcript-{interview_id}-{ctx.question_id}-r{ctx.round_num}",
)
evaluation_task = asyncio.create_task(
- TheoryEvaluatorService.evaluate_submission(
+ TheoryEvaluator.evaluate_submission(
provider=provider,
locale=ctx.locale,
answer_round=ctx.round_num,
diff --git a/assets/coding.png b/assets/coding.png
deleted file mode 100644
index a13dfff..0000000
Binary files a/assets/coding.png and /dev/null differ
diff --git a/assets/dashboard.png b/assets/dashboard.png
deleted file mode 100644
index bd09ec9..0000000
Binary files a/assets/dashboard.png and /dev/null differ
diff --git a/assets/interview-session.png b/assets/interview-session.png
deleted file mode 100644
index 6cab251..0000000
Binary files a/assets/interview-session.png and /dev/null differ
diff --git a/assets/interview-setup.png b/assets/interview-setup.png
deleted file mode 100644
index 67123dd..0000000
Binary files a/assets/interview-setup.png and /dev/null differ
diff --git a/data/config.json.bak b/data/config.json.bak
deleted file mode 100644
index 59218d1..0000000
--- a/data/config.json.bak
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "timeout": 60.0,
- "locale": "en",
- "speech_model_size": "medium",
- "question_voice_enabled": false,
- "tts_voice_id": "en_US-lessac-medium"
-}
\ No newline at end of file
diff --git a/data/config.json.tmp b/data/config.json.tmp
deleted file mode 100644
index e183951..0000000
--- a/data/config.json.tmp
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "timeout": 120.0,
- "locale": "ru",
- "speech_model_size": "medium",
- "question_voice_enabled": false,
- "tts_voice_id": "ru_RU-irina-medium"
-}
diff --git a/data/llm_models.json.bak b/data/llm_models.json.bak
deleted file mode 100644
index b28e248..0000000
--- a/data/llm_models.json.bak
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "selected": "google-gemini-3-1-flash-lite-polza",
- "models": {
- "google-gemini-3-1-flash-lite-polza": {
- "display_name": "google/gemini-3.1-flash-lite(polza)",
- "provider_type": "openai-compatible",
- "model": "google/gemini-3.1-flash-lite",
- "base_url": "https://polza.ai/api/v1",
- "api_key_required": true,
- "accepts_audio_input": true,
- "api_key": "pza_SwTJ0dA49_faG9bcz5TPi6i2EFg74GWw"
- }
- }
-}
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index d44ec59..74852e5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "grillkit"
-version = "2026.5.20"
+version = "2026.6.12"
description = "AI Interview Trainer - Practice technical interviews with AI"
license = "Apache-2.0"
license-files = ["LICENSE", "NOTICE"]
diff --git a/static/css/styles.css b/static/css/styles.css
index 70d1989..e7f0196 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -1,41 +1,43 @@
/* GrillKit - AI Interview Trainer Styles */
:root {
- /* Backgrounds (depth levels 0 -> 2) */
- --bg-primary: #0B0C0F;
- --bg-secondary: #111318;
- --bg-tertiary: #151821;
+ color-scheme: light;
- --surface: #171B26;
- --surface-hover: #1D2230;
+ /* Backgrounds (warm neutral, no colour cast) */
+ --bg-primary: #F7F6F3;
+ --bg-secondary: #F0EFEA;
+ --bg-tertiary: #E9E7E1;
- --border: #2B3140;
- --border-subtle: #202633;
+ --surface: #FFFFFF;
+ --surface-hover: #F4F2EC;
+
+ --border: #DAD7CE;
+ --border-subtle: #E6E3DB;
/* Text */
- --text-primary: #F3F4F6;
- --text-secondary: #94A3B8;
- --text-muted: #64748B;
+ --text-primary: #211F1B;
+ --text-secondary: #57534A;
+ --text-muted: #8A8579;
- /* 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);
+ /* Brand / accent — single restrained ember */
+ --primary: #C2410C;
+ --primary-hover: #A63A0A;
+ --secondary: #9A3412;
+ --primary-soft: rgb(194 65 12 / 0.10);
+ --primary-soft-strong: rgb(194 65 12 / 0.16);
/* Status */
- --success: #22C55E;
- --warning: #F59E0B;
- --danger: #EF4444;
- --info: #38BDF8;
+ --success: #2F9E44;
+ --warning: #D97706;
+ --danger: #DC2626;
+ --info: #0B7285;
- /* Editor / terminal surfaces */
- --editor-bg: #0D1117;
- --editor-border: #30363D;
- --editor-line: #6E7681;
+ /* Editor / terminal surfaces (kept dark for code) */
+ --editor-bg: #1F1E1A;
+ --editor-border: #35332D;
+ --editor-line: #6E6A60;
- /* Legacy aliases mapped onto the new system */
+ /* Legacy aliases */
--bg-surface: var(--surface);
--border-color: var(--border);
--border-light: var(--border-subtle);
@@ -44,7 +46,7 @@
--accent-hover: var(--primary-hover);
--accent-light: var(--primary-soft);
--accent-secondary: var(--secondary);
- --accent-secondary-hover: #7C3AED;
+ --accent-secondary-hover: #7A2D0E;
--content-panel-bg: var(--surface);
--content-panel-border: var(--border);
@@ -52,24 +54,24 @@
--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);
+ --content-panel-shadow: 0 1px 2px rgb(0 0 0 / 0.06);
- --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);
+ --success-bg: rgb(47 158 68 / 0.12);
+ --success-text: #237A32;
+ --success-border: rgb(47 158 68 / 0.35);
+ --error-bg: rgb(220 38 38 / 0.10);
+ --error-text: #B91C1C;
+ --error-border: rgb(220 38 38 / 0.35);
+ --warning-bg: rgb(217 119 6 / 0.12);
+ --warning-text: #9A6A00;
+ --warning-border: rgb(217 119 6 / 0.35);
- --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);
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.06);
+ --shadow-md: 0 4px 12px -2px rgb(0 0 0 / 0.08);
+ --shadow-lg: 0 12px 32px -8px rgb(0 0 0 / 0.12);
- --glow-focus: 0 0 0 1px rgb(94 106 210 / 0.4), 0 0 20px rgb(94 106 210 / 0.15);
+ --glow-focus: 0 0 0 1px rgb(194 65 12 / 0.35), 0 0 0 4px rgb(194 65 12 / 0.12);
--radius-sm: 0.375rem;
--radius: 0.5rem;
@@ -82,8 +84,80 @@
--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;
+}
+[data-theme="dark"] {
color-scheme: dark;
+
+ /* Backgrounds (warm neutral, no colour cast) */
+ --bg-primary: #161512;
+ --bg-secondary: #1D1C18;
+ --bg-tertiary: #23221C;
+
+ --surface: #23221D;
+ --surface-hover: #2B2A24;
+
+ --border: #3A382F;
+ --border-subtle: #2C2B24;
+
+ /* Text */
+ --text-primary: #EDEAE3;
+ --text-secondary: #A6A197;
+ --text-muted: #736E63;
+
+ /* Brand / accent — single restrained ember */
+ --primary: #E07A3C;
+ --primary-hover: #EC8A50;
+ --secondary: #D2691E;
+ --primary-soft: rgb(224 122 60 / 0.14);
+ --primary-soft-strong: rgb(224 122 60 / 0.22);
+
+ /* Status */
+ --success: #57C660;
+ --warning: #F5B23C;
+ --danger: #F0675C;
+ --info: #4CB8C9;
+
+ /* Editor / terminal surfaces */
+ --editor-bg: #0D0D0B;
+ --editor-border: #2E2C25;
+ --editor-line: #6E6A60;
+
+ /* Legacy aliases */
+ --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: #E56E2A;
+
+ --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(87 198 96 / 0.14);
+ --success-text: #7CE08A;
+ --success-border: rgb(87 198 96 / 0.4);
+ --error-bg: rgb(240 103 92 / 0.14);
+ --error-text: #F79A90;
+ --error-border: rgb(240 103 92 / 0.4);
+ --warning-bg: rgb(245 178 60 / 0.14);
+ --warning-text: #F7C660;
+ --warning-border: rgb(245 178 60 / 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(224 122 60 / 0.4), 0 0 0 4px rgb(224 122 60 / 0.15);
}
* {
@@ -92,6 +166,11 @@
box-sizing: border-box;
}
+/* The hidden attribute must always win over display rules on components. */
+[hidden] {
+ display: none !important;
+}
+
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
@@ -126,7 +205,7 @@ body {
height: 4rem;
display: flex;
align-items: center;
- justify-content: space-between;
+ gap: 1.5rem;
position: sticky;
top: 0;
z-index: 50;
@@ -141,6 +220,7 @@ body {
display: flex;
align-items: center;
gap: 0.5rem;
+ flex-shrink: 0;
}
.navbar-brand:hover {
@@ -152,6 +232,7 @@ body {
align-items: center;
gap: 0.5rem;
list-style: none;
+ margin-right: auto;
}
.nav-link {
@@ -174,6 +255,46 @@ body {
background-color: var(--primary-soft);
}
+.theme-toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ margin-left: 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background-color: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: 0.9375rem;
+ line-height: 1;
+ transition: var(--transition-ui);
+}
+
+.theme-toggle:hover {
+ color: var(--text-primary);
+ background-color: var(--surface-hover);
+ border-color: var(--border);
+}
+
+.theme-toggle:focus-visible {
+ outline: none;
+ box-shadow: var(--glow-focus);
+}
+
+.theme-toggle__icon--sun {
+ display: none;
+}
+
+[data-theme="dark"] .theme-toggle__icon--sun {
+ display: inline;
+}
+
+[data-theme="dark"] .theme-toggle__icon--moon {
+ display: none;
+}
+
.main-content {
flex: 1;
padding: 2rem 1.5rem;
@@ -326,9 +447,9 @@ a:hover {
}
.btn-danger:hover {
- background-color: rgb(239 68 68 / 0.25);
+ background-color: var(--error-bg);
border-color: var(--danger);
- color: #fecaca;
+ color: var(--error-text);
text-decoration: none;
}
@@ -626,6 +747,22 @@ textarea.form-control {
.navbar {
padding: 0 1rem;
height: 3.5rem;
+ gap: 0.75rem;
+ }
+
+ .navbar-nav {
+ overflow-x: auto;
+ margin-right: 0;
+ scrollbar-width: none;
+ }
+
+ .navbar-nav::-webkit-scrollbar {
+ display: none;
+ }
+
+ .theme-toggle {
+ margin-left: 0;
+ flex-shrink: 0;
}
.main-content {
@@ -679,11 +816,12 @@ textarea.form-control {
min-height: 0;
min-width: 0;
height: calc(100vh - 4rem);
+ background-color: var(--bg-primary);
}
.interview-sidebar {
- flex: 0 0 min(38%, 22rem);
- width: min(38%, 22rem);
+ flex: 0 0 20rem;
+ width: 20rem;
min-width: 16rem;
display: flex;
flex-direction: column;
@@ -929,21 +1067,15 @@ textarea.form-control {
}
.interview-chat-panel .chat-bubble.question-bubble {
- background-color: #1B2432;
background-color: var(--content-panel-bg);
- border: 1px solid #2F3D4F;
border: 1px solid var(--content-panel-border);
- color: #EAEFF4;
color: var(--content-panel-text);
box-shadow: var(--content-panel-shadow);
}
.question-bubble {
- background-color: #1B2432;
background-color: var(--content-panel-bg);
- border: 1px solid #2F3D4F;
border: 1px solid var(--content-panel-border);
- color: #EAEFF4;
color: var(--content-panel-text);
margin-right: auto;
margin-left: 0;
@@ -951,7 +1083,6 @@ textarea.form-control {
}
.question-bubble strong {
- color: #B4BEC9;
color: var(--content-panel-label);
}
@@ -967,7 +1098,7 @@ textarea.form-control {
.answer-bubble {
background-color: var(--primary-soft);
- border: 1px solid rgb(94 106 210 / 0.3);
+ border: 1px solid var(--primary-soft-strong);
color: var(--text-primary);
margin-left: auto;
margin-right: 0;
@@ -1185,9 +1316,9 @@ textarea.form-control {
}
.history-status--active {
- background-color: rgb(56 189 248 / 0.15);
+ background-color: color-mix(in srgb, var(--info) 14%, transparent);
color: var(--info);
- border: 1px solid rgb(56 189 248 / 0.3);
+ border: 1px solid color-mix(in srgb, var(--info) 30%, transparent);
}
.history-status--completed {
@@ -1522,11 +1653,6 @@ textarea.form-control {
flex: 1 1 auto;
}
-.coding-session__brief--has-runs .coding-session__brief-card {
- flex: 1 1 45%;
- max-height: 55%;
-}
-
.coding-session__task-label {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
@@ -1623,19 +1749,25 @@ textarea.form-control {
.coding-session__runs {
display: flex;
flex-direction: column;
- flex: 1 1 auto;
+ flex: 0 1 auto;
min-height: 0;
min-width: 0;
- margin: 0.375rem 0.75rem 0.75rem;
+ max-height: 42%;
+ margin: 0.75rem;
border-radius: var(--radius-md);
background-color: var(--editor-bg);
border: 1px solid var(--editor-border);
box-shadow: var(--content-panel-shadow);
overflow: hidden;
+ transition: max-height var(--transition-base), margin var(--transition-base), border-color var(--transition-base);
}
.coding-session__runs-header {
flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--editor-border);
font-size: 0.75rem;
@@ -1645,6 +1777,26 @@ textarea.form-control {
color: var(--text-secondary);
}
+.coding-session__runs-toggle {
+ padding: 0.25rem 0.625rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--border-color);
+ background-color: transparent;
+ color: var(--text-secondary);
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: none;
+ letter-spacing: 0.02em;
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.coding-session__runs-toggle:hover {
+ color: var(--text-primary);
+ background-color: var(--surface-hover);
+ border-color: var(--border);
+}
+
.coding-session__output {
flex: 1;
min-height: 0;
@@ -1701,8 +1853,8 @@ textarea.form-control {
max-height: 40vh;
}
- .coding-session__brief--has-runs .coding-session__brief-card {
- max-height: 22vh;
+ .coding-session__runs {
+ max-height: 40vh;
}
}
diff --git a/static/js/coding_editor.js b/static/js/coding_editor.js
index ecfba7d..7ba280d 100644
--- a/static/js/coding_editor.js
+++ b/static/js/coding_editor.js
@@ -50,20 +50,42 @@
return document.getElementById("coding-runs-panel");
}
- function getBrief() {
- return document.querySelector(".coding-session__brief");
+ let runsCollapsed = false;
+
+ function getRunsToggle() {
+ return document.getElementById("coding-runs-toggle");
+ }
+
+ function updateRunsToggle(panel) {
+ const toggleBtn = getRunsToggle();
+ if (!toggleBtn) {
+ return;
+ }
+ const hidden = panel ? panel.hidden : false;
+ toggleBtn.textContent = hidden ? "Show" : "Hide";
+ toggleBtn.setAttribute("aria-expanded", String(!hidden));
+ }
+
+ function toggleRunsPanel() {
+ const panel = getRunsPanel();
+ if (!panel) {
+ return;
+ }
+ runsCollapsed = !panel.hidden;
+ panel.hidden = runsCollapsed;
+ updateRunsToggle(panel);
}
function syncRunsPanel(container) {
const output = container || document.getElementById("coding-output");
const hasContent = output && output.childElementCount > 0;
const panel = getRunsPanel();
- const brief = getBrief();
if (panel) {
- panel.hidden = !hasContent;
- }
- if (brief) {
- brief.classList.toggle("coding-session__brief--has-runs", hasContent);
+ if (hasContent) {
+ runsCollapsed = false;
+ }
+ panel.hidden = !hasContent || runsCollapsed;
+ updateRunsToggle(panel);
}
}
@@ -295,5 +317,22 @@
},
syncRunsPanel: syncRunsPanel,
+ toggleRunsPanel: toggleRunsPanel,
};
+
+ function bindRunsToggle() {
+ const toggleBtn = getRunsToggle();
+ if (!toggleBtn) {
+ return;
+ }
+ toggleBtn.addEventListener("click", function () {
+ window.grillkitCodingEditor.toggleRunsPanel();
+ });
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", bindRunsToggle);
+ } else {
+ bindRunsToggle();
+ }
})();
diff --git a/templates/base.html b/templates/base.html
index a90f24b..a6d7280 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -2,8 +2,16 @@
-
{% block title %}GrillKit{% endblock %}
+
{% block extra_head %}{% endblock %}
@@ -17,12 +25,28 @@
Known Questions
Configuration
+
{% block content %}{% endblock %}
+
{% block scripts %}{% endblock %}