Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
**내 이력서를 아는 면접관과 연습하세요.**

GitHub 레포지토리와 이력서를 분석해 나만을 위한 질문을 만들고,
얕은 답변은 다시 파고드는 IT 직군 AI 모의면접 시뮬레이터입니다.
얕은 답변은 다시 파고드는 AI 모의면접 시뮬레이터입니다. 개발·비개발 직군 모두 지원합니다.

[stack-up.shop](https://stack-up.shop)

Expand Down
6 changes: 3 additions & 3 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)해 "판단 불가(정상)"와
Expand Down
92 changes: 90 additions & 2 deletions ai/src/ai_server/chain/feedback_generation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, 접근성·크로스브라우저 "
Expand All @@ -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": (
"- 정책·제도 이해와 절차 준수, 공공성과 형평성 판단, 민원·이해관계 조율 경험을 "
"봅니다. 질문의 '기대 신호'를 답변이 얼마나 짚었는지를 핵심 근거로 삼습니다."
),
}


Expand Down
3 changes: 2 additions & 1 deletion ai/src/ai_server/model/messages/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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 가 사유를 추가해도 피드백 파싱이 깨지지 않게 한다.
Expand Down
3 changes: 2 additions & 1 deletion ai/src/ai_server/model/messages/followup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand All @@ -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 충족도 채점에 사용.
Expand Down
34 changes: 34 additions & 0 deletions ai/src/ai_server/model/messages/job_category.py
Original file line number Diff line number Diff line change
@@ -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",
]
3 changes: 2 additions & 1 deletion ai/src/ai_server/model/messages/questions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion ai/src/ai_server/model/messages/tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion ai/src/ai_server/model/messages/voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand All @@ -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):
Expand Down
52 changes: 51 additions & 1 deletion ai/tests/test_feedback_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "프론트엔드 직군 시니어 기술 면접관"
Expand Down
6 changes: 3 additions & 3 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down Expand Up @@ -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" : {
Expand Down
Loading
Loading