adapter.py 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159
  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 app.prompt_service import get_prompt
  13. from langchain_openai import ChatOpenAI
  14. from langchain_core.messages import SystemMessage, HumanMessage
  15. from app.models.health_plan import HealthPlanResponse, PlanTask
  16. import uuid
  17. import time
  18. import re
  19. import logging
  20. import json
  21. logger = logging.getLogger(__name__)
  22. router = APIRouter(prefix="/api/v1", tags=["adapter"])
  23. # ---- Dify-compatible request/response models ----
  24. class DifyMessage(BaseModel):
  25. role: str
  26. content: str
  27. class DifyChatRequest(BaseModel):
  28. # 前端 userId/conversationId 可能为数字,允许 int/float 自动转 str,避免 422
  29. model_config = ConfigDict(coerce_numbers_to_str=True)
  30. query: str = ""
  31. user: str = "anonymous"
  32. conversation_id: str = ""
  33. messages: list[DifyMessage] = []
  34. inputs: dict = {}
  35. response_mode: str = "blocking" # blocking / streaming
  36. user_id: str = ""
  37. bot_name: str = "AI管家"
  38. class DifyAnalysisRequest(BaseModel):
  39. # 前端 userId 可能为数字,允许 int/float 自动转 str,避免 422
  40. model_config = ConfigDict(coerce_numbers_to_str=True)
  41. report_id: Optional[int] = None
  42. user_id: Optional[str] = None
  43. focus: Optional[str] = None
  44. messages: list[DifyMessage] = []
  45. inputs: dict = {}
  46. class HealthCoachGenerateRequest(BaseModel):
  47. """Java 后端健康方案生成专用请求体"""
  48. family_id: Optional[int] = None
  49. member_ids: Optional[str] = None
  50. dimensions: Optional[str] = None
  51. goal: str = ""
  52. class DifyChoice(BaseModel):
  53. index: int
  54. message: dict
  55. finish_reason: str = "stop"
  56. class DifyUsage(BaseModel):
  57. prompt_tokens: int = 0
  58. completion_tokens: int = 0
  59. total_tokens: int = 0
  60. class DifyResponse(BaseModel):
  61. id: str
  62. object: str = "chat.completion"
  63. created: int
  64. model: str = "langgraph-cfc"
  65. choices: list[DifyChoice]
  66. usage: DifyUsage = DifyUsage()
  67. metadata: dict = {}
  68. # ---- Helpers ----
  69. def _now_ts() -> int:
  70. return int(time.time())
  71. def _extract_query(req: DifyChatRequest) -> str:
  72. if req.query:
  73. return req.query
  74. # fallback: take last user message
  75. for msg in reversed(req.messages):
  76. if msg.role == "user":
  77. return msg.content
  78. return ""
  79. def _to_langgraph_context(req: DifyChatRequest) -> dict:
  80. ctx = {}
  81. if isinstance(req.inputs, dict):
  82. ctx["child_id"] = req.inputs.get("child_id")
  83. ctx["report_id"] = req.inputs.get("report_id")
  84. ctx["family_id"] = req.inputs.get("family_id")
  85. return ctx
  86. # ---- Dify-compatible endpoints ----
  87. @router.post("/chat/completion", response_model=DifyResponse)
  88. async def chat_completion(req: DifyChatRequest):
  89. query = _extract_query(req)
  90. if not query:
  91. raise HTTPException(status_code=400, detail="query 为空")
  92. graph = create_chat_graph()
  93. initial_state = {
  94. "query": query,
  95. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  96. "conversation_id": req.conversation_id or None,
  97. "intent": None,
  98. "context": _to_langgraph_context(req),
  99. "messages": None,
  100. "answer": None,
  101. "tasks": [],
  102. "sources": [],
  103. }
  104. config = {
  105. "configurable": {"thread_id": req.conversation_id or str(req.user_id or "default")},
  106. }
  107. result = await graph.ainvoke(initial_state, config)
  108. answer = result.get("answer") or ""
  109. return DifyResponse(
  110. id=f"chatcmpl-{uuid.uuid4().hex[:24]}",
  111. created=_now_ts(),
  112. choices=[
  113. DifyChoice(
  114. index=0,
  115. message={"role": "assistant", "content": answer},
  116. finish_reason="stop",
  117. )
  118. ],
  119. usage=DifyUsage(
  120. prompt_tokens=len(query.split()),
  121. completion_tokens=len(answer.split()),
  122. total_tokens=len(query.split()) + len(answer.split()),
  123. ),
  124. )
  125. @router.post("/analysis/run", response_model=DifyResponse)
  126. async def analysis_run(req: DifyAnalysisRequest):
  127. graph = create_analysis_graph()
  128. report_id = req.report_id
  129. if report_id is None and isinstance(req.inputs, dict):
  130. report_id = req.inputs.get("report_id")
  131. initial_state = {
  132. "report_id": report_id,
  133. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  134. "focus": req.focus,
  135. "report_data": None,
  136. "survey_data": None,
  137. "dimension_scores": None,
  138. "analysis": None,
  139. "recommendations": [],
  140. }
  141. result = await graph.ainvoke(initial_state, {})
  142. analysis = result.get("analysis") or ""
  143. recommendations = result.get("recommendations") or []
  144. content = analysis
  145. if recommendations:
  146. content += "\n\n建议:\n" + "\n".join(f"- {r}" for r in recommendations)
  147. return DifyResponse(
  148. id=f"analysis-{uuid.uuid4().hex[:24]}",
  149. created=_now_ts(),
  150. choices=[
  151. DifyChoice(
  152. index=0,
  153. message={"role": "assistant", "content": content},
  154. finish_reason="stop",
  155. )
  156. ],
  157. usage=DifyUsage(
  158. prompt_tokens=len((req.focus or "").split()),
  159. completion_tokens=len(content.split()),
  160. total_tokens=len((req.focus or "").split()) + len(content.split()),
  161. ),
  162. )
  163. @router.post("/health/coach", response_model=DifyResponse)
  164. async def health_coach(req: DifyChatRequest):
  165. query = _extract_query(req)
  166. if not query:
  167. raise HTTPException(status_code=400, detail="query 为空")
  168. graph = create_health_coach_graph()
  169. initial_state = {
  170. "query": query,
  171. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  172. "child_id": int(req.inputs.get("child_id")) if req.inputs.get("child_id") else None,
  173. "conversation_id": req.conversation_id or None,
  174. "context": _to_langgraph_context(req),
  175. "answer": None,
  176. "sources": [],
  177. "memory_messages": None,
  178. }
  179. config = {
  180. "configurable": {"thread_id": req.conversation_id or f"health_{req.user_id}"},
  181. }
  182. result = await graph.ainvoke(initial_state, config)
  183. answer = result.get("answer") or ""
  184. sources = result.get("sources") or []
  185. metadata = {}
  186. if sources:
  187. metadata["sources"] = [
  188. {"title": s.get("title", ""), "type": s.get("type", "knowledge")}
  189. for s in sources
  190. ]
  191. return DifyResponse(
  192. id=f"health-{uuid.uuid4().hex[:24]}",
  193. created=_now_ts(),
  194. model="langgraph-health-coach",
  195. choices=[
  196. DifyChoice(
  197. index=0,
  198. message={"role": "assistant", "content": answer},
  199. finish_reason="stop",
  200. )
  201. ],
  202. usage=DifyUsage(
  203. prompt_tokens=len(query.split()),
  204. completion_tokens=len(answer.split()),
  205. total_tokens=len(query.split()) + len(answer.split()),
  206. ),
  207. metadata=metadata,
  208. )
  209. @router.post("/health/butler", response_model=DifyResponse)
  210. async def health_butler(req: DifyChatRequest):
  211. """AI 健康管家 — 基于健康知识库检索 + 个性化建议 + 任务生成"""
  212. query = _extract_query(req)
  213. if not query:
  214. raise HTTPException(status_code=400, detail="query 为空")
  215. graph = create_health_butler_graph()
  216. # 从 inputs 中提取健康管家需要的上下文
  217. inputs = req.inputs or {}
  218. family_id = inputs.get("family_id")
  219. child_id = inputs.get("child_id")
  220. report_id = inputs.get("report_id")
  221. focus = inputs.get("focus")
  222. # conversation_id 用于 checkpointer thread
  223. thread_id = req.conversation_id or f"butler_{req.user_id or 'anon'}"
  224. initial_state = {
  225. "query": query,
  226. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  227. "conversation_id": thread_id,
  228. "family_id": int(family_id) if family_id else None,
  229. "child_id": int(child_id) if child_id else None,
  230. "report_id": int(report_id) if report_id else None,
  231. "focus": focus,
  232. "kb_context": None,
  233. "knowledge_results": [],
  234. "answer": None,
  235. "tasks": [],
  236. "sources": [],
  237. "messages": None,
  238. }
  239. config = {
  240. "configurable": {"thread_id": thread_id},
  241. }
  242. result = await graph.ainvoke(initial_state, config)
  243. answer = result.get("answer") or ""
  244. # 从回答中提取 TASK 标记
  245. tasks = result.get("tasks") or []
  246. sources = result.get("sources") or []
  247. metadata = {"tasks": tasks, "sources": sources}
  248. return DifyResponse(
  249. id=f"butler-{uuid.uuid4().hex[:24]}",
  250. created=_now_ts(),
  251. model="langgraph-health-butler",
  252. choices=[
  253. DifyChoice(
  254. index=0,
  255. message={"role": "assistant", "content": answer},
  256. finish_reason="stop",
  257. )
  258. ],
  259. usage=DifyUsage(
  260. prompt_tokens=len(query.split()),
  261. completion_tokens=len(answer.split()),
  262. total_tokens=len(query.split()) + len(answer.split()),
  263. ),
  264. metadata=metadata,
  265. )
  266. def _parse_normal_range(ref_range: str) -> tuple[float | None, float | None]:
  267. """解析正常范围字符串,返回 (下限, 上限),如 '30-100' → (30, 100), '<5' → (None, 5)"""
  268. if not ref_range:
  269. return None, None
  270. ref_range = ref_range.strip()
  271. m = re.match(r'([<>]=?)\s*([\d.]+)', ref_range)
  272. if m:
  273. op, val = m.group(1), float(m.group(2))
  274. if op.startswith('>'):
  275. return (val, None)
  276. else:
  277. return (None, val)
  278. m = re.match(r'([\d.]+)\s*[-~]\s*([\d.]+)', ref_range)
  279. if m:
  280. return (float(m.group(1)), float(m.group(2)))
  281. return None, None
  282. def _is_abnormal(status: str, value: float | None, low: float | None, high: float | None) -> bool:
  283. """判断指标是否异常:优先用 status 字段,否则用数值与范围比较"""
  284. if status and status not in ("正常", "正常范围", "未检出", ""):
  285. return True
  286. if value is not None and low is not None and high is not None:
  287. return value < low or value > high
  288. return False
  289. def _try_parse_value(raw: str) -> float | None:
  290. if not raw:
  291. return None
  292. raw = raw.strip().replace(",", "").replace(" ", "")
  293. try:
  294. return float(raw)
  295. except ValueError:
  296. return None
  297. async def _search_knowledge(retriever: RagRetriever, query: str, k: int = 3) -> list[dict]:
  298. """从知识库检索相关内容"""
  299. try:
  300. return await retriever.retrieve(query, k=k)
  301. except Exception as e:
  302. logger.warning("知识库检索失败: %s", e)
  303. return []
  304. @router.post("/health/coach/generate")
  305. async def health_coach_generate(req: HealthCoachGenerateRequest):
  306. """健康方案生成 — 选人→拉指标→查知识库→LLM"""
  307. goal = req.goal or "改善健康状况"
  308. member_ids_str = req.member_ids or ""
  309. dimensions = req.dimensions or ""
  310. member_ids = [m.strip() for m in member_ids_str.split(",") if m.strip()]
  311. java = JavaClient()
  312. retriever = RagRetriever(collection_name="cfc_knowledge")
  313. llm = ChatOpenAI(
  314. model=settings.llm_model,
  315. api_key=settings.llm_api_key,
  316. base_url=settings.llm_base_url,
  317. temperature=0.3,
  318. )
  319. # ====== 1. 获取家庭成员信息 ======
  320. members_info = []
  321. if member_ids:
  322. # 从 context 获取家庭信息
  323. for uid_str in member_ids:
  324. uid = int(uid_str)
  325. ctx = await java.get_family_context(uid, "child_info")
  326. children = ctx.get("children", []) if isinstance(ctx, dict) else []
  327. for child in children:
  328. child_id = str(child.get("用户ID", ""))
  329. if child_id in member_ids:
  330. members_info.append({
  331. "id": child_id,
  332. "name": child.get("姓名", f"成员{child_id}"),
  333. "age": child.get("年龄", "未知"),
  334. "energy": child.get("能量", 0),
  335. })
  336. break
  337. # 如果 context 没找到,用基本信息兜底
  338. if not any(m["id"] == uid_str for m in members_info):
  339. members_info.append({"id": uid_str, "name": f"成员{uid_str}", "age": "未知", "energy": 0})
  340. else:
  341. members_info.append({"id": "0", "name": "用户", "age": "未知", "energy": 0})
  342. # ====== 2. 获取每个成员的指标数据 ======
  343. all_indicators = []
  344. for member in members_info:
  345. uid = int(member["id"])
  346. reports = await java.get_member_reports(uid)
  347. if not reports:
  348. logger.info("成员 %s 无健康报告", member["id"])
  349. continue
  350. latest = max(reports, key=lambda r: r.get("reportDate", ""))
  351. report_id = latest.get("id")
  352. member["latest_report_id"] = report_id
  353. member["report_date"] = latest.get("reportDate", "")
  354. member["overall_score"] = latest.get("overallScore", "未知")
  355. indicators = await java.get_report_indicators(report_id)
  356. for ind in indicators:
  357. ind["_member_id"] = member["id"]
  358. ind["_member_name"] = member["name"]
  359. all_indicators.append(ind)
  360. # ====== 3. 获取指标定义+正常范围 ======
  361. known_indicators = {}
  362. for ind in all_indicators:
  363. name = ind.get("indicatorName", "").strip()
  364. if not name or name in known_indicators:
  365. continue
  366. kb = await java.query_health_knowledge("indicator", name)
  367. if not kb:
  368. kb = await java.query_health_knowledge("bacteria", name)
  369. if not kb:
  370. kb = await java.query_health_knowledge("nutrient", name)
  371. if kb:
  372. known_indicators[name] = kb
  373. # ====== 4. 识别异常指标 ======
  374. abnormal_list = []
  375. normal_list = []
  376. for ind in all_indicators:
  377. name = ind.get("indicatorName", "")
  378. raw_val = ind.get("indicatorValue", "")
  379. status = ind.get("status", "")
  380. unit = ind.get("unit", "")
  381. ref_range = ind.get("refRange", "")
  382. # 优先用知识库中的正常范围
  383. kb = known_indicators.get(name)
  384. if kb and kb.get("normalRange"):
  385. ref_range = kb.get("normalRange", ref_range)
  386. low, high = _parse_normal_range(ref_range)
  387. value = _try_parse_value(raw_val)
  388. is_abnormal = _is_abnormal(status, value, low, high)
  389. entry = {
  390. "member": ind.get("_member_name", ""),
  391. "indicator": name,
  392. "value": raw_val,
  393. "unit": unit,
  394. "ref_range": ref_range,
  395. "status": status,
  396. "is_abnormal": is_abnormal,
  397. "description": kb.get("description", "") if kb else "",
  398. "suggestion": kb.get("suggestion", "") if kb else "",
  399. }
  400. if is_abnormal:
  401. abnormal_list.append(entry)
  402. else:
  403. normal_list.append(entry)
  404. # ====== 5. 检索知识库 ======
  405. kb_results = []
  406. # 5a. 异常指标检索
  407. abnormal_queries = set()
  408. for ind in abnormal_list:
  409. abnormal_queries.add(ind["indicator"])
  410. for q in list(abnormal_queries)[:5]:
  411. results = await _search_knowledge(retriever, f"{q} 改善建议", k=3)
  412. kb_results.extend(results)
  413. # 5b. 用户需求检索
  414. goal_results = await _search_knowledge(retriever, goal, k=5)
  415. kb_results.extend(goal_results)
  416. # 5c. 维度检索
  417. if dimensions:
  418. dim_results = await _search_knowledge(retriever, dimensions, k=3)
  419. kb_results.extend(dim_results)
  420. # 去重
  421. seen_content = set()
  422. deduped_kb = []
  423. for r in kb_results:
  424. h = r.get("content", "")[:100]
  425. if h not in seen_content:
  426. seen_content.add(h)
  427. deduped_kb.append(r)
  428. # ====== 6. 组装结构化 Prompt ======
  429. prompt_parts = []
  430. # 系统提示
  431. prompt_parts.append(await get_prompt("health_plan_text") or TEXT_PLAN_SYSTEM_PROMPT)
  432. # 目标与维度
  433. prompt_parts.append(f"\n## 用户目标\n{goal}")
  434. if dimensions:
  435. prompt_parts.append(f"\n## 重点关注维度\n{dimensions}")
  436. # 成员信息
  437. prompt_parts.append("\n## 家庭成员")
  438. for m in members_info:
  439. scores = f"健康评分: {m.get('overall_score', '未知')}" if m.get('overall_score') else ""
  440. report = f"最近报告: {m.get('report_date', '无')}" if m.get('report_date') else ""
  441. prompt_parts.append(f"- {m['name']} (年龄: {m['age']}) {scores} {report}")
  442. # 异常指标
  443. if abnormal_list:
  444. prompt_parts.append("\n## 异常指标")
  445. for ind in abnormal_list:
  446. parts = [f"- {ind['member']} - {ind['indicator']}: {ind['value']}{ind['unit']} (参考范围: {ind['ref_range']})"]
  447. if ind['description']:
  448. parts.append(f" 说明: {ind['description']}")
  449. if ind['suggestion']:
  450. parts.append(f" 建议: {ind['suggestion']}")
  451. prompt_parts.append("\n".join(parts))
  452. # 正常指标
  453. if normal_list:
  454. prompt_parts.append("\n## 正常指标(参考)")
  455. normal_summary = [f"- {ind['indicator']}: {ind['value']}{ind['unit']} (正常)" for ind in normal_list[:10]]
  456. prompt_parts.extend(normal_summary)
  457. # 知识库参考
  458. if deduped_kb:
  459. prompt_parts.append("\n## 知识库参考(可引用)")
  460. for r in deduped_kb[:8]:
  461. title = r.get("metadata", {}).get("title", "")
  462. content = r.get("content", "")[:300]
  463. prompt_parts.append(f"---\n{title}\n{content}")
  464. full_prompt = "\n".join(prompt_parts)
  465. # ====== 7. 调用 LLM ======
  466. request_template = await get_prompt("health_plan_text_request") or "请基于以上数据,生成一份针对{goal}的健康改善方案。"
  467. try:
  468. request_text = request_template.format(goal=goal)
  469. except (KeyError, IndexError, ValueError):
  470. logger.warning("Java 配置的 health_plan_text_request 模板缺少占位符,回退本地模板")
  471. request_text = "请基于以上数据,生成一份针对{goal}的健康改善方案。".format(goal=goal)
  472. messages = [
  473. SystemMessage(content=full_prompt),
  474. HumanMessage(content=request_text),
  475. ]
  476. response = await llm.ainvoke(messages)
  477. answer = response.content
  478. return answer
  479. # ===== 健康方案生成(结构化 JSON)=====
  480. class HealthPlanRequest(BaseModel):
  481. member_ids: Optional[str] = None
  482. dimensions: Optional[str] = None
  483. goal: str = ""
  484. family_id: Optional[int] = None
  485. class HealthPlanRegenerateRequest(BaseModel):
  486. section: str # nutrition | diet | exercise
  487. feedback: str = ""
  488. existing_section_content: str = ""
  489. member_ids: Optional[str] = None
  490. dimensions: Optional[str] = None
  491. goal: str = ""
  492. family_id: Optional[int] = None
  493. TEXT_PLAN_SYSTEM_PROMPT = """你是一个专业的家庭健康方案生成器。请根据用户提供的健康数据,生成一份结构化的健康改善方案。
  494. 输出格式要求:
  495. ## 方案概述
  496. [简要说明方案的总体目标和适用对象]
  497. ## 成员健康概况
  498. [每个成员的关键指标摘要]
  499. ## 需要关注的异常指标
  500. [列出异常指标及对应的知识库建议]
  501. ## 改善方案
  502. ### 1. 饮食调整
  503. [具体、可执行的饮食建议]
  504. ### 2. 生活习惯
  505. [具体、可执行的生活习惯建议]
  506. ### 3. 补充建议
  507. [如需补充营养素或益生菌,给出具体建议]
  508. ### 4. 跟踪建议
  509. [建议定期复查的指标和频率]
  510. ## 注意事项
  511. [禁忌、提醒等]
  512. 请基于实际数据给出建议,不要编造科学依据。引用知识库内容时标注来源。"""
  513. PLAN_SYSTEM_PROMPT = """你是一个专业的家庭健康方案规划师。根据用户提供的健康数据和目标,生成结构化的健康改善方案。
  514. ## 输出格式(必须输出合法 JSON,不要有其他内容)
  515. {
  516. "overview": "总体概述(100字以内,说明方案目标和核心策略)",
  517. "sections": [
  518. {
  519. "key": "nutrition",
  520. "title": "营养补充建议",
  521. "content": "Markdown 格式的详细内容",
  522. "items": [
  523. {"name": "产品名", "dosage": "用量", "timing": "服用时间", "reason": "推荐理由"}
  524. ],
  525. "tasks": [
  526. {
  527. "action_type": "buy",
  528. "title": "购买维生素D3补充剂",
  529. "dimension": "wealth",
  530. "frequency": "once",
  531. "notes": "每日一粒,随餐服用"
  532. }
  533. ]
  534. },
  535. {
  536. "key": "diet",
  537. "title": "饮食建议",
  538. "content": "Markdown 格式的餐饮建议",
  539. "items": [{"meal": "餐型", "food": "食物建议", "notes": "注意事项"}],
  540. "tasks": [
  541. {
  542. "action_type": "diet",
  543. "title": "早餐增加高蛋白与膳食纤维",
  544. "dimension": "body",
  545. "frequency": "daily",
  546. "notes": ""
  547. }
  548. ]
  549. },
  550. {
  551. "key": "exercise",
  552. "title": "运动计划",
  553. "content": "Markdown 格式的运动建议",
  554. "items": [{"type": "运动类型", "duration": "时长", "frequency": "频率", "notes": "注意事项"}],
  555. "tasks": [
  556. {
  557. "action_type": "exercise",
  558. "title": "每周3次有氧运动,每次30分钟",
  559. "dimension": "body",
  560. "frequency": "daily",
  561. "notes": ""
  562. }
  563. ]
  564. }
  565. ],
  566. "abnormal_indicators": [
  567. {"member": "姓名", "indicator": "指标名", "value": "值", "unit": "单位", "suggestion": "建议"}
  568. ]
  569. }
  570. ## tasks 字段约定
  571. - 每个 section 的 `tasks` 是该 section 中"可执行的行动项"列表,与 `content`(人类可读 Markdown)分离。
  572. - `action_type` 取值仅限:`buy`(购买/补充产品)、`read`(阅读)、`exercise`(运动)、`checkin`(打卡/记录)、`diet`(饮食)、`activity`(活动/社交)。
  573. - `dimension` 取值仅限五维:`body`/`mind`/`wisdom`/`action`/`wealth`。
  574. - `frequency`:`once`=一次性任务;`daily`=每日重复任务。
  575. - `title` 是最终写入任务系统的标题,必须是**具体可执行的动作**,不要写纯原理/机制描述。
  576. - 若某 section 没有可执行的行动项,`tasks` 输出空数组 `[]`。
  577. ## 原则
  578. 1. 基于实际数据给出建议,不编造
  579. 2. 引用知识库内容时标注来源
  580. 3. 建议要具体可执行,避免空泛
  581. 4. 营养补充部分要具体到产品类型和用量
  582. 5. 严重健康问题建议咨询医生
  583. 6. 当问卷中某项关键信息缺失或不确定(如抗生素用药史、排便性状、过敏表现、饮食结构不明确)时,应在方案末尾明确提示用户:「为了获得更精准的饮食建议,建议您进行肠道菌群检测(GI Navigator 或其他权威检测),以获取确切的菌群组成数据」。
  584. ## 画像数据使用指南
  585. 如果提供了用户的画像数据(五维评分、身体指标、心理指标等),请结合这些真实数据给出更有针对性的建议。特别关注异常指标(如睡眠不足、压力偏高、运动频率低等),在方案中明确说明这些指标的现状和改善方向。
  586. """
  587. REGENERATE_SECTION_SYSTEM_PROMPT = """你是一个家庭健康方案规划师。根据用户反馈重新生成指定部分内容。
  588. ## 输出格式(必须输出合法 JSON,不要有其他内容)
  589. {
  590. "content": "重新生成的 Markdown 内容",
  591. "tasks": [
  592. {
  593. "action_type": "buy|read|exercise|checkin|diet|activity",
  594. "title": "可执行任务标题",
  595. "dimension": "body|mind|wisdom|action|wealth",
  596. "frequency": "once|daily",
  597. "notes": "补充说明"
  598. }
  599. ]
  600. }
  601. ## tasks 约定
  602. - action_type 取值:buy/read/exercise/checkin/diet/activity
  603. - dimension 取值:body/mind/wisdom/action/wealth
  604. - frequency:once=一次性;daily=每日重复
  605. - 无行动项时 tasks 输出 []
  606. ## 原则
  607. - 保持与原格式一致
  608. - 结合用户反馈进行修改
  609. - 建议要具体可执行"""
  610. def _format_diet_preferences(dp: dict) -> str:
  611. if not dp:
  612. return ""
  613. parts = []
  614. def _list(v):
  615. if not v:
  616. return ""
  617. if isinstance(v, list):
  618. return ", ".join(str(x) for x in v)
  619. try:
  620. parsed = json.loads(v)
  621. if isinstance(parsed, list):
  622. return ", ".join(str(x) for x in parsed)
  623. except Exception:
  624. pass
  625. return str(v)
  626. if dp.get("allergies"):
  627. parts.append(f"过敏原:{_list(dp['allergies'])}")
  628. if dp.get("absoluteAvoid"):
  629. parts.append(f"忌口:{_list(dp['absoluteAvoid'])}")
  630. if dp.get("mustEat"):
  631. parts.append(f"必吃:{_list(dp['mustEat'])}")
  632. if dp.get("religiousDiet") and dp["religiousDiet"] not in ("none", None, ""):
  633. parts.append(f"宗教饮食:{dp['religiousDiet']}")
  634. if dp.get("spiceLevel"):
  635. parts.append(f"辣度:{dp['spiceLevel']}")
  636. if dp.get("noPreference"):
  637. parts.append("无特殊饮食偏好")
  638. if dp.get("dietStructure"):
  639. try:
  640. ds = json.loads(dp["dietStructure"])
  641. if isinstance(ds, dict):
  642. if ds.get("vegetableFruit"):
  643. parts.append(f"每日蔬果量:{ds['vegetableFruit']}")
  644. if ds.get("dairy"):
  645. parts.append(f"乳制品:{ds['dairy']}")
  646. if ds.get("sugaryDrinks"):
  647. parts.append(f"含糖饮料:{ds['sugaryDrinks']}")
  648. except Exception:
  649. parts.append(f"饮食结构:{dp['dietStructure']}")
  650. return "; ".join(parts)
  651. def _format_health_status(hs: dict) -> str:
  652. if not hs:
  653. return ""
  654. def _arr(v):
  655. if not v:
  656. return ""
  657. if isinstance(v, list):
  658. return ", ".join(str(x.get("name", x)) if isinstance(x, dict) else str(x) for x in v)
  659. try:
  660. parsed = json.loads(v)
  661. if isinstance(parsed, list):
  662. return ", ".join(str(x.get("name", x)) if isinstance(x, dict) else str(x) for x in parsed)
  663. except Exception:
  664. pass
  665. return v
  666. out = []
  667. if hs.get("heightCm"):
  668. out.append(f"身高{hs['heightCm']}cm")
  669. if hs.get("weightKg"):
  670. out.append(f"体重{hs['weightKg']}kg")
  671. if hs.get("bloodPressure"):
  672. out.append(f"血压{hs['bloodPressure']}")
  673. if hs.get("bloodGlucose"):
  674. out.append(f"空腹血糖{hs['bloodGlucose']}mmol/L")
  675. lipids = _arr(hs.get("bloodLipids"))
  676. if lipids:
  677. out.append(f"血脂:{lipids}")
  678. diseases = _arr(hs.get("diseaseHistory"))
  679. if diseases:
  680. out.append(f"疾病史:{diseases}")
  681. meds = _arr(hs.get("medications"))
  682. if meds:
  683. out.append(f"用药:{meds}")
  684. if hs.get("notes"):
  685. out.append(f"备注:{hs['notes']}")
  686. # 生活方式与肠道健康(心情/睡眠/排便/抗生素/过敏表现)
  687. if hs.get("lifestyle"):
  688. try:
  689. ls = json.loads(hs["lifestyle"])
  690. parts = []
  691. if isinstance(ls, dict):
  692. if ls.get("mood"):
  693. parts.append(f"心情状态:{ls['mood']}")
  694. if ls.get("sleepDuration"):
  695. parts.append(f"睡眠时长:{ls['sleepDuration']}")
  696. if ls.get("bedtime"):
  697. parts.append(f"入睡时间:{ls['bedtime']}")
  698. if ls.get("bowelFrequency"):
  699. parts.append(f"排便频率:{ls['bowelFrequency']}")
  700. if ls.get("bowelForm"):
  701. parts.append(f"粪便性状:{ls['bowelForm']}")
  702. if ls.get("antibioticHistory"):
  703. parts.append(f"近2年抗生素用药史:{ls['antibioticHistory']}")
  704. if ls.get("allergySymptoms") and ls["allergySymptoms"]:
  705. sym = ", ".join(ls["allergySymptoms"]) if isinstance(ls["allergySymptoms"], list) else str(ls["allergySymptoms"])
  706. parts.append(f"过敏表现:{sym}")
  707. if parts:
  708. out.append("生活方式:" + "; ".join(parts))
  709. except Exception:
  710. out.append(f"生活方式:{hs['lifestyle']}")
  711. return "; ".join(out)
  712. async def _collect_plan_data(java: JavaClient, retriever: RagRetriever, member_ids_str: str, goal: str, dimensions: str):
  713. """统一数据收集逻辑"""
  714. member_ids = [m.strip() for m in member_ids_str.split(",") if m.strip()]
  715. # 1. 家庭成员信息
  716. members_info = []
  717. for uid_str in member_ids:
  718. ctx = await java.get_family_context(int(uid_str), "child_info")
  719. children = ctx.get("children", []) if isinstance(ctx, dict) else []
  720. for child in children:
  721. cid = str(child.get("用户ID", ""))
  722. if cid == uid_str:
  723. members_info.append({
  724. "id": cid,
  725. "name": child.get("姓名", f"成员{cid}"),
  726. "age": child.get("年龄", "未知"),
  727. })
  728. break
  729. if not any(m["id"] == uid_str for m in members_info):
  730. members_info.append({"id": uid_str, "name": f"成员{uid_str}", "age": "未知"})
  731. # 2. 健康指标
  732. all_indicators = []
  733. abnormal_list = []
  734. for member in members_info:
  735. reports = await java.get_member_reports(int(member["id"]))
  736. if not reports:
  737. continue
  738. latest = max(reports, key=lambda r: r.get("reportDate", ""))
  739. indicators = await java.get_report_indicators(latest.get("id"))
  740. for ind in indicators:
  741. ind["_member_name"] = member["name"]
  742. all_indicators.append(ind)
  743. # 2.5 健康自检问卷(health_status;未填写为空 dict)
  744. for member in members_info:
  745. try:
  746. member["health_status"] = await java.get_member_health_status(int(member["id"]))
  747. except Exception:
  748. member["health_status"] = {}
  749. try:
  750. member["diet_preferences"] = await java.get_member_diet_preferences(int(member["id"]))
  751. except Exception:
  752. member["diet_preferences"] = {}
  753. # 3. 知识库
  754. kb_results = []
  755. queries = set()
  756. for ind in all_indicators:
  757. queries.add(ind.get("indicatorName", ""))
  758. queries.add(goal)
  759. if dimensions:
  760. queries.add(dimensions)
  761. for q in list(queries)[:8]:
  762. if q:
  763. results = await retriever.retrieve(q, k=3)
  764. kb_results.extend(results)
  765. return members_info, all_indicators, kb_results
  766. # 从 LLM 原始输出中提取 tasks 的正则兜底:在 content 文本里找形如
  767. # "1. 动作(动词/名词)..." 的行。以可识别的动作词开头视为潜在任务。
  768. _TASK_FALLBACK_RE = re.compile(r"^\s*(?:\d+[\.、)]|\-\s*)\s*"
  769. r"(?:(?:购买|购置|阅读|看|运动|锻炼|跑步|散步|打卡|记录|饮食|吃|少|多|活动|参加|亲子).*)$")
  770. def _parse_plan_response(answer: str) -> dict:
  771. """解析 LLM 原始输出为 HealthPlanResponse;非法 JSON 或校验失败时用正则兜底提取 tasks。
  772. 返回 dict:{"success": bool, "data": {...}, "error": str|None}
  773. """
  774. start = answer.find("{")
  775. end = answer.rfind("}") + 1
  776. if start >= 0 and end > start:
  777. try:
  778. parsed = json.loads(answer[start:end])
  779. resp = HealthPlanResponse.model_validate(parsed)
  780. return {"success": True, "data": resp.model_dump(), "error": None}
  781. except Exception as e:
  782. # 校验失败:尝试正则兜底
  783. fallback = _fallback_extract_tasks(parsed if isinstance(parsed, dict) else {})
  784. if fallback is not None:
  785. return {"success": True, "data": fallback, "error": str(e)}
  786. return {"success": False, "data": {"raw": answer, "overview": answer[:200]},
  787. "error": str(e)}
  788. return {"success": False, "data": {"raw": answer, "overview": answer[:200]},
  789. "error": "no JSON found"}
  790. def _fallback_extract_tasks(parsed: dict) -> Optional[dict]:
  791. """当 LLM 输出缺 tasks 或校验失败时,从各 section.content 用正则提取任务并回填。
  792. 任一 section 无 tasks 才触发;全部已含 tasks 则返回 None(表示无需兜底)。"""
  793. if not isinstance(parsed, dict):
  794. return None
  795. sections = parsed.get("sections")
  796. if not isinstance(sections, list) or not sections:
  797. return None
  798. changed = False
  799. for sec in sections:
  800. if not isinstance(sec, dict):
  801. continue
  802. tasks = sec.get("tasks")
  803. if isinstance(tasks, list) and tasks:
  804. # 检查 tasks 是否全部有效;如有无效项则视为缺失,触发 fallback
  805. try:
  806. for t in tasks:
  807. PlanTask.model_validate(t)
  808. continue # 全部有效,跳过
  809. except Exception:
  810. pass # 有无效项,继续执行 fallback
  811. content = sec.get("content", "")
  812. extracted = []
  813. for raw in content.split("\n"):
  814. line = raw.strip()
  815. if not line:
  816. continue
  817. m = _TASK_FALLBACK_RE.match(line)
  818. if not m:
  819. continue
  820. # 去掉行首编号/项目符号
  821. title = re.sub(r"^\s*(?:\d+[\.、)]|\-\s*)\s*", "", line).strip()
  822. if not title:
  823. continue
  824. action = _classify_action(title)
  825. if action is None:
  826. continue
  827. extracted.append({
  828. "action_type": action["action_type"],
  829. "title": title,
  830. "dimension": action["dimension"],
  831. "frequency": action["frequency"],
  832. "notes": "",
  833. })
  834. if extracted:
  835. sec["tasks"] = extracted
  836. changed = True
  837. if changed:
  838. return parsed
  839. return None
  840. def _classify_action(title: str) -> Optional[dict]:
  841. """按动作词分类,映射到 action_type + 五维维度 + 频率(与 Java classifyTaskLine 对齐)。"""
  842. if re.search(r"购买|购置|采购|下单|买入|囤|选购", title):
  843. return {"action_type": "buy", "dimension": "wealth", "frequency": "once"}
  844. if re.search(r"阅读|看|读书", title):
  845. return {"action_type": "read", "dimension": "wisdom", "frequency": "daily"}
  846. if re.search(r"运动|锻炼|跑步|散步|健身|瑜伽|拉伸", title):
  847. return {"action_type": "exercise", "dimension": "body", "frequency": "daily"}
  848. if re.search(r"打卡|记录|复盘|记", title):
  849. return {"action_type": "checkin", "dimension": "mind", "frequency": "daily"}
  850. if re.search(r"饮食|吃|少|多|餐|营养|水", title):
  851. return {"action_type": "diet", "dimension": "body", "frequency": "daily"}
  852. if re.search(r"活动|参加|亲子|社交|户外|游戏", title):
  853. return {"action_type": "activity", "dimension": "action", "frequency": "daily"}
  854. return None
  855. @router.post("/health/plan/generate", response_model=dict)
  856. async def health_plan_generate(req: HealthPlanRequest):
  857. """健康方案生成 — 返回结构化 JSON(总览+营养+饮食+运动)"""
  858. goal = req.goal or "改善健康状况"
  859. java = JavaClient()
  860. retriever = RagRetriever(collection_name="cfc_knowledge")
  861. llm = ChatOpenAI(
  862. model=settings.llm_model,
  863. api_key=settings.llm_api_key,
  864. base_url=settings.llm_base_url,
  865. temperature=0.3,
  866. )
  867. members_info, all_indicators, kb_results = await _collect_plan_data(java, retriever, req.member_ids or "", goal, req.dimensions or "")
  868. # 获取每个成员的画像数据
  869. for member in members_info:
  870. try:
  871. profile = await java.get_member_profile(int(member["id"]))
  872. member["profile"] = profile
  873. except Exception:
  874. member["profile"] = {}
  875. # 构建 prompt
  876. parts = [await get_prompt("health_plan") or PLAN_SYSTEM_PROMPT]
  877. parts.append(f"\n## 用户目标\n{goal}")
  878. if req.dimensions:
  879. parts.append(f"\n## 重点关注维度\n{req.dimensions}")
  880. parts.append("\n## 家庭成员")
  881. for m in members_info:
  882. profile = m.get("profile", {})
  883. dims = profile.get("dimension_scores", {})
  884. body = profile.get("body_metrics", {})
  885. mind = profile.get("mind_metrics", {})
  886. parts.append(f"- {m['name']} (年龄: {m['age']})")
  887. hs_text = _format_health_status(m.get("health_status"))
  888. if hs_text:
  889. parts.append(f" 健康自检: {hs_text}")
  890. dp_text = _format_diet_preferences(m.get("diet_preferences"))
  891. if dp_text:
  892. parts.append(f" 饮食偏好: {dp_text}")
  893. if dims:
  894. parts.append(f" 五维评分: 身{dims.get('body','?')} 智{dims.get('wisdom','?')} 心{dims.get('mind','?')} 行{dims.get('action','?')} 富{dims.get('wealth','?')}")
  895. if body.get('sleep_dur_avg'):
  896. parts.append(f" 平均睡眠: {body['sleep_dur_avg']}小时/天")
  897. if mind.get('stress_avg'):
  898. parts.append(f" 平均压力: {mind['stress_avg']}/10")
  899. if body.get('exercise_count_week'):
  900. parts.append(f" 周运动: {body['exercise_count_week']}次")
  901. if all_indicators:
  902. parts.append("\n## 健康指标摘要")
  903. for ind in all_indicators[:15]:
  904. status = ind.get("status", "")
  905. if status in ("abnormal", "high", "low", "偏高", "偏低"):
  906. parts.append(f"- 【异常】{ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')} (状态: {status})")
  907. else:
  908. parts.append(f"- {ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')}")
  909. if kb_results:
  910. parts.append("\n## 知识库参考")
  911. for r in kb_results[:6]:
  912. title = r.get("metadata", {}).get("title", "")
  913. content = r.get("content", "")[:200]
  914. parts.append(f"---\n{title}\n{content}")
  915. full_prompt = "\n".join(parts)
  916. messages = [SystemMessage(content=full_prompt)]
  917. try:
  918. response = await llm.ainvoke(messages)
  919. answer = response.content
  920. result = _parse_plan_response(answer)
  921. if result["success"]:
  922. return {"success": True, "data": result["data"], "parse_error": result["error"]}
  923. return {"success": True, "data": result["data"], "parse_error": result["error"]}
  924. except Exception as e:
  925. logger.error("方案生成失败: %s", e)
  926. return {"success": False, "error": str(e)}
  927. @router.post("/health/plan/regenerate-section", response_model=dict)
  928. async def health_plan_regenerate(req: HealthPlanRegenerateRequest):
  929. """重新生成方案的某一个 section"""
  930. java = JavaClient()
  931. llm = ChatOpenAI(
  932. model=settings.llm_model,
  933. api_key=settings.llm_api_key,
  934. base_url=settings.llm_base_url,
  935. temperature=0.3,
  936. )
  937. members_info, all_indicators, kb_results = await _collect_plan_data(java, None, req.member_ids or "", req.goal, req.dimensions or "")
  938. # 构建上下文
  939. ctx_parts = [f"目标: {req.goal}"]
  940. for m in members_info:
  941. ctx_parts.append(f"- {m['name']} (年龄: {m['age']})")
  942. hs_text = _format_health_status(m.get("health_status"))
  943. if hs_text:
  944. ctx_parts.append(f" 健康自检: {hs_text}")
  945. dp_text = _format_diet_preferences(m.get("diet_preferences"))
  946. if dp_text:
  947. ctx_parts.append(f" 饮食偏好: {dp_text}")
  948. for ind in all_indicators[:10]:
  949. if ind.get("status") in ("abnormal", "high", "low", "偏高", "偏低"):
  950. ctx_parts.append(f"- 【异常】{ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')}")
  951. prompt = await get_prompt("health_plan_regenerate") or REGENERATE_SECTION_SYSTEM_PROMPT
  952. prompt += f"\n\n## 当前 {req.section} 内容\n{req.existing_section_content[:500]}"
  953. prompt += f"\n\n## 用户反馈\n{req.feedback}"
  954. prompt += f"\n\n## 相关背景\n" + "\n".join(ctx_parts[:10])
  955. try:
  956. response = await llm.ainvoke([SystemMessage(content=prompt)])
  957. answer = response.content
  958. start = answer.find("{")
  959. end = answer.rfind("}") + 1
  960. content = answer
  961. tasks = []
  962. if start >= 0 and end > start:
  963. try:
  964. parsed = json.loads(answer[start:end])
  965. content = parsed.get("content") or answer
  966. raw_tasks = parsed.get("tasks") or []
  967. # 用 PlanTask 校验,非法条目丢弃
  968. for t in raw_tasks:
  969. try:
  970. pt = PlanTask.model_validate(t)
  971. tasks.append(pt.model_dump())
  972. except Exception:
  973. continue
  974. except Exception as e:
  975. logger.warning("解析重生成 section JSON 失败: %s", e)
  976. return {"success": True, "content": content, "tasks": tasks}
  977. except Exception as e:
  978. logger.error("重新生成 section 失败: %s", e)
  979. return {"success": False, "error": str(e)}
  980. @router.post("/nutrition/send", response_model=DifyResponse)
  981. async def nutrition_send(req: DifyChatRequest):
  982. """AI 营养助手 — 基于健康报告的个性化营养建议"""
  983. query = _extract_query(req)
  984. if not query:
  985. raise HTTPException(status_code=400, detail="query 为空")
  986. graph = create_nutrition_graph()
  987. initial_state = {
  988. "query": query,
  989. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  990. "child_id": int(req.inputs.get("child_id")) if req.inputs.get("child_id") else None,
  991. "conversation_id": req.conversation_id or None,
  992. "context": _to_langgraph_context(req),
  993. "answer": None,
  994. "sources": [],
  995. "tasks": [],
  996. "messages": None,
  997. }
  998. config = {
  999. "configurable": {"thread_id": req.conversation_id or f"nutrition_{req.user_id}"},
  1000. }
  1001. result = await graph.ainvoke(initial_state, config)
  1002. answer = result.get("answer") or ""
  1003. sources = result.get("sources") or []
  1004. metadata = {}
  1005. if sources:
  1006. metadata["sources"] = [
  1007. {"title": s.get("name", ""), "type": s.get("type", "tool")}
  1008. for s in sources
  1009. ]
  1010. return DifyResponse(
  1011. id=f"nutrition-{uuid.uuid4().hex[:24]}",
  1012. created=_now_ts(),
  1013. model="langgraph-nutrition",
  1014. choices=[
  1015. DifyChoice(
  1016. index=0,
  1017. message={"role": "assistant", "content": answer},
  1018. finish_reason="stop",
  1019. )
  1020. ],
  1021. usage=DifyUsage(
  1022. prompt_tokens=len(query.split()),
  1023. completion_tokens=len(answer.split()),
  1024. total_tokens=len(query.split()) + len(answer.split()),
  1025. ),
  1026. metadata=metadata,
  1027. )