diff --git a/ai/src/ai_server/chain/prompts/question_generation.py b/ai/src/ai_server/chain/prompts/question_generation.py index d3f50d4c..17fe6adb 100644 --- a/ai/src/ai_server/chain/prompts/question_generation.py +++ b/ai/src/ai_server/chain/prompts/question_generation.py @@ -36,6 +36,11 @@ "CS_FUNDAMENTAL·BEHAVIORAL 은 선택(없으면 빈 문자열).\n" " - expected_signal: 좋은 답변이 드러내야 할 핵심(평가 관점). 한 문장.\n" "5. 지원자 컨텍스트의 기술/프로젝트를 구체적으로 언급하는 PROJECT_DEEP_DIVE / " - "TECH_CHOICE 질문을 우선합니다.\n\n" + "TECH_CHOICE 질문을 우선합니다.\n" + "6. 아래는 같은 지원자가 최근 면접에서 이미 받은 질문들입니다. 이와 " + "**의미상 중복되는 질문은 만들지 말고**, 새로운 주제나 다른 각도로 출제하세요:\n" + "---\n" + "{recent_questions}\n" + "---\n\n" "{format_instructions}" ) diff --git a/ai/src/ai_server/chain/question_generation_chain.py b/ai/src/ai_server/chain/question_generation_chain.py index 225ec806..73bfd905 100644 --- a/ai/src/ai_server/chain/question_generation_chain.py +++ b/ai/src/ai_server/chain/question_generation_chain.py @@ -18,6 +18,12 @@ class GeneratedQuestionPool(BaseModel): questions: list[GeneratedQuestion] = Field(default_factory=list) +def _format_recent_questions(recent_questions: list[str] | None) -> str: + if not recent_questions: + return "(없음)" + return "\n".join(f"- {q}" for q in recent_questions) + + class QuestionGenerator(Protocol): async def generate( self, @@ -26,6 +32,7 @@ async def generate( mode: str, max_questions: int, context: str, + recent_questions: list[str] | None = None, ) -> GeneratedQuestionPool: ... @@ -40,6 +47,7 @@ async def generate( mode: str, max_questions: int, context: str, + recent_questions: list[str] | None = None, ) -> GeneratedQuestionPool: result = await self._chain.ainvoke( { @@ -47,6 +55,7 @@ async def generate( "mode": mode, "max_questions": max_questions, "context": context, + "recent_questions": _format_recent_questions(recent_questions), } ) 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 b7a654ad..b0496886 100644 --- a/ai/src/ai_server/messaging/consumers/questions_consumer.py +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -90,6 +90,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None: mode=req.mode, max_questions=effective_pool_size, context=context_text, + recent_questions=req.recent_questions, ) payload = QuestionPoolCallbackPayload( diff --git a/ai/src/ai_server/model/messages/questions.py b/ai/src/ai_server/model/messages/questions.py index dcc14db5..07728e6d 100644 --- a/ai/src/ai_server/model/messages/questions.py +++ b/ai/src/ai_server/model/messages/questions.py @@ -36,6 +36,8 @@ class GenerateQuestionsRequest(BaseModel): documents: list[DocumentContext] = [] initial_question_count: int = 1 max_questions: int = 10 + # 같은 유저가 최근 면접에서 받은 질문들. 의미 중복 회피에 사용. + recent_questions: list[str] = [] class GeneratedQuestion(BaseModel): diff --git a/ai/tests/test_question_generation_chain.py b/ai/tests/test_question_generation_chain.py new file mode 100644 index 00000000..a253b193 --- /dev/null +++ b/ai/tests/test_question_generation_chain.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from ai_server.chain.question_generation_chain import ( + GeneratedQuestionPool, + LlmQuestionGenerator, + _format_recent_questions, +) + + +def test_format_recent_questions_empty(): + assert _format_recent_questions(None) == "(없음)" + assert _format_recent_questions([]) == "(없음)" + + +def test_format_recent_questions_bullets(): + assert _format_recent_questions(["질문 A", "질문 B"]) == "- 질문 A\n- 질문 B" + + +@pytest.mark.asyncio +async def test_generate_forwards_formatted_recent_questions_to_chain(): + chain = AsyncMock() + chain.ainvoke = AsyncMock(return_value=GeneratedQuestionPool(questions=[])) + generator = LlmQuestionGenerator(chain) + + await generator.generate( + job_category="BACKEND", + mode="TECHNICAL", + max_questions=3, + context="ctx", + recent_questions=["이전 질문"], + ) + + chain_input = chain.ainvoke.call_args.args[0] + assert chain_input["recent_questions"] == "- 이전 질문" 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 84744ce6..96654c80 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 @@ -14,6 +14,8 @@ import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload; import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload.DocumentContext; import com.stackup.stackup.session.application.event.SessionCreatedEvent; +import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.SessionQuestionPoolRepository; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -22,6 +24,8 @@ import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -44,6 +48,15 @@ public class SessionQuestionsRequester { private final RabbitMqProperties properties; private final AnalyzedDocumentRepository documentRepository; private final ObjectStorageClient storage; + private final InterviewSessionRepository sessionRepository; + private final SessionQuestionPoolRepository questionPoolRepository; + + // 최근 몇 개 세션까지 거슬러 중복 질문을 회피할지. 0 이면 비활성. + @Value("${interview.question-dedup.recent-sessions:3}") + private int recentSessionCount; + // AI 에 넘길 과거 질문 최대 개수 (envelope 비대화 방지). + @Value("${interview.question-dedup.max-questions:30}") + private int maxRecentQuestions; @Transactional(propagation = Propagation.NOT_SUPPORTED) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @@ -51,21 +64,37 @@ public void onSessionCreated(SessionCreatedEvent event) { List documents = buildDocumentContexts(event.contextDocumentIds()); int generalCount = event.generalQuestionCount() != null ? event.generalQuestionCount() : DEFAULT_GENERAL_QUESTION_COUNT; + List recentQuestions = recentQuestions(event.userId(), event.sessionId()); GenerateQuestionsPayload payload = new GenerateQuestionsPayload( event.sessionId(), event.mode(), event.jobCategory(), documents, generalCount, - event.maxQuestions() + event.maxQuestions(), + recentQuestions ); 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={}", - event.sessionId(), documents.size(), generalCount, event.maxQuestions()); + log.info("generate.questions published. sessionId={}, doc_count={}, general_count={}, max={}, recent_q={}", + event.sessionId(), documents.size(), generalCount, event.maxQuestions(), recentQuestions.size()); + } + + // 같은 유저의 최근 N개 세션에서 출제된 질문을 모아 AI 의 중복 회피용으로 전달. + private List recentQuestions(Long userId, Long currentSessionId) { + if (recentSessionCount <= 0 || maxRecentQuestions <= 0) { + return List.of(); + } + List sessionIds = sessionRepository.findRecentSessionIds( + userId, currentSessionId, PageRequest.of(0, recentSessionCount)); + if (sessionIds.isEmpty()) { + return List.of(); + } + return questionPoolRepository.findRecentQuestions( + sessionIds, PageRequest.of(0, maxRecentQuestions)); } private List buildDocumentContexts(List documentIds) { 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 25092e54..9ba2a617 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 @@ -12,7 +12,9 @@ public record GenerateQuestionsPayload( // Number of questions AI should generate for this initial callback. Integer initialQuestionCount, // Overall session question limit; not the generation batch size. - Integer maxQuestions + Integer maxQuestions, + // 같은 유저가 최근 면접에서 받은 질문들. AI 가 의미 중복 회피에 사용. + List recentQuestions ) { public record DocumentContext( Long documentId, diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java index 2f6f02c8..8d725911 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java @@ -5,9 +5,19 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface InterviewSessionRepository extends JpaRepository { + // 중복 질문 회피용: 유저의 최근 세션 id (현재 세션 제외, 최신순). + @Query("select s.id from InterviewSession s " + + "where s.user.id = :userId and s.id <> :excludeId and s.deleted = false " + + "order by s.createdAt desc") + List findRecentSessionIds(@Param("userId") Long userId, + @Param("excludeId") Long excludeId, + Pageable pageable); + List findByUser_Id(Long userId); Optional findByIdAndUser_Id(Long id, Long userId); diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionQuestionPoolRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionQuestionPoolRepository.java index c9d122f9..b4f62927 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/SessionQuestionPoolRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionQuestionPoolRepository.java @@ -1,7 +1,11 @@ package com.stackup.stackup.session.domain; +import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface SessionQuestionPoolRepository extends JpaRepository { @@ -9,4 +13,9 @@ public interface SessionQuestionPoolRepository extends JpaRepository findFirstBySessionIdAndUsedFalseOrderByIdxAsc(Long sessionId); long countBySessionId(Long sessionId); + + // 중복 질문 회피용: 주어진 세션들에서 출제된 질문 텍스트(최신순). + @Query("select p.question from SessionQuestionPool p " + + "where p.sessionId in :sessionIds order by p.createdAt desc") + List findRecentQuestions(@Param("sessionIds") List sessionIds, Pageable pageable); } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index cb48ce3c..f5692372 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -132,3 +132,9 @@ app: stream-token-ttl: 60s cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:https://your-domain} + +interview: + # 질문 풀 생성 시 최근 N개 세션의 질문을 AI 에 전달해 중복 회피. 0 이면 비활성. + question-dedup: + recent-sessions: ${INTERVIEW_DEDUP_RECENT_SESSIONS:3} + max-questions: ${INTERVIEW_DEDUP_MAX_QUESTIONS:30} 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 dfcc01ba..21b63ebf 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 @@ -13,8 +13,10 @@ import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload; import com.stackup.stackup.session.application.event.SessionCreatedEvent; +import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.JobCategory; import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.SessionQuestionPoolRepository; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -22,6 +24,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class SessionQuestionsRequesterTest { @@ -30,6 +34,8 @@ class SessionQuestionsRequesterTest { @Mock RabbitMqProperties properties; @Mock AnalyzedDocumentRepository documentRepository; @Mock ObjectStorageClient storage; + @Mock InterviewSessionRepository sessionRepository; + @Mock SessionQuestionPoolRepository questionPoolRepository; @InjectMocks SessionQuestionsRequester requester; @Test @@ -58,6 +64,29 @@ void onSessionCreated_requestsOneInitialQuestionAndKeepsSessionLimit() { // generalQuestionCount(n) 만큼 생성 요청 (이벤트의 n=3). assertThat(payload.initialQuestionCount()).isEqualTo(3); assertThat(payload.maxQuestions()).isEqualTo(5); + // dedup 비활성(기본 0) 이면 과거 질문 없이 빈 목록. + assertThat(payload.recentQuestions()).isEmpty(); + } + + @Test + void onSessionCreated_includesRecentQuestionsForDedup() { + when(properties.routingKeys()).thenReturn(mockRoutingKeys()); + ReflectionTestUtils.setField(requester, "recentSessionCount", 3); + ReflectionTestUtils.setField(requester, "maxRecentQuestions", 30); + when(sessionRepository.findRecentSessionIds(eq(1L), eq(11L), any(Pageable.class))) + .thenReturn(List.of(9L, 8L)); + when(questionPoolRepository.findRecentQuestions(eq(List.of(9L, 8L)), any(Pageable.class))) + .thenReturn(List.of("이전 질문 A", "이전 질문 B")); + + requester.onSessionCreated(new SessionCreatedEvent( + 1L, 11L, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 3, List.of() + )); + + ArgumentCaptor payloadCaptor = + ArgumentCaptor.forClass(GenerateQuestionsPayload.class); + verify(publisher).publishToAi(eq("generate.questions"), payloadCaptor.capture(), any(MessageContext.class)); + assertThat(payloadCaptor.getValue().recentQuestions()) + .containsExactly("이전 질문 A", "이전 질문 B"); } private RabbitMqProperties.RoutingKeyProperties mockRoutingKeys() { diff --git a/docs/messaging.md b/docs/messaging.md index b552e8a2..7f75df2a 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -226,7 +226,8 @@ "mode": "TECHNICAL", "jobCategory": "BACKEND", "documentIds": [42, 17], - "maxQuestions": 10 + "maxQuestions": 10, + "recentQuestions": ["이전 면접 질문 텍스트", "..."] }, "context": { "userId": 123, "sessionId": 99 } }