Skip to content

Commit fdab60a

Browse files
committed
test: core 검색 과 질문 RAG 테스트
1 parent 89916d6 commit fdab60a

3 files changed

Lines changed: 329 additions & 2 deletions

File tree

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]) == []

ai/tests/test_followup_consumer.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
from ai_server.chain.followup_generation_chain import FollowupResult
9+
from ai_server.core.client import EmbeddingSearchHit
910
from ai_server.messaging.consumers.followup_consumer import FollowupConsumer
1011
from ai_server.messaging.idempotency import LruIdempotencyStore
1112
from ai_server.model.messages.followup import (
@@ -47,6 +48,7 @@ def _envelope() -> bytes:
4748
"answerText": "RabbitMQ로 보냈습니다.",
4849
"mode": "TECHNICAL",
4950
"jobCategory": "BACKEND",
51+
"contextDocumentIds": [7],
5052
},
5153
"context": {"userId": 42, "sessionId": 99},
5254
}
@@ -86,6 +88,85 @@ async def test_consumer_generates_followup_and_publishes_callback():
8688
assert publisher.publish.await_args.kwargs["message_type"] == "callback.questions"
8789

8890

91+
@pytest.mark.asyncio
92+
async def test_consumer_injects_followup_rag_context_when_available():
93+
generator = MagicMock()
94+
generator.generate = AsyncMock(
95+
return_value=FollowupResult(
96+
followup_question="outbox 저장과 발행의 원자성은 어떻게 보장했나요?",
97+
answer_evaluation=AnswerEvaluation(
98+
specificity=2.0, logic=3.0, structure="PARTIAL_STAR"
99+
),
100+
)
101+
)
102+
publisher = MagicMock()
103+
publisher.publish = AsyncMock()
104+
core = MagicMock()
105+
core.search_embeddings = AsyncMock(
106+
return_value=[
107+
EmbeddingSearchHit(
108+
document_id=7,
109+
chunk_index=2,
110+
chunk_text="Outbox rows are inserted in the same transaction",
111+
distance=0.12,
112+
)
113+
]
114+
)
115+
embedder = MagicMock()
116+
embedder.embed = AsyncMock(return_value=[[0.1, 0.2, 0.3]])
117+
118+
consumer = FollowupConsumer(
119+
generator=generator,
120+
publisher=publisher,
121+
idempotency=LruIdempotencyStore(max_size=10),
122+
callback_routing_key="callback.questions",
123+
core_client=core,
124+
embedder=embedder,
125+
rag_top_k=3,
126+
)
127+
await consumer.handle(_StubMessage(_envelope()))
128+
129+
embedder.embed.assert_awaited_once()
130+
core.search_embeddings.assert_awaited_once_with(
131+
query_embedding=[0.1, 0.2, 0.3],
132+
document_ids=[7],
133+
top_k=3,
134+
)
135+
context = generator.generate.await_args.kwargs["context"]
136+
assert "Outbox rows are inserted in the same transaction" in context
137+
138+
139+
@pytest.mark.asyncio
140+
async def test_consumer_falls_back_when_followup_rag_fails():
141+
generator = MagicMock()
142+
generator.generate = AsyncMock(
143+
return_value=FollowupResult(
144+
followup_question="실패 재처리는 어떻게 했나요?",
145+
answer_evaluation=AnswerEvaluation(
146+
specificity=2.0, logic=3.0, structure="PARTIAL_STAR"
147+
),
148+
)
149+
)
150+
publisher = MagicMock()
151+
publisher.publish = AsyncMock()
152+
core = MagicMock()
153+
core.search_embeddings = AsyncMock(side_effect=RuntimeError("core down"))
154+
embedder = MagicMock()
155+
embedder.embed = AsyncMock(return_value=[[0.1, 0.2, 0.3]])
156+
157+
consumer = FollowupConsumer(
158+
generator=generator,
159+
publisher=publisher,
160+
idempotency=LruIdempotencyStore(max_size=10),
161+
callback_routing_key="callback.questions",
162+
core_client=core,
163+
embedder=embedder,
164+
)
165+
await consumer.handle(_StubMessage(_envelope()))
166+
167+
assert generator.generate.await_args.kwargs["context"] == "(none)"
168+
169+
89170
@pytest.mark.asyncio
90171
async def test_consumer_idempotent_skip():
91172
generator = MagicMock()

ai/tests/test_questions_consumer.py

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
from ai_server.chain.question_generation_chain import GeneratedQuestionPool
9+
from ai_server.core.client import EmbeddingSearchHit
910
from ai_server.messaging.consumers.questions_consumer import (
1011
QuestionsConsumer,
1112
_build_context,
@@ -85,6 +86,7 @@ async def test_consumer_generates_questions_and_publishes_callback():
8586
}
8687
],
8788
"maxQuestions": 5,
89+
"initialQuestionCount": 2,
8890
}
8991
)
9092
await consumer.handle(_StubMessage(body))
@@ -93,8 +95,8 @@ async def test_consumer_generates_questions_and_publishes_callback():
9395
call = generator.generate.await_args
9496
assert call.kwargs["job_category"] == "BACKEND"
9597
assert call.kwargs["mode"] == "TECHNICAL"
96-
# envelope.max_questions(=5) 무시하고 initial_pool_size(default 1) 로 강제. Core 가 첫 질문만 사용.
97-
assert call.kwargs["max_questions"] == 1
98+
# maxQuestions is the session limit; initialQuestionCount controls this result.
99+
assert call.kwargs["max_questions"] == 2
98100
assert "Java" in call.kwargs["context"]
99101

100102
publisher.publish.assert_awaited_once()
@@ -137,6 +139,166 @@ async def test_consumer_skips_when_message_id_already_seen():
137139
publisher.publish.assert_not_awaited()
138140

139141

142+
@pytest.mark.asyncio
143+
async def test_consumer_defaults_initial_question_count_to_one():
144+
generator = MagicMock()
145+
generator.generate = AsyncMock(return_value=GeneratedQuestionPool(questions=[]))
146+
publisher = MagicMock()
147+
publisher.publish = AsyncMock()
148+
149+
consumer = QuestionsConsumer(
150+
generator=generator,
151+
publisher=publisher,
152+
idempotency=LruIdempotencyStore(max_size=10),
153+
callback_routing_key="callback.questions",
154+
initial_pool_size=3,
155+
)
156+
body = _envelope(
157+
{
158+
"sessionId": 99,
159+
"mode": "TECHNICAL",
160+
"jobCategory": "BACKEND",
161+
"documents": [],
162+
"maxQuestions": 5,
163+
}
164+
)
165+
await consumer.handle(_StubMessage(body))
166+
167+
assert generator.generate.await_args.kwargs["max_questions"] == 1
168+
169+
170+
@pytest.mark.asyncio
171+
async def test_consumer_clamps_initial_question_count_to_at_least_one():
172+
generator = MagicMock()
173+
generator.generate = AsyncMock(return_value=GeneratedQuestionPool(questions=[]))
174+
publisher = MagicMock()
175+
publisher.publish = AsyncMock()
176+
177+
consumer = QuestionsConsumer(
178+
generator=generator,
179+
publisher=publisher,
180+
idempotency=LruIdempotencyStore(max_size=10),
181+
callback_routing_key="callback.questions",
182+
initial_pool_size=3,
183+
)
184+
body = _envelope(
185+
{
186+
"sessionId": 99,
187+
"mode": "TECHNICAL",
188+
"jobCategory": "BACKEND",
189+
"documents": [],
190+
"initialQuestionCount": 0,
191+
"maxQuestions": 5,
192+
}
193+
)
194+
await consumer.handle(_StubMessage(body))
195+
196+
assert generator.generate.await_args.kwargs["max_questions"] == 1
197+
198+
199+
@pytest.mark.asyncio
200+
async def test_consumer_injects_initial_rag_chunks_when_available():
201+
generator = MagicMock()
202+
generator.generate = AsyncMock(return_value=GeneratedQuestionPool(questions=[]))
203+
publisher = MagicMock()
204+
publisher.publish = AsyncMock()
205+
core = MagicMock()
206+
core.search_embeddings = AsyncMock(
207+
return_value=[
208+
EmbeddingSearchHit(
209+
document_id=1,
210+
chunk_index=4,
211+
chunk_text="Outbox table uses status and retry count",
212+
distance=0.11,
213+
)
214+
]
215+
)
216+
embedder = MagicMock()
217+
embedder.embed = AsyncMock(return_value=[[0.1, 0.2]])
218+
219+
consumer = QuestionsConsumer(
220+
generator=generator,
221+
publisher=publisher,
222+
idempotency=LruIdempotencyStore(max_size=10),
223+
callback_routing_key="callback.questions",
224+
core_client=core,
225+
embedder=embedder,
226+
rag_top_k=2,
227+
)
228+
body = _envelope(
229+
{
230+
"sessionId": 99,
231+
"mode": "TECHNICAL",
232+
"jobCategory": "BACKEND",
233+
"documents": [
234+
{
235+
"documentId": 1,
236+
"sourceType": "REPOSITORY",
237+
"summary": "outbox 구현",
238+
"techStack": ["Spring"],
239+
"markdown": "transactional publisher",
240+
}
241+
],
242+
"initialQuestionCount": 1,
243+
"maxQuestions": 5,
244+
}
245+
)
246+
await consumer.handle(_StubMessage(body))
247+
248+
embedder.embed.assert_awaited_once()
249+
core.search_embeddings.assert_awaited_once_with(
250+
query_embedding=[0.1, 0.2],
251+
document_ids=[1],
252+
top_k=2,
253+
)
254+
context = generator.generate.await_args.kwargs["context"]
255+
assert "Outbox table uses status and retry count" in context
256+
assert "outbox 구현" in context
257+
258+
259+
@pytest.mark.asyncio
260+
async def test_consumer_falls_back_to_document_context_when_rag_fails():
261+
generator = MagicMock()
262+
generator.generate = AsyncMock(return_value=GeneratedQuestionPool(questions=[]))
263+
publisher = MagicMock()
264+
publisher.publish = AsyncMock()
265+
core = MagicMock()
266+
core.search_embeddings = AsyncMock(side_effect=RuntimeError("core down"))
267+
embedder = MagicMock()
268+
embedder.embed = AsyncMock(return_value=[[0.1, 0.2]])
269+
270+
consumer = QuestionsConsumer(
271+
generator=generator,
272+
publisher=publisher,
273+
idempotency=LruIdempotencyStore(max_size=10),
274+
callback_routing_key="callback.questions",
275+
core_client=core,
276+
embedder=embedder,
277+
)
278+
body = _envelope(
279+
{
280+
"sessionId": 99,
281+
"mode": "TECHNICAL",
282+
"jobCategory": "BACKEND",
283+
"documents": [
284+
{
285+
"documentId": 1,
286+
"sourceType": "RESUME",
287+
"summary": "Java/Spring backend",
288+
"techStack": ["Java"],
289+
"markdown": "payment service",
290+
}
291+
],
292+
"maxQuestions": 5,
293+
}
294+
)
295+
await consumer.handle(_StubMessage(body))
296+
297+
context = generator.generate.await_args.kwargs["context"]
298+
assert "Java/Spring backend" in context
299+
assert "Retrieved document chunks" not in context
300+
301+
140302
def test_build_context_handles_empty_documents():
141303
assert _build_context([]) == "(no documents)"
142304

0 commit comments

Comments
 (0)