diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index b05f99d6..bd485db7 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -325,12 +325,20 @@ docker run --env-file .env -p 8000:8000 stackup-ai - 콜백: `callback.questions` (`kind=POOL|FOLLOWUP`) - **자기소개 기반 질문 생성**: `generate.questions` 는 자기소개 답변을 받은 뒤 발행되며, payload 의 `selfIntroAnswer` 를 프롬프트(`chain/prompts/question_generation.py`)의 1차 근거로 사용한다(없으면 자료만으로). + - **직무 맞춤(JOB_TAILORED) 질문 생성**: `mode=JOB_TAILORED` 면 payload 의 `targetCompanyName`· + `targetJobDescription`(JD)이 채워진다. 프롬프트가 `target_role` 블록으로 받아 이력서↔JD 교집합·갭, + JD 요구 충족 검증, 지원동기·컬처핏 질문을 우선 생성한다(다른 모드는 '(일반 면접)' 안내라 무시). - **자기소개 첫인상 평가 본 구현**: `FeedbackConsumer` 가 `messages[]` 에서 `category=SELF_INTRODUCTION` 질문+답변을 찾아 `LlmSelfIntroEvaluator`(Flash, `chain/prompts/self_intro_evaluation.py`)로 첫인상 (전달력·구조·간결성·직무적합성)을 평가하고, 결과를 `panelBreakdown` 의 `evaluator="첫인상"` 항목으로 덧붙인다(종합 generate 와 `asyncio.gather` 병렬, 실패해도 피드백 계속). 이 항목은 **종합 점수 집계에 미포함** — 메인 generator 가 모른 채 overall 을 계산한 뒤 표시용으로만 append 한다. 레거시 세션(자기소개 없음)·빈 답변은 건너뛴다. +- **직무 적합도 + 직무 이해도 평가 본 구현**: `mode=JOB_TAILORED` + JD 있을 때 `LlmJobFitEvaluator`(Pro, + `chain/prompts/job_fit_evaluation.py`)가 면접 전사·자료를 채용공고(JD)와 대조해 **두 축**을 한 번의 + 구조화 호출(`JobFitResult{fit, understanding}`)로 평가: `직무 적합도`(JD 요구 역량 매칭) + `직무 이해도` + (직무가 무엇을 하는 자리인지 이해·지원동기). 두 축을 `panelBreakdown` 의 `evaluator="직무 적합도"`· + `evaluator="직무 이해도"` 항목으로 append(첫인상과 같은 병렬·미집계 메커니즘). 그 외 모드/빈 JD/실패는 건너뜀. - **꼬리질문 토큰 스트리밍 본 구현**: followup 출력을 `{json}` 구분자 포맷으로 바꾸고(`chain/prompts/followup_generation.py`), `StreamingFollowupGenerator`(`astream`)가 `` 토큰만 `SessionRealtimeNotifier`(`messaging/session_notify.py`)로 `SESSION_MESSAGE_DELTA` 발행(`stackup.realtime`/`realtime.session.notify`, Core 우회). `DONT_KNOW` 면 델타 미발행. 종료 후 `parse_followup_result` 로 검증해 기존 `callback.questions(FOLLOWUP, followupMessageId)` 발행. 와이어링은 `messaging/runner.py`(분석 진행 publisher 재사용). - **문장 단위 TTS 본 구현 (Part B)**: followup consumer 스트림 루프가 `chain/sentence_split.next_sentences` 로 문장 경계를 잡아, 문장마다 `TtsProvider` 인라인 합성(`asyncio.create_task` 백그라운드, 텍스트 델타 비차단)→S3 `interview/tts/{sid}/{mid}/seg-{seq}.{ext}` PUT→`SessionRealtimeNotifier.emit_audio`(`SESSION_MESSAGE_AUDIO`). 콜백 전 `gather` 로 수거. 라이브 세그먼트는 휘발성(DB 미기록). - **임베딩 본 구현** (`rag/`): `MarkdownChunker` + `GeminiEmbeddingProvider` (1536d, `gemini-embedding-001`). diff --git a/ai/src/ai_server/chain/feedback_generation_chain.py b/ai/src/ai_server/chain/feedback_generation_chain.py index ded53703..066ee27a 100644 --- a/ai/src/ai_server/chain/feedback_generation_chain.py +++ b/ai/src/ai_server/chain/feedback_generation_chain.py @@ -14,6 +14,7 @@ from ai_server.chain.prompts import ( feedback_panel, feedback_synthesis, + job_fit_evaluation, self_intro_evaluation, ) from ai_server.config.settings import Settings @@ -387,6 +388,103 @@ async def evaluate( return result +# ── 직무 적합도 + 직무 이해도 평가 (직무 맞춤 모드 전용) ─────────────────────── +# 면접의 핵심은 직무 적합성이므로 두 축을 분리해 평가한다(한 번의 호출로 구조화 출력): +# · 직무 적합도(fit) — JD 요구 기술·경험·책임을 실제로 갖췄는가(역량 매칭). +# · 직무 이해도(understanding) — 직무가 무엇을 하는 자리인지·핵심 책임을 이해하고 동기로 연결했는가. +# 둘 다 패널 항목으로 표시하되 종합 점수 집계에는 포함하지 않는다(별도 정성 평가). + +JOB_FIT_EVALUATOR_LABEL = "직무 적합도" +JOB_FIT_DIMENSION = "채용공고(JD) 요구 대비 역량 적합도·갭" +ROLE_UNDERSTANDING_LABEL = "직무 이해도" +ROLE_UNDERSTANDING_DIMENSION = "직무 이해·지원동기 연결" + + +class JobFitResult(BaseModel): + """직무 맞춤 평가의 두 축. 각 축은 EvaluatorResult 형태(score/strength/weakness/detail/rationale).""" + + fit: EvaluatorResult = Field(default_factory=EvaluatorResult) + understanding: EvaluatorResult = Field(default_factory=EvaluatorResult) + + +def build_job_fit_evaluation_chain( + settings: Settings, core_client: CoreClient | None = None +) -> Runnable: + """면접 답변·자료를 JD 와 대조해 직무 적합도·직무 이해도를 함께 평가하는 체인(Pro — 갭 추론).""" + from langchain_openai import ChatOpenAI + + parser = PydanticOutputParser(pydantic_object=JobFitResult) + prompt = ChatPromptTemplate.from_messages( + [ + ("system", job_fit_evaluation.SYSTEM_PROMPT), + ("human", job_fit_evaluation.HUMAN_PROMPT), + ] + ).partial(format_instructions=parser.get_format_instructions()) + + callbacks = [] + if core_client is not None: + callbacks.append( + CoreAiLogCallback( + core_client=core_client, + request_type="generate.feedback.job_fit", + default_model=settings.llm_pro_model, + ) + ) + + llm = ChatOpenAI( + model=settings.llm_pro_model, + temperature=settings.llm_pro_temperature, + api_key=settings.llm_api_key or None, + base_url=settings.llm_base_url, + callbacks=callbacks, + ) + return prompt | llm | parser + + +class JobFitEvaluator(Protocol): + async def evaluate( + self, + *, + company_name: str, + job_description: str, + job_category: str, + mode: str, + transcript: str, + rag_context: str = "(none)", + ) -> JobFitResult: ... + + +class LlmJobFitEvaluator: + def __init__(self, chain: Runnable) -> None: + self._chain = chain + + async def evaluate( + self, + *, + company_name: str, + job_description: str, + job_category: str, + mode: str, + transcript: str, + rag_context: str = "(none)", + ) -> JobFitResult: + result = await self._chain.ainvoke( + { + "company_name": company_name or "(회사명 미입력)", + "job_description": job_description or "(JD 본문 없음)", + "job_category": job_category, + "mode": mode, + "transcript": transcript, + "rag_context": rag_context or "(none)", + } + ) + if not isinstance(result, JobFitResult): + raise TypeError( + f"chain returned {type(result).__name__}, expected JobFitResult" + ) + return result + + def _weighted_overall(pairs: list[tuple[float | None, float]]) -> float | None: """(score, weight) 중 score 가 있는 것만 가중평균. 전부 None 이면 None.""" present = [(s, w) for s, w in pairs if s is not None and w > 0] diff --git a/ai/src/ai_server/chain/prompts/job_fit_evaluation.py b/ai/src/ai_server/chain/prompts/job_fit_evaluation.py new file mode 100644 index 00000000..f5bd3dc1 --- /dev/null +++ b/ai/src/ai_server/chain/prompts/job_fit_evaluation.py @@ -0,0 +1,34 @@ +# 직무 적합도 + 직무 이해도 평가 프롬프트 (직무 맞춤 모드 전용). +# 면접의 핵심은 직무 적합성이므로 두 축을 분리해 평가한다(한 번의 호출, 구조화 출력). +# · fit(직무 적합도) — JD 요구를 실제로 갖췄는가(역량 매칭). +# · understanding(직무 이해도) — 직무가 무엇을 하는 자리인지 이해하고 동기로 연결했는가. +# 결과는 패널의 '직무 적합도'·'직무 이해도' 두 항목으로 표시되며, 종합 점수 집계엔 포함되지 않는다. + +SYSTEM_PROMPT = ( + "당신은 특정 회사·직무 채용의 평가위원입니다. 지원자의 면접 답변·자료를 채용공고(JD)와 " + "대조해 **두 축**을 각각 독립적으로 평가합니다.\n\n" + "【1. 직무 적합도(fit)】 — JD가 요구하는 기술·경험·책임을 지원자가 **실제로 갖췄는가**(역량 매칭). " + "답변·자료가 그 근거를 보였는지를 봅니다.\n" + "【2. 직무 이해도(understanding)】 — 지원자가 **이 직무가 무엇을 하는 자리인지·핵심 책임**을 이해하고, " + "왜 본인이 그 직무에 맞는지를 **지원동기와 연결**해 드러냈는가. 역량 보유와 별개로, 직무 자체에 대한 " + "이해의 깊이를 봅니다(직무를 피상적으로만 아는지, 실제 하는 일을 구체적으로 이해하는지).\n\n" + "- **두 축은 독립적**입니다: 역량은 충분해도 직무 이해가 얕을 수 있고(높은 fit·낮은 understanding), " + "그 반대도 가능합니다. 한 축의 점수를 다른 축에 전이하지 마세요.\n" + "- 기술 정답성 자체를 채점하지 말고, **'이 JD에 맞는 사람인가'** 관점만 봅니다. " + "JD에 없는 역량은 끌어들이지 않습니다.\n" + "- 각 축마다 score(0~100 정수, 근거 부족 시 null), strength/weakness 각 한 줄(한국어, 구체적), " + "detail(JD의 특정 요구/책임을 지목하며 답변을 인용해 2~4문장), score_rationale(점수 근거 한두 문장), " + "keywords(보완 키워드 0~3개)를 채웁니다.\n" + "- 점수 앵커: 90~100 폭넓게 충족·명확 / 70~89 대체로 충족하나 일부 갭 / 50~69 부분 충족 / " + "30~49 갭이 크고 근거 부족 / 0~29 거의 부적합·무이해.\n" + "- 응답은 반드시 지정된 JSON 스키마(fit, understanding 두 객체)를 따릅니다." +) + +HUMAN_PROMPT = ( + "지원 회사: {company_name}\n" + "직군: {job_category} / 면접 모드: {mode}\n\n" + "=== 채용공고(JD) ===\n{job_description}\n\n" + "=== 면접 전사(질문·답변) ===\n{transcript}\n\n" + "=== 지원자 자료 근거(이력서/레포 RAG) ===\n{rag_context}\n\n" + "{format_instructions}" +) diff --git a/ai/src/ai_server/chain/prompts/question_generation.py b/ai/src/ai_server/chain/prompts/question_generation.py index 4def7394..a4bc6760 100644 --- a/ai/src/ai_server/chain/prompts/question_generation.py +++ b/ai/src/ai_server/chain/prompts/question_generation.py @@ -14,6 +14,12 @@ "- 한국어로 작성하되 기술 용어는 영문 원어를 그대로 둡니다.\n" "- 질문 문장은 **간결하게**: 한두 문장(대략 80자 이내)으로, 장황한 배경 설명이나 " "중복 수식 없이 핵심만 묻습니다. 면접관이 입으로 자연스럽게 말할 길이여야 합니다.\n" + "- **직무 맞춤(타깃 회사/JD 제공 시)**: 채용공고의 요구 역량·기술·책임을 출발점으로, " + "지원자 자료(이력서·레포)와 JD 요구사항의 **교집합·갭**을 겨냥해 질문하세요. " + "(1) JD가 요구하는 기술/경험을 지원자가 실제로 갖췄는지 검증하는 질문, " + "(2) JD에 있으나 자료에서 근거가 약한 부분을 확인하는 질문, " + "(3) 지원동기·해당 회사/직무 적합성(왜 이 회사·직무인지, 컬처핏) 질문을 적절히 섞습니다. " + "타깃이 '(일반 면접 …)' 이면 이 항목은 무시합니다.\n" "- 응답은 반드시 지정된 JSON 스키마를 따릅니다." ) @@ -25,6 +31,10 @@ "---\n" "{self_introduction}\n" "---\n\n" + "타깃 회사/직무 (직무 맞춤 모드일 때만 채워짐):\n" + "---\n" + "{target_role}\n" + "---\n\n" "지원자 컨텍스트 (이력서/레포 분석):\n" "---\n" "{context}\n" diff --git a/ai/src/ai_server/chain/question_generation_chain.py b/ai/src/ai_server/chain/question_generation_chain.py index 8763a086..638be54a 100644 --- a/ai/src/ai_server/chain/question_generation_chain.py +++ b/ai/src/ai_server/chain/question_generation_chain.py @@ -29,6 +29,22 @@ def _format_self_introduction(self_introduction: str | None) -> str: return text if text else "(자기소개 없음)" +def _format_target_role(company_name: str | None, job_description: str | None) -> str: + """직무 맞춤 모드의 타깃 회사/JD 블록. 둘 다 없으면 일반 면접 안내.""" + company = (company_name or "").strip() + jd = (job_description or "").strip() + if not company and not jd: + return ( + "(일반 면접 — 특정 회사/직무 지정 없음. 지원자 자료와 자기소개만으로 출제.)" + ) + lines = [] + if company: + lines.append(f"지원 회사: {company}") + lines.append("채용공고(JD):") + lines.append(jd if jd else "(JD 본문 없음)") + return "\n".join(lines) + + class QuestionGenerator(Protocol): async def generate( self, @@ -39,6 +55,8 @@ async def generate( context: str, recent_questions: list[str] | None = None, self_introduction: str | None = None, + target_company_name: str | None = None, + target_job_description: str | None = None, ) -> GeneratedQuestionPool: ... @@ -55,6 +73,8 @@ async def generate( context: str, recent_questions: list[str] | None = None, self_introduction: str | None = None, + target_company_name: str | None = None, + target_job_description: str | None = None, ) -> GeneratedQuestionPool: result = await self._chain.ainvoke( { @@ -64,6 +84,9 @@ async def generate( "context": context, "recent_questions": _format_recent_questions(recent_questions), "self_introduction": _format_self_introduction(self_introduction), + "target_role": _format_target_role( + target_company_name, target_job_description + ), } ) if not isinstance(result, GeneratedQuestionPool): @@ -73,7 +96,9 @@ async def generate( return result -def build_question_generation_chain(settings: Settings, core_client: CoreClient | None = None) -> Runnable: +def build_question_generation_chain( + settings: Settings, core_client: CoreClient | None = None +) -> Runnable: from langchain_openai import ChatOpenAI parser = PydanticOutputParser(pydantic_object=GeneratedQuestionPool) @@ -86,11 +111,13 @@ def build_question_generation_chain(settings: Settings, core_client: CoreClient callbacks = [] if core_client is not None: - callbacks.append(CoreAiLogCallback( - core_client=core_client, - request_type="generate.questions", - default_model=settings.llm_pro_model, - )) + callbacks.append( + CoreAiLogCallback( + core_client=core_client, + request_type="generate.questions", + default_model=settings.llm_pro_model, + ) + ) llm = ChatOpenAI( model=settings.llm_pro_model, diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index f8ba65b7..0838417e 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -6,9 +6,15 @@ from aio_pika.abc import AbstractIncomingMessage from ai_server.chain.feedback_generation_chain import ( + JOB_FIT_DIMENSION, + JOB_FIT_EVALUATOR_LABEL, + ROLE_UNDERSTANDING_DIMENSION, + ROLE_UNDERSTANDING_LABEL, SELF_INTRO_DIMENSION, SELF_INTRO_EVALUATOR_LABEL, + EvaluatorResult, FeedbackGenerator, + JobFitEvaluator, SelfIntroEvaluator, ) from ai_server.core.client import CoreClient @@ -27,6 +33,7 @@ log = structlog.get_logger(__name__) _SELF_INTRO_CATEGORY = "SELF_INTRODUCTION" +_JOB_TAILORED_MODE = "JOB_TAILORED" class FeedbackConsumer: @@ -51,6 +58,7 @@ def __init__( embedder: EmbeddingProvider | None = None, rag_top_k: int = 5, self_intro_evaluator: SelfIntroEvaluator | None = None, + job_fit_evaluator: JobFitEvaluator | None = None, ) -> None: self._generator = generator self._publisher = publisher @@ -60,6 +68,7 @@ def __init__( self._embedder = embedder self._rag_top_k = rag_top_k self._self_intro_evaluator = self_intro_evaluator + self._job_fit_evaluator = job_fit_evaluator async def handle(self, message: AbstractIncomingMessage) -> None: async with message.process(requeue=False): @@ -100,8 +109,9 @@ async def handle(self, message: AbstractIncomingMessage) -> None: req.voice_analysis_summary ) - # 종합 피드백 + 자기소개 첫인상 평가를 병렬 실행(첫인상은 종합 점수에 미포함). - result, self_intro_item = await asyncio.gather( + # 종합 피드백 + 자기소개 첫인상 + 직무 적합도(직무 맞춤 모드)를 병렬 실행. + # 첫인상·직무 적합도는 종합 점수(overall)에 미포함 — generator 가 모른 채 계산한 뒤 표시용으로 덧붙인다. + result, self_intro_item, job_fit_items = await asyncio.gather( self._generator.generate( job_category=req.job_category, mode=req.mode, @@ -114,10 +124,11 @@ async def handle(self, message: AbstractIncomingMessage) -> None: domain_question_counts=req.domain_question_counts, ), self._evaluate_self_intro(req, voice_analysis_summary), + self._evaluate_job_fit(req, transcript, rag_context), ) if self_intro_item is not None: - # 종합 집계는 generator 가 첫인상을 모른 채 계산하므로, 여기서 표시용으로만 덧붙인다. result.panel_breakdown.append(self_intro_item) + result.panel_breakdown.extend(job_fit_items) payload = FeedbackCallbackPayload( session_id=req.session_id, @@ -181,6 +192,41 @@ async def _evaluate_self_intro( score_rationale=ev.score_rationale, ) + async def _evaluate_job_fit( + self, req: GenerateFeedbackRequest, transcript: str, rag_context: str + ) -> list[PanelBreakdownItem]: + """직무 맞춤 모드일 때 JD 대비 '직무 적합도'+'직무 이해도' 평가 → 패널 항목 2개. + 그 외 모드/빈 JD/실패는 빈 리스트.""" + if self._job_fit_evaluator is None: + return [] + if (req.mode or "") != _JOB_TAILORED_MODE: + return [] + jd = (req.target_job_description or "").strip() + if not jd: + return [] + try: + res = await self._job_fit_evaluator.evaluate( + company_name=req.target_company_name or "", + job_description=jd, + job_category=req.job_category, + mode=req.mode, + transcript=transcript, + rag_context=rag_context, + ) + except Exception as exc: # noqa: BLE001 + log.warning( + "feedback.job_fit.failed", error=str(exc), session_id=req.session_id + ) + return [] + return [ + _to_panel_item(JOB_FIT_EVALUATOR_LABEL, JOB_FIT_DIMENSION, res.fit), + _to_panel_item( + ROLE_UNDERSTANDING_LABEL, + ROLE_UNDERSTANDING_DIMENSION, + res.understanding, + ), + ] + async def _build_rag_context(self, req: GenerateFeedbackRequest) -> str: if not self._embedder or not req.context_document_ids: return "(none)" @@ -210,6 +256,21 @@ async def _build_rag_context(self, req: GenerateFeedbackRequest) -> str: ) +def _to_panel_item( + label: str, dimension: str, ev: EvaluatorResult +) -> PanelBreakdownItem: + """평가위원 결과(EvaluatorResult)를 패널 표시 항목으로 변환.""" + return PanelBreakdownItem( + evaluator=label, + dimension=dimension, + score=ev.score, + strength=ev.strength, + weakness=ev.weakness, + detail=ev.detail, + score_rationale=ev.score_rationale, + ) + + def _find_self_intro( messages: list[FeedbackMessageItem], ) -> tuple[FeedbackMessageItem, FeedbackMessageItem] | None: diff --git a/ai/src/ai_server/messaging/consumers/questions_consumer.py b/ai/src/ai_server/messaging/consumers/questions_consumer.py index 981fd7e2..04c50427 100644 --- a/ai/src/ai_server/messaging/consumers/questions_consumer.py +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -87,6 +87,8 @@ async def handle(self, message: AbstractIncomingMessage) -> None: context=context_text, recent_questions=req.recent_questions, self_introduction=req.self_intro_answer, + target_company_name=req.target_company_name, + target_job_description=req.target_job_description, ) payload = QuestionPoolCallbackPayload( diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index 9acc1f37..102886ba 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -20,9 +20,11 @@ build_streaming_followup_generator, ) from ai_server.chain.feedback_generation_chain import ( + LlmJobFitEvaluator, LlmSelfIntroEvaluator, PanelFeedbackGenerator, build_feedback_synthesis_chain, + build_job_fit_evaluation_chain, build_panel_evaluator_chain, build_self_intro_evaluation_chain, ) @@ -233,6 +235,10 @@ def __init__(self, settings: Settings) -> None: self_intro_evaluator=LlmSelfIntroEvaluator( build_self_intro_evaluation_chain(settings, core_client=core_client) ), + # 직무 적합도 평가(Pro, JD 갭 추론). 직무 맞춤 모드에서만 동작, 종합 점수엔 미포함. + job_fit_evaluator=LlmJobFitEvaluator( + build_job_fit_evaluation_chain(settings, core_client=core_client) + ), ) # 음성 답변 STT + 분석 (Phase 2) diff --git a/ai/src/ai_server/model/messages/feedback.py b/ai/src/ai_server/model/messages/feedback.py index d9a28d44..5cd3dcea 100644 --- a/ai/src/ai_server/model/messages/feedback.py +++ b/ai/src/ai_server/model/messages/feedback.py @@ -4,7 +4,7 @@ from ai_server.model._config import camel_config -InterviewMode = Literal["PERSONALITY", "TECHNICAL", "INTEGRATED"] +InterviewMode = Literal["PERSONALITY", "TECHNICAL", "INTEGRATED", "JOB_TAILORED"] class MessageEvaluation(BaseModel): @@ -64,6 +64,9 @@ class GenerateFeedbackRequest(BaseModel): voice_analysis_summary: VoiceAnalysisSummary | None = None # 다직군 패널 가중: 사용된 일반질문의 직군별 개수. 비면 단일 직군 평가. domain_question_counts: dict[str, int] = Field(default_factory=dict) + # 직무 맞춤(JOB_TAILORED) 모드 전용. 회사명 + 채용공고(JD). '직무 적합도' 평가의 근거. + target_company_name: str | None = None + target_job_description: str | None = None class PanelBreakdownItem(BaseModel): diff --git a/ai/src/ai_server/model/messages/questions.py b/ai/src/ai_server/model/messages/questions.py index 639a5c9a..db895380 100644 --- a/ai/src/ai_server/model/messages/questions.py +++ b/ai/src/ai_server/model/messages/questions.py @@ -4,7 +4,7 @@ from ai_server.model._config import camel_config -InterviewMode = Literal["PERSONALITY", "TECHNICAL", "INTEGRATED"] +InterviewMode = Literal["PERSONALITY", "TECHNICAL", "INTEGRATED", "JOB_TAILORED"] JobCategory = Literal["FRONTEND", "BACKEND", "INFRA", "DBA"] QuestionCategory = Literal[ "CS_FUNDAMENTAL", @@ -41,6 +41,9 @@ class GenerateQuestionsRequest(BaseModel): recent_questions: list[str] = [] # 지원자의 자기소개 답변. 모든 면접의 첫 질문이며 질문 생성의 1차 근거. 없으면 빈 문자열. self_intro_answer: str = "" + # 직무 맞춤(JOB_TAILORED) 모드 전용. 지원 회사명 + 채용공고(JD) 원문. 다른 모드는 None/빈값. + target_company_name: str | None = None + target_job_description: str | None = None class GeneratedQuestion(BaseModel): diff --git a/ai/tests/test_feedback_consumer.py b/ai/tests/test_feedback_consumer.py index 8b49528a..5d58aeef 100644 --- a/ai/tests/test_feedback_consumer.py +++ b/ai/tests/test_feedback_consumer.py @@ -8,7 +8,9 @@ from ai_server.chain.feedback_generation_chain import ( EvaluatorResult, FeedbackResult, + JobFitResult, LlmFeedbackGenerator, + LlmJobFitEvaluator, LlmSelfIntroEvaluator, ) from ai_server.chain.prompts.feedback_generation import HUMAN_PROMPT, SYSTEM_PROMPT @@ -497,6 +499,179 @@ async def ainvoke(self, value): assert chain.input["job_category"] == "BACKEND" +def _job_tailored_envelope( + *, with_jd: bool = True, mode: str = "JOB_TAILORED" +) -> bytes: + payload = { + "sessionId": 60, + "mode": mode, + "jobCategory": "BACKEND", + "totalQuestionCount": 2, + "endReason": "POOL_EXHAUSTED", + "messages": [ + {"id": 1, "sequenceNumber": 1, "role": "INTERVIEWER", "content": "ACID?"}, + { + "id": 2, + "sequenceNumber": 2, + "role": "INTERVIEWEE", + "content": "원자성·일관성·격리성·영속성", + "parentMessageId": 1, + }, + ], + "contextDocumentIds": [], + } + if with_jd: + payload["targetCompanyName"] = "토스" + payload["targetJobDescription"] = ( + "Kotlin/Spring 백엔드, 대용량 결제 시스템 경험 우대" + ) + env = { + "messageId": "fb-jt", + "messageType": "generate.feedback", + "version": "v1", + "traceId": "t-jt", + "publishedAt": "2026-05-30T00:00:00Z", + "publisher": "core-server", + "payload": payload, + "context": {"userId": 1, "sessionId": 60}, + } + return json.dumps(env).encode() + + +def _job_fit_evaluator(): + ev = MagicMock() + ev.evaluate = AsyncMock( + return_value=JobFitResult( + fit=EvaluatorResult( + score=72.0, + strength="결제 도메인 경험이 JD 핵심 요구와 일치", + weakness="대용량 트래픽 처리 근거는 미검증", + detail="JD의 '대용량 결제' 요구에 결제 경험은 부합하나, 처리량 근거가 약함.", + score_rationale="핵심 요구 충족하나 일부 갭", + ), + understanding=EvaluatorResult( + score=60.0, + strength="결제 도메인 책임은 인지", + weakness="직무가 다루는 범위를 피상적으로만 이해", + detail="이 직무의 핵심 책임을 구체적으로 짚지 못함.", + score_rationale="동기 연결이 약함", + ), + ) + ) + return ev + + +@pytest.mark.asyncio +async def test_consumer_appends_job_fit_panel_item(): + generator = _generator() + publisher = MagicMock() + publisher.publish = AsyncMock() + evaluator = _job_fit_evaluator() + + consumer = FeedbackConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.feedback", + core_client=MagicMock(), + embedder=None, + job_fit_evaluator=evaluator, + ) + await consumer.handle(_StubMessage(_job_tailored_envelope())) + + ev_kwargs = evaluator.evaluate.await_args.kwargs + assert ev_kwargs["company_name"] == "토스" + assert "대용량 결제" in ev_kwargs["job_description"] + + payload: FeedbackCallbackPayload = publisher.publish.await_args.kwargs["payload"] + fit_items = [b for b in payload.panel_breakdown if b.evaluator == "직무 적합도"] + assert len(fit_items) == 1 + assert fit_items[0].score == 72.0 + # 직무 이해도가 별도 항목으로 분리되어 포함된다. + und_items = [b for b in payload.panel_breakdown if b.evaluator == "직무 이해도"] + assert len(und_items) == 1 + assert und_items[0].score == 60.0 + + +@pytest.mark.asyncio +async def test_consumer_skips_job_fit_when_not_job_tailored(): + generator = _generator() + publisher = MagicMock() + publisher.publish = AsyncMock() + evaluator = _job_fit_evaluator() + + consumer = FeedbackConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.feedback", + core_client=MagicMock(), + embedder=None, + job_fit_evaluator=evaluator, + ) + # JD 가 실려 있어도 모드가 직무 맞춤이 아니면 평가 안 함. + await consumer.handle( + _StubMessage(_job_tailored_envelope(with_jd=True, mode="TECHNICAL")) + ) + + evaluator.evaluate.assert_not_awaited() + payload: FeedbackCallbackPayload = publisher.publish.await_args.kwargs["payload"] + assert all(b.evaluator != "직무 적합도" for b in payload.panel_breakdown) + + +@pytest.mark.asyncio +async def test_consumer_skips_job_fit_when_no_jd(): + generator = _generator() + publisher = MagicMock() + publisher.publish = AsyncMock() + evaluator = _job_fit_evaluator() + + consumer = FeedbackConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.feedback", + core_client=MagicMock(), + embedder=None, + job_fit_evaluator=evaluator, + ) + await consumer.handle(_StubMessage(_job_tailored_envelope(with_jd=False))) + + evaluator.evaluate.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_job_fit_evaluator_forwards_inputs_to_chain(): + class _FakeChain: + def __init__(self): + self.input = None + + async def ainvoke(self, value): + self.input = value + return JobFitResult( + fit=EvaluatorResult(score=70.0), + understanding=EvaluatorResult(score=55.0), + ) + + chain = _FakeChain() + evaluator = LlmJobFitEvaluator(chain) + + result = await evaluator.evaluate( + company_name="토스", + job_description="대용량 결제 백엔드", + job_category="BACKEND", + mode="JOB_TAILORED", + transcript="Q/A", + rag_context="resume chunk", + ) + + assert chain.input["company_name"] == "토스" + assert chain.input["job_description"] == "대용량 결제 백엔드" + assert chain.input["transcript"] == "Q/A" + assert result.fit.score == 70.0 + assert result.understanding.score == 55.0 + + def test_build_transcript_annotates_interviewee_evaluation(): from ai_server.messaging.consumers.feedback_consumer import _build_transcript from ai_server.model.messages.feedback import FeedbackMessageItem, MessageEvaluation diff --git a/ai/tests/test_question_generation_chain.py b/ai/tests/test_question_generation_chain.py index 2416b39f..edd05b93 100644 --- a/ai/tests/test_question_generation_chain.py +++ b/ai/tests/test_question_generation_chain.py @@ -9,9 +9,42 @@ LlmQuestionGenerator, _format_recent_questions, _format_self_introduction, + _format_target_role, ) +def test_format_target_role_empty_is_general(): + assert "일반 면접" in _format_target_role(None, None) + assert "일반 면접" in _format_target_role(" ", " ") + + +def test_format_target_role_includes_company_and_jd(): + out = _format_target_role("토스", "Kotlin/Spring 백엔드, 대용량 결제") + assert "지원 회사: 토스" in out + assert "채용공고(JD):" in out + assert "대용량 결제" in out + + +@pytest.mark.asyncio +async def test_generate_forwards_target_role_to_chain(): + chain = AsyncMock() + chain.ainvoke = AsyncMock(return_value=GeneratedQuestionPool(questions=[])) + generator = LlmQuestionGenerator(chain) + + await generator.generate( + job_categories=["BACKEND"], + mode="JOB_TAILORED", + max_questions=3, + context="ctx", + target_company_name="토스", + target_job_description="대용량 결제 시스템 백엔드", + ) + + target_role = chain.ainvoke.call_args.args[0]["target_role"] + assert "토스" in target_role + assert "대용량 결제" in target_role + + def test_format_recent_questions_empty(): assert _format_recent_questions(None) == "(없음)" assert _format_recent_questions([]) == "(없음)" diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 83494ef9..603d9f4c 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -359,6 +359,11 @@ docker compose up -d - ArchUnit 룰 적용 (의존 방향 · 순환 차단 · `@Transactional` application 한정 · entity는 domain 패키지) - 면접 도메인 (US-13~20) 본 구현: 세션 CRUD/start/end/interrupt, generate.questions 발행, callback.questions(POOL/FOLLOWUP) 수신, 자동 종료 +- **직무 맞춤 면접 모드(JOB_TAILORED) 본 구현**: `SessionMode.JOB_TAILORED` 추가(V18 — mode CHECK 갱신 + + `target_company_name`/`target_job_description` 컬럼). 이 모드는 회사명+채용공고(JD)를 받아(JD 필수, + `SessionService` 검증 `SESSION_JD_REQUIRED`) `InterviewSession.assignTargetRole` 로 보관. JD 는 + `SelfIntroAnsweredEvent`→`generate.questions`(적합도·지원동기 질문)와 `generate.feedback`(직무 적합도 + 평가)에 함께 실린다. 다른 모드는 JD 무시(null). - **첫 질문 자기소개 고정 본 구현**: 모든 면접의 첫 질문은 `InterviewMessage.selfIntroduction`(seq=1, category=`SELF_INTRODUCTION`)으로 세션 생성 직후 AI 없이 삽입(`QuestionsCallbackService.insertSelfIntroduction`, `SessionQuestionsRequester.onSessionCreated`). 질문 풀은 자기소개 **답변**을 받은 뒤 발행한다 — diff --git a/backend/openapi.json b/backend/openapi.json index d868d62a..37ee10b0 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -2588,7 +2588,7 @@ }, "mode" : { "type" : "string", - "enum" : [ "TECHNICAL", "PERSONALITY", "INTEGRATED" ] + "enum" : [ "TECHNICAL", "PERSONALITY", "INTEGRATED", "JOB_TAILORED" ] }, "jobCategories" : { "type" : "array", @@ -2626,6 +2626,16 @@ "type" : "integer", "format" : "int64" } + }, + "targetCompanyName" : { + "type" : "string", + "maxLength" : 200, + "minLength" : 0 + }, + "targetJobDescription" : { + "type" : "string", + "maxLength" : 20000, + "minLength" : 0 } }, "required" : [ "jobCategories", "mode" ] @@ -2645,7 +2655,7 @@ }, "mode" : { "type" : "string", - "enum" : [ "TECHNICAL", "PERSONALITY", "INTEGRATED" ] + "enum" : [ "TECHNICAL", "PERSONALITY", "INTEGRATED", "JOB_TAILORED" ] }, "jobCategory" : { "type" : "string", @@ -2697,6 +2707,12 @@ "format" : "int64" } }, + "targetCompanyName" : { + "type" : "string" + }, + "targetJobDescription" : { + "type" : "string" + }, "createdAt" : { "type" : "string", "format" : "date-time" diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java index 78054335..3fbdfaf0 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java @@ -33,6 +33,7 @@ public enum ApiErrorCode { SESSION_MAX_REACHED(HttpStatus.UNPROCESSABLE_CONTENT, "세션 제한에 도달했습니다."), SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "세션을 찾을 수 없습니다."), SESSION_FORBIDDEN(HttpStatus.FORBIDDEN, "세션에 접근할 수 없습니다."), + SESSION_JD_REQUIRED(HttpStatus.BAD_REQUEST, "직무 맞춤 면접은 채용공고(JD)를 입력해야 합니다."), FEEDBACK_NOT_READY(HttpStatus.NOT_FOUND, "피드백이 아직 생성되지 않았습니다."), FEEDBACK_NOT_FOUND(HttpStatus.NOT_FOUND, "공유된 피드백을 찾을 수 없습니다."), VOICE_EMPTY_FILE(HttpStatus.BAD_REQUEST, "음성 파일을 업로드할 수 없습니다."), diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java index 849d8c8f..f022995e 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackRequester.java @@ -85,7 +85,9 @@ public void onSessionEnded(SessionEndedEvent event) { messages, contextDocumentIds, summarizeVoiceAnalysis(event.sessionId()), - domainQuestionCounts + domainQuestionCounts, + session.getTargetCompanyName(), + session.getTargetJobDescription() ); publisher.publishToAi( diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java index 8aca69d5..80338149 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionFollowupRequester.java @@ -68,7 +68,9 @@ public void onAnswerSubmitted(AnswerSubmittedEvent event) { session.getMaxQuestions(), session.getGeneralQuestionCount(), contextDocumentIds, - answer.getContent() + answer.getContent(), + session.getTargetCompanyName(), + session.getTargetJobDescription() )); log.info("self-intro answered — requesting question pool. sessionId={}", session.getId()); return; 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 5991e2ad..9a081e25 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 @@ -87,7 +87,9 @@ public void onSelfIntroAnswered(SelfIntroAnsweredEvent event) { poolCount, event.maxQuestions(), recentQuestions, - event.selfIntroAnswer() + event.selfIntroAnswer(), + event.targetCompanyName(), + event.targetJobDescription() ); publisher.publishToAi( properties.routingKeys().generateQuestions(), 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 bf65a172..52cae9a3 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 @@ -13,6 +13,7 @@ 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.SessionMode; import com.stackup.stackup.session.domain.SessionContext; import com.stackup.stackup.session.domain.SessionContextRepository; import com.stackup.stackup.user.domain.User; @@ -46,6 +47,11 @@ public class SessionService { public SessionResult create(Long userId, SessionCreateCommand command) { User user = loadUser(userId); String title = resolveTitle(command); + // 직무 맞춤 모드는 채용공고(JD)가 질문·피드백의 핵심 근거이므로 필수. + if (command.mode() == SessionMode.JOB_TAILORED + && (command.targetJobDescription() == null || command.targetJobDescription().isBlank())) { + throw new DomainException(ApiErrorCode.SESSION_JD_REQUIRED); + } InterviewSession session = sessionRepository.save(InterviewSession.create( user, title, @@ -57,6 +63,10 @@ public SessionResult create(Long userId, SessionCreateCommand command) { command.generalQuestionCount(), command.maxFollowupsPerQuestion() )); + // 타깃 회사/JD 는 직무 맞춤 모드에서만 보관(다른 모드 입력값은 무시). + if (command.mode() == SessionMode.JOB_TAILORED) { + session.assignTargetRole(command.targetCompanyName(), command.targetJobDescription()); + } List linkedIds = linkContexts(session, userId, command.contextDocumentIds()); @@ -158,7 +168,13 @@ private String resolveTitle(SessionCreateCommand command) { String jobs = command.jobCategories().stream() .map(JobCategory::koreanLabel) .collect(Collectors.joining("·")); - return jobs + " " + command.mode().koreanLabel(); + String base = jobs + " " + command.mode().koreanLabel(); + // 직무 맞춤 면접은 회사명을 제목 앞에 붙여 히스토리·라이브 헤더에서 대상이 드러나게 한다. + if (command.mode() == SessionMode.JOB_TAILORED + && command.targetCompanyName() != null && !command.targetCompanyName().isBlank()) { + return command.targetCompanyName().trim() + " " + base; + } + return base; } private User loadUser(Long userId) { diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java index 335304e2..d684d698 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/GenerateFeedbackPayload.java @@ -15,7 +15,10 @@ public record GenerateFeedbackPayload( List contextDocumentIds, VoiceAnalysisSummary voiceAnalysisSummary, // 다직군 패널 가중: 사용된 일반질문의 직군별 개수(예: {"BACKEND":3,"FRONTEND":2}). 비면 단일. - Map domainQuestionCounts + Map domainQuestionCounts, + // 직무 맞춤 모드 전용. 회사명 + 채용공고(JD). '직무 적합도' 평가의 근거. 다른 모드는 null. + String targetCompanyName, + String targetJobDescription ) { public record MessageItem( 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 432e3498..a3d92d55 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 @@ -16,7 +16,10 @@ public record GenerateQuestionsPayload( // 같은 유저가 최근 면접에서 받은 질문들. AI 가 의미 중복 회피에 사용. List recentQuestions, // 지원자의 자기소개 답변. 질문 생성의 1차 근거(모든 면접의 기본). 없으면 빈 문자열. - String selfIntroAnswer + String selfIntroAnswer, + // 직무 맞춤 모드 전용. 지원 회사명 + 채용공고(JD). 적합도·지원동기 질문의 근거. 다른 모드는 null. + String targetCompanyName, + String targetJobDescription ) { public record DocumentContext( Long documentId, 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 f0e890ca..067bcdc6 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 @@ -13,6 +13,9 @@ public record SessionCreateCommand( Integer maxDurationMinutes, Integer generalQuestionCount, Integer maxFollowupsPerQuestion, - List contextDocumentIds + List contextDocumentIds, + // 직무 맞춤 모드 전용. 지원 회사명 + 채용공고(JD) 원문. + String targetCompanyName, + String targetJobDescription ) { } 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 d77332a7..473ef6f8 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 @@ -23,6 +23,9 @@ public record SessionResult( Instant startedAt, Instant endedAt, List contextDocumentIds, + // 직무 맞춤 모드 전용. 다른 모드는 null. + String targetCompanyName, + String targetJobDescription, Instant createdAt, Instant updatedAt ) { @@ -43,6 +46,8 @@ public static SessionResult of(InterviewSession session, List documentIds) session.getStartedAt(), session.getEndedAt(), documentIds, + session.getTargetCompanyName(), + session.getTargetJobDescription(), session.getCreatedAt(), session.getUpdatedAt() ); diff --git a/backend/src/main/java/com/stackup/stackup/session/application/event/SelfIntroAnsweredEvent.java b/backend/src/main/java/com/stackup/stackup/session/application/event/SelfIntroAnsweredEvent.java index edbe6a23..d59cca72 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/event/SelfIntroAnsweredEvent.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/event/SelfIntroAnsweredEvent.java @@ -15,6 +15,9 @@ public record SelfIntroAnsweredEvent( Integer maxQuestions, Integer generalQuestionCount, List contextDocumentIds, - String selfIntroAnswer + String selfIntroAnswer, + // 직무 맞춤 모드 전용 타깃 회사/JD. 다른 모드는 null. + String targetCompanyName, + String targetJobDescription ) { } 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 3ce80676..924b9524 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 @@ -81,6 +81,13 @@ public class InterviewSession extends BaseSoftDeleteEntity { @Column(name = "max_followups_per_question", nullable = false) private Integer maxFollowupsPerQuestion = 2; + // 직무 맞춤(JOB_TAILORED) 모드 전용: 지원 회사명 + 채용공고(JD) 원문. 다른 모드는 null. + @Column(name = "target_company_name", length = 200) + private String targetCompanyName; + + @Column(name = "target_job_description", columnDefinition = "text") + private String targetJobDescription; + @Column(nullable = false, length = 20) @Enumerated(EnumType.STRING) private SessionStatus status = SessionStatus.READY; @@ -148,6 +155,12 @@ public static InterviewSession create(User user, String title, String memo, Sess maxQuestions, maxDurationMinutes, generalQuestionCount, maxFollowupsPerQuestion); } + // 직무 맞춤 모드의 타깃 회사/JD 부여. create 시그니처를 늘리지 않으려 별도 메서드로 둔다. + public void assignTargetRole(String companyName, String jobDescription) { + this.targetCompanyName = companyName; + this.targetJobDescription = jobDescription; + } + public void start() { if (status != SessionStatus.READY) { throw new IllegalStateException("session is not READY to start (current=" + status + ")"); diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/SessionMode.java b/backend/src/main/java/com/stackup/stackup/session/domain/SessionMode.java index b9c6fd47..02c6e023 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/SessionMode.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/SessionMode.java @@ -3,13 +3,20 @@ public enum SessionMode { TECHNICAL, PERSONALITY, - INTEGRATED; + INTEGRATED, + // 직무 맞춤: 회사명 + 채용공고(JD)를 받아 적합도·지원동기 질문 + 직무 적합도 피드백을 생성. + JOB_TAILORED; public String koreanLabel() { return switch (this) { case TECHNICAL -> "기술 면접"; case PERSONALITY -> "인성 면접"; case INTEGRATED -> "종합 면접"; + case JOB_TAILORED -> "직무 맞춤 면접"; }; } + + public boolean isJobTailored() { + return this == JOB_TAILORED; + } } 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 cff2a011..ee61006d 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 @@ -25,7 +25,10 @@ public record SessionCreateRequest( @Min(1) @Max(15) Integer generalQuestionCount, // m: 일반질문당 최대 꼬리질문 수. null 이면 기본 2. @Min(0) @Max(10) Integer maxFollowupsPerQuestion, - List contextDocumentIds + List contextDocumentIds, + // 직무 맞춤(JOB_TAILORED) 모드 전용. 회사명 + 채용공고(JD). 그 모드일 때 JD 필수(서비스에서 검증). + @Size(max = 200) String targetCompanyName, + @Size(max = 20000) String targetJobDescription ) { public SessionCreateCommand toCommand() { return new SessionCreateCommand( @@ -37,7 +40,9 @@ public SessionCreateCommand toCommand() { maxDurationMinutes, generalQuestionCount, maxFollowupsPerQuestion, - contextDocumentIds + contextDocumentIds, + targetCompanyName, + targetJobDescription ); } } 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 a64cfe74..673b1157 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 @@ -23,6 +23,8 @@ public record SessionResponse( Instant startedAt, Instant endedAt, List contextDocumentIds, + String targetCompanyName, + String targetJobDescription, Instant createdAt, Instant updatedAt ) { @@ -43,6 +45,8 @@ public static SessionResponse from(SessionResult r) { r.startedAt(), r.endedAt(), r.contextDocumentIds(), + r.targetCompanyName(), + r.targetJobDescription(), r.createdAt(), r.updatedAt() ); diff --git a/backend/src/main/resources/db/migration/V18__add_job_tailored_mode_and_jd.sql b/backend/src/main/resources/db/migration/V18__add_job_tailored_mode_and_jd.sql new file mode 100644 index 00000000..7dfea8d8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V18__add_job_tailored_mode_and_jd.sql @@ -0,0 +1,13 @@ +-- 직무 맞춤 면접 모드(JOB_TAILORED) + 타깃 회사/채용공고(JD) 컨텍스트. +-- 이 모드에서만 회사명·JD 를 받아 적합도·지원동기 질문과 '직무 적합도' 피드백을 생성한다. + +ALTER TABLE interview_sessions + DROP CONSTRAINT IF EXISTS chk_interview_sessions_mode; + +ALTER TABLE interview_sessions + ADD CONSTRAINT chk_interview_sessions_mode + CHECK (mode IN ('TECHNICAL', 'PERSONALITY', 'INTEGRATED', 'JOB_TAILORED')); + +ALTER TABLE interview_sessions + ADD COLUMN target_company_name VARCHAR(200), + ADD COLUMN target_job_description TEXT; 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 742adce9..e9d53e0c 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 @@ -65,7 +65,8 @@ void onSelfIntroAnswered_requestsPoolReservingSelfIntroSlot() { 5, 3, List.of(), - "안녕하세요, 결제 시스템을 만든 백엔드 3년차입니다." + "안녕하세요, 결제 시스템을 만든 백엔드 3년차입니다.", + null, null )); ArgumentCaptor payloadCaptor = @@ -95,7 +96,7 @@ void onSelfIntroAnswered_includesRecentQuestionsForDedup() { .thenReturn(List.of("이전 질문 A", "이전 질문 B")); requester.onSelfIntroAnswered(new SelfIntroAnsweredEvent( - 1L, 11L, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 3, List.of(), "자기소개" + 1L, 11L, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 3, List.of(), "자기소개", null, null )); 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 5bc9fdf4..e24b7ba6 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 @@ -55,7 +55,7 @@ void create_savesSessionAndPublishesEvent() { SessionResult result = service.create(1L, new SessionCreateCommand( "title", "memo", SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), - 5, 30, null, null, List.of() + 5, 30, null, null, List.of(), null, null )); assertThat(result.id()).isEqualTo(100L); @@ -71,7 +71,7 @@ void create_generatesTitleFromModeAndJobWhenBlank() { SessionResult result = service.create(1L, new SessionCreateCommand( " ", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), - 5, 30, null, null, List.of() + 5, 30, null, null, List.of(), null, null )); assertThat(result.title()).isEqualTo("백엔드 기술 면접"); @@ -85,7 +85,7 @@ void create_keepsProvidedTitle() { SessionResult result = service.create(1L, new SessionCreateCommand( "내가 정한 제목", null, SessionMode.INTEGRATED, List.of(JobCategory.FRONTEND), - 5, 30, null, null, List.of() + 5, 30, null, null, List.of(), null, null )); assertThat(result.title()).isEqualTo("내가 정한 제목"); @@ -105,7 +105,7 @@ void create_linksAnalyzedContextDocuments() { SessionResult result = service.create(1L, new SessionCreateCommand( "t", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), - 5, 30, null, null, List.of(7L, 7L) + 5, 30, null, null, List.of(7L, 7L), null, null )); assertThat(result.contextDocumentIds()).containsExactly(7L); @@ -122,10 +122,43 @@ void create_rejectsNonAnalyzedDocument() { assertThatThrownBy(() -> service.create(1L, new SessionCreateCommand( "t", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), - 5, 30, null, null, List.of(8L) + 5, 30, null, null, List.of(8L), null, null ))).isInstanceOf(DomainException.class); } + @Test + void create_jobTailoredRequiresJobDescription() { + User user = userFixture(1L); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); + + // JD 미입력 → SESSION_JD_REQUIRED. save 까지 가지 않고 검증에서 막힌다. + assertThatThrownBy(() -> service.create(1L, new SessionCreateCommand( + "t", null, SessionMode.JOB_TAILORED, List.of(JobCategory.BACKEND), + 5, 30, null, null, List.of(), "토스", " " + ))).isInstanceOf(DomainException.class); + } + + @Test + void create_jobTailoredStoresCompanyAndJd() { + User user = userFixture(1L); + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user)); + when(sessionRepository.save(any(InterviewSession.class))).thenAnswer(inv -> { + InterviewSession s = inv.getArgument(0); + ReflectionTestUtils.setField(s, "id", 100L); + return s; + }); + + SessionResult result = service.create(1L, new SessionCreateCommand( + " ", null, SessionMode.JOB_TAILORED, List.of(JobCategory.BACKEND), + 5, 30, null, null, List.of(), "토스", "백엔드 엔지니어. Kotlin/Spring, 대용량 결제." + )); + + assertThat(result.targetCompanyName()).isEqualTo("토스"); + assertThat(result.targetJobDescription()).contains("결제"); + // 제목 미입력 시 회사명이 앞에 붙는다. + assertThat(result.title()).isEqualTo("토스 백엔드 직무 맞춤 면접"); + } + @Test void start_transitionsReadyToInProgress() { InterviewSession session = sessionFixture(50L); diff --git a/docs/database.md b/docs/database.md index 7fd3a3f4..d89c99c8 100644 --- a/docs/database.md +++ b/docs/database.md @@ -141,12 +141,15 @@ CREATE TABLE interview_sessions ( user_id BIGINT NOT NULL REFERENCES users(id), title VARCHAR(200), memo TEXT, - mode VARCHAR(20) NOT NULL CHECK (mode IN ('TECHNICAL','PERSONALITY','INTEGRATED')), + mode VARCHAR(20) NOT NULL CHECK (mode IN ('TECHNICAL','PERSONALITY','INTEGRATED','JOB_TAILORED')), -- 대표 직군(다중 선택 시 첫 항목). 전체 직군은 session_job_categories 참조. job_category VARCHAR(30) NOT NULL CHECK (job_category IN ('FRONTEND','BACKEND','INFRA','DBA')), max_questions INT NOT NULL DEFAULT 10, max_duration_minutes INT NOT NULL DEFAULT 60, + -- 직무 맞춤(JOB_TAILORED) 모드 전용. 지원 회사명 + 채용공고(JD) 원문. 다른 모드는 NULL. (V18) + target_company_name VARCHAR(200), + target_job_description TEXT, status VARCHAR(20) NOT NULL DEFAULT 'READY' CHECK (status IN ('READY','IN_PROGRESS','INTERRUPTED','COMPLETED','CANCELLED')), total_question_count INT DEFAULT 0, @@ -255,7 +258,7 @@ CREATE TABLE ai_request_logs ( > 코드(Enum)와 DB(VARCHAR + CHECK)는 **반드시 1:1 매칭**. Enum 추가 시 Flyway 마이그레이션도 같이 작성. ``` -mode : TECHNICAL | PERSONALITY | INTEGRATED +mode : TECHNICAL | PERSONALITY | INTEGRATED | JOB_TAILORED job_category : FRONTEND | BACKEND | INFRA | DBA session_status : READY | IN_PROGRESS | INTERRUPTED | COMPLETED | CANCELLED message_role : INTERVIEWER | INTERVIEWEE | SYSTEM diff --git a/docs/glossary.md b/docs/glossary.md index 2c67c563..e78c163f 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -35,7 +35,8 @@ | 한국어 | 영어 | 비고 | |--------|------|------| | 면접 세션 | `interview session` | DB: `interview_sessions` | -| 면접 모드 | `mode` | `TECHNICAL` / `PERSONALITY` / `INTEGRATED` | +| 면접 모드 | `mode` | `TECHNICAL` / `PERSONALITY` / `INTEGRATED` / `JOB_TAILORED` | +| 직무 맞춤 면접 | `job-tailored interview` | `mode=JOB_TAILORED`. 회사명+채용공고(JD)로 적합도·지원동기 질문 + 직무 적합도 피드백 | | 면접 유형 | `interview type` | legacy 용어. MVP에서는 `mode`로 통합 | | 직군 | `job category` | `FRONTEND` / `BACKEND` / `INFRA` / `DBA` | | 인성 면접 | `personality interview` | `PERSONALITY` | diff --git a/docs/messaging.md b/docs/messaging.md index 85be9931..7c3e93f9 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -222,19 +222,22 @@ > **발행 시점**: 세션 생성 시가 아니라 **자기소개(첫 질문) 답변을 받은 직후**(`SelfIntroAnsweredEvent`). > 모든 면접의 첫 질문은 자기소개로 고정이며, 질문 풀은 그 답변(`selfIntroAnswer`)을 1차 근거로 생성한다. > `initialQuestionCount` 는 자기소개 1자리를 예약해 `generalQuestionCount - 1` 로 보낸다. +> `mode=JOB_TAILORED` 면 `targetCompanyName`·`targetJobDescription`(JD)이 채워져 적합도·지원동기 질문의 근거가 된다(다른 모드는 null). ```json { "messageType": "generate.questions", "payload": { "sessionId": 99, - "mode": "TECHNICAL", + "mode": "JOB_TAILORED", "jobCategories": ["BACKEND", "FRONTEND"], "documents": [ { "documentId": 42, "sourceType": "RESUME", "summary": "...", "techStack": ["..."], "markdown": "..." } ], "initialQuestionCount": 2, "maxQuestions": 10, "recentQuestions": ["이전 면접 질문 텍스트", "..."], - "selfIntroAnswer": "안녕하세요, 결제 시스템을 만든 백엔드 3년차입니다…" + "selfIntroAnswer": "안녕하세요, 결제 시스템을 만든 백엔드 3년차입니다…", + "targetCompanyName": "토스", + "targetJobDescription": "Kotlin/Spring 백엔드, 대용량 결제 시스템 경험 우대 …" }, "context": { "userId": 123, "sessionId": 99 } } @@ -344,26 +347,31 @@ > `messages[]` 의 각 항목은 `category` 를 포함한다(질문 유형). AI 는 `category=SELF_INTRODUCTION` > 질문과 그 답변을 찾아 **첫인상(전달력·구성·직무적합성)** 을 별도 평가한다. +> `mode=JOB_TAILORED` 면 `targetCompanyName`·`targetJobDescription`(JD)이 채워져 **직무 적합도** 평가의 근거가 된다. ```json { "messageType": "generate.feedback", "payload": { "sessionId": 99, - "mode": "TECHNICAL", + "mode": "JOB_TAILORED", "jobCategory": "BACKEND", "messages": [ { "id": 1, "sequenceNumber": 1, "role": "INTERVIEWER", "content": "자기소개…", "category": "SELF_INTRODUCTION" }, { "id": 2, "sequenceNumber": 2, "role": "INTERVIEWEE", "content": "…", "parentMessageId": 1 } - ] + ], + "targetCompanyName": "토스", + "targetJobDescription": "Kotlin/Spring 백엔드, 대용량 결제 …" } } ``` ### 5.11 `callback.feedback` -> `panelBreakdown[]` 에 평가위원별 항목이 담긴다. 자기소개가 있던 세션은 **`evaluator="첫인상"`** -> 항목이 추가로 포함된다 — 이 항목은 **종합 점수(overallScore) 집계에서 제외**된 별도 정성 평가다. +> `panelBreakdown[]` 에 평가위원별 항목이 담긴다. 자기소개가 있던 세션은 **`evaluator="첫인상"`**, +> 직무 맞춤 모드는 **`evaluator="직무 적합도"`(역량 매칭)** + **`evaluator="직무 이해도"`(직무 이해·동기)** +> 항목이 추가로 포함된다 — 모두 **종합 점수(overallScore) 집계에서 제외**된 별도 정성 평가다(메인 +> generator 가 모른 채 overall 계산 후 표시용으로 append). ```json { diff --git a/frontend/src/features/feedback/ui/FeedbackReport.tsx b/frontend/src/features/feedback/ui/FeedbackReport.tsx index 7a38d1b5..cbfa9520 100644 --- a/frontend/src/features/feedback/ui/FeedbackReport.tsx +++ b/frontend/src/features/feedback/ui/FeedbackReport.tsx @@ -7,8 +7,10 @@ import type { Feedback } from '../api/feedbackApi' import { downloadElementAsPdf } from '../lib/downloadPdf' import { useShareFeedback } from '../model/useFeedback' -// AI 가 자기소개 첫인상 평가를 패널 항목으로 실어 보낼 때 쓰는 라벨(피드백 종합 점수엔 미포함). +// AI 가 별도 정성 평가를 패널 항목으로 실어 보낼 때 쓰는 라벨(모두 종합 점수엔 미포함). const SELF_INTRO_LABEL = '첫인상' +const JOB_FIT_LABEL = '직무 적합도' +const ROLE_UNDERSTANDING_LABEL = '직무 이해도' // shareable: 소유자 화면에서만 '공유' 버튼 노출(공개 페이지에선 false). export function FeedbackReport({ @@ -39,10 +41,13 @@ export function FeedbackReport({ } const overall = feedback.overallScore - // '첫인상'(자기소개)은 종합 점수에 포함되지 않는 별도 정성 평가 → 패널과 분리해 전용 섹션으로. + // '첫인상'·'직무 적합도'는 종합 점수에 포함되지 않는 별도 정성 평가 → 패널과 분리해 전용 섹션으로. const panel = feedback.panelBreakdown ?? [] const selfIntro = panel.find((b) => b.evaluator === SELF_INTRO_LABEL) - const interviewerPanel = panel.filter((b) => b.evaluator !== SELF_INTRO_LABEL) + const jobFit = panel.find((b) => b.evaluator === JOB_FIT_LABEL) + const roleUnderstanding = panel.find((b) => b.evaluator === ROLE_UNDERSTANDING_LABEL) + const SPECIAL = [SELF_INTRO_LABEL, JOB_FIT_LABEL, ROLE_UNDERSTANDING_LABEL] + const interviewerPanel = panel.filter((b) => !SPECIAL.includes(b.evaluator ?? '')) return (
@@ -75,6 +80,63 @@ export function FeedbackReport({ + {jobFit && ( +
+

직무 적합도

+

+ 채용공고(JD) 요구 대비 적합도·갭 평가입니다. 종합 점수에는 반영되지 않습니다. +

+
+ + {jobFit.detail && ( +

+ {jobFit.detail} +

+ )} + {(jobFit.strength || jobFit.weakness) && ( +
+ {jobFit.strength && 충족 · {jobFit.strength}} + {jobFit.weakness && 갭 · {jobFit.weakness}} +
+ )} + {jobFit.scoreRationale && ( +

+ 점수 근거 · {jobFit.scoreRationale} +

+ )} +
+
+ )} + + {roleUnderstanding && ( +
+

직무 이해도

+

+ 직무가 무엇을 하는 자리인지에 대한 이해·지원동기 평가입니다. 종합 점수에는 반영되지 + 않습니다. +

+
+ + {roleUnderstanding.detail && ( +

+ {roleUnderstanding.detail} +

+ )} + {(roleUnderstanding.strength || roleUnderstanding.weakness) && ( +
+ {roleUnderstanding.strength && 강점 · {roleUnderstanding.strength}} + {roleUnderstanding.weakness && 보완 · {roleUnderstanding.weakness}} +
+ )} + {roleUnderstanding.scoreRationale && ( +

+ 점수 근거 · {roleUnderstanding.scoreRationale} +

+ )} +
+
+ )} + {selfIntro && (

자기소개 첫인상

diff --git a/frontend/src/features/history/ui/SessionCard.tsx b/frontend/src/features/history/ui/SessionCard.tsx index b43f1c89..c9bc03f0 100644 --- a/frontend/src/features/history/ui/SessionCard.tsx +++ b/frontend/src/features/history/ui/SessionCard.tsx @@ -15,6 +15,7 @@ const MODE: Record = { TECHNICAL: '기술', PERSONALITY: '인성', INTEGRATED: '통합', + JOB_TAILORED: '직무 맞춤', } const JOB: Record = { diff --git a/frontend/src/features/interview/ui/setup/InterviewSetupForm.test.tsx b/frontend/src/features/interview/ui/setup/InterviewSetupForm.test.tsx index 0a31a4aa..7f2c5f9f 100644 --- a/frontend/src/features/interview/ui/setup/InterviewSetupForm.test.tsx +++ b/frontend/src/features/interview/ui/setup/InterviewSetupForm.test.tsx @@ -25,4 +25,39 @@ describe('InterviewSetupForm', () => { contextDocumentIds: [], }) }) + + it('직무 맞춤 모드는 JD 입력이 노출되고, JD 없으면 생성 비활성', async () => { + render() + // 다른 모드에선 JD 입력이 없다. + expect(screen.queryByLabelText(/채용공고/)).toBeNull() + + await userEvent.click(screen.getByRole('radio', { name: '직무 맞춤 면접' })) + await userEvent.click(screen.getByRole('checkbox', { name: '백엔드' })) + + // JD 입력이 노출되고, 아직 비어 있어 생성은 비활성. + expect(screen.getByLabelText(/채용공고/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: '면접 생성' })).toBeDisabled() + }) + + it('직무 맞춤 모드는 회사·JD를 요청에 담는다', async () => { + const onCreate = vi.fn() + render() + await userEvent.click(screen.getByRole('radio', { name: '직무 맞춤 면접' })) + await userEvent.click(screen.getByRole('checkbox', { name: '백엔드' })) + await userEvent.type(screen.getByLabelText(/회사명/), '토스') + await userEvent.type( + screen.getByLabelText(/채용공고/), + 'Kotlin/Spring 백엔드, 대용량 결제', + ) + await userEvent.click(screen.getByRole('button', { name: '면접 생성' })) + + expect(onCreate).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'JOB_TAILORED', + jobCategories: ['BACKEND'], + targetCompanyName: '토스', + targetJobDescription: 'Kotlin/Spring 백엔드, 대용량 결제', + }), + ) + }) }) diff --git a/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx b/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx index 091bb50b..0a2a7198 100644 --- a/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx +++ b/frontend/src/features/interview/ui/setup/InterviewSetupForm.tsx @@ -23,6 +23,10 @@ export function InterviewSetupForm({ const [maxFollowupsPerQuestion, setMaxFollowupsPerQuestion] = useState(2) const [maxQuestions, setMaxQuestions] = useState(10) const [selected, setSelected] = useState([]) + const [companyName, setCompanyName] = useState('') + const [jobDescription, setJobDescription] = useState('') + + const isJobTailored = mode === 'JOB_TAILORED' const toggle = (id: number) => setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) @@ -33,7 +37,12 @@ export function InterviewSetupForm({ ) const valid = - mode !== null && jobCategories.length > 0 && maxQuestions >= 2 && maxQuestions <= 30 + mode !== null && + jobCategories.length > 0 && + maxQuestions >= 2 && + maxQuestions <= 30 && + // 직무 맞춤 면접은 채용공고(JD)가 필수. + (!isJobTailored || jobDescription.trim().length > 0) const submit = () => { if (mode === null || jobCategories.length === 0 || !valid) return @@ -46,6 +55,8 @@ export function InterviewSetupForm({ maxFollowupsPerQuestion, maxQuestions, contextDocumentIds: selected, + targetCompanyName: isJobTailored ? companyName.trim() || undefined : undefined, + targetJobDescription: isJobTailored ? jobDescription.trim() : undefined, }) } @@ -76,6 +87,42 @@ export function InterviewSetupForm({

면접 모드

+ {isJobTailored && ( +
+

+ 지원 회사 · 채용공고 + 직무 맞춤 면접 +

+
+ + setCompanyName(e.target.value)} + maxLength={200} + placeholder="예: 토스, 우아한형제들" + className="rounded-md border border-border bg-surface-raised px-3 py-2 text-body text-fg placeholder:text-fg-muted focus:border-border-strong focus:outline-none" + /> +
+
+ +