369 lines
15 KiB
Python
369 lines
15 KiB
Python
|
|
from langchain_core.messages import HumanMessage
|
||
|
|
|
||
|
|
from agent.agents.conversation import ConversationAgent
|
||
|
|
from agent.core import nodes
|
||
|
|
from agent.core.state import AgentState
|
||
|
|
from services.core.prompt_manager import PromptManager
|
||
|
|
from services.core.sql_prompt_manager import SqlPromptManager
|
||
|
|
from workflows.workflow_manager import WorkflowManager, WorkflowType
|
||
|
|
|
||
|
|
|
||
|
|
class FakeResponse:
|
||
|
|
def __init__(self, content: str, tool_calls=None):
|
||
|
|
self.content = content
|
||
|
|
self.tool_calls = tool_calls or []
|
||
|
|
|
||
|
|
|
||
|
|
class FakeModel:
|
||
|
|
def invoke(self, messages):
|
||
|
|
if len(messages) == 2 and getattr(messages[0], "content", "").startswith("You are a translation and normalization assistant"):
|
||
|
|
return FakeResponse(messages[1].content)
|
||
|
|
if len(messages) == 2 and "table prompt JSON" in getattr(messages[0], "content", ""):
|
||
|
|
user_content = getattr(messages[1], "content", "")
|
||
|
|
lowered = user_content.lower()
|
||
|
|
if "query mode: topn" in lowered or "top 20" in lowered:
|
||
|
|
return FakeResponse("SELECT ship_to_country as country, count(soid) as qty FROM dwd_ai.apbo_eta_ful WHERE data_flag = 'Newest' GROUP BY ship_to_country ORDER BY qty DESC LIMIT 20")
|
||
|
|
if "query mode: aggregate" in lowered:
|
||
|
|
return FakeResponse("SELECT region, count(soid) as qty FROM dwd_ai.apbo_eta_ful WHERE data_flag = 'Newest' GROUP BY region ORDER BY qty DESC")
|
||
|
|
return FakeResponse("SELECT service_order_id FROM dwd_ai.apbo_eta_ful WHERE data_flag = 'Newest'")
|
||
|
|
return FakeResponse("fallback")
|
||
|
|
|
||
|
|
|
||
|
|
class FakeTemplateMatcher:
|
||
|
|
def match(self, normalized_text: str):
|
||
|
|
return {
|
||
|
|
"table_name": "apbo_eta_ful",
|
||
|
|
"candidates": [{"table_name": "apbo_eta_ful", "metadata": {"table": "apbo_eta_ful"}}],
|
||
|
|
"raw": {"query": normalized_text},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class FakeSqlPromptManager:
|
||
|
|
def get_prompt(self, table_name: str):
|
||
|
|
manager = SqlPromptManager()
|
||
|
|
return manager.get_prompt(table_name)
|
||
|
|
|
||
|
|
|
||
|
|
class EmptyTemplateMatcher:
|
||
|
|
def match(self, normalized_text: str):
|
||
|
|
return {
|
||
|
|
"table_name": None,
|
||
|
|
"candidates": [],
|
||
|
|
"raw": {"query": normalized_text},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class FirstTurnOnlyTemplateMatcher:
|
||
|
|
def match(self, normalized_text: str):
|
||
|
|
lowered = (normalized_text or "").lower()
|
||
|
|
if "4020438779" in lowered or "query so" in lowered:
|
||
|
|
return FakeTemplateMatcher().match(normalized_text)
|
||
|
|
return EmptyTemplateMatcher().match(normalized_text)
|
||
|
|
|
||
|
|
|
||
|
|
def test_prompt_manager_resolves_project_prompt_file():
|
||
|
|
manager = PromptManager()
|
||
|
|
prompt_text = manager.get("system", "sql_mysql_select_only")
|
||
|
|
assert "expert SQL generator" in prompt_text
|
||
|
|
assert "Only output a single MySQL SELECT statement." in prompt_text
|
||
|
|
|
||
|
|
|
||
|
|
def test_sql_prompt_manager_resolves_project_sql_prompt_dir():
|
||
|
|
manager = SqlPromptManager()
|
||
|
|
prompt = manager.get_prompt("apbo_eta_ful")
|
||
|
|
assert prompt is not None
|
||
|
|
assert prompt["meta"]["data_source"] == "dwd_ai.apbo_eta_ful"
|
||
|
|
|
||
|
|
|
||
|
|
def test_apbo_eta_ful_prompt_uses_identifier_specific_where_filters():
|
||
|
|
prompt = SqlPromptManager().get_prompt("apbo_eta_ful")
|
||
|
|
|
||
|
|
rule_text = prompt["business_logic_rules"]["soid_or_service_order_id"]
|
||
|
|
examples = prompt["examples"]
|
||
|
|
|
||
|
|
assert "禁止使用WHERE soid" in rule_text
|
||
|
|
assert "必须使用(service_order_id = 'xxx' or soid = 'xxx')" in rule_text
|
||
|
|
assert "service_order_id" in examples["history_records_all_fields"]["sql"]
|
||
|
|
assert " or soid " in examples["history_records_all_fields"]["sql"]
|
||
|
|
assert " or soid " in examples["specific_fields_query"]["sql"]
|
||
|
|
assert " or soid " in examples["newest_status_all_fields"]["sql"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_query_mode_and_plan_build_for_topn():
|
||
|
|
prompt = SqlPromptManager().get_prompt("apbo_eta_ful")
|
||
|
|
state = AgentState(
|
||
|
|
messages=[HumanMessage(content="CC为LT ,by country 查询 top 20")],
|
||
|
|
context={"original_input": "CC为LT ,by country 查询 top 20", "normalized_input": "CC=LT by country top 20", "table_name": "apbo_eta_ful"},
|
||
|
|
)
|
||
|
|
state.sql_prompt = prompt
|
||
|
|
state = nodes.classify_query_mode(state)
|
||
|
|
state = nodes.build_sql_plan(state)
|
||
|
|
|
||
|
|
assert state.query_mode == "topn"
|
||
|
|
assert state.sql_plan["query_entities"]["top_n"] == 20
|
||
|
|
assert state.sql_plan["selected_table"] == "apbo_eta_ful"
|
||
|
|
assert "top_n_rules" in state.sql_plan
|
||
|
|
|
||
|
|
|
||
|
|
def test_query_mode_country_does_not_trigger_aggregate():
|
||
|
|
state = AgentState(
|
||
|
|
messages=[HumanMessage(content="查询 AU country 的 DC premier stock backlog")],
|
||
|
|
context={
|
||
|
|
"original_input": "查询 AU country 的 DC premier stock backlog",
|
||
|
|
"normalized_input": "List of backlogs with values for AU Country DC premier stock",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
state = nodes.classify_query_mode(state)
|
||
|
|
|
||
|
|
assert state.query_mode == "detail"
|
||
|
|
|
||
|
|
|
||
|
|
def test_query_mode_explicit_aggregate_keyword_still_matches():
|
||
|
|
state = AgentState(
|
||
|
|
messages=[HumanMessage(content="按 country 统计 backlog 数量")],
|
||
|
|
context={
|
||
|
|
"original_input": "按 country 统计 backlog 数量",
|
||
|
|
"normalized_input": "Count backlog quantity by country",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
state = nodes.classify_query_mode(state)
|
||
|
|
|
||
|
|
assert state.query_mode == "aggregate"
|
||
|
|
|
||
|
|
|
||
|
|
def test_query_mode_current_keyword_is_not_misclassified_as_topn():
|
||
|
|
state = AgentState(
|
||
|
|
messages=[HumanMessage(content="汇总统计当前的bo数量")],
|
||
|
|
context={
|
||
|
|
"original_input": "汇总统计当前的bo数量",
|
||
|
|
"normalized_input": "Aggregate summary of the current backlog order count",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
state = nodes.classify_query_mode(state)
|
||
|
|
|
||
|
|
assert state.query_mode == "aggregate"
|
||
|
|
assert state.query_entities.get("top_n") is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_match_table_falls_back_to_config_default(monkeypatch):
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_template_matcher", lambda: EmptyTemplateMatcher())
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"agent.core.nodes.Config.get_section",
|
||
|
|
lambda section: {"default_table_name": "apbo_eta_ful"} if section == "ragflow" else {},
|
||
|
|
)
|
||
|
|
|
||
|
|
state = AgentState(
|
||
|
|
messages=[HumanMessage(content="random question")],
|
||
|
|
context={"original_input": "random question", "normalized_input": "random question"},
|
||
|
|
)
|
||
|
|
|
||
|
|
state = nodes.match_table(state)
|
||
|
|
|
||
|
|
assert state.table_name == "apbo_eta_ful"
|
||
|
|
assert state.context["table_match_fallback"] == "config_default"
|
||
|
|
assert state.context["default_table_name"] == "apbo_eta_ful"
|
||
|
|
|
||
|
|
|
||
|
|
def test_generate_response_falls_back_to_model_when_sr_api_result_empty():
|
||
|
|
class EmptyResultModel:
|
||
|
|
def invoke(self, messages):
|
||
|
|
return FakeResponse("未查询到符合条件的数据,请尝试调整筛选条件。")
|
||
|
|
|
||
|
|
state = AgentState(
|
||
|
|
messages=[HumanMessage(content="查询 AU 的 backlog")],
|
||
|
|
context={
|
||
|
|
"original_input": "查询 AU 的 backlog",
|
||
|
|
"normalized_input": "Query backlog for AU",
|
||
|
|
"final_sql": "SELECT * FROM dwd_ai.apbo_eta_ful WHERE ship_to_country='AU'",
|
||
|
|
"sr_api_result": '{"status_code": 200, "text": "{\\"code\\":\\"0\\",\\"data\\":[],\\"msg\\":\\"操作成功\\",\\"total\\":0}"}',
|
||
|
|
},
|
||
|
|
)
|
||
|
|
state.final_sql = state.context["final_sql"]
|
||
|
|
state.sr_api_result = state.context["sr_api_result"]
|
||
|
|
|
||
|
|
state = nodes.generate_response(state, EmptyResultModel())
|
||
|
|
|
||
|
|
assert state.messages[-1].content.startswith("未查询到符合条件的数据")
|
||
|
|
assert state.context["response_source"] == "model_empty_result_fallback"
|
||
|
|
assert state.context["is_empty_result"] is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_conversation_agent_run_keeps_final_sql_in_context(monkeypatch):
|
||
|
|
monkeypatch.setattr("agent.core.base_agent.create_chat_model", lambda model_section=None: FakeModel())
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_template_matcher", lambda: FakeTemplateMatcher())
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_sql_prompt_manager", lambda: FakeSqlPromptManager())
|
||
|
|
|
||
|
|
agent = ConversationAgent()
|
||
|
|
result = agent.run("查询 SO 4020438779 的 eta 信息", skip_sr_api=True)
|
||
|
|
|
||
|
|
assert result["context"]["table_name"] == "apbo_eta_ful"
|
||
|
|
assert result["context"]["query_mode"] == "detail"
|
||
|
|
assert result["context"]["final_sql"].startswith("SELECT")
|
||
|
|
assert result["messages"][-1].content == result["context"]["final_sql"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_conversation_agent_requires_explicit_memory_for_follow_up(monkeypatch):
|
||
|
|
monkeypatch.setattr("agent.core.base_agent.create_chat_model", lambda model_section=None: FakeModel())
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_template_matcher", lambda: FirstTurnOnlyTemplateMatcher())
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_sql_prompt_manager", lambda: FakeSqlPromptManager())
|
||
|
|
|
||
|
|
agent = ConversationAgent()
|
||
|
|
first = agent.run("查询 SO 4020438779 的 eta 信息", skip_sr_api=True)
|
||
|
|
second = agent.run("改成 by country top 20", skip_sr_api=True)
|
||
|
|
third = agent.run(
|
||
|
|
"改成 by country top 20",
|
||
|
|
skip_sr_api=True,
|
||
|
|
conversation_history=first["conversation_history"],
|
||
|
|
last_context=first["context"].get("last_context"),
|
||
|
|
)
|
||
|
|
|
||
|
|
assert first["context"]["table_name"] == "apbo_eta_ful"
|
||
|
|
assert second["context"]["table_name"] == "apbo_eta_ful"
|
||
|
|
assert second["context"]["table_match_fallback"] == "config_default"
|
||
|
|
assert third["context"]["table_name"] == "apbo_eta_ful"
|
||
|
|
assert third["context"]["table_match_fallback"] == "last_context"
|
||
|
|
assert third["context"]["query_mode"] == "topn"
|
||
|
|
assert "LIMIT 20" in third["context"]["final_sql"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_workflow_manager_follow_up_does_not_reuse_session_last_context(monkeypatch):
|
||
|
|
monkeypatch.setattr("agent.core.base_agent.create_chat_model", lambda model_section=None: FakeModel())
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_template_matcher", lambda: FirstTurnOnlyTemplateMatcher())
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_sql_prompt_manager", lambda: FakeSqlPromptManager())
|
||
|
|
|
||
|
|
manager = WorkflowManager(enable_multi_turn=False)
|
||
|
|
first = manager.execute_workflow(
|
||
|
|
WorkflowType.CONVERSATION,
|
||
|
|
"查询 SO 4020438779 的 eta 信息",
|
||
|
|
session_id="cid-1",
|
||
|
|
skip_sr_api=True,
|
||
|
|
)
|
||
|
|
second = manager.execute_workflow(
|
||
|
|
WorkflowType.CONVERSATION,
|
||
|
|
"改成 by country top 20",
|
||
|
|
session_id="cid-1",
|
||
|
|
skip_sr_api=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
first_context = first["result"]["context"]
|
||
|
|
second_context = second["result"]["context"]
|
||
|
|
session_info = manager.get_session_info("cid-1")
|
||
|
|
|
||
|
|
assert first_context["table_name"] == "apbo_eta_ful"
|
||
|
|
assert second_context["table_name"] == "apbo_eta_ful"
|
||
|
|
assert second_context["table_match_fallback"] == "config_default"
|
||
|
|
assert second_context["query_mode"] == "topn"
|
||
|
|
assert "LIMIT 20" in second_context["final_sql"]
|
||
|
|
assert "last_context" not in session_info
|
||
|
|
assert "conversation_history" not in session_info
|
||
|
|
|
||
|
|
|
||
|
|
def test_workflow_manager_follow_up_reuses_session_last_context_when_enabled(monkeypatch):
|
||
|
|
monkeypatch.setattr("agent.core.base_agent.create_chat_model", lambda model_section=None: FakeModel())
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_template_matcher", lambda: FirstTurnOnlyTemplateMatcher())
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_sql_prompt_manager", lambda: FakeSqlPromptManager())
|
||
|
|
|
||
|
|
manager = WorkflowManager(enable_multi_turn=True)
|
||
|
|
first = manager.execute_workflow(
|
||
|
|
WorkflowType.CONVERSATION,
|
||
|
|
"查询 SO 4020438779 的 eta 信息",
|
||
|
|
session_id="cid-enabled",
|
||
|
|
skip_sr_api=True,
|
||
|
|
)
|
||
|
|
second = manager.execute_workflow(
|
||
|
|
WorkflowType.CONVERSATION,
|
||
|
|
"改成 by country top 20",
|
||
|
|
session_id="cid-enabled",
|
||
|
|
skip_sr_api=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
first_context = first["result"]["context"]
|
||
|
|
second_context = second["result"]["context"]
|
||
|
|
session_info = manager.get_session_info("cid-enabled")
|
||
|
|
|
||
|
|
assert first_context["table_name"] == "apbo_eta_ful"
|
||
|
|
assert second_context["table_name"] == "apbo_eta_ful"
|
||
|
|
assert second_context["table_match_fallback"] == "last_context"
|
||
|
|
assert second_context["query_mode"] == "topn"
|
||
|
|
assert "LIMIT 20" in second_context["final_sql"]
|
||
|
|
assert session_info["last_context"]["table_name"] == "apbo_eta_ful"
|
||
|
|
assert len(session_info["conversation_history"]) >= 4
|
||
|
|
|
||
|
|
|
||
|
|
def test_workflow_manager_isolates_conversation_memory_by_session(monkeypatch):
|
||
|
|
monkeypatch.setattr("agent.core.base_agent.create_chat_model", lambda model_section=None: FakeModel())
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_template_matcher", lambda: FirstTurnOnlyTemplateMatcher())
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_sql_prompt_manager", lambda: FakeSqlPromptManager())
|
||
|
|
|
||
|
|
manager = WorkflowManager(enable_multi_turn=False)
|
||
|
|
manager.execute_workflow(
|
||
|
|
WorkflowType.CONVERSATION,
|
||
|
|
"查询 SO 4020438779 的 eta 信息",
|
||
|
|
session_id="session-a",
|
||
|
|
skip_sr_api=True,
|
||
|
|
)
|
||
|
|
second = manager.execute_workflow(
|
||
|
|
WorkflowType.CONVERSATION,
|
||
|
|
"改成 by country top 20",
|
||
|
|
session_id="session-b",
|
||
|
|
skip_sr_api=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert second["result"]["context"]["table_name"] == "apbo_eta_ful"
|
||
|
|
assert second["result"]["context"]["table_match_fallback"] == "config_default"
|
||
|
|
|
||
|
|
|
||
|
|
def test_conversation_agent_run_is_silent_without_debug_prints(monkeypatch, capsys):
|
||
|
|
monkeypatch.setattr("agent.core.base_agent.create_chat_model", lambda model_section=None: FakeModel())
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_template_matcher", lambda: FakeTemplateMatcher())
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_sql_prompt_manager", lambda: FakeSqlPromptManager())
|
||
|
|
|
||
|
|
agent = ConversationAgent()
|
||
|
|
agent.run("查询 SO 4020438779 的 eta 信息", skip_sr_api=True)
|
||
|
|
|
||
|
|
captured = capsys.readouterr()
|
||
|
|
assert captured.out == ""
|
||
|
|
|
||
|
|
|
||
|
|
def test_conversation_agent_run_prints_node_trace_when_enabled(monkeypatch, capsys):
|
||
|
|
monkeypatch.setattr("agent.core.base_agent.create_chat_model", lambda model_section=None: FakeModel())
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_template_matcher", lambda: FakeTemplateMatcher())
|
||
|
|
monkeypatch.setattr("agent.core.nodes.get_sql_prompt_manager", lambda: FakeSqlPromptManager())
|
||
|
|
|
||
|
|
agent = ConversationAgent()
|
||
|
|
agent.run("查询 SO 4020438779 的 eta 信息", skip_sr_api=True, debug_node_trace=True)
|
||
|
|
|
||
|
|
captured = capsys.readouterr()
|
||
|
|
assert "[process_input][in]" in captured.out
|
||
|
|
assert "[generate_response][out] source=final_sql" in captured.out
|
||
|
|
|
||
|
|
|
||
|
|
def test_generate_sql_trace_outputs_full_sql_when_enabled(capsys):
|
||
|
|
class LongSqlModel:
|
||
|
|
def invoke(self, messages):
|
||
|
|
return FakeResponse("SELECT " + ", ".join(f"col_{idx}" for idx in range(150)) + " FROM dwd_ai.apbo_eta_ful")
|
||
|
|
|
||
|
|
state = AgentState(
|
||
|
|
messages=[HumanMessage(content="查询 long sql")],
|
||
|
|
context={
|
||
|
|
"original_input": "查询 long sql",
|
||
|
|
"normalized_input": "Query long sql",
|
||
|
|
"table_name": "apbo_eta_ful",
|
||
|
|
"sql_plan": {"selected_table": "apbo_eta_ful"},
|
||
|
|
"debug_node_trace": True,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
state.sql_prompt = SqlPromptManager().get_prompt("apbo_eta_ful")
|
||
|
|
|
||
|
|
state = nodes.generate_sql(state, LongSqlModel())
|
||
|
|
|
||
|
|
captured = capsys.readouterr()
|
||
|
|
assert "[generate_sql][out] sql=" in captured.out
|
||
|
|
assert "col_149" in captured.out
|
||
|
|
assert "..." not in captured.out.split("[generate_sql][out] sql=", 1)[1]
|
||
|
|
|
||
|
|
|