diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index b4344cf4..671e361e 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -323,6 +323,8 @@ docker run --env-file .env -p 8000:8000 stackup-ai - 질문 풀 생성 (Pro 모델, `chain/question_generation_chain.py`) - 꼬리질문 + 답변 평가 (Flash 모델, `chain/followup_generation_chain.py`) - 콜백: `callback.questions` (`kind=POOL|FOLLOWUP`) + - **자기소개 기반 질문 생성**: `generate.questions` 는 자기소개 답변을 받은 뒤 발행되며, payload 의 + `selfIntroAnswer` 를 프롬프트(`chain/prompts/question_generation.py`)의 1차 근거로 사용한다(없으면 자료만으로). - **꼬리질문 토큰 스트리밍 본 구현**: followup 출력을 `{json}` 구분자 포맷으로 바꾸고(`chain/prompts/followup_generation.py`), `StreamingFollowupGenerator`(`astream`)가 `` 토큰만 `SessionRealtimeNotifier`(`messaging/session_notify.py`)로 `SESSION_MESSAGE_DELTA` 발행(`stackup.realtime`/`realtime.session.notify`, Core 우회). `DONT_KNOW` 면 델타 미발행. 종료 후 `parse_followup_result` 로 검증해 기존 `callback.questions(FOLLOWUP, followupMessageId)` 발행. 와이어링은 `messaging/runner.py`(분석 진행 publisher 재사용). - **문장 단위 TTS 본 구현 (Part B)**: followup consumer 스트림 루프가 `chain/sentence_split.next_sentences` 로 문장 경계를 잡아, 문장마다 `TtsProvider` 인라인 합성(`asyncio.create_task` 백그라운드, 텍스트 델타 비차단)→S3 `interview/tts/{sid}/{mid}/seg-{seq}.{ext}` PUT→`SessionRealtimeNotifier.emit_audio`(`SESSION_MESSAGE_AUDIO`). 콜백 전 `gather` 로 수거. 라이브 세그먼트는 휘발성(DB 미기록). - **임베딩 본 구현** (`rag/`): `MarkdownChunker` + `GeminiEmbeddingProvider` (1536d, `gemini-embedding-001`). diff --git a/ai/src/ai_server/chain/prompts/question_generation.py b/ai/src/ai_server/chain/prompts/question_generation.py index e3ec5fdf..4def7394 100644 --- a/ai/src/ai_server/chain/prompts/question_generation.py +++ b/ai/src/ai_server/chain/prompts/question_generation.py @@ -3,8 +3,10 @@ SYSTEM_PROMPT = ( "당신은 IT 직군 채용을 진행하는 시니어 면접관입니다. " - "지원자 컨텍스트(이력서, GitHub 레포 분석)와 면접 모드에 맞춰 면접 질문 풀을 " - "생성하세요.\n" + "지원자의 **자기소개 답변**을 1차 근거로, 이력서·GitHub 레포 분석 컨텍스트와 " + "면접 모드에 맞춰 면접 질문 풀을 생성하세요.\n" + "- 실제 면접처럼, 자기소개에서 지원자가 직접 강조한 경험·역량·관심사를 우선 파고듭니다. " + "자기소개에서 언급된 키워드를 이력서/레포 자료와 교차로 연결해 구체적으로 질문하세요.\n" "- 사실에 근거한 질문만. 자료에 없는 내용은 추측하지 않습니다.\n" "- 자료에 명시된 기술 스택과 프로젝트 경험을 적극 인용하세요.\n" "- 카테고리는 다음 중에서 선택: CS_FUNDAMENTAL, PROJECT_DEEP_DIVE, TECH_CHOICE, " @@ -19,11 +21,18 @@ "직군(복수 가능): {job_categories}\n" "면접 모드: {mode}\n" "최대 질문 수: {max_questions}\n\n" + "지원자 자기소개 답변 (질문 생성의 1차 근거):\n" + "---\n" + "{self_introduction}\n" + "---\n\n" "지원자 컨텍스트 (이력서/레포 분석):\n" "---\n" "{context}\n" "---\n\n" "요구 사항:\n" + "0. **자기소개 답변을 출발점으로** 삼습니다. 자기소개에서 지원자가 강조한 경험/기술/관심사를 " + "먼저 짚고, 이를 이력서·레포 자료로 검증·심화하는 질문을 우선합니다. 자기소개가 비어 있으면 " + "(자기소개 없음) 자료 컨텍스트만으로 생성합니다.\n" "1. 정확히 {max_questions}개의 질문을 생성하되, **서로 다른 주제/영역**을 다룹니다 " "(각 질문이 일반질문 1개의 씨앗이 되고 뒤에 꼬리질문이 붙으므로 같은 주제 반복 금지).\n" "2. 각 질문에 적절한 category 를 부여하고, job_category 를 직군({job_categories}) 중 " diff --git a/ai/src/ai_server/chain/question_generation_chain.py b/ai/src/ai_server/chain/question_generation_chain.py index 2cab44ec..8763a086 100644 --- a/ai/src/ai_server/chain/question_generation_chain.py +++ b/ai/src/ai_server/chain/question_generation_chain.py @@ -24,6 +24,11 @@ def _format_recent_questions(recent_questions: list[str] | None) -> str: return "\n".join(f"- {q}" for q in recent_questions) +def _format_self_introduction(self_introduction: str | None) -> str: + text = (self_introduction or "").strip() + return text if text else "(자기소개 없음)" + + class QuestionGenerator(Protocol): async def generate( self, @@ -33,6 +38,7 @@ async def generate( max_questions: int, context: str, recent_questions: list[str] | None = None, + self_introduction: str | None = None, ) -> GeneratedQuestionPool: ... @@ -48,6 +54,7 @@ async def generate( max_questions: int, context: str, recent_questions: list[str] | None = None, + self_introduction: str | None = None, ) -> GeneratedQuestionPool: result = await self._chain.ainvoke( { @@ -56,6 +63,7 @@ async def generate( "max_questions": max_questions, "context": context, "recent_questions": _format_recent_questions(recent_questions), + "self_introduction": _format_self_introduction(self_introduction), } ) if not isinstance(result, GeneratedQuestionPool): diff --git a/ai/src/ai_server/messaging/consumers/questions_consumer.py b/ai/src/ai_server/messaging/consumers/questions_consumer.py index 82875b14..981fd7e2 100644 --- a/ai/src/ai_server/messaging/consumers/questions_consumer.py +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -86,6 +86,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None: max_questions=effective_pool_size, context=context_text, recent_questions=req.recent_questions, + self_introduction=req.self_intro_answer, ) payload = QuestionPoolCallbackPayload( diff --git a/ai/src/ai_server/model/messages/questions.py b/ai/src/ai_server/model/messages/questions.py index ce49d978..639a5c9a 100644 --- a/ai/src/ai_server/model/messages/questions.py +++ b/ai/src/ai_server/model/messages/questions.py @@ -39,6 +39,8 @@ class GenerateQuestionsRequest(BaseModel): max_questions: int = 10 # 같은 유저가 최근 면접에서 받은 질문들. 의미 중복 회피에 사용. recent_questions: list[str] = [] + # 지원자의 자기소개 답변. 모든 면접의 첫 질문이며 질문 생성의 1차 근거. 없으면 빈 문자열. + self_intro_answer: str = "" class GeneratedQuestion(BaseModel): diff --git a/ai/tests/test_question_generation_chain.py b/ai/tests/test_question_generation_chain.py index dac0141a..2416b39f 100644 --- a/ai/tests/test_question_generation_chain.py +++ b/ai/tests/test_question_generation_chain.py @@ -8,6 +8,7 @@ GeneratedQuestionPool, LlmQuestionGenerator, _format_recent_questions, + _format_self_introduction, ) @@ -20,6 +21,17 @@ def test_format_recent_questions_bullets(): assert _format_recent_questions(["질문 A", "질문 B"]) == "- 질문 A\n- 질문 B" +def test_format_self_introduction_empty(): + assert _format_self_introduction(None) == "(자기소개 없음)" + assert _format_self_introduction(" ") == "(자기소개 없음)" + + +def test_format_self_introduction_trims(): + assert _format_self_introduction(" 안녕하세요 백엔드 3년차입니다. ") == ( + "안녕하세요 백엔드 3년차입니다." + ) + + @pytest.mark.asyncio async def test_generate_forwards_formatted_recent_questions_to_chain(): chain = AsyncMock() @@ -32,8 +44,26 @@ async def test_generate_forwards_formatted_recent_questions_to_chain(): max_questions=3, context="ctx", recent_questions=["이전 질문"], + self_introduction="결제 시스템을 만든 백엔드입니다.", ) chain_input = chain.ainvoke.call_args.args[0] assert chain_input["recent_questions"] == "- 이전 질문" assert chain_input["job_categories"] == "BACKEND, FRONTEND" + assert chain_input["self_introduction"] == "결제 시스템을 만든 백엔드입니다." + + +@pytest.mark.asyncio +async def test_generate_defaults_self_introduction_when_missing(): + chain = AsyncMock() + chain.ainvoke = AsyncMock(return_value=GeneratedQuestionPool(questions=[])) + generator = LlmQuestionGenerator(chain) + + await generator.generate( + job_categories=["BACKEND"], + mode="TECHNICAL", + max_questions=3, + context="ctx", + ) + + assert chain.ainvoke.call_args.args[0]["self_introduction"] == "(자기소개 없음)" diff --git a/ai/tests/test_questions_consumer.py b/ai/tests/test_questions_consumer.py index cd54d0b1..e837af4b 100644 --- a/ai/tests/test_questions_consumer.py +++ b/ai/tests/test_questions_consumer.py @@ -114,6 +114,36 @@ async def test_consumer_generates_questions_and_publishes_callback(): assert pub_call.kwargs["correlation_id"] == "m-1" +@pytest.mark.asyncio +async def test_consumer_forwards_self_intro_answer_to_generator(): + generator = MagicMock() + generator.generate = AsyncMock(return_value=GeneratedQuestionPool(questions=[])) + publisher = MagicMock() + publisher.publish = AsyncMock() + + consumer = QuestionsConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.questions", + ) + body = _envelope( + { + "sessionId": 99, + "mode": "TECHNICAL", + "jobCategories": ["BACKEND"], + "documents": [], + "maxQuestions": 5, + "initialQuestionCount": 2, + "selfIntroAnswer": "결제 시스템을 설계한 백엔드 3년차입니다.", + } + ) + await consumer.handle(_StubMessage(body)) + + call = generator.generate.await_args + assert call.kwargs["self_introduction"] == "결제 시스템을 설계한 백엔드 3년차입니다." + + @pytest.mark.asyncio async def test_consumer_skips_when_message_id_already_seen(): generator = MagicMock() diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index be5ba071..83494ef9 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -359,6 +359,12 @@ docker compose up -d - ArchUnit 룰 적용 (의존 방향 · 순환 차단 · `@Transactional` application 한정 · entity는 domain 패키지) - 면접 도메인 (US-13~20) 본 구현: 세션 CRUD/start/end/interrupt, generate.questions 발행, callback.questions(POOL/FOLLOWUP) 수신, 자동 종료 +- **첫 질문 자기소개 고정 본 구현**: 모든 면접의 첫 질문은 `InterviewMessage.selfIntroduction`(seq=1, + category=`SELF_INTRODUCTION`)으로 세션 생성 직후 AI 없이 삽입(`QuestionsCallbackService.insertSelfIntroduction`, + `SessionQuestionsRequester.onSessionCreated`). 질문 풀은 자기소개 **답변**을 받은 뒤 발행한다 — + `SessionFollowupRequester` 가 `parent.isSelfIntroduction()` 면 꼬리질문 대신 `SelfIntroAnsweredEvent` 발행 → + `SessionQuestionsRequester.onSelfIntroAnswered` 가 답변(`selfIntroAnswer`)을 씨앗으로 `generate.questions` + 발행(풀 크기 = `generalQuestionCount-1`, 자기소개 1자리 예약). 자기소개엔 꼬리질문을 달지 않는다. - 질문 TTS 발행 본 구현: 질문 영속 후 `QuestionPersistedEvent`(AFTER_COMMIT) → `SessionTtsRequester` 가 `generate.tts` 발행, `callback.tts` 수신해 메시지에 오디오 경로 반영(`TtsCallbackService`) - **꼬리질문 토큰 스트리밍 placeholder 본 구현**: `SessionFollowupRequester` 가 AI 호출 분기에서 INTERVIEWER placeholder(`InterviewMessage.followupPlaceholder`, content=`"(생성 중)"`, status=`CREATED`)를 선INSERT + `SESSION_MESSAGE` 발행 + `generate.followup.followupMessageId` 동봉. AI 가 토큰을 `SESSION_MESSAGE_DELTA` 로 흘린 뒤 `callback.questions(FOLLOWUP)` 도착 시 `QuestionsCallbackService` 가 분기: NORMAL→`completeFollowup` UPDATE+카운트, CLARIFICATION→UPDATE(카운트 X), DONT_KNOW→placeholder DELETE 후 `advanceToNextGeneral`. placeholder 없는 레거시 콜백은 기존 INSERT 폴백. 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 5519a759..7e1cd8e0 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 @@ -78,6 +78,26 @@ public void apply(QuestionsCallbackEnvelope envelope) { markProcessed(envelope.messageId()); } + // 세션 생성 직후 호출: 모든 면접의 첫 질문인 자기소개 질문(seq=1)을 AI 없이 고정 삽입한다. + // 이력서/레포 기반 질문 풀은 이 자기소개 답변을 받은 뒤에 생성된다(SessionQuestionsRequester). + @Transactional + public void insertSelfIntroduction(Long sessionId) { + InterviewSession session = sessionRepository.findById(sessionId).orElse(null); + if (session == null || session.isDeleted()) { + log.warn("self-intro insert skipped — session not found or deleted. id={}", sessionId); + return; + } + // 멱등: 이미 메시지가 있으면(중복 이벤트) skip. + if (messageRepository.countBySession_Id(sessionId) > 0) { + log.info("self-intro insert skipped — messages already exist. sessionId={}", sessionId); + return; + } + InterviewMessage message = messageRepository.save(InterviewMessage.selfIntroduction(session, 1)); + session.incrementQuestionCount(); + publishQuestionEvents(session, message, "SELF_INTRO_READY"); + log.info("self-intro question inserted. sessionId={}, msg={}", sessionId, message.getId()); + } + // POOL 콜백: AI 가 만든 일반질문들을 풀에 저장하고 첫 질문을 삽입한다. private void applyInitialQuestion(InterviewSession session, QuestionsCallbackPayload payload) { List questions = payload.questions(); 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 577beb43..8aca69d5 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 @@ -8,11 +8,13 @@ import com.stackup.stackup.session.application.dto.GenerateFollowupPayload; import com.stackup.stackup.session.application.dto.GenerateFollowupPayload.HistoryItem; import com.stackup.stackup.session.application.event.AnswerSubmittedEvent; +import com.stackup.stackup.session.application.event.SelfIntroAnsweredEvent; 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.MessageRole; import com.stackup.stackup.session.domain.SessionContextRepository; +import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; @@ -56,6 +58,22 @@ public void onAnswerSubmitted(AnswerSubmittedEvent event) { .map(c -> c.getDocument().getId()) .toList(); + // 자기소개(첫 질문) 답변이면 꼬리질문을 만들지 않고, 이 답변을 씨앗으로 질문 풀 생성을 트리거한다. + if (parent.isSelfIntroduction()) { + events.publishEvent(new SelfIntroAnsweredEvent( + event.userId(), + session.getId(), + session.getMode(), + new ArrayList<>(session.getJobCategories()), + session.getMaxQuestions(), + session.getGeneralQuestionCount(), + contextDocumentIds, + answer.getContent() + )); + log.info("self-intro answered — requesting question pool. sessionId={}", session.getId()); + return; + } + // 최근 대화 히스토리 (중복 질문 회피). 시퀀스 순 → 마지막 N개. List ordered = messageRepository.findBySession_IdOrderBySequenceNumberAsc(session.getId()); diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java index 74e9e929..5991e2ad 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java @@ -13,6 +13,7 @@ import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload; import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload.DocumentContext; +import com.stackup.stackup.session.application.event.SelfIntroAnsweredEvent; import com.stackup.stackup.session.application.event.SessionCreatedEvent; import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.SessionQuestionPoolRepository; @@ -32,7 +33,9 @@ import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; -// 세션 생성 commit 후 발화 → generate.questions envelope 발행. +// 모든 면접의 첫 질문은 자기소개로 고정한다. +// - 세션 생성 commit 후: 자기소개 질문(seq=1)만 삽입(QuestionsCallbackService 위임). 이 단계에선 풀 생성을 하지 않는다. +// - 자기소개 답변 commit 후: 그 답변을 씨앗으로 generate.questions envelope 발행 → 이력서/레포 기반 질문 풀 생성. // AI 가 documentIds 만으로 다시 fetch 하지 않도록, MD 본문까지 envelope 에 담아 보낸다 (RAG 정교화는 후속). @Component @RequiredArgsConstructor @@ -50,6 +53,7 @@ public class SessionQuestionsRequester { private final ObjectStorageClient storage; private final InterviewSessionRepository sessionRepository; private final SessionQuestionPoolRepository questionPoolRepository; + private final QuestionsCallbackService questionsCallbackService; // 최근 몇 개 세션까지 거슬러 중복 질문을 회피할지. 0 이면 비활성. @Value("${interview.question-dedup.recent-sessions:3}") @@ -58,29 +62,42 @@ public class SessionQuestionsRequester { @Value("${interview.question-dedup.max-questions:30}") private int maxRecentQuestions; + // 세션 생성 직후: 자기소개 질문을 고정 삽입한다(AI 호출 없음). 질문 풀 생성은 답변 이후로 미룬다. @Transactional(propagation = Propagation.NOT_SUPPORTED) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onSessionCreated(SessionCreatedEvent event) { + questionsCallbackService.insertSelfIntroduction(event.sessionId()); + } + + // 자기소개 답변 직후: 그 답변 + 이력서/레포 컨텍스트로 질문 풀 생성을 요청한다. + // 자기소개가 첫 질문 1자리를 차지하므로, 풀은 generalCount-1 개만 생성한다(총 일반질문 수 보존). + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onSelfIntroAnswered(SelfIntroAnsweredEvent event) { List documents = buildDocumentContexts(event.contextDocumentIds()); int generalCount = event.generalQuestionCount() != null ? event.generalQuestionCount() : DEFAULT_GENERAL_QUESTION_COUNT; + int poolCount = Math.max(1, generalCount - 1); // 자기소개 1자리 예약 List recentQuestions = recentQuestions(event.userId(), event.sessionId()); GenerateQuestionsPayload payload = new GenerateQuestionsPayload( event.sessionId(), event.mode(), event.jobCategories(), documents, - generalCount, + poolCount, event.maxQuestions(), - recentQuestions + recentQuestions, + event.selfIntroAnswer() ); publisher.publishToAi( properties.routingKeys().generateQuestions(), payload, new MessageContext(event.userId(), event.sessionId(), null, null) ); - log.info("generate.questions published. sessionId={}, doc_count={}, general_count={}, max={}, recent_q={}", - event.sessionId(), documents.size(), generalCount, event.maxQuestions(), recentQuestions.size()); + log.info("generate.questions published (post self-intro). sessionId={}, doc_count={}, " + + "pool_count={}, max={}, recent_q={}, intro_len={}", + event.sessionId(), documents.size(), poolCount, event.maxQuestions(), recentQuestions.size(), + event.selfIntroAnswer() == null ? 0 : event.selfIntroAnswer().length()); } // 같은 유저의 최근 N개 세션에서 출제된 질문을 모아 AI 의 중복 회피용으로 전달. diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java index 7bba980c..432e3498 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java @@ -14,7 +14,9 @@ public record GenerateQuestionsPayload( // Overall session question limit; not the generation batch size. Integer maxQuestions, // 같은 유저가 최근 면접에서 받은 질문들. AI 가 의미 중복 회피에 사용. - List recentQuestions + List recentQuestions, + // 지원자의 자기소개 답변. 질문 생성의 1차 근거(모든 면접의 기본). 없으면 빈 문자열. + String selfIntroAnswer ) { public record DocumentContext( Long documentId, diff --git a/backend/src/main/java/com/stackup/stackup/session/application/event/SelfIntroAnsweredEvent.java b/backend/src/main/java/com/stackup/stackup/session/application/event/SelfIntroAnsweredEvent.java new file mode 100644 index 00000000..edbe6a23 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/event/SelfIntroAnsweredEvent.java @@ -0,0 +1,20 @@ +package com.stackup.stackup.session.application.event; + +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import java.util.List; + +// 자기소개 답변 commit 후 발행. 인프라스트럭처가 받아 generate.questions 메시지를 발행한다. +// 모든 면접의 첫 질문은 자기소개로 고정이며, 이력서/레포 기반 질문 풀은 이 자기소개 답변을 +// 씨앗으로 생성된다. SessionCreatedEvent 와 동일 필드 + selfIntroAnswer 를 실어 lazy 로딩을 피한다. +public record SelfIntroAnsweredEvent( + Long userId, + Long sessionId, + SessionMode mode, + List jobCategories, + Integer maxQuestions, + Integer generalQuestionCount, + List contextDocumentIds, + String selfIntroAnswer +) { +} 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 4831454a..1df8c4e1 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 @@ -38,6 +38,12 @@ public class InterviewMessage extends BaseTimeEntity { "음성 인식에 실패했습니다. 텍스트로 다시 답변해 주세요."; public static final String FOLLOWUP_GENERATING_TEXT = "(생성 중)"; + // 모든 면접의 첫 질문은 자기소개로 고정한다. 이력서/레포 기반 질문 풀은 이 답변을 + // 씨앗으로 자기소개 답변 이후에 생성된다(SessionQuestionsRequester). category 는 느슨 + // 결합(CHECK 없음)이라 sentinel 로 안전하게 마킹 — 이 값으로 첫 질문 분기를 식별한다. + public static final String SELF_INTRODUCTION_TEXT = "먼저 간단하게 자기소개 부탁드립니다."; + public static final String SELF_INTRODUCTION_CATEGORY = "SELF_INTRODUCTION"; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -144,6 +150,18 @@ public static InterviewMessage interviewer(InterviewSession session, int seq, St return m; } + // 자기소개 질문(모든 면접의 첫 질문, seq=1). AI 호출 없이 고정 문구로 삽입하고 TTS 만 요청한다. + public static InterviewMessage selfIntroduction(InterviewSession session, int seq) { + return interviewer(session, seq, SELF_INTRODUCTION_TEXT, + SELF_INTRODUCTION_CATEGORY, null, null); + } + + // 이 메시지가 자기소개 질문인지. 답변 시 꼬리질문 대신 질문 풀 생성을 트리거하는 분기에 사용. + public boolean isSelfIntroduction() { + return role == MessageRole.INTERVIEWER + && SELF_INTRODUCTION_CATEGORY.equals(category); + } + public static InterviewMessage followup(InterviewSession session, int seq, String content, InterviewMessage parent) { InterviewMessage m = new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, parent, 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 cdcf9699..5135dfec 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 @@ -95,6 +95,40 @@ void apply_poolCallbackSeedsPoolAndInsertsFirstQuestion() { assertThat(session.getTotalQuestionCount()).isEqualTo(1); } + @Test + void insertSelfIntroduction_insertsFixedFirstQuestionAndCounts() { + InterviewSession session = sessionFixture(11L, SessionStatus.READY); + when(sessionRepository.findById(11L)).thenReturn(Optional.of(session)); + when(messageRepository.countBySession_Id(11L)).thenReturn(0L); + when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> { + InterviewMessage m = inv.getArgument(0); + ReflectionTestUtils.setField(m, "id", 400L); + return m; + }); + + service.insertSelfIntroduction(11L); + + ArgumentCaptor cap = ArgumentCaptor.forClass(InterviewMessage.class); + verify(messageRepository).save(cap.capture()); + assertThat(cap.getValue().getContent()).isEqualTo(InterviewMessage.SELF_INTRODUCTION_TEXT); + assertThat(cap.getValue().getSequenceNumber()).isEqualTo(1); + assertThat(cap.getValue().isSelfIntroduction()).isTrue(); + // 자기소개는 첫 일반질문 1자리를 차지한다. + assertThat(session.getTotalQuestionCount()).isEqualTo(1); + } + + @Test + void insertSelfIntroduction_skipsWhenMessagesAlreadyExist() { + InterviewSession session = sessionFixture(11L, SessionStatus.READY); + when(sessionRepository.findById(11L)).thenReturn(Optional.of(session)); + when(messageRepository.countBySession_Id(11L)).thenReturn(1L); + + service.insertSelfIntroduction(11L); + + verify(messageRepository, never()).save(any(InterviewMessage.class)); + assertThat(session.getTotalQuestionCount()).isEqualTo(0); + } + @Test void apply_followupDoesNotCountOrAutoEnd() { InterviewSession session = sessionFixture(11L, SessionStatus.IN_PROGRESS); diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionFollowupRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionFollowupRequesterTest.java new file mode 100644 index 00000000..3c6eebbf --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionFollowupRequesterTest.java @@ -0,0 +1,77 @@ +package com.stackup.stackup.session.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.MessageContext; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.session.application.event.AnswerSubmittedEvent; +import com.stackup.stackup.session.application.event.SelfIntroAnsweredEvent; +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.JobCategory; +import com.stackup.stackup.session.domain.SessionContextRepository; +import com.stackup.stackup.session.domain.SessionMode; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class SessionFollowupRequesterTest { + + @Mock RabbitMessagePublisher publisher; + @Mock RabbitMqProperties properties; + @Mock InterviewMessageRepository messageRepository; + @Mock SessionContextRepository contextRepository; + @Mock QuestionsCallbackService questionsCallbackService; + @Mock ApplicationEventPublisher events; + @InjectMocks SessionFollowupRequester requester; + + // 자기소개(첫 질문) 답변이면 꼬리질문을 만들지 않고 SelfIntroAnsweredEvent 만 발행한다. + @Test + void onAnswerSubmitted_selfIntroAnswer_triggersPoolInsteadOfFollowup() { + InterviewSession session = sessionFixture(); + InterviewMessage selfIntro = InterviewMessage.selfIntroduction(session, 1); + ReflectionTestUtils.setField(selfIntro, "id", 100L); + InterviewMessage answer = InterviewMessage.interviewee(session, 2, "제 소개를 드리면…", selfIntro, null); + ReflectionTestUtils.setField(answer, "id", 101L); + + when(messageRepository.findById(100L)).thenReturn(Optional.of(selfIntro)); + when(messageRepository.findById(101L)).thenReturn(Optional.of(answer)); + when(contextRepository.findBySession_Id(11L)).thenReturn(List.of()); + + requester.onAnswerSubmitted(new AnswerSubmittedEvent(1L, 11L, 100L, 101L)); + + ArgumentCaptor ev = ArgumentCaptor.forClass(Object.class); + verify(events).publishEvent(ev.capture()); + assertThat(ev.getValue()).isInstanceOf(SelfIntroAnsweredEvent.class); + SelfIntroAnsweredEvent published = (SelfIntroAnsweredEvent) ev.getValue(); + assertThat(published.sessionId()).isEqualTo(11L); + assertThat(published.selfIntroAnswer()).isEqualTo("제 소개를 드리면…"); + assertThat(published.jobCategories()).containsExactly(JobCategory.BACKEND); + + // 꼬리질문 placeholder INSERT 도, generate.followup 발행도 일어나지 않는다. + verify(messageRepository, never()).save(any(InterviewMessage.class)); + verify(publisher, never()).publishToAi(any(), any(), any(MessageContext.class)); + } + + private InterviewSession sessionFixture() { + InterviewSession s = InterviewSession.create( + com.stackup.stackup.user.domain.User.createGithubUser(1L, "u", null, null, "t"), + "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, 3, 2); + ReflectionTestUtils.setField(s, "id", 11L); + return s; + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java index 3555879a..742adce9 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -12,6 +13,7 @@ import com.stackup.stackup.common.storage.ObjectStorageClient; import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload; +import com.stackup.stackup.session.application.event.SelfIntroAnsweredEvent; import com.stackup.stackup.session.application.event.SessionCreatedEvent; import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.JobCategory; @@ -36,20 +38,34 @@ class SessionQuestionsRequesterTest { @Mock ObjectStorageClient storage; @Mock InterviewSessionRepository sessionRepository; @Mock SessionQuestionPoolRepository questionPoolRepository; + @Mock QuestionsCallbackService questionsCallbackService; @InjectMocks SessionQuestionsRequester requester; + // 세션 생성 시엔 질문 풀을 만들지 않고, 자기소개 질문만 삽입한다(generate.questions 미발행). @Test - void onSessionCreated_requestsOneInitialQuestionAndKeepsSessionLimit() { + void onSessionCreated_insertsSelfIntroductionAndDoesNotPublish() { + requester.onSessionCreated(new SessionCreatedEvent( + 1L, 11L, SessionMode.INTEGRATED, List.of(JobCategory.BACKEND), 5, 3, List.of() + )); + + verify(questionsCallbackService).insertSelfIntroduction(11L); + verify(publisher, never()).publishToAi(any(), any(), any(MessageContext.class)); + } + + // 자기소개 답변 후: 답변을 씨앗으로 generate.questions 발행. 자기소개 1자리 예약 → 풀은 n-1 개. + @Test + void onSelfIntroAnswered_requestsPoolReservingSelfIntroSlot() { when(properties.routingKeys()).thenReturn(mockRoutingKeys()); - requester.onSessionCreated(new SessionCreatedEvent( + requester.onSelfIntroAnswered(new SelfIntroAnsweredEvent( 1L, 11L, SessionMode.INTEGRATED, List.of(JobCategory.BACKEND), 5, 3, - List.of() + List.of(), + "안녕하세요, 결제 시스템을 만든 백엔드 3년차입니다." )); ArgumentCaptor payloadCaptor = @@ -61,15 +77,15 @@ void onSessionCreated_requestsOneInitialQuestionAndKeepsSessionLimit() { assertThat(payload.mode()).isEqualTo(SessionMode.INTEGRATED); assertThat(payload.jobCategories()).containsExactly(JobCategory.BACKEND); assertThat(payload.documents()).isEmpty(); - // generalQuestionCount(n) 만큼 생성 요청 (이벤트의 n=3). - assertThat(payload.initialQuestionCount()).isEqualTo(3); + // generalQuestionCount(n=3) 에서 자기소개 1자리 예약 → 풀 2개. + assertThat(payload.initialQuestionCount()).isEqualTo(2); assertThat(payload.maxQuestions()).isEqualTo(5); - // dedup 비활성(기본 0) 이면 과거 질문 없이 빈 목록. assertThat(payload.recentQuestions()).isEmpty(); + assertThat(payload.selfIntroAnswer()).contains("백엔드 3년차"); } @Test - void onSessionCreated_includesRecentQuestionsForDedup() { + void onSelfIntroAnswered_includesRecentQuestionsForDedup() { when(properties.routingKeys()).thenReturn(mockRoutingKeys()); ReflectionTestUtils.setField(requester, "recentSessionCount", 3); ReflectionTestUtils.setField(requester, "maxRecentQuestions", 30); @@ -78,8 +94,8 @@ void onSessionCreated_includesRecentQuestionsForDedup() { when(questionPoolRepository.findRecentQuestions(eq(List.of(9L, 8L)), any(Pageable.class))) .thenReturn(List.of("이전 질문 A", "이전 질문 B")); - requester.onSessionCreated(new SessionCreatedEvent( - 1L, 11L, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 3, List.of() + requester.onSelfIntroAnswered(new SelfIntroAnsweredEvent( + 1L, 11L, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 3, List.of(), "자기소개" )); ArgumentCaptor payloadCaptor = diff --git a/docs/data-flow.md b/docs/data-flow.md index d080c7f8..667c7483 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -48,24 +48,24 @@ ## 3. 면접 세션 흐름 (US-13 ~ US-20) -### 3.1 세션 생성 단계 +### 3.1 세션 생성 단계 — 첫 질문은 자기소개 고정 + +> **모든 면접의 첫 질문은 자기소개로 고정**한다. 이력서/레포 기반 질문 풀은 세션 생성 시점이 +> 아니라 **자기소개 답변을 받은 뒤** 그 답변을 1차 근거로 생성한다 (실제 면접과 동일). ``` [사용자] 세션 설정 (모드/유형/직군/최대 질문 수/참조 문서) → [Core] interview_sessions INSERT (status=READY) → session_contexts INSERT (선택된 analyzed_documents 연결) - → [Core] RabbitMQ publish: stackup.core-to-ai / generate.questions - → [AI] RAG 검색 (pgvector 유사도, Core API 경유) → 컨텍스트 추출 - → [AI] Gemini 3.1 Pro → 질문 풀 생성 (10~15개) - → [AI] RabbitMQ publish: stackup.ai-to-core / callback.questions (kind=POOL) - → [Core] 질문 풀을 interview_sessions row 또는 별도 테이블에 저장 - (Redis 미사용 — 세션 단위 데이터이므로 PG로 충분) + → [Core] SessionCreatedEvent(AFTER_COMMIT) → 자기소개 질문(seq=1, category=SELF_INTRODUCTION) 고정 삽입 + (AI 호출 없음. TTS 만 요청. totalQuestionCount=1 — 자기소개가 첫 일반질문 1자리를 차지) + → [Core] QuestionPersistedEvent → 첫 질문(자기소개) push + TTS ``` ### 3.2 질문-답변 사이클 (반복) ``` -[Core] 영속 후 RealtimeNotifyPublisher.publishToSession → [RealTime] SSE/WS → 첫 질문 push +[Core] 영속 후 RealtimeNotifyPublisher.publishToSession → [RealTime] SSE/WS → 첫 질문(자기소개) push → [Frontend] 표시 + 마이크 활성화 → [사용자] 음성 답변 (Phase 2) 또는 텍스트 → [Frontend] (음성 모드) WebRTC stream → RealTime @@ -74,6 +74,13 @@ → [Frontend] (텍스트 모드) WS send {type:"answer", content} → [RealTime] → POST /api/internal/sessions/{id}/messages ㄴ 레거시 직접 경로: POST /api/sessions/{id}/messages (프론트 WS 전환 전까지 병행) → [Core] interview_messages INSERT (role=INTERVIEWEE, parent=직전 질문) + ── 직전 질문이 자기소개(SELF_INTRODUCTION)인 경우 ───────────────────────────── + → [Core] SelfIntroAnsweredEvent → RabbitMQ publish: stackup.core-to-ai / generate.questions + (payload.selfIntroAnswer = 자기소개 답변. 풀 크기 = generalQuestionCount-1 — 자기소개 1자리 예약. 꼬리질문 없음) + → [AI] RAG 검색 + Gemini 3.1 Pro → 자기소개 답변을 1차 근거로 질문 풀 생성 + → [AI] RabbitMQ publish: stackup.ai-to-core / callback.questions (kind=POOL) + → [Core] session_question_pool 저장 + 첫 일반질문 삽입 → 다음 질문 push + ── 그 외 일반/꼬리질문 답변인 경우 ─────────────────────────────────────────── → [Core] RabbitMQ publish: stackup.core-to-ai / generate.followup → [AI] 답변 평가 + 꼬리질문 생성 (Gemini 3.1 Flash + RAG) → 답변이 음성이면 음성 분석도 병행 diff --git a/docs/messaging.md b/docs/messaging.md index dddb11d8..3a1bac01 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -218,6 +218,11 @@ → `documentPath` 는 객체 스토리지 키 (bucket 제외). Core는 같은 storage 추상화로 fetch. ### 5.6 `generate.questions` + +> **발행 시점**: 세션 생성 시가 아니라 **자기소개(첫 질문) 답변을 받은 직후**(`SelfIntroAnsweredEvent`). +> 모든 면접의 첫 질문은 자기소개로 고정이며, 질문 풀은 그 답변(`selfIntroAnswer`)을 1차 근거로 생성한다. +> `initialQuestionCount` 는 자기소개 1자리를 예약해 `generalQuestionCount - 1` 로 보낸다. + ```json { "messageType": "generate.questions", @@ -225,9 +230,11 @@ "sessionId": 99, "mode": "TECHNICAL", "jobCategories": ["BACKEND", "FRONTEND"], - "documentIds": [42, 17], + "documents": [ { "documentId": 42, "sourceType": "RESUME", "summary": "...", "techStack": ["..."], "markdown": "..." } ], + "initialQuestionCount": 2, "maxQuestions": 10, - "recentQuestions": ["이전 면접 질문 텍스트", "..."] + "recentQuestions": ["이전 면접 질문 텍스트", "..."], + "selfIntroAnswer": "안녕하세요, 결제 시스템을 만든 백엔드 3년차입니다…" }, "context": { "userId": 123, "sessionId": 99 } } diff --git a/frontend/src/features/interview/lib/categoryLabel.ts b/frontend/src/features/interview/lib/categoryLabel.ts index de7f41d2..2338bcd1 100644 --- a/frontend/src/features/interview/lib/categoryLabel.ts +++ b/frontend/src/features/interview/lib/categoryLabel.ts @@ -1,5 +1,6 @@ // 면접 질문 카테고리 ENUM → 한국어 라벨. 라이브 스테이지와 채팅 버블이 공유한다. const CATEGORY_LABEL: Record = { + SELF_INTRODUCTION: '자기소개', CS_FUNDAMENTAL: 'CS 기초', PROJECT_DEEP_DIVE: '프로젝트 심화', TECH_CHOICE: '기술 선택',