Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 출력을 `<intent>…</intent><question>…</question><meta>{json}</meta>` 구분자 포맷으로 바꾸고(`chain/prompts/followup_generation.py`), `StreamingFollowupGenerator`(`astream`)가 `<question>` 토큰만 `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`).
Expand Down
13 changes: 11 additions & 2 deletions ai/src/ai_server/chain/prompts/question_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

SYSTEM_PROMPT = (
"당신은 IT 직군 채용을 진행하는 시니어 면접관입니다. "
"지원자 컨텍스트(이력서, GitHub 레포 분석)와 면접 모드에 맞춰 면접 질문 풀을 "
"생성하세요.\n"
"지원자의 **자기소개 답변**을 1차 근거로, 이력서·GitHub 레포 분석 컨텍스트와 "
"면접 모드에 맞춰 면접 질문 풀을 생성하세요.\n"
"- 실제 면접처럼, 자기소개에서 지원자가 직접 강조한 경험·역량·관심사를 우선 파고듭니다. "
"자기소개에서 언급된 키워드를 이력서/레포 자료와 교차로 연결해 구체적으로 질문하세요.\n"
"- 사실에 근거한 질문만. 자료에 없는 내용은 추측하지 않습니다.\n"
"- 자료에 명시된 기술 스택과 프로젝트 경험을 적극 인용하세요.\n"
"- 카테고리는 다음 중에서 선택: CS_FUNDAMENTAL, PROJECT_DEEP_DIVE, TECH_CHOICE, "
Expand All @@ -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}) 중 "
Expand Down
8 changes: 8 additions & 0 deletions ai/src/ai_server/chain/question_generation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,6 +38,7 @@ async def generate(
max_questions: int,
context: str,
recent_questions: list[str] | None = None,
self_introduction: str | None = None,
) -> GeneratedQuestionPool: ...


Expand All @@ -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(
{
Expand All @@ -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):
Expand Down
1 change: 1 addition & 0 deletions ai/src/ai_server/messaging/consumers/questions_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions ai/src/ai_server/model/messages/questions.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ class GenerateQuestionsRequest(BaseModel):
max_questions: int = 10
# 같은 유저가 최근 면접에서 받은 질문들. 의미 중복 회피에 사용.
recent_questions: list[str] = []
# 지원자의 자기소개 답변. 모든 면접의 첫 질문이며 질문 생성의 1차 근거. 없으면 빈 문자열.
self_intro_answer: str = ""


class GeneratedQuestion(BaseModel):
Expand Down
30 changes: 30 additions & 0 deletions ai/tests/test_question_generation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
GeneratedQuestionPool,
LlmQuestionGenerator,
_format_recent_questions,
_format_self_introduction,
)


Expand All @@ -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()
Expand All @@ -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"] == "(자기소개 없음)"
30 changes: 30 additions & 0 deletions ai/tests/test_questions_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 6 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 폴백.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<GeneratedQuestion> questions = payload.questions();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<InterviewMessage> ordered =
messageRepository.findBySession_IdOrderBySequenceNumberAsc(session.getId());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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}")
Expand All @@ -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<DocumentContext> documents = buildDocumentContexts(event.contextDocumentIds());
int generalCount = event.generalQuestionCount() != null
? event.generalQuestionCount() : DEFAULT_GENERAL_QUESTION_COUNT;
int poolCount = Math.max(1, generalCount - 1); // 자기소개 1자리 예약
List<String> 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 의 중복 회피용으로 전달.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ public record GenerateQuestionsPayload(
// Overall session question limit; not the generation batch size.
Integer maxQuestions,
// 같은 유저가 최근 면접에서 받은 질문들. AI 가 의미 중복 회피에 사용.
List<String> recentQuestions
List<String> recentQuestions,
// 지원자의 자기소개 답변. 질문 생성의 1차 근거(모든 면접의 기본). 없으면 빈 문자열.
String selfIntroAnswer
) {
public record DocumentContext(
Long documentId,
Expand Down
Loading