Files
exAichatbot_agent/exAiChatBot-chatbot2.0-agent/scripts/tests/test_agent_service.py
T
Macbook 4b86b2a660 Agent 2.0 exdev 서버 배포 스택
- server-dev start/stop/deploy 및 Gitea push 자동 배포
- local-dev 로컬 개발 환경

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:57:30 +09:00

1297 lines
46 KiB
Python

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