diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index bd485db7..bb3c477c 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -334,6 +334,10 @@ docker run --env-file .env -p 8000:8000 stackup-ai 덧붙인다(종합 generate 와 `asyncio.gather` 병렬, 실패해도 피드백 계속). 이 항목은 **종합 점수 집계에 미포함** — 메인 generator 가 모른 채 overall 을 계산한 뒤 표시용으로만 append 한다. 레거시 세션(자기소개 없음)·빈 답변은 건너뛴다. +- **질문별 복기(답변 코칭) 본 구현**: `FeedbackConsumer` 가 자기소개 제외 모든 (질문,답변) 쌍을 찾아 + `LlmAnswerCoach`(Flash, `chain/prompts/answer_coaching.py`)로 **답변별 병렬** 코칭 — 모범 답안 + 내 답변 + 리라이트 + 한 줄 코칭. `callback.feedback.answerCoaching[{messageId,…}]` 로 보내고 Core 가 각 답변 메시지에 + 기록(종료 세션 조회에서만 노출). 종합 generate·첫인상·직무 적합도와 `asyncio.gather` 병렬. - **직무 적합도 + 직무 이해도 평가 본 구현**: `mode=JOB_TAILORED` + JD 있을 때 `LlmJobFitEvaluator`(Pro, `chain/prompts/job_fit_evaluation.py`)가 면접 전사·자료를 채용공고(JD)와 대조해 **두 축**을 한 번의 구조화 호출(`JobFitResult{fit, understanding}`)로 평가: `직무 적합도`(JD 요구 역량 매칭) + `직무 이해도` diff --git a/ai/src/ai_server/chain/feedback_generation_chain.py b/ai/src/ai_server/chain/feedback_generation_chain.py index 066ee27a..d6bba381 100644 --- a/ai/src/ai_server/chain/feedback_generation_chain.py +++ b/ai/src/ai_server/chain/feedback_generation_chain.py @@ -12,6 +12,7 @@ from ai_server.chain.prompts.feedback_generation import HUMAN_PROMPT, SYSTEM_PROMPT from ai_server.chain.prompts import ( + answer_coaching, feedback_panel, feedback_synthesis, job_fit_evaluation, @@ -485,6 +486,100 @@ async def evaluate( return result +# ── 질문별 복기 (답변 코칭) ─────────────────────────────────────────────────── +# 답변 1건당 모범 답안 + 리라이트 + 한 줄 코칭. 점수가 아니라 "어떻게 더 잘하는지"를 준다. +# 답변별 병렬 호출(Flash). 자기소개 답변은 제외(첫인상 평가가 커버). + + +class CoachingResult(BaseModel): + model_answer: str | None = Field(None, description="이 질문에 대한 강한 답변 예시") + answer_rewrite: str | None = Field( + None, description="지원자 답변을 더 좋게 고쳐 쓴 버전" + ) + coaching_comment: str | None = Field(None, description="가장 중요한 보완점 한 문장") + + +def build_answer_coaching_chain( + settings: Settings, core_client: CoreClient | None = None +) -> Runnable: + """답변 1건을 코칭(모범 답안·리라이트·한 줄 코칭)하는 체인(Flash — 답변 수만큼 병렬).""" + from langchain_openai import ChatOpenAI + + parser = PydanticOutputParser(pydantic_object=CoachingResult) + prompt = ChatPromptTemplate.from_messages( + [ + ("system", answer_coaching.SYSTEM_PROMPT), + ("human", answer_coaching.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.coaching", + default_model=settings.llm_flash_model, + ) + ) + + llm = ChatOpenAI( + model=settings.llm_flash_model, + temperature=settings.llm_flash_temperature, + api_key=settings.llm_api_key or None, + base_url=settings.llm_base_url, + callbacks=callbacks, + ) + return prompt | llm | parser + + +class AnswerCoach(Protocol): + async def coach( + self, + *, + job_category: str, + mode: str, + target_role: str, + question: str, + expected_signal: str, + answer: str, + rag_context: str = "(none)", + ) -> CoachingResult: ... + + +class LlmAnswerCoach: + def __init__(self, chain: Runnable) -> None: + self._chain = chain + + async def coach( + self, + *, + job_category: str, + mode: str, + target_role: str, + question: str, + expected_signal: str, + answer: str, + rag_context: str = "(none)", + ) -> CoachingResult: + result = await self._chain.ainvoke( + { + "job_category": job_category, + "mode": mode, + "target_role": target_role or "", + "question": question, + "expected_signal": expected_signal or "(명시 없음)", + "answer": answer or "(빈 답변)", + "rag_context": rag_context or "(none)", + } + ) + if not isinstance(result, CoachingResult): + raise TypeError( + f"chain returned {type(result).__name__}, expected CoachingResult" + ) + return result + + 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] diff --git a/ai/src/ai_server/chain/prompts/answer_coaching.py b/ai/src/ai_server/chain/prompts/answer_coaching.py new file mode 100644 index 00000000..93dbc76e --- /dev/null +++ b/ai/src/ai_server/chain/prompts/answer_coaching.py @@ -0,0 +1,26 @@ +# 질문별 복기 프롬프트 — 답변 1건을 코칭한다. +# 모범 답안(이 질문에 강한 답) + 지원자 답변 리라이트(이 답을 이렇게 고치면 더 좋다) + 한 줄 코칭. +# 면접 준비 도구의 핵심: "점수"가 아니라 "어떻게 더 잘하는지"를 보여준다. + +SYSTEM_PROMPT = ( + "당신은 IT 면접 코치입니다. 면접관 질문 하나와 지원자의 실제 답변을 보고, 지원자가 " + "**어떻게 더 잘 답할 수 있는지**를 구체적으로 코칭합니다.\n" + "- **model_answer (모범 답안)**: 이 질문에 대한 강한 답변의 예시. 질문이 기대하는 핵심(기대 신호)을 " + "짚고, 가능하면 지원자의 실제 경험/자료(아래 컨텍스트)를 근거로 구체적으로. 1~2문단, 면접에서 말할 분량.\n" + "- **answer_rewrite (내 답변 리라이트)**: 지원자의 *실제 답변을 출발점으로* 더 좋게 고쳐 쓴 버전. " + "없는 경험을 지어내지 말고, 있는 내용을 구조(두괄식·근거·결과)와 구체성으로 보강. 답변이 비었거나 " + "'모르겠다'면 '이렇게 접근했다면' 식으로 짧게.\n" + "- **coaching_comment (한 줄 코칭)**: 이 답변에서 가장 중요한 보완점 하나를 한 문장으로.\n" + "- 한국어로, 기술 용어는 영문 원어 유지. 과장·미사여구 금지, 실전 조언만.\n" + "- 응답은 반드시 지정된 JSON 스키마를 따릅니다." +) + +HUMAN_PROMPT = ( + "직군: {job_category} / 면접 모드: {mode}\n" + "{target_role}\n\n" + "=== 면접관 질문 ===\n{question}\n" + "질문이 기대한 핵심(기대 신호): {expected_signal}\n\n" + "=== 지원자의 실제 답변 ===\n{answer}\n\n" + "=== 지원자 자료 근거(이력서/레포 RAG) ===\n{rag_context}\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 0838417e..3ccf50a8 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -12,6 +12,7 @@ ROLE_UNDERSTANDING_LABEL, SELF_INTRO_DIMENSION, SELF_INTRO_EVALUATOR_LABEL, + AnswerCoach, EvaluatorResult, FeedbackGenerator, JobFitEvaluator, @@ -22,6 +23,7 @@ from ai_server.messaging.publisher import CallbackPublisher from ai_server.model.envelope import Envelope from ai_server.model.messages.feedback import ( + AnswerCoachingItem, FeedbackCallbackPayload, FeedbackMessageItem, GenerateFeedbackRequest, @@ -59,6 +61,8 @@ def __init__( rag_top_k: int = 5, self_intro_evaluator: SelfIntroEvaluator | None = None, job_fit_evaluator: JobFitEvaluator | None = None, + answer_coach: AnswerCoach | None = None, + coaching_max_answers: int = 30, ) -> None: self._generator = generator self._publisher = publisher @@ -69,6 +73,8 @@ def __init__( self._rag_top_k = rag_top_k self._self_intro_evaluator = self_intro_evaluator self._job_fit_evaluator = job_fit_evaluator + self._answer_coach = answer_coach + self._coaching_max_answers = coaching_max_answers async def handle(self, message: AbstractIncomingMessage) -> None: async with message.process(requeue=False): @@ -111,20 +117,23 @@ async def handle(self, message: AbstractIncomingMessage) -> None: # 종합 피드백 + 자기소개 첫인상 + 직무 적합도(직무 맞춤 모드)를 병렬 실행. # 첫인상·직무 적합도는 종합 점수(overall)에 미포함 — generator 가 모른 채 계산한 뒤 표시용으로 덧붙인다. - result, self_intro_item, job_fit_items = await asyncio.gather( - self._generator.generate( - job_category=req.job_category, - mode=req.mode, - total_question_count=req.total_question_count, - end_reason=req.end_reason, - transcript=transcript, - score_basis=score_basis, - rag_context=rag_context, - voice_analysis_summary=voice_analysis_summary, - domain_question_counts=req.domain_question_counts, - ), - self._evaluate_self_intro(req, voice_analysis_summary), - self._evaluate_job_fit(req, transcript, rag_context), + result, self_intro_item, job_fit_items, answer_coaching = ( + await asyncio.gather( + self._generator.generate( + job_category=req.job_category, + mode=req.mode, + total_question_count=req.total_question_count, + end_reason=req.end_reason, + transcript=transcript, + score_basis=score_basis, + rag_context=rag_context, + voice_analysis_summary=voice_analysis_summary, + domain_question_counts=req.domain_question_counts, + ), + self._evaluate_self_intro(req, voice_analysis_summary), + self._evaluate_job_fit(req, transcript, rag_context), + self._coach_answers(req, rag_context), + ) ) if self_intro_item is not None: result.panel_breakdown.append(self_intro_item) @@ -141,6 +150,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None: improvement_keywords=result.improvement_keywords, study_plan=result.study_plan, panel_breakdown=result.panel_breakdown, + answer_coaching=answer_coaching, report_s3_key=None, ) @@ -227,6 +237,56 @@ async def _evaluate_job_fit( ), ] + async def _coach_answers( + self, req: GenerateFeedbackRequest, rag_context: str + ) -> list[AnswerCoachingItem]: + """자기소개 제외 답변마다 모범 답안·리라이트·코칭을 병렬 생성 → 메시지별 복기 리스트.""" + if self._answer_coach is None: + return [] + pairs = _collect_coachable_pairs(req.messages) + if not pairs: + return [] + if len(pairs) > self._coaching_max_answers: + log.info( + "feedback.coaching.capped", + session_id=req.session_id, + total=len(pairs), + cap=self._coaching_max_answers, + ) + pairs = pairs[: self._coaching_max_answers] + target_role = _coaching_target_role(req) + + 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, + ) + except Exception as exc: # noqa: BLE001 + log.warning( + "feedback.coaching.failed", + error=str(exc), + session_id=req.session_id, + message_id=answer.id, + ) + return None + return AnswerCoachingItem( + message_id=answer.id, + model_answer=res.model_answer, + answer_rewrite=res.answer_rewrite, + coaching_comment=res.coaching_comment, + ) + + items = await asyncio.gather(*(_one(q, a) for q, a in pairs)) + return [it for it in items if it is not None] + async def _build_rag_context(self, req: GenerateFeedbackRequest) -> str: if not self._embedder or not req.context_document_ids: return "(none)" @@ -256,6 +316,36 @@ async def _build_rag_context(self, req: GenerateFeedbackRequest) -> str: ) +def _collect_coachable_pairs( + messages: list[FeedbackMessageItem], +) -> list[tuple[FeedbackMessageItem, FeedbackMessageItem]]: + """복기 대상 (질문, 답변) 쌍. 자기소개 질문에 대한 답변과 빈 답변은 제외, 시퀀스 순.""" + by_id = {m.id: m for m in messages} + pairs: list[tuple[FeedbackMessageItem, FeedbackMessageItem]] = [] + for m in messages: + if m.role != "INTERVIEWEE" or not (m.content or "").strip(): + continue + question = by_id.get(m.parent_message_id) if m.parent_message_id else None + if question is None or question.role != "INTERVIEWER": + continue + if (question.category or "") == _SELF_INTRO_CATEGORY: + continue # 자기소개는 첫인상 평가가 커버 + pairs.append((question, m)) + return pairs + + +def _coaching_target_role(req: GenerateFeedbackRequest) -> str: + """직무 맞춤 모드일 때만 코칭 프롬프트에 실을 회사/JD 발췌. 그 외 빈 문자열.""" + if (req.mode or "") != _JOB_TAILORED_MODE: + return "" + jd = (req.target_job_description or "").strip() + if not jd: + return "" + company = (req.target_company_name or "").strip() + head = f"지원 회사: {company}\n" if company else "" + return f"{head}채용공고(JD) 발췌: {jd[:800]}" + + def _to_panel_item( label: str, dimension: str, ev: EvaluatorResult ) -> PanelBreakdownItem: diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index 102886ba..c62610f8 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -20,9 +20,11 @@ build_streaming_followup_generator, ) from ai_server.chain.feedback_generation_chain import ( + LlmAnswerCoach, LlmJobFitEvaluator, LlmSelfIntroEvaluator, PanelFeedbackGenerator, + build_answer_coaching_chain, build_feedback_synthesis_chain, build_job_fit_evaluation_chain, build_panel_evaluator_chain, @@ -239,6 +241,10 @@ def __init__(self, settings: Settings) -> None: job_fit_evaluator=LlmJobFitEvaluator( build_job_fit_evaluation_chain(settings, core_client=core_client) ), + # 질문별 복기(Flash, 답변 수만큼 병렬). 모범 답안+리라이트+한 줄 코칭. + answer_coach=LlmAnswerCoach( + build_answer_coaching_chain(settings, core_client=core_client) + ), ) # 음성 답변 STT + 분석 (Phase 2) diff --git a/ai/src/ai_server/model/messages/feedback.py b/ai/src/ai_server/model/messages/feedback.py index 5cd3dcea..0b9ca553 100644 --- a/ai/src/ai_server/model/messages/feedback.py +++ b/ai/src/ai_server/model/messages/feedback.py @@ -85,6 +85,17 @@ class PanelBreakdownItem(BaseModel): score_rationale: str | None = None +class AnswerCoachingItem(BaseModel): + """질문별 복기 1건 (AI → Core). 해당 답변(INTERVIEWEE) 메시지에 기록된다.""" + + model_config = camel_config() + + message_id: int + model_answer: str | None = None + answer_rewrite: str | None = None + coaching_comment: str | None = None + + class FeedbackCallbackPayload(BaseModel): """AI → Core 종합 피드백. 점수는 0~100 (NULL 허용).""" @@ -102,4 +113,6 @@ class FeedbackCallbackPayload(BaseModel): study_plan: list[str] = Field(default_factory=list) # 평가위원별 분해(패널). 비어 있으면 단일/레거시 경로. panel_breakdown: list[PanelBreakdownItem] = Field(default_factory=list) + # 질문별 복기(답변 메시지별 모범 답안·리라이트·코칭). 비면 복기 없음. + answer_coaching: list[AnswerCoachingItem] = Field(default_factory=list) report_s3_key: str | None = None diff --git a/ai/tests/test_feedback_consumer.py b/ai/tests/test_feedback_consumer.py index 5d58aeef..fe9ad752 100644 --- a/ai/tests/test_feedback_consumer.py +++ b/ai/tests/test_feedback_consumer.py @@ -6,9 +6,11 @@ import pytest from ai_server.chain.feedback_generation_chain import ( + CoachingResult, EvaluatorResult, FeedbackResult, JobFitResult, + LlmAnswerCoach, LlmFeedbackGenerator, LlmJobFitEvaluator, LlmSelfIntroEvaluator, @@ -672,6 +674,131 @@ async def ainvoke(self, value): assert result.understanding.score == 55.0 +def _answer_coach(): + c = MagicMock() + c.coach = AsyncMock( + return_value=CoachingResult( + model_answer="격리수준별 이상현상과 트레이드오프를 설명…", + answer_rewrite="두괄식으로 핵심 먼저, 그다음 근거…", + coaching_comment="결론을 먼저 말하고 근거로 받치세요.", + ) + ) + return c + + +@pytest.mark.asyncio +async def test_consumer_appends_answer_coaching_excluding_self_intro(): + generator = _generator() + publisher = MagicMock() + publisher.publish = AsyncMock() + coach = _answer_coach() + + consumer = FeedbackConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.feedback", + core_client=MagicMock(), + embedder=None, + answer_coach=coach, + ) + # _self_intro_envelope: 자기소개 Q(200)/A(201) + ACID Q(202)/A(203). + await consumer.handle(_StubMessage(_self_intro_envelope())) + + # 자기소개 답변(201)은 코칭 제외, ACID 답변(203)만 코칭. + assert coach.coach.await_count == 1 + assert "ACID" in coach.coach.await_args.kwargs["question"] + + payload: FeedbackCallbackPayload = publisher.publish.await_args.kwargs["payload"] + assert len(payload.answer_coaching) == 1 + item = payload.answer_coaching[0] + assert item.message_id == 203 + assert item.model_answer.startswith("격리수준") + assert all(c.message_id != 201 for c in payload.answer_coaching) + + +def test_collect_coachable_pairs_excludes_self_intro_and_empty(): + from ai_server.messaging.consumers.feedback_consumer import _collect_coachable_pairs + + msgs = [ + FeedbackMessageItem( + id=1, + sequence_number=1, + role="INTERVIEWER", + content="자기소개?", + category="SELF_INTRODUCTION", + ), + FeedbackMessageItem( + id=2, + sequence_number=2, + role="INTERVIEWEE", + content="소개", + parent_message_id=1, + ), + FeedbackMessageItem( + id=3, + sequence_number=3, + role="INTERVIEWER", + content="ACID?", + category="CS_FUNDAMENTAL", + ), + FeedbackMessageItem( + id=4, + sequence_number=4, + role="INTERVIEWEE", + content="원자성…", + parent_message_id=3, + ), + # 빈 답변은 제외 + FeedbackMessageItem( + id=5, + sequence_number=5, + role="INTERVIEWER", + content="Q2?", + category="TECH_CHOICE", + ), + FeedbackMessageItem( + id=6, + sequence_number=6, + role="INTERVIEWEE", + content=" ", + parent_message_id=5, + ), + ] + pairs = _collect_coachable_pairs(msgs) + assert [(q.id, a.id) for q, a in pairs] == [(3, 4)] + + +@pytest.mark.asyncio +async def test_answer_coach_forwards_inputs_to_chain(): + class _FakeChain: + def __init__(self): + self.input = None + + async def ainvoke(self, value): + self.input = value + return CoachingResult( + model_answer="m", answer_rewrite="r", coaching_comment="c" + ) + + chain = _FakeChain() + coach = LlmAnswerCoach(chain) + + await coach.coach( + job_category="BACKEND", + mode="TECHNICAL", + target_role="", + question="트랜잭션 격리수준?", + expected_signal="격리수준·이상현상", + answer="음… 잘 모르겠습니다", + rag_context="resume chunk", + ) + + assert chain.input["question"] == "트랜잭션 격리수준?" + assert chain.input["expected_signal"] == "격리수준·이상현상" + assert chain.input["answer"] == "음… 잘 모르겠습니다" + + def test_build_transcript_annotates_interviewee_evaluation(): from ai_server.messaging.consumers.feedback_consumer import _build_transcript from ai_server.model.messages.feedback import FeedbackMessageItem, MessageEvaluation diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 603d9f4c..dc4adc66 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -359,6 +359,11 @@ docker compose up -d - ArchUnit 룰 적용 (의존 방향 · 순환 차단 · `@Transactional` application 한정 · entity는 domain 패키지) - 면접 도메인 (US-13~20) 본 구현: 세션 CRUD/start/end/interrupt, generate.questions 발행, callback.questions(POOL/FOLLOWUP) 수신, 자동 종료 +- **질문별 복기 본 구현**: V19 로 `interview_messages` 에 `model_answer`/`answer_rewrite`/`coaching_comment` + 추가. AI 가 `callback.feedback.answerCoaching[{messageId,…}]` 로 답변별 모범 답안·리라이트·코칭을 보내면 + `FeedbackCallbackService` 가 각 메시지에 `recordCoaching` 기록. `MessageResult`/`MessageResponse` 가 답변 + 평가 점수 + 복기를 노출하되 **종료 세션 조회에서만**(`expectedSignal` 과 동일 게이팅). 프론트는 답변 버블 + 아래 '복기' 아코디언으로 표시. - **직무 맞춤 면접 모드(JOB_TAILORED) 본 구현**: `SessionMode.JOB_TAILORED` 추가(V18 — mode CHECK 갱신 + `target_company_name`/`target_job_description` 컬럼). 이 모드는 회사명+채용공고(JD)를 받아(JD 필수, `SessionService` 검증 `SESSION_JD_REQUIRED`) `InterviewSession.assignTargetRole` 로 보관. JD 는 diff --git a/backend/openapi.json b/backend/openapi.json index 37ee10b0..7bb832c4 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -2804,6 +2804,30 @@ }, "expectedSignal" : { "type" : "string" + }, + "answerSpecificity" : { + "type" : "number", + "format" : "double" + }, + "answerLogic" : { + "type" : "number", + "format" : "double" + }, + "answerStructure" : { + "type" : "string" + }, + "answerCorrectness" : { + "type" : "number", + "format" : "double" + }, + "modelAnswer" : { + "type" : "string" + }, + "answerRewrite" : { + "type" : "string" + }, + "coachingComment" : { + "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 45b31cd3..fe4d0fe7 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 @@ -7,8 +7,11 @@ import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; import com.stackup.stackup.common.sse.SseEventType; import org.springframework.context.ApplicationEventPublisher; +import com.stackup.stackup.session.application.dto.AnswerCoachingItem; import com.stackup.stackup.session.application.dto.FeedbackCallbackEnvelope; 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.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.SessionFeedback; @@ -33,6 +36,7 @@ public class FeedbackCallbackService { private final InterviewSessionRepository sessionRepository; private final SessionFeedbackRepository feedbackRepository; + private final InterviewMessageRepository messageRepository; private final ProcessedMessageRepository processedMessageRepository; private final ApplicationEventPublisher events; @@ -87,6 +91,8 @@ public void apply(FeedbackCallbackEnvelope envelope) { return; } + applyAnswerCoaching(sessionId, payload.answerCoaching()); + events.publishEvent(RealtimeNotifyEvent.session(sessionId, SseEventType.FEEDBACK_READY, new SessionFeedbackNotice(sessionId, feedback.getId()))); events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.FEEDBACK_READY, @@ -96,6 +102,24 @@ public void apply(FeedbackCallbackEnvelope envelope) { log.info("callback.feedback processed. sessionId={}, feedbackId={}", sessionId, feedback.getId()); } + // 답변별 복기를 각 INTERVIEWEE 메시지에 기록. 메시지가 다른 세션이면 방어적으로 skip. + private void applyAnswerCoaching(Long sessionId, java.util.List items) { + if (items == null || items.isEmpty()) { + return; + } + for (AnswerCoachingItem item : items) { + if (item == null || item.messageId() == null) { + continue; + } + InterviewMessage message = messageRepository.findById(item.messageId()).orElse(null); + if (message == null || !sessionId.equals(message.getSession().getId())) { + continue; + } + message.recordCoaching(item.modelAnswer(), item.answerRewrite(), item.coachingComment()); + messageRepository.save(message); + } + } + public record SessionFeedbackNotice(Long sessionId, Long feedbackId) { } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java b/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java index c65ff62b..50fc4d7b 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java @@ -51,12 +51,11 @@ public List list(Long userId, Long sessionId) { // 재생용 presigned URL 동봉: 질문 TTS(SUCCEEDED) + 음성 답변 원본. // presign 실패가 메시지 조회 전체를 깨뜨리지 않도록 개별 try/catch. - private MessageResult toResultWithAudioUrls(InterviewMessage m, boolean revealExpectedSignal) { + private MessageResult toResultWithAudioUrls(InterviewMessage m, boolean revealInsights) { String ttsUrl = m.getTtsStatus() == TtsStatus.SUCCEEDED ? presign(m.getTtsAudioPath()) : null; String audioUrl = presign(m.getAudioFilePath()); - String expectedSignal = revealExpectedSignal ? m.getExpectedSignal() : null; - return MessageResult.of(m, ttsUrl, audioUrl, expectedSignal); + return MessageResult.of(m, ttsUrl, audioUrl, revealInsights); } private String presign(String key) { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/AnswerCoachingItem.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/AnswerCoachingItem.java new file mode 100644 index 00000000..9dfae7fd --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/AnswerCoachingItem.java @@ -0,0 +1,10 @@ +package com.stackup.stackup.session.application.dto; + +// callback.feedback 의 답변별 복기 1건 (AI → Core). 해당 답변(INTERVIEWEE) 메시지에 기록된다. +public record AnswerCoachingItem( + Long messageId, + String modelAnswer, + String answerRewrite, + String coachingComment +) { +} 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 d9dad1d1..fe7dc81f 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 @@ -16,6 +16,8 @@ public record FeedbackCallbackPayload( List improvementKeywords, List studyPlan, List panelBreakdown, + // 질문별 복기 (답변 메시지별 모범 답안·리라이트·코칭). 비면 복기 없음. + List answerCoaching, String reportS3Key ) { } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java index 26b3fc97..309c9c6b 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java @@ -26,18 +26,27 @@ public record MessageResult( String audioFileUrl, // 질문이 기대한 핵심(평가 관점). 라이브 중엔 null(정답 유출 방지), // 종료된 세션 조회에서만 채워 피드백 학습용으로 노출. - String expectedSignal + String expectedSignal, + // 답변별 복기 — 종료 세션 조회에서만 노출(expectedSignal 과 동일 게이팅). 라이브 중엔 모두 null. + Double answerSpecificity, + Double answerLogic, + String answerStructure, + Double answerCorrectness, + String modelAnswer, + String answerRewrite, + String coachingComment ) { public static MessageResult of(InterviewMessage m) { - return of(m, null, null, null); + return of(m, null, null, false); } public static MessageResult of(InterviewMessage m, String ttsAudioUrl, String audioFileUrl) { - return of(m, ttsAudioUrl, audioFileUrl, null); + return of(m, ttsAudioUrl, audioFileUrl, false); } + // revealInsights=true 면 답변 평가·복기·expectedSignal 노출(종료 세션). 라이브/대기 중엔 false. public static MessageResult of( - InterviewMessage m, String ttsAudioUrl, String audioFileUrl, String expectedSignal) { + InterviewMessage m, String ttsAudioUrl, String audioFileUrl, boolean revealInsights) { return new MessageResult( m.getId(), m.getSession().getId(), @@ -55,7 +64,14 @@ public static MessageResult of( m.getTargetEvidence(), ttsAudioUrl, audioFileUrl, - expectedSignal + revealInsights ? m.getExpectedSignal() : null, + revealInsights ? m.getAnswerSpecificity() : null, + revealInsights ? m.getAnswerLogic() : null, + revealInsights ? m.getAnswerStructure() : null, + revealInsights ? m.getAnswerCorrectness() : null, + revealInsights ? m.getModelAnswer() : null, + revealInsights ? m.getAnswerRewrite() : null, + revealInsights ? m.getCoachingComment() : null ); } } diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java index 1df8c4e1..3e7972c5 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java @@ -109,6 +109,16 @@ public class InterviewMessage extends BaseTimeEntity { @Column(name = "answer_correctness") private Double answerCorrectness; + // 질문별 복기 (INTERVIEWEE 답변에만 채워짐). 피드백 생성 시 AI가 만든 모범 답안·리라이트·코칭. + @Column(name = "model_answer", columnDefinition = "text") + private String modelAnswer; + + @Column(name = "answer_rewrite", columnDefinition = "text") + private String answerRewrite; + + @Column(name = "coaching_comment", columnDefinition = "text") + private String coachingComment; + // 부연(질문 재설명) 메시지 여부. true 면 총 질문 수·꼬리 깊이에 카운트하지 않는다. @Column(nullable = false) private boolean clarification = false; @@ -218,6 +228,13 @@ public void recordAnswerEvaluation(Double specificity, Double logic, this.answerCorrectness = correctness; } + // 피드백 생성 시 답변별 복기(모범 답안·리라이트·코칭)를 이 답변 메시지에 기록. + public void recordCoaching(String modelAnswer, String answerRewrite, String coachingComment) { + this.modelAnswer = modelAnswer; + this.answerRewrite = answerRewrite; + this.coachingComment = coachingComment; + } + public void attachAudio(String audioFilePath) { this.audioFilePath = audioFilePath; } diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java index a8272f96..8ec27946 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java @@ -25,7 +25,15 @@ public record MessageResponse( String ttsAudioUrl, String audioFileUrl, // 질문이 기대한 핵심(평가 관점). 종료된 세션에서만 채워짐(라이브 중엔 null — 정답 유출 방지). - String expectedSignal + String expectedSignal, + // 답변별 복기(종료 세션에서만). 답변 평가 점수 + 모범 답안 + 리라이트 + 한 줄 코칭. + Double answerSpecificity, + Double answerLogic, + String answerStructure, + Double answerCorrectness, + String modelAnswer, + String answerRewrite, + String coachingComment ) { public static MessageResponse from(MessageResult r) { return new MessageResponse( @@ -45,7 +53,14 @@ public static MessageResponse from(MessageResult r) { r.targetEvidence(), r.ttsAudioUrl(), r.audioFileUrl(), - r.expectedSignal() + r.expectedSignal(), + r.answerSpecificity(), + r.answerLogic(), + r.answerStructure(), + r.answerCorrectness(), + r.modelAnswer(), + r.answerRewrite(), + r.coachingComment() ); } } diff --git a/backend/src/main/resources/db/migration/V19__add_answer_coaching_to_interview_messages.sql b/backend/src/main/resources/db/migration/V19__add_answer_coaching_to_interview_messages.sql new file mode 100644 index 00000000..ede0a578 --- /dev/null +++ b/backend/src/main/resources/db/migration/V19__add_answer_coaching_to_interview_messages.sql @@ -0,0 +1,7 @@ +-- 질문별 복기: 답변마다 AI가 생성한 모범 답안 + 내 답변 리라이트 + 한 줄 코칭. +-- 피드백 생성(callback.feedback) 시 답변(INTERVIEWEE) 메시지에 기록되고, 종료 세션 조회에서만 노출된다. + +ALTER TABLE interview_messages + ADD COLUMN model_answer TEXT, + ADD COLUMN answer_rewrite TEXT, + ADD COLUMN coaching_comment TEXT; 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 cef5a58f..0539e7e1 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 @@ -10,9 +10,12 @@ import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; import com.stackup.stackup.common.sse.SseEventType; +import com.stackup.stackup.session.application.dto.AnswerCoachingItem; 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.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.JobCategory; @@ -36,6 +39,7 @@ class FeedbackCallbackServiceTest { @Mock InterviewSessionRepository sessionRepository; @Mock SessionFeedbackRepository feedbackRepository; + @Mock InterviewMessageRepository messageRepository; @Mock ProcessedMessageRepository processedMessageRepository; @Mock ApplicationEventPublisher events; @InjectMocks FeedbackCallbackService service; @@ -49,7 +53,7 @@ void apply_insertsFeedbackAndPushesSse() { List.of("Redis 분산 락 직접 구현"), List.of(new PanelBreakdownItem("기술", "기술 정확도·깊이", 80.0, "설계 깊이", "테스트 부족", "상세 평가 문단", "근거")), - null)); + List.of(), null)); when(processedMessageRepository.existsById("fb-1")).thenReturn(false); when(feedbackRepository.existsBySession_Id(50L)).thenReturn(false); @@ -79,10 +83,36 @@ void apply_insertsFeedbackAndPushesSse() { }); } + @Test + void apply_writesAnswerCoachingToMessages() { + InterviewSession session = sessionFixture(50L); + InterviewMessage answer = InterviewMessage.interviewee(session, 2, "내 답변", null, null); + ReflectionTestUtils.setField(answer, "id", 600L); + + FeedbackCallbackEnvelope env = envelope(50L, "fb-coach", + new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, + List.of(), List.of(), List.of(), + List.of(new AnswerCoachingItem(600L, "모범 답안", "리라이트", "두괄식으로")), + null)); + + when(processedMessageRepository.existsById("fb-coach")).thenReturn(false); + when(feedbackRepository.existsBySession_Id(50L)).thenReturn(false); + when(sessionRepository.findById(50L)).thenReturn(Optional.of(session)); + when(feedbackRepository.save(any(SessionFeedback.class))).thenAnswer(inv -> inv.getArgument(0)); + when(messageRepository.findById(600L)).thenReturn(Optional.of(answer)); + + service.apply(env); + + assertThat(answer.getModelAnswer()).isEqualTo("모범 답안"); + assertThat(answer.getAnswerRewrite()).isEqualTo("리라이트"); + assertThat(answer.getCoachingComment()).isEqualTo("두괄식으로"); + verify(messageRepository).save(answer); + } + @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(), null)); + new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, List.of(), List.of(), List.of(), List.of(), null)); when(processedMessageRepository.existsById("dup")).thenReturn(true); service.apply(env); @@ -94,7 +124,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(), null)); + new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, 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); diff --git a/docs/database.md b/docs/database.md index d89c99c8..a2d7ddc0 100644 --- a/docs/database.md +++ b/docs/database.md @@ -193,6 +193,10 @@ CREATE TABLE interview_messages ( UNIQUE (session_id, sequence_number), CHECK (content IS NOT NULL OR audio_file_path IS NOT NULL) ); +-- interview_messages 는 이후 마이그레이션으로 컬럼 추가: +-- tts_* (V7), category/target_evidence/expected_signal (V9), answer_* 평가 4종 (V10), +-- clarification (V13), 그리고 질문별 복기 3종 (V19): +-- model_answer TEXT, answer_rewrite TEXT, coaching_comment TEXT -- 답변(INTERVIEWEE)에만, 종료 세션 조회에서만 노출 -- 10. message_voice_analyses CREATE TABLE message_voice_analyses ( diff --git a/docs/messaging.md b/docs/messaging.md index 7c3e93f9..1defbfc0 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -368,6 +368,8 @@ ### 5.11 `callback.feedback` +> `answerCoaching[]` 은 질문별 복기 — 답변(INTERVIEWEE) 메시지별 모범 답안·리라이트·한 줄 코칭(자기소개 제외). +> Core 가 각 `messageId` 의 메시지에 기록하고 종료 세션 조회에서만 노출한다. > `panelBreakdown[]` 에 평가위원별 항목이 담긴다. 자기소개가 있던 세션은 **`evaluator="첫인상"`**, > 직무 맞춤 모드는 **`evaluator="직무 적합도"`(역량 매칭)** + **`evaluator="직무 이해도"`(직무 이해·동기)** > 항목이 추가로 포함된다 — 모두 **종합 점수(overallScore) 집계에서 제외**된 별도 정성 평가다(메인 @@ -386,6 +388,9 @@ "weaknessesSummary": "...", "improvementKeywords": ["JPA 영속성 컨텍스트", "TCP 3-way handshake"], "studyPlan": ["..."], + "answerCoaching": [ + { "messageId": 203, "modelAnswer": "이 질문에 강한 답변 예시…", "answerRewrite": "내 답변을 이렇게 고치면…", "coachingComment": "결론을 먼저 말하세요." } + ], "panelBreakdown": [ { "evaluator": "백엔드", "dimension": "기술 정확도·깊이", "score": 80.0, "detail": "...", "scoreRationale": "..." }, { "evaluator": "첫인상", "dimension": "자기소개 전달력·구성·직무적합성", "score": 78.0, "detail": "...", "scoreRationale": "..." } diff --git a/frontend/src/features/interview/ui/InterviewTranscript.tsx b/frontend/src/features/interview/ui/InterviewTranscript.tsx index 9a1a10e2..383efb2c 100644 --- a/frontend/src/features/interview/ui/InterviewTranscript.tsx +++ b/frontend/src/features/interview/ui/InterviewTranscript.tsx @@ -3,6 +3,7 @@ import { Spinner } from '@/shared/ui/Spinner' import { useSessionMessages } from '../model/useSessionMessages' import { QuestionBubble } from './live/QuestionBubble' import { AnswerBubble } from './live/AnswerBubble' +import { AnswerCoachingAccordion } from './live/AnswerCoachingAccordion' // 종료된 세션의 질문/답변 원문(읽기 전용). 라이브 버블을 재사용해 // 음성 질문 재생(TTS)·음성 답변 재생도 그대로 노출한다. @@ -30,7 +31,10 @@ export function InterviewTranscript({ sessionId }: { sessionId: number }) { isQuestion(m) ? ( ) : ( - +
+ + +
), )} diff --git a/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx b/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx new file mode 100644 index 00000000..4464fcd1 --- /dev/null +++ b/frontend/src/features/interview/ui/live/AnswerCoachingAccordion.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react' +import type { Message } from '@/domain/session' + +// 답변 구조 채점값(FULL_STAR/PARTIAL_STAR/NONE) → 한국어 라벨. +const STRUCTURE_LABEL: Record = { + FULL_STAR: '구조 양호', + PARTIAL_STAR: '구조 보통', + NONE: '구조 미흡', +} + +function score5(v?: number | null): string | null { + return typeof v === 'number' ? `${v}/5` : null +} + +// 종료된 세션의 답변 아래 붙는 '복기' 아코디언: 평가 점수 → 한 줄 코칭 → 모범 답안 → 내 답변 리라이트. +// 코칭 데이터가 없으면(라이브 중·자기소개 답변 등) 렌더하지 않는다. +export function AnswerCoachingAccordion({ message }: { message: Message }) { + const [open, setOpen] = useState(false) + const hasCoaching = Boolean( + message.modelAnswer || message.answerRewrite || message.coachingComment, + ) + if (!hasCoaching) return null + + const spec = score5(message.answerSpecificity) + const logic = score5(message.answerLogic) + const correctness = score5(message.answerCorrectness) + const evals = [ + spec && `구체성 ${spec}`, + logic && `논리 ${logic}`, + correctness && `정답성 ${correctness}`, + message.answerStructure + ? (STRUCTURE_LABEL[message.answerStructure] ?? message.answerStructure) + : null, + ].filter(Boolean) as string[] + + return ( +
+ + {open && ( +
+ {evals.length > 0 && ( +
+ {evals.map((e) => ( + + {e} + + ))} +
+ )} + {message.coachingComment && ( +

+ 코칭 · + {message.coachingComment} +

+ )} + {message.modelAnswer && ( +
+ 모범 답안 +

+ {message.modelAnswer} +

+
+ )} + {message.answerRewrite && ( +
+ 내 답변, 이렇게 고치면 +

+ {message.answerRewrite} +

+
+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index 05b8aa57..917f940b 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -900,6 +900,16 @@ export interface components { ttsAudioUrl?: string; audioFileUrl?: string; expectedSignal?: string; + /** Format: double */ + answerSpecificity?: number; + /** Format: double */ + answerLogic?: number; + answerStructure?: string; + /** Format: double */ + answerCorrectness?: number; + modelAnswer?: string; + answerRewrite?: string; + coachingComment?: string; }; VoiceStreamBeginResponse: { /** Format: int64 */