adapter.py 36 KB

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