adapter.py 62 KB

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