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

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

아키텍처 V2 카드뷰·포커스뷰 배선 작성일: 2026-07-15 용도: 이슈 수정 시 정확한 코드 지점 찾기 (전 단계 파일:줄 앵커 + 증상→의심 지점 맵)

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

한 장 요약 (전체 흐름)

┌─ 진행자(모니터) 브라우저 ──────────────────────────────────────────────────┐ 아동 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]──┐ provider 분기 → Gemini generateContent | OpenAI gpt-4o-audio (JSON 강제) │ 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 (299L)서버 분석 프록시. Gemini :70-176 / OpenAI :177-295 분기
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)
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 route.ts:70-176OpenAI route.ts:177-295
엔드포인트models/{model}:generateContent :115-122/v1/chat/completions (gpt-4o-audio 계열) :230-240
오디오 전달턴별 inline_data parts :95-97턴별 input_audio content :202-208
지원 포맷 가드wav/mp3/mpeg/aiff/aac/ogg/flac :32-40,80-89wav/mp3만 :25-29,192-201
프롬프트system_instruction + 마지막 text part :98-104system 메시지 + user content 끝 text :209-221
JSON 강제responseMimeType: application/json :105response_format: json_object :216
API 키GEMINI_API_KEY :71OPENAI_API_KEY :179

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

수정 시 체크리스트