adapter.py 59 KB

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