diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index dc4adc66..03dc7163 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -362,8 +362,8 @@ docker compose up -d - **질문별 복기 본 구현**: V19 로 `interview_messages` 에 `model_answer`/`answer_rewrite`/`coaching_comment` 추가. AI 가 `callback.feedback.answerCoaching[{messageId,…}]` 로 답변별 모범 답안·리라이트·코칭을 보내면 `FeedbackCallbackService` 가 각 메시지에 `recordCoaching` 기록. `MessageResult`/`MessageResponse` 가 답변 - 평가 점수 + 복기를 노출하되 **종료 세션 조회에서만**(`expectedSignal` 과 동일 게이팅). 프론트는 답변 버블 - 아래 '복기' 아코디언으로 표시. + 평가 점수 + 복기 + **답변 전달력 메트릭(WPM·무음·간투어, `MessageVoiceAnalysis` 에서 파싱)** 을 노출하되 + **종료 세션 조회에서만**(`expectedSignal` 과 동일 게이팅). 프론트는 답변 버블 아래 '복기' 아코디언으로 표시. - **직무 맞춤 면접 모드(JOB_TAILORED) 본 구현**: `SessionMode.JOB_TAILORED` 추가(V18 — mode CHECK 갱신 + `target_company_name`/`target_job_description` 컬럼). 이 모드는 회사명+채용공고(JD)를 받아(JD 필수, `SessionService` 검증 `SESSION_JD_REQUIRED`) `InterviewSession.assignTargetRole` 로 보관. JD 는 diff --git a/backend/openapi.json b/backend/openapi.json index 7bb832c4..e5962af8 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -2828,6 +2828,21 @@ }, "coachingComment" : { "type" : "string" + }, + "speakingRateWpm" : { + "type" : "number", + "format" : "double" + }, + "silenceDurationSec" : { + "type" : "number", + "format" : "double" + }, + "fillerWordCounts" : { + "type" : "object", + "additionalProperties" : { + "type" : "integer", + "format" : "int32" + } } } }, diff --git a/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java b/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java index 50fc4d7b..799da424 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java @@ -1,5 +1,7 @@ package com.stackup.stackup.session.application; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.json.JsonMapper; import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.exception.DomainException; import com.stackup.stackup.common.storage.ObjectStorageClient; @@ -11,11 +13,14 @@ import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.MessageRole; import com.stackup.stackup.session.domain.MessageStatus; +import com.stackup.stackup.session.domain.MessageVoiceAnalysis; +import com.stackup.stackup.session.domain.MessageVoiceAnalysisRepository; import com.stackup.stackup.session.domain.SessionStatus; import com.stackup.stackup.session.domain.TtsStatus; import java.net.URI; import java.time.Duration; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,29 +38,55 @@ public class InterviewMessageService { private static final Logger log = LoggerFactory.getLogger(InterviewMessageService.class); private static final Duration AUDIO_URL_TTL = Duration.ofMinutes(30); + private static final JsonMapper JSON = JsonMapper.builder().build(); + private static final TypeReference> FILLER_TYPE = new TypeReference<>() {}; + private final InterviewSessionRepository sessionRepository; private final InterviewMessageRepository messageRepository; + private final MessageVoiceAnalysisRepository voiceAnalysisRepository; private final ObjectStorageClient storage; private final ApplicationEventPublisher events; public List list(Long userId, Long sessionId) { InterviewSession session = ownedSession(userId, sessionId); - // 종료된 세션에서만 expected_signal 노출(라이브/대기 중엔 정답 유출 방지). - boolean revealExpectedSignal = + // 종료된 세션에서만 평가·복기·전달력 노출(라이브/대기 중엔 정답 유출 방지). + boolean revealInsights = session.getStatus() != SessionStatus.IN_PROGRESS && session.getStatus() != SessionStatus.READY; + // 답변별 음성 분석(전달력 메트릭) — 메시지 id 로 매핑. 종료 세션에서만 조회. + Map voiceByMessageId = revealInsights + ? voiceAnalysisRepository.findByMessage_Session_Id(sessionId).stream() + .collect(java.util.stream.Collectors.toMap( + v -> v.getMessage().getId(), v -> v, (a, b) -> a)) + : Map.of(); return messageRepository.findBySession_IdOrderBySequenceNumberAsc(sessionId).stream() - .map(m -> toResultWithAudioUrls(m, revealExpectedSignal)) + .map(m -> toResultWithAudioUrls(m, revealInsights, voiceByMessageId.get(m.getId()))) .toList(); } // 재생용 presigned URL 동봉: 질문 TTS(SUCCEEDED) + 음성 답변 원본. // presign 실패가 메시지 조회 전체를 깨뜨리지 않도록 개별 try/catch. - private MessageResult toResultWithAudioUrls(InterviewMessage m, boolean revealInsights) { + private MessageResult toResultWithAudioUrls( + InterviewMessage m, boolean revealInsights, MessageVoiceAnalysis voice) { String ttsUrl = m.getTtsStatus() == TtsStatus.SUCCEEDED ? presign(m.getTtsAudioPath()) : null; String audioUrl = presign(m.getAudioFilePath()); - return MessageResult.of(m, ttsUrl, audioUrl, revealInsights); + Double wpm = voice == null ? null : voice.getSpeakingRateWpm(); + Double silence = voice == null ? null : voice.getSilenceDurationSec(); + Map fillers = voice == null ? null : parseFillers(voice.getFillerWordCounts()); + return MessageResult.of(m, ttsUrl, audioUrl, revealInsights, wpm, silence, fillers); + } + + private Map parseFillers(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + return JSON.readValue(json, FILLER_TYPE); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + log.warn("filler_word_counts parse failed", e); + return null; + } } private String presign(String key) { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java index 309c9c6b..bdd205cd 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java @@ -5,6 +5,7 @@ import com.stackup.stackup.session.domain.MessageStatus; import com.stackup.stackup.session.domain.TtsStatus; import java.time.Instant; +import java.util.Map; public record MessageResult( Long id, @@ -34,7 +35,11 @@ public record MessageResult( Double answerCorrectness, String modelAnswer, String answerRewrite, - String coachingComment + String coachingComment, + // 답변 전달력 메트릭(음성 답변에만, 종료 세션 조회에서만). 별도 엔티티(MessageVoiceAnalysis)에서 온다. + Double speakingRateWpm, + Double silenceDurationSec, + Map fillerWordCounts ) { public static MessageResult of(InterviewMessage m) { return of(m, null, null, false); @@ -44,9 +49,15 @@ public static MessageResult of(InterviewMessage m, String ttsAudioUrl, String au return of(m, ttsAudioUrl, audioFileUrl, false); } - // revealInsights=true 면 답변 평가·복기·expectedSignal 노출(종료 세션). 라이브/대기 중엔 false. public static MessageResult of( InterviewMessage m, String ttsAudioUrl, String audioFileUrl, boolean revealInsights) { + return of(m, ttsAudioUrl, audioFileUrl, revealInsights, null, null, null); + } + + // revealInsights=true 면 답변 평가·복기·전달력 메트릭·expectedSignal 노출(종료 세션). 라이브/대기 중엔 false. + public static MessageResult of( + InterviewMessage m, String ttsAudioUrl, String audioFileUrl, boolean revealInsights, + Double speakingRateWpm, Double silenceDurationSec, Map fillerWordCounts) { return new MessageResult( m.getId(), m.getSession().getId(), @@ -71,7 +82,10 @@ public static MessageResult of( revealInsights ? m.getAnswerCorrectness() : null, revealInsights ? m.getModelAnswer() : null, revealInsights ? m.getAnswerRewrite() : null, - revealInsights ? m.getCoachingComment() : null + revealInsights ? m.getCoachingComment() : null, + revealInsights ? speakingRateWpm : null, + revealInsights ? silenceDurationSec : null, + revealInsights ? fillerWordCounts : null ); } } diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java index 8ec27946..4b698fa3 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java @@ -33,7 +33,11 @@ public record MessageResponse( Double answerCorrectness, String modelAnswer, String answerRewrite, - String coachingComment + String coachingComment, + // 답변 전달력 메트릭(음성 답변·종료 세션에서만). + Double speakingRateWpm, + Double silenceDurationSec, + java.util.Map fillerWordCounts ) { public static MessageResponse from(MessageResult r) { return new MessageResponse( @@ -60,7 +64,10 @@ public static MessageResponse from(MessageResult r) { r.answerCorrectness(), r.modelAnswer(), r.answerRewrite(), - r.coachingComment() + r.coachingComment(), + r.speakingRateWpm(), + r.silenceDurationSec(), + r.fillerWordCounts() ); } } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java index 91046c61..d6110924 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java @@ -18,8 +18,11 @@ import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.MessageVoiceAnalysis; +import com.stackup.stackup.session.domain.MessageVoiceAnalysisRepository; import com.stackup.stackup.session.domain.SessionMode; import com.stackup.stackup.user.domain.User; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -39,10 +42,37 @@ class InterviewMessageServiceTest { @Mock InterviewSessionRepository sessionRepository; @Mock InterviewMessageRepository messageRepository; + @Mock MessageVoiceAnalysisRepository voiceAnalysisRepository; @Mock ObjectStorageClient storage; @Mock ApplicationEventPublisher events; @InjectMocks InterviewMessageService service; + @Test + void list_exposesPerAnswerVoiceMetricsForEndedSession() { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", USER_ID); + InterviewSession session = InterviewSession.create( + user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null); + ReflectionTestUtils.setField(session, "id", SESSION_ID); + session.start(); + session.end(); // COMPLETED → revealInsights=true + InterviewMessage answer = InterviewMessage.interviewee(session, 2, "내 답변", null, null); + ReflectionTestUtils.setField(answer, "id", 600L); + MessageVoiceAnalysis va = MessageVoiceAnalysis.of(answer, 145.0, 6.0, "{\"음\":4,\"어\":2}", -0.3); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(SESSION_ID, USER_ID)) + .thenReturn(Optional.of(session)); + when(voiceAnalysisRepository.findByMessage_Session_Id(SESSION_ID)).thenReturn(List.of(va)); + when(messageRepository.findBySession_IdOrderBySequenceNumberAsc(SESSION_ID)) + .thenReturn(List.of(answer)); + + MessageResult r = service.list(USER_ID, SESSION_ID).get(0); + + assertThat(r.speakingRateWpm()).isEqualTo(145.0); + assertThat(r.silenceDurationSec()).isEqualTo(6.0); + assertThat(r.fillerWordCounts()).containsEntry("음", 4).containsEntry("어", 2); + } + @Test void submitAnswer_insertsIntervieweeAndPublishesEvent() { InterviewSession session = sessionInProgress(10L); diff --git a/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx b/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx index 4464fcd1..d39f07c0 100644 --- a/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx +++ b/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx @@ -12,14 +12,31 @@ function score5(v?: number | null): string | null { return typeof v === 'number' ? `${v}/5` : null } -// 종료된 세션의 답변 아래 붙는 '복기' 아코디언: 평가 점수 → 한 줄 코칭 → 모범 답안 → 내 답변 리라이트. -// 코칭 데이터가 없으면(라이브 중·자기소개 답변 등) 렌더하지 않는다. +// 답변 음성 전달력 메트릭 → 표시용 칩 문자열들. +function deliveryChips(message: Message): string[] { + const chips: string[] = [] + if (typeof message.speakingRateWpm === 'number') { + chips.push(`${Math.round(message.speakingRateWpm)} WPM`) + } + if (typeof message.silenceDurationSec === 'number' && message.silenceDurationSec > 0) { + chips.push(`무음 ${Math.round(message.silenceDurationSec)}초`) + } + const fillers = Object.entries(message.fillerWordCounts ?? {}).filter(([, c]) => c > 0) + if (fillers.length > 0) { + chips.push('간투어 ' + fillers.map(([w, c]) => `${w} ${c}`).join(' · ')) + } + return chips +} + +// 종료된 세션의 답변 아래 붙는 '복기' 아코디언: 전달력 메트릭 → 평가 점수 → 한 줄 코칭 → 모범 답안 → 리라이트. +// 코칭·전달력 데이터가 모두 없으면(라이브 중·자기소개 답변 등) 렌더하지 않는다. export function AnswerCoachingAccordion({ message }: { message: Message }) { const [open, setOpen] = useState(false) - const hasCoaching = Boolean( - message.modelAnswer || message.answerRewrite || message.coachingComment, + const delivery = deliveryChips(message) + const hasContent = Boolean( + message.modelAnswer || message.answerRewrite || message.coachingComment || delivery.length > 0, ) - if (!hasCoaching) return null + if (!hasContent) return null const spec = score5(message.answerSpecificity) const logic = score5(message.answerLogic) @@ -44,6 +61,21 @@ export function AnswerCoachingAccordion({ message }: { message: Message }) { {open && (
+ {delivery.length > 0 && ( +
+ 전달력 +
+ {delivery.map((d) => ( + + {d} + + ))} +
+
+ )} {evals.length > 0 && (
{evals.map((e) => ( diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index 917f940b..ad0ec384 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -910,6 +910,13 @@ export interface components { modelAnswer?: string; answerRewrite?: string; coachingComment?: string; + /** Format: double */ + speakingRateWpm?: number; + /** Format: double */ + silenceDurationSec?: number; + fillerWordCounts?: { + [key: string]: number; + }; }; VoiceStreamBeginResponse: { /** Format: int64 */