diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index 981687b5..933e141f 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -492,6 +492,26 @@ docker run --env-file .env -p 8000:8000 stackup-ai - **실시간 스트리밍 음성 답변 본 구현** (RT3, `api/voice_stream.py`, `voice/stt/{live,mock_live,deepgram_live,live_factory}.py`): FastAPI WS `/internal/voice/stream` 수신(RealTime 프록시 경유) → Deepgram Live(`deepgram_live.py`, mock fallback)로 부분/최종 자막 다운 → 발화 종료 시 메트릭 계산 후 `callback.voice` 발행. `VoiceCallbackService`/followup 무변경 재사용. 신규 의존성 `websockets`. - 배치 음성 분석(STT/WPM/filler) 모듈은 `voice/stt/whisper_api.py`(+ Deepgram) + `voice/analysis/metrics.py`로 본 구현(`analyze.voice` consumer) +- **비개발 직군 면접 본 구현 (2026-09-29)**: 서비스 대상이 취준생 전반(개발 8 + 비개발 12 직군)인데 + **평가 패널만 직군별로 확장돼 있었고 질문·꼬리질문·첫인상·문서분석 프롬프트는 "IT 직군" 페르소나로 + 고정**돼 있었다. 그 상태의 영업·인사 지원자 면접은: IT 렌즈로 이력서가 요약되고 → IT 채용 담당자가 + → 'CS 기초'·'기술 선택' 카테고리로 질문을 만들고 → IT 면접관이 꼬리질문을 단다. 평가만 직군 맞춤. + - **카테고리가 개발 전용인 것이 핵심**이다. 문구만 바꾸면 LLM 은 여전히 `CS_FUNDAMENTAL` 을 고른다. + `DOMAIN_KNOWLEDGE`(직무 지식·업무 도구·업계 이해)를 추가하고, 비개발이면 `CS_FUNDAMENTAL`· + `TECH_CHOICE` 를 **쓰지 말라고 명시**한다. Core 는 category 를 문자열로만 저장하므로(V9, CHECK 없음) + 마이그레이션이 없고 프론트 `categoryLabel` 에 라벨만 더하면 된다. + - `job_category.py` 에 `ENGINEERING_CATEGORIES`(8개) + `is_engineering()` 을 **직군 목록과 같은 파일**에 + 둔다 — 목록만 늘리고 집합을 빠뜨리면 그 직군이 조용히 반대 취급을 받는다(검증에서 안 튕긴다). + - `_format_job_guide` 가 개발/비개발/혼합 3분기 블록을 만들어 `{job_guide}` 로 주입한다. 패널의 + `_DOMAIN_TECH_GUIDE` 와 같은 이유로 라벨이 아니라 **무엇을 묻고 무엇을 묻지 말지**를 문장으로 준다. + 직군이 비면 개발을 가정하지 않는다 — 틀렸을 때 비개발 지원자에게 기술 질문이 나가는 쪽이 더 나쁘다. + - `followup`·`self_intro` 프롬프트는 **예전부터 `{job_category}` 를 받고 있었는데 쓰지 않았다** + (페르소나만 IT 고정). 받은 값을 페르소나에 넣었다. + - `document_analysis` 는 업로드 시점이라 직군을 모른다 → 페르소나를 중립화하고 `tech_stack` 의 의미를 + "개발이면 기술 스택, 아니면 그 직무의 실무 역량·도구·자격"으로 넓혔다(필드명 때문에 없는 기술을 + 지어내지 말라고 명시). 이게 가장 상류다 — 분석 마크다운이 RAG·질문·피드백 전부의 근거다. + - 운영 사용 현황은 아직 BACKEND 64 / FRONTEND 39 로 비개발 세션이 **0건**이다 — 라이브 회귀가 아니라 + 미검증 경로를 연 것이다. - **멀티 평가위원 패널 본 구현** (`chain/feedback_generation_chain.py: PanelFeedbackGenerator`): 세션에 등장한 직군(질문 카테고리) 수만큼 기술 평가위원을 동적 생성(`_domain_specs_weighted`) + 논리·전달력 평가위원을 더해 `asyncio.gather` 로 병렬 채점 → 직군 평가는 질문 등장 빈도로 가중평균해 `technical_accuracy` 산출, diff --git a/ai/src/ai_server/chain/prompts/document_analysis.py b/ai/src/ai_server/chain/prompts/document_analysis.py index 306566c9..c676b22d 100644 --- a/ai/src/ai_server/chain/prompts/document_analysis.py +++ b/ai/src/ai_server/chain/prompts/document_analysis.py @@ -3,8 +3,10 @@ # 2단계 지향: ① 원문 근거(source_quote)와 함께 사실을 구조화 추출 → # ② 추출 결과만 근거로 summary·markdown 생성 (환각 차단). SYSTEM_PROMPT = ( - "당신은 IT 직군 채용을 진행하는 시니어 면접관입니다. " - "지원자 자료(이력서·레포 README·기술 블로그 등)를 분석하여 면접 준비에 쓸 " + "당신은 채용 면접을 준비하는 시니어 면접관입니다. 지원자의 직군은 개발일 수도 아닐 수도 " + "있습니다 — 자료에 드러난 직무를 그대로 따르고 IT 를 가정하지 마세요. " + "지원자 자료(이력서·자소서·포트폴리오, 개발 직군이면 레포 README·기술 블로그 등)를 " + "분석하여 면접 준비에 쓸 " "구조화된 결과를 산출하세요.\n" "작업 순서:\n" "1) 먼저 자료에서 사실을 구조화 추출합니다(projects·experiences·skills). " @@ -26,10 +28,14 @@ "stack, source_quote(원문 인용).\n" "2. experiences: 경력/활동. 각 항목에 title, detail, source_quote.\n" "3. skills: 핵심 기술. 각 항목에 name, evidence(원문 인용).\n" - "4. tech_stack: skills/projects 에서 도출한 핵심 기술 5~15개. 영문 표기.\n" + "4. tech_stack: skills/projects 에서 도출한 핵심 역량 5~15개. 개발 직군이면 기술 스택을 " + "영문 표기로, 그 외 직군이면 **그 직무의 실무 역량·도구·자격**을 자료의 표기대로 " + "넣습니다(예: SAP, GA4, CAD, 원가관리, 노무, 계약검토, 사용자 리서치). 필드 이름이 " + "tech_stack 이라고 해서 없는 기술을 지어내지 마세요.\n" "5. summary: 위 추출 내용만 근거로 한 2~4문장 한국어 요약.\n" "6. markdown: 면접관이 훑어볼 한국어 마크다운. 섹션 구조는 " - "'## 개요', '## 주요 경험', '## 기술', '## 추가 메모' 사용. " + "'## 개요', '## 주요 경험', '## 기술', '## 추가 메모' 사용" + "(개발 직군이 아니면 '## 기술' 대신 '## 역량·도구'). " "추출된 projects/experiences/skills 를 반영하되 추측은 넣지 마세요.\n" "※ 출처 유형이 COVER_LETTER(자기소개서)이면: 기술 나열보다 **지원동기·가치관·성장 경험·" "직무 적합성·핵심 강점/소재**를 우선 추출하세요. experiences 에 자소서 문항별 핵심 주장과 " diff --git a/ai/src/ai_server/chain/prompts/followup_generation.py b/ai/src/ai_server/chain/prompts/followup_generation.py index 1e6ec077..2c19a8e9 100644 --- a/ai/src/ai_server/chain/prompts/followup_generation.py +++ b/ai/src/ai_server/chain/prompts/followup_generation.py @@ -2,7 +2,9 @@ # Flash 모델 + 저지연 (< 3s). 답변을 4축으로 평가하고 가장 약한 축을 파고드는 꼬리질문 1개. SYSTEM_PROMPT = ( - "당신은 IT 직군 면접관입니다. 직전 질문에 대한 지원자의 답변을 평가하고, " + "당신은 {job_category} 직군의 면접관입니다. 직군이 개발 직군이 아니면 코드·기술 스택이 " + "아니라 그 직무의 실무 역량·도구·성과를 기준으로 판단합니다. " + "직전 질문에 대한 지원자의 답변을 평가하고, " "가장 약한 부분을 파고드는 꼬리질문 1개를 한국어로 만드세요.\n" "- 평가 항목:\n" " - specificity (0~5): 답변에 구체적 수치/사례/기술 선택 근거가 있는가.\n" diff --git a/ai/src/ai_server/chain/prompts/question_generation.py b/ai/src/ai_server/chain/prompts/question_generation.py index 786a4fbf..67ea8acf 100644 --- a/ai/src/ai_server/chain/prompts/question_generation.py +++ b/ai/src/ai_server/chain/prompts/question_generation.py @@ -1,20 +1,25 @@ # 질문 풀 생성 프롬프트 (US-18) -# 분석된 이력서/레포 컨텍스트 + 직군 + 면접 모드 → 질문 N개. +# 분석된 지원자 자료 컨텍스트 + 직군 + 면접 모드 → 질문 N개. +# +# 페르소나를 "IT 직군"으로 고정하지 않는다. 서비스 대상이 취준생 전반(개발 8 + 비개발 12 직군) +# 인데 페르소나만 IT 로 박혀 있으면, 영업·인사·법무 지원자에게 IT 채용 담당자가 기술 스택을 +# 묻는 면접이 나간다. 직군별 차이는 `_format_job_guide` 가 만든 {job_guide} 블록이 담당한다. SYSTEM_PROMPT = ( - "당신은 IT 직군 채용을 진행하는 시니어 면접관입니다. " - "지원자의 **자기소개 답변**을 1차 근거로, 이력서·GitHub 레포 분석 컨텍스트와 " - "면접 모드에 맞춰 면접 질문 풀을 생성하세요.\n" + "당신은 지원자가 지원한 직군의 채용을 진행하는 시니어 면접관입니다. " + "지원자의 **자기소개 답변**을 1차 근거로, 지원자 자료(이력서·자소서·포트폴리오, 개발 직군이면 " + "GitHub 레포) 분석 컨텍스트와 면접 모드에 맞춰 면접 질문 풀을 생성하세요.\n" "- 실제 면접처럼, 자기소개에서 지원자가 직접 강조한 경험·역량·관심사를 우선 파고듭니다. " "자기소개에서 언급된 키워드를 이력서/레포 자료와 교차로 연결해 구체적으로 질문하세요.\n" "- 사실에 근거한 질문만. 자료에 없는 내용은 추측하지 않습니다.\n" - "- 자료에 명시된 기술 스택과 프로젝트 경험을 적극 인용하세요.\n" + "- 자료에 명시된 역량·도구·프로젝트 경험을 적극 인용하세요.\n" "- **모든 카테고리(CS_FUNDAMENTAL·BEHAVIORAL 포함)를 지원자의 특정 발화·프로젝트·기술 " "선택에 앵커링**하세요. 자료 없이 누구나 답할 수 있는 일반 개념·교과서 문제는 금지. " "예) ✗ 'HTTP와 HTTPS의 차이는?' → ✓ '레포의 결제 API를 HTTPS로 전환할 때 인증서·핸드셰이크에서 " - "겪은 이슈는?'. 개념을 묻더라도 지원자가 실제 다룬 맥락에 붙여 묻습니다.\n" + "겪은 이슈는?' / ✗ '4대보험이란?' → ✓ '이력서의 120명 규모 입퇴사 처리에서 4대보험 취득·상실 " + "신고를 어떤 절차로 관리하셨나요?'. 개념을 묻더라도 지원자가 실제 다룬 맥락에 붙여 묻습니다.\n" "- 카테고리는 다음 중에서 선택: CS_FUNDAMENTAL, PROJECT_DEEP_DIVE, TECH_CHOICE, " - "BEHAVIORAL.\n" + "BEHAVIORAL, DOMAIN_KNOWLEDGE. **어느 것을 쓸지는 아래 '직군 지침'이 정합니다.**\n" "- 한국어로 작성하되 기술 용어는 영문 원어를 그대로 둡니다.\n" "- 질문·근거 텍스트에는 마크다운 서식 기호를 쓰지 마세요 (화면이 plain text 렌더, 스트리밍 표시).\n" "- 질문 문장은 **간결하게**: 한두 문장(대략 80자 이내)으로, 장황한 배경 설명이나 " @@ -57,6 +62,10 @@ "---\n" "{self_introduction}\n" "---\n\n" + "직군 지침 (이 면접의 직군에 맞춘 규칙 — 위 카테고리 선택은 여기를 따릅니다):\n" + "---\n" + "{job_guide}\n" + "---\n\n" "이번 면접의 집중 영역 (약점 집중 재도전일 때만 채워짐):\n" "---\n" "{focus_areas}\n" @@ -77,7 +86,8 @@ "(각 질문이 일반질문 1개의 씨앗이 되고 뒤에 꼬리질문이 붙으므로 같은 주제 반복 금지).\n" "2. 각 질문에 적절한 category 를 부여하고, job_category 를 직군({job_categories}) 중 " "그 질문이 겨냥하는 **하나**로 지정합니다(직군이 하나면 모두 그 값).\n" - "3. 직군({job_categories})·면접 모드({mode}) 에 맞는 비중으로 카테고리 분배:\n" + "3. 직군({job_categories})·면접 모드({mode}) 에 맞는 비중으로 카테고리 분배. " + "**아래 비중은 개발 직군 기준이고, 비개발 직군이면 위 '직군 지침'의 대체 카테고리를 씁니다**:\n" " - TECHNICAL: CS_FUNDAMENTAL + TECH_CHOICE + PROJECT_DEEP_DIVE 중심.\n" " - PERSONALITY: BEHAVIORAL 중심.\n" " - INTEGRATED: 모두 균형.\n" diff --git a/ai/src/ai_server/chain/prompts/self_intro_evaluation.py b/ai/src/ai_server/chain/prompts/self_intro_evaluation.py index 0b2c697c..cc52a6d8 100644 --- a/ai/src/ai_server/chain/prompts/self_intro_evaluation.py +++ b/ai/src/ai_server/chain/prompts/self_intro_evaluation.py @@ -3,7 +3,9 @@ # 결과는 패널의 '첫인상' 평가위원 한 명으로 표시되며, 종합 점수 집계에는 포함되지 않는다. SYSTEM_PROMPT = ( - "당신은 IT 직군 면접의 **첫인상 평가위원**입니다. 지원자의 자기소개 답변 한 건만 보고 " + "당신은 {job_category} 직군 면접의 **첫인상 평가위원**입니다. 직무적합성은 그 직군의 " + "기준으로 봅니다(개발 직군이 아니면 기술 역량이 아니라 해당 직무의 실무 역량). " + "지원자의 자기소개 답변 한 건만 보고 " "면접 도입부의 첫인상을 평가합니다.\n" "- **기술 정확성은 평가하지 않습니다.** 오직 전달력·구조·간결성·직무적합성만 봅니다.\n" " · 전달력/명료성: 메시지가 분명하고 자신감 있게 전달되는가\n" diff --git a/ai/src/ai_server/chain/question_generation_chain.py b/ai/src/ai_server/chain/question_generation_chain.py index d7bb13ce..f92f8d28 100644 --- a/ai/src/ai_server/chain/question_generation_chain.py +++ b/ai/src/ai_server/chain/question_generation_chain.py @@ -10,6 +10,7 @@ from ai_server.chain.prompts.question_generation import HUMAN_PROMPT, SYSTEM_PROMPT from ai_server.config.settings import Settings from ai_server.core.client import CoreClient +from ai_server.model.messages.job_category import is_engineering from ai_server.model.messages.questions import GeneratedQuestion from ai_server.observability.llm_logging_callback import CoreAiLogCallback @@ -122,6 +123,7 @@ async def generate( target_company_name, target_job_description ), "focus_areas": _format_focus_areas(focus_areas), + "job_guide": _format_job_guide(job_categories), # 비어 있으면 "(지정 없음)" — 프롬프트가 산업 지침을 무시하도록. "industry": (industry or "").strip() or "(지정 없음)", } @@ -133,6 +135,47 @@ async def generate( return result +# 직군 지침. 개발/비개발은 묻는 것이 다르고, 특히 **카테고리 자체가 개발 전용**이다 +# (CS_FUNDAMENTAL·TECH_CHOICE). 이걸 주지 않으면 영업·인사 지원자에게 'CS 기초' 질문이 나간다. +# 라벨만 넘기면 LLM 이 제각각 해석하므로, 패널 평가의 `_DOMAIN_TECH_GUIDE` 와 같이 +# **무엇을 묻고 무엇을 묻지 말지**를 문장으로 못 박는다. +_ENGINEERING_GUIDE = ( + "개발 직군입니다. 기술 스택·설계 선택·트러블슈팅을 중심으로 묻고, " + "CS_FUNDAMENTAL·TECH_CHOICE·PROJECT_DEEP_DIVE 를 적극 활용합니다. " + "DOMAIN_KNOWLEDGE 는 쓰지 않습니다." +) +_NON_ENGINEERING_GUIDE = ( + "비개발 직군입니다.\n" + "- 코드·아키텍처·기술 스택을 묻지 않습니다. **CS_FUNDAMENTAL·TECH_CHOICE 를 쓰지 마세요.**\n" + "- 대신 DOMAIN_KNOWLEDGE(직무 지식·업무 도구·업계 이해)와 PROJECT_DEEP_DIVE(담당 업무·" + "성과), BEHAVIORAL 을 씁니다.\n" + "- '역량·도구'는 그 직군의 실무를 뜻합니다 — 예: 마케팅이면 퍼포먼스 지표·GA4, 재무면 결산·" + "세무, 생산·품질이면 공정·불량률, 법무면 계약 검토, 디자인이면 사용자 리서치, " + "인사면 채용·평가·노무.\n" + "- 성과는 숫자로 확인합니다(전환율·불량률·원가 절감·처리 건수·리드타임 등).\n" + "- 자료에 GitHub 레포가 없는 것이 정상입니다. 없는 자료를 전제로 묻지 마세요." +) +_MIXED_GUIDE = ( + "개발 직군과 비개발 직군이 함께 지정됐습니다. **질문마다 그 질문이 겨냥하는 직군의 규칙을 " + "따릅니다** — 개발 직군 질문에만 CS_FUNDAMENTAL·TECH_CHOICE 를 쓰고, 비개발 직군 질문에는 " + "DOMAIN_KNOWLEDGE 를 씁니다.\n\n" + "[개발] " + _ENGINEERING_GUIDE + "\n\n[비개발] " + _NON_ENGINEERING_GUIDE +) + + +def _format_job_guide(job_categories: list[str] | None) -> str: + cats = [c for c in (job_categories or []) if c] + if not cats: + # 직군이 비는 경로는 없지만, 비면 개발을 가정하지 않는다 — 가정이 틀렸을 때 + # 비개발 지원자에게 기술 질문이 나가는 쪽이 반대보다 나쁘다. + return _NON_ENGINEERING_GUIDE + eng = [c for c in cats if is_engineering(c)] + non = [c for c in cats if not is_engineering(c)] + if eng and non: + return _MIXED_GUIDE + return _ENGINEERING_GUIDE if eng else _NON_ENGINEERING_GUIDE + + def build_question_generation_chain( settings: Settings, core_client: CoreClient | None = None ) -> Runnable: diff --git a/ai/src/ai_server/model/messages/job_category.py b/ai/src/ai_server/model/messages/job_category.py index 76af0431..23d0a503 100644 --- a/ai/src/ai_server/model/messages/job_category.py +++ b/ai/src/ai_server/model/messages/job_category.py @@ -32,3 +32,25 @@ "LEGAL", "PUBLIC", ] + + +# 개발 직군 집합. 위 목록과 **같은 파일**에 두는 이유는 이 파일의 존재 이유와 같다 — +# 직군을 늘릴 때 목록만 고치고 이 집합을 빠뜨리면, 그 직군은 조용히 비개발로 취급돼 +# 기술 질문이 사라지거나 반대로 영업 지원자에게 CS 기초가 나간다(검증에서 튕기지 않으므로 +# 아무도 모른다). `tests/test_job_category.py` 가 두 목록의 합집합을 고정한다. +ENGINEERING_CATEGORIES = frozenset( + { + "FRONTEND", + "BACKEND", + "INFRA", + "DBA", + "MOBILE", + "DATA_AI", + "SECURITY", + "QA", + } +) + + +def is_engineering(job_category: str | None) -> bool: + return (job_category or "").upper() in ENGINEERING_CATEGORIES diff --git a/ai/src/ai_server/model/messages/questions.py b/ai/src/ai_server/model/messages/questions.py index 4df97412..7372234a 100644 --- a/ai/src/ai_server/model/messages/questions.py +++ b/ai/src/ai_server/model/messages/questions.py @@ -12,6 +12,10 @@ "PROJECT_DEEP_DIVE", "TECH_CHOICE", "BEHAVIORAL", + # 비개발 직군용. CS_FUNDAMENTAL·TECH_CHOICE 가 개발 전용이라 그 자리를 대신한다 + # (직무 지식·업무 도구·업계 이해). Core 는 category 를 문자열로만 저장하므로 + # 마이그레이션이 필요 없고, 프론트는 categoryLabel 에 라벨이 있다. + "DOMAIN_KNOWLEDGE", ] CallbackKind = Literal["POOL", "FOLLOWUP"] GenerationStatus = Literal["OK", "FAILED"] diff --git a/ai/tests/test_non_engineering_interview.py b/ai/tests/test_non_engineering_interview.py new file mode 100644 index 00000000..1ce60476 --- /dev/null +++ b/ai/tests/test_non_engineering_interview.py @@ -0,0 +1,105 @@ +"""비개발 직군 면접이 개발 면접의 복사본이 되지 않아야 한다. + +서비스 대상은 취준생 전반(개발 8 + 비개발 12 직군)인데, 평가 패널만 직군별로 확장되고 +**질문·꼬리질문·첫인상·문서분석 프롬프트는 "IT 직군" 페르소나로 고정**돼 있었다. +그 상태에서 영업·인사 지원자는 IT 채용 담당자에게 'CS 기초'·'기술 선택' 질문을 받는다. + +카테고리 자체가 개발 전용이라는 게 핵심이다 — 문구만 바꾸면 LLM 은 여전히 +CS_FUNDAMENTAL 을 고른다. +""" + +from __future__ import annotations + +import pytest + +from ai_server.chain.prompts.document_analysis import SYSTEM_PROMPT as DOC_PROMPT +from ai_server.chain.prompts.followup_generation import ( + SYSTEM_PROMPT as FOLLOWUP_PROMPT, +) +from ai_server.chain.prompts.question_generation import ( + HUMAN_PROMPT, + SYSTEM_PROMPT as QUESTION_PROMPT, +) +from ai_server.chain.prompts.self_intro_evaluation import ( + SYSTEM_PROMPT as SELF_INTRO_PROMPT, +) +from ai_server.chain.question_generation_chain import _format_job_guide +from ai_server.model.messages.job_category import ( + ENGINEERING_CATEGORIES, + JobCategory, + is_engineering, +) +from ai_server.model.messages.questions import QuestionCategory +from typing import get_args + + +def test_engineering_set_is_a_subset_of_the_job_category_list(): + """두 목록이 어긋나면 그 직군만 조용히 반대 취급을 받는다 — 검증에서 튕기지 않는다.""" + assert ENGINEERING_CATEGORIES <= set(get_args(JobCategory)) + + +def test_every_job_category_is_classified(): + # 새 직군을 넣고 분류를 빠뜨리면 비개발로 떨어진다. 그 자체는 안전한 기본값이지만, + # 개발 직군을 추가했는데 기술 질문이 사라지는 사고를 막으려면 수를 고정해야 한다. + assert len(ENGINEERING_CATEGORIES) == 8 + assert len(get_args(JobCategory)) == 20 + + +@pytest.mark.parametrize("cat", ["SALES", "HR", "LEGAL", "MANUFACTURING", "DESIGN"]) +def test_non_engineering_guide_forbids_engineering_categories(cat): + assert not is_engineering(cat) + guide = _format_job_guide([cat]) + + assert "CS_FUNDAMENTAL" in guide and "쓰지 마세요" in guide + assert "DOMAIN_KNOWLEDGE" in guide + # 없는 자료를 전제로 묻지 않게 — 비개발 지원자에게 레포는 없다. + assert "GitHub 레포가 없는 것이 정상" in guide + + +@pytest.mark.parametrize("cat", ["BACKEND", "FRONTEND", "DATA_AI"]) +def test_engineering_guide_keeps_current_behaviour(cat): + guide = _format_job_guide([cat]) + + assert "CS_FUNDAMENTAL" in guide + assert "쓰지 마세요" not in guide + assert "DOMAIN_KNOWLEDGE 는 쓰지 않습니다" in guide + + +def test_mixed_selection_gets_both_rules_not_one(): + # 직군은 복수 선택이 가능하다. 한쪽 규칙만 주면 나머지 직군 질문이 통째로 어긋난다. + guide = _format_job_guide(["BACKEND", "SALES"]) + + assert "질문마다" in guide + assert "CS_FUNDAMENTAL" in guide and "DOMAIN_KNOWLEDGE" in guide + + +def test_empty_job_categories_do_not_assume_engineering(): + # 가정이 틀렸을 때 비개발 지원자에게 기술 질문이 나가는 쪽이 반대보다 나쁘다. + assert "쓰지 마세요" in _format_job_guide([]) + assert "쓰지 마세요" in _format_job_guide(None) + + +def test_domain_knowledge_is_an_allowed_category(): + # Literal 에 없으면 LLM 이 지침대로 골라도 검증에서 튕겨 생성이 통째로 실패한다. + assert "DOMAIN_KNOWLEDGE" in get_args(QuestionCategory) + + +def test_prompts_do_not_hardcode_an_it_persona(): + """직군을 받아 놓고 페르소나만 IT 로 박아 두면 LLM 은 페르소나 쪽으로 기운다.""" + for name, prompt in ( + ("question", QUESTION_PROMPT), + ("followup", FOLLOWUP_PROMPT), + ("self_intro", SELF_INTRO_PROMPT), + ("document_analysis", DOC_PROMPT), + ): + assert "IT 직군" not in prompt, f"{name} 프롬프트가 직군을 IT 로 고정한다" + + +def test_followup_and_self_intro_use_the_job_category_they_receive(): + # 두 프롬프트는 예전부터 {job_category} 를 받고 있었는데 쓰지 않았다. + assert "{job_category} 직군" in FOLLOWUP_PROMPT + assert "{job_category} 직군" in SELF_INTRO_PROMPT + + +def test_question_prompt_injects_the_job_guide(): + assert "{job_guide}" in HUMAN_PROMPT diff --git a/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java b/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java index 9cd7cbb0..8e1e8c6a 100644 --- a/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java +++ b/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java @@ -140,6 +140,19 @@ private List searchVectorOnly( // 벡터(코사인) 랭킹과 full-text(ts_rank_cd) 랭킹을 각각 구한 뒤 // RRF(Reciprocal Rank Fusion, k=60): score = 1/(k+rank) 합으로 융합한다. // 점수 스케일이 다른 두 랭킹을 "순위"만으로 합치므로 가중치 튜닝이 불필요. + // + // **tsquery 는 OR 로 묶는다.** `plainto_tsquery` 는 토큰을 전부 `&`(AND)로 묶는데, 이쪽 + // 호출자의 질의는 "직전질문 + 답변"(꼬리질문)이나 "모드 + 직군 + 자기소개 600자" + // (질문 풀)라 토큰이 10~90개다. 그 전부를 담은 청크는 존재하지 않으므로 full-text + // 브랜치가 **항상 0건**이었고, RRF 가 조용히 벡터 단독으로 퇴화했다 — 운영 실측으로 + // 꼬리질문 47/47, 질문 풀 30/30 이 0건이었다. 구현돼 있고 테스트도 통과하지만 실제 + // 질의 형태에서는 무효인 상태였다. + // + // OR 로 바꾼 효과(운영과 같은 헤딩 청킹 인덱스, 질의 47개, top_k=5): + // MRR 0.791 → 0.885, Top-1 0.681 → 0.830, Recall 0.936 → 0.979 + // (paired bootstrap 95% CI: MRR +0.029~+0.162, Top-1 +0.043~+0.277). + // 후보 풀 `:cand` 은 손대지 않았다 — 측정한 구성이 `cand = top_k` 였고, 바꾸면 + // 측정하지 않은 것을 배포하는 셈이 된다. private List searchHybrid( float[] queryEmbedding, String queryText, @@ -153,7 +166,13 @@ private List searchHybrid( // 쪼개면 `.formatted` 가 마지막 조각에만 걸려 placeholder 가 밀린다(실제로 그렇게 깨졌다). // 조각을 넣어야 하면 여기처럼 %%s 인자로 주입한다 — 순서는 등장 순. String sql = """ - WITH v AS ( + WITH tq AS ( + -- plainto_tsquery 의 AND 결합을 OR 로 바꾼다. 어휘소 사이의 구분자만 + -- 치환하므로 어휘소 안의 문자는 건드리지 않는다(`&` 는 구분자라 어휘소가 + -- 되지 못한다). 빈 질의는 빈 tsquery 가 되어 0건 — 현행과 같다. + SELECT replace(plainto_tsquery('simple', :qtext)::text, ' & ', ' | ')::tsquery AS tsq + ), + v AS ( SELECT e.document_id, e.chunk_index, e.chunk_text, (e.embedding <=> CAST(:qvec AS vector)) AS distance, ROW_NUMBER() OVER (ORDER BY e.embedding <=> CAST(:qvec AS vector)) AS rnk @@ -166,13 +185,13 @@ WITH v AS ( t AS ( SELECT e.document_id, e.chunk_index, e.chunk_text, ROW_NUMBER() OVER ( - ORDER BY ts_rank_cd(e.chunk_text_tsv, plainto_tsquery('simple', :qtext)) DESC + ORDER BY ts_rank_cd(e.chunk_text_tsv, (SELECT tsq FROM tq)) DESC ) AS rnk FROM document_embeddings e %s - WHERE e.chunk_text_tsv @@ plainto_tsquery('simple', :qtext) + WHERE e.chunk_text_tsv @@ (SELECT tsq FROM tq) %s - ORDER BY ts_rank_cd(e.chunk_text_tsv, plainto_tsquery('simple', :qtext)) DESC + ORDER BY ts_rank_cd(e.chunk_text_tsv, (SELECT tsq FROM tq)) DESC LIMIT :cand ) SELECT COALESCE(v.document_id, t.document_id) AS document_id, diff --git a/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java b/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java index 4a43524f..451c0f06 100644 --- a/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java +++ b/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java @@ -100,6 +100,56 @@ void hybridSearchExcludesDeletedDocuments() { assertThat(hits).isEmpty(); } + /** + * 하이브리드의 full-text 절반이 실제 질의 형태에서 죽어 있지 않아야 한다. + * + *

`plainto_tsquery` 는 토큰을 전부 AND 로 묶는다. 그런데 이 검색의 질의는 + * 꼬리질문이면 "직전질문 + 답변", 질문 풀이면 "모드 + 직군 + 자기소개 600자"라 + * 토큰이 수십 개다 — 그 전부를 담은 청크는 없으므로 full-text 브랜치가 **항상 0건**이었고 + * RRF 가 조용히 벡터 단독으로 퇴화했다(운영 실측 47/47, 30/30 이 0건). + * + *

고장이 "결과가 없다"가 아니라 "벡터 결과와 같다"로 나타나 눈에 띄지 않는다. 그래서 + * **벡터만으로는 top_k 밖인 청크를 키워드로만 끌어올리게** 만들어 두 절반을 구분한다 — + * 미끼를 top_k 만큼 채우지 않으면 벡터가 어차피 전부 반환해 AND 에서도 통과한다. + */ + @Test + void hybridMatchesOnAnySignificantTokenNotAllOfThem() { + AnalyzedDocument doc = document(97010L, "or-tsquery"); + List chunks = new java.util.ArrayList<>(); + // 질의와 같은 방향(=가까운) 미끼를 top_k 개. 질의 토큰은 하나도 담지 않는다. + for (int i = 0; i < 5; i++) { + chunks.add(new EmbeddingChunk(i, "미끼 문단 " + i, axis(0))); + } + // 질의 토큰 중 "카프카" 하나만 담은 청크. 방향이 직교라 벡터로는 꼴찌다. + chunks.add(new EmbeddingChunk(5, "카프카 컨슈머 랙을 줄인 경험", axis(1))); + embeddingRepository.upsertAll(doc.getId(), "test-model", chunks); + + // 실제 호출자를 흉내 낸 여러 토큰 질의 — 이 전부를 담은 청크는 없다. + String query = "앞서 말씀하신 카프카 이야기를 이어가면 파티션 재배치와 리밸런싱을 " + + "어떻게 처리하셨는지 궁금합니다"; + + List hits = embeddingRepository.search(axis(0), query, List.of(doc.getId()), 5); + + assertThat(hits) + .as("키워드 절반이 죽어 있으면 벡터가 고른 미끼 5개만 남는다") + .extracting(SearchHit::chunkIndex) + .contains(5); + } + + // 질의에 어휘소가 없으면(구두점만 등) tsquery 가 비는데, 그때도 깨지지 않고 + // 벡터 단독으로 동작해야 한다 — 수정 전과 같은 동작. + @Test + void hybridFallsBackToVectorWhenQueryHasNoLexemes() { + AnalyzedDocument doc = document(97011L, "empty-tsquery"); + embeddingRepository.upsertAll(doc.getId(), "test-model", + List.of(new EmbeddingChunk(0, "레디스 캐시 도입", axis(0)))); + + List hits = embeddingRepository.search( + axis(0), "!!! ??? ...", List.of(doc.getId()), 5); + + assertThat(hits).extracting(SearchHit::chunkIndex).containsExactly(0); + } + // 검색 범위는 호출자가 뭘 보내든 요청자 소유 문서를 벗어나면 안 된다. // 이전에는 documentIds 를 그대로 믿었고, 비면 전체 사용자 청크가 대상이었다. @Test @@ -168,6 +218,14 @@ private List search(List documentIds) { return embeddingRepository.search(vector(0.9f), null, documentIds, 10); } + // 서로 직교하는 단위 벡터. 기존 vector(head) 는 0번 차원만 크기를 달리해서 + // **모든 벡터가 평행**이다 — 코사인 거리가 언제나 0이라 순위가 갈리지 않는다. + private static float[] axis(int dim) { + float[] v = new float[1536]; + v[dim] = 1f; + return v; + } + private static float[] vector(float head) { float[] v = new float[1536]; v[0] = head; diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 2fdef25a..7a8eefd4 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -425,6 +425,8 @@ HTTP/1.1 202 Accepted RAG 검색. AI가 질문 풀·꼬리질문·피드백 생성 시 컨텍스트 청크를 가져온다. `document_embeddings`에서 pgvector cosine topK — `queryText`가 주어지면 벡터 + full-text(BM25) RRF 하이브리드로 검색한다. +> full-text 쪽 tsquery 는 **OR 결합**이다(2026-09-29). `plainto_tsquery` 기본값인 AND 로는 이 API 의 실제 질의(꼬리질문 = 직전질문+답변, 질문 풀 = 모드+직군+자기소개 600자, 토큰 수십 개)를 전부 담은 청크가 없어 full-text 브랜치가 항상 0건이었고 RRF 가 벡터 단독으로 퇴화했다 — 운영 실측 47/47·30/30 이 0건. OR 로 바꾼 뒤 운영과 같은 헤딩 청킹 인덱스에서 MRR 0.791→0.885, Top-1 0.681→0.830, Recall 0.936→0.979. + **Request body** ```json { diff --git a/docs/database.md b/docs/database.md index 38375cef..80c8f8c8 100644 --- a/docs/database.md +++ b/docs/database.md @@ -344,6 +344,8 @@ CREATE TABLE document_embeddings ( CREATE INDEX idx_document_embeddings_hnsw ON document_embeddings USING hnsw (embedding vector_cosine_ops); -- 하이브리드 검색용 full-text 색인(V8). chunk_text 의 generated tsvector 컬럼. +-- 질의 쪽 tsquery 는 OR 결합으로 만든다(JdbcDocumentEmbeddingRepository) — AND 로는 +-- 실제 질의가 길어 이 색인이 한 번도 매칭되지 않았다. CREATE INDEX idx_document_embeddings_tsv ON document_embeddings USING GIN (chunk_text_tsv); ``` diff --git a/frontend/src/features/interview/lib/categoryLabel.ts b/frontend/src/features/interview/lib/categoryLabel.ts index 2338bcd1..adc26374 100644 --- a/frontend/src/features/interview/lib/categoryLabel.ts +++ b/frontend/src/features/interview/lib/categoryLabel.ts @@ -5,6 +5,8 @@ const CATEGORY_LABEL: Record = { PROJECT_DEEP_DIVE: '프로젝트 심화', TECH_CHOICE: '기술 선택', BEHAVIORAL: '인성·행동', + // 비개발 직군용. CS 기초·기술 선택이 개발 전용이라 그 자리를 대신한다. + DOMAIN_KNOWLEDGE: '직무 지식', } export function categoryLabel(category?: string | null): string | null {