오디오 분석 에이전트 — 통합 코드 레벨 문서

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

오디오 분석 에이전트 — 통합 코드 레벨 문서: 입력: 0. 데이터 모델 — 무엇이 흐르는가, 주요 처리 단계: 한눈에 보는 파이프라인, 결과: 6. 단계 ④ 결과 표시 (V1 호스트) 흐름
동작 흐름 요약
  1. 입력: 0. 데이터 모델 — 무엇이 흐르는가
  2. 주요 처리 단계: 한눈에 보는 파이프라인
  3. 결과: 6. 단계 ④ 결과 표시 (V1 호스트)
오디오 분석 에이전트 STT 오인식 검증 멀티턴 (nTurns) TurnAnalysisCore MediaRecorder 링버퍼 gpt-4o-audio / Gemini {핑퐁이발화} 치환 audio_analysis 세션로그 PPI-1006 2026-06-23 업데이트
💬 대화로 먼저 이해하기 — "속기사 녹음 검수 비유" (비개발자·처음 읽는 사람용)
Q오디오 분석 에이전트가 뭐 하는 거예요?
A수업에는 아동 말을 받아 적는 속기사(STT)가 있는데, 가끔 잘못 알아들어요. 이 에이전트는 실제 발화 녹음 테이프를 턴 단위로 보관해 뒀다가, 감수자(gpt-4o-audio/Gemini)에게 최근 몇 턴의 녹음과 받아 적은 텍스트를 함께 건네 "잘못 받아 적었을 가능성"을 판정하게 해요.
Q감수는 언제 하러 가나요? 매번 사람이 시키나요?
A자동이에요. 핑퐁이 응답이 도착하면 "직전 아동 발화"를 검수하고, 아동이 말했는데 8초간 응답이 없어도 검수해요. 같은 발화를 두 번 검수하지 않도록 장부(seenIds/targeted)를 관리하는 게 TurnAnalysisCore의 일입니다.
Q검수 결과는 어디서 볼 수 있어요?
AV1 호스트에서는 진행자에게 🔴/🟡/🟢 모달 경고가 뜨고, 검수에 쓴 녹음 합본(WAV)은 S3에, 판정 결과는 audio_analysis 세션 로그에 남아 뷰어에서 재생·다운로드할 수 있어요.
Q읽기 전에 조심할 점이 있다면요?
A어떤 감수자를 쓸지는 서버가 아니라 진행자 브라우저의 localStorage 한 칸에 저장돼요 — PC가 바뀌면 설정도 사라지죠. 이런 지점들은 문서 끝의 "읽을 때 주의할 함정 5가지"에 모아져 있습니다.

TL;DR

오디오 분석 에이전트는 수업 중 핑퐁이/아동의 실제 발화 오디오 Blob을 턴 단위로 녹음하고, 분석 대상 아동 발화 기준 최근 nTurns개 오디오 턴을 LLM(gpt-4o-audio / Gemini)에 보내 STT 오인식 가능성을 판정한다. 예를 들어 nTurns=2이고 분석 대상이 아동 답변이면 합본 범위는 핑퐁 → 아동이다. 운영자가 만든 에이전트(프롬프트·모델·턴 수)를 진행자가 자기 브라우저에서 활성화해 두면, 별도 조작 없이 턴마다 자동으로 돈다.

코드는 5단계 파이프라인이다 — ① 오디오 캡처(링버퍼) → ② 턴 감지·중복 제거(TurnAnalysisCore) → ③ 분석 요청(/api/audio-analysis/analyze) → ④ 결과 표시(모달) → ⑤ 영속화(S3 + audio_analysis 세션 로그). 이 문서는 각 단계를 파일:라인 칩으로 짚어가며 코드에서 직접 따라갈 수 있게 한다.

한눈에 보는 파이프라인

1

오디오 캡처 — 핑퐁/아동 발화를 role 포함 Blob 링버퍼로

hooks/use-guest-audio-capture.ts · isUserSpeaking/isAiSpeaking에 반응하는 MediaRecorder

아동과 핑퐁이 발화를 각각 role(user/assistant)·시각 메타와 함께 녹음한다. getRecentBlobs(n, chatLogs, targetLogId)가 분석 대상 턴 기준으로 실제 Blob이 모두 있는 경우에만 반환한다.

2

턴 감지 & 중복 제거 — 언제 분석을 쏠지 결정

hooks/turn-analysis-core.ts + hooks/use-turn-analysis.ts

채팅 로그를 ingest()하며 assistant 도착(직전 user 분석) 또는 8초 무응답을 트리거로 잡고, seenIds/targeted로 같은 발화를 두 번 분석하지 않게 막음.

3

분석 요청 — 오디오 + 전사를 LLM에 비교 분석

app/api/audio-analysis/analyze/route.ts(프록시) → apps/audio-analysis · provider 분기(Gemini / OpenAI / Qwen)

최근 n턴 Blob을 base64로 변환해 전송. 시스템 프롬프트의 {핑퐁이발화}를 실제 AI 발화로 치환. JSON(probability/reason/…)로 응답.

4

결과 표시 — 진행자에게 모달 경고 (V1 호스트)

components/pages/host.tsx:578 · onResult 콜백

오인식 가능성(🔴/🟡/🟢)·사유·추정 발화를 모달로 표시. (V2 세션카드는 onResult 미연결 — 로그만 남김)

5

영속화 — 오디오는 S3, 메타+결과는 세션 로그

hooks/use-audio-analysis-logger.ts/api/audio-analysis-logs/upload-url + /api/session-logs

합친 오디오를 presigned PUT으로 S3 업로드, 결과 JSON을 type:"audio_analysis" 세션 로그로 저장(audioAnalysisS3Key 포함). 세션 로그 뷰어에서 재생·다운로드.

등장 인물 (모듈 맵)

관리 (운영자)

  • audio-analysis-agents-section.tsx — CRUD UI
  • use-audio-analysis-agents.ts — CRUD 훅
  • /api/audio-analysis-agents

런타임 (진행자)

  • use-guest-audio-capture.ts — 캡처
  • turn-analysis-core.ts — 트리거 로직
  • use-turn-analysis.ts — 오케스트레이션

분석 (서버)

  • /api/audio-analysis/analyze
  • Gemini generateContent
  • OpenAI chat/completions

영속화 (서버)

  • use-audio-analysis-logger.ts
  • /api/audio-analysis-logs/*
  • session-log.tsx — 뷰어

0. 데이터 모델 — 무엇이 흐르는가

먼저 두 타입만 머리에 넣으면 전체가 읽힌다. apps/web/types/db/audio-analysis-agent.types.ts

// 운영자가 등록하는 에이전트 정의 (DynamoDB chatPreset 테이블에 저장)
interface AudioAnalysisAgent {
  userId: string;        // 파티션 키 = "AUDIO_ANALYSIS_AGENT" (AUDIO_ANALYSIS_AGENT_KEY)
  id: string;
  name: string;
  provider: "gemini" | "openai" | "qwen";
  model: string;         // 예: gpt-4o-audio-preview, gemini-2.5-flash, qwen3.5-omni-flash
  systemPrompt: string;  // {핑퐁이발화} 변수 사용 가능
  nTurns: number;       // 분석에 포함할 최근 아동 발화 턴 수 (1~5)
  order?: number;
}

// LLM이 돌려주는 분석 결과 (JSON)
interface TurnAnalysisResult {
  probability: "high" | "medium" | "low";  // 오인식 가능성
  reason: string;            // 판단 사유 (한국어)
  transcript?: string;       // 오디오에서 실제로 들린 내용
  heardContent?: string;     // AI가 인식한 내용
  estimatedActual?: string;  // 실제 발화 추정
  error?: string;
}

저장 위치 트릭 — 에이전트는 별도 테이블이 아니라 채팅 프리셋 테이블에 userId = "AUDIO_ANALYSIS_AGENT"라는 고정 파티션 키로 묶여 저장된다. lib/db-queries.ts:657 getAudioAnalysisAgents() 가 이 키로 Query → order로 정렬한다.

1. 관리 UI — 에이전트 만들고 "내 브라우저에서" 켜기

운영자 화면은 components/sections/audio-analysis-agents-section.tsx:204. CRUD는 entities/audio-analysis-agent/model/use-audio-analysis-agents.ts 훅이 /api/audio-analysis-agents(목록·생성), /api/audio-analysis-agents/[id](수정·삭제)를 호출한다.

활성화는 DB가 아니라 localStorage

중요한 포인트 — 어느 에이전트를 쓸지는 서버에 저장되지 않는다. audio-analysis-agents-section.tsx:282 handleActivate()localStorage["ppi_active_analysis_agent_id"] 한 칸만 토글한다(토글식, 같은 걸 누르면 해제).

const LS_ACTIVE_AGENT = "ppi_active_analysis_agent_id";
const handleActivate = (id) => {
  const newId = activeAgentId === id ? "" : id;  // 다시 누르면 OFF
  newId ? localStorage.setItem(LS_ACTIVE_AGENT, newId)
        : localStorage.removeItem(LS_ACTIVE_AGENT);
};

그래서 활성 에이전트는 진행자 PC·브라우저 단위다. 같은 키를 런타임에서 use-turn-analysis.ts:10 getActiveAgentId()가 읽어 매 분석마다 에이전트를 고른다. 활성이 없으면 분석은 그냥 일어나지 않는다.

nTurns 시각화 — 분석 대상 아동 발화 기준 최근 N개 오디오 턴

audio-analysis-agents-section.tsx:80 TurnPreview분석 대상 아동 발화로 끝나는 최근 n개 오디오 턴을 보여준다. 새 핑퐁 응답은 분석을 트리거할 뿐 합본 범위에는 포함되지 않는다.

예) nTurns=2 · 분석 대상 = 아동 답변

핑퐁 질문 아동 답변 새 핑퐁 응답(트리거)

이때 실제 LLM과 audio_analysis 합본 WAV에 들어가는 오디오는 핑퐁 질문 Blob → 아동 답변 Blob 2개다. {핑퐁이발화} 변수도 새 응답이 아니라 아동 답변 직전의 핑퐁 질문으로 치환된다.

2. 런타임 진입점 — 어디서 켜지나

같은 3개 훅을 V1 호스트와 V2 세션카드가 동일하게 엮는다.

진입점배선특징
V1 호스트
components/pages/host.tsx:558~592
useGuestAudioCapture + useAudioAnalysisLogger + useTurnAnalysis onResult 연결 → 모달 표시, isPeerTalking = sessionRelay.isPeerTalking
V2 세션카드
features/session/ui/session-card.tsx:600~616
동일 3종 onResult 미연결(로그만), isPeerTalking = monitorSession.isSpeaking
// host.tsx:558 — 3개 훅이 데이터로 연결된다
const { getRecentBlobs } = useGuestAudioCapture({ remoteVideoRef, isPeerTalking: sessionRelay.isPeerTalking });

const { logAnalysis } = useAudioAnalysisLogger(  // userId/lessonIndex/activityId 컨텍스트
  selectedUserId && selectedLessonIndex ? { userId, lessonIndex, getActivityId } : null);

const { analysisResults } = useTurnAnalysis({
  agents: audioAnalysisAgents,        // 활성 후보 목록(localStorage로 1개 선택)
  getRecentBlobs,                     // ← 단계 1이 제공
  chatLogs,                           // ← 단계 2의 입력
  isSessionActive: sessionRelay.isActive,
  onResult: (_logId, result) => { setAnalysisAlert(...) },  // ← 단계 4 (V1만)
  onLogData: logAnalysis,             // ← 단계 5
});

3. 단계 ① 오디오 캡처 — role/time 기반 턴 Blob 링버퍼

hooks/use-guest-audio-capture.ts. 핵심은 핑퐁이와 아동 발화를 모두 TurnAudioBlob으로 기록하되, 최종 합본에는 실제 녹음 Blob이 매칭된 턴만 넣는 것이다.

// 한 턴 녹음이 끝날 때 role/time 메타와 함께 보관
turnAudioBlobsRef.current = [
  ...turnAudioBlobsRef.current,
  { role, blob, startedAt, endedAt: Date.now() },
].slice(-Math.max(maxTurns * 3, maxTurns));
부분 합본 방지. nTurns=3이면 기대 오디오 턴 3개 모두에 실제 Blob이 매칭되어야 한다. 하나라도 부족하면 getRecentBlobs()[]를 반환해 분석 API 호출과 audio_analysis WAV 저장을 건너뛴다. 1~2턴짜리 부분 WAV를 남기지 않는다.

4. 단계 ② 턴 감지 & 중복 제거 — TurnAnalysisCore

가장 머리를 써야 하는 부분. 순수 로직은 React와 분리되어 hooks/turn-analysis-core.ts에 클래스로 있고(테스트는 turn-analysis-core.test.ts), use-turn-analysis.ts가 타이머·fetch 같은 부수효과를 주입한다.

입력: 채팅 로그를 흘려넣는다

use-turn-analysis.ts:113 effect가 chatLogscore.ingest()로 전달한다. turn-analysis-core.ts:45 ingest()seenIds처음 보는 로그만 골라 누적하고, 역할별로 분기한다.

두 가지 트리거

트리거발생코드분석 대상
assistant 핑퐁이(assistant) 로그 도착 :77 handleAssistant() 대기 폴백 전부 취소 후, 직전 user 발화 1개를 분석 대상으로 삼는다. pingpongSpeech는 새 assistant 응답이 아니라 해당 user 직전의 assistant 메시지를 우선 사용한다.
no-response user 발화 후 8초 동안 응답 없음 :63 scheduleFallback() 그 user 발화 (pingpongSpeech = "(무응답)")
// :77 — assistant 도착 = 직전 아동 발화에 핑퐁이가 응답한 순간
private handleAssistant(assistantLog) {
  for (const [, p] of this.pending) this.deps.clearTimer(p.handle); // 무응답 폴백 취소
  this.pending.clear();
  // 내부 히스토리를 역방향으로 훑어 직전 user 1개를 찾음
  for (let i = assistantIdx - 1; i >= 0; i--) {
    if (this.allLogs[i].role === "user") {
      const previousPingpongSpeech = this.findPreviousAssistantMessage(i);
      this.emit({ userLogId, transcribedText, pingpongSpeech: previousPingpongSpeech ?? assistantLog.message, trigger: "assistant" });
      return;
    }
  }
}

중복 방지의 두 집합: seenIds vs targeted

seenIds

  • ingest된 모든 로그 id
  • 같은 로그를 두 번 누적하지 않게

targeted

  • 이미 분석 대상으로 확정된 user 로그 id (:97 emit())
  • assistant·no-response가 같은 발화를 가리켜도 한 번만
  • 너무 짧은 발화(normalizedLength < 2)는 분석 스킵하되 targeted에 넣어 재시도 차단

★ 스텝 전환 중복 알림 버그 수정 (핵심 함정)

use-turn-analysis.ts:113~125의 effect 분기가 의도적으로 미묘하다.

if (chatLogs.length === 0) { core.reset(); return; }  // 게스트 접속 종료 등에만 상태 초기화
if (!isSessionActive) return;                  // 스텝 전환으로 잠깐 inactive → 누적/dedup 상태 유지
core.ingest(chatLogs.map(...));

스텝(활동) 전환 시 isSessionActive가 잠깐 false가 된다. 이때 상태를 리셋하면 누적된 과거 대화의 targeted가 날아가, 전환 후 이미 분석했던 턴을 다시 분석(중복 알림)한다. 그래서 리셋은 대화 로그 자체가 빌 때(chatLogs.length === 0, = 게스트 접속 종료)만 한다. turn-analysis-core.ts:107 reset()이 네 집합(seen/targeted/pending/allLogs)을 모두 비운다.

5. 단계 ③ 분석 요청 — analyze() → 서버

코어가 타깃을 onTarget으로 올리면 use-turn-analysis.ts:61 analyze()가 실행된다.

  1. :62 getActiveAgentId()로 활성 에이전트 선택 — 없으면 즉시 return.
  2. :64 getRecentBlobs(agent.nTurns, chatLogs, target.userLogId)로 분석 대상 user 기준 최근 n턴 오디오를 요청 — 기대 턴 수만큼 실제 Blob이 없으면 return.
  3. :69 Blob들을 blobToBase64로 변환해 POST /api/audio-analysis/analyze 호출.
  4. :87~89 응답 result를 analysisResults Map에 저장 → onResult(단계 4)와 onLogData(단계 5) 콜백 호출.
  5. :90 실패 시 probability:"medium" 에러 result를 만들어 그래도 결과를 흘린다.

분석 서버 분리 — web은 프록시만 한다 PPI-1224

app/api/audio-analysis/analyze/route.ts는 더 이상 provider를 호출하지 않는다. withAuthMember로 인증만 하고 AUDIO_ANALYSIS_SERVICE_ORIGINPOST /internal/analyze로 body를 스트림 전달하는 얇은 프록시다(x-internal-api-key 헤더, 16MB 상한, 기본 35초 timeout → 504 AUDIO_ANALYSIS_TIMEOUT, 설정 누락·연결 실패는 502 AUDIO_ANALYSIS_SERVICE_UNAVAILABLE).

실제 분석은 독립 서비스 apps/audio-analysis(express, 기본 포트 3101)에서 돈다. OOM 진단을 위해 /metrics와 30초 주기 memory_heartbeat 로그를 노출한다. provider 분기는 src/analyze.ts, 호출·timeout·메트릭은 src/provider.ts가 담당한다. 공통으로 substitutePrompt(){핑퐁이발화}를 실제 발화로 치환한다 analyze.ts:42.

Gemini :117OpenAI :183Qwen :183
엔드포인트generativelanguage…/{model}:generateContentapi.openai.com/v1/chat/completionsDASHSCOPE_BASE_URL(기본 dashscope-intl)/chat/completions
API 키GEMINI_API_KEYOPENAI_API_KEYDASHSCOPE_API_KEY
오디오 전달parts[].inline_data (mime+base64)content[].input_audio (data+format)같음. 단 data:;base64, 접두사 필요
포맷 제한GEMINI_SUPPORTED_AUDIO 화이트리스트:46 toOpenAIAudioFormatwav/mp3만, 그 외 400 UNSUPPORTED_AUDIO_FORMAT
시스템 프롬프트system_instructionrole:"system" 메시지
JSON 강제responseMimeType: application/jsonresponse_format: json_object미사용 — 프롬프트로만 유도
응답 방식단건 JSONstream: true SSE → qwen-stream.ts parseQwenSseText로 델타 합성
턴 수n턴 Blob 배열 그대로합본 1개만 허용 — 아니면 400 INVALID_QWEN_AUDIO_COUNT

Qwen은 왜 합본 1개인가. 클라이언트가 use-turn-analysis.ts:213~218에서 mono blob들을 하나로 합쳐(qwenCombined) 보낸다. 서버는 audioBase64.length !== 1이면 거부하므로, Qwen 에이전트에서 nTurns를 올렸는데 분석이 400으로 실패한다면 합본 단계를 먼저 본다. 모델 후보는 qwen3.5-omni-flash·qwen3.5-omni-plus·qwen3-omni-flash이고, qwen3-omni- 접두사 모델만 enable_thinking:false가 붙는다.

응답 text를 JSON.parseTurnAnalysisResult로 만들어 { result, resolvedSystemPrompt, textPrompt }를 돌려준다(프롬프트도 같이 반환 → 로그에 남기기 위함).

API 키는 클라이언트도 web 서버도 아니라 분석 서비스(apps/audio-analysis)의 환경변수(GEMINI_API_KEY / OPENAI_API_KEY / DASHSCOPE_API_KEY)에서만 읽는다. 관리 UI에는 키 입력란이 없다(…section.tsx:307). 키 누락은 500 *_API_KEY_MISSING으로 드러난다.

6. 단계 ④ 결과 표시 (V1 호스트)

host.tsx:578 onResultprobability높음 🔴 / 중간 🟡 / 낮음 🟢 라벨로 바꾸고, 사유·전사·추정 발화를 줄글로 모아 setAnalysisAlert(...) 모달을 띄운다. 결과 Map(analysisResults)은 자식 컴포넌트로도 내려간다(host.tsx:1521).

V1/V2 차이 — V2 세션카드는 onResult를 넘기지 않는다(session-card.tsx:611~616). 즉 V2에서는 실시간 모달 경고 없이 세션 로그에만 남는다.

7. 단계 ⑤ 영속화 — S3 + audio_analysis 세션 로그

onLogData = hooks/use-audio-analysis-logger.ts logAnalysis(). 두 군데에 나눠 저장한다.

// :61 — message에 분석 전모를 JSON으로 직렬화해 세션 로그 한 줄에 담는다
const message = JSON.stringify({
  agentName, provider, model, nTurns,
  systemPrompt: data.resolvedSystemPrompt,  textPrompt: data.textPrompt,
  blobCount: data.blobs.length,             result: data.result,
});

세션 로그 타입은 types/db/session-log.types.ts:26"audio_analysis"로, 오디오 키는 :82 audioAnalysisS3Key?로 추가돼 있다(최근 "세션 로그 전용 컬럼" 작업).

실패해도 죽지 않는다 — S3 업로드/로그 저장이 실패하면 logError에 모아 logger.warn만 남기고 진행한다(:91). 오디오가 없으면(blobs.length === 0) 업로드를 건너뛰고 메타만 저장.

세션 로그 뷰어에서 보기

components/pages/session-log.tsx:546 "오디오 분석" 컬럼이 type === "audio_analysis" 행을 렌더한다 — 에이전트 배지, result 필드 나열, 프롬프트 <details>, 그리고 audioAnalysisS3Key가 있으면 🎵 재생 / 다운로드 버튼. 재생·다운로드는 :347 / :362에서 POST /api/audio-analysis-logs/download-url로 presigned 조회 URL을 받아 쓴다.

8. 2026-06-23 nTurns 합본 WAV 수정 기록

문제 증상은 nTurns=2인데 세션 로그의 합본 WAV가 아동 턴만 2개 들어가거나, 반대로 핑퐁 1턴만 들어가는 것이었다. 근본 원인은 실제 오디오 Blob이 있는 턴과 transcript/STT 보정 턴을 구별하는 기준이 불완전했던 것이다.

문제원인수정
아동 턴만 2개 합본assistant transcript에 source:"audio"가 없어 핑퐁이 턴이 오디오 후보에서 제외됨. 아동 stt_verified 전사 턴은 후보에 섞여 아동 → 아동처럼 선택됨.use-ai-session.ts assistant transcript에 source:"audio" 추가. 모니터/세션카드 변환은 source === "audio" || type === "audio"를 오디오 턴으로 인정.
핑퐁 1턴만 합본아동 턴이 raw audio 로그가 아니라 stt_verified로 먼저/대신 들어오는 경우, 기존 선택 로직이 아동 턴을 기대 오디오 턴으로 세지 못함.stt_verified를 오디오 발화 턴의 대체 기준으로 인정하되, relatedToLogId가 raw audio를 가리키면 중복 제거하고 raw audio 턴으로 해석.
부분 WAV 저장기대 턴보다 적은 Blob이 매칭되어도 남은 Blob만 합본 저장 가능.selected.length < expectedCount이면 경고 후 [] 반환. 분석과 로그 저장을 모두 스킵.

현재 보장. nTurns=2는 분석 대상 아동 발화 기준으로 핑퐁 → 아동 2개 실제 Blob이 모두 있어야 합본 WAV가 저장된다. nTurns=3도 동일하게 3개 Blob이 모두 매칭되어야 한다. transcript나 stt_verified 자체가 오디오로 저장되는 것이 아니라, 실제 녹음 Blob을 찾기 위한 턴 기준으로만 쓰인다.

// turn-audio-selection.ts — stt_verified는 raw audio와 중복되지 않게 해석
const rawAudioIds = new Set(chatLogs
  .filter((log) => isAudioRole(log.role) && log.type === "audio")
  .map((log) => log.id));

const audioTurns = chatLogs.filter((log) => {
  if (!isAudioTurn(log)) return false;
  return !(log.type === "stt_verified" && log.relatedToLogId && rawAudioIds.has(log.relatedToLogId));
});

전체 시퀀스 (한 턴 기준)

아동이 말함 → 멈춤

isPeerTalking true→false

MediaRecorder.onstop → Blob 1개가 링버퍼에 적재

핑퐁이 응답 로그 도착

chatLogs 갱신 → core.ingest()

handleAssistant가 직전 user를 targeted에 넣고 emit (중복이면 무시)

analyze() 호출

활성 에이전트 + getRecentBlobs(nTurns)

base64 변환 → /api/audio-analysis/analyze → LLM → TurnAnalysisResult

④⑤

결과 분기

onResult(모달, V1) · onLogData(영속화)

진행자에게 경고 + S3 오디오/세션 로그 저장 → 뷰어에서 재생

코드 맵 — 파일별 역할 한눈에

레이어파일역할
타입types/db/audio-analysis-agent.types.tsAudioAnalysisAgent, TurnAnalysisResult
관리 UIcomponents/sections/audio-analysis-agents-section.tsxCRUD 폼 · TurnPreview · localStorage 활성화
관리 훅entities/audio-analysis-agent/model/use-audio-analysis-agents.ts목록/생성/수정/삭제
캡처hooks/use-guest-audio-capture.tsMediaRecorder 턴 단위 링버퍼, getRecentBlobs
트리거 로직hooks/turn-analysis-core.tsingest/handleAssistant/scheduleFallback/emit/reset
오케스트레이션hooks/use-turn-analysis.ts코어 + analyze() fetch + 결과 Map
로거hooks/use-audio-analysis-logger.tsS3 업로드 + 세션 로그 저장
분석 API(프록시)app/api/audio-analysis/analyze/route.ts인증 후 분석 서비스로 스트림 전달만 (16MB·35s)
분석 서비스apps/audio-analysis/src/analyze.ts · provider.tsGemini/OpenAI/Qwen 분기, {핑퐁이발화} 치환, /metrics
에이전트 APIapp/api/audio-analysis-agents/route.ts + [id]CRUD 엔드포인트
로그 APIapp/api/audio-analysis-logs/upload-url · download-urlpresigned URL 발급(20MB 제한)
S3lib/s3.ts:248{stage}/audio-analysis/{userId}/{logId}.{ext}
DBlib/db-queries.ts:657~751chatPreset 테이블, AUDIO_ANALYSIS_AGENT 파티션
뷰어components/pages/session-log.tsx:546audio_analysis 컬럼 · 재생/다운로드
진입점components/pages/host.tsx · features/session/ui/session-card.tsxV1/V2 배선

읽을 때 주의할 함정 5가지

통합된 이전 문서

아래 두 문서는 이 문서로 통합되었다. 기존 URL 호환을 위해 얇은 안내 페이지로 남긴다.