From 7ec1c554e4a353c9a0dd7cd800251190ee99bdc0 Mon Sep 17 00:00:00 2001 From: jmj Date: Mon, 29 Jun 2026 12:05:29 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(feedback):=20AI=20=ED=95=B5=EC=8B=AC?= =?UTF-8?q?=20=EA=B5=AC=EC=A0=88(highlights)=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=E2=80=94=20=EC=84=9C=EB=B2=84=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 피드백 리포트에서 중요한 부분을 하이라이트하기 위한 데이터. AI가 강점/개선점 본문에서 핵심 구절 3~6개를 그대로 발췌(부분 문자열 매칭 보장: 실제 substring 만 통과) → FeedbackCallbackPayload.highlights → DB(V21 JSONB) → 피드백 응답(소유자+공유 모두) 노출. - AI: feedback_synthesis 프롬프트 highlights, FeedbackResult/SynthesisResult.highlights, _filter_highlights(본문 substring·dedup·cap6), consumer 매핑. 테스트 추가. - Backend: V21 session_feedbacks.highlights JSONB, SessionFeedback 필드, FeedbackCallbackService 영속, FeedbackResponse 노출. OpenAPI 재생성. - 백엔드 전체 테스트 통과, AI 300 passed. Co-Authored-By: Claude Opus 4.8 --- .../chain/feedback_generation_chain.py | 37 ++++++++++++++++++- .../chain/prompts/feedback_synthesis.py | 4 ++ .../messaging/consumers/feedback_consumer.py | 1 + ai/src/ai_server/model/messages/feedback.py | 2 + ai/tests/test_feedback_highlights.py | 29 +++++++++++++++ backend/openapi.json | 7 ++++ .../application/FeedbackCallbackService.java | 1 + .../dto/FeedbackCallbackPayload.java | 2 + .../application/dto/FeedbackResult.java | 2 + .../session/domain/SessionFeedback.java | 14 +++++-- .../presentation/dto/FeedbackResponse.java | 4 +- .../V21__add_feedback_highlights.sql | 4 ++ .../FeedbackCallbackServiceTest.java | 8 ++-- 13 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 ai/tests/test_feedback_highlights.py create mode 100644 backend/src/main/resources/db/migration/V21__add_feedback_highlights.sql diff --git a/ai/src/ai_server/chain/feedback_generation_chain.py b/ai/src/ai_server/chain/feedback_generation_chain.py index d6bba381..8258701a 100644 --- a/ai/src/ai_server/chain/feedback_generation_chain.py +++ b/ai/src/ai_server/chain/feedback_generation_chain.py @@ -35,6 +35,8 @@ class FeedbackResult(BaseModel): weaknesses_summary: str | None = Field(None) improvement_keywords: list[str] = Field(default_factory=list) study_plan: list[str] = Field(default_factory=list) + # 강조 표시용 핵심 구절(강점·개선 본문에서 그대로 발췌). 프론트가 부분 문자열 매칭해 하이라이트. + highlights: list[str] = Field(default_factory=list) panel_breakdown: list[PanelBreakdownItem] = Field(default_factory=list) @@ -143,6 +145,8 @@ class SynthesisResult(BaseModel): weaknesses_summary: str | None = None improvement_keywords: list[str] = Field(default_factory=list) study_plan: list[str] = Field(default_factory=list) + # strengths/weaknesses 본문에서 그대로 발췌한 핵심 구절(강조 표시용). + highlights: list[str] = Field(default_factory=list) @dataclass(frozen=True) @@ -609,6 +613,30 @@ def _dedup_keywords(keywords: list[str], cap: int = 8) -> list[str]: return out +def _filter_highlights( + highlights: list[str], + strengths: str | None, + weaknesses: str | None, + cap: int = 6, +) -> list[str]: + """LLM 이 뽑은 핵심 구절 중 강점·개선 본문에 실제로 등장하는 것만 남긴다. + 프론트가 부분 문자열 매칭으로 하이라이트하므로, 본문에 없는(지어낸/바꿔쓴) 구절은 버려 + 매칭 실패를 막는다. 중복 제거 + cap 개로 제한.""" + corpus = f"{strengths or ''}\n{weaknesses or ''}" + seen: set[str] = set() + out: list[str] = [] + for h in highlights: + phrase = (h or "").strip() + if len(phrase) < 2 or phrase in seen: + continue + if phrase in corpus: + seen.add(phrase) + out.append(phrase) + if len(out) >= cap: + break + return out + + class PanelFeedbackGenerator: """직군·논리·커뮤니케이션 평가위원을 병렬 호출 → 가중평균 종합. FeedbackGenerator 호환.""" @@ -740,11 +768,14 @@ async def generate( ) # 종합 서술형 + 학습 방향(synthesis). 미설정/실패 시 기계적 병합으로 폴백. - strengths, weaknesses, keywords, study_plan = ( + # highlights 는 synthesis 가 만든 strengths/weaknesses 본문에서 발췌되므로 synthesis 가 + # 없거나 실패하면 빈 리스트(불일치 하이라이트 방지). + strengths, weaknesses, keywords, study_plan, highlights = ( fb_strengths, fb_weaknesses, fb_keywords, [], + [], ) if self._synthesis is not None: panel_summary = "\n".join( @@ -767,6 +798,9 @@ async def generate( weaknesses = syn.weaknesses_summary or fb_weaknesses keywords = _dedup_keywords(syn.improvement_keywords) or fb_keywords study_plan = syn.study_plan or [] + highlights = _filter_highlights( + syn.highlights, strengths, weaknesses + ) except Exception as exc: # noqa: BLE001 log.warning("feedback.synthesis.failed", error=str(exc)) @@ -779,5 +813,6 @@ async def generate( weaknesses_summary=weaknesses, improvement_keywords=keywords, study_plan=study_plan, + highlights=highlights, panel_breakdown=breakdown, ) diff --git a/ai/src/ai_server/chain/prompts/feedback_synthesis.py b/ai/src/ai_server/chain/prompts/feedback_synthesis.py index 420268c2..a336c610 100644 --- a/ai/src/ai_server/chain/prompts/feedback_synthesis.py +++ b/ai/src/ai_server/chain/prompts/feedback_synthesis.py @@ -11,6 +11,10 @@ "- improvement_keywords: 다음 면접에서 보완할 키워드 5~10개(짧은 명사구).\n" "- study_plan: 구체적 학습 방향/다음 단계 액션 아이템 3~6개. 각 항목은 '무엇을 어떻게'가 " "드러나는 실행 문장(예: 'Redis 분산 락의 SETNX·TTL 옵션을 직접 구현해보며 원자성 보장 원리 정리').\n" + "- highlights: 지원자가 꼭 기억해야 할 **가장 중요한 핵심 구절 3~6개**. 화면에서 강조 표시할 " + "용도라, 반드시 위 strengths_summary·weaknesses_summary 본문에 **그대로 등장한 짧은 구절" + "(각 2~12어절)을 글자 그대로 발췌**하세요(새로 짓거나 바꿔쓰지 말 것 — 부분 문자열 매칭에 사용). " + "임팩트가 큰 강점·보완 포인트 위주로.\n" "- 평가위원 점수·근거에 모순되지 않게, 과장 없이 사실에 기반해 작성합니다.\n" "- 응답은 반드시 지정된 JSON 스키마를 따릅니다." ) diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index 1047596b..3e6fd031 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -154,6 +154,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None: weaknesses_summary=result.weaknesses_summary, improvement_keywords=result.improvement_keywords, study_plan=result.study_plan, + highlights=result.highlights, panel_breakdown=result.panel_breakdown, answer_coaching=answer_coaching, 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 7b2cdee1..20f01ab0 100644 --- a/ai/src/ai_server/model/messages/feedback.py +++ b/ai/src/ai_server/model/messages/feedback.py @@ -113,6 +113,8 @@ class FeedbackCallbackPayload(BaseModel): improvement_keywords: list[str] = Field(default_factory=list) # 패널을 통합한 학습 방향/다음 단계 액션 아이템. study_plan: list[str] = Field(default_factory=list) + # 강조 표시용 핵심 구절(강점·개선 본문에서 발췌). 프론트가 부분 문자열 매칭해 하이라이트. + highlights: list[str] = Field(default_factory=list) # 평가위원별 분해(패널). 비어 있으면 단일/레거시 경로. panel_breakdown: list[PanelBreakdownItem] = Field(default_factory=list) # 질문별 복기(답변 메시지별 모범 답안·리라이트·코칭). 비면 복기 없음. diff --git a/ai/tests/test_feedback_highlights.py b/ai/tests/test_feedback_highlights.py new file mode 100644 index 00000000..4565aa2a --- /dev/null +++ b/ai/tests/test_feedback_highlights.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from ai_server.chain.feedback_generation_chain import _filter_highlights + + +def test_keeps_only_verbatim_substrings(): + strengths = "지원자는 동시성 제어를 명확히 이해하고 있습니다." + weaknesses = "다만 인덱스 설계 근거가 부족합니다." + # 본문에 그대로 있는 구절만 통과, 지어낸/바꿔쓴 구절은 탈락. + out = _filter_highlights( + ["동시성 제어를 명확히 이해", "인덱스 설계 근거가 부족", "존재하지 않는 칭찬"], + strengths, + weaknesses, + ) + assert out == ["동시성 제어를 명확히 이해", "인덱스 설계 근거가 부족"] + + +def test_dedup_and_cap(): + body = "가가 나나 다다 라라 마마 바바 사사 가가" + out = _filter_highlights( + ["가가", "가가", "나나", "다다", "라라", "마마", "바바", "사사"], body, None, cap=6 + ) + assert out == ["가가", "나나", "다다", "라라", "마마", "바바"] # 중복 제거 + 6개 상한 + + +def test_empty_when_no_match_or_no_body(): + assert _filter_highlights(["무엇이든"], None, None) == [] + assert _filter_highlights([], "본문", "본문") == [] + assert _filter_highlights(["x"], "전혀 다른 본문", "") == [] diff --git a/backend/openapi.json b/backend/openapi.json index d9ee123b..c6c196d2 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -3590,6 +3590,13 @@ "type" : "string" } }, + "highlights" : { + "type" : "array", + "description" : "Key phrases (verbatim excerpts from strengths/weaknesses) for the report to highlight.", + "items" : { + "type" : "string" + } + }, "reportFilePath" : { "type" : "string", "description" : "Stored report path when AI generates a detailed learning guide/report." 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 74a102e9..851396a1 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 @@ -82,6 +82,7 @@ public void apply(FeedbackCallbackEnvelope envelope) { keywordsToJson(payload.improvementKeywords()), breakdownToJson(payload.panelBreakdown()), keywordsToJson(payload.studyPlan()), + keywordsToJson(payload.highlights()), 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 fe7dc81f..67893c59 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 @@ -15,6 +15,8 @@ public record FeedbackCallbackPayload( String weaknessesSummary, List improvementKeywords, List studyPlan, + // 강조 표시용 핵심 구절(강점·개선 본문 발췌). 프론트가 부분 문자열 매칭해 하이라이트. + List highlights, List panelBreakdown, // 질문별 복기 (답변 메시지별 모범 답안·리라이트·코칭). 비면 복기 없음. List answerCoaching, 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 37bb4f69..e2dd7e70 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 @@ -20,6 +20,7 @@ public record FeedbackResult( List improvementKeywords, List panelBreakdown, List studyPlan, + List highlights, String reportFilePath, Instant createdAt ) { @@ -39,6 +40,7 @@ public static FeedbackResult of(SessionFeedback f) { parseKeywords(f.getImprovementKeywords()), parseBreakdown(f.getPanelBreakdown()), parseKeywords(f.getStudyPlan()), + parseKeywords(f.getHighlights()), f.getReportFilePath(), f.getCreatedAt() ); 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 ae917f6e..53e45654 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 @@ -62,6 +62,11 @@ public class SessionFeedback extends BaseSoftDeleteEntity { @Column(name = "study_plan", columnDefinition = "jsonb") private String studyPlan; + // 강조 표시용 핵심 구절 JSON 배열(강점·개선 본문에서 발췌). 프론트가 부분 문자열 매칭해 하이라이트. + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "highlights", columnDefinition = "jsonb") + private String highlights; + @Column(name = "report_file_path", length = 1000) private String reportFilePath; @@ -73,7 +78,7 @@ private SessionFeedback(InterviewSession session, Double overallScore, Double te Double logicScore, Double communicationScore, String strengthsSummary, String weaknessesSummary, String improvementKeywordsJson, String panelBreakdownJson, - String studyPlanJson, String reportFilePath) { + String studyPlanJson, String highlightsJson, String reportFilePath) { if (session == null) { throw new IllegalArgumentException("session must not be null"); } @@ -87,6 +92,7 @@ private SessionFeedback(InterviewSession session, Double overallScore, Double te this.improvementKeywords = improvementKeywordsJson; this.panelBreakdown = panelBreakdownJson; this.studyPlan = studyPlanJson; + this.highlights = highlightsJson; this.reportFilePath = reportFilePath; } @@ -95,10 +101,12 @@ public static SessionFeedback of(InterviewSession session, Double overallScore, Double communicationScore, String strengthsSummary, String weaknessesSummary, String improvementKeywordsJson, String panelBreakdownJson, - String studyPlanJson, String reportFilePath) { + String studyPlanJson, String highlightsJson, + String reportFilePath) { return new SessionFeedback(session, overallScore, technicalAccuracy, logicScore, communicationScore, strengthsSummary, weaknessesSummary, - improvementKeywordsJson, panelBreakdownJson, studyPlanJson, reportFilePath); + improvementKeywordsJson, panelBreakdownJson, studyPlanJson, highlightsJson, + 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 f411228d..28ba2b79 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 @@ -21,6 +21,8 @@ public record FeedbackResponse( List panelBreakdown, @Schema(description = "Study plan / next-step action items synthesized from the panel.") List studyPlan, + @Schema(description = "Key phrases (verbatim excerpts from strengths/weaknesses) for the report to highlight.") + List highlights, @Schema(description = "Stored report path when AI generates a detailed learning guide/report.") String reportFilePath, Instant createdAt @@ -31,7 +33,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.panelBreakdown(), r.studyPlan(), + r.improvementKeywords(), r.panelBreakdown(), r.studyPlan(), r.highlights(), r.reportFilePath(), r.createdAt() ); } diff --git a/backend/src/main/resources/db/migration/V21__add_feedback_highlights.sql b/backend/src/main/resources/db/migration/V21__add_feedback_highlights.sql new file mode 100644 index 00000000..06f05865 --- /dev/null +++ b/backend/src/main/resources/db/migration/V21__add_feedback_highlights.sql @@ -0,0 +1,4 @@ +-- 피드백 핵심 구절 하이라이트: 강점·개선 본문에서 발췌한 짧은 구절 배열. +-- 프론트가 부분 문자열 매칭으로 리포트에서 강조. +ALTER TABLE session_feedbacks + ADD COLUMN highlights JSONB NOT NULL DEFAULT '[]'::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 0539e7e1..578922f8 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 @@ -51,6 +51,7 @@ void apply_insertsFeedbackAndPushesSse() { new FeedbackCallbackPayload(50L, 85.0, 80.0, 90.0, 75.0, "strength summary", "weakness summary", List.of("Spring", "JPA"), List.of("Redis 분산 락 직접 구현"), + List.of("설계 깊이"), List.of(new PanelBreakdownItem("기술", "기술 정확도·깊이", 80.0, "설계 깊이", "테스트 부족", "상세 평가 문단", "근거")), List.of(), null)); @@ -72,6 +73,7 @@ void apply_insertsFeedbackAndPushesSse() { assertThat(cap.getValue().getImprovementKeywords()).contains("Spring"); assertThat(cap.getValue().getPanelBreakdown()).contains("기술"); assertThat(cap.getValue().getStudyPlan()).contains("Redis"); + assertThat(cap.getValue().getHighlights()).contains("설계 깊이"); ArgumentCaptor evCap = ArgumentCaptor.forClass(Object.class); verify(events, atLeastOnce()).publishEvent(evCap.capture()); @@ -91,7 +93,7 @@ void apply_writesAnswerCoachingToMessages() { FeedbackCallbackEnvelope env = envelope(50L, "fb-coach", new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, - List.of(), List.of(), List.of(), + List.of(), List.of(), List.of(), List.of(), List.of(new AnswerCoachingItem(600L, "모범 답안", "리라이트", "두괄식으로")), null)); @@ -112,7 +114,7 @@ void apply_writesAnswerCoachingToMessages() { @Test void apply_skipsWhenDuplicateMessage() { FeedbackCallbackEnvelope env = envelope(50L, "dup", - new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, List.of(), List.of(), List.of(), List.of(), null)); + new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, List.of(), List.of(), List.of(), List.of(), List.of(), null)); when(processedMessageRepository.existsById("dup")).thenReturn(true); service.apply(env); @@ -124,7 +126,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(), List.of(), List.of(), null)); + new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, List.of(), List.of(), 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); From 0fb676030ff2f014f5067d39d93d093405256924 Mon Sep 17 00:00:00 2001 From: jmj Date: Mon, 29 Jun 2026 12:09:33 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(feedback):=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=ED=95=B5=EC=8B=AC=20=EB=B6=80=EB=B6=84=20=ED=95=98?= =?UTF-8?q?=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8=20(=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 피드백 리포트에서 중요한 부분을 로 강조해 스캔성 개선(UX). AI 가 고른 핵심 구절(highlights) ∪ 다음에 채울 키워드(improvementKeywords)를 강점/개선점/패널 detail/ 학습방향 문단에서 자동 하이라이트. - HighlightedText: 정규식 이스케이프 + 긴 구절 우선 매칭 + 대소문자 무시, html2canvas(PDF) 렌더 OK. - FeedbackReport 의 강점·개선점·각 detail·학습방향에 적용. - 프론트 타입 재생성, npm run build 통과 / lint clean(신규 파일). Co-Authored-By: Claude Opus 4.8 --- backend/CLAUDE.md | 4 ++ .../features/feedback/ui/FeedbackReport.tsx | 22 ++++++---- .../features/feedback/ui/HighlightedText.tsx | 42 +++++++++++++++++++ frontend/src/shared/api/generated.ts | 2 + 4 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 frontend/src/features/feedback/ui/HighlightedText.tsx diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 08e0c907..4aeaf263 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -372,6 +372,10 @@ docker compose up -d - **종료 후 콜백 드롭**: `QuestionsCallbackService.apply` 와 `SessionFollowupRequester.onAnswerSubmitted` 는 세션이 `isTerminal()` 이면 처리를 건너뛴다 → 자동종료 뒤 늦게 도착한 POOL/FOLLOWUP 콜백이나 막판 답변 발화가 종료 세션에 질문·placeholder 를 추가하는 사후 변조를 차단. +- **피드백 하이라이트 본 구현**: V21 로 `session_feedbacks.highlights`(JSONB) 추가. AI 가 강점/개선점 본문에서 + 핵심 구절 3~6개를 **그대로 발췌**(부분 문자열 매칭 보장)해 `callback.feedback.highlights[]` 로 보내고, + `FeedbackResponse` 가 소유자·공유 엔드포인트 모두에 노출. 프론트는 이 구절 ∪ 다음에 채울 키워드를 + 리포트 문단에서 `` 강조(`HighlightedText`). - **질문별 복기 본 구현**: V19 로 `interview_messages` 에 `model_answer`/`answer_rewrite`/`coaching_comment` 추가. AI 가 `callback.feedback.answerCoaching[{messageId,…}]` 로 답변별 모범 답안·리라이트·코칭을 보내면 `FeedbackCallbackService` 가 각 메시지에 `recordCoaching` 기록. `MessageResult`/`MessageResponse` 가 답변 diff --git a/frontend/src/features/feedback/ui/FeedbackReport.tsx b/frontend/src/features/feedback/ui/FeedbackReport.tsx index cbfa9520..e119a90b 100644 --- a/frontend/src/features/feedback/ui/FeedbackReport.tsx +++ b/frontend/src/features/feedback/ui/FeedbackReport.tsx @@ -6,6 +6,7 @@ import { useCopyToClipboard } from '@/shared/hooks' import type { Feedback } from '../api/feedbackApi' import { downloadElementAsPdf } from '../lib/downloadPdf' import { useShareFeedback } from '../model/useFeedback' +import { HighlightedText } from './HighlightedText' // AI 가 별도 정성 평가를 패널 항목으로 실어 보낼 때 쓰는 라벨(모두 종합 점수엔 미포함). const SELF_INTRO_LABEL = '첫인상' @@ -41,6 +42,11 @@ export function FeedbackReport({ } const overall = feedback.overallScore + // 강조 대상: AI 가 고른 핵심 구절 ∪ 다음에 채울 키워드. 본문 문단에서 처리. + const highlightTerms = [ + ...(feedback.highlights ?? []), + ...(feedback.improvementKeywords ?? []), + ] // '첫인상'·'직무 적합도'는 종합 점수에 포함되지 않는 별도 정성 평가 → 패널과 분리해 전용 섹션으로. const panel = feedback.panelBreakdown ?? [] const selfIntro = panel.find((b) => b.evaluator === SELF_INTRO_LABEL) @@ -90,7 +96,7 @@ export function FeedbackReport({ {jobFit.detail && (

- {jobFit.detail} +

)} {(jobFit.strength || jobFit.weakness) && ( @@ -119,7 +125,7 @@ export function FeedbackReport({ {roleUnderstanding.detail && (

- {roleUnderstanding.detail} +

)} {(roleUnderstanding.strength || roleUnderstanding.weakness) && ( @@ -147,7 +153,7 @@ export function FeedbackReport({ {selfIntro.detail && (

- {selfIntro.detail} +

)} {(selfIntro.strength || selfIntro.weakness) && ( @@ -174,7 +180,7 @@ export function FeedbackReport({ {b.detail && (

- {b.detail} +

)} {(b.strength || b.weakness) && ( @@ -196,7 +202,7 @@ export function FeedbackReport({

강점

- {feedback.strengthsSummary} +

)} @@ -205,7 +211,7 @@ export function FeedbackReport({

개선할 점

- {feedback.weaknessesSummary} +

)} @@ -230,7 +236,9 @@ export function FeedbackReport({ {feedback.studyPlan.map((step, i) => (
  • - {step} + + +
  • ))} diff --git a/frontend/src/features/feedback/ui/HighlightedText.tsx b/frontend/src/features/feedback/ui/HighlightedText.tsx new file mode 100644 index 00000000..87fb6b25 --- /dev/null +++ b/frontend/src/features/feedback/ui/HighlightedText.tsx @@ -0,0 +1,42 @@ +import { Fragment, useMemo } from 'react' + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// 피드백 본문에서 주어진 구절/키워드를 로 강조한다. +// terms = AI 가 고른 핵심 구절(highlights) ∪ 다음에 채울 키워드(improvementKeywords). +// 긴 구절을 먼저 매칭(부분 겹침 방지), 대소문자 무시. mark 는 html2canvas(PDF)에서도 렌더된다. +export function HighlightedText({ + text, + terms, +}: { + text: string + terms: (string | null | undefined)[] +}) { + const regex = useMemo(() => { + const cleaned = Array.from( + new Set(terms.map((t) => (t ?? '').trim()).filter((t) => t.length >= 2)), + ).sort((a, b) => b.length - a.length) + if (cleaned.length === 0) return null + return new RegExp(`(${cleaned.map(escapeRegExp).join('|')})`, 'gi') + }, [terms]) + + if (!regex || !text) return <>{text} + + // 캡처 그룹 1개로 split → 홀수 인덱스가 매칭부(원문 대소문자 보존). + const parts = text.split(regex) + return ( + <> + {parts.map((part, i) => + i % 2 === 1 ? ( + + {part} + + ) : ( + {part} + ), + )} + + ) +} diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index b7af9d27..8feef3f6 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -1239,6 +1239,8 @@ export interface components { panelBreakdown?: components["schemas"]["PanelBreakdownItem"][]; /** @description Study plan / next-step action items synthesized from the panel. */ studyPlan?: string[]; + /** @description Key phrases (verbatim excerpts from strengths/weaknesses) for the report to highlight. */ + highlights?: string[]; /** @description Stored report path when AI generates a detailed learning guide/report. */ reportFilePath?: string; /** Format: date-time */