Browse Source

feat: 健康方案生成工作流重构 - LangGraph结构化方案+Java对接

Sisyphus 1 month ago
parent
commit
415ee28914

+ 75 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -20,6 +20,8 @@ import java.util.List;
 import java.util.Map;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.etotem.cfc.util.SortUtil;
 
 @Slf4j
@@ -35,6 +37,8 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     @Value("${cfc.langgraph.base-url:}")
     private String langgraphBaseUrl;
 
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
     // 匹配方案文本中的任务行(与前端 parseTasksFromPlan 保持一致)
     // 注意:字符类 [.、)\]] 后不能再有多余的 ],否则数字编号行(如 "1. 腹部B超")永远无法匹配
     private static final Pattern TASK_PATTERN = Pattern.compile(
@@ -69,6 +73,20 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return healthPlanMapper.selectById(planId);
     }
 
+    @Override
+    public Long confirmPlan(Long planId) {
+        HealthPlan plan = healthPlanMapper.selectById(planId);
+        if (plan == null) throw new RuntimeException("方案不存在");
+        plan.setStatus("confirmed");
+        healthPlanMapper.updateById(plan);
+        try {
+            generateDailyTasksFromPlan(plan);
+        } catch (Exception e) {
+            log.warn("方案{}确认时生成任务失败: {}", planId, e.getMessage());
+        }
+        return planId;
+    }
+
     @Override
     public String generatePlan(Long familyId, String memberIds, String dimensions, String goal) {
         if (langgraphBaseUrl == null || langgraphBaseUrl.trim().isEmpty()) {
@@ -81,10 +99,26 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             body.put("memberIds", memberIds);
             body.put("dimensions", dimensions);
             body.put("goal", goal);
-            String url = langgraphBaseUrl + "/api/v1/health/coach/generate";
+            String url = langgraphBaseUrl + "/api/v1/health/plan/generate";
             ResponseEntity<String> response = restTemplate.postForEntity(url, body, String.class);
             if (response.getBody() != null && response.getStatusCode().is2xxSuccessful()) {
-                return response.getBody();
+                JsonNode root = objectMapper.readTree(response.getBody());
+                if (root.has("success") && root.get("success").asBoolean()) {
+                    JsonNode data = root.get("data");
+                    if (data != null && data.has("overview")) {
+                        StringBuilder sb = new StringBuilder();
+                        sb.append("## 方案概述\n\n").append(data.get("overview").asText()).append("\n\n");
+                        if (data.has("sections")) {
+                            for (JsonNode section : data.get("sections")) {
+                                sb.append("## ").append(section.get("title").asText()).append("\n\n");
+                                sb.append(section.get("content").asText()).append("\n\n");
+                            }
+                        }
+                        return sb.toString();
+                    }
+                }
+                return root.has("data") && root.get("data").has("raw")
+                    ? root.get("data").get("raw").asText() : response.getBody();
             }
         } catch (Exception e) {
             log.warn("调用LangGraph生成方案失败: {}", e.getMessage());
@@ -92,6 +126,45 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return buildSamplePlan(memberIds, dimensions, goal);
     }
 
+
+    @Override
+    public String regenerateSection(Long familyId, String section, String feedback, String planJsonStr) {
+        if (langgraphBaseUrl == null || langgraphBaseUrl.trim().isEmpty()) {
+            return buildSampleSection(section, feedback);
+        }
+        try {
+            RestTemplate restTemplate = new RestTemplate();
+            Map<String, Object> body = new HashMap<>();
+            body.put("section", section);
+            body.put("feedback", feedback != null ? feedback : "");
+            body.put("existing_section_content", planJsonStr);
+            String url = langgraphBaseUrl + "/api/v1/health/plan/regenerate-section";
+            ResponseEntity<String> response = restTemplate.postForEntity(url, body, String.class);
+            if (response.getBody() != null && response.getStatusCode().is2xxSuccessful()) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                if (root.has("success") && root.get("success").asBoolean()) {
+                    return root.has("content") ? root.get("content").asText() : response.getBody();
+                }
+            }
+        } catch (Exception e) {
+            log.warn("调用LangGraph重新生成方案部分失败: {}", e.getMessage());
+        }
+        return buildSampleSection(section, feedback);
+    }
+
+    private String buildSampleSection(String section, String feedback) {
+        switch (section) {
+            case "nutrition":
+                return "【营养补充建议】\n1. 益生菌:每日1次,餐后服用\n2. 维生素D3:每日1次,随餐服用\n3. Omega-3:每日1次,晚餐后服用";
+            case "diet":
+                return "【饮食建议】\n1. 早餐:高蛋白+膳食纤维\n2. 午餐:均衡搭配,少油少盐\n3. 晚餐:轻量为主,餐前3小时完成";
+            case "exercise":
+                return "【运动计划】\n1. 有氧运动:每周3次,每次30分钟\n2. 力量训练:每周2次,每次20分钟\n3. 拉伸放松:每日10分钟";
+            default:
+                return "【" + section + "】建议请根据用户具体情况调整";
+        }
+    }
+
     private String buildSamplePlan(String memberIds, String dimensions, String goal) {
         StringBuilder sb = new StringBuilder();
         sb.append("# 健康方案\n\n");

+ 217 - 0
cfc-langgraph/app/api/adapter.py

@@ -561,3 +561,220 @@ async def health_coach_generate(req: HealthCoachGenerateRequest):
     answer = response.content
 
     return answer
+
+
+# ===== 健康方案生成(结构化 JSON)=====
+
+class HealthPlanRequest(BaseModel):
+    member_ids: Optional[str] = None
+    dimensions: Optional[str] = None
+    goal: str = ""
+    family_id: Optional[int] = None
+
+
+class HealthPlanRegenerateRequest(BaseModel):
+    section: str  # nutrition | diet | exercise
+    feedback: str = ""
+    existing_section_content: str = ""
+    member_ids: Optional[str] = None
+    dimensions: Optional[str] = None
+    goal: str = ""
+    family_id: Optional[int] = None
+
+
+PLAN_SYSTEM_PROMPT = """你是一个专业的家庭健康方案规划师。根据用户提供的健康数据和目标,生成结构化的健康改善方案。
+
+## 输出格式(必须输出合法 JSON,不要有其他内容)
+
+{
+  "overview": "总体概述(100字以内,说明方案目标和核心策略)",
+  "sections": [
+    {
+      "key": "nutrition",
+      "title": "营养补充建议",
+      "content": "Markdown 格式的详细内容",
+      "items": [
+        {"name": "产品名", "dosage": "用量", "timing": "服用时间", "reason": "推荐理由"}
+      ]
+    },
+    {
+      "key": "diet",
+      "title": "饮食建议",
+      "content": "Markdown 格式的餐饮建议",
+      "items": [{"meal": "餐型", "food": "食物建议", "notes": "注意事项"}]
+    },
+    {
+      "key": "exercise",
+      "title": "运动计划",
+      "content": "Markdown 格式的运动建议",
+      "items": [{"type": "运动类型", "duration": "时长", "frequency": "频率", "notes": "注意事项"}]
+    }
+  ],
+  "abnormal_indicators": [
+    {"member": "姓名", "indicator": "指标名", "value": "值", "unit": "单位", "suggestion": "建议"}
+  ]
+}
+
+## 原则
+1. 基于实际数据给出建议,不编造
+2. 引用知识库内容时标注来源
+3. 建议要具体可执行,避免空泛
+4. 营养补充部分要具体到产品类型和用量
+5. 严重健康问题建议咨询医生
+"""
+
+REGENERATE_SECTION_SYSTEM_PROMPT = """你是一个家庭健康方案规划师。根据用户反馈重新生成指定部分内容。
+
+## 输出格式
+只输出新的 content 字段值(Markdown 格式字符串),不要输出 JSON 结构。
+
+## 原则
+- 保持与原格式一致
+- 结合用户反馈进行修改
+- 建议要具体可执行"""
+
+
+async def _collect_plan_data(java: JavaClient, retriever: RagRetriever, member_ids_str: str, goal: str, dimensions: str):
+    """统一数据收集逻辑"""
+    member_ids = [m.strip() for m in member_ids_str.split(",") if m.strip()]
+
+    # 1. 家庭成员信息
+    members_info = []
+    for uid_str in member_ids:
+        ctx = await java.get_family_context(int(uid_str), "child_info")
+        children = ctx.get("children", []) if isinstance(ctx, dict) else []
+        for child in children:
+            cid = str(child.get("用户ID", ""))
+            if cid == uid_str:
+                members_info.append({
+                    "id": cid,
+                    "name": child.get("姓名", f"成员{cid}"),
+                    "age": child.get("年龄", "未知"),
+                })
+                break
+        if not any(m["id"] == uid_str for m in members_info):
+            members_info.append({"id": uid_str, "name": f"成员{uid_str}", "age": "未知"})
+
+    # 2. 健康指标
+    all_indicators = []
+    abnormal_list = []
+    for member in members_info:
+        reports = await java.get_member_reports(int(member["id"]))
+        if not reports:
+            continue
+        latest = max(reports, key=lambda r: r.get("reportDate", ""))
+        indicators = await java.get_report_indicators(latest.get("id"))
+        for ind in indicators:
+            ind["_member_name"] = member["name"]
+            all_indicators.append(ind)
+
+    # 3. 知识库
+    kb_results = []
+    queries = set()
+    for ind in all_indicators:
+        queries.add(ind.get("indicatorName", ""))
+    queries.add(goal)
+    if dimensions:
+        queries.add(dimensions)
+    for q in list(queries)[:8]:
+        if q:
+            results = await retriever.retrieve(q, k=3)
+            kb_results.extend(results)
+
+    return members_info, all_indicators, kb_results
+
+
+@router.post("/health/plan/generate", response_model=dict)
+async def health_plan_generate(req: HealthPlanRequest):
+    """健康方案生成 — 返回结构化 JSON(总览+营养+饮食+运动)"""
+    goal = req.goal or "改善健康状况"
+    java = JavaClient()
+    retriever = RagRetriever(collection_name="cfc_knowledge")
+    llm = ChatOpenAI(
+        model=settings.llm_model,
+        api_key=settings.llm_api_key,
+        base_url=settings.llm_base_url,
+        temperature=0.3,
+    )
+
+    members_info, all_indicators, kb_results = await _collect_plan_data(java, retriever, req.member_ids or "", goal, req.dimensions or "")
+
+    # 构建 prompt
+    parts = [PLAN_SYSTEM_PROMPT]
+    parts.append(f"\n## 用户目标\n{goal}")
+    if req.dimensions:
+        parts.append(f"\n## 重点关注维度\n{req.dimensions}")
+    parts.append("\n## 家庭成员")
+    for m in members_info:
+        parts.append(f"- {m['name']} (年龄: {m['age']})")
+
+    if all_indicators:
+        parts.append("\n## 健康指标摘要")
+        for ind in all_indicators[:15]:
+            status = ind.get("status", "")
+            if status in ("abnormal", "high", "low", "偏高", "偏低"):
+                parts.append(f"- 【异常】{ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')} {ind.get('unit','')} (状态: {status})")
+            else:
+                parts.append(f"- {ind.get('_member_name','')} - {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}")
+
+    full_prompt = "\n".join(parts)
+
+    messages = [SystemMessage(content=full_prompt)]
+    try:
+        response = await llm.ainvoke(messages)
+        answer = response.content
+        # 解析 JSON
+        import json
+        try:
+            start = answer.find("{")
+            end = answer.rfind("}") + 1
+            if start >= 0 and end > start:
+                parsed = json.loads(answer[start:end])
+                return {"success": True, "data": parsed}
+        except Exception as e:
+            logger.warning("解析方案 JSON 失败: %s", e)
+        return {"success": True, "data": {"raw": answer, "overview": answer[:200]}, "parse_error": str(e)}
+    except Exception as e:
+        logger.error("方案生成失败: %s", e)
+        return {"success": False, "error": str(e)}
+
+
+@router.post("/health/plan/regenerate-section", response_model=dict)
+async def health_plan_regenerate(req: HealthPlanRegenerateRequest):
+    """重新生成方案的某一个 section"""
+    java = JavaClient()
+    llm = ChatOpenAI(
+        model=settings.llm_model,
+        api_key=settings.llm_api_key,
+        base_url=settings.llm_base_url,
+        temperature=0.3,
+    )
+
+    members_info, all_indicators, kb_results = await _collect_plan_data(java, None, req.member_ids or "", req.goal, req.dimensions or "")
+
+    # 构建上下文
+    ctx_parts = [f"目标: {req.goal}"]
+    for m in members_info:
+        ctx_parts.append(f"- {m['name']} (年龄: {m['age']})")
+    for ind in all_indicators[:10]:
+        if ind.get("status") in ("abnormal", "high", "low", "偏高", "偏低"):
+            ctx_parts.append(f"- 【异常】{ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')}")
+
+    prompt = REGENERATE_SECTION_SYSTEM_PROMPT
+    prompt += f"\n\n## 当前 {req.section} 内容\n{req.existing_section_content[:500]}"
+    prompt += f"\n\n## 用户反馈\n{req.feedback}"
+    prompt += f"\n\n## 相关背景\n" + "\n".join(ctx_parts[:10])
+
+    try:
+        response = await llm.ainvoke([SystemMessage(content=prompt)])
+        return {"success": True, "content": response.content}
+    except Exception as e:
+        logger.error("重新生成 section 失败: %s", e)
+        return {"success": False, "error": str(e)}

+ 297 - 0
cfc-langgraph/app/graphs/health_plan_graph.py

@@ -0,0 +1,297 @@
+"""
+健康方案生成 LangGraph - 分步骤结构化方案生成
+
+工作流:
+1. 数据收集: 家庭成员信息 + 健康指标 + 知识库检索
+2. LLM 生成: 总览概述 + 营养/饮食/运动 三个 section
+3. 返回结构化 JSON
+
+每次调用 LLM 时遵循统一的数据组装方式:
+  - 用户指标 (来自 Java context API)
+  - 知识库参考 (RAG 检索)
+  - 系统提示词 (带输出格式模板)
+"""
+from typing import TypedDict, Literal
+from langgraph.graph import StateGraph, START, END
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.rag.retriever import RagRetriever
+from app.config import settings
+from app.tools.java_client import JavaClient
+import logging
+
+logger = logging.getLogger(__name__)
+
+SECTION_KEYS = ["nutrition", "diet", "exercise"]
+
+DEFAULT_PROMPT = """你是一个专业的家庭健康方案规划师。根据用户的健康数据和目标,生成结构化的改善方案。
+
+## 输出格式(必须严格遵守 JSON)
+
+```json
+{
+  "overview": "总体概述(100字以内,说明方案目标和核心策略)",
+  "sections": [
+    {
+      "key": "nutrition",
+      "title": "营养补充建议",
+      "content": "Markdown 格式的详细内容,包含具体产品推荐、用量、服用时间",
+      "items": [
+        {"name": "产品名", "dosage": "用量", "timing": "服用时间", "reason": "推荐理由"}
+      ]
+    },
+    {
+      "key": "diet", 
+      "title": "饮食建议",
+      "content": "Markdown 格式的餐饮建议,包含早餐/午餐/晚餐建议",
+      "items": [{"meal": "餐型", "food": "食物建议", "notes": "注意事项"}]
+    },
+    {
+      "key": "exercise",
+      "title": "运动计划",
+      "content": "Markdown 格式的运动建议,包含频率、时长、类型",
+      "items": [{"type": "运动类型", "duration": "时长", "frequency": "频率", "notes": "注意事项"}]
+    }
+  ]
+}
+```
+
+## 原则
+1. 基于实际数据给出建议,不编造
+2. 引用知识库时标注来源
+3. 建议要具体可执行,避免空泛
+4. 营养补充部分要具体到品牌/产品类型和用量
+5. 严重健康问题建议咨询医生
+"""
+
+REGENERATE_SECTION_PROMPT = """你是一个家庭健康方案规划师。根据用户反馈重新生成指定部分的内容。
+
+## 当前方案内容
+{existing_section_content}
+
+## 用户反馈
+{feedback}
+
+## 相关背景数据
+{context_summary}
+
+请重新生成该部分内容,保持与原格式一致。只输出新的 content 字段值(Markdown 格式),不需要输出 JSON 结构。"""
+
+
+class PlanState(TypedDict):
+    member_ids: str
+    dimensions: str
+    goal: str
+    family_id: int
+    members_info: dict
+    indicators: list
+    abnormal_indicators: list
+    kb_results: list
+    overview: str
+    nutrition_section: str
+    diet_section: str
+    exercise_section: str
+    full_response: dict
+    error: str
+
+
+async def collect_data(state: PlanState) -> dict:
+    """Step 1: 收集用户数据 + 知识库检索"""
+    java = JavaClient()
+    retriever = RagRetriever(collection_name="cfc_knowledge")
+
+    member_ids_str = state.get("member_ids", "")
+    member_ids = [m.strip() for m in member_ids_str.split(",") if m.strip()]
+    goal = state.get("goal", "")
+    dimensions = state.get("dimensions", "") or ""
+
+    # 1a. 获取家庭成员信息
+    members_info = []
+    for uid_str in member_ids:
+        ctx = await java.get_family_context(int(uid_str), "child_info")
+        children = ctx.get("children", []) if isinstance(ctx, dict) else []
+        for child in children:
+            cid = str(child.get("用户ID", ""))
+            if cid == uid_str:
+                members_info.append({
+                    "id": cid,
+                    "name": child.get("姓名", f"成员{cid}"),
+                    "age": child.get("年龄", "未知"),
+                    "energy": child.get("能量", 0),
+                })
+                break
+        if not any(m["id"] == uid_str for m in members_info):
+            members_info.append({"id": uid_str, "name": f"成员{uid_str}", "age": "未知", "energy": 0})
+
+    # 1b. 获取健康指标
+    all_indicators = []
+    abnormal_list = []
+    for member in members_info:
+        reports = await java.get_member_reports(int(member["id"]))
+        if not reports:
+            continue
+        latest = max(reports, key=lambda r: r.get("reportDate", ""))
+        report_id = latest.get("id")
+        indicators = await java.get_report_indicators(report_id)
+        for ind in indicators:
+            ind["_member_id"] = member["id"]
+            ind["_member_name"] = member["name"]
+            all_indicators.append(ind)
+
+    # 1c. 识别异常指标
+    known_indicators = {}
+    for ind in all_indicators:
+        name = ind.get("indicatorName", "").strip()
+        if not name or name in known_indicators:
+            continue
+        for itype in ["indicator", "bacteria", "nutrient"]:
+            kb = await java.query_health_knowledge(itype, name)
+            if kb:
+                known_indicators[name] = kb
+                break
+
+    for ind in all_indicators:
+        name = ind.get("indicatorName", "")
+        status = ind.get("status", "")
+        kb = known_indicators.get(name, {})
+        entry = {
+            "member": ind.get("_member_name", ""),
+            "indicator": name,
+            "value": ind.get("indicatorValue", ""),
+            "unit": ind.get("unit", ""),
+            "ref_range": kb.get("normalRange", ind.get("refRange", "")),
+            "description": kb.get("description", ""),
+            "suggestion": kb.get("suggestion", ""),
+        }
+        if status in ("abnormal", "high", "low", "偏高", "偏低"):
+            abnormal_list.append(entry)
+
+    # 1d. 知识库检索
+    kb_results = []
+    queries = set()
+    for ind in abnormal_list:
+        queries.add(ind["indicator"])
+    queries.add(goal)
+    if dimensions:
+        queries.add(dimensions)
+    for q in list(queries)[:8]:
+        results = await retriever.retrieve(q, k=3)
+        kb_results.extend(results)
+
+    return {
+        "members_info": members_info,
+        "indicators": all_indicators,
+        "abnormal_indicators": abnormal_list,
+        "kb_results": kb_results,
+    }
+
+
+def build_prompt(state: PlanState) -> str:
+    """组装 LLM prompt"""
+    parts = [DEFAULT_PROMPT]
+
+    parts.append(f"\n## 用户目标\n{state['goal']}")
+    if state.get("dimensions"):
+        parts.append(f"\n## 重点关注维度\n{state['dimensions']}")
+
+    parts.append("\n## 家庭成员")
+    for m in state["members_info"]:
+        parts.append(f"- {m['name']} (年龄: {m['age']})")
+
+    if state["abnormal_indicators"]:
+        parts.append("\n## 异常指标")
+        for ind in state["abnormal_indicators"][:8]:
+            parts.append(
+                f"- {ind['member']} - {ind['indicator']}: {ind['value']}{ind.get('unit','')} "
+                f"(参考: {ind['ref_range']})"
+            )
+            if ind.get("description"):
+                parts.append(f"  说明: {ind['description']}")
+
+    if state["kb_results"]:
+        parts.append("\n## 知识库参考")
+        for r in state["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_plan(state: PlanState) -> dict:
+    """Step 2: 调用 LLM 生成结构化方案"""
+    llm = ChatOpenAI(
+        model=settings.llm_model,
+        api_key=settings.llm_api_key,
+        base_url=settings.llm_base_url,
+        temperature=0.3,
+    )
+    prompt = build_prompt(state)
+    messages = [SystemMessage(content=prompt)]
+    try:
+        response = await llm.ainvoke(messages)
+        answer = response.content
+        # 解析 JSON
+        import json
+        try:
+            # 提取 JSON 块
+            start = answer.find("{")
+            end = answer.rfind("}") + 1
+            if start >= 0 and end > start:
+                json_str = answer[start:end]
+                parsed = json.loads(json_str)
+                return {"full_response": parsed, "overview": parsed.get("overview", "")}
+        except (json.JSONDecodeError, Exception) as e:
+            logger.warning("解析方案 JSON 失败,使用原始文本: %s", e)
+        return {"full_response": {"raw": answer}, "overview": answer[:200]}
+    except Exception as e:
+        logger.error("LLM 生成方案失败: %s", e)
+        return {"error": str(e)}
+
+
+async def regenerate_section(state: PlanState) -> dict:
+    """重新生成指定 section"""
+    section_key = state.get("section", "nutrition")
+    feedback = state.get("feedback", "")
+    existing_content = state.get("existing_section_content", "")
+    llm = ChatOpenAI(
+        model=settings.llm_model,
+        api_key=settings.llm_api_key,
+        base_url=settings.llm_base_url,
+        temperature=0.3,
+    )
+
+    # 构建上下文摘要
+    ctx_parts = []
+    for m in state.get("members_info", []):
+        ctx_parts.append(f"- {m['name']} (年龄: {m['age']})")
+    if state.get("goal"):
+        ctx_parts.append(f"目标: {state['goal']}")
+    if state.get("abnormal_indicators"):
+        for ind in state["abnormal_indicators"][:5]:
+            ctx_parts.append(f"- {ind['member']}: {ind['indicator']}={ind['value']}")
+
+    prompt = REGENERATE_SECTION_PROMPT.format(
+        existing_section_content=existing_content[:500],
+        feedback=feedback,
+        context_summary="\n".join(ctx_parts),
+    )
+    try:
+        response = await llm.ainvoke([SystemMessage(content=prompt)])
+        return {"regenerated_content": response.content}
+    except Exception as e:
+        logger.error("重新生成方案 section 失败: %s", e)
+        return {"error": str(e)}
+
+
+def create_health_plan_graph():
+    builder = StateGraph(PlanState)
+    builder.add_node("collect_data", collect_data)
+    builder.add_node("generate_plan", generate_plan)
+    builder.add_node("regenerate_section", regenerate_section)
+    builder.add_edge(START, "collect_data")
+    builder.add_edge("collect_data", "generate_plan")
+    builder.add_edge("generate_plan", END)
+    # regenerate_section 从外部直接调用,不走图
+    graph = builder.compile()
+    return graph

+ 23 - 0
cfc-langgraph/app/models/health_plan.py

@@ -0,0 +1,23 @@
+from pydantic import BaseModel
+from typing import Optional, List
+
+
+class HealthPlanGenerateRequest(BaseModel):
+    member_ids: Optional[str] = None
+    dimensions: Optional[str] = None
+    goal: str = ""
+    family_id: Optional[int] = None
+
+
+class HealthPlanSection(BaseModel):
+    key: str  # "nutrition" | "diet" | "exercise"
+    title: str
+    content: str  # markdown text
+    items: List[dict] = []  # structured items for UI rendering
+
+
+class HealthPlanResponse(BaseModel):
+    overview: str = ""
+    sections: List[HealthPlanSection] = []
+    abnormal_indicators: List[dict] = []
+    knowledge_sources: List[dict] = []