From 855a24f1e90aa5fdfb5f323ac784252ba648fbb3 Mon Sep 17 00:00:00 2001 From: jmj Date: Thu, 4 Jun 2026 16:41:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(interview):=20=EC=A7=81=EA=B5=B0=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=84=A0=ED=83=9D=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한 면접 세션에서 여러 직군 질문을 함께 출제할 수 있게 한다. 모드는 단일 유지. 기존 interview_sessions.job_category 는 대표 직군(첫 선택)으로 유지해 하위호환, 전체 직군은 session_job_categories(V14)에 저장. - DB: session_job_categories 테이블 + 기존 행 백필. - Core: InterviewSession.jobCategories(@ElementCollection), 요청/커맨드/이벤트/ payload/응답을 직군 리스트로 확장(대표 직군 병행 노출). 규칙 제목도 다중 직군("프론트엔드·백엔드 기술 면접")으로. - AI: generate.questions job_categories 리스트 + 프롬프트 균형 분배 지시. - FE: CheckboxCardGroup 신규, JobCategorySelector 다중화, 셋업 폼/히스토리 카드 반영, OpenAPI 타입 재생성. Co-Authored-By: Claude Opus 4.8 --- .../chain/prompts/question_generation.py | 5 +- .../chain/question_generation_chain.py | 6 +-- .../messaging/consumers/questions_consumer.py | 4 +- ai/src/ai_server/model/messages/questions.py | 3 +- ai/tests/test_question_generation_chain.py | 3 +- ai/tests/test_questions_consumer.py | 16 +++---- backend/openapi.json | 19 ++++++-- .../SessionQuestionsRequester.java | 2 +- .../session/application/SessionService.java | 13 +++-- .../dto/GenerateQuestionsPayload.java | 2 +- .../application/dto/SessionCreateCommand.java | 2 +- .../application/dto/SessionResult.java | 2 + .../event/SessionCreatedEvent.java | 2 +- .../session/domain/InterviewSession.java | 40 ++++++++++++++-- .../dto/SessionCreateRequest.java | 6 ++- .../presentation/dto/SessionResponse.java | 2 + .../migration/V14__session_job_categories.sql | 15 ++++++ .../SessionQuestionsRequesterTest.java | 6 +-- .../application/SessionServiceTest.java | 12 ++--- docs/database.md | 10 ++++ docs/messaging.md | 2 +- frontend/src/domain/session/model/types.ts | 2 +- .../src/features/history/ui/SessionCard.tsx | 15 +++++- .../ui/setup/InterviewSetupForm.test.tsx | 7 +-- .../interview/ui/setup/InterviewSetupForm.tsx | 21 ++++++--- .../ui/setup/JobCategorySelector.tsx | 10 ++-- frontend/src/shared/api/generated.ts | 4 +- .../CheckboxCardGroup/CheckboxCardGroup.tsx | 47 +++++++++++++++++++ .../src/shared/ui/CheckboxCardGroup/index.ts | 2 + 29 files changed, 216 insertions(+), 64 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V14__session_job_categories.sql create mode 100644 frontend/src/shared/ui/CheckboxCardGroup/CheckboxCardGroup.tsx create mode 100644 frontend/src/shared/ui/CheckboxCardGroup/index.ts diff --git a/ai/src/ai_server/chain/prompts/question_generation.py b/ai/src/ai_server/chain/prompts/question_generation.py index 17fe6adb..ae3c8863 100644 --- a/ai/src/ai_server/chain/prompts/question_generation.py +++ b/ai/src/ai_server/chain/prompts/question_generation.py @@ -14,7 +14,7 @@ ) HUMAN_PROMPT = ( - "직군: {job_category}\n" + "직군(복수 가능): {job_categories}\n" "면접 모드: {mode}\n" "최대 질문 수: {max_questions}\n\n" "지원자 컨텍스트 (이력서/레포 분석):\n" @@ -25,10 +25,11 @@ "1. 정확히 {max_questions}개의 질문을 생성하되, **서로 다른 주제/영역**을 다룹니다 " "(각 질문이 일반질문 1개의 씨앗이 되고 뒤에 꼬리질문이 붙으므로 같은 주제 반복 금지).\n" "2. 각 질문에 적절한 category 를 부여합니다.\n" - "3. 직군({job_category})·면접 모드({mode}) 에 맞는 비중으로 카테고리 분배:\n" + "3. 직군({job_categories})·면접 모드({mode}) 에 맞는 비중으로 카테고리 분배:\n" " - TECHNICAL: CS_FUNDAMENTAL + TECH_CHOICE + PROJECT_DEEP_DIVE 중심.\n" " - PERSONALITY: BEHAVIORAL 중심.\n" " - INTEGRATED: 모두 균형.\n" + " 직군이 여러 개면 각 직군에 질문이 고르게 분배되도록 합니다.\n" " 자료가 받쳐주는 만큼만 분배하고, 근거 없는 카테고리를 억지로 채우지 마세요.\n" "4. 각 질문에 target_evidence 와 expected_signal 을 채웁니다:\n" " - target_evidence: 질문의 근거가 된 컨텍스트 원문을 짧게 인용. " diff --git a/ai/src/ai_server/chain/question_generation_chain.py b/ai/src/ai_server/chain/question_generation_chain.py index 73bfd905..2cab44ec 100644 --- a/ai/src/ai_server/chain/question_generation_chain.py +++ b/ai/src/ai_server/chain/question_generation_chain.py @@ -28,7 +28,7 @@ class QuestionGenerator(Protocol): async def generate( self, *, - job_category: str, + job_categories: list[str], mode: str, max_questions: int, context: str, @@ -43,7 +43,7 @@ def __init__(self, chain: Runnable) -> None: async def generate( self, *, - job_category: str, + job_categories: list[str], mode: str, max_questions: int, context: str, @@ -51,7 +51,7 @@ async def generate( ) -> GeneratedQuestionPool: result = await self._chain.ainvoke( { - "job_category": job_category, + "job_categories": ", ".join(job_categories), "mode": mode, "max_questions": max_questions, "context": context, diff --git a/ai/src/ai_server/messaging/consumers/questions_consumer.py b/ai/src/ai_server/messaging/consumers/questions_consumer.py index b0496886..caad4fcd 100644 --- a/ai/src/ai_server/messaging/consumers/questions_consumer.py +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -86,7 +86,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None: context_text = await self._build_context(req) pool = await self._generator.generate( - job_category=req.job_category, + job_categories=req.job_categories, mode=req.mode, max_questions=effective_pool_size, context=context_text, @@ -173,7 +173,7 @@ def _build_context(documents: list[DocumentContext]) -> str: def _build_initial_rag_query(req: GenerateQuestionsRequest) -> str: parts = [ f"mode: {req.mode}", - f"job category: {req.job_category}", + f"job categories: {', '.join(req.job_categories)}", ] for d in req.documents: doc_parts = [f"document #{d.document_id} {d.source_type}"] diff --git a/ai/src/ai_server/model/messages/questions.py b/ai/src/ai_server/model/messages/questions.py index 07728e6d..5536bef7 100644 --- a/ai/src/ai_server/model/messages/questions.py +++ b/ai/src/ai_server/model/messages/questions.py @@ -32,7 +32,8 @@ class GenerateQuestionsRequest(BaseModel): session_id: int mode: InterviewMode - job_category: JobCategory + # 직군 다중 선택. 첫 항목이 대표 직군. + job_categories: list[JobCategory] = [] documents: list[DocumentContext] = [] initial_question_count: int = 1 max_questions: int = 10 diff --git a/ai/tests/test_question_generation_chain.py b/ai/tests/test_question_generation_chain.py index a253b193..dac0141a 100644 --- a/ai/tests/test_question_generation_chain.py +++ b/ai/tests/test_question_generation_chain.py @@ -27,7 +27,7 @@ async def test_generate_forwards_formatted_recent_questions_to_chain(): generator = LlmQuestionGenerator(chain) await generator.generate( - job_category="BACKEND", + job_categories=["BACKEND", "FRONTEND"], mode="TECHNICAL", max_questions=3, context="ctx", @@ -36,3 +36,4 @@ async def test_generate_forwards_formatted_recent_questions_to_chain(): chain_input = chain.ainvoke.call_args.args[0] assert chain_input["recent_questions"] == "- 이전 질문" + assert chain_input["job_categories"] == "BACKEND, FRONTEND" diff --git a/ai/tests/test_questions_consumer.py b/ai/tests/test_questions_consumer.py index e52cbd04..275c126a 100644 --- a/ai/tests/test_questions_consumer.py +++ b/ai/tests/test_questions_consumer.py @@ -79,7 +79,7 @@ async def test_consumer_generates_questions_and_publishes_callback(): { "sessionId": 99, "mode": "TECHNICAL", - "jobCategory": "BACKEND", + "jobCategories": ["BACKEND"], "documents": [ { "documentId": 1, @@ -97,7 +97,7 @@ async def test_consumer_generates_questions_and_publishes_callback(): generator.generate.assert_awaited_once() call = generator.generate.await_args - assert call.kwargs["job_category"] == "BACKEND" + assert call.kwargs["job_categories"] == ["BACKEND"] assert call.kwargs["mode"] == "TECHNICAL" # maxQuestions is the session limit; initialQuestionCount controls this result. assert call.kwargs["max_questions"] == 2 @@ -133,7 +133,7 @@ async def test_consumer_skips_when_message_id_already_seen(): { "sessionId": 99, "mode": "TECHNICAL", - "jobCategory": "BACKEND", + "jobCategories": ["BACKEND"], "documents": [], "maxQuestions": 3, } @@ -161,7 +161,7 @@ async def test_consumer_defaults_initial_question_count_to_one(): { "sessionId": 99, "mode": "TECHNICAL", - "jobCategory": "BACKEND", + "jobCategories": ["BACKEND"], "documents": [], "maxQuestions": 5, } @@ -189,7 +189,7 @@ async def test_consumer_clamps_initial_question_count_to_at_least_one(): { "sessionId": 99, "mode": "TECHNICAL", - "jobCategory": "BACKEND", + "jobCategories": ["BACKEND"], "documents": [], "initialQuestionCount": 0, "maxQuestions": 5, @@ -233,7 +233,7 @@ async def test_consumer_injects_initial_rag_chunks_when_available(): { "sessionId": 99, "mode": "TECHNICAL", - "jobCategory": "BACKEND", + "jobCategories": ["BACKEND"], "documents": [ { "documentId": 1, @@ -292,7 +292,7 @@ async def test_single_document_skips_rag_plan_mode(): { "sessionId": 1, "mode": "TECHNICAL", - "jobCategory": "BACKEND", + "jobCategories": ["BACKEND"], "documents": [ { "documentId": 1, @@ -336,7 +336,7 @@ async def test_consumer_falls_back_to_document_context_when_rag_fails(): { "sessionId": 99, "mode": "TECHNICAL", - "jobCategory": "BACKEND", + "jobCategories": ["BACKEND"], "documents": [ { "documentId": 1, diff --git a/backend/openapi.json b/backend/openapi.json index 887a1e0f..81330e8d 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -2515,9 +2515,13 @@ "type" : "string", "enum" : [ "TECHNICAL", "PERSONALITY", "INTEGRATED" ] }, - "jobCategory" : { - "type" : "string", - "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ] + "jobCategories" : { + "type" : "array", + "items" : { + "type" : "string", + "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ] + }, + "minItems" : 1 }, "maxQuestions" : { "type" : "integer", @@ -2549,7 +2553,7 @@ } } }, - "required" : [ "jobCategory", "mode" ] + "required" : [ "jobCategories", "mode" ] }, "SessionResponse" : { "type" : "object", @@ -2572,6 +2576,13 @@ "type" : "string", "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ] }, + "jobCategories" : { + "type" : "array", + "items" : { + "type" : "string", + "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ] + } + }, "maxQuestions" : { "type" : "integer", "format" : "int32" 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 96654c80..74e9e929 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 @@ -68,7 +68,7 @@ public void onSessionCreated(SessionCreatedEvent event) { GenerateQuestionsPayload payload = new GenerateQuestionsPayload( event.sessionId(), event.mode(), - event.jobCategory(), + event.jobCategories(), documents, generalCount, event.maxQuestions(), diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java index dafd7a75..bf65a172 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java @@ -12,6 +12,7 @@ import com.stackup.stackup.session.application.event.SessionEndedEvent; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; +import com.stackup.stackup.session.domain.JobCategory; import com.stackup.stackup.session.domain.SessionContext; import com.stackup.stackup.session.domain.SessionContextRepository; import com.stackup.stackup.user.domain.User; @@ -19,6 +20,7 @@ import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; @@ -49,7 +51,7 @@ public SessionResult create(Long userId, SessionCreateCommand command) { title, command.memo(), command.mode(), - command.jobCategory(), + command.jobCategories(), command.maxQuestions(), command.maxDurationMinutes(), command.generalQuestionCount(), @@ -63,7 +65,7 @@ public SessionResult create(Long userId, SessionCreateCommand command) { userId, session.getId(), session.getMode(), - session.getJobCategory(), + command.jobCategories(), session.getMaxQuestions(), session.getGeneralQuestionCount(), linkedIds @@ -146,14 +148,17 @@ public SessionResult updateMeta(Long userId, Long sessionId, String title, Strin return SessionResult.of(session, contextDocumentIds(sessionId)); } - // 제목 미입력 시 모드+직군으로 규칙 기반 제목 생성 (예: "백엔드 기술 면접"). + // 제목 미입력 시 모드+직군으로 규칙 기반 제목 생성 (예: "프론트엔드·백엔드 기술 면접"). // 히스토리에서 "면접 #id" 대신 의미 있는 제목을 보이게 한다. private String resolveTitle(SessionCreateCommand command) { String title = command.title(); if (title != null && !title.isBlank()) { return title; } - return command.jobCategory().koreanLabel() + " " + command.mode().koreanLabel(); + String jobs = command.jobCategories().stream() + .map(JobCategory::koreanLabel) + .collect(Collectors.joining("·")); + return jobs + " " + command.mode().koreanLabel(); } private User loadUser(Long userId) { 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 9ba2a617..7bba980c 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 @@ -7,7 +7,7 @@ public record GenerateQuestionsPayload( Long sessionId, SessionMode mode, - JobCategory jobCategory, + List jobCategories, List documents, // Number of questions AI should generate for this initial callback. Integer initialQuestionCount, diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java index cf2c9669..f0e890ca 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionCreateCommand.java @@ -8,7 +8,7 @@ public record SessionCreateCommand( String title, String memo, SessionMode mode, - JobCategory jobCategory, + List jobCategories, Integer maxQuestions, Integer maxDurationMinutes, Integer generalQuestionCount, diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java index b2ff0583..d77332a7 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/SessionResult.java @@ -13,6 +13,7 @@ public record SessionResult( String memo, SessionMode mode, JobCategory jobCategory, + List jobCategories, Integer maxQuestions, Integer maxDurationMinutes, Integer generalQuestionCount, @@ -32,6 +33,7 @@ public static SessionResult of(InterviewSession session, List documentIds) session.getMemo(), session.getMode(), session.getJobCategory(), + List.copyOf(session.getJobCategories()), session.getMaxQuestions(), session.getMaxDurationMinutes(), session.getGeneralQuestionCount(), diff --git a/backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java b/backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java index 19d310b3..fa9c0ed1 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/event/SessionCreatedEvent.java @@ -9,7 +9,7 @@ public record SessionCreatedEvent( Long userId, Long sessionId, SessionMode mode, - JobCategory jobCategory, + List jobCategories, Integer maxQuestions, Integer generalQuestionCount, List contextDocumentIds diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java index 920fa51c..a293ebc6 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewSession.java @@ -2,7 +2,9 @@ import com.stackup.stackup.common.entity.BaseSoftDeleteEntity; import com.stackup.stackup.user.domain.User; +import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -19,6 +21,9 @@ import lombok.NoArgsConstructor; import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; @Getter @Entity @@ -50,10 +55,18 @@ public class InterviewSession extends BaseSoftDeleteEntity { @Enumerated(EnumType.STRING) private SessionMode mode; + // 대표 직군(다중 선택 시 첫 항목). 기존 표시/피드백/인덱스 하위호환용으로 유지. @Column(name = "job_category", nullable = false, length = 30) @Enumerated(EnumType.STRING) private JobCategory jobCategory; + // 직군 다중 선택. 한 세션이 여러 직군 질문을 함께 다룰 수 있다. + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable(name = "session_job_categories", joinColumns = @JoinColumn(name = "session_id")) + @Column(name = "job_category", nullable = false, length = 30) + @Enumerated(EnumType.STRING) + private Set jobCategories = new LinkedHashSet<>(); + @Column(name = "max_questions", nullable = false) private Integer maxQuestions = 10; @@ -82,14 +95,16 @@ public class InterviewSession extends BaseSoftDeleteEntity { private Instant endedAt; private InterviewSession(User user, String title, String memo, SessionMode mode, - JobCategory jobCategory, + List jobCategories, Integer maxQuestions, Integer maxDurationMinutes, Integer generalQuestionCount, Integer maxFollowupsPerQuestion) { this.user = user; this.title = title; this.memo = memo; this.mode = mode; - this.jobCategory = jobCategory; + // 대표 직군 = 첫 선택, 전체 직군 = 중복 제거 후 보존. + this.jobCategory = jobCategories.get(0); + this.jobCategories = new LinkedHashSet<>(jobCategories); if (maxQuestions != null) { this.maxQuestions = maxQuestions; } @@ -104,17 +119,32 @@ private InterviewSession(User user, String title, String memo, SessionMode mode, } } + // 단일 직군 편의 오버로드 (테스트/기존 호출부 호환). public static InterviewSession create(User user, String title, String memo, SessionMode mode, JobCategory jobCategory, Integer maxQuestions, Integer maxDurationMinutes, Integer generalQuestionCount, Integer maxFollowupsPerQuestion) { + if (jobCategory == null) { + throw new IllegalArgumentException("jobCategory must not be null"); + } + return create(user, title, memo, mode, List.of(jobCategory), + maxQuestions, maxDurationMinutes, generalQuestionCount, maxFollowupsPerQuestion); + } + + public static InterviewSession create(User user, String title, String memo, SessionMode mode, + List jobCategories, + Integer maxQuestions, Integer maxDurationMinutes, + Integer generalQuestionCount, Integer maxFollowupsPerQuestion) { if (user == null) { throw new IllegalArgumentException("user must not be null"); } - if (mode == null || jobCategory == null) { - throw new IllegalArgumentException("mode/jobCategory must not be null"); + if (mode == null) { + throw new IllegalArgumentException("mode must not be null"); + } + if (jobCategories == null || jobCategories.isEmpty()) { + throw new IllegalArgumentException("jobCategories must not be empty"); } - return new InterviewSession(user, title, memo, mode, jobCategory, + return new InterviewSession(user, title, memo, mode, jobCategories, maxQuestions, maxDurationMinutes, generalQuestionCount, maxFollowupsPerQuestion); } diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java index f42179fa..cff2a011 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionCreateRequest.java @@ -5,6 +5,7 @@ import com.stackup.stackup.session.domain.SessionMode; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.util.List; @@ -14,7 +15,8 @@ public record SessionCreateRequest( @Size(max = 4000) String memo, // mode: TECHNICAL/PERSONALITY/INTEGRATED 중 하나를 선택한다. @NotNull SessionMode mode, - @NotNull JobCategory jobCategory, + // 직군 다중 선택. 최소 1개. 첫 항목이 대표 직군이 된다. + @NotEmpty List jobCategories, // k: 총 질문 상한(안전장치). 첫 질문 + 꼬리질문 최소 1쌍 보장을 위해 하한 2. @Min(2) @Max(30) Integer maxQuestions, // maxDurationMinutes: 시간 기반 자동 종료 미구현. DB 저장만 유지, 입력 검증 풀어 미입력 허용. @@ -30,7 +32,7 @@ public SessionCreateCommand toCommand() { title, memo, mode, - jobCategory, + jobCategories, maxQuestions, maxDurationMinutes, generalQuestionCount, diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java index 799357e6..a64cfe74 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/SessionResponse.java @@ -13,6 +13,7 @@ public record SessionResponse( String memo, SessionMode mode, JobCategory jobCategory, + List jobCategories, Integer maxQuestions, Integer maxDurationMinutes, Integer generalQuestionCount, @@ -32,6 +33,7 @@ public static SessionResponse from(SessionResult r) { r.memo(), r.mode(), r.jobCategory(), + r.jobCategories(), r.maxQuestions(), r.maxDurationMinutes(), r.generalQuestionCount(), diff --git a/backend/src/main/resources/db/migration/V14__session_job_categories.sql b/backend/src/main/resources/db/migration/V14__session_job_categories.sql new file mode 100644 index 00000000..8e33da6b --- /dev/null +++ b/backend/src/main/resources/db/migration/V14__session_job_categories.sql @@ -0,0 +1,15 @@ +-- 직군 다중 선택: 한 세션이 여러 직군 질문을 함께 다룰 수 있다. +-- 기존 interview_sessions.job_category 는 "대표 직군"(첫 선택)으로 유지(하위호환). +CREATE TABLE session_job_categories ( + session_id BIGINT NOT NULL REFERENCES interview_sessions (id), + job_category VARCHAR(30) NOT NULL, + CONSTRAINT uq_session_job_category UNIQUE (session_id, job_category), + CONSTRAINT chk_session_job_category + CHECK (job_category IN ('FRONTEND', 'BACKEND', 'INFRA', 'DBA')) +); + +CREATE INDEX idx_session_job_categories_session ON session_job_categories (session_id); + +-- 기존 세션의 대표 직군을 다중 테이블에 백필. +INSERT INTO session_job_categories (session_id, job_category) +SELECT id, job_category FROM interview_sessions; 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 21b63ebf..3555879a 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 @@ -46,7 +46,7 @@ void onSessionCreated_requestsOneInitialQuestionAndKeepsSessionLimit() { 1L, 11L, SessionMode.INTEGRATED, - JobCategory.BACKEND, + List.of(JobCategory.BACKEND), 5, 3, List.of() @@ -59,7 +59,7 @@ void onSessionCreated_requestsOneInitialQuestionAndKeepsSessionLimit() { GenerateQuestionsPayload payload = payloadCaptor.getValue(); assertThat(payload.sessionId()).isEqualTo(11L); assertThat(payload.mode()).isEqualTo(SessionMode.INTEGRATED); - assertThat(payload.jobCategory()).isEqualTo(JobCategory.BACKEND); + assertThat(payload.jobCategories()).containsExactly(JobCategory.BACKEND); assertThat(payload.documents()).isEmpty(); // generalQuestionCount(n) 만큼 생성 요청 (이벤트의 n=3). assertThat(payload.initialQuestionCount()).isEqualTo(3); @@ -79,7 +79,7 @@ void onSessionCreated_includesRecentQuestionsForDedup() { .thenReturn(List.of("이전 질문 A", "이전 질문 B")); requester.onSessionCreated(new SessionCreatedEvent( - 1L, 11L, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 3, List.of() + 1L, 11L, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 3, List.of() )); ArgumentCaptor payloadCaptor = diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java index 675d8dfb..5bc9fdf4 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionServiceTest.java @@ -54,7 +54,7 @@ void create_savesSessionAndPublishesEvent() { }); SessionResult result = service.create(1L, new SessionCreateCommand( - "title", "memo", SessionMode.TECHNICAL, JobCategory.BACKEND, + "title", "memo", SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null, List.of() )); @@ -70,7 +70,7 @@ void create_generatesTitleFromModeAndJobWhenBlank() { when(sessionRepository.save(any(InterviewSession.class))).thenAnswer(inv -> inv.getArgument(0)); SessionResult result = service.create(1L, new SessionCreateCommand( - " ", null, SessionMode.TECHNICAL, JobCategory.BACKEND, + " ", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null, List.of() )); @@ -84,7 +84,7 @@ void create_keepsProvidedTitle() { when(sessionRepository.save(any(InterviewSession.class))).thenAnswer(inv -> inv.getArgument(0)); SessionResult result = service.create(1L, new SessionCreateCommand( - "내가 정한 제목", null, SessionMode.INTEGRATED, JobCategory.FRONTEND, + "내가 정한 제목", null, SessionMode.INTEGRATED, List.of(JobCategory.FRONTEND), 5, 30, null, null, List.of() )); @@ -104,7 +104,7 @@ void create_linksAnalyzedContextDocuments() { when(documentRepository.findActiveByIdAndOwner(7L, 1L)).thenReturn(Optional.of(doc)); SessionResult result = service.create(1L, new SessionCreateCommand( - "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, + "t", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null, List.of(7L, 7L) )); @@ -121,7 +121,7 @@ void create_rejectsNonAnalyzedDocument() { when(documentRepository.findActiveByIdAndOwner(8L, 1L)).thenReturn(Optional.of(pending)); assertThatThrownBy(() -> service.create(1L, new SessionCreateCommand( - "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, + "t", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null, List.of(8L) ))).isInstanceOf(DomainException.class); } @@ -168,7 +168,7 @@ private User userFixture(Long id) { private InterviewSession sessionFixture(Long id) { InterviewSession s = InterviewSession.create( - userFixture(1L), "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30, null, null + userFixture(1L), "t", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null ); ReflectionTestUtils.setField(s, "id", id); return s; diff --git a/docs/database.md b/docs/database.md index a620fa17..7fd3a3f4 100644 --- a/docs/database.md +++ b/docs/database.md @@ -28,6 +28,7 @@ interview_sessions → session_feedbacks | 5 | `resumes` | 이력서 메타 (실 파일은 S3) | | 6 | `analyzed_documents` | AI 분석 결과 메타 + S3 경로 | | 7 | `interview_sessions` | 면접 세션 설정·상태·히스토리 | +| 7-1 | `session_job_categories` | 세션 직군 다중 선택 (대표 직군은 interview_sessions) | | 8 | `session_contexts` | 세션 ↔ 분석 문서 N:M | | 9 | `interview_messages` | 면접 질문·답변 시퀀스 (트리) | | 10 | `message_voice_analyses` | 답변별 음성 분석 (1:1) | @@ -141,6 +142,7 @@ CREATE TABLE interview_sessions ( title VARCHAR(200), memo TEXT, mode VARCHAR(20) NOT NULL CHECK (mode IN ('TECHNICAL','PERSONALITY','INTEGRATED')), + -- 대표 직군(다중 선택 시 첫 항목). 전체 직군은 session_job_categories 참조. job_category VARCHAR(30) NOT NULL CHECK (job_category IN ('FRONTEND','BACKEND','INFRA','DBA')), max_questions INT NOT NULL DEFAULT 10, @@ -155,6 +157,14 @@ CREATE TABLE interview_sessions ( is_deleted BOOLEAN NOT NULL DEFAULT FALSE ); +-- 7-1. session_job_categories (직군 다중 선택; 대표 직군은 interview_sessions.job_category) +CREATE TABLE session_job_categories ( + session_id BIGINT NOT NULL REFERENCES interview_sessions(id), + job_category VARCHAR(30) NOT NULL + CHECK (job_category IN ('FRONTEND','BACKEND','INFRA','DBA')), + UNIQUE (session_id, job_category) +); + -- 8. session_contexts CREATE TABLE session_contexts ( id BIGSERIAL PRIMARY KEY, diff --git a/docs/messaging.md b/docs/messaging.md index 7f75df2a..6ab55b7b 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -224,7 +224,7 @@ "payload": { "sessionId": 99, "mode": "TECHNICAL", - "jobCategory": "BACKEND", + "jobCategories": ["BACKEND", "FRONTEND"], "documentIds": [42, 17], "maxQuestions": 10, "recentQuestions": ["이전 면접 질문 텍스트", "..."] diff --git a/frontend/src/domain/session/model/types.ts b/frontend/src/domain/session/model/types.ts index 552ca3c5..99845da1 100644 --- a/frontend/src/domain/session/model/types.ts +++ b/frontend/src/domain/session/model/types.ts @@ -8,5 +8,5 @@ export type SessionCreateRequest = Schemas['SessionCreateRequest'] export type SessionStatus = NonNullable export type SessionMode = NonNullable -export type JobCategory = NonNullable +export type JobCategory = NonNullable[number] export type MessageRole = NonNullable diff --git a/frontend/src/features/history/ui/SessionCard.tsx b/frontend/src/features/history/ui/SessionCard.tsx index e7ba769b..b43f1c89 100644 --- a/frontend/src/features/history/ui/SessionCard.tsx +++ b/frontend/src/features/history/ui/SessionCard.tsx @@ -17,9 +17,22 @@ const MODE: Record = { INTEGRATED: '통합', } +const JOB: Record = { + FRONTEND: '프론트엔드', + BACKEND: '백엔드', + INFRA: '인프라', + DBA: 'DBA', +} + export function SessionCard({ session }: { session: Session }) { const status = session.status ? STATUS[session.status] : undefined const completed = session.status === 'COMPLETED' + const jobs = session.jobCategories?.length + ? session.jobCategories + : session.jobCategory + ? [session.jobCategory] + : [] + const jobLabel = jobs.map((j) => JOB[j] ?? j).join('·') const body = (
@@ -27,7 +40,7 @@ export function SessionCard({ session }: { session: Session }) { {session.title || `면접 #${session.id}`} {formatDate(session.createdAt)} · {MODE[session.mode ?? ''] ?? session.mode} ·{' '} - {session.jobCategory} · 질문 {session.totalQuestionCount ?? 0}개 + {jobLabel} · 질문 {session.totalQuestionCount ?? 0}개
diff --git a/frontend/src/features/interview/ui/setup/InterviewSetupForm.test.tsx b/frontend/src/features/interview/ui/setup/InterviewSetupForm.test.tsx index 8bc146a2..0a31a4aa 100644 --- a/frontend/src/features/interview/ui/setup/InterviewSetupForm.test.tsx +++ b/frontend/src/features/interview/ui/setup/InterviewSetupForm.test.tsx @@ -9,15 +9,16 @@ describe('InterviewSetupForm', () => { expect(screen.getByRole('button', { name: '면접 생성' })).toBeDisabled() }) - it('모드·직군 선택 후 생성하면 요청을 만든다', async () => { + it('모드·직군(복수) 선택 후 생성하면 요청을 만든다', async () => { const onCreate = vi.fn() render() await userEvent.click(screen.getByRole('radio', { name: '기술 면접' })) - await userEvent.click(screen.getByRole('radio', { name: '백엔드' })) + await userEvent.click(screen.getByRole('checkbox', { name: '백엔드' })) + await userEvent.click(screen.getByRole('checkbox', { name: '프론트엔드' })) await userEvent.click(screen.getByRole('button', { name: '면접 생성' })) expect(onCreate).toHaveBeenCalledWith({ mode: 'TECHNICAL', - jobCategory: 'BACKEND', + jobCategories: ['BACKEND', 'FRONTEND'], generalQuestionCount: 3, maxFollowupsPerQuestion: 2, maxQuestions: 10, diff --git a/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx b/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx index 80ab18d7..47cabeb5 100644 --- a/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx +++ b/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx @@ -17,7 +17,7 @@ export function InterviewSetupForm({ isSubmitting?: boolean }) { const [mode, setMode] = useState(null) - const [jobCategory, setJobCategory] = useState(null) + const [jobCategories, setJobCategories] = useState([]) const [generalQuestionCount, setGeneralQuestionCount] = useState(3) const [maxFollowupsPerQuestion, setMaxFollowupsPerQuestion] = useState(2) const [maxQuestions, setMaxQuestions] = useState(10) @@ -26,13 +26,19 @@ export function InterviewSetupForm({ const toggle = (id: number) => setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) - const valid = mode !== null && jobCategory !== null && maxQuestions >= 2 && maxQuestions <= 30 + const toggleJob = (job: JobCategory) => + setJobCategories((prev) => + prev.includes(job) ? prev.filter((x) => x !== job) : [...prev, job], + ) + + const valid = + mode !== null && jobCategories.length > 0 && maxQuestions >= 2 && maxQuestions <= 30 const submit = () => { - if (mode === null || jobCategory === null || !valid) return + if (mode === null || jobCategories.length === 0 || !valid) return onCreate({ mode, - jobCategory, + jobCategories, generalQuestionCount, maxFollowupsPerQuestion, maxQuestions, @@ -53,8 +59,11 @@ export function InterviewSetupForm({
-

직군

- +

+ 직군 + 복수 선택 가능 +

+

면접 구성

diff --git a/frontend/src/features/interview/ui/setup/JobCategorySelector.tsx b/frontend/src/features/interview/ui/setup/JobCategorySelector.tsx index afd24f12..bb9c8a4b 100644 --- a/frontend/src/features/interview/ui/setup/JobCategorySelector.tsx +++ b/frontend/src/features/interview/ui/setup/JobCategorySelector.tsx @@ -1,4 +1,4 @@ -import { RadioCardGroup } from '@/shared/ui/RadioCardGroup' +import { CheckboxCardGroup } from '@/shared/ui/CheckboxCardGroup' import type { JobCategory } from '@/domain/session' const OPTIONS = [ @@ -10,10 +10,10 @@ const OPTIONS = [ export function JobCategorySelector({ value, - onChange, + onToggle, }: { - value: JobCategory | null - onChange: (value: JobCategory) => void + value: JobCategory[] + onToggle: (value: JobCategory) => void }) { - return + return } diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index b5552a5f..b4d75812 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -800,8 +800,7 @@ export interface components { memo?: string; /** @enum {string} */ mode: "TECHNICAL" | "PERSONALITY" | "INTEGRATED"; - /** @enum {string} */ - jobCategory: "FRONTEND" | "BACKEND" | "INFRA" | "DBA"; + jobCategories: ("FRONTEND" | "BACKEND" | "INFRA" | "DBA")[]; /** Format: int32 */ maxQuestions?: number; /** Format: int32 */ @@ -821,6 +820,7 @@ export interface components { mode?: "TECHNICAL" | "PERSONALITY" | "INTEGRATED"; /** @enum {string} */ jobCategory?: "FRONTEND" | "BACKEND" | "INFRA" | "DBA"; + jobCategories?: ("FRONTEND" | "BACKEND" | "INFRA" | "DBA")[]; /** Format: int32 */ maxQuestions?: number; /** Format: int32 */ diff --git a/frontend/src/shared/ui/CheckboxCardGroup/CheckboxCardGroup.tsx b/frontend/src/shared/ui/CheckboxCardGroup/CheckboxCardGroup.tsx new file mode 100644 index 00000000..c5e0dc4c --- /dev/null +++ b/frontend/src/shared/ui/CheckboxCardGroup/CheckboxCardGroup.tsx @@ -0,0 +1,47 @@ +export type CheckboxOption = { + value: T + label: string + description?: string +} + +export type CheckboxCardGroupProps = { + options: CheckboxOption[] + value: T[] + onToggle: (value: T) => void + ariaLabel: string +} + +// 다중 선택 카드 그룹. RadioCardGroup 의 다중 선택 버전. +export function CheckboxCardGroup({ + options, + value, + onToggle, + ariaLabel, +}: CheckboxCardGroupProps) { + return ( +
+ {options.map((opt) => { + const selected = value.includes(opt.value) + return ( + + ) + })} +
+ ) +} diff --git a/frontend/src/shared/ui/CheckboxCardGroup/index.ts b/frontend/src/shared/ui/CheckboxCardGroup/index.ts new file mode 100644 index 00000000..6bbe0826 --- /dev/null +++ b/frontend/src/shared/ui/CheckboxCardGroup/index.ts @@ -0,0 +1,2 @@ +export { CheckboxCardGroup } from './CheckboxCardGroup' +export type { CheckboxCardGroupProps, CheckboxOption } from './CheckboxCardGroup'