"""Unit tests for AgentService — state reset, clarify, pending.""" import json from unittest.mock import MagicMock import pytest from agent.agent_service import AgentService from agent.pending_store import AgentPendingStore from agent.tool_executor import ToolExecutor from handlers.search_handler import SearchHandler from sparse_encoder import encode_document, encode_query class FakePendingStore: def __init__(self): self.saved = None self.cleared = False self._stored = None def get(self, bot_id): return None def save(self, bot_id, *, pending_intent_type, pending_params=None, missing_params=None): self.saved = { "bot_id": bot_id, "pending_intent_type": pending_intent_type, "pending_params": pending_params, "missing_params": missing_params, } def clear(self, bot_id): self.cleared = True class FakeLlmClient: def __init__(self, messages_sequence): self.messages_sequence = messages_sequence self.calls = 0 self.call_kwargs = [] def chat_completion_message(self, **kwargs): self.call_kwargs.append(kwargs) response = self.messages_sequence[self.calls] self.calls += 1 return response @pytest.fixture def config(): cfg = MagicMock() cfg.llm_max_tokens = 512 return cfg @pytest.fixture def tool_executor(): search_handler = MagicMock() config = MagicMock() client = MagicMock() client.list_tool_definitions.return_value = [] executor = ToolExecutor(search_handler=search_handler, config=config, chatbot_tool_client=client) return executor def test_reset_state_clears_trace(tool_executor): tool_executor.tool_trace.append({"tool": "rag_search"}) tool_executor.last_domain_result = {"intentType": "FARE_SEARCH"} tool_executor.last_rag_result = {"references": []} tool_executor.reset_state() assert tool_executor.tool_trace == [] assert tool_executor.last_domain_result is None assert tool_executor.last_rag_result is None def test_sparse_encoder_matches_exact_keyword(): doc = encode_document( { "q": "고속도로 사고 피해자 재활보조금 지원 희망드림 신청자 모집", "a": "희망드림 사업 안내입니다.", } ) query = encode_query("희망드림") assert doc["indices"] assert query["indices"] assert set(doc["indices"]) & set(query["indices"]) def test_search_handler_uses_hybrid_when_enabled(): vector_store = MagicMock() vector_store.hybrid_search.return_value = [ {"meta": {"q": "희망드림", "a": "답변"}, "score": 1.0} ] embed_client = MagicMock() rerank_client = MagicMock() cfg = MagicMock() cfg.hybrid_search_enabled = True cfg.top_k = 30 cfg.sparse_top_k = 30 cfg.hybrid_merge_top_k = 40 handler = SearchHandler(vector_store, embed_client, rerank_client, cfg) handler._last_query_text = "희망드림" results = handler.search([0.1, 0.2], 0.55, "ts") vector_store.hybrid_search.assert_called_once() assert results[0]["meta"]["q"] == "희망드림" assert handler.last_search_info["searchMode"] == "hybrid_sparse" def test_ask_user_saves_pending_and_returns_clarify(tool_executor, config): pending = FakePendingStore() pending._stored = { "pendingIntentType": "FARE_UNPAID", "pendingParams": {}, "missingParams": ["carNo"], } def get_pending(bot_id): return pending._stored pending.get = get_pending llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "call_1", "function": { "name": "ask_user", "arguments": json.dumps({"question": "차량번호를 알려주세요."}), }, } ], } ] ) service = AgentService(llm, tool_executor, config, pending_store=pending) result = service.chat("미납 조회", bot_id="user-1") assert result["routeType"] == "clarify" assert result["needsClarification"] is True assert "차량번호" in result["answer"] assert pending.saved is not None assert pending.saved["pending_intent_type"] == "FARE_UNPAID" def test_pending_instruction_is_merged_into_first_system_message(tool_executor, config): """LLM 게이트웨이 400 방지: pending 안내는 첫 system prompt에 병합한다.""" pending = FakePendingStore() llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "call_1", "function": { "name": "ask_user", "arguments": json.dumps({"question": "차량번호를 입력해 주세요."}), }, } ], } ] ) service = AgentService(llm, tool_executor, config, pending_store=pending) service.chat( "1234", bot_id="user-pending", pending_intent_type="FARE_UNPAID", pending_params={"carNo": None}, ) messages = llm.call_kwargs[0]["messages"] system_positions = [i for i, m in enumerate(messages) if m.get("role") == "system"] assert system_positions == [0] assert "【이전 턴 pending】" in messages[0]["content"] assert "FARE_UNPAID" in messages[0]["content"] def test_domain_success_clears_pending(tool_executor, config): pending = FakePendingStore() pending.saved = {"pendingIntentType": "FARE_UNPAID"} def execute_side_effect(tool_name, arguments, **kwargs): payload = { "intentType": "FARE_UNPAID", "fetchOwner": "WAS", "uiType": "TEXT_ONLY", "domainData": {"status": True}, "params": {"carNo": "12가3456"}, } tool_executor.last_domain_result = payload return json.dumps(payload, ensure_ascii=False) tool_executor.execute = MagicMock(side_effect=execute_side_effect) llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "call_1", "function": { "name": "FARE_UNPAID", "arguments": json.dumps({"carNo": "12가3456"}), }, } ], }, {"content": "미납 내역을 안내드립니다.", "tool_calls": []}, ] ) service = AgentService(llm, tool_executor, config, pending_store=pending) result = service.chat("12가3456", bot_id="user-1") assert result["routeType"] == "domain" assert result["intentType"] == "FARE_UNPAID" assert pending.cleared is True def test_domain_success_uses_legacy_llm_not_raw_llm_summary(tool_executor, config): """llmSummary(프롬프트용 지시문)가 사용자에게 그대로 노출되지 않아야 한다.""" pending = FakePendingStore() llm_summary = ( "차량번호 11마1234 조회 결과, 미납 통행료는 없습니다. " "고객에게 미납금액이 없다고 명확히 안내하세요." ) def execute_side_effect(tool_name, arguments, **kwargs): payload = { "intentType": "FARE_UNPAID", "fetchOwner": "WAS", "uiType": "TEXT_ONLY", "domainData": {"status": True, "llmSummary": llm_summary}, "params": {"carNo": "11마1234"}, } tool_executor.last_domain_result = payload return json.dumps(payload, ensure_ascii=False) tool_executor.execute = MagicMock(side_effect=execute_side_effect) llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "call_1", "function": { "name": "FARE_UNPAID", "arguments": json.dumps({"carNo": "11마1234"}), }, } ], }, {"content": llm_summary, "tool_calls": []}, ] ) prompt_builder = MagicMock() prompt_builder._has_usable_domain_data.return_value = True prompt_builder.build_answer_prompt_messages.return_value = [ {"role": "system", "content": "system"}, {"role": "user", "content": "user"}, ] llm_handler = MagicMock() llm_handler.generate_answer_from_messages.return_value = ( "11마1234 차량은 현재 미납 통행료가 없습니다." ) service = AgentService( llm, tool_executor, config, pending_store=pending, prompt_builder=prompt_builder, llm_handler=llm_handler, ) result = service.chat("11마1234", bot_id="user-1") assert result["answer"] == "11마1234 차량은 현재 미납 통행료가 없습니다." assert "안내하세요" not in result["answer"] prompt_builder.build_answer_prompt_messages.assert_called_once() call_kwargs = prompt_builder.build_answer_prompt_messages.call_args.kwargs assert call_kwargs["domain_data"]["llmSummary"] == llm_summary llm_handler.generate_answer_from_messages.assert_called_once() def test_ask_user_saves_intent_from_argument(tool_executor, config): pending = FakePendingStore() llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "call_1", "function": { "name": "ask_user", "arguments": json.dumps( { "question": "차량번호를 알려주세요.", "intentType": "FARE_UNPAID", } ), }, } ], } ] ) service = AgentService(llm, tool_executor, config, pending_store=pending) result = service.chat("미납 조회", bot_id="user-2") assert result["routeType"] == "clarify" assert pending.saved is not None assert pending.saved["pending_intent_type"] == "FARE_UNPAID" def test_rag_search_clears_stale_pending(tool_executor, config): pending = FakePendingStore() pending._stored = { "pendingIntentType": "FARE_SEARCH", "pendingParams": {"toIc": "신갈"}, "missingParams": ["fromIc"], } pending.get = lambda bot_id: pending._stored def execute_side_effect(tool_name, arguments, **kwargs): if tool_name == "rag_search": payload = { "status": True, "references": [ {"question": "Q", "answer": "A", "url": "https://faq.example/1"} ], } tool_executor.last_rag_result = payload return json.dumps(payload, ensure_ascii=False) return json.dumps({"status": False}, ensure_ascii=False) tool_executor.execute = MagicMock(side_effect=execute_side_effect) llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "call_1", "function": { "name": "rag_search", "arguments": json.dumps({"query": "하이패스 충전 방법"}), }, } ], }, {"content": "하이패스 충전은 앱에서 가능합니다.", "tool_calls": []}, ] ) service = AgentService(llm, tool_executor, config, pending_store=pending) result = service.chat("하이패스 충전 방법", bot_id="user-3") assert result["routeType"] == "agent" assert pending.cleared is True assert result["faqUrls"] == ["https://faq.example/1"] assert len(result["references"]) == 1 def test_rag_search_includes_faq_urls(tool_executor, config): pending = FakePendingStore() def execute_side_effect(tool_name, arguments, **kwargs): payload = { "status": True, "references": [ {"question": "Q1", "answer": "A1", "url": "https://faq.example/1"}, {"question": "Q2", "answer": "A2", "url": "https://faq.example/2"}, ], } tool_executor.last_rag_result = payload return json.dumps(payload, ensure_ascii=False) tool_executor.execute = MagicMock(side_effect=execute_side_effect) llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "call_1", "function": { "name": "rag_search", "arguments": json.dumps({"query": "환불 절차"}), }, } ], }, {"content": "환불 절차를 안내드립니다.", "tool_calls": []}, ] ) service = AgentService(llm, tool_executor, config, pending_store=pending) result = service.chat("환불 절차", bot_id="user-4") assert result["faqUrls"] == ["https://faq.example/1", "https://faq.example/2"] assert result["references"][0]["url"] == "https://faq.example/1" def test_domain_tool_failure_returns_status_msg(tool_executor, config): pending = FakePendingStore() def execute_side_effect(tool_name, arguments, **kwargs): payload = { "status": False, "statusMsg": "Oracle 연결 실패", "intentType": "FARE_SEARCH", } return json.dumps(payload, ensure_ascii=False) tool_executor.execute = MagicMock(side_effect=execute_side_effect) llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "call_1", "function": { "name": "FARE_SEARCH", "arguments": json.dumps({"fromIc": "판교", "toIc": "신갈"}), }, } ], } ] ) service = AgentService(llm, tool_executor, config, pending_store=pending) result = service.chat("판교에서 신갈까지 통행요금", bot_id="user-5") assert result["answer"] == "Oracle 연결 실패" assert result.get("intentType") is None assert result.get("domainData") is None assert tool_executor.last_domain_result is None def test_rag_only_uses_prompt_builder_like_legacy(tool_executor, config): """rag-only(FAQ) 경로도 PromptBuilder로 최종 답변을 재생성해 Legacy/Admin과 문체를 맞춘다.""" pending = FakePendingStore() def execute_side_effect(tool_name, arguments, **kwargs): payload = { "status": True, "references": [ { "question": "다자녀 통행료 할인", "answer": "2026년 7월 28일부터 미성년 자녀 3명 이상 가구 주말·공휴일 할인", "url": "https://faq.example/multichild", } ], } tool_executor.last_rag_result = payload return json.dumps(payload, ensure_ascii=False) tool_executor.execute = MagicMock(side_effect=execute_side_effect) llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "call_1", "function": { "name": "rag_search", "arguments": json.dumps({"query": "다자녀할인"}), }, } ], }, {"content": "다자녀 할인은 적용되지 않습니다. 하이패스 10% ...", "tool_calls": []}, ] ) prompt_builder = MagicMock() prompt_builder._has_usable_domain_data.return_value = False prompt_builder.build_answer_prompt_messages.return_value = [ {"role": "system", "content": "system"}, {"role": "user", "content": "user"}, ] llm_handler = MagicMock() llm_handler.generate_answer_from_messages.return_value = ( "2026년 7월 28일부터 미성년 자녀 3명 이상 가구는 주말·공휴일 통행료 할인을 받습니다." ) service = AgentService( llm, tool_executor, config, pending_store=pending, prompt_builder=prompt_builder, llm_handler=llm_handler, ) result = service.chat("다자녀할인", bot_id="user-6") assert result["routeType"] == "agent" assert result["answer"].startswith("2026년 7월 28일") assert "하이패스 10%" not in result["answer"] prompt_builder.build_answer_prompt_messages.assert_called_once() call_kwargs = prompt_builder.build_answer_prompt_messages.call_args.kwargs assert call_kwargs["domain_data"] is None assert call_kwargs["references"][0]["a"].startswith("2026년 7월 28일") llm_handler.generate_answer_from_messages.assert_called_once() def test_execute_rag_search_unwraps_meta(): """search 결과 {'meta': {...}}를 언랩해 rerank/references가 정상 채워져야 한다.""" search_handler = MagicMock() search_handler.embed_query.return_value = [0.1, 0.2, 0.3] search_handler.search.return_value = [ {"meta": {"q": "다자녀 할인", "a": "2026-07-28 시행", "url": "u1", "category": "요금"}, "score": 0.9}, ] captured = {} def rerank_side_effect(query, candidates, ts): captured["candidates"] = candidates return candidates[:5], [0.88], True, {} search_handler.rerank = MagicMock(side_effect=rerank_side_effect) cfg = MagicMock() cfg.threshold = 0.55 client = MagicMock() client.list_tool_definitions.return_value = [] executor = ToolExecutor(search_handler=search_handler, config=cfg, chatbot_tool_client=client) result = executor._execute_rag_search({"query": "다자녀할인"}) # rerank에는 meta dict가 그대로 전달되어야 함 (q/a 키 보유) assert captured["candidates"][0]["q"] == "다자녀 할인" assert result["status"] is True assert result["references"][0]["question"] == "다자녀 할인" assert result["references"][0]["answer"] == "2026-07-28 시행" assert result["references"][0]["url"] == "u1" def _make_prompt_builder_and_handler(final_answer, guidance_answer="콜센터(1588-2504)로 문의해 주세요."): prompt_builder = MagicMock() prompt_builder._has_usable_domain_data.side_effect = lambda dd: bool( dd and dd.get("status") is not False ) prompt_builder.build_answer_prompt_messages.return_value = [ {"role": "system", "content": "system"}, {"role": "user", "content": "user"}, ] prompt_builder.build_guidance_prompt.return_value = ("guidance-sys", "guidance-user") llm_handler = MagicMock() llm_handler.generate_answer_from_messages.return_value = final_answer llm_handler.generate_answer.return_value = guidance_answer return prompt_builder, llm_handler def test_no_grounding_falls_back_to_guidance(tool_executor, config): """tool/qdrant 근거가 없으면 agent LLM 자유 답변(환각)을 폐기하고 guidance로 폴백한다.""" pending = FakePendingStore() # agent LLM이 tool 없이 곧바로 환각성 답변을 냄 llm = FakeLlmClient( [ { "content": "다자녀 할인은 하이패스 10% 할인이 적용됩니다.", "tool_calls": [], } ] ) prompt_builder, llm_handler = _make_prompt_builder_and_handler( final_answer="(사용 안 됨)", guidance_answer="정확한 정보를 확인하기 어렵습니다. 한국도로공사 콜센터(1588-2504)로 문의해 주세요.", ) response_handler = MagicMock() service = AgentService( llm, tool_executor, config, pending_store=pending, prompt_builder=prompt_builder, llm_handler=llm_handler, response_handler=response_handler, ) result = service.chat("다자녀할인", bot_id="user-7") assert "하이패스 10%" not in result["answer"] assert "1588-2504" in result["answer"] prompt_builder.build_guidance_prompt.assert_called_once() llm_handler.generate_answer.assert_called_once() prompt_builder.build_answer_prompt_messages.assert_not_called() saved_metadata = response_handler.save_to_mongodb.call_args.kwargs["metadata"] assert saved_metadata["type"] == "no_match" assert saved_metadata["answer_confidence"] == "low" assert saved_metadata["num_references"] == 0 def test_rag_empty_references_falls_back_to_guidance(tool_executor, config): """rag_search가 0건이면 근거가 없으므로 guidance로 폴백한다.""" pending = FakePendingStore() def execute_side_effect(tool_name, arguments, **kwargs): payload = {"status": True, "references": []} tool_executor.last_rag_result = payload return json.dumps(payload, ensure_ascii=False) tool_executor.execute = MagicMock(side_effect=execute_side_effect) llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "call_1", "function": { "name": "rag_search", "arguments": json.dumps({"query": "존재하지않는제도"}), }, } ], }, {"content": "임의로 만든 답변입니다.", "tool_calls": []}, ] ) prompt_builder, llm_handler = _make_prompt_builder_and_handler( final_answer="(사용 안 됨)", guidance_answer="정확한 정보를 확인하기 어렵습니다. 콜센터(1588-2504)로 문의해 주세요.", ) response_handler = MagicMock() service = AgentService( llm, tool_executor, config, pending_store=pending, prompt_builder=prompt_builder, llm_handler=llm_handler, response_handler=response_handler, ) result = service.chat("존재하지않는제도", bot_id="user-8") assert "임의로 만든 답변" not in result["answer"] assert "1588-2504" in result["answer"] prompt_builder.build_guidance_prompt.assert_called_once() prompt_builder.build_answer_prompt_messages.assert_not_called() saved_metadata = response_handler.save_to_mongodb.call_args.kwargs["metadata"] assert saved_metadata["type"] == "no_match" assert saved_metadata["answer_confidence"] == "low" assert saved_metadata["statusMsg"] == "no_match" def test_llm_skips_rag_forces_qdrant_search(tool_executor, config): """LLM이 대화이력만 보고 tool을 스킵해도, 근거가 없으면 rag_search를 강제 실행해 일관된 답을 낸다.""" pending = FakePendingStore() def execute_side_effect(tool_name, arguments, **kwargs): assert tool_name == "rag_search" payload = { "status": True, "references": [ {"question": "다자녀 할인", "answer": "2026-07-28부터 주말·공휴일 50% 할인.", "score": 0.9} ], } tool_executor.last_rag_result = payload return json.dumps(payload, ensure_ascii=False) tool_executor.execute = MagicMock(side_effect=execute_side_effect) # 2번째 반복 질문: LLM이 이력만 보고 tool 없이 답변 시도 llm = FakeLlmClient( [ {"content": "이전에 안내드린 내용을 참고하세요.", "tool_calls": []}, ] ) prompt_builder, llm_handler = _make_prompt_builder_and_handler( final_answer="2026-07-28부터 미성년 자녀 3명 이상 가구는 주말·공휴일 통행료 50% 할인됩니다.", ) service = AgentService( llm, tool_executor, config, pending_store=pending, prompt_builder=prompt_builder, llm_handler=llm_handler, ) result = service.chat("다자녀할인", bot_id="user-repeat") # 강제 rag_search가 실행되었고, guidance가 아닌 grounded 답변이 나와야 한다 tool_executor.execute.assert_called_once() assert tool_executor.execute.call_args[0][0] == "rag_search" assert "50% 할인" in result["answer"] prompt_builder.build_answer_prompt_messages.assert_called_once() prompt_builder.build_guidance_prompt.assert_not_called() def test_domain_grounding_skips_forced_rag(tool_executor, config): """domain tool이 데이터를 가져오면 그 자체가 근거이므로 rag_search를 강제하지 않는다.""" pending = FakePendingStore() calls = [] def execute_side_effect(tool_name, arguments, **kwargs): calls.append(tool_name) payload = { "intentType": "FARE_SEARCH", "fetchOwner": "WAS", "domainData": {"status": True, "llmSummary": "판교→신갈 통행요금은 1,900원입니다."}, "params": {}, } tool_executor.last_domain_result = payload tool_executor.domain_results.append(payload) return json.dumps(payload, ensure_ascii=False) tool_executor.execute = MagicMock(side_effect=execute_side_effect) llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "call_1", "function": { "name": "FARE_SEARCH", "arguments": json.dumps({"start": "판교", "end": "신갈"}), }, } ], }, {"content": "통행요금 안내", "tool_calls": []}, ] ) prompt_builder, llm_handler = _make_prompt_builder_and_handler( final_answer="판교에서 신갈까지 통행요금은 1,900원입니다.", ) service = AgentService( llm, tool_executor, config, pending_store=pending, prompt_builder=prompt_builder, llm_handler=llm_handler, ) result = service.chat("판교 신갈 통행료", bot_id="user-fare") # domain tool만 호출되고, rag_search는 강제되지 않아야 한다 assert "rag_search" not in calls assert "1,900원" in result["answer"] def test_domain_intent_guard_detects_and_ignores(): """domain 의도 가드: 요금/미납은 감지, FAQ성(할인 등)은 무시한다.""" service = AgentService(FakeLlmClient([]), MagicMock(), MagicMock()) # 명백한 domain 의도 → 감지 fare = service._domain_intent_guard("김천에서 서울까지 통행요금 얼마야?") assert fare is not None and fare["intent_type"] == "FARE_SEARCH" unpaid = service._domain_intent_guard("12가3456 미납 조회해줘") assert unpaid is not None and unpaid["intent_type"] == "FARE_UNPAID" # FAQ성 질문 → 가드하지 않음(회귀 방지) assert service._domain_intent_guard("다자녀 할인 있어?") is None assert service._domain_intent_guard("통행료 할인 받는 방법 알려줘") is None assert service._domain_intent_guard("하이패스 단말기 어디서 사?") is None def test_fare_question_diverts_to_clarify_instead_of_rag(tool_executor, config): """LLM이 tool을 못 골라 폴백에 도달해도, 명백한 요금 질문이면 FAQ 대신 되물음으로 유도한다.""" pending = FakePendingStore() tool_executor.execute = MagicMock() # LLM이 tool 없이 답변 시도(오라우팅 상황) llm = FakeLlmClient([{"content": "요금은 노선마다 다릅니다.", "tool_calls": []}]) prompt_builder, llm_handler = _make_prompt_builder_and_handler(final_answer="(사용 안 됨)") service = AgentService( llm, tool_executor, config, pending_store=pending, prompt_builder=prompt_builder, llm_handler=llm_handler, ) result = service.chat("김천에서 서울까지 통행요금 얼마야?", bot_id="user-fare2") # 강제 rag를 실행하지 않고, FARE_SEARCH 되물음을 반환해야 한다 tool_executor.execute.assert_not_called() assert result.get("needsClarification") is True assert "출발 IC" in result["answer"] assert pending.saved and pending.saved["pending_intent_type"] == "FARE_SEARCH" def test_pending_turn_skips_forced_rag_and_preserves_pending(tool_executor, config): """pending(슬롯필링) 진행 중 LLM이 tool 재호출에 실패해도, 강제 rag/가드로 pending을 훼손하지 않는다.""" pending = FakePendingStore() tool_executor.execute = MagicMock() # rag 강제 실행되면 호출됨 → 호출되면 안 됨 # pending 중인데 LLM이 tool 없이 애매하게 답한 상황 llm = FakeLlmClient([{"content": "무슨 말씀인지 잘 모르겠어요.", "tool_calls": []}]) prompt_builder, llm_handler = _make_prompt_builder_and_handler( final_answer="(사용 안 됨)", guidance_answer="정확한 정보를 확인하기 어렵습니다. 콜센터(1588-2504)로 문의해 주세요.", ) service = AgentService( llm, tool_executor, config, pending_store=pending, prompt_builder=prompt_builder, llm_handler=llm_handler, ) result = service.chat( "어제 그거", bot_id="user-pending", pending_intent_type="FARE_UNPAID", pending_params={"carNo": None}, ) # 강제 rag 미실행 + pending 보존(clear 안 됨) + guidance로 응답 tool_executor.execute.assert_not_called() assert pending.cleared is False assert "1588-2504" in result["answer"] def test_ask_user_without_intent_falls_back_to_trace(tool_executor, config): """ask_user에 intentType이 없으면 이번 턴에 시도한 domain tool로 pending을 저장한다.""" pending = FakePendingStore() def execute_side_effect(tool_name, arguments, **kwargs): tool_executor.tool_trace.append({"tool": tool_name}) return json.dumps({"status": True}, ensure_ascii=False) tool_executor.execute = MagicMock(side_effect=execute_side_effect) llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "c1", "function": { "name": "FARE_SEARCH", "arguments": json.dumps({"fromIc": "판교"}), }, } ], }, { "content": "", "tool_calls": [ { "id": "c2", "function": { "name": "ask_user", "arguments": json.dumps({"question": "도착 IC를 알려주세요"}), }, } ], }, ] ) service = AgentService(llm, tool_executor, config, pending_store=pending) result = service.chat("판교에서 통행료", bot_id="user-fallback") assert result.get("needsClarification") is True assert pending.saved and pending.saved["pending_intent_type"] == "FARE_SEARCH" def test_clarify_turn_saved_to_history(tool_executor, config): """clarify(되물음) 턴도 대화 이력에 저장되어 멀티턴 맥락에 반영된다.""" pending = FakePendingStore() response_handler = MagicMock() tool_executor.execute = MagicMock() llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "c1", "function": { "name": "ask_user", "arguments": json.dumps( {"question": "차량번호를 알려주세요", "intentType": "FARE_UNPAID"} ), }, } ], } ] ) service = AgentService( llm, tool_executor, config, pending_store=pending, response_handler=response_handler, ) service.chat("미납 조회", bot_id="user-clarify-save") response_handler.save_to_mongodb.assert_called_once() kwargs = response_handler.save_to_mongodb.call_args.kwargs assert kwargs["ai_response"] == "차량번호를 알려주세요" assert kwargs["metadata"]["type"] == "clarify" def test_forced_rag_contextualizes_followup_query(tool_executor, config): """강제 rag 경로에서 후속 질문은 QueryRewriter로 완결형 검색어로 바꿔 검색한다.""" pending = FakePendingStore() captured = {} def execute_side_effect(tool_name, arguments, **kwargs): captured["query"] = arguments.get("query") payload = { "status": True, "references": [{"question": "하이패스 가격", "answer": "약 2만원대입니다.", "score": 0.9}], } tool_executor.last_rag_result = payload return json.dumps(payload, ensure_ascii=False) tool_executor.execute = MagicMock(side_effect=execute_side_effect) chat_manager = MagicMock() chat_manager.get_recent_history.return_value = [ {"user_query": "하이패스 단말기 알려줘", "ai_response": "하이패스 단말기는..."} ] query_rewriter = MagicMock() query_rewriter.rewrite_query.return_value = "하이패스 단말기 가격은 얼마인가요?" llm = FakeLlmClient([{"content": "이전 안내를 참고하세요.", "tool_calls": []}]) prompt_builder, llm_handler = _make_prompt_builder_and_handler(final_answer="약 2만원대입니다.") service = AgentService( llm, tool_executor, config, pending_store=pending, prompt_builder=prompt_builder, llm_handler=llm_handler, chat_manager=chat_manager, query_rewriter=query_rewriter, ) # "얼마야?"는 경로/차량번호가 없어 domain 가드에 걸리지 않고 강제 rag로 간다 service.chat("얼마야?", bot_id="user-follow") query_rewriter.rewrite_query.assert_called_once() assert captured["query"] == "하이패스 단말기 가격은 얼마인가요?" def test_combine_domain_data_merges_multiple_summaries(tool_executor, config): """복합 domain 결과의 llmSummary가 모두 최종 프롬프트에 반영되어야 한다.""" pending = FakePendingStore() def execute_side_effect(tool_name, arguments, **kwargs): if tool_name == "FARE_SEARCH": payload = { "intentType": "FARE_SEARCH", "fetchOwner": "WAS", "domainData": {"status": True, "llmSummary": "판교→신갈 통행요금은 1,900원입니다."}, "params": {}, } else: payload = { "intentType": "IC_TEL", "fetchOwner": "WAS", "domainData": {"status": True, "llmSummary": "판교IC 대표전화는 031-000-0000입니다."}, "params": {}, } tool_executor.last_domain_result = payload tool_executor.domain_results.append(payload) return json.dumps(payload, ensure_ascii=False) tool_executor.execute = MagicMock(side_effect=execute_side_effect) llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "c1", "function": {"name": "FARE_SEARCH", "arguments": json.dumps({})}, }, { "id": "c2", "function": {"name": "IC_TEL", "arguments": json.dumps({})}, }, ], }, {"content": "복합 안내", "tool_calls": []}, ] ) prompt_builder, llm_handler = _make_prompt_builder_and_handler( final_answer="판교→신갈 통행요금은 1,900원이며, 판교IC 대표전화는 031-000-0000입니다." ) service = AgentService( llm, tool_executor, config, pending_store=pending, prompt_builder=prompt_builder, llm_handler=llm_handler, ) result = service.chat("판교에서 신갈 요금이랑 판교IC 전화번호", bot_id="user-9") prompt_builder.build_answer_prompt_messages.assert_called_once() call_kwargs = prompt_builder.build_answer_prompt_messages.call_args.kwargs combined_summary = call_kwargs["domain_data"]["llmSummary"] assert "1,900원" in combined_summary assert "031-000-0000" in combined_summary assert "1,900원" in result["answer"] or "031-000-0000" in result["answer"] def test_greeting_intent_returns_fixed_response(tool_executor, config): """인사는 tool loop 없이 greeting_handler 고정 응답으로 처리된다 (Legacy parity).""" pending = FakePendingStore() intent = MagicMock() intent.name = "greeting" intent.matched_keywords = ["안녕"] intent.is_special.return_value = True intent_detector = MagicMock() intent_detector.detect.return_value = intent greeting_handler = MagicMock() greeting_handler.generate_response.return_value = { "answer": "안녕하세요! 한국도로공사 채팅상담 챗봇입니다.", "quick_replies": ["통행료 조회", "하이패스"], } # tool loop에 진입하면 안 되므로 LLM 시퀀스는 비워둠 (호출 시 IndexError로 감지) llm = FakeLlmClient([]) service = AgentService( llm, tool_executor, config, pending_store=pending, intent_detector=intent_detector, greeting_handler=greeting_handler, ) result = service.chat("안녕하세요", bot_id="user-10") assert result["routeType"] == "greeting" assert "한국도로공사" in result["answer"] assert result["quickReplies"] == ["통행료 조회", "하이패스"] assert llm.calls == 0 # tool loop 미진입 def test_rag_parity_history_emotion_suggestion_and_save(tool_executor, config): """rag-only 답변에 대화이력·감정·제안·저장이 모두 반영된다 (Legacy parity).""" pending = FakePendingStore() def execute_side_effect(tool_name, arguments, **kwargs): payload = { "status": True, "references": [ {"question": "환불 방법", "answer": "앱에서 신청", "url": "https://faq/1", "score": 0.7}, ], } tool_executor.last_rag_result = payload return json.dumps(payload, ensure_ascii=False) tool_executor.execute = MagicMock(side_effect=execute_side_effect) llm = FakeLlmClient( [ { "content": "", "tool_calls": [ { "id": "call_1", "function": { "name": "rag_search", "arguments": json.dumps({"query": "환불 방법"}), }, } ], }, {"content": "자유답변", "tool_calls": []}, ] ) prompt_builder, llm_handler = _make_prompt_builder_and_handler( final_answer="통행료 환불은 앱에서 신청하실 수 있습니다." ) history = [ {"role": "user", "content": "이전 질문"}, {"role": "assistant", "content": "이전 답변"}, ] chat_manager = MagicMock() chat_manager.get_messages_for_llm.return_value = history emotion = MagicMock() emotion.primary = "frustrated" emotion_detector = MagicMock() emotion_detector.detect.return_value = emotion emotion_handler = MagicMock() emotion_handler.get_emotion_instruction.return_value = "\n【감정 고려사항】공감하세요." suggestion_handler = MagicMock() suggestion_handler.enhance_answer_with_suggestions.side_effect = ( lambda answer, **kw: answer + "\n\n💡 혹시 이런 것을 찾으셨나요?" ) response_handler = MagicMock() intent = MagicMock() intent.is_special.return_value = False intent_detector = MagicMock() intent_detector.detect.return_value = intent service = AgentService( llm, tool_executor, config, pending_store=pending, prompt_builder=prompt_builder, llm_handler=llm_handler, intent_detector=intent_detector, emotion_detector=emotion_detector, emotion_handler=emotion_handler, suggestion_handler=suggestion_handler, chat_manager=chat_manager, response_handler=response_handler, ) result = service.chat("환불 방법 알려줘", bot_id="user-11") # 대화이력·감정이 프롬프트로 전달됨 call_kwargs = prompt_builder.build_answer_prompt_messages.call_args.kwargs assert call_kwargs["conversation_history"] == history assert call_kwargs["emotion_name"] == "frustrated" assert call_kwargs["emotion_instruction"] == "\n【감정 고려사항】공감하세요." # 제안 문구가 답변에 추가됨 assert "💡 혹시 이런 것을 찾으셨나요?" in result["answer"] # MongoDB 저장 호출 response_handler.save_to_mongodb.assert_called_once() def test_execute_rag_search_retries_on_no_match(): """threshold 미달로 0건이면 완화된 threshold로 재검색한다 (recall parity).""" search_handler = MagicMock() search_handler.embed_query.return_value = [0.1, 0.2] calls = [] def search_side_effect(vec, threshold, ts): calls.append(threshold) if threshold >= 0.55: return [] return [{"meta": {"q": "재검색 히트", "a": "답변"}, "score": 0.52}] search_handler.search = MagicMock(side_effect=search_side_effect) search_handler.rerank = MagicMock( side_effect=lambda q, c, ts: (c[:5], [0.52], True, {}) ) cfg = MagicMock() cfg.threshold = 0.55 cfg.threshold_rewrite = 0.50 client = MagicMock() client.list_tool_definitions.return_value = [] executor = ToolExecutor(search_handler=search_handler, config=cfg, chatbot_tool_client=client) result = executor._execute_rag_search({"query": "희귀질문"}) assert calls == [0.55, 0.50] # 1차 실패 → 완화 재검색 assert result["references"][0]["question"] == "재검색 히트" def test_execute_rag_search_keyword_retry_accepts_specific_keyword(): """벡터 검색이 실패해도 고유 키워드 문자열 검색 결과가 rerank 기준을 넘으면 채택한다.""" search_handler = MagicMock() search_handler.embed_query.return_value = [0.1, 0.2] search_handler.search.return_value = [] search_handler.vector_store.keyword_search.return_value = ( [ { "id": "p1", "meta": { "q": "한국도로공사, 고속도로 사고 피해자 재활보조금 지원 희망드림 신청자 모집", "a": "희망드림 신청자 모집 안내입니다.", "url": "https://example.com/hope", }, } ], 1, False, ) search_handler.rerank = MagicMock( side_effect=lambda q, c, ts: (c[:3], [0.81], True, {}) ) cfg = MagicMock() cfg.threshold = 0.55 cfg.threshold_rewrite = 0.50 cfg.low_confidence_threshold = 0.65 client = MagicMock() client.list_tool_definitions.return_value = [] executor = ToolExecutor(search_handler=search_handler, config=cfg, chatbot_tool_client=client) result = executor._execute_rag_search({"query": "희망드림"}) search_handler.vector_store.keyword_search.assert_called_once() assert result["searchMode"] == "keyword_retry" assert result["keywordRetryUsed"] is True assert result["keywordRetryAccepted"] is True assert result["referenceCount"] == 1 assert result["references"][0]["question"].endswith("희망드림 신청자 모집") def test_execute_rag_search_keyword_retry_rejects_low_rerank_score(): """문자열 검색 결과라도 rerank 점수가 LOW_CONFIDENCE_THRESHOLD 미만이면 guidance로 떨어지도록 비운다.""" search_handler = MagicMock() search_handler.embed_query.return_value = [0.1, 0.2] search_handler.search.return_value = [] search_handler.vector_store.keyword_search.return_value = ( [{"id": "p1", "meta": {"q": "희망드림", "a": "답변"}}], 1, False, ) search_handler.rerank = MagicMock( side_effect=lambda q, c, ts: (c[:3], [0.42], True, {}) ) cfg = MagicMock() cfg.threshold = 0.55 cfg.threshold_rewrite = 0.50 cfg.low_confidence_threshold = 0.65 client = MagicMock() client.list_tool_definitions.return_value = [] executor = ToolExecutor(search_handler=search_handler, config=cfg, chatbot_tool_client=client) result = executor._execute_rag_search({"query": "희망드림"}) assert result["keywordRetryUsed"] is True assert result["keywordRetryAccepted"] is False assert result["references"] == [] assert result["referenceCount"] == 0 def test_execute_rag_search_keyword_retry_skips_generic_single_word(): """할인/요금 같은 일반어 단독 발화는 문자열 검색 폭증을 막기 위해 retry하지 않는다.""" search_handler = MagicMock() search_handler.embed_query.return_value = [0.1, 0.2] search_handler.search.return_value = [] search_handler.rerank = MagicMock(return_value=([], [], False, {})) cfg = MagicMock() cfg.threshold = 0.55 cfg.threshold_rewrite = 0.50 cfg.low_confidence_threshold = 0.65 client = MagicMock() client.list_tool_definitions.return_value = [] executor = ToolExecutor(search_handler=search_handler, config=cfg, chatbot_tool_client=client) result = executor._execute_rag_search({"query": "할인"}) search_handler.vector_store.keyword_search.assert_not_called() assert result["keywordRetryUsed"] is False assert result["references"] == []