@@ -480,3 +480,163 @@ async def test_callback_includes_answer_message_id():
480480 payload = publisher .publish .await_args .kwargs ["payload" ]
481481 assert payload .answer_message_id == 502 # _envelope 의 answerMessageId
482482 assert payload .followup_message_id == 503 # _envelope 의 followupMessageId
483+
484+
485+ # ---------------------------------------------------------------------------
486+ # 꼬리질문 RAG 저지연 픽스: rerank_enabled=False, timeout 폴백
487+ # ---------------------------------------------------------------------------
488+
489+
490+ def _make_req () -> "GenerateFollowupRequest" :
491+ from ai_server .model .messages .followup import GenerateFollowupRequest
492+
493+ return GenerateFollowupRequest (
494+ session_id = 99 ,
495+ parent_message_id = 501 ,
496+ answer_message_id = 502 ,
497+ followup_message_id = 503 ,
498+ previous_question = "결제 outbox 어떻게 구현?" ,
499+ answer_text = "RabbitMQ로 보냈습니다." ,
500+ mode = "TECHNICAL" ,
501+ job_category = "BACKEND" ,
502+ context_document_ids = [1 ],
503+ )
504+
505+
506+ def _make_hit (document_id : int = 1 , chunk_index : int = 0 , chunk_text : str = "x" ):
507+ from types import SimpleNamespace
508+
509+ return SimpleNamespace (
510+ document_id = document_id , chunk_index = chunk_index , chunk_text = chunk_text
511+ )
512+
513+
514+ @pytest .mark .asyncio
515+ async def test_rag_rerank_skipped_when_disabled ():
516+ """rerank_enabled=False: search_embeddings 는 top_k 만 요청, reranker.rerank 는 호출 안 됨."""
517+ import asyncio
518+
519+ from ai_server .messaging .consumers .followup_consumer import FollowupConsumer
520+ from ai_server .messaging .idempotency import LruIdempotencyStore
521+
522+ hit = _make_hit (chunk_text = "이 청크가 반환돼야 한다" )
523+
524+ embedder = MagicMock ()
525+ embedder .embed = AsyncMock (return_value = [[0.0 ]])
526+
527+ core = MagicMock ()
528+ core .search_embeddings = AsyncMock (return_value = [hit ])
529+
530+ fake_reranker = MagicMock ()
531+ fake_reranker .rerank = AsyncMock (return_value = [0 ])
532+
533+ generator = MagicMock ()
534+ generator .generate = AsyncMock ()
535+ publisher = MagicMock ()
536+ publisher .publish = AsyncMock ()
537+
538+ consumer = FollowupConsumer (
539+ generator = generator ,
540+ publisher = publisher ,
541+ idempotency = LruIdempotencyStore (max_size = 10 ),
542+ callback_routing_key = "callback.questions" ,
543+ core_client = core ,
544+ embedder = embedder ,
545+ rag_top_k = 5 ,
546+ reranker = fake_reranker ,
547+ candidate_k = 20 ,
548+ rerank_enabled = False ,
549+ )
550+
551+ req = _make_req ()
552+ result = await consumer ._build_rag_context (req )
553+
554+ # 청크 텍스트가 결과에 포함돼야 한다
555+ assert "이 청크가 반환돼야 한다" in result
556+ # rerank 비활성 시 candidate 확장 없이 rag_top_k 만 검색
557+ call_kwargs = core .search_embeddings .await_args .kwargs
558+ assert call_kwargs ["top_k" ] == 5 # rag_top_k, NOT candidate_k(20)
559+ # 리랭커 rerank 는 절대 호출되지 않음
560+ fake_reranker .rerank .assert_not_awaited ()
561+
562+
563+ @pytest .mark .asyncio
564+ async def test_rag_rerank_used_when_enabled ():
565+ """rerank_enabled=True: search_embeddings 는 candidate_k, reranker.rerank 는 호출됨."""
566+ hit = _make_hit (chunk_text = "후보 청크" )
567+
568+ embedder = MagicMock ()
569+ embedder .embed = AsyncMock (return_value = [[0.0 ]])
570+
571+ core = MagicMock ()
572+ core .search_embeddings = AsyncMock (return_value = [hit ])
573+
574+ fake_reranker = MagicMock ()
575+ fake_reranker .rerank = AsyncMock (return_value = [0 ])
576+
577+ generator = MagicMock ()
578+ generator .generate = AsyncMock ()
579+ publisher = MagicMock ()
580+ publisher .publish = AsyncMock ()
581+
582+ consumer = FollowupConsumer (
583+ generator = generator ,
584+ publisher = publisher ,
585+ idempotency = LruIdempotencyStore (max_size = 10 ),
586+ callback_routing_key = "callback.questions" ,
587+ core_client = core ,
588+ embedder = embedder ,
589+ rag_top_k = 5 ,
590+ reranker = fake_reranker ,
591+ candidate_k = 20 ,
592+ rerank_enabled = True ,
593+ )
594+
595+ req = _make_req ()
596+ result = await consumer ._build_rag_context (req )
597+
598+ assert "후보 청크" in result
599+ call_kwargs = core .search_embeddings .await_args .kwargs
600+ assert call_kwargs ["top_k" ] == 20 # candidate_k
601+ # 리랭커 rerank 는 호출돼야 한다
602+ fake_reranker .rerank .assert_awaited_once ()
603+
604+
605+ @pytest .mark .asyncio
606+ async def test_rag_timeout_returns_none ():
607+ """rag_timeout_sec 초과 시 _build_rag_context 가 '(none)' 을 반환한다."""
608+ import asyncio
609+
610+ from ai_server .messaging .consumers .followup_consumer import FollowupConsumer
611+ from ai_server .messaging .idempotency import LruIdempotencyStore
612+
613+ async def _slow_embed (texts , * , task_type = "" ):
614+ await asyncio .sleep (0.1 ) # 타임아웃(0.01s) 보다 훨씬 길다
615+ return [[0.0 ]]
616+
617+ embedder = MagicMock ()
618+ embedder .embed = _slow_embed
619+
620+ core = MagicMock ()
621+ core .search_embeddings = AsyncMock (return_value = [])
622+
623+ generator = MagicMock ()
624+ generator .generate = AsyncMock ()
625+ publisher = MagicMock ()
626+ publisher .publish = AsyncMock ()
627+
628+ consumer = FollowupConsumer (
629+ generator = generator ,
630+ publisher = publisher ,
631+ idempotency = LruIdempotencyStore (max_size = 10 ),
632+ callback_routing_key = "callback.questions" ,
633+ core_client = core ,
634+ embedder = embedder ,
635+ rag_top_k = 5 ,
636+ rag_timeout_sec = 0.01 ,
637+ )
638+
639+ req = _make_req ()
640+ result = await consumer ._build_rag_context (req )
641+
642+ assert result == "(none)"
0 commit comments