diff --git a/ai/src/ai_server/chain/prompts/question_generation.py b/ai/src/ai_server/chain/prompts/question_generation.py index eb53718c..7c49a7cc 100644 --- a/ai/src/ai_server/chain/prompts/question_generation.py +++ b/ai/src/ai_server/chain/prompts/question_generation.py @@ -28,7 +28,13 @@ " - TECHNICAL: CS_FUNDAMENTAL + TECH_CHOICE + PROJECT_DEEP_DIVE 중심.\n" " - PERSONALITY: BEHAVIORAL 중심.\n" " - INTEGRATED: 모두 균형.\n" - "4. 지원자 컨텍스트에 명시된 기술/프로젝트를 구체적으로 언급하는 PROJECT_DEEP_DIVE / " - "TECH_CHOICE 질문을 최소 절반 이상 포함하세요.\n\n" + " 자료가 받쳐주는 만큼만 분배하고, 근거 없는 카테고리를 억지로 채우지 마세요.\n" + "4. 각 질문에 target_evidence 와 expected_signal 을 채웁니다:\n" + " - target_evidence: 질문의 근거가 된 컨텍스트 원문을 짧게 인용. " + "PROJECT_DEEP_DIVE·TECH_CHOICE 는 **필수**(근거를 못 찾으면 그 질문을 만들지 마세요). " + "CS_FUNDAMENTAL·BEHAVIORAL 은 선택(없으면 빈 문자열).\n" + " - expected_signal: 좋은 답변이 드러내야 할 핵심(평가 관점). 한 문장.\n" + "5. 지원자 컨텍스트의 기술/프로젝트를 구체적으로 언급하는 PROJECT_DEEP_DIVE / " + "TECH_CHOICE 질문을 우선합니다.\n\n" "{format_instructions}" ) diff --git a/ai/src/ai_server/messaging/consumers/questions_consumer.py b/ai/src/ai_server/messaging/consumers/questions_consumer.py index bc5e8007..b7a654ad 100644 --- a/ai/src/ai_server/messaging/consumers/questions_consumer.py +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -123,6 +123,11 @@ async def _build_context(self, req: GenerateQuestionsRequest) -> str: if not document_ids: return base_context + # PLAN vs Retrieve: 문서가 1개면 markdown 전체가 이미 base_context 에 들어 있어 + # 검색이 중복이다 → 검색 생략(PLAN). 여러 문서일 때만 RAG 로 관련 청크를 추린다. + if len(document_ids) <= 1: + return base_context + query = _build_initial_rag_query(req) try: query_vec = ( diff --git a/ai/src/ai_server/model/messages/questions.py b/ai/src/ai_server/model/messages/questions.py index c0efaa39..dcc14db5 100644 --- a/ai/src/ai_server/model/messages/questions.py +++ b/ai/src/ai_server/model/messages/questions.py @@ -17,6 +17,7 @@ class DocumentContext(BaseModel): """Core 가 generate.questions envelope 에 담아 보내는 문서 컨텍스트.""" + model_config = camel_config() document_id: int @@ -39,10 +40,15 @@ class GenerateQuestionsRequest(BaseModel): class GeneratedQuestion(BaseModel): """LLM 응답 단위. category 는 Spring 측 enum 과 동기.""" + model_config = camel_config() category: QuestionCategory question: str + # 질문이 근거한 자료 인용(PROJECT/TECH 는 필수). 라이브 화면에 힌트로 노출. + target_evidence: str = "" + # 좋은 답이 드러내야 할 것 — 내부 평가용. 라이브 비노출(정답 유출 방지). + expected_signal: str = "" class QuestionPoolCallbackPayload(BaseModel): diff --git a/ai/tests/test_questions_consumer.py b/ai/tests/test_questions_consumer.py index fcfa535b..e52cbd04 100644 --- a/ai/tests/test_questions_consumer.py +++ b/ai/tests/test_questions_consumer.py @@ -56,8 +56,12 @@ async def test_consumer_generates_questions_and_publishes_callback(): generator.generate = AsyncMock( return_value=GeneratedQuestionPool( questions=[ - GeneratedQuestion(category="CS_FUNDAMENTAL", question="DB 트랜잭션 격리수준?"), - GeneratedQuestion(category="PROJECT_DEEP_DIVE", question="결제 outbox 어떻게 보장?"), + GeneratedQuestion( + category="CS_FUNDAMENTAL", question="DB 트랜잭션 격리수준?" + ), + GeneratedQuestion( + category="PROJECT_DEEP_DIVE", question="결제 outbox 어떻게 보장?" + ), ] ) ) @@ -237,7 +241,14 @@ async def test_consumer_injects_initial_rag_chunks_when_available(): "summary": "outbox 구현", "techStack": ["Spring"], "markdown": "transactional publisher", - } + }, + { + "documentId": 2, + "sourceType": "RESUME", + "summary": "백엔드 3년", + "techStack": ["Java"], + "markdown": "결제 시스템", + }, ], "initialQuestionCount": 1, "maxQuestions": 5, @@ -245,10 +256,11 @@ async def test_consumer_injects_initial_rag_chunks_when_available(): ) await consumer.handle(_StubMessage(body)) + # 문서 2개 → Retrieve 경로(RAG 검색 발동) embedder.embed.assert_awaited_once() call = core.search_embeddings.await_args assert call.kwargs["query_embedding"] == [0.1, 0.2] - assert call.kwargs["document_ids"] == [1] + assert call.kwargs["document_ids"] == [1, 2] assert call.kwargs["top_k"] == 20 # candidate_k (리랭크 후보 수) assert call.kwargs["query_text"] # 하이브리드 검색: 쿼리 텍스트 동봉 context = generator.generate.await_args.kwargs["context"] @@ -256,6 +268,51 @@ async def test_consumer_injects_initial_rag_chunks_when_available(): assert "outbox 구현" in context +@pytest.mark.asyncio +async def test_single_document_skips_rag_plan_mode(): + # 문서 1개 → PLAN: markdown 이 이미 컨텍스트에 있으니 검색 생략 + generator = MagicMock() + generator.generate = AsyncMock(return_value=GeneratedQuestionPool(questions=[])) + publisher = MagicMock() + publisher.publish = AsyncMock() + core = MagicMock() + core.search_embeddings = AsyncMock() + embedder = MagicMock() + embedder.embed = AsyncMock() + + consumer = QuestionsConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.questions", + core_client=core, + embedder=embedder, + ) + body = _envelope( + { + "sessionId": 1, + "mode": "TECHNICAL", + "jobCategory": "BACKEND", + "documents": [ + { + "documentId": 1, + "sourceType": "RESUME", + "summary": "백엔드", + "techStack": ["Java"], + "markdown": "결제 시스템 구현", + } + ], + "maxQuestions": 5, + } + ) + await consumer.handle(_StubMessage(body)) + + core.search_embeddings.assert_not_awaited() + embedder.embed.assert_not_awaited() + context = generator.generate.await_args.kwargs["context"] + assert "결제 시스템 구현" in context # base context 사용 + + @pytest.mark.asyncio async def test_consumer_falls_back_to_document_context_when_rag_fails(): generator = MagicMock() @@ -327,3 +384,23 @@ def test_build_context_joins_doc_blocks(): assert "본문1" in text assert "문서 #2 (REPOSITORY)" in text assert "readme" in text + + +def test_generated_question_parses_evidence_and_signal(): + from ai_server.model.messages.questions import GeneratedQuestion + + q = GeneratedQuestion.model_validate( + { + "category": "PROJECT_DEEP_DIVE", + "question": "결제 동시성 어떻게 처리했나요?", + "targetEvidence": "결제 시스템에서 분산락 도입", + "expectedSignal": "동시성 제어 trade-off 이해", + } + ) + assert q.target_evidence == "결제 시스템에서 분산락 도입" + assert q.expected_signal == "동시성 제어 trade-off 이해" + + # 하위호환: 신규 필드 없어도 기본값 + q2 = GeneratedQuestion.model_validate({"category": "BEHAVIORAL", "question": "Q?"}) + assert q2.target_evidence == "" + assert q2.expected_signal == "" diff --git a/backend/openapi.json b/backend/openapi.json index 88d6445e..a315d2bd 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -2517,6 +2517,12 @@ "createdAt" : { "type" : "string", "format" : "date-time" + }, + "category" : { + "type" : "string" + }, + "targetEvidence" : { + "type" : "string" } } }, diff --git a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java index d13d784a..54cd8917 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java @@ -86,7 +86,10 @@ private void applyInitialQuestion(InterviewSession session, QuestionsCallbackPay session.getId(), questions.size() - 1); } InterviewMessage message = messageRepository.save( - InterviewMessage.interviewer(session, 1, first.question()) + InterviewMessage.interviewer( + session, 1, first.question(), + first.category(), first.targetEvidence(), first.expectedSignal() + ) ); session.incrementQuestionCount(); events.publishEvent(new QuestionPersistedEvent( 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 63cbb6e2..a4ef63cd 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 @@ -18,7 +18,10 @@ public record MessageResult( Double ttsDurationSec, Long parentMessageId, MessageStatus status, - Instant createdAt + Instant createdAt, + String category, + String targetEvidence + // expectedSignal 은 의도적으로 제외 — 정답 유출 방지(라이브 비노출). ) { public static MessageResult of(InterviewMessage m) { return new MessageResult( @@ -33,7 +36,9 @@ public static MessageResult of(InterviewMessage m) { m.getTtsDurationSec(), m.getParentMessage() == null ? null : m.getParentMessage().getId(), m.getStatus(), - m.getCreatedAt() + m.getCreatedAt(), + m.getCategory(), + m.getTargetEvidence() ); } } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java index e17a0076..978afa79 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java @@ -22,7 +22,9 @@ public boolean isFollowup() { public record GeneratedQuestion( String category, - String question + String question, + String targetEvidence, // 질문 근거(자료 인용). 라이브 노출. + String expectedSignal // 좋은 답이 드러낼 것. 내부 저장만(라이브 비노출). ) { } 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 2f84cd8e..dc79588a 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 @@ -79,6 +79,16 @@ public class InterviewMessage extends BaseTimeEntity { @Column(name = "idempotency_key", length = 64) private String idempotencyKey; + // 질문 메타데이터 (초기 질문에만 채워짐). category/targetEvidence 는 노출, expectedSignal 은 내부용. + @Column(length = 30) + private String category; + + @Column(name = "target_evidence", columnDefinition = "text") + private String targetEvidence; + + @Column(name = "expected_signal", columnDefinition = "text") + private String expectedSignal; + private InterviewMessage(InterviewSession session, Integer sequenceNumber, MessageRole role, String content, InterviewMessage parentMessage, MessageStatus initialStatus, String idempotencyKey) { @@ -101,8 +111,17 @@ private InterviewMessage(InterviewSession session, Integer sequenceNumber, Messa } public static InterviewMessage interviewer(InterviewSession session, int seq, String content) { + return interviewer(session, seq, content, null, null, null); + } + + public static InterviewMessage interviewer(InterviewSession session, int seq, String content, + String category, String targetEvidence, + String expectedSignal) { InterviewMessage m = new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, null, MessageStatus.CREATED, null); + m.category = category; + m.targetEvidence = targetEvidence; + m.expectedSignal = expectedSignal; m.markTtsPending(); return m; } 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 2a08c01b..b2a9f9e5 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 @@ -18,7 +18,10 @@ public record MessageResponse( Double ttsDurationSec, Long parentMessageId, MessageStatus status, - Instant createdAt + Instant createdAt, + String category, + String targetEvidence + // expectedSignal 은 노출하지 않음 — 자기연습 시 정답 유출 방지. ) { public static MessageResponse from(MessageResult r) { return new MessageResponse( @@ -33,7 +36,9 @@ public static MessageResponse from(MessageResult r) { r.ttsDurationSec(), r.parentMessageId(), r.status(), - r.createdAt() + r.createdAt(), + r.category(), + r.targetEvidence() ); } } diff --git a/backend/src/main/resources/db/migration/V9__add_question_metadata_to_interview_messages.sql b/backend/src/main/resources/db/migration/V9__add_question_metadata_to_interview_messages.sql new file mode 100644 index 00000000..80a87b07 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__add_question_metadata_to_interview_messages.sql @@ -0,0 +1,12 @@ +-- ============================================================================= +-- 초기 질문 메타데이터: category / target_evidence / expected_signal +-- ============================================================================= +-- 근거 기반 질문을 위해 AI 가 생성한 메타데이터를 보관한다. +-- - category: 질문 유형 (CS_FUNDAMENTAL/PROJECT_DEEP_DIVE/TECH_CHOICE/BEHAVIORAL). 느슨 결합(CHECK 없음). +-- - target_evidence: 질문 근거(자료 인용). 라이브 노출. +-- - expected_signal: 좋은 답이 드러낼 것. 내부용(라이브 비노출). +-- 모두 nullable — 기존 행/꼬리질문은 NULL. +ALTER TABLE interview_messages + ADD COLUMN category VARCHAR(30), + ADD COLUMN target_evidence TEXT, + ADD COLUMN expected_signal TEXT; diff --git a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java index e44e3aad..2d0a8190 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java @@ -45,8 +45,9 @@ class QuestionsCallbackServiceTest { void apply_poolCallbackStoresOnlyInitialQuestionAndPushesSse() { InterviewSession session = sessionFixture(11L, SessionStatus.READY); QuestionsCallbackEnvelope env = poolEnvelope(11L, List.of( - new GeneratedQuestion("INTRO", "Introduce yourself"), - new GeneratedQuestion("TECH", "JPA?") + new GeneratedQuestion("PROJECT_DEEP_DIVE", "Introduce yourself", + "이력서: 결제 시스템", "협업/문제해결 깊이"), + new GeneratedQuestion("TECH", "JPA?", null, null) )); when(processedMessageRepository.existsById("m-1")).thenReturn(false); when(sessionRepository.findById(11L)).thenReturn(Optional.of(session)); @@ -62,6 +63,10 @@ void apply_poolCallbackStoresOnlyInitialQuestionAndPushesSse() { verify(messageRepository).save(messageCaptor.capture()); assertThat(messageCaptor.getValue().getContent()).isEqualTo("Introduce yourself"); assertThat(messageCaptor.getValue().getSequenceNumber()).isEqualTo(1); + // 질문 메타데이터가 첫 질문에서 영속됨 + assertThat(messageCaptor.getValue().getCategory()).isEqualTo("PROJECT_DEEP_DIVE"); + assertThat(messageCaptor.getValue().getTargetEvidence()).isEqualTo("이력서: 결제 시스템"); + assertThat(messageCaptor.getValue().getExpectedSignal()).isEqualTo("협업/문제해결 깊이"); ArgumentCaptor ev = ArgumentCaptor.forClass(Object.class); verify(events, atLeastOnce()).publishEvent(ev.capture()); @@ -108,7 +113,8 @@ void apply_followupAutoEndsSessionAtMaxQuestions() { @Test void apply_skipsDuplicateMessageId() { - QuestionsCallbackEnvelope env = poolEnvelope(11L, List.of(new GeneratedQuestion("X", "Q"))); + QuestionsCallbackEnvelope env = + poolEnvelope(11L, List.of(new GeneratedQuestion("X", "Q", null, null))); when(processedMessageRepository.existsById("m-1")).thenReturn(true); service.apply(env); diff --git a/frontend/src/features/interview/ui/live/QuestionBubble.tsx b/frontend/src/features/interview/ui/live/QuestionBubble.tsx index 81fe2b12..183fde30 100644 --- a/frontend/src/features/interview/ui/live/QuestionBubble.tsx +++ b/frontend/src/features/interview/ui/live/QuestionBubble.tsx @@ -1,10 +1,35 @@ import type { Message } from '@/domain/session' +import { StatusBadge } from '@/shared/ui/StatusBadge' + +const CATEGORY_LABEL: Record = { + CS_FUNDAMENTAL: 'CS 기초', + PROJECT_DEEP_DIVE: '프로젝트 심화', + TECH_CHOICE: '기술 선택', + BEHAVIORAL: '인성·행동', +} export function QuestionBubble({ message }: { message: Message }) { + const categoryLabel = message.category + ? (CATEGORY_LABEL[message.category] ?? message.category) + : null + const hasMeta = Boolean(categoryLabel || message.targetEvidence) + return (
-
- {message.content} +
+ {hasMeta && ( +
+ {categoryLabel && {categoryLabel}} + {message.targetEvidence && ( + + 근거: {message.targetEvidence} + + )} +
+ )} +
+ {message.content} +
) diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index b3683179..89e5ae2b 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -809,6 +809,8 @@ export interface components { status?: "CREATED" | "COMPLETED" | "FAILED"; /** Format: date-time */ createdAt?: string; + category?: string; + targetEvidence?: string; }; VoiceStreamBeginResponse: { /** Format: int64 */