fix(templates): route dictation results into active template sessions

This commit is contained in:
Yun Chan 2026-09-28 02:16:19 +09:00
parent 11bf995409
commit 115e8488ac
7 changed files with 386 additions and 15 deletions

View file

@ -14,6 +14,9 @@ import {
type UserScopedCollectionFactory,
} from './account-scope'
import { getMainWindow } from '../windows/WindowManager'
import { registerVoiceTextSink } from '../voice-text-routing'
import { dictatedFieldValue, nextTemplateField, renderTemplateOutput } from './dictation-template-policy'
import { textInsertTemplateOutputPort, type TemplateOutputPort } from './dictation-template-output'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type {
@ -80,9 +83,15 @@ class DictationTemplateService extends EventEmitter {
private _items: DictationTemplate[] = []
private readonly _unsubscribeScope: () => void
private _session: TemplateSessionInfo | null = null
private readonly _output: TemplateOutputPort
private readonly _unregisterVoiceSink: () => void
constructor(factory: UserScopedCollectionFactory = collectionFactory) {
constructor(
factory: UserScopedCollectionFactory = collectionFactory,
output: TemplateOutputPort = outputPort,
) {
super()
this._output = output
this._list = new AccountScopedList<DictationTemplate>({
name: 'dictation-templates',
factory,
@ -90,6 +99,8 @@ class DictationTemplateService extends EventEmitter {
})
this._loadScope(currentAccountScope())
this._unsubscribeScope = onAccountScopeChanged((scopeId) => this._loadScope(scopeId))
// 진행 중인 템플릿 세션이 있으면 받아쓰기 결과를 현재 필드 값으로 가져간다
this._unregisterVoiceSink = registerVoiceTextSink(({ text }) => this.consumeDictatedText(text))
}
// ── CRUD ──
@ -227,7 +238,7 @@ class DictationTemplateService extends EventEmitter {
const currentField = this._session.currentField
const nextIndex = this._session.currentFieldIndex + 1
const nextField = nextIndex < template.fields.length ? template.fields[nextIndex] : null
const nextField = nextTemplateField(template, this._session.currentFieldIndex)
// 필드 완료 이벤트
const fieldEvent: TemplateFieldCompletedEvent = {
@ -251,6 +262,17 @@ class DictationTemplateService extends EventEmitter {
}
}
/**
* 받아쓰기 결과를 현재 필드 값으로 받는다. 필드 입력 대기 중인 세션이 없거나 결과가 비었으면
* 아무것도 하지 않고 false를 돌려준다 — 그 결과는 일반 삽입 경로로 간다.
*/
consumeDictatedText(text: string): boolean {
const target = dictatedFieldValue(this._session, text)
if (!target) return false
this.setFieldValue(target.fieldId, target.value)
return true
}
cancelSession(): void {
if (!this._session) return
logger.info('Template session cancelled')
@ -264,17 +286,10 @@ class DictationTemplateService extends EventEmitter {
this._session.state = 'completing'
this._emitSessionState()
// 출력 포맷 적용 (mustache-like 치환)
let output = template.outputFormat
for (const [key, value] of Object.entries(this._session.fieldValues)) {
output = output.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
}
// 미입력 필드 자리표시자 제거
output = output.replace(/\{\{[^}]+\}\}/g, '')
// 출력 포맷 적용 (mustache-like 치환, 미입력 자리표시자 제거)
const completedEvent: TemplateSessionCompletedEvent = {
templateId: template.id,
outputText: output.trim(),
outputText: renderTemplateOutput(template.outputFormat, this._session.fieldValues),
fieldValues: { ...this._session.fieldValues },
}
@ -283,6 +298,11 @@ class DictationTemplateService extends EventEmitter {
this._session = null
logger.info(`Template session completed: ${template.name}`)
// 완성된 결과를 활성 앱에 전달한다 — 실패해도 세션은 이미 끝났다(렌더러가 결과를 보여 준다)
this._output.deliver(completedEvent.outputText).catch((err: unknown) => {
logger.warn(`Template output delivery failed: ${err instanceof Error ? err.message : String(err)}`)
})
}
private _emitSessionState(): void {
@ -334,6 +354,7 @@ class DictationTemplateService extends EventEmitter {
dispose(): void {
this._unsubscribeScope()
this._unregisterVoiceSink()
this.cancelSession()
this.removeAllListeners()
}
@ -351,6 +372,7 @@ function readLegacyTemplates(): DictationTemplate[] | null {
}
let collectionFactory: UserScopedCollectionFactory = electronStoreCollectionFactory
let outputPort: TemplateOutputPort = textInsertTemplateOutputPort
// ── 싱글톤 ──
let instance: DictationTemplateService | null = null
@ -363,9 +385,11 @@ export function getDictationTemplateService(): DictationTemplateService {
}
export function resetDictationTemplateServiceForTests(
factory: UserScopedCollectionFactory = electronStoreCollectionFactory
factory: UserScopedCollectionFactory = electronStoreCollectionFactory,
output: TemplateOutputPort = textInsertTemplateOutputPort,
): void {
instance?.dispose()
instance = null
collectionFactory = factory
outputPort = output
}

View file

@ -0,0 +1,19 @@
// 받아쓰기 템플릿 결과 전달 포트. 기본 구현은 활성 앱에 텍스트를 삽입한다.
// 서비스는 이 인터페이스에만 기대고, 테스트는 가짜 포트를 넣는다.
export interface TemplateOutputPort {
/** 완성된 템플릿 결과를 사용자에게 전달한다. 실패하면 reject한다. */
deliver(text: string): Promise<void>
}
/** 일반 받아쓰기와 같은 설정(autoInsert, insertMethod)으로 TextInsertService를 통해 삽입한다. */
export const textInsertTemplateOutputPort: TemplateOutputPort = {
async deliver(text: string): Promise<void> {
if (text.length === 0) return
// 지연 import — 네이티브 키보드 모듈을 템플릿 CRUD 경로에서 끌어오지 않는다
const { configGet } = await import('./ConfigService')
if (!configGet('autoInsert')) return
const { getTextInsertService } = await import('./TextInsertService')
await getTextInsertService().insertText(text, configGet('insertMethod'))
},
}

View file

@ -0,0 +1,37 @@
// 받아쓰기 템플릿 순수 정책 — IO 없음. DictationTemplateService와 테스트가 함께 쓴다.
import type { DictationTemplate, TemplateField, TemplateSessionInfo } from '@d3ro/core/types'
const PLACEHOLDER = /\{\{([^}]+)\}\}/g
/**
* 출력 포맷의 {{필드 id}} 자리표시자를 값으로 바꾼다. 값이 없는 자리표시자는 지운다.
* 한 번의 치환으로 처리하므로 값 안의 `$&`, `{{...}}`, 정규식 특수문자가 든 필드 id도 그대로 다룬다.
*/
export function renderTemplateOutput(format: string, fieldValues: Readonly<Record<string, string>>): string {
return format
.replace(PLACEHOLDER, (_match, key: string) =>
Object.prototype.hasOwnProperty.call(fieldValues, key) ? fieldValues[key] : '',
)
.trim()
}
/** 현재 필드 다음 필드. 마지막이면 null. */
export function nextTemplateField(template: DictationTemplate, currentIndex: number): TemplateField | null {
const nextIndex = currentIndex + 1
return nextIndex < template.fields.length ? template.fields[nextIndex] : null
}
/**
* 받아쓰기 결과를 현재 필드 값으로 받을지 판단한다. 받을 수 없으면 null.
* 세션이 필드 입력 대기 중이고, 공백을 뺀 결과가 비어 있지 않을 때만 받는다.
*/
export function dictatedFieldValue(
session: TemplateSessionInfo | null,
text: string,
): { fieldId: string; value: string } | null {
if (!session || session.state !== 'field-prompting' || !session.currentField) return null
const value = text.trim()
if (value.length === 0) return null
return { fieldId: session.currentField.id, value }
}

View file

@ -1,10 +1,12 @@
// VoiceMode session-completed → History 저장. bootstrap과 테스트가 같은 함수를 호출한다.
// VoiceMode session-completed → 결과 라우팅(템플릿 필드 등) + History 저장.
// bootstrap과 테스트가 같은 함수를 호출한다.
import { join } from 'path'
import { app } from 'electron'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { getHistoryService } from './services/HistoryService'
import { getLogger } from './services/LoggerService'
import { routeCompletedVoiceText } from './voice-text-routing'
import type { HistoryEntry } from '@d3ro/core/types'
const logger = getLogger('voice-persist')
@ -37,11 +39,21 @@ export function persistCompletedVoiceSession(
})
}
/**
* 완료된 받아쓰기 결과를 소비자(진행 중인 받아쓰기 템플릿 세션의 현재 필드 등)에게 넘긴다.
* 소비되면 true — 일반 삽입 경로는 isVoiceSessionClaimed(session.id)로 이를 확인한다.
*/
export function routeCompletedVoiceSession(session: PersistableVoiceSession, finalText: string): boolean {
return routeCompletedVoiceText(session.id, finalText)
}
export function persistCompletedVoiceSessionSafe(
emitError: (error: D3ROError, session: PersistableVoiceSession) => void,
session: PersistableVoiceSession,
finalText: string,
): HistoryEntry | null {
// 히스토리 저장이 실패해도 결과는 소비자에게 전달돼야 하므로 먼저 라우팅한다
routeCompletedVoiceSession(session, finalText)
try {
return persistCompletedVoiceSession(session, finalText)
} catch (error) {

View file

@ -0,0 +1,62 @@
// 받아쓰기 결과 라우팅 포트.
// VoiceMode 세션이 끝나면 전사 결과를 여기로 넘긴다. 등록된 소비자(sink)가 결과를 가져가면
// (예: 진행 중인 받아쓰기 템플릿 세션의 현재 필드) 그 세션은 "소비됨"으로 기록된다.
// 일반 삽입 경로는 isVoiceSessionClaimed(sessionId)로 소비 여부를 확인하고 삽입을 건너뛸 수 있다.
// 이 모듈은 소비자 구현을 모른다 — 소비자가 스스로 등록한다(의존성 역전).
import { getLogger } from './services/LoggerService'
const logger = getLogger('voice-text-routing')
export interface VoiceTextOffer {
sessionId: string
text: string
}
/** 결과를 가져가면 true. 가져가지 않으면 false — 다음 소비자에게 넘어간다. */
export type VoiceTextSink = (offer: VoiceTextOffer) => boolean
/** 소비 기록 보존 개수 — 최근 세션만 기억하면 된다. */
const MAX_CLAIMED_SESSIONS = 32
const sinks = new Set<VoiceTextSink>()
const claimedSessions: string[] = []
export function registerVoiceTextSink(sink: VoiceTextSink): () => void {
sinks.add(sink)
return () => {
sinks.delete(sink)
}
}
/**
* 완료된 받아쓰기 결과를 소비자에게 제안한다. 같은 세션을 두 번 넘겨도 한 번만 소비된다.
* @returns 소비자가 가져갔으면 true
*/
export function routeCompletedVoiceText(sessionId: string, text: string): boolean {
if (claimedSessions.includes(sessionId)) return true
for (const sink of [...sinks]) {
let claimed = false
try {
claimed = sink({ sessionId, text })
} catch (err) {
logger.warn(`Voice text sink failed: ${err instanceof Error ? err.message : String(err)}`)
}
if (claimed) {
claimedSessions.push(sessionId)
if (claimedSessions.length > MAX_CLAIMED_SESSIONS) claimedSessions.shift()
return true
}
}
return false
}
/** 이 세션의 결과를 소비자가 가져갔는지 — 일반 삽입을 건너뛸지 판단할 때 쓴다. */
export function isVoiceSessionClaimed(sessionId: string): boolean {
return claimedSessions.includes(sessionId)
}
export function resetVoiceTextRoutingForTests(): void {
sinks.clear()
claimedSessions.length = 0
}

View file

@ -21,6 +21,8 @@ export function TemplateSection(): React.ReactElement {
const [formOutput, setFormOutput] = useState('')
const [formFields, setFormFields] = useState<TemplateField[]>([])
const [session, setSession] = useState<TemplateSessionInfo | null>(null)
/** 마지막으로 완성된 템플릿 결과 — 활성 앱 삽입이 꺼져 있거나 실패해도 복사할 수 있게 보여 준다 */
const [lastOutput, setLastOutput] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const isSavingRef = useRef(false)
@ -50,8 +52,9 @@ export function TemplateSection(): React.ReactElement {
const unsub = window.electronAPI.dictationTemplate.onSessionStateChanged((data) => {
setSession(data)
})
const unsubComplete = window.electronAPI.dictationTemplate.onSessionCompleted(() => {
const unsubComplete = window.electronAPI.dictationTemplate.onSessionCompleted((data) => {
setSession(null)
setLastOutput(data.outputText.length > 0 ? data.outputText : null)
loadData()
})
return () => { unsub(); unsubComplete() }
@ -118,9 +121,14 @@ export function TemplateSection(): React.ReactElement {
}
const handleStartSession = async (templateId: string) => {
setLastOutput(null)
await window.electronAPI.dictationTemplate.startSession({ templateId })
}
const handleCopyOutput = () => {
if (lastOutput) void navigator.clipboard.writeText(lastOutput)
}
const handleCancelSession = async () => {
await window.electronAPI.dictationTemplate.cancelSession()
setSession(null)
@ -176,6 +184,29 @@ export function TemplateSection(): React.ReactElement {
{t('template.cancelSession')}
</PhysicalButton>
</Box>
{/* 현재 필드의 음성 프롬프트 — 받아쓰기 결과가 이 필드 값으로 들어간다 */}
{session.currentField?.promptText && (
<PhosphorText variant="dim" sx={{ mt: 1 }}>
{session.currentField.promptText}
</PhosphorText>
)}
</MetalCard>
)}
{/* 마지막 완성 결과 */}
{!session && lastOutput && (
<MetalCard sx={{ mb: 2 }}>
<PhosphorText variant="body" sx={{ whiteSpace: 'pre-wrap' }}>
{lastOutput}
</PhosphorText>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>
<PhysicalButton size="small" onClick={handleCopyOutput}>
{t('common.copy')}
</PhysicalButton>
<PhysicalButton size="small" onClick={() => setLastOutput(null)}>
{t('common.close')}
</PhysicalButton>
</Box>
</MetalCard>
)}
@ -311,7 +342,7 @@ export function TemplateSection(): React.ReactElement {
{t('common.cancel')}
</Button>
<Button onClick={handleSave} variant="contained" disabled={!formName.trim() || formFields.length === 0 || saving}>
{saving ? t('common.saving') || '저장 중...' : t('common.save')}
{saving ? t('common.saving') : t('common.save')}
</Button>
</DialogActions>
</Dialog>

View file

@ -0,0 +1,186 @@
// 받아쓰기 템플릿 세션 배선 회귀 테스트 (redteam r2-15)
// 버그: 템플릿 세션을 시작해도 받아쓰기 결과가 필드로 들어가지 않아 세션이 영구히 남았다.
// 이제 VoiceMode 완료 경로(persistCompletedVoiceSessionSafe)가 결과를 템플릿 세션으로 라우팅하고,
// 완성된 결과는 출력 포트로 전달된다.
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
getDictationTemplateService,
resetDictationTemplateServiceForTests,
} from '../../src/main/services/DictationTemplateService'
import type { TemplateOutputPort } from '../../src/main/services/dictation-template-output'
import {
dictatedFieldValue,
nextTemplateField,
renderTemplateOutput,
} from '../../src/main/services/dictation-template-policy'
import {
isVoiceSessionClaimed,
registerVoiceTextSink,
resetVoiceTextRoutingForTests,
routeCompletedVoiceText,
} from '../../src/main/voice-text-routing'
import {
persistCompletedVoiceSessionSafe,
type PersistableVoiceSession,
} from '../../src/main/voice-session-persist'
import { getHistoryService } from '../../src/main/services/HistoryService'
import { useRedHarness } from './harness'
import type { DictationTemplate, TemplateSessionInfo } from '@d3ro/core/types'
useRedHarness()
afterEach(() => {
resetVoiceTextRoutingForTests()
})
function voiceSession(id: string, text: string): PersistableVoiceSession {
return { id, transcription: text, processedText: null, mode: 'dictation', startedAt: Date.now() }
}
function fakeOutput(): TemplateOutputPort & { delivered: string[] } {
const delivered: string[] = []
return {
delivered,
deliver: vi.fn(async (text: string) => {
delivered.push(text)
}),
}
}
function completeVoice(id: string, text: string): void {
persistCompletedVoiceSessionSafe(() => undefined, voiceSession(id, text), text)
}
describe('받아쓰기 템플릿 세션 ← VoiceMode 완료 배선', () => {
it('세션 중 받아쓰기 결과가 필드를 차례로 채우고, 완성 결과가 출력 포트로 전달된다', async () => {
const output = fakeOutput()
resetDictationTemplateServiceForTests(undefined, output)
const svc = getDictationTemplateService()
const completed: string[] = []
svc.on('session-completed', (e: { outputText: string }) => completed.push(e.outputText))
svc.startSession('builtin-email')
completeVoice('r2-15-a', ' kim@example.test ')
expect(svc.getSessionState()?.currentField?.id).toBe('subject')
completeVoice('r2-15-b', 'Quarterly plan')
expect(svc.getSessionState()?.currentField?.id).toBe('body')
completeVoice('r2-15-c', 'See attached.')
expect(svc.getSessionState()).toBeNull()
expect(completed).toEqual(['To: kim@example.test\nSubject: Quarterly plan\n\nSee attached.'])
await vi.waitFor(() => expect(output.delivered).toEqual(completed))
// 소비된 세션은 일반 삽입 경로가 건너뛸 수 있도록 표시된다
expect(isVoiceSessionClaimed('r2-15-a')).toBe(true)
expect(isVoiceSessionClaimed('r2-15-c')).toBe(true)
// 히스토리 저장은 그대로 일어난다
expect(getHistoryService().list({ page: 0, pageSize: 10 }).total).toBe(3)
})
it('템플릿 세션이 없으면 결과를 가져가지 않는다 (일반 삽입 경로 유지)', () => {
resetDictationTemplateServiceForTests(undefined, fakeOutput())
getDictationTemplateService()
completeVoice('r2-15-none', 'plain dictation')
expect(isVoiceSessionClaimed('r2-15-none')).toBe(false)
expect(getHistoryService().list({ page: 0, pageSize: 10 }).total).toBe(1)
})
it('빈 받아쓰기 결과는 필드를 채우지 않는다', () => {
resetDictationTemplateServiceForTests(undefined, fakeOutput())
const svc = getDictationTemplateService()
svc.startSession('builtin-email')
expect(routeCompletedVoiceText('r2-15-empty', ' ')).toBe(false)
expect(svc.getSessionState()?.currentField?.id).toBe('recipient')
})
it('같은 VoiceMode 세션을 두 번 라우팅해도 필드는 한 번만 채워진다', () => {
resetDictationTemplateServiceForTests(undefined, fakeOutput())
const svc = getDictationTemplateService()
svc.startSession('builtin-email')
expect(routeCompletedVoiceText('r2-15-dup', 'a@b.test')).toBe(true)
expect(routeCompletedVoiceText('r2-15-dup', 'a@b.test')).toBe(true)
expect(svc.getSessionState()?.currentField?.id).toBe('subject')
expect(svc.getSessionState()?.fieldValues).toEqual({ recipient: 'a@b.test' })
})
it('서비스를 dispose하면 소비자 등록이 풀린다', () => {
resetDictationTemplateServiceForTests(undefined, fakeOutput())
const svc = getDictationTemplateService()
svc.startSession('builtin-email')
resetDictationTemplateServiceForTests(undefined, fakeOutput())
expect(routeCompletedVoiceText('r2-15-disposed', 'text')).toBe(false)
})
it('출력 전달이 실패해도 세션은 끝나고 예외가 새지 않는다', async () => {
const deliver = vi.fn(async () => {
throw new Error('insert failed')
})
resetDictationTemplateServiceForTests(undefined, { deliver })
const svc = getDictationTemplateService()
const created = svc.create({
name: 'one',
description: '',
fields: [{ id: 'f1', name: 'f1', label: 'F1', promptText: '', required: true, maxDurationSec: 10 }],
outputFormat: '[{{f1}}]',
})
svc.startSession(created.id)
expect(svc.consumeDictatedText('hello')).toBe(true)
expect(svc.getSessionState()).toBeNull()
await vi.waitFor(() => expect(deliver).toHaveBeenCalledWith('[hello]'))
})
})
describe('라우팅 포트', () => {
it('던지는 소비자는 건너뛰고 다음 소비자에게 넘긴다', () => {
registerVoiceTextSink(() => {
throw new Error('boom')
})
const seen: string[] = []
registerVoiceTextSink(({ text }) => {
seen.push(text)
return true
})
expect(routeCompletedVoiceText('r2-15-sink', 'x')).toBe(true)
expect(seen).toEqual(['x'])
})
})
describe('받아쓰기 템플릿 정책', () => {
it('자리표시자를 값으로 바꾸고 미입력 자리표시자는 지운다', () => {
expect(renderTemplateOutput('To: {{a}}\n{{b}}\n', { a: 'x' })).toBe('To: x')
})
it('값 안의 치환 패턴($&)과 중괄호는 그대로 남는다', () => {
expect(renderTemplateOutput('{{a}}|{{b}}', { a: 'cost $& more', b: '{{a}}' })).toBe('cost $& more|{{a}}')
})
it('정규식 특수문자가 든 필드 id도 치환된다', () => {
expect(renderTemplateOutput('<{{a.b(1)}}>', { 'a.b(1)': 'ok' })).toBe('<ok>')
})
it('다음 필드와 필드 값 판정', () => {
const template = {
fields: [
{ id: 'f1', name: 'f1', label: 'F1', promptText: '', required: true, maxDurationSec: 1 },
{ id: 'f2', name: 'f2', label: 'F2', promptText: '', required: true, maxDurationSec: 1 },
],
} as unknown as DictationTemplate
expect(nextTemplateField(template, 0)?.id).toBe('f2')
expect(nextTemplateField(template, 1)).toBeNull()
const session: TemplateSessionInfo = {
templateId: 't',
templateName: 'T',
state: 'field-prompting',
currentFieldIndex: 0,
totalFields: 2,
currentField: template.fields[0],
fieldValues: {},
}
expect(dictatedFieldValue(session, ' hi ')).toEqual({ fieldId: 'f1', value: 'hi' })
expect(dictatedFieldValue(session, ' ')).toBeNull()
expect(dictatedFieldValue({ ...session, state: 'completing' }, 'hi')).toBeNull()
expect(dictatedFieldValue(null, 'hi')).toBeNull()
})
})