From 81a33c906e1d8dfbfff770c23c1cd52787e65257 Mon Sep 17 00:00:00 2001 From: jmj Date: Sun, 28 Jun 2026 20:57:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(feedback):=20=EC=A0=84=EB=8B=AC=EB=A0=A5?= =?UTF-8?q?=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EA=B0=95=ED=99=94=20=E2=80=94?= =?UTF-8?q?=20=EB=B0=B0=EC=A7=80=C2=B7=EC=BD=94=EC=B9=AD=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=A5=C2=B7=EB=B0=9C=EC=9D=8C=20=EC=A0=95=ED=99=95=EB=8F=84?= =?UTF-8?q?=C2=B7=EC=9E=90=EA=B8=B0=EC=86=8C=EA=B0=9C=20=EA=B0=9C=EB=B3=84?= =?UTF-8?q?=20=EC=9D=8C=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실전 면접 후 "목소리 피드백을 강화하고 싶다"는 요청 반영. - DeliveryFeedback.assess: 답변 음성 메트릭(어절/분·무음 비율·100어절당 간투어· 발음 신뢰도)에서 결정론적으로 전달력 배지(GOOD/FAIR/POOR) + 한 줄 코칭 산정(LLM 비호출). MessageResult/MessageResponse 에 deliveryRating/deliveryComment + pronunciationAccuracy 노출(종료 세션·음성 답변만, 기존 게이팅 동일). - 자기소개 첫인상: 세션 평균이 아니라 '자기소개 답변 단독' 음성 지표로 평가. SessionFeedbackRequester 가 generate.feedback.selfIntroVoiceAnalysis 동봉, AI feedback_consumer 가 첫인상 평가에 사용, self_intro 프롬프트가 음성 지표 적극 반영. - 프론트 AnswerCoachingAccordion: 전달력 배지 + 발음 정확도 칩 + 코칭 문장 표시. - OpenAPI 재생성, 프론트 타입 갱신. 백엔드 DeliveryFeedbackTest 추가, 전체 테스트 통과, 프론트 빌드 통과(기존 lint 에러 2파일 무관). Co-Authored-By: Claude Opus 4.8 --- .../chain/prompts/self_intro_evaluation.py | 3 +- .../messaging/consumers/feedback_consumer.py | 8 +- ai/src/ai_server/model/messages/feedback.py | 2 + backend/CLAUDE.md | 5 ++ backend/openapi.json | 10 +++ .../application/InterviewMessageService.java | 4 +- .../application/SessionFeedbackRequester.java | 41 +++++++-- .../application/dto/DeliveryFeedback.java | 84 +++++++++++++++++++ .../dto/GenerateFeedbackPayload.java | 4 +- .../application/dto/MessageResult.java | 21 ++++- .../presentation/dto/MessageResponse.java | 11 ++- .../application/dto/DeliveryFeedbackTest.java | 54 ++++++++++++ .../ui/live/AnswerCoachingAccordion.tsx | 47 +++++++++-- frontend/src/shared/api/generated.ts | 4 + 14 files changed, 271 insertions(+), 27 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/DeliveryFeedback.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/dto/DeliveryFeedbackTest.java diff --git a/ai/src/ai_server/chain/prompts/self_intro_evaluation.py b/ai/src/ai_server/chain/prompts/self_intro_evaluation.py index 870028be..0b2c697c 100644 --- a/ai/src/ai_server/chain/prompts/self_intro_evaluation.py +++ b/ai/src/ai_server/chain/prompts/self_intro_evaluation.py @@ -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}" ) diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index b7464306..40a2d206 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -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( diff --git a/ai/src/ai_server/model/messages/feedback.py b/ai/src/ai_server/model/messages/feedback.py index 0b9ca553..7b2cdee1 100644 --- a/ai/src/ai_server/model/messages/feedback.py +++ b/ai/src/ai_server/model/messages/feedback.py @@ -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). '직무 적합도' 평가의 근거. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index f42ab8db..7fe58134 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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 는 diff --git a/backend/openapi.json b/backend/openapi.json index e5962af8..353cb425 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -2843,6 +2843,16 @@ "type" : "integer", "format" : "int32" } + }, + "pronunciationAccuracy" : { + "type" : "number", + "format" : "double" + }, + "deliveryRating" : { + "type" : "string" + }, + "deliveryComment" : { + "type" : "string" } } }, 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 799da424..29bbdb56 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 @@ -74,7 +74,9 @@ private MessageResult toResultWithAudioUrls( 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); + Double pronunciation = voice == null ? null : voice.getPronunciationAccuracy(); + return MessageResult.of( + m, ttsUrl, audioUrl, revealInsights, wpm, silence, fillers, pronunciation); } private Map parseFillers(String json) { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java index f022995e..5a4ca929 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java @@ -61,10 +61,9 @@ public void onSessionEnded(SessionEndedEvent event) { return; } - List messages = messageRepository - .findBySession_IdOrderBySequenceNumberAsc(event.sessionId()).stream() - .map(this::toItem) - .toList(); + List msgEntities = + messageRepository.findBySession_IdOrderBySequenceNumberAsc(event.sessionId()); + List messages = msgEntities.stream().map(this::toItem).toList(); List contextDocumentIds = contextRepository.findBySession_Id(event.sessionId()).stream() .map(c -> c.getDocument().getId()) .toList(); @@ -76,6 +75,8 @@ public void onSessionEnded(SessionEndedEvent event) { domainQuestionCounts.merge(jc, 1, Integer::sum); } + List voiceAnalyses = + voiceAnalysisRepository.findByMessage_Session_Id(event.sessionId()); GenerateFeedbackPayload payload = new GenerateFeedbackPayload( session.getId(), session.getMode().name(), @@ -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( @@ -124,8 +126,31 @@ private MessageItem toItem(InterviewMessage m) { ); } - private VoiceAnalysisSummary summarizeVoiceAnalysis(Long sessionId) { - List analyses = voiceAnalysisRepository.findByMessage_Session_Id(sessionId); + // 자기소개 답변(첫 질문에 대한 INTERVIEWEE 답변)의 음성 메트릭만 따로 요약. 첫인상 평가에서 + // 세션 평균 대신 자기소개 단독 전달력을 보게 한다. 자기소개 답변·음성 분석이 없으면 null. + private VoiceAnalysisSummary selfIntroVoiceAnalysis( + List msgEntities, List 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 own = analyses.stream() + .filter(a -> answerId.equals(a.getMessage().getId())) + .toList(); + return own.isEmpty() ? null : summarize(own); + } + + private VoiceAnalysisSummary summarize(List analyses) { if (analyses.isEmpty()) { return new VoiceAnalysisSummary(0, null, 0.0, Map.of()); } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/DeliveryFeedback.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/DeliveryFeedback.java new file mode 100644 index 00000000..4a5e761e --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/DeliveryFeedback.java @@ -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 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 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; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java index d684d698..74d1d798 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java @@ -18,7 +18,9 @@ public record GenerateFeedbackPayload( Map domainQuestionCounts, // 직무 맞춤 모드 전용. 회사명 + 채용공고(JD). '직무 적합도' 평가의 근거. 다른 모드는 null. String targetCompanyName, - String targetJobDescription + String targetJobDescription, + // 자기소개 답변 단독의 음성 메트릭. 첫인상 평가에서 세션 평균 대신 사용. 없으면 null. + VoiceAnalysisSummary selfIntroVoiceAnalysis ) { public record MessageItem( 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 bdd205cd..c700ed96 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 @@ -39,7 +39,11 @@ public record MessageResult( // 답변 전달력 메트릭(음성 답변에만, 종료 세션 조회에서만). 별도 엔티티(MessageVoiceAnalysis)에서 온다. Double speakingRateWpm, Double silenceDurationSec, - Map fillerWordCounts + Map fillerWordCounts, + Double pronunciationAccuracy, + // 위 메트릭에서 결정론적으로 산정한 전달력 평가(배지 GOOD/FAIR/POOR + 한 줄 코칭). 음성 답변에만. + String deliveryRating, + String deliveryComment ) { public static MessageResult of(InterviewMessage m) { return of(m, null, null, false); @@ -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 fillerWordCounts) { + Double speakingRateWpm, Double silenceDurationSec, Map fillerWordCounts, + Double pronunciationAccuracy) { + DeliveryFeedback delivery = revealInsights + ? DeliveryFeedback.assess( + m.getContent(), speakingRateWpm, silenceDurationSec, + fillerWordCounts, pronunciationAccuracy) + : null; return new MessageResult( m.getId(), m.getSession().getId(), @@ -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() ); } } 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 4b698fa3..890f35de 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 @@ -37,7 +37,11 @@ public record MessageResponse( // 답변 전달력 메트릭(음성 답변·종료 세션에서만). Double speakingRateWpm, Double silenceDurationSec, - java.util.Map fillerWordCounts + java.util.Map fillerWordCounts, + Double pronunciationAccuracy, + // 전달력 메트릭에서 산정한 배지(GOOD/FAIR/POOR)와 한 줄 코칭. + String deliveryRating, + String deliveryComment ) { public static MessageResponse from(MessageResult r) { return new MessageResponse( @@ -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() ); } } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/dto/DeliveryFeedbackTest.java b/backend/src/test/java/com/stackup/stackup/session/application/dto/DeliveryFeedbackTest.java new file mode 100644 index 00000000..f3d4f17a --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/dto/DeliveryFeedbackTest.java @@ -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"); + } +} diff --git a/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx b/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx index 06c97362..8225e897 100644 --- a/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx +++ b/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx @@ -12,6 +12,13 @@ function score5(v?: number | null): string | null { return typeof v === 'number' ? `${v}/5` : null } +// 전달력 배지(결정론적 평가) → 라벨 + 색상 토큰. +const DELIVERY_RATING: Record = { + GOOD: { label: '전달력 우수', cls: 'bg-success-50 text-success-700' }, + FAIR: { label: '전달력 보통', cls: 'bg-warning-50 text-warning-700' }, + POOR: { label: '전달력 미흡', cls: 'bg-danger-50 text-danger-700' }, +} + // 답변 음성 전달력 메트릭 → 표시용 칩 문자열들. function deliveryChips(message: Message): string[] { const chips: string[] = [] @@ -25,6 +32,9 @@ function deliveryChips(message: Message): string[] { if (fillers.length > 0) { chips.push('간투어 ' + fillers.map(([w, c]) => `${w} ${c}`).join(' · ')) } + if (typeof message.pronunciationAccuracy === 'number') { + chips.push(`발음 정확도 ${Math.round(message.pronunciationAccuracy * 100)}%`) + } return chips } @@ -33,8 +43,13 @@ function deliveryChips(message: Message): string[] { export function AnswerCoachingAccordion({ message }: { message: Message }) { const [open, setOpen] = useState(false) const delivery = deliveryChips(message) + const rating = message.deliveryRating ? DELIVERY_RATING[message.deliveryRating] : undefined const hasContent = Boolean( - message.modelAnswer || message.answerRewrite || message.coachingComment || delivery.length > 0, + message.modelAnswer || + message.answerRewrite || + message.coachingComment || + message.deliveryComment || + delivery.length > 0, ) if (!hasContent) return null @@ -61,19 +76,33 @@ export function AnswerCoachingAccordion({ message }: { message: Message }) { {open && (
- {delivery.length > 0 && ( + {(delivery.length > 0 || rating || message.deliveryComment) && (
- 전달력 -
- {delivery.map((d) => ( +
+ 전달력 + {rating && ( - {d} + {rating.label} - ))} + )}
+ {delivery.length > 0 && ( +
+ {delivery.map((d) => ( + + {d} + + ))} +
+ )} + {message.deliveryComment && ( +

{message.deliveryComment}

+ )}
)} {evals.length > 0 && ( diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index ad0ec384..5ef70d07 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -917,6 +917,10 @@ export interface components { fillerWordCounts?: { [key: string]: number; }; + /** Format: double */ + pronunciationAccuracy?: number; + deliveryRating?: string; + deliveryComment?: string; }; VoiceStreamBeginResponse: { /** Format: int64 */