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