From 078187e9b5abe6a97e61c058300f85a313decdf9 Mon Sep 17 00:00:00 2001 From: Yutsuo Date: Sun, 30 Aug 2026 20:42:52 -0300 Subject: [PATCH] feat: configure transcription models and credential target --- src/voice_transcriptor/models.py | 7 ++++ .../services/credentials.py | 10 +++-- src/voice_transcriptor/services/settings.py | 30 +++++++++++--- tests/test_credentials.py | 40 +++++++++++++------ tests/test_settings.py | 20 ++++++++-- 5 files changed, 83 insertions(+), 24 deletions(-) diff --git a/src/voice_transcriptor/models.py b/src/voice_transcriptor/models.py index 5203bf0..3334772 100644 --- a/src/voice_transcriptor/models.py +++ b/src/voice_transcriptor/models.py @@ -3,6 +3,12 @@ from decimal import Decimal from pathlib import Path +DEFAULT_CONTEXT = ( + "Brazilian Portuguese conversation. Preserve Brazilian spelling and punctuation. " + "Vocabulary may include AWS, Kubernetes, OpenAI, PostgreSQL, Brasília, Banco do Brasil." +) + + @dataclass(frozen=True, slots=True) class AppSettings: model: str @@ -11,6 +17,7 @@ class AppSettings: chunk_duration_seconds: int = 900 chunk_overlap_seconds: int = 15 retain_temporary_files: bool = False + context_vocabulary: str = DEFAULT_CONTEXT @dataclass(frozen=True, slots=True) diff --git a/src/voice_transcriptor/services/credentials.py b/src/voice_transcriptor/services/credentials.py index e770568..2b6a90b 100644 --- a/src/voice_transcriptor/services/credentials.py +++ b/src/voice_transcriptor/services/credentials.py @@ -5,8 +5,7 @@ from __future__ import annotations import keyring -SERVICE_NAME = "voice-transcriptor" -ACCOUNT_NAME = "openai-api-key" +TARGET_NAME = "OPENAI_API_KEY" class CredentialError(Exception): @@ -21,7 +20,8 @@ class CredentialService: def get_api_key(self) -> str | None: try: - return self._backend.get_password(SERVICE_NAME, ACCOUNT_NAME) + credential = self._backend.get_credential(TARGET_NAME, None) + return credential.password if credential is not None else None except Exception: raise CredentialError("Unable to access the saved API key.") from None @@ -32,6 +32,8 @@ class CredentialService: if not isinstance(value, str) or not value.strip(): raise CredentialError("An API key is required.") try: - self._backend.set_password(SERVICE_NAME, ACCOUNT_NAME, value) + credential = self._backend.get_credential(TARGET_NAME, None) + username = credential.username if credential is not None else TARGET_NAME + self._backend.set_password(TARGET_NAME, username, value) except Exception: raise CredentialError("Unable to save the API key.") from None diff --git a/src/voice_transcriptor/services/settings.py b/src/voice_transcriptor/services/settings.py index 3f46f98..9a8b569 100644 --- a/src/voice_transcriptor/services/settings.py +++ b/src/voice_transcriptor/services/settings.py @@ -7,11 +7,16 @@ import os import tempfile from pathlib import Path -from voice_transcriptor.models import AppSettings +from voice_transcriptor.models import DEFAULT_CONTEXT, AppSettings -DEFAULT_MODEL = "gpt-4o-transcribe" +DEFAULT_MODEL = "gpt-transcribe" DEFAULT_LANGUAGE = "pt-BR" +SUPPORTED_MODEL_SUGGESTIONS = ( + "gpt-transcribe", + "gpt-4o-transcribe", + "gpt-4o-mini-transcribe", +) class SettingsError(Exception): @@ -34,7 +39,12 @@ class SettingsRepository: def default_settings() -> AppSettings: home = Path.home() documents = home / "Documents" - return AppSettings(DEFAULT_MODEL, DEFAULT_LANGUAGE, documents if documents.is_dir() else home) + return AppSettings( + DEFAULT_MODEL, + DEFAULT_LANGUAGE, + documents if documents.is_dir() else home, + context_vocabulary=DEFAULT_CONTEXT, + ) def load(self) -> tuple[AppSettings, str | None]: if not self.path.exists(): @@ -54,6 +64,7 @@ class SettingsRepository: "chunk_duration_seconds": settings.chunk_duration_seconds, "chunk_overlap_seconds": settings.chunk_overlap_seconds, "retain_temporary_files": settings.retain_temporary_files, + "context_vocabulary": settings.context_vocabulary, } temporary_path: Path | None = None try: @@ -93,11 +104,20 @@ class SettingsRepository: chunk_duration = payload.get("chunk_duration_seconds", 900) overlap = payload.get("chunk_overlap_seconds", 15) retain = payload.get("retain_temporary_files", False) + context = payload.get("context_vocabulary", DEFAULT_CONTEXT) if ( isinstance(chunk_duration, bool) or not isinstance(chunk_duration, int) or isinstance(overlap, bool) or not isinstance(overlap, int) - or not isinstance(retain, bool) or chunk_duration <= 0 + or not isinstance(retain, bool) or not isinstance(context, str) or chunk_duration <= 0 or overlap < 0 or overlap >= chunk_duration ): raise ValueError("Preprocessing settings are invalid.") - return AppSettings(model, language, Path(output_directory), chunk_duration, overlap, retain) + return AppSettings( + model, + language, + Path(output_directory), + chunk_duration, + overlap, + retain, + context, + ) diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 94b9fb3..f1da2c5 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -1,28 +1,29 @@ import pytest +from types import SimpleNamespace from voice_transcriptor.services.credentials import CredentialError, CredentialService class FakeKeyring: def __init__(self) -> None: - self.value: str | None = None + self.credential = None self.calls: list[tuple[str, str, str | None]] = [] - def get_password(self, service: str, account: str) -> str | None: + def get_credential(self, service: str, account: str | None): self.calls.append(("get", service, account)) - return self.value + return self.credential def set_password(self, service: str, account: str, value: str) -> None: self.calls.append(("set", service, account, value)) - self.value = value + self.credential = SimpleNamespace(username=account, password=value) -def test_get_api_key_uses_stable_keyring_service_and_account() -> None: +def test_get_api_key_uses_existing_windows_credential_target() -> None: backend = FakeKeyring() - backend.value = "secret" + backend.credential = SimpleNamespace(username="stored-user", password="secret") assert CredentialService(backend).get_api_key() == "secret" - assert backend.calls == [("get", "voice-transcriptor", "openai-api-key")] + assert backend.calls == [("get", "OPENAI_API_KEY", None)] def test_has_api_key_reflects_keyring_value() -> None: @@ -30,7 +31,7 @@ def test_has_api_key_reflects_keyring_value() -> None: service = CredentialService(backend) assert service.has_api_key() is False - backend.value = "secret" + backend.credential = SimpleNamespace(username="stored-user", password="secret") assert service.has_api_key() is True @@ -45,16 +46,31 @@ def test_set_api_key_rejects_blank_values() -> None: def test_set_api_key_persists_nonblank_value() -> None: backend = FakeKeyring() + backend.credential = SimpleNamespace(username="stored-user", password="old") CredentialService(backend).set_api_key("secret") - assert backend.calls == [("set", "voice-transcriptor", "openai-api-key", "secret")] + assert backend.calls == [ + ("get", "OPENAI_API_KEY", None), + ("set", "OPENAI_API_KEY", "stored-user", "secret"), + ] -@pytest.mark.parametrize("method", ["get_password", "set_password"]) +def test_set_api_key_creates_target_with_stable_username_when_missing() -> None: + backend = FakeKeyring() + + CredentialService(backend).set_api_key("secret") + + assert backend.calls == [ + ("get", "OPENAI_API_KEY", None), + ("set", "OPENAI_API_KEY", "OPENAI_API_KEY", "secret"), + ] + + +@pytest.mark.parametrize("method", ["get_credential", "set_password"]) def test_backend_failures_are_sanitized(method: str) -> None: class FailingKeyring: - def get_password(self, service: str, account: str) -> str | None: + def get_credential(self, service: str, account: str | None): raise RuntimeError("backend leaked secret") def set_password(self, service: str, account: str, value: str) -> None: @@ -63,7 +79,7 @@ def test_backend_failures_are_sanitized(method: str) -> None: service = CredentialService(FailingKeyring()) with pytest.raises(CredentialError) as caught: - if method == "get_password": + if method == "get_credential": service.get_api_key() else: service.set_api_key("secret") diff --git a/tests/test_settings.py b/tests/test_settings.py index 372b98b..95a370e 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest from voice_transcriptor.models import AppSettings -from voice_transcriptor.services.settings import SettingsError, SettingsRepository +from voice_transcriptor.services.settings import DEFAULT_CONTEXT, SettingsError, SettingsRepository def test_load_missing_file_returns_documented_defaults(monkeypatch, tmp_path: Path) -> None: @@ -14,7 +14,8 @@ def test_load_missing_file_returns_documented_defaults(monkeypatch, tmp_path: Pa settings, warning = SettingsRepository(tmp_path / "settings.json").load() - assert settings == AppSettings("gpt-4o-transcribe", "pt-BR", documents) + assert settings == AppSettings("gpt-transcribe", "pt-BR", documents) + assert "AWS, Kubernetes, OpenAI" in settings.context_vocabulary assert warning is None @@ -46,6 +47,7 @@ def test_save_and_load_round_trip_utf8_settings(tmp_path: Path) -> None: "chunk_duration_seconds": 900, "chunk_overlap_seconds": 15, "retain_temporary_files": False, + "context_vocabulary": DEFAULT_CONTEXT, } @@ -55,7 +57,7 @@ def test_load_malformed_file_recovers_defaults_with_nonfatal_warning(tmp_path: P settings, warning = SettingsRepository(path).load() - assert settings.model == "gpt-4o-transcribe" + assert settings.model == "gpt-transcribe" assert warning is not None assert "settings" in warning.lower() @@ -127,4 +129,16 @@ def test_legacy_settings_receive_preprocessing_defaults(tmp_path: Path) -> None: path.write_text(json.dumps({"model": "model", "language": "pt-BR", "output_directory": str(tmp_path)}), encoding="utf-8") settings, warning = SettingsRepository(path).load() assert (settings.chunk_duration_seconds, settings.chunk_overlap_seconds, settings.retain_temporary_files) == (900, 15, False) + assert settings.context_vocabulary == DEFAULT_CONTEXT + assert warning is None + + +def test_context_vocabulary_round_trips_as_non_secret_setting(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + expected = AppSettings("future-transcribe", "pt-BR", tmp_path, context_vocabulary="Brasília, Pix") + + SettingsRepository(path).save(expected) + actual, warning = SettingsRepository(path).load() + + assert actual == expected assert warning is None