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
5 changes: 5 additions & 0 deletions ai/src/ai_server/chain/prompts/question_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,16 @@
"장점과 단점 / 상사와의 갈등 해결 / 원하는 부서 미배치 시 대응 / 개인의 비전 / 갈등 해결 경험 / "
"가장 몰입한 경험 / 본인을 뽑아야 하는 이유·강점 / 10년 후 모습 / 마지막으로 하고 싶은 말 / "
"왜 다른 곳이 아닌 이 회사인가(지원동기).\n"
"- **산업이 주어지면 그 현장의 언어로 묻습니다.** 같은 직군이라도 산업이 다르면 묻는 것이 "
"다릅니다 — 같은 '생산·품질' 이라도 반도체는 수율·공정 편차·클린룸을, 건설은 공정표·안전관리·"
"협력업체를 다룹니다. 다만 **지원자 자료에 근거가 없는 산업 지식을 지어내 묻지 않습니다** — "
"산업은 질문의 맥락이지 새로운 근거가 아닙니다. '(지정 없음)' 이면 이 지침을 무시합니다.\n"
"- 응답은 반드시 지정된 JSON 스키마를 따릅니다."
)

HUMAN_PROMPT = (
"직군(복수 가능): {job_categories}\n"
"산업: {industry}\n"
"면접 모드: {mode}\n"
"최대 질문 수: {max_questions}\n\n"
"지원자 자기소개 답변 (질문 생성의 1차 근거):\n"
Expand Down
4 changes: 4 additions & 0 deletions ai/src/ai_server/chain/question_generation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ async def generate(
target_company_name: str | None = None,
target_job_description: str | None = None,
focus_areas: list[str] | None = None,
industry: str | None = None,
) -> GeneratedQuestionPool: ...


Expand All @@ -107,6 +108,7 @@ async def generate(
target_company_name: str | None = None,
target_job_description: str | None = None,
focus_areas: list[str] | None = None,
industry: str | None = None,
) -> GeneratedQuestionPool:
result = await self._chain.ainvoke(
{
Expand All @@ -120,6 +122,8 @@ async def generate(
target_company_name, target_job_description
),
"focus_areas": _format_focus_areas(focus_areas),
# 비어 있으면 "(지정 없음)" — 프롬프트가 산업 지침을 무시하도록.
"industry": (industry or "").strip() or "(지정 없음)",
}
)
if not isinstance(result, GeneratedQuestionPool):
Expand Down
1 change: 1 addition & 0 deletions ai/src/ai_server/messaging/consumers/questions_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ async def _process(
target_company_name=req.target_company_name,
target_job_description=req.target_job_description,
focus_areas=req.focus_areas,
industry=req.industry,
)
# 실패는 예외로 위 가드에 넘어가므로 여기 도달 = 성공 —
# FAILED 콜백 직전에 "마무리하고 있어요" 가 스치는 오해가 구조적으로 없다.
Expand Down
3 changes: 3 additions & 0 deletions ai/src/ai_server/model/messages/questions.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ class GenerateQuestionsRequest(BaseModel):
# 약점 집중 재도전에서만 채워진다. TECHNICAL|LOGIC|COMMUNICATION 중 1~2개.
# 지난 면접에서 낮았던 평가 축이며, 그 영역을 검증하는 질문을 우선 배치한다.
focus_areas: list[str] = []
# 희망 산업(자유 입력, 선택). 직군만으로는 맥락이 얇다 — 같은 "생산·품질" 이라도
# 반도체 공정과 건설 현장은 묻는 것이 전혀 다르다. 비어 있으면 프롬프트가 무시한다.
industry: str | None = None


class GeneratedQuestion(BaseModel):
Expand Down
66 changes: 66 additions & 0 deletions ai/tests/test_question_generation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from __future__ import annotations

import pytest

from ai_server.chain.question_generation_chain import GeneratedQuestionPool

# ── 산업 맥락 ────────────────────────────────────────────────────────────────
#
# 직군만으로는 질문 맥락이 얇다. 같은 "생산·품질" 이라도 반도체 공정과 건설 현장은 묻는
# 것이 전혀 다르다. 산업은 자유 입력이라 CHECK·Literal 이 없고, 그래서 **프롬프트까지
# 실제로 도달하는지**를 테스트가 봐야 한다.


def _capture_chain():
"""ainvoke 에 들어온 입력을 그대로 잡아두는 가짜 체인."""
seen: dict = {}

class _Chain:
async def ainvoke(self, payload):
seen.update(payload)
return GeneratedQuestionPool(questions=[])

return _Chain(), seen


@pytest.mark.asyncio
async def test_industry_reaches_the_prompt():
from ai_server.chain.question_generation_chain import LlmQuestionGenerator

chain, seen = _capture_chain()
await LlmQuestionGenerator(chain).generate(
job_categories=["MANUFACTURING"],
mode="TECHNICAL",
max_questions=3,
context="",
industry="반도체",
)

assert seen["industry"] == "반도체"


@pytest.mark.asyncio
async def test_missing_industry_becomes_explicit_none():
"""빈 값을 그대로 흘리면 프롬프트에 'None' 이나 빈 줄이 박힌다 —
LLM 이 그걸 산업 이름으로 읽을 수 있다. 명시적으로 '(지정 없음)' 을 넣는다."""
from ai_server.chain.question_generation_chain import LlmQuestionGenerator

for value in (None, "", " "):
chain, seen = _capture_chain()
await LlmQuestionGenerator(chain).generate(
job_categories=["SALES"],
mode="PERSONALITY",
max_questions=3,
context="",
industry=value,
)
assert seen["industry"] == "(지정 없음)", f"industry={value!r}"


def test_prompt_has_industry_slot_and_guidance():
"""슬롯만 있고 지침이 없으면 LLM 이 산업을 장식으로 흘려보낸다."""
from ai_server.chain.prompts.question_generation import HUMAN_PROMPT, SYSTEM_PROMPT

assert "{industry}" in HUMAN_PROMPT
# 산업을 근거로 착각해 없는 지식을 지어내면 안 된다 — 그 경계가 지침에 있어야 한다.
assert "지어내" in SYSTEM_PROMPT
20 changes: 20 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,26 @@ docker compose up -d
`/api/system/health` 의 `scheduler` 가 DOWN. DB·네트워크를 건드리지 않는다 — 심장박동이
자기가 감시하는 문제(느린 I/O)로 막히면 안 된다. `BACKUP` 과 같이 **informational** 이라
aggregate 는 끌어내리지 않는다(스케줄러가 멈춰도 면접·로그인·조회는 된다).
- **사용자 취업 프로필 본 구현 (`profile` 슬라이스)**: 희망 직군(다중)·희망 산업(자유 입력)·
경력 수준. `GET/PUT /api/users/me/job-profile`. 면접을 만들 때마다 직군을 다시 고르게 했고,
직군만으로는 질문 맥락이 얇았다 — **같은 '생산·품질' 이라도 반도체 공정과 건설 현장은 묻는
것이 전혀 다르다.**
- **왜 별도 슬라이스인가**: `User` 에 직접 달면 user 가 `JobCategory`(session 슬라이스)를
참조해 **user→session→user 순환**이 생기고 ArchUnit 이 막는다(`OAuthProvider` 를
`user.domain` 에 둔 것과 같은 이유). `profile → {user, session}` 이고 역방향이 없다.
실제로 `User` 에 붙였다가 `top_level_domain_slices_are_free_of_cycles` 로 걸렸다.
- **산업에는 CHECK 를 걸지 않는다.** 직군은 평가 관점을 큐레이션해야 해서 유한 집합이지만,
산업은 프롬프트 맥락으로만 쓰이므로 열거하면 빠진 산업의 지원자가 또 배제된다
(반도체·건설·토목을 넣어도 조선·방산·바이오…). 프론트는 `datalist` 로 제안만 한다.
- **null 은 "그대로 두기", 빈 값은 "지우기"**. 계정 화면이 일부만 보내도 나머지가 날아가면
안 된다. 비우는 수단이 따로 있어야 해서 빈 목록/빈 문자열을 지우기로 쓴다.
- **프로필 → 세션은 프론트 프리필로 잇는다.** `SessionService` 가 프로필을 읽으면
session→profile 이 되어 다시 순환이다. 프론트가 프로필을 읽어 기본값을 채우고 사용자가
그 자리에서 바꿀 수 있으며, 세션은 **실제로 쓰인 값**만 `interview_sessions.industry` 에
기록한다(프로필은 나중에 바뀌므로 세션이 스스로 남겨야 한다 — 직군 복사와 같은 이유).
- 산업은 `generate.questions` payload 에 실려 질문 프롬프트의 맥락이 된다. 프롬프트는
"산업은 질문의 맥락이지 새로운 근거가 아니다 — 자료에 없는 산업 지식을 지어내 묻지 않는다"
를 명시한다. 비어 있으면 `(지정 없음)` 이 들어가 지침이 무시된다.
- **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당.
- **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로.

Expand Down
129 changes: 129 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
}, {
"name" : "Session Feedback",
"description" : "세션 종합 피드백 (US-24)"
}, {
"name" : "Users (Job Profile)",
"description" : "희망 직군·산업·경력 — 면접 생성 시 기본값으로 쓰인다."
}, {
"name" : "Internal: Document Embeddings",
"description" : "X-Internal-API-Key 필요. AI 서버가 분석 결과 청크/임베딩을 pgvector 에 idempotent upsert 할 때 호출."
Expand All @@ -70,6 +73,84 @@
"description" : "X-Internal-API-Key 필요. AI 서버가 질문/피드백 생성 시 컨텍스트 청크 검색 (pgvector cosine)."
} ],
"paths" : {
"/api/users/me/job-profile" : {
"get" : {
"tags" : [ "Users (Job Profile)" ],
"summary" : "내 취업 프로필 조회",
"description" : "프로필을 채운 적 없으면 404 가 아니라 빈 프로필(filled=false)을 반환한다.",
"operationId" : "getMyJobProfile",
"responses" : {
"200" : {
"description" : "프로필 반환",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/JobProfileResponse"
}
}
}
},
"401" : {
"description" : "인증 실패",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/JobProfileResponse"
}
}
}
}
}
},
"put" : {
"tags" : [ "Users (Job Profile)" ],
"summary" : "내 취업 프로필 저장",
"description" : "생략한(null) 항목은 그대로 둔다. 비우려면 빈 목록 / 빈 문자열을 보낸다.",
"operationId" : "updateMyJobProfile",
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/JobProfileUpdateRequest"
}
}
},
"required" : true
},
"responses" : {
"200" : {
"description" : "저장 후 최신 프로필 반환",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/JobProfileResponse"
}
}
}
},
"400" : {
"description" : "검증 실패",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/JobProfileResponse"
}
}
}
},
"401" : {
"description" : "인증 실패",
"content" : {
"*/*" : {
"schema" : {
"$ref" : "#/components/schemas/JobProfileResponse"
}
}
}
}
}
}
},
"/api/sessions/{sessionId}/messages/{messageId}/bookmark" : {
"put" : {
"tags" : [ "Session Messages" ],
Expand Down Expand Up @@ -3259,6 +3340,49 @@
},
"components" : {
"schemas" : {
"JobProfileUpdateRequest" : {
"type" : "object",
"properties" : {
"desiredJobCategories" : {
"type" : "array",
"items" : {
"type" : "string",
"enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA", "MOBILE", "DATA_AI", "SECURITY", "QA", "PLANNING", "MARKETING", "SALES", "HR", "FINANCE", "DESIGN", "MANUFACTURING", "RND", "CUSTOMER_SERVICE", "LOGISTICS", "LEGAL", "PUBLIC" ]
}
},
"desiredIndustry" : {
"type" : "string",
"maxLength" : 100,
"minLength" : 0
},
"careerLevel" : {
"type" : "string",
"enum" : [ "NEW", "EXPERIENCED", "INTERN", "CAREER_CHANGE" ]
}
}
},
"JobProfileResponse" : {
"type" : "object",
"properties" : {
"desiredJobCategories" : {
"type" : "array",
"items" : {
"type" : "string",
"enum" : [ "FRONTEND", "BACKEND", "INFRA", "DBA", "MOBILE", "DATA_AI", "SECURITY", "QA", "PLANNING", "MARKETING", "SALES", "HR", "FINANCE", "DESIGN", "MANUFACTURING", "RND", "CUSTOMER_SERVICE", "LOGISTICS", "LEGAL", "PUBLIC" ]
}
},
"desiredIndustry" : {
"type" : "string"
},
"careerLevel" : {
"type" : "string",
"enum" : [ "NEW", "EXPERIENCED", "INTERN", "CAREER_CHANGE" ]
},
"filled" : {
"type" : "boolean"
}
}
},
"QuestionBookmarkRequest" : {
"type" : "object",
"properties" : {
Expand Down Expand Up @@ -3435,6 +3559,11 @@
"type" : "string",
"maxLength" : 20000,
"minLength" : 0
},
"industry" : {
"type" : "string",
"maxLength" : 100,
"minLength" : 0
}
},
"required" : [ "jobCategories", "mode" ]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.stackup.stackup.profile.application;

import com.stackup.stackup.profile.application.dto.JobProfileResponse;
import com.stackup.stackup.profile.application.dto.JobProfileUpdateRequest;
import com.stackup.stackup.profile.domain.UserJobProfile;
import com.stackup.stackup.profile.domain.UserJobProfileRepository;
import com.stackup.stackup.session.domain.JobCategory;
import java.util.LinkedHashSet;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserJobProfileService {

private final UserJobProfileRepository repository;

/** 프로필이 없으면 빈 것을 돌려준다 — "아직 안 채움" 은 오류가 아니다. */
public JobProfileResponse get(Long userId) {
return repository.findByUserId(userId)
.map(JobProfileResponse::from)
.orElseGet(JobProfileResponse::empty);
}

@Transactional
public JobProfileResponse update(Long userId, JobProfileUpdateRequest request) {
UserJobProfile profile = repository.findByUserId(userId)
.orElseGet(() -> UserJobProfile.empty(userId));

Set<JobCategory> categories = request.desiredJobCategories() == null
? null
: new LinkedHashSet<>(request.desiredJobCategories());

profile.update(categories, request.desiredIndustry(), request.careerLevel());
return JobProfileResponse.from(repository.save(profile));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.stackup.stackup.profile.application.dto;

import com.stackup.stackup.profile.domain.CareerLevel;
import com.stackup.stackup.profile.domain.UserJobProfile;
import com.stackup.stackup.session.domain.JobCategory;
import java.util.List;
import java.util.Set;

public record JobProfileResponse(
List<JobCategory> desiredJobCategories,
String desiredIndustry,
CareerLevel careerLevel,
boolean filled
) {
public static JobProfileResponse from(UserJobProfile profile) {
Set<JobCategory> categories = profile.getDesiredJobCategories();
return new JobProfileResponse(
List.copyOf(categories),
profile.getDesiredIndustry(),
profile.getCareerLevel(),
profile.isFilled()
);
}

/** 프로필을 만든 적 없는 사용자. 404 대신 빈 프로필을 준다 — 없는 것도 정상 상태다. */
public static JobProfileResponse empty() {
return new JobProfileResponse(List.of(), null, null, false);
}
}
Loading
Loading