Przeglądaj źródła

feat(health): 健康问卷按性别/年龄动态调整问题

- LifestyleStep 新增 profile prop,按 gender/age 动态渲染:
  · 女性专属:月经周期规律性 + 特殊时期(备孕/怀孕/哺乳)
  · 儿童(<12):生长发育 + 挑食情况
  · 青少年(12-18):屏幕时间 + 情绪状态
  · 老年(≥60):骨质疏松风险 + 跌倒风险
- MemberChipPicker 透传 gender/age
- health-plan-summary 新增 memberProfileMap 计算属性 + 传递给向导
- HealthQuestionnaireWizard 新增 memberProfileMap prop + currentMemberProfile 计算
- buildHealthPayload/parseLifestyle 包含全部 lifestyle 新字段
- LangGraph adapter 消费 lifestyle + diet_structure,prompt 不确定时提示菌群检测
- health_plan.py 新增 MultiMemberPlanResponse(多成员方案)
- 修复 mergeWizardForm 丢失 lifestyle 字段 bug
Sisyphus 1 tydzień temu
rodzic
commit
37f53013e0

+ 21 - 1
cfc-frontend/pages/health/components/HealthQuestionnaireWizard.vue

@@ -39,6 +39,7 @@
         <lifestyle-step
           ref="stepRef"
           :value="currentMemberForm"
+          :profile="currentMemberProfile"
           @input="onLifestyleInput"
         />
       </view>
@@ -105,6 +106,11 @@ export default {
       type: Object,
       default: function() { return {} }
     },
+    // 成员基本信息:memberId → { gender, age }(问卷按 profile 动态调整问题)
+    memberProfileMap: {
+      type: Object,
+      default: function() { return {} }
+    },
     // 当前填写成员
     activeFormMemberId: {
       type: String,
@@ -147,7 +153,15 @@ export default {
         bowelFrequency: snapshot.lifestyle && snapshot.lifestyle.bowelFrequency || '',
         bowelForm: snapshot.lifestyle && snapshot.lifestyle.bowelForm || '',
         antibioticHistory: snapshot.lifestyle && snapshot.lifestyle.antibioticHistory || '',
-        allergySymptoms: snapshot.lifestyle && Array.isArray(snapshot.lifestyle.allergySymptoms) ? snapshot.lifestyle.allergySymptoms : []
+        allergySymptoms: snapshot.lifestyle && Array.isArray(snapshot.lifestyle.allergySymptoms) ? snapshot.lifestyle.allergySymptoms : [],
+        menstrualCycle: snapshot.lifestyle && snapshot.lifestyle.menstrualCycle || '',
+        specialPeriod: snapshot.lifestyle && snapshot.lifestyle.specialPeriod || '',
+        growthDevelopment: snapshot.lifestyle && snapshot.lifestyle.growthDevelopment || '',
+        pickyEating: snapshot.lifestyle && snapshot.lifestyle.pickyEating || '',
+        osteoporosisRisk: snapshot.lifestyle && snapshot.lifestyle.osteoporosisRisk || '',
+        fallRisk: snapshot.lifestyle && snapshot.lifestyle.fallRisk || '',
+        screenTime: snapshot.lifestyle && snapshot.lifestyle.screenTime || '',
+        teenMood: snapshot.lifestyle && snapshot.lifestyle.teenMood || ''
       }
     },
     // 当前成员的饮食习惯
@@ -155,6 +169,12 @@ export default {
       var map = this.dietMap || {}
       var key = this.activeFormMemberId || '__account'
       return map[key] || {}
+    },
+    // 当前成员的基本信息(性别/年龄),供 LifestyleStep 动态渲染
+    currentMemberProfile: function() {
+      var map = this.memberProfileMap || {}
+      var key = this.activeFormMemberId || '__account'
+      return map[key] || {}
     }
   },
   watch: {

+ 146 - 8
cfc-frontend/pages/health/components/LifestyleStep.vue

@@ -59,6 +59,70 @@
         <view v-for="opt in allergyOptions" :key="opt" class="chip-item-wrap" :class="{ 'chip-active': inner.allergySymptoms && inner.allergySymptoms.indexOf(opt) >= 0 }" @click="toggleChip('allergySymptoms', opt)"><text>{{ opt }}</text></view>
       </view>
     </view>
+
+    <!-- ===== 按性别动态:女性专属 ===== -->
+    <view v-if="isFemale">
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">月经周期</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in menstrualOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.menstrualCycle === opt }" @click="setField('menstrualCycle', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">特殊时期</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in specialPeriodOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.specialPeriod === opt }" @click="setField('specialPeriod', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+    </view>
+
+    <!-- ===== 按年龄动态:儿童专属 ===== -->
+    <view v-if="isChild">
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">生长发育</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in growthOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.growthDevelopment === opt }" @click="setField('growthDevelopment', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">挑食情况</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in pickyEatingOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.pickyEating === opt }" @click="setField('pickyEating', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+    </view>
+
+    <!-- ===== 按年龄动态:老年专属 ===== -->
+    <view v-if="isElderly">
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">骨质疏松风险</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in osteoporosisOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.osteoporosisRisk === opt }" @click="setField('osteoporosisRisk', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">跌倒风险</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in fallRiskOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.fallRisk === opt }" @click="setField('fallRisk', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+    </view>
+
+    <!-- ===== 按年龄动态:青少年专属 ===== -->
+    <view v-if="isTeenager">
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">屏幕时间</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in screenTimeOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.screenTime === opt }" @click="setField('screenTime', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+      <view class="form-card">
+        <view class="card-head"><text class="card-title">情绪状态</text><text class="card-hint">单选</text></view>
+        <view class="chip-row">
+          <view v-for="opt in teenMoodOptions" :key="opt" class="chip-item" :class="{ 'chip-active': inner.teenMood === opt }" @click="setField('teenMood', opt)"><text>{{ opt }}</text></view>
+        </view>
+      </view>
+    </view>
   </view>
 </template>
 
@@ -66,27 +130,86 @@
 export default {
   name: 'LifestyleStep',
   props: {
-    value: { type: Object, default: function() { return {} } }
+    value: { type: Object, default: function() { return {} } },
+    profile: { type: Object, default: function() { return {} } }
   },
   data: function() {
     return {
-      inner: { mood: '', sleepDuration: '', bedtime: '', bowelFrequency: '', bowelForm: '', antibioticHistory: '', allergySymptoms: [] }
+      inner: {
+        mood: '', sleepDuration: '', bedtime: '', bowelFrequency: '', bowelForm: '', antibioticHistory: '', allergySymptoms: [],
+        menstrualCycle: '', specialPeriod: '', growthDevelopment: '', pickyEating: '', osteoporosisRisk: '', fallRisk: '', screenTime: '', teenMood: ''
+      }
     }
   },
   computed: {
+    isFemale: function() {
+      var g = this.profile && this.profile.gender
+      return g === 'female' || g === '女'
+    },
+    isChild: function() {
+      var age = this.profile && this.profile.age
+      return age != null && Number(age) > 0 && Number(age) < 12
+    },
+    isTeenager: function() {
+      var age = this.profile && this.profile.age
+      return age != null && Number(age) >= 12 && Number(age) < 18
+    },
+    isElderly: function() {
+      var age = this.profile && this.profile.age
+      return age != null && Number(age) >= 60
+    },
     moodOptions: function() { return ['良好', '一般', '低落', '焦虑'] },
     sleepOptions: function() { return ['<6小时', '6-7小时', '7-8小时', '>8小时'] },
     bedtimeOptions: function() { return ['22:00前', '22:00-24:00', '24:00后'] },
     bowelFreqOptions: function() { return ['每天1次(正常)', '隔天1次', '每周2-3次', '每周<2次'] },
     bowelFormOptions: function() { return ['干硬颗粒', '条状干硬', '正常香蕉状', '软便', '水样'] },
     antibioticOptions: function() { return ['无', '曾使用过(未用完疗程)', '按疗程使用完', '频繁使用(近2年≥3次)'] },
-    allergyOptions: function() { return ['无', '反复皮疹', '鼻痒/打喷嚏', '眼痒/流泪', '哮喘样症状', '食物过敏'] }
+    allergyOptions: function() { return ['无', '反复皮疹', '鼻痒/打喷嚏', '眼痒/流泪', '哮喘样症状', '食物过敏'] },
+    menstrualOptions: function() { return ['规律(28-32天)', '不规律', '已停经', '尚未开始'] },
+    specialPeriodOptions: function() { return ['无', '备孕中', '怀孕', '哺乳期'] },
+    growthOptions: function() { return ['正常(在生长曲线内)', '偏慢', '偏快', '不清楚'] },
+    pickyEatingOptions: function() { return ['无挑食', '轻微挑食', '明显挑食', '严重挑食'] },
+    osteoporosisOptions: function() { return ['无风险', '有风险(曾骨折/骨密度低)', '不清楚'] },
+    fallRiskOptions: function() { return ['近1年无跌倒', '近1年跌倒1次', '近1年跌倒2次及以上'] },
+    screenTimeOptions: function() { return ['<1小时/天', '1-3小时/天', '3-5小时/天', '>5小时/天'] },
+    teenMoodOptions: function() { return ['开朗', '一般', '易怒', '低落'] }
   },
   watch: {
-    value: { deep: true, handler: function(val) { this.syncFromValue(val) } }
+    value: { deep: true, handler: function(val) { this.syncFromValue(val) } },
+    profile: { deep: true, handler: function(val) { this.syncProfile(val) } }
+  },
+  created: function() {
+    this.syncFromValue(this.value)
+    this.syncProfile(this.profile)
   },
-  created: function() { this.syncFromValue(this.value) },
   methods: {
+    // 切换成员/更新 profile 时:仅清除当前性别/年龄不再适用的专属字段,
+    // 保留适用字段的已存答案(先由 syncFromValue 回填,这里只做减法)
+    syncProfile: function(p) {
+      if (!p) return
+      var g = p.gender
+      var age = p.age != null ? Number(p.age) : null
+      var isFemale = g === 'female' || g === '女'
+      var isChild = age != null && age > 0 && age < 12
+      var isTeen = age != null && age >= 12 && age < 18
+      var isElderly = age != null && age >= 60
+      if (!isFemale) {
+        this.inner.menstrualCycle = ''
+        this.inner.specialPeriod = ''
+      }
+      if (!isChild) {
+        this.inner.growthDevelopment = ''
+        this.inner.pickyEating = ''
+      }
+      if (!isTeen) {
+        this.inner.screenTime = ''
+        this.inner.teenMood = ''
+      }
+      if (!isElderly) {
+        this.inner.osteoporosisRisk = ''
+        this.inner.fallRisk = ''
+      }
+    },
     syncFromValue: function(val) {
       if (!val) return
       this.inner.mood = val.mood || ''
@@ -96,6 +219,15 @@ export default {
       this.inner.bowelForm = val.bowelForm || ''
       this.inner.antibioticHistory = val.antibioticHistory || ''
       this.inner.allergySymptoms = Array.isArray(val.allergySymptoms) ? val.allergySymptoms.slice() : []
+      // 兼容:按成员加载时若已有答案则回填(不在此处重置)
+      this.inner.menstrualCycle = val.menstrualCycle || ''
+      this.inner.specialPeriod = val.specialPeriod || ''
+      this.inner.growthDevelopment = val.growthDevelopment || ''
+      this.inner.pickyEating = val.pickyEating || ''
+      this.inner.osteoporosisRisk = val.osteoporosisRisk || ''
+      this.inner.fallRisk = val.fallRisk || ''
+      this.inner.screenTime = val.screenTime || ''
+      this.inner.teenMood = val.teenMood || ''
     },
     setField: function(field, value) {
       this.inner[field] = value
@@ -115,7 +247,15 @@ export default {
         bowelFrequency: this.inner.bowelFrequency,
         bowelForm: this.inner.bowelForm,
         antibioticHistory: this.inner.antibioticHistory,
-        allergySymptoms: (this.inner.allergySymptoms || []).slice()
+        allergySymptoms: (this.inner.allergySymptoms || []).slice(),
+        menstrualCycle: this.inner.menstrualCycle,
+        specialPeriod: this.inner.specialPeriod,
+        growthDevelopment: this.inner.growthDevelopment,
+        pickyEating: this.inner.pickyEating,
+        osteoporosisRisk: this.inner.osteoporosisRisk,
+        fallRisk: this.inner.fallRisk,
+        screenTime: this.inner.screenTime,
+        teenMood: this.inner.teenMood
       })
     }
   }
@@ -135,6 +275,4 @@ export default {
 .chip-row-wrap { display: flex; flex-wrap: wrap; }
 .chip-item, .chip-item-wrap { padding: 12rpx 28rpx; border-radius: 999rpx; background: #F1F5F9; font-size: 24rpx; color: #334155; margin: 0 16rpx 14rpx 0; }
 .chip-active { background: #FFF3E0; color: #F97316; font-weight: 600; }
-.custom-row { margin-top: 10rpx; }
-.custom-hint { font-size: 22rpx; color: #F97316; }
 </style>

+ 4 - 0
cfc-frontend/pages/health/components/MemberChipPicker.vue

@@ -80,6 +80,8 @@ export default {
           memberId: m.memberId,
           name: m.name,
           nickname: m.nickname,
+          gender: m.gender || '',
+          age: m.age != null ? m.age : null,
           selected: !!selected[String(m.id)]
         })
       }
@@ -135,6 +137,8 @@ export default {
           memberId: String(mid),
           name: String(nm),
           nickname: String(nm),
+          gender: m.gender || '',
+          age: m.age != null ? m.age : null,
           selected: false
         })
       }

+ 62 - 18
cfc-frontend/pages/health/health-plan-summary.vue

@@ -56,6 +56,7 @@
         ref="wizard"
         :member-form-map="memberFormMap"
         :diet-map="memberDietMap"
+        :member-profile-map="memberProfileMap"
         :active-form-member-id="activeFormMemberId"
         :submitting="loading"
         @update-vitals="onWizardVitals"
@@ -208,6 +209,8 @@ export default {
       activeFormMemberId: '',
       // 方案数据
       planData: {},
+      // 多人方案结构化数据(common/individual),保存时原样写入 planJson
+      multiMemberPlanJson: null,
       sections: {
         nutrition: { content: '', confirmed: false },
         diet: { content: '', confirmed: false },
@@ -283,6 +286,22 @@ selectedMembers: function() {
         }
       }
       return '成员'
+    },
+    // 成员基本信息(性别/年龄),供问卷按 profile 动态调整问题
+    memberProfileMap: function() {
+      var out = {}
+      var self = this
+      this.selectedMemberIds.forEach(function(id) {
+        var found = null
+        for (var i = 0; i < self.pickerMembers.length; i++) {
+          var mid = self.pickerMembers[i].memberId || self.pickerMembers[i].id
+          if (String(mid) === String(id)) { found = self.pickerMembers[i]; break }
+        }
+        if (found) {
+          out[String(id)] = { gender: found.gender || '', age: found.age != null ? found.age : null }
+        }
+      })
+      return out
     }
   },
   onLoad: function(options) {
@@ -527,7 +546,8 @@ selectedMembers: function() {
         lipidCholesterol: flat.lipidCholesterol != null ? flat.lipidCholesterol : (prev.lipidCholesterol || ''),
         selectedDiseases: flat.selectedDiseases != null ? flat.selectedDiseases : (prev.selectedDiseases || []),
         medRows: flat.medRows != null ? flat.medRows : (prev.medRows || []),
-        noMedication: flat.noMedication != null ? flat.noMedication : (prev.noMedication || false)
+        noMedication: flat.noMedication != null ? flat.noMedication : (prev.noMedication || false),
+        lifestyle: prev.lifestyle || {}
       })
     },
     buildHealthPayload: function(snapshot) {
@@ -562,7 +582,7 @@ selectedMembers: function() {
       if (meds.length > 0) payload.medications = JSON.stringify(meds)
       if (s.form.notes) payload.notes = s.form.notes
       var lifestyle = s.lifestyle || {}
-      var lifestyleKeys = ['mood', 'sleepDuration', 'bedtime', 'bowelFrequency', 'bowelForm', 'antibioticHistory', 'allergySymptoms']
+      var lifestyleKeys = ['mood', 'sleepDuration', 'bedtime', 'bowelFrequency', 'bowelForm', 'antibioticHistory', 'allergySymptoms', 'menstrualCycle', 'specialPeriod', 'growthDevelopment', 'pickyEating', 'osteoporosisRisk', 'fallRisk', 'screenTime', 'teenMood']
       var hasLifestyle = lifestyleKeys.some(function(k) {
         return lifestyle[k] && (Array.isArray(lifestyle[k]) ? lifestyle[k].length > 0 : true)
       })
@@ -597,6 +617,14 @@ selectedMembers: function() {
           obj.bowelForm = parsed.bowelForm || ''
           obj.antibioticHistory = parsed.antibioticHistory || ''
           obj.allergySymptoms = Array.isArray(parsed.allergySymptoms) ? parsed.allergySymptoms.slice() : []
+          obj.menstrualCycle = parsed.menstrualCycle || ''
+          obj.specialPeriod = parsed.specialPeriod || ''
+          obj.growthDevelopment = parsed.growthDevelopment || ''
+          obj.pickyEating = parsed.pickyEating || ''
+          obj.osteoporosisRisk = parsed.osteoporosisRisk || ''
+          obj.fallRisk = parsed.fallRisk || ''
+          obj.screenTime = parsed.screenTime || ''
+          obj.teenMood = parsed.teenMood || ''
         }
       } catch (e) {}
       return obj
@@ -659,11 +687,18 @@ selectedMembers: function() {
         self.loading = false
         if (res && res.code === 200 && res.data) {
           var data = res.data
-          if (typeof data === 'string') {
+          // 多人方案:后端返回 {planContent, common, individual} 结构化对象
+          if (data && typeof data === 'object' && data.common && data.individual) {
+            self.planData = { overview: data.planContent || '' }
+            self.multiMemberPlanJson = { common: data.common, individual: data.individual }
+            self.step = 3
+          } else if (typeof data === 'string') {
             self.planData = { overview: data }
+            self.multiMemberPlanJson = null
             self.step = 3
           } else if (data.overview) {
             self.planData = data
+            self.multiMemberPlanJson = null
             if (data.sections && data.sections instanceof Array) {
               data.sections.forEach(function(sec) {
                 if (sec.key && self.sections[sec.key]) {
@@ -675,6 +710,7 @@ selectedMembers: function() {
             self.step = 3
           } else if (data.raw) {
             self.planData = { overview: data.raw.substring(0, 200) }
+            self.multiMemberPlanJson = null
             self.step = 3
           } else {
             uni.showToast({ title: '生成失败,请重试', icon: 'none' })
@@ -784,12 +820,14 @@ selectedMembers: function() {
     submitPlan: function() {
       var self = this
       self.loading = true
-      var planJson = JSON.stringify({
-        overview: self.planData.overview || '',
-        sections: self.sectionKeys.map(function(key) {
-          return { key: key, content: self.sections[key].content, items: self.sections[key].items || [], tasks: self.sections[key].tasks || [] }
+      var planJson = self.multiMemberPlanJson
+        ? JSON.stringify(self.multiMemberPlanJson)
+        : JSON.stringify({
+          overview: self.planData.overview || '',
+          sections: self.sectionKeys.map(function(key) {
+            return { key: key, content: self.sections[key].content, items: self.sections[key].items || [], tasks: self.sections[key].tasks || [] }
+          })
         })
-      })
       // 读取前端规划的师选择(teacher-service.vue 已写入本地缓存)
       var teacherId = uni.getStorageSync('healthPlan_teacherId') || null
       var saveData = {
@@ -843,20 +881,26 @@ selectedMembers: function() {
         if (res && res.code === 200 && res.data) {
           self.planDetail = res.data
           self.userGoal = res.data.goal != null ? String(res.data.goal) : ''
-          if (res.data.memberIds) {
+            if (res.data.memberIds) {
             self.selectedMemberIds = res.data.memberIds.split(',').filter(function(id) { return id }).map(function(id) { return String(parseInt(id) || id) })
           }
           try {
             var pj = JSON.parse(res.data.planJson || '{}')
-            if (pj.overview) self.planData.overview = pj.overview
-            if (pj.sections && pj.sections instanceof Array) {
-              pj.sections.forEach(function(sec) {
-                if (sec.key && self.sections[sec.key]) {
-                  self.sections[sec.key].content = sec.content || ''
-                  self.sections[sec.key].items = sec.items || []
-                  self.sections[sec.key].confirmed = true
-                }
-              })
+            // 多人方案:planJson 为 {common: {...}, individual: {memberId: {...}}}
+            if (pj.common && pj.individual) {
+              self.planData = { overview: res.data.planContent || (pj.common.overview || '') }
+              self.multiMemberPlanJson = pj
+            } else {
+              if (pj.overview) self.planData.overview = pj.overview
+              if (pj.sections && pj.sections instanceof Array) {
+                pj.sections.forEach(function(sec) {
+                  if (sec.key && self.sections[sec.key]) {
+                    self.sections[sec.key].content = sec.content || ''
+                    self.sections[sec.key].items = sec.items || []
+                    self.sections[sec.key].confirmed = true
+                  }
+                })
+              }
             }
           } catch (e) {
             if (res.data.planContent) {

+ 208 - 2
cfc-langgraph/app/api/adapter.py

@@ -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}")

+ 56 - 1
cfc-langgraph/app/models/health_plan.py

@@ -1,5 +1,5 @@
 from pydantic import BaseModel
-from typing import Literal, Optional, List
+from typing import Literal, Optional, List, Dict, Any
 
 
 class HealthPlanGenerateRequest(BaseModel):
@@ -26,7 +26,62 @@ class HealthPlanSection(BaseModel):
 
 
 class HealthPlanResponse(BaseModel):
+    """单成员方案 / 兼容旧结构(扁平 sections)。
+
+    多成员(common+individual)请使用 MultiMemberPlanResponse。
+    本类保留以向后兼容单人方案路径(len(member_ids) <= 1)。
+    """
+    overview: str = ""
+    sections: List[HealthPlanSection] = []
+    abnormal_indicators: List[dict] = []
+    knowledge_sources: List[dict] = []
+
+
+class PlanPart(BaseModel):
+    """方案的一个逻辑部分:可承载共性(common)或个性(individual)。"""
     overview: str = ""
     sections: List[HealthPlanSection] = []
     abnormal_indicators: List[dict] = []
     knowledge_sources: List[dict] = []
+
+
+class MultiMemberPlanResponse(BaseModel):
+    """多人方案:共性 + 各成员个性。
+
+    - common: 适用于全体成员的共性方案部分
+    - individual: 按 member_id 分组的个性方案部分
+    """
+    common: PlanPart = PlanPart()
+    individual: Dict[str, PlanPart] = {}
+
+    def build_rendered_content(self, member_names: Dict[str, str]) -> str:
+        """把 common+individual 组合渲染为 Markdown 文本(供 planContent 使用)。
+
+        member_names: {memberId: 成员名},用于在标题里标注个性部分归属。
+        """
+        out: List[str] = []
+        out.append("# 健康方案\n\n")
+        common = self.common
+        out.append("## 共性方案\n")
+        if common.overview:
+            out.append("### 方案概述\n\n" + common.overview + "\n\n")
+        for sec in common.sections:
+            out.append(f"## {sec.title}\n\n{sec.content}\n\n")
+        if common.abnormal_indicators:
+            out.append("## 共性异常指标\n")
+            for ind in common.abnormal_indicators:
+                out.append(f"- {ind.get('member','?')} - {ind.get('indicator','')}: {ind.get('value','')} {ind.get('unit','')}")
+        if self.individual:
+            out.append("\n---\n\n# 个性方案\n")
+            for mid, part in self.individual.items():
+                name = member_names.get(mid, f"成员{mid}")
+                out.append(f"\n## {name} 个性方案\n")
+                if part.overview:
+                    out.append("### 概述\n\n" + part.overview + "\n")
+                for sec in part.sections:
+                    out.append(f"### {sec.title}\n\n{sec.content}\n")
+                if part.abnormal_indicators:
+                    out.append("### 异常指标\n")
+                    for ind in part.abnormal_indicators:
+                        out.append(f"- {ind.get('member','?')} - {ind.get('indicator','')}: {ind.get('value','')} {ind.get('unit','')}")
+        return "".join(out)