health_plan_graph.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. """
  2. 健康方案生成 LangGraph - 分步骤结构化方案生成
  3. 工作流:
  4. 1. 数据收集: 家庭成员信息 + 健康指标 + 知识库检索
  5. 2. LLM 生成: 总览概述 + 营养/饮食/运动 三个 section
  6. 3. 返回结构化 JSON
  7. 每次调用 LLM 时遵循统一的数据组装方式:
  8. - 用户指标 (来自 Java context API)
  9. - 知识库参考 (RAG 检索)
  10. - 系统提示词 (带输出格式模板)
  11. """
  12. from typing import TypedDict, Literal
  13. from langgraph.graph import StateGraph, START, END
  14. from langchain_openai import ChatOpenAI
  15. from langchain_core.messages import SystemMessage, HumanMessage
  16. from app.rag.retriever import RagRetriever
  17. from app.config import settings
  18. from app.tools.java_client import JavaClient
  19. from app.prompt_service import get_prompt
  20. import logging
  21. logger = logging.getLogger(__name__)
  22. SECTION_KEYS = ["nutrition", "diet", "exercise"]
  23. DEFAULT_PROMPT = """你是一个专业的家庭健康方案规划师。根据用户的健康数据和目标,生成结构化的改善方案。
  24. ## 输出格式(必须严格遵守 JSON)
  25. ```json
  26. {
  27. "overview": "总体概述(100字以内,说明方案目标和核心策略)",
  28. "sections": [
  29. {
  30. "key": "nutrition",
  31. "title": "营养补充建议",
  32. "content": "Markdown 格式的详细内容,包含具体产品推荐、用量、服用时间",
  33. "items": [
  34. {"name": "产品名", "dosage": "用量", "timing": "服用时间", "reason": "推荐理由"}
  35. ]
  36. },
  37. {
  38. "key": "diet",
  39. "title": "饮食建议",
  40. "content": "Markdown 格式的餐饮建议,包含早餐/午餐/晚餐建议",
  41. "items": [{"meal": "餐型", "food": "食物建议", "notes": "注意事项"}]
  42. },
  43. {
  44. "key": "exercise",
  45. "title": "运动计划",
  46. "content": "Markdown 格式的运动建议,包含频率、时长、类型",
  47. "items": [{"type": "运动类型", "duration": "时长", "frequency": "频率", "notes": "注意事项"}]
  48. }
  49. ]
  50. }
  51. ```
  52. ## 原则
  53. 1. 基于实际数据给出建议,不编造
  54. 2. 引用知识库时标注来源
  55. 3. 建议要具体可执行,避免空泛
  56. 4. 营养补充部分要具体到品牌/产品类型和用量
  57. 5. 严重健康问题建议咨询医生
  58. """
  59. REGENERATE_SECTION_PROMPT = """你是一个家庭健康方案规划师。根据用户反馈重新生成指定部分的内容。
  60. ## 当前方案内容
  61. {existing_section_content}
  62. ## 用户反馈
  63. {feedback}
  64. ## 相关背景数据
  65. {context_summary}
  66. 请重新生成该部分内容,保持与原格式一致。只输出新的 content 字段值(Markdown 格式),不需要输出 JSON 结构。"""
  67. class PlanState(TypedDict):
  68. member_ids: str
  69. dimensions: str
  70. goal: str
  71. family_id: int
  72. members_info: dict
  73. indicators: list
  74. abnormal_indicators: list
  75. indicator_changes: list
  76. kb_results: list
  77. overview: str
  78. nutrition_section: str
  79. diet_section: str
  80. exercise_section: str
  81. full_response: dict
  82. error: str
  83. async def collect_data(state: PlanState) -> dict:
  84. """Step 1: 收集用户数据 + 知识库检索"""
  85. java = JavaClient()
  86. retriever = RagRetriever(collection_name="cfc_knowledge")
  87. member_ids_str = state.get("member_ids", "")
  88. member_ids = [m.strip() for m in member_ids_str.split(",") if m.strip()]
  89. goal = state.get("goal", "")
  90. dimensions = state.get("dimensions", "") or ""
  91. # 1a. 获取家庭成员信息
  92. members_info = []
  93. for uid_str in member_ids:
  94. ctx = await java.get_family_context(int(uid_str), "child_info")
  95. children = ctx.get("children", []) if isinstance(ctx, dict) else []
  96. for child in children:
  97. cid = str(child.get("用户ID", ""))
  98. if cid == uid_str:
  99. members_info.append({
  100. "id": cid,
  101. "name": child.get("姓名", f"成员{cid}"),
  102. "age": child.get("年龄", "未知"),
  103. "energy": child.get("能量", 0),
  104. })
  105. break
  106. if not any(m["id"] == uid_str for m in members_info):
  107. members_info.append({"id": uid_str, "name": f"成员{uid_str}", "age": "未知", "energy": 0})
  108. # 1b. 获取健康指标
  109. all_indicators = []
  110. abnormal_list = []
  111. indicator_changes = [] # 成员指标"当前值 vs 上次"变化 (来自 member_indicator_current)
  112. for member in members_info:
  113. reports = await java.get_member_reports(int(member["id"]))
  114. if not reports:
  115. continue
  116. latest = max(reports, key=lambda r: r.get("reportDate", ""))
  117. report_id = latest.get("id")
  118. indicators = await java.get_report_indicators(report_id)
  119. for ind in indicators:
  120. ind["_member_id"] = member["id"]
  121. ind["_member_name"] = member["name"]
  122. all_indicators.append(ind)
  123. # 拉取指标变化 (delta/trend), 即使无报告也可能有历史指标
  124. cur_list = await java.get_member_indicator_current(int(member["id"]))
  125. for cur in cur_list:
  126. delta = cur.get("delta")
  127. trend = cur.get("trend", "")
  128. if delta is not None and trend in ("up", "down"):
  129. indicator_changes.append({
  130. "member": member["name"],
  131. "member_id": member["id"],
  132. "indicator": cur.get("name", ""),
  133. "current": cur.get("value", ""),
  134. "previous": cur.get("previousValue", ""),
  135. "delta": delta,
  136. "trend": trend,
  137. })
  138. # 1c. 识别异常指标
  139. known_indicators = {}
  140. for ind in all_indicators:
  141. name = ind.get("indicatorName", "").strip()
  142. if not name or name in known_indicators:
  143. continue
  144. for itype in ["indicator", "bacteria", "nutrient"]:
  145. kb = await java.query_health_knowledge(itype, name)
  146. if kb:
  147. known_indicators[name] = kb
  148. break
  149. for ind in all_indicators:
  150. name = ind.get("indicatorName", "")
  151. status = ind.get("status", "")
  152. kb = known_indicators.get(name, {})
  153. entry = {
  154. "member": ind.get("_member_name", ""),
  155. "indicator": name,
  156. "value": ind.get("indicatorValue", ""),
  157. "unit": ind.get("unit", ""),
  158. "ref_range": kb.get("normalRange", ind.get("refRange", "")),
  159. "description": kb.get("description", ""),
  160. "suggestion": kb.get("suggestion", ""),
  161. }
  162. if status in ("abnormal", "high", "low", "偏高", "偏低"):
  163. abnormal_list.append(entry)
  164. # 1c-2. 把"指标变化(delta 较大)"也视为值得关注的变化点, 并入 abnormal_list
  165. _change_seen = set()
  166. for ch in indicator_changes:
  167. key = (ch["member"], ch["indicator"])
  168. if key in _change_seen:
  169. continue
  170. _change_seen.add(key)
  171. try:
  172. d = float(ch["delta"])
  173. except (TypeError, ValueError):
  174. continue
  175. if abs(d) >= 10: # 显著变化阈值 (按指标量级可调)
  176. arrow = "上升" if d > 0 else "下降"
  177. abnormal_list.append({
  178. "member": ch["member"],
  179. "indicator": ch["indicator"],
  180. "value": str(ch.get("current", "")),
  181. "unit": "",
  182. "ref_range": "上次: %s" % ch.get("previous", ""),
  183. "description": "指标较上次%s %.1f (当前 %.1f, 上次 %s)" % (
  184. arrow, abs(d), float(ch["current"]) if ch.get("current") is not None else -1, ch.get("previous", "")),
  185. "suggestion": "该指标近期%s明显, 方案中应体现针对性调整" % arrow,
  186. "_is_change": True,
  187. })
  188. # 1d. 知识库检索
  189. kb_results = []
  190. queries = set()
  191. for ind in abnormal_list:
  192. queries.add(ind["indicator"])
  193. queries.add(goal)
  194. if dimensions:
  195. queries.add(dimensions)
  196. for q in list(queries)[:8]:
  197. results = await retriever.retrieve(q, k=3)
  198. kb_results.extend(results)
  199. return {
  200. "members_info": members_info,
  201. "indicators": all_indicators,
  202. "abnormal_indicators": abnormal_list,
  203. "indicator_changes": indicator_changes,
  204. "kb_results": kb_results,
  205. }
  206. async def build_prompt(state: PlanState) -> str:
  207. """组装 LLM prompt"""
  208. base_prompt = await get_prompt("health_plan") or DEFAULT_PROMPT
  209. parts = [base_prompt]
  210. parts.append(f"\n## 用户目标\n{state['goal']}")
  211. if state.get("dimensions"):
  212. parts.append(f"\n## 重点关注维度\n{state['dimensions']}")
  213. parts.append("\n## 家庭成员")
  214. for m in state["members_info"]:
  215. parts.append(f"- {m['name']} (年龄: {m['age']})")
  216. if state["abnormal_indicators"]:
  217. parts.append("\n## 异常指标")
  218. for ind in state["abnormal_indicators"][:8]:
  219. parts.append(
  220. f"- {ind['member']} - {ind['indicator']}: {ind['value']}{ind.get('unit','')} "
  221. f"(参考: {ind['ref_range']})"
  222. )
  223. if ind.get("description"):
  224. parts.append(f" 说明: {ind['description']}")
  225. if state.get("indicator_changes"):
  226. parts.append("\n## 指标较上次报告的变化(方案必须考虑这些趋势)")
  227. for ch in state["indicator_changes"][:12]:
  228. arrow = "上升" if ch.get("trend") == "up" else "下降"
  229. parts.append(
  230. f"- {ch[member]} - {ch[indicator]}: {ch.get(current,)} "
  231. f"(上次: {ch.get(previous,)}, {arrow} {abs(ch.get(delta, 0))})"
  232. )
  233. if state["kb_results"]:
  234. parts.append("\n## 知识库参考")
  235. for r in state["kb_results"][:6]:
  236. title = r.get("metadata", {}).get("title", "")
  237. content = r.get("content", "")[:200]
  238. parts.append(f"---\n{title}\n{content}")
  239. return "\n".join(parts)
  240. async def generate_plan(state: PlanState) -> dict:
  241. """Step 2: 调用 LLM 生成结构化方案"""
  242. llm = ChatOpenAI(
  243. model=settings.llm_model,
  244. api_key=settings.llm_api_key,
  245. base_url=settings.llm_base_url,
  246. temperature=0.3,
  247. )
  248. prompt = await build_prompt(state)
  249. messages = [SystemMessage(content=prompt)]
  250. try:
  251. response = await llm.ainvoke(messages)
  252. answer = response.content
  253. # 解析 JSON
  254. import json
  255. try:
  256. # 提取 JSON 块
  257. start = answer.find("{")
  258. end = answer.rfind("}") + 1
  259. if start >= 0 and end > start:
  260. json_str = answer[start:end]
  261. parsed = json.loads(json_str)
  262. return {"full_response": parsed, "overview": parsed.get("overview", "")}
  263. except (json.JSONDecodeError, Exception) as e:
  264. logger.warning("解析方案 JSON 失败,使用原始文本: %s", e)
  265. return {"full_response": {"raw": answer}, "overview": answer[:200]}
  266. except Exception as e:
  267. logger.error("LLM 生成方案失败: %s", e)
  268. return {"error": str(e)}
  269. async def regenerate_section(state: PlanState) -> dict:
  270. """重新生成指定 section"""
  271. section_key = state.get("section", "nutrition")
  272. feedback = state.get("feedback", "")
  273. existing_content = state.get("existing_section_content", "")
  274. llm = ChatOpenAI(
  275. model=settings.llm_model,
  276. api_key=settings.llm_api_key,
  277. base_url=settings.llm_base_url,
  278. temperature=0.3,
  279. )
  280. # 构建上下文摘要
  281. ctx_parts = []
  282. for m in state.get("members_info", []):
  283. ctx_parts.append(f"- {m['name']} (年龄: {m['age']})")
  284. if state.get("goal"):
  285. ctx_parts.append(f"目标: {state['goal']}")
  286. if state.get("abnormal_indicators"):
  287. for ind in state["abnormal_indicators"][:5]:
  288. ctx_parts.append(f"- {ind['member']}: {ind['indicator']}={ind['value']}")
  289. regenerate_template = await get_prompt("health_plan_regenerate") or REGENERATE_SECTION_PROMPT
  290. try:
  291. prompt = regenerate_template.format(
  292. existing_section_content=existing_content[:500],
  293. feedback=feedback,
  294. context_summary="\n".join(ctx_parts),
  295. )
  296. except (KeyError, IndexError, ValueError):
  297. logger.warning("Java 配置的 health_plan_regenerate 模板缺少占位符,回退本地模板")
  298. prompt = REGENERATE_SECTION_PROMPT.format(
  299. existing_section_content=existing_content[:500],
  300. feedback=feedback,
  301. context_summary="\n".join(ctx_parts),
  302. )
  303. try:
  304. response = await llm.ainvoke([SystemMessage(content=prompt)])
  305. return {"regenerated_content": response.content}
  306. except Exception as e:
  307. logger.error("重新生成方案 section 失败: %s", e)
  308. return {"error": str(e)}
  309. def create_health_plan_graph():
  310. builder = StateGraph(PlanState)
  311. builder.add_node("collect_data", collect_data)
  312. builder.add_node("generate_plan", generate_plan)
  313. builder.add_node("regenerate_section", regenerate_section)
  314. builder.add_edge(START, "collect_data")
  315. builder.add_edge("collect_data", "generate_plan")
  316. builder.add_edge("generate_plan", END)
  317. # regenerate_section 从外部直接调用,不走图
  318. graph = builder.compile()
  319. return graph