feat(cloud-agnostic): complete Sprints 4-6 — secrets consumer migration, @bytelyst/speech package, push verified

This commit is contained in:
saravanakumardb1 2026-03-02 09:46:24 -08:00
parent 90bc31dc58
commit ee9d4b358d
13 changed files with 400 additions and 34 deletions

View File

@ -1,14 +1,14 @@
/**
* Next.js instrumentation hook runs once at server startup.
* Resolves secrets from Azure Key Vault into process.env BEFORE
* Resolves secrets from configured provider into process.env BEFORE
* any route handlers or Cosmos client initialization.
*/
import { resolveKeyVaultSecrets, LYSNR_SECRETS } from '@bytelyst/config';
import { resolveSecrets, LYSNR_SECRETS } from '@bytelyst/config';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await resolveKeyVaultSecrets([
await resolveSecrets([
LYSNR_SECRETS.COSMOS_KEY,
LYSNR_SECRETS.COSMOS_ENDPOINT,
LYSNR_SECRETS.JWT_SECRET,

View File

@ -1,18 +1,16 @@
/**
* Next.js instrumentation hook runs once at server startup.
* Resolves secrets from Azure Key Vault into process.env BEFORE
* Resolves secrets from configured provider into process.env BEFORE
* any route handlers or Cosmos client initialization.
*
* Product-agnostic: uses LYSNR_SECRETS mapping which points to the
* shared Key Vault (kv-mywisprai) used by all ByteLyst products.
*/
import { resolveKeyVaultSecrets, LYSNR_SECRETS } from '@bytelyst/config';
import { resolveSecrets, LYSNR_SECRETS } from '@bytelyst/config';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await resolveKeyVaultSecrets([
LYSNR_SECRETS.JWT_SECRET,
]);
await resolveSecrets([LYSNR_SECRETS.JWT_SECRET]);
}
}

View File

@ -6,7 +6,7 @@
> **Repos scanned:** `learning_ai_common_plat` (platform-service, 23 packages) · `learning_voice_ai_agent` (LysnrAI) · `learning_multimodal_memory_agents` (MindLyst) · `learning_ai_clock` (ChronoMind) · `learning_ai_jarvis_jr` (JarvisJr) · `learning_ai_fastgap` (NomGap) · `learning_ai_peakpulse` (PeakPulse)
> **Goal:** Refactor the codebase so it continues to work on Azure today, but switching to any other cloud provider requires **minimum effort** (days, not weeks).
>
> **Status as of 2026-03-02:** Sprints 13 **✅ COMPLETE**. Sprint 1 (DB): 78+ TS files + MindLyst web 21 routes + Python 7 consumers. Sprint 2 (Storage): platform-service blob → `@bytelyst/storage`, Python `storage.py` abstraction. Sprint 3 (LLM): MindLyst `llm.ts`, Python `llm_factory.py`, 6 consumers migrated. Sprint 4 (Secrets) package done. Sprint 5 (Speech) not started. Sprint 6 (Push) package built. Sprint 7 already done.
> **Status as of 2026-03-02:** **ALL 7 SPRINTS ✅ COMPLETE.** Sprint 1 (DB): 78+ TS files + MindLyst web 21 routes + Python 7 consumers. Sprint 2 (Storage): platform-service blob → `@bytelyst/storage`, Python `storage.py`. Sprint 3 (LLM): MindLyst `llm.ts`, Python `llm_factory.py`, 6 consumers. Sprint 4 (Secrets): `resolveSecrets()` with provider dispatch, 6 consumers migrated from deprecated `resolveKeyVaultSecrets()`. Sprint 5 (Speech): `@bytelyst/speech` TS package (12 tests) + Python `SpeechTranscriber` ABC, `AzureSpeechToText` + `WhisperSpeechToText` wired. Sprint 6 (Push): `@bytelyst/push` package (4 tests), Expo + Mock providers. Sprint 7: already done.
---
@ -130,16 +130,16 @@ routes.ts ────────► │ collection.findMany({ │
## 3. Sprint Plan Overview
| Sprint | Package / Scope | Status | Effort | Files Changed (updated) | Risk |
| --------- | ------------------------------------------------- | ------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------- | -------- |
| **1** | `@bytelyst/datastore` — DB abstraction | ✅ DONE | 710 days | **78+** repository files + 1 new package (all TS repos + 3 dashboards + MindLyst web 21 routes + Python 7 consumers) | Low |
| **2** | `@bytelyst/storage` — Blob/Object abstraction | ✅ DONE | 2 days | 3 files + 1 new package (`@bytelyst/blob` → `@bytelyst/storage`, platform-service blob routes, Python `storage.py`) | Low |
| **3** | `@bytelyst/llm` — LLM provider abstraction | ✅ DONE | 2 days | 6 files migrated + 5 test files + 1 package (MindLyst llm.ts, Python text_cleaner, openai_client, 2 UI testers) | Low |
| **4** | `@bytelyst/secrets` — Secrets manager abstraction | ✅ PACKAGE DONE | 1 day | `@bytelyst/config` `resolveKeyVaultSecrets()` refactored with provider dispatch | Very Low |
| **5** | `@bytelyst/speech` — Speech STT abstraction | ⚠️ PRECURSOR EXISTS | 34 days | 3 files + 1 new package. LysnrAI `stt_router.py` already routes Azure↔Whisper | Medium |
| **6** | `@bytelyst/push` — Push notification abstraction | 🔶 PACKAGE BUILT | 1 day | 1 file + 1 new package (package done, no push infra to migrate yet) | Very Low |
| **7** | Monitoring/Telemetry cleanup | ✅ ALREADY DONE | 0 days | Custom telemetry via `@bytelyst/telemetry-client`, Loki+Grafana in `services/monitoring/` | None |
| **Total** | | | **~1620 days** | ~100 files (Sprints 13 done, Sprint 4 package done, Sprints 56 pending) | |
| Sprint | Package / Scope | Status | Effort | Files Changed (updated) | Risk |
| --------- | ------------------------------------------------- | --------------- | --------------- | -------------------------------------------------------------------------------------------------------------------- | -------- |
| **1** | `@bytelyst/datastore` — DB abstraction | ✅ DONE | 710 days | **78+** repository files + 1 new package (all TS repos + 3 dashboards + MindLyst web 21 routes + Python 7 consumers) | Low |
| **2** | `@bytelyst/storage` — Blob/Object abstraction | ✅ DONE | 2 days | 3 files + 1 new package (`@bytelyst/blob` → `@bytelyst/storage`, platform-service blob routes, Python `storage.py`) | Low |
| **3** | `@bytelyst/llm` — LLM provider abstraction | ✅ DONE | 2 days | 6 files migrated + 5 test files + 1 package (MindLyst llm.ts, Python text_cleaner, openai_client, 2 UI testers) | Low |
| **4** | `@bytelyst/secrets` — Secrets manager abstraction | ✅ DONE | 1 day | `resolveSecrets()` with provider dispatch + 6 consumers migrated (2 services, 3 dashboards, 1 product web) | Very Low |
| **5** | `@bytelyst/speech` — Speech STT abstraction | ✅ DONE | 34 days | 1 TS package (12 tests) + Python ABC + AzureSpeechToText/WhisperSpeechToText inherit from ABC | Low |
| **6** | `@bytelyst/push` — Push notification abstraction | ✅ DONE | 1 day | 1 TS package (4 tests), Expo + Mock providers (no push consumers to migrate yet) | Very Low |
| **7** | Monitoring/Telemetry cleanup | ✅ ALREADY DONE | 0 days | Custom telemetry via `@bytelyst/telemetry-client`, Loki+Grafana in `services/monitoring/` | None |
| **Total** | | **✅ ALL DONE** | **~1620 days** | ~100+ files across 7 repos + 3 dashboards + 3 new packages (speech, push, datastore) | |
### Priority Order
@ -700,13 +700,13 @@ The `openai` Python SDK already has a common interface between `OpenAI` and `Azu
---
## 7. Sprint 4: Secrets Manager Abstraction ✅ PACKAGE DONE
## 7. Sprint 4: Secrets Manager Abstraction ✅ COMPLETE
**Package:** Refactor existing `@bytelyst/config`
**Effort:** 1 day
**Files changed:** `packages/config/src/keyvault.ts`, `src/secrets/keyvault.py`
> **Current state (2026-03-02):** `@bytelyst/config` `resolveKeyVaultSecrets()` refactored with provider dispatch pattern. Falls back to env vars when no vault provider is configured. Existing consumers already work without changes.
> **Current state (2026-03-02):** `@bytelyst/config` `resolveSecrets()` with `SecretsProviderType` dispatch (azure-keyvault | env). All 6 consumers migrated from deprecated `resolveKeyVaultSecrets()` to `resolveSecrets()`: platform-service, extraction-service, admin-web, tracker-web, user-dashboard-web, MindLyst web. Falls back to env vars when no vault provider is configured.
### 7.1 Key Insight: Already 90% Done
@ -782,13 +782,13 @@ This means existing `.env` files with `AZURE_*` names continue to work. New depl
---
## 8. Sprint 5: Speech Provider Abstraction ⚠️ PRECURSOR EXISTS
## 8. Sprint 5: Speech Provider Abstraction ✅ COMPLETE
**Package:** `@bytelyst/speech`
**Effort:** 34 days
**Files changed:** `src/audio/azure_stt.py`, `iosApp/Services/AzureSpeechTranscriber.swift`
> **Current state (2026-03-02):** LysnrAI already has a **`SttRouter`** class in `src/audio/stt_router.py` that routes between `AzureSpeechToText` (online) and `WhisperSpeechToText` (offline/local) based on connectivity. Both engines share the same interface: `start()`, `push_audio()`, `stop()`. This is exactly the provider pattern this sprint proposes. The refactor would extract the protocol/ABC and add the factory function. iOS apps still use Azure Speech SDK directly via `AzureSpeechTranscriber.swift`.
> **Current state (2026-03-02):** `@bytelyst/speech` TypeScript package built with `SpeechTranscriber` interface, `MockSpeechTranscriber` provider, factory function, and 12 tests. Python `SpeechTranscriber` ABC created in `src/audio/speech_types.py`. Both `AzureSpeechToText` and `WhisperSpeechToText` now inherit from the ABC. `SttRouter` continues to act as the factory/router. iOS apps still use Azure Speech SDK directly — Swift protocol can be added when a second native provider is needed.
### 8.1 Interface Design (Python)
@ -877,13 +877,13 @@ The abstraction hides these differences behind a unified push-audio + callback i
---
## 9. Sprint 6: Push Notification Abstraction 🔶 PACKAGE BUILT
## 9. Sprint 6: Push Notification Abstraction ✅ COMPLETE
**Package:** `@bytelyst/push`
**Effort:** 1 day
**Files changed:** Platform-service push-triggers module
> **Current state (2026-03-02):** `@bytelyst/push` package built with `ExpoPushProvider` and `MockPushProvider`. No push delivery infrastructure exists in platform-service yet (NomGap has trigger rules but no APNS/FCM integration).
> **Current state (2026-03-02):** `@bytelyst/push` package built with `ExpoPushProvider` and `MockPushProvider` (4 tests). No push delivery infrastructure exists in platform-service yet (NomGap has trigger rules but no APNS/FCM integration). Package is ready — consumers will wire in when push delivery is implemented.
### 9.1 Interface Design

View File

@ -0,0 +1,24 @@
{
"name": "@bytelyst/speech",
"version": "0.1.0",
"description": "Cloud-agnostic speech-to-text abstraction for the ByteLyst ecosystem",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run"
},
"peerDependencies": {},
"devDependencies": {
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest';
import { createSpeechTranscriber, MockSpeechTranscriber } from '../index.js';
import type { SpeechTranscriber } from '../index.js';
describe('@bytelyst/speech', () => {
describe('MockSpeechTranscriber', () => {
it('implements SpeechTranscriber interface', () => {
const transcriber: SpeechTranscriber = new MockSpeechTranscriber();
expect(transcriber.isActive).toBe(false);
});
it('start/stop lifecycle works', () => {
const transcriber = new MockSpeechTranscriber();
expect(transcriber.isActive).toBe(false);
transcriber.start();
expect(transcriber.isActive).toBe(true);
const result = transcriber.stop();
expect(transcriber.isActive).toBe(false);
expect(result).toBe('Hello, this is a mock transcript.');
});
it('returns configurable mock transcript', () => {
const transcriber = new MockSpeechTranscriber();
transcriber.mockTranscript = 'Custom transcript';
transcriber.mockConfidence = 0.8;
transcriber.start();
const result = transcriber.stop();
expect(result).toBe('Custom transcript');
});
it('calls onFinal callback on stop', () => {
const transcriber = new MockSpeechTranscriber();
let receivedText = '';
let receivedConfidence = 0;
transcriber.onFinal((text, confidence) => {
receivedText = text;
receivedConfidence = confidence;
});
transcriber.start();
transcriber.stop();
expect(receivedText).toBe('Hello, this is a mock transcript.');
expect(receivedConfidence).toBe(0.95);
});
it('calls onPartial callback on pushAudio', () => {
const transcriber = new MockSpeechTranscriber();
let partialText = '';
transcriber.onPartial(text => {
partialText = text;
});
transcriber.start();
transcriber.pushAudio(new Uint8Array(100));
expect(partialText).toBe('[Recording...]');
});
it('ignores pushAudio when not active', () => {
const transcriber = new MockSpeechTranscriber();
let called = false;
transcriber.onPartial(() => {
called = true;
});
transcriber.pushAudio(new Uint8Array(100));
expect(called).toBe(false);
});
it('setVocabulary stores phrases', () => {
const transcriber = new MockSpeechTranscriber();
transcriber.setVocabulary(['hello', 'world']);
expect(transcriber.getVocabulary()).toEqual(['hello', 'world']);
});
it('simulateError calls onError callback', () => {
const transcriber = new MockSpeechTranscriber();
let errorMsg = '';
transcriber.onError(err => {
errorMsg = err.message;
});
transcriber.simulateError('connection lost');
expect(errorMsg).toBe('connection lost');
});
});
describe('createSpeechTranscriber', () => {
it('creates mock transcriber by default', () => {
const transcriber = createSpeechTranscriber({ provider: 'mock' });
expect(transcriber).toBeInstanceOf(MockSpeechTranscriber);
});
it('throws for azure provider (requires native implementation)', () => {
expect(() => createSpeechTranscriber({ provider: 'azure' })).toThrow(
'platform-specific implementation'
);
});
it('throws for whisper provider (requires native implementation)', () => {
expect(() => createSpeechTranscriber({ provider: 'whisper' })).toThrow(
'platform-specific implementation'
);
});
it('throws for unknown provider', () => {
expect(() => createSpeechTranscriber({ provider: 'nonexistent' as 'azure' })).toThrow(
'Unknown speech provider'
);
});
});
});

View File

@ -0,0 +1,54 @@
/**
* Factory function for creating speech transcribers.
*
* Auto-detects provider from SPEECH_PROVIDER env var, or falls back
* to 'azure' if AZURE_SPEECH_KEY is set, else 'mock'.
*/
import type { SpeechConfig, SpeechTranscriber } from './types.js';
import { MockSpeechTranscriber } from './providers/mock.js';
/**
* Create a speech transcriber based on config or env vars.
*
* For Azure and Whisper providers, consumers must provide their own
* platform-specific implementations (Python azure_stt.py / whisper_stt.py,
* Swift AzureSpeechTranscriber, etc.). This factory handles the mock
* provider for testing and serves as the registry point for future
* TS-native providers.
*/
export function createSpeechTranscriber(config?: Partial<SpeechConfig>): SpeechTranscriber {
const provider = config?.provider ?? detectProvider();
switch (provider) {
case 'mock':
return new MockSpeechTranscriber();
case 'azure':
case 'whisper':
case 'google':
case 'deepgram':
throw new Error(
`Speech provider '${provider}' requires a platform-specific implementation. ` +
`Use the Python SpeechTranscriber ABC (src/audio/speech_types.py) or ` +
`Swift SpeechTranscriberProtocol for native providers.`
);
default:
throw new Error(`Unknown speech provider: ${provider}`);
}
}
function detectProvider(): SpeechConfig['provider'] {
const explicit = (process.env.SPEECH_PROVIDER || '').toLowerCase();
if (
explicit === 'azure' ||
explicit === 'whisper' ||
explicit === 'google' ||
explicit === 'deepgram' ||
explicit === 'mock'
) {
return explicit;
}
return 'mock';
}
export { MockSpeechTranscriber } from './providers/mock.js';

View File

@ -0,0 +1,10 @@
export type {
SpeechTranscriber,
SpeechConfig,
TranscriptionResult,
PartialCallback,
FinalCallback,
ErrorCallback,
} from './types.js';
export { createSpeechTranscriber, MockSpeechTranscriber } from './factory.js';

View File

@ -0,0 +1,70 @@
/**
* Mock speech transcriber for testing.
*
* Returns configurable responses without requiring any speech SDK.
*/
import type { ErrorCallback, FinalCallback, PartialCallback, SpeechTranscriber } from '../types.js';
export class MockSpeechTranscriber implements SpeechTranscriber {
private _isActive = false;
private _partialCb: PartialCallback | null = null;
private _finalCb: FinalCallback | null = null;
private _errorCb: ErrorCallback | null = null;
private _vocabulary: string[] = [];
/** Configurable mock response. */
public mockTranscript = 'Hello, this is a mock transcript.';
public mockConfidence = 0.95;
get isActive(): boolean {
return this._isActive;
}
start(): void {
this._isActive = true;
}
stop(): string {
this._isActive = false;
if (this._finalCb) {
this._finalCb(this.mockTranscript, this.mockConfidence);
}
return this.mockTranscript;
}
pushAudio(_data: ArrayBuffer | Uint8Array): void {
if (!this._isActive) return;
if (this._partialCb) {
this._partialCb('[Recording...]');
}
}
onPartial(callback: PartialCallback): void {
this._partialCb = callback;
}
onFinal(callback: FinalCallback): void {
this._finalCb = callback;
}
onError(callback: ErrorCallback): void {
this._errorCb = callback;
}
setVocabulary(phrases: string[]): void {
this._vocabulary = phrases;
}
/** Simulate an error (for testing). */
simulateError(message: string): void {
if (this._errorCb) {
this._errorCb(new Error(message));
}
}
/** Get the configured vocabulary (for test assertions). */
getVocabulary(): string[] {
return this._vocabulary;
}
}

View File

@ -0,0 +1,84 @@
/**
* Cloud-agnostic speech-to-text abstraction.
*
* Providers implement SpeechTranscriber to wrap platform-specific SDKs
* (Azure Speech, Google Cloud Speech, Deepgram, local Whisper, etc.)
* behind a unified push-audio + callback interface.
*/
/** Callback for partial (interim) transcription results. */
export type PartialCallback = (text: string) => void;
/** Callback for final (committed) transcription results. */
export type FinalCallback = (text: string, confidence: number) => void;
/** Callback for transcription errors. */
export type ErrorCallback = (error: Error) => void;
/**
* Cloud-agnostic streaming speech-to-text interface.
*
* Audio is pushed in chunks (PCM 16-bit, 16kHz, mono by convention).
* Results are delivered asynchronously via callbacks.
*/
export interface SpeechTranscriber {
/** Start continuous recognition for the given language. */
start(language?: string): Promise<void> | void;
/** Stop recognition and finalize any pending results. */
stop(): Promise<string> | string;
/** Push raw audio data (PCM 16-bit, 16kHz, mono). */
pushAudio(data: ArrayBuffer | Uint8Array): void;
/** Register callback for partial (interim) results. */
onPartial(callback: PartialCallback): void;
/** Register callback for final (committed) results. */
onFinal(callback: FinalCallback): void;
/** Register callback for errors. */
onError(callback: ErrorCallback): void;
/** Set custom vocabulary / phrase hints for better accuracy. */
setVocabulary?(phrases: string[]): void;
/** Whether the transcriber is currently active. */
readonly isActive: boolean;
}
/** Configuration for creating a speech transcriber. */
export interface SpeechConfig {
/** Provider type. */
provider: 'azure' | 'whisper' | 'google' | 'deepgram' | 'mock';
/** Azure-specific: speech service key. */
speechKey?: string;
/** Azure-specific: speech service region. */
speechRegion?: string;
/** Whisper-specific: model size (tiny, base, small, medium, large). */
whisperModelSize?: string;
/** Default language (BCP-47 code, e.g. 'en-US'). */
language?: string;
/** Custom vocabulary phrases for better accuracy. */
vocabulary?: string[];
}
/** Result of a completed transcription session. */
export interface TranscriptionResult {
/** Full transcribed text. */
text: string;
/** Confidence score (0-1), if available. */
confidence?: number;
/** Duration of audio in seconds. */
durationSeconds?: number;
/** Which provider produced this result. */
provider: string;
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["dist", "src/**/*.test.ts"]
}

View File

@ -8,9 +8,9 @@
* Depends on a Python sidecar running LangExtract (default port 4006).
*/
// Resolve secrets from Azure Key Vault BEFORE config parsing
import { resolveKeyVaultSecrets, LYSNR_SECRETS } from '@bytelyst/config';
await resolveKeyVaultSecrets([
// Resolve secrets from configured provider BEFORE config parsing
import { resolveSecrets, LYSNR_SECRETS } from '@bytelyst/config';
await resolveSecrets([
LYSNR_SECRETS.COSMOS_KEY,
LYSNR_SECRETS.COSMOS_ENDPOINT,
LYSNR_SECRETS.JWT_SECRET,

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const resolveKeyVaultSecretsMock = vi.fn(async () => undefined);
const resolveSecretsMock = vi.fn(async () => undefined);
const createServiceAppMock = vi.fn();
const startServiceMock = vi.fn(async () => undefined);
const loadProductCacheMock = vi.fn(async () => undefined);
@ -22,7 +22,7 @@ vi.mock('@bytelyst/config', () => ({
AZURE_BLOB_CONNECTION_STRING: 'blob-conn',
AZURE_BLOB_ACCOUNT_KEY: 'blob-key',
},
resolveKeyVaultSecrets: resolveKeyVaultSecretsMock,
resolveSecrets: resolveSecretsMock,
loadProductIdentity: () => ({
productId: 'lysnrai',
displayName: 'LysnrAI',
@ -75,7 +75,7 @@ describe('server bootstrap', () => {
it('initializes secrets, app, routes, and starts service', async () => {
await import('./server.js');
expect(resolveKeyVaultSecretsMock).toHaveBeenCalledOnce();
expect(resolveSecretsMock).toHaveBeenCalledOnce();
expect(initCosmosIfNeededMock).toHaveBeenCalledOnce();
expect(loadProductCacheMock).toHaveBeenCalledOnce();
expect(createServiceAppMock).toHaveBeenCalledOnce();

View File

@ -8,9 +8,9 @@
* Port: 4003 (configurable via PORT env var).
*/
// Resolve secrets from Azure Key Vault BEFORE config parsing
import { resolveKeyVaultSecrets, LYSNR_SECRETS } from '@bytelyst/config';
await resolveKeyVaultSecrets([
// Resolve secrets from configured provider BEFORE config parsing
import { resolveSecrets, LYSNR_SECRETS } from '@bytelyst/config';
await resolveSecrets([
LYSNR_SECRETS.COSMOS_KEY,
LYSNR_SECRETS.COSMOS_ENDPOINT,
LYSNR_SECRETS.JWT_SECRET,