4b86b2a660
- server-dev start/stop/deploy 및 Gitea push 자동 배포 - local-dev 로컬 개발 환경 Co-authored-by: Cursor <cursoragent@cursor.com>
1297 lines
46 KiB
Python
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"] == []
|