From 9d7b857f194b17ed782379ba7dbae147a644db46 Mon Sep 17 00:00:00 2001 From: vitchenkokir Date: Sun, 9 Aug 2026 16:42:33 +0300 Subject: [PATCH] refactor: layered architecture (use_cases/queries/domain) + UI theme + release 2026.8.9 - Replace */services/ layer with use_cases/, queries/, domain/ and support/ - Move evaluators, rules, and view-model builders into their owning layers - New shared helpers: timed_task, section, json_parser, infrastructure gateways - UI: light theme by default with dark-mode toggle (sun/moon), warm ember accent - Docs: add release 2026.8.9 to CHANGELOG, theme note in README, architecture update - Remove assets/ (screenshots re-uploaded to GitHub) and leftover backups - Ignore SQLite WAL/SHM files --- .gitignore | 2 + ARCHITECTURE.md | 372 ++++++++++-------- CHANGELOG.md | 14 + README.md | 27 +- app/ai/__init__.py | 10 +- app/ai/audio_probe.py | 21 +- app/ai/base.py | 111 ++---- app/ai/faster_whisper_transcriber.py | 2 +- app/ai/openai_compatible.py | 70 ++-- app/coding/api/routes.py | 23 +- app/coding/api/ws_protocol.py | 4 +- app/coding/api/ws_session.py | 6 +- app/coding/domain/entities.py | 370 ++++------------- .../service.py => domain/evaluator.py} | 25 +- .../models.py => domain/evaluator_models.py} | 0 .../evaluator_prompts.py} | 0 app/coding/domain/exceptions.py | 4 +- .../coding/domain/rules}/__init__.py | 0 .../run_execution.py => domain/run_result.py} | 161 +++----- .../planning.py => domain/task_planner.py} | 14 +- .../harness.py => domain/test_harness.py} | 0 .../coding/queries}/__init__.py | 0 .../{services/state.py => queries/loader.py} | 2 +- .../review.py => queries/review_page.py} | 10 +- .../section.py => queries/section_state.py} | 22 +- .../query.py => queries/section_summary.py} | 8 +- .../page.py => queries/task_page.py} | 12 +- app/coding/repositories/mappers.py | 2 +- app/coding/services/__init__.py | 3 - app/coding/services/evaluator/__init__.py | 15 - .../rules => app/coding/support}/__init__.py | 0 .../coding_availability.py} | 9 +- .../evaluation_commit.py} | 13 +- app/coding/{services => support}/events.py | 0 app/coding/support/run_result_mapper.py | 61 +++ .../coding/use_cases}/__init__.py | 0 .../create_section.py} | 2 +- .../navigate_tasks.py} | 45 +-- .../runner.py => use_cases/run_tests.py} | 18 +- .../submit_solution.py} | 32 +- app/interview/api/dashboard.py | 4 +- app/interview/api/deps.py | 154 ++++---- app/interview/api/known_questions.py | 64 +-- app/interview/api/results.py | 4 +- app/interview/api/routes.py | 12 +- app/interview/api/setup.py | 10 +- app/interview/api/setup_form.py | 4 +- .../evaluation_aggregator.py | 2 +- app/interview/{services => domain}/events.py | 0 .../interview/domain/rules}/__init__.py | 0 .../rules/bank_selection.py | 0 .../{services => domain}/rules/feedback.py | 0 .../{services => domain}/rules/selection.py | 10 +- app/interview/{services => domain}/scoring.py | 2 +- .../section_evaluation.py | 2 +- .../{services => domain}/section_feedback.py | 0 app/interview/domain/serialization.py | 9 +- .../session_evaluation.py} | 29 +- .../sections.py => domain/session_phases.py} | 14 +- .../interview/queries}/__init__.py | 0 .../{services => queries}/dashboard.py | 25 +- .../{services/query.py => queries/loader.py} | 11 +- app/interview/queries/projection.py | 182 +++++++++ .../{services => queries}/results_page.py | 28 +- .../review_context.py} | 21 +- .../page.py => queries/session_page.py} | 34 +- app/interview/repositories/interview.py | 16 +- app/interview/repositories/mappers.py | 79 ++-- app/interview/schemas/interview.py | 5 +- app/interview/services/__init__.py | 3 - app/interview/services/known_questions.py | 79 ---- app/interview/services/read_model.py | 86 ---- app/interview/services/section_prefetch.py | 63 --- .../interview/support}/__init__.py | 0 .../{services => support}/ai_errors.py | 0 .../{services => support}/bank_text.py | 0 .../feedback_prefetch.py} | 158 +++----- app/interview/use_cases/__init__.py | 0 .../phases.py => use_cases/advance_phase.py} | 12 +- .../complete_session.py} | 28 +- .../create_session.py} | 45 ++- app/main.py | 21 +- app/platform/api/config.py | 12 +- app/platform/api/deps.py | 2 +- app/platform/domain/__init__.py | 0 app/platform/{services => domain}/config.py | 51 ++- .../{services => domain}/llm_catalog.py | 8 +- app/platform/domain/rules/__init__.py | 0 .../{services => domain}/speech_runtime.py | 12 +- .../{services => domain}/speech_settings.py | 2 +- app/platform/queries/__init__.py | 0 .../{services => queries}/config_form.py | 4 +- .../{services => queries}/llm_page.py | 4 +- .../page.py => queries/platform_page.py} | 11 +- app/platform/schemas.py | 34 -- app/platform/services/__init__.py | 3 - app/platform/support/__init__.py | 0 app/platform/use_cases/__init__.py | 0 app/question_voice/api/deps.py | 2 +- app/question_voice/api/routes.py | 4 +- app/question_voice/queries/__init__.py | 0 .../page.py => queries/voice_page.py} | 16 +- .../status.py => queries/voice_status.py} | 8 +- app/question_voice/services/__init__.py | 3 - app/question_voice/services/question_audio.py | 82 ---- app/question_voice/support/__init__.py | 0 app/question_voice/use_cases/__init__.py | 0 .../use_cases/generate_question_audio.py | 94 +++++ app/shared/api/negotiated_response.py | 21 +- app/shared/application/uow_deps.py | 2 + app/shared/infrastructure/database.py | 10 +- .../infrastructure/gateways/__init__.py | 0 .../infrastructure/gateways}/ai_context.py | 2 +- .../infrastructure/gateways/judge0.py} | 8 +- .../infrastructure/gateways}/judge0_config.py | 0 .../infrastructure/gateways/piper.py} | 7 +- .../infrastructure/gateways}/piper_storage.py | 0 .../infrastructure/gateways}/piper_voice.py | 8 +- .../infrastructure/gateways}/tts_cache.py | 8 +- .../gateways}/tts_exceptions.py | 0 .../infrastructure/gateways/whisper.py} | 7 +- .../infrastructure/gateways}/whisper_model.py | 12 +- .../gateways}/whisper_storage.py | 0 app/shared/infrastructure/models.py | 2 +- app/shared/infrastructure/uow.py | 5 +- app/shared/json_parser.py | 145 +++++++ app/shared/repositories/base.py | 3 +- app/shared/section.py | 222 +++++++++++ app/shared/structured_evaluation.py | 139 +++---- app/shared/timed_task.py | 107 +++++ app/speech/api/deps.py | 2 +- app/speech/api/dictation.py | 6 +- app/speech/api/routes.py | 2 +- app/speech/{services => domain}/__init__.py | 2 +- app/speech/domain/rules/__init__.py | 0 .../transcriber_resolver.py | 6 +- app/speech/queries/__init__.py | 0 app/speech/{services => queries}/readiness.py | 7 +- .../page.py => queries/speech_page.py} | 4 +- app/speech/support/__init__.py | 0 app/speech/use_cases/__init__.py | 0 .../{services => use_cases}/dictation.py | 0 app/theory/api/audio_answer.py | 6 +- app/theory/api/routes.py | 34 +- app/theory/api/ws_protocol.py | 16 +- app/theory/api/ws_session.py | 12 +- app/theory/domain/entities.py | 364 +++-------------- .../service.py => domain/evaluator.py} | 70 ++-- .../models.py => domain/evaluator_models.py} | 0 .../evaluator_prompts.py} | 0 app/theory/domain/exceptions.py | 4 +- .../question_planner.py} | 14 +- app/theory/domain/rules/__init__.py | 0 app/theory/{services => domain}/timer.py | 31 +- app/theory/queries/__init__.py | 0 .../{services/query.py => queries/loader.py} | 8 +- .../review.py => queries/review_page.py} | 10 +- .../section.py => queries/section_state.py} | 22 +- .../page.py => queries/task_page.py} | 12 +- app/theory/services/__init__.py | 3 - app/theory/services/evaluator/__init__.py | 17 - app/theory/support/__init__.py | 0 .../answer_commit.py} | 48 ++- app/theory/use_cases/__init__.py | 0 .../create_section.py} | 6 +- .../navigate_tasks.py} | 8 +- .../submit_answer.py} | 48 ++- assets/coding.png | Bin 77850 -> 0 bytes assets/dashboard.png | Bin 82408 -> 0 bytes assets/interview-session.png | Bin 118418 -> 0 bytes assets/interview-setup.png | Bin 64662 -> 0 bytes data/config.json.bak | 7 - data/config.json.tmp | 7 - data/llm_models.json.bak | 14 - pyproject.toml | 2 +- static/css/styles.css | 284 +++++++++---- static/js/coding_editor.js | 53 ++- templates/base.html | 26 +- templates/coding_interview.html | 12 +- tests/ai/test_faster_whisper_transcriber.py | 6 +- tests/app/test_main.py | 14 +- tests/coding/api/test_coding_full_flow.py | 4 +- tests/coding/api/test_routes.py | 14 +- tests/coding/api/test_ws_protocol.py | 4 +- tests/coding/api/test_ws_session.py | 4 +- .../{services => domain}/test_evaluator.py | 8 +- .../test_task_planner.py} | 10 +- .../test_test_harness.py} | 2 +- tests/coding/queries/__init__.py | 0 .../test_review_page.py} | 8 +- .../test_section_state.py} | 8 +- .../test_task_page.py} | 10 +- tests/coding/support/__init__.py | 0 .../test_coding_availability.py} | 26 +- tests/coding/use_cases/__init__.py | 0 tests/coding/use_cases/test_create_section.py | 10 + .../test_run_tests.py} | 4 +- .../coding/use_cases/test_submit_solution.py | 20 + tests/conftest.py | 31 +- tests/e2e/test_e2e_full_lifecycle.py | 16 +- tests/fakes.py | 18 +- tests/helpers/known_questions_seed.py | 6 +- tests/helpers/session_creation.py | 11 +- .../api/test_combined_phase_switch.py | 6 +- tests/interview/api/test_dashboard.py | 14 +- tests/interview/api/test_errors.py | 2 +- tests/interview/api/test_known_questions.py | 4 +- .../interview/api/test_negative_scenarios.py | 2 +- tests/interview/api/test_review_mark_known.py | 4 +- tests/interview/api/test_routes.py | 4 +- tests/interview/api/test_setup.py | 16 +- .../interview/api/test_setup_exclude_known.py | 6 +- tests/interview/api/test_setup_modes.py | 16 +- tests/interview/api/test_setup_negative.py | 20 +- tests/interview/api/test_timer.py | 2 +- tests/interview/domain/__init__.py | 0 tests/interview/domain/rules/__init__.py | 0 .../rules/test_feedback.py | 2 +- .../{services => domain}/test_scoring.py | 20 +- .../test_section_feedback.py | 2 +- .../test_session_evaluation.py | 10 +- tests/interview/queries/__init__.py | 0 .../{services => queries}/test_dashboard.py | 6 +- .../test_query.py => queries/test_loader.py} | 14 +- .../test_projection.py} | 6 +- .../test_results_page.py | 6 +- .../test_review_context.py} | 16 +- .../test_session_page.py} | 6 +- .../interview/repositories/test_interview.py | 2 +- .../services/test_known_questions.py | 19 - tests/interview/services/test_selection.py | 272 ------------- tests/interview/support/__init__.py | 0 .../{services => support}/test_ai_errors.py | 2 +- .../{services => support}/test_bank_text.py | 34 +- .../test_feedback_prefetch.py} | 12 +- tests/interview/use_cases/__init__.py | 0 .../test_advance_phase.py} | 12 +- .../test_complete_session.py} | 14 +- .../test_create_session.py} | 8 +- tests/platform/api/test_config.py | 48 ++- tests/platform/api/test_config_edge_cases.py | 42 +- tests/platform/api/test_config_flow.py | 48 ++- tests/platform/domain/__init__.py | 0 .../{services => domain}/test_config.py | 45 +-- .../{services => domain}/test_llm_catalog.py | 6 +- .../test_speech_runtime.py | 110 ++++-- .../test_speech_settings.py | 4 +- tests/platform/queries/__init__.py | 0 .../{services => queries}/test_config_form.py | 18 +- tests/platform/repositories/__init__.py | 0 tests/platform/services/test_ai_context.py | 94 ----- tests/platform/support/__init__.py | 0 tests/platform/use_cases/__init__.py | 0 tests/question_voice/api/test_routes.py | 38 +- tests/question_voice/api/test_tts.py | 30 +- tests/question_voice/queries/__init__.py | 0 .../test_voice_status.py} | 12 +- .../services/test_piper_runtime.py | 188 --------- .../services/test_piper_storage.py | 27 -- .../question_voice/services/test_tts_cache.py | 93 ----- tests/question_voice/support/__init__.py | 0 tests/question_voice/use_cases/__init__.py | 0 .../test_generate_question_audio.py} | 32 +- .../infrastructure/gateways/__init__.py | 0 .../infrastructure/gateways/test_judge0.py} | 22 +- .../infrastructure/test_artifact_download.py | 4 +- tests/shared/infrastructure/test_audio_wav.py | 5 +- tests/shared/test_structured_evaluation.py | 43 +- tests/speech/api/test_dictation_ws.py | 12 +- tests/speech/api/test_routes.py | 48 ++- tests/speech/domain/__init__.py | 0 .../test_transcriber_resolver.py | 20 +- tests/speech/queries/__init__.py | 0 .../{services => queries}/test_readiness.py | 8 +- tests/speech/services/test_whisper_model.py | 323 --------------- tests/speech/services/test_whisper_runtime.py | 59 --- tests/speech/services/test_whisper_storage.py | 114 ------ tests/speech/support/__init__.py | 0 tests/speech/use_cases/__init__.py | 0 .../{services => use_cases}/test_dictation.py | 2 +- tests/theory/api/test_audio_answer.py | 31 +- tests/theory/api/test_routes.py | 4 +- tests/theory/api/test_theory_full_flow.py | 2 +- tests/theory/api/test_ws_protocol.py | 2 +- tests/theory/api/test_ws_routes.py | 18 +- tests/theory/domain/__init__.py | 0 .../{services => domain}/test_evaluator.py | 4 +- .../test_evaluator_parsing.py | 4 +- .../test_question_planner.py} | 2 +- .../theory/{services => domain}/test_timer.py | 22 +- tests/theory/integration/test_ws.py | 2 +- tests/theory/queries/__init__.py | 0 .../test_query.py => queries/test_loader.py} | 22 +- .../test_review_page.py} | 4 +- .../test_section_state.py} | 41 +- tests/theory/support/__init__.py | 0 tests/theory/use_cases/__init__.py | 0 .../test_create_section.py} | 26 +- .../test_navigate_tasks.py} | 14 +- .../test_submit_answer.py} | 36 +- uv.lock | 2 +- 301 files changed, 3328 insertions(+), 4129 deletions(-) rename app/coding/{services/evaluator/service.py => domain/evaluator.py} (91%) rename app/coding/{services/evaluator/models.py => domain/evaluator_models.py} (100%) rename app/coding/{services/evaluator/prompts.py => domain/evaluator_prompts.py} (100%) rename {tests/coding/services => app/coding/domain/rules}/__init__.py (100%) rename app/coding/{services/run_execution.py => domain/run_result.py} (57%) rename app/coding/{services/planning.py => domain/task_planner.py} (97%) rename app/coding/{services/harness.py => domain/test_harness.py} (100%) rename {tests/interview/services => app/coding/queries}/__init__.py (100%) rename app/coding/{services/state.py => queries/loader.py} (99%) rename app/coding/{services/review.py => queries/review_page.py} (96%) rename app/coding/{services/section.py => queries/section_state.py} (92%) rename app/coding/{services/query.py => queries/section_summary.py} (91%) rename app/coding/{services/page.py => queries/task_page.py} (90%) delete mode 100644 app/coding/services/__init__.py delete mode 100644 app/coding/services/evaluator/__init__.py rename {tests/interview/services/rules => app/coding/support}/__init__.py (100%) rename app/coding/{services/availability.py => support/coding_availability.py} (88%) rename app/coding/{services/evaluation_persistence.py => support/evaluation_commit.py} (96%) rename app/coding/{services => support}/events.py (100%) create mode 100644 app/coding/support/run_result_mapper.py rename {tests/platform/services => app/coding/use_cases}/__init__.py (100%) rename app/coding/{services/creation.py => use_cases/create_section.py} (98%) rename app/coding/{services/navigation.py => use_cases/navigate_tasks.py} (68%) rename app/coding/{services/runner.py => use_cases/run_tests.py} (95%) rename app/coding/{services/submission.py => use_cases/submit_solution.py} (93%) rename app/interview/{services => domain}/evaluation_aggregator.py (98%) rename app/interview/{services => domain}/events.py (100%) rename {tests/question_voice/services => app/interview/domain/rules}/__init__.py (100%) rename app/interview/{services => domain}/rules/bank_selection.py (100%) rename app/interview/{services => domain}/rules/feedback.py (100%) rename app/interview/{services => domain}/rules/selection.py (97%) rename app/interview/{services => domain}/scoring.py (97%) rename app/interview/{services => domain}/section_evaluation.py (94%) rename app/interview/{services => domain}/section_feedback.py (100%) rename app/interview/{services/session_evaluator.py => domain/session_evaluation.py} (89%) rename app/interview/{services/sections.py => domain/session_phases.py} (92%) rename {tests/speech/services => app/interview/queries}/__init__.py (100%) rename app/interview/{services => queries}/dashboard.py (93%) rename app/interview/{services/query.py => queries/loader.py} (92%) create mode 100644 app/interview/queries/projection.py rename app/interview/{services => queries}/results_page.py (93%) rename app/interview/{services/section_review_support.py => queries/review_context.py} (87%) rename app/interview/{services/page.py => queries/session_page.py} (90%) delete mode 100644 app/interview/services/__init__.py delete mode 100644 app/interview/services/known_questions.py delete mode 100644 app/interview/services/read_model.py delete mode 100644 app/interview/services/section_prefetch.py rename {tests/theory/services => app/interview/support}/__init__.py (100%) rename app/interview/{services => support}/ai_errors.py (100%) rename app/interview/{services => support}/bank_text.py (100%) rename app/interview/{services/section_service_support.py => support/feedback_prefetch.py} (65%) create mode 100644 app/interview/use_cases/__init__.py rename app/interview/{services/phases.py => use_cases/advance_phase.py} (92%) rename app/interview/{services/completion.py => use_cases/complete_session.py} (87%) rename app/interview/{services/creation.py => use_cases/create_session.py} (76%) create mode 100644 app/platform/domain/__init__.py rename app/platform/{services => domain}/config.py (90%) rename app/platform/{services => domain}/llm_catalog.py (98%) create mode 100644 app/platform/domain/rules/__init__.py rename app/platform/{services => domain}/speech_runtime.py (88%) rename app/platform/{services => domain}/speech_settings.py (96%) create mode 100644 app/platform/queries/__init__.py rename app/platform/{services => queries}/config_form.py (96%) rename app/platform/{services => queries}/llm_page.py (92%) rename app/platform/{services/page.py => queries/platform_page.py} (87%) delete mode 100644 app/platform/services/__init__.py create mode 100644 app/platform/support/__init__.py create mode 100644 app/platform/use_cases/__init__.py create mode 100644 app/question_voice/queries/__init__.py rename app/question_voice/{services/page.py => queries/voice_page.py} (74%) rename app/question_voice/{services/status.py => queries/voice_status.py} (92%) delete mode 100644 app/question_voice/services/__init__.py delete mode 100644 app/question_voice/services/question_audio.py create mode 100644 app/question_voice/support/__init__.py create mode 100644 app/question_voice/use_cases/__init__.py create mode 100644 app/question_voice/use_cases/generate_question_audio.py create mode 100644 app/shared/infrastructure/gateways/__init__.py rename app/{platform/services => shared/infrastructure/gateways}/ai_context.py (93%) rename app/{coding/services/judge0_client.py => shared/infrastructure/gateways/judge0.py} (96%) rename app/{coding/services => shared/infrastructure/gateways}/judge0_config.py (100%) rename app/{question_voice/services/piper_runtime.py => shared/infrastructure/gateways/piper.py} (95%) rename app/{question_voice/services => shared/infrastructure/gateways}/piper_storage.py (100%) rename app/{question_voice/services => shared/infrastructure/gateways}/piper_voice.py (97%) rename app/{question_voice/services => shared/infrastructure/gateways}/tts_cache.py (91%) rename app/{question_voice/services => shared/infrastructure/gateways}/tts_exceptions.py (100%) rename app/{speech/services/whisper_runtime.py => shared/infrastructure/gateways/whisper.py} (94%) rename app/{speech/services => shared/infrastructure/gateways}/whisper_model.py (97%) rename app/{speech/services => shared/infrastructure/gateways}/whisper_storage.py (100%) create mode 100644 app/shared/json_parser.py create mode 100644 app/shared/section.py create mode 100644 app/shared/timed_task.py rename app/speech/{services => domain}/__init__.py (68%) create mode 100644 app/speech/domain/rules/__init__.py rename app/speech/{services => domain}/transcriber_resolver.py (89%) create mode 100644 app/speech/queries/__init__.py rename app/speech/{services => queries}/readiness.py (82%) rename app/speech/{services/page.py => queries/speech_page.py} (90%) create mode 100644 app/speech/support/__init__.py create mode 100644 app/speech/use_cases/__init__.py rename app/speech/{services => use_cases}/dictation.py (100%) rename app/theory/{services/evaluator/service.py => domain/evaluator.py} (89%) rename app/theory/{services/evaluator/models.py => domain/evaluator_models.py} (100%) rename app/theory/{services/evaluator/prompts.py => domain/evaluator_prompts.py} (100%) rename app/theory/{services/planning.py => domain/question_planner.py} (97%) create mode 100644 app/theory/domain/rules/__init__.py rename app/theory/{services => domain}/timer.py (75%) create mode 100644 app/theory/queries/__init__.py rename app/theory/{services/query.py => queries/loader.py} (91%) rename app/theory/{services/review.py => queries/review_page.py} (93%) rename app/theory/{services/section.py => queries/section_state.py} (90%) rename app/theory/{services/page.py => queries/task_page.py} (91%) delete mode 100644 app/theory/services/__init__.py delete mode 100644 app/theory/services/evaluator/__init__.py create mode 100644 app/theory/support/__init__.py rename app/theory/{services/evaluation_persistence.py => support/answer_commit.py} (86%) create mode 100644 app/theory/use_cases/__init__.py rename app/theory/{services/creation.py => use_cases/create_section.py} (92%) rename app/theory/{services/navigation.py => use_cases/navigate_tasks.py} (94%) rename app/theory/{services/submission.py => use_cases/submit_answer.py} (94%) delete mode 100644 assets/coding.png delete mode 100644 assets/dashboard.png delete mode 100644 assets/interview-session.png delete mode 100644 assets/interview-setup.png delete mode 100644 data/config.json.bak delete mode 100644 data/config.json.tmp delete mode 100644 data/llm_models.json.bak rename tests/coding/{services => domain}/test_evaluator.py (90%) rename tests/coding/{services/test_planning.py => domain/test_task_planner.py} (98%) rename tests/coding/{services/test_harness.py => domain/test_test_harness.py} (92%) create mode 100644 tests/coding/queries/__init__.py rename tests/coding/{services/test_review.py => queries/test_review_page.py} (87%) rename tests/coding/{services/test_section.py => queries/test_section_state.py} (83%) rename tests/coding/{services/test_page.py => queries/test_task_page.py} (85%) create mode 100644 tests/coding/support/__init__.py rename tests/coding/{services/test_availability.py => support/test_coding_availability.py} (62%) create mode 100644 tests/coding/use_cases/__init__.py create mode 100644 tests/coding/use_cases/test_create_section.py rename tests/coding/{services/test_runner.py => use_cases/test_run_tests.py} (97%) create mode 100644 tests/coding/use_cases/test_submit_solution.py create mode 100644 tests/interview/domain/__init__.py create mode 100644 tests/interview/domain/rules/__init__.py rename tests/interview/{services => domain}/rules/test_feedback.py (81%) rename tests/interview/{services => domain}/test_scoring.py (94%) rename tests/interview/{services => domain}/test_section_feedback.py (94%) rename tests/interview/{services => domain}/test_session_evaluation.py (90%) create mode 100644 tests/interview/queries/__init__.py rename tests/interview/{services => queries}/test_dashboard.py (96%) rename tests/interview/{services/test_query.py => queries/test_loader.py} (92%) rename tests/interview/{services/test_read_model.py => queries/test_projection.py} (98%) rename tests/interview/{services => queries}/test_results_page.py (83%) rename tests/interview/{services/test_section_review_support.py => queries/test_review_context.py} (93%) rename tests/interview/{services/test_page.py => queries/test_session_page.py} (94%) delete mode 100644 tests/interview/services/test_known_questions.py delete mode 100644 tests/interview/services/test_selection.py create mode 100644 tests/interview/support/__init__.py rename tests/interview/{services => support}/test_ai_errors.py (97%) rename tests/interview/{services => support}/test_bank_text.py (81%) rename tests/interview/{services/test_section_prefetch.py => support/test_feedback_prefetch.py} (91%) create mode 100644 tests/interview/use_cases/__init__.py rename tests/interview/{services/test_phases.py => use_cases/test_advance_phase.py} (94%) rename tests/interview/{services/test_completion.py => use_cases/test_complete_session.py} (92%) rename tests/interview/{services/test_creation.py => use_cases/test_create_session.py} (96%) create mode 100644 tests/platform/domain/__init__.py rename tests/platform/{services => domain}/test_config.py (91%) rename tests/platform/{services => domain}/test_llm_catalog.py (97%) rename tests/platform/{services => domain}/test_speech_runtime.py (77%) rename tests/platform/{services => domain}/test_speech_settings.py (97%) create mode 100644 tests/platform/queries/__init__.py rename tests/platform/{services => queries}/test_config_form.py (91%) create mode 100644 tests/platform/repositories/__init__.py delete mode 100644 tests/platform/services/test_ai_context.py create mode 100644 tests/platform/support/__init__.py create mode 100644 tests/platform/use_cases/__init__.py create mode 100644 tests/question_voice/queries/__init__.py rename tests/question_voice/{services/test_status.py => queries/test_voice_status.py} (95%) delete mode 100644 tests/question_voice/services/test_piper_runtime.py delete mode 100644 tests/question_voice/services/test_piper_storage.py delete mode 100644 tests/question_voice/services/test_tts_cache.py create mode 100644 tests/question_voice/support/__init__.py create mode 100644 tests/question_voice/use_cases/__init__.py rename tests/question_voice/{services/test_question_audio.py => use_cases/test_generate_question_audio.py} (83%) create mode 100644 tests/shared/infrastructure/gateways/__init__.py rename tests/{coding/services/test_judge0_client.py => shared/infrastructure/gateways/test_judge0.py} (77%) create mode 100644 tests/speech/domain/__init__.py rename tests/speech/{services => domain}/test_transcriber_resolver.py (87%) create mode 100644 tests/speech/queries/__init__.py rename tests/speech/{services => queries}/test_readiness.py (82%) delete mode 100644 tests/speech/services/test_whisper_model.py delete mode 100644 tests/speech/services/test_whisper_runtime.py delete mode 100644 tests/speech/services/test_whisper_storage.py create mode 100644 tests/speech/support/__init__.py create mode 100644 tests/speech/use_cases/__init__.py rename tests/speech/{services => use_cases}/test_dictation.py (95%) create mode 100644 tests/theory/domain/__init__.py rename tests/theory/{services => domain}/test_evaluator.py (95%) rename tests/theory/{services => domain}/test_evaluator_parsing.py (95%) rename tests/theory/{services/test_planning.py => domain/test_question_planner.py} (97%) rename tests/theory/{services => domain}/test_timer.py (89%) create mode 100644 tests/theory/queries/__init__.py rename tests/theory/{services/test_query.py => queries/test_loader.py} (91%) rename tests/theory/{services/test_review.py => queries/test_review_page.py} (89%) rename tests/theory/{services/test_section.py => queries/test_section_state.py} (84%) create mode 100644 tests/theory/support/__init__.py create mode 100644 tests/theory/use_cases/__init__.py rename tests/theory/{services/test_creation.py => use_cases/test_create_section.py} (85%) rename tests/theory/{services/test_navigation.py => use_cases/test_navigate_tasks.py} (94%) rename tests/theory/{services/test_submission.py => use_cases/test_submit_answer.py} (96%) 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 a13dfff06333a8d6a9425a00123363abca2cac6f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 77850 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 diff --git a/assets/dashboard.png b/assets/dashboard.png deleted file mode 100644 index bd09ec9fc9f0dfb21824dc3a4ed7b58794d98e6f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 82408 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

`P{$lbmb4HHTrev}<*-k^$Y$Ep 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 diff --git a/assets/interview-session.png b/assets/interview-session.png deleted file mode 100644 index 6cab251fee60d92c542fe8de452c482dd02e0d48..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 118418 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

k{je(_*Lz87nlx|3lkgsW=&nHYimQ=Q9 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 +
{% 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 @@

+ +