test_growth_report_contract.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. """SC-B-04 / SC-B-11 contract tests: growth-report interpretation & follow-up routing.
  2. Pure structural tests (no real LLM, no network, no DB):
  3. - SC-B-04 coach.growthReport.interpret:
  4. analysis_graph.create_analysis_graph end-to-end offline run — gather_data collects
  5. report/survey/dimension data via tools, analyze node feeds them into the prompt and
  6. returns the LLM interpretation. We stub ChatOpenAI + the three report tools.
  7. - SC-B-11 coach.growthReport.ask:
  8. a report follow-up question ("再解释一下报告里的免疫力指标") must be routed by
  9. IntentClassifier to Intent.ANALYSIS (report interpretation), while LLM failure or
  10. unknown output falls back to Intent.CHAT (no false behavior).
  11. NOTE: this test lives in tests_contract/ (not tests/) on purpose — tests/conftest.py
  12. imports app.main, which drags in faster_whisper->ctranslate2 native libs that are not
  13. reproducibly loadable in this venv (native access-violation 0xC0000005). This contract
  14. suite needs only app.graphs.analysis_graph + app.agents.intent_classifier, which import
  15. cleanly, so it runs in a directory without that conftest.
  16. """
  17. import asyncio
  18. import os
  19. import sys
  20. from unittest.mock import patch
  21. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  22. from app.agents.intent_classifier import CLASSIFY_PROMPT, Intent, IntentClassifier
  23. class FakeLLM:
  24. """Offline LLM stub returning fixed content (or raising), mirroring langchain ainvoke shape."""
  25. def __init__(self, content=""):
  26. self._content = content
  27. self._exc = None
  28. def failure(self, exc):
  29. self._exc = exc
  30. return self
  31. async def ainvoke(self, *args, **kwargs):
  32. if self._exc:
  33. raise self._exc
  34. return type("Resp", (), {"content": self._content})()
  35. class FakeBoundLLM:
  36. def __init__(self, content):
  37. self._content = content
  38. async def ainvoke(self, messages):
  39. return type("Resp", (), {"content": self._content})()
  40. class FakeChatOpenAI:
  41. def __init__(self, content="report interpretation stub", **kwargs):
  42. self._content = content
  43. def bind_tools(self, tools):
  44. return FakeBoundLLM(self._content)
  45. class FakeTool:
  46. """Offline stand-in for a langchain StructuredTool: exposes only ainvoke."""
  47. def __init__(self, fn):
  48. self._fn = fn
  49. async def ainvoke(self, arg):
  50. return self._fn(arg)
  51. class TestGrowthReportInterpretB04:
  52. """SC-B-04 报告智能解读: analysis graph offline contract."""
  53. def test_analysis_graph_offline_run_returns_interpretation(self, monkeypatch):
  54. import app.graphs.analysis_graph as g
  55. monkeypatch.setattr(g, "ChatOpenAI", FakeChatOpenAI)
  56. async def fake_get_prompt(*a, **k):
  57. return g.ANALYSIS_SYSTEM_PROMPT
  58. monkeypatch.setattr(g, "get_prompt", fake_get_prompt)
  59. monkeypatch.setattr(g, "get_report_detail", FakeTool(lambda arg: {"gut_health_score": 60, "immunity": "偏低"}))
  60. monkeypatch.setattr(g, "get_survey_data", FakeTool(lambda arg: {"sleep": "不足"}))
  61. monkeypatch.setattr(g, "get_dimension_scores", FakeTool(lambda arg: {"免疫力": 55, "肠胃": 70}))
  62. graph = g.create_analysis_graph()
  63. result = asyncio.run(graph.ainvoke({
  64. "report_id": 200,
  65. "user_id": 9,
  66. "focus": "immunity",
  67. "report_data": None,
  68. "survey_data": None,
  69. "dimension_scores": None,
  70. "analysis": None,
  71. "recommendations": [],
  72. }, config={"configurable": {"thread_id": "b04-offline"}}))
  73. # gather_data ran via stubbed tools, analyze returned stub LLM interpretation
  74. assert result.get("analysis") == FakeChatOpenAI()._content
  75. assert result.get("recommendations") is not None
  76. def test_analysis_prompt_covers_interpretation_contract(self):
  77. from app.graphs.analysis_graph import ANALYSIS_SYSTEM_PROMPT
  78. assert "解读" in ANALYSIS_SYSTEM_PROMPT
  79. assert "总体评估" in ANALYSIS_SYSTEM_PROMPT # 输出格式: 总体评估 -> 分项分析 -> 改善建议
  80. assert "异常指标" in ANALYSIS_SYSTEM_PROMPT # 关注异常指标给出改善建议
  81. def test_analysis_state_shape(self):
  82. from app.graphs.analysis_graph import AnalysisState
  83. state = AnalysisState(
  84. report_id=200, user_id=9, focus=None,
  85. report_data=None, survey_data=None,
  86. dimension_scores=None, analysis=None, recommendations=[],
  87. )
  88. assert state["report_id"] == 200
  89. assert "analysis" in state and "recommendations" in state
  90. class TestGrowthReportAskB11:
  91. """SC-B-11 报告细节追问: intent routing contract."""
  92. def _classifier(self, llm):
  93. classifier = IntentClassifier()
  94. classifier.llm = llm # offline stub replaces hard-created ChatOpenAI
  95. return classifier
  96. def test_report_followup_routed_to_analysis(self, monkeypatch):
  97. with patch("app.agents.intent_classifier.get_prompt", return_value=""):
  98. classifier = self._classifier(FakeLLM(content="analysis"))
  99. intent = asyncio.run(classifier.classify("报告里的免疫力指标再解释一下"))
  100. assert intent == Intent.ANALYSIS, "报告追问应路由到分析(解读)"
  101. def test_explicit_analysis_keyword_routed(self, monkeypatch):
  102. with patch("app.agents.intent_classifier.get_prompt", return_value=""):
  103. classifier = self._classifier(FakeLLM(content="analysis"))
  104. intent = asyncio.run(classifier.classify("帮我分析这份成长报告"))
  105. assert intent == Intent.ANALYSIS
  106. def test_classify_prompt_has_report_routing_rules(self):
  107. assert "analysis" in CLASSIFY_PROMPT
  108. assert "报告解读" in CLASSIFY_PROMPT
  109. def test_llm_failure_falls_back_to_chat(self, monkeypatch):
  110. with patch("app.agents.intent_classifier.get_prompt", return_value=""):
  111. llm = FakeLLM().failure(RuntimeError("llm down"))
  112. classifier = self._classifier(llm)
  113. intent = asyncio.run(classifier.classify("今天天气不错"))
  114. assert intent == Intent.CHAT, "LLM 异常时应降级 chat 而非抛出"
  115. def test_unknown_llm_output_falls_back_to_chat(self, monkeypatch):
  116. with patch("app.agents.intent_classifier.get_prompt", return_value=""):
  117. classifier = self._classifier(FakeLLM(content="banana_intent"))
  118. intent = asyncio.run(classifier.classify("随便聊聊"))
  119. assert intent == Intent.CHAT, "未识别意图应降级 chat"