오디오 분석 에이전트 (오인식 감지·자동 정정) — 코드레벨 동작 흐름 & 이슈 분석 가이드

마지막 업데이트 2026-09-12

오디오 분석 에이전트 (오인식 감지·자동 정정) — 코드레벨 동작 흐름 &…: 입력: 1. 구성 요소 지도, 주요 처리 단계: 한 장 요약 (전체 흐름), 결과: 4. 턴 완료 감지 — TurnAnalysisCore 상태 머신 흐름
동작 흐름 요약
  1. 입력: 1. 구성 요소 지도
  2. 주요 처리 단계: 한 장 요약 (전체 흐름)
  3. 결과: 4. 턴 완료 감지 — TurnAnalysisCore 상태 머신
문서 읽는 법 · 설명식

이 문서는 이렇게 읽으면 됩니다

오디오 분석 에이전트 (오인식 감지·자동 정정) — 코드레벨 동작 흐름 & 이슈 분석 가이드의 핵심을 설명식으로 먼저 안내합니다. 기술적 결론과 원문 근거는 아래 본문에 보존되어 있습니다.

핵심 흐름 펼쳐 보기
  1. 비유와 핵심 질문으로 먼저 전체 구조를 잡습니다.
  2. 실제 컴포넌트·파일·데이터 흐름을 따라 내려갑니다.
  3. 코드 라인과 주의사항에서 구현 근거를 확인합니다.
  • 한 장 요약 (전체 흐름)
  • 0. 증상 → 의심 코드 지점 디버깅 맵
  • 1. 구성 요소 지도
아키텍처 V2 카드뷰·포커스뷰 배선 작성일: 2026-07-15 용도: 이슈 수정 시 정확한 코드 지점 찾기 (전 단계 파일:줄 앵커 + 증상→의심 지점 맵)

핑퐁이(OpenAI Realtime)가 아동 발화를 잘못 알아듣는 경우(오인식)를 잡아내는 시스템이다. 진행자(모니터) 브라우저가 SFU로 수신 중인 아동 마이크·핑퐁이 음성을 발화 구간 단위로 녹음해 두었다가, 대화 턴이 끝나면 최근 n턴의 실제 오디오를 Gemini / GPT-4o-audio / Qwen omni에게 직접 들려주고 "전사와 실제 발화가 다른가?"를 JSON으로 판정받는다. 결과는 ① 모니터 알림, ② (설정 시) 핑퐁이 세션에 정정 문구 자동 주입, ③ S3+세션 로그 영속화의 3갈래로 소비된다. 핵심 파일은 use-turn-analysis · TurnAnalysisCore · use-guest-audio-capture · /api/audio-analysis/analyze(프록시) · apps/audio-analysis(분석 서비스).

한 장 요약 (전체 흐름)

┌─ 진행자(모니터) 브라우저 ──────────────────────────────────────────────────┐ 아동 mic 스트림 ─┐ (SFU 수신 스트림) 핑퐁이 AI 스트림 ─┴→ use-guest-audio-capture : 발화 구간별 MediaRecorder → blob FIFO [§3] │ 전사 로그(chatLogs) ──→ TurnAnalysisCore : 턴 완료 감지 (assistant 응답 or 8s 무응답) [§4] │ AnalysisTarget use-turn-analysis.analyze() [§6] │ ① 활성 에이전트 로드(nTurns·프롬프트) │ ② getRecentBlobs(n) — 로그턴↔blob 매칭·대기 [§5] │ ③ 전 턴 WAV 변환 → base64 ▼ ┌─ 서버 ───────────────────────────── POST /api/audio-analysis/analyze ──[§6]──┐ web은 프록시만 → apps/audio-analysis POST /internal/analyze provider 분기 → Gemini generateContent | OpenAI gpt-4o-audio | Qwen omni(SSE) │ TurnAnalysisResult {probability, reason, heardContent, estimatedActual…} └────────────────────────────────────────────────────────────────────────────┘[§7] ┌───────────────────────────────┼─────────────────────────────────┐ ① 모니터 알림 ② 자동 정정 주입 ③ 로그 영속화 AUDIO_MISRECOGNITION probability=high & 설정 ON n턴 WAV remux → S3 (카드뷰/포커스뷰 알림) → buildCorrectionText + session-logs → sendMessage → 핑퐁이 세션 type: audio_analysis
실행 위치가 핵심. 분석 파이프라인 전체(녹음·턴 감지·API 호출)는 아동 기기가 아니라 진행자 모니터 브라우저에서 돈다. 아동 기기에는 부하가 없고, 모니터가 SFU로 받는 guestStream.audioStream(아동 mic)·aiAudioStream(핑퐁이)을 녹음 소스로 쓴다. 따라서 모니터를 안 보고 있으면(카드 언마운트) 그 구간은 녹음도 분석도 없다.

0. 증상 → 의심 코드 지점 디버깅 맵

이슈 수정 시 여기서 시작. 모든 경로는 apps/web/ 기준.

증상1차 의심 지점판별 근거
분석이 아예 실행 안 됨 (알림 0건) ① 활성 에이전트 미설정 — hooks/use-turn-analysis.ts:64-65 (agent 못 찾으면 조용히 return)
② blob 매칭 부족 스킵 — hooks/use-guest-audio-capture.ts:241-249
③ 2자 미만 비용 가드 — hooks/turn-analysis-core.ts:104-107
isSessionActive false로 ingest 중단 — hooks/use-turn-analysis.ts:138
②는 콘솔 [AudioCapture] nTurns 오디오 blob 매칭 부족으로 분석을 건너뜁니다 경고가 남는 유일한 스킵. ①③④는 로그 없이 침묵 — 재현 시 우선 breakpoint 지점
같은 턴 중복 알림 dedup reset 조건 — hooks/use-turn-analysis.ts:134-137 (chatLogs가 0으로 비워졌다 다시 차는 경우 core.reset() 후 재ingest) transcripts 배열이 일시적으로 빈 배열이 되는 상위 스토어 이벤트(게스트 재접속·모니터 재구독)를 찾을 것. targeted 가드 자체는 hooks/turn-analysis-core.ts:103
분석 결과가 엉뚱한 턴 오디오 기준 근접도 매칭 — hooks/turn-audio-selection.ts:63-85
FIFO eviction — hooks/use-guest-audio-capture.ts:109-112
매칭은 role+시각 근접뿐, 내용 검증 없음. transcript timestamp(게스트 기록)와 blob startedAt(모니터 Date.now)의 기기 간 시계·전파 지연 skew가 크면 오매칭. S3 저장 오디오를 들어보면 확정
발화 꼬리 잘림 / 두 발화가 한 blob STOP_TAIL_MARGIN_MS=500hooks/use-guest-audio-capture.ts:39,146-153 마진이 짧으면 꼬리 잘림, 발화 간격이 500ms 미만이면 한 턴으로 병합됨(의도된 동작). scheduleStop/cancelScheduledStop 타이밍 로그로 판별
400 UNSUPPORTED_AUDIO_FORMAT WAV 변환 실패 → webm 원본 폴백 — hooks/use-turn-analysis.ts:74-78
서버 거부 — route.ts:80-89(Gemini) :192-201(OpenAI)
폴백 webm은 서버에서 반드시 400. audioBlobToWav(lib/wav.ts)의 디코드 실패 원인(손상 blob·빈 blob)을 먼저 볼 것
no-response 오발 (응답 있었는데 "(무응답)" 분석) 8초 타이머 — hooks/turn-analysis-core.ts:26,66-78
assistant 도착 시 전체 취소 — hooks/turn-analysis-core.ts:81-83
핑퐁이 응답 지연(TTS 지연 등)이 8초를 넘으면 폴백이 먼저 발화. 이후 도착한 assistant는 이미 targeted라 재분석 없음 — pingpongSpeech가 "(무응답)"으로 남는 게 시그니처
자동 정정이 안 들어감 3중 조건 — session-card.tsx:682-688 / monitor-dashboard page.tsx:1041-1046 enabled && probability==="high" && !error. LLM이 "high"를 문자열 그대로 안 주면(대소문자·한글) 불발 — 결과 JSON 원문은 audio_analysis 로그 message.result에 있음
자동 정정이 이상한 문구로 들어감 템플릿 치환 — types/db/audio-analysis-agent.types.ts:72-82 결과 JSON에 없는 {키}빈 문자열로 치환됨(에러 아님). 에이전트 프롬프트의 JSON 스키마와 정정 템플릿 키 불일치를 의심
로그 페이지에서 분석 로그가 안 보임 병합 필터 — lib/session-log-audio-analysis.ts:10-12 target이 role==="assistant" && type==="audio"일 때만 병합. no-response 케이스는 relatedToLogId가 user 턴이라 병합에서 빠짐(알려진 공백, 아래 §7-③)
알림은 오는데 S3 오디오 없음 업로드 실패 경로 — hooks/use-audio-analysis-logger.ts:37-69 실패해도 세션 로그는 저장됨(audioAnalysisS3Key만 누락). 브라우저 콘솔 오디오 분석 로그 영속화 일부 실패 경고 + logError 문자열로 단계 판별
probability가 항상 medium + error 클라이언트 오류 변환 — hooks/use-turn-analysis.ts:105-112 fetch/서버 오류가 이 모양으로 변환됨. 서버 로그 채널 AUDIO_ANALYSIS_ANALYZE에서 502 원인(외부 API 오류·JSON 파싱 실패) 확인

1. 구성 요소 지도

파일역할
types/db/audio-analysis-agent.types.ts에이전트·결과·자동정정 타입과 상수. buildCorrectionText :72, extractJsonKeysFromPrompt :56, 기본 정정 템플릿 :43
entities/audio-analysis-agent/model/use-audio-analysis-agents.ts에이전트 목록·활성 토글·정정 설정 CRUD 훅. 낙관적 업데이트+실패 롤백 (setActiveAgentId :75, setCorrectionInjection :53)
app/api/audio-analysis-agents/*CRUD·/active(활성 1개, PUT은 admin 전용)·/correction — DynamoDB (키: AUDIO_ANALYSIS_AGENT[_ACTIVE|_CORRECTION])
hooks/use-guest-audio-capture.ts (261L)role별 발화 구간 MediaRecorder 녹음 + blob FIFO + getRecentBlobs :199
hooks/turn-audio-selection.ts (111L)chatLog 오디오 턴 ↔ blob 매칭 순수 함수 (selectRecentTurnAudioBlobs :45)
hooks/turn-analysis-core.ts (125L)턴 완료 감지 상태 머신. React 무의존 — 단위 테스트: turn-analysis-core.test.ts
hooks/use-turn-analysis.ts (147L)core↔React 연결 + 분석 요청 조립 (analyze :63)
app/api/audio-analysis/analyze/route.ts인증 전용 프록시. body를 apps/audio-analysis로 스트림 전달만 PPI-1224
apps/audio-analysis/src/analyze.ts실제 분석. Gemini :117 / OpenAI·Qwen :183 분기
hooks/use-audio-analysis-logger.ts (111L)입력 오디오 S3 업로드 + audio_analysis 세션 로그 (logAnalysis :27)
lib/session-log-audio-analysis.ts로그 페이지 병합 (buildAudioAnalysisMap :3, 사용처 session-log.tsx:160)
components/sections/audio-analysis-agents-section.tsx관리 UI. 프리셋 관리 페이지에 마운트 (chat-preset.tsx:181)

배선(사용처) — 파이프라인이 실제로 조립되는 곳

사용처배선 지점비고
V2 카드뷰 세션 카드features/session/ui/session-card.tsx:620-690 (capture :645, logger :653, useTurnAnalysis :659, onResult :666, 정정 주입 :682)운영 실사용 경로
V2 포커스뷰app/monitor-dashboard/[group]/[roomId]/page.tsx:1004-1046카드뷰와 동일 배선 — 수정 시 두 곳 모두 반영 필요
V1 host 페이지components/pages/host.tsx:575,593레거시 — V1은 운영 미사용
중복 배선 주의. 카드뷰와 포커스뷰가 동일 파이프라인을 각자 조립한다(공용 훅으로 안 묶임). 조건·알림 포맷·정정 게이트를 고칠 때 한쪽만 고치면 뷰에 따라 동작이 갈리는 사이드 이펙트가 난다. V1 host.tsx에도 유사 배선이 남아 있다.

2. 에이전트 설정 계층 — 무엇을 어떻게 분석할지

에이전트는 "어느 LLM에게(provider/model), 어떤 지시로(systemPrompt), 최근 몇 턴을(nTurns) 들려줄지"의 프리셋이다. 여러 개를 만들어 두고 전역으로 1개만 활성화한다.

프리셋 관리 페이지(chat-preset) ── AudioAnalysisAgentsSection │ useAudioAnalysisAgents() — 마운트 시 3개 API 병렬 fetch (:21-51) ├─ GET/POST/PATCH/DELETE /api/audio-analysis-agents[/:id] ──→ DynamoDB (AUDIO_ANALYSIS_AGENT) ├─ GET/PUT /api/audio-analysis-agents/active ─→ 단일 레코드 (활성 id, PUT=admin 전용) └─ GET/PUT /api/audio-analysis-agents/correction → 단일 레코드 (자동정정 enabled+template)
AudioAnalysisAgent 필드 types:13-23의미
provider / model"gemini"(generateContent) · "openai"(gpt-4o-audio chat/completions) · "qwen"(DashScope omni, SSE 스트리밍). 타입은 AUDIO_ANALYSIS_PROVIDERS 상수 배열에서 파생되고 isAudioAnalysisProvider()로 런타임 검증한다 audio-analysis-agent.types.ts:11-19
systemPrompt분석 지시. {핑퐁이발화} 플레이스홀더가 서버에서 치환됨 route.ts:19-21. 출력 JSON 스키마도 이 프롬프트에 선언
nTurnsLLM에게 들려줄 최근 오디오 턴 수 (문맥 제공용)
결과 스키마는 프롬프트가 정의한다. 서버는 LLM 출력 JSON을 그대로 TurnAnalysisResult로 파싱할 뿐이고(route.ts:165-174, 283-292), 정정 템플릿의 {키} 치환도 결과 JSON의 동명 필드를 동적으로 읽는다. 에이전트 프롬프트의 JSON 스키마를 바꾸면 정정 템플릿 변수도 자동으로 따라온다(extractJsonKeysFromPrompt가 관리 UI에 키 목록 제공). 단 probability("high"/"medium"/"low")는 트리거 판정에 쓰이는 계약 필드다 — 프롬프트가 이 필드를 안 내놓으면 자동 정정이 영구 불발된다.

3. 오디오 캡처 — 발화 구간만 role별로 녹음

hooks/use-guest-audio-capture.ts. 아동(user)과 핑퐁이(assistant)를 독립된 MediaRecorder 2대로, 발화 신호가 켜진 구간만 녹음한다. 발화 신호의 출처는 배선부 — 카드뷰 기준 monitorSession.isSpeaking/isAiSpeaking session-card.tsx:648-649.

isUserSpeaking=true ──→ syncCapture("user") :155-172 ├ 녹음 중 아님 → startCapture :79 : 오디오 트랙만 뽑아 MediaRecorder.start(500ms chunk) :116 ├ 녹음 중 + 같은 스트림 → 유지 (꼬리마진 stop 예약이 있었다면 취소 :159 → 한 턴으로 이어짐) └ 녹음 중 + 스트림 교체 → flushStop :134 후 새 스트림으로 재시작 :162-165 isUserSpeaking=false ─→ scheduleStop :146 : 500ms(STOP_TAIL_MARGIN_MS :39) 후 stop └ onstop :105-114 → blob 완성 → turnAudioBlobsRef FIFO에 {role, blob, startedAt, endedAt} push (최대 maxTurns×3=9개 유지 :112)
꼬리 마진 500ms의 이유 (:36-39 주석). 발화 종료 신호는 소켓 컨트롤 채널로 즉시 오지만, 녹음 대상은 jitter buffer·네트워크로 지연되는 원격 WebRTC 스트림이다. 신호 즉시 stop하면 아직 트랙에 도달하지 않은 발화 꼬리가 잘리므로 stop을 500ms 지연한다. 마진 중 발화가 재개되면 예약을 취소해 한 턴으로 잇는다 — 턴 개수와 로그 턴 개수가 어긋나는 첫 번째 원인 지점.
recorder 상태는 클로저로 격리 (:88-92). chunks/startedAt은 recorder 인스턴스 전용 클로저 변수다. 발화 도중 스트림이 교체돼 새 recorder가 시작돼도, 이전 recorder의 비동기 dataavailable/onstop이 새 녹음을 오염시키지 않는다.
이슈 분석 시 유의. ① FIFO 9개 초과 시 오래된 blob이 소리 없이 버려진다(:112) — nTurns가 크거나 발화가 잦으면 매칭 실패의 원인. ② MediaRecorder 시작 실패는 console.warn("[AudioCapture] MediaRecorder 시작 실패")(:121)만 남기고 그 턴 녹음이 통째로 없다. ③ 녹음 소스가 SFU 수신 스트림이므로 iOS clone-track 무음류 이슈(수신 오디오 자체가 무음)면 분석 입력도 무음이 된다 — LLM이 "안 들림"으로 판정하는 케이스는 파이프라인 버그가 아닐 수 있음.

4. 턴 완료 감지 — TurnAnalysisCore 상태 머신

hooks/turn-analysis-core.ts. React 무의존 순수 클래스(단위 테스트 있음 — 로직 수정 시 turn-analysis-core.test.ts 먼저). 전사 로그 스트림에서 "분석할 아동 턴"을 뽑는다.

ingest(chatLogs) :48 — seenIds로 신규 로그만 처리 │ ├ user 턴 도착 ──→ scheduleFallback :66 : 8초(NO_RESPONSE_FALLBACK_MS :26) 타이머 예약 │ └ 8초 내 assistant 없음 → emit(trigger: "no-response", 핑퐁이발화="(무응답)" :70-75) │ └ assistant 턴 도착 → handleAssistant :80 ① 대기 중인 user 폴백 전부 취소 :82-83 (응답된 것으로 간주) ② 히스토리 역방향 탐색으로 직전 user 턴 1개만 선택 :86-99 → emit(trigger: "assistant", 핑퐁이발화=assistant.message) emit 공통 가드 :102-110: ├ targeted에 이미 있으면 skip :103 (턴당 1회 보장) └ 공백 제거 후 2자 미만이면 targeted 기록 후 skip :104-107 (비용 가드)
hooks/turn-analysis-core.ts:80-99 — assistant 트리거의 두 가지 함정private handleAssistant(assistantLog: CoreLog): void {
  // ① assistant 도착 → 대기 중인 "모든" user 폴백 취소 — 연속 user 발화 후 응답이 오면
  //    마지막 user 턴만 분석되고 앞의 user 턴들은 폴백까지 취소돼 영영 분석 안 됨
  for (const [, p] of this.pending) this.deps.clearTimer(p.handle);
  this.pending.clear();
  // ② 역방향 첫 user 턴 1개만 emit — pingpongSpeech는 "이 assistant 발화"로 고정
  const assistantIdx = this.allLogs.indexOf(assistantLog);
  for (let i = assistantIdx - 1; i >= 0; i--) {
    if (this.allLogs[i].role === "user") { this.emit({ ... }); return; }
  }
}
dedup 상태는 스텝 전환에도 유지된다 (use-turn-analysis.ts:128-140). chatLogs.length === 0(게스트 접속 종료 등으로 전사가 비워질 때)에만 core.reset(). 스텝 전환으로 isSessionActive가 잠시 false가 돼도 ingest만 멈출 뿐 seenIds/targeted는 남아, 과거 턴 재분석(중복 알림)을 막는다. 반대로 말하면 — transcripts가 0으로 튀는 순간이 있으면 그 뒤 전체 재분석 폭탄이 가능하다.
알려진 커버리지 공백. ① 아동이 연속으로 여러 번 말한 뒤 핑퐁이가 응답하면 마지막 user 턴만 분석된다(위 코드 ①+②). ② 핑퐁이 응답이 8초를 넘게 지연되면 no-response로 먼저 분석돼 실제 응답 문맥(pingpongSpeech) 없이 판정된다. ③ isSessionActive=false 구간에 도착한 턴은 그 구간이 끝나도 ingest 시점의 chatLogs에 이미 있으면 seenIds에 들어가 분석 기회를 잃는다 — "스텝 전환 직후 턴이 분석 안 됐다"류 신고의 유력 지점.

5. blob 선별과 대기 — 로그 턴 ↔ 녹음 blob 매칭

getRecentBlobs(nTurns, chatLogs, targetLogId) use-guest-audio-capture.ts:199-253 → 매칭 로직은 turn-audio-selection.ts.

  1. 오디오 턴 필터 turn-audio-selection.ts:32-34, 88-97: type === "audio" 또는 "stt_verified"인 user/assistant 턴만. stt_verified가 raw audio 로그를 가리키면(relatedToLogId) 중복 제거, raw가 없으면 대체 기준.
  2. target까지 자르기 :98-109: targetLogId 위치까지의 턴 중 마지막 n개가 기대 목록. target이 stt_verified면 raw audio id로 해석(:101-106).
  3. 근접도 매칭 :63-85: role 동일 + 미사용 blob 중, blob 구간 중앙시각이 로그 timestamp에 가장 가까운 것. minStartedAt 단조 증가로 시간 역행 매칭 차단(:83).
getRecentBlobs 대기 루프 :220-249 (target 턴 오디오가 아직 onstop 전일 수 있음) while (선택 blob < 기대 수 && now < deadline 15s :220): 100ms 대기 후 재선택 ├ blob 늘었음 → stale=0 (진행 중) ├ 녹음 진행 중 → stale=0 (flush 대기 :234-236) └ 둘 다 아님 × 8회(800ms) :221조기 중단 (짧은 발화·VAD miss·FIFO eviction으로 영영 안 생기는 경우) 부족하면 → 분석 스킵 + console.warn :242-248 (불완전한 오디오로 오판하느니 안 한다)
오매칭 분석 절차. 매칭은 내용 검증이 없으므로, "분석이 엉뚱한 소리를 근거로 판정했다"는 신고는 ① S3의 audio_analysis 오디오(분석에 실제 쓰인 입력)를 재생 → ② 로그 턴 timestamp와 blob startedAt/endedAt 차이 계산 → ③ skew가 크면 전사 timestamp의 출처(게스트 기록 시각 vs 모니터 수신 시각)를 추적하는 순서로. 근접도 정렬식은 :73-77 한 곳이다.

6. 분석 요청 파이프라인 — WAV 변환과 provider 분기

analyze() use-turn-analysis.ts:63-116POST /api/audio-analysis/analyze route.ts:42-296.

hooks/use-turn-analysis.ts:71-78 — 형식 결정 지점 (400 이슈의 진원지)// OpenAI(wav/mp3)·Gemini(wav/mp3/ogg/flac/aac/aiff) 모두 webm 미지원 → 전송 전 턴별 WAV 변환.
// "전 턴 성공 시에만" wav — 하나라도 실패하면 형식 혼용을 피해 원본(webm) 그대로 폴백 → 서버 400
const wavBlobs = await Promise.all(blobs.map((b) => audioBlobToWav(b)));
const allWav = wavBlobs.every((b): b is Blob => b !== null);
const sendBlobs = allWav ? wavBlobs : blobs;
const mimeType = allWav ? "audio/wav" : blobs[0]?.type || "audio/webm";
Gemini analyze.ts:117OpenAI analyze.ts:183Qwen analyze.ts:183
엔드포인트models/{model}:generateContent/v1/chat/completions (gpt-4o-audio 계열)DASHSCOPE_BASE_URL/chat/completions
오디오 전달턴별 inline_data parts턴별 input_audio content같음 + data:;base64, 접두사
지원 포맷 가드GEMINI_SUPPORTED_AUDIO 화이트리스트wav/mp3만 toOpenAIAudioFormat :46
프롬프트system_instruction + 마지막 text partsystem 메시지 + user content 끝 text
JSON 강제responseMimeType: application/jsonresponse_format: json_object없음 — 프롬프트 유도 + SSE 파싱
턴 수n턴 배열 그대로합본 1개 강제, 아니면 400
API 키GEMINI_API_KEYOPENAI_API_KEYDASHSCOPE_API_KEY

7. 결과 소비 3갈래

① 모니터 알림 session-card.tsx:666-680 / monitor-dashboard page.tsx:1025-1039

onResult → 결과 JSON의 non-empty 필드를 줄 단위로 펼쳐 AlertType.AUDIO_MISRECOGNITION("오디오 오인식 감지", shared/types/index.ts:58) 알림 추가. priority: NONE이라 조용한 정보성 알림이다.

② 자동 정정 주입 (correction injection) session-card.tsx:682-688

조건 3중 게이트: correctionInjection.enabled && result.probability === "high" && !result.errorbuildCorrectionText(template, result) types:72-82 — {heardContent}/{estimatedActual} 등 치환, 없는 키는 빈 문자열 → monitorSession.sendMessage(text) — 진행자 채팅과 같은 경로 → 소켓 → 게스트 → 핑퐁이 Realtime 세션에 텍스트 주입 → 핑퐁이가 소리 내지 않고 직전 오답을 취소하고 실제 발화 기준으로 이어감

기본 템플릿(types:43-47)은 "인식된 발화(잘못 들음) vs 실제 발화"를 명시하고 '소리 내어 말하지 말고 다음 발화에 반영만 하라'고 지시한다. 설정은 전역 단일 레코드라 모든 수업에 일괄 적용 (기본 off, types:49-52).

정정 주입 이슈 분석 순서. ① audio_analysis 로그의 message.result에서 probability 원문 확인("high" 정확 일치인지) → ② /api/audio-analysis-agents/correction GET으로 enabled 확인 → ③ 게스트 LogRocket에서 sendMessage 텍스트 수신 여부 → ④ 주입은 됐는데 핑퐁이가 소리 내어 읽었다면 템플릿 지시문 문제(프롬프트 튜닝 영역).

③ 로그 영속화 use-audio-analysis-logger.ts:27-107

onLogDatalogAnalysis ① n턴 blob → combineAudioBlobsToWav 단일 WAV remux :40 (타임라인 정상화) └ 실패 시 원본 WebM 접합본 폴백 :41-48 (+ logError "타임라인 깨질 수 있음") ② /api/audio-analysis-logs/upload-url → presigned PUT → S3 업로드 :49-64/api/session-logs POST :83-97 — role: "system", type: "audio_analysis" message = {agentName, provider, model, nTurns, systemPrompt(치환본), textPrompt, blobCount, result} relatedToLogId = assistant 턴 우선, 무응답이면 user 턴 (use-turn-analysis.ts:103) audioAnalysisS3Key = 업로드 성공 시만

세션 로그 페이지(components/pages/session-log.tsx:160)는 buildAudioAnalysisMap으로 분석 로그를 관련 턴 아래 병합 표시하고, /api/audio-analysis-logs/download-url로 분석에 실제 쓰인 오디오를 재생할 수 있다 — "LLM이 뭘 듣고 이렇게 판정했나" 사후 검증용.

병합 필터의 공백 (lib/session-log-audio-analysis.ts:10-12). target이 role==="assistant" && type==="audio"일 때만 병합한다. 따라서 no-response 케이스(relatedToLogId=user 턴)는 DB에는 있어도 로그 페이지 병합 표시에서 빠진다. "무응답 분석 로그가 안 보인다"는 신고는 데이터 유실이 아니라 이 필터가 원인일 가능성부터.

8. 설계 결정·엣지 케이스 모음

결정이유코드 지점
분석을 모니터(진행자) 측에서 실행아동 기기(iPad) 부하 회피. 모니터는 이미 SFU로 아동/AI 오디오 수신 중이라 추가 전송 비용 없음session-card.tsx:645-651
webm → WAV 클라이언트 변환OpenAI(wav/mp3)·Gemini 모두 webm 미지원. 일부만 성공하면 형식 혼용 대신 전체 원본 폴백use-turn-analysis.ts:71-78
2자 미만 발화 스킵비용 가드 — 짧은 맞장구는 분석 가치 낮음turn-analysis-core.ts:104
턴당 분석 1회 (targeted dedup)assistant 트리거·폴백 중복 방지. 스킵된 턴도 targeted 기록turn-analysis-core.ts:103-108
blob 부족 시 분석 포기일부 턴 오디오만으로 판정하면 오판 위험 — 불완전 입력이면 아예 안 함use-guest-audio-capture.ts:241-249
자동 정정은 high + 무오류 + 전역 opt-inLLM 판정을 세션에 주입하는 위험 부담 → 3중 게이트session-card.tsx:682-685
API 오류도 결과로 변환 (medium + error)"분석 실패" 사실을 진행자에게 노출하되 자동 정정은 차단use-turn-analysis.ts:105-112
로그에 프롬프트 치환본까지 저장프리셋이 바뀌어도 "그때 정확히 뭘 물었는지" 재현 가능 (프롬프트 튜닝 루프)use-audio-analysis-logger.ts:71-80

수정 시 체크리스트