fix(voice): stop mic-test ref stealing, unblock action queue, keep tail audio, and move context/LLM routing behind ports and pure policies

This commit is contained in:
Yun Chan 2026-09-28 02:16:16 +09:00
parent 2cd462333f
commit 2f94d24c99
13 changed files with 1672 additions and 353 deletions

View file

@ -0,0 +1,68 @@
// packages/core/__tests__/voice-llm-route.test.ts
// 받아쓰기 LLM 경로 결정 우선순위 표 테스트 (override > chain > instruction > action > none).
import { describe, expect, it } from 'vitest'
import { resolveVoiceLlmRoute, routeUsesLlm, type VoiceLlmRouteInput } from '../src/voice-llm-route'
const base: VoiceLlmRouteInput = {
defaultAction: 'refine',
overrideInstructionId: null,
activeChainId: null,
activeInstructionId: null,
localLlmAvailable: true,
backend: 'local',
}
function route(patch: Partial<VoiceLlmRouteInput>): ReturnType<typeof resolveVoiceLlmRoute> {
return resolveVoiceLlmRoute({ ...base, ...patch })
}
describe('resolveVoiceLlmRoute — 우선순위 표', () => {
it.each([
// [설명, 입력, 기대 경로]
['override 는 체인보다 앞선다', { defaultAction: 'chain', activeChainId: 'c1', overrideInstructionId: 'i9' }, { kind: 'instruction', instructionId: 'i9', source: 'override' }],
['override 는 none 을 이긴다', { defaultAction: 'none', overrideInstructionId: 'i9' }, { kind: 'instruction', instructionId: 'i9', source: 'override' }],
['override 는 일반 액션을 이긴다', { defaultAction: 'translate', overrideInstructionId: 'i9' }, { kind: 'instruction', instructionId: 'i9', source: 'override' }],
['override 는 활성 지시문보다 앞선다', { defaultAction: 'custom', activeInstructionId: 'i1', overrideInstructionId: 'i9' }, { kind: 'instruction', instructionId: 'i9', source: 'override' }],
['활성 체인이 있으면 체인', { defaultAction: 'chain', activeChainId: 'c1' }, { kind: 'chain', chainId: 'c1' }],
['체인인데 활성 체인이 없으면 일반 액션 chain (기존 동작)', { defaultAction: 'chain', activeChainId: null }, { kind: 'action', action: 'chain' }],
['custom 은 활성 지시문', { defaultAction: 'custom', activeInstructionId: 'i1' }, { kind: 'instruction', instructionId: 'i1', source: 'active' }],
['custom 인데 활성 지시문이 없으면 지시문 없이 custom', { defaultAction: 'custom', activeInstructionId: '' }, { kind: 'instruction', instructionId: null, source: 'active' }],
['활성 지시문은 일반 액션을 덮어쓰지 않는다', { defaultAction: 'refine', activeInstructionId: 'i1' }, { kind: 'action', action: 'refine' }],
['translate 는 일반 액션', { defaultAction: 'translate' }, { kind: 'action', action: 'translate' }],
['none 은 원문 삽입', { defaultAction: 'none' }, { kind: 'insert-raw', reason: 'none' }],
] as const)('%s', (_label, patch, expected) => {
expect(route(patch as Partial<VoiceLlmRouteInput>)).toEqual(expected)
})
})
describe('resolveVoiceLlmRoute — 로컬 LLM 가용성', () => {
it('로컬 백엔드에서 LLM 을 쓸 수 없으면 사유를 밝혀 원문 삽입', () => {
expect(route({ localLlmAvailable: false })).toEqual({ kind: 'insert-raw', reason: 'llm-unavailable' })
expect(route({ localLlmAvailable: false, overrideInstructionId: 'i9' })).toEqual({
kind: 'insert-raw',
reason: 'llm-unavailable',
})
expect(route({ localLlmAvailable: false, defaultAction: 'chain', activeChainId: 'c1' })).toEqual({
kind: 'insert-raw',
reason: 'llm-unavailable',
})
})
it("none 은 LLM 가용성과 무관하게 'none' 사유다 (경고 없음)", () => {
expect(route({ defaultAction: 'none', localLlmAvailable: false })).toEqual({ kind: 'insert-raw', reason: 'none' })
})
it('온라인 백엔드는 로컬 가용성을 보지 않는다', () => {
expect(route({ backend: 'online', localLlmAvailable: false })).toEqual({ kind: 'action', action: 'refine' })
expect(route({ backend: undefined, localLlmAvailable: false })).toEqual({ kind: 'action', action: 'refine' })
})
})
describe('routeUsesLlm', () => {
it('원문 삽입만 LLM 을 쓰지 않는다', () => {
expect(routeUsesLlm({ kind: 'insert-raw', reason: 'none' })).toBe(false)
expect(routeUsesLlm({ kind: 'chain', chainId: 'c' })).toBe(true)
expect(routeUsesLlm({ kind: 'action', action: 'refine' })).toBe(true)
})
})

View file

@ -0,0 +1,89 @@
// packages/core/src/voice-llm-route.ts
// 받아쓰기 세션의 LLM 후처리 경로 결정 — 순수 함수만 둔다 (IO·설정 저장소 의존 없음).
//
// 예전엔 같은 결정을 VoiceModeService._transcribe(스킵 여부)와 _processWithLLM(무엇을 실행할지)이
// 서로 다른 규칙으로 두 번 내렸다. 두 사본이 어긋나 '체인' 기본 액션에서 음성 단축키가 지목한
// 지시문이 조용히 무시됐다. 이제 경로는 여기서 한 번만 정하고, 서비스는 결과에 따라 IO 만 실행한다.
//
// 우선순위: 음성 단축키(override) > 체인 > 지시문(custom) > 일반 액션 > none
// - 지시문 경로는 기본 액션이 'custom' 이거나 override 가 있을 때만 탄다.
// (활성 지시문이 있어도 refine/translate 등 일반 액션을 덮어쓰지 않는다)
// - 체인을 골랐는데 활성 체인이 없으면 일반 액션 'chain' 으로 LLM 에 넘긴다(기존 동작 유지).
// - 로컬 백엔드인데 로컬 LLM 을 쓸 수 없으면 원문 삽입으로 떨어지고, 그 이유를 밝힌다
// (이 경우에만 호출자가 경고를 띄운다).
import type { LLMAction, LLMActionSelection } from './types'
export type VoiceLlmBackend = 'local' | 'online'
export interface VoiceLlmRouteInput {
/** 설정의 기본 후처리 액션 */
defaultAction: LLMActionSelection
/** 음성 단축키가 지목한 지시문 id (없으면 null) */
overrideInstructionId: string | null
/** 활성 체인 id */
activeChainId: string | null
/** 활성 지시문 id ('custom' 기본 액션에서만 쓴다) */
activeInstructionId: string | null
/** 로컬 LLM 사용 가능 여부 */
localLlmAvailable: boolean
/** LLM 백엔드 — 알 수 없으면 undefined (가용성 검사를 건너뛴다) */
backend: VoiceLlmBackend | undefined
}
export type VoiceLlmRoute =
/** LLM 없이 원문을 삽입한다 */
| { kind: 'insert-raw'; reason: 'none' | 'llm-unavailable' }
/** 활성 체인을 실행한다 */
| { kind: 'chain'; chainId: string }
/** 지시문('custom')으로 처리한다. instructionId 가 null 이면 지시문 없이 custom 처리 */
| { kind: 'instruction'; instructionId: string | null; source: 'override' | 'active' }
/** 일반 액션으로 처리한다 */
| { kind: 'action'; action: Exclude<LLMAction, 'custom'> }
function nonEmpty(value: string | null | undefined): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
/** LLM 이 필요한 경로를 고른다 (가용성 검사 전). */
function resolveDesiredRoute(input: VoiceLlmRouteInput): VoiceLlmRoute {
const override = nonEmpty(input.overrideInstructionId)
if (override) {
// 명시적 음성 명령은 기본 설정(none·chain 포함)이 무효화하지 않는다
return { kind: 'instruction', instructionId: override, source: 'override' }
}
switch (input.defaultAction) {
case 'none':
return { kind: 'insert-raw', reason: 'none' }
case 'chain': {
const chainId = nonEmpty(input.activeChainId)
return chainId ? { kind: 'chain', chainId } : { kind: 'action', action: 'chain' }
}
case 'custom':
return {
kind: 'instruction',
instructionId: nonEmpty(input.activeInstructionId),
source: 'active',
}
default:
return { kind: 'action', action: input.defaultAction }
}
}
/** 이 경로가 LLM 호출을 필요로 하는가 */
export function routeUsesLlm(route: VoiceLlmRoute): boolean {
return route.kind !== 'insert-raw'
}
/**
* 받아쓰기 한 건의 LLM 후처리 경로를 결정한다.
*/
export function resolveVoiceLlmRoute(input: VoiceLlmRouteInput): VoiceLlmRoute {
const desired = resolveDesiredRoute(input)
if (!routeUsesLlm(desired)) return desired
if (input.backend === 'local' && !input.localLlmAvailable) {
return { kind: 'insert-raw', reason: 'llm-unavailable' }
}
return desired
}