Skip to content

Commit 8a9bde3

Browse files
i3monthsclaude
andcommitted
feat(question): 초기 질문에 근거(target_evidence)·평가시그널 추가 + PLAN/Retrieve 분기
질문 출력을 {category, question, target_evidence, expected_signal} 로 확장해 근거 기반 질문을 만들고, 라이브 화면에 카테고리·근거를 노출한다. AI: - GeneratedQuestion 에 target_evidence/expected_signal(기본 빈문자열) 추가 - 프롬프트: 카테고리별 grounding(PROJECT/TECH 는 근거 필수) + 근거/시그널 생성 요구 - questions_consumer: 문서 1개면 RAG 검색 생략(PLAN) — markdown 이 이미 컨텍스트에 있어 중복 제거 Core: - QuestionsCallbackPayload.GeneratedQuestion +targetEvidence/expectedSignal - InterviewMessage 에 category/target_evidence/expected_signal 컬럼 + V9 마이그레이션 - applyInitialQuestion 이 메타데이터까지 영속 - MessageResponse 는 category·targetEvidence 만 노출. expectedSignal 은 의도적 비노출 (자기연습 시 정답 유출 방지) 프론트: - OpenAPI 타입 재생성(category/targetEvidence 반영, expectedSignal 미포함) - QuestionBubble 에 카테고리 배지 + 근거 힌트 표시 테스트: AI 205 passed, backend 컴파일+OpenApiSpec 재생성(expectedSignal 비노출 확인), 프론트 build 통과. V9 는 머지→deploy 시 Flyway 자동 적용(nullable 컬럼). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7159f7c commit 8a9bde3

14 files changed

Lines changed: 196 additions & 17 deletions

File tree

ai/src/ai_server/chain/prompts/question_generation.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@
2828
" - TECHNICAL: CS_FUNDAMENTAL + TECH_CHOICE + PROJECT_DEEP_DIVE 중심.\n"
2929
" - PERSONALITY: BEHAVIORAL 중심.\n"
3030
" - INTEGRATED: 모두 균형.\n"
31-
"4. 지원자 컨텍스트에 명시된 기술/프로젝트를 구체적으로 언급하는 PROJECT_DEEP_DIVE / "
32-
"TECH_CHOICE 질문을 최소 절반 이상 포함하세요.\n\n"
31+
" 자료가 받쳐주는 만큼만 분배하고, 근거 없는 카테고리를 억지로 채우지 마세요.\n"
32+
"4. 각 질문에 target_evidence 와 expected_signal 을 채웁니다:\n"
33+
" - target_evidence: 질문의 근거가 된 컨텍스트 원문을 짧게 인용. "
34+
"PROJECT_DEEP_DIVE·TECH_CHOICE 는 **필수**(근거를 못 찾으면 그 질문을 만들지 마세요). "
35+
"CS_FUNDAMENTAL·BEHAVIORAL 은 선택(없으면 빈 문자열).\n"
36+
" - expected_signal: 좋은 답변이 드러내야 할 핵심(평가 관점). 한 문장.\n"
37+
"5. 지원자 컨텍스트의 기술/프로젝트를 구체적으로 언급하는 PROJECT_DEEP_DIVE / "
38+
"TECH_CHOICE 질문을 우선합니다.\n\n"
3339
"{format_instructions}"
3440
)

ai/src/ai_server/messaging/consumers/questions_consumer.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ async def _build_context(self, req: GenerateQuestionsRequest) -> str:
123123
if not document_ids:
124124
return base_context
125125

126+
# PLAN vs Retrieve: 문서가 1개면 markdown 전체가 이미 base_context 에 들어 있어
127+
# 검색이 중복이다 → 검색 생략(PLAN). 여러 문서일 때만 RAG 로 관련 청크를 추린다.
128+
if len(document_ids) <= 1:
129+
return base_context
130+
126131
query = _build_initial_rag_query(req)
127132
try:
128133
query_vec = (

ai/src/ai_server/model/messages/questions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
class DocumentContext(BaseModel):
1919
"""Core 가 generate.questions envelope 에 담아 보내는 문서 컨텍스트."""
20+
2021
model_config = camel_config()
2122

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

4041
class GeneratedQuestion(BaseModel):
4142
"""LLM 응답 단위. category 는 Spring 측 enum 과 동기."""
43+
4244
model_config = camel_config()
4345

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

4753

4854
class QuestionPoolCallbackPayload(BaseModel):

ai/tests/test_questions_consumer.py

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,12 @@ async def test_consumer_generates_questions_and_publishes_callback():
5656
generator.generate = AsyncMock(
5757
return_value=GeneratedQuestionPool(
5858
questions=[
59-
GeneratedQuestion(category="CS_FUNDAMENTAL", question="DB 트랜잭션 격리수준?"),
60-
GeneratedQuestion(category="PROJECT_DEEP_DIVE", question="결제 outbox 어떻게 보장?"),
59+
GeneratedQuestion(
60+
category="CS_FUNDAMENTAL", question="DB 트랜잭션 격리수준?"
61+
),
62+
GeneratedQuestion(
63+
category="PROJECT_DEEP_DIVE", question="결제 outbox 어떻게 보장?"
64+
),
6165
]
6266
)
6367
)
@@ -237,25 +241,78 @@ async def test_consumer_injects_initial_rag_chunks_when_available():
237241
"summary": "outbox 구현",
238242
"techStack": ["Spring"],
239243
"markdown": "transactional publisher",
240-
}
244+
},
245+
{
246+
"documentId": 2,
247+
"sourceType": "RESUME",
248+
"summary": "백엔드 3년",
249+
"techStack": ["Java"],
250+
"markdown": "결제 시스템",
251+
},
241252
],
242253
"initialQuestionCount": 1,
243254
"maxQuestions": 5,
244255
}
245256
)
246257
await consumer.handle(_StubMessage(body))
247258

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

258270

271+
@pytest.mark.asyncio
272+
async def test_single_document_skips_rag_plan_mode():
273+
# 문서 1개 → PLAN: markdown 이 이미 컨텍스트에 있으니 검색 생략
274+
generator = MagicMock()
275+
generator.generate = AsyncMock(return_value=GeneratedQuestionPool(questions=[]))
276+
publisher = MagicMock()
277+
publisher.publish = AsyncMock()
278+
core = MagicMock()
279+
core.search_embeddings = AsyncMock()
280+
embedder = MagicMock()
281+
embedder.embed = AsyncMock()
282+
283+
consumer = QuestionsConsumer(
284+
generator=generator,
285+
publisher=publisher,
286+
idempotency=LruIdempotencyStore(max_size=10),
287+
callback_routing_key="callback.questions",
288+
core_client=core,
289+
embedder=embedder,
290+
)
291+
body = _envelope(
292+
{
293+
"sessionId": 1,
294+
"mode": "TECHNICAL",
295+
"jobCategory": "BACKEND",
296+
"documents": [
297+
{
298+
"documentId": 1,
299+
"sourceType": "RESUME",
300+
"summary": "백엔드",
301+
"techStack": ["Java"],
302+
"markdown": "결제 시스템 구현",
303+
}
304+
],
305+
"maxQuestions": 5,
306+
}
307+
)
308+
await consumer.handle(_StubMessage(body))
309+
310+
core.search_embeddings.assert_not_awaited()
311+
embedder.embed.assert_not_awaited()
312+
context = generator.generate.await_args.kwargs["context"]
313+
assert "결제 시스템 구현" in context # base context 사용
314+
315+
259316
@pytest.mark.asyncio
260317
async def test_consumer_falls_back_to_document_context_when_rag_fails():
261318
generator = MagicMock()
@@ -327,3 +384,23 @@ def test_build_context_joins_doc_blocks():
327384
assert "본문1" in text
328385
assert "문서 #2 (REPOSITORY)" in text
329386
assert "readme" in text
387+
388+
389+
def test_generated_question_parses_evidence_and_signal():
390+
from ai_server.model.messages.questions import GeneratedQuestion
391+
392+
q = GeneratedQuestion.model_validate(
393+
{
394+
"category": "PROJECT_DEEP_DIVE",
395+
"question": "결제 동시성 어떻게 처리했나요?",
396+
"targetEvidence": "결제 시스템에서 분산락 도입",
397+
"expectedSignal": "동시성 제어 trade-off 이해",
398+
}
399+
)
400+
assert q.target_evidence == "결제 시스템에서 분산락 도입"
401+
assert q.expected_signal == "동시성 제어 trade-off 이해"
402+
403+
# 하위호환: 신규 필드 없어도 기본값
404+
q2 = GeneratedQuestion.model_validate({"category": "BEHAVIORAL", "question": "Q?"})
405+
assert q2.target_evidence == ""
406+
assert q2.expected_signal == ""

backend/openapi.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2517,6 +2517,12 @@
25172517
"createdAt" : {
25182518
"type" : "string",
25192519
"format" : "date-time"
2520+
},
2521+
"category" : {
2522+
"type" : "string"
2523+
},
2524+
"targetEvidence" : {
2525+
"type" : "string"
25202526
}
25212527
}
25222528
},

backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,10 @@ private void applyInitialQuestion(InterviewSession session, QuestionsCallbackPay
8686
session.getId(), questions.size() - 1);
8787
}
8888
InterviewMessage message = messageRepository.save(
89-
InterviewMessage.interviewer(session, 1, first.question())
89+
InterviewMessage.interviewer(
90+
session, 1, first.question(),
91+
first.category(), first.targetEvidence(), first.expectedSignal()
92+
)
9093
);
9194
session.incrementQuestionCount();
9295
events.publishEvent(new QuestionPersistedEvent(

backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ public record MessageResult(
1818
Double ttsDurationSec,
1919
Long parentMessageId,
2020
MessageStatus status,
21-
Instant createdAt
21+
Instant createdAt,
22+
String category,
23+
String targetEvidence
24+
// expectedSignal 은 의도적으로 제외 — 정답 유출 방지(라이브 비노출).
2225
) {
2326
public static MessageResult of(InterviewMessage m) {
2427
return new MessageResult(
@@ -33,7 +36,9 @@ public static MessageResult of(InterviewMessage m) {
3336
m.getTtsDurationSec(),
3437
m.getParentMessage() == null ? null : m.getParentMessage().getId(),
3538
m.getStatus(),
36-
m.getCreatedAt()
39+
m.getCreatedAt(),
40+
m.getCategory(),
41+
m.getTargetEvidence()
3742
);
3843
}
3944
}

backend/src/main/java/com/stackup/stackup/session/application/dto/QuestionsCallbackPayload.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ public boolean isFollowup() {
2222

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

backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ public class InterviewMessage extends BaseTimeEntity {
7979
@Column(name = "idempotency_key", length = 64)
8080
private String idempotencyKey;
8181

82+
// 질문 메타데이터 (초기 질문에만 채워짐). category/targetEvidence 는 노출, expectedSignal 은 내부용.
83+
@Column(length = 30)
84+
private String category;
85+
86+
@Column(name = "target_evidence", columnDefinition = "text")
87+
private String targetEvidence;
88+
89+
@Column(name = "expected_signal", columnDefinition = "text")
90+
private String expectedSignal;
91+
8292
private InterviewMessage(InterviewSession session, Integer sequenceNumber, MessageRole role,
8393
String content, InterviewMessage parentMessage,
8494
MessageStatus initialStatus, String idempotencyKey) {
@@ -101,8 +111,17 @@ private InterviewMessage(InterviewSession session, Integer sequenceNumber, Messa
101111
}
102112

103113
public static InterviewMessage interviewer(InterviewSession session, int seq, String content) {
114+
return interviewer(session, seq, content, null, null, null);
115+
}
116+
117+
public static InterviewMessage interviewer(InterviewSession session, int seq, String content,
118+
String category, String targetEvidence,
119+
String expectedSignal) {
104120
InterviewMessage m = new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, null,
105121
MessageStatus.CREATED, null);
122+
m.category = category;
123+
m.targetEvidence = targetEvidence;
124+
m.expectedSignal = expectedSignal;
106125
m.markTtsPending();
107126
return m;
108127
}

backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ public record MessageResponse(
1818
Double ttsDurationSec,
1919
Long parentMessageId,
2020
MessageStatus status,
21-
Instant createdAt
21+
Instant createdAt,
22+
String category,
23+
String targetEvidence
24+
// expectedSignal 은 노출하지 않음 — 자기연습 시 정답 유출 방지.
2225
) {
2326
public static MessageResponse from(MessageResult r) {
2427
return new MessageResponse(
@@ -33,7 +36,9 @@ public static MessageResponse from(MessageResult r) {
3336
r.ttsDurationSec(),
3437
r.parentMessageId(),
3538
r.status(),
36-
r.createdAt()
39+
r.createdAt(),
40+
r.category(),
41+
r.targetEvidence()
3742
);
3843
}
3944
}

0 commit comments

Comments
 (0)