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 @@ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-yellow.svg)](https://opensource.org/licenses/Apache-2.0) -[![Version](https://img.shields.io/badge/version-2026.6.12-blue.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-2026.8.9-blue.svg)](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 - -

- GrillKit dashboard -

- -**Interview setup** — question-bank tracks, levels, topics, and session options - -

- Interview setup -

- -**Coding section** — Monaco editor, Run on public tests, Submit for AI evaluation - -

- Coding interview session -

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

- Completed interview with evaluation -

- ## 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 %} diff --git a/templates/coding_interview.html b/templates/coding_interview.html index b0cd663..c5a9fd5 100644 --- a/templates/coding_interview.html +++ b/templates/coding_interview.html @@ -68,10 +68,6 @@

    {{ coding.current_task.prompt_text }}
    -
    @@ -87,6 +83,14 @@

    + +