diff --git a/CLAUDE.md b/CLAUDE.md index 72463f9c..a3213731 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,12 +1,12 @@ # StackUp — Claude 컨텍스트 (루트) -> **IT 직군 멀티모달 AI 면접 시뮬레이터.** 본 문서는 전체 오버뷰 + 모든 컨텍스트 문서의 **인덱스**다. 작업 디렉토리에서 가장 가까운 `CLAUDE.md`를 먼저 읽고, 필요한 횡단 관심사는 `docs/`에서 찾는다. +> **멀티모달 AI 모의면접 시뮬레이터 (취준생 전반 — 개발·비개발 직군).** 본 문서는 전체 오버뷰 + 모든 컨텍스트 문서의 **인덱스**다. 작업 디렉토리에서 가장 가까운 `CLAUDE.md`를 먼저 읽고, 필요한 횡단 관심사는 `docs/`에서 찾는다. --- ## 0. 한 줄 요약 -GitHub 레포 + 이력서 → AI가 분석 → 개인 맞춤 모의 면접 → 음성·비언어적 분석 포함 종합 피드백. +이력서·자소서(+개발 직군이면 GitHub 레포) → AI가 분석 → 개인 맞춤 모의 면접 → 음성·비언어적 분석 포함 종합 피드백. 상세 비전·페르소나·메트릭: [`docs/product-overview.md`](./docs/product-overview.md) diff --git a/README.md b/README.md index aef9cc90..f9934a9c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **내 이력서를 아는 면접관과 연습하세요.** GitHub 레포지토리와 이력서를 분석해 나만을 위한 질문을 만들고, -얕은 답변은 다시 파고드는 IT 직군 AI 모의면접 시뮬레이터입니다. +얕은 답변은 다시 파고드는 AI 모의면접 시뮬레이터입니다. 개발·비개발 직군 모두 지원합니다. [stack-up.shop](https://stack-up.shop) diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index dbf509bd..bb8f61a4 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -432,9 +432,9 @@ docker run --env-file .env -p 8000:8000 stackup-ai 직군(질문 카테고리) 수만큼 기술 평가위원을 동적 생성(`_domain_specs_weighted`) + 논리·전달력 평가위원을 더해 `asyncio.gather` 로 병렬 채점 → 직군 평가는 질문 등장 빈도로 가중평균해 `technical_accuracy` 산출, 이를 논리·전달력과 고정 비율(0.5:0.25:0.25)로 다시 가중평균해 `overall_score` 결정(순수 파이썬, LLM 미호출). - 직군별 평가 관점(`dimension_guide`)은 `_DOMAIN_TECH_GUIDE`(FRONTEND/BACKEND/INFRA/DBA 각각 실무 관점이 - 다른 문구)로 실제로 차별화되어 있다 — 이전에는 `persona` 라벨만 다르고 가이드 내용이 공통이었던 문제를 - 회귀 테스트(`tests/test_feedback_panel.py`)로 막아둠. 목록에 없는 직군은 `_TECH_GUIDE`(범용)로 폴백. + 직군별 평가 관점(`dimension_guide`)은 `_DOMAIN_TECH_GUIDE`(직군 20종 각각 실무 관점이 + 다른 문구 — 2026-09-28 에 개발 8종 + 비개발 12종으로 확장)로 실제로 차별화되어 있다 — 이전에는 `persona` 라벨만 다르고 가이드 내용이 공통이었던 문제를 + 회귀 테스트(`tests/test_feedback_panel.py`)로 막아둠. 목록에 없는 직군은 `_TECH_GUIDE`(범용)로 폴백 — 이 기본 문구는 **기술 이야기를 하지 않는다**. 서비스 대상이 취준생 전반이라 '기술 정확성·trade-off' 로 뭉뚱그리면 영업·인사 지원자에게는 평가 근거가 통째로 헛돈다(`test_generic_guide_is_not_engineering_flavoured`). 첫인상·직무적합도·인성 평가는 별도 체인으로 같은 병렬 흐름에 얹히지만 이 가중평균 계산에는 미포함(표시용). 평가위원 호출이 실패(레이트리밋·타임아웃 등)하면 `score=None`으로 가중평균에서 자동 제외되고, `panel_breakdown`의 `detail`에 실패 사실을 명시(`_EVAL_FAILED_DETAIL`)해 "판단 불가(정상)"와 diff --git a/ai/src/ai_server/chain/feedback_generation_chain.py b/ai/src/ai_server/chain/feedback_generation_chain.py index b074ec61..16da4467 100644 --- a/ai/src/ai_server/chain/feedback_generation_chain.py +++ b/ai/src/ai_server/chain/feedback_generation_chain.py @@ -160,20 +160,44 @@ class _EvaluatorSpec: dimension_guide: str +# 직군을 모를 때의 기본 관점. **기술 이야기를 하지 않는다** — 비개발 직군(영업·인사· +# 마케팅…)까지 대상이라, "기술 정확성·trade-off" 로 뭉뚱그리면 그 지원자들에게는 +# 평가 근거가 통째로 헛돈다. _TECH_GUIDE = ( - "- 기술 정확성, 깊이, trade-off, 근거를 봅니다. 질문의 '기대 신호'를 " + "- 직무 이해도, 판단의 근거, 경험의 구체성을 봅니다. 질문의 '기대 신호'를 " "답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." ) _DOMAIN_KO = { + # 개발 "FRONTEND": "프론트엔드", "BACKEND": "백엔드", - "INFRA": "인프라", + "INFRA": "인프라·DevOps", "DBA": "DBA", + "MOBILE": "모바일", + "DATA_AI": "데이터·AI", + "SECURITY": "보안", + "QA": "QA·테스트", + # 비개발 + "PLANNING": "기획·PM", + "MARKETING": "마케팅·광고", + "SALES": "영업·영업관리", + "HR": "인사·노무", + "FINANCE": "재무·회계", + "DESIGN": "디자인", + "MANUFACTURING": "생산·품질", + "RND": "연구개발", + "CUSTOMER_SERVICE": "고객지원·CS", + "LOGISTICS": "물류·유통·구매", + "LEGAL": "법무", + "PUBLIC": "공공·행정", } # 직군별 실제 평가 관점. 라벨(persona)만 다르고 내용은 공통이던 문제를 해결하기 위해 # 직군마다 실무에서 중요도가 높은 관점을 명시한다. 목록에 없는 직군은 _TECH_GUIDE로 대체. +# +# 비개발 직군을 추가하면서 문장을 직군의 언어로 썼다 — 영업 지원자에게 "trade-off 와 +# 기술 깊이" 를 묻는 평가 기준을 들이대면 점수의 근거가 성립하지 않는다. _DOMAIN_TECH_GUIDE = { "FRONTEND": ( "- 렌더링·상태관리 설계, 성능(리렌더링·번들·네트워크) trade-off, 접근성·크로스브라우저 " @@ -191,6 +215,70 @@ class _EvaluatorSpec: "- 인덱스·쿼리 실행계획 분석, 정규화·비정규화 trade-off, 트랜잭션 격리수준과 " "백업·복구 전략을 봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." ), + "MOBILE": ( + "- 화면 생명주기·상태 보존, 배터리·네트워크 제약 아래의 설계 선택, 스토어 배포와 " + "버전 파편화 대응을 봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "DATA_AI": ( + "- 문제 정의와 지표 설계, 데이터 품질·편향 점검, 모델 선택의 근거와 검증 방법, " + "재현성을 봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "SECURITY": ( + "- 위협 모델링, 취약점의 원리와 영향 범위 판단, 방어 수단의 trade-off와 사고 대응 " + "절차를 봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "QA": ( + "- 테스트 설계(경계·동등분할)와 우선순위 판단, 자동화 범위 선정, 결함 재현·보고의 " + "정확성을 봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "PLANNING": ( + "- 문제 정의와 우선순위 판단 근거, 지표 설계와 가설 검증, 이해관계자 조율 과정을 " + "봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "MARKETING": ( + "- 타깃·메시지 설정의 근거, 채널 선택과 예산 배분 판단, 성과 지표 해석과 개선 " + "사이클을 봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "SALES": ( + "- 고객 문제 파악과 제안 논리, 거절·이견 대응 방식, 파이프라인 관리와 수치 기반 " + "목표 관리를 봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "HR": ( + "- 채용·평가 기준의 공정성, 노동 관계 법규 이해, 조직 이슈를 다루는 절차와 " + "비밀 유지 감각을 봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "FINANCE": ( + "- 회계 처리의 정확성과 근거, 재무제표 해석, 예산·자금 계획의 판단과 내부통제 " + "감각을 봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "DESIGN": ( + "- 사용자 문제 정의와 시안 결정의 근거, 일관성·접근성 고려, 피드백 수용과 개선 " + "과정을 봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "MANUFACTURING": ( + "- 공정 이해와 불량 원인 분석, 품질 지표 관리와 개선 활동, 안전·표준 준수 감각을 " + "봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "RND": ( + "- 연구 문제 설정과 선행 연구 파악, 실험 설계와 결과 해석의 엄밀성, 실패를 다루는 " + "태도를 봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "CUSTOMER_SERVICE": ( + "- 문의 상황 파악과 응대 판단, 불만·클레임 처리 절차, 반복 문제를 구조적으로 " + "줄이는 제안을 봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "LOGISTICS": ( + "- 수요 예측과 재고 판단, 배송·조달 리드타임 관리, 비용과 납기의 trade-off를 " + "봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "LEGAL": ( + "- 계약·규정 해석의 정확성, 법적 위험의 식별과 완화 방안, 사업 요구와 준법 사이의 " + "조율을 봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), + "PUBLIC": ( + "- 정책·제도 이해와 절차 준수, 공공성과 형평성 판단, 민원·이해관계 조율 경험을 " + "봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다." + ), } diff --git a/ai/src/ai_server/model/messages/feedback.py b/ai/src/ai_server/model/messages/feedback.py index aef2c01c..8e3a1d20 100644 --- a/ai/src/ai_server/model/messages/feedback.py +++ b/ai/src/ai_server/model/messages/feedback.py @@ -4,6 +4,7 @@ from ai_server.model._config import camel_config from ai_server.model.messages.questions import GenerationStatus +from ai_server.model.messages.job_category import JobCategory InterviewMode = Literal["PERSONALITY", "TECHNICAL", "INTEGRATED", "JOB_TAILORED"] @@ -55,7 +56,7 @@ class GenerateFeedbackRequest(BaseModel): session_id: int mode: InterviewMode - job_category: Literal["FRONTEND", "BACKEND", "INFRA", "DBA"] + job_category: JobCategory total_question_count: int | None = None # 종료 사유(USER_REQUEST/MAX_QUESTIONS_REACHED/POOL_EXHAUSTED 등). 프롬프트 컨텍스트로만 # 쓰이므로 자유 문자열로 둬 Core 가 사유를 추가해도 피드백 파싱이 깨지지 않게 한다. diff --git a/ai/src/ai_server/model/messages/followup.py b/ai/src/ai_server/model/messages/followup.py index 39d9ae0e..58f31deb 100644 --- a/ai/src/ai_server/model/messages/followup.py +++ b/ai/src/ai_server/model/messages/followup.py @@ -4,6 +4,7 @@ from ai_server.model._config import camel_config from ai_server.model.messages.questions import GenerationStatus +from ai_server.model.messages.job_category import JobCategory InterviewMode = Literal["PERSONALITY", "TECHNICAL", "INTEGRATED"] @@ -29,7 +30,7 @@ class GenerateFollowupRequest(BaseModel): previous_question: str answer_text: str mode: InterviewMode - job_category: Literal["FRONTEND", "BACKEND", "INFRA", "DBA"] + job_category: JobCategory context_document_ids: list[int] = Field(default_factory=list) parent_category: str | None = None # 직전 질문 카테고리 (루브릭 선택) # 직전 질문이 기대하는 핵심(평가 관점). correctness/specificity 충족도 채점에 사용. diff --git a/ai/src/ai_server/model/messages/job_category.py b/ai/src/ai_server/model/messages/job_category.py new file mode 100644 index 00000000..76af0431 --- /dev/null +++ b/ai/src/ai_server/model/messages/job_category.py @@ -0,0 +1,34 @@ +"""직군 타입의 단일 정의. + +전에는 같은 `Literal[...]` 목록이 questions·followup·feedback·voice·tts 5곳에 복사돼 +있었다. 직군을 늘릴 때 한 곳이라도 빠뜨리면 **그 메시지만 검증에서 튕기고** 해당 경로가 +조용히 DLQ 로 간다 — 다섯 곳을 동시에 맞추는 일에 사람을 믿을 이유가 없다. +""" + +from typing import Literal + +# Core 의 JobCategory enum 과 1:1. 개발은 세부 직무, 비개발은 대분류 단위다. +JobCategory = Literal[ + # 개발 + "FRONTEND", + "BACKEND", + "INFRA", + "DBA", + "MOBILE", + "DATA_AI", + "SECURITY", + "QA", + # 비개발 + "PLANNING", + "MARKETING", + "SALES", + "HR", + "FINANCE", + "DESIGN", + "MANUFACTURING", + "RND", + "CUSTOMER_SERVICE", + "LOGISTICS", + "LEGAL", + "PUBLIC", +] diff --git a/ai/src/ai_server/model/messages/questions.py b/ai/src/ai_server/model/messages/questions.py index 8de1be05..61b04f08 100644 --- a/ai/src/ai_server/model/messages/questions.py +++ b/ai/src/ai_server/model/messages/questions.py @@ -3,9 +3,10 @@ from pydantic import BaseModel from ai_server.model._config import camel_config +from ai_server.model.messages.job_category import JobCategory # noqa: F401 InterviewMode = Literal["PERSONALITY", "TECHNICAL", "INTEGRATED", "JOB_TAILORED"] -JobCategory = Literal["FRONTEND", "BACKEND", "INFRA", "DBA"] + QuestionCategory = Literal[ "CS_FUNDAMENTAL", "PROJECT_DEEP_DIVE", diff --git a/ai/src/ai_server/model/messages/tts.py b/ai/src/ai_server/model/messages/tts.py index 213fa557..a78ac3b1 100644 --- a/ai/src/ai_server/model/messages/tts.py +++ b/ai/src/ai_server/model/messages/tts.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from ai_server.model._config import camel_config +from ai_server.model.messages.job_category import JobCategory class GenerateTtsRequest(BaseModel): @@ -14,7 +15,7 @@ class GenerateTtsRequest(BaseModel): message_id: int # interview_messages.id (INTERVIEWER 질문) text: str mode: Literal["PERSONALITY", "TECHNICAL", "INTEGRATED"] - job_category: Literal["FRONTEND", "BACKEND", "INFRA", "DBA"] + job_category: JobCategory class TtsCallbackPayload(BaseModel): diff --git a/ai/src/ai_server/model/messages/voice.py b/ai/src/ai_server/model/messages/voice.py index 95e96ef4..6ef10e37 100644 --- a/ai/src/ai_server/model/messages/voice.py +++ b/ai/src/ai_server/model/messages/voice.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, Field from ai_server.model._config import camel_config +from ai_server.model.messages.job_category import JobCategory InterviewMode = Literal["PERSONALITY", "TECHNICAL", "INTEGRATED"] @@ -19,7 +20,7 @@ class AnalyzeVoiceRequest(BaseModel): content_type: str previous_question_text: str | None = None mode: InterviewMode - job_category: Literal["FRONTEND", "BACKEND", "INFRA", "DBA"] + job_category: JobCategory class VoiceCallbackPayload(BaseModel): diff --git a/ai/tests/test_feedback_panel.py b/ai/tests/test_feedback_panel.py index 26e551e1..4f41c31b 100644 --- a/ai/tests/test_feedback_panel.py +++ b/ai/tests/test_feedback_panel.py @@ -216,11 +216,61 @@ def test_domain_guides_differ_by_job_category(): def test_unknown_job_category_falls_back_to_generic_guide(): from ai_server.chain.feedback_generation_chain import _TECH_GUIDE - assert _tech_guide_for("QA") == _TECH_GUIDE + # 예전엔 "QA" 를 미등록 예시로 썼다 — 이제 실제 직군이라 더는 fallback 이 아니다. + assert _tech_guide_for("NOT_A_REAL_CATEGORY") == _TECH_GUIDE assert _tech_guide_for("") == _TECH_GUIDE assert _tech_guide_for(None) == _TECH_GUIDE +def test_generic_guide_is_not_engineering_flavoured(): + """비개발 직군까지 대상이라 기본 관점이 기술 이야기를 하면 안 된다. + + 예전 기본값은 "기술 정확성, 깊이, trade-off" 였다. 영업·인사 지원자에게 그 기준을 + 들이대면 점수의 근거가 통째로 헛돈다. + """ + from ai_server.chain.feedback_generation_chain import _TECH_GUIDE + + for word in ("기술", "trade-off"): + assert word not in _TECH_GUIDE, f"기본 관점에 '{word}' 가 들어 있다" + + +def test_every_job_category_has_label_and_guide(): + """직군을 늘릴 때 라벨·평가관점 중 하나만 빠뜨리면 그 직군만 조용히 일반 문구를 받는다.""" + from ai_server.chain.feedback_generation_chain import _DOMAIN_KO, _DOMAIN_TECH_GUIDE + from ai_server.model.messages.job_category import JobCategory + from typing import get_args + + categories = set(get_args(JobCategory)) + assert categories - set(_DOMAIN_KO) == set(), "한국어 라벨 누락" + assert categories - set(_DOMAIN_TECH_GUIDE) == set(), "평가 관점 누락" + assert set(_DOMAIN_KO) - categories == set(), "타입에 없는 라벨이 남아 있다" + + +def test_literal_matches_db_check_constraint(): + """AI 의 Literal 과 DB CHECK 가 어긋나면 그 직군 세션이 저장되거나 검증되다 터진다. + + 직군 목록은 Java enum·마이그레이션·Python Literal·프론트 세 군데에 흩어져 있다. + 사람이 동시에 맞추는 일을 믿지 않는다 — 마이그레이션 원문에서 읽어 대조한다. + """ + import pathlib + import re + from typing import get_args + + from ai_server.model.messages.job_category import JobCategory + + sql = ( + pathlib.Path(__file__).resolve().parents[2] + / "backend/src/main/resources/db/migration/V35__extend_job_categories.sql" + ).read_text(encoding="utf-8") + in_sql = set(re.findall(r"'([A-Z_]+)'", sql)) + + assert in_sql == set(get_args(JobCategory)), ( + "Literal 과 마이그레이션 CHECK 가 다르다: " + f"only-sql={sorted(in_sql - set(get_args(JobCategory)))} " + f"only-py={sorted(set(get_args(JobCategory)) - in_sql)}" + ) + + def test_domain_spec_uses_domain_specific_guide(): spec = _domain_spec("FRONTEND", "TECHNICAL") assert spec.persona == "프론트엔드 직군 시니어 기술 면접관" diff --git a/backend/openapi.json b/backend/openapi.json index a4b27f33..fa9a1d74 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -3393,7 +3393,7 @@ "type" : "array", "items" : { "type" : "string", - "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ] + "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA", "MOBILE", "DATA_AI", "SECURITY", "QA", "PLANNING", "MARKETING", "SALES", "HR", "FINANCE", "DESIGN", "MANUFACTURING", "RND", "CUSTOMER_SERVICE", "LOGISTICS", "LEGAL", "PUBLIC" ] }, "minItems" : 1 }, @@ -3458,13 +3458,13 @@ }, "jobCategory" : { "type" : "string", - "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ] + "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA", "MOBILE", "DATA_AI", "SECURITY", "QA", "PLANNING", "MARKETING", "SALES", "HR", "FINANCE", "DESIGN", "MANUFACTURING", "RND", "CUSTOMER_SERVICE", "LOGISTICS", "LEGAL", "PUBLIC" ] }, "jobCategories" : { "type" : "array", "items" : { "type" : "string", - "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA" ] + "enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA", "MOBILE", "DATA_AI", "SECURITY", "QA", "PLANNING", "MARKETING", "SALES", "HR", "FINANCE", "DESIGN", "MANUFACTURING", "RND", "CUSTOMER_SERVICE", "LOGISTICS", "LEGAL", "PUBLIC" ] } }, "maxQuestions" : { diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/JobCategory.java b/backend/src/main/java/com/stackup/stackup/session/domain/JobCategory.java index d32d6835..a6f599fc 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/JobCategory.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/JobCategory.java @@ -1,17 +1,57 @@ package com.stackup.stackup.session.domain; +/** + * 면접 직군. + * + *

처음엔 개발 4종(FRONTEND·BACKEND·INFRA·DBA)뿐이었다. 서비스가 "IT 직군"이 아니라 + * 취준생 전반을 대상으로 하는데 직군 선택지에 개발 직무만 있어서, 개발이 아닌 + * 지원자는 시작 화면에서 고를 수 있는 게 없었다. + * + *

개발 직군은 세부 직무 단위로, 비개발 직군은 대분류 단위로 둔다. 입도가 섞이지만 + * 기존 4개를 그대로 둬야 이미 쌓인 세션·통계가 유지된다(enum 이름은 DB 에 문자열로 저장된다). + * 더 좁은 직무는 직무 맞춤 모드(회사명 + 채용공고)가 받는다 — 거기가 자유 입력 창구다. + */ public enum JobCategory { - FRONTEND, - BACKEND, - INFRA, - DBA; + + // ── 개발 ────────────────────────────────────────────────────────────── + FRONTEND("프론트엔드"), + BACKEND("백엔드"), + INFRA("인프라·DevOps"), + DBA("DBA"), + MOBILE("모바일"), + DATA_AI("데이터·AI"), + SECURITY("보안"), + QA("QA·테스트"), + + // ── 비개발 ──────────────────────────────────────────────────────────── + PLANNING("기획·PM"), + MARKETING("마케팅·광고"), + SALES("영업·영업관리"), + HR("인사·노무"), + FINANCE("재무·회계"), + DESIGN("디자인"), + MANUFACTURING("생산·품질"), + RND("연구개발"), + CUSTOMER_SERVICE("고객지원·CS"), + LOGISTICS("물류·유통·구매"), + LEGAL("법무"), + PUBLIC("공공·행정"); + + private final String koreanLabel; + + JobCategory(String koreanLabel) { + this.koreanLabel = koreanLabel; + } public String koreanLabel() { + return koreanLabel; + } + + /** 개발 직군인가. GitHub 레포 분석처럼 개발자에게만 의미 있는 자료를 안내할 때 쓴다. */ + public boolean isEngineering() { return switch (this) { - case FRONTEND -> "프론트엔드"; - case BACKEND -> "백엔드"; - case INFRA -> "인프라"; - case DBA -> "DBA"; + case FRONTEND, BACKEND, INFRA, DBA, MOBILE, DATA_AI, SECURITY, QA -> true; + default -> false; }; } } diff --git a/backend/src/main/resources/db/migration/V35__extend_job_categories.sql b/backend/src/main/resources/db/migration/V35__extend_job_categories.sql new file mode 100644 index 00000000..79be3df5 --- /dev/null +++ b/backend/src/main/resources/db/migration/V35__extend_job_categories.sql @@ -0,0 +1,27 @@ +-- 직군 확장: 개발 4종 → 개발 8종 + 비개발 12종. +-- +-- 서비스는 "IT 직군"이 아니라 취준생 전반을 대상으로 하는데 직군 선택지에 개발 직무만 +-- 있었다. 기존 4개는 이름을 그대로 두므로 이미 쌓인 세션·통계는 영향이 없다. +-- +-- CHECK 제약이 두 곳에 있다(V1 의 interview_sessions.job_category, V14 의 +-- session_job_categories.job_category). 한 곳만 고치면 다른 쪽에서 INSERT 가 터진다. + +-- 제약 이름은 운영 DB 에서 직접 확인했다(pg_constraint) — 추측한 이름으로 DROP 하면 +-- IF EXISTS 가 조용히 넘어가고 옛 제약이 남아 새 값 INSERT 가 터진다. +ALTER TABLE interview_sessions DROP CONSTRAINT IF EXISTS chk_interview_sessions_job_category; +ALTER TABLE interview_sessions + ADD CONSTRAINT chk_interview_sessions_job_category + CHECK (job_category IN ( + 'FRONTEND', 'BACKEND', 'INFRA', 'DBA', 'MOBILE', 'DATA_AI', 'SECURITY', 'QA', + 'PLANNING', 'MARKETING', 'SALES', 'HR', 'FINANCE', 'DESIGN', + 'MANUFACTURING', 'RND', 'CUSTOMER_SERVICE', 'LOGISTICS', 'LEGAL', 'PUBLIC' + )); + +ALTER TABLE session_job_categories DROP CONSTRAINT IF EXISTS chk_session_job_category; +ALTER TABLE session_job_categories + ADD CONSTRAINT chk_session_job_category + CHECK (job_category IN ( + 'FRONTEND', 'BACKEND', 'INFRA', 'DBA', 'MOBILE', 'DATA_AI', 'SECURITY', 'QA', + 'PLANNING', 'MARKETING', 'SALES', 'HR', 'FINANCE', 'DESIGN', + 'MANUFACTURING', 'RND', 'CUSTOMER_SERVICE', 'LOGISTICS', 'LEGAL', 'PUBLIC' + )); diff --git a/backend/src/test/java/com/stackup/stackup/session/domain/JobCategoryPersistenceTest.java b/backend/src/test/java/com/stackup/stackup/session/domain/JobCategoryPersistenceTest.java new file mode 100644 index 00000000..e44d2ac8 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/domain/JobCategoryPersistenceTest.java @@ -0,0 +1,70 @@ +package com.stackup.stackup.session.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.support.PostgresRepositoryTest; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * 새 직군이 실제로 저장되는지 확인한다. + * + *

enum 에 값을 추가해도 DB CHECK 제약이 그대로면 그 직군으로 만든 세션은 INSERT 에서 + * 터진다. 그리고 그 실패는 사용자가 면접을 시작하는 순간에만 드러난다 — enum·프롬프트· + * UI 를 다 고쳐 놓고 마이그레이션 한 줄을 빠뜨리면 정확히 그렇게 된다. + * + *

CHECK 제약이 두 곳(interview_sessions, session_job_categories)에 있어서 한 곳만 고쳐도 + * 절반은 통과한다 — 다중 선택까지 저장해 둘 다 건드린다. + */ +@PostgresRepositoryTest +class JobCategoryPersistenceTest { + + @Autowired UserRepository userRepository; + @Autowired InterviewSessionRepository sessionRepository; + + @Test + void 비개발_직군으로_세션을_만들_수_있다() { + User user = userRepository.save(User.createGithubUser(97001L, "non-eng", null, null, "t")); + + InterviewSession saved = sessionRepository.save(InterviewSession.create( + user, "영업 면접", null, SessionMode.PERSONALITY, + List.of(JobCategory.SALES), 5, 30, null, null)); + sessionRepository.flush(); + + assertThat(saved.getJobCategory()).isEqualTo(JobCategory.SALES); + assertThat(saved.getJobCategories()).containsExactly(JobCategory.SALES); + } + + @Test + void 모든_직군이_CHECK_제약을_통과한다() { + // 하나라도 마이그레이션 목록에서 빠지면 여기서 터진다. + long id = 97100L; + for (JobCategory category : JobCategory.values()) { + User user = userRepository.save( + User.createGithubUser(id++, "u" + category.name(), null, null, "t")); + sessionRepository.save(InterviewSession.create( + user, category.koreanLabel(), null, SessionMode.TECHNICAL, + List.of(category), 5, 30, null, null)); + } + sessionRepository.flush(); + + assertThat(sessionRepository.count()).isEqualTo(JobCategory.values().length); + } + + @Test + void 개발_비개발_다중_선택도_저장된다() { + User user = userRepository.save(User.createGithubUser(97200L, "mixed", null, null, "t")); + + InterviewSession saved = sessionRepository.save(InterviewSession.create( + user, "혼합", null, SessionMode.INTEGRATED, + List.of(JobCategory.BACKEND, JobCategory.PLANNING), 5, 30, null, null)); + sessionRepository.flush(); + + // session_job_categories 쪽 CHECK 까지 건드린다. + assertThat(saved.getJobCategories()) + .containsExactly(JobCategory.BACKEND, JobCategory.PLANNING); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/domain/JobCategoryTest.java b/backend/src/test/java/com/stackup/stackup/session/domain/JobCategoryTest.java new file mode 100644 index 00000000..6da21ea0 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/domain/JobCategoryTest.java @@ -0,0 +1,80 @@ +package com.stackup.stackup.session.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +/** + * 직군 목록은 네 군데에 흩어져 있다 — 이 enum, Flyway CHECK 제약, AI 서버의 + * Pydantic Literal, 프론트 선택지. 하나만 빠뜨리면 그 직군으로 만든 세션이 저장에서 + * 터지거나(CHECK) 메시지 검증에서 튕겨 조용히 DLQ 로 간다. + * + *

사람이 네 곳을 동시에 맞추는 일을 믿지 않는다. 여기서는 enum ↔ 마이그레이션을 + * 대조하고, Python Literal 대조는 AI 쪽 테스트가 같은 파일을 읽어 맡는다. + */ +class JobCategoryTest { + + private static final Path MIGRATION = Path.of( + "src/main/resources/db/migration/V35__extend_job_categories.sql"); + + @Test + void enum_과_마이그레이션_CHECK_목록이_같다() throws IOException { + String sql = Files.readString(MIGRATION, StandardCharsets.UTF_8); + + Matcher m = Pattern.compile("'([A-Z_]+)'").matcher(sql); + Set inSql = m.results().map(r -> r.group(1)).collect(Collectors.toSet()); + Set inEnum = Arrays.stream(JobCategory.values()) + .map(Enum::name).collect(Collectors.toSet()); + + assertThat(inSql) + .as("CHECK 제약과 enum 이 어긋나면 그 직군 세션의 INSERT 가 터진다") + .isEqualTo(inEnum); + } + + @Test + void 모든_직군에_한국어_라벨이_있다() { + for (JobCategory c : JobCategory.values()) { + // DBA 처럼 한국어 표기가 없는 직군은 이름이 곧 라벨이라 같아도 된다. + assertThat(c.koreanLabel()) + .as("%s 의 한국어 라벨", c) + .isNotBlank(); + } + } + + @Test + void 기존_개발_직군_이름은_바뀌지_않는다() { + // enum 이름이 DB 에 문자열로 저장된다 — 바꾸면 이미 쌓인 세션·통계가 깨진다. + assertThat(JobCategory.valueOf("FRONTEND")).isNotNull(); + assertThat(JobCategory.valueOf("BACKEND")).isNotNull(); + assertThat(JobCategory.valueOf("INFRA")).isNotNull(); + assertThat(JobCategory.valueOf("DBA")).isNotNull(); + } + + @Test + void 비개발_직군이_포함되어_있다() { + // 서비스 대상이 IT 직군이 아니라 취준생 전반이다. + long nonEngineering = Arrays.stream(JobCategory.values()) + .filter(c -> !c.isEngineering()).count(); + + assertThat(nonEngineering) + .as("비개발 직군이 없으면 개발자가 아닌 지원자는 고를 수 있는 게 없다") + .isGreaterThanOrEqualTo(10); + } + + @Test + void isEngineering_이_개발_직군만_참이다() { + assertThat(JobCategory.BACKEND.isEngineering()).isTrue(); + assertThat(JobCategory.DATA_AI.isEngineering()).isTrue(); + assertThat(JobCategory.SALES.isEngineering()).isFalse(); + assertThat(JobCategory.HR.isEngineering()).isFalse(); + } +} diff --git a/docs/product-overview.md b/docs/product-overview.md index e295e4bf..17a21a43 100644 --- a/docs/product-overview.md +++ b/docs/product-overview.md @@ -1,6 +1,6 @@ # Product Overview — StackUp -> IT 직군 멀티모달 AI 면접 시뮬레이터 +> 멀티모달 AI 모의면접 시뮬레이터 (취준생 전반 — 개발·비개발 직군) --- diff --git a/frontend/index.html b/frontend/index.html index 153a8233..da45cedd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -17,7 +17,7 @@ rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=Geist+Mono:wght@400;500;600&display=swap" /> - STACK-UP — IT 면접 시뮬레이터 + STACK-UP — AI 모의면접 시뮬레이터 - + - + = Object.fromEntries( + [...ENGINEERING_JOB_CATEGORIES, ...NON_ENGINEERING_JOB_CATEGORIES].map( + (o) => [o.value, o.label] as const, + ), +) as Record + +/** 개발 직군인가. GitHub 레포처럼 개발자에게만 의미 있는 자료를 안내할 때 쓴다. */ +export function isEngineeringJob(value: JobCategory): boolean { + return ENGINEERING_JOB_CATEGORIES.some((o) => o.value === value) +} diff --git a/frontend/src/features/history/ui/SessionCard.tsx b/frontend/src/features/history/ui/SessionCard.tsx index bcdb95b1..1191c7c7 100644 --- a/frontend/src/features/history/ui/SessionCard.tsx +++ b/frontend/src/features/history/ui/SessionCard.tsx @@ -2,6 +2,7 @@ import { Link } from 'react-router-dom' import { StatusBadge, type StatusTone } from '@/shared/ui' import { formatDate } from '@/shared/utils' import type { Session } from '../api/historyApi' +import { JOB_CATEGORY_LABEL } from '@/domain/session/model/jobCategory' const STATUS: Record = { READY: { label: '준비', tone: 'neutral' }, @@ -18,12 +19,6 @@ const MODE: Record = { JOB_TAILORED: '직무 맞춤', } -const JOB: Record = { - FRONTEND: '프론트엔드', - BACKEND: '백엔드', - INFRA: '인프라', - DBA: 'DBA', -} /** * 상태별로 눌렀을 때 갈 곳. 예전에는 COMPLETED 만 링크였고 나머지는 아예 눌리지 않아, @@ -45,7 +40,7 @@ export function SessionCard({ session }: { session: Session }) { : session.jobCategory ? [session.jobCategory] : [] - const jobLabel = jobs.map((j) => JOB[j] ?? j).join('·') + const jobLabel = jobs.map((j) => JOB_CATEGORY_LABEL[j] ?? j).join('·') const body = (

diff --git a/frontend/src/features/interview/ui/setup/JobCategorySelector.tsx b/frontend/src/features/interview/ui/setup/JobCategorySelector.tsx index bb9c8a4b..3f7b91b4 100644 --- a/frontend/src/features/interview/ui/setup/JobCategorySelector.tsx +++ b/frontend/src/features/interview/ui/setup/JobCategorySelector.tsx @@ -1,13 +1,17 @@ +import { + ENGINEERING_JOB_CATEGORIES, + NON_ENGINEERING_JOB_CATEGORIES, +} from '@/domain/session/model/jobCategory' import { CheckboxCardGroup } from '@/shared/ui/CheckboxCardGroup' import type { JobCategory } from '@/domain/session' -const OPTIONS = [ - { value: 'FRONTEND' as const, label: '프론트엔드' }, - { value: 'BACKEND' as const, label: '백엔드' }, - { value: 'INFRA' as const, label: '인프라' }, - { value: 'DBA' as const, label: 'DBA' }, -] - +/** + * 직군 선택. + * + * 개발/비개발로 나눠 보여준다 — 20개를 한 덩어리로 늘어놓으면 비개발 지원자가 자기 직군을 + * 찾기 전에 개발 직무만 훑고 "여긴 개발자용이구나" 하고 나간다. 실제로 선택지가 개발 4종뿐일 + * 때가 그랬다. + */ export function JobCategorySelector({ value, onToggle, @@ -15,5 +19,26 @@ export function JobCategorySelector({ 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 f3d6998f..99e874b1 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -1098,7 +1098,7 @@ export interface components { memo?: string; /** @enum {string} */ mode: "TECHNICAL" | "PERSONALITY" | "INTEGRATED" | "JOB_TAILORED"; - jobCategories: ("FRONTEND" | "BACKEND" | "INFRA" | "DBA")[]; + jobCategories: ("FRONTEND" | "BACKEND" | "INFRA" | "DBA" | "MOBILE" | "DATA_AI" | "SECURITY" | "QA" | "PLANNING" | "MARKETING" | "SALES" | "HR" | "FINANCE" | "DESIGN" | "MANUFACTURING" | "RND" | "CUSTOMER_SERVICE" | "LOGISTICS" | "LEGAL" | "PUBLIC")[]; /** Format: int32 */ maxQuestions?: number; /** Format: int32 */ @@ -1119,8 +1119,8 @@ export interface components { /** @enum {string} */ mode?: "TECHNICAL" | "PERSONALITY" | "INTEGRATED" | "JOB_TAILORED"; /** @enum {string} */ - jobCategory?: "FRONTEND" | "BACKEND" | "INFRA" | "DBA"; - jobCategories?: ("FRONTEND" | "BACKEND" | "INFRA" | "DBA")[]; + jobCategory?: "FRONTEND" | "BACKEND" | "INFRA" | "DBA" | "MOBILE" | "DATA_AI" | "SECURITY" | "QA" | "PLANNING" | "MARKETING" | "SALES" | "HR" | "FINANCE" | "DESIGN" | "MANUFACTURING" | "RND" | "CUSTOMER_SERVICE" | "LOGISTICS" | "LEGAL" | "PUBLIC"; + jobCategories?: ("FRONTEND" | "BACKEND" | "INFRA" | "DBA" | "MOBILE" | "DATA_AI" | "SECURITY" | "QA" | "PLANNING" | "MARKETING" | "SALES" | "HR" | "FINANCE" | "DESIGN" | "MANUFACTURING" | "RND" | "CUSTOMER_SERVICE" | "LOGISTICS" | "LEGAL" | "PUBLIC")[]; /** Format: int32 */ maxQuestions?: number; /** Format: int32 */ diff --git a/frontend/src/widgets/home-hero/ui/HomeHero.tsx b/frontend/src/widgets/home-hero/ui/HomeHero.tsx index 22cb97d9..ad66b02a 100644 --- a/frontend/src/widgets/home-hero/ui/HomeHero.tsx +++ b/frontend/src/widgets/home-hero/ui/HomeHero.tsx @@ -20,7 +20,7 @@ export function HomeHero() {

- IT 면접 시뮬레이터 + AI 모의면접 시뮬레이터

{/* 브랜드를 히어로의 주역으로 — 하이픈만 브랜드색으로 끊어 로고 락업처럼 읽히게. */}