김리호 18회기 "대화시작" AI 무응답 — 끝난 영상의 재생 이벤트가 시작 응답을 취소 분석수정 제안 · 미적용

마지막 업데이트 2026-10-07

2026-10-07 작성 · 같은 날 정정: "늦게 온 ended"는 원인이 아님 수업 2026-10-06 18:25~18:57 KST · roomId 9c9c3d65…_18 apps/web (아동 화면) 코드 기준 ppi develop@152976a7 (ppi-web@3.4.7)

1. 신고 내용과 결론

신고: "대화시작" 텍스트가 전송된 뒤 AI 발화가 생성되지 않고 무응답. 진행자가 "답답함"을 입력하자 그제야 대화시작 멘트를 말함.

결론: 서버나 AI 모델 문제가 아니라 아동 화면 코드가 시작 응답을 스스로 취소했다. 영상 스텝이 끝나 AI 스텝으로 넘어간 직후, 숨겨진 영상 요소가 끝난 영상을 0초부터 다시 재생했다. 이 재생 이벤트를 "영상 스텝 시작"으로 처리해 cancelResponse()가 불렸고, 방금 보낸 "대화시작"의 응답이 LiveKit interrupt로 끊겼다.

원인 층: ① 직접 원인 — handleVideoStart가 현재 스텝이 영상 스텝인지 확인하지 않고 응답을 취소한다. ② 계기 — 아동 화면의 isPlaying이 항상 true라 끝나서 멈춘 영상이 비영상 스텝에서도 다시 재생된다. ③ 드러난 이유(추정) — PPI-1351 이후 시작 메시지가 잘못된 취소보다 먼저 나가 실제 응답에 걸린다(5절).

같은 회기에서 같은 일이 3번 일어났다 (18:41:24, 18:46:07, 18:47:52). "영상 → AI 스텝" 전환 4번 중 3번이라 드문 경쟁 상태가 아니라 이 구조에서 반복되는 버그다.

"대화시작"은 대화 기록에 남아 있었다. 그래서 진행자의 다음 텍스트("답답함")에 대한 첫 응답(response-1)이 대화시작 멘트가 됐다.

"늦게 도착한 ended 이벤트 때문"이 아니다. 영상이 near-end/watchdog으로 일찍 종료 처리돼 ended가 스텝 전환 뒤에 오는 경우가 많지만 필수 조건이 아니다. 18:33 건은 ended가 시작 메시지보다 먼저 왔는데도 똑같이 다시 재생됐고, 18:47 건은 ended 이벤트 처리 전에 재생 요청이 먼저 나갔다(4절). 다시 재생은 "영상이 끝나 멈춘 상태 + isPlaying 항상 true"면 스텝 전환 렌더 때 언제든 일어난다.

interrupt 이유가 host_cancel_ai_response로 찍혔지만 진행자가 누른 것이 아니다. 진행자 로그 5,022줄에 취소·interrupt 조작은 0건이다. buildLiveKitManualInterruptPayload()의 기본 이유값이 그대로 나간 것이다.

이슈 분류기(ppi-issue-classifier)는 "네트워크 × 연결 60%"로 오분류했다. 분류 체계에 "클라이언트가 AI 응답을 잘못 취소함"을 담을 유형이 없고, 시작 메시지 직후 interrupt 패턴을 보는 규칙도 없다.

2. 시간순 인과 (18:41, 아동측)

18:41:24.469  "상황 영상" 87.912초 / 87.936초에서 near-end fallback → onEnd() (ended:false, 실제 끝 전)
           → handleVideoEnd → goToNextStep()
18:41:24.474  Moving to next step  5/0 → 5/1 "도원 감정파악" (split:image, AI 스텝)
18:41:24.488  Sending start message  "대화시작" 4자 → LiveKit user text
18:41:24.489  prepared_session_activated  (PPI-1351, 영상 중 미리 붙여둔 세션 활성화)
18:41:24.516  Video ended event fired  (장면은 이미 5/1, paused:true, ended:true)  ← 순서상 끼어 있을 뿐 인과 아님
18:41:24.537  Attempting programmatic play from playback effect   ← isPlaying 항상 true + 영상이 멈춘 상태 → play()
18:41:24.562  Video play event fired  currentTime 0  (끝난 영상이 처음부터 재생, 화면엔 숨김)
18:41:24.563  Notifying video play started   ← handleVideoStart
18:41:24.700  Sent LiveKit manual interrupt  reason: host_cancel_ai_response
18:41:29.509  No response from OpenAI within 5 seconds
              … 32초 무응답 …
18:41:56.522  진행자 텍스트 3자("답답함") 전송
18:41:57.837  AI response-1 "리호야, 방금 단톡방에서 친구들이랑 대화를 나눴는데…" (대화시작 멘트)

3. 코드 경로

① 영상 조기 종료
   apps/web/shared/ui/meet-video.tsx:779   currentTime ≥ duration−0.1 → onEndCalledRef=true, props.onEnd()   (near-end fallback)
   apps/web/shared/ui/meet-video.tsx:189   watchdog onEnd                                                    (watchdog backstop)
     └ guest-layout-content.tsx  handleVideoEnd → session.goToNextStep()

② 다음 AI 스텝에서 시작 메시지
   entities/guest-page-session/model/use-step-transition.ts   prepared_session_activate (PPI-1351)
     └ entities/guest-session/model/use-ai-session.ts:1427   "Sending start message"

③ 영상이 끝나 멈춘 상태로 남음 (ended 이벤트 도착 시점과 무관)
   meet-video.tsx:650  handleEnded   — onEndCalledRef 이미 true라 onEnd 재호출은 없음
   요소는 paused/ended 상태로 숨겨진 채 유지 (스텝 전환 전에 끝났어도 같음, 18:33 건)

④ 끝난 영상 재생
   guest-layout-content.tsx:222  <MeetVideo key="meet-video"> 항상 마운트, 비영상 스텝에선 숨김만
   guest-layout-content.tsx:236  isPlaying={isSessionProp ? true : …}   ← 아동 화면은 항상 true
   meet-video.tsx:471  if (props.isPlaying && video.readyState >= 3 && video.paused) video.play()   ← ended도 다시 틂

⑤ 재생 = 영상 스텝 시작으로 처리
   meet-video.tsx:532 handlePlay → :563 props.onStart()
     └ guest-layout-content.tsx:153  handleVideoStart
          if (isSessionProp && !hasNotifiedPlayRef.current)        ← videoResource 바뀌면 false로 초기화
            notifyVideoPlayStarted(...)
            cancelResponseForVideoStepStart(props.session)     ← 현재 스텝 종류 확인 없음

⑥ 응답 취소
   shared/lib/video-step-response.ts:149  controller.cancelResponse()   (#885 2026-07-16 도입: "LiveKit 영상 step 시작 시 응답 취소")
     └ use-ai-session.ts:840 cancelResponse → :877 liveKitSession.interrupt()
         └ lib/voice-agent/livekit-client-session.ts:737  reason 기본값 "host_cancel_ai_response"

진행자 화면에서는 왜 안 일어났나

4. 증거 로그

원본: ppi-issue-classifier/inputs/김리호_18회기/session-20261006.jsonl.gz (guest 10,209줄 + monitor 5,022줄). 시각은 KST.

전환영상 종료 방식시작 메시지0초 재생영상 시작 알림interrupt결과
18:33:25 잘듣기 대회 영상 → 질문near-end fallback.921.938없음없음정상 (1.5초 뒤 response-1)
18:41:24 상황 영상 → 도원 감정파악near-end fallback.488.562.563.700 (+0.21초)5초 무응답
18:46:07 도원 등장 → 도원시비watchdog backstop.051.113.113.411 (+0.36초)5초 무응답
18:47:52 소율 등장 → 소율의 고민상담 1watchdog backstop.172.232.232.555 (+0.38초)5초 무응답

ended 도착 시점 — 원인이 아님을 보여주는 비교

전환Video ended event fired다시 재생 요청결과
18:33.893 — 시작 메시지(.921)보다 먼저.896정상 (알림 없음)
18:41.516 — 시작 메시지 뒤.537취소
18:46.083 — 시작 메시지 뒤.088취소
18:47.220 — 재생 요청(.205)보다 늦음.205 (이미 ended:true)취소

결과를 가른 것은 ended 도착 순서가 아니라 Notifying video play started(→ 응답 취소) 발생 여부다.

5. 왜 지금 드러났나 — PPI-1351과의 관계 (추정)

cancelResponseForVideoStepStart는 2026-07-16(#885)부터 있었고, 끝난 영상의 재생도 그 전부터 가능했던 동작이다. 달라진 것은 시작 메시지가 나가는 시점이다.

PPI-1351 이전 — 영상 스텝 다음 AI 스텝에서 세션을 새로 연결했다. 연결에 약 1초가 걸려 시작 메시지는 잘못된 취소(+0.1초 안팎)보다 뒤에 나갔다. 취소할 응답이 없어 영향이 없었을 것이다.

PPI-1351 이후 (2026-10-02 병합, ppi-web@3.4.6~) — 영상 중 미리 붙여둔 세션을 쓰므로 시작 메시지가 스텝 전환 14ms 뒤 나간다. 그 뒤 0.1~0.2초에 오는 잘못된 취소가 시작 응답을 정확히 끊는다.

이 수업 당시 클라이언트 버전은 로그에 남지 않아 3.4.6 이상이었는지는 확인하지 못했다. 다만 4건 모두 prepared 경로를 탔으므로 PPI-1351 코드가 실행된 것은 확실하다. 관련 설계: PPI-1351 첫 영상 AI 세션 사전 준비

6. 해결 방법 (제안 · 미적용)

A. 영상 스텝일 때만 응답 취소 — 최소 수정, 우선 적용 권장

guest-layout-content.tsx handleVideoStart에서 현재 레이아웃이 영상 스텝일 때만 취소한다. #885의 원래 목적(영상 스텝 위로 AI 발화가 흐르는 것 방지)은 그대로 유지된다.

const handleVideoStart = useCallback(() => {
  if (isSessionProp && !hasNotifiedPlayRef.current) {
    hasNotifiedPlayRef.current = true;
    props.session.notifyVideoPlayStarted(...);
+   const isVideoStep = layoutMode === "full:video" || layoutMode === "split:video";
+   if (isVideoStep) cancelResponseForVideoStepStart(props.session);
-   cancelResponseForVideoStepStart(props.session);
  }
}, [isSessionProp, props, layoutMode]);

B. 끝난 영상을 다시 틀지 않기 — 원인 제거

비영상 스텝에서 끝나서 멈춘 영상을 다시 재생하지 않게 한다. ended 도착 시점을 고치는 것(조기 종료 판정 제거 등)은 해결책이 아니다. 두 가지 방법 중 하나.

  1. 아동 isPlaying을 실제 상태로: isPlaying={isSessionProp ? isVideoVisible : …}. 비영상 스텝에선 effect가 pause() 분기로 간다. 영상 스텝 재진입·준비 팝업 재생에 영향이 없는지 확인 필요.
  2. 재생 effect에서 ended 제외: meet-video.tsx:471 조건에 !video.ended 추가. 범위는 작지만 같은 영상 리소스로 스텝을 다시 시작할 때 currentTime 초기화 경로가 따로 있는지 확인 필요.

C. interrupt 이유를 호출처별로 — 추적성

cancelResponse에 이유를 받아 liveKitSession.interrupt(reason)로 넘긴다 (video_step_start, host_cancel, non_ai_step, noise_guard 등). 이번처럼 진행자가 누르지 않은 취소가 host_cancel_ai_response로 찍혀 원인을 헷갈리게 하는 일을 막는다.

권장 조합

A + C를 먼저 적용해 재발을 막고 원인 추적을 쉽게 한다. B는 영상 재생 상태 전체에 영향이 있으므로 실기기 확인을 거쳐 별도로 진행한다.

검증 항목

항목방법
단위 테스트비영상 레이아웃에서 onStart가 와도 cancelResponse가 불리지 않음 / 영상 레이아웃에서는 불림
재현첫 스텝이 full:video이고 다음 스텝이 AI인 활동 (예: (S2)주도16_3a 상황파악, 16_4 시비거는 도원, 16_5a 고민상담 소율). 영상 끝 → 시작 멘트가 1~2초 안에 나오는지
회귀 (#885·PPI-1233·PPI-1252)AI 발화 중 영상 스텝으로 넘어갈 때 AI 소리가 영상 위로 흐르지 않는지
실기기Windows Chrome, iPad Safari, Android Chrome에서 영상 → AI 전환 각 3회. near-end·watchdog 두 종료 경로 모두
로그 확인전환 직후 Sent LiveKit manual interrupt와 No response from OpenAI within 5 seconds가 없을 것

7. 판별법 — 같은 증상 재발 시

  1. 아동 세션 로그에서 Sending start message 뒤 1초 안에 Sent LiveKit manual interrupt가 있는지 본다.
  2. 그 사이에 Video play event fired(currentTime 0, stepLabel이 AI 스텝)와 Notifying video play started가 있으면 이 버그다.
  3. 5초 뒤 No response from OpenAI within 5 seconds, 이후 진행자 텍스트 뒤 response-1이 시작 멘트이면 확정.
  4. 진행자 로그에 취소 조작이 없는지 확인해 실제 진행자 취소와 구분한다.

시작 메시지 후 interrupt가 없는데 무응답이면 다른 원인이다. agent 쪽 턴 폐기는 이현우 18회기 AI 무응답 (Half Cascade 턴 폐기) 참고.

8. 미확정 · 관련 문서

관련 문서