From 02c50b5f2fe3554fccd6c1cafdda5d2a72c0c5de Mon Sep 17 00:00:00 2001 From: jmj Date: Thu, 25 Jun 2026 16:50:50 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9D=98=EC=A0=81=20=EB=A9=80?= =?UTF-8?q?=ED=8B=B0=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=ED=9B=84=20=ED=95=98=EB=93=9C=EB=8B=9D=2012?= =?UTF-8?q?=EA=B1=B4=20(AI/Core/Frontend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7차원 적대적 리뷰 + 검증으로 확정된 이슈 중 고가치·저비용 묶음을 수정. AI: - 코칭 fan-out에 세마포어(동시성 5) — 세션 종료 시 동시 LLM 호출 폭주/429 방지 - RAG 검색 쿼리에 자기소개·JD·회사명 주입 — 직무 맞춤·다문서 세션 retrieval steering - 빈 평가위원 패널 항목(점수·내용 없음) 제외 — JobFitResult 부분 응답이 빈 패널로 새는 것 방지 - 질문 생성 프롬프트에 JOB_TAILORED 카테고리 분배 규칙 + JD-갭/지원동기 질문 evidence 면제 Core: - 코칭·답변평가 write에 INTERVIEWEE role 가드 — 잘못된 messageId 로 질문에 기록 방지 - SessionCreateRequest mode 주석에 JOB_TAILORED 반영 Frontend: - ScoreTrend 델타를 '바로 직전 세션' 기준으로 — sparse 지표에서 비인접 세션 비교 버그 수정 - 진행바·로비 카피 분모를 generalQuestionCount(실제 목표)로 — maxQuestions 상한으로 나눠 진행률 과소 표시되던 것 수정 - 직무 맞춤 JD 미입력 시 생성 버튼 비활성 사유 안내 - 복기 아코디언 평가 칩에 '답변 평가' 라벨 - 모드 전환 시 회사명·JD state 정리 테스트: AI 271 · Core 전체 · Frontend 59 통과. 신규 테스트(RAG 쿼리·빈 패널 제외) 추가. Co-Authored-By: Claude Opus 4.8 --- .../chain/prompts/question_generation.py | 3 ++ .../messaging/consumers/feedback_consumer.py | 41 +++++++++++++------ .../messaging/consumers/questions_consumer.py | 9 ++++ ai/tests/test_feedback_consumer.py | 31 ++++++++++++++ ai/tests/test_questions_consumer.py | 25 ++++++++++- .../application/FeedbackCallbackService.java | 6 ++- .../application/QuestionsCallbackService.java | 14 ++++--- .../dto/SessionCreateRequest.java | 2 +- frontend/src/domain/session/lib/turn.ts | 6 ++- .../src/features/history/ui/ScoreTrend.tsx | 17 +++++--- .../ui/live/AnswerCoachingAccordion.tsx | 21 ++++++---- .../interview/ui/setup/InterviewSetupForm.tsx | 25 +++++++++-- 12 files changed, 161 insertions(+), 39 deletions(-) diff --git a/ai/src/ai_server/chain/prompts/question_generation.py b/ai/src/ai_server/chain/prompts/question_generation.py index a4bc6760..52e08de8 100644 --- a/ai/src/ai_server/chain/prompts/question_generation.py +++ b/ai/src/ai_server/chain/prompts/question_generation.py @@ -51,6 +51,9 @@ " - TECHNICAL: CS_FUNDAMENTAL + TECH_CHOICE + PROJECT_DEEP_DIVE 중심.\n" " - PERSONALITY: BEHAVIORAL 중심.\n" " - INTEGRATED: 모두 균형.\n" + " - JOB_TAILORED: JD 요구를 검증하는 TECH_CHOICE·PROJECT_DEEP_DIVE 와 지원동기·" + "직무적합(컬처핏)을 묻는 BEHAVIORAL 을 균형 있게 섞습니다. JD 요구 대비 갭·지원동기 질문은 " + "지원자 자료에 근거가 없을 수 있으니 그 경우 target_evidence 를 비워도 됩니다(JD 인용은 가능).\n" " 직군이 여러 개면 각 직군에 질문이 고르게 분배되도록 합니다.\n" " 자료가 받쳐주는 만큼만 분배하고, 근거 없는 카테고리를 억지로 채우지 마세요.\n" "4. 각 질문에 target_evidence 와 expected_signal 을 채웁니다:\n" diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index 3ccf50a8..b7464306 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -63,6 +63,7 @@ def __init__( job_fit_evaluator: JobFitEvaluator | None = None, answer_coach: AnswerCoach | None = None, coaching_max_answers: int = 30, + coaching_concurrency: int = 5, ) -> None: self._generator = generator self._publisher = publisher @@ -75,6 +76,7 @@ def __init__( self._job_fit_evaluator = job_fit_evaluator self._answer_coach = answer_coach self._coaching_max_answers = coaching_max_answers + self._coaching_concurrency = max(1, coaching_concurrency) async def handle(self, message: AbstractIncomingMessage) -> None: async with message.process(requeue=False): @@ -135,9 +137,11 @@ async def handle(self, message: AbstractIncomingMessage) -> None: self._coach_answers(req, rag_context), ) ) - if self_intro_item is not None: - result.panel_breakdown.append(self_intro_item) - result.panel_breakdown.extend(job_fit_items) + # 빈 평가위원 항목(점수·내용 모두 없음)은 표시하지 않는다 — LLM 부분 응답이 빈 패널로 새는 것 방지. + extras = [self_intro_item, *job_fit_items] + result.panel_breakdown.extend( + e for e in extras if e is not None and _panel_has_content(e) + ) payload = FeedbackCallbackPayload( session_id=req.session_id, @@ -255,20 +259,23 @@ async def _coach_answers( ) pairs = pairs[: self._coaching_max_answers] target_role = _coaching_target_role(req) + # 게이트웨이 429/과부하 방지 — 답변별 코칭 호출 동시성을 제한한다. + sem = asyncio.Semaphore(self._coaching_concurrency) async def _one( question: FeedbackMessageItem, answer: FeedbackMessageItem ) -> AnswerCoachingItem | None: try: - res = await self._answer_coach.coach( - job_category=req.job_category, - mode=req.mode, - target_role=target_role, - question=question.content, - expected_signal=question.expected_signal or "", - answer=answer.content, - rag_context=rag_context, - ) + async with sem: + res = await self._answer_coach.coach( + job_category=req.job_category, + mode=req.mode, + target_role=target_role, + question=question.content, + expected_signal=question.expected_signal or "", + answer=answer.content, + rag_context=rag_context, + ) except Exception as exc: # noqa: BLE001 log.warning( "feedback.coaching.failed", @@ -346,6 +353,16 @@ def _coaching_target_role(req: GenerateFeedbackRequest) -> str: return f"{head}채용공고(JD) 발췌: {jd[:800]}" +def _panel_has_content(item: PanelBreakdownItem) -> bool: + """점수도 서술도 없는 빈 평가위원 항목인지. 빈 항목은 표시에서 제외한다.""" + return ( + item.score is not None + or bool((item.detail or "").strip()) + or bool((item.strength or "").strip()) + or bool((item.weakness or "").strip()) + ) + + def _to_panel_item( label: str, dimension: str, ev: EvaluatorResult ) -> PanelBreakdownItem: diff --git a/ai/src/ai_server/messaging/consumers/questions_consumer.py b/ai/src/ai_server/messaging/consumers/questions_consumer.py index 04c50427..506f4319 100644 --- a/ai/src/ai_server/messaging/consumers/questions_consumer.py +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -170,6 +170,15 @@ def _build_initial_rag_query(req: GenerateQuestionsRequest) -> str: f"mode: {req.mode}", f"job categories: {', '.join(req.job_categories)}", ] + # 자기소개·JD 를 쿼리에 포함해 검색이 지원자 발화/직무 요구로 steering 되게 한다. + intro = (req.self_intro_answer or "").strip() + if intro: + parts.append(f"self-introduction: {intro[:600]}") + jd = (req.target_job_description or "").strip() + if jd: + company = (req.target_company_name or "").strip() + head = f"target role at {company}" if company else "target role (JD)" + parts.append(f"{head}: {jd[:800]}") for d in req.documents: doc_parts = [f"document #{d.document_id} {d.source_type}"] if d.summary: diff --git a/ai/tests/test_feedback_consumer.py b/ai/tests/test_feedback_consumer.py index fe9ad752..cf5e7df5 100644 --- a/ai/tests/test_feedback_consumer.py +++ b/ai/tests/test_feedback_consumer.py @@ -595,6 +595,37 @@ async def test_consumer_appends_job_fit_panel_item(): assert und_items[0].score == 60.0 +@pytest.mark.asyncio +async def test_consumer_drops_empty_job_fit_axis(): + # understanding 축이 빈(점수·내용 없음) 경우 그 패널 항목은 표시에서 제외된다. + generator = _generator() + publisher = MagicMock() + publisher.publish = AsyncMock() + evaluator = MagicMock() + evaluator.evaluate = AsyncMock( + return_value=JobFitResult( + fit=EvaluatorResult(score=72.0, detail="결제 경험 부합"), + understanding=EvaluatorResult(), # 빈 축 + ) + ) + + consumer = FeedbackConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.feedback", + core_client=MagicMock(), + embedder=None, + job_fit_evaluator=evaluator, + ) + await consumer.handle(_StubMessage(_job_tailored_envelope())) + + payload: FeedbackCallbackPayload = publisher.publish.await_args.kwargs["payload"] + labels = [b.evaluator for b in payload.panel_breakdown] + assert "직무 적합도" in labels + assert "직무 이해도" not in labels # 빈 축은 제외 + + @pytest.mark.asyncio async def test_consumer_skips_job_fit_when_not_job_tailored(): generator = _generator() diff --git a/ai/tests/test_questions_consumer.py b/ai/tests/test_questions_consumer.py index e837af4b..9eb6b3b8 100644 --- a/ai/tests/test_questions_consumer.py +++ b/ai/tests/test_questions_consumer.py @@ -141,7 +141,9 @@ async def test_consumer_forwards_self_intro_answer_to_generator(): await consumer.handle(_StubMessage(body)) call = generator.generate.await_args - assert call.kwargs["self_introduction"] == "결제 시스템을 설계한 백엔드 3년차입니다." + assert ( + call.kwargs["self_introduction"] == "결제 시스템을 설계한 백엔드 3년차입니다." + ) @pytest.mark.asyncio @@ -386,6 +388,27 @@ async def test_consumer_falls_back_to_document_context_when_rag_fails(): assert "Retrieved document chunks" not in context +def test_build_initial_rag_query_includes_self_intro_and_jd(): + from ai_server.messaging.consumers.questions_consumer import ( + _build_initial_rag_query, + ) + from ai_server.model.messages.questions import GenerateQuestionsRequest + + req = GenerateQuestionsRequest( + session_id=1, + mode="JOB_TAILORED", + job_categories=["BACKEND"], + documents=[], + self_intro_answer="결제 시스템을 만든 백엔드 3년차", + target_company_name="토스", + target_job_description="대용량 결제 백엔드, Kotlin/Spring", + ) + q = _build_initial_rag_query(req) + assert "결제 시스템을 만든 백엔드" in q # 자기소개가 쿼리에 포함 + assert "대용량 결제 백엔드" in q # JD 가 쿼리에 포함 + assert "토스" in q + + def test_build_context_handles_empty_documents(): assert _build_context([]) == "(no documents)" 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 fe4d0fe7..74a102e9 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 @@ -12,6 +12,7 @@ import com.stackup.stackup.session.application.dto.FeedbackCallbackPayload; import com.stackup.stackup.session.domain.InterviewMessage; import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.MessageRole; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.SessionFeedback; @@ -112,7 +113,10 @@ private void applyAnswerCoaching(Long sessionId, java.util.List { - answer.recordAnswerEvaluation( - eval.specificity(), eval.logic(), eval.structure(), eval.correctness()); - messageRepository.save(answer); - }); + messageRepository.findById(payload.answerMessageId()) + // 답변(INTERVIEWEE) 메시지에만 평가 기록 — 잘못된 messageId 로 질문에 점수가 달리는 것 방지. + .filter(answer -> answer.getRole() == MessageRole.INTERVIEWEE) + .ifPresent(answer -> { + answer.recordAnswerEvaluation( + eval.specificity(), eval.logic(), eval.structure(), eval.correctness()); + messageRepository.save(answer); + }); } private boolean isProcessed(String messageId) { diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java index ee61006d..6f53141e 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java @@ -13,7 +13,7 @@ public record SessionCreateRequest( @Size(max = 200) String title, @Size(max = 4000) String memo, - // mode: TECHNICAL/PERSONALITY/INTEGRATED 중 하나를 선택한다. + // mode: TECHNICAL/PERSONALITY/INTEGRATED/JOB_TAILORED 중 하나. JOB_TAILORED 는 아래 회사명·JD 필수. @NotNull SessionMode mode, // 직군 다중 선택. 최소 1개. 첫 항목이 대표 직군이 된다. @NotEmpty List jobCategories, diff --git a/frontend/src/domain/session/lib/turn.ts b/frontend/src/domain/session/lib/turn.ts index d958b043..71071885 100644 --- a/frontend/src/domain/session/lib/turn.ts +++ b/frontend/src/domain/session/lib/turn.ts @@ -43,7 +43,11 @@ export function canSubmitAnswer(session: Session, messages: Message[]): boolean } export function sessionProgress(session: Session): { current: number; max: number; ratio: number } { - const max = session.maxQuestions ?? 0 + // 실제 면접은 일반질문 수(자기소개 포함 = generalQuestionCount)에서 끝나거나 maxQuestions 상한에서 + // 끝난다 — 둘 중 작은 값이 진짜 목표. maxQuestions(상한)로만 나누면 진행률이 과소 표시된다. + const cap = session.maxQuestions ?? 0 + const general = session.generalQuestionCount ?? cap + const max = cap > 0 ? Math.min(general || cap, cap) : general const current = session.totalQuestionCount ?? 0 const ratio = max > 0 ? Math.min(current / max, 1) : 0 return { current, max, ratio } diff --git a/frontend/src/features/history/ui/ScoreTrend.tsx b/frontend/src/features/history/ui/ScoreTrend.tsx index 459c9620..eefaf2be 100644 --- a/frontend/src/features/history/ui/ScoreTrend.tsx +++ b/frontend/src/features/history/ui/ScoreTrend.tsx @@ -45,12 +45,19 @@ export function ScoreTrend({ stats }: { stats: UserStats }) { : null, ) .filter((p): p is { x: number; y: number } => p !== null) - const vals = sessions - .map((s) => s[m.key]) - .filter((v): v is number => typeof v === 'number') - const latest = vals.length ? Math.round(vals[vals.length - 1]) : null + // 최신 점수 + 지난번(바로 직전 세션) 대비 델타. sparse 지표에서 비인접 세션끼리 비교하지 않는다. + const seq = sessions.map((s) => (typeof s[m.key] === 'number' ? (s[m.key] as number) : null)) + let latestIdx = -1 + for (let i = seq.length - 1; i >= 0; i--) { + if (seq[i] !== null) { + latestIdx = i + break + } + } + const latest = latestIdx >= 0 ? Math.round(seq[latestIdx] as number) : null + const prev = latestIdx > 0 ? seq[latestIdx - 1] : null const delta = - vals.length >= 2 ? Math.round(vals[vals.length - 1] - vals[vals.length - 2]) : null + latest !== null && prev !== null ? Math.round((seq[latestIdx] as number) - prev) : null return { ...m, pts, latest, delta } }) diff --git a/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx b/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx index d39f07c0..06c97362 100644 --- a/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx +++ b/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx @@ -77,15 +77,18 @@ export function AnswerCoachingAccordion({ message }: { message: Message }) { )} {evals.length > 0 && ( -
- {evals.map((e) => ( - - {e} - - ))} +
+ 답변 평가 +
+ {evals.map((e) => ( + + {e} + + ))} +
)} {message.coachingComment && ( diff --git a/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx b/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx index 0a2a7198..1bb82bcb 100644 --- a/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx +++ b/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx @@ -85,7 +85,17 @@ export function InterviewSetupForm({

면접 모드

- + { + setMode(m) + // 직무 맞춤이 아니면 회사명·JD 입력값을 비운다(전송엔 이미 미포함이지만 상태도 정리). + if (m !== 'JOB_TAILORED') { + setCompanyName('') + setJobDescription('') + } + }} + />
{isJobTailored && (
@@ -176,9 +186,16 @@ export function InterviewSetupForm({

참고 문서 (선택)

- +
+ {isJobTailored && jobDescription.trim().length === 0 && ( +

+ 채용공고(JD)를 입력해야 직무 맞춤 면접을 생성할 수 있어요. +

+ )} + +
) }