Skip to content

Commit f01ec42

Browse files
authored
Merge pull request #122 from Team-StackUp/feature/answer-delivery-metrics
feat(feedback): 답변별 전달력 메트릭을 복기에 노출
2 parents d67704b + 1874e9d commit f01ec42

8 files changed

Lines changed: 153 additions & 17 deletions

File tree

backend/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,8 +362,8 @@ docker compose up -d
362362
- **질문별 복기 본 구현**: V19 로 `interview_messages``model_answer`/`answer_rewrite`/`coaching_comment`
363363
추가. AI 가 `callback.feedback.answerCoaching[{messageId,…}]` 로 답변별 모범 답안·리라이트·코칭을 보내면
364364
`FeedbackCallbackService` 가 각 메시지에 `recordCoaching` 기록. `MessageResult`/`MessageResponse` 가 답변
365-
평가 점수 + 복기를 노출하되 **종료 세션 조회에서만**(`expectedSignal` 과 동일 게이팅). 프론트는 답변 버블
366-
아래 '복기' 아코디언으로 표시.
365+
평가 점수 + 복기 + **답변 전달력 메트릭(WPM·무음·간투어, `MessageVoiceAnalysis` 에서 파싱)** 을 노출하되
366+
**종료 세션 조회에서만**(`expectedSignal` 과 동일 게이팅). 프론트는 답변 버블 아래 '복기' 아코디언으로 표시.
367367
- **직무 맞춤 면접 모드(JOB_TAILORED) 본 구현**: `SessionMode.JOB_TAILORED` 추가(V18 — mode CHECK 갱신 +
368368
`target_company_name`/`target_job_description` 컬럼). 이 모드는 회사명+채용공고(JD)를 받아(JD 필수,
369369
`SessionService` 검증 `SESSION_JD_REQUIRED`) `InterviewSession.assignTargetRole` 로 보관. JD 는

backend/openapi.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2828,6 +2828,21 @@
28282828
},
28292829
"coachingComment" : {
28302830
"type" : "string"
2831+
},
2832+
"speakingRateWpm" : {
2833+
"type" : "number",
2834+
"format" : "double"
2835+
},
2836+
"silenceDurationSec" : {
2837+
"type" : "number",
2838+
"format" : "double"
2839+
},
2840+
"fillerWordCounts" : {
2841+
"type" : "object",
2842+
"additionalProperties" : {
2843+
"type" : "integer",
2844+
"format" : "int32"
2845+
}
28312846
}
28322847
}
28332848
},

backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.stackup.stackup.session.application;
22

3+
import com.fasterxml.jackson.core.type.TypeReference;
4+
import com.fasterxml.jackson.databind.json.JsonMapper;
35
import com.stackup.stackup.common.exception.ApiErrorCode;
46
import com.stackup.stackup.common.exception.DomainException;
57
import com.stackup.stackup.common.storage.ObjectStorageClient;
@@ -11,11 +13,14 @@
1113
import com.stackup.stackup.session.domain.InterviewSessionRepository;
1214
import com.stackup.stackup.session.domain.MessageRole;
1315
import com.stackup.stackup.session.domain.MessageStatus;
16+
import com.stackup.stackup.session.domain.MessageVoiceAnalysis;
17+
import com.stackup.stackup.session.domain.MessageVoiceAnalysisRepository;
1418
import com.stackup.stackup.session.domain.SessionStatus;
1519
import com.stackup.stackup.session.domain.TtsStatus;
1620
import java.net.URI;
1721
import java.time.Duration;
1822
import java.util.List;
23+
import java.util.Map;
1924
import lombok.RequiredArgsConstructor;
2025
import org.slf4j.Logger;
2126
import org.slf4j.LoggerFactory;
@@ -33,29 +38,55 @@ public class InterviewMessageService {
3338
private static final Logger log = LoggerFactory.getLogger(InterviewMessageService.class);
3439
private static final Duration AUDIO_URL_TTL = Duration.ofMinutes(30);
3540

41+
private static final JsonMapper JSON = JsonMapper.builder().build();
42+
private static final TypeReference<Map<String, Integer>> FILLER_TYPE = new TypeReference<>() {};
43+
3644
private final InterviewSessionRepository sessionRepository;
3745
private final InterviewMessageRepository messageRepository;
46+
private final MessageVoiceAnalysisRepository voiceAnalysisRepository;
3847
private final ObjectStorageClient storage;
3948
private final ApplicationEventPublisher events;
4049

4150
public List<MessageResult> list(Long userId, Long sessionId) {
4251
InterviewSession session = ownedSession(userId, sessionId);
43-
// 종료된 세션에서만 expected_signal 노출(라이브/대기 중엔 정답 유출 방지).
44-
boolean revealExpectedSignal =
52+
// 종료된 세션에서만 평가·복기·전달력 노출(라이브/대기 중엔 정답 유출 방지).
53+
boolean revealInsights =
4554
session.getStatus() != SessionStatus.IN_PROGRESS
4655
&& session.getStatus() != SessionStatus.READY;
56+
// 답변별 음성 분석(전달력 메트릭) — 메시지 id 로 매핑. 종료 세션에서만 조회.
57+
Map<Long, MessageVoiceAnalysis> voiceByMessageId = revealInsights
58+
? voiceAnalysisRepository.findByMessage_Session_Id(sessionId).stream()
59+
.collect(java.util.stream.Collectors.toMap(
60+
v -> v.getMessage().getId(), v -> v, (a, b) -> a))
61+
: Map.of();
4762
return messageRepository.findBySession_IdOrderBySequenceNumberAsc(sessionId).stream()
48-
.map(m -> toResultWithAudioUrls(m, revealExpectedSignal))
63+
.map(m -> toResultWithAudioUrls(m, revealInsights, voiceByMessageId.get(m.getId())))
4964
.toList();
5065
}
5166

5267
// 재생용 presigned URL 동봉: 질문 TTS(SUCCEEDED) + 음성 답변 원본.
5368
// presign 실패가 메시지 조회 전체를 깨뜨리지 않도록 개별 try/catch.
54-
private MessageResult toResultWithAudioUrls(InterviewMessage m, boolean revealInsights) {
69+
private MessageResult toResultWithAudioUrls(
70+
InterviewMessage m, boolean revealInsights, MessageVoiceAnalysis voice) {
5571
String ttsUrl = m.getTtsStatus() == TtsStatus.SUCCEEDED
5672
? presign(m.getTtsAudioPath()) : null;
5773
String audioUrl = presign(m.getAudioFilePath());
58-
return MessageResult.of(m, ttsUrl, audioUrl, revealInsights);
74+
Double wpm = voice == null ? null : voice.getSpeakingRateWpm();
75+
Double silence = voice == null ? null : voice.getSilenceDurationSec();
76+
Map<String, Integer> fillers = voice == null ? null : parseFillers(voice.getFillerWordCounts());
77+
return MessageResult.of(m, ttsUrl, audioUrl, revealInsights, wpm, silence, fillers);
78+
}
79+
80+
private Map<String, Integer> parseFillers(String json) {
81+
if (json == null || json.isBlank()) {
82+
return null;
83+
}
84+
try {
85+
return JSON.readValue(json, FILLER_TYPE);
86+
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
87+
log.warn("filler_word_counts parse failed", e);
88+
return null;
89+
}
5990
}
6091

6192
private String presign(String key) {

backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.stackup.stackup.session.domain.MessageStatus;
66
import com.stackup.stackup.session.domain.TtsStatus;
77
import java.time.Instant;
8+
import java.util.Map;
89

910
public record MessageResult(
1011
Long id,
@@ -34,7 +35,11 @@ public record MessageResult(
3435
Double answerCorrectness,
3536
String modelAnswer,
3637
String answerRewrite,
37-
String coachingComment
38+
String coachingComment,
39+
// 답변 전달력 메트릭(음성 답변에만, 종료 세션 조회에서만). 별도 엔티티(MessageVoiceAnalysis)에서 온다.
40+
Double speakingRateWpm,
41+
Double silenceDurationSec,
42+
Map<String, Integer> fillerWordCounts
3843
) {
3944
public static MessageResult of(InterviewMessage m) {
4045
return of(m, null, null, false);
@@ -44,9 +49,15 @@ public static MessageResult of(InterviewMessage m, String ttsAudioUrl, String au
4449
return of(m, ttsAudioUrl, audioFileUrl, false);
4550
}
4651

47-
// revealInsights=true 면 답변 평가·복기·expectedSignal 노출(종료 세션). 라이브/대기 중엔 false.
4852
public static MessageResult of(
4953
InterviewMessage m, String ttsAudioUrl, String audioFileUrl, boolean revealInsights) {
54+
return of(m, ttsAudioUrl, audioFileUrl, revealInsights, null, null, null);
55+
}
56+
57+
// revealInsights=true 면 답변 평가·복기·전달력 메트릭·expectedSignal 노출(종료 세션). 라이브/대기 중엔 false.
58+
public static MessageResult of(
59+
InterviewMessage m, String ttsAudioUrl, String audioFileUrl, boolean revealInsights,
60+
Double speakingRateWpm, Double silenceDurationSec, Map<String, Integer> fillerWordCounts) {
5061
return new MessageResult(
5162
m.getId(),
5263
m.getSession().getId(),
@@ -71,7 +82,10 @@ public static MessageResult of(
7182
revealInsights ? m.getAnswerCorrectness() : null,
7283
revealInsights ? m.getModelAnswer() : null,
7384
revealInsights ? m.getAnswerRewrite() : null,
74-
revealInsights ? m.getCoachingComment() : null
85+
revealInsights ? m.getCoachingComment() : null,
86+
revealInsights ? speakingRateWpm : null,
87+
revealInsights ? silenceDurationSec : null,
88+
revealInsights ? fillerWordCounts : null
7589
);
7690
}
7791
}

backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ public record MessageResponse(
3333
Double answerCorrectness,
3434
String modelAnswer,
3535
String answerRewrite,
36-
String coachingComment
36+
String coachingComment,
37+
// 답변 전달력 메트릭(음성 답변·종료 세션에서만).
38+
Double speakingRateWpm,
39+
Double silenceDurationSec,
40+
java.util.Map<String, Integer> fillerWordCounts
3741
) {
3842
public static MessageResponse from(MessageResult r) {
3943
return new MessageResponse(
@@ -60,7 +64,10 @@ public static MessageResponse from(MessageResult r) {
6064
r.answerCorrectness(),
6165
r.modelAnswer(),
6266
r.answerRewrite(),
63-
r.coachingComment()
67+
r.coachingComment(),
68+
r.speakingRateWpm(),
69+
r.silenceDurationSec(),
70+
r.fillerWordCounts()
6471
);
6572
}
6673
}

backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@
1818
import com.stackup.stackup.session.domain.InterviewSession;
1919
import com.stackup.stackup.session.domain.InterviewSessionRepository;
2020
import com.stackup.stackup.session.domain.JobCategory;
21+
import com.stackup.stackup.session.domain.MessageVoiceAnalysis;
22+
import com.stackup.stackup.session.domain.MessageVoiceAnalysisRepository;
2123
import com.stackup.stackup.session.domain.SessionMode;
2224
import com.stackup.stackup.user.domain.User;
25+
import java.util.List;
2326
import java.util.Optional;
2427
import org.junit.jupiter.api.Test;
2528
import org.junit.jupiter.api.extension.ExtendWith;
@@ -39,10 +42,37 @@ class InterviewMessageServiceTest {
3942

4043
@Mock InterviewSessionRepository sessionRepository;
4144
@Mock InterviewMessageRepository messageRepository;
45+
@Mock MessageVoiceAnalysisRepository voiceAnalysisRepository;
4246
@Mock ObjectStorageClient storage;
4347
@Mock ApplicationEventPublisher events;
4448
@InjectMocks InterviewMessageService service;
4549

50+
@Test
51+
void list_exposesPerAnswerVoiceMetricsForEndedSession() {
52+
User user = User.createGithubUser(1L, "u", null, null, "t");
53+
ReflectionTestUtils.setField(user, "id", USER_ID);
54+
InterviewSession session = InterviewSession.create(
55+
user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null);
56+
ReflectionTestUtils.setField(session, "id", SESSION_ID);
57+
session.start();
58+
session.end(); // COMPLETED → revealInsights=true
59+
InterviewMessage answer = InterviewMessage.interviewee(session, 2, "내 답변", null, null);
60+
ReflectionTestUtils.setField(answer, "id", 600L);
61+
MessageVoiceAnalysis va = MessageVoiceAnalysis.of(answer, 145.0, 6.0, "{\"\":4,\"\":2}", -0.3);
62+
63+
when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(SESSION_ID, USER_ID))
64+
.thenReturn(Optional.of(session));
65+
when(voiceAnalysisRepository.findByMessage_Session_Id(SESSION_ID)).thenReturn(List.of(va));
66+
when(messageRepository.findBySession_IdOrderBySequenceNumberAsc(SESSION_ID))
67+
.thenReturn(List.of(answer));
68+
69+
MessageResult r = service.list(USER_ID, SESSION_ID).get(0);
70+
71+
assertThat(r.speakingRateWpm()).isEqualTo(145.0);
72+
assertThat(r.silenceDurationSec()).isEqualTo(6.0);
73+
assertThat(r.fillerWordCounts()).containsEntry("음", 4).containsEntry("어", 2);
74+
}
75+
4676
@Test
4777
void submitAnswer_insertsIntervieweeAndPublishesEvent() {
4878
InterviewSession session = sessionInProgress(10L);

frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,31 @@ function score5(v?: number | null): string | null {
1212
return typeof v === 'number' ? `${v}/5` : null
1313
}
1414

15-
// 종료된 세션의 답변 아래 붙는 '복기' 아코디언: 평가 점수 → 한 줄 코칭 → 모범 답안 → 내 답변 리라이트.
16-
// 코칭 데이터가 없으면(라이브 중·자기소개 답변 등) 렌더하지 않는다.
15+
// 답변 음성 전달력 메트릭 → 표시용 칩 문자열들.
16+
function deliveryChips(message: Message): string[] {
17+
const chips: string[] = []
18+
if (typeof message.speakingRateWpm === 'number') {
19+
chips.push(`${Math.round(message.speakingRateWpm)} WPM`)
20+
}
21+
if (typeof message.silenceDurationSec === 'number' && message.silenceDurationSec > 0) {
22+
chips.push(`무음 ${Math.round(message.silenceDurationSec)}초`)
23+
}
24+
const fillers = Object.entries(message.fillerWordCounts ?? {}).filter(([, c]) => c > 0)
25+
if (fillers.length > 0) {
26+
chips.push('간투어 ' + fillers.map(([w, c]) => `${w} ${c}`).join(' · '))
27+
}
28+
return chips
29+
}
30+
31+
// 종료된 세션의 답변 아래 붙는 '복기' 아코디언: 전달력 메트릭 → 평가 점수 → 한 줄 코칭 → 모범 답안 → 리라이트.
32+
// 코칭·전달력 데이터가 모두 없으면(라이브 중·자기소개 답변 등) 렌더하지 않는다.
1733
export function AnswerCoachingAccordion({ message }: { message: Message }) {
1834
const [open, setOpen] = useState(false)
19-
const hasCoaching = Boolean(
20-
message.modelAnswer || message.answerRewrite || message.coachingComment,
35+
const delivery = deliveryChips(message)
36+
const hasContent = Boolean(
37+
message.modelAnswer || message.answerRewrite || message.coachingComment || delivery.length > 0,
2138
)
22-
if (!hasCoaching) return null
39+
if (!hasContent) return null
2340

2441
const spec = score5(message.answerSpecificity)
2542
const logic = score5(message.answerLogic)
@@ -44,6 +61,21 @@ export function AnswerCoachingAccordion({ message }: { message: Message }) {
4461
</button>
4562
{open && (
4663
<div className="mt-1.5 flex flex-col gap-3 rounded-lg border border-border bg-surface px-4 py-3 text-left">
64+
{delivery.length > 0 && (
65+
<div className="flex flex-col gap-1">
66+
<span className="text-caption text-fg-subtle">전달력</span>
67+
<div className="flex flex-wrap gap-1.5">
68+
{delivery.map((d) => (
69+
<span
70+
key={d}
71+
className="rounded-pill bg-info-50 px-2 py-0.5 text-caption text-info-700"
72+
>
73+
{d}
74+
</span>
75+
))}
76+
</div>
77+
</div>
78+
)}
4779
{evals.length > 0 && (
4880
<div className="flex flex-wrap gap-1.5">
4981
{evals.map((e) => (

frontend/src/shared/api/generated.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,6 +910,13 @@ export interface components {
910910
modelAnswer?: string;
911911
answerRewrite?: string;
912912
coachingComment?: string;
913+
/** Format: double */
914+
speakingRateWpm?: number;
915+
/** Format: double */
916+
silenceDurationSec?: number;
917+
fillerWordCounts?: {
918+
[key: string]: number;
919+
};
913920
};
914921
VoiceStreamBeginResponse: {
915922
/** Format: int64 */

0 commit comments

Comments
 (0)