adapter.py 54 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385
  1. from fastapi import APIRouter, HTTPException
  2. from pydantic import BaseModel, ConfigDict, AliasChoices, Field
  3. from typing import Optional, List, Dict, Any
  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, MultiMemberPlanResponse, PlanPart, HealthPlanSection
  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. # 兼容 camelCase(Java 侧历史写法)与 snake_case(本服务规范)两种入参键名
  482. model_config = ConfigDict(populate_by_name=True)
  483. member_ids: Optional[str] = Field(default=None, validation_alias=AliasChoices("member_ids", "memberIds"))
  484. dimensions: Optional[str] = None
  485. goal: str = ""
  486. family_id: Optional[int] = Field(default=None, validation_alias=AliasChoices("family_id", "familyId"))
  487. class HealthPlanRegenerateRequest(BaseModel):
  488. model_config = ConfigDict(populate_by_name=True)
  489. section: str # nutrition | diet | exercise
  490. feedback: str = ""
  491. existing_section_content: str = ""
  492. member_ids: Optional[str] = Field(default=None, validation_alias=AliasChoices("member_ids", "memberIds"))
  493. dimensions: Optional[str] = None
  494. goal: str = ""
  495. family_id: Optional[int] = Field(default=None, validation_alias=AliasChoices("family_id", "familyId"))
  496. TEXT_PLAN_SYSTEM_PROMPT = """你是一个专业的家庭健康方案生成器。请根据用户提供的健康数据,生成一份结构化的健康改善方案。
  497. 输出格式要求:
  498. ## 方案概述
  499. [简要说明方案的总体目标和适用对象]
  500. ## 成员健康概况
  501. [每个成员的关键指标摘要]
  502. ## 需要关注的异常指标
  503. [列出异常指标及对应的知识库建议]
  504. ## 改善方案
  505. ### 1. 饮食调整
  506. [具体、可执行的饮食建议]
  507. ### 2. 生活习惯
  508. [具体、可执行的生活习惯建议]
  509. ### 3. 补充建议
  510. [如需补充营养素或益生菌,给出具体建议]
  511. ### 4. 跟踪建议
  512. [建议定期复查的指标和频率]
  513. ## 注意事项
  514. [禁忌、提醒等]
  515. 请基于实际数据给出建议,不要编造科学依据。引用知识库内容时标注来源。"""
  516. PLAN_SYSTEM_PROMPT = """你是一个专业的家庭健康方案规划师。根据用户提供的健康数据和目标,生成结构化的健康改善方案。
  517. ## 输出格式(必须输出合法 JSON,不要有其他内容)
  518. {
  519. "overview": "总体概述(100字以内,说明方案目标和核心策略)",
  520. "sections": [
  521. {
  522. "key": "nutrition",
  523. "title": "营养补充建议",
  524. "content": "Markdown 格式的详细内容",
  525. "items": [
  526. {"name": "产品名", "dosage": "用量", "timing": "服用时间", "reason": "推荐理由"}
  527. ],
  528. "tasks": [
  529. {
  530. "action_type": "buy",
  531. "title": "购买维生素D3补充剂",
  532. "dimension": "wealth",
  533. "frequency": "once",
  534. "notes": "每日一粒,随餐服用"
  535. }
  536. ]
  537. },
  538. {
  539. "key": "diet",
  540. "title": "饮食建议",
  541. "content": "Markdown 格式的餐饮建议",
  542. "items": [{"meal": "餐型", "food": "食物建议", "notes": "注意事项"}],
  543. "tasks": [
  544. {
  545. "action_type": "diet",
  546. "title": "早餐增加高蛋白与膳食纤维",
  547. "dimension": "body",
  548. "frequency": "daily",
  549. "notes": ""
  550. }
  551. ]
  552. },
  553. {
  554. "key": "exercise",
  555. "title": "运动计划",
  556. "content": "Markdown 格式的运动建议",
  557. "items": [{"type": "运动类型", "duration": "时长", "frequency": "频率", "notes": "注意事项"}],
  558. "tasks": [
  559. {
  560. "action_type": "exercise",
  561. "title": "每周3次有氧运动,每次30分钟",
  562. "dimension": "body",
  563. "frequency": "daily",
  564. "notes": ""
  565. }
  566. ]
  567. }
  568. ],
  569. "abnormal_indicators": [
  570. {"member": "姓名", "indicator": "指标名", "value": "值", "unit": "单位", "suggestion": "建议"}
  571. ]
  572. }
  573. ## tasks 字段约定
  574. - 每个 section 的 `tasks` 是该 section 中"可执行的行动项"列表,与 `content`(人类可读 Markdown)分离。
  575. - `action_type` 取值仅限:`buy`(购买/补充产品)、`read`(阅读)、`exercise`(运动)、`checkin`(打卡/记录)、`diet`(饮食)、`activity`(活动/社交)。
  576. - `dimension` 取值仅限五维:`body`/`mind`/`wisdom`/`action`/`wealth`。
  577. - `frequency`:`once`=一次性任务;`daily`=每日重复任务。
  578. - `title` 是最终写入任务系统的标题,必须是**具体可执行的动作**,不要写纯原理/机制描述。
  579. - 若某 section 没有可执行的行动项,`tasks` 输出空数组 `[]`。
  580. ## 原则
  581. 1. 基于实际数据给出建议,不编造
  582. 2. 引用知识库内容时标注来源
  583. 3. 建议要具体可执行,避免空泛
  584. 4. 营养补充部分要具体到产品类型和用量
  585. 5. 严重健康问题建议咨询医生
  586. 6. 当问卷中某项关键信息缺失或不确定(如抗生素用药史、排便性状、过敏表现、饮食结构不明确)时,应在方案末尾明确提示用户:「为了获得更精准的饮食建议,建议您进行肠道菌群检测(GI Navigator 或其他权威检测),以获取确切的菌群组成数据」。
  587. ## 画像数据使用指南
  588. 如果提供了用户的画像数据(五维评分、身体指标、心理指标等),请结合这些真实数据给出更有针对性的建议。特别关注异常指标(如睡眠不足、压力偏高、运动频率低等),在方案中明确说明这些指标的现状和改善方向。
  589. """
  590. REGENERATE_SECTION_SYSTEM_PROMPT = """你是一个家庭健康方案规划师。根据用户反馈重新生成指定部分内容。
  591. ## 输出格式(必须输出合法 JSON,不要有其他内容)
  592. {
  593. "content": "重新生成的 Markdown 内容",
  594. "tasks": [
  595. {
  596. "action_type": "buy|read|exercise|checkin|diet|activity",
  597. "title": "可执行任务标题",
  598. "dimension": "body|mind|wisdom|action|wealth",
  599. "frequency": "once|daily",
  600. "notes": "补充说明"
  601. }
  602. ]
  603. }
  604. ## tasks 约定
  605. - action_type 取值:buy/read/exercise/checkin/diet/activity
  606. - dimension 取值:body/mind/wisdom/action/wealth
  607. - frequency:once=一次性;daily=每日重复
  608. - 无行动项时 tasks 输出 []
  609. ## 原则
  610. - 保持与原格式一致
  611. - 结合用户反馈进行修改
  612. - 建议要具体可执行"""
  613. def _format_diet_preferences(dp: dict) -> str:
  614. if not dp:
  615. return ""
  616. parts = []
  617. def _list(v):
  618. if not v:
  619. return ""
  620. if isinstance(v, list):
  621. return ", ".join(str(x) for x in v)
  622. try:
  623. parsed = json.loads(v)
  624. if isinstance(parsed, list):
  625. return ", ".join(str(x) for x in parsed)
  626. except Exception:
  627. pass
  628. return str(v)
  629. if dp.get("allergies"):
  630. parts.append(f"过敏原:{_list(dp['allergies'])}")
  631. if dp.get("absoluteAvoid"):
  632. parts.append(f"忌口:{_list(dp['absoluteAvoid'])}")
  633. if dp.get("mustEat"):
  634. parts.append(f"必吃:{_list(dp['mustEat'])}")
  635. if dp.get("religiousDiet") and dp["religiousDiet"] not in ("none", None, ""):
  636. parts.append(f"宗教饮食:{dp['religiousDiet']}")
  637. if dp.get("spiceLevel"):
  638. parts.append(f"辣度:{dp['spiceLevel']}")
  639. if dp.get("noPreference"):
  640. parts.append("无特殊饮食偏好")
  641. if dp.get("dietStructure"):
  642. try:
  643. ds = json.loads(dp["dietStructure"])
  644. if isinstance(ds, dict):
  645. if ds.get("vegetableFruit"):
  646. parts.append(f"每日蔬果量:{ds['vegetableFruit']}")
  647. if ds.get("dairy"):
  648. parts.append(f"乳制品:{ds['dairy']}")
  649. if ds.get("sugaryDrinks"):
  650. parts.append(f"含糖饮料:{ds['sugaryDrinks']}")
  651. except Exception:
  652. parts.append(f"饮食结构:{dp['dietStructure']}")
  653. return "; ".join(parts)
  654. def _format_health_status(hs: dict) -> str:
  655. if not hs:
  656. return ""
  657. def _arr(v):
  658. if not v:
  659. return ""
  660. if isinstance(v, list):
  661. return ", ".join(str(x.get("name", x)) if isinstance(x, dict) else str(x) for x in v)
  662. try:
  663. parsed = json.loads(v)
  664. if isinstance(parsed, list):
  665. return ", ".join(str(x.get("name", x)) if isinstance(x, dict) else str(x) for x in parsed)
  666. except Exception:
  667. pass
  668. return v
  669. out = []
  670. if hs.get("heightCm"):
  671. out.append(f"身高{hs['heightCm']}cm")
  672. if hs.get("weightKg"):
  673. out.append(f"体重{hs['weightKg']}kg")
  674. if hs.get("bloodPressure"):
  675. out.append(f"血压{hs['bloodPressure']}")
  676. if hs.get("bloodGlucose"):
  677. out.append(f"空腹血糖{hs['bloodGlucose']}mmol/L")
  678. lipids = _arr(hs.get("bloodLipids"))
  679. if lipids:
  680. out.append(f"血脂:{lipids}")
  681. diseases = _arr(hs.get("diseaseHistory"))
  682. if diseases:
  683. out.append(f"疾病史:{diseases}")
  684. meds = _arr(hs.get("medications"))
  685. if meds:
  686. out.append(f"用药:{meds}")
  687. if hs.get("notes"):
  688. out.append(f"备注:{hs['notes']}")
  689. # 生活方式与肠道健康(心情/睡眠/排便/抗生素/过敏表现)
  690. if hs.get("lifestyle"):
  691. try:
  692. ls = json.loads(hs["lifestyle"])
  693. parts = []
  694. if isinstance(ls, dict):
  695. if ls.get("mood"):
  696. parts.append(f"心情状态:{ls['mood']}")
  697. if ls.get("sleepDuration"):
  698. parts.append(f"睡眠时长:{ls['sleepDuration']}")
  699. if ls.get("bedtime"):
  700. parts.append(f"入睡时间:{ls['bedtime']}")
  701. if ls.get("bowelFrequency"):
  702. parts.append(f"排便频率:{ls['bowelFrequency']}")
  703. if ls.get("bowelForm"):
  704. parts.append(f"粪便性状:{ls['bowelForm']}")
  705. if ls.get("antibioticHistory"):
  706. parts.append(f"近2年抗生素用药史:{ls['antibioticHistory']}")
  707. if ls.get("allergySymptoms") and ls["allergySymptoms"]:
  708. sym = ", ".join(ls["allergySymptoms"]) if isinstance(ls["allergySymptoms"], list) else str(ls["allergySymptoms"])
  709. parts.append(f"过敏表现:{sym}")
  710. # 按性别/年龄动态采集的专属项
  711. if ls.get("menstrualCycle"):
  712. parts.append(f"月经周期:{ls['menstrualCycle']}")
  713. if ls.get("specialPeriod"):
  714. parts.append(f"特殊时期:{ls['specialPeriod']}")
  715. if ls.get("growthDevelopment"):
  716. parts.append(f"生长发育:{ls['growthDevelopment']}")
  717. if ls.get("pickyEating"):
  718. parts.append(f"挑食情况:{ls['pickyEating']}")
  719. if ls.get("osteoporosisRisk"):
  720. parts.append(f"骨质疏松风险:{ls['osteoporosisRisk']}")
  721. if ls.get("fallRisk"):
  722. parts.append(f"跌倒风险:{ls['fallRisk']}")
  723. if ls.get("screenTime"):
  724. parts.append(f"屏幕时间:{ls['screenTime']}")
  725. if ls.get("teenMood"):
  726. parts.append(f"情绪状态:{ls['teenMood']}")
  727. if parts:
  728. out.append("生活方式:" + "; ".join(parts))
  729. except Exception:
  730. out.append(f"生活方式:{hs['lifestyle']}")
  731. return "; ".join(out)
  732. async def _collect_plan_data(java: JavaClient, retriever: RagRetriever, member_ids_str: str, goal: str, dimensions: str):
  733. """统一数据收集逻辑"""
  734. member_ids = [m.strip() for m in member_ids_str.split(",") if m.strip()]
  735. # 1. 家庭成员信息
  736. members_info = []
  737. for uid_str in member_ids:
  738. ctx = await java.get_family_context(int(uid_str), "child_info")
  739. children = ctx.get("children", []) if isinstance(ctx, dict) else []
  740. for child in children:
  741. cid = str(child.get("用户ID", ""))
  742. if cid == uid_str:
  743. members_info.append({
  744. "id": cid,
  745. "name": child.get("姓名", f"成员{cid}"),
  746. "age": child.get("年龄", "未知"),
  747. })
  748. break
  749. if not any(m["id"] == uid_str for m in members_info):
  750. members_info.append({"id": uid_str, "name": f"成员{uid_str}", "age": "未知"})
  751. # 2. 健康指标
  752. all_indicators = []
  753. abnormal_list = []
  754. for member in members_info:
  755. reports = await java.get_member_reports(int(member["id"]))
  756. if not reports:
  757. continue
  758. latest = max(reports, key=lambda r: r.get("reportDate", ""))
  759. indicators = await java.get_report_indicators(latest.get("id"))
  760. for ind in indicators:
  761. ind["_member_name"] = member["name"]
  762. all_indicators.append(ind)
  763. # 2.5 健康自检问卷(health_status;未填写为空 dict)
  764. for member in members_info:
  765. try:
  766. member["health_status"] = await java.get_member_health_status(int(member["id"]))
  767. except Exception:
  768. member["health_status"] = {}
  769. try:
  770. member["diet_preferences"] = await java.get_member_diet_preferences(int(member["id"]))
  771. except Exception:
  772. member["diet_preferences"] = {}
  773. # 3. 知识库
  774. kb_results = []
  775. queries = set()
  776. for ind in all_indicators:
  777. queries.add(ind.get("indicatorName", ""))
  778. queries.add(goal)
  779. if dimensions:
  780. queries.add(dimensions)
  781. for q in list(queries)[:8]:
  782. if q:
  783. results = await retriever.retrieve(q, k=3)
  784. kb_results.extend(results)
  785. return members_info, all_indicators, kb_results
  786. # 从 LLM 原始输出中提取 tasks 的正则兜底:在 content 文本里找形如
  787. # "1. 动作(动词/名词)..." 的行。以可识别的动作词开头视为潜在任务。
  788. _TASK_FALLBACK_RE = re.compile(r"^\s*(?:\d+[\.、)]|\-\s*)\s*"
  789. r"(?:(?:购买|购置|阅读|看|运动|锻炼|跑步|散步|打卡|记录|饮食|吃|少|多|活动|参加|亲子).*)$")
  790. def _parse_plan_response(answer: str) -> dict:
  791. """解析 LLM 原始输出为 HealthPlanResponse;非法 JSON 或校验失败时用正则兜底提取 tasks。
  792. 返回 dict:{"success": bool, "data": {...}, "error": str|None}
  793. """
  794. start = answer.find("{")
  795. end = answer.rfind("}") + 1
  796. if start >= 0 and end > start:
  797. try:
  798. parsed = json.loads(answer[start:end])
  799. resp = HealthPlanResponse.model_validate(parsed)
  800. return {"success": True, "data": resp.model_dump(), "error": None}
  801. except Exception as e:
  802. # 校验失败:尝试正则兜底
  803. fallback = _fallback_extract_tasks(parsed if isinstance(parsed, dict) else {})
  804. if fallback is not None:
  805. return {"success": True, "data": fallback, "error": str(e)}
  806. return {"success": False, "data": {"raw": answer, "overview": answer[:200]},
  807. "error": str(e)}
  808. return {"success": False, "data": {"raw": answer, "overview": answer[:200]},
  809. "error": "no JSON found"}
  810. def _fallback_extract_tasks(parsed: dict) -> Optional[dict]:
  811. """当 LLM 输出缺 tasks 或校验失败时,从各 section.content 用正则提取任务并回填。
  812. 任一 section 无 tasks 才触发;全部已含 tasks 则返回 None(表示无需兜底)。"""
  813. if not isinstance(parsed, dict):
  814. return None
  815. sections = parsed.get("sections")
  816. if not isinstance(sections, list) or not sections:
  817. return None
  818. changed = False
  819. for sec in sections:
  820. if not isinstance(sec, dict):
  821. continue
  822. tasks = sec.get("tasks")
  823. if isinstance(tasks, list) and tasks:
  824. # 检查 tasks 是否全部有效;如有无效项则视为缺失,触发 fallback
  825. try:
  826. for t in tasks:
  827. PlanTask.model_validate(t)
  828. continue # 全部有效,跳过
  829. except Exception:
  830. pass # 有无效项,继续执行 fallback
  831. content = sec.get("content", "")
  832. extracted = []
  833. for raw in content.split("\n"):
  834. line = raw.strip()
  835. if not line:
  836. continue
  837. m = _TASK_FALLBACK_RE.match(line)
  838. if not m:
  839. continue
  840. # 去掉行首编号/项目符号
  841. title = re.sub(r"^\s*(?:\d+[\.、)]|\-\s*)\s*", "", line).strip()
  842. if not title:
  843. continue
  844. action = _classify_action(title)
  845. if action is None:
  846. continue
  847. extracted.append({
  848. "action_type": action["action_type"],
  849. "title": title,
  850. "dimension": action["dimension"],
  851. "frequency": action["frequency"],
  852. "notes": "",
  853. })
  854. if extracted:
  855. sec["tasks"] = extracted
  856. changed = True
  857. if changed:
  858. return parsed
  859. return None
  860. def _classify_action(title: str) -> Optional[dict]:
  861. """按动作词分类,映射到 action_type + 五维维度 + 频率(与 Java classifyTaskLine 对齐)。"""
  862. if re.search(r"购买|购置|采购|下单|买入|囤|选购", title):
  863. return {"action_type": "buy", "dimension": "wealth", "frequency": "once"}
  864. if re.search(r"阅读|看|读书", title):
  865. return {"action_type": "read", "dimension": "wisdom", "frequency": "daily"}
  866. if re.search(r"运动|锻炼|跑步|散步|健身|瑜伽|拉伸", title):
  867. return {"action_type": "exercise", "dimension": "body", "frequency": "daily"}
  868. if re.search(r"打卡|记录|复盘|记", title):
  869. return {"action_type": "checkin", "dimension": "mind", "frequency": "daily"}
  870. if re.search(r"饮食|吃|少|多|餐|营养|水", title):
  871. return {"action_type": "diet", "dimension": "body", "frequency": "daily"}
  872. if re.search(r"活动|参加|亲子|社交|户外|游戏", title):
  873. return {"action_type": "activity", "dimension": "action", "frequency": "daily"}
  874. return None
  875. MULTI_MEMBER_SYNTHESIS_PROMPT = """你是一个家庭健康方案规划师。以下是一个家庭中 N 位成员的独立健康方案(每位成员已由 LLM 单独生成)。请分析这些方案,找出共性部分与个性部分,输出结构化 JSON。
  876. ## 输出格式(必须输出合法 JSON,不要有其他内容)
  877. {
  878. "common": {
  879. "overview": "所有成员共性的总体概述(100 字以内,说明方案目标和核心策略)",
  880. "sections": [
  881. {
  882. "key": "nutrition|diet|exercise",
  883. "title": "营养补充建议",
  884. "content": "Markdown 格式的详细内容",
  885. "items": [],
  886. "tasks": [
  887. {
  888. "action_type": "buy|read|exercise|checkin|diet|activity",
  889. "title": "具体可执行动作(如:购买维生素D3补充剂)",
  890. "dimension": "body|mind|wisdom|action|wealth",
  891. "frequency": "once|daily",
  892. "notes": "补充说明(可空)"
  893. }
  894. ]
  895. }
  896. ],
  897. "abnormal_indicators": []
  898. },
  899. "individual": {
  900. "<memberId>": {
  901. "overview": "该成员独有的总体概述(如该成员无独有内容,写\"无\")",
  902. "sections": [
  903. {
  904. "key": "nutrition|diet|exercise",
  905. "title": "营养补充建议(个性化)",
  906. "content": "Markdown 格式的详细内容",
  907. "items": [],
  908. "tasks": [
  909. {
  910. "action_type": "buy|read|exercise|checkin|diet|activity",
  911. "title": "具体可执行动作",
  912. "dimension": "body|mind|wisdom|action|wealth",
  913. "frequency": "once|daily",
  914. "notes": "补充说明(可空)"
  915. }
  916. ]
  917. }
  918. ],
  919. "abnormal_indicators": [
  920. {"member": "姓名", "indicator": "指标名", "value": "值", "unit": "单位", "suggestion": "建议"}
  921. ]
  922. }
  923. }
  924. }
  925. ## 规则
  926. 1. **common**:出现在至少两个成员方案中的相似/重复内容。包括:共同适用的饮食大方向、共同的生活习惯、所有人都需要执行的锻炼/打卡等。**common 的 sections 应保留完整 tasks,因为这些任务会分配给每个成员各一份。**
  927. 2. **individual.<memberId>**:只适用于该成员独有的内容,包括:针对该成员专属指标异常的建议、该成员特有的疾病史/过敏史、年龄相关的个性化建议等。该成员的个性 section 不能包含已放入 common 的内容(避免重复)。
  928. 3. 如果某成员没有独有内容,其 individual 的 sections 为空数组 `[]`,overview 写"无",abnormal_indicators 为 `[]`。
  929. 4. **abnormal_indicators**:多成员共有的异常指标放入 common;只属于某个成员的异常指标放入 individual 该成员的 abnormal_indicators。
  930. 5. knowledge_sources 不输出。
  931. ## 任务约定
  932. - `action_type` 取值仅限:`buy`(购买/补充产品)、`read`(阅读)、`exercise`(运动)、`checkin`(打卡/记录)、`diet`(饮食)、`activity`(活动/社交)。
  933. - `dimension` 取值仅限五维:`body`/`mind`/`wisdom`/`action`/`wealth`。
  934. - `frequency`:`once`=一次性任务;`daily`=每日重复任务。
  935. - `title` 必须是**具体可执行的动作**,不要写纯原理/机制描述。
  936. - 若某 section 没有可执行的行动项,`tasks` 输出空数组 `[]`。"""
  937. def _build_individual_plan_prompt(member: dict, member_indicators: list, kb_results: list, goal: str, dimensions: str) -> str:
  938. """为单个成员构建独立方案生成 prompt(只含该成员自身数据)。"""
  939. parts = [PLAN_SYSTEM_PROMPT]
  940. parts.append(f"\n## 用户目标\n{goal}")
  941. if dimensions:
  942. parts.append(f"\n## 重点关注维度\n{dimensions}")
  943. parts.append("\n## 成员信息")
  944. profile = member.get("profile", {})
  945. dims = profile.get("dimension_scores", {})
  946. body = profile.get("body_metrics", {})
  947. mind = profile.get("mind_metrics", {})
  948. parts.append(f"- {member['name']} (id: {member['id']}, 年龄: {member['age']})")
  949. hs_text = _format_health_status(member.get("health_status"))
  950. if hs_text:
  951. parts.append(f" 健康自检: {hs_text}")
  952. dp_text = _format_diet_preferences(member.get("diet_preferences"))
  953. if dp_text:
  954. parts.append(f" 饮食偏好: {dp_text}")
  955. if dims:
  956. parts.append(f" 五维评分: 身{dims.get('body','?')} 智{dims.get('wisdom','?')} 心{dims.get('mind','?')} 行{dims.get('action','?')} 富{dims.get('wealth','?')}")
  957. if body.get('sleep_dur_avg'):
  958. parts.append(f" 平均睡眠: {body['sleep_dur_avg']}小时/天")
  959. if mind.get('stress_avg'):
  960. parts.append(f" 平均压力: {mind['stress_avg']}/10")
  961. if body.get('exercise_count_week'):
  962. parts.append(f" 周运动: {body['exercise_count_week']}次")
  963. if member_indicators:
  964. parts.append("\n## 该成员健康指标")
  965. for ind in member_indicators[:15]:
  966. status = ind.get("status", "")
  967. if status in ("abnormal", "high", "low", "偏高", "偏低"):
  968. parts.append(f"- 【异常】{ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')} (状态: {status})")
  969. else:
  970. parts.append(f"- {ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')}")
  971. if kb_results:
  972. parts.append("\n## 知识库参考")
  973. for r in kb_results[:6]:
  974. title = r.get("metadata", {}).get("title", "")
  975. content = r.get("content", "")[:200]
  976. parts.append(f"---\n{title}\n{content}")
  977. return "\n".join(parts)
  978. async def _generate_individual_plan(llm, member: dict, member_indicators: list, kb_results: list, goal: str, dimensions: str) -> dict:
  979. """为单个成员调用 LLM 生成独立方案;返回 HealthPlanResponse.model_dump() 结构,失败返回 {}。"""
  980. prompt = _build_individual_plan_prompt(member, member_indicators, kb_results, goal, dimensions)
  981. try:
  982. response = await llm.ainvoke([SystemMessage(content=prompt)])
  983. result = _parse_plan_response(response.content)
  984. if result["success"]:
  985. return result["data"]
  986. logger.warning("成员 %s 方案生成解析失败: %s", member.get("name"), result.get("error"))
  987. return {}
  988. except Exception as e:
  989. logger.error("成员 %s 方案生成失败: %s", member.get("name"), e)
  990. return {}
  991. async def _synthesize_multi_member_plan(llm, members_info: list, individual_plans: dict, goal: str, dimensions: str) -> dict:
  992. """将 N 份单人方案交给 LLM 做交集合成,返回 MultiMemberPlanResponse.model_dump() 结构。
  993. LLM 失败或输出不合法时,退化为纯程序拼接:common 各部分置空,individual 直接透传各成员方案。
  994. """
  995. blocks = []
  996. for m in members_info:
  997. mid = str(m["id"])
  998. plan = individual_plans.get(mid, {})
  999. block_lines = [f"### 成员 {m['name']} (memberId: {mid})"]
  1000. if not plan:
  1001. block_lines.append("(无数据,individual 对应位置输出空 sections)")
  1002. else:
  1003. ov = plan.get("overview", "")
  1004. if ov:
  1005. block_lines.append(f"概述: {ov}")
  1006. for s in plan.get("sections", []):
  1007. skey = s.get("key", "")
  1008. stitle = s.get("title", "")
  1009. scontent = s.get("content", "")
  1010. sitems = s.get("items") or []
  1011. stasks = s.get("tasks") or []
  1012. block_lines.append(f"[{skey}] {stitle}")
  1013. block_lines.append(f" content:\n{scontent}")
  1014. if sitems:
  1015. block_lines.append(f" items: {json.dumps(sitems, ensure_ascii=False)[:400]}")
  1016. if stasks:
  1017. block_lines.append(f" tasks: {json.dumps(stasks, ensure_ascii=False)[:400]}")
  1018. ai = plan.get("abnormal_indicators") or []
  1019. if ai:
  1020. block_lines.append(f"异常指标: {json.dumps(ai, ensure_ascii=False)[:300]}")
  1021. blocks.append("\n".join(block_lines))
  1022. prompt = MULTI_MEMBER_SYNTHESIS_PROMPT + "\n\n## 各成员独立方案(请按 memberId 归并,memberId 必须与下列标题中的 id 完全一致)\n\n"
  1023. prompt += "\n\n---\n\n".join(blocks)
  1024. try:
  1025. response = await llm.ainvoke([SystemMessage(content=prompt)])
  1026. answer = response.content
  1027. start = answer.find("{")
  1028. end = answer.rfind("}") + 1
  1029. if start >= 0 and end > start:
  1030. parsed = json.loads(answer[start:end])
  1031. # 用 Pydantic 校验并规整结构
  1032. mmpr = MultiMemberPlanResponse.model_validate(parsed)
  1033. return mmpr.model_dump()
  1034. logger.warning("多人方案交集合成 LLM 输出无 JSON,退化为程序拼接")
  1035. except Exception as e:
  1036. logger.error("多人方案交集合成失败: %s,退化为程序拼接", e)
  1037. # 程序化兜底:common 置空,individual 直接透传各成员方案
  1038. individual = {}
  1039. for m in members_info:
  1040. mid = str(m["id"])
  1041. plan = individual_plans.get(mid, {})
  1042. if plan:
  1043. sections = []
  1044. for s in plan.get("sections", []):
  1045. try:
  1046. sections.append(HealthPlanSection.model_validate(s))
  1047. except Exception:
  1048. continue
  1049. individual[mid] = PlanPart(
  1050. overview=plan.get("overview", ""),
  1051. sections=sections,
  1052. abnormal_indicators=plan.get("abnormal_indicators", []),
  1053. )
  1054. return MultiMemberPlanResponse(common=PlanPart(overview=""), individual=individual).model_dump()
  1055. @router.post("/health/plan/generate", response_model=dict)
  1056. async def health_plan_generate(req: HealthPlanRequest):
  1057. """健康方案生成 — 返回结构化 JSON(总览+营养+饮食+运动)"""
  1058. goal = req.goal or "改善健康状况"
  1059. java = JavaClient()
  1060. retriever = RagRetriever(collection_name="cfc_knowledge")
  1061. llm = ChatOpenAI(
  1062. model=settings.llm_model,
  1063. api_key=settings.llm_api_key,
  1064. base_url=settings.llm_base_url,
  1065. temperature=0.3,
  1066. )
  1067. members_info, all_indicators, kb_results = await _collect_plan_data(java, retriever, req.member_ids or "", goal, req.dimensions or "")
  1068. # 获取每个成员的画像数据
  1069. for member in members_info:
  1070. try:
  1071. profile = await java.get_member_profile(int(member["id"]))
  1072. member["profile"] = profile
  1073. except Exception:
  1074. member["profile"] = {}
  1075. # === 多人方案:先每人单独生成,再 LLM 交集合成(共性/个性) ===
  1076. if len(members_info) > 1:
  1077. individual_plans = {}
  1078. for member in members_info:
  1079. member_indicators = [ind for ind in all_indicators if ind.get("_member_name") == member["name"]]
  1080. plan = await _generate_individual_plan(llm, member, member_indicators, kb_results, goal, req.dimensions or "")
  1081. individual_plans[str(member["id"])] = plan
  1082. result = await _synthesize_multi_member_plan(llm, members_info, individual_plans, goal, req.dimensions or "")
  1083. return {"success": True, "data": result, "parse_error": None}
  1084. # === 单人生成:保持原有 flat 结构(向后兼容) ===
  1085. # 构建 prompt
  1086. parts = [await get_prompt("health_plan") or PLAN_SYSTEM_PROMPT]
  1087. parts.append(f"\n## 用户目标\n{goal}")
  1088. if req.dimensions:
  1089. parts.append(f"\n## 重点关注维度\n{req.dimensions}")
  1090. parts.append("\n## 家庭成员")
  1091. for m in members_info:
  1092. profile = m.get("profile", {})
  1093. dims = profile.get("dimension_scores", {})
  1094. body = profile.get("body_metrics", {})
  1095. mind = profile.get("mind_metrics", {})
  1096. parts.append(f"- {m['name']} (年龄: {m['age']})")
  1097. hs_text = _format_health_status(m.get("health_status"))
  1098. if hs_text:
  1099. parts.append(f" 健康自检: {hs_text}")
  1100. dp_text = _format_diet_preferences(m.get("diet_preferences"))
  1101. if dp_text:
  1102. parts.append(f" 饮食偏好: {dp_text}")
  1103. if dims:
  1104. parts.append(f" 五维评分: 身{dims.get('body','?')} 智{dims.get('wisdom','?')} 心{dims.get('mind','?')} 行{dims.get('action','?')} 富{dims.get('wealth','?')}")
  1105. if body.get('sleep_dur_avg'):
  1106. parts.append(f" 平均睡眠: {body['sleep_dur_avg']}小时/天")
  1107. if mind.get('stress_avg'):
  1108. parts.append(f" 平均压力: {mind['stress_avg']}/10")
  1109. if body.get('exercise_count_week'):
  1110. parts.append(f" 周运动: {body['exercise_count_week']}次")
  1111. if all_indicators:
  1112. parts.append("\n## 健康指标摘要")
  1113. for ind in all_indicators[:15]:
  1114. status = ind.get("status", "")
  1115. if status in ("abnormal", "high", "low", "偏高", "偏低"):
  1116. parts.append(f"- 【异常】{ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')} (状态: {status})")
  1117. else:
  1118. parts.append(f"- {ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')}")
  1119. if kb_results:
  1120. parts.append("\n## 知识库参考")
  1121. for r in kb_results[:6]:
  1122. title = r.get("metadata", {}).get("title", "")
  1123. content = r.get("content", "")[:200]
  1124. parts.append(f"---\n{title}\n{content}")
  1125. full_prompt = "\n".join(parts)
  1126. messages = [SystemMessage(content=full_prompt)]
  1127. try:
  1128. response = await llm.ainvoke(messages)
  1129. answer = response.content
  1130. result = _parse_plan_response(answer)
  1131. if result["success"]:
  1132. return {"success": True, "data": result["data"], "parse_error": result["error"]}
  1133. return {"success": True, "data": result["data"], "parse_error": result["error"]}
  1134. except Exception as e:
  1135. logger.error("方案生成失败: %s", e)
  1136. return {"success": False, "error": str(e)}
  1137. @router.post("/health/plan/regenerate-section", response_model=dict)
  1138. async def health_plan_regenerate(req: HealthPlanRegenerateRequest):
  1139. """重新生成方案的某一个 section"""
  1140. java = JavaClient()
  1141. llm = ChatOpenAI(
  1142. model=settings.llm_model,
  1143. api_key=settings.llm_api_key,
  1144. base_url=settings.llm_base_url,
  1145. temperature=0.3,
  1146. )
  1147. members_info, all_indicators, kb_results = await _collect_plan_data(java, None, req.member_ids or "", req.goal, req.dimensions or "")
  1148. # 构建上下文
  1149. ctx_parts = [f"目标: {req.goal}"]
  1150. for m in members_info:
  1151. ctx_parts.append(f"- {m['name']} (年龄: {m['age']})")
  1152. hs_text = _format_health_status(m.get("health_status"))
  1153. if hs_text:
  1154. ctx_parts.append(f" 健康自检: {hs_text}")
  1155. dp_text = _format_diet_preferences(m.get("diet_preferences"))
  1156. if dp_text:
  1157. ctx_parts.append(f" 饮食偏好: {dp_text}")
  1158. for ind in all_indicators[:10]:
  1159. if ind.get("status") in ("abnormal", "high", "low", "偏高", "偏低"):
  1160. ctx_parts.append(f"- 【异常】{ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')}")
  1161. prompt = await get_prompt("health_plan_regenerate") or REGENERATE_SECTION_SYSTEM_PROMPT
  1162. prompt += f"\n\n## 当前 {req.section} 内容\n{req.existing_section_content[:500]}"
  1163. prompt += f"\n\n## 用户反馈\n{req.feedback}"
  1164. prompt += f"\n\n## 相关背景\n" + "\n".join(ctx_parts[:10])
  1165. try:
  1166. response = await llm.ainvoke([SystemMessage(content=prompt)])
  1167. answer = response.content
  1168. start = answer.find("{")
  1169. end = answer.rfind("}") + 1
  1170. content = answer
  1171. tasks = []
  1172. if start >= 0 and end > start:
  1173. try:
  1174. parsed = json.loads(answer[start:end])
  1175. content = parsed.get("content") or answer
  1176. raw_tasks = parsed.get("tasks") or []
  1177. # 用 PlanTask 校验,非法条目丢弃
  1178. for t in raw_tasks:
  1179. try:
  1180. pt = PlanTask.model_validate(t)
  1181. tasks.append(pt.model_dump())
  1182. except Exception:
  1183. continue
  1184. except Exception as e:
  1185. logger.warning("解析重生成 section JSON 失败: %s", e)
  1186. return {"success": True, "content": content, "tasks": tasks}
  1187. except Exception as e:
  1188. logger.error("重新生成 section 失败: %s", e)
  1189. return {"success": False, "error": str(e)}
  1190. @router.post("/nutrition/send", response_model=DifyResponse)
  1191. async def nutrition_send(req: DifyChatRequest):
  1192. """AI 营养助手 — 基于健康报告的个性化营养建议"""
  1193. query = _extract_query(req)
  1194. if not query:
  1195. raise HTTPException(status_code=400, detail="query 为空")
  1196. graph = create_nutrition_graph()
  1197. initial_state = {
  1198. "query": query,
  1199. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  1200. "child_id": int(req.inputs.get("child_id")) if req.inputs.get("child_id") else None,
  1201. "conversation_id": req.conversation_id or None,
  1202. "context": _to_langgraph_context(req),
  1203. "answer": None,
  1204. "sources": [],
  1205. "tasks": [],
  1206. "messages": None,
  1207. }
  1208. config = {
  1209. "configurable": {"thread_id": req.conversation_id or f"nutrition_{req.user_id}"},
  1210. }
  1211. result = await graph.ainvoke(initial_state, config)
  1212. answer = result.get("answer") or ""
  1213. sources = result.get("sources") or []
  1214. metadata = {}
  1215. if sources:
  1216. metadata["sources"] = [
  1217. {"title": s.get("name", ""), "type": s.get("type", "tool")}
  1218. for s in sources
  1219. ]
  1220. return DifyResponse(
  1221. id=f"nutrition-{uuid.uuid4().hex[:24]}",
  1222. created=_now_ts(),
  1223. model="langgraph-nutrition",
  1224. choices=[
  1225. DifyChoice(
  1226. index=0,
  1227. message={"role": "assistant", "content": answer},
  1228. finish_reason="stop",
  1229. )
  1230. ],
  1231. usage=DifyUsage(
  1232. prompt_tokens=len(query.split()),
  1233. completion_tokens=len(answer.split()),
  1234. total_tokens=len(query.split()) + len(answer.split()),
  1235. ),
  1236. metadata=metadata,
  1237. )