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
7 changes: 6 additions & 1 deletion ai/src/ai_server/chain/prompts/question_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
)
9 changes: 9 additions & 0 deletions ai/src/ai_server/chain/question_generation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +32,7 @@ async def generate(
mode: str,
max_questions: int,
context: str,
recent_questions: list[str] | None = None,
) -> GeneratedQuestionPool: ...


Expand All @@ -40,13 +47,15 @@ async def generate(
mode: str,
max_questions: int,
context: str,
recent_questions: list[str] | None = None,
) -> GeneratedQuestionPool:
result = await self._chain.ainvoke(
{
"job_category": job_category,
"mode": mode,
"max_questions": max_questions,
"context": context,
"recent_questions": _format_recent_questions(recent_questions),
}
)
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 @@ -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(
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 @@ -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):
Expand Down
38 changes: 38 additions & 0 deletions ai/tests/test_question_generation_chain.py
Original file line number Diff line number Diff line change
@@ -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"] == "- 이전 질문"
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -44,28 +48,53 @@ 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)
public void onSessionCreated(SessionCreatedEvent event) {
List<DocumentContext> documents = buildDocumentContexts(event.contextDocumentIds());
int generalCount = event.generalQuestionCount() != null
? event.generalQuestionCount() : DEFAULT_GENERAL_QUESTION_COUNT;
List<String> 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<String> recentQuestions(Long userId, Long currentSessionId) {
if (recentSessionCount <= 0 || maxRecentQuestions <= 0) {
return List.of();
}
List<Long> 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<DocumentContext> buildDocumentContexts(List<Long> documentIds) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> recentQuestions
) {
public record DocumentContext(
Long documentId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<InterviewSession, Long> {

// 중복 질문 회피용: 유저의 최근 세션 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<Long> findRecentSessionIds(@Param("userId") Long userId,
@Param("excludeId") Long excludeId,
Pageable pageable);

List<InterviewSession> findByUser_Id(Long userId);

Optional<InterviewSession> findByIdAndUser_Id(Long id, Long userId);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
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<SessionQuestionPool, Long> {

// 다음에 쓸 일반질문(미사용 중 idx 최솟값).
Optional<SessionQuestionPool> 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<String> findRecentQuestions(@Param("sessionIds") List<Long> sessionIds, Pageable pageable);
}
6 changes: 6 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,19 @@
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;
import org.mockito.ArgumentCaptor;
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 {
Expand All @@ -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
Expand Down Expand Up @@ -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<GenerateQuestionsPayload> 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() {
Expand Down
3 changes: 2 additions & 1 deletion docs/messaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@
"mode": "TECHNICAL",
"jobCategory": "BACKEND",
"documentIds": [42, 17],
"maxQuestions": 10
"maxQuestions": 10,
"recentQuestions": ["이전 면접 질문 텍스트", "..."]
},
"context": { "userId": 123, "sessionId": 99 }
}
Expand Down