From 551e9314c29729bd7233e8309fc9380aeeae222f Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 3 Jun 2026 13:40:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(scoring):=20expected=5Fsignal=EC=9D=84=20?= =?UTF-8?q?=EB=8B=B5=EB=B3=80=20=ED=8F=89=EA=B0=80=C2=B7=EC=A2=85=ED=95=A9?= =?UTF-8?q?=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=B1=84=EC=A0=90=EC=97=90=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 질문 생성 시 만든 expected_signal(기대 평가 관점)이 저장만 되고 채점에 쓰이지 않았음. 부모 질문의 expected_signal을 꼬리질문 답변 평가와 종합 피드백에 전달해 "질문이 기대한 핵심을 짚었는가"를 채점 근거로 사용. Core: - GenerateFollowupPayload: parentExpectedSignal 추가 (SessionFollowupRequester 가 parent.getExpectedSignal() 전달) - GenerateFeedbackPayload.MessageItem: expectedSignal 추가 (질문 메시지에 한해) AI: - followup: GenerateFollowupRequest.parent_expected_signal → 체인/프롬프트에 {expected_signal} 슬롯 + "기대 신호 충족도를 specificity·correctness 핵심 기준으로" 지시 - feedback: FeedbackMessageItem.expected_signal → 전사에 "기대 신호" 라인 노출 + 프롬프트가 technical_accuracy 근거로 사용 - 테스트 추가, AI 전체 통과. 프론트 비노출 유지(정답 유출 방지). docs/messaging.md generate.followup 예시 현행화. Co-Authored-By: Claude Opus 4.8 --- .../ai_server/chain/followup_generation_chain.py | 3 +++ .../chain/prompts/feedback_generation.py | 2 ++ .../chain/prompts/followup_generation.py | 4 ++++ .../messaging/consumers/feedback_consumer.py | 2 ++ .../messaging/consumers/followup_consumer.py | 1 + ai/src/ai_server/model/messages/feedback.py | 2 ++ ai/src/ai_server/model/messages/followup.py | 2 ++ ai/tests/test_feedback_consumer.py | 16 ++++++++++++++++ ai/tests/test_followup_consumer.py | 2 ++ .../application/SessionFeedbackRequester.java | 1 + .../application/SessionFollowupRequester.java | 1 + .../application/dto/GenerateFeedbackPayload.java | 1 + .../application/dto/GenerateFollowupPayload.java | 1 + docs/messaging.md | 12 ++++++++++-- 14 files changed, 48 insertions(+), 2 deletions(-) diff --git a/ai/src/ai_server/chain/followup_generation_chain.py b/ai/src/ai_server/chain/followup_generation_chain.py index 1b4f5af9..b3998711 100644 --- a/ai/src/ai_server/chain/followup_generation_chain.py +++ b/ai/src/ai_server/chain/followup_generation_chain.py @@ -29,6 +29,7 @@ async def generate( answer_text: str, context: str = "(none)", parent_category: str = "UNKNOWN", + expected_signal: str = "(none)", history: str = "(none)", ) -> FollowupResult: ... @@ -46,6 +47,7 @@ async def generate( answer_text: str, context: str = "(none)", parent_category: str = "UNKNOWN", + expected_signal: str = "(none)", history: str = "(none)", ) -> FollowupResult: result = await self._chain.ainvoke( @@ -56,6 +58,7 @@ async def generate( "answer_text": answer_text, "context": context, "parent_category": parent_category, + "expected_signal": expected_signal, "history": history, } ) diff --git a/ai/src/ai_server/chain/prompts/feedback_generation.py b/ai/src/ai_server/chain/prompts/feedback_generation.py index 311c713f..e23301cd 100644 --- a/ai/src/ai_server/chain/prompts/feedback_generation.py +++ b/ai/src/ai_server/chain/prompts/feedback_generation.py @@ -20,6 +20,8 @@ " - 점수를 매기기 **전에** 강점/약점의 근거를 먼저 정리한 뒤 점수를 산정하세요(즉흥 점수 금지).\n" " - strengths_summary·weaknesses_summary 에는 **어느 질문/순간 때문인지 구체적으로** 적으세요 " "(예: 'Q3에서 동시성 처리를 RDB 락으로만 설명해 분산 환경 고려가 빠짐').\n" + " - 질문에 '기대 신호(평가 기준)' 가 붙어 있으면, 각 답변이 그 핵심을 얼마나 짚었는지를 " + "technical_accuracy 판단의 핵심 근거로 삼고, 빗나간 지점은 weaknesses 에 구체적으로 적으세요.\n" " - 답변이 짧거나 비어 있으면 해당 점수는 낮게 또는 null.\n" " - 컨텍스트 청크(분석 문서 일부) 가 있다면 사실 검증에만 활용 (직접 인용 X).\n" "- 응답은 반드시 지정된 JSON 스키마를 따릅니다." diff --git a/ai/src/ai_server/chain/prompts/followup_generation.py b/ai/src/ai_server/chain/prompts/followup_generation.py index 091d58a1..00d8b09d 100644 --- a/ai/src/ai_server/chain/prompts/followup_generation.py +++ b/ai/src/ai_server/chain/prompts/followup_generation.py @@ -14,6 +14,9 @@ "PROJECT_DEEP_DIVE 같은 기술형이면 structure 는 참고만 하고 정확성·깊이를 우선한다.\n" " - correctness (0~5): 답변이 '검색 문서 컨텍스트'의 사실과 일치하는가. " "**컨텍스트가 '(none)' 이면 판단 불가 → correctness 는 null**. 추측으로 채우지 마세요.\n" + "- '기대 신호(expected_signal)' 가 주어지면, 그 질문이 드러내려던 핵심을 답변이 " + "얼마나 짚었는지를 specificity·correctness 채점의 핵심 기준으로 삼으세요 " + "(기대 신호를 정확히 충족하면 높게, 빗나가거나 누락하면 낮게). '(none)' 이면 무시.\n" "- 꼬리질문은 가장 약한 축을 겨냥합니다 (예: 구체성 낮음→수치/사례 요구, " "correctness 의심→자료와의 불일치 확인).\n" "- '이미 나눈 대화'에서 다룬 내용을 그대로 반복하지 말고 새로운 각도로 파고드세요.\n" @@ -30,6 +33,7 @@ "- INTEGRATED: 기술 질문과 인성 질문의 관점을 균형 있게 반영합니다.\n\n" "이미 나눈 대화 (반복 금지):\n{history}\n\n" "직전 질문:\n{previous_question}\n\n" + "직전 질문의 기대 신호(평가 기준):\n{expected_signal}\n\n" "지원자 답변:\n{answer_text}\n\n" "검색 문서 컨텍스트:\n---\n{context}\n---\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 0378c674..24be44d7 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -174,6 +174,8 @@ def _build_transcript(messages: list[FeedbackMessageItem]) -> str: else ("지원자" if m.role == "INTERVIEWEE" else m.role) ) line = f"[{m.sequence_number}] {speaker}: {m.content}" + if m.role == "INTERVIEWER" and m.expected_signal: + line += f"\n └ 기대 신호(평가 기준): {m.expected_signal}" if m.role == "INTERVIEWEE" and m.evaluation is not None: line += f"\n └ 답변평가: {_format_evaluation(m.evaluation)}" lines.append(line) diff --git a/ai/src/ai_server/messaging/consumers/followup_consumer.py b/ai/src/ai_server/messaging/consumers/followup_consumer.py index 487963fb..8fe8420d 100644 --- a/ai/src/ai_server/messaging/consumers/followup_consumer.py +++ b/ai/src/ai_server/messaging/consumers/followup_consumer.py @@ -80,6 +80,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None: answer_text=req.answer_text, context=await self._build_rag_context(req), parent_category=req.parent_category or "UNKNOWN", + expected_signal=req.parent_expected_signal or "(none)", history=_format_history(req.history), ) diff --git a/ai/src/ai_server/model/messages/feedback.py b/ai/src/ai_server/model/messages/feedback.py index 8b391df2..1cbfcd3b 100644 --- a/ai/src/ai_server/model/messages/feedback.py +++ b/ai/src/ai_server/model/messages/feedback.py @@ -28,6 +28,8 @@ class FeedbackMessageItem(BaseModel): role: Literal["INTERVIEWER", "INTERVIEWEE", "SYSTEM"] content: str parent_message_id: int | None = None + # 질문(INTERVIEWER) 메시지에만 채워짐. 답변이 짚어야 할 핵심(평가 기준). + expected_signal: str | None = None # 답변(INTERVIEWEE) 메시지에만 채워짐. 피드백 종합 채점의 근거. evaluation: MessageEvaluation | None = None diff --git a/ai/src/ai_server/model/messages/followup.py b/ai/src/ai_server/model/messages/followup.py index edf84726..927ac833 100644 --- a/ai/src/ai_server/model/messages/followup.py +++ b/ai/src/ai_server/model/messages/followup.py @@ -30,6 +30,8 @@ class GenerateFollowupRequest(BaseModel): job_category: Literal["FRONTEND", "BACKEND", "INFRA", "DBA"] context_document_ids: list[int] = Field(default_factory=list) parent_category: str | None = None # 직전 질문 카테고리 (루브릭 선택) + # 직전 질문이 기대하는 핵심(평가 관점). correctness/specificity 충족도 채점에 사용. + parent_expected_signal: str | None = None history: list[HistoryItem] = Field(default_factory=list) # 최근 대화 (중복 회피) diff --git a/ai/tests/test_feedback_consumer.py b/ai/tests/test_feedback_consumer.py index 196e066d..1cc19e64 100644 --- a/ai/tests/test_feedback_consumer.py +++ b/ai/tests/test_feedback_consumer.py @@ -14,6 +14,7 @@ from ai_server.messaging.consumers.feedback_consumer import ( FeedbackConsumer, _build_score_basis, + _build_transcript, ) from ai_server.messaging.idempotency import LruIdempotencyStore from ai_server.model.messages.feedback import ( @@ -231,6 +232,21 @@ def test_build_score_basis_empty_when_no_evaluations(): assert "per-answer 평가 없음" in _build_score_basis([]) +def test_transcript_includes_expected_signal_on_question(): + msgs = [ + FeedbackMessageItem( + id=1, + sequence_number=1, + role="INTERVIEWER", + content="동시성을 어떻게 제어하나요?", + expected_signal="DB 락/격리수준까지 설명하는지", + ), + _answer(2, spec=3.0, logic=3.0, structure="NONE"), + ] + transcript = _build_transcript(msgs) + assert "기대 신호(평가 기준): DB 락/격리수준까지 설명하는지" in transcript + + def test_feedback_prompt_has_score_anchor_and_slot(): assert "점수 앵커" in SYSTEM_PROMPT assert "±15" in SYSTEM_PROMPT diff --git a/ai/tests/test_followup_consumer.py b/ai/tests/test_followup_consumer.py index 8d9b4234..a4f232db 100644 --- a/ai/tests/test_followup_consumer.py +++ b/ai/tests/test_followup_consumer.py @@ -245,6 +245,7 @@ async def test_consumer_passes_parent_category_and_history_to_generator(): "mode": "TECHNICAL", "jobCategory": "BACKEND", "parentCategory": "PROJECT_DEEP_DIVE", + "parentExpectedSignal": "동시성 제어를 DB 레벨까지 설명하는지", "history": [ {"role": "INTERVIEWER", "content": "이전 질문"}, {"role": "INTERVIEWEE", "content": "이전 답변"}, @@ -256,6 +257,7 @@ async def test_consumer_passes_parent_category_and_history_to_generator(): kwargs = generator.generate.await_args.kwargs assert kwargs["parent_category"] == "PROJECT_DEEP_DIVE" + assert kwargs["expected_signal"] == "동시성 제어를 DB 레벨까지 설명하는지" assert "이전 질문" in kwargs["history"] assert "면접관:" in kwargs["history"] diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java index 0a8ed594..e6c7fa0d 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java @@ -107,6 +107,7 @@ private MessageItem toItem(InterviewMessage m) { m.getRole().name(), m.getContent(), parentId, + m.getExpectedSignal(), evaluation ); } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java index 596032f2..d061d10b 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java @@ -69,6 +69,7 @@ public void onAnswerSubmitted(AnswerSubmittedEvent event) { session.getJobCategory(), contextDocumentIds, parent.getCategory(), + parent.getExpectedSignal(), history ); publisher.publishToAi( diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java index 64efd3ae..abb0be9d 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java @@ -22,6 +22,7 @@ public record MessageItem( String role, String content, Long parentMessageId, + String expectedSignal, // INTERVIEWER 질문에만(평가 기준). 답변은 null MessageEvaluation evaluation // INTERVIEWEE 답변에만(없으면 null) ) { } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFollowupPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFollowupPayload.java index 273a0333..0bc78376 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFollowupPayload.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFollowupPayload.java @@ -14,6 +14,7 @@ public record GenerateFollowupPayload( JobCategory jobCategory, List contextDocumentIds, // RAG(자료 근거/correctness) 용 String parentCategory, // 직전 질문 카테고리 (루브릭 선택) + String parentExpectedSignal, // 직전 질문이 기대하는 핵심(평가 관점) — 충족도 채점용 List history // 최근 대화 (중복 회피) ) { public record HistoryItem(String role, String content) { diff --git a/docs/messaging.md b/docs/messaging.md index 29cd6f83..b552e8a2 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -253,14 +253,22 @@ "messageType": "generate.followup", "payload": { "sessionId": 99, - "questionMessageId": 501, + "parentMessageId": 501, "answerMessageId": 502, + "previousQuestion": "...", "answerText": "...", - "audioS3Key": "session/99/audio/502.webm" + "mode": "TECHNICAL", + "jobCategory": "BACKEND", + "contextDocumentIds": [12, 13], + "parentCategory": "PROJECT_DEEP_DIVE", + "parentExpectedSignal": "동시성 제어를 DB 레벨까지 설명하는지", + "history": [{ "role": "INTERVIEWER", "content": "..." }] } } ``` +> `parentExpectedSignal` = 직전 질문 생성 시 만든 기대 신호(평가 관점). AI 가 specificity/correctness 채점의 핵심 기준으로 사용(없으면 무시). `contextDocumentIds` 로 RAG 검색 → correctness 판정. + ### 5.9 `callback.questions` (꼬리질문) ```json {