test_health_plan.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. from app.models.health_plan import PlanTask, HealthPlanSection, HealthPlanResponse, MultiMemberPlanResponse, PlanPart
  2. from app.api.adapter import _parse_plan_response, _fallback_extract_tasks, _synthesize_multi_member_plan
  3. def test_plan_task_valid():
  4. t = PlanTask(action_type="buy", title="购买维生素D3", dimension="wealth", frequency="once")
  5. assert t.frequency == "once"
  6. assert t.notes is None
  7. def test_plan_task_default_frequency_daily():
  8. t = PlanTask(action_type="exercise", title="每周跑步3次", dimension="body")
  9. assert t.frequency == "daily"
  10. def test_plan_task_invalid_action_type():
  11. import pytest
  12. from pydantic import ValidationError
  13. with pytest.raises(ValidationError):
  14. PlanTask.model_validate({"action_type": "cook", "title": "做饭", "dimension": "body"})
  15. def test_plan_task_invalid_dimension():
  16. import pytest
  17. from pydantic import ValidationError
  18. with pytest.raises(ValidationError):
  19. PlanTask.model_validate({"action_type": "diet", "title": "少油少盐", "dimension": "earth"})
  20. def test_plan_response_with_tasks():
  21. resp = HealthPlanResponse(
  22. overview="总览",
  23. sections=[HealthPlanSection(
  24. key="nutrition", title="营养补充", content="## 建议",
  25. tasks=[PlanTask(action_type="buy", title="购买鱼油", dimension="wealth", frequency="once")]
  26. )]
  27. )
  28. assert resp.sections[0].tasks[0].action_type == "buy"
  29. # 序列化后 tasks 字段存在
  30. data = resp.model_dump()
  31. assert "tasks" in data["sections"][0]
  32. def test_parse_plan_response_valid():
  33. answer = '{"overview":"o","sections":[{"key":"diet","title":"t","content":"c","items":[],"tasks":[{"action_type":"diet","title":"少油少盐","dimension":"body","frequency":"daily"}]}],"abnormal_indicators":[]}'
  34. res = _parse_plan_response(answer)
  35. assert res["success"] is True
  36. assert res["data"]["sections"][0]["tasks"][0]["action_type"] == "diet"
  37. def test_parse_plan_response_invalid_action_falls_back():
  38. # LLM 输出含非法 action_type("cook"),校验失败但可被 fallback 处理
  39. answer = '{"overview":"o","sections":[{"key":"diet","title":"t","content":"1. 少油少盐\\n2. 多吃蔬菜\\n3. 纯原理描述无动作","items":[],"tasks":[{"action_type":"cook","title":"做饭","dimension":"body"}]}],"abnormal_indicators":[]}'
  40. res = _parse_plan_response(answer)
  41. # fallback 从 content 提取动作行
  42. assert res["success"] is True
  43. tasks = res["data"]["sections"][0].get("tasks", [])
  44. assert len(tasks) >= 2
  45. def test_parse_plan_response_no_json():
  46. res = _parse_plan_response("这是纯文本没有 JSON")
  47. assert res["success"] is False
  48. def test_fallback_skips_when_tasks_exist():
  49. parsed = {"sections": [{"key": "diet", "title": "t", "content": "1. 少油少盐", "tasks": [{"action_type": "diet", "title": "已有", "dimension": "body"}]}]}
  50. assert _fallback_extract_tasks(parsed) is None
  51. def test_regenerate_tasks_validation_filter():
  52. raw_tasks = [
  53. {"action_type": "diet", "title": "少油少盐", "dimension": "body", "frequency": "daily"},
  54. {"action_type": "cook", "title": "非法", "dimension": "body"}, # 非法 action,应被丢弃
  55. ]
  56. valid = []
  57. for t in raw_tasks:
  58. try:
  59. valid.append(PlanTask.model_validate(t).model_dump())
  60. except Exception:
  61. continue
  62. assert len(valid) == 1
  63. assert valid[0]["action_type"] == "diet"
  64. def test_multi_member_plan_response_structure():
  65. """多人方案结构校验:common + individual 按 memberId 分组"""
  66. resp = MultiMemberPlanResponse()
  67. assert resp.common.overview == ""
  68. assert resp.individual == {}
  69. def test_multi_member_plan_response_with_data():
  70. resp = MultiMemberPlanResponse(
  71. common=PlanPart(overview="全家共性方案", sections=[
  72. HealthPlanSection(
  73. key="diet", title="饮食建议", content="## 共同饮食",
  74. tasks=[PlanTask(action_type="diet", title="每日三餐均衡", dimension="body", frequency="daily")]
  75. )
  76. ]),
  77. individual={
  78. "123": PlanPart(overview="成员A个性方案", sections=[
  79. HealthPlanSection(
  80. key="nutrition", title="营养补充", content="## 维生素D3",
  81. tasks=[PlanTask(action_type="buy", title="购买维生素D3", dimension="wealth", frequency="once")]
  82. )
  83. ])
  84. }
  85. )
  86. data = resp.model_dump()
  87. assert data["common"]["sections"][0]["tasks"][0]["action_type"] == "diet"
  88. assert data["individual"]["123"]["sections"][0]["tasks"][0]["action_type"] == "buy"
  89. assert "123" in data["individual"]
  90. def test_multi_member_plan_response_fallback_structure():
  91. """LLM 合成失败时程序化兜底:common 置空,individual 透传各成员方案"""
  92. resp = MultiMemberPlanResponse(common=PlanPart(overview=""), individual={
  93. "456": PlanPart(overview="成员B个性", sections=[
  94. HealthPlanSection(key="exercise", title="运动计划", content="## 跑步",
  95. tasks=[PlanTask(action_type="exercise", title="每周跑步3次", dimension="body", frequency="daily")])
  96. ])
  97. })
  98. data = resp.model_dump()
  99. assert data["common"]["overview"] == ""
  100. assert data["individual"]["456"]["sections"][0]["tasks"][0]["title"] == "每周跑步3次"