Browse Source

feat: configure transcription models and credential target

master
Yutsuo 4 days ago
parent
commit
078187e9b5
  1. 7
      src/voice_transcriptor/models.py
  2. 10
      src/voice_transcriptor/services/credentials.py
  3. 30
      src/voice_transcriptor/services/settings.py
  4. 40
      tests/test_credentials.py
  5. 20
      tests/test_settings.py

7
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)

10
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

30
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,
)

40
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")

20
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

Loading…
Cancel
Save