Skip to content

feat(session): STT 실패한 음성 답변 재전사 - #245

Merged
i3months merged 1 commit into
devfrom
feature/voice-retranscribe
Sep 23, 2026
Merged

i3months merged 1 commit into
devfrom
feature/voice-retranscribe

Conversation

@i3months

Copy link
Copy Markdown
Member

작업 내용

  • POST /api/sessions/{sessionId}/messages/voice/{messageId}/retranscribe (VoiceRetranscribeService)
  • InterviewMessage.retryVoiceTranscription() — placeholder 상태로 되돌림
  • 업로드와 같은 VoiceAnswerUploadedEvent + AFTER_COMMIT 경로로 analyze.voice 재발행
  • 프론트: 스테이지 인라인 배너 + 대화 기록 드로어 양쪽에 '다시 인식하기'
  • 실패 문구를 조건부로 — 녹음이 남아 있으면 "다시 답변해 주세요" 대신 "녹음은 남아 있어요"

변경 이유 / 배경

STT 가 실패하면 failVoiceTranscription() 이 content 를 실패 문구로 덮고 FAILED 로 확정합니다. 턴은 풀리지만 오디오는 S3 에 그대로 남아 있는데 꺼낼 경로가 없어서, 사용자는 방금 말한 답변을 통째로 다시 입력해야 했습니다.

실패의 대부분은 Deepgram 이 간헐적으로 멎는 것이고, 같은 파일을 재전송하면 대체로 전사됩니다 (운영 실패 파일 6개 중 5개가 재전송에서 1.2~4.0초에 성공 — #244 참고). #244 의 자동 재시도가 1차 방어선이고, 이 PR 은 그마저 소진됐을 때 사용자가 직접 당기는 2차 방어선입니다.

스테이지에 실패 안내가 없던 문제

STT 실패 시 currentTurn 이 AWAITING_ANSWER 로 풀려 컴포저만 다시 열립니다. 그런데 실패 안내(AnswerBubble)는 대화 기록 드로어 안에만 있어서, 몰입형 스테이지에서는 사용자가 왜 답변이 사라졌는지 모른 채 같은 질문을 다시 마주합니다. 배너를 컴포저 위에 두어 실패 사실과 복구 수단을 같은 자리에서 보이게 했습니다.

마지막 메시지일 때만 허용하는 이유

실패 뒤 이미 다시 답변했다면 그 답변이 이 턴의 진짜 답입니다. 여기서 전사를 되살리면 뒤늦은 콜백이 지나간 턴의 내용을 덮어씁니다. 서버가 VOICE_RETRANSCRIBE_NOT_ALLOWED 로 거절하고, 프론트도 마지막 메시지가 아니면 버튼 자체를 내지 않습니다.

content_type 역산

interview_messages 는 content_type 을 저장하지 않습니다. 업로드 때 buildKey 가 확장자로 인코딩한 정보를 되읽어 복원했습니다(.webm → audio/webm). 알 수 없는 확장자는 application/octet-stream 으로 넘겨 Deepgram 이 컨테이너를 스스로 판별하게 합니다.

테스트

  • 백엔드 단위 테스트 7건 — 정상 재전사 / 확장자별 content_type / 이미 재답변함 / 실패 아닌 메시지 / 오디오 없음 / 다른 세션 / 세션 상태 / 소유권
  • 프론트 테스트 7건 — 버튼 노출·호출·진행 중 잠금·문구 전환, 오디오 없음·성공 답변 미노출, 마지막 메시지 게이팅
  • 프론트 전체 vitest run 45 files / 191 tests passed, tsc --noEmit, eslint --max-warnings=0 clean
  • 백엔드 ./gradlew test — 로컬에 Docker 가 없어 Testcontainers 기반 17건은 컨텍스트 로드 실패(이 변경과 무관, CI 에서 검증). 나머지 269건 통과

영향 범위

  • DB 마이그레이션: 없음
  • API contract 변경: 있음 — 엔드포인트 1개 추가. backend/openapi.json + frontend/src/shared/api/generated.ts 재생성 (추가만, 삭제 없음)
  • 환경변수 추가/변경: 없음

리뷰어 체크포인트

  • 엔드포인트 경로가 /messages/voice/{messageId}/retranscribe 입니다. VoiceAnswerController 의 @RequestMapping("/api/sessions/{sessionId}/messages/voice") 를 따른 결과인데, /messages/{messageId}/voice/retranscribe 가 더 자연스럽다면 컨트롤러를 나누겠습니다.
  • 재전사 요청에 멱등 키를 두지 않았습니다. 중복 발행은 프론트의 disabled 와 analyze.voice 의 멱등(LruIdempotencyStore)에 기대고 있는데, 서버에서 "이미 transcribing 이면 무시" 를 넣는 편이 나을지 봐주세요.
  • 스테이지 배너 위치(컴포저 바로 위)와 문구가 적절한지.

🤖 Generated with Claude Code

STT 가 실패하면 답변이 "음성 인식에 실패했습니다" 로 확정되고 끝이었다. 오디오는
S3 에 그대로 남아 있는데 꺼낼 경로가 없어서, 사용자는 방금 말한 답변을 통째로 다시
입력해야 했다.

실패의 대부분은 Deepgram 이 간헐적으로 멎는 것이고 같은 파일을 재전송하면 대체로
전사된다(PR #244 의 자동 재시도가 1차 방어선). 이건 그마저 소진됐을 때 사용자가
직접 당기는 2차 방어선이다.

- POST /api/sessions/{sid}/messages/voice/{mid}/retranscribe
  (VoiceRetranscribeService) — placeholder 상태로 되돌리고 업로드와 같은
  VoiceAnswerUploadedEvent + AFTER_COMMIT 경로로 analyze.voice 재발행
- 실패 메시지가 세션의 마지막일 때만 허용. 이미 다시 답변했다면 뒤늦은 전사가
  지나간 턴을 덮어쓴다(VOICE_RETRANSCRIBE_NOT_ALLOWED)
- content_type 은 저장하지 않으므로 S3 key 확장자에서 역산(buildKey 의 역함수)
- 프론트: 스테이지 인라인 배너 + 대화 기록 드로어 양쪽에 '다시 인식하기'.
  스테이지에는 실패 안내가 아예 없어서, 사용자는 왜 답변이 사라졌는지 모른 채
  컴포저만 다시 열리는 걸 봐야 했다
- 녹음이 남아 있을 때는 문구도 "다시 답변해 주세요" 대신 "녹음은 남아 있어요" 로
@i3months
i3months merged commit df7120c into dev Sep 23, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant