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
14 changes: 14 additions & 0 deletions ai/src/ai_server/chain/feedback_generation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand All @@ -328,4 +341,5 @@ async def generate(
strengths_summary=strengths,
weaknesses_summary=weaknesses,
improvement_keywords=keywords,
panel_breakdown=breakdown,
)
1 change: 1 addition & 0 deletions ai/src/ai_server/messaging/consumers/feedback_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
14 changes: 14 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,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 허용)."""

Expand All @@ -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
3 changes: 3 additions & 0 deletions ai/tests/test_feedback_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public void apply(FeedbackCallbackEnvelope envelope) {
payload.strengthsSummary(),
payload.weaknessesSummary(),
keywordsToJson(payload.improvementKeywords()),
breakdownToJson(payload.panelBreakdown()),
payload.reportS3Key()
);
try {
Expand Down Expand Up @@ -109,6 +110,19 @@ private String keywordsToJson(java.util.List<String> keywords) {
}
}

private String breakdownToJson(
java.util.List<com.stackup.stackup.session.application.dto.PanelBreakdownItem> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public record FeedbackCallbackPayload(
String strengthsSummary,
String weaknessesSummary,
List<String> improvementKeywords,
List<PanelBreakdownItem> panelBreakdown,
String reportS3Key
) {
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,6 +18,7 @@ public record FeedbackResult(
String strengthsSummary,
String weaknessesSummary,
List<String> improvementKeywords,
List<PanelBreakdownItem> panelBreakdown,
String reportFilePath,
Instant createdAt
) {
Expand All @@ -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<PanelBreakdownItem> parseBreakdown(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
return JSON.readValue(json, new TypeReference<List<PanelBreakdownItem>>() {});
} catch (JsonProcessingException e) {
return Collections.emptyList();
}
}

@SuppressWarnings("unchecked")
private static List<String> parseKeywords(String json) {
if (json == null || json.isBlank()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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");
}
Expand All @@ -74,17 +80,19 @@ private SessionFeedback(InterviewSession session, Double overallScore, Double te
this.strengthsSummary = strengthsSummary;
this.weaknessesSummary = weaknessesSummary;
this.improvementKeywords = improvementKeywordsJson;
this.panelBreakdown = panelBreakdownJson;
this.reportFilePath = reportFilePath;
}

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);
}

// 공유 토큰을 보장(없으면 발급)하고 현재 토큰 반환. 멱등.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,6 +17,8 @@ public record FeedbackResponse(
String weaknessesSummary,
@Schema(description = "Improvement keywords returned by AI. The current contract is a string list.")
List<String> improvementKeywords,
@Schema(description = "Per-evaluator panel breakdown (multi-interviewer). Empty for single/legacy feedback.")
List<PanelBreakdownItem> panelBreakdown,
@Schema(description = "Stored report path when AI generates a detailed learning guide/report.")
String reportFilePath,
Instant createdAt
Expand All @@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- 멀티 면접관 패널: 평가위원별 분해(평가축/점수/강약점)를 JSON 으로 보관.
-- 비어 있으면 단일/레거시 피드백. 표시 전용이라 jsonb 단일 컬럼으로 충분.
ALTER TABLE session_feedbacks ADD COLUMN panel_breakdown jsonb;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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<Object> evCap = ArgumentCaptor.forClass(Object.class);
verify(events, atLeastOnce()).publishEvent(evCap.capture());
Expand All @@ -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);
Expand All @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/features/feedback/ui/FeedbackReport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,25 @@ export function FeedbackReport({
<ScoreBar label="전달력" score={feedback.communicationScore} />
</section>

{feedback.panelBreakdown && feedback.panelBreakdown.length > 0 && (
<section className="flex flex-col gap-3">
<h2 className="text-h6 text-fg">면접관 패널 평가</h2>
<div className="flex flex-col gap-4">
{feedback.panelBreakdown.map((b) => (
<div key={b.evaluator} className="flex flex-col gap-1.5">
<ScoreBar label={`${b.evaluator} 면접관`} score={b.score} />
{(b.strength || b.weakness) && (
<div className="flex flex-col gap-0.5 pl-1 text-caption text-fg-muted">
{b.strength && <span>강점 · {b.strength}</span>}
{b.weakness && <span>보완 · {b.weakness}</span>}
</div>
)}
</div>
))}
</div>
</section>
)}

{feedback.strengthsSummary && (
<section className="flex flex-col gap-2">
<h2 className="text-h6 text-fg">강점</h2>
Expand Down
Loading