adapter.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029
  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. ## 画像数据使用指南
  584. 如果提供了用户的画像数据(五维评分、身体指标、心理指标等),请结合这些真实数据给出更有针对性的建议。特别关注异常指标(如睡眠不足、压力偏高、运动频率低等),在方案中明确说明这些指标的现状和改善方向。
  585. """
  586. REGENERATE_SECTION_SYSTEM_PROMPT = """你是一个家庭健康方案规划师。根据用户反馈重新生成指定部分内容。
  587. ## 输出格式(必须输出合法 JSON,不要有其他内容)
  588. {
  589. "content": "重新生成的 Markdown 内容",
  590. "tasks": [
  591. {
  592. "action_type": "buy|read|exercise|checkin|diet|activity",
  593. "title": "可执行任务标题",
  594. "dimension": "body|mind|wisdom|action|wealth",
  595. "frequency": "once|daily",
  596. "notes": "补充说明"
  597. }
  598. ]
  599. }
  600. ## tasks 约定
  601. - action_type 取值:buy/read/exercise/checkin/diet/activity
  602. - dimension 取值:body/mind/wisdom/action/wealth
  603. - frequency:once=一次性;daily=每日重复
  604. - 无行动项时 tasks 输出 []
  605. ## 原则
  606. - 保持与原格式一致
  607. - 结合用户反馈进行修改
  608. - 建议要具体可执行"""
  609. async def _collect_plan_data(java: JavaClient, retriever: RagRetriever, member_ids_str: str, goal: str, dimensions: str):
  610. """统一数据收集逻辑"""
  611. member_ids = [m.strip() for m in member_ids_str.split(",") if m.strip()]
  612. # 1. 家庭成员信息
  613. members_info = []
  614. for uid_str in member_ids:
  615. ctx = await java.get_family_context(int(uid_str), "child_info")
  616. children = ctx.get("children", []) if isinstance(ctx, dict) else []
  617. for child in children:
  618. cid = str(child.get("用户ID", ""))
  619. if cid == uid_str:
  620. members_info.append({
  621. "id": cid,
  622. "name": child.get("姓名", f"成员{cid}"),
  623. "age": child.get("年龄", "未知"),
  624. })
  625. break
  626. if not any(m["id"] == uid_str for m in members_info):
  627. members_info.append({"id": uid_str, "name": f"成员{uid_str}", "age": "未知"})
  628. # 2. 健康指标
  629. all_indicators = []
  630. abnormal_list = []
  631. for member in members_info:
  632. reports = await java.get_member_reports(int(member["id"]))
  633. if not reports:
  634. continue
  635. latest = max(reports, key=lambda r: r.get("reportDate", ""))
  636. indicators = await java.get_report_indicators(latest.get("id"))
  637. for ind in indicators:
  638. ind["_member_name"] = member["name"]
  639. all_indicators.append(ind)
  640. # 3. 知识库
  641. kb_results = []
  642. queries = set()
  643. for ind in all_indicators:
  644. queries.add(ind.get("indicatorName", ""))
  645. queries.add(goal)
  646. if dimensions:
  647. queries.add(dimensions)
  648. for q in list(queries)[:8]:
  649. if q:
  650. results = await retriever.retrieve(q, k=3)
  651. kb_results.extend(results)
  652. return members_info, all_indicators, kb_results
  653. # 从 LLM 原始输出中提取 tasks 的正则兜底:在 content 文本里找形如
  654. # "1. 动作(动词/名词)..." 的行。以可识别的动作词开头视为潜在任务。
  655. _TASK_FALLBACK_RE = re.compile(r"^\s*(?:\d+[\.、)]|\-\s*)\s*"
  656. r"(?:(?:购买|购置|阅读|看|运动|锻炼|跑步|散步|打卡|记录|饮食|吃|少|多|活动|参加|亲子).*)$")
  657. def _parse_plan_response(answer: str) -> dict:
  658. """解析 LLM 原始输出为 HealthPlanResponse;非法 JSON 或校验失败时用正则兜底提取 tasks。
  659. 返回 dict:{"success": bool, "data": {...}, "error": str|None}
  660. """
  661. start = answer.find("{")
  662. end = answer.rfind("}") + 1
  663. if start >= 0 and end > start:
  664. try:
  665. parsed = json.loads(answer[start:end])
  666. resp = HealthPlanResponse.model_validate(parsed)
  667. return {"success": True, "data": resp.model_dump(), "error": None}
  668. except Exception as e:
  669. # 校验失败:尝试正则兜底
  670. fallback = _fallback_extract_tasks(parsed if isinstance(parsed, dict) else {})
  671. if fallback is not None:
  672. return {"success": True, "data": fallback, "error": str(e)}
  673. return {"success": False, "data": {"raw": answer, "overview": answer[:200]},
  674. "error": str(e)}
  675. return {"success": False, "data": {"raw": answer, "overview": answer[:200]},
  676. "error": "no JSON found"}
  677. def _fallback_extract_tasks(parsed: dict) -> Optional[dict]:
  678. """当 LLM 输出缺 tasks 或校验失败时,从各 section.content 用正则提取任务并回填。
  679. 任一 section 无 tasks 才触发;全部已含 tasks 则返回 None(表示无需兜底)。"""
  680. if not isinstance(parsed, dict):
  681. return None
  682. sections = parsed.get("sections")
  683. if not isinstance(sections, list) or not sections:
  684. return None
  685. changed = False
  686. for sec in sections:
  687. if not isinstance(sec, dict):
  688. continue
  689. tasks = sec.get("tasks")
  690. if isinstance(tasks, list) and tasks:
  691. # 检查 tasks 是否全部有效;如有无效项则视为缺失,触发 fallback
  692. try:
  693. for t in tasks:
  694. PlanTask.model_validate(t)
  695. continue # 全部有效,跳过
  696. except Exception:
  697. pass # 有无效项,继续执行 fallback
  698. content = sec.get("content", "")
  699. extracted = []
  700. for raw in content.split("\n"):
  701. line = raw.strip()
  702. if not line:
  703. continue
  704. m = _TASK_FALLBACK_RE.match(line)
  705. if not m:
  706. continue
  707. # 去掉行首编号/项目符号
  708. title = re.sub(r"^\s*(?:\d+[\.、)]|\-\s*)\s*", "", line).strip()
  709. if not title:
  710. continue
  711. action = _classify_action(title)
  712. if action is None:
  713. continue
  714. extracted.append({
  715. "action_type": action["action_type"],
  716. "title": title,
  717. "dimension": action["dimension"],
  718. "frequency": action["frequency"],
  719. "notes": "",
  720. })
  721. if extracted:
  722. sec["tasks"] = extracted
  723. changed = True
  724. if changed:
  725. return parsed
  726. return None
  727. def _classify_action(title: str) -> Optional[dict]:
  728. """按动作词分类,映射到 action_type + 五维维度 + 频率(与 Java classifyTaskLine 对齐)。"""
  729. if re.search(r"购买|购置|采购|下单|买入|囤|选购", title):
  730. return {"action_type": "buy", "dimension": "wealth", "frequency": "once"}
  731. if re.search(r"阅读|看|读书", title):
  732. return {"action_type": "read", "dimension": "wisdom", "frequency": "daily"}
  733. if re.search(r"运动|锻炼|跑步|散步|健身|瑜伽|拉伸", title):
  734. return {"action_type": "exercise", "dimension": "body", "frequency": "daily"}
  735. if re.search(r"打卡|记录|复盘|记", title):
  736. return {"action_type": "checkin", "dimension": "mind", "frequency": "daily"}
  737. if re.search(r"饮食|吃|少|多|餐|营养|水", title):
  738. return {"action_type": "diet", "dimension": "body", "frequency": "daily"}
  739. if re.search(r"活动|参加|亲子|社交|户外|游戏", title):
  740. return {"action_type": "activity", "dimension": "action", "frequency": "daily"}
  741. return None
  742. @router.post("/health/plan/generate", response_model=dict)
  743. async def health_plan_generate(req: HealthPlanRequest):
  744. """健康方案生成 — 返回结构化 JSON(总览+营养+饮食+运动)"""
  745. goal = req.goal or "改善健康状况"
  746. java = JavaClient()
  747. retriever = RagRetriever(collection_name="cfc_knowledge")
  748. llm = ChatOpenAI(
  749. model=settings.llm_model,
  750. api_key=settings.llm_api_key,
  751. base_url=settings.llm_base_url,
  752. temperature=0.3,
  753. )
  754. members_info, all_indicators, kb_results = await _collect_plan_data(java, retriever, req.member_ids or "", goal, req.dimensions or "")
  755. # 获取每个成员的画像数据
  756. for member in members_info:
  757. try:
  758. profile = await java.get_member_profile(int(member["id"]))
  759. member["profile"] = profile
  760. except Exception:
  761. member["profile"] = {}
  762. # 构建 prompt
  763. parts = [await get_prompt("health_plan") or PLAN_SYSTEM_PROMPT]
  764. parts.append(f"\n## 用户目标\n{goal}")
  765. if req.dimensions:
  766. parts.append(f"\n## 重点关注维度\n{req.dimensions}")
  767. parts.append("\n## 家庭成员")
  768. for m in members_info:
  769. profile = m.get("profile", {})
  770. dims = profile.get("dimension_scores", {})
  771. body = profile.get("body_metrics", {})
  772. mind = profile.get("mind_metrics", {})
  773. parts.append(f"- {m['name']} (年龄: {m['age']})")
  774. if dims:
  775. parts.append(f" 五维评分: 身{dims.get('body','?')} 智{dims.get('wisdom','?')} 心{dims.get('mind','?')} 行{dims.get('action','?')} 富{dims.get('wealth','?')}")
  776. if body.get('sleep_dur_avg'):
  777. parts.append(f" 平均睡眠: {body['sleep_dur_avg']}小时/天")
  778. if mind.get('stress_avg'):
  779. parts.append(f" 平均压力: {mind['stress_avg']}/10")
  780. if body.get('exercise_count_week'):
  781. parts.append(f" 周运动: {body['exercise_count_week']}次")
  782. if all_indicators:
  783. parts.append("\n## 健康指标摘要")
  784. for ind in all_indicators[:15]:
  785. status = ind.get("status", "")
  786. if status in ("abnormal", "high", "low", "偏高", "偏低"):
  787. parts.append(f"- 【异常】{ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')} (状态: {status})")
  788. else:
  789. parts.append(f"- {ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')}")
  790. if kb_results:
  791. parts.append("\n## 知识库参考")
  792. for r in kb_results[:6]:
  793. title = r.get("metadata", {}).get("title", "")
  794. content = r.get("content", "")[:200]
  795. parts.append(f"---\n{title}\n{content}")
  796. full_prompt = "\n".join(parts)
  797. messages = [SystemMessage(content=full_prompt)]
  798. try:
  799. response = await llm.ainvoke(messages)
  800. answer = response.content
  801. result = _parse_plan_response(answer)
  802. if result["success"]:
  803. return {"success": True, "data": result["data"], "parse_error": result["error"]}
  804. return {"success": True, "data": result["data"], "parse_error": result["error"]}
  805. except Exception as e:
  806. logger.error("方案生成失败: %s", e)
  807. return {"success": False, "error": str(e)}
  808. @router.post("/health/plan/regenerate-section", response_model=dict)
  809. async def health_plan_regenerate(req: HealthPlanRegenerateRequest):
  810. """重新生成方案的某一个 section"""
  811. java = JavaClient()
  812. llm = ChatOpenAI(
  813. model=settings.llm_model,
  814. api_key=settings.llm_api_key,
  815. base_url=settings.llm_base_url,
  816. temperature=0.3,
  817. )
  818. members_info, all_indicators, kb_results = await _collect_plan_data(java, None, req.member_ids or "", req.goal, req.dimensions or "")
  819. # 构建上下文
  820. ctx_parts = [f"目标: {req.goal}"]
  821. for m in members_info:
  822. ctx_parts.append(f"- {m['name']} (年龄: {m['age']})")
  823. for ind in all_indicators[:10]:
  824. if ind.get("status") in ("abnormal", "high", "low", "偏高", "偏低"):
  825. ctx_parts.append(f"- 【异常】{ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')}")
  826. prompt = await get_prompt("health_plan_regenerate") or REGENERATE_SECTION_SYSTEM_PROMPT
  827. prompt += f"\n\n## 当前 {req.section} 内容\n{req.existing_section_content[:500]}"
  828. prompt += f"\n\n## 用户反馈\n{req.feedback}"
  829. prompt += f"\n\n## 相关背景\n" + "\n".join(ctx_parts[:10])
  830. try:
  831. response = await llm.ainvoke([SystemMessage(content=prompt)])
  832. answer = response.content
  833. start = answer.find("{")
  834. end = answer.rfind("}") + 1
  835. content = answer
  836. tasks = []
  837. if start >= 0 and end > start:
  838. try:
  839. parsed = json.loads(answer[start:end])
  840. content = parsed.get("content") or answer
  841. raw_tasks = parsed.get("tasks") or []
  842. # 用 PlanTask 校验,非法条目丢弃
  843. for t in raw_tasks:
  844. try:
  845. pt = PlanTask.model_validate(t)
  846. tasks.append(pt.model_dump())
  847. except Exception:
  848. continue
  849. except Exception as e:
  850. logger.warning("解析重生成 section JSON 失败: %s", e)
  851. return {"success": True, "content": content, "tasks": tasks}
  852. except Exception as e:
  853. logger.error("重新生成 section 失败: %s", e)
  854. return {"success": False, "error": str(e)}
  855. @router.post("/nutrition/send", response_model=DifyResponse)
  856. async def nutrition_send(req: DifyChatRequest):
  857. """AI 营养助手 — 基于健康报告的个性化营养建议"""
  858. query = _extract_query(req)
  859. if not query:
  860. raise HTTPException(status_code=400, detail="query 为空")
  861. graph = create_nutrition_graph()
  862. initial_state = {
  863. "query": query,
  864. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  865. "child_id": int(req.inputs.get("child_id")) if req.inputs.get("child_id") else None,
  866. "conversation_id": req.conversation_id or None,
  867. "context": _to_langgraph_context(req),
  868. "answer": None,
  869. "sources": [],
  870. "tasks": [],
  871. "messages": None,
  872. }
  873. config = {
  874. "configurable": {"thread_id": req.conversation_id or f"nutrition_{req.user_id}"},
  875. }
  876. result = await graph.ainvoke(initial_state, config)
  877. answer = result.get("answer") or ""
  878. sources = result.get("sources") or []
  879. metadata = {}
  880. if sources:
  881. metadata["sources"] = [
  882. {"title": s.get("name", ""), "type": s.get("type", "tool")}
  883. for s in sources
  884. ]
  885. return DifyResponse(
  886. id=f"nutrition-{uuid.uuid4().hex[:24]}",
  887. created=_now_ts(),
  888. model="langgraph-nutrition",
  889. choices=[
  890. DifyChoice(
  891. index=0,
  892. message={"role": "assistant", "content": answer},
  893. finish_reason="stop",
  894. )
  895. ],
  896. usage=DifyUsage(
  897. prompt_tokens=len(query.split()),
  898. completion_tokens=len(answer.split()),
  899. total_tokens=len(query.split()) + len(answer.split()),
  900. ),
  901. metadata=metadata,
  902. )