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>
185 lines
6.2 KiB
Rust
185 lines
6.2 KiB
Rust
//! `TranscriptionProvider` is the async-native trait every transcription
|
|
//! backend implements, regardless of whether the work happens locally
|
|
//! (Whisper, Parakeet, Moonshine via the `LocalProviderAdapter` in
|
|
//! `lumotia-transcription`) or remotely (OpenAI Whisper, Groq,
|
|
//! Deepgram, etc.).
|
|
//!
|
|
//! Living in `lumotia-cloud-providers` is deliberate: the AGPL OEM
|
|
//! exception (≥£2k/yr) requires a clean trait surface that an OEM
|
|
//! licensee can implement without depending on Lumotia's transcription
|
|
//! internals. The trait crate stays small; provider implementations
|
|
//! sit alongside.
|
|
//!
|
|
//! Object-safety discipline: no generic methods, no `Self`-returning
|
|
//! methods. The compile-time witness in `tests` enforces this.
|
|
|
|
use std::fmt;
|
|
|
|
use async_trait::async_trait;
|
|
use lumotia_core::error::Result;
|
|
use lumotia_core::types::{AudioSamples, ModelId, Transcript, TranscriptionOptions};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Stable, lower-kebab-case identifier for a provider. Used in user
|
|
/// profiles, settings storage, and logs. Examples: `local-whisper`,
|
|
/// `local-parakeet`, `openai-whisper`, `groq-whisper-v3`.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub struct ProviderId(String);
|
|
|
|
impl ProviderId {
|
|
pub fn new(id: impl Into<String>) -> Self {
|
|
Self(id.into())
|
|
}
|
|
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for ProviderId {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(&self.0)
|
|
}
|
|
}
|
|
|
|
/// Whether a provider runs locally or over the network. The orchestrator
|
|
/// inspects this to decide whether to honour an offline-only profile.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum ProviderKind {
|
|
Local,
|
|
Cloud(NetworkRequirement),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum NetworkRequirement {
|
|
/// Provider requires a live network connection on every call.
|
|
Online,
|
|
/// Provider can fall back to a cached or queued path when offline.
|
|
OnlineWithFallback,
|
|
}
|
|
|
|
/// Indicative cost class for UI surfacing. Not a billing source of truth.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum CostClass {
|
|
/// No marginal cost beyond the user's local hardware.
|
|
Free,
|
|
/// Per-call cost; user supplies their own API key (BYOK).
|
|
PaidByok,
|
|
/// Per-call cost; provider billed by CORBEL on the user's behalf.
|
|
PaidManaged,
|
|
}
|
|
|
|
/// Capabilities a provider advertises to the orchestrator and the UI.
|
|
/// Superset of `lumotia_transcription::TranscriberCapabilities` for
|
|
/// local providers, with extra fields cloud providers populate.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ProviderCapabilities {
|
|
pub sample_rate: u32,
|
|
pub channels: u16,
|
|
pub initial_prompt_supported: bool,
|
|
pub language_hint_supported: bool,
|
|
pub streaming_supported: bool,
|
|
pub cost_class: CostClass,
|
|
}
|
|
|
|
/// User-selectable engine configuration. The orchestrator resolves
|
|
/// `engine_id` against the `EngineRegistry`, then derives
|
|
/// `TranscriptionOptions` from the remaining fields.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct EngineProfile {
|
|
pub engine_id: ProviderId,
|
|
pub model_id: Option<ModelId>,
|
|
pub language: Option<String>,
|
|
pub initial_prompt: Option<String>,
|
|
}
|
|
|
|
impl EngineProfile {
|
|
pub fn new(engine_id: ProviderId) -> Self {
|
|
Self {
|
|
engine_id,
|
|
model_id: None,
|
|
language: None,
|
|
initial_prompt: None,
|
|
}
|
|
}
|
|
|
|
pub fn to_options(&self) -> TranscriptionOptions {
|
|
TranscriptionOptions {
|
|
language: self.language.clone(),
|
|
initial_prompt: self.initial_prompt.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Result returned by `TranscriptionProvider::transcribe`. Carries the
|
|
/// transcript plus inference timing so the orchestrator can surface
|
|
/// latency without losing it across the trait boundary.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProviderTranscript {
|
|
pub transcript: Transcript,
|
|
pub inference_ms: u64,
|
|
}
|
|
|
|
/// Async-native unified interface for transcription providers.
|
|
///
|
|
/// `Send + Sync` supertraits: providers live in an `Arc<dyn
|
|
/// TranscriptionProvider>` shared across tokio tasks. `async_trait`
|
|
/// macro converts the async methods into boxed futures so the trait
|
|
/// stays object-safe.
|
|
#[async_trait]
|
|
pub trait TranscriptionProvider: Send + Sync {
|
|
fn provider_id(&self) -> ProviderId;
|
|
|
|
fn kind(&self) -> ProviderKind;
|
|
|
|
fn capabilities(&self) -> ProviderCapabilities;
|
|
|
|
/// Idempotent pre-flight. Local providers verify the model is
|
|
/// loaded into memory; cloud providers validate credentials and
|
|
/// reach the endpoint. Called before the first `transcribe` of a
|
|
/// session, and again after a profile or settings change that
|
|
/// invalidates the previous prepare.
|
|
async fn prepare(&self, profile: &EngineProfile) -> Result<()>;
|
|
|
|
/// Transcribe a chunk of audio. The orchestrator passes raw audio
|
|
/// already resampled to the provider's `capabilities().sample_rate`.
|
|
async fn transcribe(
|
|
&self,
|
|
audio: AudioSamples,
|
|
options: TranscriptionOptions,
|
|
) -> Result<ProviderTranscript>;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn provider_trait_is_object_safe() {
|
|
// Compile-time witness: if the trait stops being object-safe
|
|
// (generic method, Self-returning method, missing async_trait
|
|
// attribute) this declaration fails to build. No runtime work.
|
|
let _: Option<std::sync::Arc<dyn TranscriptionProvider>> = None;
|
|
}
|
|
|
|
#[test]
|
|
fn provider_id_round_trips_display_and_str() {
|
|
let id = ProviderId::new("local-whisper");
|
|
assert_eq!(id.as_str(), "local-whisper");
|
|
assert_eq!(id.to_string(), "local-whisper");
|
|
}
|
|
|
|
#[test]
|
|
fn engine_profile_derives_options() {
|
|
let profile = EngineProfile {
|
|
engine_id: ProviderId::new("local-whisper"),
|
|
model_id: None,
|
|
language: Some("en".to_string()),
|
|
initial_prompt: Some("Lumotia".to_string()),
|
|
};
|
|
let opts = profile.to_options();
|
|
assert_eq!(opts.language, Some("en".to_string()));
|
|
assert_eq!(opts.initial_prompt, Some("Lumotia".to_string()));
|
|
}
|
|
}
|