Skip to content

Commit ce3def1

Browse files
authored
Merge pull request #94 from Team-StackUp/feature/interview-multi-job-category
feat(interview): 직군 다중 선택 지원
2 parents 67b7834 + 855a24f commit ce3def1

29 files changed

Lines changed: 216 additions & 64 deletions

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
)
1515

1616
HUMAN_PROMPT = (
17-
"직군: {job_category}\n"
17+
"직군(복수 가능): {job_categories}\n"
1818
"면접 모드: {mode}\n"
1919
"최대 질문 수: {max_questions}\n\n"
2020
"지원자 컨텍스트 (이력서/레포 분석):\n"
@@ -25,10 +25,11 @@
2525
"1. 정확히 {max_questions}개의 질문을 생성하되, **서로 다른 주제/영역**을 다룹니다 "
2626
"(각 질문이 일반질문 1개의 씨앗이 되고 뒤에 꼬리질문이 붙으므로 같은 주제 반복 금지).\n"
2727
"2. 각 질문에 적절한 category 를 부여합니다.\n"
28-
"3. 직군({job_category})·면접 모드({mode}) 에 맞는 비중으로 카테고리 분배:\n"
28+
"3. 직군({job_categories})·면접 모드({mode}) 에 맞는 비중으로 카테고리 분배:\n"
2929
" - TECHNICAL: CS_FUNDAMENTAL + TECH_CHOICE + PROJECT_DEEP_DIVE 중심.\n"
3030
" - PERSONALITY: BEHAVIORAL 중심.\n"
3131
" - INTEGRATED: 모두 균형.\n"
32+
" 직군이 여러 개면 각 직군에 질문이 고르게 분배되도록 합니다.\n"
3233
" 자료가 받쳐주는 만큼만 분배하고, 근거 없는 카테고리를 억지로 채우지 마세요.\n"
3334
"4. 각 질문에 target_evidence 와 expected_signal 을 채웁니다:\n"
3435
" - target_evidence: 질문의 근거가 된 컨텍스트 원문을 짧게 인용. "

ai/src/ai_server/chain/question_generation_chain.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class QuestionGenerator(Protocol):
2828
async def generate(
2929
self,
3030
*,
31-
job_category: str,
31+
job_categories: list[str],
3232
mode: str,
3333
max_questions: int,
3434
context: str,
@@ -43,15 +43,15 @@ def __init__(self, chain: Runnable) -> None:
4343
async def generate(
4444
self,
4545
*,
46-
job_category: str,
46+
job_categories: list[str],
4747
mode: str,
4848
max_questions: int,
4949
context: str,
5050
recent_questions: list[str] | None = None,
5151
) -> GeneratedQuestionPool:
5252
result = await self._chain.ainvoke(
5353
{
54-
"job_category": job_category,
54+
"job_categories": ", ".join(job_categories),
5555
"mode": mode,
5656
"max_questions": max_questions,
5757
"context": context,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
8686

8787
context_text = await self._build_context(req)
8888
pool = await self._generator.generate(
89-
job_category=req.job_category,
89+
job_categories=req.job_categories,
9090
mode=req.mode,
9191
max_questions=effective_pool_size,
9292
context=context_text,
@@ -173,7 +173,7 @@ def _build_context(documents: list[DocumentContext]) -> str:
173173
def _build_initial_rag_query(req: GenerateQuestionsRequest) -> str:
174174
parts = [
175175
f"mode: {req.mode}",
176-
f"job category: {req.job_category}",
176+
f"job categories: {', '.join(req.job_categories)}",
177177
]
178178
for d in req.documents:
179179
doc_parts = [f"document #{d.document_id} {d.source_type}"]

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ class GenerateQuestionsRequest(BaseModel):
3232

3333
session_id: int
3434
mode: InterviewMode
35-
job_category: JobCategory
35+
# 직군 다중 선택. 첫 항목이 대표 직군.
36+
job_categories: list[JobCategory] = []
3637
documents: list[DocumentContext] = []
3738
initial_question_count: int = 1
3839
max_questions: int = 10

ai/tests/test_question_generation_chain.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ async def test_generate_forwards_formatted_recent_questions_to_chain():
2727
generator = LlmQuestionGenerator(chain)
2828

2929
await generator.generate(
30-
job_category="BACKEND",
30+
job_categories=["BACKEND", "FRONTEND"],
3131
mode="TECHNICAL",
3232
max_questions=3,
3333
context="ctx",
@@ -36,3 +36,4 @@ async def test_generate_forwards_formatted_recent_questions_to_chain():
3636

3737
chain_input = chain.ainvoke.call_args.args[0]
3838
assert chain_input["recent_questions"] == "- 이전 질문"
39+
assert chain_input["job_categories"] == "BACKEND, FRONTEND"

ai/tests/test_questions_consumer.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ async def test_consumer_generates_questions_and_publishes_callback():
7979
{
8080
"sessionId": 99,
8181
"mode": "TECHNICAL",
82-
"jobCategory": "BACKEND",
82+
"jobCategories": ["BACKEND"],
8383
"documents": [
8484
{
8585
"documentId": 1,
@@ -97,7 +97,7 @@ async def test_consumer_generates_questions_and_publishes_callback():
9797

9898
generator.generate.assert_awaited_once()
9999
call = generator.generate.await_args
100-
assert call.kwargs["job_category"] == "BACKEND"
100+
assert call.kwargs["job_categories"] == ["BACKEND"]
101101
assert call.kwargs["mode"] == "TECHNICAL"
102102
# maxQuestions is the session limit; initialQuestionCount controls this result.
103103
assert call.kwargs["max_questions"] == 2
@@ -133,7 +133,7 @@ async def test_consumer_skips_when_message_id_already_seen():
133133
{
134134
"sessionId": 99,
135135
"mode": "TECHNICAL",
136-
"jobCategory": "BACKEND",
136+
"jobCategories": ["BACKEND"],
137137
"documents": [],
138138
"maxQuestions": 3,
139139
}
@@ -161,7 +161,7 @@ async def test_consumer_defaults_initial_question_count_to_one():
161161
{
162162
"sessionId": 99,
163163
"mode": "TECHNICAL",
164-
"jobCategory": "BACKEND",
164+
"jobCategories": ["BACKEND"],
165165
"documents": [],
166166
"maxQuestions": 5,
167167
}
@@ -189,7 +189,7 @@ async def test_consumer_clamps_initial_question_count_to_at_least_one():
189189
{
190190
"sessionId": 99,
191191
"mode": "TECHNICAL",
192-
"jobCategory": "BACKEND",
192+
"jobCategories": ["BACKEND"],
193193
"documents": [],
194194
"initialQuestionCount": 0,
195195
"maxQuestions": 5,
@@ -233,7 +233,7 @@ async def test_consumer_injects_initial_rag_chunks_when_available():
233233
{
234234
"sessionId": 99,
235235
"mode": "TECHNICAL",
236-
"jobCategory": "BACKEND",
236+
"jobCategories": ["BACKEND"],
237237
"documents": [
238238
{
239239
"documentId": 1,
@@ -292,7 +292,7 @@ async def test_single_document_skips_rag_plan_mode():
292292
{
293293
"sessionId": 1,
294294
"mode": "TECHNICAL",
295-
"jobCategory": "BACKEND",
295+
"jobCategories": ["BACKEND"],
296296
"documents": [
297297
{
298298
"documentId": 1,
@@ -336,7 +336,7 @@ async def test_consumer_falls_back_to_document_context_when_rag_fails():
336336
{
337337
"sessionId": 99,
338338
"mode": "TECHNICAL",
339-
"jobCategory": "BACKEND",
339+
"jobCategories": ["BACKEND"],
340340
"documents": [
341341
{
342342
"documentId": 1,

backend/openapi.json

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2515,9 +2515,13 @@
25152515
"type" : "string",
25162516
"enum" : [ "TECHNICAL", "PERSONALITY", "INTEGRATED" ]
25172517
},
2518-
"jobCategory" : {
2519-
"type" : "string",
2520-
"enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ]
2518+
"jobCategories" : {
2519+
"type" : "array",
2520+
"items" : {
2521+
"type" : "string",
2522+
"enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ]
2523+
},
2524+
"minItems" : 1
25212525
},
25222526
"maxQuestions" : {
25232527
"type" : "integer",
@@ -2549,7 +2553,7 @@
25492553
}
25502554
}
25512555
},
2552-
"required" : [ "jobCategory", "mode" ]
2556+
"required" : [ "jobCategories", "mode" ]
25532557
},
25542558
"SessionResponse" : {
25552559
"type" : "object",
@@ -2572,6 +2576,13 @@
25722576
"type" : "string",
25732577
"enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ]
25742578
},
2579+
"jobCategories" : {
2580+
"type" : "array",
2581+
"items" : {
2582+
"type" : "string",
2583+
"enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ]
2584+
}
2585+
},
25752586
"maxQuestions" : {
25762587
"type" : "integer",
25772588
"format" : "int32"

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public void onSessionCreated(SessionCreatedEvent event) {
6868
GenerateQuestionsPayload payload = new GenerateQuestionsPayload(
6969
event.sessionId(),
7070
event.mode(),
71-
event.jobCategory(),
71+
event.jobCategories(),
7272
documents,
7373
generalCount,
7474
event.maxQuestions(),

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212
import com.stackup.stackup.session.application.event.SessionEndedEvent;
1313
import com.stackup.stackup.session.domain.InterviewSession;
1414
import com.stackup.stackup.session.domain.InterviewSessionRepository;
15+
import com.stackup.stackup.session.domain.JobCategory;
1516
import com.stackup.stackup.session.domain.SessionContext;
1617
import com.stackup.stackup.session.domain.SessionContextRepository;
1718
import com.stackup.stackup.user.domain.User;
1819
import com.stackup.stackup.user.domain.UserRepository;
1920
import java.util.ArrayList;
2021
import java.util.LinkedHashSet;
2122
import java.util.List;
23+
import java.util.stream.Collectors;
2224
import lombok.RequiredArgsConstructor;
2325
import org.springframework.context.ApplicationEventPublisher;
2426
import org.springframework.data.domain.Page;
@@ -49,7 +51,7 @@ public SessionResult create(Long userId, SessionCreateCommand command) {
4951
title,
5052
command.memo(),
5153
command.mode(),
52-
command.jobCategory(),
54+
command.jobCategories(),
5355
command.maxQuestions(),
5456
command.maxDurationMinutes(),
5557
command.generalQuestionCount(),
@@ -63,7 +65,7 @@ public SessionResult create(Long userId, SessionCreateCommand command) {
6365
userId,
6466
session.getId(),
6567
session.getMode(),
66-
session.getJobCategory(),
68+
command.jobCategories(),
6769
session.getMaxQuestions(),
6870
session.getGeneralQuestionCount(),
6971
linkedIds
@@ -146,14 +148,17 @@ public SessionResult updateMeta(Long userId, Long sessionId, String title, Strin
146148
return SessionResult.of(session, contextDocumentIds(sessionId));
147149
}
148150

149-
// 제목 미입력 시 모드+직군으로 규칙 기반 제목 생성 (예: "백엔드 기술 면접").
151+
// 제목 미입력 시 모드+직군으로 규칙 기반 제목 생성 (예: "프론트엔드·백엔드 기술 면접").
150152
// 히스토리에서 "면접 #id" 대신 의미 있는 제목을 보이게 한다.
151153
private String resolveTitle(SessionCreateCommand command) {
152154
String title = command.title();
153155
if (title != null && !title.isBlank()) {
154156
return title;
155157
}
156-
return command.jobCategory().koreanLabel() + " " + command.mode().koreanLabel();
158+
String jobs = command.jobCategories().stream()
159+
.map(JobCategory::koreanLabel)
160+
.collect(Collectors.joining("·"));
161+
return jobs + " " + command.mode().koreanLabel();
157162
}
158163

159164
private User loadUser(Long userId) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
public record GenerateQuestionsPayload(
88
Long sessionId,
99
SessionMode mode,
10-
JobCategory jobCategory,
10+
List<JobCategory> jobCategories,
1111
List<DocumentContext> documents,
1212
// Number of questions AI should generate for this initial callback.
1313
Integer initialQuestionCount,

0 commit comments

Comments
 (0)