diff --git a/ai/src/ai_server/chain/feedback_generation_chain.py b/ai/src/ai_server/chain/feedback_generation_chain.py index 147444ff..c3b4fa73 100644 --- a/ai/src/ai_server/chain/feedback_generation_chain.py +++ b/ai/src/ai_server/chain/feedback_generation_chain.py @@ -14,6 +14,7 @@ from ai_server.chain.prompts import feedback_panel from ai_server.config.settings import Settings from ai_server.core.client import CoreClient +from ai_server.model.messages.feedback import PanelBreakdownItem from ai_server.observability.llm_logging_callback import CoreAiLogCallback log = structlog.get_logger(__name__) @@ -27,6 +28,7 @@ class FeedbackResult(BaseModel): strengths_summary: str | None = Field(None) weaknesses_summary: str | None = Field(None) improvement_keywords: list[str] = Field(default_factory=list) + panel_breakdown: list[PanelBreakdownItem] = Field(default_factory=list) class FeedbackGenerator(Protocol): @@ -320,6 +322,17 @@ async def generate( ) keywords = _dedup_keywords(tech.keywords + logic.keywords + comm.keywords) + breakdown = [ + PanelBreakdownItem( + evaluator=spec.label, + dimension=spec.dimension_name, + score=res.score, + strength=res.strength, + weakness=res.weakness, + ) + for spec, res in zip(specs, (tech, logic, comm)) + ] + return FeedbackResult( overall_score=overall, technical_accuracy=tech.score, @@ -328,4 +341,5 @@ async def generate( strengths_summary=strengths, weaknesses_summary=weaknesses, improvement_keywords=keywords, + panel_breakdown=breakdown, ) diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index d61dd1cc..2c8a88d0 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -108,6 +108,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None: strengths_summary=result.strengths_summary, weaknesses_summary=result.weaknesses_summary, improvement_keywords=result.improvement_keywords, + panel_breakdown=result.panel_breakdown, report_s3_key=None, ) diff --git a/ai/src/ai_server/model/messages/feedback.py b/ai/src/ai_server/model/messages/feedback.py index 9c73f01a..675de679 100644 --- a/ai/src/ai_server/model/messages/feedback.py +++ b/ai/src/ai_server/model/messages/feedback.py @@ -62,6 +62,18 @@ class GenerateFeedbackRequest(BaseModel): voice_analysis_summary: VoiceAnalysisSummary | None = None +class PanelBreakdownItem(BaseModel): + """멀티 면접관 패널의 평가위원 1명 결과(분해 표시·저장용).""" + + model_config = camel_config() + + evaluator: str # 라벨: 기술/인성/논리/전달 + dimension: str # 평가축 이름 + score: float | None = None + strength: str | None = None + weakness: str | None = None + + class FeedbackCallbackPayload(BaseModel): """AI → Core 종합 피드백. 점수는 0~100 (NULL 허용).""" @@ -75,4 +87,6 @@ class FeedbackCallbackPayload(BaseModel): strengths_summary: str | None = None weaknesses_summary: str | None = None improvement_keywords: list[str] = Field(default_factory=list) + # 평가위원별 분해(패널). 비어 있으면 단일/레거시 경로. + panel_breakdown: list[PanelBreakdownItem] = Field(default_factory=list) report_s3_key: str | None = None diff --git a/ai/tests/test_feedback_panel.py b/ai/tests/test_feedback_panel.py index 2737b7bc..4894c0ba 100644 --- a/ai/tests/test_feedback_panel.py +++ b/ai/tests/test_feedback_panel.py @@ -53,6 +53,9 @@ async def test_weighted_overall_and_dimension_mapping(): assert r.overall_score == 65 assert "[기술]" in r.strengths_summary and "[논리]" in r.strengths_summary assert set(r.improvement_keywords) == {"JPA", "trade-off", "STAR"} + # 평가위원별 분해 + assert [b.evaluator for b in r.panel_breakdown] == ["기술", "논리", "전달"] + assert [b.score for b in r.panel_breakdown] == [80, 60, 40] @pytest.mark.asyncio diff --git a/backend/openapi.json b/backend/openapi.json index 42a7f194..f898e4ea 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -3341,6 +3341,13 @@ "type" : "string" } }, + "panelBreakdown" : { + "type" : "array", + "description" : "Per-evaluator panel breakdown (multi-interviewer). Empty for single/legacy feedback.", + "items" : { + "$ref" : "#/components/schemas/PanelBreakdownItem" + } + }, "reportFilePath" : { "type" : "string", "description" : "Stored report path when AI generates a detailed learning guide/report." @@ -3351,6 +3358,27 @@ } } }, + "PanelBreakdownItem" : { + "type" : "object", + "properties" : { + "evaluator" : { + "type" : "string" + }, + "dimension" : { + "type" : "string" + }, + "score" : { + "type" : "number", + "format" : "double" + }, + "strength" : { + "type" : "string" + }, + "weakness" : { + "type" : "string" + } + } + }, "CandidateRepositoryResponse" : { "type" : "object", "properties" : { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java index 40846c41..0a332bdc 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java @@ -75,6 +75,7 @@ public void apply(FeedbackCallbackEnvelope envelope) { payload.strengthsSummary(), payload.weaknessesSummary(), keywordsToJson(payload.improvementKeywords()), + breakdownToJson(payload.panelBreakdown()), payload.reportS3Key() ); try { @@ -109,6 +110,19 @@ private String keywordsToJson(java.util.List keywords) { } } + private String breakdownToJson( + java.util.List breakdown) { + if (breakdown == null || breakdown.isEmpty()) { + return null; + } + try { + return JSON.writeValueAsString(breakdown); + } catch (JsonProcessingException e) { + log.warn("panel_breakdown json serialize failed", e); + return null; + } + } + private boolean isProcessed(String messageId) { if (messageId == null || messageId.isBlank()) { return false; diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/FeedbackCallbackPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/FeedbackCallbackPayload.java index 840e3cc5..ceb2d0ce 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/FeedbackCallbackPayload.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/FeedbackCallbackPayload.java @@ -14,6 +14,7 @@ public record FeedbackCallbackPayload( String strengthsSummary, String weaknessesSummary, List improvementKeywords, + List panelBreakdown, String reportS3Key ) { } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/FeedbackResult.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/FeedbackResult.java index 18fe6885..d3e77bdd 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/FeedbackResult.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/FeedbackResult.java @@ -1,6 +1,7 @@ package com.stackup.stackup.session.application.dto; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.stackup.stackup.session.domain.SessionFeedback; import java.time.Instant; @@ -17,6 +18,7 @@ public record FeedbackResult( String strengthsSummary, String weaknessesSummary, List improvementKeywords, + List panelBreakdown, String reportFilePath, Instant createdAt ) { @@ -34,11 +36,23 @@ public static FeedbackResult of(SessionFeedback f) { f.getStrengthsSummary(), f.getWeaknessesSummary(), parseKeywords(f.getImprovementKeywords()), + parseBreakdown(f.getPanelBreakdown()), f.getReportFilePath(), f.getCreatedAt() ); } + private static List parseBreakdown(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + return JSON.readValue(json, new TypeReference>() {}); + } catch (JsonProcessingException e) { + return Collections.emptyList(); + } + } + @SuppressWarnings("unchecked") private static List parseKeywords(String json) { if (json == null || json.isBlank()) { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/PanelBreakdownItem.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/PanelBreakdownItem.java new file mode 100644 index 00000000..7a63244b --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/PanelBreakdownItem.java @@ -0,0 +1,14 @@ +package com.stackup.stackup.session.application.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +// 멀티 면접관 패널의 평가위원 1명 결과(분해). AI 콜백 camelCase 와 1:1, 응답에도 그대로 노출. +@JsonInclude(JsonInclude.Include.NON_NULL) +public record PanelBreakdownItem( + String evaluator, + String dimension, + Double score, + String strength, + String weakness +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedback.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedback.java index b5f1330e..1a20d47f 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedback.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedback.java @@ -52,6 +52,11 @@ public class SessionFeedback extends BaseSoftDeleteEntity { @Column(name = "improvement_keywords", columnDefinition = "jsonb") private String improvementKeywords; + // 멀티 면접관 패널의 평가위원별 분해 JSON. null = 단일/레거시. + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "panel_breakdown", columnDefinition = "jsonb") + private String panelBreakdown; + @Column(name = "report_file_path", length = 1000) private String reportFilePath; @@ -62,7 +67,8 @@ public class SessionFeedback extends BaseSoftDeleteEntity { private SessionFeedback(InterviewSession session, Double overallScore, Double technicalAccuracy, Double logicScore, Double communicationScore, String strengthsSummary, String weaknessesSummary, - String improvementKeywordsJson, String reportFilePath) { + String improvementKeywordsJson, String panelBreakdownJson, + String reportFilePath) { if (session == null) { throw new IllegalArgumentException("session must not be null"); } @@ -74,6 +80,7 @@ private SessionFeedback(InterviewSession session, Double overallScore, Double te this.strengthsSummary = strengthsSummary; this.weaknessesSummary = weaknessesSummary; this.improvementKeywords = improvementKeywordsJson; + this.panelBreakdown = panelBreakdownJson; this.reportFilePath = reportFilePath; } @@ -81,10 +88,11 @@ public static SessionFeedback of(InterviewSession session, Double overallScore, Double technicalAccuracy, Double logicScore, Double communicationScore, String strengthsSummary, String weaknessesSummary, - String improvementKeywordsJson, String reportFilePath) { + String improvementKeywordsJson, String panelBreakdownJson, + String reportFilePath) { return new SessionFeedback(session, overallScore, technicalAccuracy, logicScore, communicationScore, strengthsSummary, weaknessesSummary, - improvementKeywordsJson, reportFilePath); + improvementKeywordsJson, panelBreakdownJson, reportFilePath); } // 공유 토큰을 보장(없으면 발급)하고 현재 토큰 반환. 멱등. diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/FeedbackResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/FeedbackResponse.java index c3d9d629..9c7188f6 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/FeedbackResponse.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/FeedbackResponse.java @@ -1,6 +1,7 @@ package com.stackup.stackup.session.presentation.dto; import com.stackup.stackup.session.application.dto.FeedbackResult; +import com.stackup.stackup.session.application.dto.PanelBreakdownItem; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import java.util.List; @@ -16,6 +17,8 @@ public record FeedbackResponse( String weaknessesSummary, @Schema(description = "Improvement keywords returned by AI. The current contract is a string list.") List improvementKeywords, + @Schema(description = "Per-evaluator panel breakdown (multi-interviewer). Empty for single/legacy feedback.") + List panelBreakdown, @Schema(description = "Stored report path when AI generates a detailed learning guide/report.") String reportFilePath, Instant createdAt @@ -26,7 +29,7 @@ public static FeedbackResponse from(FeedbackResult r) { r.id(), r.sessionId(), r.overallScore(), r.technicalAccuracy(), r.logicScore(), r.communicationScore(), r.strengthsSummary(), r.weaknessesSummary(), - r.improvementKeywords(), r.reportFilePath(), r.createdAt() + r.improvementKeywords(), r.panelBreakdown(), r.reportFilePath(), r.createdAt() ); } } diff --git a/backend/src/main/resources/db/migration/V15__add_panel_breakdown_to_feedbacks.sql b/backend/src/main/resources/db/migration/V15__add_panel_breakdown_to_feedbacks.sql new file mode 100644 index 00000000..0f283655 --- /dev/null +++ b/backend/src/main/resources/db/migration/V15__add_panel_breakdown_to_feedbacks.sql @@ -0,0 +1,3 @@ +-- 멀티 면접관 패널: 평가위원별 분해(평가축/점수/강약점)를 JSON 으로 보관. +-- 비어 있으면 단일/레거시 피드백. 표시 전용이라 jsonb 단일 컬럼으로 충분. +ALTER TABLE session_feedbacks ADD COLUMN panel_breakdown jsonb; diff --git a/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java index ed201a87..a8a541a0 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java @@ -12,6 +12,7 @@ import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.session.application.dto.FeedbackCallbackEnvelope; import com.stackup.stackup.session.application.dto.FeedbackCallbackPayload; +import com.stackup.stackup.session.application.dto.PanelBreakdownItem; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.JobCategory; @@ -44,7 +45,9 @@ void apply_insertsFeedbackAndPushesSse() { InterviewSession session = sessionFixture(50L); FeedbackCallbackEnvelope env = envelope(50L, "fb-1", new FeedbackCallbackPayload(50L, 85.0, 80.0, 90.0, 75.0, - "strength summary", "weakness summary", List.of("Spring", "JPA"), null)); + "strength summary", "weakness summary", List.of("Spring", "JPA"), + List.of(new PanelBreakdownItem("기술", "기술 정확도·깊이", 80.0, "설계 깊이", "테스트 부족")), + null)); when(processedMessageRepository.existsById("fb-1")).thenReturn(false); when(feedbackRepository.existsBySession_Id(50L)).thenReturn(false); @@ -61,6 +64,7 @@ void apply_insertsFeedbackAndPushesSse() { verify(feedbackRepository).save(cap.capture()); assertThat(cap.getValue().getOverallScore()).isEqualTo(85.0); assertThat(cap.getValue().getImprovementKeywords()).contains("Spring"); + assertThat(cap.getValue().getPanelBreakdown()).contains("기술"); ArgumentCaptor evCap = ArgumentCaptor.forClass(Object.class); verify(events, atLeastOnce()).publishEvent(evCap.capture()); @@ -75,7 +79,7 @@ void apply_insertsFeedbackAndPushesSse() { @Test void apply_skipsWhenDuplicateMessage() { FeedbackCallbackEnvelope env = envelope(50L, "dup", - new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, List.of(), null)); + new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, List.of(), List.of(), null)); when(processedMessageRepository.existsById("dup")).thenReturn(true); service.apply(env); @@ -87,7 +91,7 @@ void apply_skipsWhenDuplicateMessage() { void apply_skipsWhenFeedbackAlreadyExists() { InterviewSession session = sessionFixture(50L); FeedbackCallbackEnvelope env = envelope(50L, "fb-2", - new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, List.of(), null)); + new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, List.of(), List.of(), null)); when(processedMessageRepository.existsById("fb-2")).thenReturn(false); when(sessionRepository.findById(50L)).thenReturn(Optional.of(session)); when(feedbackRepository.existsBySession_Id(50L)).thenReturn(true); diff --git a/frontend/src/features/feedback/ui/FeedbackReport.tsx b/frontend/src/features/feedback/ui/FeedbackReport.tsx index b373bbcf..ba9aa050 100644 --- a/frontend/src/features/feedback/ui/FeedbackReport.tsx +++ b/frontend/src/features/feedback/ui/FeedbackReport.tsx @@ -68,6 +68,25 @@ export function FeedbackReport({ + {feedback.panelBreakdown && feedback.panelBreakdown.length > 0 && ( +
+

면접관 패널 평가

+
+ {feedback.panelBreakdown.map((b) => ( +
+ + {(b.strength || b.weakness) && ( +
+ {b.strength && 강점 · {b.strength}} + {b.weakness && 보완 · {b.weakness}} +
+ )} +
+ ))} +
+
+ )} + {feedback.strengthsSummary && (

강점

diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index b4d75812..1a3b5dfe 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -555,6 +555,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/sessions/{sessionId}/messages/{messageId}/audio/segments/{seq}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 라이브 TTS 문장 세그먼트 스트리밍 + * @description AI 서버가 S3에 쓴 per-sentence TTS 세그먼트를 규칙 기반 키로 프록시한다. 세그먼트는 DB에 미저장(휘발성 라이브)이므로 Core 가 키를 재구성해 스트리밍. + */ + get: operations["streamMessageAudioSegment"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/sessions/{sessionId}/feedback": { parameters: { query?: never; @@ -1132,11 +1152,21 @@ export interface components { weaknessesSummary?: string; /** @description Improvement keywords returned by AI. The current contract is a string list. */ improvementKeywords?: string[]; + /** @description Per-evaluator panel breakdown (multi-interviewer). Empty for single/legacy feedback. */ + panelBreakdown?: components["schemas"]["PanelBreakdownItem"][]; /** @description Stored report path when AI generates a detailed learning guide/report. */ reportFilePath?: string; /** Format: date-time */ createdAt?: string; }; + PanelBreakdownItem: { + evaluator?: string; + dimension?: string; + /** Format: double */ + score?: number; + strength?: string; + weakness?: string; + }; CandidateRepositoryResponse: { /** Format: int64 */ githubRepoId?: number; @@ -2763,6 +2793,50 @@ export interface operations { }; }; }; + streamMessageAudioSegment: { + parameters: { + query: { + ext: string; + }; + header?: never; + path: { + sessionId: number; + messageId: number; + seq: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 세그먼트 오디오 바이트 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + /** @description 인증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + /** @description 세션/메시지/세그먼트 없음 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; getSessionFeedback: { parameters: { query?: never;