Skip to content

Commit c0e45ca

Browse files
authored
Merge pull request #51 from Team-StackUp/feature/ai-core-contract-question-rag
feat: 초기 질문/꼬리질문 생성에 Core embedding search 기반 RAG 추가
2 parents c124338 + fdab60a commit c0e45ca

10 files changed

Lines changed: 435 additions & 7 deletions

File tree

ai/src/ai_server/chain/followup_generation_chain.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ async def generate(
2727
mode: str,
2828
previous_question: str,
2929
answer_text: str,
30+
context: str = "(none)",
3031
) -> FollowupResult: ...
3132

3233

@@ -41,13 +42,15 @@ async def generate(
4142
mode: str,
4243
previous_question: str,
4344
answer_text: str,
45+
context: str = "(none)",
4446
) -> FollowupResult:
4547
result = await self._chain.ainvoke(
4648
{
4749
"job_category": job_category,
4850
"mode": mode,
4951
"previous_question": previous_question,
5052
"answer_text": answer_text,
53+
"context": context,
5154
}
5255
)
5356
if not isinstance(result, FollowupResult):

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@
1818
HUMAN_PROMPT = (
1919
"직군: {job_category}\n"
2020
"면접 모드: {mode}\n\n"
21+
"모드별 지침:\n"
22+
"- TECHNICAL: 기술 역량, 프로젝트 경험, 문제 해결을 중심으로 파고듭니다.\n"
23+
"- PERSONALITY: 협업, 갈등 해결, 성장 경험을 중심으로 파고듭니다.\n"
24+
"- INTEGRATED: 기술 질문과 인성 질문의 관점을 균형 있게 반영합니다.\n\n"
2125
"직전 질문:\n{previous_question}\n\n"
2226
"지원자 답변:\n{answer_text}\n\n"
27+
"검색 문서 컨텍스트:\n---\n{context}\n---\n\n"
2328
"{format_instructions}"
2429
)

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
from aio_pika.abc import AbstractIncomingMessage
55

66
from ai_server.chain.followup_generation_chain import FollowupGenerator
7+
from ai_server.core.client import CoreClient
78
from ai_server.messaging.idempotency import LruIdempotencyStore
89
from ai_server.messaging.publisher import CallbackPublisher
910
from ai_server.model.envelope import Envelope
1011
from ai_server.model.messages.followup import (
1112
FollowupCallbackPayload,
1213
GenerateFollowupRequest,
1314
)
15+
from ai_server.rag.embedder import EmbeddingProvider
1416

1517
log = structlog.get_logger(__name__)
1618

@@ -23,11 +25,17 @@ def __init__(
2325
publisher: CallbackPublisher,
2426
idempotency: LruIdempotencyStore,
2527
callback_routing_key: str,
28+
core_client: CoreClient | None = None,
29+
embedder: EmbeddingProvider | None = None,
30+
rag_top_k: int = 5,
2631
) -> None:
2732
self._generator = generator
2833
self._publisher = publisher
2934
self._idempotency = idempotency
3035
self._callback_routing_key = callback_routing_key
36+
self._core = core_client
37+
self._embedder = embedder
38+
self._rag_top_k = rag_top_k
3139

3240
async def handle(self, message: AbstractIncomingMessage) -> None:
3341
async with message.process(requeue=False):
@@ -65,6 +73,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
6573
mode=req.mode,
6674
previous_question=req.previous_question,
6775
answer_text=req.answer_text,
76+
context=await self._build_rag_context(req),
6877
)
6978

7079
payload = FollowupCallbackPayload(
@@ -89,3 +98,25 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
8998
session_id=req.session_id,
9099
trace_id=envelope.trace_id,
91100
)
101+
102+
async def _build_rag_context(self, req: GenerateFollowupRequest) -> str:
103+
if not self._core or not self._embedder or not req.context_document_ids:
104+
return "(none)"
105+
106+
query = f"{req.previous_question}\n\n{req.answer_text}"
107+
try:
108+
query_vec = (await self._embedder.embed([query]))[0]
109+
hits = await self._core.search_embeddings(
110+
query_embedding=query_vec,
111+
document_ids=req.context_document_ids,
112+
top_k=self._rag_top_k,
113+
)
114+
except Exception as exc:
115+
log.warn("followup.rag.failed", error=str(exc), session_id=req.session_id)
116+
return "(none)"
117+
118+
if not hits:
119+
return "(none)"
120+
return "\n---\n".join(
121+
f"[doc#{h.document_id} chunk#{h.chunk_index}] {h.chunk_text}" for h in hits
122+
)

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

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from aio_pika.abc import AbstractIncomingMessage
55

66
from ai_server.chain.question_generation_chain import QuestionGenerator
7+
from ai_server.core.client import CoreClient
78
from ai_server.messaging.idempotency import LruIdempotencyStore
89
from ai_server.messaging.publisher import CallbackPublisher
910
from ai_server.model.envelope import Envelope
@@ -12,6 +13,7 @@
1213
GenerateQuestionsRequest,
1314
QuestionPoolCallbackPayload,
1415
)
16+
from ai_server.rag.embedder import EmbeddingProvider
1517

1618
log = structlog.get_logger(__name__)
1719

@@ -25,14 +27,20 @@ def __init__(
2527
idempotency: LruIdempotencyStore,
2628
callback_routing_key: str,
2729
initial_pool_size: int = 1,
30+
core_client: CoreClient | None = None,
31+
embedder: EmbeddingProvider | None = None,
32+
rag_top_k: int = 5,
2833
) -> None:
2934
self._generator = generator
3035
self._publisher = publisher
3136
self._idempotency = idempotency
3237
self._callback_routing_key = callback_routing_key
33-
# Core 의 QuestionsCallbackService.applyPool 은 questions[0] 만 INSERT 하고 나머지는 폐기.
34-
# 토큰 낭비를 줄이기 위해 envelope.max_questions 대신 풀 크기를 강제. 후속 작업에서 풀 저장 도입 시 늘리기 쉬움.
38+
# Core compatibility keeps callback.kind=POOL, but this is the initial
39+
# question result. maxQuestions remains the full session limit.
3540
self._initial_pool_size = max(1, initial_pool_size)
41+
self._core = core_client
42+
self._embedder = embedder
43+
self._rag_top_k = rag_top_k
3644

3745
async def handle(self, message: AbstractIncomingMessage) -> None:
3846
async with message.process(requeue=False):
@@ -57,7 +65,10 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
5765
return
5866

5967
req = envelope.payload
60-
effective_pool_size = self._initial_pool_size # envelope.max_questions 무시
68+
effective_pool_size = max(
69+
1,
70+
req.initial_question_count,
71+
)
6172
log.info(
6273
"questions.generate.start",
6374
message_id=envelope.message_id,
@@ -68,7 +79,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
6879
trace_id=envelope.trace_id,
6980
)
7081

71-
context_text = _build_context(req.documents)
82+
context_text = await self._build_context(req)
7283
pool = await self._generator.generate(
7384
job_category=req.job_category,
7485
mode=req.mode,
@@ -98,6 +109,34 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
98109
trace_id=envelope.trace_id,
99110
)
100111

112+
async def _build_context(self, req: GenerateQuestionsRequest) -> str:
113+
base_context = _build_context(req.documents)
114+
if not self._core or not self._embedder:
115+
return base_context
116+
117+
document_ids = [d.document_id for d in req.documents]
118+
if not document_ids:
119+
return base_context
120+
121+
query = _build_initial_rag_query(req)
122+
try:
123+
query_vec = (await self._embedder.embed([query]))[0]
124+
hits = await self._core.search_embeddings(
125+
query_embedding=query_vec,
126+
document_ids=document_ids,
127+
top_k=self._rag_top_k,
128+
)
129+
except Exception as exc:
130+
log.warn("questions.rag.failed", error=str(exc), session_id=req.session_id)
131+
return base_context
132+
133+
if not hits:
134+
return base_context
135+
rag_context = "\n---\n".join(
136+
f"[doc#{h.document_id} chunk#{h.chunk_index}] {h.chunk_text}" for h in hits
137+
)
138+
return f"{base_context}\n\n## Retrieved document chunks\n{rag_context}"
139+
101140

102141
def _build_context(documents: list[DocumentContext]) -> str:
103142
parts: list[str] = []
@@ -112,3 +151,20 @@ def _build_context(documents: list[DocumentContext]) -> str:
112151
block.append(d.markdown)
113152
parts.append("\n".join(block))
114153
return "\n\n".join(parts) if parts else "(no documents)"
154+
155+
156+
def _build_initial_rag_query(req: GenerateQuestionsRequest) -> str:
157+
parts = [
158+
f"mode: {req.mode}",
159+
f"job category: {req.job_category}",
160+
]
161+
for d in req.documents:
162+
doc_parts = [f"document #{d.document_id} {d.source_type}"]
163+
if d.summary:
164+
doc_parts.append(d.summary)
165+
if d.tech_stack:
166+
doc_parts.append("tech stack: " + ", ".join(d.tech_stack))
167+
if d.markdown:
168+
doc_parts.append(d.markdown[:1000])
169+
parts.append("\n".join(doc_parts))
170+
return "\n\n".join(parts)

ai/src/ai_server/messaging/runner.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ def __init__(self, settings: Settings) -> None:
154154
idempotency=self._idempotency,
155155
callback_routing_key=settings.ai_callback_routing_questions,
156156
initial_pool_size=settings.questions_initial_pool_size,
157+
core_client=core_client,
158+
embedder=embedder,
157159
)
158160

159161
# 꼬리질문 생성 (US-19)
@@ -165,6 +167,8 @@ def __init__(self, settings: Settings) -> None:
165167
publisher=self._publisher,
166168
idempotency=self._idempotency,
167169
callback_routing_key=settings.ai_callback_routing_questions,
170+
core_client=core_client,
171+
embedder=embedder,
168172
)
169173

170174
# 종합 피드백 생성 (US-24)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Literal
22

3-
from pydantic import BaseModel
3+
from pydantic import BaseModel, Field
44

55
from ai_server.model._config import camel_config
66

@@ -18,6 +18,7 @@ class GenerateFollowupRequest(BaseModel):
1818
answer_text: str
1919
mode: InterviewMode
2020
job_category: Literal["FRONTEND", "BACKEND", "INFRA", "DBA"]
21+
context_document_ids: list[int] = Field(default_factory=list)
2122

2223

2324
class AnswerEvaluation(BaseModel):

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class GenerateQuestionsRequest(BaseModel):
3333
mode: InterviewMode
3434
job_category: JobCategory
3535
documents: list[DocumentContext] = []
36+
initial_question_count: int = 1
3637
max_questions: int = 10
3738

3839

ai/tests/test_core_client.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
CoreEmbeddingUpsertError,
88
CoreTokenError,
99
EmbeddingChunkPayload,
10+
EmbeddingSearchHit,
1011
HttpCoreClient,
1112
)
1213

@@ -241,3 +242,86 @@ async def test_upsert_embeddings_httpx_error_retriable() -> None:
241242
)
242243
assert exc_info.value.code == "CORE_UNAVAILABLE"
243244
assert exc_info.value.retriable is True
245+
246+
247+
# ----------- search_embeddings -----------
248+
249+
250+
def _make_post_client(
251+
*,
252+
status: int = 200,
253+
json_body: dict | None = None,
254+
text: str = "",
255+
raise_exc: Exception | None = None,
256+
) -> MagicMock:
257+
client = MagicMock()
258+
resp = MagicMock(spec=httpx.Response)
259+
resp.status_code = status
260+
resp.text = text
261+
resp.json = (
262+
MagicMock(return_value=json_body)
263+
if json_body is not None
264+
else MagicMock(side_effect=ValueError("no json"))
265+
)
266+
if raise_exc is not None:
267+
client.post = AsyncMock(side_effect=raise_exc)
268+
else:
269+
client.post = AsyncMock(return_value=resp)
270+
return client
271+
272+
273+
@pytest.mark.asyncio
274+
async def test_search_embeddings_uses_latest_core_contract() -> None:
275+
client = _make_post_client(
276+
json_body={
277+
"hits": [
278+
{
279+
"documentId": 7,
280+
"chunkIndex": 2,
281+
"chunkText": "Spring transaction boundaries",
282+
"distance": 0.17,
283+
}
284+
]
285+
}
286+
)
287+
core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client)
288+
289+
hits = await core.search_embeddings(
290+
query_embedding=[0.1, 0.2],
291+
document_ids=[7, 8],
292+
top_k=3,
293+
)
294+
295+
assert hits == [
296+
EmbeddingSearchHit(
297+
document_id=7,
298+
chunk_index=2,
299+
chunk_text="Spring transaction boundaries",
300+
distance=0.17,
301+
)
302+
]
303+
client.post.assert_awaited_once_with(
304+
"/api/internal/embeddings/search",
305+
json={
306+
"queryEmbedding": [0.1, 0.2],
307+
"documentIds": [7, 8],
308+
"topK": 3,
309+
},
310+
)
311+
312+
313+
@pytest.mark.parametrize("status", [400, 401, 403, 404, 500])
314+
@pytest.mark.asyncio
315+
async def test_search_embeddings_non_2xx_returns_empty(status: int) -> None:
316+
client = _make_post_client(status=status, text="bad")
317+
core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client)
318+
319+
assert await core.search_embeddings(query_embedding=[0.1]) == []
320+
321+
322+
@pytest.mark.asyncio
323+
async def test_search_embeddings_http_error_returns_empty() -> None:
324+
client = _make_post_client(raise_exc=httpx.ConnectError("dns fail"))
325+
core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client)
326+
327+
assert await core.search_embeddings(query_embedding=[0.1]) == []

0 commit comments

Comments
 (0)