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
37 changes: 36 additions & 1 deletion ai/src/ai_server/chain/feedback_generation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 호환."""

Expand Down Expand Up @@ -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(
Expand All @@ -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))

Expand All @@ -779,5 +813,6 @@ async def generate(
weaknesses_summary=weaknesses,
improvement_keywords=keywords,
study_plan=study_plan,
highlights=highlights,
panel_breakdown=breakdown,
)
4 changes: 4 additions & 0 deletions ai/src/ai_server/chain/prompts/feedback_synthesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 스키마를 따릅니다."
)
Expand Down
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 @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions ai/src/ai_server/model/messages/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# 질문별 복기(답변 메시지별 모범 답안·리라이트·코칭). 비면 복기 없음.
Expand Down
29 changes: 29 additions & 0 deletions ai/tests/test_feedback_highlights.py
Original file line number Diff line number Diff line change
@@ -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"], "전혀 다른 본문", "") == []
4 changes: 4 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 가 소유자·공유 엔드포인트 모두에 노출. 프론트는 이 구절 ∪ 다음에 채울 키워드를
리포트 문단에서 `<mark>` 강조(`HighlightedText`).
- **질문별 복기 본 구현**: V19 로 `interview_messages` 에 `model_answer`/`answer_rewrite`/`coaching_comment`
추가. AI 가 `callback.feedback.answerCoaching[{messageId,…}]` 로 답변별 모범 답안·리라이트·코칭을 보내면
`FeedbackCallbackService` 가 각 메시지에 `recordCoaching` 기록. `MessageResult`/`MessageResponse` 가 답변
Expand Down
7 changes: 7 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public void apply(FeedbackCallbackEnvelope envelope) {
keywordsToJson(payload.improvementKeywords()),
breakdownToJson(payload.panelBreakdown()),
keywordsToJson(payload.studyPlan()),
keywordsToJson(payload.highlights()),
payload.reportS3Key()
);
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public record FeedbackCallbackPayload(
String weaknessesSummary,
List<String> improvementKeywords,
List<String> studyPlan,
// 강조 표시용 핵심 구절(강점·개선 본문 발췌). 프론트가 부분 문자열 매칭해 하이라이트.
List<String> highlights,
List<PanelBreakdownItem> panelBreakdown,
// 질문별 복기 (답변 메시지별 모범 답안·리라이트·코칭). 비면 복기 없음.
List<AnswerCoachingItem> answerCoaching,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public record FeedbackResult(
List<String> improvementKeywords,
List<PanelBreakdownItem> panelBreakdown,
List<String> studyPlan,
List<String> highlights,
String reportFilePath,
Instant createdAt
) {
Expand All @@ -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()
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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");
}
Expand All @@ -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;
}

Expand All @@ -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);
}

// 공유 토큰을 보장(없으면 발급)하고 현재 토큰 반환. 멱등.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public record FeedbackResponse(
List<PanelBreakdownItem> panelBreakdown,
@Schema(description = "Study plan / next-step action items synthesized from the panel.")
List<String> studyPlan,
@Schema(description = "Key phrases (verbatim excerpts from strengths/weaknesses) for the report to highlight.")
List<String> highlights,
@Schema(description = "Stored report path when AI generates a detailed learning guide/report.")
String reportFilePath,
Instant createdAt
Expand All @@ -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()
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- 피드백 핵심 구절 하이라이트: 강점·개선 본문에서 발췌한 짧은 구절 배열.
-- 프론트가 부분 문자열 매칭으로 리포트에서 <mark> 강조.
ALTER TABLE session_feedbacks
ADD COLUMN highlights JSONB NOT NULL DEFAULT '[]'::jsonb;
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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<Object> evCap = ArgumentCaptor.forClass(Object.class);
verify(events, atLeastOnce()).publishEvent(evCap.capture());
Expand All @@ -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));

Expand All @@ -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);
Expand All @@ -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);
Expand Down
Loading