From d2006381e62d476d629e28e6a9779f5c34959270 Mon Sep 17 00:00:00 2001 From: jmj Date: Mon, 28 Sep 2026 21:58:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A7=81=EA=B5=B0=EC=9D=84=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=204=EC=A2=85=20=E2=86=92=20=EA=B0=9C=EB=B0=9C=208?= =?UTF-8?q?=EC=A2=85=20+=20=EB=B9=84=EA=B0=9C=EB=B0=9C=2012=EC=A2=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서비스 대상은 IT 직군이 아니라 **취준생 전반**인데, 면접 시작 화면의 직군 선택지가 프론트엔드·백엔드·인프라·DBA 넷뿐이었다. 개발자가 아닌 지원자는 고를 수 있는 게 없었다. ## 직군 개발 FRONTEND BACKEND INFRA DBA + MOBILE DATA_AI SECURITY QA 비개발 PLANNING MARKETING SALES HR FINANCE DESIGN MANUFACTURING RND CUSTOMER_SERVICE LOGISTICS LEGAL PUBLIC 개발은 세부 직무, 비개발은 대분류 단위다. 입도가 섞이지만 기존 4개를 그대로 둬야 이미 쌓인 세션·통계가 유지된다(enum 이름이 DB 에 문자열로 저장된다). 더 좁은 직무는 **직무 맞춤 모드**(회사명 + 채용공고)가 받는다 — 자유 입력 창구는 이미 있다. ## 평가 관점을 직군의 언어로 썼다 이게 이름만 늘리는 것과의 차이다. 기본 fallback 이 "기술 정확성, 깊이, trade-off" 였는데, 영업·인사 지원자에게 그 기준을 들이대면 **점수의 근거가 통째로 헛돈다.** 기본 문구를 "직무 이해도, 판단의 근거, 경험의 구체성" 으로 바꾸고, 20개 직군마다 실무 관점을 따로 썼다. 영업 고객 문제 파악과 제안 논리, 거절·이견 대응, 파이프라인 관리 인사 채용·평가 기준의 공정성, 노동 관계 법규, 비밀 유지 감각 법무 계약·규정 해석, 법적 위험 식별, 사업 요구와 준법의 조율 ## 목록이 네 군데에 흩어져 있다 enum · Flyway CHECK · Pydantic Literal · 프론트 선택지. 하나만 빠뜨리면 그 직군 세션이 **INSERT 에서 터지거나**(CHECK) 메시지 검증에서 튕겨 조용히 DLQ 로 간다. 사람이 네 곳을 동시에 맞추는 일을 믿지 않는다: - AI: 5곳에 복사돼 있던 Literal 을 `model/messages/job_category.py` 하나로 - 프론트: 선택 화면과 히스토리 카드가 각자 들고 있던 라벨 맵을 하나로 (한쪽만 고치면 히스토리에 영어 코드가 그대로 뜬다) - Java 테스트: enum ↔ 마이그레이션 CHECK 대조 - Python 테스트: Literal ↔ 같은 마이그레이션 파일 대조 가드가 실제로 잡는지 확인했다 — 마이그레이션에서 SALES 를 빼니 양쪽 다 실패했다. ## 검증 - `JobCategoryPersistenceTest`(@PostgresRepositoryTest): **20개 직군 전부** 실제 PostgreSQL 에 INSERT 되는지 + 개발/비개발 다중 선택이 session_job_categories 까지 저장되는지. CHECK 가 두 곳이라 한 곳만 고쳐도 절반은 통과하기 때문에 둘 다 건드린다. - 백엔드 338건 / AI 431건 / 프론트 201건 통과 ## UI·카피 선택지를 개발/비개발로 나눴다 — 20개를 한 덩어리로 늘어놓으면 비개발 지원자가 자기 직군을 찾기 전에 개발 직무만 훑고 나간다. "IT 면접 시뮬레이터" → "AI 모의면접 시뮬레이터"(타이틀·OG·트위터·히어로), README·docs·루트 CLAUDE.md 도 같이. GitHub 레포 분석은 그대로 두고 개발 직군용 자료로 남긴다(`isEngineering()` / `isEngineeringJob()` 추가). ## 남은 것 점수 축 이름이 `technical_accuracy`("기술 정확도")다. 비개발 직군에는 어울리지 않지만 DB 컬럼·API 필드·프론트 표시가 묶여 있어 별도 PR 로 다룬다. --- CLAUDE.md | 4 +- README.md | 2 +- ai/CLAUDE.md | 6 +- .../chain/feedback_generation_chain.py | 92 ++++++++++++++++++- ai/src/ai_server/model/messages/feedback.py | 3 +- ai/src/ai_server/model/messages/followup.py | 3 +- .../ai_server/model/messages/job_category.py | 34 +++++++ ai/src/ai_server/model/messages/questions.py | 3 +- ai/src/ai_server/model/messages/tts.py | 3 +- ai/src/ai_server/model/messages/voice.py | 3 +- ai/tests/test_feedback_panel.py | 52 ++++++++++- backend/openapi.json | 6 +- .../stackup/session/domain/JobCategory.java | 56 +++++++++-- .../migration/V35__extend_job_categories.sql | 27 ++++++ .../domain/JobCategoryPersistenceTest.java | 70 ++++++++++++++ .../session/domain/JobCategoryTest.java | 80 ++++++++++++++++ docs/product-overview.md | 2 +- frontend/index.html | 6 +- .../src/domain/session/model/jobCategory.ts | 48 ++++++++++ .../src/features/history/ui/SessionCard.tsx | 9 +- .../ui/setup/JobCategorySelector.tsx | 41 +++++++-- frontend/src/shared/api/generated.ts | 6 +- .../src/widgets/home-hero/ui/HomeHero.tsx | 2 +- 23 files changed, 510 insertions(+), 48 deletions(-) create mode 100644 ai/src/ai_server/model/messages/job_category.py create mode 100644 backend/src/main/resources/db/migration/V35__extend_job_categories.sql create mode 100644 backend/src/test/java/com/stackup/stackup/session/domain/JobCategoryPersistenceTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/domain/JobCategoryTest.java create mode 100644 frontend/src/domain/session/model/jobCategory.ts 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 모의면접 시뮬레이터

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