diff --git a/.gitignore b/.gitignore index b5eac8b..c3ae381 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ dmypy.json /.kilocode/ /data/whisper-models/ /.qwen/ +docs/ diff --git a/.qwen/settings.json b/.qwen/settings.json deleted file mode 100644 index a32fff2..0000000 --- a/.qwen/settings.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "permissions": { - "allow": [ - "Agent(Explore)", - "Skill(qc-helper)", - "Bash(python *)", - "Bash(pytest *)", - "Bash(uv *)", - "Bash(find *)", - "Agent(general-purpose)", - "Bash(sleep *)", - "Bash(do *)", - "Bash(done)", - "Bash(python3 *)", - "Bash(ruff *)", - "Bash(\"\"\"debug submitted_code persistence.\"\"\")", - "Bash(from *)", - "Bash(engine *)", - "Bash(\"sqlite:///:memory:\",)", - "Bash(false},)", - "Bash(base.metadata.create_all)", - "Bash(session_factory *)", - "Bash(db_module.sessionlocal *)", - "Bash(session *)", - "Bash(interview *)", - "Bash(trackselection)", - "Bash(session.add)", - "Bash(session.flush)", - "Bash(section *)", - "Bash(session, *)", - "Bash(coding_sel *)", - "Bash(.coding_creation)", - "Bash(section.selection_spec *)", - "Bash(coding_sel)", - "Bash(tasks *)", - "Bash(session.commit)", - "Bash(task_id *)", - "Bash(print)", - "Bash(uow *)", - "Bash(section_agg *)", - "Bash(\"debug-1\")", - "Bash(updated *)", - "Bash(task_id,)", - "Bash(uow.coding_sections.save_aggregate)", - "Bash(uow.flush)", - "Bash(uow.commit)", - "Bash(uow.close)", - "Bash(uow2 *)", - "Bash(section2 *)", - "Bash(uow2.close)", - "Bash(pyeof)" - ] - }, - "$version": 4 -} \ No newline at end of file diff --git a/.qwen/settings.json.orig b/.qwen/settings.json.orig deleted file mode 100644 index 7338d3a..0000000 --- a/.qwen/settings.json.orig +++ /dev/null @@ -1,54 +0,0 @@ -{ - "permissions": { - "allow": [ - "Agent(Explore)", - "Skill(qc-helper)", - "Bash(python *)", - "Bash(pytest *)", - "Bash(uv *)", - "Bash(find *)", - "Agent(general-purpose)", - "Bash(sleep *)", - "Bash(do *)", - "Bash(done)", - "Bash(python3 *)", - "Bash(ruff *)", - "Bash(\"\"\"debug submitted_code persistence.\"\"\")", - "Bash(from *)", - "Bash(engine *)", - "Bash(\"sqlite:///:memory:\",)", - "Bash(false},)", - "Bash(base.metadata.create_all)", - "Bash(session_factory *)", - "Bash(db_module.sessionlocal *)", - "Bash(session *)", - "Bash(interview *)", - "Bash(trackselection)", - "Bash(session.add)", - "Bash(session.flush)", - "Bash(section *)", - "Bash(session, *)", - "Bash(coding_sel *)", - "Bash(.coding_creation)", - "Bash(section.selection_spec *)", - "Bash(coding_sel)", - "Bash(tasks *)", - "Bash(session.commit)", - "Bash(task_id *)", - "Bash(print)", - "Bash(uow *)", - "Bash(section_agg *)", - "Bash(\"debug-1\")", - "Bash(updated *)", - "Bash(task_id,)", - "Bash(uow.coding_sections.save_aggregate)", - "Bash(uow.flush)", - "Bash(uow.commit)", - "Bash(uow.close)", - "Bash(uow2 *)", - "Bash(section2 *)", - "Bash(uow2.close)" - ] - }, - "$version": 4 -} \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ed37edb..0008b3b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -628,7 +628,7 @@ Run attempts are rate-limited per task (`CODING_MAX_RUNS_PER_TASK`, default 20). ## Data Flow: Dictation WebSocket -Separate from answer/evaluation WS. Requires active interview and loaded transcriber (`app.state.speech_transcriber`). +Separate from answer/evaluation WS. Requires active interview and loaded transcriber (from the in-process Whisper runtime). ``` Client → WS connect /interview/{id}/dictation @@ -655,7 +655,7 @@ User → GET /config (speech_model_size, locale) User → POST /speech/model/download → WhisperModelService.start_download(size from config) → Hugging Face snapshot → data/whisper-models// - → WhisperRuntime.load_size(size) → app.state.speech_transcriber + → WhisperRuntime.load_size(size) → transcriber in the in-process runtime User → GET /speech/model/status (HTMX poll while downloading) ``` diff --git a/app/__init__.py b/app/__init__.py index c92de00..9b223ef 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -2,6 +2,14 @@ # SPDX-License-Identifier: Apache-2.0 """GrillKit application package.""" +from importlib.metadata import PackageNotFoundError, version + from app.shared.infrastructure.hf_hub_runtime import configure_hf_hub +try: + __version__ = version("grillkit") +except PackageNotFoundError: + __version__ = "2026.8.9" + + configure_hf_hub() diff --git a/app/coding/api/__init__.py b/app/coding/api/__init__.py index d1c6e9d..41cc0aa 100644 --- a/app/coding/api/__init__.py +++ b/app/coding/api/__init__.py @@ -1,3 +1,16 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Coding API transport layer.""" +"""Coding API transport layer. + +This package aggregates all coding sub-routers into a single ``router`` +that the application factory mounts via :func:`app.include_router`. +""" + +from fastapi import APIRouter + +from app.coding.api import routes + +router = APIRouter() +router.include_router(routes.router) + +__all__ = ["router"] diff --git a/app/interview/api/__init__.py b/app/interview/api/__init__.py index 5282dbb..6eb922a 100644 --- a/app/interview/api/__init__.py +++ b/app/interview/api/__init__.py @@ -1,3 +1,20 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Interview feature HTTP and WebSocket endpoints.""" +"""Interview feature HTTP and WebSocket endpoints. + +This package aggregates all interview sub-routers into a single ``router`` +that the application factory mounts via :func:`app.include_router`. +""" + +from fastapi import APIRouter + +from app.interview.api import dashboard, known_questions, results, routes, setup + +router = APIRouter() +router.include_router(dashboard.router) +router.include_router(setup.router) +router.include_router(known_questions.router) +router.include_router(routes.router) +router.include_router(results.router) + +__all__ = ["router"] diff --git a/app/interview/api/deps.py b/app/interview/api/deps.py index 2beb038..caa04fa 100644 --- a/app/interview/api/deps.py +++ b/app/interview/api/deps.py @@ -5,7 +5,7 @@ from collections.abc import AsyncIterator from typing import Annotated -from fastapi import Depends, HTTPException, Request +from fastapi import Depends, HTTPException from app.ai.base import AIProvider from app.ai.speech_transcriber import SpeechTranscriber @@ -21,7 +21,7 @@ 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.api.deps import SpeechRuntimeDep from app.shared.application.uow_deps import UoWAutoCommitDep, UoWDep from app.shared.infrastructure.gateways.ai_context import ai_provider_from_config from app.speech.domain.transcriber_resolver import ( @@ -216,26 +216,24 @@ def get_coding_review_service(uow: UoWDep) -> CodingReviewService: async def get_speech_transcriber( - request: Request, - config_service: ConfigServiceDep, + coordinator: SpeechRuntimeDep, ) -> SpeechTranscriber: - """Resolve a loaded Whisper transcriber from application state. + """Resolve a loaded speech transcriber through the speech runtime. Args: - request: FastAPI request with ASGI app state. - config_service: Provider configuration service. + coordinator: App-lifetime speech runtime coordinator. Returns: Loaded speech transcriber. Raises: - HTTPException: When Whisper is not installed or loaded. + HTTPException: When a speech model is not installed or loaded. """ - transcriber = await resolve_speech_transcriber(request.app, config_service) + transcriber = await resolve_speech_transcriber(coordinator) if transcriber is None: raise HTTPException( status_code=503, - detail=speech_transcriber_unavailable_message(), + detail=speech_transcriber_unavailable_message(coordinator), ) return transcriber diff --git a/app/interview/api/routes.py b/app/interview/api/routes.py index 39af7ca..5d70d08 100644 --- a/app/interview/api/routes.py +++ b/app/interview/api/routes.py @@ -12,8 +12,7 @@ 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.domain.speech_runtime import SpeechRuntimeCoordinator +from app.platform.api.deps import ConfigServiceDep, SpeechRuntimeDep from app.question_voice.use_cases.generate_question_audio import GenerateQuestionAudio from app.shared.infrastructure.gateways.tts_exceptions import ( QuestionVoiceDisabledError, @@ -46,6 +45,7 @@ async def interview_page( config_service: ConfigServiceDep, whisper_model_service: WhisperModelServiceDep, page_service: ActiveSessionPageDep, + coordinator: SpeechRuntimeDep, ) -> Response: """View an interview session. @@ -57,6 +57,8 @@ async def interview_page( interview_id: The session UUID. config_service: Provider configuration service. whisper_model_service: Whisper model download service. + page_service: Active session page service. + coordinator: App-lifetime speech runtime coordinator. Returns: HTML response with interview view, or redirect if not found. @@ -77,8 +79,7 @@ async def interview_page( status_code=303, ) - await SpeechRuntimeCoordinator.preload_whisper_for_active_interview( - request.app, + await coordinator.preload_whisper_for_active_interview( config, interview_active=page.interview_active, ) @@ -92,22 +93,26 @@ async def interview_page( @router.get("/{interview_id}/question-audio") async def question_audio( interview_id: str, + coordinator: SpeechRuntimeDep, answer_id: int | None = None, ) -> FileResponse: """Stream WAV audio for the current or specified unanswered question. Args: interview_id: Interview session UUID. + coordinator: App-lifetime speech runtime coordinator. answer_id: Optional answer row id; defaults to the first unanswered question. Returns: - ``audio/wav`` file from cache or Piper synthesis. + ``audio/wav`` file from cache or TTS synthesis. Raises: HTTPException: When voice is disabled, the session is invalid, or TTS fails. """ try: - path = await GenerateQuestionAudio.execute(interview_id, answer_id) + path = await GenerateQuestionAudio.execute( + coordinator.tts, 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/main.py b/app/main.py index 4526fa5..e90d56c 100644 --- a/app/main.py +++ b/app/main.py @@ -13,49 +13,31 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from app.coding.api import routes as coding_router -from app.interview.api import dashboard as dashboard_router -from app.interview.api import known_questions as known_questions_router -from app.interview.api import results as results_router -from app.interview.api import routes as interview_router -from app.interview.api import setup as setup_router -from app.platform.api import config as config_router +from app import __version__ +from app.coding.api import router as coding_router +from app.interview.api import router as interview_router +from app.platform.api import router as platform_router 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.question_voice.api import router as question_voice_router +from app.shared.infrastructure.gateways.piper import PiperRuntime +from app.shared.infrastructure.gateways.whisper import WhisperRuntime from app.shared.paths import STATIC_DIR -from app.speech.api import dictation as dictation_router -from app.speech.api import routes as speech_router -from app.theory.api import routes as theory_router - - -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" +from app.speech.api import router as speech_router +from app.theory.api import router as theory_router @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan handler. - Initializes database and loads the Whisper model when installed. + Creates the speech runtime coordinator, loads the Whisper model (and Piper + when configured) on startup, and unloads them on shutdown. """ - run_migrations() - await SpeechRuntimeCoordinator.startup(app) + coordinator = SpeechRuntimeCoordinator(WhisperRuntime, PiperRuntime) + app.state.speech_runtime = coordinator + await coordinator.startup() yield - SpeechRuntimeCoordinator.unload_all() + await coordinator.shutdown() def create_app() -> FastAPI: @@ -67,22 +49,17 @@ def create_app() -> FastAPI: app = FastAPI( title="GrillKit", description="AI Interview Trainer", - version=_get_app_version(), + version=__version__, lifespan=lifespan, ) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") - app.include_router(dashboard_router.router) - app.include_router(setup_router.router) - app.include_router(known_questions_router.router) - app.include_router(config_router.router) - app.include_router(interview_router.router) - app.include_router(results_router.router) - app.include_router(theory_router.router) - app.include_router(coding_router.router) - app.include_router(dictation_router.router) - app.include_router(speech_router.router) - app.include_router(question_voice_router.router) + app.include_router(interview_router) + app.include_router(platform_router) + app.include_router(theory_router) + app.include_router(coding_router) + app.include_router(speech_router) + app.include_router(question_voice_router) return app diff --git a/app/platform/api/__init__.py b/app/platform/api/__init__.py index 0b42ad2..01d3d8f 100644 --- a/app/platform/api/__init__.py +++ b/app/platform/api/__init__.py @@ -1,3 +1,16 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Platform HTTP endpoints.""" +"""Platform HTTP endpoints. + +This package aggregates all platform sub-routers into a single ``router`` +that the application factory mounts via :func:`app.include_router`. +""" + +from fastapi import APIRouter + +from app.platform.api import config + +router = APIRouter() +router.include_router(config.router) + +__all__ = ["router"] diff --git a/app/platform/api/config.py b/app/platform/api/config.py index 32ae076..ab0e085 100644 --- a/app/platform/api/config.py +++ b/app/platform/api/config.py @@ -2,20 +2,23 @@ # SPDX-License-Identifier: Apache-2.0 """Configuration endpoints.""" -from typing import Annotated, Any +from typing import Annotated from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse 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.api.deps import ( + AddLLMModelUseCaseDep, + ConfigServiceDep, + DeleteConfigUseCaseDep, + SaveConfigUseCaseDep, +) +from app.platform.domain.config import AppConfig +from app.platform.queries.config_form import parse_and_test +from app.platform.queries.platform_page import build_page_context from app.platform.schemas import NewLLMModel -from app.shared.infrastructure.gateways.whisper_model import WhisperModelService +from app.platform.use_cases.add_llm_model import AddLLMModelResult from app.shared.locales import DEFAULT_LOCALE from app.shared.speech_models import DEFAULT_SPEECH_MODEL_SIZE from app.speech.api.deps import WhisperModelServiceDep @@ -34,7 +37,7 @@ async def _config_from_form( question_voice_enabled: bool = Form(False), ) -> tuple[AppConfig, bool, str]: """Parse the config form, build AppConfig, and test the connection.""" - return await ConfigFormService.parse_and_test( + return await parse_and_test( config_service, llm_preset_id=llm_preset_id, api_key=api_key, @@ -45,40 +48,6 @@ async def _config_from_form( ) -async def build_config_page_context( - *, - config: AppConfig | None, - whisper_model_service: type[WhisperModelService], - error: str | None = None, - message: str | None = None, - mask_secret: bool = True, - selected_llm_preset_id: str | None = None, -) -> dict[str, Any]: - """Build the full Jinja context for ``config.html``. - - Args: - config: Saved provider configuration, if any. - whisper_model_service: Whisper model download service class. - error: Optional form validation or connection error message. - message: Optional success or informational message. - mask_secret: Whether to mask the API key in the config dict. - selected_llm_preset_id: Override selected preset after catalog edits. - - Returns: - Context dict for ``config.html``. - """ - return ( - await ConfigPageService.build_page_context( - config=config, - whisper_model_service=whisper_model_service, - error=error, - message=message, - mask_secret=mask_secret, - selected_llm_preset_id=selected_llm_preset_id, - ) - ).model_dump() - - ConfigFromForm = Annotated[tuple[AppConfig, bool, str], Depends(_config_from_form)] @@ -99,10 +68,12 @@ async def config_page( HTML response with configuration form. """ config = config_service.get_config() - context = await build_config_page_context( - config=config, - whisper_model_service=whisper_model_service, - ) + context = ( + await build_page_context( + config=config, + whisper_model_service=whisper_model_service, + ) + ).model_dump() return templates.TemplateResponse(request, "config.html", context) @@ -110,32 +81,48 @@ async def config_page( async def save_config( request: Request, form: ConfigFromForm, - config_service: ConfigServiceDep, whisper_model_service: WhisperModelServiceDep, + save_config: SaveConfigUseCaseDep, ) -> HTMLResponse: """Save configuration. Args: request: FastAPI request object. form: Parsed form fields and connection test result. - config_service: Provider configuration service. whisper_model_service: Whisper model download service. + save_config: Use case that persists config and reloads speech runtimes. Returns: HTML response with success message or error. """ config, success, message = form if not success: - context = await build_config_page_context( - config=config, - whisper_model_service=whisper_model_service, - error=message, - mask_secret=False, - ) + context = ( + await build_page_context( + config=config, + whisper_model_service=whisper_model_service, + error=message, + mask_secret=False, + ) + ).model_dump() return templates.TemplateResponse(request, "config.html", context) - config_service.save_config(config) - await SpeechRuntimeCoordinator.reload_after_config_save(config) + result = await save_config.execute(config) + if result.speech_errors: + # Config is saved, but one of the speech models failed to load. + # Warn the user instead of silently ignoring the failure. + warning = ( + "Configuration saved, but a speech model failed to load: " + + " | ".join(result.speech_errors) + ) + context = ( + await build_page_context( + config=config, + whisper_model_service=whisper_model_service, + message=warning, + ) + ).model_dump() + return templates.TemplateResponse(request, "config.html", context) return templates.TemplateResponse( request, "config_success.html", @@ -146,26 +133,27 @@ async def save_config( @router.delete("", response_class=HTMLResponse) async def delete_config( request: Request, - config_service: ConfigServiceDep, whisper_model_service: WhisperModelServiceDep, + delete_config: DeleteConfigUseCaseDep, ) -> HTMLResponse: """Delete configuration. Args: request: FastAPI request object. - config_service: Provider configuration service. whisper_model_service: Whisper model download service. + delete_config: Use case that removes config and unloads speech runtimes. Returns: HTML response with empty form. """ - config_service.delete_config() - SpeechRuntimeCoordinator.unload_all() - context = await build_config_page_context( - config=None, - whisper_model_service=whisper_model_service, - message="Configuration removed", - ) + delete_config.execute() + context = ( + await build_page_context( + config=None, + whisper_model_service=whisper_model_service, + message="Configuration removed", + ) + ).model_dump() return templates.TemplateResponse(request, "config.html", context) @@ -193,6 +181,7 @@ async def add_llm_model( request: Request, config_service: ConfigServiceDep, whisper_model_service: WhisperModelServiceDep, + add_model: AddLLMModelUseCaseDep, display_name: str = Form(...), base_url: str = Form(...), model: str = Form(...), @@ -206,6 +195,7 @@ async def add_llm_model( request: FastAPI request object. config_service: Provider configuration service. whisper_model_service: Whisper model download service. + add_model: Use case that probes and persists the new catalog entry. display_name: Label shown in the interview model selector. base_url: OpenAI-compatible API base URL. model: Provider model name. @@ -216,51 +206,26 @@ async def add_llm_model( Returns: Configuration page with a success or validation error message. """ - config = config_service.get_config() - selected_preset_id: str | None = None - message: str | None = None - error: str | None = None try: payload = NewLLMModel( display_name=display_name, base_url=base_url, model=model, - api_key_required=api_key_required, api_key=api_key, + api_key_required=api_key_required, accepts_audio_input=accepts_audio_input, ) - speech_model_size = ( - config.speech_model_size - if config is not None - else DEFAULT_SPEECH_MODEL_SIZE - ) - probe_config = AppConfig( - provider_type="openai-compatible", - base_url=payload.base_url, - model=payload.model, - api_key=payload.api_key, - speech_model_size=speech_model_size, - locale=config.locale if config is not None else DEFAULT_LOCALE, - ) - success, test_message = await ConfigService.test_catalog_model( - probe_config, - accepts_audio_input=payload.accepts_audio_input, - ) - if not success: - raise ValueError(test_message) - entry = LLMCatalogService.add_user_model(payload) - selected_preset_id = entry.id - message = f"Added model '{entry.display_name}' to the catalog." except ValidationError as exc: - error = exc.errors()[0]["msg"] - except ValueError as exc: - error = str(exc) - - context = await build_config_page_context( - config=config, - whisper_model_service=whisper_model_service, - error=error, - message=message, - selected_llm_preset_id=selected_preset_id, - ) + result = AddLLMModelResult(error=exc.errors()[0]["msg"]) + else: + result = await add_model.execute(payload) + context = ( + await build_page_context( + config=config_service.get_config(), + whisper_model_service=whisper_model_service, + error=result.error, + message=result.message, + selected_llm_preset_id=result.selected_preset_id, + ) + ).model_dump() return templates.TemplateResponse(request, "config.html", context) diff --git a/app/platform/api/deps.py b/app/platform/api/deps.py index d4997be..8050af0 100644 --- a/app/platform/api/deps.py +++ b/app/platform/api/deps.py @@ -4,9 +4,14 @@ from typing import Annotated -from fastapi import Depends +from fastapi import Depends, Request from app.platform.domain.config import ConfigService +from app.platform.domain.llm_catalog import LLMCatalogService +from app.platform.domain.speech_runtime import SpeechRuntimeCoordinator +from app.platform.use_cases.add_llm_model import AddLLMModelUseCase +from app.platform.use_cases.delete_config import DeleteConfigUseCase +from app.platform.use_cases.save_config import SaveConfigUseCase def get_config_service() -> type[ConfigService]: @@ -15,3 +20,60 @@ def get_config_service() -> type[ConfigService]: ConfigServiceDep = Annotated[type[ConfigService], Depends(get_config_service)] + + +def get_add_llm_model_use_case() -> AddLLMModelUseCase: + """Return the add-model use case wired with its services.""" + return AddLLMModelUseCase( + config_service=ConfigService, + llm_catalog_service=LLMCatalogService, + ) + + +AddLLMModelUseCaseDep = Annotated[ + AddLLMModelUseCase, + Depends(get_add_llm_model_use_case), +] + + +def get_save_config_use_case(request: Request) -> SaveConfigUseCase: + """Return the save-config use case wired with app-lifetime services.""" + return SaveConfigUseCase( + config_service=ConfigService, + coordinator=request.app.state.speech_runtime, + ) + + +SaveConfigUseCaseDep = Annotated[ + SaveConfigUseCase, + Depends(get_save_config_use_case), +] + + +def get_delete_config_use_case(request: Request) -> DeleteConfigUseCase: + """Return the delete-config use case wired with app-lifetime services.""" + return DeleteConfigUseCase( + config_service=ConfigService, + coordinator=request.app.state.speech_runtime, + ) + + +DeleteConfigUseCaseDep = Annotated[ + DeleteConfigUseCase, + Depends(get_delete_config_use_case), +] + + +def get_speech_runtime(request: Request) -> SpeechRuntimeCoordinator: + """Return the app-lifetime speech runtime coordinator.""" + coordinator = request.app.state.speech_runtime + assert isinstance(coordinator, SpeechRuntimeCoordinator), ( + "speech runtime not initialized" + ) + return coordinator + + +SpeechRuntimeDep = Annotated[ + SpeechRuntimeCoordinator, + Depends(get_speech_runtime), +] diff --git a/app/platform/domain/config.py b/app/platform/domain/config.py index d42b07f..841c4fe 100644 --- a/app/platform/domain/config.py +++ b/app/platform/domain/config.py @@ -365,14 +365,14 @@ async def test_interview_model( Returns: Tuple of (success: bool, message: str). """ + # test_catalog_model already performs the text (and optional audio) + # readiness probes; only the Whisper check is specific to interview models. success, message = await ConfigService.test_catalog_model( config, accepts_audio_input=accepts_audio_input, ) - if not success: - return False, message - if not accepts_audio_input: - return True, message + if not success or not accepts_audio_input: + return success, message whisper_ok, whisper_message = ConfigService.check_whisper_ready( config.speech_model_size ) diff --git a/app/platform/domain/speech_runtime.py b/app/platform/domain/speech_runtime.py index 82bbbe2..1f8c1e8 100644 --- a/app/platform/domain/speech_runtime.py +++ b/app/platform/domain/speech_runtime.py @@ -1,102 +1,159 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Coordinate Whisper and Piper in-process runtimes across app lifecycle.""" - -from fastapi import FastAPI +"""Coordinate in-process speech runtimes (STT and TTS) across the app lifecycle.""" +from app.ai.speech_transcriber import SpeechTranscriber 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.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 +from app.speech.domain.stt_loader import SttModelLoader +from app.speech.domain.tts_engine import TtsEngine class SpeechRuntimeCoordinator: - """Load or unload speech artifacts on startup, config save, and interview pages.""" + """Single owner of in-process speech artifacts. - @staticmethod - def unload_all() -> None: - """Unload in-process Whisper and Piper models synchronously.""" - WhisperRuntime.unload() - PiperRuntime.unload() + Created during the FastAPI lifespan and exposed through ``app.state`` / + dependency injection. All read/write access to the loaded speech models flows + through this coordinator so the concrete STT/TTS backends can be swapped + without touching the rest of the application. + """ - @staticmethod - async def startup(app: FastAPI) -> None: - """Bind Whisper to the app and load configured artifacts when installed. + def __init__( + self, + stt_loader: SttModelLoader, + tts_engine: TtsEngine, + config_service: type[ConfigService] = ConfigService, + ) -> None: + """Initialize the coordinator with STT and TTS backends. Args: - app: FastAPI application instance. + stt_loader: Backend that loads a :class:`SpeechTranscriber` into memory. + tts_engine: Backend that holds a voice and synthesizes WAV bytes. + config_service: Provider configuration service class. """ - WhisperRuntime.bind_app(app) - config = ConfigService.get_config() - await SpeechRuntimeCoordinator.sync_whisper(config) - await SpeechRuntimeCoordinator.sync_piper(config) + self._stt = stt_loader + self._tts = tts_engine + self._config_service = config_service + + @property + def config_service(self) -> type[ConfigService]: + """Return the configuration service class bound to this coordinator.""" + return self._config_service + + @property + def tts(self) -> TtsEngine: + """Return the TTS engine owned by this coordinator.""" + return self._tts + + def unload_all(self) -> None: + """Unload any in-memory Whisper and Piper models.""" + self._stt.unload() + self._tts.unload() - @staticmethod - async def sync_whisper(config: AppConfig | None) -> None: + async def startup(self) -> None: + """Load configured speech artifacts when installed.""" + _ = await self.sync(self._config_service.get_config()) + + async def shutdown(self) -> None: + """Unload all in-memory speech artifacts.""" + self.unload_all() + + async def sync(self, config: AppConfig | None) -> list[str]: + """Align both speech runtimes with the given configuration. + + Args: + config: Saved provider configuration, if any. + + Returns: + Human-readable load errors for STT/TTS that failed to load, if any. + """ + errors: list[str] = [] + if stt_error := await self.sync_whisper(config): + errors.append(stt_error) + if tts_error := await self.sync_piper(config): + errors.append(tts_error) + return errors + + async def reload_after_config_save(self, config: AppConfig) -> list[str]: + """Reload speech runtimes after configuration is persisted. + + Args: + config: Configuration that was just saved. + + Returns: + Human-readable load errors for STT/TTS that failed to load, if any. + """ + return await self.sync(config) + + async def sync_whisper(self, config: AppConfig | None) -> str | None: """Load or unload Whisper based on configuration and on-disk install state. Args: config: Saved provider configuration, if any. + + Returns: + A load error message when Whisper failed to load, otherwise ``None``. """ if config is None: - WhisperRuntime.unload() - return + self._stt.unload() + return None settings = speech_settings_from_config(config) - if is_installed(settings.speech_model_size): - await WhisperRuntime.load_size(settings.speech_model_size) + if self._stt.is_installed(settings.speech_model_size): + _ = await self._stt.load_size(settings.speech_model_size) else: - WhisperRuntime.unload() + self._stt.unload() + return self._stt.load_error() - @staticmethod - async def sync_piper(config: AppConfig | None) -> None: + async def sync_piper(self, config: AppConfig | None) -> str | None: """Load or unload Piper based on configuration and on-disk install state. Args: config: Saved provider configuration, if any. + + Returns: + A load error message when Piper failed to load, otherwise ``None``. """ if config is None: - PiperRuntime.unload() - return + self._tts.unload() + return None settings = question_voice_settings_from_config(config) - if settings.enabled and is_voice_installed(settings.voice_id): - await PiperRuntime.load_voice(settings.voice_id) + if settings.enabled and self._tts.is_installed(settings.voice_id): + _ = await self._tts.load_voice(settings.voice_id) else: - PiperRuntime.unload() - - @staticmethod - async def reload_after_config_save(config: AppConfig) -> None: - """Reload speech runtimes after configuration is persisted. - - Args: - config: Configuration that was just saved. - """ - await SpeechRuntimeCoordinator.sync_whisper(config) - await SpeechRuntimeCoordinator.sync_piper(config) + self._tts.unload() + return self._tts.load_error() - @staticmethod async def preload_whisper_for_active_interview( - app: FastAPI, + self, config: AppConfig | None, *, interview_active: bool, ) -> None: - """Ensure Whisper is bound and loaded when an interview session is active. + """Ensure Whisper is loaded when an interview session is active. Args: - app: FastAPI application instance. config: Saved provider configuration. interview_active: Whether the interview session is still active. """ - WhisperRuntime.bind_app(app) if config is None or not interview_active: return settings = speech_settings_from_config(config) - if is_installed(settings.speech_model_size) and not WhisperRuntime.is_loaded( + if self._stt.is_installed( settings.speech_model_size - ): - await WhisperRuntime.load_size(settings.speech_model_size) + ) and not self._stt.is_loaded(settings.speech_model_size): + _ = await self._stt.load_size(settings.speech_model_size) + + def get_transcriber(self) -> SpeechTranscriber | None: + """Return the currently loaded speech transcriber, if any.""" + return self._stt.get() + + def load_error(self) -> str | None: + """Return the last STT load error message, if any.""" + return self._stt.load_error() + + def is_loaded(self, size: str) -> bool: + """Return whether the STT model for ``size`` is loaded in memory.""" + return self._stt.is_loaded(size) diff --git a/app/platform/queries/config_form.py b/app/platform/queries/config_form.py index 9fbc057..9524f1f 100644 --- a/app/platform/queries/config_form.py +++ b/app/platform/queries/config_form.py @@ -10,87 +10,82 @@ from app.shared.tts_voices import default_voice_for_locale -class ConfigFormService: - """Parse configuration form submissions and test provider connectivity.""" +async def parse_and_test( + config_service: type[ConfigService], + *, + llm_preset_id: str, + api_key: str, + timeout: float, + locale: str, + speech_model_size: str, + question_voice_enabled: bool, +) -> tuple[AppConfig, bool, str]: + """Parse the config form, build ``AppConfig``, and test the connection. - @staticmethod - async def parse_and_test( - config_service: type[ConfigService], - *, - llm_preset_id: str, - api_key: str, - timeout: float, - locale: str, - speech_model_size: str, - question_voice_enabled: bool, - ) -> tuple[AppConfig, bool, str]: - """Parse the config form, build ``AppConfig``, and test the connection. + Args: + config_service: Provider configuration service. + llm_preset_id: Selected catalog model id from the form. + api_key: API key field value (may be empty or masked). + timeout: Request timeout in seconds. + locale: Interview locale code. + speech_model_size: Whisper model size slug. + question_voice_enabled: Whether question voice is enabled. - Args: - config_service: Provider configuration service. - llm_preset_id: Selected catalog model id from the form. - api_key: API key field value (may be empty or masked). - timeout: Request timeout in seconds. - locale: Interview locale code. - speech_model_size: Whisper model size slug. - question_voice_enabled: Whether question voice is enabled. + Returns: + Tuple of configuration, connection success flag, and message. + """ + existing = config_service.get_config() + try: + normalized_preset_id = normalize_model_id( + llm_preset_id, LLMCatalogService.load_catalog() + ) + except ValueError as exc: + fallback = existing or AppConfig( + provider_type="openai-compatible", + base_url="", + model="", + locale=normalize_locale(locale), + speech_model_size=normalize_speech_model_size(speech_model_size), + question_voice_enabled=question_voice_enabled, + ) + return fallback, False, str(exc) - Returns: - Tuple of configuration, connection success flag, and message. - """ - existing = config_service.get_config() - try: - normalized_preset_id = normalize_model_id( - llm_preset_id, LLMCatalogService.load_catalog() - ) - except ValueError as exc: - fallback = existing or AppConfig( + entry = LLMCatalogService.get_model(normalized_preset_id) + if entry is None: + return ( + existing + or AppConfig( provider_type="openai-compatible", base_url="", model="", - locale=normalize_locale(locale), - speech_model_size=normalize_speech_model_size(speech_model_size), - question_voice_enabled=question_voice_enabled, - ) - return fallback, False, str(exc) - - entry = LLMCatalogService.get_model(normalized_preset_id) - if entry is None: - return ( - existing - or AppConfig( - provider_type="openai-compatible", - base_url="", - model="", - ), - False, - "Interview model not found", - ) - - normalized_locale = normalize_locale(locale) - keep_existing_voice = ( - existing is not None - and normalize_locale(existing.locale) == normalized_locale + ), + False, + "Interview model not found", ) - if keep_existing_voice and existing is not None: - tts_voice_id = existing.tts_voice_id - else: - tts_voice_id = default_voice_for_locale(normalized_locale) - config = AppConfig( - provider_type=entry.provider_type, - base_url=entry.base_url, - model=entry.model, - api_key=AppConfig.resolve_api_key_from_form(api_key, normalized_preset_id), - timeout=timeout, - locale=normalized_locale, - speech_model_size=normalize_speech_model_size(speech_model_size), - question_voice_enabled=question_voice_enabled, - tts_voice_id=tts_voice_id, - llm_preset_id=normalized_preset_id, - ) - success, message = await config_service.test_interview_model( - config, - accepts_audio_input=entry.accepts_audio_input, - ) - return config, success, message + normalized_locale = normalize_locale(locale) + keep_existing_voice = ( + existing is not None and normalize_locale(existing.locale) == normalized_locale + ) + if keep_existing_voice and existing is not None: + tts_voice_id = existing.tts_voice_id + else: + tts_voice_id = default_voice_for_locale(normalized_locale) + + config = AppConfig( + provider_type=entry.provider_type, + base_url=entry.base_url, + model=entry.model, + api_key=AppConfig.resolve_api_key_from_form(api_key, normalized_preset_id), + timeout=timeout, + locale=normalized_locale, + speech_model_size=normalize_speech_model_size(speech_model_size), + question_voice_enabled=question_voice_enabled, + tts_voice_id=tts_voice_id, + llm_preset_id=normalized_preset_id, + ) + success, message = await config_service.test_interview_model( + config, + accepts_audio_input=entry.accepts_audio_input, + ) + return config, success, message diff --git a/app/platform/queries/llm_page.py b/app/platform/queries/llm_page.py index 32788cf..5107c87 100644 --- a/app/platform/queries/llm_page.py +++ b/app/platform/queries/llm_page.py @@ -7,40 +7,36 @@ from app.platform.schemas import LLMPresetOptionRead -class LLMPageService: - """Build LLM catalog sections for the configuration page.""" - - @staticmethod - def list_preset_options() -> list[LLMPresetOptionRead]: - """Load catalog entries for the interview model selector. - - Returns: - Preset read models for ``config_form.html``. - """ - return [ - LLMPresetOptionRead( - id=entry.id, - display_name=entry.display_name, - description=entry.model, - model=entry.model, - base_url=entry.base_url, - api_key_required=entry.api_key_required, - accepts_audio_input=entry.accepts_audio_input, - ) - for entry in LLMCatalogService.list_models() - ] - - @staticmethod - def resolve_selected_preset_id(config: AppConfig | None) -> str | None: - """Return the active catalog model id for the configuration form. - - Args: - config: Saved provider configuration, if any. - - Returns: - Selected preset id from config or catalog storage. - """ - selected_id = LLMCatalogService.get_selected_model_id() - if config is not None and config.llm_preset_id: - return config.llm_preset_id - return selected_id +def list_preset_options() -> list[LLMPresetOptionRead]: + """Load catalog entries for the interview model selector. + + Returns: + Preset read models for ``config_form.html``. + """ + return [ + LLMPresetOptionRead( + id=entry.id, + display_name=entry.display_name, + description=entry.model, + model=entry.model, + base_url=entry.base_url, + api_key_required=entry.api_key_required, + accepts_audio_input=entry.accepts_audio_input, + ) + for entry in LLMCatalogService.list_models() + ] + + +def resolve_selected_preset_id(config: AppConfig | None) -> str | None: + """Return the active catalog model id for the configuration form. + + Args: + config: Saved provider configuration, if any. + + Returns: + Selected preset id from config or catalog storage. + """ + selected_id = LLMCatalogService.get_selected_model_id() + if config is not None and config.llm_preset_id: + return config.llm_preset_id + return selected_id diff --git a/app/platform/queries/platform_page.py b/app/platform/queries/platform_page.py index 1334d52..0f73ede 100644 --- a/app/platform/queries/platform_page.py +++ b/app/platform/queries/platform_page.py @@ -3,7 +3,10 @@ """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.queries.llm_page import ( + list_preset_options, + resolve_selected_preset_id, +) from app.platform.schemas import ( ConfigPageContext, speech_model_specs_for_config, @@ -14,58 +17,52 @@ from app.speech.queries.speech_page import SpeechModelPageService -class ConfigPageService: - """Build template context for the provider configuration page.""" - - @staticmethod - async def build_page_context( - *, - config: AppConfig | None, - whisper_model_service: type[WhisperModelService] = WhisperModelService, - error: str | None = None, - message: str | None = None, - mask_secret: bool = True, - selected_llm_preset_id: str | None = None, - ) -> ConfigPageContext: - """Assemble the full context for ``config.html``. +async def build_page_context( + *, + config: AppConfig | None, + whisper_model_service: type[WhisperModelService] = WhisperModelService, + error: str | None = None, + message: str | None = None, + mask_secret: bool = True, + selected_llm_preset_id: str | None = None, +) -> ConfigPageContext: + """Assemble the full context for ``config.html``. - Args: - config: Saved provider configuration, if any. - whisper_model_service: Whisper model service class (injectable in tests). - error: Optional form validation or connection error message. - message: Optional success or informational message. - mask_secret: Whether to mask the API key in the config dict. - selected_llm_preset_id: Override selected preset after catalog edits. + Args: + config: Saved provider configuration, if any. + whisper_model_service: Whisper model service class (injectable in tests). + error: Optional form validation or connection error message. + message: Optional success or informational message. + mask_secret: Whether to mask the API key in the config dict. + selected_llm_preset_id: Override selected preset after catalog edits. - Returns: - Frozen page context for the configuration template. - """ - speech_ctx = SpeechModelPageService.build_page_context( - config, - whisper_model_service=whisper_model_service, - ) - voice_ctx = await QuestionVoicePageService.build_page_context(config) - preset_id = ( - selected_llm_preset_id - if selected_llm_preset_id is not None - else LLMPageService.resolve_selected_preset_id(config) - ) + Returns: + Frozen page context for the configuration template. + """ + speech_ctx = SpeechModelPageService.build_page_context( + config, + whisper_model_service=whisper_model_service, + ) + voice_ctx = await QuestionVoicePageService.build_page_context(config) + preset_id = ( + selected_llm_preset_id + if selected_llm_preset_id is not None + else resolve_selected_preset_id(config) + ) - return ConfigPageContext( - config=( - app_config_read_from(config, mask_secret=mask_secret) - if config - else None - ), - locales=dict(SUPPORTED_LOCALES), - speech_model_specs=speech_model_specs_for_config(), - speech_model_status=speech_ctx.speech_model_status, - speech_model_banner=speech_ctx.speech_model_banner, - status=speech_ctx.status, - tts_voice_status=voice_ctx.tts_voice_status, - tts_voice_banner=voice_ctx.tts_voice_banner, - llm_presets=LLMPageService.list_preset_options(), - selected_llm_preset_id=preset_id, - error=error, - message=message, - ) + return ConfigPageContext( + config=( + app_config_read_from(config, mask_secret=mask_secret) if config else None + ), + locales=dict(SUPPORTED_LOCALES), + speech_model_specs=speech_model_specs_for_config(), + speech_model_status=speech_ctx.speech_model_status, + speech_model_banner=speech_ctx.speech_model_banner, + status=speech_ctx.status, + tts_voice_status=voice_ctx.tts_voice_status, + tts_voice_banner=voice_ctx.tts_voice_banner, + llm_presets=list_preset_options(), + selected_llm_preset_id=preset_id, + error=error, + message=message, + ) diff --git a/app/platform/use_cases/add_llm_model.py b/app/platform/use_cases/add_llm_model.py new file mode 100644 index 0000000..b909124 --- /dev/null +++ b/app/platform/use_cases/add_llm_model.py @@ -0,0 +1,88 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Use case for adding a user-defined LLM model to the catalog.""" + +from dataclasses import dataclass + +from app.platform.domain.config import AppConfig, ConfigService +from app.platform.domain.llm_catalog import LLMCatalogService +from app.platform.schemas import NewLLMModel +from app.shared.locales import DEFAULT_LOCALE +from app.shared.speech_models import DEFAULT_SPEECH_MODEL_SIZE + + +@dataclass(frozen=True) +class AddLLMModelResult: + """Outcome of adding a model to the catalog. + + Attributes: + error: Optional validation or connection error message. + message: Optional success message. + selected_preset_id: Catalog id to preselect on the config page. + """ + + error: str | None = None + message: str | None = None + selected_preset_id: str | None = None + + +class AddLLMModelUseCase: + """Probe a user-defined model and append it to the catalog. + + The full add flow lives here instead of the HTTP handler: build the + probe config from the submitted form, verify the provider connection, + and only persist the entry when the probe succeeds. + """ + + def __init__( + self, + config_service: type[ConfigService], + llm_catalog_service: type[LLMCatalogService], + ) -> None: + self._config_service: type[ConfigService] = config_service + self._catalog_service: type[LLMCatalogService] = llm_catalog_service + + async def execute( + self, + payload: NewLLMModel, + ) -> AddLLMModelResult: + """Run the add-model flow. + + Args: + payload: Validated add-model form values from the catalog form. + + Returns: + Result with an error or success message for the config page. + """ + config = self._config_service.get_config() + try: + speech_model_size = ( + config.speech_model_size + if config is not None + else DEFAULT_SPEECH_MODEL_SIZE + ) + probe_config = AppConfig( + provider_type="openai-compatible", + base_url=payload.base_url, + model=payload.model, + api_key=payload.api_key, + speech_model_size=speech_model_size, + locale=config.locale if config is not None else DEFAULT_LOCALE, + ) + if payload.api_key_required and not payload.api_key: + raise ValueError( + "API key is required for this model but none was provided." + ) + success, test_message = await self._config_service.test_catalog_model( + probe_config, + accepts_audio_input=payload.accepts_audio_input, + ) + if not success: + raise ValueError(test_message) + entry = self._catalog_service.add_user_model(payload) + return AddLLMModelResult( + message=f"Added model '{entry.display_name}' to the catalog.", + selected_preset_id=entry.id, + ) + except ValueError as exc: + return AddLLMModelResult(error=str(exc)) diff --git a/app/platform/use_cases/delete_config.py b/app/platform/use_cases/delete_config.py new file mode 100644 index 0000000..912305a --- /dev/null +++ b/app/platform/use_cases/delete_config.py @@ -0,0 +1,28 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Use case for deleting application configuration and unloading speech runtimes.""" + +from app.platform.domain.config import ConfigService +from app.platform.domain.speech_runtime import SpeechRuntimeCoordinator + + +class DeleteConfigUseCase: + """Remove persisted config and unload the in-memory speech runtimes. + + Deleting the configuration and unloading the speech models are tightly + coupled: once no config remains, no speech model should stay resident. The + orchestration lives here instead of the HTTP handler. + """ + + def __init__( + self, + config_service: type[ConfigService], + coordinator: SpeechRuntimeCoordinator, + ) -> None: + self._config_service: type[ConfigService] = config_service + self._coordinator: SpeechRuntimeCoordinator = coordinator + + def execute(self) -> None: + """Delete the persisted configuration and unload speech runtimes.""" + self._config_service.delete_config() + self._coordinator.unload_all() diff --git a/app/platform/use_cases/save_config.py b/app/platform/use_cases/save_config.py new file mode 100644 index 0000000..25853c1 --- /dev/null +++ b/app/platform/use_cases/save_config.py @@ -0,0 +1,55 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Use case for saving application configuration and syncing speech runtimes.""" + +from dataclasses import dataclass + +from app.platform.domain.config import AppConfig, ConfigService +from app.platform.domain.speech_runtime import SpeechRuntimeCoordinator + + +@dataclass(frozen=True) +class SaveConfigResult: + """Outcome of saving configuration. + + Attributes: + saved: Whether the configuration was persisted. + speech_errors: Load errors for STT/TTS that failed after saving. + """ + + saved: bool = True + speech_errors: tuple[str, ...] = () + + +class SaveConfigUseCase: + """Persist application config and reload the speech runtimes. + + Saving the config and aligning the in-memory speech runtimes are tightly + coupled, so the orchestration lives here instead of the HTTP handler. The + config is always saved even when a speech model fails to load; the caller + surfaces the collected errors as a warning. + """ + + def __init__( + self, + config_service: type[ConfigService], + coordinator: SpeechRuntimeCoordinator, + ) -> None: + self._config_service: type[ConfigService] = config_service + self._coordinator: SpeechRuntimeCoordinator = coordinator + + async def execute(self, config: AppConfig) -> SaveConfigResult: + """Save ``config`` and reload speech runtimes. + + Args: + config: Validated configuration to persist. + + Returns: + Result with collected speech load errors, if any. + """ + self._config_service.save_config(config) + speech_errors = await self._coordinator.reload_after_config_save(config) + return SaveConfigResult( + saved=True, + speech_errors=tuple(speech_errors), + ) diff --git a/app/question_voice/api/__init__.py b/app/question_voice/api/__init__.py index daa8bc1..29fec1d 100644 --- a/app/question_voice/api/__init__.py +++ b/app/question_voice/api/__init__.py @@ -1,3 +1,16 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""HTTP routes for question-voice (TTS status).""" +"""HTTP routes for question-voice (TTS status). + +This package aggregates all question-voice sub-routers into a single +``router`` that the application factory mounts via :func:`app.include_router`. +""" + +from fastapi import APIRouter + +from app.question_voice.api import routes + +router = APIRouter() +router.include_router(routes.router) + +__all__ = ["router"] diff --git a/app/question_voice/use_cases/generate_question_audio.py b/app/question_voice/use_cases/generate_question_audio.py index 2978f13..99d613b 100644 --- a/app/question_voice/use_cases/generate_question_audio.py +++ b/app/question_voice/use_cases/generate_question_audio.py @@ -10,14 +10,16 @@ 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 +from app.speech.domain.tts_engine import TtsEngine async def get_question_audio_path( + engine: TtsEngine, interview_id: str, answer_id: int | None = None, ) -> Path: """Convenience wrapper for :meth:`GenerateQuestionAudio.execute`.""" - return await GenerateQuestionAudio.execute(interview_id, answer_id) + return await GenerateQuestionAudio.execute(engine, interview_id, answer_id) class GenerateQuestionAudio: @@ -25,12 +27,14 @@ class GenerateQuestionAudio: @staticmethod async def execute( + engine: TtsEngine, interview_id: str, answer_id: int | None = None, ) -> Path: """Return a WAV path for interview question audio. Args: + engine: TTS engine used to synthesize audio. interview_id: Interview session UUID. answer_id: Optional answer row id; defaults to the current question. @@ -54,6 +58,7 @@ async def execute( interview = InterviewQuery.get_active_interview_or_raise(interview_id) answer = _resolve_answer(interview, answer_id) return await TtsCacheService.get_or_fetch( + engine, voice_settings.voice_id, interview.locale, answer.question_text, diff --git a/app/shared/infrastructure/gateways/piper.py b/app/shared/infrastructure/gateways/piper.py index 14354a1..f79ed8e 100644 --- a/app/shared/infrastructure/gateways/piper.py +++ b/app/shared/infrastructure/gateways/piper.py @@ -3,6 +3,7 @@ """In-process Piper voice loading and synthesis.""" import asyncio +import gc import io import logging from typing import TYPE_CHECKING @@ -22,20 +23,21 @@ class PiperGateway(InProcessArtifactRuntime): - """Hold the loaded :class:`PiperVoice` for the configured question voice.""" + """Hold a loaded :class:`PiperVoice` for the configured question voice. - @classmethod - def normalize_key(cls, key: str) -> str: + Satisfies the :class:`TtsEngine` protocol, so it can be injected into the + :class:`SpeechRuntimeCoordinator` as the TTS backend. + """ + + def _normalize_key(self, key: str) -> str: """Normalize a Piper voice identifier.""" return normalize_tts_voice_id(key) - @classmethod - def is_installed(cls, key: str) -> bool: + def _is_installed(self, key: str) -> bool: """Return whether a valid Piper voice is on disk for ``key``.""" return is_voice_installed(key) - @classmethod - def load_sync(cls, key: str) -> "PiperVoice": + def _load_sync(self, key: str) -> "PiperVoice": """Load ``PiperVoice`` from a local voice directory (blocking).""" from piper import PiperVoice @@ -44,8 +46,15 @@ def load_sync(cls, key: str) -> "PiperVoice": config_path = directory / f"{key}.onnx.json" return PiperVoice.load(model_path, config_path=config_path) - @classmethod - async def load_voice(cls, voice_id: str) -> bool: + def is_installed(self, voice_id: str) -> bool: + """Return whether a valid Piper voice is on disk for ``voice_id``.""" + return self._is_installed(voice_id) + + def is_loaded(self, voice_id: str) -> bool: + """Return whether the voice for ``voice_id`` is loaded in memory.""" + return self._has_loaded_key(voice_id) + + async def load_voice(self, voice_id: str) -> bool: """Load or reload the Piper voice for ``voice_id`` from disk. Args: @@ -54,20 +63,23 @@ async def load_voice(cls, voice_id: str) -> bool: Returns: True if a voice is loaded for the id after this call. """ - code = cls.normalize_key(voice_id) - loaded = await cls.load(voice_id) + code = self._normalize_key(voice_id) + loaded = await self._load(voice_id) if loaded: logger.info("Loaded Piper voice %s from %s", code, voice_dir(code)) return loaded - @classmethod - def on_loaded(cls, key: str, artifact: "PiperVoice") -> None: + def on_loaded(self, key: str, artifact: "PiperVoice") -> None: """Log successful voice load.""" del artifact logger.debug("Piper voice %s loaded into memory", key) - @classmethod - def synthesize_wav_bytes_sync(cls, text: str) -> bytes: + def on_unloaded(self) -> None: + """Log successful voice unload.""" + logger.debug("Piper voice unloaded from memory") + _ = gc.collect() + + def synthesize_wav_bytes_sync(self, text: str) -> bytes: """Synthesize WAV audio for ``text`` using the loaded voice (blocking). Args: @@ -79,7 +91,7 @@ def synthesize_wav_bytes_sync(cls, text: str) -> bytes: Raises: RuntimeError: When no voice is loaded. """ - voice = cls._artifact + voice = self._artifact if voice is None: raise RuntimeError("Piper voice is not loaded") @@ -88,8 +100,7 @@ def synthesize_wav_bytes_sync(cls, text: str) -> bytes: voice.synthesize_wav(text, wav_file) return buffer.getvalue() - @classmethod - async def synthesize_wav_bytes(cls, text: str) -> bytes: + async def synthesize_wav_bytes(self, text: str) -> bytes: """Synthesize WAV audio for ``text`` in a worker thread. Args: @@ -98,4 +109,8 @@ async def synthesize_wav_bytes(cls, text: str) -> bytes: Returns: Raw WAV file bytes. """ - return await asyncio.to_thread(cls.synthesize_wav_bytes_sync, text) + return await asyncio.to_thread(self.synthesize_wav_bytes_sync, text) + + +# Single in-process runtime instance shared by the coordinator and status services. +PiperRuntime = PiperGateway() diff --git a/app/shared/infrastructure/gateways/piper_voice.py b/app/shared/infrastructure/gateways/piper_voice.py index 4a0333e..54a7a17 100644 --- a/app/shared/infrastructure/gateways/piper_voice.py +++ b/app/shared/infrastructure/gateways/piper_voice.py @@ -12,7 +12,7 @@ from app.question_voice.schemas import PiperVoiceStatusRead 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 import PiperRuntime from app.shared.infrastructure.gateways.piper_storage import ( is_valid_voice_dir, is_voice_installed, diff --git a/app/shared/infrastructure/gateways/tts_cache.py b/app/shared/infrastructure/gateways/tts_cache.py index 72e3824..441f54b 100644 --- a/app/shared/infrastructure/gateways/tts_cache.py +++ b/app/shared/infrastructure/gateways/tts_cache.py @@ -6,13 +6,12 @@ from pathlib import Path import re -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 +from app.speech.domain.tts_engine import TtsEngine _WHITESPACE_RE = re.compile(r"\s+") _CACHE_VERSION = "v2" @@ -51,10 +50,16 @@ def cache_path(locale: str, text: str) -> Path: return TTS_CACHE_DIR / _CACHE_VERSION / code / f"{digest}.wav" @staticmethod - async def get_or_fetch(voice_id: str, locale: str, text: str) -> Path: + async def get_or_fetch( + engine: TtsEngine, + voice_id: str, + locale: str, + text: str, + ) -> Path: """Return a cached WAV path, synthesizing on miss. Args: + engine: TTS engine used to synthesize on a cache miss. voice_id: Piper voice id from provider configuration. locale: Interview locale code. text: Question text snapshot. @@ -69,19 +74,19 @@ async def get_or_fetch(voice_id: str, locale: str, text: str) -> Path: if path.is_file(): return path - if not is_voice_installed(voice_id): + if not engine.is_installed(voice_id): raise QuestionVoiceSynthesisError( "Question voice is not installed. Download it on the Configuration page." ) - if not PiperRuntime.is_loaded(voice_id): - loaded = await PiperRuntime.load_voice(voice_id) + if not engine.is_loaded(voice_id): + loaded = await engine.load_voice(voice_id) if not loaded: - detail = PiperRuntime.load_error() or "Could not load question voice." + detail = engine.load_error() or "Could not load question voice." raise QuestionVoiceSynthesisError(detail) try: - audio = await PiperRuntime.synthesize_wav_bytes(text) + audio = await engine.synthesize_wav_bytes(text) except Exception as exc: raise QuestionVoiceSynthesisError("TTS synthesis failed.") from exc diff --git a/app/shared/infrastructure/gateways/whisper.py b/app/shared/infrastructure/gateways/whisper.py index da3fd39..0d217c5 100644 --- a/app/shared/infrastructure/gateways/whisper.py +++ b/app/shared/infrastructure/gateways/whisper.py @@ -2,11 +2,10 @@ # SPDX-License-Identifier: Apache-2.0 """In-process speech transcriber loading and hot-reload.""" +import gc import logging import os -from typing import ClassVar -from fastapi import FastAPI from faster_whisper import WhisperModel from app.ai.faster_whisper_transcriber import FasterWhisperTranscriber @@ -22,22 +21,21 @@ class WhisperGateway(InProcessArtifactRuntime): - """Hold the loaded :class:`SpeechTranscriber` and sync it to ``app.state``.""" + """Hold a loaded :class:`SpeechTranscriber` in this process. - _app: ClassVar[FastAPI | None] = None + Satisfies the :class:`SttModelLoader` protocol, so it can be injected into the + :class:`SpeechRuntimeCoordinator` as the STT backend. + """ - @classmethod - def normalize_key(cls, key: str) -> str: + def _normalize_key(self, key: str) -> str: """Normalize a speech model size identifier.""" return normalize_speech_model_size(key) - @classmethod - def is_installed(cls, key: str) -> bool: + def _is_installed(self, key: str) -> bool: """Return whether a valid Whisper model is on disk for ``key``.""" return is_installed(key) - @classmethod - def load_sync(cls, key: str) -> SpeechTranscriber: + def _load_sync(self, key: str) -> SpeechTranscriber: """Load ``WhisperModel`` and wrap it in a transcriber (blocking).""" path = model_dir(key) model = WhisperModel( @@ -47,18 +45,23 @@ def load_sync(cls, key: str) -> SpeechTranscriber: ) return FasterWhisperTranscriber(model) - @classmethod - def bind_app(cls, app: FastAPI) -> None: - """Register the FastAPI app for ``app.state`` updates after load/unload.""" - cls._app = app + def is_installed(self, size: str) -> bool: + """Return whether a valid Whisper model is on disk for ``size``.""" + return self._is_installed(size) - @classmethod - def loaded_size(cls) -> str | None: + def is_loaded(self, size: str) -> bool: + """Return whether the model for ``size`` is loaded in memory.""" + return self._has_loaded_key(size) + + def get(self) -> SpeechTranscriber | None: + """Return the currently loaded transcriber, if any.""" + return self._artifact + + def loaded_size(self) -> str | None: """Return the size of the model currently in memory, if any.""" - return cls.loaded_key() + return self.loaded_key() - @classmethod - async def load_size(cls, size: str) -> bool: + async def load_size(self, size: str) -> bool: """Load or reload the Whisper model for ``size`` from disk. Args: @@ -67,33 +70,25 @@ async def load_size(cls, size: str) -> bool: Returns: True if a transcriber is loaded for the size after this call. """ - loaded = await cls.load(size) + loaded = await self._load(size) if loaded: logger.info( "Loaded Whisper model %s from %s", - cls.normalize_key(size), - model_dir(cls.normalize_key(size)), + self._normalize_key(size), + model_dir(self._normalize_key(size)), ) return loaded - @classmethod - def on_loaded(cls, key: str, artifact: SpeechTranscriber) -> None: - """Mirror runtime handles onto the bound FastAPI application.""" + def on_loaded(self, key: str, artifact: SpeechTranscriber) -> None: + """Log a successful load.""" del artifact - cls._sync_app_state() - - @classmethod - def on_unloaded(cls) -> None: - """Clear ``app.state`` when the transcriber is dropped.""" - cls._sync_app_state() + logger.debug("Whisper model %s loaded into memory", key) - @classmethod - def _sync_app_state(cls) -> None: - """Mirror runtime handles onto the bound FastAPI application.""" - app = cls._app - if app is None: - return - app.state.speech_transcriber = cls._artifact + def on_unloaded(self) -> None: + """Log when the transcriber is dropped.""" + logger.debug("Whisper model unloaded from memory") + _ = gc.collect() -WhisperRuntime = WhisperGateway +# Single in-process runtime instance shared by the coordinator and status services. +WhisperRuntime = WhisperGateway() diff --git a/app/shared/infrastructure/gateways/whisper_model.py b/app/shared/infrastructure/gateways/whisper_model.py index 06c1a63..fbfb65a 100644 --- a/app/shared/infrastructure/gateways/whisper_model.py +++ b/app/shared/infrastructure/gateways/whisper_model.py @@ -11,7 +11,7 @@ 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 import WhisperRuntime from app.shared.infrastructure.gateways.whisper_storage import ( is_installed, is_valid_model_dir, diff --git a/app/shared/infrastructure/in_process_runtime.py b/app/shared/infrastructure/in_process_runtime.py index 2260b10..3b58eb7 100644 --- a/app/shared/infrastructure/in_process_runtime.py +++ b/app/shared/infrastructure/in_process_runtime.py @@ -3,8 +3,9 @@ """Base class for loading ML artifacts into the current process.""" import asyncio +import gc import logging -from typing import Any, ClassVar +from typing import Any logger = logging.getLogger(__name__) @@ -12,47 +13,45 @@ class InProcessArtifactRuntime: """Hold one loaded artifact and expose load/unload helpers. - Subclasses implement ``normalize_key``, ``is_installed``, and ``load_sync``. + Subclasses implement ``_normalize_key``, ``_is_installed`` and ``_load_sync``, + and expose public, protocol-named wrappers (e.g. ``is_installed(size)`` or + ``is_installed(voice_id)``) that delegate to the protected helpers. Each + instance owns its own in-memory artifact state. """ - _artifact: ClassVar[Any | None] = None - _loaded_key: ClassVar[str | None] = None - _load_error: ClassVar[str | None] = None + def __init__(self) -> None: + """Initialize empty artifact state.""" + self._artifact: Any | None = None + self._loaded_key: str | None = None + self._load_error: str | None = None - @classmethod - def normalize_key(cls, key: str) -> str: + def _normalize_key(self, key: str) -> str: """Normalize an artifact identifier to the canonical form.""" raise NotImplementedError - @classmethod - def is_installed(cls, key: str) -> bool: + def _is_installed(self, key: str) -> bool: """Return whether artifact files are present on disk.""" raise NotImplementedError - @classmethod - def load_sync(cls, key: str) -> Any: + def _load_sync(self, key: str) -> Any: """Load the artifact from disk (blocking).""" raise NotImplementedError - @classmethod - def loaded_key(cls) -> str | None: + def loaded_key(self) -> str | None: """Return the key of the artifact currently in memory, if any.""" - return cls._loaded_key + return self._loaded_key - @classmethod - def load_error(cls) -> str | None: + def load_error(self) -> str | None: """Return the last in-process load error message, if any.""" - return cls._load_error + return self._load_error - @classmethod - def is_loaded(cls, key: str) -> bool: - """Return whether an artifact for ``key`` is loaded in this process.""" - if cls._artifact is None or cls._loaded_key is None: + def _has_loaded_key(self, key: str) -> bool: + """Return whether an artifact for the canonical ``key`` is loaded in this process.""" + if self._artifact is None or self._loaded_key is None: return False - return cls._loaded_key == cls.normalize_key(key) + return self._loaded_key == self._normalize_key(key) - @classmethod - async def load(cls, key: str) -> bool: + async def _load(self, key: str) -> bool: """Load or reload the artifact for ``key`` from disk. Args: @@ -61,37 +60,54 @@ async def load(cls, key: str) -> bool: Returns: True if an artifact is loaded for the key after this call. """ - code = cls.normalize_key(key) - if not cls.is_installed(code): - cls.unload() - cls._load_error = None + code = self._normalize_key(key) + if not self._is_installed(code): + self.unload() + self._load_error = None return False try: - artifact = await asyncio.to_thread(cls.load_sync, code) + artifact = await asyncio.to_thread(self._load_sync, code) except Exception as exc: logger.exception("Failed to load artifact %s", code) - cls.unload() - cls._load_error = str(exc) + self.unload() + self._load_error = str(exc) return False - cls._artifact = artifact - cls._loaded_key = code - cls._load_error = None - cls.on_loaded(code, artifact) + self._artifact = artifact + self._loaded_key = code + self._load_error = None + self.on_loaded(code, artifact) return True - @classmethod - def unload(cls) -> None: - """Drop the in-memory artifact.""" - cls._artifact = None - cls._loaded_key = None - cls.on_unloaded() + def unload(self) -> None: + """Drop the in-memory artifact and release its resources. - @classmethod - def on_loaded(cls, key: str, artifact: Any) -> None: + The held reference is dropped, then :meth:`_release_artifact` gives + subclasses a chance to free native resources (CUDA/ctranslate2, + onnxruntime) explicitly. Finally a GC pass is forced so reference + cycles held by ML frameworks do not keep the memory alive. + """ + artifact = self._artifact + self._artifact = None + self._loaded_key = None + if artifact is not None: + self._release_artifact(artifact) + _ = gc.collect() + self.on_unloaded() + + def _release_artifact(self, artifact: Any) -> None: + """Release native resources held by ``artifact`` (optional hook). + + Subclasses override this to explicitly close underlying native model + sessions (e.g. CT2/onnxruntime) instead of relying solely on garbage + collection. The default implementation keeps the reference until it is + reclaimed by the reference counter / GC. + """ + del artifact + + def on_loaded(self, key: str, artifact: Any) -> None: """Hook invoked after a successful load (optional).""" - @classmethod - def on_unloaded(cls) -> None: + def on_unloaded(self) -> None: """Hook invoked after unload (optional).""" diff --git a/app/speech/api/__init__.py b/app/speech/api/__init__.py index 8a7aae7..73f8f7d 100644 --- a/app/speech/api/__init__.py +++ b/app/speech/api/__init__.py @@ -1,3 +1,17 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Speech feature HTTP and WebSocket endpoints.""" +"""Speech feature HTTP and WebSocket endpoints. + +This package aggregates all speech sub-routers into a single ``router`` +that the application factory mounts via :func:`app.include_router`. +""" + +from fastapi import APIRouter + +from app.speech.api import dictation, routes + +router = APIRouter() +router.include_router(routes.router) +router.include_router(dictation.router) + +__all__ = ["router"] diff --git a/app/speech/api/dictation.py b/app/speech/api/dictation.py index 21c49af..ab71fb3 100644 --- a/app/speech/api/dictation.py +++ b/app/speech/api/dictation.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect from app.interview.queries.loader import InterviewQuery -from app.platform.api.deps import ConfigServiceDep +from app.platform.domain.speech_runtime import SpeechRuntimeCoordinator from app.speech.api.dictation_protocol import ( DICTATION_CLIENT_START, DICTATION_CLIENT_STOP, @@ -47,7 +47,6 @@ async def _reject_dictation(websocket: WebSocket, message: str) -> None: async def interview_dictation_ws( websocket: WebSocket, interview_id: str, - config_service: ConfigServiceDep, ) -> None: """Stream PCM audio and return a final transcript for the answer field. @@ -67,8 +66,8 @@ async def interview_dictation_ws( Args: websocket: Dictation WebSocket connection. interview_id: Interview session UUID. - config_service: Provider configuration service. """ + coordinator: SpeechRuntimeCoordinator = websocket.app.state.speech_runtime interview = InterviewQuery.load(interview_id) if interview is None: await _reject_dictation(websocket, "Interview not found") @@ -78,11 +77,11 @@ async def interview_dictation_ws( await _reject_dictation(websocket, "Interview is not active") return - transcriber = await resolve_speech_transcriber(websocket.app, config_service) + transcriber = await resolve_speech_transcriber(coordinator) if transcriber is None: await _reject_dictation( websocket, - speech_transcriber_unavailable_message(), + speech_transcriber_unavailable_message(coordinator), ) return diff --git a/app/speech/domain/stt_loader.py b/app/speech/domain/stt_loader.py new file mode 100644 index 0000000..f137831 --- /dev/null +++ b/app/speech/domain/stt_loader.py @@ -0,0 +1,41 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Protocol for loading a speech-to-text model into memory.""" + +from typing import Protocol + +from app.ai.speech_transcriber import SpeechTranscriber + + +class SttModelLoader(Protocol): + """Load and hold a single in-memory :class:`SpeechTranscriber`. + + A loader is the concrete bridge between a vendor's model (e.g. faster-whisper) + and the rest of the application. The :class:`SpeechRuntimeCoordinator` treats + any object satisfying this protocol as its STT backend, so swapping STT + implementations only requires a different loader. + """ + + def is_installed(self, size: str) -> bool: + """Return whether a valid model for ``size`` is present on disk.""" + ... + + def is_loaded(self, size: str) -> bool: + """Return whether the model for ``size`` is currently in memory.""" + ... + + def load_error(self) -> str | None: + """Return the last load failure message, if any.""" + ... + + def unload(self) -> None: + """Drop the in-memory model.""" + ... + + async def load_size(self, size: str) -> bool: + """Load or reload the model for ``size``, returning success.""" + ... + + def get(self) -> SpeechTranscriber | None: + """Return the currently loaded transcriber, if any.""" + ... diff --git a/app/speech/domain/transcriber_resolver.py b/app/speech/domain/transcriber_resolver.py index 43f2778..ff23638 100644 --- a/app/speech/domain/transcriber_resolver.py +++ b/app/speech/domain/transcriber_resolver.py @@ -1,49 +1,44 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Resolve a loaded Whisper speech transcriber from application state.""" - -from typing import cast - -from starlette.applications import Starlette +"""Resolve a loaded speech transcriber through the speech runtime coordinator.""" from app.ai.speech_transcriber import SpeechTranscriber -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 +from app.platform.domain.speech_runtime import SpeechRuntimeCoordinator _UNLOADED_MESSAGE = "Speech model is not loaded. Download it in Configuration." async def resolve_speech_transcriber( - app: Starlette, - config_service: type[ConfigService], + coordinator: SpeechRuntimeCoordinator, ) -> SpeechTranscriber | None: """Return a loaded speech transcriber, attempting runtime load when needed. Args: - app: ASGI application with optional ``speech_transcriber`` on state. - config_service: Provider configuration service class. + coordinator: App-lifetime speech runtime coordinator. Returns: - Loaded transcriber, or None when Whisper is unavailable. + Loaded transcriber, or None when a model is unavailable. """ - transcriber = getattr(app.state, "speech_transcriber", None) - if transcriber is None: - config = config_service.get_config() - if config is not None and is_installed(config.speech_model_size): - await WhisperRuntime.load_size(config.speech_model_size) - transcriber = getattr(app.state, "speech_transcriber", None) + transcriber = coordinator.get_transcriber() if transcriber is None: - return None - return cast(SpeechTranscriber, transcriber) + config = coordinator.config_service.get_config() + if config is not None: + await coordinator.sync_whisper(config) + transcriber = coordinator.get_transcriber() + return transcriber -def speech_transcriber_unavailable_message() -> str: +def speech_transcriber_unavailable_message( + coordinator: SpeechRuntimeCoordinator, +) -> str: """Build a user-facing message when no speech transcriber is loaded. + Args: + coordinator: App-lifetime speech runtime coordinator. + Returns: - Error text including optional Whisper load error details. + Error text including optional model load error details. """ - load_error = WhisperRuntime.load_error() + load_error = coordinator.load_error() detail = f" Speech model load error: {load_error}" if load_error else "" return _UNLOADED_MESSAGE + detail diff --git a/app/speech/domain/tts_engine.py b/app/speech/domain/tts_engine.py new file mode 100644 index 0000000..5e35991 --- /dev/null +++ b/app/speech/domain/tts_engine.py @@ -0,0 +1,42 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Protocol for a text-to-speech engine held in memory.""" + +from typing import Protocol + + +class TtsEngine(Protocol): + """Load a voice and synthesize WAV audio. + + The :class:`SpeechRuntimeCoordinator` treats any object satisfying this + protocol as its TTS backend, so swapping TTS implementations only requires a + different engine. + """ + + def is_installed(self, voice_id: str) -> bool: + """Return whether a valid voice for ``voice_id`` is present on disk.""" + ... + + def is_loaded(self, voice_id: str) -> bool: + """Return whether the voice for ``voice_id`` is currently in memory.""" + ... + + def load_error(self) -> str | None: + """Return the last load failure message, if any.""" + ... + + def unload(self) -> None: + """Drop the in-memory voice.""" + ... + + async def load_voice(self, voice_id: str) -> bool: + """Load or reload the voice for ``voice_id``, returning success.""" + ... + + async def synthesize_wav_bytes(self, text: str) -> bytes: + """Synthesize WAV bytes for ``text`` using the loaded voice. + + Raises: + RuntimeError: When no voice is loaded. + """ + ... diff --git a/app/theory/api/__init__.py b/app/theory/api/__init__.py index c380a2a..7861d90 100644 --- a/app/theory/api/__init__.py +++ b/app/theory/api/__init__.py @@ -1,3 +1,16 @@ # Copyright 2026 GrillKit Contributors # SPDX-License-Identifier: Apache-2.0 -"""Theory HTTP and WebSocket transport (scaffold for Phase 4+).""" +"""Theory HTTP and WebSocket transport (scaffold for Phase 4+). + +This package aggregates all theory sub-routers into a single ``router`` +that the application factory mounts via :func:`app.include_router`. +""" + +from fastapi import APIRouter + +from app.theory.api import routes + +router = APIRouter() +router.include_router(routes.router) + +__all__ = ["router"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index c75b4e3..9dd2970 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -12,4 +12,6 @@ if [ "$(id -u)" = "0" ]; then exec gosu "${PUID}:${PGID}" "$@" fi +python -c "from app.shared.infrastructure.database import run_migrations; run_migrations()" + exec "$@" diff --git a/tests/app/test_main.py b/tests/app/test_main.py index dd69027..1b950dc 100644 --- a/tests/app/test_main.py +++ b/tests/app/test_main.py @@ -19,7 +19,7 @@ def test_app_creation(self): assert app is not None assert app.title == "GrillKit" assert app.description == "AI Interview Trainer" - assert app.version == "2026.6.12" + assert app.version == "2026.8.9" def test_static_files_mounted(self): """Test that static files are mounted.""" @@ -45,31 +45,10 @@ def test_routers_included(self): class TestLifespan: """Tests for lifespan context manager.""" - @pytest.mark.asyncio - async def test_lifespan_calls_run_migrations(self): - """Test that lifespan runs database migrations on startup.""" - with ( - patch("app.main.run_migrations") as mock_run_migrations, - patch( - "app.platform.domain.speech_runtime.SpeechRuntimeCoordinator.startup", - new=AsyncMock(), - ), - patch( - "app.platform.domain.speech_runtime.SpeechRuntimeCoordinator.unload_all", - ), - ): - mock_app = MagicMock() - - async with lifespan(mock_app): - pass - - mock_run_migrations.assert_called_once() - @pytest.mark.asyncio async def test_lifespan_yields_control(self): """Test that lifespan yields control to the app.""" with ( - patch("app.main.run_migrations"), patch( "app.platform.domain.speech_runtime.SpeechRuntimeCoordinator.startup", new=AsyncMock(), @@ -97,7 +76,6 @@ class TestAppIntegration: def client(self): """Create a test client.""" with ( - patch("app.main.run_migrations"), patch( "app.platform.domain.speech_runtime.SpeechRuntimeCoordinator.startup", new=AsyncMock(), diff --git a/tests/conftest.py b/tests/conftest.py index db97a87..ef35f01 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,7 +29,6 @@ def client(): """Create a test client with mocked database init.""" with ( - patch("app.main.run_migrations"), patch( "app.platform.domain.speech_runtime.SpeechRuntimeCoordinator.startup", new=AsyncMock(), diff --git a/tests/platform/api/test_config.py b/tests/platform/api/test_config.py index bc54d4c..2cf8a2f 100644 --- a/tests/platform/api/test_config.py +++ b/tests/platform/api/test_config.py @@ -88,7 +88,7 @@ async def test_save_config_preserves_api_key_when_field_empty(self, client): return_value="cloud", ), patch( - "app.platform.api.config.LLMCatalogService.get_model", + "app.platform.queries.config_form.LLMCatalogService.get_model", return_value=self._catalog_entry, ), patch( @@ -119,7 +119,7 @@ async def test_save_config_success(self, client): return_value="cloud", ), patch( - "app.platform.api.config.LLMCatalogService.get_model", + "app.platform.queries.config_form.LLMCatalogService.get_model", return_value=self._catalog_entry, ), patch( @@ -149,7 +149,7 @@ async def test_save_config_failure(self, client): return_value="cloud", ), patch( - "app.platform.api.config.LLMCatalogService.get_model", + "app.platform.queries.config_form.LLMCatalogService.get_model", return_value=self._catalog_entry, ), patch( @@ -189,7 +189,7 @@ async def test_test_config_success(self, client): return_value="cloud", ), patch( - "app.platform.api.config.LLMCatalogService.get_model", + "app.platform.queries.config_form.LLMCatalogService.get_model", return_value=self._catalog_entry, ), patch( @@ -217,7 +217,7 @@ async def test_test_config_failure(self, client): return_value="cloud", ), patch( - "app.platform.api.config.LLMCatalogService.get_model", + "app.platform.queries.config_form.LLMCatalogService.get_model", return_value=self._catalog_entry, ), patch( diff --git a/tests/platform/api/test_config_edge_cases.py b/tests/platform/api/test_config_edge_cases.py index b5d7279..b605b0c 100644 --- a/tests/platform/api/test_config_edge_cases.py +++ b/tests/platform/api/test_config_edge_cases.py @@ -32,7 +32,7 @@ def test_locale_change_saved(self, client, isolated_db): return_value="cloud", ), patch( - "app.platform.api.config.LLMCatalogService.get_model", + "app.platform.queries.config_form.LLMCatalogService.get_model", return_value=self._catalog_entry(), ), patch( @@ -175,7 +175,7 @@ def test_config_save_without_api_key_when_not_required(self, client, isolated_db return_value="local", ), patch( - "app.platform.api.config.LLMCatalogService.get_model", + "app.platform.queries.config_form.LLMCatalogService.get_model", return_value=ollama_entry, ), patch( @@ -217,7 +217,7 @@ def test_speech_model_size_change_stored(self, client, isolated_db): return_value="cloud", ), patch( - "app.platform.api.config.LLMCatalogService.get_model", + "app.platform.queries.config_form.LLMCatalogService.get_model", return_value=self._catalog_entry(), ), patch( diff --git a/tests/platform/api/test_config_flow.py b/tests/platform/api/test_config_flow.py index 929ece8..86ed863 100644 --- a/tests/platform/api/test_config_flow.py +++ b/tests/platform/api/test_config_flow.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 """Tests for first-time configuration flow (S1 scenarios).""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from app.ai.llm_models import LLMModelEntry from app.platform.domain.config import AppConfig @@ -74,7 +74,7 @@ def test_config_save_fails_with_unreachable_ollama(self, client): return_value="local", ), patch( - "app.platform.api.config.LLMCatalogService.get_model", + "app.platform.queries.config_form.LLMCatalogService.get_model", return_value=None, ), patch( @@ -107,7 +107,7 @@ def test_config_test_connection_success(self, client): return_value="cloud", ), patch( - "app.platform.api.config.LLMCatalogService.get_model", + "app.platform.queries.config_form.LLMCatalogService.get_model", return_value=self._catalog_entry(), ), patch( @@ -135,7 +135,7 @@ def test_config_test_connection_failure(self, client): return_value="cloud", ), patch( - "app.platform.api.config.LLMCatalogService.get_model", + "app.platform.queries.config_form.LLMCatalogService.get_model", return_value=self._catalog_entry(), ), patch( @@ -218,7 +218,7 @@ def test_config_save_redirects_or_shows_success(self, client): return_value="cloud", ), patch( - "app.platform.api.config.LLMCatalogService.get_model", + "app.platform.queries.config_form.LLMCatalogService.get_model", return_value=self._catalog_entry(), ), patch( @@ -227,7 +227,8 @@ def test_config_save_redirects_or_shows_success(self, client): ), patch("app.platform.domain.config.ConfigService.save_config") as mock_save, patch( - "app.platform.domain.speech_runtime.SpeechRuntimeCoordinator.reload_after_config_save" + "app.platform.domain.speech_runtime.SpeechRuntimeCoordinator.reload_after_config_save", + new=AsyncMock(return_value=[]), ), ): response = client.post( @@ -243,6 +244,40 @@ def test_config_save_redirects_or_shows_success(self, client): mock_save.assert_called_once() assert "saved" in response.text.lower() or "success" in response.text.lower() + def test_config_save_warns_when_speech_model_fails_to_load(self, client): + """S1.7b: Config saved but warning shown when a speech model fails to load.""" + with ( + patch( + "app.platform.queries.config_form.normalize_model_id", + return_value="cloud", + ), + patch( + "app.platform.queries.config_form.LLMCatalogService.get_model", + return_value=self._catalog_entry(), + ), + patch( + "app.platform.domain.config.ConfigService.test_connection", + return_value=(True, "OK"), + ), + patch("app.platform.domain.config.ConfigService.save_config") as mock_save, + patch( + "app.platform.domain.speech_runtime.SpeechRuntimeCoordinator.reload_after_config_save", + new=AsyncMock(return_value=["Whisper failed to load"]), + ), + ): + response = client.post( + "/config", + data={ + "llm_preset_id": "cloud", + "api_key": "test-key", + "timeout": "60", + "locale": "en", + }, + ) + assert response.status_code == 200 + mock_save.assert_called_once() + assert "Whisper failed to load" in response.text + def test_after_save_setup_no_longer_redirects(self, client): """S1.8: After config saved, GET /setup returns setup page.""" mock_config = AppConfig( diff --git a/tests/platform/domain/test_speech_runtime.py b/tests/platform/domain/test_speech_runtime.py index 9aefaff..9f35b41 100644 --- a/tests/platform/domain/test_speech_runtime.py +++ b/tests/platform/domain/test_speech_runtime.py @@ -2,141 +2,121 @@ # SPDX-License-Identifier: Apache-2.0 """Tests for SpeechRuntimeCoordinator.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock -from fastapi import FastAPI import pytest +from app.ai.speech_transcriber import SpeechTranscriber from app.platform.domain.config import AppConfig from app.platform.domain.speech_runtime import SpeechRuntimeCoordinator +from app.speech.domain.stt_loader import SttModelLoader +from app.speech.domain.tts_engine import TtsEngine -@pytest.fixture(autouse=True) -def reset_runtimes(): - """Reset runtime class state before each test.""" - from app.shared.infrastructure.gateways.piper import PiperGateway as PiperRuntime - from app.shared.infrastructure.gateways.whisper import ( - WhisperGateway as WhisperRuntime, - ) - - WhisperRuntime._app = None - WhisperRuntime._artifact = None - WhisperRuntime._loaded_key = None - PiperRuntime._artifact = None - PiperRuntime._loaded_key = None - PiperRuntime._load_error = None - yield - WhisperRuntime._app = None - WhisperRuntime._artifact = None - WhisperRuntime._loaded_key = None - PiperRuntime._artifact = None - PiperRuntime._loaded_key = None - PiperRuntime._load_error = None +class MockConfigService: + """Config service stub returning no saved config.""" + + @staticmethod + def get_config(): + return None + + +class MockConfigServiceWithModel: + """Config service stub returning a model + voice enabled config.""" + + @staticmethod + def get_config(): + return AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + speech_model_size="small", + question_voice_enabled=True, + tts_voice_id="en_US-lessac-medium", + locale="en", + ) + + +def make_loader( + *, + installed: bool = True, + loaded: bool = False, + transcriber: SpeechTranscriber | None = None, +) -> MagicMock: + """Build a spec'd STT loader mock with predictable defaults.""" + loader = MagicMock(spec=SttModelLoader) + loader.is_installed.return_value = installed + loader.is_loaded.return_value = loaded + loader.get.return_value = transcriber + loader.load_error.return_value = None + loader.load_size = AsyncMock(return_value=True) + return loader + + +def make_engine( + *, + installed: bool = True, + loaded: bool = False, +) -> MagicMock: + """Build a spec'd TTS engine mock with predictable defaults.""" + engine = MagicMock(spec=TtsEngine) + engine.is_installed.return_value = installed + engine.is_loaded.return_value = loaded + engine.load_error.return_value = None + engine.load_voice = AsyncMock(return_value=True) + return engine + + +def make_coordinator( + loader: MagicMock, + engine: MagicMock, + config_service: type = MockConfigService, +) -> SpeechRuntimeCoordinator: + """Build a coordinator bound to the given loader, engine and config service.""" + return SpeechRuntimeCoordinator(loader, engine, config_service=config_service) class TestUnloadAll: """Tests for SpeechRuntimeCoordinator.unload_all.""" - def test_unloads_both_runtimes(self): - """unload_all calls unload on Whisper and Piper.""" - from app.shared.infrastructure.gateways.piper import ( - PiperGateway as PiperRuntime, - ) - from app.shared.infrastructure.gateways.whisper import ( - WhisperGateway as WhisperRuntime, - ) + def test_unloads_stt_and_tts(self): + """unload_all unloads the STT loader and the TTS engine.""" + loader = make_loader() + engine = make_engine() + coordinator = make_coordinator(loader, engine) - with ( - patch.object(WhisperRuntime, "unload") as mock_whisper_unload, - patch.object(PiperRuntime, "unload") as mock_piper_unload, - ): - SpeechRuntimeCoordinator.unload_all() + coordinator.unload_all() - mock_whisper_unload.assert_called_once() - mock_piper_unload.assert_called_once() + loader.unload.assert_called_once() + engine.unload.assert_called_once() class TestStartup: """Tests for SpeechRuntimeCoordinator.startup.""" @pytest.mark.asyncio - async def test_startup_binds_app_and_loads_both(self): - """startup binds app, loads Whisper and Piper when installed.""" - from app.shared.infrastructure.gateways.piper import ( - PiperGateway as PiperRuntime, - ) - from app.shared.infrastructure.gateways.whisper import ( - WhisperGateway as WhisperRuntime, - ) + async def test_startup_loads_both_when_installed(self): + """startup reads config and loads Whisper and Piper when installed.""" + loader = make_loader(installed=True) + engine = make_engine(installed=True) + coordinator = make_coordinator(loader, engine, MockConfigServiceWithModel) - app = FastAPI() - config = AppConfig( - provider_type="openai-compatible", - base_url="http://localhost", - model="gpt-4", - speech_model_size="small", - question_voice_enabled=True, - tts_voice_id="en_US-lessac-medium", - locale="en", - ) + await coordinator.startup() - with ( - patch.object(WhisperRuntime, "bind_app") as mock_bind, - patch( - "app.platform.domain.speech_runtime.ConfigService.get_config", - return_value=config, - ), - patch( - "app.platform.domain.speech_runtime.is_installed", - return_value=True, - ), - patch( - "app.platform.domain.speech_runtime.is_voice_installed", - return_value=True, - ), - patch.object( - WhisperRuntime, - "load_size", - return_value=True, - ) as mock_whisper_load, - patch.object( - PiperRuntime, - "load_voice", - return_value=True, - ) as mock_piper_load, - ): - await SpeechRuntimeCoordinator.startup(app) - - mock_bind.assert_called_once_with(app) - mock_whisper_load.assert_awaited_once_with("small") - mock_piper_load.assert_awaited_once_with("en_US-lessac-medium") + loader.load_size.assert_awaited_once_with("small") + engine.load_voice.assert_awaited_once_with("en_US-lessac-medium") @pytest.mark.asyncio - async def test_startup_skips_when_no_config(self): + async def test_startup_unloads_when_no_config(self): """startup unloads both when no config exists.""" - from app.shared.infrastructure.gateways.piper import ( - PiperGateway as PiperRuntime, - ) - from app.shared.infrastructure.gateways.whisper import ( - WhisperGateway as WhisperRuntime, - ) - - app = FastAPI() + loader = make_loader() + engine = make_engine() + coordinator = make_coordinator(loader, engine, MockConfigService) - with ( - patch.object(WhisperRuntime, "bind_app") as mock_bind, - patch( - "app.platform.domain.speech_runtime.ConfigService.get_config", - return_value=None, - ), - patch.object(WhisperRuntime, "unload") as mock_whisper_unload, - patch.object(PiperRuntime, "unload") as mock_piper_unload, - ): - await SpeechRuntimeCoordinator.startup(app) + await coordinator.startup() - mock_bind.assert_called_once_with(app) - mock_whisper_unload.assert_called_once() - mock_piper_unload.assert_called_once() + loader.unload.assert_called_once() + engine.unload.assert_called_once() class TestSyncWhisper: @@ -144,11 +124,9 @@ class TestSyncWhisper: @pytest.mark.asyncio async def test_loads_when_installed(self): - """sync_whisper loads when model is installed.""" - from app.shared.infrastructure.gateways.whisper import ( - WhisperGateway as WhisperRuntime, - ) - + """sync_whisper loads when the model is installed.""" + loader = make_loader(installed=True) + coordinator = make_coordinator(loader, make_engine()) config = AppConfig( provider_type="openai-compatible", base_url="http://localhost", @@ -156,28 +134,15 @@ async def test_loads_when_installed(self): speech_model_size="medium", ) - with ( - patch( - "app.platform.domain.speech_runtime.is_installed", - return_value=True, - ), - patch.object( - WhisperRuntime, - "load_size", - return_value=True, - ) as mock_load, - ): - await SpeechRuntimeCoordinator.sync_whisper(config) - - mock_load.assert_awaited_once_with("medium") + await coordinator.sync_whisper(config) + + loader.load_size.assert_awaited_once_with("medium") @pytest.mark.asyncio async def test_unloads_when_not_installed(self): - """sync_whisper unloads when model is not installed.""" - from app.shared.infrastructure.gateways.whisper import ( - WhisperGateway as WhisperRuntime, - ) - + """sync_whisper unloads when the model is not installed.""" + loader = make_loader(installed=False) + coordinator = make_coordinator(loader, make_engine()) config = AppConfig( provider_type="openai-compatible", base_url="http://localhost", @@ -185,28 +150,19 @@ async def test_unloads_when_not_installed(self): speech_model_size="large", ) - with ( - patch( - "app.platform.domain.speech_runtime.is_installed", - return_value=False, - ), - patch.object(WhisperRuntime, "unload") as mock_unload, - ): - await SpeechRuntimeCoordinator.sync_whisper(config) + await coordinator.sync_whisper(config) - mock_unload.assert_called_once() + loader.unload.assert_called_once() @pytest.mark.asyncio async def test_unloads_when_no_config(self): """sync_whisper unloads when config is None.""" - from app.shared.infrastructure.gateways.whisper import ( - WhisperGateway as WhisperRuntime, - ) + loader = make_loader() + coordinator = make_coordinator(loader, make_engine()) - with patch.object(WhisperRuntime, "unload") as mock_unload: - await SpeechRuntimeCoordinator.sync_whisper(None) + await coordinator.sync_whisper(None) - mock_unload.assert_called_once() + loader.unload.assert_called_once() class TestSyncPiper: @@ -214,11 +170,10 @@ class TestSyncPiper: @pytest.mark.asyncio async def test_loads_when_enabled_and_installed(self): - """sync_piper loads voice when enabled and installed.""" - from app.shared.infrastructure.gateways.piper import ( - PiperGateway as PiperRuntime, - ) - + """sync_piper loads a voice when enabled and installed.""" + loader = make_loader() + engine = make_engine(installed=True) + coordinator = make_coordinator(loader, engine) config = AppConfig( provider_type="openai-compatible", base_url="http://localhost", @@ -227,28 +182,16 @@ async def test_loads_when_enabled_and_installed(self): tts_voice_id="en_US-lessac-medium", ) - with ( - patch( - "app.platform.domain.speech_runtime.is_voice_installed", - return_value=True, - ), - patch.object( - PiperRuntime, - "load_voice", - return_value=True, - ) as mock_load, - ): - await SpeechRuntimeCoordinator.sync_piper(config) - - mock_load.assert_awaited_once_with("en_US-lessac-medium") + await coordinator.sync_piper(config) + + engine.load_voice.assert_awaited_once_with("en_US-lessac-medium") @pytest.mark.asyncio async def test_unloads_when_disabled(self): """sync_piper unloads when question voice is disabled.""" - from app.shared.infrastructure.gateways.piper import ( - PiperGateway as PiperRuntime, - ) - + loader = make_loader() + engine = make_engine() + coordinator = make_coordinator(loader, engine) config = AppConfig( provider_type="openai-compatible", base_url="http://localhost", @@ -256,18 +199,16 @@ async def test_unloads_when_disabled(self): question_voice_enabled=False, ) - with patch.object(PiperRuntime, "unload") as mock_unload: - await SpeechRuntimeCoordinator.sync_piper(config) + await coordinator.sync_piper(config) - mock_unload.assert_called_once() + engine.unload.assert_called_once() @pytest.mark.asyncio async def test_unloads_when_not_installed(self): - """sync_piper unloads when voice is not on disk.""" - from app.shared.infrastructure.gateways.piper import ( - PiperGateway as PiperRuntime, - ) - + """sync_piper unloads when the voice is not on disk.""" + loader = make_loader() + engine = make_engine(installed=False) + coordinator = make_coordinator(loader, engine) config = AppConfig( provider_type="openai-compatible", base_url="http://localhost", @@ -276,43 +217,31 @@ async def test_unloads_when_not_installed(self): tts_voice_id="en_US-lessac-medium", ) - with ( - patch( - "app.platform.domain.speech_runtime.is_voice_installed", - return_value=False, - ), - patch.object(PiperRuntime, "unload") as mock_unload, - ): - await SpeechRuntimeCoordinator.sync_piper(config) + await coordinator.sync_piper(config) - mock_unload.assert_called_once() + engine.unload.assert_called_once() @pytest.mark.asyncio async def test_unloads_when_no_config(self): """sync_piper unloads when config is None.""" - from app.shared.infrastructure.gateways.piper import ( - PiperGateway as PiperRuntime, - ) + loader = make_loader() + engine = make_engine() + coordinator = make_coordinator(loader, engine) - with patch.object(PiperRuntime, "unload") as mock_unload: - await SpeechRuntimeCoordinator.sync_piper(None) + await coordinator.sync_piper(None) - mock_unload.assert_called_once() + engine.unload.assert_called_once() class TestReloadAfterConfigSave: """Tests for SpeechRuntimeCoordinator.reload_after_config_save.""" @pytest.mark.asyncio - async def test_reloads_whisper_and_piper(self): - """reload_after_config_save re-runs sync for both runtimes.""" - from app.shared.infrastructure.gateways.piper import ( - PiperGateway as PiperRuntime, - ) - from app.shared.infrastructure.gateways.whisper import ( - WhisperGateway as WhisperRuntime, - ) - + async def test_syncs_both_runtimes(self): + """reload_after_config_save syncs Whisper and Piper.""" + loader = make_loader(installed=True) + engine = make_engine(installed=True) + coordinator = make_coordinator(loader, engine) config = AppConfig( provider_type="openai-compatible", base_url="http://localhost", @@ -322,30 +251,10 @@ async def test_reloads_whisper_and_piper(self): tts_voice_id="ru_RU-dmitri-medium", ) - with ( - patch( - "app.platform.domain.speech_runtime.is_installed", - return_value=True, - ), - patch( - "app.platform.domain.speech_runtime.is_voice_installed", - return_value=True, - ), - patch.object( - WhisperRuntime, - "load_size", - return_value=True, - ) as mock_whisper_load, - patch.object( - PiperRuntime, - "load_voice", - return_value=True, - ) as mock_piper_load, - ): - await SpeechRuntimeCoordinator.reload_after_config_save(config) - - mock_whisper_load.assert_awaited_once_with("small") - mock_piper_load.assert_awaited_once_with("ru_RU-dmitri-medium") + await coordinator.reload_after_config_save(config) + + loader.load_size.assert_awaited_once_with("small") + engine.load_voice.assert_awaited_once_with("ru_RU-dmitri-medium") class TestPreloadWhisperForActiveInterview: @@ -354,11 +263,8 @@ class TestPreloadWhisperForActiveInterview: @pytest.mark.asyncio async def test_loads_when_interview_active(self): """Preloads Whisper when interview is active and model installed.""" - from app.shared.infrastructure.gateways.whisper import ( - WhisperGateway as WhisperRuntime, - ) - - app = FastAPI() + loader = make_loader(installed=True, loaded=False) + coordinator = make_coordinator(loader, make_engine()) config = AppConfig( provider_type="openai-compatible", base_url="http://localhost", @@ -366,58 +272,29 @@ async def test_loads_when_interview_active(self): speech_model_size="small", ) - with ( - patch.object(WhisperRuntime, "bind_app") as mock_bind, - patch( - "app.platform.domain.speech_runtime.is_installed", - return_value=True, - ), - patch.object( - WhisperRuntime, - "is_loaded", - return_value=False, - ), - patch.object( - WhisperRuntime, - "load_size", - return_value=True, - ) as mock_load, - ): - await SpeechRuntimeCoordinator.preload_whisper_for_active_interview( - app, config, interview_active=True - ) - - mock_bind.assert_called_once_with(app) - mock_load.assert_awaited_once_with("small") + await coordinator.preload_whisper_for_active_interview( + config, interview_active=True + ) + + loader.load_size.assert_awaited_once_with("small") @pytest.mark.asyncio async def test_skips_when_no_config(self): """No loading when config is None.""" - from app.shared.infrastructure.gateways.whisper import ( - WhisperGateway as WhisperRuntime, - ) + loader = make_loader() + coordinator = make_coordinator(loader, make_engine()) - app = FastAPI() - - with ( - patch.object(WhisperRuntime, "bind_app") as mock_bind, - patch.object(WhisperRuntime, "load_size") as mock_load, - ): - await SpeechRuntimeCoordinator.preload_whisper_for_active_interview( - app, None, interview_active=True - ) + await coordinator.preload_whisper_for_active_interview( + None, interview_active=True + ) - mock_bind.assert_called_once_with(app) - mock_load.assert_not_called() + loader.load_size.assert_not_called() @pytest.mark.asyncio async def test_skips_when_interview_not_active(self): """No loading when interview is not active.""" - from app.shared.infrastructure.gateways.whisper import ( - WhisperGateway as WhisperRuntime, - ) - - app = FastAPI() + loader = make_loader() + coordinator = make_coordinator(loader, make_engine()) config = AppConfig( provider_type="openai-compatible", base_url="http://localhost", @@ -425,25 +302,17 @@ async def test_skips_when_interview_not_active(self): speech_model_size="small", ) - with ( - patch.object(WhisperRuntime, "bind_app") as mock_bind, - patch.object(WhisperRuntime, "load_size") as mock_load, - ): - await SpeechRuntimeCoordinator.preload_whisper_for_active_interview( - app, config, interview_active=False - ) + await coordinator.preload_whisper_for_active_interview( + config, interview_active=False + ) - mock_bind.assert_called_once_with(app) - mock_load.assert_not_called() + loader.load_size.assert_not_called() @pytest.mark.asyncio async def test_skips_when_already_loaded(self): - """No loading when model is already in memory.""" - from app.shared.infrastructure.gateways.whisper import ( - WhisperGateway as WhisperRuntime, - ) - - app = FastAPI() + """No loading when the model is already in memory.""" + loader = make_loader(installed=True, loaded=True) + coordinator = make_coordinator(loader, make_engine()) config = AppConfig( provider_type="openai-compatible", base_url="http://localhost", @@ -451,22 +320,8 @@ async def test_skips_when_already_loaded(self): speech_model_size="small", ) - with ( - patch.object(WhisperRuntime, "bind_app") as mock_bind, - patch( - "app.platform.domain.speech_runtime.is_installed", - return_value=True, - ), - patch.object( - WhisperRuntime, - "is_loaded", - return_value=True, - ), - patch.object(WhisperRuntime, "load_size") as mock_load, - ): - await SpeechRuntimeCoordinator.preload_whisper_for_active_interview( - app, config, interview_active=True - ) - - mock_bind.assert_called_once_with(app) - mock_load.assert_not_called() + await coordinator.preload_whisper_for_active_interview( + config, interview_active=True + ) + + loader.load_size.assert_not_called() diff --git a/tests/platform/queries/test_config_form.py b/tests/platform/queries/test_config_form.py index fe56b19..4bddd4e 100644 --- a/tests/platform/queries/test_config_form.py +++ b/tests/platform/queries/test_config_form.py @@ -8,7 +8,7 @@ from app.ai.llm_models import LLMModelEntry from app.platform.domain.config import AppConfig -from app.platform.queries.config_form import ConfigFormService +from app.platform.queries.config_form import parse_and_test class TestParseAndTest: @@ -51,7 +51,7 @@ def mock_config_service(self): async def test_parse_and_test_success(self, mock_catalog, mock_config_service): """Valid form data yields config, success=True, and message.""" _, entry = mock_catalog - config, success, message = await ConfigFormService.parse_and_test( + config, success, message = await parse_and_test( config_service=mock_config_service, llm_preset_id="cloud", api_key="secret", @@ -85,7 +85,7 @@ async def test_parse_and_test_uses_existing_voice_when_locale_unchanged( ) mock_config_service.get_config.return_value = existing - config, success, message = await ConfigFormService.parse_and_test( + config, success, message = await parse_and_test( config_service=mock_config_service, llm_preset_id="cloud", api_key="secret", @@ -110,7 +110,7 @@ async def test_parse_and_test_selects_voice_by_locale_when_locale_changes( ) mock_config_service.get_config.return_value = existing - config, success, message = await ConfigFormService.parse_and_test( + config, success, message = await parse_and_test( config_service=mock_config_service, llm_preset_id="cloud", api_key="secret", @@ -134,7 +134,7 @@ async def test_parse_and_test_rejects_invalid_preset(self, mock_config_service): side_effect=ValueError("Unsupported LLM model"), ), ): - config, success, message = await ConfigFormService.parse_and_test( + config, success, message = await parse_and_test( config_service=mock_config_service, llm_preset_id="invalid", api_key="", @@ -166,7 +166,7 @@ async def test_parse_and_test_rejects_missing_model_entry( return_value="cloud", ), ): - config, success, message = await ConfigFormService.parse_and_test( + config, success, message = await parse_and_test( config_service=mock_config_service, llm_preset_id="cloud", api_key="", @@ -187,7 +187,7 @@ async def test_parse_and_test_connection_failure( return_value=(False, "Connection refused") ) - config, success, message = await ConfigFormService.parse_and_test( + config, success, message = await parse_and_test( config_service=mock_config_service, llm_preset_id="cloud", api_key="bad", @@ -206,7 +206,7 @@ async def test_parse_and_test_normalizes_inputs( ): """Locale and speech_model_size are normalized.""" _, entry = mock_catalog - config, success, message = await ConfigFormService.parse_and_test( + config, success, message = await parse_and_test( config_service=mock_config_service, llm_preset_id="cloud", api_key="", diff --git a/tests/platform/use_cases/test_delete_config.py b/tests/platform/use_cases/test_delete_config.py new file mode 100644 index 0000000..410db28 --- /dev/null +++ b/tests/platform/use_cases/test_delete_config.py @@ -0,0 +1,23 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for DeleteConfigUseCase.""" + +from unittest.mock import MagicMock, patch + +from app.platform.domain.config import ConfigService +from app.platform.use_cases.delete_config import DeleteConfigUseCase + + +class TestDeleteConfigUseCase: + """Tests for the delete-config orchestration.""" + + def test_deletes_config_and_unloads_speech(self): + """Delete removes the persisted config and unloads speech runtimes.""" + coordinator = MagicMock() + use_case = DeleteConfigUseCase(ConfigService, coordinator) + + with patch.object(ConfigService, "delete_config") as mock_delete: + use_case.execute() + + mock_delete.assert_called_once_with() + coordinator.unload_all.assert_called_once_with() diff --git a/tests/platform/use_cases/test_save_config.py b/tests/platform/use_cases/test_save_config.py new file mode 100644 index 0000000..0b557b4 --- /dev/null +++ b/tests/platform/use_cases/test_save_config.py @@ -0,0 +1,57 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for SaveConfigUseCase.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.platform.domain.config import AppConfig, ConfigService +from app.platform.use_cases.save_config import SaveConfigUseCase + + +def _config() -> AppConfig: + return AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + speech_model_size="small", + ) + + +def make_coordinator(*, speech_errors: tuple[str, ...] = ()) -> MagicMock: + """Build a coordinator mock whose reload reports the given errors.""" + coordinator = MagicMock() + coordinator.reload_after_config_save = AsyncMock(return_value=list(speech_errors)) + return coordinator + + +class TestSaveConfigUseCase: + """Tests for the save-config orchestration.""" + + @pytest.mark.asyncio + async def test_saves_config_and_reloads_speech(self): + """Config is persisted and speech runtimes are reloaded.""" + coordinator = make_coordinator() + use_case = SaveConfigUseCase(ConfigService, coordinator) + + with patch.object(ConfigService, "save_config") as mock_save: + result = await use_case.execute(_config()) + + mock_save.assert_called_once_with(_config()) + coordinator.reload_after_config_save.assert_awaited_once_with(_config()) + assert result.saved is True + assert result.speech_errors == () + + @pytest.mark.asyncio + async def test_saves_config_even_when_speech_fails(self): + """Config is still saved when a speech model fails to load.""" + coordinator = make_coordinator(speech_errors=("Whisper failed to load",)) + use_case = SaveConfigUseCase(ConfigService, coordinator) + + with patch.object(ConfigService, "save_config") as mock_save: + result = await use_case.execute(_config()) + + mock_save.assert_called_once_with(_config()) + assert result.saved is True + assert result.speech_errors == ("Whisper failed to load",) diff --git a/tests/question_voice/use_cases/test_generate_question_audio.py b/tests/question_voice/use_cases/test_generate_question_audio.py index aa8a04a..fe8131e 100644 --- a/tests/question_voice/use_cases/test_generate_question_audio.py +++ b/tests/question_voice/use_cases/test_generate_question_audio.py @@ -4,7 +4,7 @@ from datetime import datetime from pathlib import Path -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -15,6 +15,12 @@ _resolve_answer, ) from app.shared.infrastructure.gateways.tts_exceptions import QuestionVoiceDisabledError +from app.speech.domain.tts_engine import TtsEngine + + +def _engine(): + """Build a dummy TTS engine for orchestration tests.""" + return MagicMock(spec=TtsEngine) def _make_answer( @@ -69,10 +75,7 @@ async def test_raises_when_no_config(self): ), pytest.raises(QuestionVoiceDisabledError), ): - await GenerateQuestionAudio.execute("interview-id") - - @pytest.mark.asyncio - async def test_raises_when_voice_disabled(self): + await GenerateQuestionAudio.execute(_engine(), "interview-id") """Disabled voice in config raises QuestionVoiceDisabledError.""" config = AppConfig( provider_type="openai-compatible", @@ -87,11 +90,12 @@ async def test_raises_when_voice_disabled(self): ), pytest.raises(QuestionVoiceDisabledError), ): - await GenerateQuestionAudio.execute("interview-id") + await GenerateQuestionAudio.execute(_engine(), "interview-id") @pytest.mark.asyncio async def test_returns_cached_path_with_answer_id(self): """Enabled voice returns WAV path for a specific answer_id.""" + engine = _engine() config = AppConfig( provider_type="openai-compatible", base_url="http://localhost", @@ -119,10 +123,13 @@ async def test_returns_cached_path_with_answer_id(self): return_value=cached_path, ) as mock_cache, ): - result = await GenerateQuestionAudio.execute("interview-id", answer_id=7) + result = await GenerateQuestionAudio.execute( + engine, "interview-id", answer_id=7 + ) assert result == cached_path mock_cache.assert_called_once_with( + engine, "en_US-lessac-medium", "en", "What is Python?", @@ -131,6 +138,7 @@ async def test_returns_cached_path_with_answer_id(self): @pytest.mark.asyncio async def test_returns_cached_path_for_current_question(self): """Enabled voice returns path for current unanswered question.""" + engine = _engine() config = AppConfig( provider_type="openai-compatible", base_url="http://localhost", @@ -158,10 +166,11 @@ async def test_returns_cached_path_for_current_question(self): return_value=cached_path, ) as mock_cache, ): - result = await GenerateQuestionAudio.execute("interview-id") + result = await GenerateQuestionAudio.execute(engine, "interview-id") assert result == cached_path mock_cache.assert_called_once_with( + engine, "en_US-lessac-medium", "en", "What is Python?", diff --git a/tests/speech/api/test_dictation_ws.py b/tests/speech/api/test_dictation_ws.py index 5ec83c9..7d3a6f7 100644 --- a/tests/speech/api/test_dictation_ws.py +++ b/tests/speech/api/test_dictation_ws.py @@ -15,7 +15,6 @@ def client(): """Create a test client without loading Whisper on startup.""" with ( - patch("app.main.run_migrations"), patch( "app.platform.domain.speech_runtime.SpeechRuntimeCoordinator.startup", new=AsyncMock(), @@ -25,7 +24,6 @@ def client(): ), ): app = create_app() - app.state.speech_transcriber = None with TestClient(app) as test_client: yield test_client @@ -43,12 +41,16 @@ class TestDictationWebSocket: """Tests for WS /interview/{id}/dictation.""" def test_rejects_when_model_not_loaded(self, client): - """Connection closes with error when speech_transcriber is absent.""" + """Connection closes with error when speech transcriber is absent.""" with ( patch( "app.interview.queries.loader.InterviewQuery.load", return_value=_active_interview(), ), + patch( + "app.speech.api.dictation.resolve_speech_transcriber", + new=AsyncMock(return_value=None), + ), client.websocket_connect("/interview/test-session/dictation") as ws, ): data = ws.receive_json() @@ -66,20 +68,23 @@ def test_start_stop_returns_final_text(self, client): "app.interview.queries.loader.InterviewQuery.load", return_value=_active_interview(), ), + patch( + "app.speech.api.dictation.resolve_speech_transcriber", + new=AsyncMock(return_value=mock_transcriber), + ), patch( "app.speech.api.dictation.DictationSession", return_value=mock_session ), + client.websocket_connect("/interview/test-session/dictation") as ws, ): - client.app.state.speech_transcriber = mock_transcriber - with client.websocket_connect("/interview/test-session/dictation") as ws: - ws.send_json({"type": "start"}) - assert ws.receive_json() == {"type": "ready"} - ws.send_bytes(b"\x00\x00" * 50) - ws.send_json({"type": "stop"}) - final = ws.receive_json() - assert final == {"type": "final", "text": "hello world"} - mock_session.append_pcm.assert_called() - mock_session.finalize.assert_awaited_once_with(mock_transcriber, "en") + ws.send_json({"type": "start"}) + assert ws.receive_json() == {"type": "ready"} + ws.send_bytes(b"\x00\x00" * 50) + ws.send_json({"type": "stop"}) + final = ws.receive_json() + assert final == {"type": "final", "text": "hello world"} + mock_session.append_pcm.assert_called() + mock_session.finalize.assert_awaited_once_with(mock_transcriber, "en") def test_rejects_completed_interview(self, client): """Completed interviews receive an error and close.""" diff --git a/tests/speech/api/test_routes.py b/tests/speech/api/test_routes.py index cb233db..610d4e4 100644 --- a/tests/speech/api/test_routes.py +++ b/tests/speech/api/test_routes.py @@ -7,7 +7,7 @@ import pytest from app.platform.domain.config import AppConfig -from app.shared.infrastructure.gateways.whisper import WhisperGateway as WhisperRuntime +from app.shared.infrastructure.gateways.whisper import WhisperRuntime from app.shared.infrastructure.gateways.whisper_model import WhisperModelService diff --git a/tests/speech/domain/test_transcriber_resolver.py b/tests/speech/domain/test_transcriber_resolver.py index 43231f5..10789e3 100644 --- a/tests/speech/domain/test_transcriber_resolver.py +++ b/tests/speech/domain/test_transcriber_resolver.py @@ -2,15 +2,18 @@ # SPDX-License-Identifier: Apache-2.0 """Tests for transcriber resolution.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock import pytest -from starlette.applications import Starlette +from app.platform.domain.config import AppConfig, ConfigService +from app.platform.domain.speech_runtime import SpeechRuntimeCoordinator +from app.speech.domain.stt_loader import SttModelLoader from app.speech.domain.transcriber_resolver import ( resolve_speech_transcriber, speech_transcriber_unavailable_message, ) +from app.speech.domain.tts_engine import TtsEngine class FakeTranscriber: @@ -20,179 +23,133 @@ async def transcribe(self, audio, locale): return "fake transcript" -class TestResolveSpeechTranscriber: - """Tests for resolve_speech_transcriber.""" +class MockConfigService(ConfigService): + """Config service stub returning no saved config.""" - @pytest.fixture - def app(self): - """Create a Starlette app with clean state.""" - return Starlette() + @staticmethod + def get_config(): + return None - @pytest.fixture - def mock_config_service(self): - """Return a mock ConfigService class with no config.""" - class MockConfigService: - @staticmethod - def get_config(): - return None +class MockConfigServiceWithModel(ConfigService): + """Config service stub returning a model size.""" - return MockConfigService + @staticmethod + def get_config(): + return AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + speech_model_size="small", + ) - @pytest.mark.asyncio - async def test_returns_app_state_transcriber_when_present( - self, app, mock_config_service - ): - """Returns speech_transcriber from app.state when available.""" - fake = FakeTranscriber() - app.state.speech_transcriber = fake - result = await resolve_speech_transcriber(app, mock_config_service) +class TestResolveSpeechTranscriber: + """Tests for resolve_speech_transcriber.""" - assert result is fake + def _coordinator( + self, + *, + transcriber=None, + get_side_effect=None, + load_error=None, + config_service: type = MockConfigService, + ) -> tuple[SpeechRuntimeCoordinator, MagicMock]: + """Build a coordinator plus its fake STT loader mock.""" + loader = MagicMock(spec=SttModelLoader) + loader.is_installed.return_value = True + loader.get.side_effect = get_side_effect or [transcriber] + loader.load_error.return_value = load_error + loader.load_size = AsyncMock(return_value=True) + engine = MagicMock(spec=TtsEngine) + coordinator = SpeechRuntimeCoordinator( + loader, engine, config_service=config_service + ) + return coordinator, loader @pytest.mark.asyncio - async def test_loads_from_runtime_when_app_state_none( - self, app, mock_config_service - ): - """Falls back to WhisperRuntime.load_size when app.state is empty.""" + async def test_returns_loaded_transcriber_when_present(self): + """Returns the loaded transcriber from the coordinator.""" fake = FakeTranscriber() - app.state.speech_transcriber = None - - class ConfigWithModel: - speech_model_size = "small" - - class MockConfigServiceWithModel: - @staticmethod - def get_config(): - return ConfigWithModel() - - with ( - patch( - "app.speech.domain.transcriber_resolver.is_installed", - return_value=True, - ), - patch( - "app.speech.domain.transcriber_resolver.WhisperRuntime.load_size" - ) as mock_load, - ): - - async def _load_and_set(size): - app.state.speech_transcriber = fake - return True + coordinator, _ = self._coordinator(transcriber=fake) - mock_load.side_effect = _load_and_set - result = await resolve_speech_transcriber(app, MockConfigServiceWithModel) + result = await resolve_speech_transcriber(coordinator) assert result is fake - mock_load.assert_called_once_with("small") @pytest.mark.asyncio - async def test_returns_none_when_not_installed(self, app, mock_config_service): - """Returns None when model is not installed.""" - app.state.speech_transcriber = None - - class ConfigWithModel: - speech_model_size = "small" - - class MockConfigServiceWithModel: - @staticmethod - def get_config(): - return ConfigWithModel() + async def test_loads_from_runtime_when_empty(self): + """Falls back to a load when no transcriber is loaded.""" + fake = FakeTranscriber() + coordinator, loader = self._coordinator( + get_side_effect=[None, fake], + config_service=MockConfigServiceWithModel, + ) - with patch( - "app.speech.domain.transcriber_resolver.is_installed", - return_value=False, - ): - result = await resolve_speech_transcriber(app, MockConfigServiceWithModel) + result = await resolve_speech_transcriber(coordinator) - assert result is None + assert result is fake + loader.load_size.assert_awaited_once_with("small") @pytest.mark.asyncio - async def test_returns_none_when_no_config(self, app, mock_config_service): + async def test_returns_none_when_no_config(self): """Returns None when there is no saved config.""" - app.state.speech_transcriber = None + coordinator, _ = self._coordinator(get_side_effect=[None, None]) - result = await resolve_speech_transcriber(app, mock_config_service) + result = await resolve_speech_transcriber(coordinator) assert result is None @pytest.mark.asyncio - async def test_returns_none_when_load_fails(self, app, mock_config_service): - """Returns None when runtime load fails and app.state stays empty.""" - app.state.speech_transcriber = None - - class ConfigWithModel: - speech_model_size = "medium" - - class MockConfigServiceWithModel: - @staticmethod - def get_config(): - return ConfigWithModel() - - with ( - patch( - "app.speech.domain.transcriber_resolver.is_installed", - return_value=True, - ), - patch( - "app.speech.domain.transcriber_resolver.WhisperRuntime.load_size", - return_value=False, - ) as mock_load, - ): - result = await resolve_speech_transcriber(app, MockConfigServiceWithModel) + async def test_returns_none_when_load_fails(self): + """Returns None when a load does not populate a transcriber.""" + coordinator, loader = self._coordinator( + get_side_effect=[None, None], + config_service=MockConfigServiceWithModel, + ) + + result = await resolve_speech_transcriber(coordinator) assert result is None - mock_load.assert_called_once_with("medium") + loader.load_size.assert_awaited_once_with("small") @pytest.mark.asyncio - async def test_normalizes_model_size_via_config(self, app, mock_config_service): - """Config speech_model_size is used for runtime lookup.""" - app.state.speech_transcriber = None + async def test_normalizes_model_size_via_config(self): + """Config speech_model_size is used for the runtime load.""" + coordinator, loader = self._coordinator( + get_side_effect=[None, None], + config_service=MockConfigServiceWithModel, + ) - class ConfigWithModel: - speech_model_size = "large" + await resolve_speech_transcriber(coordinator) - class MockConfigServiceWithModel: - @staticmethod - def get_config(): - return ConfigWithModel() - - with ( - patch( - "app.speech.domain.transcriber_resolver.is_installed", - return_value=True, - ), - patch( - "app.speech.domain.transcriber_resolver.WhisperRuntime.load_size", - return_value=False, - ) as mock_load, - ): - await resolve_speech_transcriber(app, MockConfigServiceWithModel) - - mock_load.assert_called_once_with("large") + loader.load_size.assert_awaited_once_with("small") class TestSpeechTranscriberUnavailableMessage: """Tests for speech_transcriber_unavailable_message.""" + def _coordinator(self, *, load_error=None) -> SpeechRuntimeCoordinator: + loader = MagicMock(spec=SttModelLoader) + loader.load_error.return_value = load_error + engine = MagicMock(spec=TtsEngine) + return SpeechRuntimeCoordinator( + loader, engine, config_service=MockConfigService + ) + def test_returns_base_message(self): """Returns the standard unavailable message.""" - with patch( - "app.speech.domain.transcriber_resolver.WhisperRuntime.load_error", - return_value=None, - ): - msg = speech_transcriber_unavailable_message() + coordinator = self._coordinator(load_error=None) + + msg = speech_transcriber_unavailable_message(coordinator) assert "not loaded" in msg assert "Download it in Configuration" in msg def test_includes_load_error_when_present(self): - """Appends runtime load error when one exists.""" - with patch( - "app.speech.domain.transcriber_resolver.WhisperRuntime.load_error", - return_value="Out of memory", - ): - msg = speech_transcriber_unavailable_message() + """Appends the runtime load error when one exists.""" + coordinator = self._coordinator(load_error="Out of memory") + + msg = speech_transcriber_unavailable_message(coordinator) assert "Speech model load error: Out of memory" in msg diff --git a/tests/theory/api/test_audio_answer.py b/tests/theory/api/test_audio_answer.py index 50f12a2..8505c9b 100644 --- a/tests/theory/api/test_audio_answer.py +++ b/tests/theory/api/test_audio_answer.py @@ -96,11 +96,14 @@ def _active_interview_read(interview_id: str) -> InterviewRead: @pytest.fixture def audio_api_client(client, override_ws_ai_provider): - """Test client with speech transcriber attached to app state.""" + """Test client with a fake speech transcriber resolved from the runtime.""" override_ws_ai_provider(client, []) - client.app.state.speech_transcriber = FakeTranscriber("spoken via api") - yield client - client.app.state.speech_transcriber = None + fake = FakeTranscriber("spoken via api") + with patch( + "app.interview.api.deps.resolve_speech_transcriber", + new=AsyncMock(return_value=fake), + ): + yield client class TestAudioAnswerApi: @@ -205,12 +208,11 @@ def test_audio_answer_rejects_when_whisper_unavailable( ) override_ws_ai_provider(client, []) interview_id = seed_two_question_interview("audio-api-no-whisper") - client.app.state.speech_transcriber = None wav_bytes = minimal_wav_bytes() with patch( - "app.speech.domain.transcriber_resolver.is_installed", - return_value=False, + "app.interview.api.deps.resolve_speech_transcriber", + new=AsyncMock(return_value=None), ): response = client.post( f"/interview/{interview_id}/theory/audio-answer", diff --git a/tests/theory/api/test_ws_routes.py b/tests/theory/api/test_ws_routes.py index f63da2c..46e8f2f 100644 --- a/tests/theory/api/test_ws_routes.py +++ b/tests/theory/api/test_ws_routes.py @@ -37,7 +37,6 @@ async def _fake_ai_provider(): yield FakeProvider([]) with ( - patch("app.main.run_migrations"), patch( "app.platform.domain.speech_runtime.SpeechRuntimeCoordinator.startup", new=AsyncMock(),