// 받아쓰기 결과 라우팅 포트. // 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() 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 }