diff --git a/services/ai/localNlpService.ts b/services/ai/localNlpService.ts index 825bbccd..dcc0b526 100644 --- a/services/ai/localNlpService.ts +++ b/services/ai/localNlpService.ts @@ -1,6 +1,6 @@ -// QNBS-v3: Local NLP service — sentiment, summarization, topic classification via inference.worker.ts. -// Adapted from CannaGuide-2025 nlpService.ts patterns for creative-writing context. -// All inference runs off-main-thread; falls back gracefully when worker unavailable. +// QNBS-v3: [Local NLP service — sentiment/summarization via workers/v2/inference.worker.ts through WorkerBus v2 (docs/adr/0014 migration).] + +import { ensureInferencePool } from '../workerBusManager'; export interface SentimentResult { label: 'POSITIVE' | 'NEGATIVE' | 'NEUTRAL'; @@ -12,74 +12,64 @@ const SENTIMENT_MODEL = 'Xenova/distilbert-base-uncased-finetuned-sst-2-english' const SUMMARIZATION_MODEL = 'Xenova/distilbart-cnn-6-6'; const MAX_SUMMARY_TOKENS = 150; -let workerInstance: Worker | null = null; - -function getWorker(): Worker { - if (!workerInstance) { - workerInstance = new Worker(new URL('../../workers/inference.worker.ts', import.meta.url), { - type: 'module', - }); - } - return workerInstance; -} - -function postToWorker( +async function requestInference( task: string, modelId: string, input: string, inferenceOptions?: Record, -): Promise<{ ok: boolean; result?: string; error?: string }> { - return new Promise((resolve) => { - const messageId = `nlp-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; - const worker = getWorker(); - - const handler = (event: MessageEvent) => { - const data = event.data as { - messageId: string; - ok: boolean; - result?: string; - error?: string; - }; - if (data.messageId !== messageId) return; - worker.removeEventListener('message', handler); - resolve(data); - }; - - worker.addEventListener('message', handler); - worker.postMessage({ messageId, task, modelId, input, inferenceOptions }); - }); +): Promise { + const bus = await ensureInferencePool(); + if (!bus) throw new Error('WorkerBus v2 unavailable'); + // QNBS-v3: Omit inferenceOptions key (not `: undefined`) — exactOptionalPropertyTypes rejects + // an explicit undefined against the optional payload field. + const payload = { + task, + modelId, + input, + ...(inferenceOptions !== undefined ? { inferenceOptions } : {}), + }; + const handle = bus.enqueue< + { task: string; modelId: string; input: string; inferenceOptions?: Record }, + string + >('inference.text', payload, { capabilities: ['inference.text'] }); + return handle.result; } export async function analyzeSentiment(text: string): Promise { const capped = text.slice(0, 512); // model input limit - const response = await postToWorker('sentiment-analysis', SENTIMENT_MODEL, capped); - if (!response.ok || !response.result) { - return { label: 'NEUTRAL', score: 0.5, normalized: 0 }; - } + try { + const result = await requestInference('sentiment-analysis', SENTIMENT_MODEL, capped); - // Worker returns "LABEL:score" string (see inference.worker.ts sentiment handler) - const [labelRaw, scoreRaw] = response.result.split(':'); - const rawLabel = (labelRaw ?? 'NEUTRAL').toUpperCase(); - const score = parseFloat(scoreRaw ?? '0.5'); + // Worker returns "LABEL:score" string (see workers/v2/inference.worker.ts sentiment branch) + // QNBS-v3: labelRaw is always defined — String.split(':') never returns an empty array — noUncheckedIndexedAccess can't see that, so `as string` (not `??`, which would be dead code) satisfies the type checker without an untestable runtime branch. scoreRaw genuinely can be undefined when the worker omits the ":score" suffix, so it keeps its `??` fallback. + const [labelRaw, scoreRaw] = result.split(':'); + const rawLabel = (labelRaw as string).toUpperCase(); + const score = parseFloat(scoreRaw ?? '0.5'); - const label: SentimentResult['label'] = - rawLabel === 'POSITIVE' ? 'POSITIVE' : rawLabel === 'NEGATIVE' ? 'NEGATIVE' : 'NEUTRAL'; + const label: SentimentResult['label'] = + rawLabel === 'POSITIVE' ? 'POSITIVE' : rawLabel === 'NEGATIVE' ? 'NEGATIVE' : 'NEUTRAL'; - const normalized = label === 'POSITIVE' ? score : label === 'NEGATIVE' ? -score : 0; + const normalized = label === 'POSITIVE' ? score : label === 'NEGATIVE' ? -score : 0; - return { label, score, normalized }; + return { label, score, normalized }; + } catch { + // QNBS-v3: [Graceful degrade — same fallback v1 returned on any worker failure.] + return { label: 'NEUTRAL', score: 0.5, normalized: 0 }; + } } export async function summarizeText(text: string, maxLength = MAX_SUMMARY_TOKENS): Promise { const capped = text.slice(0, 1024); - const response = await postToWorker('summarization', SUMMARIZATION_MODEL, capped, { - max_new_tokens: maxLength, - do_sample: false, - }); - if (!response.ok || !response.result) return text.slice(0, 280); // graceful degrade - return response.result; + try { + return await requestInference('summarization', SUMMARIZATION_MODEL, capped, { + max_new_tokens: maxLength, + do_sample: false, + }); + } catch { + return text.slice(0, 280); // graceful degrade + } } // QNBS-v3: Zero-shot topic classification for creative-writing genres using sentiment model heuristic. @@ -108,10 +98,3 @@ export async function classifyWritingTopic(text: string): Promise { return bestGenre; } - -export function _resetWorkerForTest(): void { - if (workerInstance) { - workerInstance.terminate(); - workerInstance = null; - } -} diff --git a/services/ai/pipelineLruCache.ts b/services/ai/pipelineLruCache.ts index 029b6ee5..7aac4ad9 100644 --- a/services/ai/pipelineLruCache.ts +++ b/services/ai/pipelineLruCache.ts @@ -1,8 +1,8 @@ /** * Shared LRU cache for loaded inference pipelines (transformers.js / ONNX). - * QNBS-v3: Phase 2.3 — extracted from the byte-identical LRU loops that were duplicated in - * `workers/inference.worker.ts` and `workers/v2/inference.worker.ts`. Two real fixes - * over the inlined version: + * QNBS-v3: Phase 2.3 — extracted from byte-identical LRU loops duplicated across the v1/v2 worker + * generations (v1 since deleted, docs/adr/0014); only `workers/v2/inference.worker.ts` + * uses this now. Two real fixes over the inlined version: * 1. **dispose-on-evict** — evicted pipelines now `dispose()`, closing the VRAM/RAM * leak (same bug-class as the WebLLM eviction fix in AUDIT 2026-06-01 #1). * 2. **in-flight dedup** — concurrent loads of the same key share one promise, so the diff --git a/services/voice/wasmSttEngine.ts b/services/voice/wasmSttEngine.ts index 6a044f21..a7077b21 100644 --- a/services/voice/wasmSttEngine.ts +++ b/services/voice/wasmSttEngine.ts @@ -61,7 +61,7 @@ export class WasmSttEngine implements SttEngine { typedEnv.backends.onnx.wasm.proxy = false; } // QNBS-v3: cast pipeline to a loose signature — v3's typed overload union is too large to - // represent (TS2590). Same pattern as workers/inference.worker.ts loadPipeline(). + // represent (TS2590). Same pattern as workers/v2/inference.worker.ts's loadPipeline(). const createPipeline = pipeline as ( task: string, model: string, diff --git a/tests/unit/inferenceWorker.test.ts b/tests/unit/inferenceWorker.test.ts deleted file mode 100644 index 72e55b1b..00000000 --- a/tests/unit/inferenceWorker.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; - -// QNBS-v3: Relative path mock — same resolution trick as aiCoreFallbackPaths.test.ts. -const mockPipelineFn = vi.hoisted(() => vi.fn()); - -vi.mock( - '../../packages/ai-core/node_modules/@huggingface/transformers/dist/transformers.web.js', - () => ({ - pipeline: mockPipelineFn, - }), -); - -// Simulate worker globals. postMessages are pushed to `posted` for assertion. -const posted: unknown[] = []; -const messageListeners: Array<(e: MessageEvent) => void> = []; - -vi.stubGlobal('self', { - addEventListener: (_type: string, handler: (e: MessageEvent) => void) => { - messageListeners.push(handler); - }, - postMessage: (msg: unknown) => { - posted.push(msg); - }, - location: { origin: '' }, -}); -vi.stubGlobal('navigator', { hardwareConcurrency: 4 }); - -function sendMessage(data: unknown) { - const event = { data, origin: '' } as MessageEvent; - for (const listener of messageListeners) listener(event); -} - -// Wait for a message to appear in `posted` -function waitForResponse(timeout = 5000): Promise { - return new Promise((resolve, reject) => { - const deadline = Date.now() + timeout; - const check = () => { - if (posted.length > 0) { - resolve(posted.shift()); - } else if (Date.now() > deadline) { - reject(new Error('waitForResponse timeout')); - } else { - setTimeout(check, 20); - } - }; - check(); - }); -} - -beforeAll(async () => { - await import('../../workers/inference.worker'); - await vi.waitFor(() => messageListeners.length > 0, { timeout: 2000 }); -}); - -describe('inference.worker', () => { - beforeEach(() => { - posted.length = 0; - mockPipelineFn.mockReset(); - }); - - afterEach(() => { - posted.length = 0; - }); - - it('registers a message listener on import', () => { - expect(messageListeners.length).toBeGreaterThan(0); - }); - - it('returns inference result for text-generation', async () => { - const mockPipe = vi.fn().mockResolvedValue([{ generated_text: 'Once upon a time' }]); - mockPipelineFn.mockResolvedValue(mockPipe); - - sendMessage({ - messageId: 'msg-1', - task: 'text-generation', - modelId: 'test-model-textgen-001', - input: 'Once', - }); - - const response = (await waitForResponse()) as { - messageId: string; - ok: boolean; - result: string; - }; - expect(response.messageId).toBe('msg-1'); - expect(response.ok).toBe(true); - expect(response.result).toBe('Once upon a time'); - }); - - it('returns feature-extraction result as number[]', async () => { - const vec = new Float32Array([0.1, 0.2, 0.3]); - const mockPipe = vi.fn().mockResolvedValue(vec); - mockPipelineFn.mockResolvedValue(mockPipe); - - sendMessage({ - messageId: 'emb-1', - task: 'feature-extraction', - modelId: 'test-model-embedding-001', - input: 'Hello world', - }); - - const response = (await waitForResponse()) as { ok: boolean; result: number[] }; - expect(response.ok).toBe(true); - expect(Array.isArray(response.result)).toBe(true); - expect((response.result as number[]).length).toBe(3); - }); - - it('returns error response when pipeline throws', async () => { - mockPipelineFn.mockRejectedValue(new Error('OOM error')); - - sendMessage({ - messageId: 'err-1', - task: 'text-generation', - modelId: 'test-model-error-001', - input: 'test', - }); - - const response = (await waitForResponse()) as { ok: boolean; error: string }; - expect(response.ok).toBe(false); - expect(response.error).toContain('OOM'); - }); - - it('returns sentiment label for sentiment-analysis task', async () => { - const mockPipe = vi.fn().mockResolvedValue([{ label: 'POSITIVE', score: 0.9932 }]); - mockPipelineFn.mockResolvedValue(mockPipe); - - sendMessage({ - messageId: 'sent-1', - task: 'sentiment-analysis', - modelId: 'test-model-sentiment-001', - input: 'I love writing!', - }); - - const response = (await waitForResponse()) as { ok: boolean; result: string }; - expect(response.ok).toBe(true); - expect(response.result).toMatch(/POSITIVE/); - }); - - it('ignores WORKER_CANCEL messages without posting a response', async () => { - sendMessage({ type: 'WORKER_CANCEL', messageId: 'nonexistent-cancel-test' }); - await new Promise((r) => setTimeout(r, 100)); - expect(posted).toHaveLength(0); - }); - - it('responds with WORKER_PONG when receiving WORKER_PING', async () => { - sendMessage({ type: 'WORKER_PING' }); - const response = (await waitForResponse()) as { type: string; ts: number }; - expect(response.type).toBe('WORKER_PONG'); - expect(typeof response.ts).toBe('number'); - }); -}); diff --git a/tests/unit/localNlpService.test.ts b/tests/unit/localNlpService.test.ts index 8856b01d..0be678b5 100644 --- a/tests/unit/localNlpService.test.ts +++ b/tests/unit/localNlpService.test.ts @@ -1,163 +1,153 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// QNBS-v3: Arrow functions cannot be constructors — use hoisted vi.fn() refs + class stub. -const workerPostMessage = vi.hoisted(() => vi.fn()); -const workerAddEventListener = vi.hoisted(() => vi.fn()); -const workerRemoveEventListener = vi.hoisted(() => vi.fn()); -const workerTerminate = vi.hoisted(() => vi.fn()); - -// QNBS-v3: Class-based Worker stub so `new Worker(...)` in source doesn't throw. -class MockWorker { - postMessage = workerPostMessage; - addEventListener = workerAddEventListener; - removeEventListener = workerRemoveEventListener; - terminate = workerTerminate; -} +import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.stubGlobal('Worker', MockWorker); +// QNBS-v3: [localNlpService now routes through WorkerBus v2 — mock ensureInferencePool() instead of the global Worker constructor. Consolidates the former tests/unit/services/localNlpService.test.ts, which duplicated most of this coverage.] -import { - _resetWorkerForTest, - analyzeSentiment, - classifyWritingTopic, - summarizeText, -} from '../../services/ai/localNlpService'; +const { mockEnqueue, mockEnsureInferencePool } = vi.hoisted(() => ({ + mockEnqueue: vi.fn(), + mockEnsureInferencePool: vi.fn(), +})); -beforeEach(() => { - _resetWorkerForTest(); - workerPostMessage.mockReset(); - workerAddEventListener.mockReset(); - workerRemoveEventListener.mockReset(); -}); +vi.mock('../../services/workerBusManager', () => ({ + ensureInferencePool: mockEnsureInferencePool, +})); -afterEach(() => { - _resetWorkerForTest(); -}); +const { analyzeSentiment, classifyWritingTopic, summarizeText } = await import( + '../../services/ai/localNlpService' +); + +function makeHandle(result: Promise) { + return { taskId: 't1', result, progress: (async function* () {})(), cancel: vi.fn() }; +} -// Helper: fire the message handler captured by the last addEventListener call -function resolveWorkerMessage(responseOverride?: { - ok?: boolean; - result?: string | undefined; - error?: string; -}) { - const [, handler] = - workerAddEventListener.mock.calls[workerAddEventListener.mock.calls.length - 1] ?? []; - if (typeof handler !== 'function') throw new Error('No handler registered'); - - const lastMsg = workerPostMessage.mock.calls[workerPostMessage.mock.calls.length - 1]?.[0] as { - messageId: string; - }; - - handler({ - data: { - messageId: lastMsg?.messageId ?? 'nlp-test', - ok: true, - result: 'POSITIVE:0.95', - ...responseOverride, - }, - } as MessageEvent); +function makeBus() { + return { enqueue: mockEnqueue }; } -// ─── analyzeSentiment ────────────────────────────────────────────────────────── +let requestCalls: Array<{ + task: string; + modelId: string; + input: string; + inferenceOptions?: Record; +}> = []; +let nextResult = 'POSITIVE:0.95'; + +beforeEach(() => { + vi.clearAllMocks(); + requestCalls = []; + nextResult = 'POSITIVE:0.95'; + mockEnsureInferencePool.mockResolvedValue(makeBus()); + mockEnqueue.mockImplementation((_taskType: string, payload: unknown) => { + requestCalls.push(payload as (typeof requestCalls)[number]); + return makeHandle(Promise.resolve(nextResult)); + }); +}); + +// ─── analyzeSentiment ──────────────────────────────────────────────────────── describe('analyzeSentiment', () => { it('parses POSITIVE result correctly', async () => { - const promise = analyzeSentiment('I love writing!'); - resolveWorkerMessage({ result: 'POSITIVE:0.95' }); - const r = await promise; + nextResult = 'POSITIVE:0.95'; + const r = await analyzeSentiment('I love writing!'); expect(r.label).toBe('POSITIVE'); expect(r.score).toBeCloseTo(0.95, 3); expect(r.normalized).toBeCloseTo(0.95, 3); }); it('parses NEGATIVE result correctly', async () => { - const promise = analyzeSentiment('I hate this.'); - resolveWorkerMessage({ result: 'NEGATIVE:0.88' }); - const r = await promise; + nextResult = 'NEGATIVE:0.88'; + const r = await analyzeSentiment('I hate this.'); expect(r.label).toBe('NEGATIVE'); expect(r.score).toBeCloseTo(0.88, 3); expect(r.normalized).toBeCloseTo(-0.88, 3); }); it('parses NEUTRAL result with normalized = 0', async () => { - const promise = analyzeSentiment('The document exists.'); - resolveWorkerMessage({ result: 'NEUTRAL:0.6' }); - const r = await promise; + nextResult = 'NEUTRAL:0.6'; + const r = await analyzeSentiment('The document exists.'); expect(r.label).toBe('NEUTRAL'); expect(r.normalized).toBe(0); }); - it('returns NEUTRAL fallback on worker error', async () => { - const promise = analyzeSentiment('anything'); - resolveWorkerMessage({ ok: false, result: undefined, error: 'OOM' }); - const r = await promise; + it('returns NEUTRAL fallback when the task rejects', async () => { + mockEnqueue.mockReturnValue(makeHandle(Promise.reject(new Error('OOM')))); + const r = await analyzeSentiment('anything'); expect(r.label).toBe('NEUTRAL'); expect(r.score).toBe(0.5); expect(r.normalized).toBe(0); }); - it('returns NEUTRAL fallback on empty result', async () => { - const promise = analyzeSentiment('anything'); - resolveWorkerMessage({ ok: true, result: undefined }); - const r = await promise; - expect(r.label).toBe('NEUTRAL'); + it('caps input text to 512 chars before enqueuing', async () => { + await analyzeSentiment('x'.repeat(600)); + expect(requestCalls[0]?.input.length).toBe(512); }); - it('caps input text to 512 chars before sending', async () => { - const promise = analyzeSentiment('x'.repeat(600)); - resolveWorkerMessage(); - await promise; - const posted = workerPostMessage.mock.calls[0]?.[0] as { input: string }; - expect(posted.input.length).toBe(512); + it('maps an unknown label to NEUTRAL', async () => { + nextResult = 'GARBAGE:0.7'; + const r = await analyzeSentiment('weird text'); + expect(r.label).toBe('NEUTRAL'); + expect(r.normalized).toBe(0); }); - it('maps unknown label to NEUTRAL', async () => { - const promise = analyzeSentiment('weird text'); - resolveWorkerMessage({ result: 'GARBAGE:0.7' }); - const r = await promise; + it('returns NEUTRAL fallback when the WorkerBus pool is unavailable', async () => { + mockEnsureInferencePool.mockResolvedValue(null); + const r = await analyzeSentiment('anything'); expect(r.label).toBe('NEUTRAL'); + expect(r.score).toBe(0.5); expect(r.normalized).toBe(0); }); + + it('defaults score to 0.5 when the worker omits the ":score" suffix', async () => { + nextResult = 'POSITIVE'; + const r = await analyzeSentiment('no score suffix'); + expect(r.label).toBe('POSITIVE'); + expect(r.score).toBe(0.5); + }); + + it('enqueues inference.text with the inference.text capability', async () => { + await analyzeSentiment('test'); + expect(requestCalls[0]?.task).toBe('sentiment-analysis'); + expect(mockEnqueue).toHaveBeenCalledWith( + 'inference.text', + expect.anything(), + expect.objectContaining({ capabilities: ['inference.text'] }), + ); + }); + + it('omits the inferenceOptions key entirely when none is passed (exactOptionalPropertyTypes)', async () => { + await analyzeSentiment('test'); + expect(requestCalls).toHaveLength(1); + expect(Object.hasOwn(requestCalls[0]!, 'inferenceOptions')).toBe(false); + }); }); -// ─── summarizeText ────────────────────────────────────────────────────────── +// ─── summarizeText ──────────────────────────────────────────────────────────── describe('summarizeText', () => { - it('returns worker result on success', async () => { - const promise = summarizeText('A long piece of text about storytelling and craft.'); - resolveWorkerMessage({ result: 'Short summary.' }); - const result = await promise; + it('returns the worker result on success', async () => { + nextResult = 'Short summary.'; + const result = await summarizeText('A long piece of text about storytelling and craft.'); expect(result).toBe('Short summary.'); }); - it('falls back to text.slice(0, 280) when worker fails', async () => { + it('falls back to text.slice(0, 280) when the task rejects', async () => { + mockEnqueue.mockReturnValue(makeHandle(Promise.reject(new Error('unavailable')))); const text = 'b'.repeat(400); - const promise = summarizeText(text); - resolveWorkerMessage({ ok: false, result: undefined }); - const result = await promise; + const result = await summarizeText(text); expect(result).toBe(text.slice(0, 280)); }); - it('caps input to 1024 chars before sending to worker', async () => { - const promise = summarizeText('c'.repeat(2000)); - resolveWorkerMessage({ result: 'ok' }); - await promise; - const posted = workerPostMessage.mock.calls[0]?.[0] as { input: string }; - expect(posted.input.length).toBe(1024); + it('caps input to 1024 chars before enqueuing', async () => { + await summarizeText('c'.repeat(2000)); + expect(requestCalls[0]?.input.length).toBe(1024); }); - it('passes max_new_tokens option from maxLength param', async () => { - const promise = summarizeText('some text', 200); - resolveWorkerMessage({ result: 'ok' }); - await promise; - const posted = workerPostMessage.mock.calls[0]?.[0] as { - inferenceOptions: { max_new_tokens: number }; - }; - expect(posted.inferenceOptions.max_new_tokens).toBe(200); + it('passes max_new_tokens from the maxLength param', async () => { + await summarizeText('some text', 200); + expect(requestCalls[0]?.inferenceOptions?.['max_new_tokens']).toBe(200); }); }); -// ─── classifyWritingTopic ──────────────────────────────────────────────────── +// ─── classifyWritingTopic ───────────────────────────────────────────────────── describe('classifyWritingTopic', () => { it('returns Fantasy for fantasy-keyword text', async () => { diff --git a/tests/unit/services/localNlpService.test.ts b/tests/unit/services/localNlpService.test.ts deleted file mode 100644 index 56e38911..00000000 --- a/tests/unit/services/localNlpService.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Tests for services/ai/localNlpService.ts - * QNBS-v3: Tests classifyWritingTopic (pure keyword matching) and the worker-path functions - * with a mocked Worker that returns controlled responses. - */ - -import { afterEach, describe, expect, it, vi } from 'vitest'; - -// --------------------------------------------------------------------------- -// Mock Worker -// --------------------------------------------------------------------------- - -class MockWorker { - private handlers: ((event: MessageEvent) => void)[] = []; - - postMessage(data: { messageId: string; task: string }) { - // Simulate async response - const responseData = { - messageId: data.messageId, - ok: true, - result: 'POSITIVE:0.9', - }; - setTimeout(() => { - for (const h of this.handlers) { - h(new MessageEvent('message', { data: responseData })); - } - }, 0); - } - - addEventListener(_type: string, handler: (event: MessageEvent) => void) { - this.handlers.push(handler); - } - - removeEventListener(_type: string, handler: (event: MessageEvent) => void) { - this.handlers = this.handlers.filter((h) => h !== handler); - } - - terminate() { - this.handlers = []; - } -} - -vi.stubGlobal('Worker', MockWorker); - -// --------------------------------------------------------------------------- -// Import after mocks -// --------------------------------------------------------------------------- - -import { - _resetWorkerForTest, - analyzeSentiment, - classifyWritingTopic, -} from '../../../services/ai/localNlpService'; - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('classifyWritingTopic', () => { - it('classifies dragon-related text as Fantasy', async () => { - const result = await classifyWritingTopic('The dragon soared over the magical realm'); - expect(result).toBe('Fantasy'); - }); - - it('classifies space-related text as SciFi', async () => { - const result = await classifyWritingTopic('The spaceship launched into the alien galaxy'); - expect(result).toBe('SciFi'); - }); - - it('classifies thriller keywords', async () => { - const result = await classifyWritingTopic('The detective uncovered a conspiracy and murder'); - expect(result).toBe('Thriller'); - }); - - it('classifies romance keywords', async () => { - const result = await classifyWritingTopic( - 'Their love blossomed and they shared a kiss at the wedding', - ); - expect(result).toBe('Romance'); - }); - - it('returns General Fiction when no keywords match', async () => { - const result = await classifyWritingTopic('She walked to the store and bought bread.'); - expect(result).toBe('General Fiction'); - }); - - it('picks the genre with the most keyword matches', async () => { - // 2 Fantasy keywords vs 1 SciFi - const result = await classifyWritingTopic('A dragon and a wizard met a robot'); - expect(result).toBe('Fantasy'); - }); - - it('is case-insensitive', async () => { - const result = await classifyWritingTopic('DRAGON AND MAGIC'); - expect(result).toBe('Fantasy'); - }); -}); - -describe('analyzeSentiment', () => { - afterEach(() => { - _resetWorkerForTest(); - }); - - it('returns parsed sentiment from worker response', async () => { - const result = await analyzeSentiment('This is wonderful!'); - expect(result.label).toBe('POSITIVE'); - expect(result.score).toBe(0.9); - expect(result.normalized).toBeCloseTo(0.9); - }); -}); diff --git a/workers/inference.worker.ts b/workers/inference.worker.ts deleted file mode 100644 index 6ab6e34a..00000000 --- a/workers/inference.worker.ts +++ /dev/null @@ -1,174 +0,0 @@ -/// -// QNBS-v3: Off-main-thread inference worker for @huggingface/transformers v3 (ONNX backend). -// Uses WorkerBus request/response protocol with messageId correlation. -// Adapted from CannaGuide-2025 inference.worker.ts patterns for creative-writing tasks. - -import { PipelineLruCache } from '../services/ai/pipelineLruCache'; - -export type WorkerTaskType = - | 'text-generation' - | 'feature-extraction' - | 'sentiment-analysis' - | 'summarization'; - -export interface InferenceRequest { - messageId: string; - task: WorkerTaskType; - modelId: string; - input: string; - pipelineOptions?: { quantized?: boolean }; - inferenceOptions?: { - max_new_tokens?: number; - do_sample?: boolean; - temperature?: number; - return_full_text?: boolean; - }; -} - -export interface InferenceResponse { - messageId: string; - ok: boolean; - result?: string | number[]; - error?: string; - latencyMs?: number; -} - -// QNBS-v3: Phase 2.3 — shared LRU now disposes evicted pipelines (closes a VRAM leak) and dedups -// concurrent same-model loads. `dispose()` is best-effort; absent on some backends. -const pipelineCache = new PipelineLruCache({ - // QNBS-v3: return the (possibly async) dispose result; PipelineLruCache catches sync/async failure. - dispose: (pipe) => (pipe as { dispose?: () => void | Promise }).dispose?.(), -}); - -let transformersModule: { pipeline: (...args: unknown[]) => Promise } | null = null; - -// QNBS-v3: Lazy import — avoids loading the 2 MB transformers bundle until first inference request. -async function getTransformers() { - if (!transformersModule) { - // Dynamic import so the worker bundle is only loaded when actually needed. - const mod = await import('@huggingface/transformers'); - transformersModule = mod as unknown as typeof transformersModule; - } - return transformersModule!; -} - -// QNBS-v3: Security guard — only process messages from the same origin. -function isTrustedWorkerMessage(event: MessageEvent): boolean { - // In a dedicated worker, event.origin is '' (empty string) for same-origin posts. - // We reject messages with non-empty origins that don't match our scope. - return event.origin === '' || event.origin === globalThis.location?.origin; -} - -async function loadPipeline(task: WorkerTaskType, modelId: string, quantized = true) { - const cacheKey = `${task}::${modelId}`; - return pipelineCache.getOrLoad(cacheKey, async () => { - const { pipeline } = await getTransformers(); - // QNBS-v3: Device auto-selection — webgpu if available, else wasm. - const device = - typeof globalThis.navigator !== 'undefined' && 'gpu' in globalThis.navigator - ? 'webgpu' - : 'wasm'; - // QNBS-v3: transformers.js v3 replaced `quantized: boolean` with `dtype`; q8 ≈ old quantized=true. - return (pipeline as (task: string, model: string, opts: unknown) => Promise)( - task, - modelId, - { dtype: quantized ? 'q8' : 'fp32', device }, - ); - }); -} - -async function runInference( - req: InferenceRequest, - signal?: AbortSignal, -): Promise { - const start = Date.now(); - try { - if (signal?.aborted) throw new Error('Aborted'); - const pipe = await loadPipeline(req.task, req.modelId, req.pipelineOptions?.quantized ?? true); - if (signal?.aborted) throw new Error('Aborted'); - - const opts = req.inferenceOptions ?? {}; - let rawResult: unknown; - - if (req.task === 'feature-extraction') { - rawResult = await (pipe as (input: string, opts: unknown) => Promise)(req.input, { - pooling: 'mean', - normalize: true, - ...opts, - }); - } else { - rawResult = await (pipe as (input: string, opts: unknown) => Promise)( - req.input, - opts, - ); - } - - // Normalise result to string or number[] - let result: string | number[]; - if (req.task === 'feature-extraction') { - // Flatten Float32Array / nested array to plain number[] - const flat = rawResult as { data?: Float32Array } | Float32Array | number[]; - if (flat instanceof Float32Array) { - result = Array.from(flat); - } else if ( - 'data' in (flat as object) && - (flat as { data: Float32Array }).data instanceof Float32Array - ) { - result = Array.from((flat as { data: Float32Array }).data); - } else { - result = Array.from(flat as Iterable); - } - } else { - // text-generation, summarization, sentiment-analysis → extract text / label - const arr = rawResult as Array<{ - generated_text?: string; - summary_text?: string; - label?: string; - score?: number; - }>; - if (req.task === 'sentiment-analysis') { - result = `${arr[0]?.label ?? 'NEUTRAL'}:${(arr[0]?.score ?? 0).toFixed(4)}`; - } else { - result = arr[0]?.generated_text ?? arr[0]?.summary_text ?? ''; - } - } - - return { messageId: req.messageId, ok: true, result, latencyMs: Date.now() - start }; - } catch (err) { - return { - messageId: req.messageId, - ok: false, - error: err instanceof Error ? err.message : String(err), - latencyMs: Date.now() - start, - }; - } -} - -// QNBS-v3: In-flight AbortController map so callers can cancel long-running inference. -const abortMap = new Map(); - -self.addEventListener('message', (event: MessageEvent) => { - if (!isTrustedWorkerMessage(event)) return; - - const data = event.data as { type?: string; messageId?: string } & InferenceRequest; - - // QNBS-v3: Health check ping — reply with pong so the host can detect worker liveness. - if (data.type === 'WORKER_PING') { - self.postMessage({ type: 'WORKER_PONG', ts: Date.now() }); - return; - } - - if (data.type === 'WORKER_CANCEL' && data.messageId) { - abortMap.get(data.messageId)?.abort(); - abortMap.delete(data.messageId); - return; - } - - const controller = new AbortController(); - abortMap.set(data.messageId, controller); - - void runInference(data, controller.signal).then((response) => { - abortMap.delete(data.messageId); - self.postMessage(response); - }); -});