feat: implement ASR configuration and HuggingFace token management services

- Introduced `AsrConfigService` for managing ASR engine reconfiguration, including job tracking and capabilities validation.
- Added `HfTokenService` for secure management of HuggingFace API tokens, including storage, validation, and retrieval functionalities.
- Updated gRPC service to include endpoints for ASR configuration and HuggingFace token management.
- Refactored existing services to improve integration with new ASR and token management features.
- Added unit and integration tests for both services to ensure functionality and reliability.
This commit is contained in:
2026-01-13 09:58:31 +00:00
parent 103ed09f32
commit ee82183fd7
23 changed files with 9276 additions and 2311 deletions

View File

@@ -7,7 +7,8 @@ from noteflow.application.services.auth_service import (
LogoutResult,
UserInfo,
)
from noteflow.application.services.export_service import ExportFormat, ExportService
from noteflow.application.services.export_service import ExportService
from noteflow.domain.value_objects import ExportFormat
from noteflow.application.services.identity import IdentityService
from noteflow.application.services.meeting import MeetingService
from noteflow.application.services.project_service import ProjectService

View File

@@ -0,0 +1,307 @@
"""ASR configuration service for runtime reconfiguration.
Orchestrates ASR engine reconfiguration including model reload with progress tracking.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from noteflow.application.services.asr_config_types import (
AsrCapabilities,
AsrComputeType,
AsrConfigJob,
AsrConfigPhase,
AsrDevice,
DEVICE_COMPUTE_TYPES,
)
from noteflow.domain.constants.fields import (
JOB_STATUS_COMPLETED,
JOB_STATUS_FAILED,
JOB_STATUS_QUEUED,
JOB_STATUS_RUNNING,
)
from noteflow.infrastructure.asr import VALID_MODEL_SIZES
from noteflow.infrastructure.logging import get_logger
if TYPE_CHECKING:
from noteflow.infrastructure.asr import FasterWhisperEngine
logger = get_logger(__name__)
class AsrConfigService:
"""Service for managing ASR engine configuration at runtime.
Provides capabilities discovery, validation, and orchestrated model reload
with progress tracking via background jobs.
"""
def __init__(
self,
asr_engine: FasterWhisperEngine | None,
*,
on_engine_update: Callable[[FasterWhisperEngine], None] | None = None,
) -> None:
"""Initialize the service.
Args:
asr_engine: The ASR engine instance to manage. May be None if ASR is disabled.
on_engine_update: Optional callback when the active engine is replaced.
"""
self._asr_engine = asr_engine
self._on_engine_update = on_engine_update
self._jobs: dict[UUID, AsrConfigJob] = {}
self._job_lock = asyncio.Lock()
self._reload_lock = asyncio.Lock()
def detect_cuda_available(self) -> bool:
"""Detect if CUDA is available for ASR.
Returns:
True if CUDA is available, False otherwise.
"""
try:
import torch
return torch.cuda.is_available()
except ImportError:
return False
def get_capabilities(self) -> AsrCapabilities:
"""Get current ASR configuration and capabilities.
Returns:
Current ASR configuration including available options.
"""
cuda_available = self.detect_cuda_available()
current_device = AsrDevice.CPU
current_compute_type = AsrComputeType.INT8
if self._asr_engine is not None:
current_device = AsrDevice(self._asr_engine.device)
current_compute_type = AsrComputeType(self._asr_engine.compute_type)
return AsrCapabilities(
model_size=self._asr_engine.model_size if self._asr_engine else None,
device=current_device,
compute_type=current_compute_type,
is_ready=self._asr_engine.is_loaded if self._asr_engine else False,
cuda_available=cuda_available,
available_model_sizes=VALID_MODEL_SIZES,
available_compute_types=DEVICE_COMPUTE_TYPES[current_device],
)
def validate_configuration(
self,
model_size: str | None,
device: AsrDevice | None,
compute_type: AsrComputeType | None,
) -> str | None:
"""Validate configuration before applying.
Args:
model_size: Requested model size (or None to keep current).
device: Requested device (or None to keep current).
compute_type: Requested compute type (or None to keep current).
Returns:
Error message if validation fails, None if valid.
"""
if model_size is not None and model_size not in VALID_MODEL_SIZES:
valid_sizes = ", ".join(VALID_MODEL_SIZES)
return f"Invalid model size: {model_size}. Valid: {valid_sizes}"
if device == AsrDevice.CUDA and not self.detect_cuda_available():
return "CUDA requested but not available on this server"
if device is not None and compute_type is not None:
valid_types = DEVICE_COMPUTE_TYPES[device]
if compute_type not in valid_types:
return (
f"Compute type {compute_type.value} not available for {device.value}"
)
return None
def _check_reconfiguration_preconditions(
self,
has_active_recordings: bool,
) -> str | None:
"""Check preconditions for ASR reconfiguration.
Args:
has_active_recordings: Whether there are active recording streams.
Returns:
Error message if preconditions fail, None if OK.
"""
if has_active_recordings:
return "Cannot reconfigure ASR while recordings are active"
return "ASR engine is not available" if self._asr_engine is None else None
async def start_reconfiguration(
self,
model_size: str | None,
device: AsrDevice | None,
compute_type: AsrComputeType | None,
has_active_recordings: bool,
) -> tuple[UUID | None, str | None]:
"""Start ASR reconfiguration.
Returns:
Tuple of (job_id, error_message). job_id is None on validation failure.
"""
if precondition_error := self._check_reconfiguration_preconditions(
has_active_recordings
):
return None, precondition_error
# Use current values as defaults
caps = self.get_capabilities()
target_model = model_size or caps.model_size or "base"
target_device = device or caps.device
target_compute = compute_type or caps.compute_type
if error := self.validate_configuration(
target_model, target_device, target_compute
):
return None, error
# Create and queue job
job_id = uuid4()
job = AsrConfigJob(
job_id=job_id,
status=JOB_STATUS_QUEUED,
phase=AsrConfigPhase.VALIDATING,
progress_percent=0.0,
error_message="",
target_model_size=target_model,
target_device=target_device,
target_compute_type=target_compute,
)
async with self._job_lock:
self._jobs[job_id] = job
job.task = asyncio.create_task(self._run_reconfiguration(job))
return job_id, None
def _build_engine_for_job(
self,
job: AsrConfigJob,
current_engine: FasterWhisperEngine | None,
) -> tuple[FasterWhisperEngine, bool]:
"""Return an engine to load the model into.
If device/compute settings change, create a new engine but keep the
current one active until the new model loads successfully.
Args:
job: The reconfiguration job with target settings.
current_engine: The currently active engine (if any).
Returns:
Tuple of (engine_to_use, is_new_engine).
"""
if (
current_engine is not None
and job.target_device.value == current_engine.device
and job.target_compute_type.value == current_engine.compute_type
):
return current_engine, False
# Import here to avoid circular imports
from noteflow.infrastructure.asr import FasterWhisperEngine
return (
FasterWhisperEngine(
compute_type=job.target_compute_type.value,
device=job.target_device.value,
),
True,
)
def _set_active_engine(self, engine: FasterWhisperEngine) -> None:
"""Swap the active engine and notify listeners."""
self._asr_engine = engine
if self._on_engine_update is not None:
self._on_engine_update(engine)
async def _load_model(
self,
engine: FasterWhisperEngine,
model_size: str,
) -> None:
"""Load a model into the provided engine."""
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, engine.load_model, model_size)
def _mark_job_completed(self, job: AsrConfigJob) -> None:
"""Update job state for successful completion."""
job.phase = AsrConfigPhase.COMPLETED
job.status = JOB_STATUS_COMPLETED
job.progress_percent = 100.0
def _mark_job_failed(self, job: AsrConfigJob, error: Exception) -> None:
"""Update job state for failure and log."""
job.phase = AsrConfigPhase.FAILED
job.status = JOB_STATUS_FAILED
job.error_message = str(error)
logger.error("asr_reconfiguration_failed", error=str(error))
async def _execute_reconfiguration(self, job: AsrConfigJob) -> None:
"""Run reconfiguration steps and swap engine after success."""
current_engine = self._asr_engine
job.status = JOB_STATUS_RUNNING
job.phase = AsrConfigPhase.LOADING
job.progress_percent = 10.0
engine_to_use, is_new_engine = self._build_engine_for_job(job, current_engine)
job.progress_percent = 50.0
await self._load_model(engine_to_use, job.target_model_size)
job.progress_percent = 90.0
if is_new_engine:
self._set_active_engine(engine_to_use)
if current_engine is not None:
current_engine.unload()
self._mark_job_completed(job)
logger.info(
"asr_reconfigured",
model_size=job.target_model_size,
device=job.target_device.value,
compute_type=job.target_compute_type.value,
)
async def _run_reconfiguration(self, job: AsrConfigJob) -> None:
"""Execute the reconfiguration in background.
Args:
job: The job to execute.
"""
async with self._reload_lock:
try:
await self._execute_reconfiguration(job)
except Exception as e:
self._mark_job_failed(job, e)
def get_job_status(self, job_id: UUID) -> AsrConfigJob | None:
"""Get status of a reconfiguration job.
Args:
job_id: The job identifier.
Returns:
The job status, or None if not found.
"""
job = self._jobs.get(job_id)
if job is None:
logger.debug("job_not_found", job_id=str(job_id))
return job

View File

@@ -0,0 +1,72 @@
"""Types and constants for ASR configuration."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from enum import Enum
from typing import Final
from uuid import UUID
class AsrConfigPhase(str, Enum):
"""Phases of ASR reconfiguration."""
VALIDATING = "validating"
DOWNLOADING = "downloading"
LOADING = "loading"
COMPLETED = "completed"
FAILED = "failed"
class AsrDevice(str, Enum):
"""Supported ASR devices."""
CPU = "cpu"
CUDA = "cuda"
class AsrComputeType(str, Enum):
"""Supported compute types."""
INT8 = "int8"
FLOAT16 = "float16"
FLOAT32 = "float32"
DEVICE_COMPUTE_TYPES: Final[dict[AsrDevice, tuple[AsrComputeType, ...]]] = {
AsrDevice.CPU: (AsrComputeType.INT8, AsrComputeType.FLOAT32),
AsrDevice.CUDA: (
AsrComputeType.INT8,
AsrComputeType.FLOAT16,
AsrComputeType.FLOAT32,
),
}
@dataclass
class AsrConfigJob:
"""Tracks ASR reconfiguration job state."""
job_id: UUID
status: str # One of JOB_STATUS_* constants
phase: AsrConfigPhase
progress_percent: float
error_message: str
target_model_size: str
target_device: AsrDevice
target_compute_type: AsrComputeType
task: asyncio.Task[None] | None = field(default=None, repr=False)
@dataclass(frozen=True)
class AsrCapabilities:
"""Current ASR capabilities and configuration."""
model_size: str | None
device: AsrDevice
compute_type: AsrComputeType
is_ready: bool
cuda_available: bool
available_model_sizes: tuple[str, ...]
available_compute_types: tuple[AsrComputeType, ...]

View File

@@ -5,7 +5,6 @@ Orchestrates transcript export to various formats.
from __future__ import annotations
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING
@@ -14,6 +13,7 @@ from noteflow.config.constants import (
EXPORT_EXT_HTML,
EXPORT_EXT_PDF,
)
from noteflow.domain.value_objects import ExportFormat
from noteflow.infrastructure.export import (
HtmlExporter,
MarkdownExporter,
@@ -31,14 +31,6 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
class ExportFormat(Enum):
"""Supported export formats."""
MARKDOWN = "markdown"
HTML = "html"
PDF = "pdf"
# Module-level constant mapping file extensions to format values.
# Keys are lowercase extensions, values are ExportFormat enum value strings.
_EXTENSION_TO_FORMAT: dict[str, str] = {

View File

@@ -0,0 +1,248 @@
"""HuggingFace token service for secure token management."""
from __future__ import annotations
import base64
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, cast
import httpx
from noteflow.domain.utils.time import utc_now
from noteflow.infrastructure.logging import get_logger
from noteflow.infrastructure.security.protocols import EncryptedChunk
if TYPE_CHECKING:
from collections.abc import Callable
from noteflow.domain.ports.unit_of_work import UnitOfWork
from noteflow.infrastructure.security.crypto import AesGcmCryptoBox
_MetadataDict = dict[str, str | float | bool | None]
_EncryptedDataDict = dict[str, str]
logger = get_logger(__name__)
# Preference keys for HF token storage
_HF_TOKEN_KEY: Final[str] = "_hf_token_encrypted"
_HF_TOKEN_META_KEY: Final[str] = "_hf_token_meta"
_HF_WHOAMI_URL: Final[str] = "https://huggingface.co/api/whoami-v2"
# Metadata and encrypted data field keys
_META_USERNAME: Final[str] = "username"
_META_VALIDATED_AT: Final[str] = "validated_at"
_META_IS_VALIDATED: Final[str] = "is_validated"
_ENC_WRAPPED_DEK: Final[str] = "wrapped_dek"
_ENC_NONCE: Final[str] = "nonce"
_ENC_CIPHERTEXT: Final[str] = "ciphertext"
_ENC_TAG: Final[str] = "tag"
_ASCII_ENCODING: Final[str] = "ascii"
@dataclass(frozen=True)
class HfTokenStatus:
"""Status of the HuggingFace token."""
is_configured: bool
is_validated: bool
username: str
validated_at: float | None
@dataclass(frozen=True)
class HfValidationResult:
"""Result of HuggingFace token validation."""
valid: bool
username: str
error_message: str
class HfTokenService:
"""Service for managing HuggingFace API tokens."""
def __init__(
self,
uow_factory: Callable[[], UnitOfWork],
crypto: AesGcmCryptoBox,
) -> None:
"""Initialize with UoW factory and crypto box."""
self._uow_factory = uow_factory
self._crypto = crypto
async def set_token(
self,
token: str,
*,
validate: bool = True,
) -> tuple[bool, HfValidationResult | None]:
"""Set and optionally validate a HuggingFace token."""
validation_result: HfValidationResult | None = None
username = ""
validated_at: float | None = None
if validate:
validation_result = await self.validate_token_internal(token)
if not validation_result.valid:
return False, validation_result
username = validation_result.username
validated_at = utc_now().timestamp()
encrypted_data = self.encrypt_token(token)
is_valid = validate and validation_result is not None and validation_result.valid
metadata: dict[str, str | float | bool | None] = {
_META_USERNAME: username,
_META_VALIDATED_AT: validated_at,
_META_IS_VALIDATED: is_valid,
}
async with self._uow_factory() as uow:
if not uow.supports_preferences:
error_result = HfValidationResult(
False,
"",
"Preferences storage not available",
)
logger.warning("hf_token_storage_unavailable")
return False, error_result
await uow.preferences.set(_HF_TOKEN_KEY, encrypted_data)
await uow.preferences.set(_HF_TOKEN_META_KEY, metadata)
await uow.commit()
logger.info("hf_token_stored", username=username, validated=validate)
return True, validation_result
async def get_status(self) -> HfTokenStatus:
"""Get the current status of the HuggingFace token."""
async with self._uow_factory() as uow:
if not uow.supports_preferences:
return HfTokenStatus(False, False, "", None)
encrypted_data = await uow.preferences.get(_HF_TOKEN_KEY)
metadata = await uow.preferences.get(_HF_TOKEN_META_KEY)
if encrypted_data is None:
return HfTokenStatus(False, False, "", None)
if metadata is not None and isinstance(metadata, dict):
meta = cast(_MetadataDict, metadata)
username = str(meta.get(_META_USERNAME, ""))
is_validated = bool(meta.get(_META_IS_VALIDATED, False))
raw_validated_at = meta.get(_META_VALIDATED_AT)
validated_at = float(raw_validated_at) if raw_validated_at is not None else None
else:
username, is_validated, validated_at = "", False, None
return HfTokenStatus(True, is_validated, username, validated_at)
async def delete_token(self) -> bool:
"""Delete the stored HuggingFace token."""
async with self._uow_factory() as uow:
if not uow.supports_preferences:
return False
deleted_token = await uow.preferences.delete(_HF_TOKEN_KEY)
await uow.preferences.delete(_HF_TOKEN_META_KEY)
await uow.commit()
if deleted_token:
logger.info("hf_token_deleted")
return deleted_token
async def _update_validation_metadata(self, username: str) -> None:
"""Update token validation metadata after successful validation."""
metadata: dict[str, str | float | bool | None] = {
_META_USERNAME: username,
_META_VALIDATED_AT: utc_now().timestamp(),
_META_IS_VALIDATED: True,
}
async with self._uow_factory() as uow:
if uow.supports_preferences:
await uow.preferences.set(_HF_TOKEN_META_KEY, metadata)
await uow.commit()
async def validate_stored_token(self) -> HfValidationResult:
"""Validate the currently stored HuggingFace token."""
async with self._uow_factory() as uow:
if not uow.supports_preferences:
return HfValidationResult(False, "", "Preferences storage not available")
encrypted_data = await uow.preferences.get(_HF_TOKEN_KEY)
if encrypted_data is None:
return HfValidationResult(False, "", "No token configured")
try:
token = self.decrypt_token(encrypted_data)
except ValueError as e:
logger.error("hf_token_decrypt_failed", error=str(e))
return HfValidationResult(False, "", "Token decryption failed")
result = await self.validate_token_internal(token)
if result.valid:
await self._update_validation_metadata(result.username)
return result
async def get_token(self) -> str | None:
"""Get the decrypted HuggingFace token, or None if not configured."""
async with self._uow_factory() as uow:
if not uow.supports_preferences:
return None
encrypted_data = await uow.preferences.get(_HF_TOKEN_KEY)
if encrypted_data is None:
return None
try:
return self.decrypt_token(encrypted_data)
except ValueError:
return None
async def validate_token_internal(self, token: str) -> HfValidationResult:
"""Validate a token against HuggingFace API."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
_HF_WHOAMI_URL,
headers={"Authorization": f"Bearer {token}"},
)
if response.status_code == 200:
data = response.json()
return HfValidationResult(True, str(data.get("name", "")), "")
if response.status_code == 401:
return HfValidationResult(False, "", "Invalid or expired token")
return HfValidationResult(False, "", f"HuggingFace API error: {response.status_code}")
except httpx.TimeoutException:
return HfValidationResult(False, "", "Connection timeout")
except httpx.RequestError as e:
return HfValidationResult(False, "", f"Connection error: {e}")
def encrypt_token(self, token: str) -> dict[str, str]:
"""Encrypt a token for storage."""
dek = self._crypto.generate_dek()
wrapped_dek = self._crypto.wrap_dek(dek)
token_bytes = token.encode("utf-8")
encrypted = self._crypto.encrypt_chunk(token_bytes, dek)
return {
_ENC_WRAPPED_DEK: base64.b64encode(wrapped_dek).decode(_ASCII_ENCODING),
_ENC_NONCE: base64.b64encode(encrypted.nonce).decode(_ASCII_ENCODING),
_ENC_CIPHERTEXT: base64.b64encode(encrypted.ciphertext).decode(_ASCII_ENCODING),
_ENC_TAG: base64.b64encode(encrypted.tag).decode(_ASCII_ENCODING),
}
def decrypt_token(self, encrypted_data: object) -> str:
"""Decrypt a stored token. Raises ValueError if decryption fails."""
if not isinstance(encrypted_data, dict):
raise ValueError("Invalid encrypted data format")
data = cast(_EncryptedDataDict, encrypted_data)
try:
wrapped_dek = base64.b64decode(data[_ENC_WRAPPED_DEK])
nonce = base64.b64decode(data[_ENC_NONCE])
ciphertext = base64.b64decode(data[_ENC_CIPHERTEXT])
tag = base64.b64decode(data[_ENC_TAG])
except (KeyError, ValueError) as e:
raise ValueError(f"Invalid encrypted data: {e}") from e
dek = self._crypto.unwrap_dek(wrapped_dek)
chunk = EncryptedChunk(nonce=nonce, ciphertext=ciphertext, tag=tag)
plaintext = self._crypto.decrypt_chunk(chunk, dek)
return plaintext.decode("utf-8")

View File

@@ -69,3 +69,10 @@ WRAPPED_DEK: Final[str] = "wrapped_dek"
# Entity type names (for logging/messages)
ENTITY_MEETING: Final[str] = "Meeting"
ENTITY_WORKSPACE: Final[str] = "Workspace"
# Job status constants
JOB_STATUS_QUEUED: Final[str] = "queued"
JOB_STATUS_RUNNING: Final[str] = "running"
JOB_STATUS_COMPLETED: Final[str] = "completed"
JOB_STATUS_FAILED: Final[str] = "failed"
JOB_STATUS_CANCELLED: Final[str] = "cancelled"

View File

@@ -5,7 +5,18 @@ from __future__ import annotations
from collections.abc import Sequence
from typing import Protocol
from noteflow.domain.constants.fields import ACTION_ITEM, DECISION, NOTE, RISK, UNKNOWN
from noteflow.domain.constants.fields import (
ACTION_ITEM,
DECISION,
JOB_STATUS_CANCELLED,
JOB_STATUS_COMPLETED,
JOB_STATUS_FAILED,
JOB_STATUS_QUEUED,
JOB_STATUS_RUNNING,
NOTE,
RISK,
UNKNOWN,
)
from noteflow.grpc._types import AnnotationInfo, MeetingInfo
from noteflow.grpc.proto import noteflow_pb2
@@ -84,10 +95,11 @@ EXPORT_FORMAT_TO_PROTO: dict[str, int] = {
# Job status mapping
JOB_STATUS_MAP: dict[int, str] = {
noteflow_pb2.JOB_STATUS_UNSPECIFIED: UNKNOWN,
noteflow_pb2.JOB_STATUS_QUEUED: "queued",
noteflow_pb2.JOB_STATUS_RUNNING: "running",
noteflow_pb2.JOB_STATUS_COMPLETED: "completed",
noteflow_pb2.JOB_STATUS_FAILED: "failed",
noteflow_pb2.JOB_STATUS_QUEUED: JOB_STATUS_QUEUED,
noteflow_pb2.JOB_STATUS_RUNNING: JOB_STATUS_RUNNING,
noteflow_pb2.JOB_STATUS_COMPLETED: JOB_STATUS_COMPLETED,
noteflow_pb2.JOB_STATUS_FAILED: JOB_STATUS_FAILED,
noteflow_pb2.JOB_STATUS_CANCELLED: JOB_STATUS_CANCELLED,
}

View File

@@ -2,11 +2,13 @@
from ._types import GrpcContext, GrpcStatusContext
from .annotation import AnnotationMixin
from .asr_config import AsrConfigMixin
from .calendar import CalendarMixin
from .diarization import DiarizationMixin
from .diarization_job import DiarizationJobMixin
from .entities import EntitiesMixin
from .export import ExportMixin
from .hf_token import HfTokenMixin
from .identity import IdentityMixin
from .meeting import MeetingMixin
from .observability import ObservabilityMixin
@@ -24,6 +26,7 @@ from .webhooks import WebhooksMixin
__all__ = [
"AnnotationMixin",
"AsrConfigMixin",
"CalendarMixin",
"DiarizationJobMixin",
"DiarizationMixin",
@@ -31,6 +34,7 @@ __all__ = [
"ExportMixin",
"GrpcContext",
"GrpcStatusContext",
"HfTokenMixin",
"IdentityMixin",
"MeetingMixin",
"ObservabilityMixin",

View File

@@ -13,7 +13,9 @@ if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from noteflow.application.services.asr_config_service import AsrConfigService
from noteflow.application.services.calendar import CalendarService
from noteflow.application.services.hf_token_service import HfTokenService
from noteflow.application.services.identity import IdentityService
from noteflow.application.services.ner_service import NerService
from noteflow.application.services.project_service import ProjectService
@@ -42,6 +44,7 @@ class ServicerState(Protocol):
# Engines and services
asr_engine: FasterWhisperEngine | None
asr_config_service: AsrConfigService | None
diarization_engine: DiarizationEngine | None
summarization_service: SummarizationService | None
ner_service: NerService | None
@@ -49,6 +52,7 @@ class ServicerState(Protocol):
webhook_service: WebhookService | None
project_service: ProjectService | None
identity_service: IdentityService
hf_token_service: HfTokenService | None
diarization_refinement_enabled: bool
# Audio writers

View File

@@ -0,0 +1,288 @@
"""ASR configuration mixin for gRPC service.
Provides runtime ASR engine reconfiguration including model size,
device, and compute type changes with job-based progress tracking.
"""
from __future__ import annotations
from typing import Protocol, cast
from noteflow.application.services.asr_config_service import (
AsrComputeType,
AsrConfigPhase,
AsrConfigService,
AsrDevice,
)
from noteflow.domain.constants.fields import (
JOB_STATUS_COMPLETED,
JOB_STATUS_FAILED,
JOB_STATUS_QUEUED,
JOB_STATUS_RUNNING,
)
from noteflow.infrastructure.logging import get_logger
from ..proto import noteflow_pb2
from ._types import GrpcContext
class _Copyable(Protocol):
"""Protocol for protobuf CopyFrom method."""
def CopyFrom(self, other: _Copyable) -> None: ...
logger = get_logger(__name__)
# Proto enum value maps
_DEVICE_TO_PROTO: dict[AsrDevice, int] = {
AsrDevice.CPU: noteflow_pb2.ASR_DEVICE_CPU,
AsrDevice.CUDA: noteflow_pb2.ASR_DEVICE_CUDA,
}
_PROTO_TO_DEVICE: dict[int, AsrDevice] = {
noteflow_pb2.ASR_DEVICE_CPU: AsrDevice.CPU,
noteflow_pb2.ASR_DEVICE_CUDA: AsrDevice.CUDA,
}
_COMPUTE_TYPE_TO_PROTO: dict[AsrComputeType, int] = {
AsrComputeType.INT8: noteflow_pb2.ASR_COMPUTE_TYPE_INT8,
AsrComputeType.FLOAT16: noteflow_pb2.ASR_COMPUTE_TYPE_FLOAT16,
AsrComputeType.FLOAT32: noteflow_pb2.ASR_COMPUTE_TYPE_FLOAT32,
}
_PROTO_TO_COMPUTE_TYPE: dict[int, AsrComputeType] = {
noteflow_pb2.ASR_COMPUTE_TYPE_INT8: AsrComputeType.INT8,
noteflow_pb2.ASR_COMPUTE_TYPE_FLOAT16: AsrComputeType.FLOAT16,
noteflow_pb2.ASR_COMPUTE_TYPE_FLOAT32: AsrComputeType.FLOAT32,
}
_STATUS_TO_PROTO: dict[str, int] = {
JOB_STATUS_QUEUED: noteflow_pb2.JOB_STATUS_QUEUED,
JOB_STATUS_RUNNING: noteflow_pb2.JOB_STATUS_RUNNING,
JOB_STATUS_COMPLETED: noteflow_pb2.JOB_STATUS_COMPLETED,
JOB_STATUS_FAILED: noteflow_pb2.JOB_STATUS_FAILED,
}
_PHASE_TO_STRING: dict[AsrConfigPhase, str] = {
AsrConfigPhase.VALIDATING: "validating",
AsrConfigPhase.DOWNLOADING: "downloading",
AsrConfigPhase.LOADING: "loading",
AsrConfigPhase.COMPLETED: "completed",
AsrConfigPhase.FAILED: "failed",
}
def _build_configuration_proto(
service: AsrConfigService,
) -> noteflow_pb2.AsrConfiguration:
"""Build ASR configuration proto message from service state.
Args:
service: The ASR config service.
Returns:
Populated AsrConfiguration proto message.
"""
caps = service.get_capabilities()
# Convert compute types to proto enum values
available_compute_types = [
_COMPUTE_TYPE_TO_PROTO[ct] for ct in caps.available_compute_types
]
return noteflow_pb2.AsrConfiguration(
model_size=caps.model_size or "",
device=_DEVICE_TO_PROTO.get(caps.device, noteflow_pb2.ASR_DEVICE_UNSPECIFIED),
compute_type=_COMPUTE_TYPE_TO_PROTO.get(
caps.compute_type, noteflow_pb2.ASR_COMPUTE_TYPE_UNSPECIFIED
),
is_ready=caps.is_ready,
cuda_available=caps.cuda_available,
available_model_sizes=list(caps.available_model_sizes),
available_compute_types=available_compute_types,
)
def _parse_update_request(
request: noteflow_pb2.UpdateAsrConfigurationRequest,
) -> tuple[str | None, AsrDevice | None, AsrComputeType | None]:
"""Parse UpdateAsrConfiguration request parameters.
Args:
request: The gRPC request.
Returns:
Tuple of (model_size, device, compute_type) with None for unspecified fields.
"""
model_size = request.model_size if request.HasField("model_size") else None
device = (
_PROTO_TO_DEVICE.get(request.device)
if request.HasField("device")
and request.device != noteflow_pb2.ASR_DEVICE_UNSPECIFIED
else None
)
compute_type = (
_PROTO_TO_COMPUTE_TYPE.get(request.compute_type)
if request.HasField("compute_type")
and request.compute_type != noteflow_pb2.ASR_COMPUTE_TYPE_UNSPECIFIED
else None
)
return model_size, device, compute_type
def _create_job_error_response(
job_id: str,
error_message: str,
) -> noteflow_pb2.AsrConfigurationJobStatus:
"""Create error response for job status request.
Args:
job_id: The requested job ID.
error_message: The error message to include.
Returns:
Error response proto message.
"""
return noteflow_pb2.AsrConfigurationJobStatus(
job_id=job_id,
status=noteflow_pb2.JOB_STATUS_FAILED,
progress_percent=0.0,
phase="failed",
error_message=error_message,
)
class AsrConfigMixin:
"""Mixin providing ASR configuration management functionality.
Requires host to have:
- asr_config_service: AsrConfigService | None
- active_streams: set[str]
"""
# Protocol requirements (declared for type checking)
asr_config_service: AsrConfigService | None
active_streams: set[str]
async def GetAsrConfiguration(
self,
request: noteflow_pb2.GetAsrConfigurationRequest,
context: GrpcContext,
) -> noteflow_pb2.GetAsrConfigurationResponse:
"""Get current ASR configuration and available options.
Returns capabilities including available model sizes, compute types,
and CUDA availability for the UI to display configuration options.
"""
if self.asr_config_service is None:
logger.warning("asr_config_requested_but_no_service")
# Return empty configuration if ASR is not available
return noteflow_pb2.GetAsrConfigurationResponse(
configuration=noteflow_pb2.AsrConfiguration(
model_size="",
device=noteflow_pb2.ASR_DEVICE_UNSPECIFIED,
compute_type=noteflow_pb2.ASR_COMPUTE_TYPE_UNSPECIFIED,
is_ready=False,
cuda_available=False,
available_model_sizes=[],
available_compute_types=[],
)
)
config_proto = _build_configuration_proto(self.asr_config_service)
return noteflow_pb2.GetAsrConfigurationResponse(configuration=config_proto)
async def UpdateAsrConfiguration(
self,
request: noteflow_pb2.UpdateAsrConfigurationRequest,
context: GrpcContext,
) -> noteflow_pb2.UpdateAsrConfigurationResponse:
"""Update ASR configuration and start model reload.
Initiates a background job to reconfigure the ASR engine. The job
handles model download (if needed) and reload. Returns immediately
with a job ID for progress tracking.
"""
if self.asr_config_service is None:
return noteflow_pb2.UpdateAsrConfigurationResponse(
job_id="",
status=noteflow_pb2.JOB_STATUS_FAILED,
accepted=False,
error_message="ASR service is not available",
)
model_size, device, compute_type = _parse_update_request(request)
has_active_recordings = len(self.active_streams) > 0
job_id, error = await self.asr_config_service.start_reconfiguration(
model_size=model_size,
device=device,
compute_type=compute_type,
has_active_recordings=has_active_recordings,
)
if error:
return noteflow_pb2.UpdateAsrConfigurationResponse(
job_id="",
status=noteflow_pb2.JOB_STATUS_FAILED,
accepted=False,
error_message=error,
)
return noteflow_pb2.UpdateAsrConfigurationResponse(
job_id=str(job_id),
status=noteflow_pb2.JOB_STATUS_QUEUED,
accepted=True,
error_message="",
)
async def GetAsrConfigurationJobStatus(
self,
request: noteflow_pb2.GetAsrConfigurationJobStatusRequest,
context: GrpcContext,
) -> noteflow_pb2.AsrConfigurationJobStatus:
"""Get status of an ASR reconfiguration job.
Returns progress information for a running or completed job,
including the new configuration once reload is complete.
"""
from uuid import UUID
if self.asr_config_service is None:
return _create_job_error_response(request.job_id, "ASR service is not available")
try:
job_id = UUID(request.job_id)
except ValueError:
return _create_job_error_response(request.job_id, "Invalid job ID format")
job = self.asr_config_service.get_job_status(job_id)
if job is None:
return noteflow_pb2.AsrConfigurationJobStatus(
job_id=request.job_id,
status=noteflow_pb2.JOB_STATUS_UNSPECIFIED,
progress_percent=0.0,
phase="",
error_message="Job not found",
)
# Build response
status_proto = _STATUS_TO_PROTO.get(job.status, noteflow_pb2.JOB_STATUS_UNSPECIFIED)
phase_str = _PHASE_TO_STRING.get(job.phase, "")
response = noteflow_pb2.AsrConfigurationJobStatus(
job_id=request.job_id,
status=status_proto,
progress_percent=job.progress_percent,
phase=phase_str,
error_message=job.error_message,
)
# Include new configuration if job completed successfully
if job.status == JOB_STATUS_COMPLETED:
config_field = cast(_Copyable, response.new_configuration)
config_proto = _build_configuration_proto(self.asr_config_service)
config_field.CopyFrom(cast(_Copyable, config_proto))
return response

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
import time
from typing import TYPE_CHECKING, Protocol, cast
from noteflow.application.services.export_service import ExportFormat as ApplicationExportFormat
from noteflow.domain.entities import (
Annotation,
Meeting,
@@ -18,8 +17,7 @@ from noteflow.domain.entities import (
Summary,
WordTiming,
)
from noteflow.domain.value_objects import AnnotationType, MeetingId
from noteflow.domain.value_objects import ExportFormat as DomainExportFormat
from noteflow.domain.value_objects import AnnotationType, ExportFormat, MeetingId
from noteflow.infrastructure.converters import AsrConverter
from ...proto import noteflow_pb2
@@ -324,25 +322,23 @@ def create_segment_from_asr(
)
def proto_to_export_format(proto_format: int) -> ApplicationExportFormat:
"""Convert protobuf ExportFormat to application ExportFormat."""
def proto_to_export_format(proto_format: int) -> ExportFormat:
"""Convert protobuf ExportFormat to domain ExportFormat."""
if proto_format == noteflow_pb2.EXPORT_FORMAT_HTML:
return ApplicationExportFormat.HTML
return ExportFormat.HTML
if proto_format == noteflow_pb2.EXPORT_FORMAT_PDF:
return ApplicationExportFormat.PDF
return ApplicationExportFormat.MARKDOWN # Default to Markdown
return ExportFormat.PDF
return ExportFormat.MARKDOWN # Default to Markdown
def export_format_to_proto(fmt: DomainExportFormat | ApplicationExportFormat) -> int:
"""Convert domain or application ExportFormat to proto enum."""
# Both enums have the same values, so we can use either
format_value = fmt.value if hasattr(fmt, "value") else str(fmt)
def export_format_to_proto(fmt: ExportFormat) -> int:
"""Convert domain ExportFormat to proto enum."""
mapping: dict[str, int] = {
"markdown": noteflow_pb2.EXPORT_FORMAT_MARKDOWN,
"html": noteflow_pb2.EXPORT_FORMAT_HTML,
"pdf": noteflow_pb2.EXPORT_FORMAT_PDF,
}
return mapping.get(format_value, noteflow_pb2.EXPORT_FORMAT_UNSPECIFIED)
return mapping.get(fmt.value, noteflow_pb2.EXPORT_FORMAT_UNSPECIFIED)
class _Copyable(Protocol):

View File

@@ -0,0 +1,134 @@
"""HuggingFace token management mixin for gRPC service.
Provides secure storage, validation, and retrieval of HuggingFace
API tokens used for accessing gated models like pyannote.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from noteflow.infrastructure.logging import get_logger
from ..proto import noteflow_pb2
from ._types import GrpcContext
if TYPE_CHECKING:
from noteflow.application.services.hf_token_service import HfTokenService
logger = get_logger(__name__)
class HfTokenMixin:
"""Mixin providing HuggingFace token management functionality.
Requires host to have:
- hf_token_service: HfTokenService | None
"""
# Protocol requirements (declared for type checking)
hf_token_service: HfTokenService | None
async def SetHuggingFaceToken(
self,
request: noteflow_pb2.SetHuggingFaceTokenRequest,
context: GrpcContext,
) -> noteflow_pb2.SetHuggingFaceTokenResponse:
"""Set and optionally validate a HuggingFace API token.
The token is encrypted before storage. If validation is requested,
the token is verified against the HuggingFace API before saving.
"""
if self.hf_token_service is None:
return noteflow_pb2.SetHuggingFaceTokenResponse(
success=False,
validation_error="HuggingFace token service is not available",
)
if not request.token:
return noteflow_pb2.SetHuggingFaceTokenResponse(
success=False,
validation_error="Token cannot be empty",
)
success, validation_result = await self.hf_token_service.set_token(
token=request.token,
validate=request.validate,
)
response = noteflow_pb2.SetHuggingFaceTokenResponse(success=success)
if validation_result is not None:
response.valid = validation_result.valid
response.username = validation_result.username
if not validation_result.valid:
response.validation_error = validation_result.error_message
return response
async def GetHuggingFaceTokenStatus(
self,
request: noteflow_pb2.GetHuggingFaceTokenStatusRequest,
context: GrpcContext,
) -> noteflow_pb2.GetHuggingFaceTokenStatusResponse:
"""Get the current status of the HuggingFace token.
Returns whether a token is configured and validated, along with
the associated username and validation timestamp.
"""
if self.hf_token_service is None:
return noteflow_pb2.GetHuggingFaceTokenStatusResponse(
is_configured=False,
is_validated=False,
username="",
validated_at=0.0,
)
status = await self.hf_token_service.get_status()
return noteflow_pb2.GetHuggingFaceTokenStatusResponse(
is_configured=status.is_configured,
is_validated=status.is_validated,
username=status.username,
validated_at=status.validated_at or 0.0,
)
async def DeleteHuggingFaceToken(
self,
request: noteflow_pb2.DeleteHuggingFaceTokenRequest,
context: GrpcContext,
) -> noteflow_pb2.DeleteHuggingFaceTokenResponse:
"""Delete the stored HuggingFace token.
Removes both the encrypted token and its metadata from storage.
"""
if self.hf_token_service is None:
return noteflow_pb2.DeleteHuggingFaceTokenResponse(success=False)
success = await self.hf_token_service.delete_token()
return noteflow_pb2.DeleteHuggingFaceTokenResponse(success=success)
async def ValidateHuggingFaceToken(
self,
request: noteflow_pb2.ValidateHuggingFaceTokenRequest,
context: GrpcContext,
) -> noteflow_pb2.ValidateHuggingFaceTokenResponse:
"""Validate the currently stored HuggingFace token.
Tests the stored token against the HuggingFace API and updates
the validation status in storage.
"""
if self.hf_token_service is None:
return noteflow_pb2.ValidateHuggingFaceTokenResponse(
valid=False,
username="",
error_message="HuggingFace token service is not available",
)
result = await self.hf_token_service.validate_stored_token()
return noteflow_pb2.ValidateHuggingFaceTokenResponse(
valid=result.valid,
username=result.username,
error_message=result.error_message,
)

View File

@@ -52,6 +52,11 @@ service NoteFlowService {
// Server health and capabilities
rpc GetServerInfo(ServerInfoRequest) returns (ServerInfo);
// ASR Configuration Management (Sprint 19)
rpc GetAsrConfiguration(GetAsrConfigurationRequest) returns (GetAsrConfigurationResponse);
rpc UpdateAsrConfiguration(UpdateAsrConfigurationRequest) returns (UpdateAsrConfigurationResponse);
rpc GetAsrConfigurationJobStatus(GetAsrConfigurationJobStatusRequest) returns (AsrConfigurationJobStatus);
// Named entity extraction (Sprint 4) + mutations (Sprint 8)
rpc ExtractEntities(ExtractEntitiesRequest) returns (ExtractEntitiesResponse);
rpc UpdateEntity(UpdateEntityRequest) returns (UpdateEntityResponse);
@@ -79,6 +84,12 @@ service NoteFlowService {
rpc RevokeCloudConsent(RevokeCloudConsentRequest) returns (RevokeCloudConsentResponse);
rpc GetCloudConsentStatus(GetCloudConsentStatusRequest) returns (GetCloudConsentStatusResponse);
// HuggingFace Token Management (Sprint 19)
rpc SetHuggingFaceToken(SetHuggingFaceTokenRequest) returns (SetHuggingFaceTokenResponse);
rpc GetHuggingFaceTokenStatus(GetHuggingFaceTokenStatusRequest) returns (GetHuggingFaceTokenStatusResponse);
rpc DeleteHuggingFaceToken(DeleteHuggingFaceTokenRequest) returns (DeleteHuggingFaceTokenResponse);
rpc ValidateHuggingFaceToken(ValidateHuggingFaceTokenRequest) returns (ValidateHuggingFaceTokenResponse);
// User preferences sync (Sprint 14)
rpc GetPreferences(GetPreferencesRequest) returns (GetPreferencesResponse);
rpc SetPreferences(SetPreferencesRequest) returns (SetPreferencesResponse);
@@ -613,6 +624,103 @@ message ServerInfo {
int64 state_version = 10;
}
// =============================================================================
// ASR Configuration Messages (Sprint 19)
// =============================================================================
// Valid ASR devices
enum AsrDevice {
ASR_DEVICE_UNSPECIFIED = 0;
ASR_DEVICE_CPU = 1;
ASR_DEVICE_CUDA = 2;
}
// Valid ASR compute types
enum AsrComputeType {
ASR_COMPUTE_TYPE_UNSPECIFIED = 0;
ASR_COMPUTE_TYPE_INT8 = 1;
ASR_COMPUTE_TYPE_FLOAT16 = 2;
ASR_COMPUTE_TYPE_FLOAT32 = 3;
}
// Current ASR configuration and capabilities
message AsrConfiguration {
// Currently loaded model size (e.g., "base", "small", "medium")
string model_size = 1;
// Current device in use
AsrDevice device = 2;
// Current compute type
AsrComputeType compute_type = 3;
// Whether ASR engine is ready for transcription
bool is_ready = 4;
// Whether CUDA is available on this server
bool cuda_available = 5;
// Available model sizes that can be loaded
repeated string available_model_sizes = 6;
// Available compute types for current device
repeated AsrComputeType available_compute_types = 7;
}
message GetAsrConfigurationRequest {}
message GetAsrConfigurationResponse {
AsrConfiguration configuration = 1;
}
message UpdateAsrConfigurationRequest {
// New model size to load (optional, keeps current if empty)
optional string model_size = 1;
// New device (optional, keeps current if unspecified)
optional AsrDevice device = 2;
// New compute type (optional, keeps current if unspecified)
optional AsrComputeType compute_type = 3;
}
message UpdateAsrConfigurationResponse {
// Background job identifier for tracking reload progress
string job_id = 1;
// Initial status (always QUEUED or RUNNING)
JobStatus status = 2;
// Error message if validation failed before job creation
string error_message = 3;
// Whether the request was accepted (false if active recording)
bool accepted = 4;
}
message GetAsrConfigurationJobStatusRequest {
string job_id = 1;
}
message AsrConfigurationJobStatus {
string job_id = 1;
// Current status
JobStatus status = 2;
// Progress percentage (0.0-100.0), primarily for model download
float progress_percent = 3;
// Current phase: "validating", "downloading", "loading", "completed"
string phase = 4;
// Error message if failed
string error_message = 5;
// New configuration after successful reload
optional AsrConfiguration new_configuration = 6;
}
// =============================================================================
// Annotation Messages
// =============================================================================
@@ -1309,6 +1417,62 @@ message GetCloudConsentStatusResponse {
bool consent_granted = 1;
}
// =============================================================================
// HuggingFace Token Management Messages (Sprint 19)
// =============================================================================
message SetHuggingFaceTokenRequest {
// HuggingFace access token (will be encrypted at rest)
string token = 1;
// Whether to validate token against HuggingFace API
bool validate = 2;
}
message SetHuggingFaceTokenResponse {
// Whether the token was saved successfully
bool success = 1;
// Whether the token passed validation (if validate=true)
optional bool valid = 2;
// Validation error message if valid=false
string validation_error = 3;
// HuggingFace username associated with token (if validate=true and valid)
string username = 4;
}
message GetHuggingFaceTokenStatusRequest {}
message GetHuggingFaceTokenStatusResponse {
// Whether a token is configured
bool is_configured = 1;
// Whether the token has been validated
bool is_validated = 2;
// HuggingFace username (if validated)
string username = 3;
// Last validation timestamp (Unix epoch seconds)
double validated_at = 4;
}
message DeleteHuggingFaceTokenRequest {}
message DeleteHuggingFaceTokenResponse {
bool success = 1;
}
message ValidateHuggingFaceTokenRequest {}
message ValidateHuggingFaceTokenResponse {
bool valid = 1;
string username = 2;
string error_message = 3;
}
// =============================================================================
// User Preferences Sync Messages (Sprint 14)
// =============================================================================

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -168,6 +168,21 @@ class NoteFlowServiceStub(object):
request_serializer=noteflow__pb2.ServerInfoRequest.SerializeToString,
response_deserializer=noteflow__pb2.ServerInfo.FromString,
_registered_method=True)
self.GetAsrConfiguration = channel.unary_unary(
'/noteflow.NoteFlowService/GetAsrConfiguration',
request_serializer=noteflow__pb2.GetAsrConfigurationRequest.SerializeToString,
response_deserializer=noteflow__pb2.GetAsrConfigurationResponse.FromString,
_registered_method=True)
self.UpdateAsrConfiguration = channel.unary_unary(
'/noteflow.NoteFlowService/UpdateAsrConfiguration',
request_serializer=noteflow__pb2.UpdateAsrConfigurationRequest.SerializeToString,
response_deserializer=noteflow__pb2.UpdateAsrConfigurationResponse.FromString,
_registered_method=True)
self.GetAsrConfigurationJobStatus = channel.unary_unary(
'/noteflow.NoteFlowService/GetAsrConfigurationJobStatus',
request_serializer=noteflow__pb2.GetAsrConfigurationJobStatusRequest.SerializeToString,
response_deserializer=noteflow__pb2.AsrConfigurationJobStatus.FromString,
_registered_method=True)
self.ExtractEntities = channel.unary_unary(
'/noteflow.NoteFlowService/ExtractEntities',
request_serializer=noteflow__pb2.ExtractEntitiesRequest.SerializeToString,
@@ -253,6 +268,26 @@ class NoteFlowServiceStub(object):
request_serializer=noteflow__pb2.GetCloudConsentStatusRequest.SerializeToString,
response_deserializer=noteflow__pb2.GetCloudConsentStatusResponse.FromString,
_registered_method=True)
self.SetHuggingFaceToken = channel.unary_unary(
'/noteflow.NoteFlowService/SetHuggingFaceToken',
request_serializer=noteflow__pb2.SetHuggingFaceTokenRequest.SerializeToString,
response_deserializer=noteflow__pb2.SetHuggingFaceTokenResponse.FromString,
_registered_method=True)
self.GetHuggingFaceTokenStatus = channel.unary_unary(
'/noteflow.NoteFlowService/GetHuggingFaceTokenStatus',
request_serializer=noteflow__pb2.GetHuggingFaceTokenStatusRequest.SerializeToString,
response_deserializer=noteflow__pb2.GetHuggingFaceTokenStatusResponse.FromString,
_registered_method=True)
self.DeleteHuggingFaceToken = channel.unary_unary(
'/noteflow.NoteFlowService/DeleteHuggingFaceToken',
request_serializer=noteflow__pb2.DeleteHuggingFaceTokenRequest.SerializeToString,
response_deserializer=noteflow__pb2.DeleteHuggingFaceTokenResponse.FromString,
_registered_method=True)
self.ValidateHuggingFaceToken = channel.unary_unary(
'/noteflow.NoteFlowService/ValidateHuggingFaceToken',
request_serializer=noteflow__pb2.ValidateHuggingFaceTokenRequest.SerializeToString,
response_deserializer=noteflow__pb2.ValidateHuggingFaceTokenResponse.FromString,
_registered_method=True)
self.GetPreferences = channel.unary_unary(
'/noteflow.NoteFlowService/GetPreferences',
request_serializer=noteflow__pb2.GetPreferencesRequest.SerializeToString,
@@ -596,6 +631,25 @@ class NoteFlowServiceServicer(object):
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetAsrConfiguration(self, request, context):
"""ASR Configuration Management (Sprint 19)
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def UpdateAsrConfiguration(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetAsrConfigurationJobStatus(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def ExtractEntities(self, request, context):
"""Named entity extraction (Sprint 4) + mutations (Sprint 8)
"""
@@ -703,6 +757,31 @@ class NoteFlowServiceServicer(object):
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def SetHuggingFaceToken(self, request, context):
"""HuggingFace Token Management (Sprint 19)
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetHuggingFaceTokenStatus(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def DeleteHuggingFaceToken(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def ValidateHuggingFaceToken(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetPreferences(self, request, context):
"""User preferences sync (Sprint 14)
"""
@@ -1049,6 +1128,21 @@ def add_NoteFlowServiceServicer_to_server(servicer, server):
request_deserializer=noteflow__pb2.ServerInfoRequest.FromString,
response_serializer=noteflow__pb2.ServerInfo.SerializeToString,
),
'GetAsrConfiguration': grpc.unary_unary_rpc_method_handler(
servicer.GetAsrConfiguration,
request_deserializer=noteflow__pb2.GetAsrConfigurationRequest.FromString,
response_serializer=noteflow__pb2.GetAsrConfigurationResponse.SerializeToString,
),
'UpdateAsrConfiguration': grpc.unary_unary_rpc_method_handler(
servicer.UpdateAsrConfiguration,
request_deserializer=noteflow__pb2.UpdateAsrConfigurationRequest.FromString,
response_serializer=noteflow__pb2.UpdateAsrConfigurationResponse.SerializeToString,
),
'GetAsrConfigurationJobStatus': grpc.unary_unary_rpc_method_handler(
servicer.GetAsrConfigurationJobStatus,
request_deserializer=noteflow__pb2.GetAsrConfigurationJobStatusRequest.FromString,
response_serializer=noteflow__pb2.AsrConfigurationJobStatus.SerializeToString,
),
'ExtractEntities': grpc.unary_unary_rpc_method_handler(
servicer.ExtractEntities,
request_deserializer=noteflow__pb2.ExtractEntitiesRequest.FromString,
@@ -1134,6 +1228,26 @@ def add_NoteFlowServiceServicer_to_server(servicer, server):
request_deserializer=noteflow__pb2.GetCloudConsentStatusRequest.FromString,
response_serializer=noteflow__pb2.GetCloudConsentStatusResponse.SerializeToString,
),
'SetHuggingFaceToken': grpc.unary_unary_rpc_method_handler(
servicer.SetHuggingFaceToken,
request_deserializer=noteflow__pb2.SetHuggingFaceTokenRequest.FromString,
response_serializer=noteflow__pb2.SetHuggingFaceTokenResponse.SerializeToString,
),
'GetHuggingFaceTokenStatus': grpc.unary_unary_rpc_method_handler(
servicer.GetHuggingFaceTokenStatus,
request_deserializer=noteflow__pb2.GetHuggingFaceTokenStatusRequest.FromString,
response_serializer=noteflow__pb2.GetHuggingFaceTokenStatusResponse.SerializeToString,
),
'DeleteHuggingFaceToken': grpc.unary_unary_rpc_method_handler(
servicer.DeleteHuggingFaceToken,
request_deserializer=noteflow__pb2.DeleteHuggingFaceTokenRequest.FromString,
response_serializer=noteflow__pb2.DeleteHuggingFaceTokenResponse.SerializeToString,
),
'ValidateHuggingFaceToken': grpc.unary_unary_rpc_method_handler(
servicer.ValidateHuggingFaceToken,
request_deserializer=noteflow__pb2.ValidateHuggingFaceTokenRequest.FromString,
response_serializer=noteflow__pb2.ValidateHuggingFaceTokenResponse.SerializeToString,
),
'GetPreferences': grpc.unary_unary_rpc_method_handler(
servicer.GetPreferences,
request_deserializer=noteflow__pb2.GetPreferencesRequest.FromString,
@@ -2021,6 +2135,87 @@ class NoteFlowService(object):
metadata,
_registered_method=True)
@staticmethod
def GetAsrConfiguration(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/noteflow.NoteFlowService/GetAsrConfiguration',
noteflow__pb2.GetAsrConfigurationRequest.SerializeToString,
noteflow__pb2.GetAsrConfigurationResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def UpdateAsrConfiguration(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/noteflow.NoteFlowService/UpdateAsrConfiguration',
noteflow__pb2.UpdateAsrConfigurationRequest.SerializeToString,
noteflow__pb2.UpdateAsrConfigurationResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GetAsrConfigurationJobStatus(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/noteflow.NoteFlowService/GetAsrConfigurationJobStatus',
noteflow__pb2.GetAsrConfigurationJobStatusRequest.SerializeToString,
noteflow__pb2.AsrConfigurationJobStatus.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def ExtractEntities(request,
target,
@@ -2480,6 +2675,114 @@ class NoteFlowService(object):
metadata,
_registered_method=True)
@staticmethod
def SetHuggingFaceToken(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/noteflow.NoteFlowService/SetHuggingFaceToken',
noteflow__pb2.SetHuggingFaceTokenRequest.SerializeToString,
noteflow__pb2.SetHuggingFaceTokenResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GetHuggingFaceTokenStatus(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/noteflow.NoteFlowService/GetHuggingFaceTokenStatus',
noteflow__pb2.GetHuggingFaceTokenStatusRequest.SerializeToString,
noteflow__pb2.GetHuggingFaceTokenStatusResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def DeleteHuggingFaceToken(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/noteflow.NoteFlowService/DeleteHuggingFaceToken',
noteflow__pb2.DeleteHuggingFaceTokenRequest.SerializeToString,
noteflow__pb2.DeleteHuggingFaceTokenResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def ValidateHuggingFaceToken(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/noteflow.NoteFlowService/ValidateHuggingFaceToken',
noteflow__pb2.ValidateHuggingFaceTokenRequest.SerializeToString,
noteflow__pb2.ValidateHuggingFaceTokenResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GetPreferences(request,
target,

View File

@@ -7,12 +7,10 @@ Provides real-time ASR streaming and meeting management
import abc
import collections.abc
import typing
import grpc
import grpc.aio
import noteflow_pb2
import typing
_T = typing.TypeVar("_T")
@@ -44,6 +42,14 @@ class NoteFlowServiceStub:
DeleteMeeting: grpc.UnaryUnaryMultiCallable[noteflow_pb2.DeleteMeetingRequest, noteflow_pb2.DeleteMeetingResponse]
GenerateSummary: grpc.UnaryUnaryMultiCallable[noteflow_pb2.GenerateSummaryRequest, noteflow_pb2.Summary]
"""Summary generation"""
ListSummarizationTemplates: grpc.UnaryUnaryMultiCallable[noteflow_pb2.ListSummarizationTemplatesRequest, noteflow_pb2.ListSummarizationTemplatesResponse]
"""Summarization templates"""
GetSummarizationTemplate: grpc.UnaryUnaryMultiCallable[noteflow_pb2.GetSummarizationTemplateRequest, noteflow_pb2.GetSummarizationTemplateResponse]
CreateSummarizationTemplate: grpc.UnaryUnaryMultiCallable[noteflow_pb2.CreateSummarizationTemplateRequest, noteflow_pb2.SummarizationTemplateMutationResponse]
UpdateSummarizationTemplate: grpc.UnaryUnaryMultiCallable[noteflow_pb2.UpdateSummarizationTemplateRequest, noteflow_pb2.SummarizationTemplateMutationResponse]
ArchiveSummarizationTemplate: grpc.UnaryUnaryMultiCallable[noteflow_pb2.ArchiveSummarizationTemplateRequest, noteflow_pb2.SummarizationTemplateProto]
ListSummarizationTemplateVersions: grpc.UnaryUnaryMultiCallable[noteflow_pb2.ListSummarizationTemplateVersionsRequest, noteflow_pb2.ListSummarizationTemplateVersionsResponse]
RestoreSummarizationTemplateVersion: grpc.UnaryUnaryMultiCallable[noteflow_pb2.RestoreSummarizationTemplateVersionRequest, noteflow_pb2.SummarizationTemplateProto]
AddAnnotation: grpc.UnaryUnaryMultiCallable[noteflow_pb2.AddAnnotationRequest, noteflow_pb2.Annotation]
"""Annotation management"""
GetAnnotation: grpc.UnaryUnaryMultiCallable[noteflow_pb2.GetAnnotationRequest, noteflow_pb2.Annotation]
@@ -60,6 +66,10 @@ class NoteFlowServiceStub:
GetActiveDiarizationJobs: grpc.UnaryUnaryMultiCallable[noteflow_pb2.GetActiveDiarizationJobsRequest, noteflow_pb2.GetActiveDiarizationJobsResponse]
GetServerInfo: grpc.UnaryUnaryMultiCallable[noteflow_pb2.ServerInfoRequest, noteflow_pb2.ServerInfo]
"""Server health and capabilities"""
GetAsrConfiguration: grpc.UnaryUnaryMultiCallable[noteflow_pb2.GetAsrConfigurationRequest, noteflow_pb2.GetAsrConfigurationResponse]
"""ASR Configuration Management (Sprint 19)"""
UpdateAsrConfiguration: grpc.UnaryUnaryMultiCallable[noteflow_pb2.UpdateAsrConfigurationRequest, noteflow_pb2.UpdateAsrConfigurationResponse]
GetAsrConfigurationJobStatus: grpc.UnaryUnaryMultiCallable[noteflow_pb2.GetAsrConfigurationJobStatusRequest, noteflow_pb2.AsrConfigurationJobStatus]
ExtractEntities: grpc.UnaryUnaryMultiCallable[noteflow_pb2.ExtractEntitiesRequest, noteflow_pb2.ExtractEntitiesResponse]
"""Named entity extraction (Sprint 4) + mutations (Sprint 8)"""
UpdateEntity: grpc.UnaryUnaryMultiCallable[noteflow_pb2.UpdateEntityRequest, noteflow_pb2.UpdateEntityResponse]
@@ -82,6 +92,11 @@ class NoteFlowServiceStub:
"""Cloud consent management (Sprint 7)"""
RevokeCloudConsent: grpc.UnaryUnaryMultiCallable[noteflow_pb2.RevokeCloudConsentRequest, noteflow_pb2.RevokeCloudConsentResponse]
GetCloudConsentStatus: grpc.UnaryUnaryMultiCallable[noteflow_pb2.GetCloudConsentStatusRequest, noteflow_pb2.GetCloudConsentStatusResponse]
SetHuggingFaceToken: grpc.UnaryUnaryMultiCallable[noteflow_pb2.SetHuggingFaceTokenRequest, noteflow_pb2.SetHuggingFaceTokenResponse]
"""HuggingFace Token Management (Sprint 19)"""
GetHuggingFaceTokenStatus: grpc.UnaryUnaryMultiCallable[noteflow_pb2.GetHuggingFaceTokenStatusRequest, noteflow_pb2.GetHuggingFaceTokenStatusResponse]
DeleteHuggingFaceToken: grpc.UnaryUnaryMultiCallable[noteflow_pb2.DeleteHuggingFaceTokenRequest, noteflow_pb2.DeleteHuggingFaceTokenResponse]
ValidateHuggingFaceToken: grpc.UnaryUnaryMultiCallable[noteflow_pb2.ValidateHuggingFaceTokenRequest, noteflow_pb2.ValidateHuggingFaceTokenResponse]
GetPreferences: grpc.UnaryUnaryMultiCallable[noteflow_pb2.GetPreferencesRequest, noteflow_pb2.GetPreferencesResponse]
"""User preferences sync (Sprint 14)"""
SetPreferences: grpc.UnaryUnaryMultiCallable[noteflow_pb2.SetPreferencesRequest, noteflow_pb2.SetPreferencesResponse]
@@ -119,6 +134,12 @@ class NoteFlowServiceStub:
UpdateProjectMemberRole: grpc.UnaryUnaryMultiCallable[noteflow_pb2.UpdateProjectMemberRoleRequest, noteflow_pb2.ProjectMembershipProto]
RemoveProjectMember: grpc.UnaryUnaryMultiCallable[noteflow_pb2.RemoveProjectMemberRequest, noteflow_pb2.RemoveProjectMemberResponse]
ListProjectMembers: grpc.UnaryUnaryMultiCallable[noteflow_pb2.ListProjectMembersRequest, noteflow_pb2.ListProjectMembersResponse]
GetCurrentUser: grpc.UnaryUnaryMultiCallable[noteflow_pb2.GetCurrentUserRequest, noteflow_pb2.GetCurrentUserResponse]
"""Identity management (Sprint 16+)"""
ListWorkspaces: grpc.UnaryUnaryMultiCallable[noteflow_pb2.ListWorkspacesRequest, noteflow_pb2.ListWorkspacesResponse]
SwitchWorkspace: grpc.UnaryUnaryMultiCallable[noteflow_pb2.SwitchWorkspaceRequest, noteflow_pb2.SwitchWorkspaceResponse]
GetWorkspaceSettings: grpc.UnaryUnaryMultiCallable[noteflow_pb2.GetWorkspaceSettingsRequest, noteflow_pb2.WorkspaceSettingsProto]
UpdateWorkspaceSettings: grpc.UnaryUnaryMultiCallable[noteflow_pb2.UpdateWorkspaceSettingsRequest, noteflow_pb2.WorkspaceSettingsProto]
@typing.type_check_only
class NoteFlowServiceAsyncStub(NoteFlowServiceStub):
@@ -138,6 +159,14 @@ class NoteFlowServiceAsyncStub(NoteFlowServiceStub):
DeleteMeeting: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.DeleteMeetingRequest, noteflow_pb2.DeleteMeetingResponse] # type: ignore[assignment]
GenerateSummary: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.GenerateSummaryRequest, noteflow_pb2.Summary] # type: ignore[assignment]
"""Summary generation"""
ListSummarizationTemplates: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.ListSummarizationTemplatesRequest, noteflow_pb2.ListSummarizationTemplatesResponse] # type: ignore[assignment]
"""Summarization templates"""
GetSummarizationTemplate: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.GetSummarizationTemplateRequest, noteflow_pb2.GetSummarizationTemplateResponse] # type: ignore[assignment]
CreateSummarizationTemplate: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.CreateSummarizationTemplateRequest, noteflow_pb2.SummarizationTemplateMutationResponse] # type: ignore[assignment]
UpdateSummarizationTemplate: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.UpdateSummarizationTemplateRequest, noteflow_pb2.SummarizationTemplateMutationResponse] # type: ignore[assignment]
ArchiveSummarizationTemplate: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.ArchiveSummarizationTemplateRequest, noteflow_pb2.SummarizationTemplateProto] # type: ignore[assignment]
ListSummarizationTemplateVersions: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.ListSummarizationTemplateVersionsRequest, noteflow_pb2.ListSummarizationTemplateVersionsResponse] # type: ignore[assignment]
RestoreSummarizationTemplateVersion: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.RestoreSummarizationTemplateVersionRequest, noteflow_pb2.SummarizationTemplateProto] # type: ignore[assignment]
AddAnnotation: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.AddAnnotationRequest, noteflow_pb2.Annotation] # type: ignore[assignment]
"""Annotation management"""
GetAnnotation: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.GetAnnotationRequest, noteflow_pb2.Annotation] # type: ignore[assignment]
@@ -154,6 +183,10 @@ class NoteFlowServiceAsyncStub(NoteFlowServiceStub):
GetActiveDiarizationJobs: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.GetActiveDiarizationJobsRequest, noteflow_pb2.GetActiveDiarizationJobsResponse] # type: ignore[assignment]
GetServerInfo: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.ServerInfoRequest, noteflow_pb2.ServerInfo] # type: ignore[assignment]
"""Server health and capabilities"""
GetAsrConfiguration: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.GetAsrConfigurationRequest, noteflow_pb2.GetAsrConfigurationResponse] # type: ignore[assignment]
"""ASR Configuration Management (Sprint 19)"""
UpdateAsrConfiguration: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.UpdateAsrConfigurationRequest, noteflow_pb2.UpdateAsrConfigurationResponse] # type: ignore[assignment]
GetAsrConfigurationJobStatus: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.GetAsrConfigurationJobStatusRequest, noteflow_pb2.AsrConfigurationJobStatus] # type: ignore[assignment]
ExtractEntities: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.ExtractEntitiesRequest, noteflow_pb2.ExtractEntitiesResponse] # type: ignore[assignment]
"""Named entity extraction (Sprint 4) + mutations (Sprint 8)"""
UpdateEntity: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.UpdateEntityRequest, noteflow_pb2.UpdateEntityResponse] # type: ignore[assignment]
@@ -176,6 +209,11 @@ class NoteFlowServiceAsyncStub(NoteFlowServiceStub):
"""Cloud consent management (Sprint 7)"""
RevokeCloudConsent: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.RevokeCloudConsentRequest, noteflow_pb2.RevokeCloudConsentResponse] # type: ignore[assignment]
GetCloudConsentStatus: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.GetCloudConsentStatusRequest, noteflow_pb2.GetCloudConsentStatusResponse] # type: ignore[assignment]
SetHuggingFaceToken: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.SetHuggingFaceTokenRequest, noteflow_pb2.SetHuggingFaceTokenResponse] # type: ignore[assignment]
"""HuggingFace Token Management (Sprint 19)"""
GetHuggingFaceTokenStatus: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.GetHuggingFaceTokenStatusRequest, noteflow_pb2.GetHuggingFaceTokenStatusResponse] # type: ignore[assignment]
DeleteHuggingFaceToken: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.DeleteHuggingFaceTokenRequest, noteflow_pb2.DeleteHuggingFaceTokenResponse] # type: ignore[assignment]
ValidateHuggingFaceToken: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.ValidateHuggingFaceTokenRequest, noteflow_pb2.ValidateHuggingFaceTokenResponse] # type: ignore[assignment]
GetPreferences: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.GetPreferencesRequest, noteflow_pb2.GetPreferencesResponse] # type: ignore[assignment]
"""User preferences sync (Sprint 14)"""
SetPreferences: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.SetPreferencesRequest, noteflow_pb2.SetPreferencesResponse] # type: ignore[assignment]
@@ -213,6 +251,12 @@ class NoteFlowServiceAsyncStub(NoteFlowServiceStub):
UpdateProjectMemberRole: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.UpdateProjectMemberRoleRequest, noteflow_pb2.ProjectMembershipProto] # type: ignore[assignment]
RemoveProjectMember: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.RemoveProjectMemberRequest, noteflow_pb2.RemoveProjectMemberResponse] # type: ignore[assignment]
ListProjectMembers: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.ListProjectMembersRequest, noteflow_pb2.ListProjectMembersResponse] # type: ignore[assignment]
GetCurrentUser: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.GetCurrentUserRequest, noteflow_pb2.GetCurrentUserResponse] # type: ignore[assignment]
"""Identity management (Sprint 16+)"""
ListWorkspaces: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.ListWorkspacesRequest, noteflow_pb2.ListWorkspacesResponse] # type: ignore[assignment]
SwitchWorkspace: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.SwitchWorkspaceRequest, noteflow_pb2.SwitchWorkspaceResponse] # type: ignore[assignment]
GetWorkspaceSettings: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.GetWorkspaceSettingsRequest, noteflow_pb2.WorkspaceSettingsProto] # type: ignore[assignment]
UpdateWorkspaceSettings: grpc.aio.UnaryUnaryMultiCallable[noteflow_pb2.UpdateWorkspaceSettingsRequest, noteflow_pb2.WorkspaceSettingsProto] # type: ignore[assignment]
class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
"""=============================================================================
@@ -225,7 +269,7 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request_iterator: _MaybeAsyncIterator[noteflow_pb2.AudioChunk],
context: _ServicerContext,
) -> collections.abc.Iterator[noteflow_pb2.TranscriptUpdate] | collections.abc.AsyncIterator[noteflow_pb2.TranscriptUpdate]:
) -> typing.Union[collections.abc.Iterator[noteflow_pb2.TranscriptUpdate], collections.abc.AsyncIterator[noteflow_pb2.TranscriptUpdate]]:
"""Bidirectional streaming: client sends audio chunks, server returns transcripts"""
@abc.abstractmethod
@@ -233,7 +277,7 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.CreateMeetingRequest,
context: _ServicerContext,
) -> noteflow_pb2.Meeting | collections.abc.Awaitable[noteflow_pb2.Meeting]:
) -> typing.Union[noteflow_pb2.Meeting, collections.abc.Awaitable[noteflow_pb2.Meeting]]:
"""Meeting lifecycle management"""
@abc.abstractmethod
@@ -241,43 +285,93 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.StopMeetingRequest,
context: _ServicerContext,
) -> noteflow_pb2.Meeting | collections.abc.Awaitable[noteflow_pb2.Meeting]: ...
) -> typing.Union[noteflow_pb2.Meeting, collections.abc.Awaitable[noteflow_pb2.Meeting]]: ...
@abc.abstractmethod
def ListMeetings(
self,
request: noteflow_pb2.ListMeetingsRequest,
context: _ServicerContext,
) -> noteflow_pb2.ListMeetingsResponse | collections.abc.Awaitable[noteflow_pb2.ListMeetingsResponse]: ...
) -> typing.Union[noteflow_pb2.ListMeetingsResponse, collections.abc.Awaitable[noteflow_pb2.ListMeetingsResponse]]: ...
@abc.abstractmethod
def GetMeeting(
self,
request: noteflow_pb2.GetMeetingRequest,
context: _ServicerContext,
) -> noteflow_pb2.Meeting | collections.abc.Awaitable[noteflow_pb2.Meeting]: ...
) -> typing.Union[noteflow_pb2.Meeting, collections.abc.Awaitable[noteflow_pb2.Meeting]]: ...
@abc.abstractmethod
def DeleteMeeting(
self,
request: noteflow_pb2.DeleteMeetingRequest,
context: _ServicerContext,
) -> noteflow_pb2.DeleteMeetingResponse | collections.abc.Awaitable[noteflow_pb2.DeleteMeetingResponse]: ...
) -> typing.Union[noteflow_pb2.DeleteMeetingResponse, collections.abc.Awaitable[noteflow_pb2.DeleteMeetingResponse]]: ...
@abc.abstractmethod
def GenerateSummary(
self,
request: noteflow_pb2.GenerateSummaryRequest,
context: _ServicerContext,
) -> noteflow_pb2.Summary | collections.abc.Awaitable[noteflow_pb2.Summary]:
) -> typing.Union[noteflow_pb2.Summary, collections.abc.Awaitable[noteflow_pb2.Summary]]:
"""Summary generation"""
@abc.abstractmethod
def ListSummarizationTemplates(
self,
request: noteflow_pb2.ListSummarizationTemplatesRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.ListSummarizationTemplatesResponse, collections.abc.Awaitable[noteflow_pb2.ListSummarizationTemplatesResponse]]:
"""Summarization templates"""
@abc.abstractmethod
def GetSummarizationTemplate(
self,
request: noteflow_pb2.GetSummarizationTemplateRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.GetSummarizationTemplateResponse, collections.abc.Awaitable[noteflow_pb2.GetSummarizationTemplateResponse]]: ...
@abc.abstractmethod
def CreateSummarizationTemplate(
self,
request: noteflow_pb2.CreateSummarizationTemplateRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.SummarizationTemplateMutationResponse, collections.abc.Awaitable[noteflow_pb2.SummarizationTemplateMutationResponse]]: ...
@abc.abstractmethod
def UpdateSummarizationTemplate(
self,
request: noteflow_pb2.UpdateSummarizationTemplateRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.SummarizationTemplateMutationResponse, collections.abc.Awaitable[noteflow_pb2.SummarizationTemplateMutationResponse]]: ...
@abc.abstractmethod
def ArchiveSummarizationTemplate(
self,
request: noteflow_pb2.ArchiveSummarizationTemplateRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.SummarizationTemplateProto, collections.abc.Awaitable[noteflow_pb2.SummarizationTemplateProto]]: ...
@abc.abstractmethod
def ListSummarizationTemplateVersions(
self,
request: noteflow_pb2.ListSummarizationTemplateVersionsRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.ListSummarizationTemplateVersionsResponse, collections.abc.Awaitable[noteflow_pb2.ListSummarizationTemplateVersionsResponse]]: ...
@abc.abstractmethod
def RestoreSummarizationTemplateVersion(
self,
request: noteflow_pb2.RestoreSummarizationTemplateVersionRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.SummarizationTemplateProto, collections.abc.Awaitable[noteflow_pb2.SummarizationTemplateProto]]: ...
@abc.abstractmethod
def AddAnnotation(
self,
request: noteflow_pb2.AddAnnotationRequest,
context: _ServicerContext,
) -> noteflow_pb2.Annotation | collections.abc.Awaitable[noteflow_pb2.Annotation]:
) -> typing.Union[noteflow_pb2.Annotation, collections.abc.Awaitable[noteflow_pb2.Annotation]]:
"""Annotation management"""
@abc.abstractmethod
@@ -285,35 +379,35 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.GetAnnotationRequest,
context: _ServicerContext,
) -> noteflow_pb2.Annotation | collections.abc.Awaitable[noteflow_pb2.Annotation]: ...
) -> typing.Union[noteflow_pb2.Annotation, collections.abc.Awaitable[noteflow_pb2.Annotation]]: ...
@abc.abstractmethod
def ListAnnotations(
self,
request: noteflow_pb2.ListAnnotationsRequest,
context: _ServicerContext,
) -> noteflow_pb2.ListAnnotationsResponse | collections.abc.Awaitable[noteflow_pb2.ListAnnotationsResponse]: ...
) -> typing.Union[noteflow_pb2.ListAnnotationsResponse, collections.abc.Awaitable[noteflow_pb2.ListAnnotationsResponse]]: ...
@abc.abstractmethod
def UpdateAnnotation(
self,
request: noteflow_pb2.UpdateAnnotationRequest,
context: _ServicerContext,
) -> noteflow_pb2.Annotation | collections.abc.Awaitable[noteflow_pb2.Annotation]: ...
) -> typing.Union[noteflow_pb2.Annotation, collections.abc.Awaitable[noteflow_pb2.Annotation]]: ...
@abc.abstractmethod
def DeleteAnnotation(
self,
request: noteflow_pb2.DeleteAnnotationRequest,
context: _ServicerContext,
) -> noteflow_pb2.DeleteAnnotationResponse | collections.abc.Awaitable[noteflow_pb2.DeleteAnnotationResponse]: ...
) -> typing.Union[noteflow_pb2.DeleteAnnotationResponse, collections.abc.Awaitable[noteflow_pb2.DeleteAnnotationResponse]]: ...
@abc.abstractmethod
def ExportTranscript(
self,
request: noteflow_pb2.ExportTranscriptRequest,
context: _ServicerContext,
) -> noteflow_pb2.ExportTranscriptResponse | collections.abc.Awaitable[noteflow_pb2.ExportTranscriptResponse]:
) -> typing.Union[noteflow_pb2.ExportTranscriptResponse, collections.abc.Awaitable[noteflow_pb2.ExportTranscriptResponse]]:
"""Export functionality"""
@abc.abstractmethod
@@ -321,7 +415,7 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.RefineSpeakerDiarizationRequest,
context: _ServicerContext,
) -> noteflow_pb2.RefineSpeakerDiarizationResponse | collections.abc.Awaitable[noteflow_pb2.RefineSpeakerDiarizationResponse]:
) -> typing.Union[noteflow_pb2.RefineSpeakerDiarizationResponse, collections.abc.Awaitable[noteflow_pb2.RefineSpeakerDiarizationResponse]]:
"""Speaker diarization"""
@abc.abstractmethod
@@ -329,43 +423,65 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.RenameSpeakerRequest,
context: _ServicerContext,
) -> noteflow_pb2.RenameSpeakerResponse | collections.abc.Awaitable[noteflow_pb2.RenameSpeakerResponse]: ...
) -> typing.Union[noteflow_pb2.RenameSpeakerResponse, collections.abc.Awaitable[noteflow_pb2.RenameSpeakerResponse]]: ...
@abc.abstractmethod
def GetDiarizationJobStatus(
self,
request: noteflow_pb2.GetDiarizationJobStatusRequest,
context: _ServicerContext,
) -> noteflow_pb2.DiarizationJobStatus | collections.abc.Awaitable[noteflow_pb2.DiarizationJobStatus]: ...
) -> typing.Union[noteflow_pb2.DiarizationJobStatus, collections.abc.Awaitable[noteflow_pb2.DiarizationJobStatus]]: ...
@abc.abstractmethod
def CancelDiarizationJob(
self,
request: noteflow_pb2.CancelDiarizationJobRequest,
context: _ServicerContext,
) -> noteflow_pb2.CancelDiarizationJobResponse | collections.abc.Awaitable[noteflow_pb2.CancelDiarizationJobResponse]: ...
) -> typing.Union[noteflow_pb2.CancelDiarizationJobResponse, collections.abc.Awaitable[noteflow_pb2.CancelDiarizationJobResponse]]: ...
@abc.abstractmethod
def GetActiveDiarizationJobs(
self,
request: noteflow_pb2.GetActiveDiarizationJobsRequest,
context: _ServicerContext,
) -> noteflow_pb2.GetActiveDiarizationJobsResponse | collections.abc.Awaitable[noteflow_pb2.GetActiveDiarizationJobsResponse]: ...
) -> typing.Union[noteflow_pb2.GetActiveDiarizationJobsResponse, collections.abc.Awaitable[noteflow_pb2.GetActiveDiarizationJobsResponse]]: ...
@abc.abstractmethod
def GetServerInfo(
self,
request: noteflow_pb2.ServerInfoRequest,
context: _ServicerContext,
) -> noteflow_pb2.ServerInfo | collections.abc.Awaitable[noteflow_pb2.ServerInfo]:
) -> typing.Union[noteflow_pb2.ServerInfo, collections.abc.Awaitable[noteflow_pb2.ServerInfo]]:
"""Server health and capabilities"""
@abc.abstractmethod
def GetAsrConfiguration(
self,
request: noteflow_pb2.GetAsrConfigurationRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.GetAsrConfigurationResponse, collections.abc.Awaitable[noteflow_pb2.GetAsrConfigurationResponse]]:
"""ASR Configuration Management (Sprint 19)"""
@abc.abstractmethod
def UpdateAsrConfiguration(
self,
request: noteflow_pb2.UpdateAsrConfigurationRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.UpdateAsrConfigurationResponse, collections.abc.Awaitable[noteflow_pb2.UpdateAsrConfigurationResponse]]: ...
@abc.abstractmethod
def GetAsrConfigurationJobStatus(
self,
request: noteflow_pb2.GetAsrConfigurationJobStatusRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.AsrConfigurationJobStatus, collections.abc.Awaitable[noteflow_pb2.AsrConfigurationJobStatus]]: ...
@abc.abstractmethod
def ExtractEntities(
self,
request: noteflow_pb2.ExtractEntitiesRequest,
context: _ServicerContext,
) -> noteflow_pb2.ExtractEntitiesResponse | collections.abc.Awaitable[noteflow_pb2.ExtractEntitiesResponse]:
) -> typing.Union[noteflow_pb2.ExtractEntitiesResponse, collections.abc.Awaitable[noteflow_pb2.ExtractEntitiesResponse]]:
"""Named entity extraction (Sprint 4) + mutations (Sprint 8)"""
@abc.abstractmethod
@@ -373,21 +489,21 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.UpdateEntityRequest,
context: _ServicerContext,
) -> noteflow_pb2.UpdateEntityResponse | collections.abc.Awaitable[noteflow_pb2.UpdateEntityResponse]: ...
) -> typing.Union[noteflow_pb2.UpdateEntityResponse, collections.abc.Awaitable[noteflow_pb2.UpdateEntityResponse]]: ...
@abc.abstractmethod
def DeleteEntity(
self,
request: noteflow_pb2.DeleteEntityRequest,
context: _ServicerContext,
) -> noteflow_pb2.DeleteEntityResponse | collections.abc.Awaitable[noteflow_pb2.DeleteEntityResponse]: ...
) -> typing.Union[noteflow_pb2.DeleteEntityResponse, collections.abc.Awaitable[noteflow_pb2.DeleteEntityResponse]]: ...
@abc.abstractmethod
def ListCalendarEvents(
self,
request: noteflow_pb2.ListCalendarEventsRequest,
context: _ServicerContext,
) -> noteflow_pb2.ListCalendarEventsResponse | collections.abc.Awaitable[noteflow_pb2.ListCalendarEventsResponse]:
) -> typing.Union[noteflow_pb2.ListCalendarEventsResponse, collections.abc.Awaitable[noteflow_pb2.ListCalendarEventsResponse]]:
"""Calendar integration (Sprint 5)"""
@abc.abstractmethod
@@ -395,14 +511,14 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.GetCalendarProvidersRequest,
context: _ServicerContext,
) -> noteflow_pb2.GetCalendarProvidersResponse | collections.abc.Awaitable[noteflow_pb2.GetCalendarProvidersResponse]: ...
) -> typing.Union[noteflow_pb2.GetCalendarProvidersResponse, collections.abc.Awaitable[noteflow_pb2.GetCalendarProvidersResponse]]: ...
@abc.abstractmethod
def InitiateOAuth(
self,
request: noteflow_pb2.InitiateOAuthRequest,
context: _ServicerContext,
) -> noteflow_pb2.InitiateOAuthResponse | collections.abc.Awaitable[noteflow_pb2.InitiateOAuthResponse]:
) -> typing.Union[noteflow_pb2.InitiateOAuthResponse, collections.abc.Awaitable[noteflow_pb2.InitiateOAuthResponse]]:
"""OAuth integration (generic for calendar, email, PKM, etc.)"""
@abc.abstractmethod
@@ -410,28 +526,28 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.CompleteOAuthRequest,
context: _ServicerContext,
) -> noteflow_pb2.CompleteOAuthResponse | collections.abc.Awaitable[noteflow_pb2.CompleteOAuthResponse]: ...
) -> typing.Union[noteflow_pb2.CompleteOAuthResponse, collections.abc.Awaitable[noteflow_pb2.CompleteOAuthResponse]]: ...
@abc.abstractmethod
def GetOAuthConnectionStatus(
self,
request: noteflow_pb2.GetOAuthConnectionStatusRequest,
context: _ServicerContext,
) -> noteflow_pb2.GetOAuthConnectionStatusResponse | collections.abc.Awaitable[noteflow_pb2.GetOAuthConnectionStatusResponse]: ...
) -> typing.Union[noteflow_pb2.GetOAuthConnectionStatusResponse, collections.abc.Awaitable[noteflow_pb2.GetOAuthConnectionStatusResponse]]: ...
@abc.abstractmethod
def DisconnectOAuth(
self,
request: noteflow_pb2.DisconnectOAuthRequest,
context: _ServicerContext,
) -> noteflow_pb2.DisconnectOAuthResponse | collections.abc.Awaitable[noteflow_pb2.DisconnectOAuthResponse]: ...
) -> typing.Union[noteflow_pb2.DisconnectOAuthResponse, collections.abc.Awaitable[noteflow_pb2.DisconnectOAuthResponse]]: ...
@abc.abstractmethod
def RegisterWebhook(
self,
request: noteflow_pb2.RegisterWebhookRequest,
context: _ServicerContext,
) -> noteflow_pb2.WebhookConfigProto | collections.abc.Awaitable[noteflow_pb2.WebhookConfigProto]:
) -> typing.Union[noteflow_pb2.WebhookConfigProto, collections.abc.Awaitable[noteflow_pb2.WebhookConfigProto]]:
"""Webhook management (Sprint 6)"""
@abc.abstractmethod
@@ -439,35 +555,35 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.ListWebhooksRequest,
context: _ServicerContext,
) -> noteflow_pb2.ListWebhooksResponse | collections.abc.Awaitable[noteflow_pb2.ListWebhooksResponse]: ...
) -> typing.Union[noteflow_pb2.ListWebhooksResponse, collections.abc.Awaitable[noteflow_pb2.ListWebhooksResponse]]: ...
@abc.abstractmethod
def UpdateWebhook(
self,
request: noteflow_pb2.UpdateWebhookRequest,
context: _ServicerContext,
) -> noteflow_pb2.WebhookConfigProto | collections.abc.Awaitable[noteflow_pb2.WebhookConfigProto]: ...
) -> typing.Union[noteflow_pb2.WebhookConfigProto, collections.abc.Awaitable[noteflow_pb2.WebhookConfigProto]]: ...
@abc.abstractmethod
def DeleteWebhook(
self,
request: noteflow_pb2.DeleteWebhookRequest,
context: _ServicerContext,
) -> noteflow_pb2.DeleteWebhookResponse | collections.abc.Awaitable[noteflow_pb2.DeleteWebhookResponse]: ...
) -> typing.Union[noteflow_pb2.DeleteWebhookResponse, collections.abc.Awaitable[noteflow_pb2.DeleteWebhookResponse]]: ...
@abc.abstractmethod
def GetWebhookDeliveries(
self,
request: noteflow_pb2.GetWebhookDeliveriesRequest,
context: _ServicerContext,
) -> noteflow_pb2.GetWebhookDeliveriesResponse | collections.abc.Awaitable[noteflow_pb2.GetWebhookDeliveriesResponse]: ...
) -> typing.Union[noteflow_pb2.GetWebhookDeliveriesResponse, collections.abc.Awaitable[noteflow_pb2.GetWebhookDeliveriesResponse]]: ...
@abc.abstractmethod
def GrantCloudConsent(
self,
request: noteflow_pb2.GrantCloudConsentRequest,
context: _ServicerContext,
) -> noteflow_pb2.GrantCloudConsentResponse | collections.abc.Awaitable[noteflow_pb2.GrantCloudConsentResponse]:
) -> typing.Union[noteflow_pb2.GrantCloudConsentResponse, collections.abc.Awaitable[noteflow_pb2.GrantCloudConsentResponse]]:
"""Cloud consent management (Sprint 7)"""
@abc.abstractmethod
@@ -475,21 +591,50 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.RevokeCloudConsentRequest,
context: _ServicerContext,
) -> noteflow_pb2.RevokeCloudConsentResponse | collections.abc.Awaitable[noteflow_pb2.RevokeCloudConsentResponse]: ...
) -> typing.Union[noteflow_pb2.RevokeCloudConsentResponse, collections.abc.Awaitable[noteflow_pb2.RevokeCloudConsentResponse]]: ...
@abc.abstractmethod
def GetCloudConsentStatus(
self,
request: noteflow_pb2.GetCloudConsentStatusRequest,
context: _ServicerContext,
) -> noteflow_pb2.GetCloudConsentStatusResponse | collections.abc.Awaitable[noteflow_pb2.GetCloudConsentStatusResponse]: ...
) -> typing.Union[noteflow_pb2.GetCloudConsentStatusResponse, collections.abc.Awaitable[noteflow_pb2.GetCloudConsentStatusResponse]]: ...
@abc.abstractmethod
def SetHuggingFaceToken(
self,
request: noteflow_pb2.SetHuggingFaceTokenRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.SetHuggingFaceTokenResponse, collections.abc.Awaitable[noteflow_pb2.SetHuggingFaceTokenResponse]]:
"""HuggingFace Token Management (Sprint 19)"""
@abc.abstractmethod
def GetHuggingFaceTokenStatus(
self,
request: noteflow_pb2.GetHuggingFaceTokenStatusRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.GetHuggingFaceTokenStatusResponse, collections.abc.Awaitable[noteflow_pb2.GetHuggingFaceTokenStatusResponse]]: ...
@abc.abstractmethod
def DeleteHuggingFaceToken(
self,
request: noteflow_pb2.DeleteHuggingFaceTokenRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.DeleteHuggingFaceTokenResponse, collections.abc.Awaitable[noteflow_pb2.DeleteHuggingFaceTokenResponse]]: ...
@abc.abstractmethod
def ValidateHuggingFaceToken(
self,
request: noteflow_pb2.ValidateHuggingFaceTokenRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.ValidateHuggingFaceTokenResponse, collections.abc.Awaitable[noteflow_pb2.ValidateHuggingFaceTokenResponse]]: ...
@abc.abstractmethod
def GetPreferences(
self,
request: noteflow_pb2.GetPreferencesRequest,
context: _ServicerContext,
) -> noteflow_pb2.GetPreferencesResponse | collections.abc.Awaitable[noteflow_pb2.GetPreferencesResponse]:
) -> typing.Union[noteflow_pb2.GetPreferencesResponse, collections.abc.Awaitable[noteflow_pb2.GetPreferencesResponse]]:
"""User preferences sync (Sprint 14)"""
@abc.abstractmethod
@@ -497,14 +642,14 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.SetPreferencesRequest,
context: _ServicerContext,
) -> noteflow_pb2.SetPreferencesResponse | collections.abc.Awaitable[noteflow_pb2.SetPreferencesResponse]: ...
) -> typing.Union[noteflow_pb2.SetPreferencesResponse, collections.abc.Awaitable[noteflow_pb2.SetPreferencesResponse]]: ...
@abc.abstractmethod
def StartIntegrationSync(
self,
request: noteflow_pb2.StartIntegrationSyncRequest,
context: _ServicerContext,
) -> noteflow_pb2.StartIntegrationSyncResponse | collections.abc.Awaitable[noteflow_pb2.StartIntegrationSyncResponse]:
) -> typing.Union[noteflow_pb2.StartIntegrationSyncResponse, collections.abc.Awaitable[noteflow_pb2.StartIntegrationSyncResponse]]:
"""Integration sync orchestration (Sprint 9)"""
@abc.abstractmethod
@@ -512,21 +657,21 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.GetSyncStatusRequest,
context: _ServicerContext,
) -> noteflow_pb2.GetSyncStatusResponse | collections.abc.Awaitable[noteflow_pb2.GetSyncStatusResponse]: ...
) -> typing.Union[noteflow_pb2.GetSyncStatusResponse, collections.abc.Awaitable[noteflow_pb2.GetSyncStatusResponse]]: ...
@abc.abstractmethod
def ListSyncHistory(
self,
request: noteflow_pb2.ListSyncHistoryRequest,
context: _ServicerContext,
) -> noteflow_pb2.ListSyncHistoryResponse | collections.abc.Awaitable[noteflow_pb2.ListSyncHistoryResponse]: ...
) -> typing.Union[noteflow_pb2.ListSyncHistoryResponse, collections.abc.Awaitable[noteflow_pb2.ListSyncHistoryResponse]]: ...
@abc.abstractmethod
def GetUserIntegrations(
self,
request: noteflow_pb2.GetUserIntegrationsRequest,
context: _ServicerContext,
) -> noteflow_pb2.GetUserIntegrationsResponse | collections.abc.Awaitable[noteflow_pb2.GetUserIntegrationsResponse]:
) -> typing.Union[noteflow_pb2.GetUserIntegrationsResponse, collections.abc.Awaitable[noteflow_pb2.GetUserIntegrationsResponse]]:
"""Integration cache validation (Sprint 18.1)"""
@abc.abstractmethod
@@ -534,7 +679,7 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.GetRecentLogsRequest,
context: _ServicerContext,
) -> noteflow_pb2.GetRecentLogsResponse | collections.abc.Awaitable[noteflow_pb2.GetRecentLogsResponse]:
) -> typing.Union[noteflow_pb2.GetRecentLogsResponse, collections.abc.Awaitable[noteflow_pb2.GetRecentLogsResponse]]:
"""Observability (Sprint 9)"""
@abc.abstractmethod
@@ -542,14 +687,14 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.GetPerformanceMetricsRequest,
context: _ServicerContext,
) -> noteflow_pb2.GetPerformanceMetricsResponse | collections.abc.Awaitable[noteflow_pb2.GetPerformanceMetricsResponse]: ...
) -> typing.Union[noteflow_pb2.GetPerformanceMetricsResponse, collections.abc.Awaitable[noteflow_pb2.GetPerformanceMetricsResponse]]: ...
@abc.abstractmethod
def RegisterOidcProvider(
self,
request: noteflow_pb2.RegisterOidcProviderRequest,
context: _ServicerContext,
) -> noteflow_pb2.OidcProviderProto | collections.abc.Awaitable[noteflow_pb2.OidcProviderProto]:
) -> typing.Union[noteflow_pb2.OidcProviderProto, collections.abc.Awaitable[noteflow_pb2.OidcProviderProto]]:
"""OIDC Provider Management (Sprint 17)"""
@abc.abstractmethod
@@ -557,49 +702,49 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.ListOidcProvidersRequest,
context: _ServicerContext,
) -> noteflow_pb2.ListOidcProvidersResponse | collections.abc.Awaitable[noteflow_pb2.ListOidcProvidersResponse]: ...
) -> typing.Union[noteflow_pb2.ListOidcProvidersResponse, collections.abc.Awaitable[noteflow_pb2.ListOidcProvidersResponse]]: ...
@abc.abstractmethod
def GetOidcProvider(
self,
request: noteflow_pb2.GetOidcProviderRequest,
context: _ServicerContext,
) -> noteflow_pb2.OidcProviderProto | collections.abc.Awaitable[noteflow_pb2.OidcProviderProto]: ...
) -> typing.Union[noteflow_pb2.OidcProviderProto, collections.abc.Awaitable[noteflow_pb2.OidcProviderProto]]: ...
@abc.abstractmethod
def UpdateOidcProvider(
self,
request: noteflow_pb2.UpdateOidcProviderRequest,
context: _ServicerContext,
) -> noteflow_pb2.OidcProviderProto | collections.abc.Awaitable[noteflow_pb2.OidcProviderProto]: ...
) -> typing.Union[noteflow_pb2.OidcProviderProto, collections.abc.Awaitable[noteflow_pb2.OidcProviderProto]]: ...
@abc.abstractmethod
def DeleteOidcProvider(
self,
request: noteflow_pb2.DeleteOidcProviderRequest,
context: _ServicerContext,
) -> noteflow_pb2.DeleteOidcProviderResponse | collections.abc.Awaitable[noteflow_pb2.DeleteOidcProviderResponse]: ...
) -> typing.Union[noteflow_pb2.DeleteOidcProviderResponse, collections.abc.Awaitable[noteflow_pb2.DeleteOidcProviderResponse]]: ...
@abc.abstractmethod
def RefreshOidcDiscovery(
self,
request: noteflow_pb2.RefreshOidcDiscoveryRequest,
context: _ServicerContext,
) -> noteflow_pb2.RefreshOidcDiscoveryResponse | collections.abc.Awaitable[noteflow_pb2.RefreshOidcDiscoveryResponse]: ...
) -> typing.Union[noteflow_pb2.RefreshOidcDiscoveryResponse, collections.abc.Awaitable[noteflow_pb2.RefreshOidcDiscoveryResponse]]: ...
@abc.abstractmethod
def ListOidcPresets(
self,
request: noteflow_pb2.ListOidcPresetsRequest,
context: _ServicerContext,
) -> noteflow_pb2.ListOidcPresetsResponse | collections.abc.Awaitable[noteflow_pb2.ListOidcPresetsResponse]: ...
) -> typing.Union[noteflow_pb2.ListOidcPresetsResponse, collections.abc.Awaitable[noteflow_pb2.ListOidcPresetsResponse]]: ...
@abc.abstractmethod
def CreateProject(
self,
request: noteflow_pb2.CreateProjectRequest,
context: _ServicerContext,
) -> noteflow_pb2.ProjectProto | collections.abc.Awaitable[noteflow_pb2.ProjectProto]:
) -> typing.Union[noteflow_pb2.ProjectProto, collections.abc.Awaitable[noteflow_pb2.ProjectProto]]:
"""Project management (Sprint 18)"""
@abc.abstractmethod
@@ -607,56 +752,56 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.GetProjectRequest,
context: _ServicerContext,
) -> noteflow_pb2.ProjectProto | collections.abc.Awaitable[noteflow_pb2.ProjectProto]: ...
) -> typing.Union[noteflow_pb2.ProjectProto, collections.abc.Awaitable[noteflow_pb2.ProjectProto]]: ...
@abc.abstractmethod
def GetProjectBySlug(
self,
request: noteflow_pb2.GetProjectBySlugRequest,
context: _ServicerContext,
) -> noteflow_pb2.ProjectProto | collections.abc.Awaitable[noteflow_pb2.ProjectProto]: ...
) -> typing.Union[noteflow_pb2.ProjectProto, collections.abc.Awaitable[noteflow_pb2.ProjectProto]]: ...
@abc.abstractmethod
def ListProjects(
self,
request: noteflow_pb2.ListProjectsRequest,
context: _ServicerContext,
) -> noteflow_pb2.ListProjectsResponse | collections.abc.Awaitable[noteflow_pb2.ListProjectsResponse]: ...
) -> typing.Union[noteflow_pb2.ListProjectsResponse, collections.abc.Awaitable[noteflow_pb2.ListProjectsResponse]]: ...
@abc.abstractmethod
def UpdateProject(
self,
request: noteflow_pb2.UpdateProjectRequest,
context: _ServicerContext,
) -> noteflow_pb2.ProjectProto | collections.abc.Awaitable[noteflow_pb2.ProjectProto]: ...
) -> typing.Union[noteflow_pb2.ProjectProto, collections.abc.Awaitable[noteflow_pb2.ProjectProto]]: ...
@abc.abstractmethod
def ArchiveProject(
self,
request: noteflow_pb2.ArchiveProjectRequest,
context: _ServicerContext,
) -> noteflow_pb2.ProjectProto | collections.abc.Awaitable[noteflow_pb2.ProjectProto]: ...
) -> typing.Union[noteflow_pb2.ProjectProto, collections.abc.Awaitable[noteflow_pb2.ProjectProto]]: ...
@abc.abstractmethod
def RestoreProject(
self,
request: noteflow_pb2.RestoreProjectRequest,
context: _ServicerContext,
) -> noteflow_pb2.ProjectProto | collections.abc.Awaitable[noteflow_pb2.ProjectProto]: ...
) -> typing.Union[noteflow_pb2.ProjectProto, collections.abc.Awaitable[noteflow_pb2.ProjectProto]]: ...
@abc.abstractmethod
def DeleteProject(
self,
request: noteflow_pb2.DeleteProjectRequest,
context: _ServicerContext,
) -> noteflow_pb2.DeleteProjectResponse | collections.abc.Awaitable[noteflow_pb2.DeleteProjectResponse]: ...
) -> typing.Union[noteflow_pb2.DeleteProjectResponse, collections.abc.Awaitable[noteflow_pb2.DeleteProjectResponse]]: ...
@abc.abstractmethod
def SetActiveProject(
self,
request: noteflow_pb2.SetActiveProjectRequest,
context: _ServicerContext,
) -> noteflow_pb2.SetActiveProjectResponse | collections.abc.Awaitable[noteflow_pb2.SetActiveProjectResponse]:
) -> typing.Union[noteflow_pb2.SetActiveProjectResponse, collections.abc.Awaitable[noteflow_pb2.SetActiveProjectResponse]]:
"""Active project (Sprint 18)"""
@abc.abstractmethod
@@ -664,14 +809,14 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.GetActiveProjectRequest,
context: _ServicerContext,
) -> noteflow_pb2.GetActiveProjectResponse | collections.abc.Awaitable[noteflow_pb2.GetActiveProjectResponse]: ...
) -> typing.Union[noteflow_pb2.GetActiveProjectResponse, collections.abc.Awaitable[noteflow_pb2.GetActiveProjectResponse]]: ...
@abc.abstractmethod
def AddProjectMember(
self,
request: noteflow_pb2.AddProjectMemberRequest,
context: _ServicerContext,
) -> noteflow_pb2.ProjectMembershipProto | collections.abc.Awaitable[noteflow_pb2.ProjectMembershipProto]:
) -> typing.Union[noteflow_pb2.ProjectMembershipProto, collections.abc.Awaitable[noteflow_pb2.ProjectMembershipProto]]:
"""Project membership management (Sprint 18)"""
@abc.abstractmethod
@@ -679,20 +824,56 @@ class NoteFlowServiceServicer(metaclass=abc.ABCMeta):
self,
request: noteflow_pb2.UpdateProjectMemberRoleRequest,
context: _ServicerContext,
) -> noteflow_pb2.ProjectMembershipProto | collections.abc.Awaitable[noteflow_pb2.ProjectMembershipProto]: ...
) -> typing.Union[noteflow_pb2.ProjectMembershipProto, collections.abc.Awaitable[noteflow_pb2.ProjectMembershipProto]]: ...
@abc.abstractmethod
def RemoveProjectMember(
self,
request: noteflow_pb2.RemoveProjectMemberRequest,
context: _ServicerContext,
) -> noteflow_pb2.RemoveProjectMemberResponse | collections.abc.Awaitable[noteflow_pb2.RemoveProjectMemberResponse]: ...
) -> typing.Union[noteflow_pb2.RemoveProjectMemberResponse, collections.abc.Awaitable[noteflow_pb2.RemoveProjectMemberResponse]]: ...
@abc.abstractmethod
def ListProjectMembers(
self,
request: noteflow_pb2.ListProjectMembersRequest,
context: _ServicerContext,
) -> noteflow_pb2.ListProjectMembersResponse | collections.abc.Awaitable[noteflow_pb2.ListProjectMembersResponse]: ...
) -> typing.Union[noteflow_pb2.ListProjectMembersResponse, collections.abc.Awaitable[noteflow_pb2.ListProjectMembersResponse]]: ...
def add_NoteFlowServiceServicer_to_server(servicer: NoteFlowServiceServicer, server: grpc.Server | grpc.aio.Server) -> None: ...
@abc.abstractmethod
def GetCurrentUser(
self,
request: noteflow_pb2.GetCurrentUserRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.GetCurrentUserResponse, collections.abc.Awaitable[noteflow_pb2.GetCurrentUserResponse]]:
"""Identity management (Sprint 16+)"""
@abc.abstractmethod
def ListWorkspaces(
self,
request: noteflow_pb2.ListWorkspacesRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.ListWorkspacesResponse, collections.abc.Awaitable[noteflow_pb2.ListWorkspacesResponse]]: ...
@abc.abstractmethod
def SwitchWorkspace(
self,
request: noteflow_pb2.SwitchWorkspaceRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.SwitchWorkspaceResponse, collections.abc.Awaitable[noteflow_pb2.SwitchWorkspaceResponse]]: ...
@abc.abstractmethod
def GetWorkspaceSettings(
self,
request: noteflow_pb2.GetWorkspaceSettingsRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.WorkspaceSettingsProto, collections.abc.Awaitable[noteflow_pb2.WorkspaceSettingsProto]]: ...
@abc.abstractmethod
def UpdateWorkspaceSettings(
self,
request: noteflow_pb2.UpdateWorkspaceSettingsRequest,
context: _ServicerContext,
) -> typing.Union[noteflow_pb2.WorkspaceSettingsProto, collections.abc.Awaitable[noteflow_pb2.WorkspaceSettingsProto]]: ...
def add_NoteFlowServiceServicer_to_server(servicer: NoteFlowServiceServicer, server: typing.Union[grpc.Server, grpc.aio.Server]) -> None: ...

View File

@@ -23,11 +23,13 @@ from ._config import ServicesConfig
from ._identity_singleton import default_identity_service
from ._mixins import (
AnnotationMixin,
AsrConfigMixin,
CalendarMixin,
DiarizationJobMixin,
DiarizationMixin,
EntitiesMixin,
ExportMixin,
HfTokenMixin,
IdentityMixin,
MeetingMixin,
ObservabilityMixin,
@@ -56,6 +58,8 @@ from .stream_state import MeetingStreamState
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from noteflow.application.services.asr_config_service import AsrConfigService
from noteflow.application.services.hf_token_service import HfTokenService
from noteflow.infrastructure.asr import FasterWhisperEngine
from noteflow.infrastructure.auth.oidc_registry import OidcAuthService
@@ -88,6 +92,8 @@ class NoteFlowServicer(
IdentityMixin,
ProjectMixin,
ProjectMembershipMixin,
AsrConfigMixin,
HfTokenMixin,
NoteFlowServicerStubs,
GrpcBaseServicer,
):
@@ -159,6 +165,13 @@ class NoteFlowServicer(
session_factory: async_sessionmaker[AsyncSession] | None,
) -> None:
"""Initialize audio recording infrastructure."""
from noteflow.application.services.asr_config_service import (
AsrConfigService as AsrConfigServiceImpl,
)
from noteflow.application.services.hf_token_service import (
HfTokenService as HfTokenServiceImpl,
)
self.memory_store: MeetingStore | None = (
MeetingStore() if session_factory is None else None
)
@@ -167,6 +180,26 @@ class NoteFlowServicer(
self.crypto = AesGcmCryptoBox(self._keystore)
self.audio_writers: dict[str, MeetingAudioWriter] = {}
# Initialize ASR configuration service
self.asr_config_service: AsrConfigService | None = (
AsrConfigServiceImpl(
self.asr_engine,
on_engine_update=self._set_asr_engine,
)
if self.asr_engine
else None
)
# Initialize HuggingFace token service (requires UoW factory and crypto)
self.hf_token_service: HfTokenService | None = HfTokenServiceImpl(
uow_factory=self.create_uow,
crypto=self.crypto,
)
def _set_asr_engine(self, engine: FasterWhisperEngine) -> None:
"""Update the active ASR engine reference for streaming."""
self.asr_engine = engine
def _init_streaming_state(self) -> None:
"""Initialize per-meeting streaming state containers."""
self.vad_instances: dict[str, StreamingVad] = {}

View File

@@ -10,7 +10,7 @@ from noteflow.infrastructure.asr.dto import (
VadEventType,
WordTiming,
)
from noteflow.infrastructure.asr.engine import FasterWhisperEngine
from noteflow.infrastructure.asr.engine import FasterWhisperEngine, VALID_MODEL_SIZES
from noteflow.infrastructure.asr.protocols import AsrEngine
from noteflow.infrastructure.asr.segmenter import (
AudioSegment,
@@ -37,6 +37,7 @@ __all__ = [
"SegmenterConfig",
"SegmenterState",
"StreamingVad",
"VALID_MODEL_SIZES",
"VadEngine",
"VadEvent",
"VadEventType",

View File

@@ -0,0 +1,414 @@
"""Unit tests for AsrConfigService.
Tests cover:
- get_capabilities: Returns current ASR configuration and available options
- validate_configuration: Validates model size, device, and compute type
- start_reconfiguration: Starts background reconfiguration job
- get_job_status: Returns status of reconfiguration jobs
"""
from __future__ import annotations
import asyncio
import contextlib
from unittest.mock import MagicMock, patch
from uuid import UUID
import pytest
from noteflow.application.services.asr_config_service import (
AsrComputeType,
AsrConfigJob,
AsrConfigService,
AsrDevice,
)
from noteflow.domain.constants.fields import (
JOB_STATUS_COMPLETED,
JOB_STATUS_FAILED,
)
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def mock_asr_engine() -> MagicMock:
"""Create mock ASR engine for testing."""
engine = MagicMock()
engine.model_size = "base"
engine.device = "cpu"
engine.compute_type = "int8"
engine.is_loaded = True
engine.unload = MagicMock()
engine.load_model = MagicMock()
return engine
@pytest.fixture
def asr_config_service(mock_asr_engine: MagicMock) -> AsrConfigService:
"""Create AsrConfigService with mock engine."""
return AsrConfigService(asr_engine=mock_asr_engine)
@pytest.fixture
def asr_config_service_no_engine() -> AsrConfigService:
"""Create AsrConfigService without engine."""
return AsrConfigService(asr_engine=None)
# =============================================================================
# get_capabilities tests
# =============================================================================
def test_get_capabilities_returns_current_config(
asr_config_service: AsrConfigService,
) -> None:
"""get_capabilities returns current ASR configuration."""
with patch.object(asr_config_service, "detect_cuda_available", return_value=False):
caps = asr_config_service.get_capabilities()
assert caps.model_size == "base", "model_size should be 'base' from engine"
assert caps.device == AsrDevice.CPU, "device should be CPU"
assert caps.compute_type == AsrComputeType.INT8, "compute_type should be INT8"
assert caps.is_ready is True, "is_ready should be True when engine is loaded"
assert caps.cuda_available is False, "cuda_available should be False"
assert "base" in caps.available_model_sizes, "available_model_sizes should include 'base'"
assert AsrComputeType.INT8 in caps.available_compute_types, "INT8 should be available"
def test_get_capabilities_no_engine_returns_defaults(
asr_config_service_no_engine: AsrConfigService,
) -> None:
"""get_capabilities returns defaults when no engine."""
with patch.object(asr_config_service_no_engine, "detect_cuda_available", return_value=False):
caps = asr_config_service_no_engine.get_capabilities()
assert caps.model_size is None, "model_size should be None without engine"
assert caps.device == AsrDevice.CPU, "device should default to CPU"
assert caps.is_ready is False, "is_ready should be False without engine"
def test_get_capabilities_with_cuda_available(
asr_config_service: AsrConfigService,
mock_asr_engine: MagicMock,
) -> None:
"""get_capabilities includes CUDA compute types when available."""
mock_asr_engine.device = "cuda"
with patch.object(asr_config_service, "detect_cuda_available", return_value=True):
caps = asr_config_service.get_capabilities()
assert caps.cuda_available is True, "cuda_available should be True when CUDA detected"
assert caps.device == AsrDevice.CUDA, "device should be CUDA"
assert AsrComputeType.FLOAT16 in caps.available_compute_types, (
"FLOAT16 should be available for CUDA"
)
# =============================================================================
# validate_configuration tests
# =============================================================================
def test_validate_configuration_valid_cpu_config(
asr_config_service: AsrConfigService,
) -> None:
"""validate_configuration accepts valid CPU configuration."""
with patch.object(asr_config_service, "detect_cuda_available", return_value=False):
error = asr_config_service.validate_configuration(
model_size="small",
device=AsrDevice.CPU,
compute_type=AsrComputeType.INT8,
)
assert error is None, "valid CPU configuration should not return an error"
def test_validate_configuration_invalid_model_size(
asr_config_service: AsrConfigService,
) -> None:
"""validate_configuration rejects invalid model size."""
error = asr_config_service.validate_configuration(
model_size="invalid-model",
device=None,
compute_type=None,
)
assert error is not None, "error should be set for invalid model"
assert "Invalid model size" in error, "error should mention invalid model size"
def test_validate_configuration_cuda_unavailable(
asr_config_service: AsrConfigService,
) -> None:
"""validate_configuration rejects CUDA when unavailable."""
with patch.object(asr_config_service, "detect_cuda_available", return_value=False):
error = asr_config_service.validate_configuration(
model_size=None,
device=AsrDevice.CUDA,
compute_type=None,
)
assert error is not None, "error should be set when CUDA unavailable"
assert "CUDA" in error, "error should mention CUDA"
def test_validate_configuration_invalid_compute_for_device(
asr_config_service: AsrConfigService,
) -> None:
"""validate_configuration rejects invalid compute type for device."""
error = asr_config_service.validate_configuration(
model_size=None,
device=AsrDevice.CPU,
compute_type=AsrComputeType.FLOAT16, # FLOAT16 not available for CPU
)
assert error is not None, "error should be set for invalid compute type"
assert "not available" in error, "error should mention unavailability"
def test_validate_configuration_none_values_accepted(
asr_config_service: AsrConfigService,
) -> None:
"""validate_configuration accepts None values (keep current)."""
error = asr_config_service.validate_configuration(
model_size=None,
device=None,
compute_type=None,
)
assert error is None, "None values should be accepted for validation"
# =============================================================================
# start_reconfiguration tests
# =============================================================================
@pytest.mark.asyncio
async def test_start_reconfiguration_returns_job_id(
asr_config_service: AsrConfigService,
) -> None:
"""start_reconfiguration returns job ID on success."""
with patch.object(asr_config_service, "detect_cuda_available", return_value=False):
job_id, error = await asr_config_service.start_reconfiguration(
model_size="small",
device=None,
compute_type=None,
has_active_recordings=False,
)
assert job_id is not None, "job_id should be returned on success"
assert isinstance(job_id, UUID), "job_id should be a UUID"
assert error is None, "error should be None on success"
# Clean up background task
job = asr_config_service.get_job_status(job_id)
assert job is not None, "job should exist after start_reconfiguration"
assert job.task is not None, "job task should be created"
job.task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await job.task
@pytest.mark.asyncio
async def test_start_reconfiguration_blocked_during_recording(
asr_config_service: AsrConfigService,
) -> None:
"""start_reconfiguration is blocked while recordings are active."""
job_id, error = await asr_config_service.start_reconfiguration(
model_size="small",
device=None,
compute_type=None,
has_active_recordings=True,
)
assert job_id is None, "job_id should be None when blocked"
assert error is not None, "error should be set when recordings active"
assert "recordings are active" in error, "error should explain why blocked"
@pytest.mark.asyncio
async def test_start_reconfiguration_no_engine(
asr_config_service_no_engine: AsrConfigService,
) -> None:
"""start_reconfiguration fails when no engine available."""
job_id, error = await asr_config_service_no_engine.start_reconfiguration(
model_size="small",
device=None,
compute_type=None,
has_active_recordings=False,
)
assert job_id is None, "job_id should be None without engine"
assert error is not None, "error should be set without engine"
assert "not available" in error, "error should explain unavailability"
@pytest.mark.asyncio
async def test_start_reconfiguration_validation_failure(
asr_config_service: AsrConfigService,
) -> None:
"""start_reconfiguration fails on invalid configuration."""
with patch.object(asr_config_service, "detect_cuda_available", return_value=False):
job_id, error = await asr_config_service.start_reconfiguration(
model_size="invalid-model",
device=None,
compute_type=None,
has_active_recordings=False,
)
assert job_id is None, "job_id should be None on validation failure"
assert error is not None, "error should be set on validation failure"
assert "Invalid model size" in error, "error should mention invalid model"
# =============================================================================
# get_job_status tests
# =============================================================================
@pytest.mark.asyncio
async def test_get_job_status_returns_job(
asr_config_service: AsrConfigService,
) -> None:
"""get_job_status returns job info for valid ID."""
with patch.object(asr_config_service, "detect_cuda_available", return_value=False):
job_id, _ = await asr_config_service.start_reconfiguration(
model_size="small",
device=None,
compute_type=None,
has_active_recordings=False,
)
assert job_id is not None, "job_id should be returned"
job = asr_config_service.get_job_status(job_id)
assert job is not None, "job should be found for valid ID"
assert isinstance(job, AsrConfigJob), "job should be AsrConfigJob type"
assert job.target_model_size == "small", "target_model_size should match request"
# Clean up
assert job.task is not None, "job task should be created"
job.task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await job.task
def test_get_job_status_returns_none_for_unknown_id(
asr_config_service: AsrConfigService,
) -> None:
"""get_job_status returns None for unknown job ID."""
from uuid import uuid4
job = asr_config_service.get_job_status(uuid4())
assert job is None, "job should be None for unknown ID"
# =============================================================================
# detect_cuda_available tests
# =============================================================================
def test_detect_cuda_available_with_cuda(
asr_config_service: AsrConfigService,
) -> None:
"""detect_cuda_available returns True when CUDA is available."""
mock_torch = MagicMock()
mock_torch.cuda.is_available.return_value = True
with patch.dict("sys.modules", {"torch": mock_torch}):
result = asr_config_service.detect_cuda_available()
assert result is True, "detect_cuda_available should return True when CUDA available"
def test_detect_cuda_available_no_cuda(
asr_config_service: AsrConfigService,
) -> None:
"""detect_cuda_available returns False when CUDA is not available."""
mock_torch = MagicMock()
mock_torch.cuda.is_available.return_value = False
with patch.dict("sys.modules", {"torch": mock_torch}):
result = asr_config_service.detect_cuda_available()
assert result is False, "detect_cuda_available should return False when CUDA unavailable"
# =============================================================================
# reconfiguration behavior tests
# =============================================================================
@pytest.mark.asyncio
async def test_reconfiguration_failure_keeps_active_engine(
mock_asr_engine: MagicMock,
) -> None:
"""Reconfiguration failure should not replace or unload the active engine."""
updates: list[MagicMock] = []
asr_config_service = AsrConfigService(
asr_engine=mock_asr_engine,
on_engine_update=updates.append,
)
new_engine = MagicMock()
with (
patch.object(asr_config_service, "_build_engine_for_job", return_value=(new_engine, True)),
patch.object(asr_config_service, "_load_model", side_effect=RuntimeError("boom")),
):
job_id, error = await asr_config_service.start_reconfiguration(
model_size="small",
device=None,
compute_type=None,
has_active_recordings=False,
)
assert job_id is not None, "job should be created even if load fails"
assert error is None, "start_reconfiguration should not return an error on async failure"
job = asr_config_service.get_job_status(job_id)
assert job is not None, "job should be retrievable"
assert job.task is not None, "job task should be created"
await job.task
assert job.status == JOB_STATUS_FAILED, "job should be marked failed on load error"
mock_asr_engine.unload.assert_not_called()
assert updates == [], "engine update callback should not fire on failure"
@pytest.mark.asyncio
async def test_reconfiguration_success_swaps_engine(
mock_asr_engine: MagicMock,
) -> None:
"""Successful reconfiguration should swap engine and unload the old one."""
updates: list[MagicMock] = []
asr_config_service = AsrConfigService(
asr_engine=mock_asr_engine,
on_engine_update=updates.append,
)
new_engine = MagicMock()
with (
patch.object(asr_config_service, "_build_engine_for_job", return_value=(new_engine, True)),
patch.object(asr_config_service, "_load_model", return_value=None),
):
job_id, error = await asr_config_service.start_reconfiguration(
model_size="small",
device=None,
compute_type=None,
has_active_recordings=False,
)
assert job_id is not None, "job should be created"
assert error is None, "no error should be returned for successful start"
job = asr_config_service.get_job_status(job_id)
assert job is not None, "job should be retrievable"
assert job.task is not None, "job task should be created"
await job.task
assert job.status == JOB_STATUS_COMPLETED, "job should complete successfully"
mock_asr_engine.unload.assert_called_once()
assert updates == [new_engine], "engine update callback should fire on success"

View File

@@ -0,0 +1,546 @@
"""Unit tests for HfTokenService.
Tests cover:
- set_token: Store and optionally validate a HuggingFace token
- get_status: Get current token configuration status
- delete_token: Remove stored token
- validate_stored_token: Validate the stored token against HuggingFace API
- get_token: Retrieve decrypted token
- Encryption/decryption roundtrip
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from noteflow.application.services.hf_token_service import (
HfTokenService,
HfValidationResult,
)
# =============================================================================
# Constants
# =============================================================================
# Sample timestamp for validation tests (2023-11-15 00:00:00 UTC)
SAMPLE_VALIDATION_TIMESTAMP = 1700000000.0
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def mock_crypto() -> MagicMock:
"""Create mock crypto box for testing."""
crypto = MagicMock()
# Mock DEK generation and wrapping
crypto.generate_dek.return_value = b"mock_dek_16bytes"
crypto.wrap_dek.return_value = b"wrapped_dek_bytes"
crypto.unwrap_dek.return_value = b"mock_dek_16bytes"
# Mock encryption
mock_encrypted = MagicMock()
mock_encrypted.nonce = b"nonce_12_bytes"
mock_encrypted.ciphertext = b"encrypted_token_data"
mock_encrypted.tag = b"tag_16_bytes_xx"
crypto.encrypt_chunk.return_value = mock_encrypted
# Mock decryption
crypto.decrypt_chunk.return_value = b"hf_test_token_value"
return crypto
@pytest.fixture
def mock_preferences() -> MagicMock:
"""Create mock preferences repository."""
prefs = MagicMock()
prefs.get = AsyncMock(return_value=None)
prefs.set = AsyncMock()
prefs.delete = AsyncMock(return_value=True)
return prefs
@pytest.fixture
def prefs_mock_uow(mock_preferences: MagicMock) -> MagicMock:
"""Create mock unit of work with preferences support."""
uow = MagicMock()
uow.supports_preferences = True
uow.preferences = mock_preferences
uow.commit = AsyncMock()
uow.__aenter__ = AsyncMock(return_value=uow)
uow.__aexit__ = AsyncMock(return_value=None)
return uow
@pytest.fixture
def hf_token_service(prefs_mock_uow: MagicMock, mock_crypto: MagicMock) -> HfTokenService:
"""Create HfTokenService with mocks."""
def uow_factory() -> MagicMock:
return prefs_mock_uow
return HfTokenService(uow_factory=uow_factory, crypto=mock_crypto)
@pytest.fixture
def hf_token_service_no_prefs(mock_crypto: MagicMock) -> HfTokenService:
"""Create HfTokenService with UoW that doesn't support preferences."""
uow = MagicMock()
uow.supports_preferences = False
uow.__aenter__ = AsyncMock(return_value=uow)
uow.__aexit__ = AsyncMock(return_value=None)
def uow_factory() -> MagicMock:
return uow
return HfTokenService(uow_factory=uow_factory, crypto=mock_crypto)
# =============================================================================
# set_token tests
# =============================================================================
@pytest.mark.asyncio
async def test_set_token_without_validation(
hf_token_service: HfTokenService,
prefs_mock_uow: MagicMock,
) -> None:
"""set_token stores token without validation when validate=False."""
success, result = await hf_token_service.set_token(
"hf_test_token",
validate=False,
)
assert success is True, "set_token should succeed"
assert result is None, "result should be None when validation skipped"
prefs_mock_uow.preferences.set.assert_called()
prefs_mock_uow.commit.assert_called_once()
@pytest.mark.asyncio
async def test_set_token_with_successful_validation(
hf_token_service: HfTokenService,
prefs_mock_uow: MagicMock,
) -> None:
"""set_token validates and stores token when validate=True."""
with patch.object(
hf_token_service,
"validate_token_internal",
return_value=HfValidationResult(
valid=True,
username="testuser",
error_message="",
),
):
success, result = await hf_token_service.set_token(
"hf_valid_token",
validate=True,
)
assert success is True, "set_token should succeed with valid token"
assert result is not None, "result should contain validation info"
assert result.valid is True, "validation should pass"
assert result.username == "testuser", "username should match"
prefs_mock_uow.commit.assert_called_once()
@pytest.mark.asyncio
async def test_set_token_with_failed_validation(
hf_token_service: HfTokenService,
prefs_mock_uow: MagicMock,
) -> None:
"""set_token returns failure when validation fails."""
with patch.object(
hf_token_service,
"validate_token_internal",
return_value=HfValidationResult(
valid=False,
username="",
error_message="Invalid or expired token",
),
):
success, result = await hf_token_service.set_token(
"hf_invalid_token",
validate=True,
)
assert success is False, "set_token should fail with invalid token"
assert result is not None, "result should contain validation info"
assert result.valid is False, "validation should fail"
assert "Invalid" in result.error_message, "error should explain failure"
prefs_mock_uow.commit.assert_not_called()
@pytest.mark.asyncio
async def test_set_token_no_preferences_support(
hf_token_service_no_prefs: HfTokenService,
) -> None:
"""set_token returns failure when preferences storage is unavailable."""
success, result = await hf_token_service_no_prefs.set_token(
"hf_test_token",
validate=False,
)
assert success is False, "set_token should fail without preferences support"
assert result is not None, "result should describe storage failure"
assert "not available" in result.error_message, "error should mention storage unavailability"
# =============================================================================
# get_status tests
# =============================================================================
@pytest.mark.asyncio
async def test_get_status_no_token_configured(
hf_token_service: HfTokenService,
mock_preferences: MagicMock,
) -> None:
"""get_status returns not configured when no token stored."""
mock_preferences.get.return_value = None
status = await hf_token_service.get_status()
assert status.is_configured is False, "is_configured should be False"
assert status.is_validated is False, "is_validated should be False"
assert status.username == "", "username should be empty"
assert status.validated_at is None, "validated_at should be None"
@pytest.mark.asyncio
async def test_get_status_token_configured_with_metadata(
hf_token_service: HfTokenService,
mock_preferences: MagicMock,
) -> None:
"""get_status returns correct status when token is configured."""
mock_preferences.get.side_effect = [
{"wrapped_dek": "abc", "nonce": "def", "ciphertext": "ghi", "tag": "jkl"},
{"username": "testuser", "is_validated": True, "validated_at": SAMPLE_VALIDATION_TIMESTAMP},
]
status = await hf_token_service.get_status()
assert status.is_configured is True, "is_configured should be True"
assert status.is_validated is True, "is_validated should be True"
assert status.username == "testuser", "username should match stored value"
assert status.validated_at == SAMPLE_VALIDATION_TIMESTAMP, (
"validated_at should match stored value"
)
@pytest.mark.asyncio
async def test_get_status_no_preferences_support(
hf_token_service_no_prefs: HfTokenService,
) -> None:
"""get_status returns not configured when preferences not supported."""
status = await hf_token_service_no_prefs.get_status()
assert status.is_configured is False, "is_configured should be False without prefs"
assert status.is_validated is False, "is_validated should be False without prefs"
# =============================================================================
# delete_token tests
# =============================================================================
@pytest.mark.asyncio
async def test_delete_token_success(
hf_token_service: HfTokenService,
mock_preferences: MagicMock,
prefs_mock_uow: MagicMock,
) -> None:
"""delete_token removes token and returns True."""
mock_preferences.delete.return_value = True
result = await hf_token_service.delete_token()
assert result is True, "delete_token should return True when token deleted"
prefs_mock_uow.commit.assert_called_once()
@pytest.mark.asyncio
async def test_delete_token_not_found(
hf_token_service: HfTokenService,
mock_preferences: MagicMock,
prefs_mock_uow: MagicMock,
) -> None:
"""delete_token returns False when no token exists."""
mock_preferences.delete.return_value = False
result = await hf_token_service.delete_token()
assert result is False, "delete_token should return False when token missing"
prefs_mock_uow.commit.assert_called_once()
@pytest.mark.asyncio
async def test_delete_token_no_preferences_support(
hf_token_service_no_prefs: HfTokenService,
) -> None:
"""delete_token returns False when preferences not supported."""
result = await hf_token_service_no_prefs.delete_token()
assert result is False, "delete_token should return False without prefs support"
# =============================================================================
# validate_stored_token tests
# =============================================================================
@pytest.mark.asyncio
async def test_validate_stored_token_success(
hf_token_service: HfTokenService,
mock_preferences: MagicMock,
prefs_mock_uow: MagicMock,
) -> None:
"""validate_stored_token validates and updates metadata."""
mock_preferences.get.return_value = {
"wrapped_dek": "abc",
"nonce": "def",
"ciphertext": "ghi",
"tag": "jkl",
}
with (
patch.object(
hf_token_service,
"decrypt_token",
return_value="hf_test_token",
),
patch.object(
hf_token_service,
"validate_token_internal",
return_value=HfValidationResult(
valid=True,
username="validuser",
error_message="",
),
),
):
result = await hf_token_service.validate_stored_token()
assert result.valid is True, "validation should succeed"
assert result.username == "validuser", "username should match validation result"
mock_preferences.set.assert_called_once()
prefs_mock_uow.commit.assert_called_once()
@pytest.mark.asyncio
async def test_validate_stored_token_no_token(
hf_token_service: HfTokenService,
mock_preferences: MagicMock,
) -> None:
"""validate_stored_token returns error when no token configured."""
mock_preferences.get.return_value = None
result = await hf_token_service.validate_stored_token()
assert result.valid is False, "validation should fail when no token"
assert "No token configured" in result.error_message, "error should explain missing token"
@pytest.mark.asyncio
async def test_validate_stored_token_no_preferences_support(
hf_token_service_no_prefs: HfTokenService,
) -> None:
"""validate_stored_token returns error when preferences not supported."""
result = await hf_token_service_no_prefs.validate_stored_token()
assert result.valid is False, "validation should fail without prefs"
assert "not available" in result.error_message, "error should explain unavailability"
@pytest.mark.asyncio
async def test_validate_stored_token_decrypt_failure(
hf_token_service: HfTokenService,
mock_preferences: MagicMock,
prefs_mock_uow: MagicMock,
) -> None:
"""validate_stored_token returns error when decryption fails."""
mock_preferences.get.return_value = {
"wrapped_dek": "abc",
"nonce": "def",
"ciphertext": "ghi",
"tag": "jkl",
}
with patch.object(
hf_token_service,
"decrypt_token",
side_effect=ValueError("bad token"),
):
result = await hf_token_service.validate_stored_token()
assert result.valid is False, "validation should fail when decryption fails"
assert "decryption" in result.error_message.lower(), (
"error should mention decryption failure"
)
mock_preferences.set.assert_not_called()
prefs_mock_uow.commit.assert_not_called()
# =============================================================================
# get_token tests
# =============================================================================
@pytest.mark.asyncio
async def test_get_token_returns_decrypted(
hf_token_service: HfTokenService,
mock_preferences: MagicMock,
) -> None:
"""get_token returns decrypted token when configured."""
mock_preferences.get.return_value = {
"wrapped_dek": "abc",
"nonce": "def",
"ciphertext": "ghi",
"tag": "jkl",
}
with patch.object(
hf_token_service,
"decrypt_token",
return_value="hf_test_token_value",
):
token = await hf_token_service.get_token()
assert token == "hf_test_token_value", "get_token should return decrypted token"
@pytest.mark.asyncio
async def test_get_token_returns_none_when_not_configured(
hf_token_service: HfTokenService,
mock_preferences: MagicMock,
) -> None:
"""get_token returns None when no token configured."""
mock_preferences.get.return_value = None
token = await hf_token_service.get_token()
assert token is None, "get_token should return None when not configured"
@pytest.mark.asyncio
async def test_get_token_no_preferences_support(
hf_token_service_no_prefs: HfTokenService,
) -> None:
"""get_token returns None when preferences not supported."""
token = await hf_token_service_no_prefs.get_token()
assert token is None, "get_token should return None without prefs support"
# =============================================================================
# Encryption roundtrip tests
# =============================================================================
def test_encrypt_token_returns_expected_keys(
hf_token_service: HfTokenService,
) -> None:
"""encrypt_token returns dict with required keys."""
result = hf_token_service.encrypt_token("test_token")
assert "wrapped_dek" in result, "wrapped_dek should be present in encrypted output"
assert "nonce" in result, "nonce should be present in encrypted output"
assert "ciphertext" in result, "ciphertext should be present in encrypted output"
assert "tag" in result, "tag should be present in encrypted output"
def test_decrypt_token_invalid_format(
hf_token_service: HfTokenService,
) -> None:
"""decrypt_token raises ValueError for invalid format."""
with pytest.raises(ValueError, match="Invalid encrypted data format"):
hf_token_service.decrypt_token("not_a_dict")
def test_decrypt_token_missing_keys(
hf_token_service: HfTokenService,
) -> None:
"""decrypt_token raises ValueError for missing keys."""
with pytest.raises(ValueError, match="Invalid encrypted data"):
hf_token_service.decrypt_token({"wrapped_dek": "abc"})
# =============================================================================
# validate_token_internal tests (mocked HTTP)
# =============================================================================
@pytest.mark.asyncio
async def test_validate_token_internal_success(
hf_token_service: HfTokenService,
) -> None:
"""validate_token_internal returns success for valid token."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"name": "apiuser"}
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
result = await hf_token_service.validate_token_internal("hf_valid")
assert result.valid is True, "token should be valid"
assert result.username == "apiuser", "username should match API response"
@pytest.mark.asyncio
async def test_validate_token_internal_unauthorized(
hf_token_service: HfTokenService,
) -> None:
"""validate_token_internal returns failure for 401 response."""
mock_response = MagicMock()
mock_response.status_code = 401
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
result = await hf_token_service.validate_token_internal("hf_invalid")
assert result.valid is False, "token should be invalid for 401"
assert "Invalid or expired" in result.error_message, "error should mention invalid token"
@pytest.mark.asyncio
async def test_validate_token_internal_timeout(
hf_token_service: HfTokenService,
) -> None:
"""validate_token_internal handles timeout gracefully."""
import httpx
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
side_effect=httpx.TimeoutException("timeout")
)
result = await hf_token_service.validate_token_internal("hf_token")
assert result.valid is False, "timeout should return invalid result"
assert "timeout" in result.error_message.lower(), "error should mention timeout"
@pytest.mark.asyncio
async def test_validate_token_internal_connection_error(
hf_token_service: HfTokenService,
) -> None:
"""validate_token_internal handles connection error gracefully."""
import httpx
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
side_effect=httpx.RequestError("connection failed")
)
result = await hf_token_service.validate_token_internal("hf_token")
assert result.valid is False, "connection error should return invalid result"
assert "Connection error" in result.error_message, "error should mention connection error"

View File

@@ -0,0 +1,225 @@
"""Integration tests for ASR configuration gRPC endpoints."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Final
from unittest.mock import MagicMock, patch
from uuid import UUID
import pytest
from noteflow.grpc.proto import noteflow_pb2
from noteflow.grpc.service import NoteFlowServicer
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
# ============================================================================
# Test Constants
# ============================================================================
MODEL_BASE: Final[str] = "base"
MODEL_SMALL: Final[str] = "small"
DEVICE_CPU: Final[str] = "cpu"
COMPUTE_INT8: Final[str] = "int8"
INVALID_JOB_ID: Final[str] = "not-a-uuid"
ACTIVE_STREAM_ID: Final[str] = "meeting-active"
ERROR_ACTIVE_RECORDINGS: Final[str] = "Cannot reconfigure ASR while recordings are active"
ERROR_INVALID_JOB_ID: Final[str] = "Invalid job ID format"
PHASE_FAILED: Final[str] = "failed"
EMPTY_JOB_ID: Final[str] = ""
@pytest.fixture
def asr_engine_mock() -> MagicMock:
"""Create mock ASR engine with CPU defaults."""
engine = MagicMock()
engine.model_size = MODEL_BASE
engine.device = DEVICE_CPU
engine.compute_type = COMPUTE_INT8
engine.is_loaded = True
engine.load_model = MagicMock()
engine.unload = MagicMock()
return engine
@pytest.fixture
async def asr_servicer(
session_factory: async_sessionmaker[AsyncSession],
meetings_dir: Path,
asr_engine_mock: MagicMock,
) -> NoteFlowServicer:
"""Create servicer with ASR engine and database backing."""
return NoteFlowServicer(
asr_engine=asr_engine_mock,
session_factory=session_factory,
meetings_dir=meetings_dir,
)
async def _run_asr_update_and_wait(
servicer: NoteFlowServicer,
model_size: str,
context: MagicMock,
) -> str:
"""Start an ASR update job and wait for completion."""
service = servicer.asr_config_service
assert service is not None, "asr_config_service should be initialized"
async def _load_model_stub(engine: MagicMock, size: str) -> None:
engine.model_size = size
with patch.object(service, "_load_model", new=_load_model_stub):
response = await servicer.UpdateAsrConfiguration(
noteflow_pb2.UpdateAsrConfigurationRequest(model_size=model_size),
context,
)
assert response.accepted is True, "update should be accepted"
assert (
response.status == noteflow_pb2.JOB_STATUS_QUEUED
), "initial job status should be QUEUED"
assert response.job_id, "job_id should be returned for accepted request"
job = service.get_job_status(UUID(response.job_id))
assert job is not None, "job should be retrievable by id"
assert job.task is not None, "job task should be created"
await job.task
return response.job_id
@pytest.mark.integration
class TestAsrConfigGrpc:
"""Integration tests for ASR config gRPC handlers."""
async def test_get_asr_configuration_returns_engine_state(
self,
asr_servicer: NoteFlowServicer,
mock_grpc_context: MagicMock,
) -> None:
"""GetAsrConfiguration reflects current engine configuration."""
service = asr_servicer.asr_config_service
assert service is not None, "asr_config_service should be initialized"
with patch.object(service, "detect_cuda_available", return_value=False):
response = await asr_servicer.GetAsrConfiguration(
noteflow_pb2.GetAsrConfigurationRequest(),
mock_grpc_context,
)
config = response.configuration
assert config.model_size == MODEL_BASE, "model_size should match engine"
assert (
config.device == noteflow_pb2.ASR_DEVICE_CPU
), "device should map to ASR_DEVICE_CPU"
assert (
config.compute_type == noteflow_pb2.ASR_COMPUTE_TYPE_INT8
), "compute_type should map to ASR_COMPUTE_TYPE_INT8"
assert config.is_ready is True, "is_ready should reflect engine readiness"
assert config.cuda_available is False, "cuda_available should reflect detection"
assert (
MODEL_BASE in config.available_model_sizes
), "available_model_sizes should include current model"
assert (
noteflow_pb2.ASR_COMPUTE_TYPE_INT8 in config.available_compute_types
), "available_compute_types should include INT8"
async def test_update_asr_configuration_completes_job_and_returns_new_config(
self,
asr_servicer: NoteFlowServicer,
mock_grpc_context: MagicMock,
) -> None:
"""UpdateAsrConfiguration starts job and returns new configuration on completion."""
job_id = await _run_asr_update_and_wait(
asr_servicer,
MODEL_SMALL,
mock_grpc_context,
)
status_response = await asr_servicer.GetAsrConfigurationJobStatus(
noteflow_pb2.GetAsrConfigurationJobStatusRequest(job_id=job_id),
mock_grpc_context,
)
assert (
status_response.status == noteflow_pb2.JOB_STATUS_COMPLETED
), "job status should be COMPLETED after reload"
assert status_response.HasField("new_configuration"), (
"new_configuration should be returned on completion"
)
assert (
status_response.new_configuration.model_size == MODEL_SMALL
), "new_configuration should reflect updated model size"
async def test_update_asr_configuration_rejects_when_active_recordings(
self,
asr_servicer: NoteFlowServicer,
mock_grpc_context: MagicMock,
) -> None:
"""UpdateAsrConfiguration rejects while recordings are active."""
asr_servicer.active_streams = {ACTIVE_STREAM_ID}
response = await asr_servicer.UpdateAsrConfiguration(
noteflow_pb2.UpdateAsrConfigurationRequest(model_size=MODEL_SMALL),
mock_grpc_context,
)
assert response.accepted is False, "update should be rejected when active"
assert (
response.status == noteflow_pb2.JOB_STATUS_FAILED
), "status should be FAILED when rejected"
assert response.error_message == ERROR_ACTIVE_RECORDINGS, (
"error_message should explain active recording rejection"
)
assert response.job_id == EMPTY_JOB_ID, "job_id should be empty on rejection"
async def test_get_asr_configuration_job_status_invalid_id(
self,
asr_servicer: NoteFlowServicer,
mock_grpc_context: MagicMock,
) -> None:
"""GetAsrConfigurationJobStatus returns failure for invalid job ID."""
response = await asr_servicer.GetAsrConfigurationJobStatus(
noteflow_pb2.GetAsrConfigurationJobStatusRequest(job_id=INVALID_JOB_ID),
mock_grpc_context,
)
assert (
response.status == noteflow_pb2.JOB_STATUS_FAILED
), "invalid job id should produce FAILED status"
assert response.phase == PHASE_FAILED, "phase should be failed for invalid job id"
assert response.error_message == ERROR_INVALID_JOB_ID, (
"error_message should explain invalid job id format"
)
async def test_get_asr_configuration_when_service_unavailable(
self,
session_factory: async_sessionmaker[AsyncSession],
meetings_dir: Path,
mock_grpc_context: MagicMock,
) -> None:
"""GetAsrConfiguration returns empty configuration when ASR is unavailable."""
servicer = NoteFlowServicer(session_factory=session_factory, meetings_dir=meetings_dir)
response = await servicer.GetAsrConfiguration(
noteflow_pb2.GetAsrConfigurationRequest(),
mock_grpc_context,
)
config = response.configuration
assert config.model_size == "", "model_size should be empty when ASR unavailable"
assert (
config.device == noteflow_pb2.ASR_DEVICE_UNSPECIFIED
), "device should be unspecified when ASR unavailable"
assert (
config.compute_type == noteflow_pb2.ASR_COMPUTE_TYPE_UNSPECIFIED
), "compute_type should be unspecified when ASR unavailable"
assert config.is_ready is False, "is_ready should be False when ASR unavailable"
assert config.cuda_available is False, "cuda_available should be False by default"
assert (
config.available_model_sizes == []
), "available_model_sizes should be empty when ASR unavailable"
assert (
config.available_compute_types == []
), "available_compute_types should be empty when ASR unavailable"

View File

@@ -0,0 +1,259 @@
"""Integration tests for HuggingFace token gRPC endpoints."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Final
from unittest.mock import MagicMock, patch
import pytest
from noteflow.application.services.hf_token_service import HfTokenService, HfValidationResult
from noteflow.grpc.proto import noteflow_pb2
from noteflow.grpc.service import NoteFlowServicer
from noteflow.infrastructure.security.crypto import AesGcmCryptoBox
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
# ============================================================================
# Test Constants
# ============================================================================
TOKEN_VALUE: Final[str] = "hf_test_token"
TOKEN_VALUE_SECOND: Final[str] = "hf_second_token"
USERNAME_PRIMARY: Final[str] = "primary-user"
USERNAME_VALIDATED: Final[str] = "validated-user"
EMPTY_TOKEN: Final[str] = ""
EMPTY_USERNAME: Final[str] = ""
ERROR_EMPTY_TOKEN: Final[str] = "Token cannot be empty"
ZERO_FLOAT: Final[float] = 0.0
@pytest.fixture
async def hf_token_servicer(
session_factory: async_sessionmaker[AsyncSession],
meetings_dir: Path,
crypto: AesGcmCryptoBox,
) -> NoteFlowServicer:
"""Create servicer with HuggingFace token service using in-memory crypto."""
servicer = NoteFlowServicer(session_factory=session_factory, meetings_dir=meetings_dir)
servicer.crypto = crypto
servicer.hf_token_service = HfTokenService(uow_factory=servicer.create_uow, crypto=crypto)
return servicer
async def _set_hf_token(
servicer: NoteFlowServicer,
token: str,
validate: bool,
context: MagicMock,
) -> noteflow_pb2.SetHuggingFaceTokenResponse:
"""Invoke SetHuggingFaceToken for test helpers."""
return await servicer.SetHuggingFaceToken(
noteflow_pb2.SetHuggingFaceTokenRequest(token=token, validate=validate),
context,
)
async def _get_hf_status(
servicer: NoteFlowServicer,
context: MagicMock,
) -> noteflow_pb2.GetHuggingFaceTokenStatusResponse:
"""Invoke GetHuggingFaceTokenStatus for test helpers."""
return await servicer.GetHuggingFaceTokenStatus(
noteflow_pb2.GetHuggingFaceTokenStatusRequest(),
context,
)
async def _validate_hf_token(
servicer: NoteFlowServicer,
context: MagicMock,
) -> noteflow_pb2.ValidateHuggingFaceTokenResponse:
"""Invoke ValidateHuggingFaceToken for test helpers."""
return await servicer.ValidateHuggingFaceToken(
noteflow_pb2.ValidateHuggingFaceTokenRequest(),
context,
)
async def _delete_hf_token(
servicer: NoteFlowServicer,
context: MagicMock,
) -> noteflow_pb2.DeleteHuggingFaceTokenResponse:
"""Invoke DeleteHuggingFaceToken for test helpers."""
return await servicer.DeleteHuggingFaceToken(
noteflow_pb2.DeleteHuggingFaceTokenRequest(),
context,
)
async def _set_unvalidated_token_and_assert(
servicer: NoteFlowServicer,
context: MagicMock,
) -> None:
"""Set a token without validation and assert initial status."""
set_response = await _set_hf_token(
servicer,
TOKEN_VALUE_SECOND,
False,
context,
)
assert set_response.success is True, "set token without validation should succeed"
status_before = await _get_hf_status(servicer, context)
assert status_before.is_configured is True, "token should be configured after set"
assert status_before.is_validated is False, "token should be unvalidated before validate"
assert status_before.username == EMPTY_USERNAME, "username should be empty before validate"
assert status_before.validated_at == ZERO_FLOAT, "validated_at should be unset before validate"
@pytest.mark.integration
class TestHuggingFaceTokenGrpc:
"""Integration tests for HuggingFace token gRPC handlers."""
async def test_set_token_returns_validated_response(
self,
hf_token_servicer: NoteFlowServicer,
mock_grpc_context: MagicMock,
) -> None:
"""SetHuggingFaceToken returns validated response when validation succeeds."""
service = hf_token_servicer.hf_token_service
assert service is not None, "hf_token_service should be initialized"
with patch.object(
service,
"validate_token_internal",
return_value=HfValidationResult(
valid=True,
username=USERNAME_PRIMARY,
error_message="",
),
):
response = await _set_hf_token(
hf_token_servicer,
TOKEN_VALUE,
True,
mock_grpc_context,
)
assert response.success is True, "set token should succeed"
assert response.valid is True, "validated token should return valid=True"
assert response.username == USERNAME_PRIMARY, "username should match validation"
async def test_get_status_reflects_validated_token(
self,
hf_token_servicer: NoteFlowServicer,
mock_grpc_context: MagicMock,
) -> None:
"""GetHuggingFaceTokenStatus reflects validated token metadata."""
service = hf_token_servicer.hf_token_service
assert service is not None, "hf_token_service should be initialized"
with patch.object(
service,
"validate_token_internal",
return_value=HfValidationResult(
valid=True,
username=USERNAME_PRIMARY,
error_message="",
),
):
set_response = await _set_hf_token(
hf_token_servicer,
TOKEN_VALUE,
True,
mock_grpc_context,
)
assert set_response.success is True, "set token should succeed"
status = await _get_hf_status(hf_token_servicer, mock_grpc_context)
assert status.is_configured is True, "status should indicate token configured"
assert status.is_validated is True, "status should indicate token validated"
assert status.username == USERNAME_PRIMARY, "status username should match"
assert status.validated_at > ZERO_FLOAT, "validated_at should be set"
async def test_validate_stored_token_updates_status(
self,
hf_token_servicer: NoteFlowServicer,
mock_grpc_context: MagicMock,
) -> None:
"""ValidateHuggingFaceToken updates status for stored token."""
service = hf_token_servicer.hf_token_service
assert service is not None, "hf_token_service should be initialized"
await _set_unvalidated_token_and_assert(hf_token_servicer, mock_grpc_context)
with patch.object(
service,
"validate_token_internal",
return_value=HfValidationResult(
valid=True,
username=USERNAME_VALIDATED,
error_message="",
),
):
validate_response = await _validate_hf_token(
hf_token_servicer,
mock_grpc_context,
)
assert validate_response.valid is True, "validate token should succeed"
assert validate_response.username == USERNAME_VALIDATED, "validated username should match"
status_after = await _get_hf_status(hf_token_servicer, mock_grpc_context)
assert status_after.is_validated is True, "status should be validated after validation"
assert status_after.username == USERNAME_VALIDATED, "status username should update"
assert status_after.validated_at > ZERO_FLOAT, "validated_at should be set after validate"
async def test_delete_token_clears_status(
self,
hf_token_servicer: NoteFlowServicer,
mock_grpc_context: MagicMock,
) -> None:
"""DeleteHuggingFaceToken removes token and resets status."""
set_response = await _set_hf_token(
hf_token_servicer,
TOKEN_VALUE,
False,
mock_grpc_context,
)
assert set_response.success is True, "set token should succeed before delete"
delete_response = await _delete_hf_token(
hf_token_servicer,
mock_grpc_context,
)
assert delete_response.success is True, "delete token should succeed"
status = await _get_hf_status(hf_token_servicer, mock_grpc_context)
assert status.is_configured is False, "token should be cleared after delete"
assert status.is_validated is False, "validation should reset after delete"
assert status.username == EMPTY_USERNAME, "username should be empty after delete"
assert status.validated_at == ZERO_FLOAT, "validated_at should reset after delete"
async def test_set_token_rejects_empty_value(
self,
hf_token_servicer: NoteFlowServicer,
mock_grpc_context: MagicMock,
) -> None:
"""SetHuggingFaceToken rejects empty token values."""
response = await _set_hf_token(
hf_token_servicer,
EMPTY_TOKEN,
True,
mock_grpc_context,
)
assert response.success is False, "empty token should be rejected"
assert response.validation_error == ERROR_EMPTY_TOKEN, (
"validation_error should explain empty token rejection"
)