Эх сурвалжийг харах

feat(health-status): 当前状态调研健康现状档案 — 后端+前端+LangGraph 全链路

后端: 迁移231 (health_status 表) + Entity/Mapper/Service/Controller (/api/health-status/get|save)
前端: 档案页 health-status-form.vue + pages.json 注册 + 健康主页入口
出方案: health-plan-summary.vue 读取健康现状+饮食偏好, 未填弹窗提醒, 随请求传参
AI: adapter._to_langgraph_context 提取 inputs.health_status/diet_preferences
    chat_graph.llm_call 注入健康现状/饮食偏好到 SystemMessage (prompt 上下文)
DI: @Resource, 统一 @PostMapping, JWT userId, upsert 语义
Xiaogang Liao 2 сар өмнө
parent
commit
bd5fe702bf

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -8904,6 +8904,7 @@ private void runMigration100() {
 			log.warn("插入admin_menu种子数据失败: {}", ex.getMessage());
 			log.warn("插入admin_menu种子数据失败: {}", ex.getMessage());
 		}
 		}
 
 
+<<<<<<< Updated upstream
 		// 迁移231: 画像驱动推荐 - member_profile_dimension 表
 		// 迁移231: 画像驱动推荐 - member_profile_dimension 表
 		try {
 		try {
 			jdbcTemplate.execute(
 			jdbcTemplate.execute(
@@ -9045,5 +9046,30 @@ private void runMigration100() {
 		ensureColumn("package_orders", "logistics_status", "VARCHAR(20) DEFAULT 'none' COMMENT 'none/kit_shipped/kit_received/sample_shipped/sample_received'");
 		ensureColumn("package_orders", "logistics_status", "VARCHAR(20) DEFAULT 'none' COMMENT 'none/kit_shipped/kit_received/sample_shipped/sample_received'");
 		ensureColumn("package_orders", "tracking_no", "VARCHAR(100) DEFAULT '' COMMENT '快递单号'");
 		ensureColumn("package_orders", "tracking_no", "VARCHAR(100) DEFAULT '' COMMENT '快递单号'");
 		log.info("已添加package_orders物流字段");
 		log.info("已添加package_orders物流字段");
+=======
+		// 迁移231: 创建 health_status 表(当前状态调研-健康现状档案,SS-2026-08-13)
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS health_status (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"user_id BIGINT NOT NULL COMMENT '所属账号(一个账号一份)', " +
+				"height_cm DECIMAL(5,1) DEFAULT NULL COMMENT '身高(cm)', " +
+				"weight_kg DECIMAL(5,1) DEFAULT NULL COMMENT '体重(kg)', " +
+				"blood_pressure VARCHAR(20) DEFAULT NULL COMMENT '血压,如 120/80', " +
+				"blood_glucose DECIMAL(4,1) DEFAULT NULL COMMENT '空腹血糖(mmol/L)', " +
+				"blood_lipids JSON DEFAULT NULL COMMENT '血脂 [{name,value,unit,note}]', " +
+				"disease_history JSON DEFAULT NULL COMMENT '疾病史 [{name,note}]', " +
+				"medications JSON DEFAULT NULL COMMENT '在服药物/治疗 [{name,note}]', " +
+				"notes VARCHAR(500) DEFAULT NULL COMMENT '其他补充说明', " +
+				"filled_at DATETIME DEFAULT NULL COMMENT '首次填写时间', " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
+				"UNIQUE KEY uk_user (user_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当前状态调研-健康现状档案'"
+			);
+			log.info("已创建health_status表");
+		} catch (Exception e) {
+			log.warn("创建health_status表失败: " + e.getMessage());
+		}
+>>>>>>> Stashed changes
 	}
 	}
 }
 }

+ 73 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java

@@ -0,0 +1,73 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthStatus;
+import com.etotem.cfc.service.HealthStatusService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+
+@Tag(name = "健康现状档案", description = "当前状态调研-健康现状档案接口")
+@RestController
+@RequestMapping("/api/health-status")
+public class HealthStatusController {
+
+    @Resource
+    private HealthStatusService healthStatusService;
+
+    @Operation(summary = "查询当前账号健康现状(未填写返回 null)")
+    @PostMapping("/get")
+    public Result<HealthStatus> get(@RequestAttribute("userId") Long userId) {
+        return Result.success(healthStatusService.getByUserId(userId));
+    }
+
+    @Operation(summary = "保存/更新当前账号健康现状(upsert)")
+    @PostMapping("/save")
+    public Result<HealthStatus> save(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody HealthStatus status) {
+        if (status == null || isEmpty(status)) {
+            return Result.error("请至少填写一项");
+        }
+        String validateMsg = validate(status);
+        if (validateMsg != null) {
+            return Result.error(validateMsg);
+        }
+        return Result.success(healthStatusService.save(userId, status));
+    }
+
+    /** 至少填写一项才算有效 */
+    private boolean isEmpty(HealthStatus s) {
+        return s.getHeightCm() == null && s.getWeightKg() == null
+                && s.getBloodPressure() == null && s.getBloodGlucose() == null
+                && s.getBloodLipids() == null && s.getDiseaseHistory() == null
+                && s.getMedications() == null && s.getNotes() == null;
+    }
+
+    /** 数值校验,返回错误信息或 null */
+    private String validate(HealthStatus s) {
+        if (s.getHeightCm() != null && (s.getHeightCm().compareTo(BigDecimal.valueOf(30)) < 0
+                || s.getHeightCm().compareTo(BigDecimal.valueOf(250)) > 0)) {
+            return "身高需在 30-250 cm 之间";
+        }
+        if (s.getWeightKg() != null && (s.getWeightKg().compareTo(BigDecimal.valueOf(3)) < 0
+                || s.getWeightKg().compareTo(BigDecimal.valueOf(300)) > 0)) {
+            return "体重需在 3-300 kg 之间";
+        }
+        if (s.getBloodPressure() != null && !s.getBloodPressure().matches("\\d{2,3}/\\d{2,3}")) {
+            return "血压格式应为 120/80";
+        }
+        if (s.getBloodGlucose() != null && (s.getBloodGlucose().compareTo(BigDecimal.ONE) < 0
+                || s.getBloodGlucose().compareTo(BigDecimal.valueOf(30)) > 0)) {
+            return "血糖需在 1-30 mmol/L 之间";
+        }
+        return null;
+    }
+}

+ 51 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthStatus.java

@@ -0,0 +1,51 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("health_status")
+public class HealthStatus implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 所属账号(一个账号一份) */
+    private Long userId;
+
+    /** 身高(cm) */
+    private BigDecimal heightCm;
+
+    /** 体重(kg) */
+    private BigDecimal weightKg;
+
+    /** 血压,如 120/80 */
+    private String bloodPressure;
+
+    /** 空腹血糖(mmol/L) */
+    private BigDecimal bloodGlucose;
+
+    /** 血脂 [{name,value,unit,note}] */
+    private String bloodLipids;
+
+    /** 疾病史 [{name,note}] */
+    private String diseaseHistory;
+
+    /** 在服药物/治疗 [{name,note}] */
+    private String medications;
+
+    /** 其他补充说明 */
+    private String notes;
+
+    /** 首次填写时间 */
+    private Date filledAt;
+
+    /** 更新时间 */
+    private Date updatedAt;
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthStatusMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.HealthStatus;
+
+public interface HealthStatusMapper extends BaseMapper<HealthStatus> {
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthStatusService.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.HealthStatus;
+
+public interface HealthStatusService {
+
+    /** 查询指定账号的健康现状,未填写返回 null */
+    HealthStatus getByUserId(Long userId);
+
+    /** 保存/更新(upsert:有则更新,无则插入;filledAt 仅首次赋值) */
+    HealthStatus save(Long userId, HealthStatus status);
+}

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthStatusServiceImpl.java

@@ -0,0 +1,46 @@
+package com.etotem.cfc.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HealthStatus;
+import com.etotem.cfc.mapper.HealthStatusMapper;
+import com.etotem.cfc.service.HealthStatusService;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+
+@Service
+public class HealthStatusServiceImpl implements HealthStatusService {
+
+    @Resource
+    private HealthStatusMapper healthStatusMapper;
+
+    @Override
+    public HealthStatus getByUserId(Long userId) {
+        if (userId == null) return null;
+        return healthStatusMapper.selectOne(
+                new LambdaQueryWrapper<HealthStatus>()
+                        .eq(HealthStatus::getUserId, userId));
+    }
+
+    @Override
+    public HealthStatus save(Long userId, HealthStatus status) {
+        if (userId == null) throw new IllegalArgumentException("userId不能为空");
+        if (status == null) throw new IllegalArgumentException("健康现状不能为空");
+
+        HealthStatus existing = getByUserId(userId);
+        status.setUserId(userId);
+        if (existing == null) {
+            status.setId(null);
+            status.setFilledAt(new Date());
+            status.setUpdatedAt(new Date());
+            healthStatusMapper.insert(status);
+        } else {
+            status.setId(existing.getId());
+            status.setFilledAt(existing.getFilledAt());
+            status.setUpdatedAt(new Date());
+            healthStatusMapper.updateById(status);
+        }
+        return getByUserId(userId);
+    }
+}

+ 19 - 0
cfc-backend/src/main/resources/schema.sql

@@ -4836,6 +4836,7 @@ INSERT IGNORE INTO admin_menu (parent_id, name, path, icon, sort_order, perm, co
 (14, '菜单管理', '/menu-manage', 'el-icon-menu', 1, 'system:menu', 'admin/MenuManage', 1),
 (14, '菜单管理', '/menu-manage', 'el-icon-menu', 1, 'system:menu', 'admin/MenuManage', 1),
 (14, '系统配置', '/sys-config', 'el-icon-setting', 2, 'system:config', 'admin/SysConfig', 1);
 (14, '系统配置', '/sys-config', 'el-icon-setting', 2, 'system:config', 'admin/SysConfig', 1);
 
 
+<<<<<<< Updated upstream
 -- =============================================
 -- =============================================
 -- 改版v1: 需求驱动·逐步解锁(问题域/用户意图/站内通知)
 -- 改版v1: 需求驱动·逐步解锁(问题域/用户意图/站内通知)
 -- =============================================
 -- =============================================
@@ -4886,3 +4887,21 @@ CREATE TABLE IF NOT EXISTS user_notifications (
   created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
   created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
   INDEX idx_user_read (user_id, is_read)
   INDEX idx_user_read (user_id, is_read)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站内通知(改版v1)';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站内通知(改版v1)';
+=======
+-- 当前状态调研-健康现状档案(迁移231)
+CREATE TABLE IF NOT EXISTS health_status (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '所属账号(一个账号一份)',
+    height_cm DECIMAL(5,1) DEFAULT NULL COMMENT '身高(cm)',
+    weight_kg DECIMAL(5,1) DEFAULT NULL COMMENT '体重(kg)',
+    blood_pressure VARCHAR(20) DEFAULT NULL COMMENT '血压,如 120/80',
+    blood_glucose DECIMAL(4,1) DEFAULT NULL COMMENT '空腹血糖(mmol/L)',
+    blood_lipids JSON DEFAULT NULL COMMENT '血脂 [{name,value,unit,note}]',
+    disease_history JSON DEFAULT NULL COMMENT '疾病史 [{name,note}]',
+    medications JSON DEFAULT NULL COMMENT '在服药物/治疗 [{name,note}]',
+    notes VARCHAR(500) DEFAULT NULL COMMENT '其他补充说明',
+    filled_at DATETIME DEFAULT NULL COMMENT '首次填写时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当前状态调研-健康现状档案';
+>>>>>>> Stashed changes

+ 6 - 0
cfc-frontend/pages.json

@@ -895,6 +895,12 @@
             "navigationBarTitleText": "方案汇总"
             "navigationBarTitleText": "方案汇总"
           }
           }
         },
         },
+        {
+          "path": "health-status-form",
+          "style": {
+            "navigationBarTitleText": "健康档案"
+          }
+        },
         {
         {
           "path": "survey-questionnaire",
           "path": "survey-questionnaire",
           "style": {
           "style": {

+ 5 - 0
cfc-frontend/pages/health-main/index.vue

@@ -31,6 +31,11 @@
           <text class="feat-title">智能健康方案</text>
           <text class="feat-title">智能健康方案</text>
           <text class="feat-desc">选维度·选成员·定目标</text>
           <text class="feat-desc">选维度·选成员·定目标</text>
         </view>
         </view>
+        <view class="feat-card" @click="goPage('/pages/health/health-status-form')">
+          <text class="feat-icon">📋</text>
+          <text class="feat-title">健康档案</text>
+          <text class="feat-desc">身高体重·血压血糖·疾病史</text>
+        </view>
       </view>
       </view>
       <!-- 自测入口 -->
       <!-- 自测入口 -->
       <view class="self-test-card" @click="goSelfTest">
       <view class="self-test-card" @click="goSelfTest">

+ 70 - 3
cfc-frontend/pages/health/health-plan-summary.vue

@@ -157,7 +157,8 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { getFamilyReports, getFamilyMembers, aiSendMessage, createTask } from '../../utils/api'
+import { getFamilyReports, getFamilyMembers, getHealthStatus, getDietPreferences, aiSendMessage, createTask } from '../../utils/api'
+import { parseDate } from '../../utils/format'
 
 
 export default {
 export default {
   data() {
   data() {
@@ -173,7 +174,9 @@ export default {
       planContent: '',
       planContent: '',
       parsedTasks: [],
       parsedTasks: [],
       taskSubmitted: false,
       taskSubmitted: false,
-      taskSubmittedCount: 0
+      taskSubmittedCount: 0,
+      healthStatus: null,
+      dietPrefs: null
     }
     }
   },
   },
   computed: {
   computed: {
@@ -192,7 +195,10 @@ export default {
     this.subjectId = options.subjectId || ''
     this.subjectId = options.subjectId || ''
     this.loadFamilyReports()
     this.loadFamilyReports()
     this.loadExecutors()
     this.loadExecutors()
+    this.loadHealthContext()
   },
   },
+  onShow: function() {
+    this.loadHealthContext()
   methods: {
   methods: {
     formatDate: function(ts) {
     formatDate: function(ts) {
       if (!ts) return '--'
       if (!ts) return '--'
@@ -238,6 +244,19 @@ export default {
         }
         }
       }).catch(function() { self.executors = [] })
       }).catch(function() { self.executors = [] })
     },
     },
+    loadHealthContext: function() {
+      var self = this
+      getHealthStatus().then(function(res) {
+        if (res && res.code === 200) {
+          self.healthStatus = res.data || null
+        }
+      }).catch(function() { self.healthStatus = null })
+      getDietPreferences().then(function(res) {
+        if (res && res.code === 200) {
+          self.dietPrefs = res.data || null
+        }
+      }).catch(function() { self.dietPrefs = null })
+    },
     toggleReport: function(reportId) {
     toggleReport: function(reportId) {
       var idx = this.selectedReportIds.indexOf(reportId)
       var idx = this.selectedReportIds.indexOf(reportId)
       if (idx >= 0) this.selectedReportIds.splice(idx, 1)
       if (idx >= 0) this.selectedReportIds.splice(idx, 1)
@@ -246,9 +265,57 @@ export default {
     generatePlan: function() {
     generatePlan: function() {
       var self = this
       var self = this
       if (!self.canGenerate) return
       if (!self.canGenerate) return
+      if (!self.healthStatus) {
+        uni.showModal({
+          title: '填写健康现状',
+          content: '尚未填写健康现状,方案可能不够精准。是否先填写?',
+          confirmText: '去填写',
+          cancelText: '直接生成',
+          success: function(res) {
+            if (res.confirm) {
+              uni.navigateTo({ url: '/pages/health/health-status-form' })
+            } else {
+              self.doGeneratePlan()
+            }
+          }
+        })
+        return
+      }
+      if (self.isStatusStale()) {
+        uni.showModal({
+          title: '健康现状已较旧',
+          content: '上次填写时间较早,建议先更新健康现状以获得更精准方案。是否更新?',
+          confirmText: '去更新',
+          cancelText: '直接生成',
+          success: function(res) {
+            if (res.confirm) {
+              uni.navigateTo({ url: '/pages/health/health-status-form' })
+            } else {
+              self.doGeneratePlan()
+            }
+          }
+        })
+        return
+      }
+      self.doGeneratePlan()
+    },
+    isStatusStale: function() {
+      var updatedAt = this.healthStatus && this.healthStatus.updatedAt
+      if (!updatedAt) return false
+      var d = parseDate(updatedAt)
+      if (!d) return false
+      var sixMonthsAgo = new Date()
+      sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6)
+      return d.getTime() < sixMonthsAgo.getTime()
+    },
+    doGeneratePlan: function() {
+      var self = this
       self.loading = true
       self.loading = true
       var reportIdsStr = self.selectedReportIds.join(',')
       var reportIdsStr = self.selectedReportIds.join(',')
-      aiSendMessage({ query: self.userGoal.trim(), reportId: reportIdsStr }).then(function(res) {
+      var sendData = { query: self.userGoal.trim(), reportId: reportIdsStr }
+      if (self.healthStatus) sendData.healthStatus = JSON.stringify(self.healthStatus)
+      if (self.dietPrefs) sendData.dietPrefs = JSON.stringify(self.dietPrefs)
+      aiSendMessage(sendData).then(function(res) {
         self.loading = false
         self.loading = false
         var answer = ''
         var answer = ''
         if (res && res.answer) {
         if (res && res.answer) {

+ 298 - 0
cfc-frontend/pages/health/health-status-form.vue

@@ -0,0 +1,298 @@
+<template>
+  <view class="hs-page">
+    <view class="nav-bar">
+      <text class="nav-back" @click="goBack">\ue601 返回</text>
+      <text class="nav-title">健康档案</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <view class="hs-tip">
+      <text class="hs-tip-text">填写当前健康现状,生成方案时 AI 将参考这些信息,方案更精准。可不填,但未填时方案可能不够准确。</text>
+    </view>
+
+    <view class="form-section">
+      <view class="form-title">基础体征</view>
+      <view class="form-row">
+        <text class="form-label">身高 (cm)</text>
+        <input class="form-input" type="digit" v-model="form.heightCm" placeholder="如 170" />
+      </view>
+      <view class="form-row">
+        <text class="form-label">体重 (kg)</text>
+        <input class="form-input" type="digit" v-model="form.weightKg" placeholder="如 60" />
+      </view>
+      <view class="form-row">
+        <text class="form-label">血压</text>
+        <input class="form-input" type="text" v-model="form.bloodPressure" placeholder="如 120/80" />
+      </view>
+      <view class="form-row">
+        <text class="form-label">空腹血糖 (mmol/L)</text>
+        <input class="form-input" type="digit" v-model="form.bloodGlucose" placeholder="如 5.6" />
+      </view>
+    </view>
+
+    <view class="form-section">
+      <view class="form-title">血脂</view>
+      <view v-for="(item, idx) in lipidRows" :key="idx" class="lipid-row">
+        <input class="lipid-name" type="text" v-model="item.name" placeholder="指标名" />
+        <input class="lipid-value" type="digit" v-model="item.value" placeholder="数值" />
+        <input class="lipid-unit" type="text" v-model="item.unit" placeholder="单位" />
+        <text class="lipid-del" @click="removeLipid(idx)">删除</text>
+      </view>
+      <text class="add-link" @click="addLipid">+ 添加血脂指标</text>
+    </view>
+
+    <view class="form-section">
+      <view class="form-title">疾病史</view>
+      <view class="tag-wrap">
+        <view
+          v-for="(tag, idx) in diseaseTags"
+          :key="idx"
+          class="tag-item"
+          :class="{ 'tag-active': form.diseaseNames.indexOf(tag) >= 0 }"
+          @click="toggleDisease(tag)"
+        >
+          <text>{{ tag }}</text>
+        </view>
+        <view v-for="(custom, idx) in customDiseases" :key="'c' + idx" class="tag-item tag-active">
+          <text>{{ custom }}</text>
+          <text class="tag-del" @click="removeCustomDisease(idx)">×</text>
+        </view>
+      </view>
+      <view class="custom-row">
+        <input class="custom-input" type="text" v-model="diseaseInput" placeholder="输入其他疾病" />
+        <text class="add-link" @click="addCustomDisease">添加</text>
+      </view>
+    </view>
+
+    <view class="form-section">
+      <view class="form-title">在服药物/治疗</view>
+      <view v-for="(med, idx) in medRows" :key="idx" class="med-row">
+        <input class="med-name" type="text" v-model="med.name" placeholder="药物/治疗名称" />
+        <input class="med-note" type="text" v-model="med.note" placeholder="备注(可选)" />
+        <text class="lipid-del" @click="removeMed(idx)">删除</text>
+      </view>
+      <text class="add-link" @click="addMed">+ 添加药物/治疗</text>
+    </view>
+
+    <view class="form-section">
+      <view class="form-title">其他补充</view>
+      <textarea class="notes-area" v-model="form.notes" placeholder="其他想补充的健康信息(选填)" :maxlength="500" />
+    </view>
+
+    <view class="save-bar">
+      <button class="save-btn" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存' }}</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getHealthStatus, saveHealthStatus } from '../../utils/api'
+
+var DISEASE_PRESETS = ['高血压', '糖尿病', '高血脂', '过敏性鼻炎', '哮喘', '贫血', '胃炎', '甲状腺疾病', '心脏疾病', '肾脏疾病']
+
+export default {
+  data() {
+    return {
+      form: {
+        heightCm: '',
+        weightKg: '',
+        bloodPressure: '',
+        bloodGlucose: '',
+        bloodLipids: '',
+        diseaseHistory: '',
+        medications: '',
+        notes: ''
+      },
+      lipidRows: [],
+      diseaseTags: DISEASE_PRESETS.slice(),
+      diseaseNames: [],
+      customDiseases: [],
+      diseaseInput: '',
+      medRows: [],
+      saving: false
+    }
+  },
+  onLoad: function() {
+    this.load()
+  },
+  methods: {
+    load: function() {
+      var self = this
+      getHealthStatus().then(function(res) {
+        if (res && res.code === 200 && res.data) {
+          var d = res.data
+          self.form.heightCm = d.heightCm || ''
+          self.form.weightKg = d.weightKg || ''
+          self.form.bloodPressure = d.bloodPressure || ''
+          self.form.bloodGlucose = d.bloodGlucose || ''
+          self.form.notes = d.notes || ''
+          self.lipidRows = self.parseLipids(d.bloodLipids)
+          self.loadDiseases(d.diseaseHistory)
+          self.medRows = self.parseMeds(d.medications)
+        }
+      }).catch(function() {})
+    },
+    parseLipids: function(json) {
+      var list = []
+      try {
+        var arr = JSON.parse(json || '[]')
+        if (arr instanceof Array) {
+          arr.forEach(function(item) {
+            list.push({ name: item.name || '', value: item.value || '', unit: item.unit || '', note: item.note || '' })
+          })
+        }
+      } catch (e) {}
+      if (list.length === 0) list.push({ name: '', value: '', unit: '', note: '' })
+      return list
+    },
+    loadDiseases: function(json) {
+      var self = this
+      try {
+        var arr = JSON.parse(json || '[]')
+        if (arr instanceof Array) {
+          arr.forEach(function(item) {
+            if (item.name && self.diseaseNames.indexOf(item.name) < 0) {
+              self.diseaseNames.push(item.name)
+            }
+          })
+        }
+      } catch (e) {}
+    },
+    parseMeds: function(json) {
+      var list = []
+      try {
+        var arr = JSON.parse(json || '[]')
+        if (arr instanceof Array) {
+          arr.forEach(function(item) {
+            list.push({ name: item.name || '', note: item.note || '' })
+          })
+        }
+      } catch (e) {}
+      if (list.length === 0) list.push({ name: '', note: '' })
+      return list
+    },
+    addLipid: function() {
+      this.lipidRows.push({ name: '', value: '', unit: '', note: '' })
+    },
+    removeLipid: function(idx) {
+      if (this.lipidRows.length > 1) this.lipidRows.splice(idx, 1)
+    },
+    toggleDisease: function(tag) {
+      var idx = this.diseaseNames.indexOf(tag)
+      if (idx >= 0) this.diseaseNames.splice(idx, 1)
+      else this.diseaseNames.push(tag)
+    },
+    addCustomDisease: function() {
+      var name = (this.diseaseInput || '').trim()
+      if (!name) return
+      if (this.diseaseNames.indexOf(name) < 0) {
+        this.diseaseNames.push(name)
+        this.customDiseases.push(name)
+      }
+      this.diseaseInput = ''
+    },
+    removeCustomDisease: function(idx) {
+      var name = this.customDiseases[idx]
+      this.customDiseases.splice(idx, 1)
+      var i = this.diseaseNames.indexOf(name)
+      if (i >= 0) this.diseaseNames.splice(i, 1)
+    },
+    addMed: function() {
+      this.medRows.push({ name: '', note: '' })
+    },
+    removeMed: function(idx) {
+      if (this.medRows.length > 1) this.medRows.splice(idx, 1)
+    },
+    buildPayload: function() {
+      var lipids = []
+      this.lipidRows.forEach(function(item) {
+        if (item.name && item.value) {
+          lipids.push({ name: item.name, value: parseFloat(item.value), unit: item.unit || '', note: item.note || '' })
+        }
+      })
+      var diseases = []
+      this.diseaseNames.forEach(function(name) {
+        diseases.push({ name: name, note: '' })
+      })
+      var meds = []
+      this.medRows.forEach(function(item) {
+        if (item.name) {
+          meds.push({ name: item.name, note: item.note || '' })
+        }
+      })
+      var payload = {}
+      if (this.form.heightCm) payload.heightCm = Number(this.form.heightCm)
+      if (this.form.weightKg) payload.weightKg = Number(this.form.weightKg)
+      if (this.form.bloodPressure) payload.bloodPressure = this.form.bloodPressure
+      if (this.form.bloodGlucose) payload.bloodGlucose = Number(this.form.bloodGlucose)
+      if (lipids.length > 0) payload.bloodLipids = JSON.stringify(lipids)
+      if (diseases.length > 0) payload.diseaseHistory = JSON.stringify(diseases)
+      if (meds.length > 0) payload.medications = JSON.stringify(meds)
+      if (this.form.notes) payload.notes = this.form.notes
+      return payload
+    },
+    save: function() {
+      var self = this
+      var payload = this.buildPayload()
+      if (Object.keys(payload).length === 0) {
+        uni.showToast({ title: '请至少填写一项', icon: 'none' })
+        return
+      }
+      if (this.form.bloodPressure && !/^\d{2,3}\/\d{2,3}$/.test(this.form.bloodPressure)) {
+        uni.showToast({ title: '血压格式应为 120/80', icon: 'none' })
+        return
+      }
+      self.saving = true
+      saveHealthStatus(payload).then(function(res) {
+        self.saving = false
+        if (res && res.code === 200) {
+          uni.showToast({ title: '已保存', icon: 'success' })
+          setTimeout(function() { uni.navigateBack() }, 800)
+        } else {
+          uni.showToast({ title: (res && res.message) || '保存失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.saving = false
+        uni.showToast({ title: '保存失败,请检查网络', icon: 'none' })
+      })
+    },
+    goBack: function() {
+      uni.navigateBack()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.hs-page { min-height: 100vh; background: #F5F7FA; padding-bottom: 140rpx; }
+.nav-bar { display: flex; align-items: center; justify-content: space-between; height: 88rpx; padding: 0 30rpx; background: #fff; border-bottom: 1rpx solid #eee; position: sticky; top: 0; z-index: 10; }
+.nav-back { font-size: 32rpx; color: #F97316; }
+.nav-title { font-size: 34rpx; font-weight: bold; color: #333; }
+.nav-placeholder { width: 80rpx; }
+.hs-tip { margin: 24rpx 30rpx; padding: 20rpx 24rpx; background: #FFF7E6; border-radius: 12rpx; }
+.hs-tip-text { font-size: 26rpx; color: #8A6D3B; line-height: 1.6; }
+.form-section { margin: 0 30rpx 24rpx; background: #fff; border-radius: 16rpx; padding: 24rpx; }
+.form-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; }
+.form-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20rpx; }
+.form-label { font-size: 28rpx; color: #555; }
+.form-input { flex: 1; margin-left: 30rpx; height: 68rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 20rpx; font-size: 28rpx; text-align: right; }
+.lipid-row { display: flex; align-items: center; margin-bottom: 16rpx; }
+.lipid-name { width: 30%; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
+.lipid-value { width: 25%; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
+.lipid-unit { width: 22%; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
+.lipid-del { font-size: 26rpx; color: #E64340; }
+.tag-wrap { display: flex; flex-wrap: wrap; }
+.tag-item { padding: 12rpx 24rpx; background: #F5F7FA; border-radius: 30rpx; font-size: 26rpx; color: #666; margin: 0 16rpx 16rpx 0; }
+.tag-active { background: #F97316; color: #fff; }
+.tag-del { margin-left: 8rpx; font-size: 24rpx; }
+.custom-row { display: flex; align-items: center; margin-top: 8rpx; }
+.custom-input { flex: 1; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 20rpx; font-size: 26rpx; margin-right: 20rpx; }
+.add-link { font-size: 28rpx; color: #F97316; }
+.med-row { display: flex; align-items: center; margin-bottom: 16rpx; }
+.med-name { flex: 1; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
+.med-note { flex: 1; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
+.notes-area { width: 100%; height: 160rpx; background: #F5F7FA; border-radius: 10rpx; padding: 16rpx 20rpx; font-size: 26rpx; box-sizing: border-box; }
+.save-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); }
+.save-btn { width: 100%; background: #F97316; color: #fff; border-radius: 44rpx; height: 84rpx; line-height: 84rpx; font-size: 32rpx; }
+.save-btn[disabled] { opacity: 0.6; }
+</style>

+ 14 - 1
cfc-frontend/utils/api.js

@@ -1326,8 +1326,18 @@ export const aiSendMessage = (data) => {
     response_mode: 'blocking',
     response_mode: 'blocking',
     bot_name: 'AI管家'
     bot_name: 'AI管家'
   }
   }
+  var inputs = {}
   if (data && data.reportId) {
   if (data && data.reportId) {
-    langgraphBody.inputs = { report_id: data.reportId }
+    inputs.report_id = data.reportId
+  }
+  if (data && data.healthStatus) {
+    inputs.health_status = data.healthStatus
+  }
+  if (data && data.dietPrefs) {
+    inputs.diet_preferences = data.dietPrefs
+  }
+  if (Object.keys(inputs).length > 0) {
+    langgraphBody.inputs = inputs
   }
   }
   return langgraphRequest({
   return langgraphRequest({
     langgraphPath: '/api/v1/chat/completion',
     langgraphPath: '/api/v1/chat/completion',
@@ -2614,6 +2624,9 @@ export const uploadServiceRoleImage = (filePath) => {
 export const getRecommendList = (data) => request('/api/recommend/list', 'POST', data)
 export const getRecommendList = (data) => request('/api/recommend/list', 'POST', data)
 export const trackRecommendAction = (data) => request('/api/recommend/track', 'POST', data)
 export const trackRecommendAction = (data) => request('/api/recommend/track', 'POST', data)
 
 
+export const getHealthStatus = () => request('/api/health-status/get', 'POST', {})
+export const saveHealthStatus = (data) => request('/api/health-status/save', 'POST', data)
+
 // ===== 饮食模块 =====
 // ===== 饮食模块 =====
 export const getDietPreferences = () => request('/api/diet/preferences/current-member', 'POST', {})
 export const getDietPreferences = () => request('/api/diet/preferences/current-member', 'POST', {})
 export const saveDietPreferences = (data) => request('/api/diet/preferences/save', 'POST', data)
 export const saveDietPreferences = (data) => request('/api/diet/preferences/save', 'POST', data)

+ 271 - 28
cfc-langgraph/app/api/adapter.py

@@ -5,8 +5,18 @@ from app.graphs.chat_graph import create_chat_graph
 from app.graphs.analysis_graph import create_analysis_graph
 from app.graphs.analysis_graph import create_analysis_graph
 from app.graphs.health_coach_graph import create_health_coach_graph
 from app.graphs.health_coach_graph import create_health_coach_graph
 from app.graphs.health_butler_graph import create_health_butler_graph
 from app.graphs.health_butler_graph import create_health_butler_graph
+from app.tools.java_client import JavaClient
+from app.rag.retriever import RagRetriever
+from app.config import settings
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
 import uuid
 import uuid
 import time
 import time
+import re
+import logging
+import json
+
+logger = logging.getLogger(__name__)
 
 
 router = APIRouter(prefix="/api/v1", tags=["adapter"])
 router = APIRouter(prefix="/api/v1", tags=["adapter"])
 
 
@@ -284,37 +294,270 @@ async def health_butler(req: DifyChatRequest):
     )
     )
 
 
 
 
+def _parse_normal_range(ref_range: str) -> tuple[float | None, float | None]:
+    """解析正常范围字符串,返回 (下限, 上限),如 '30-100' → (30, 100), '<5' → (None, 5)"""
+    if not ref_range:
+        return None, None
+    ref_range = ref_range.strip()
+    m = re.match(r'([<>]=?)\s*([\d.]+)', ref_range)
+    if m:
+        op, val = m.group(1), float(m.group(2))
+        if op.startswith('>'):
+            return (val, None)
+        else:
+            return (None, val)
+    m = re.match(r'([\d.]+)\s*[-~]\s*([\d.]+)', ref_range)
+    if m:
+        return (float(m.group(1)), float(m.group(2)))
+    return None, None
+
+
+def _is_abnormal(status: str, value: float | None, low: float | None, high: float | None) -> bool:
+    """判断指标是否异常:优先用 status 字段,否则用数值与范围比较"""
+    if status and status not in ("正常", "正常范围", "未检出", ""):
+        return True
+    if value is not None and low is not None and high is not None:
+        return value < low or value > high
+    return False
+
+
+def _try_parse_value(raw: str) -> float | None:
+    if not raw:
+        return None
+    raw = raw.strip().replace(",", "").replace(" ", "")
+    try:
+        return float(raw)
+    except ValueError:
+        return None
+
+
+async def _search_knowledge(retriever: RagRetriever, query: str, k: int = 3) -> list[dict]:
+    """从知识库检索相关内容"""
+    try:
+        return await retriever.retrieve(query, k=k)
+    except Exception as e:
+        logger.warning("知识库检索失败: %s", e)
+        return []
+
+
 @router.post("/health/coach/generate")
 @router.post("/health/coach/generate")
 async def health_coach_generate(req: HealthCoachGenerateRequest):
 async def health_coach_generate(req: HealthCoachGenerateRequest):
-    """Java 后端健康方案生成端点 — 接收 familyId/memberIds/dimensions/goal,返回方案文本"""
-    graph = create_health_coach_graph()
-    goal = req.goal or "生成健康方案"
-    family_id = req.family_id
-    member_ids = req.member_ids
-    dimensions = req.dimensions
+    """健康方案生成 — 选人→拉指标→查知识库→LLM"""
+    goal = req.goal or "改善健康状况"
+    member_ids_str = req.member_ids or ""
+    dimensions = req.dimensions or ""
+    member_ids = [m.strip() for m in member_ids_str.split(",") if m.strip()]
+
+    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,
+    )
 
 
-    # 构造用户查询:将目标和维度信息注入 prompt
-    query_parts = [goal]
-    if dimensions:
-        query_parts.append(f"重点关注维度:{dimensions}")
+    # ====== 1. 获取家庭成员信息 ======
+    members_info = []
     if member_ids:
     if member_ids:
-        query_parts.append(f"目标成员IDs:{member_ids}")
-    query = ";".join(query_parts)
+        # 从 context 获取家庭信息
+        for uid_str in member_ids:
+            uid = int(uid_str)
+            ctx = await java.get_family_context(uid, "child_info")
+            children = ctx.get("children", []) if isinstance(ctx, dict) else []
+            for child in children:
+                child_id = str(child.get("用户ID", ""))
+                if child_id in member_ids:
+                    members_info.append({
+                        "id": child_id,
+                        "name": child.get("姓名", f"成员{child_id}"),
+                        "age": child.get("年龄", "未知"),
+                        "energy": child.get("能量", 0),
+                    })
+                    break
+            # 如果 context 没找到,用基本信息兜底
+            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})
+    else:
+        members_info.append({"id": "0", "name": "用户", "age": "未知", "energy": 0})
+
+    # ====== 2. 获取每个成员的指标数据 ======
+    all_indicators = []
+    for member in members_info:
+        uid = int(member["id"])
+        reports = await java.get_member_reports(uid)
+        if not reports:
+            logger.info("成员 %s 无健康报告", member["id"])
+            continue
+        latest = max(reports, key=lambda r: r.get("reportDate", ""))
+        report_id = latest.get("id")
+        member["latest_report_id"] = report_id
+        member["report_date"] = latest.get("reportDate", "")
+        member["overall_score"] = latest.get("overallScore", "未知")
+
+        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)
+
+    # ====== 3. 获取指标定义+正常范围 ======
+    known_indicators = {}
+    for ind in all_indicators:
+        name = ind.get("indicatorName", "").strip()
+        if not name or name in known_indicators:
+            continue
+        kb = await java.query_health_knowledge("indicator", name)
+        if not kb:
+            kb = await java.query_health_knowledge("bacteria", name)
+        if not kb:
+            kb = await java.query_health_knowledge("nutrient", name)
+        if kb:
+            known_indicators[name] = kb
+
+    # ====== 4. 识别异常指标 ======
+    abnormal_list = []
+    normal_list = []
+    for ind in all_indicators:
+        name = ind.get("indicatorName", "")
+        raw_val = ind.get("indicatorValue", "")
+        status = ind.get("status", "")
+        unit = ind.get("unit", "")
+        ref_range = ind.get("refRange", "")
+
+        # 优先用知识库中的正常范围
+        kb = known_indicators.get(name)
+        if kb and kb.get("normalRange"):
+            ref_range = kb.get("normalRange", ref_range)
+
+        low, high = _parse_normal_range(ref_range)
+        value = _try_parse_value(raw_val)
+        is_abnormal = _is_abnormal(status, value, low, high)
+
+        entry = {
+            "member": ind.get("_member_name", ""),
+            "indicator": name,
+            "value": raw_val,
+            "unit": unit,
+            "ref_range": ref_range,
+            "status": status,
+            "is_abnormal": is_abnormal,
+            "description": kb.get("description", "") if kb else "",
+            "suggestion": kb.get("suggestion", "") if kb else "",
+        }
+        if is_abnormal:
+            abnormal_list.append(entry)
+        else:
+            normal_list.append(entry)
+
+    # ====== 5. 检索知识库 ======
+    kb_results = []
+
+    # 5a. 异常指标检索
+    abnormal_queries = set()
+    for ind in abnormal_list:
+        abnormal_queries.add(ind["indicator"])
+    for q in list(abnormal_queries)[:5]:
+        results = await _search_knowledge(retriever, f"{q} 改善建议", k=3)
+        kb_results.extend(results)
+
+    # 5b. 用户需求检索
+    goal_results = await _search_knowledge(retriever, goal, k=5)
+    kb_results.extend(goal_results)
+
+    # 5c. 维度检索
+    if dimensions:
+        dim_results = await _search_knowledge(retriever, dimensions, k=3)
+        kb_results.extend(dim_results)
+
+    # 去重
+    seen_content = set()
+    deduped_kb = []
+    for r in kb_results:
+        h = r.get("content", "")[:100]
+        if h not in seen_content:
+            seen_content.add(h)
+            deduped_kb.append(r)
+
+    # ====== 6. 组装结构化 Prompt ======
+    prompt_parts = []
+
+    # 系统提示
+    prompt_parts.append("""你是一个专业的家庭健康方案生成器。请根据用户提供的健康数据,生成一份结构化的健康改善方案。
+
+输出格式要求:
+## 方案概述
+[简要说明方案的总体目标和适用对象]
+
+## 成员健康概况
+[每个成员的关键指标摘要]
+
+## 需要关注的异常指标
+[列出异常指标及对应的知识库建议]
+
+## 改善方案
+### 1. 饮食调整
+[具体、可执行的饮食建议]
+
+### 2. 生活习惯
+[具体、可执行的生活习惯建议]
+
+### 3. 补充建议
+[如需补充营养素或益生菌,给出具体建议]
+
+### 4. 跟踪建议
+[建议定期复查的指标和频率]
+
+## 注意事项
+[禁忌、提醒等]
+
+请基于实际数据给出建议,不要编造科学依据。引用知识库内容时标注来源。""")
+
+    # 目标与维度
+    prompt_parts.append(f"\n## 用户目标\n{goal}")
+    if dimensions:
+        prompt_parts.append(f"\n## 重点关注维度\n{dimensions}")
+
+    # 成员信息
+    prompt_parts.append("\n## 家庭成员")
+    for m in members_info:
+        scores = f"健康评分: {m.get('overall_score', '未知')}" if m.get('overall_score') else ""
+        report = f"最近报告: {m.get('report_date', '无')}" if m.get('report_date') else ""
+        prompt_parts.append(f"- {m['name']} (年龄: {m['age']}) {scores} {report}")
+
+    # 异常指标
+    if abnormal_list:
+        prompt_parts.append("\n## 异常指标")
+        for ind in abnormal_list:
+            parts = [f"- {ind['member']} - {ind['indicator']}: {ind['value']}{ind['unit']} (参考范围: {ind['ref_range']})"]
+            if ind['description']:
+                parts.append(f"  说明: {ind['description']}")
+            if ind['suggestion']:
+                parts.append(f"  建议: {ind['suggestion']}")
+            prompt_parts.append("\n".join(parts))
+
+    # 正常指标
+    if normal_list:
+        prompt_parts.append("\n## 正常指标(参考)")
+        normal_summary = [f"- {ind['indicator']}: {ind['value']}{ind['unit']} (正常)" for ind in normal_list[:10]]
+        prompt_parts.extend(normal_summary)
+
+    # 知识库参考
+    if deduped_kb:
+        prompt_parts.append("\n## 知识库参考(可引用)")
+        for r in deduped_kb[:8]:
+            title = r.get("metadata", {}).get("title", "")
+            content = r.get("content", "")[:300]
+            prompt_parts.append(f"---\n{title}\n{content}")
+
+    full_prompt = "\n".join(prompt_parts)
+
+    # ====== 7. 调用 LLM ======
+    messages = [
+        SystemMessage(content=full_prompt),
+        HumanMessage(content=f"请基于以上数据,生成一份针对{goal}的健康改善方案。"),
+    ]
+    response = await llm.ainvoke(messages)
+    answer = response.content
 
 
-    initial_state = {
-        "query": query,
-        "user_id": family_id or 0,
-        "child_id": None,
-        "conversation_id": None,
-        "context": {
-            "family_id": family_id,
-            "member_ids": member_ids,
-            "dimensions": dimensions,
-        },
-        "answer": None,
-        "sources": [],
-        "memory_messages": None,
-    }
-    result = await graph.ainvoke(initial_state, {})
-    answer = result.get("answer") or ""
     return answer
     return answer

+ 15 - 4
cfc-langgraph/app/graphs/chat_graph.py

@@ -89,11 +89,22 @@ def create_chat_graph():
         """核心 LLM 调用 + Tool"""
         """核心 LLM 调用 + Tool"""
         messages = [SystemMessage(content=CHAT_SYSTEM_PROMPT)]
         messages = [SystemMessage(content=CHAT_SYSTEM_PROMPT)]
 
 
-        # 注入家庭上下文
+        # 注入家庭上下文 + 用户健康现状
         ctx = state.get("context", {})
         ctx = state.get("context", {})
-        if ctx:
-            ctx_text = f"\n家庭上下文:\n{ctx}"
-            messages.append(SystemMessage(content=ctx_text))
+        if isinstance(ctx, dict):
+            ctx_parts = []
+            if ctx.get("child_id") is not None:
+                ctx_parts.append(f"child_id: {ctx['child_id']}")
+            if ctx.get("report_id") is not None:
+                ctx_parts.append(f"report_id: {ctx['report_id']}")
+            if ctx.get("family_id") is not None:
+                ctx_parts.append(f"family_id: {ctx['family_id']}")
+            if ctx.get("health_status"):
+                ctx_parts.append(f"用户健康现状: {ctx['health_status']}")
+            if ctx.get("diet_preferences"):
+                ctx_parts.append(f"用户饮食偏好: {ctx['diet_preferences']}")
+            if ctx_parts:
+                messages.append(SystemMessage(content="\n家庭上下文:\n" + "\n".join(ctx_parts)))
 
 
         # 注入长期记忆
         # 注入长期记忆
         try:
         try: