Files
Lumotia/crates/transcription/src/orchestrator.rs
Jake 2491c7a7dd
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
agent: lumotia-rebrand — fix Codex cross-model review blockers
Codex independent review found 11 blockers post-cascade. All addressed.

CRITICAL (data-loss / crash):

10. crates/core/src/paths.rs — migrate_legacy_data_dir_inner used
    fs::rename which fails with EXDEV when source + target are on
    different filesystems (encrypted-home, bind mounts, separate
    $XDG_DATA_HOME partition). Combined with the Phase 5 QC fix that
    made migration errors fatal, this would crash on first launch
    for any user whose data dir spans filesystems. Added
    rename_or_copy_tree() that falls back to copy_dir_recursive +
    remove_dir_all on CrossesDevices / errno 18 (EXDEV). Symlinks
    preserved verbatim. Same fallback applied to magnotia.db ->
    lumotia.db inside the dir.

11. Added 4 unit tests: copy_dir_recursive preserves nested
    structure, rename_or_copy_tree same-filesystem happy path,
    is_cross_device classifies CrossesDevices kind + raw errno 18.

Doc residuals (blockers 1-9):

1. crates/cloud-providers/Cargo.toml — "Wyrdnote pending rebrand"
   description.
2. crates/cloud-providers/src/provider.rs — module docs + test
   fixture Wyrdnote refs.
3. crates/transcription/src/orchestrator.rs — module docs + test
   fixture Wyrdnote refs.
4. docs/roadmap/2026-05-10-pkm-phase-tooling-shortlist.md — phase
   name + outputs/wyrdnote path refs.
5. docs/architecture-map/04-llm-formatting-mcp/llm-tests.md —
   MAGNOTIA_LLM_TEST_MODEL env var.
6. .../cloud-providers-stubs.md — MAGNOTIA_API_KEY_*.
7. docs/architecture-map/03-audio-transcription/tests-and-fixtures.md
   — MAGNOTIA_WHISPER_*.
8. docs/gpu-tuning/plan.md — MAGNOTIA_BENCH_RUN.
9. docs/superpowers/specs/2026-05-09-battery-gpu-aware-thread-tuning-
   design.md + corresponding plan — MAGNOTIA_POWER_STATE_OVERRIDE,
   MAGNOTIA_INFERENCE_THREADS.

cargo test --workspace: 343 pass / 0 fail (up from 339; +4 EXDEV
fallback tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:39:21 +01:00

282 lines
9.6 KiB
Rust

//! `Orchestrator` is the single entry point for transcription. Tauri
//! commands resolve a provider through it; nothing else calls a
//! provider directly. This is where the AGPL OEM trait boundary meets
//! Lumotia's local engines.
//!
//! `LocalProviderAdapter` lives here, not as an `impl
//! TranscriptionProvider for LocalEngine`. The adapter wraps an
//! `Arc<LocalEngine>` and bridges the synchronous `Transcriber` trait
//! to the async `TranscriptionProvider` interface via
//! `tokio::task::spawn_blocking`. Keeping the adapter in the
//! orchestrator (rather than in `local_engine.rs`) means the
//! transcription crate's core types do not need to depend on
//! `async_trait`, and the dependency edge stays one-directional:
//! `cloud_providers` defines the trait, `transcription` implements it
//! via adapter without leaking async-runtime requirements onto
//! `Transcriber`.
use std::sync::Arc;
use async_trait::async_trait;
use lumotia_cloud_providers::{
CostClass, EngineProfile, ProviderCapabilities, ProviderId, ProviderKind, ProviderTranscript,
TranscriptionProvider,
};
use lumotia_core::error::{Error, Result};
use lumotia_core::types::{AudioSamples, TranscriptionOptions};
use crate::local_engine::LocalEngine;
use crate::registry::EngineRegistry;
/// Wraps a `LocalEngine` and presents the async `TranscriptionProvider`
/// interface upward. One adapter per local engine instance; multiple
/// engines (Whisper, Parakeet) live as multiple adapters in the
/// registry.
pub struct LocalProviderAdapter {
provider_id: ProviderId,
engine: Arc<LocalEngine>,
}
impl LocalProviderAdapter {
pub fn new(provider_id: ProviderId, engine: Arc<LocalEngine>) -> Self {
Self {
provider_id,
engine,
}
}
/// Direct access to the wrapped engine. Used by warmup, model
/// management, and the GPU-sequencing guard, none of which want to
/// route through the async trait surface.
pub fn engine(&self) -> Arc<LocalEngine> {
self.engine.clone()
}
}
#[async_trait]
impl TranscriptionProvider for LocalProviderAdapter {
fn provider_id(&self) -> ProviderId {
self.provider_id.clone()
}
fn kind(&self) -> ProviderKind {
ProviderKind::Local
}
fn capabilities(&self) -> ProviderCapabilities {
let local = self.engine.capabilities();
ProviderCapabilities {
sample_rate: local
.map(|c| c.sample_rate)
.unwrap_or(lumotia_core::constants::WHISPER_SAMPLE_RATE),
channels: local.map(|c| c.channels).unwrap_or(1),
initial_prompt_supported: local.map(|c| c.supports_initial_prompt).unwrap_or(false),
language_hint_supported: true,
streaming_supported: false,
cost_class: CostClass::Free,
}
}
async fn prepare(&self, _profile: &EngineProfile) -> Result<()> {
if self.engine.is_loaded() {
Ok(())
} else {
Err(Error::EngineNotLoaded)
}
}
async fn transcribe(
&self,
audio: AudioSamples,
options: TranscriptionOptions,
) -> Result<ProviderTranscript> {
let engine = self.engine.clone();
let timed = tokio::task::spawn_blocking(move || engine.transcribe_sync(&audio, &options))
.await
.map_err(|e| Error::TranscriptionFailed(format!("Task join error: {e}")))??;
Ok(ProviderTranscript {
transcript: timed.transcript,
inference_ms: timed.inference_ms,
})
}
}
/// The single entry point through which all transcription flows.
/// Resolves a provider against the registry, runs `prepare` if the
/// caller has not already done so, and dispatches `transcribe`.
pub struct Orchestrator {
registry: Arc<EngineRegistry>,
}
impl Orchestrator {
pub fn new(registry: Arc<EngineRegistry>) -> Self {
Self { registry }
}
/// Underlying registry. Exposed so the UI can list providers and
/// query capabilities without going through a transcribe call.
pub fn registry(&self) -> Arc<EngineRegistry> {
self.registry.clone()
}
/// Resolve the provider for a profile. Returns a clear error when
/// the profile names an unregistered engine.
pub fn resolve(&self, profile: &EngineProfile) -> Result<Arc<dyn TranscriptionProvider>> {
self.registry
.get(&profile.engine_id)
.ok_or_else(|| Error::ProviderNotRegistered(profile.engine_id.to_string()))
}
/// Transcribe audio using the provider named in the profile. The
/// orchestrator builds `TranscriptionOptions` from the profile and
/// delegates to the provider.
///
/// Phase A scope: this is the new entry point. Existing call sites
/// (`commands/transcription.rs::transcribe_file`) still call
/// `LocalEngine` directly; their rewire is a follow-up commit so
/// the chunking + post-processing logic moves cleanly without
/// inflating this diff. See `KNOWN-ISSUES.md` KI-06.
pub async fn transcribe(
&self,
audio: AudioSamples,
profile: &EngineProfile,
) -> Result<ProviderTranscript> {
let provider = self.resolve(profile)?;
let options = profile.to_options();
provider.transcribe(audio, options).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use lumotia_core::types::{Segment, Transcript};
/// Mock provider that returns a canned transcript and counts
/// invocations. Used to validate the orchestrator's dispatch logic
/// without booting a real model.
struct CannedProvider {
id: ProviderId,
calls: Arc<AtomicUsize>,
canned_text: String,
}
#[async_trait]
impl TranscriptionProvider for CannedProvider {
fn provider_id(&self) -> ProviderId {
self.id.clone()
}
fn kind(&self) -> ProviderKind {
ProviderKind::Local
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
sample_rate: 16_000,
channels: 1,
initial_prompt_supported: true,
language_hint_supported: true,
streaming_supported: false,
cost_class: CostClass::Free,
}
}
async fn prepare(&self, _profile: &EngineProfile) -> Result<()> {
Ok(())
}
async fn transcribe(
&self,
audio: AudioSamples,
_options: TranscriptionOptions,
) -> Result<ProviderTranscript> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(ProviderTranscript {
transcript: Transcript::new(
vec![Segment {
start: 0.0,
end: audio.duration_secs(),
text: self.canned_text.clone(),
}],
"en".to_string(),
audio.duration_secs(),
),
inference_ms: 7,
})
}
}
fn build_registry_with(id: &str, text: &str, calls: Arc<AtomicUsize>) -> Arc<EngineRegistry> {
let mut registry = EngineRegistry::new(ProviderId::new(id));
registry.register(Arc::new(CannedProvider {
id: ProviderId::new(id),
calls,
canned_text: text.to_string(),
}));
Arc::new(registry)
}
fn one_second_of_silence() -> AudioSamples {
AudioSamples::mono_16khz(vec![0.0_f32; 16_000])
}
#[tokio::test]
async fn orchestrator_dispatches_to_named_provider() {
let calls = Arc::new(AtomicUsize::new(0));
let registry = build_registry_with("canned", "hello lumotia", calls.clone());
let orchestrator = Orchestrator::new(registry);
let profile = EngineProfile::new(ProviderId::new("canned"));
let result = orchestrator
.transcribe(one_second_of_silence(), &profile)
.await
.expect("dispatch succeeds");
assert_eq!(result.transcript.text(), "hello lumotia");
assert_eq!(result.inference_ms, 7);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn orchestrator_returns_clear_error_for_unregistered_provider() {
let calls = Arc::new(AtomicUsize::new(0));
let registry = build_registry_with("canned", "_", calls);
let orchestrator = Orchestrator::new(registry);
let profile = EngineProfile::new(ProviderId::new("not-registered"));
let err = orchestrator
.transcribe(one_second_of_silence(), &profile)
.await
.expect_err("unregistered provider must error");
let msg = err.to_string();
assert!(
msg.contains("not-registered"),
"error must name the missing provider, got: {msg}"
);
}
#[tokio::test]
async fn orchestrator_routes_initial_prompt_through_options() {
let calls = Arc::new(AtomicUsize::new(0));
let registry = build_registry_with("canned", "transcript", calls.clone());
let orchestrator = Orchestrator::new(registry);
let mut profile = EngineProfile::new(ProviderId::new("canned"));
profile.language = Some("en".to_string());
profile.initial_prompt = Some("Lumotia".to_string());
let _ = orchestrator
.transcribe(one_second_of_silence(), &profile)
.await
.expect("dispatch succeeds");
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn local_provider_adapter_is_object_safe() {
let _: Option<Arc<dyn TranscriptionProvider>> = None;
}
}