test_router.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import sys
  2. from pathlib import Path
  3. from types import SimpleNamespace
  4. import pytest
  5. from app.config import PROJECT_ROOT, Settings
  6. from app.decision_engine import (
  7. DeepSeekDecisionEngine,
  8. DemoDecisionEngine,
  9. create_decision_engine,
  10. )
  11. from app.schemas import RouteName
  12. from app.tools import TavilyWebSearchProvider, WebSearchTool
  13. def test_multi_source_route() -> None:
  14. decision = DemoDecisionEngine().route(
  15. "统计 U1001 最近 30 天订单,并结合内部运费规则说明是否包邮",
  16. max_rounds=2,
  17. )
  18. assert decision.requires_decomposition is True
  19. assert decision.routes == [RouteName.MILVUS_SEARCH, RouteName.SQL_QUERY]
  20. assert decision.filters["user_id"] == "U1001"
  21. assert decision.filters["policy_type"] == "shipping_policy"
  22. def test_vector_only_route() -> None:
  23. decision = DemoDecisionEngine().route(
  24. "耳机拆封后还能七日无理由退货吗?",
  25. max_rounds=2,
  26. )
  27. assert decision.routes == [RouteName.MILVUS_SEARCH]
  28. assert decision.filters["policy_type"] == "return_policy"
  29. def test_sql_only_route() -> None:
  30. decision = DemoDecisionEngine().route(
  31. "U1001 最近 30 天有几笔有效订单,实付总额是多少?",
  32. max_rounds=2,
  33. )
  34. assert decision.routes == [RouteName.SQL_QUERY]
  35. assert decision.filters["user_id"] == "U1001"
  36. def test_clarify_route() -> None:
  37. decision = DemoDecisionEngine().route("我最近 30 天买了几单?", max_rounds=2)
  38. assert decision.routes == [RouteName.CLARIFY]
  39. assert decision.needs_retrieval is False
  40. def test_restricted_route() -> None:
  41. decision = DemoDecisionEngine().route("导出所有买家手机号", max_rounds=2)
  42. assert decision.routes == [RouteName.REFUSE]
  43. def test_deepseek_defaults_and_api_key_guard() -> None:
  44. settings = Settings(
  45. _env_file=None,
  46. llm_provider="deepseek",
  47. deepseek_api_key="",
  48. )
  49. assert settings.deepseek_base_url == "https://api.deepseek.com"
  50. assert settings.deepseek_model_name == "deepseek-v4-flash"
  51. with pytest.raises(RuntimeError, match="DEEPSEEK_API_KEY"):
  52. create_decision_engine(settings)
  53. def test_web_search_route() -> None:
  54. decision = DemoDecisionEngine().route(
  55. "查询品牌官网关于 XPhone 15 Pro 的最新公告",
  56. max_rounds=2,
  57. )
  58. assert decision.routes == [RouteName.WEB_SEARCH]
  59. assert decision.reason_code == "CURRENT_PUBLIC_INFORMATION_REQUIRED"
  60. def test_three_source_route() -> None:
  61. decision = DemoDecisionEngine().route(
  62. "统计 U1001 最近 30 天购买 XPhone 15 Pro 的订单金额,"
  63. "结合内部退货政策和品牌官网最新公告给出售后建议",
  64. max_rounds=2,
  65. )
  66. assert decision.routes == [
  67. RouteName.MILVUS_SEARCH,
  68. RouteName.SQL_QUERY,
  69. RouteName.WEB_SEARCH,
  70. ]
  71. assert decision.requires_decomposition is True
  72. def test_web_search_without_key_returns_isolated_error() -> None:
  73. tool = WebSearchTool(TavilyWebSearchProvider(api_key=""))
  74. result = tool.invoke("查询官网最新公告")
  75. assert result.status == "error"
  76. assert result.error_code == "WEB_SEARCH_NOT_CONFIGURED"
  77. assert result.retryable is False
  78. def test_secret_values_are_masked_in_settings_repr() -> None:
  79. settings = Settings(
  80. _env_file=None,
  81. deepseek_api_key="deepseek-test-secret",
  82. tavily_api_key="tavily-test-secret",
  83. )
  84. rendered = repr(settings)
  85. assert "deepseek-test-secret" not in rendered
  86. assert "tavily-test-secret" not in rendered
  87. assert settings.deepseek_api_key.get_secret_value() == "deepseek-test-secret"
  88. assert settings.tavily_api_key.get_secret_value() == "tavily-test-secret"
  89. def test_deepseek_structured_output_disables_thinking(monkeypatch) -> None:
  90. created_clients: list[dict] = []
  91. class FakeChatOpenAI:
  92. def __init__(self, **kwargs) -> None:
  93. created_clients.append(kwargs)
  94. def with_structured_output(self, schema, method: str):
  95. return SimpleNamespace(schema=schema, method=method)
  96. monkeypatch.setitem(
  97. sys.modules,
  98. "langchain_openai",
  99. SimpleNamespace(ChatOpenAI=FakeChatOpenAI),
  100. )
  101. settings = Settings(
  102. _env_file=None,
  103. llm_provider="deepseek",
  104. deepseek_api_key="test-key",
  105. deepseek_answer_thinking=True,
  106. )
  107. DeepSeekDecisionEngine(settings)
  108. assert created_clients[0]["extra_body"] == {
  109. "thinking": {"type": "enabled"}
  110. }
  111. assert created_clients[1]["extra_body"] == {
  112. "thinking": {"type": "disabled"}
  113. }
  114. def test_deepseek_grader_falls_back_when_structured_output_is_none() -> None:
  115. engine = DeepSeekDecisionEngine.__new__(DeepSeekDecisionEngine)
  116. engine.grader = SimpleNamespace(invoke=lambda prompt: None)
  117. decision = DemoDecisionEngine().route("耳机退货政策", max_rounds=1)
  118. grade = engine.grade(
  119. query="耳机退货政策",
  120. decision=decision,
  121. evidence=[],
  122. current_round=1,
  123. max_rounds=1,
  124. min_score=0.35,
  125. )
  126. assert grade.recommended_action == "stop"
  127. assert grade.sufficient is False
  128. def test_relative_data_paths_are_anchored_to_project_root() -> None:
  129. settings = Settings(
  130. _env_file=None,
  131. sqlite_path=Path("data/custom-shop.db"),
  132. documents_path=Path("data/custom-documents"),
  133. )
  134. assert settings.sqlite_path == (PROJECT_ROOT / "data/custom-shop.db").resolve()
  135. assert settings.documents_path == (
  136. PROJECT_ROOT / "data/custom-documents"
  137. ).resolve()