|
|
@@ -1,6 +1,6 @@
|
|
|
from fastapi import APIRouter, HTTPException
|
|
|
from pydantic import BaseModel, ConfigDict
|
|
|
-from typing import Optional
|
|
|
+from typing import Optional, List, Dict, Any
|
|
|
from app.graphs.chat_graph import create_chat_graph
|
|
|
from app.graphs.analysis_graph import create_analysis_graph
|
|
|
from app.graphs.health_coach_graph import create_health_coach_graph
|
|
|
@@ -12,7 +12,7 @@ from app.config import settings
|
|
|
from app.prompt_service import get_prompt
|
|
|
from langchain_openai import ChatOpenAI
|
|
|
from langchain_core.messages import SystemMessage, HumanMessage
|
|
|
-from app.models.health_plan import HealthPlanResponse, PlanTask
|
|
|
+from app.models.health_plan import HealthPlanResponse, PlanTask, MultiMemberPlanResponse, PlanPart, HealthPlanSection
|
|
|
import uuid
|
|
|
import time
|
|
|
import re
|
|
|
@@ -966,6 +966,201 @@ def _classify_action(title: str) -> Optional[dict]:
|
|
|
return None
|
|
|
|
|
|
|
|
|
+MULTI_MEMBER_SYNTHESIS_PROMPT = """你是一个家庭健康方案规划师。以下是一个家庭中 N 位成员的独立健康方案(每位成员已由 LLM 单独生成)。请分析这些方案,找出共性部分与个性部分,输出结构化 JSON。
|
|
|
+
|
|
|
+## 输出格式(必须输出合法 JSON,不要有其他内容)
|
|
|
+
|
|
|
+{
|
|
|
+ "common": {
|
|
|
+ "overview": "所有成员共性的总体概述(100 字以内,说明方案目标和核心策略)",
|
|
|
+ "sections": [
|
|
|
+ {
|
|
|
+ "key": "nutrition|diet|exercise",
|
|
|
+ "title": "营养补充建议",
|
|
|
+ "content": "Markdown 格式的详细内容",
|
|
|
+ "items": [],
|
|
|
+ "tasks": [
|
|
|
+ {
|
|
|
+ "action_type": "buy|read|exercise|checkin|diet|activity",
|
|
|
+ "title": "具体可执行动作(如:购买维生素D3补充剂)",
|
|
|
+ "dimension": "body|mind|wisdom|action|wealth",
|
|
|
+ "frequency": "once|daily",
|
|
|
+ "notes": "补充说明(可空)"
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "abnormal_indicators": []
|
|
|
+ },
|
|
|
+ "individual": {
|
|
|
+ "<memberId>": {
|
|
|
+ "overview": "该成员独有的总体概述(如该成员无独有内容,写\"无\")",
|
|
|
+ "sections": [
|
|
|
+ {
|
|
|
+ "key": "nutrition|diet|exercise",
|
|
|
+ "title": "营养补充建议(个性化)",
|
|
|
+ "content": "Markdown 格式的详细内容",
|
|
|
+ "items": [],
|
|
|
+ "tasks": [
|
|
|
+ {
|
|
|
+ "action_type": "buy|read|exercise|checkin|diet|activity",
|
|
|
+ "title": "具体可执行动作",
|
|
|
+ "dimension": "body|mind|wisdom|action|wealth",
|
|
|
+ "frequency": "once|daily",
|
|
|
+ "notes": "补充说明(可空)"
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "abnormal_indicators": [
|
|
|
+ {"member": "姓名", "indicator": "指标名", "value": "值", "unit": "单位", "suggestion": "建议"}
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+## 规则
|
|
|
+1. **common**:出现在至少两个成员方案中的相似/重复内容。包括:共同适用的饮食大方向、共同的生活习惯、所有人都需要执行的锻炼/打卡等。**common 的 sections 应保留完整 tasks,因为这些任务会分配给每个成员各一份。**
|
|
|
+2. **individual.<memberId>**:只适用于该成员独有的内容,包括:针对该成员专属指标异常的建议、该成员特有的疾病史/过敏史、年龄相关的个性化建议等。该成员的个性 section 不能包含已放入 common 的内容(避免重复)。
|
|
|
+3. 如果某成员没有独有内容,其 individual 的 sections 为空数组 `[]`,overview 写"无",abnormal_indicators 为 `[]`。
|
|
|
+4. **abnormal_indicators**:多成员共有的异常指标放入 common;只属于某个成员的异常指标放入 individual 该成员的 abnormal_indicators。
|
|
|
+5. knowledge_sources 不输出。
|
|
|
+
|
|
|
+## 任务约定
|
|
|
+- `action_type` 取值仅限:`buy`(购买/补充产品)、`read`(阅读)、`exercise`(运动)、`checkin`(打卡/记录)、`diet`(饮食)、`activity`(活动/社交)。
|
|
|
+- `dimension` 取值仅限五维:`body`/`mind`/`wisdom`/`action`/`wealth`。
|
|
|
+- `frequency`:`once`=一次性任务;`daily`=每日重复任务。
|
|
|
+- `title` 必须是**具体可执行的动作**,不要写纯原理/机制描述。
|
|
|
+- 若某 section 没有可执行的行动项,`tasks` 输出空数组 `[]`。"""
|
|
|
+
|
|
|
+
|
|
|
+def _build_individual_plan_prompt(member: dict, member_indicators: list, kb_results: list, goal: str, dimensions: str) -> str:
|
|
|
+ """为单个成员构建独立方案生成 prompt(只含该成员自身数据)。"""
|
|
|
+ parts = [PLAN_SYSTEM_PROMPT]
|
|
|
+ parts.append(f"\n## 用户目标\n{goal}")
|
|
|
+ if dimensions:
|
|
|
+ parts.append(f"\n## 重点关注维度\n{dimensions}")
|
|
|
+ parts.append("\n## 成员信息")
|
|
|
+ profile = member.get("profile", {})
|
|
|
+ dims = profile.get("dimension_scores", {})
|
|
|
+ body = profile.get("body_metrics", {})
|
|
|
+ mind = profile.get("mind_metrics", {})
|
|
|
+ parts.append(f"- {member['name']} (id: {member['id']}, 年龄: {member['age']})")
|
|
|
+ hs_text = _format_health_status(member.get("health_status"))
|
|
|
+ if hs_text:
|
|
|
+ parts.append(f" 健康自检: {hs_text}")
|
|
|
+ dp_text = _format_diet_preferences(member.get("diet_preferences"))
|
|
|
+ if dp_text:
|
|
|
+ parts.append(f" 饮食偏好: {dp_text}")
|
|
|
+ if dims:
|
|
|
+ parts.append(f" 五维评分: 身{dims.get('body','?')} 智{dims.get('wisdom','?')} 心{dims.get('mind','?')} 行{dims.get('action','?')} 富{dims.get('wealth','?')}")
|
|
|
+ if body.get('sleep_dur_avg'):
|
|
|
+ parts.append(f" 平均睡眠: {body['sleep_dur_avg']}小时/天")
|
|
|
+ if mind.get('stress_avg'):
|
|
|
+ parts.append(f" 平均压力: {mind['stress_avg']}/10")
|
|
|
+ if body.get('exercise_count_week'):
|
|
|
+ parts.append(f" 周运动: {body['exercise_count_week']}次")
|
|
|
+ if member_indicators:
|
|
|
+ parts.append("\n## 该成员健康指标")
|
|
|
+ for ind in member_indicators[:15]:
|
|
|
+ status = ind.get("status", "")
|
|
|
+ if status in ("abnormal", "high", "low", "偏高", "偏低"):
|
|
|
+ parts.append(f"- 【异常】{ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')} (状态: {status})")
|
|
|
+ else:
|
|
|
+ parts.append(f"- {ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')}")
|
|
|
+ if kb_results:
|
|
|
+ parts.append("\n## 知识库参考")
|
|
|
+ for r in kb_results[:6]:
|
|
|
+ title = r.get("metadata", {}).get("title", "")
|
|
|
+ content = r.get("content", "")[:200]
|
|
|
+ parts.append(f"---\n{title}\n{content}")
|
|
|
+ return "\n".join(parts)
|
|
|
+
|
|
|
+
|
|
|
+async def _generate_individual_plan(llm, member: dict, member_indicators: list, kb_results: list, goal: str, dimensions: str) -> dict:
|
|
|
+ """为单个成员调用 LLM 生成独立方案;返回 HealthPlanResponse.model_dump() 结构,失败返回 {}。"""
|
|
|
+ prompt = _build_individual_plan_prompt(member, member_indicators, kb_results, goal, dimensions)
|
|
|
+ try:
|
|
|
+ response = await llm.ainvoke([SystemMessage(content=prompt)])
|
|
|
+ result = _parse_plan_response(response.content)
|
|
|
+ if result["success"]:
|
|
|
+ return result["data"]
|
|
|
+ logger.warning("成员 %s 方案生成解析失败: %s", member.get("name"), result.get("error"))
|
|
|
+ return {}
|
|
|
+ except Exception as e:
|
|
|
+ logger.error("成员 %s 方案生成失败: %s", member.get("name"), e)
|
|
|
+ return {}
|
|
|
+
|
|
|
+
|
|
|
+async def _synthesize_multi_member_plan(llm, members_info: list, individual_plans: dict, goal: str, dimensions: str) -> dict:
|
|
|
+ """将 N 份单人方案交给 LLM 做交集合成,返回 MultiMemberPlanResponse.model_dump() 结构。
|
|
|
+
|
|
|
+ LLM 失败或输出不合法时,退化为纯程序拼接:common 各部分置空,individual 直接透传各成员方案。
|
|
|
+ """
|
|
|
+ blocks = []
|
|
|
+ for m in members_info:
|
|
|
+ mid = str(m["id"])
|
|
|
+ plan = individual_plans.get(mid, {})
|
|
|
+ block_lines = [f"### 成员 {m['name']} (memberId: {mid})"]
|
|
|
+ if not plan:
|
|
|
+ block_lines.append("(无数据,individual 对应位置输出空 sections)")
|
|
|
+ else:
|
|
|
+ ov = plan.get("overview", "")
|
|
|
+ if ov:
|
|
|
+ block_lines.append(f"概述: {ov}")
|
|
|
+ for s in plan.get("sections", []):
|
|
|
+ skey = s.get("key", "")
|
|
|
+ stitle = s.get("title", "")
|
|
|
+ scontent = s.get("content", "")
|
|
|
+ sitems = s.get("items") or []
|
|
|
+ stasks = s.get("tasks") or []
|
|
|
+ block_lines.append(f"[{skey}] {stitle}")
|
|
|
+ block_lines.append(f" content:\n{scontent}")
|
|
|
+ if sitems:
|
|
|
+ block_lines.append(f" items: {json.dumps(sitems, ensure_ascii=False)[:400]}")
|
|
|
+ if stasks:
|
|
|
+ block_lines.append(f" tasks: {json.dumps(stasks, ensure_ascii=False)[:400]}")
|
|
|
+ ai = plan.get("abnormal_indicators") or []
|
|
|
+ if ai:
|
|
|
+ block_lines.append(f"异常指标: {json.dumps(ai, ensure_ascii=False)[:300]}")
|
|
|
+ blocks.append("\n".join(block_lines))
|
|
|
+
|
|
|
+ prompt = MULTI_MEMBER_SYNTHESIS_PROMPT + "\n\n## 各成员独立方案(请按 memberId 归并,memberId 必须与下列标题中的 id 完全一致)\n\n"
|
|
|
+ prompt += "\n\n---\n\n".join(blocks)
|
|
|
+ try:
|
|
|
+ response = await llm.ainvoke([SystemMessage(content=prompt)])
|
|
|
+ answer = response.content
|
|
|
+ start = answer.find("{")
|
|
|
+ end = answer.rfind("}") + 1
|
|
|
+ if start >= 0 and end > start:
|
|
|
+ parsed = json.loads(answer[start:end])
|
|
|
+ # 用 Pydantic 校验并规整结构
|
|
|
+ mmpr = MultiMemberPlanResponse.model_validate(parsed)
|
|
|
+ return mmpr.model_dump()
|
|
|
+ logger.warning("多人方案交集合成 LLM 输出无 JSON,退化为程序拼接")
|
|
|
+ except Exception as e:
|
|
|
+ logger.error("多人方案交集合成失败: %s,退化为程序拼接", e)
|
|
|
+
|
|
|
+ # 程序化兜底:common 置空,individual 直接透传各成员方案
|
|
|
+ individual = {}
|
|
|
+ for m in members_info:
|
|
|
+ mid = str(m["id"])
|
|
|
+ plan = individual_plans.get(mid, {})
|
|
|
+ if plan:
|
|
|
+ sections = []
|
|
|
+ for s in plan.get("sections", []):
|
|
|
+ try:
|
|
|
+ sections.append(HealthPlanSection.model_validate(s))
|
|
|
+ except Exception:
|
|
|
+ continue
|
|
|
+ individual[mid] = PlanPart(
|
|
|
+ overview=plan.get("overview", ""),
|
|
|
+ sections=sections,
|
|
|
+ abnormal_indicators=plan.get("abnormal_indicators", []),
|
|
|
+ )
|
|
|
+ return MultiMemberPlanResponse(common=PlanPart(overview=""), individual=individual).model_dump()
|
|
|
+
|
|
|
+
|
|
|
@router.post("/health/plan/generate", response_model=dict)
|
|
|
async def health_plan_generate(req: HealthPlanRequest):
|
|
|
"""健康方案生成 — 返回结构化 JSON(总览+营养+饮食+运动)"""
|
|
|
@@ -989,6 +1184,17 @@ async def health_plan_generate(req: HealthPlanRequest):
|
|
|
except Exception:
|
|
|
member["profile"] = {}
|
|
|
|
|
|
+ # === 多人方案:先每人单独生成,再 LLM 交集合成(共性/个性) ===
|
|
|
+ if len(members_info) > 1:
|
|
|
+ individual_plans = {}
|
|
|
+ for member in members_info:
|
|
|
+ member_indicators = [ind for ind in all_indicators if ind.get("_member_name") == member["name"]]
|
|
|
+ plan = await _generate_individual_plan(llm, member, member_indicators, kb_results, goal, req.dimensions or "")
|
|
|
+ individual_plans[str(member["id"])] = plan
|
|
|
+ result = await _synthesize_multi_member_plan(llm, members_info, individual_plans, goal, req.dimensions or "")
|
|
|
+ return {"success": True, "data": result, "parse_error": None}
|
|
|
+
|
|
|
+ # === 单人生成:保持原有 flat 结构(向后兼容) ===
|
|
|
# 构建 prompt
|
|
|
parts = [await get_prompt("health_plan") or PLAN_SYSTEM_PROMPT]
|
|
|
parts.append(f"\n## 用户目标\n{goal}")
|