adapter.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857
  1. from fastapi import APIRouter, HTTPException
  2. from pydantic import BaseModel, ConfigDict
  3. from typing import Optional
  4. from app.graphs.chat_graph import create_chat_graph
  5. from app.graphs.analysis_graph import create_analysis_graph
  6. from app.graphs.health_coach_graph import create_health_coach_graph
  7. from app.graphs.health_butler_graph import create_health_butler_graph
  8. from app.graphs.nutrition_graph import create_nutrition_graph
  9. from app.tools.java_client import JavaClient
  10. from app.rag.retriever import RagRetriever
  11. from app.config import settings
  12. from langchain_openai import ChatOpenAI
  13. from langchain_core.messages import SystemMessage, HumanMessage
  14. import uuid
  15. import time
  16. import re
  17. import logging
  18. import json
  19. logger = logging.getLogger(__name__)
  20. router = APIRouter(prefix="/api/v1", tags=["adapter"])
  21. # ---- Dify-compatible request/response models ----
  22. class DifyMessage(BaseModel):
  23. role: str
  24. content: str
  25. class DifyChatRequest(BaseModel):
  26. # 前端 userId/conversationId 可能为数字,允许 int/float 自动转 str,避免 422
  27. model_config = ConfigDict(coerce_numbers_to_str=True)
  28. query: str = ""
  29. user: str = "anonymous"
  30. conversation_id: str = ""
  31. messages: list[DifyMessage] = []
  32. inputs: dict = {}
  33. response_mode: str = "blocking" # blocking / streaming
  34. user_id: str = ""
  35. bot_name: str = "AI管家"
  36. class DifyAnalysisRequest(BaseModel):
  37. # 前端 userId 可能为数字,允许 int/float 自动转 str,避免 422
  38. model_config = ConfigDict(coerce_numbers_to_str=True)
  39. report_id: Optional[int] = None
  40. user_id: Optional[str] = None
  41. focus: Optional[str] = None
  42. messages: list[DifyMessage] = []
  43. inputs: dict = {}
  44. class HealthCoachGenerateRequest(BaseModel):
  45. """Java 后端健康方案生成专用请求体"""
  46. family_id: Optional[int] = None
  47. member_ids: Optional[str] = None
  48. dimensions: Optional[str] = None
  49. goal: str = ""
  50. class DifyChoice(BaseModel):
  51. index: int
  52. message: dict
  53. finish_reason: str = "stop"
  54. class DifyUsage(BaseModel):
  55. prompt_tokens: int = 0
  56. completion_tokens: int = 0
  57. total_tokens: int = 0
  58. class DifyResponse(BaseModel):
  59. id: str
  60. object: str = "chat.completion"
  61. created: int
  62. model: str = "langgraph-cfc"
  63. choices: list[DifyChoice]
  64. usage: DifyUsage = DifyUsage()
  65. metadata: dict = {}
  66. # ---- Helpers ----
  67. def _now_ts() -> int:
  68. return int(time.time())
  69. def _extract_query(req: DifyChatRequest) -> str:
  70. if req.query:
  71. return req.query
  72. # fallback: take last user message
  73. for msg in reversed(req.messages):
  74. if msg.role == "user":
  75. return msg.content
  76. return ""
  77. def _to_langgraph_context(req: DifyChatRequest) -> dict:
  78. ctx = {}
  79. if isinstance(req.inputs, dict):
  80. ctx["child_id"] = req.inputs.get("child_id")
  81. ctx["report_id"] = req.inputs.get("report_id")
  82. ctx["family_id"] = req.inputs.get("family_id")
  83. return ctx
  84. # ---- Dify-compatible endpoints ----
  85. @router.post("/chat/completion", response_model=DifyResponse)
  86. async def chat_completion(req: DifyChatRequest):
  87. query = _extract_query(req)
  88. if not query:
  89. raise HTTPException(status_code=400, detail="query 为空")
  90. graph = create_chat_graph()
  91. initial_state = {
  92. "query": query,
  93. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  94. "conversation_id": req.conversation_id or None,
  95. "intent": None,
  96. "context": _to_langgraph_context(req),
  97. "messages": None,
  98. "answer": None,
  99. "tasks": [],
  100. "sources": [],
  101. }
  102. config = {
  103. "configurable": {"thread_id": req.conversation_id or str(req.user_id or "default")},
  104. }
  105. result = await graph.ainvoke(initial_state, config)
  106. answer = result.get("answer") or ""
  107. return DifyResponse(
  108. id=f"chatcmpl-{uuid.uuid4().hex[:24]}",
  109. created=_now_ts(),
  110. choices=[
  111. DifyChoice(
  112. index=0,
  113. message={"role": "assistant", "content": answer},
  114. finish_reason="stop",
  115. )
  116. ],
  117. usage=DifyUsage(
  118. prompt_tokens=len(query.split()),
  119. completion_tokens=len(answer.split()),
  120. total_tokens=len(query.split()) + len(answer.split()),
  121. ),
  122. )
  123. @router.post("/analysis/run", response_model=DifyResponse)
  124. async def analysis_run(req: DifyAnalysisRequest):
  125. graph = create_analysis_graph()
  126. report_id = req.report_id
  127. if report_id is None and isinstance(req.inputs, dict):
  128. report_id = req.inputs.get("report_id")
  129. initial_state = {
  130. "report_id": report_id,
  131. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  132. "focus": req.focus,
  133. "report_data": None,
  134. "survey_data": None,
  135. "dimension_scores": None,
  136. "analysis": None,
  137. "recommendations": [],
  138. }
  139. result = await graph.ainvoke(initial_state, {})
  140. analysis = result.get("analysis") or ""
  141. recommendations = result.get("recommendations") or []
  142. content = analysis
  143. if recommendations:
  144. content += "\n\n建议:\n" + "\n".join(f"- {r}" for r in recommendations)
  145. return DifyResponse(
  146. id=f"analysis-{uuid.uuid4().hex[:24]}",
  147. created=_now_ts(),
  148. choices=[
  149. DifyChoice(
  150. index=0,
  151. message={"role": "assistant", "content": content},
  152. finish_reason="stop",
  153. )
  154. ],
  155. usage=DifyUsage(
  156. prompt_tokens=len((req.focus or "").split()),
  157. completion_tokens=len(content.split()),
  158. total_tokens=len((req.focus or "").split()) + len(content.split()),
  159. ),
  160. )
  161. @router.post("/health/coach", response_model=DifyResponse)
  162. async def health_coach(req: DifyChatRequest):
  163. query = _extract_query(req)
  164. if not query:
  165. raise HTTPException(status_code=400, detail="query 为空")
  166. graph = create_health_coach_graph()
  167. initial_state = {
  168. "query": query,
  169. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  170. "child_id": int(req.inputs.get("child_id")) if req.inputs.get("child_id") else None,
  171. "conversation_id": req.conversation_id or None,
  172. "context": _to_langgraph_context(req),
  173. "answer": None,
  174. "sources": [],
  175. "memory_messages": None,
  176. }
  177. config = {
  178. "configurable": {"thread_id": req.conversation_id or f"health_{req.user_id}"},
  179. }
  180. result = await graph.ainvoke(initial_state, config)
  181. answer = result.get("answer") or ""
  182. sources = result.get("sources") or []
  183. metadata = {}
  184. if sources:
  185. metadata["sources"] = [
  186. {"title": s.get("title", ""), "type": s.get("type", "knowledge")}
  187. for s in sources
  188. ]
  189. return DifyResponse(
  190. id=f"health-{uuid.uuid4().hex[:24]}",
  191. created=_now_ts(),
  192. model="langgraph-health-coach",
  193. choices=[
  194. DifyChoice(
  195. index=0,
  196. message={"role": "assistant", "content": answer},
  197. finish_reason="stop",
  198. )
  199. ],
  200. usage=DifyUsage(
  201. prompt_tokens=len(query.split()),
  202. completion_tokens=len(answer.split()),
  203. total_tokens=len(query.split()) + len(answer.split()),
  204. ),
  205. metadata=metadata,
  206. )
  207. @router.post("/health/butler", response_model=DifyResponse)
  208. async def health_butler(req: DifyChatRequest):
  209. """AI 健康管家 — 基于健康知识库检索 + 个性化建议 + 任务生成"""
  210. query = _extract_query(req)
  211. if not query:
  212. raise HTTPException(status_code=400, detail="query 为空")
  213. graph = create_health_butler_graph()
  214. # 从 inputs 中提取健康管家需要的上下文
  215. inputs = req.inputs or {}
  216. family_id = inputs.get("family_id")
  217. child_id = inputs.get("child_id")
  218. report_id = inputs.get("report_id")
  219. focus = inputs.get("focus")
  220. # conversation_id 用于 checkpointer thread
  221. thread_id = req.conversation_id or f"butler_{req.user_id or 'anon'}"
  222. initial_state = {
  223. "query": query,
  224. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  225. "conversation_id": thread_id,
  226. "family_id": int(family_id) if family_id else None,
  227. "child_id": int(child_id) if child_id else None,
  228. "report_id": int(report_id) if report_id else None,
  229. "focus": focus,
  230. "kb_context": None,
  231. "knowledge_results": [],
  232. "answer": None,
  233. "tasks": [],
  234. "sources": [],
  235. "messages": None,
  236. }
  237. config = {
  238. "configurable": {"thread_id": thread_id},
  239. }
  240. result = await graph.ainvoke(initial_state, config)
  241. answer = result.get("answer") or ""
  242. # 从回答中提取 TASK 标记
  243. tasks = result.get("tasks") or []
  244. sources = result.get("sources") or []
  245. metadata = {"tasks": tasks, "sources": sources}
  246. return DifyResponse(
  247. id=f"butler-{uuid.uuid4().hex[:24]}",
  248. created=_now_ts(),
  249. model="langgraph-health-butler",
  250. choices=[
  251. DifyChoice(
  252. index=0,
  253. message={"role": "assistant", "content": answer},
  254. finish_reason="stop",
  255. )
  256. ],
  257. usage=DifyUsage(
  258. prompt_tokens=len(query.split()),
  259. completion_tokens=len(answer.split()),
  260. total_tokens=len(query.split()) + len(answer.split()),
  261. ),
  262. metadata=metadata,
  263. )
  264. def _parse_normal_range(ref_range: str) -> tuple[float | None, float | None]:
  265. """解析正常范围字符串,返回 (下限, 上限),如 '30-100' → (30, 100), '<5' → (None, 5)"""
  266. if not ref_range:
  267. return None, None
  268. ref_range = ref_range.strip()
  269. m = re.match(r'([<>]=?)\s*([\d.]+)', ref_range)
  270. if m:
  271. op, val = m.group(1), float(m.group(2))
  272. if op.startswith('>'):
  273. return (val, None)
  274. else:
  275. return (None, val)
  276. m = re.match(r'([\d.]+)\s*[-~]\s*([\d.]+)', ref_range)
  277. if m:
  278. return (float(m.group(1)), float(m.group(2)))
  279. return None, None
  280. def _is_abnormal(status: str, value: float | None, low: float | None, high: float | None) -> bool:
  281. """判断指标是否异常:优先用 status 字段,否则用数值与范围比较"""
  282. if status and status not in ("正常", "正常范围", "未检出", ""):
  283. return True
  284. if value is not None and low is not None and high is not None:
  285. return value < low or value > high
  286. return False
  287. def _try_parse_value(raw: str) -> float | None:
  288. if not raw:
  289. return None
  290. raw = raw.strip().replace(",", "").replace(" ", "")
  291. try:
  292. return float(raw)
  293. except ValueError:
  294. return None
  295. async def _search_knowledge(retriever: RagRetriever, query: str, k: int = 3) -> list[dict]:
  296. """从知识库检索相关内容"""
  297. try:
  298. return await retriever.retrieve(query, k=k)
  299. except Exception as e:
  300. logger.warning("知识库检索失败: %s", e)
  301. return []
  302. @router.post("/health/coach/generate")
  303. async def health_coach_generate(req: HealthCoachGenerateRequest):
  304. """健康方案生成 — 选人→拉指标→查知识库→LLM"""
  305. goal = req.goal or "改善健康状况"
  306. member_ids_str = req.member_ids or ""
  307. dimensions = req.dimensions or ""
  308. member_ids = [m.strip() for m in member_ids_str.split(",") if m.strip()]
  309. java = JavaClient()
  310. retriever = RagRetriever(collection_name="cfc_knowledge")
  311. llm = ChatOpenAI(
  312. model=settings.llm_model,
  313. api_key=settings.llm_api_key,
  314. base_url=settings.llm_base_url,
  315. temperature=0.3,
  316. )
  317. # ====== 1. 获取家庭成员信息 ======
  318. members_info = []
  319. if member_ids:
  320. # 从 context 获取家庭信息
  321. for uid_str in member_ids:
  322. uid = int(uid_str)
  323. ctx = await java.get_family_context(uid, "child_info")
  324. children = ctx.get("children", []) if isinstance(ctx, dict) else []
  325. for child in children:
  326. child_id = str(child.get("用户ID", ""))
  327. if child_id in member_ids:
  328. members_info.append({
  329. "id": child_id,
  330. "name": child.get("姓名", f"成员{child_id}"),
  331. "age": child.get("年龄", "未知"),
  332. "energy": child.get("能量", 0),
  333. })
  334. break
  335. # 如果 context 没找到,用基本信息兜底
  336. if not any(m["id"] == uid_str for m in members_info):
  337. members_info.append({"id": uid_str, "name": f"成员{uid_str}", "age": "未知", "energy": 0})
  338. else:
  339. members_info.append({"id": "0", "name": "用户", "age": "未知", "energy": 0})
  340. # ====== 2. 获取每个成员的指标数据 ======
  341. all_indicators = []
  342. for member in members_info:
  343. uid = int(member["id"])
  344. reports = await java.get_member_reports(uid)
  345. if not reports:
  346. logger.info("成员 %s 无健康报告", member["id"])
  347. continue
  348. latest = max(reports, key=lambda r: r.get("reportDate", ""))
  349. report_id = latest.get("id")
  350. member["latest_report_id"] = report_id
  351. member["report_date"] = latest.get("reportDate", "")
  352. member["overall_score"] = latest.get("overallScore", "未知")
  353. indicators = await java.get_report_indicators(report_id)
  354. for ind in indicators:
  355. ind["_member_id"] = member["id"]
  356. ind["_member_name"] = member["name"]
  357. all_indicators.append(ind)
  358. # ====== 3. 获取指标定义+正常范围 ======
  359. known_indicators = {}
  360. for ind in all_indicators:
  361. name = ind.get("indicatorName", "").strip()
  362. if not name or name in known_indicators:
  363. continue
  364. kb = await java.query_health_knowledge("indicator", name)
  365. if not kb:
  366. kb = await java.query_health_knowledge("bacteria", name)
  367. if not kb:
  368. kb = await java.query_health_knowledge("nutrient", name)
  369. if kb:
  370. known_indicators[name] = kb
  371. # ====== 4. 识别异常指标 ======
  372. abnormal_list = []
  373. normal_list = []
  374. for ind in all_indicators:
  375. name = ind.get("indicatorName", "")
  376. raw_val = ind.get("indicatorValue", "")
  377. status = ind.get("status", "")
  378. unit = ind.get("unit", "")
  379. ref_range = ind.get("refRange", "")
  380. # 优先用知识库中的正常范围
  381. kb = known_indicators.get(name)
  382. if kb and kb.get("normalRange"):
  383. ref_range = kb.get("normalRange", ref_range)
  384. low, high = _parse_normal_range(ref_range)
  385. value = _try_parse_value(raw_val)
  386. is_abnormal = _is_abnormal(status, value, low, high)
  387. entry = {
  388. "member": ind.get("_member_name", ""),
  389. "indicator": name,
  390. "value": raw_val,
  391. "unit": unit,
  392. "ref_range": ref_range,
  393. "status": status,
  394. "is_abnormal": is_abnormal,
  395. "description": kb.get("description", "") if kb else "",
  396. "suggestion": kb.get("suggestion", "") if kb else "",
  397. }
  398. if is_abnormal:
  399. abnormal_list.append(entry)
  400. else:
  401. normal_list.append(entry)
  402. # ====== 5. 检索知识库 ======
  403. kb_results = []
  404. # 5a. 异常指标检索
  405. abnormal_queries = set()
  406. for ind in abnormal_list:
  407. abnormal_queries.add(ind["indicator"])
  408. for q in list(abnormal_queries)[:5]:
  409. results = await _search_knowledge(retriever, f"{q} 改善建议", k=3)
  410. kb_results.extend(results)
  411. # 5b. 用户需求检索
  412. goal_results = await _search_knowledge(retriever, goal, k=5)
  413. kb_results.extend(goal_results)
  414. # 5c. 维度检索
  415. if dimensions:
  416. dim_results = await _search_knowledge(retriever, dimensions, k=3)
  417. kb_results.extend(dim_results)
  418. # 去重
  419. seen_content = set()
  420. deduped_kb = []
  421. for r in kb_results:
  422. h = r.get("content", "")[:100]
  423. if h not in seen_content:
  424. seen_content.add(h)
  425. deduped_kb.append(r)
  426. # ====== 6. 组装结构化 Prompt ======
  427. prompt_parts = []
  428. # 系统提示
  429. prompt_parts.append("""你是一个专业的家庭健康方案生成器。请根据用户提供的健康数据,生成一份结构化的健康改善方案。
  430. 输出格式要求:
  431. ## 方案概述
  432. [简要说明方案的总体目标和适用对象]
  433. ## 成员健康概况
  434. [每个成员的关键指标摘要]
  435. ## 需要关注的异常指标
  436. [列出异常指标及对应的知识库建议]
  437. ## 改善方案
  438. ### 1. 饮食调整
  439. [具体、可执行的饮食建议]
  440. ### 2. 生活习惯
  441. [具体、可执行的生活习惯建议]
  442. ### 3. 补充建议
  443. [如需补充营养素或益生菌,给出具体建议]
  444. ### 4. 跟踪建议
  445. [建议定期复查的指标和频率]
  446. ## 注意事项
  447. [禁忌、提醒等]
  448. 请基于实际数据给出建议,不要编造科学依据。引用知识库内容时标注来源。""")
  449. # 目标与维度
  450. prompt_parts.append(f"\n## 用户目标\n{goal}")
  451. if dimensions:
  452. prompt_parts.append(f"\n## 重点关注维度\n{dimensions}")
  453. # 成员信息
  454. prompt_parts.append("\n## 家庭成员")
  455. for m in members_info:
  456. scores = f"健康评分: {m.get('overall_score', '未知')}" if m.get('overall_score') else ""
  457. report = f"最近报告: {m.get('report_date', '无')}" if m.get('report_date') else ""
  458. prompt_parts.append(f"- {m['name']} (年龄: {m['age']}) {scores} {report}")
  459. # 异常指标
  460. if abnormal_list:
  461. prompt_parts.append("\n## 异常指标")
  462. for ind in abnormal_list:
  463. parts = [f"- {ind['member']} - {ind['indicator']}: {ind['value']}{ind['unit']} (参考范围: {ind['ref_range']})"]
  464. if ind['description']:
  465. parts.append(f" 说明: {ind['description']}")
  466. if ind['suggestion']:
  467. parts.append(f" 建议: {ind['suggestion']}")
  468. prompt_parts.append("\n".join(parts))
  469. # 正常指标
  470. if normal_list:
  471. prompt_parts.append("\n## 正常指标(参考)")
  472. normal_summary = [f"- {ind['indicator']}: {ind['value']}{ind['unit']} (正常)" for ind in normal_list[:10]]
  473. prompt_parts.extend(normal_summary)
  474. # 知识库参考
  475. if deduped_kb:
  476. prompt_parts.append("\n## 知识库参考(可引用)")
  477. for r in deduped_kb[:8]:
  478. title = r.get("metadata", {}).get("title", "")
  479. content = r.get("content", "")[:300]
  480. prompt_parts.append(f"---\n{title}\n{content}")
  481. full_prompt = "\n".join(prompt_parts)
  482. # ====== 7. 调用 LLM ======
  483. messages = [
  484. SystemMessage(content=full_prompt),
  485. HumanMessage(content=f"请基于以上数据,生成一份针对{goal}的健康改善方案。"),
  486. ]
  487. response = await llm.ainvoke(messages)
  488. answer = response.content
  489. return answer
  490. # ===== 健康方案生成(结构化 JSON)=====
  491. class HealthPlanRequest(BaseModel):
  492. member_ids: Optional[str] = None
  493. dimensions: Optional[str] = None
  494. goal: str = ""
  495. family_id: Optional[int] = None
  496. class HealthPlanRegenerateRequest(BaseModel):
  497. section: str # nutrition | diet | exercise
  498. feedback: str = ""
  499. existing_section_content: str = ""
  500. member_ids: Optional[str] = None
  501. dimensions: Optional[str] = None
  502. goal: str = ""
  503. family_id: Optional[int] = None
  504. PLAN_SYSTEM_PROMPT = """你是一个专业的家庭健康方案规划师。根据用户提供的健康数据和目标,生成结构化的健康改善方案。
  505. ## 输出格式(必须输出合法 JSON,不要有其他内容)
  506. {
  507. "overview": "总体概述(100字以内,说明方案目标和核心策略)",
  508. "sections": [
  509. {
  510. "key": "nutrition",
  511. "title": "营养补充建议",
  512. "content": "Markdown 格式的详细内容",
  513. "items": [
  514. {"name": "产品名", "dosage": "用量", "timing": "服用时间", "reason": "推荐理由"}
  515. ]
  516. },
  517. {
  518. "key": "diet",
  519. "title": "饮食建议",
  520. "content": "Markdown 格式的餐饮建议",
  521. "items": [{"meal": "餐型", "food": "食物建议", "notes": "注意事项"}]
  522. },
  523. {
  524. "key": "exercise",
  525. "title": "运动计划",
  526. "content": "Markdown 格式的运动建议",
  527. "items": [{"type": "运动类型", "duration": "时长", "frequency": "频率", "notes": "注意事项"}]
  528. }
  529. ],
  530. "abnormal_indicators": [
  531. {"member": "姓名", "indicator": "指标名", "value": "值", "unit": "单位", "suggestion": "建议"}
  532. ]
  533. }
  534. ## 原则
  535. 1. 基于实际数据给出建议,不编造
  536. 2. 引用知识库内容时标注来源
  537. 3. 建议要具体可执行,避免空泛
  538. 4. 营养补充部分要具体到产品类型和用量
  539. 5. 严重健康问题建议咨询医生
  540. ## 画像数据使用指南
  541. 如果提供了用户的画像数据(五维评分、身体指标、心理指标等),请结合这些真实数据给出更有针对性的建议。特别关注异常指标(如睡眠不足、压力偏高、运动频率低等),在方案中明确说明这些指标的现状和改善方向。
  542. """
  543. REGENERATE_SECTION_SYSTEM_PROMPT = """你是一个家庭健康方案规划师。根据用户反馈重新生成指定部分内容。
  544. ## 输出格式
  545. 只输出新的 content 字段值(Markdown 格式字符串),不要输出 JSON 结构。
  546. ## 原则
  547. - 保持与原格式一致
  548. - 结合用户反馈进行修改
  549. - 建议要具体可执行"""
  550. async def _collect_plan_data(java: JavaClient, retriever: RagRetriever, member_ids_str: str, goal: str, dimensions: str):
  551. """统一数据收集逻辑"""
  552. member_ids = [m.strip() for m in member_ids_str.split(",") if m.strip()]
  553. # 1. 家庭成员信息
  554. members_info = []
  555. for uid_str in member_ids:
  556. ctx = await java.get_family_context(int(uid_str), "child_info")
  557. children = ctx.get("children", []) if isinstance(ctx, dict) else []
  558. for child in children:
  559. cid = str(child.get("用户ID", ""))
  560. if cid == uid_str:
  561. members_info.append({
  562. "id": cid,
  563. "name": child.get("姓名", f"成员{cid}"),
  564. "age": child.get("年龄", "未知"),
  565. })
  566. break
  567. if not any(m["id"] == uid_str for m in members_info):
  568. members_info.append({"id": uid_str, "name": f"成员{uid_str}", "age": "未知"})
  569. # 2. 健康指标
  570. all_indicators = []
  571. abnormal_list = []
  572. for member in members_info:
  573. reports = await java.get_member_reports(int(member["id"]))
  574. if not reports:
  575. continue
  576. latest = max(reports, key=lambda r: r.get("reportDate", ""))
  577. indicators = await java.get_report_indicators(latest.get("id"))
  578. for ind in indicators:
  579. ind["_member_name"] = member["name"]
  580. all_indicators.append(ind)
  581. # 3. 知识库
  582. kb_results = []
  583. queries = set()
  584. for ind in all_indicators:
  585. queries.add(ind.get("indicatorName", ""))
  586. queries.add(goal)
  587. if dimensions:
  588. queries.add(dimensions)
  589. for q in list(queries)[:8]:
  590. if q:
  591. results = await retriever.retrieve(q, k=3)
  592. kb_results.extend(results)
  593. return members_info, all_indicators, kb_results
  594. @router.post("/health/plan/generate", response_model=dict)
  595. async def health_plan_generate(req: HealthPlanRequest):
  596. """健康方案生成 — 返回结构化 JSON(总览+营养+饮食+运动)"""
  597. goal = req.goal or "改善健康状况"
  598. java = JavaClient()
  599. retriever = RagRetriever(collection_name="cfc_knowledge")
  600. llm = ChatOpenAI(
  601. model=settings.llm_model,
  602. api_key=settings.llm_api_key,
  603. base_url=settings.llm_base_url,
  604. temperature=0.3,
  605. )
  606. members_info, all_indicators, kb_results = await _collect_plan_data(java, retriever, req.member_ids or "", goal, req.dimensions or "")
  607. # 获取每个成员的画像数据
  608. for member in members_info:
  609. try:
  610. profile = await java.get_member_profile(int(member["id"]))
  611. member["profile"] = profile
  612. except Exception:
  613. member["profile"] = {}
  614. # 构建 prompt
  615. parts = [PLAN_SYSTEM_PROMPT]
  616. parts.append(f"\n## 用户目标\n{goal}")
  617. if req.dimensions:
  618. parts.append(f"\n## 重点关注维度\n{req.dimensions}")
  619. parts.append("\n## 家庭成员")
  620. for m in members_info:
  621. profile = m.get("profile", {})
  622. dims = profile.get("dimension_scores", {})
  623. body = profile.get("body_metrics", {})
  624. mind = profile.get("mind_metrics", {})
  625. parts.append(f"- {m['name']} (年龄: {m['age']})")
  626. if dims:
  627. parts.append(f" 五维评分: 身{dims.get('body','?')} 智{dims.get('wisdom','?')} 心{dims.get('mind','?')} 行{dims.get('action','?')} 富{dims.get('wealth','?')}")
  628. if body.get('sleep_dur_avg'):
  629. parts.append(f" 平均睡眠: {body['sleep_dur_avg']}小时/天")
  630. if mind.get('stress_avg'):
  631. parts.append(f" 平均压力: {mind['stress_avg']}/10")
  632. if body.get('exercise_count_week'):
  633. parts.append(f" 周运动: {body['exercise_count_week']}次")
  634. if all_indicators:
  635. parts.append("\n## 健康指标摘要")
  636. for ind in all_indicators[:15]:
  637. status = ind.get("status", "")
  638. if status in ("abnormal", "high", "low", "偏高", "偏低"):
  639. parts.append(f"- 【异常】{ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')} (状态: {status})")
  640. else:
  641. parts.append(f"- {ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')}")
  642. if kb_results:
  643. parts.append("\n## 知识库参考")
  644. for r in kb_results[:6]:
  645. title = r.get("metadata", {}).get("title", "")
  646. content = r.get("content", "")[:200]
  647. parts.append(f"---\n{title}\n{content}")
  648. full_prompt = "\n".join(parts)
  649. messages = [SystemMessage(content=full_prompt)]
  650. try:
  651. response = await llm.ainvoke(messages)
  652. answer = response.content
  653. # 解析 JSON
  654. import json
  655. try:
  656. start = answer.find("{")
  657. end = answer.rfind("}") + 1
  658. if start >= 0 and end > start:
  659. parsed = json.loads(answer[start:end])
  660. return {"success": True, "data": parsed}
  661. except Exception as e:
  662. logger.warning("解析方案 JSON 失败: %s", e)
  663. return {"success": True, "data": {"raw": answer, "overview": answer[:200]}, "parse_error": str(e)}
  664. except Exception as e:
  665. logger.error("方案生成失败: %s", e)
  666. return {"success": False, "error": str(e)}
  667. @router.post("/health/plan/regenerate-section", response_model=dict)
  668. async def health_plan_regenerate(req: HealthPlanRegenerateRequest):
  669. """重新生成方案的某一个 section"""
  670. java = JavaClient()
  671. llm = ChatOpenAI(
  672. model=settings.llm_model,
  673. api_key=settings.llm_api_key,
  674. base_url=settings.llm_base_url,
  675. temperature=0.3,
  676. )
  677. members_info, all_indicators, kb_results = await _collect_plan_data(java, None, req.member_ids or "", req.goal, req.dimensions or "")
  678. # 构建上下文
  679. ctx_parts = [f"目标: {req.goal}"]
  680. for m in members_info:
  681. ctx_parts.append(f"- {m['name']} (年龄: {m['age']})")
  682. for ind in all_indicators[:10]:
  683. if ind.get("status") in ("abnormal", "high", "low", "偏高", "偏低"):
  684. ctx_parts.append(f"- 【异常】{ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')}")
  685. prompt = REGENERATE_SECTION_SYSTEM_PROMPT
  686. prompt += f"\n\n## 当前 {req.section} 内容\n{req.existing_section_content[:500]}"
  687. prompt += f"\n\n## 用户反馈\n{req.feedback}"
  688. prompt += f"\n\n## 相关背景\n" + "\n".join(ctx_parts[:10])
  689. try:
  690. response = await llm.ainvoke([SystemMessage(content=prompt)])
  691. return {"success": True, "content": response.content}
  692. except Exception as e:
  693. logger.error("重新生成 section 失败: %s", e)
  694. return {"success": False, "error": str(e)}
  695. @router.post("/nutrition/send", response_model=DifyResponse)
  696. async def nutrition_send(req: DifyChatRequest):
  697. """AI 营养助手 — 基于健康报告的个性化营养建议"""
  698. query = _extract_query(req)
  699. if not query:
  700. raise HTTPException(status_code=400, detail="query 为空")
  701. graph = create_nutrition_graph()
  702. initial_state = {
  703. "query": query,
  704. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  705. "child_id": int(req.inputs.get("child_id")) if req.inputs.get("child_id") else None,
  706. "conversation_id": req.conversation_id or None,
  707. "context": _to_langgraph_context(req),
  708. "answer": None,
  709. "sources": [],
  710. "tasks": [],
  711. "messages": None,
  712. }
  713. config = {
  714. "configurable": {"thread_id": req.conversation_id or f"nutrition_{req.user_id}"},
  715. }
  716. result = await graph.ainvoke(initial_state, config)
  717. answer = result.get("answer") or ""
  718. sources = result.get("sources") or []
  719. metadata = {}
  720. if sources:
  721. metadata["sources"] = [
  722. {"title": s.get("name", ""), "type": s.get("type", "tool")}
  723. for s in sources
  724. ]
  725. return DifyResponse(
  726. id=f"nutrition-{uuid.uuid4().hex[:24]}",
  727. created=_now_ts(),
  728. model="langgraph-nutrition",
  729. choices=[
  730. DifyChoice(
  731. index=0,
  732. message={"role": "assistant", "content": answer},
  733. finish_reason="stop",
  734. )
  735. ],
  736. usage=DifyUsage(
  737. prompt_tokens=len(query.split()),
  738. completion_tokens=len(answer.split()),
  739. total_tokens=len(query.split()) + len(answer.split()),
  740. ),
  741. metadata=metadata,
  742. )