음성 입력 정책 공통화 (agent-input-policy) — 코드레벨 동작 흐름 PPI-1165코드레벨

마지막 업데이트 2026-08-04

작성일: 2026-08-04 대상: 개발자 — "AI가 지금 아동 말을 들어도 되는가" 판정 경로 핵심 파일: lib/voice-agent/agent-input-policy.ts · livekit-input-gate.ts · use-ai-session.ts
💬 대화로 먼저 이해하기 — "귀 스위치를 누가 켜는가" (비개발자·처음 읽는 사람용)
Q여기서 말하는 "입력"이 뭔가요?
A핑퐁이(AI)의 예요. 아동 마이크 소리를 AI에게 흘려보낼지 말지 결정하는 스위치입니다. 진행자 화면의 '듣기' 토글이 이 스위치를 만지죠. 주의할 점은 진행자 모니터로 가는 소리는 이 스위치와 무관하다는 거예요(진행자는 계속 들립니다).
Q스위치 하나면 간단한데 왜 별도 문서가 필요하죠?
A켜야 할지는 네 가지를 동시에 봐야 해요 — ① 진행자가 켜뒀는지 ② AI가 지금 말하는 중인지 ③ 끼어들기를 허용했는지 ④ 에코 제거(AEC)가 아직 안정화 중인지. 예전엔 이 판단이 런타임(LiveKit/Realtime/TTS)마다 따로 흩어져 있어서 한쪽만 조건이 빠지는 일이 있었어요.
Q조건이 빠지면 어떤 사고가 나요?
AAI가 말하는 도중에 귀가 열려서 AI가 자기 목소리를 아동 발화로 착각합니다. 그러면 스스로 끼어들어 말을 끊거나, 엉뚱한 전사가 기록돼요. 반대 방향 사고도 있어요 — 활동이 바뀐 뒤 켜져 있어야 할 귀가 안 열려서 아동이 말해도 반응이 없는 경우(PPI-1167).
Q이번 변경의 한 줄 요약은?
A스위치를 켜는 판단은 한 곳(공통 정책)에서만 하고, LiveKit·Realtime·TTS는 그 결론을 적용만 하도록 바꿨습니다. "첫 멘트라서 예외" 같은 특수 분기도 없앴어요.

TL;DR한 장 요약

PPI-1165(ae197557)는 흩어져 있던 입력 on/off 판정을 provider-neutral 순수 reducer로 모았습니다. agent-input-policy.ts듣기 의도 · AI 발화 · 끼어들기 허용 · AEC 차단 네 상태에서 effectiveInputEnabled를 단일 산출하고, LiveKit/Realtime/TTS 어댑터는 그 결과만 적용합니다. livekit-input-gate.ts는 정책 재판정을 버리고 턴 lifecycle 전담으로 축소됐습니다. conversationMode 분기와 "첫 AI 멘트" 예외는 의도적으로 만들지 않았습니다.

01왜 만들었나 — 첫 응답 중 열리던 귀

활동 전환 직후 예약된 마이크 ON이 AI 첫 응답 도중에 입력을 열어, VAD/STT가 AI 목소리를 아동 발화로 수집할 수 있었습니다.

  1. use-step-transition.ts가 듣기 의도(configured 또는 진행자 부재 fallback)가 있으면 새 세션에 scheduleMicOnAfterFirstResponse()를 예약한다.
  2. 첫 응답 경로가 AEC 안정화 300msapplyDeferredListening()applyListeningIntent()를 호출한다.
  3. LiveKit gate는 이 의도를 즉시 actual input ON으로 환원한다. 반면 Realtime 경로에는 "끼어들기 OFF && AI 발화 중이면 열지 않는다"는 조건이 별도로 있었다.
  4. 결과: 같은 상황에서 LiveKit만 첫 응답 중 입력이 열려, 진행자가 끼어들기를 껐는데도 AI가 자기 발화에 반응할 수 있었다 (2026-07-31 재현 사례).
근본 원인 형태결함의 형태는 "조건 누락"이 아니라 같은 판단이 두 군데에 중복 구현된 것입니다. 한쪽에만 조건을 더 붙이는 수정은 다음 런타임이 추가될 때 같은 사고를 반복하므로, 판단 자체를 한 곳으로 올렸습니다.
관련 선행 이슈PPI-1167은 반대 방향(켜져야 할 듣기가 복원되지 않는) 결함이었고, 원인은 진행자 '듣기' 의도가 page-session ref(정본)와 ai-session ref(세션마다 리셋되는 사본) 두 곳에 존재한 것이었습니다. PPI-1165는 그 계보의 마무리로, 의도(intent)와 실효(effective)를 타입 수준에서 분리했습니다. PPI-1167 문서 참고.

02상태 · 이벤트 · 이펙트

정책은 상태 4개 + 파생값 1개, 이벤트 4종, 이펙트 1종으로 끝나는 순수 함수입니다. 타이머·트랙·RPC는 하나도 알지 못합니다.

// lib/voice-agent/agent-input-policy.ts
interface AgentInputPolicyState {
  listeningIntent: boolean;        // 진행자 '듣기' 의도 (configured + fallback 합산 결과)
  isAiSpeaking: boolean;           // AI 발화 중
  allowInterruptions: boolean;     // 끼어들기 허용
  aecBlocked: boolean;             // AEC 안정화 대기 등으로 차단
  effectiveInputEnabled: boolean;  // ← 파생값. 어댑터가 적용하는 유일한 결론
}

function resolveEffectiveInputEnabled(state) {
  return (
    state.listeningIntent &&
    !state.aecBlocked &&
    (!state.isAiSpeaking || state.allowInterruptions)
  );
}

// 이벤트 4종 → reduceAgentInputPolicy()
listening_intent_changed | ai_speaking_changed | allow_interruptions_changed | aec_blocked_changed

// 이펙트: 결론이 '바뀔 때만' 1종 발행 (같은 값이면 빈 배열 → 중복 RPC/게인 적용 없음)
{ type: "apply_effective_input", enabled: boolean }
설계 포인트이펙트는 전이(transition)에만 발행됩니다(state.effectiveInputEnabled === next ? [] : [...]). 덕분에 상위에서 같은 이벤트를 여러 번 보내도 트랙 토글·RPC가 중복되지 않습니다. agent-input-policy.ts:51–61, 76–106

보조 판정 두 개

03진리표 — 첫 멘트는 특별하지 않다

듣기 의도AI 발화끼어들기AEC실효 입력비고
OFFfalse의도가 없으면 무조건 닫힘
ONidleON/OFF해제true정상 청취
ON발화 중ON해제true끼어들기 허용 시 열림
ON발화 중OFF해제false첫 AI 멘트도 이 행
ON차단falseAEC 안정화 대기 우선
회귀 방지 계약"첫 응답만 예외로 열어줘" 류의 요청이 오면 정책에 분기를 추가하지 말고 끼어들기 허용(allowInterruptions) 축으로 표현해야 합니다. 첫 응답 예외를 되살리는 순간 §01의 결함이 그대로 재발합니다. 진리표는 agent-input-policy.test.ts가 고정합니다.

04런타임 어댑터 — 결론을 적용만 한다

진행자 듣기 토글 · 부재 fallback
listening_intent_changed
AI 발화 시작/종료
ai_speaking_changed
끼어들기 토글
allow_interruptions_changed
AEC 300ms · defer 5s
aec_blocked_changed
effectiveInputEnabled변경 시에만 apply_effective_input
LiveKit
agentInputGain 0/1→ RPC agent.setInputEnabled
Realtime · TTS
MediaStreamTrack.enabledOFF 시 입력 버퍼 clear
턴 보존gate는 이미 accepted된 턴(activeTurnId/pendingTurnId)만 종결시킵니다. 듣기를 도중에 껐더라도 진행 중이던 턴은 버려지지 않고, 새 발화가 들어오면 이전 턴은 superseded_by_new_turn으로 정리됩니다. 최근 종결 턴 20개를 기억해 stale 이벤트를 무시합니다.

05활동 전환 · 예약 마이크 ON 경로

활동이 바뀌면 세션이 새로 뜨므로, 의도 전달은 상위(page-session)가 하고 타이밍 판단은 정책이 합니다.

근거use-ai-session.ts:133–134(상수) · use-ai-session.ts:1676–1705, 3825–3835(defer/AEC 로그) · use-step-transition.ts(configuredIntent 전달)

06판별법 — 로그로 어디서 막혔는지 보기

증상확인 지점해석
활동 전환 후 아동이 말해도 반응 없음 Defer mic: will apply after first AI response or fallback 뒤에 Defer mic: applied…가 있는지 예약만 있고 적용 로그가 없으면 의도 전달(configuredIntent) 문제, 적용됐는데도 무반응이면 어댑터/미디어 경로 문제
AI가 자기 발화에 반응해 스스로 끊김 첫 응답 구간의 실효 입력 값 · LiveKit setInputEnabled RPC 값 끼어들기 OFF인데 RPC가 true면 정책 우회 경로가 남아 있다는 뜻(§03 계약 위반)
듣기 OFF인데 전사가 남는다 gate의 turn 종결 사유(turn_committed/dropped) OFF 이전에 accepted된 턴은 정상적으로 보존·전사됩니다(설계된 동작)
진행자 모니터에서 아동 소리가 안 들림 이 정책과 무관 — 릴레이 경로 확인 agentInputGain은 AI 입력만 줄입니다. 릴레이 무음은 브리지 재발행 계열 이슈

함정 · 주의

파일 · 라인 레퍼런스

파일역할
lib/voice-agent/agent-input-policy.ts상태·이벤트·reducer·보조 판정 (정책 단일 소스)
lib/voice-agent/agent-input-policy.test.ts진리표 및 전이 회귀 고정
lib/voice-agent/livekit-input-gate.ts턴 lifecycle · stale 보호 · disconnect 정리 (정책 위임)
entities/guest-session/model/use-ai-session.ts정책 이벤트 발행 · 어댑터 적용 · AEC/defer 타이머
entities/guest-page-session/model/use-step-transition.ts활동 전환 시 configuredIntent 전달 · 예약
apps/livekit-agent/input_gate.py · agent.py워커 측 gate — RPC 결과 적용 및 턴 감사
docs/plans/livekit-input-policy.md설계 계획서(결정 사유·검증 매트릭스)

관련 문서