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
10 changes: 8 additions & 2 deletions ai/src/ai_server/chain/prompts/question_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@
" - TECHNICAL: CS_FUNDAMENTAL + TECH_CHOICE + PROJECT_DEEP_DIVE 중심.\n"
" - PERSONALITY: BEHAVIORAL 중심.\n"
" - INTEGRATED: 모두 균형.\n"
"4. 지원자 컨텍스트에 명시된 기술/프로젝트를 구체적으로 언급하는 PROJECT_DEEP_DIVE / "
"TECH_CHOICE 질문을 최소 절반 이상 포함하세요.\n\n"
" 자료가 받쳐주는 만큼만 분배하고, 근거 없는 카테고리를 억지로 채우지 마세요.\n"
"4. 각 질문에 target_evidence 와 expected_signal 을 채웁니다:\n"
" - target_evidence: 질문의 근거가 된 컨텍스트 원문을 짧게 인용. "
"PROJECT_DEEP_DIVE·TECH_CHOICE 는 **필수**(근거를 못 찾으면 그 질문을 만들지 마세요). "
"CS_FUNDAMENTAL·BEHAVIORAL 은 선택(없으면 빈 문자열).\n"
" - expected_signal: 좋은 답변이 드러내야 할 핵심(평가 관점). 한 문장.\n"
"5. 지원자 컨텍스트의 기술/프로젝트를 구체적으로 언급하는 PROJECT_DEEP_DIVE / "
"TECH_CHOICE 질문을 우선합니다.\n\n"
"{format_instructions}"
)
5 changes: 5 additions & 0 deletions ai/src/ai_server/messaging/consumers/questions_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ async def _build_context(self, req: GenerateQuestionsRequest) -> str:
if not document_ids:
return base_context

# PLAN vs Retrieve: 문서가 1개면 markdown 전체가 이미 base_context 에 들어 있어
# 검색이 중복이다 → 검색 생략(PLAN). 여러 문서일 때만 RAG 로 관련 청크를 추린다.
if len(document_ids) <= 1:
return base_context

query = _build_initial_rag_query(req)
try:
query_vec = (
Expand Down
6 changes: 6 additions & 0 deletions ai/src/ai_server/model/messages/questions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

class DocumentContext(BaseModel):
"""Core 가 generate.questions envelope 에 담아 보내는 문서 컨텍스트."""

model_config = camel_config()

document_id: int
Expand All @@ -39,10 +40,15 @@ class GenerateQuestionsRequest(BaseModel):

class GeneratedQuestion(BaseModel):
"""LLM 응답 단위. category 는 Spring 측 enum 과 동기."""

model_config = camel_config()

category: QuestionCategory
question: str
# 질문이 근거한 자료 인용(PROJECT/TECH 는 필수). 라이브 화면에 힌트로 노출.
target_evidence: str = ""
# 좋은 답이 드러내야 할 것 — 내부 평가용. 라이브 비노출(정답 유출 방지).
expected_signal: str = ""


class QuestionPoolCallbackPayload(BaseModel):
Expand Down
85 changes: 81 additions & 4 deletions ai/tests/test_questions_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ async def test_consumer_generates_questions_and_publishes_callback():
generator.generate = AsyncMock(
return_value=GeneratedQuestionPool(
questions=[
GeneratedQuestion(category="CS_FUNDAMENTAL", question="DB 트랜잭션 격리수준?"),
GeneratedQuestion(category="PROJECT_DEEP_DIVE", question="결제 outbox 어떻게 보장?"),
GeneratedQuestion(
category="CS_FUNDAMENTAL", question="DB 트랜잭션 격리수준?"
),
GeneratedQuestion(
category="PROJECT_DEEP_DIVE", question="결제 outbox 어떻게 보장?"
),
]
)
)
Expand Down Expand Up @@ -237,25 +241,78 @@ async def test_consumer_injects_initial_rag_chunks_when_available():
"summary": "outbox 구현",
"techStack": ["Spring"],
"markdown": "transactional publisher",
}
},
{
"documentId": 2,
"sourceType": "RESUME",
"summary": "백엔드 3년",
"techStack": ["Java"],
"markdown": "결제 시스템",
},
],
"initialQuestionCount": 1,
"maxQuestions": 5,
}
)
await consumer.handle(_StubMessage(body))

# 문서 2개 → Retrieve 경로(RAG 검색 발동)
embedder.embed.assert_awaited_once()
call = core.search_embeddings.await_args
assert call.kwargs["query_embedding"] == [0.1, 0.2]
assert call.kwargs["document_ids"] == [1]
assert call.kwargs["document_ids"] == [1, 2]
assert call.kwargs["top_k"] == 20 # candidate_k (리랭크 후보 수)
assert call.kwargs["query_text"] # 하이브리드 검색: 쿼리 텍스트 동봉
context = generator.generate.await_args.kwargs["context"]
assert "Outbox table uses status and retry count" in context
assert "outbox 구현" in context


@pytest.mark.asyncio
async def test_single_document_skips_rag_plan_mode():
# 문서 1개 → PLAN: markdown 이 이미 컨텍스트에 있으니 검색 생략
generator = MagicMock()
generator.generate = AsyncMock(return_value=GeneratedQuestionPool(questions=[]))
publisher = MagicMock()
publisher.publish = AsyncMock()
core = MagicMock()
core.search_embeddings = AsyncMock()
embedder = MagicMock()
embedder.embed = AsyncMock()

consumer = QuestionsConsumer(
generator=generator,
publisher=publisher,
idempotency=LruIdempotencyStore(max_size=10),
callback_routing_key="callback.questions",
core_client=core,
embedder=embedder,
)
body = _envelope(
{
"sessionId": 1,
"mode": "TECHNICAL",
"jobCategory": "BACKEND",
"documents": [
{
"documentId": 1,
"sourceType": "RESUME",
"summary": "백엔드",
"techStack": ["Java"],
"markdown": "결제 시스템 구현",
}
],
"maxQuestions": 5,
}
)
await consumer.handle(_StubMessage(body))

core.search_embeddings.assert_not_awaited()
embedder.embed.assert_not_awaited()
context = generator.generate.await_args.kwargs["context"]
assert "결제 시스템 구현" in context # base context 사용


@pytest.mark.asyncio
async def test_consumer_falls_back_to_document_context_when_rag_fails():
generator = MagicMock()
Expand Down Expand Up @@ -327,3 +384,23 @@ def test_build_context_joins_doc_blocks():
assert "본문1" in text
assert "문서 #2 (REPOSITORY)" in text
assert "readme" in text


def test_generated_question_parses_evidence_and_signal():
from ai_server.model.messages.questions import GeneratedQuestion

q = GeneratedQuestion.model_validate(
{
"category": "PROJECT_DEEP_DIVE",
"question": "결제 동시성 어떻게 처리했나요?",
"targetEvidence": "결제 시스템에서 분산락 도입",
"expectedSignal": "동시성 제어 trade-off 이해",
}
)
assert q.target_evidence == "결제 시스템에서 분산락 도입"
assert q.expected_signal == "동시성 제어 trade-off 이해"

# 하위호환: 신규 필드 없어도 기본값
q2 = GeneratedQuestion.model_validate({"category": "BEHAVIORAL", "question": "Q?"})
assert q2.target_evidence == ""
assert q2.expected_signal == ""
6 changes: 6 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2517,6 +2517,12 @@
"createdAt" : {
"type" : "string",
"format" : "date-time"
},
"category" : {
"type" : "string"
},
"targetEvidence" : {
"type" : "string"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ private void applyInitialQuestion(InterviewSession session, QuestionsCallbackPay
session.getId(), questions.size() - 1);
}
InterviewMessage message = messageRepository.save(
InterviewMessage.interviewer(session, 1, first.question())
InterviewMessage.interviewer(
session, 1, first.question(),
first.category(), first.targetEvidence(), first.expectedSignal()
)
);
session.incrementQuestionCount();
events.publishEvent(new QuestionPersistedEvent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ public record MessageResult(
Double ttsDurationSec,
Long parentMessageId,
MessageStatus status,
Instant createdAt
Instant createdAt,
String category,
String targetEvidence
// expectedSignal 은 의도적으로 제외 — 정답 유출 방지(라이브 비노출).
) {
public static MessageResult of(InterviewMessage m) {
return new MessageResult(
Expand All @@ -33,7 +36,9 @@ public static MessageResult of(InterviewMessage m) {
m.getTtsDurationSec(),
m.getParentMessage() == null ? null : m.getParentMessage().getId(),
m.getStatus(),
m.getCreatedAt()
m.getCreatedAt(),
m.getCategory(),
m.getTargetEvidence()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ public boolean isFollowup() {

public record GeneratedQuestion(
String category,
String question
String question,
String targetEvidence, // 질문 근거(자료 인용). 라이브 노출.
String expectedSignal // 좋은 답이 드러낼 것. 내부 저장만(라이브 비노출).
) {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ public class InterviewMessage extends BaseTimeEntity {
@Column(name = "idempotency_key", length = 64)
private String idempotencyKey;

// 질문 메타데이터 (초기 질문에만 채워짐). category/targetEvidence 는 노출, expectedSignal 은 내부용.
@Column(length = 30)
private String category;

@Column(name = "target_evidence", columnDefinition = "text")
private String targetEvidence;

@Column(name = "expected_signal", columnDefinition = "text")
private String expectedSignal;

private InterviewMessage(InterviewSession session, Integer sequenceNumber, MessageRole role,
String content, InterviewMessage parentMessage,
MessageStatus initialStatus, String idempotencyKey) {
Expand All @@ -101,8 +111,17 @@ private InterviewMessage(InterviewSession session, Integer sequenceNumber, Messa
}

public static InterviewMessage interviewer(InterviewSession session, int seq, String content) {
return interviewer(session, seq, content, null, null, null);
}

public static InterviewMessage interviewer(InterviewSession session, int seq, String content,
String category, String targetEvidence,
String expectedSignal) {
InterviewMessage m = new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, null,
MessageStatus.CREATED, null);
m.category = category;
m.targetEvidence = targetEvidence;
m.expectedSignal = expectedSignal;
m.markTtsPending();
return m;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ public record MessageResponse(
Double ttsDurationSec,
Long parentMessageId,
MessageStatus status,
Instant createdAt
Instant createdAt,
String category,
String targetEvidence
// expectedSignal 은 노출하지 않음 — 자기연습 시 정답 유출 방지.
) {
public static MessageResponse from(MessageResult r) {
return new MessageResponse(
Expand All @@ -33,7 +36,9 @@ public static MessageResponse from(MessageResult r) {
r.ttsDurationSec(),
r.parentMessageId(),
r.status(),
r.createdAt()
r.createdAt(),
r.category(),
r.targetEvidence()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- =============================================================================
-- 초기 질문 메타데이터: category / target_evidence / expected_signal
-- =============================================================================
-- 근거 기반 질문을 위해 AI 가 생성한 메타데이터를 보관한다.
-- - category: 질문 유형 (CS_FUNDAMENTAL/PROJECT_DEEP_DIVE/TECH_CHOICE/BEHAVIORAL). 느슨 결합(CHECK 없음).
-- - target_evidence: 질문 근거(자료 인용). 라이브 노출.
-- - expected_signal: 좋은 답이 드러낼 것. 내부용(라이브 비노출).
-- 모두 nullable — 기존 행/꼬리질문은 NULL.
ALTER TABLE interview_messages
ADD COLUMN category VARCHAR(30),
ADD COLUMN target_evidence TEXT,
ADD COLUMN expected_signal TEXT;
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ class QuestionsCallbackServiceTest {
void apply_poolCallbackStoresOnlyInitialQuestionAndPushesSse() {
InterviewSession session = sessionFixture(11L, SessionStatus.READY);
QuestionsCallbackEnvelope env = poolEnvelope(11L, List.of(
new GeneratedQuestion("INTRO", "Introduce yourself"),
new GeneratedQuestion("TECH", "JPA?")
new GeneratedQuestion("PROJECT_DEEP_DIVE", "Introduce yourself",
"이력서: 결제 시스템", "협업/문제해결 깊이"),
new GeneratedQuestion("TECH", "JPA?", null, null)
));
when(processedMessageRepository.existsById("m-1")).thenReturn(false);
when(sessionRepository.findById(11L)).thenReturn(Optional.of(session));
Expand All @@ -62,6 +63,10 @@ void apply_poolCallbackStoresOnlyInitialQuestionAndPushesSse() {
verify(messageRepository).save(messageCaptor.capture());
assertThat(messageCaptor.getValue().getContent()).isEqualTo("Introduce yourself");
assertThat(messageCaptor.getValue().getSequenceNumber()).isEqualTo(1);
// 질문 메타데이터가 첫 질문에서 영속됨
assertThat(messageCaptor.getValue().getCategory()).isEqualTo("PROJECT_DEEP_DIVE");
assertThat(messageCaptor.getValue().getTargetEvidence()).isEqualTo("이력서: 결제 시스템");
assertThat(messageCaptor.getValue().getExpectedSignal()).isEqualTo("협업/문제해결 깊이");

ArgumentCaptor<Object> ev = ArgumentCaptor.forClass(Object.class);
verify(events, atLeastOnce()).publishEvent(ev.capture());
Expand Down Expand Up @@ -108,7 +113,8 @@ void apply_followupAutoEndsSessionAtMaxQuestions() {

@Test
void apply_skipsDuplicateMessageId() {
QuestionsCallbackEnvelope env = poolEnvelope(11L, List.of(new GeneratedQuestion("X", "Q")));
QuestionsCallbackEnvelope env =
poolEnvelope(11L, List.of(new GeneratedQuestion("X", "Q", null, null)));
when(processedMessageRepository.existsById("m-1")).thenReturn(true);

service.apply(env);
Expand Down
29 changes: 27 additions & 2 deletions frontend/src/features/interview/ui/live/QuestionBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,35 @@
import type { Message } from '@/domain/session'
import { StatusBadge } from '@/shared/ui/StatusBadge'

const CATEGORY_LABEL: Record<string, string> = {
CS_FUNDAMENTAL: 'CS 기초',
PROJECT_DEEP_DIVE: '프로젝트 심화',
TECH_CHOICE: '기술 선택',
BEHAVIORAL: '인성·행동',
}

export function QuestionBubble({ message }: { message: Message }) {
const categoryLabel = message.category
? (CATEGORY_LABEL[message.category] ?? message.category)
: null
const hasMeta = Boolean(categoryLabel || message.targetEvidence)

return (
<div className="flex justify-start">
<div className="max-w-[80%] whitespace-pre-wrap rounded-lg rounded-tl-sm bg-surface-raised px-4 py-3 text-body text-fg shadow-sm">
{message.content}
<div className="flex max-w-[80%] flex-col gap-1.5">
{hasMeta && (
<div className="flex flex-wrap items-center gap-2">
{categoryLabel && <StatusBadge tone="info">{categoryLabel}</StatusBadge>}
{message.targetEvidence && (
<span className="text-caption text-fg-muted">
근거: {message.targetEvidence}
</span>
)}
</div>
)}
<div className="whitespace-pre-wrap rounded-lg rounded-tl-sm bg-surface-raised px-4 py-3 text-body text-fg shadow-sm">
{message.content}
</div>
</div>
</div>
)
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/shared/api/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,8 @@ export interface components {
status?: "CREATED" | "COMPLETED" | "FAILED";
/** Format: date-time */
createdAt?: string;
category?: string;
targetEvidence?: string;
};
VoiceStreamBeginResponse: {
/** Format: int64 */
Expand Down