Skip to content

Commit 1966756

Browse files
i3monthsclaude
andcommitted
feat: 멀티 면접관 패널 — 평가위원별 분해 저장·표시 (Phase 2A)
패널의 평가위원별 점수·강약점을 끝까지 흘려 피드백 리포트에 노출. "기술 80 / 논리 60 / 전달 40 → 종합 65" 가 보이게. - AI: callback.feedback 에 panel_breakdown(평가위원별 evaluator/dimension/ score/strength/weakness) 추가, PanelFeedbackGenerator 가 채움 - Core: session_feedbacks.panel_breakdown jsonb 컬럼(V15) + 콜백 수신·직렬화 저장 + FeedbackResponse 노출(공개 공유 응답 포함). improvement_keywords 와 동일 jsonb 패턴 - Frontend: 피드백 리포트에 "면접관 패널 평가" 섹션(평가위원별 ScoreBar + 강점/보완), OpenAPI 타입 재생성 출력은 비어 있으면 단일/레거시로 graceful. 검증: AI 249 / 백엔드 BUILD OK (분해 저장 테스트 포함) / 프론트 tsc·eslint·vitest 55. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 66e19e7 commit 1966756

15 files changed

Lines changed: 221 additions & 7 deletions

File tree

ai/src/ai_server/chain/feedback_generation_chain.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from ai_server.chain.prompts import feedback_panel
1515
from ai_server.config.settings import Settings
1616
from ai_server.core.client import CoreClient
17+
from ai_server.model.messages.feedback import PanelBreakdownItem
1718
from ai_server.observability.llm_logging_callback import CoreAiLogCallback
1819

1920
log = structlog.get_logger(__name__)
@@ -27,6 +28,7 @@ class FeedbackResult(BaseModel):
2728
strengths_summary: str | None = Field(None)
2829
weaknesses_summary: str | None = Field(None)
2930
improvement_keywords: list[str] = Field(default_factory=list)
31+
panel_breakdown: list[PanelBreakdownItem] = Field(default_factory=list)
3032

3133

3234
class FeedbackGenerator(Protocol):
@@ -320,6 +322,17 @@ async def generate(
320322
)
321323
keywords = _dedup_keywords(tech.keywords + logic.keywords + comm.keywords)
322324

325+
breakdown = [
326+
PanelBreakdownItem(
327+
evaluator=spec.label,
328+
dimension=spec.dimension_name,
329+
score=res.score,
330+
strength=res.strength,
331+
weakness=res.weakness,
332+
)
333+
for spec, res in zip(specs, (tech, logic, comm))
334+
]
335+
323336
return FeedbackResult(
324337
overall_score=overall,
325338
technical_accuracy=tech.score,
@@ -328,4 +341,5 @@ async def generate(
328341
strengths_summary=strengths,
329342
weaknesses_summary=weaknesses,
330343
improvement_keywords=keywords,
344+
panel_breakdown=breakdown,
331345
)

ai/src/ai_server/messaging/consumers/feedback_consumer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
108108
strengths_summary=result.strengths_summary,
109109
weaknesses_summary=result.weaknesses_summary,
110110
improvement_keywords=result.improvement_keywords,
111+
panel_breakdown=result.panel_breakdown,
111112
report_s3_key=None,
112113
)
113114

ai/src/ai_server/model/messages/feedback.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ class GenerateFeedbackRequest(BaseModel):
6262
voice_analysis_summary: VoiceAnalysisSummary | None = None
6363

6464

65+
class PanelBreakdownItem(BaseModel):
66+
"""멀티 면접관 패널의 평가위원 1명 결과(분해 표시·저장용)."""
67+
68+
model_config = camel_config()
69+
70+
evaluator: str # 라벨: 기술/인성/논리/전달
71+
dimension: str # 평가축 이름
72+
score: float | None = None
73+
strength: str | None = None
74+
weakness: str | None = None
75+
76+
6577
class FeedbackCallbackPayload(BaseModel):
6678
"""AI → Core 종합 피드백. 점수는 0~100 (NULL 허용)."""
6779

@@ -75,4 +87,6 @@ class FeedbackCallbackPayload(BaseModel):
7587
strengths_summary: str | None = None
7688
weaknesses_summary: str | None = None
7789
improvement_keywords: list[str] = Field(default_factory=list)
90+
# 평가위원별 분해(패널). 비어 있으면 단일/레거시 경로.
91+
panel_breakdown: list[PanelBreakdownItem] = Field(default_factory=list)
7892
report_s3_key: str | None = None

ai/tests/test_feedback_panel.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ async def test_weighted_overall_and_dimension_mapping():
5353
assert r.overall_score == 65
5454
assert "[기술]" in r.strengths_summary and "[논리]" in r.strengths_summary
5555
assert set(r.improvement_keywords) == {"JPA", "trade-off", "STAR"}
56+
# 평가위원별 분해
57+
assert [b.evaluator for b in r.panel_breakdown] == ["기술", "논리", "전달"]
58+
assert [b.score for b in r.panel_breakdown] == [80, 60, 40]
5659

5760

5861
@pytest.mark.asyncio

backend/openapi.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3341,6 +3341,13 @@
33413341
"type" : "string"
33423342
}
33433343
},
3344+
"panelBreakdown" : {
3345+
"type" : "array",
3346+
"description" : "Per-evaluator panel breakdown (multi-interviewer). Empty for single/legacy feedback.",
3347+
"items" : {
3348+
"$ref" : "#/components/schemas/PanelBreakdownItem"
3349+
}
3350+
},
33443351
"reportFilePath" : {
33453352
"type" : "string",
33463353
"description" : "Stored report path when AI generates a detailed learning guide/report."
@@ -3351,6 +3358,27 @@
33513358
}
33523359
}
33533360
},
3361+
"PanelBreakdownItem" : {
3362+
"type" : "object",
3363+
"properties" : {
3364+
"evaluator" : {
3365+
"type" : "string"
3366+
},
3367+
"dimension" : {
3368+
"type" : "string"
3369+
},
3370+
"score" : {
3371+
"type" : "number",
3372+
"format" : "double"
3373+
},
3374+
"strength" : {
3375+
"type" : "string"
3376+
},
3377+
"weakness" : {
3378+
"type" : "string"
3379+
}
3380+
}
3381+
},
33543382
"CandidateRepositoryResponse" : {
33553383
"type" : "object",
33563384
"properties" : {

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public void apply(FeedbackCallbackEnvelope envelope) {
7575
payload.strengthsSummary(),
7676
payload.weaknessesSummary(),
7777
keywordsToJson(payload.improvementKeywords()),
78+
breakdownToJson(payload.panelBreakdown()),
7879
payload.reportS3Key()
7980
);
8081
try {
@@ -109,6 +110,19 @@ private String keywordsToJson(java.util.List<String> keywords) {
109110
}
110111
}
111112

113+
private String breakdownToJson(
114+
java.util.List<com.stackup.stackup.session.application.dto.PanelBreakdownItem> breakdown) {
115+
if (breakdown == null || breakdown.isEmpty()) {
116+
return null;
117+
}
118+
try {
119+
return JSON.writeValueAsString(breakdown);
120+
} catch (JsonProcessingException e) {
121+
log.warn("panel_breakdown json serialize failed", e);
122+
return null;
123+
}
124+
}
125+
112126
private boolean isProcessed(String messageId) {
113127
if (messageId == null || messageId.isBlank()) {
114128
return false;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public record FeedbackCallbackPayload(
1414
String strengthsSummary,
1515
String weaknessesSummary,
1616
List<String> improvementKeywords,
17+
List<PanelBreakdownItem> panelBreakdown,
1718
String reportS3Key
1819
) {
1920
}

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

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

33
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.core.type.TypeReference;
45
import com.fasterxml.jackson.databind.ObjectMapper;
56
import com.stackup.stackup.session.domain.SessionFeedback;
67
import java.time.Instant;
@@ -17,6 +18,7 @@ public record FeedbackResult(
1718
String strengthsSummary,
1819
String weaknessesSummary,
1920
List<String> improvementKeywords,
21+
List<PanelBreakdownItem> panelBreakdown,
2022
String reportFilePath,
2123
Instant createdAt
2224
) {
@@ -34,11 +36,23 @@ public static FeedbackResult of(SessionFeedback f) {
3436
f.getStrengthsSummary(),
3537
f.getWeaknessesSummary(),
3638
parseKeywords(f.getImprovementKeywords()),
39+
parseBreakdown(f.getPanelBreakdown()),
3740
f.getReportFilePath(),
3841
f.getCreatedAt()
3942
);
4043
}
4144

45+
private static List<PanelBreakdownItem> parseBreakdown(String json) {
46+
if (json == null || json.isBlank()) {
47+
return List.of();
48+
}
49+
try {
50+
return JSON.readValue(json, new TypeReference<List<PanelBreakdownItem>>() {});
51+
} catch (JsonProcessingException e) {
52+
return Collections.emptyList();
53+
}
54+
}
55+
4256
@SuppressWarnings("unchecked")
4357
private static List<String> parseKeywords(String json) {
4458
if (json == null || json.isBlank()) {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.stackup.stackup.session.application.dto;
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude;
4+
5+
// 멀티 면접관 패널의 평가위원 1명 결과(분해). AI 콜백 camelCase 와 1:1, 응답에도 그대로 노출.
6+
@JsonInclude(JsonInclude.Include.NON_NULL)
7+
public record PanelBreakdownItem(
8+
String evaluator,
9+
String dimension,
10+
Double score,
11+
String strength,
12+
String weakness
13+
) {
14+
}

backend/src/main/java/com/stackup/stackup/session/domain/SessionFeedback.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ public class SessionFeedback extends BaseSoftDeleteEntity {
5252
@Column(name = "improvement_keywords", columnDefinition = "jsonb")
5353
private String improvementKeywords;
5454

55+
// 멀티 면접관 패널의 평가위원별 분해 JSON. null = 단일/레거시.
56+
@JdbcTypeCode(SqlTypes.JSON)
57+
@Column(name = "panel_breakdown", columnDefinition = "jsonb")
58+
private String panelBreakdown;
59+
5560
@Column(name = "report_file_path", length = 1000)
5661
private String reportFilePath;
5762

@@ -62,7 +67,8 @@ public class SessionFeedback extends BaseSoftDeleteEntity {
6267
private SessionFeedback(InterviewSession session, Double overallScore, Double technicalAccuracy,
6368
Double logicScore, Double communicationScore,
6469
String strengthsSummary, String weaknessesSummary,
65-
String improvementKeywordsJson, String reportFilePath) {
70+
String improvementKeywordsJson, String panelBreakdownJson,
71+
String reportFilePath) {
6672
if (session == null) {
6773
throw new IllegalArgumentException("session must not be null");
6874
}
@@ -74,17 +80,19 @@ private SessionFeedback(InterviewSession session, Double overallScore, Double te
7480
this.strengthsSummary = strengthsSummary;
7581
this.weaknessesSummary = weaknessesSummary;
7682
this.improvementKeywords = improvementKeywordsJson;
83+
this.panelBreakdown = panelBreakdownJson;
7784
this.reportFilePath = reportFilePath;
7885
}
7986

8087
public static SessionFeedback of(InterviewSession session, Double overallScore,
8188
Double technicalAccuracy, Double logicScore,
8289
Double communicationScore,
8390
String strengthsSummary, String weaknessesSummary,
84-
String improvementKeywordsJson, String reportFilePath) {
91+
String improvementKeywordsJson, String panelBreakdownJson,
92+
String reportFilePath) {
8593
return new SessionFeedback(session, overallScore, technicalAccuracy, logicScore,
8694
communicationScore, strengthsSummary, weaknessesSummary,
87-
improvementKeywordsJson, reportFilePath);
95+
improvementKeywordsJson, panelBreakdownJson, reportFilePath);
8896
}
8997

9098
// 공유 토큰을 보장(없으면 발급)하고 현재 토큰 반환. 멱등.

0 commit comments

Comments
 (0)