Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 는
Expand Down
15 changes: 15 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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<Map<String, Integer>> 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<MessageResult> 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<Long, MessageVoiceAnalysis> 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<String, Integer> fillers = voice == null ? null : parseFillers(voice.getFillerWordCounts());
return MessageResult.of(m, ttsUrl, audioUrl, revealInsights, wpm, silence, fillers);
}

private Map<String, Integer> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -34,7 +35,11 @@ public record MessageResult(
Double answerCorrectness,
String modelAnswer,
String answerRewrite,
String coachingComment
String coachingComment,
// 답변 전달력 메트릭(음성 답변에만, 종료 세션 조회에서만). 별도 엔티티(MessageVoiceAnalysis)에서 온다.
Double speakingRateWpm,
Double silenceDurationSec,
Map<String, Integer> fillerWordCounts
) {
public static MessageResult of(InterviewMessage m) {
return of(m, null, null, false);
Expand All @@ -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<String, Integer> fillerWordCounts) {
return new MessageResult(
m.getId(),
m.getSession().getId(),
Expand All @@ -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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ public record MessageResponse(
Double answerCorrectness,
String modelAnswer,
String answerRewrite,
String coachingComment
String coachingComment,
// 답변 전달력 메트릭(음성 답변·종료 세션에서만).
Double speakingRateWpm,
Double silenceDurationSec,
java.util.Map<String, Integer> fillerWordCounts
) {
public static MessageResponse from(MessageResult r) {
return new MessageResponse(
Expand All @@ -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()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -44,6 +61,21 @@ export function AnswerCoachingAccordion({ message }: { message: Message }) {
</button>
{open && (
<div className="mt-1.5 flex flex-col gap-3 rounded-lg border border-border bg-surface px-4 py-3 text-left">
{delivery.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-caption text-fg-subtle">전달력</span>
<div className="flex flex-wrap gap-1.5">
{delivery.map((d) => (
<span
key={d}
className="rounded-pill bg-info-50 px-2 py-0.5 text-caption text-info-700"
>
{d}
</span>
))}
</div>
</div>
)}
{evals.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{evals.map((e) => (
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/shared/api/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down