Skip to content

Commit d936c72

Browse files
i3monthsclaude
andcommitted
feat(interview): 최근 N개 세션 질문 전달해 중복 질문 회피
여러 번 면접 시 같은 질문이 반복되는 문제를 줄이기 위해, 질문 풀 생성 요청에 같은 유저의 최근 세션 질문을 동봉하고 AI 프롬프트가 의미 중복을 피하도록 한다. 범위는 최근 N개 세션(기본 3, interview.question-dedup.*). - Core: InterviewSessionRepository/SessionQuestionPoolRepository 조회 추가, SessionQuestionsRequester 가 recentQuestions 를 payload 에 동봉. - AI: GenerateQuestionsRequest.recent_questions + 프롬프트 회피 지시. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6669609 commit d936c72

12 files changed

Lines changed: 147 additions & 6 deletions

File tree

ai/src/ai_server/chain/prompts/question_generation.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@
3636
"CS_FUNDAMENTAL·BEHAVIORAL 은 선택(없으면 빈 문자열).\n"
3737
" - expected_signal: 좋은 답변이 드러내야 할 핵심(평가 관점). 한 문장.\n"
3838
"5. 지원자 컨텍스트의 기술/프로젝트를 구체적으로 언급하는 PROJECT_DEEP_DIVE / "
39-
"TECH_CHOICE 질문을 우선합니다.\n\n"
39+
"TECH_CHOICE 질문을 우선합니다.\n"
40+
"6. 아래는 같은 지원자가 최근 면접에서 이미 받은 질문들입니다. 이와 "
41+
"**의미상 중복되는 질문은 만들지 말고**, 새로운 주제나 다른 각도로 출제하세요:\n"
42+
"---\n"
43+
"{recent_questions}\n"
44+
"---\n\n"
4045
"{format_instructions}"
4146
)

ai/src/ai_server/chain/question_generation_chain.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ class GeneratedQuestionPool(BaseModel):
1818
questions: list[GeneratedQuestion] = Field(default_factory=list)
1919

2020

21+
def _format_recent_questions(recent_questions: list[str] | None) -> str:
22+
if not recent_questions:
23+
return "(없음)"
24+
return "\n".join(f"- {q}" for q in recent_questions)
25+
26+
2127
class QuestionGenerator(Protocol):
2228
async def generate(
2329
self,
@@ -26,6 +32,7 @@ async def generate(
2632
mode: str,
2733
max_questions: int,
2834
context: str,
35+
recent_questions: list[str] | None = None,
2936
) -> GeneratedQuestionPool: ...
3037

3138

@@ -40,13 +47,15 @@ async def generate(
4047
mode: str,
4148
max_questions: int,
4249
context: str,
50+
recent_questions: list[str] | None = None,
4351
) -> GeneratedQuestionPool:
4452
result = await self._chain.ainvoke(
4553
{
4654
"job_category": job_category,
4755
"mode": mode,
4856
"max_questions": max_questions,
4957
"context": context,
58+
"recent_questions": _format_recent_questions(recent_questions),
5059
}
5160
)
5261
if not isinstance(result, GeneratedQuestionPool):

ai/src/ai_server/messaging/consumers/questions_consumer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
9090
mode=req.mode,
9191
max_questions=effective_pool_size,
9292
context=context_text,
93+
recent_questions=req.recent_questions,
9394
)
9495

9596
payload = QuestionPoolCallbackPayload(

ai/src/ai_server/model/messages/questions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ class GenerateQuestionsRequest(BaseModel):
3636
documents: list[DocumentContext] = []
3737
initial_question_count: int = 1
3838
max_questions: int = 10
39+
# 같은 유저가 최근 면접에서 받은 질문들. 의미 중복 회피에 사용.
40+
recent_questions: list[str] = []
3941

4042

4143
class GeneratedQuestion(BaseModel):
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from __future__ import annotations
2+
3+
from unittest.mock import AsyncMock
4+
5+
import pytest
6+
7+
from ai_server.chain.question_generation_chain import (
8+
GeneratedQuestionPool,
9+
LlmQuestionGenerator,
10+
_format_recent_questions,
11+
)
12+
13+
14+
def test_format_recent_questions_empty():
15+
assert _format_recent_questions(None) == "(없음)"
16+
assert _format_recent_questions([]) == "(없음)"
17+
18+
19+
def test_format_recent_questions_bullets():
20+
assert _format_recent_questions(["질문 A", "질문 B"]) == "- 질문 A\n- 질문 B"
21+
22+
23+
@pytest.mark.asyncio
24+
async def test_generate_forwards_formatted_recent_questions_to_chain():
25+
chain = AsyncMock()
26+
chain.ainvoke = AsyncMock(return_value=GeneratedQuestionPool(questions=[]))
27+
generator = LlmQuestionGenerator(chain)
28+
29+
await generator.generate(
30+
job_category="BACKEND",
31+
mode="TECHNICAL",
32+
max_questions=3,
33+
context="ctx",
34+
recent_questions=["이전 질문"],
35+
)
36+
37+
chain_input = chain.ainvoke.call_args.args[0]
38+
assert chain_input["recent_questions"] == "- 이전 질문"

backend/src/main/java/com/stackup/stackup/session/application/SessionQuestionsRequester.java

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload;
1515
import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload.DocumentContext;
1616
import com.stackup.stackup.session.application.event.SessionCreatedEvent;
17+
import com.stackup.stackup.session.domain.InterviewSessionRepository;
18+
import com.stackup.stackup.session.domain.SessionQuestionPoolRepository;
1719
import java.io.IOException;
1820
import java.io.InputStream;
1921
import java.nio.charset.StandardCharsets;
@@ -22,6 +24,8 @@
2224
import lombok.RequiredArgsConstructor;
2325
import org.slf4j.Logger;
2426
import org.slf4j.LoggerFactory;
27+
import org.springframework.beans.factory.annotation.Value;
28+
import org.springframework.data.domain.PageRequest;
2529
import org.springframework.stereotype.Component;
2630
import org.springframework.transaction.annotation.Propagation;
2731
import org.springframework.transaction.annotation.Transactional;
@@ -44,28 +48,53 @@ public class SessionQuestionsRequester {
4448
private final RabbitMqProperties properties;
4549
private final AnalyzedDocumentRepository documentRepository;
4650
private final ObjectStorageClient storage;
51+
private final InterviewSessionRepository sessionRepository;
52+
private final SessionQuestionPoolRepository questionPoolRepository;
53+
54+
// 최근 몇 개 세션까지 거슬러 중복 질문을 회피할지. 0 이면 비활성.
55+
@Value("${interview.question-dedup.recent-sessions:3}")
56+
private int recentSessionCount;
57+
// AI 에 넘길 과거 질문 최대 개수 (envelope 비대화 방지).
58+
@Value("${interview.question-dedup.max-questions:30}")
59+
private int maxRecentQuestions;
4760

4861
@Transactional(propagation = Propagation.NOT_SUPPORTED)
4962
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
5063
public void onSessionCreated(SessionCreatedEvent event) {
5164
List<DocumentContext> documents = buildDocumentContexts(event.contextDocumentIds());
5265
int generalCount = event.generalQuestionCount() != null
5366
? event.generalQuestionCount() : DEFAULT_GENERAL_QUESTION_COUNT;
67+
List<String> recentQuestions = recentQuestions(event.userId(), event.sessionId());
5468
GenerateQuestionsPayload payload = new GenerateQuestionsPayload(
5569
event.sessionId(),
5670
event.mode(),
5771
event.jobCategory(),
5872
documents,
5973
generalCount,
60-
event.maxQuestions()
74+
event.maxQuestions(),
75+
recentQuestions
6176
);
6277
publisher.publishToAi(
6378
properties.routingKeys().generateQuestions(),
6479
payload,
6580
new MessageContext(event.userId(), event.sessionId(), null, null)
6681
);
67-
log.info("generate.questions published. sessionId={}, doc_count={}, general_count={}, max={}",
68-
event.sessionId(), documents.size(), generalCount, event.maxQuestions());
82+
log.info("generate.questions published. sessionId={}, doc_count={}, general_count={}, max={}, recent_q={}",
83+
event.sessionId(), documents.size(), generalCount, event.maxQuestions(), recentQuestions.size());
84+
}
85+
86+
// 같은 유저의 최근 N개 세션에서 출제된 질문을 모아 AI 의 중복 회피용으로 전달.
87+
private List<String> recentQuestions(Long userId, Long currentSessionId) {
88+
if (recentSessionCount <= 0 || maxRecentQuestions <= 0) {
89+
return List.of();
90+
}
91+
List<Long> sessionIds = sessionRepository.findRecentSessionIds(
92+
userId, currentSessionId, PageRequest.of(0, recentSessionCount));
93+
if (sessionIds.isEmpty()) {
94+
return List.of();
95+
}
96+
return questionPoolRepository.findRecentQuestions(
97+
sessionIds, PageRequest.of(0, maxRecentQuestions));
6998
}
7099

71100
private List<DocumentContext> buildDocumentContexts(List<Long> documentIds) {

backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateQuestionsPayload.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ public record GenerateQuestionsPayload(
1212
// Number of questions AI should generate for this initial callback.
1313
Integer initialQuestionCount,
1414
// Overall session question limit; not the generation batch size.
15-
Integer maxQuestions
15+
Integer maxQuestions,
16+
// 같은 유저가 최근 면접에서 받은 질문들. AI 가 의미 중복 회피에 사용.
17+
List<String> recentQuestions
1618
) {
1719
public record DocumentContext(
1820
Long documentId,

backend/src/main/java/com/stackup/stackup/session/domain/InterviewSessionRepository.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,19 @@
55
import org.springframework.data.domain.Page;
66
import org.springframework.data.domain.Pageable;
77
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Query;
9+
import org.springframework.data.repository.query.Param;
810

911
public interface InterviewSessionRepository extends JpaRepository<InterviewSession, Long> {
1012

13+
// 중복 질문 회피용: 유저의 최근 세션 id (현재 세션 제외, 최신순).
14+
@Query("select s.id from InterviewSession s "
15+
+ "where s.user.id = :userId and s.id <> :excludeId and s.deleted = false "
16+
+ "order by s.createdAt desc")
17+
List<Long> findRecentSessionIds(@Param("userId") Long userId,
18+
@Param("excludeId") Long excludeId,
19+
Pageable pageable);
20+
1121
List<InterviewSession> findByUser_Id(Long userId);
1222

1323
Optional<InterviewSession> findByIdAndUser_Id(Long id, Long userId);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
package com.stackup.stackup.session.domain;
22

3+
import java.util.List;
34
import java.util.Optional;
5+
import org.springframework.data.domain.Pageable;
46
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
8+
import org.springframework.data.repository.query.Param;
59

610
public interface SessionQuestionPoolRepository extends JpaRepository<SessionQuestionPool, Long> {
711

812
// 다음에 쓸 일반질문(미사용 중 idx 최솟값).
913
Optional<SessionQuestionPool> findFirstBySessionIdAndUsedFalseOrderByIdxAsc(Long sessionId);
1014

1115
long countBySessionId(Long sessionId);
16+
17+
// 중복 질문 회피용: 주어진 세션들에서 출제된 질문 텍스트(최신순).
18+
@Query("select p.question from SessionQuestionPool p "
19+
+ "where p.sessionId in :sessionIds order by p.createdAt desc")
20+
List<String> findRecentQuestions(@Param("sessionIds") List<Long> sessionIds, Pageable pageable);
1221
}

backend/src/main/resources/application.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,9 @@ app:
132132
stream-token-ttl: 60s
133133
cors:
134134
allowed-origins: ${CORS_ALLOWED_ORIGINS:https://your-domain}
135+
136+
interview:
137+
# 질문 풀 생성 시 최근 N개 세션의 질문을 AI 에 전달해 중복 회피. 0 이면 비활성.
138+
question-dedup:
139+
recent-sessions: ${INTERVIEW_DEDUP_RECENT_SESSIONS:3}
140+
max-questions: ${INTERVIEW_DEDUP_MAX_QUESTIONS:30}

0 commit comments

Comments
 (0)