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
3 changes: 2 additions & 1 deletion ai/src/ai_server/chain/prompts/self_intro_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"지원 직군: {job_category} / 면접 모드: {mode}\n\n"
"=== 면접관 질문(자기소개) ===\n{self_intro_question}\n\n"
"=== 지원자 자기소개 답변 ===\n{self_intro_answer}\n\n"
"=== (참고) 세션 전체 음성 지표 — 자기소개 단독이 아님 ===\n{voice_analysis_summary}\n\n"
"=== 자기소개 답변의 음성 지표(없으면 세션 평균) ===\n{voice_analysis_summary}\n\n"
"전달력 평가 시 위 음성 지표(발화 속도·무음·간투어)를 적극 반영하세요.\n\n"
"{format_instructions}"
)
8 changes: 7 additions & 1 deletion ai/src/ai_server/messaging/consumers/feedback_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,19 @@ async def _evaluate_self_intro(
if pair is None:
return None # 레거시 세션(자기소개 없음) 또는 빈 답변 — 건너뜀
question, answer = pair
# 자기소개 답변 단독 음성 지표가 있으면 그걸, 없으면 세션 평균을 폴백으로 사용.
intro_voice_summary = (
_build_voice_analysis_summary(req.self_intro_voice_analysis)
if req.self_intro_voice_analysis is not None
else voice_analysis_summary
)
try:
ev = await self._self_intro_evaluator.evaluate(
job_category=req.job_category,
mode=req.mode,
self_intro_question=question.content,
self_intro_answer=answer.content,
voice_analysis_summary=voice_analysis_summary,
voice_analysis_summary=intro_voice_summary,
)
except Exception as exc: # noqa: BLE001
log.warning(
Expand Down
2 changes: 2 additions & 0 deletions ai/src/ai_server/model/messages/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ class GenerateFeedbackRequest(BaseModel):
messages: list[FeedbackMessageItem] = Field(default_factory=list)
context_document_ids: list[int] = Field(default_factory=list)
voice_analysis_summary: VoiceAnalysisSummary | None = None
# 자기소개 답변 단독의 음성 메트릭. 첫인상 평가에서 세션 평균 대신 사용. 없으면 None.
self_intro_voice_analysis: VoiceAnalysisSummary | None = None
# 다직군 패널 가중: 사용된 일반질문의 직군별 개수. 비면 단일 직군 평가.
domain_question_counts: dict[str, int] = Field(default_factory=dict)
# 직무 맞춤(JOB_TAILORED) 모드 전용. 회사명 + 채용공고(JD). '직무 적합도' 평가의 근거.
Expand Down
5 changes: 5 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,11 @@ docker compose up -d
`FeedbackCallbackService` 가 각 메시지에 `recordCoaching` 기록. `MessageResult`/`MessageResponse` 가 답변
평가 점수 + 복기 + **답변 전달력 메트릭(WPM·무음·간투어, `MessageVoiceAnalysis` 에서 파싱)** 을 노출하되
**종료 세션 조회에서만**(`expectedSignal` 과 동일 게이팅). 프론트는 답변 버블 아래 '복기' 아코디언으로 표시.
- **전달력 피드백 강화 본 구현**: `MessageResult`/`MessageResponse` 가 `pronunciationAccuracy`(STT 신뢰도 근사)
와 **결정론적 전달력 평가**(`DeliveryFeedback.assess` — 어절/분·무음 비율·100어절당 간투어·발음 임계치 →
`deliveryRating` GOOD/FAIR/POOR + `deliveryComment` 한 줄 코칭, LLM 비호출)를 추가 노출(종료 세션만, 음성 답변만).
자기소개 첫인상은 세션 평균이 아니라 **자기소개 답변 단독 음성 지표**로 평가 — `SessionFeedbackRequester` 가
`generate.feedback.selfIntroVoiceAnalysis`(자기소개 답변의 WPM/무음/간투어)를 동봉하고 AI 첫인상 평가가 이를 사용.
- **직무 맞춤 면접 모드(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
10 changes: 10 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2843,6 +2843,16 @@
"type" : "integer",
"format" : "int32"
}
},
"pronunciationAccuracy" : {
"type" : "number",
"format" : "double"
},
"deliveryRating" : {
"type" : "string"
},
"deliveryComment" : {
"type" : "string"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ private MessageResult toResultWithAudioUrls(
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);
Double pronunciation = voice == null ? null : voice.getPronunciationAccuracy();
return MessageResult.of(
m, ttsUrl, audioUrl, revealInsights, wpm, silence, fillers, pronunciation);
}

private Map<String, Integer> parseFillers(String json) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,9 @@ public void onSessionEnded(SessionEndedEvent event) {
return;
}

List<MessageItem> messages = messageRepository
.findBySession_IdOrderBySequenceNumberAsc(event.sessionId()).stream()
.map(this::toItem)
.toList();
List<InterviewMessage> msgEntities =
messageRepository.findBySession_IdOrderBySequenceNumberAsc(event.sessionId());
List<MessageItem> messages = msgEntities.stream().map(this::toItem).toList();
List<Long> contextDocumentIds = contextRepository.findBySession_Id(event.sessionId()).stream()
.map(c -> c.getDocument().getId())
.toList();
Expand All @@ -76,6 +75,8 @@ public void onSessionEnded(SessionEndedEvent event) {
domainQuestionCounts.merge(jc, 1, Integer::sum);
}

List<MessageVoiceAnalysis> voiceAnalyses =
voiceAnalysisRepository.findByMessage_Session_Id(event.sessionId());
GenerateFeedbackPayload payload = new GenerateFeedbackPayload(
session.getId(),
session.getMode().name(),
Expand All @@ -84,10 +85,11 @@ public void onSessionEnded(SessionEndedEvent event) {
event.reason(),
messages,
contextDocumentIds,
summarizeVoiceAnalysis(event.sessionId()),
summarize(voiceAnalyses),
domainQuestionCounts,
session.getTargetCompanyName(),
session.getTargetJobDescription()
session.getTargetJobDescription(),
selfIntroVoiceAnalysis(msgEntities, voiceAnalyses)
);

publisher.publishToAi(
Expand Down Expand Up @@ -124,8 +126,31 @@ private MessageItem toItem(InterviewMessage m) {
);
}

private VoiceAnalysisSummary summarizeVoiceAnalysis(Long sessionId) {
List<MessageVoiceAnalysis> analyses = voiceAnalysisRepository.findByMessage_Session_Id(sessionId);
// 자기소개 답변(첫 질문에 대한 INTERVIEWEE 답변)의 음성 메트릭만 따로 요약. 첫인상 평가에서
// 세션 평균 대신 자기소개 단독 전달력을 보게 한다. 자기소개 답변·음성 분석이 없으면 null.
private VoiceAnalysisSummary selfIntroVoiceAnalysis(
List<InterviewMessage> msgEntities, List<MessageVoiceAnalysis> analyses) {
InterviewMessage selfIntroQuestion = msgEntities.stream()
.filter(InterviewMessage::isSelfIntroduction)
.findFirst().orElse(null);
if (selfIntroQuestion == null) {
return null;
}
Long answerId = msgEntities.stream()
.filter(m -> m.getParentMessage() != null
&& selfIntroQuestion.getId().equals(m.getParentMessage().getId()))
.map(InterviewMessage::getId)
.findFirst().orElse(null);
if (answerId == null) {
return null;
}
List<MessageVoiceAnalysis> own = analyses.stream()
.filter(a -> answerId.equals(a.getMessage().getId()))
.toList();
return own.isEmpty() ? null : summarize(own);
}

private VoiceAnalysisSummary summarize(List<MessageVoiceAnalysis> analyses) {
if (analyses.isEmpty()) {
return new VoiceAnalysisSummary(0, null, 0.0, Map.of());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.stackup.stackup.session.application.dto;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

// 답변 음성 메트릭(WPM·무음·간투어·발음 신뢰도) → 결정론적 전달력 평가. LLM 비호출.
// rating: GOOD/FAIR/POOR (이슈 0/1/2+개), comment: 답변별 한 줄 전달력 코칭.
// 임계치는 어절(공백 기준) 단위 기준. content 로 어절 수를 세어 무음 비율·간투어 밀도를 정규화.
public record DeliveryFeedback(String rating, String comment) {

private static final double WPM_SLOW = 90; // 어절/분 미만이면 느림
private static final double WPM_FAST = 150; // 어절/분 초과면 빠름
private static final double SILENCE_RATIO_HIGH = 0.35; // 전체 발화시간 중 무음 비율
private static final double FILLERS_PER_100_HIGH = 3.0; // 100어절당 간투어 개수
private static final double PRONUNCIATION_LOW = 0.85; // STT 신뢰도 근사

// 음성 메트릭이 하나라도 있으면 평가, 전부 없으면(텍스트 답변 등) null.
public static DeliveryFeedback assess(
String content, Double wpm, Double silenceSec,
Map<String, Integer> fillers, Double pronunciation) {
int fillerTotal = fillers == null ? 0
: fillers.values().stream().mapToInt(Integer::intValue).sum();
boolean noMetrics = wpm == null && silenceSec == null
&& fillerTotal == 0 && pronunciation == null;
if (noMetrics) {
return null;
}

int words = wordCount(content);
List<String> issues = new ArrayList<>();

if (wpm != null && words > 0) {
if (wpm > WPM_FAST) {
issues.add("말이 빠른 편입니다(약 %d 어절/분). 핵심 문장 앞에서 한 박자 쉬어 강조하세요."
.formatted(Math.round(wpm)));
} else if (wpm < WPM_SLOW) {
issues.add("말이 다소 느립니다(약 %d 어절/분). 군더더기를 줄이고 핵심을 먼저 말해보세요."
.formatted(Math.round(wpm)));
}
}

// 무음 비율: duration ≈ words / wpm * 60 (WPM 의 분모가 전체 오디오 길이).
if (silenceSec != null && silenceSec > 0 && wpm != null && wpm > 0 && words > 0) {
double durationSec = words / wpm * 60.0;
double ratio = durationSec > 0 ? silenceSec / durationSec : 0;
if (ratio > SILENCE_RATIO_HIGH) {
issues.add("말 사이 침묵이 깁니다(총 %d초). 답변을 미리 구조화해 끊김을 줄여보세요."
.formatted(Math.round(silenceSec)));
}
}

if (fillerTotal > 0 && words > 0) {
double per100 = fillerTotal * 100.0 / words;
if (per100 > FILLERS_PER_100_HIGH) {
String top = fillers.entrySet().stream()
.max(Map.Entry.comparingByValue()).map(Map.Entry::getKey).orElse("어");
issues.add("간투어가 잦습니다('%s' 등 %d회). 한 문장을 끝맺고 잠깐 멈추는 연습이 도움이 됩니다."
.formatted(top, fillerTotal));
}
}

if (pronunciation != null && pronunciation < PRONUNCIATION_LOW) {
issues.add("발음이 다소 뭉개져 인식 정확도가 낮습니다. 또박또박 발음해보세요.");
}

String rating = switch (issues.size()) {
case 0 -> "GOOD";
case 1 -> "FAIR";
default -> "POOR";
};
String comment = issues.isEmpty()
? "전달이 안정적입니다. 속도·간투어·침묵 모두 적정 범위입니다."
: String.join(" ", issues);
return new DeliveryFeedback(rating, comment);
}

private static int wordCount(String content) {
if (content == null || content.isBlank()) {
return 0;
}
return content.trim().split("\\s+").length;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ public record GenerateFeedbackPayload(
Map<String, Integer> domainQuestionCounts,
// 직무 맞춤 모드 전용. 회사명 + 채용공고(JD). '직무 적합도' 평가의 근거. 다른 모드는 null.
String targetCompanyName,
String targetJobDescription
String targetJobDescription,
// 자기소개 답변 단독의 음성 메트릭. 첫인상 평가에서 세션 평균 대신 사용. 없으면 null.
VoiceAnalysisSummary selfIntroVoiceAnalysis
) {

public record MessageItem(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ public record MessageResult(
// 답변 전달력 메트릭(음성 답변에만, 종료 세션 조회에서만). 별도 엔티티(MessageVoiceAnalysis)에서 온다.
Double speakingRateWpm,
Double silenceDurationSec,
Map<String, Integer> fillerWordCounts
Map<String, Integer> fillerWordCounts,
Double pronunciationAccuracy,
// 위 메트릭에서 결정론적으로 산정한 전달력 평가(배지 GOOD/FAIR/POOR + 한 줄 코칭). 음성 답변에만.
String deliveryRating,
String deliveryComment
) {
public static MessageResult of(InterviewMessage m) {
return of(m, null, null, false);
Expand All @@ -51,13 +55,19 @@ public static MessageResult of(InterviewMessage m, String ttsAudioUrl, String au

public static MessageResult of(
InterviewMessage m, String ttsAudioUrl, String audioFileUrl, boolean revealInsights) {
return of(m, ttsAudioUrl, audioFileUrl, revealInsights, null, null, null);
return of(m, ttsAudioUrl, audioFileUrl, revealInsights, null, 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) {
Double speakingRateWpm, Double silenceDurationSec, Map<String, Integer> fillerWordCounts,
Double pronunciationAccuracy) {
DeliveryFeedback delivery = revealInsights
? DeliveryFeedback.assess(
m.getContent(), speakingRateWpm, silenceDurationSec,
fillerWordCounts, pronunciationAccuracy)
: null;
return new MessageResult(
m.getId(),
m.getSession().getId(),
Expand Down Expand Up @@ -85,7 +95,10 @@ public static MessageResult of(
revealInsights ? m.getCoachingComment() : null,
revealInsights ? speakingRateWpm : null,
revealInsights ? silenceDurationSec : null,
revealInsights ? fillerWordCounts : null
revealInsights ? fillerWordCounts : null,
revealInsights ? pronunciationAccuracy : null,
delivery == null ? null : delivery.rating(),
delivery == null ? null : delivery.comment()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ public record MessageResponse(
// 답변 전달력 메트릭(음성 답변·종료 세션에서만).
Double speakingRateWpm,
Double silenceDurationSec,
java.util.Map<String, Integer> fillerWordCounts
java.util.Map<String, Integer> fillerWordCounts,
Double pronunciationAccuracy,
// 전달력 메트릭에서 산정한 배지(GOOD/FAIR/POOR)와 한 줄 코칭.
String deliveryRating,
String deliveryComment
) {
public static MessageResponse from(MessageResult r) {
return new MessageResponse(
Expand Down Expand Up @@ -67,7 +71,10 @@ public static MessageResponse from(MessageResult r) {
r.coachingComment(),
r.speakingRateWpm(),
r.silenceDurationSec(),
r.fillerWordCounts()
r.fillerWordCounts(),
r.pronunciationAccuracy(),
r.deliveryRating(),
r.deliveryComment()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.stackup.stackup.session.application.dto;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.Map;
import org.junit.jupiter.api.Test;

class DeliveryFeedbackTest {

// 약 60어절 분량의 답변(무음 비율·간투어 밀도 정규화의 분모).
private static final String ANSWER = "저는 백엔드 개발자로서 "
+ "대용량 트래픽을 처리하는 결제 시스템을 설계하고 운영한 경험이 있습니다 "
+ "특히 데이터베이스 인덱스를 튜닝하고 캐시 계층을 도입해 응답 속도를 크게 "
+ "개선했으며 장애 상황에서도 안정적으로 동작하도록 모니터링과 알림 체계를 "
+ "구축했습니다 또한 동료들과 코드 리뷰 문화를 정착시켜 품질을 높였습니다 "
+ "앞으로도 견고한 시스템을 만드는 개발자가 되고 싶습니다 감사합니다";

@Test
void noMetrics_returnsNull() {
assertThat(DeliveryFeedback.assess(ANSWER, null, null, null, null)).isNull();
assertThat(DeliveryFeedback.assess(ANSWER, null, null, Map.of(), null)).isNull();
}

@Test
void cleanDelivery_isGood() {
DeliveryFeedback fb = DeliveryFeedback.assess(ANSWER, 120.0, 4.0, Map.of(), 0.95);
assertThat(fb).isNotNull();
assertThat(fb.rating()).isEqualTo("GOOD");
assertThat(fb.comment()).contains("안정");
}

@Test
void fastPace_isFlagged() {
DeliveryFeedback fb = DeliveryFeedback.assess(ANSWER, 180.0, 4.0, Map.of(), 0.95);
assertThat(fb.rating()).isEqualTo("FAIR");
assertThat(fb.comment()).contains("빠른");
}

@Test
void multipleIssues_arePoor() {
// 빠른 속도 + 잦은 간투어 + 낮은 발음 신뢰도 → 2건 이상 → POOR.
DeliveryFeedback fb = DeliveryFeedback.assess(
ANSWER, 185.0, 4.0, Map.of("어", 6, "그", 5), 0.70);
assertThat(fb.rating()).isEqualTo("POOR");
assertThat(fb.comment()).contains("간투어");
}

@Test
void normalFillerDensity_notFlagged() {
// ~60어절에 간투어 1회 → 100어절당 ~1.7개로 임계(3) 미만 → 미지적.
DeliveryFeedback fb = DeliveryFeedback.assess(ANSWER, 120.0, 4.0, Map.of("어", 1), 0.95);
assertThat(fb.rating()).isEqualTo("GOOD");
}
}
Loading