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/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,11 @@ 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차 근거로 사용한다(없으면 자료만으로).
- **약점 집중 질문 생성**: payload 의 `focusAreas`(TECHNICAL|LOGIC|COMMUNICATION, 지난 면접에서
낮았던 축)가 채워지면 프롬프트의 집중 영역 블록(`_format_focus_areas`, 축별 질문 설계 관점을
`_FOCUS_GUIDE` 로 명시)이 그 영역을 검증하는 질문을 과반으로 배치하게 한다. 라벨만 넘기면 LLM 이
제각각 해석하므로 관점 문구를 붙인다. 근거 없는 질문 생성 금지 조건은 그대로 — 자료가 부족하면
집중 비중을 줄인다. 지정이 없으면 "(지정 없음)" 이 들어가 일반 면접과 동일.
- **직무 맞춤(JOB_TAILORED) 질문 생성**: `mode=JOB_TAILORED` 면 payload 의 `targetCompanyName`·
`targetJobDescription`(JD)이 채워진다. 프롬프트가 `target_role` 블록으로 받아 이력서↔JD 교집합·갭,
JD 요구 충족 검증, 지원동기·컬처핏 질문을 우선 생성한다(다른 모드는 '(일반 면접)' 안내라 무시).
Expand Down
10 changes: 9 additions & 1 deletion ai/src/ai_server/chain/prompts/question_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
"---\n"
"{self_introduction}\n"
"---\n\n"
"이번 면접의 집중 영역 (약점 집중 재도전일 때만 채워짐):\n"
"---\n"
"{focus_areas}\n"
"---\n\n"
"타깃 회사/직무 (직무 맞춤 모드일 때만 채워짐):\n"
"---\n"
"{target_role}\n"
Expand Down Expand Up @@ -84,7 +88,11 @@
" - expected_signal: 좋은 답변이 드러내야 할 핵심(평가 관점). 한 문장.\n"
"5. 지원자 컨텍스트의 기술/프로젝트를 구체적으로 언급하는 PROJECT_DEEP_DIVE / "
"TECH_CHOICE 질문을 우선합니다.\n"
"6. 아래는 같은 지원자가 최근 면접에서 이미 받은 질문들입니다. 이와 "
"6. **집중 영역이 지정돼 있으면** 그 영역을 검증하는 질문이 과반이 되도록 배치합니다. "
"다만 자료에 근거가 없는 질문을 지어내지는 마세요 — 근거가 부족하면 집중 영역 비중을 줄이고 "
"자료가 받쳐주는 질문으로 채웁니다. 집중 영역은 **무엇을 묻는가**를 바꾸는 것이지 "
"카테고리 규칙(4번의 target_evidence 필수 조건)을 면제하지 않습니다.\n"
"7. 아래는 같은 지원자가 최근 면접에서 이미 받은 질문들입니다. 이와 "
"**의미상 중복되는 질문은 만들지 말고**, 새로운 주제나 다른 각도로 출제하세요:\n"
"---\n"
"{recent_questions}\n"
Expand Down
33 changes: 33 additions & 0 deletions ai/src/ai_server/chain/question_generation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,36 @@ def _format_self_introduction(self_introduction: str | None) -> str:
return text if text else "(자기소개 없음)"


# 평가 축 → 질문 설계 지침. 라벨만 넘기면 LLM 이 제각각 해석하므로 관점을 명시한다.
_FOCUS_GUIDE = {
"TECHNICAL": (
"기술 정확도 — 개념을 정확히 아는지 파고드는 질문. 사용한 기술의 동작 원리·트레이드오프·"
"대안과의 비교를 묻고, 뭉뚱그린 답으로는 통과할 수 없게 구체적인 지점을 짚으세요."
),
"LOGIC": (
"논리력 — 결론에 이른 근거와 판단 과정을 드러내게 하는 질문. 왜 그 선택이었는지, 다른 안을 "
"왜 버렸는지, 가정이 틀렸다면 어떻게 되는지를 묻습니다."
),
"COMMUNICATION": (
"전달력 — 복잡한 내용을 상대 눈높이로 설명하게 하는 질문. 비전공자/신입에게 설명하기, "
"설계 의도를 짧게 요약하기처럼 구조적으로 말해야 풀리는 질문을 섞으세요."
),
}


def _format_focus_areas(focus_areas: list[str] | None) -> str:
"""약점 집중 블록. 지정이 없으면 일반 면접 안내."""
if not focus_areas:
return "(지정 없음 — 특정 영역에 치우치지 말고 자료가 받쳐주는 대로 출제.)"
lines = [
"지난 면접에서 아래 영역의 점수가 낮았습니다. 이번 면접은 그 영역을 다시 검증합니다:"
]
for area in focus_areas:
guide = _FOCUS_GUIDE.get(area)
lines.append(f"- {area}: {guide}" if guide else f"- {area}")
return "\n".join(lines)


def _format_target_role(company_name: str | None, job_description: str | None) -> str:
"""직무 맞춤 모드의 타깃 회사/JD 블록. 둘 다 없으면 일반 면접 안내."""
company = (company_name or "").strip()
Expand Down Expand Up @@ -57,6 +87,7 @@ async def generate(
self_introduction: str | None = None,
target_company_name: str | None = None,
target_job_description: str | None = None,
focus_areas: list[str] | None = None,
) -> GeneratedQuestionPool: ...


Expand All @@ -75,6 +106,7 @@ async def generate(
self_introduction: str | None = None,
target_company_name: str | None = None,
target_job_description: str | None = None,
focus_areas: list[str] | None = None,
) -> GeneratedQuestionPool:
result = await self._chain.ainvoke(
{
Expand All @@ -87,6 +119,7 @@ async def generate(
"target_role": _format_target_role(
target_company_name, target_job_description
),
"focus_areas": _format_focus_areas(focus_areas),
}
)
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 @@ -124,6 +124,7 @@ async def _generate_pool_payload(
self_introduction=req.self_intro_answer,
target_company_name=req.target_company_name,
target_job_description=req.target_job_description,
focus_areas=req.focus_areas,
)
except Exception as exc: # noqa: BLE001
log.exception(
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 @@ -45,6 +45,9 @@ class GenerateQuestionsRequest(BaseModel):
# 직무 맞춤(JOB_TAILORED) 모드 전용. 지원 회사명 + 채용공고(JD) 원문. 다른 모드는 None/빈값.
target_company_name: str | None = None
target_job_description: str | None = None
# 약점 집중 재도전에서만 채워진다. TECHNICAL|LOGIC|COMMUNICATION 중 1~2개.
# 지난 면접에서 낮았던 평가 축이며, 그 영역을 검증하는 질문을 우선 배치한다.
focus_areas: list[str] = []


class GeneratedQuestion(BaseModel):
Expand Down
110 changes: 110 additions & 0 deletions ai/tests/test_question_focus_areas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from unittest.mock import AsyncMock, MagicMock

import pytest

from ai_server.chain.question_generation_chain import (
GeneratedQuestionPool,
LlmQuestionGenerator,
_format_focus_areas,
)
from ai_server.model.messages.questions import (
GeneratedQuestion,
GenerateQuestionsRequest,
)


class TestFormatFocusAreas:
def test_no_focus_says_do_not_skew(self) -> None:
text = _format_focus_areas(None)

assert "지정 없음" in text
# 지정이 없을 때 특정 영역으로 쏠리라는 지시가 새어 들어가면 안 된다.
assert "낮았습니다" not in text

def test_empty_list_is_same_as_none(self) -> None:
assert _format_focus_areas([]) == _format_focus_areas(None)

# 라벨만 넘기면 LLM 이 제각각 해석한다 — 축마다 질문 설계 관점을 붙여야 한다.
@pytest.mark.parametrize(
("area", "expected"),
[
("TECHNICAL", "동작 원리"),
("LOGIC", "근거"),
("COMMUNICATION", "전달력"),
],
)
def test_known_area_carries_guidance(self, area: str, expected: str) -> None:
text = _format_focus_areas([area])

assert area in text
assert expected in text

def test_multiple_areas_are_all_listed(self) -> None:
text = _format_focus_areas(["LOGIC", "COMMUNICATION"])

assert "LOGIC" in text
assert "COMMUNICATION" in text

# Core 가 새 축을 추가해도 프롬프트가 깨지지 않아야 한다.
def test_unknown_area_degrades_gracefully(self) -> None:
text = _format_focus_areas(["SOMETHING_NEW"])

assert "SOMETHING_NEW" in text


class TestGeneratorPassesFocusAreas:
@pytest.mark.asyncio
async def test_focus_areas_reach_the_prompt(self) -> None:
chain = MagicMock()
chain.ainvoke = AsyncMock(
return_value=GeneratedQuestionPool(
questions=[
GeneratedQuestion(category="CS_FUNDAMENTAL", question="q?"),
]
)
)
generator = LlmQuestionGenerator(chain)

await generator.generate(
job_categories=["BACKEND"],
mode="TECHNICAL",
max_questions=3,
context="ctx",
focus_areas=["LOGIC"],
)

variables = chain.ainvoke.await_args.args[0]
assert "LOGIC" in variables["focus_areas"]

@pytest.mark.asyncio
async def test_omitting_focus_areas_still_fills_the_slot(self) -> None:
chain = MagicMock()
chain.ainvoke = AsyncMock(
return_value=GeneratedQuestionPool(
questions=[
GeneratedQuestion(category="CS_FUNDAMENTAL", question="q?"),
]
)
)
generator = LlmQuestionGenerator(chain)

await generator.generate(
job_categories=["BACKEND"], mode="TECHNICAL", max_questions=3, context="ctx"
)

# 프롬프트 변수는 항상 채워져야 한다 — 비면 템플릿 렌더가 KeyError 로 깨진다.
assert "지정 없음" in chain.ainvoke.await_args.args[0]["focus_areas"]


class TestRequestModel:
def test_focus_areas_default_to_empty(self) -> None:
req = GenerateQuestionsRequest(sessionId=1, mode="TECHNICAL")

assert req.focus_areas == []

def test_focus_areas_parse_from_camel_case(self) -> None:
req = GenerateQuestionsRequest(
sessionId=1, mode="TECHNICAL", focusAreas=["LOGIC", "COMMUNICATION"]
)

assert req.focus_areas == ["LOGIC", "COMMUNICATION"]
11 changes: 11 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,17 @@ docker compose up -d
분석 미완료에 `DOC_NOT_ANALYZED` 를 던지므로, 원본 설정을 그대로 재전송하면 그 사이 자료 하나
지운 사용자는 재도전 자체가 막힌다. 빠진 자료는 응답 `contextDocumentIds` 를 원본과 비교해
프론트가 안내한다.
- **약점 집중 재도전 본 구현 (B-3)**: `POST /api/sessions/{id}/retry` 에 `{"focusOnWeakness":true}`
를 주면 `SessionService.retry` 가 원본 세션 피드백에서 낮은 평가 축을 골라 새 세션의
`focus_areas`(V25, JSONB, `SessionFocusArea` name 배열)에 새긴다. 선정 기준: 임계값
(`interview.weakness-focus.score-threshold`, 기본 70) 미만인 축을 낮은 순 최대 2개.
**전부 기준 이상이어도 가장 낮은 하나는 고른다** — 눌렀는데 아무것도 안 바뀌면 고장으로 보인다.
피드백이 없으면(중단 세션) 빈 목록이라 일반 재도전과 같아진다.
`SessionQuestionsRequester` 가 세션에서 읽어 `generate.questions.focusAreas` 로 싣고,
AI 프롬프트가 그 영역을 검증하는 질문을 과반으로 배치한다(단, 자료 근거 없는 질문 생성 금지 —
target_evidence 필수 조건은 면제되지 않는다).
- `@Value` 필드에 **자바 초기값도 함께** 둔다(`= 70`). Spring 밖(단위 테스트)에서는 주입이
안 돼 0.0 이 되고, 그러면 모든 축이 '기준 이상'으로 판정돼 조용히 다른 동작을 한다.
- **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당.
- **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로.

Expand Down
25 changes: 24 additions & 1 deletion backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@
"post" : {
"tags" : [ "Sessions" ],
"summary" : "같은 설정으로 다시 면접 (US-13 재도전)",
"description" : "기존 세션의 설정(모드·직군·질문 수·JD 등)을 그대로 복사해 새 세션을 만든다. 연결 자료는 지금도 살아있고 분석이 끝난 것만 다시 잇는다 — 그 사이 삭제된 자료 때문에 재도전 전체가 404 로 막히지 않게 하기 위함. 응답의 contextDocumentIds 를 원본과 비교하면 무엇이 빠졌는지 알 수 있다.",
"description" : "기존 세션의 설정(모드·직군·질문 수·JD 등)을 그대로 복사해 새 세션을 만든다. focusOnWeakness=true 면 원본 피드백에서 낮았던 평가 축을 집중 영역으로 새겨, 질문 생성이 그 영역을 검증하는 질문을 우선 배치한다. 연결 자료는 지금도 살아있고 분석이 끝난 것만 다시 잇는다 — 그 사이 삭제된 자료 때문에 재도전 전체가 404 로 막히지 않게 하기 위함. 응답의 contextDocumentIds 를 원본과 비교하면 무엇이 빠졌는지 알 수 있다.",
"operationId" : "retrySession",
"parameters" : [ {
"name" : "sessionId",
Expand All @@ -386,6 +386,15 @@
"format" : "int64"
}
} ],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/SessionRetryRequest"
}
}
}
},
"responses" : {
"201" : {
"description" : "새 세션 생성 완료",
Expand Down Expand Up @@ -3056,6 +3065,12 @@
"targetJobDescription" : {
"type" : "string"
},
"focusAreas" : {
"type" : "array",
"items" : {
"type" : "string"
}
},
"createdAt" : {
"type" : "string",
"format" : "date-time"
Expand All @@ -3074,6 +3089,14 @@
}
}
},
"SessionRetryRequest" : {
"type" : "object",
"properties" : {
"focusOnWeakness" : {
"type" : "boolean"
}
}
},
"MessageSubmitRequest" : {
"type" : "object",
"properties" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import com.stackup.stackup.session.application.dto.GenerateQuestionsPayload.DocumentContext;
import com.stackup.stackup.session.application.event.SelfIntroAnsweredEvent;
import com.stackup.stackup.session.application.event.SessionCreatedEvent;
import com.stackup.stackup.session.domain.InterviewSession;
import com.stackup.stackup.session.domain.InterviewSessionRepository;
import com.stackup.stackup.session.domain.SessionQuestionPoolRepository;
import java.io.IOException;
Expand Down Expand Up @@ -44,6 +45,7 @@ public class SessionQuestionsRequester {
private static final Logger log = LoggerFactory.getLogger(SessionQuestionsRequester.class);
private static final JsonMapper JSON = JsonMapper.builder().build();
private static final TypeReference<List<String>> TECH_STACK_TYPE = new TypeReference<>() {};
private static final TypeReference<List<String>> FOCUS_AREA_TYPE = new TypeReference<>() {};
private static final long MAX_MARKDOWN_BYTES = 200_000L; // envelope 비대화 방지
private static final int DEFAULT_GENERAL_QUESTION_COUNT = 3;

Expand Down Expand Up @@ -91,6 +93,7 @@ public void onSelfIntroAnswered(SelfIntroAnsweredEvent event) {
}

List<String> recentQuestions = recentQuestions(event.userId(), event.sessionId());
List<String> focusAreas = focusAreas(event.sessionId());
GenerateQuestionsPayload payload = new GenerateQuestionsPayload(
event.sessionId(),
event.mode(),
Expand All @@ -101,17 +104,35 @@ public void onSelfIntroAnswered(SelfIntroAnsweredEvent event) {
recentQuestions,
event.selfIntroAnswer(),
event.targetCompanyName(),
event.targetJobDescription()
event.targetJobDescription(),
focusAreas
);
publisher.publishToAi(
properties.routingKeys().generateQuestions(),
payload,
new MessageContext(event.userId(), event.sessionId(), null, null)
);
log.info("generate.questions published (post self-intro). sessionId={}, doc_count={}, "
+ "pool_count={}, max={}, recent_q={}, intro_len={}",
+ "pool_count={}, max={}, recent_q={}, intro_len={}, focus={}",
event.sessionId(), documents.size(), poolCount, event.maxQuestions(), recentQuestions.size(),
event.selfIntroAnswer() == null ? 0 : event.selfIntroAnswer().length());
event.selfIntroAnswer() == null ? 0 : event.selfIntroAnswer().length(), focusAreas);
}

// 약점 집중 재도전으로 만들어진 세션이면 겨냥할 평가 축을 꺼낸다. 아니면 빈 목록.
// 이벤트를 늘리지 않고 세션에서 직접 읽는다(이미 sessionRepository 를 쓰고 있다).
private List<String> focusAreas(Long sessionId) {
return sessionRepository.findById(sessionId)
.map(InterviewSession::getFocusAreas)
.filter(json -> json != null && !json.isBlank())
.map(json -> {
try {
return JSON.readValue(json, FOCUS_AREA_TYPE);
} catch (JsonProcessingException e) {
log.warn("focus_areas parse failed. sessionId={}, raw={}", sessionId, json, e);
return List.<String>of();
}
})
.orElseGet(List::of);
}

// 같은 유저의 최근 N개 세션에서 출제된 질문을 모아 AI 의 중복 회피용으로 전달.
Expand Down
Loading
Loading