diff --git a/ai/src/ai_server/chain/feedback_generation_chain.py b/ai/src/ai_server/chain/feedback_generation_chain.py index 72cb0b6a..3edccc05 100644 --- a/ai/src/ai_server/chain/feedback_generation_chain.py +++ b/ai/src/ai_server/chain/feedback_generation_chain.py @@ -11,7 +11,7 @@ from pydantic import BaseModel, Field from ai_server.chain.prompts.feedback_generation import HUMAN_PROMPT, SYSTEM_PROMPT -from ai_server.chain.prompts import feedback_panel +from ai_server.chain.prompts import feedback_panel, feedback_synthesis from ai_server.config.settings import Settings from ai_server.core.client import CoreClient from ai_server.model.messages.feedback import PanelBreakdownItem @@ -28,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) + study_plan: list[str] = Field(default_factory=list) panel_breakdown: list[PanelBreakdownItem] = Field(default_factory=list) @@ -126,9 +127,18 @@ class EvaluatorResult(BaseModel): score: float | None = Field(None, description="0~100, 산정 불가 시 null") strength: str | None = None weakness: str | None = None + detail: str | None = Field(None, description="근거·예시 포함 2~4문장 상세 평가") + score_rationale: str | None = Field(None, description="점수 근거 한두 문장") keywords: list[str] = Field(default_factory=list) +class SynthesisResult(BaseModel): + strengths_summary: str | None = None + weaknesses_summary: str | None = None + improvement_keywords: list[str] = Field(default_factory=list) + study_plan: list[str] = Field(default_factory=list) + + @dataclass(frozen=True) class _EvaluatorSpec: key: str # 'technical' | 'logic' | 'communication' @@ -253,6 +263,40 @@ def build_panel_evaluator_chain( return prompt | llm | parser +def build_feedback_synthesis_chain( + settings: Settings, core_client: CoreClient | None = None +) -> Runnable: + """패널 결과를 통합한 종합 서술형 평 + 학습 방향 생성 체인.""" + from langchain_openai import ChatOpenAI + + parser = PydanticOutputParser(pydantic_object=SynthesisResult) + prompt = ChatPromptTemplate.from_messages( + [ + ("system", feedback_synthesis.SYSTEM_PROMPT), + ("human", feedback_synthesis.HUMAN_PROMPT), + ] + ).partial(format_instructions=parser.get_format_instructions()) + + callbacks = [] + if core_client is not None: + callbacks.append( + CoreAiLogCallback( + core_client=core_client, + request_type="generate.feedback.synthesis", + default_model=settings.llm_pro_model, + ) + ) + + llm = ChatOpenAI( + model=settings.llm_pro_model, + temperature=settings.llm_pro_temperature, + api_key=settings.llm_api_key or None, + base_url=settings.llm_base_url, + callbacks=callbacks, + ) + return prompt | llm | parser + + def _weighted_overall(pairs: list[tuple[float | None, float]]) -> float | None: """(score, weight) 중 score 가 있는 것만 가중평균. 전부 None 이면 None.""" present = [(s, w) for s, w in pairs if s is not None and w > 0] @@ -283,8 +327,15 @@ def _dedup_keywords(keywords: list[str], cap: int = 8) -> list[str]: class PanelFeedbackGenerator: """직군·논리·커뮤니케이션 평가위원을 병렬 호출 → 가중평균 종합. FeedbackGenerator 호환.""" - def __init__(self, chain: Runnable, *, weights: tuple[float, float, float] = (0.5, 0.25, 0.25)) -> None: + def __init__( + self, + chain: Runnable, + *, + synthesis_chain: Runnable | None = None, + weights: tuple[float, float, float] = (0.5, 0.25, 0.25), + ) -> None: self._chain = chain + self._synthesis = synthesis_chain self._w_tech, self._w_logic, self._w_comm = weights async def generate( @@ -353,18 +404,18 @@ async def generate( ] ) - note_items = [(spec.label, r.strength) for spec, _, r in domain_results] - note_items += [("논리", logic.strength), ("전달", comm.strength)] - strengths = _merge_notes(note_items) - weak_items = [(spec.label, r.weakness) for spec, _, r in domain_results] - weak_items += [("논리", logic.weakness), ("전달", comm.weakness)] - weaknesses = _merge_notes(weak_items) - - all_keywords: list[str] = [] - for _, _, r in domain_results: - all_keywords += r.keywords - all_keywords += logic.keywords + comm.keywords - keywords = _dedup_keywords(all_keywords) + # 기계적 병합(synthesis 미설정/실패 시 폴백용). + fb_strength_items = [(spec.label, r.strength) for spec, _, r in domain_results] + fb_strength_items += [("논리", logic.strength), ("전달", comm.strength)] + fb_strengths = _merge_notes(fb_strength_items) + fb_weak_items = [(spec.label, r.weakness) for spec, _, r in domain_results] + fb_weak_items += [("논리", logic.weakness), ("전달", comm.weakness)] + fb_weaknesses = _merge_notes(fb_weak_items) + fb_keywords = _dedup_keywords( + [k for _, _, r in domain_results for k in r.keywords] + + logic.keywords + + comm.keywords + ) breakdown = [ PanelBreakdownItem( @@ -373,6 +424,8 @@ async def generate( score=r.score, strength=r.strength, weakness=r.weakness, + detail=r.detail, + score_rationale=r.score_rationale, ) for spec, _, r in domain_results ] @@ -384,9 +437,37 @@ async def generate( score=r.score, strength=r.strength, weakness=r.weakness, + detail=r.detail, + score_rationale=r.score_rationale, ) ) + # 종합 서술형 + 학습 방향(synthesis). 미설정/실패 시 기계적 병합으로 폴백. + strengths, weaknesses, keywords, study_plan = fb_strengths, fb_weaknesses, fb_keywords, [] + if self._synthesis is not None: + panel_summary = "\n".join( + f"- {b.evaluator}({b.dimension}) " + f"{('%g점' % b.score) if b.score is not None else '점수없음'}: " + f"{(b.detail or '').strip() or (b.strength or '')}" + for b in breakdown + ) + try: + syn = await self._synthesis.ainvoke( + { + "job_category": job_category, + "mode": mode, + "panel_summary": panel_summary, + "transcript": transcript, + } + ) + if isinstance(syn, SynthesisResult): + strengths = syn.strengths_summary or fb_strengths + weaknesses = syn.weaknesses_summary or fb_weaknesses + keywords = _dedup_keywords(syn.improvement_keywords) or fb_keywords + study_plan = syn.study_plan or [] + except Exception as exc: # noqa: BLE001 + log.warning("feedback.synthesis.failed", error=str(exc)) + return FeedbackResult( overall_score=overall, technical_accuracy=technical_accuracy, @@ -395,5 +476,6 @@ async def generate( strengths_summary=strengths, weaknesses_summary=weaknesses, improvement_keywords=keywords, + study_plan=study_plan, panel_breakdown=breakdown, ) diff --git a/ai/src/ai_server/chain/prompts/feedback_panel.py b/ai/src/ai_server/chain/prompts/feedback_panel.py index 4929ae3c..389f1718 100644 --- a/ai/src/ai_server/chain/prompts/feedback_panel.py +++ b/ai/src/ai_server/chain/prompts/feedback_panel.py @@ -14,6 +14,9 @@ "- 점수를 매기기 전에 강점/약점 근거를 먼저 정리한 뒤 산정한다(즉흥 점수 금지).\n" "- strength/weakness 는 각각 한 줄(한국어, 구체적으로). keywords 는 이 축에서 보완할 " "개선 키워드 0~4개(짧은 명사구).\n" + "- detail: 이 축에 대한 2~4문장 상세 평가. **답변의 구체적 부분을 인용/지목**하고 무엇이 " + "왜 좋았는지/아쉬웠는지 근거를 들어 서술한다(추상적 총평 금지).\n" + "- score_rationale: 그 점수를 준 핵심 근거(가점/감점 요인)를 한두 문장으로.\n" "- 응답은 반드시 지정된 JSON 스키마를 따른다." ) diff --git a/ai/src/ai_server/chain/prompts/feedback_synthesis.py b/ai/src/ai_server/chain/prompts/feedback_synthesis.py new file mode 100644 index 00000000..420268c2 --- /dev/null +++ b/ai/src/ai_server/chain/prompts/feedback_synthesis.py @@ -0,0 +1,23 @@ +# 멀티 면접관 패널 종합 — 평가위원들의 결과를 통합해 서술형 종합평 + 학습 방향 생성. + +SYSTEM_PROMPT = ( + "당신은 면접 평가 패널을 총괄하는 수석 면접관입니다. 여러 평가위원(직군 기술·논리·" + "전달 등)의 평가를 받아 지원자에게 줄 **통합 종합 피드백**을 작성하세요.\n" + "- 평가위원들의 관점을 단순 나열하지 말고 **하나의 일관된 서술**로 엮습니다.\n" + "- strengths_summary: 지원자의 강점을 3~5문장 한국어 문단으로. 어느 영역에서 어떻게 " + "드러났는지 구체적으로.\n" + "- weaknesses_summary: 보완점을 3~5문장 한국어 문단으로. 무엇이 왜 부족했고 어떤 방향으로 " + "개선해야 하는지.\n" + "- improvement_keywords: 다음 면접에서 보완할 키워드 5~10개(짧은 명사구).\n" + "- study_plan: 구체적 학습 방향/다음 단계 액션 아이템 3~6개. 각 항목은 '무엇을 어떻게'가 " + "드러나는 실행 문장(예: 'Redis 분산 락의 SETNX·TTL 옵션을 직접 구현해보며 원자성 보장 원리 정리').\n" + "- 평가위원 점수·근거에 모순되지 않게, 과장 없이 사실에 기반해 작성합니다.\n" + "- 응답은 반드시 지정된 JSON 스키마를 따릅니다." +) + +HUMAN_PROMPT = ( + "직군: {job_category} / 면접 모드: {mode}\n\n" + "=== 평가위원별 평가 결과 ===\n{panel_summary}\n\n" + "=== 면접 전사(참고) ===\n{transcript}\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 cd2d4e8f..582b5033 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -109,6 +109,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None: strengths_summary=result.strengths_summary, weaknesses_summary=result.weaknesses_summary, improvement_keywords=result.improvement_keywords, + study_plan=result.study_plan, panel_breakdown=result.panel_breakdown, report_s3_key=None, ) diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index 19c15818..5926d884 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -21,6 +21,7 @@ ) from ai_server.chain.feedback_generation_chain import ( PanelFeedbackGenerator, + build_feedback_synthesis_chain, build_panel_evaluator_chain, ) from ai_server.chain.question_generation_chain import ( @@ -211,8 +212,10 @@ def __init__(self, settings: Settings) -> None: ) # 종합 피드백 생성 (US-24) — 멀티 면접관 패널(직군·논리·커뮤 평가위원 병렬 → 가중평균) + # + 종합 서술형 평·학습 방향 synthesis. feedback_generator = PanelFeedbackGenerator( - build_panel_evaluator_chain(settings, core_client=core_client) + build_panel_evaluator_chain(settings, core_client=core_client), + synthesis_chain=build_feedback_synthesis_chain(settings, core_client=core_client), ) self._feedback_consumer = FeedbackConsumer( generator=feedback_generator, diff --git a/ai/src/ai_server/model/messages/feedback.py b/ai/src/ai_server/model/messages/feedback.py index 19979fde..e452f498 100644 --- a/ai/src/ai_server/model/messages/feedback.py +++ b/ai/src/ai_server/model/messages/feedback.py @@ -74,6 +74,10 @@ class PanelBreakdownItem(BaseModel): score: float | None = None strength: str | None = None weakness: str | None = None + # 구체적 근거·예시(답변 인용)를 담은 2~4문장 상세 평가. + detail: str | None = None + # 이 점수를 준 근거(감점/가점 요인) 간단 설명. + score_rationale: str | None = None class FeedbackCallbackPayload(BaseModel): @@ -89,6 +93,8 @@ class FeedbackCallbackPayload(BaseModel): strengths_summary: str | None = None weaknesses_summary: str | None = None improvement_keywords: list[str] = Field(default_factory=list) + # 패널을 통합한 학습 방향/다음 단계 액션 아이템. + study_plan: 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 a33547eb..ccae55f9 100644 --- a/ai/tests/test_feedback_panel.py +++ b/ai/tests/test_feedback_panel.py @@ -3,8 +3,18 @@ from ai_server.chain.feedback_generation_chain import ( EvaluatorResult, PanelFeedbackGenerator, + SynthesisResult, ) + +class _FakeSynthesis: + def __init__(self, result: SynthesisResult): + self._result = result + + async def ainvoke(self, _v): + return self._result + + # 평가축(dimension_name) 으로 라우팅하는 가짜 체인. TECH = "기술 정확도·깊이" PERSONALITY = "인성·협업 역량" @@ -123,6 +133,44 @@ async def test_multi_domain_weighted_by_question_counts(): assert r.overall_score == 62 +@pytest.mark.asyncio +async def test_synthesis_narrative_study_plan_and_breakdown_detail(): + syn = SynthesisResult( + strengths_summary="통합 강점 서술", + weaknesses_summary="통합 약점 서술", + improvement_keywords=["동시성"], + study_plan=["Redis 분산 락 SETNX/TTL 직접 구현"], + ) + gen = PanelFeedbackGenerator( + _FakeChain({ + TECH: EvaluatorResult(score=80, strength="s", detail="상세 평가", score_rationale="근거"), + LOGIC: EvaluatorResult(score=60), + COMM: EvaluatorResult(score=50), + }), + synthesis_chain=_FakeSynthesis(syn), + ) + r = await _run_gen(gen) + # 종합 서술형(synthesis 결과로 대체) + assert r.strengths_summary == "통합 강점 서술" + assert r.weaknesses_summary == "통합 약점 서술" + assert r.improvement_keywords == ["동시성"] + assert r.study_plan == ["Redis 분산 락 SETNX/TTL 직접 구현"] + # 평가위원 분해에 detail/score_rationale 포함 + assert r.panel_breakdown[0].detail == "상세 평가" + assert r.panel_breakdown[0].score_rationale == "근거" + + +async def _run_gen(gen): + return await gen.generate( + job_category="BACKEND", + mode="TECHNICAL", + total_question_count=3, + end_reason="x", + transcript="t", + rag_context="(none)", + ) + + @pytest.mark.asyncio async def test_keyword_dedup(): r = await _run( diff --git a/backend/openapi.json b/backend/openapi.json index f898e4ea..d868d62a 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -3348,6 +3348,13 @@ "$ref" : "#/components/schemas/PanelBreakdownItem" } }, + "studyPlan" : { + "type" : "array", + "description" : "Study plan / next-step action items synthesized from the panel.", + "items" : { + "type" : "string" + } + }, "reportFilePath" : { "type" : "string", "description" : "Stored report path when AI generates a detailed learning guide/report." @@ -3376,6 +3383,12 @@ }, "weakness" : { "type" : "string" + }, + "detail" : { + "type" : "string" + }, + "scoreRationale" : { + "type" : "string" } } }, 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 0a332bdc..45b31cd3 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 @@ -76,6 +76,7 @@ public void apply(FeedbackCallbackEnvelope envelope) { payload.weaknessesSummary(), keywordsToJson(payload.improvementKeywords()), breakdownToJson(payload.panelBreakdown()), + keywordsToJson(payload.studyPlan()), payload.reportS3Key() ); try { 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 ceb2d0ce..d9dad1d1 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 studyPlan, 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 d3e77bdd..37bb4f69 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 @@ -19,6 +19,7 @@ public record FeedbackResult( String weaknessesSummary, List improvementKeywords, List panelBreakdown, + List studyPlan, String reportFilePath, Instant createdAt ) { @@ -37,6 +38,7 @@ public static FeedbackResult of(SessionFeedback f) { f.getWeaknessesSummary(), parseKeywords(f.getImprovementKeywords()), parseBreakdown(f.getPanelBreakdown()), + parseKeywords(f.getStudyPlan()), f.getReportFilePath(), f.getCreatedAt() ); 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 index 7a63244b..fbc6deec 100644 --- 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 @@ -9,6 +9,8 @@ public record PanelBreakdownItem( String dimension, Double score, String strength, - String weakness + String weakness, + String detail, // 근거·예시 포함 상세 평가 + String scoreRationale // 점수 근거 ) { } 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 1a20d47f..ae917f6e 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 @@ -57,6 +57,11 @@ public class SessionFeedback extends BaseSoftDeleteEntity { @Column(name = "panel_breakdown", columnDefinition = "jsonb") private String panelBreakdown; + // 학습 방향/다음 단계 액션 아이템 JSON 배열. + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "study_plan", columnDefinition = "jsonb") + private String studyPlan; + @Column(name = "report_file_path", length = 1000) private String reportFilePath; @@ -68,7 +73,7 @@ private SessionFeedback(InterviewSession session, Double overallScore, Double te Double logicScore, Double communicationScore, String strengthsSummary, String weaknessesSummary, String improvementKeywordsJson, String panelBreakdownJson, - String reportFilePath) { + String studyPlanJson, String reportFilePath) { if (session == null) { throw new IllegalArgumentException("session must not be null"); } @@ -81,6 +86,7 @@ private SessionFeedback(InterviewSession session, Double overallScore, Double te this.weaknessesSummary = weaknessesSummary; this.improvementKeywords = improvementKeywordsJson; this.panelBreakdown = panelBreakdownJson; + this.studyPlan = studyPlanJson; this.reportFilePath = reportFilePath; } @@ -89,10 +95,10 @@ public static SessionFeedback of(InterviewSession session, Double overallScore, Double communicationScore, String strengthsSummary, String weaknessesSummary, String improvementKeywordsJson, String panelBreakdownJson, - String reportFilePath) { + String studyPlanJson, String reportFilePath) { return new SessionFeedback(session, overallScore, technicalAccuracy, logicScore, communicationScore, strengthsSummary, weaknessesSummary, - improvementKeywordsJson, panelBreakdownJson, reportFilePath); + improvementKeywordsJson, panelBreakdownJson, studyPlanJson, 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 9c7188f6..f411228d 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 @@ -19,6 +19,8 @@ public record FeedbackResponse( List improvementKeywords, @Schema(description = "Per-evaluator panel breakdown (multi-interviewer). Empty for single/legacy feedback.") List panelBreakdown, + @Schema(description = "Study plan / next-step action items synthesized from the panel.") + List studyPlan, @Schema(description = "Stored report path when AI generates a detailed learning guide/report.") String reportFilePath, Instant createdAt @@ -29,7 +31,8 @@ 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.panelBreakdown(), r.reportFilePath(), r.createdAt() + r.improvementKeywords(), r.panelBreakdown(), r.studyPlan(), + r.reportFilePath(), r.createdAt() ); } } diff --git a/backend/src/main/resources/db/migration/V17__add_study_plan_to_feedbacks.sql b/backend/src/main/resources/db/migration/V17__add_study_plan_to_feedbacks.sql new file mode 100644 index 00000000..2f938ea5 --- /dev/null +++ b/backend/src/main/resources/db/migration/V17__add_study_plan_to_feedbacks.sql @@ -0,0 +1,2 @@ +-- 상세 피드백: 패널을 통합한 학습 방향/다음 단계 액션 아이템(JSON 문자열 배열). null=레거시. +ALTER TABLE session_feedbacks ADD COLUMN study_plan 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 a8a541a0..cef5a58f 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 @@ -46,7 +46,9 @@ void apply_insertsFeedbackAndPushesSse() { 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"), - List.of(new PanelBreakdownItem("기술", "기술 정확도·깊이", 80.0, "설계 깊이", "테스트 부족")), + List.of("Redis 분산 락 직접 구현"), + List.of(new PanelBreakdownItem("기술", "기술 정확도·깊이", 80.0, + "설계 깊이", "테스트 부족", "상세 평가 문단", "근거")), null)); when(processedMessageRepository.existsById("fb-1")).thenReturn(false); @@ -65,6 +67,7 @@ void apply_insertsFeedbackAndPushesSse() { assertThat(cap.getValue().getOverallScore()).isEqualTo(85.0); assertThat(cap.getValue().getImprovementKeywords()).contains("Spring"); assertThat(cap.getValue().getPanelBreakdown()).contains("기술"); + assertThat(cap.getValue().getStudyPlan()).contains("Redis"); ArgumentCaptor evCap = ArgumentCaptor.forClass(Object.class); verify(events, atLeastOnce()).publishEvent(evCap.capture()); @@ -79,7 +82,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(), List.of(), null)); + new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, List.of(), List.of(), List.of(), null)); when(processedMessageRepository.existsById("dup")).thenReturn(true); service.apply(env); @@ -91,7 +94,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(), List.of(), null)); + new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, List.of(), 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 ba9aa050..13d0d2d2 100644 --- a/frontend/src/features/feedback/ui/FeedbackReport.tsx +++ b/frontend/src/features/feedback/ui/FeedbackReport.tsx @@ -75,12 +75,20 @@ export function FeedbackReport({ {feedback.panelBreakdown.map((b) => (
+ {b.detail && ( +

+ {b.detail} +

+ )} {(b.strength || b.weakness) && (
{b.strength && 강점 · {b.strength}} {b.weakness && 보완 · {b.weakness}}
)} + {b.scoreRationale && ( +

점수 근거 · {b.scoreRationale}

+ )}
))} @@ -117,6 +125,20 @@ export function FeedbackReport({ )} + + {feedback.studyPlan && feedback.studyPlan.length > 0 && ( +
+

학습 방향

+
    + {feedback.studyPlan.map((step, i) => ( +
  • + + {step} +
  • + ))} +
+
+ )} ) diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index 1a3b5dfe..e2708c80 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -1154,6 +1154,8 @@ export interface components { improvementKeywords?: string[]; /** @description Per-evaluator panel breakdown (multi-interviewer). Empty for single/legacy feedback. */ panelBreakdown?: components["schemas"]["PanelBreakdownItem"][]; + /** @description Study plan / next-step action items synthesized from the panel. */ + studyPlan?: string[]; /** @description Stored report path when AI generates a detailed learning guide/report. */ reportFilePath?: string; /** Format: date-time */ @@ -1166,6 +1168,8 @@ export interface components { score?: number; strength?: string; weakness?: string; + detail?: string; + scoreRationale?: string; }; CandidateRepositoryResponse: { /** Format: int64 */