Просмотр исходного кода

HL-03-merge: HL-03归因方案+R3对话追问并入cfclub基线(含HL-04对话式调整) - 冲突双方保留

HealthPlanService: HL-03 generateAttributionPlans/detectFollowUpQuestions/5参generatePlanResult 与 HL-04 adjustPlanSection 并存
health-plan-summary.vue: HL-03 Step4a归因+Step4b追问 与 HL-04 对话式调整 UI/methods 并存
验证: mvn compile+test-compile 通过; verify-vue-compile PASS; audit-mp-antipatterns ERROR=0
Agnes Agent 6 дней назад
Родитель
Сommit
0e72ed7b98

+ 44 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java

@@ -11,6 +11,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import com.etotem.cfc.util.ParamUtils;
@@ -50,10 +52,51 @@ public class HealthPlanController {
         if (dimensions == null || goal == null || goal.trim().isEmpty()) {
             return Result.error("dimensions和goal不能为空");
         }
-        Object result = healthPlanService.generatePlanResult(familyId, memberIds, dimensions, goal);
+        // R3(HL-03):携带 followUpAnswers 时进入「对话补充后生成」链路;
+        // 未携带时先检测不确定点——命中返回 needFollowUp + followUpQuestions(不生成方案,前端对话收集回答后回传)
+        @SuppressWarnings("unchecked")
+        Map<String, Object> followUpAnswers = (Map<String, Object>) params.get("followUpAnswers");
+        boolean hasAnswers = followUpAnswers != null && !followUpAnswers.isEmpty();
+        if (!hasAnswers) {
+            List<Map<String, Object>> questions = healthPlanService.detectFollowUpQuestions(familyId, memberIds, goal);
+            if (questions != null && !questions.isEmpty()) {
+                Map<String, Object> followUpResp = new HashMap<>();
+                followUpResp.put("needFollowUp", true);
+                followUpResp.put("followUpQuestions", questions);
+                return Result.success(followUpResp);
+            }
+            Object result = healthPlanService.generatePlanResult(familyId, memberIds, dimensions, goal);
+            return Result.success(result);
+        }
+        Object result = healthPlanService.generatePlanResult(familyId, memberIds, dimensions, goal, followUpAnswers);
         return Result.success(result);
     }
 
+    /**
+     * R2 归因方案(HL-03):签名/问卷确认后、解决方案生成之前,先生成归因方案讲清要解决的问题。
+     *
+     * <p>Body: { memberIds, goal } → { goal, summary, members: [{memberId, name, age, problems, riskNotes, dataGaps}] }
+     * 规则驱动(读取 health_status + diet_preferences),不依赖 AI / 归因报告管线。
+     */
+    @Operation(summary = "归因方案(讲清要解决的问题,R2/HL-03)")
+    @PostMapping("/attribution-plans")
+    public Result<Map<String, Object>> attributionPlans(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        boolean isMember = !"FREE".equals(membershipService.getMemberLevel(userId));
+        java.util.Map<String, Object> purchaseCheck = purchaseService.checkPurchase(userId);
+        if (!isMember && !Boolean.TRUE.equals(purchaseCheck.get("isActive"))) {
+            return Result.error(4001, "请升级为家庭会员或购买健康启航计划");
+        }
+        String memberIds = (String) params.get("memberIds");
+        String goal = (String) params.get("goal");
+        if (memberIds == null || memberIds.trim().isEmpty() || goal == null || goal.trim().isEmpty()) {
+            return Result.error("memberIds和goal不能为空");
+        }
+        return Result.success(healthPlanService.generateAttributionPlans(familyId, memberIds, goal));
+    }
+
     @Operation(summary = "重新生成方案某一部分(营养/饮食/运动)")
     @PostMapping("/regenerate-section")
     public Result<String> regenerateSection(

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java

@@ -36,6 +36,35 @@ public interface HealthPlanService {
      * @return Map{section, content, tasks?};AI 服务不可用或调整失败返回 null(调用方保持原内容)
      */
     Map<String, Object> adjustPlanSection(Long familyId, String section, String intent, String sectionContent);
+
+    /**
+     * R2 归因方案:基于成员健康档案(health_status)+ 饮食偏好(diet_preferences)+ 目标,生成归因说明,
+     * 讲清「当前要解决的核心问题」(规则驱动,不依赖 AI / 归因报告管线)。
+     *
+     * <p>HL-03(2026-11-07 方案制定流程重构 R2):解决方案生成之前先出归因方案,用户确认后才进入解决方案。
+     *
+     * @param familyId  家庭ID(可为 null,按 memberIds 解析成员时用不到)
+     * @param memberIds 逗号分隔的成员ID
+     * @param goal      用户健康目标(用于目标-问题对齐)
+     * @return { goal, summary, members: [{memberId, name, age, problems[], riskNotes[], dataGaps[]}] }
+     */
+    Map<String, Object> generateAttributionPlans(Long familyId, String memberIds, String goal);
+
+    /**
+     * R3 检测方案生成前需补充的特殊问题(问卷未覆盖但生成方案时需要)。
+     *
+     * <p>仅当成员存在问卷数据且存在「不确定点」时返回;无问卷数据(跳过问卷)、或信息充足返回空列表。
+     * 返回项:{ questionId, question, options? },questionId 用于回传 followUpAnswers 对应键。
+     */
+    List<Map<String, Object>> detectFollowUpQuestions(Long familyId, String memberIds, String goal);
+
+    /**
+     * R3 生成方案(携带用户对话补充答案):答案并入生成上下文(goal 增强)后再走既有生成链路。
+     *
+     * @param followUpAnswers 用户补充答案 { questionId: answer };为空时等价于 4 参 generatePlanResult
+     */
+    Object generatePlanResult(Long familyId, String memberIds, String dimensions, String goal,
+                              Map<String, Object> followUpAnswers);
     /** 规划师查看待审核方案列表 */
     List<HealthPlan> listPendingReviewPlans(Long familyId, Long teacherId);
     /** 规划师编辑方案内容 */

+ 439 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -9,12 +9,17 @@ import com.etotem.cfc.entity.HealthPlan;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.User;
+import com.etotem.cfc.entity.HealthStatus;
+import com.etotem.cfc.entity.DietPreferences;
 import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
 import com.etotem.cfc.mapper.HealthPlanMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.mapper.HealthStatusMapper;
+import com.etotem.cfc.mapper.DietPreferencesMapper;
 import com.etotem.cfc.service.HealthPlanService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
@@ -61,6 +66,12 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     @Resource
     private FiveDimensionSelfCheckMapper selfCheckMapper;
 
+    @Resource
+    private HealthStatusMapper healthStatusMapper;
+
+    @Resource
+    private DietPreferencesMapper dietPreferencesMapper;
+
     @Resource
     private com.etotem.cfc.service.AiGateway aiGateway;
 
@@ -322,6 +333,434 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return buildSamplePlan(memberIds, dimensions, goal);
     }
 
+    /**
+     * R2 归因方案(HL-03):基于成员健康档案 + 饮食偏好 + 目标,规则驱动生成归因说明。
+     * 讲清当前要解决的问题;不含治疗方案(RL05 合规:不宣称治疗,只陈述数据事实与建议方向)。
+     */
+    @Override
+    public Map<String, Object> generateAttributionPlans(Long familyId, String memberIds, String goal) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("goal", goal != null ? goal.trim() : "");
+        List<Map<String, Object>> members = new ArrayList<>();
+        List<String> memberIdList = splitMemberIds(memberIds);
+        for (String rawId : memberIdList) {
+            Long memberId = Long.valueOf(rawId.trim());
+            FamilyMember fm = familyMemberMapper.selectById(memberId);
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("memberId", memberId);
+            m.put("name", fm != null && fm.getNickname() != null ? fm.getNickname() : "成员" + memberId);
+            if (fm != null && fm.getBirthday() != null) {
+                m.put("age", computeAge(fm.getBirthday()));
+            } else {
+                m.put("age", null);
+            }
+            List<String> problems = new ArrayList<>();
+            List<String> riskNotes = new ArrayList<>();
+            List<String> dataGaps = new ArrayList<>();
+
+            HealthStatus hs = loadHealthStatus(memberId);
+            DietPreferences dp = loadDietPreferences(memberId);
+
+            collectAttributionFromHealth(hs, problems, riskNotes, dataGaps);
+            collectAttributionFromDiet(dp, problems, dataGaps);
+
+            m.put("problems", problems);
+            m.put("riskNotes", riskNotes);
+            m.put("dataGaps", dataGaps);
+            members.add(m);
+        }
+        result.put("members", members);
+
+        // 摘要:把每个成员的问题合并为「要解决的问题」陈述(目标对齐)
+        StringBuilder summary = new StringBuilder();
+        summary.append("基于成员健康问卷与饮食档案,当前重点需要解决的问题如下");
+        if (goal != null && !goal.trim().isEmpty()) {
+            summary.append("(围绕目标「").append(goal.trim().length() > 30 ? goal.trim().substring(0, 30) + "…" : goal.trim()).append("」)");
+        }
+        summary.append("。");
+        result.put("summary", summary.toString());
+        return result;
+    }
+
+    /**
+     * R3 不确定点检测(HL-03):问卷有数据但关键信息缺失时,返回需对话补充的问题。
+     * 无数据(跳过问卷)或信息充足 → 空列表(不影响既有回归)。
+     */
+    @Override
+    public List<Map<String, Object>> detectFollowUpQuestions(Long familyId, String memberIds, String goal) {
+        List<Map<String, Object>> questions = new ArrayList<>();
+        List<String> memberIdList = splitMemberIds(memberIds);
+        if (memberIdList.isEmpty()) return questions;
+        boolean goalSleep = containsAny(goal, "睡眠", "失眠", "入睡", "熬夜", "早起");
+        boolean goalGut = containsAny(goal, "肠胃", "肠道", "消化", "排便", "便秘", "腹泻", "菌群", "益生");
+        boolean goalAllergy = containsAny(goal, "过敏", "湿疹", "皮肤", "鼻炎", "荨麻疹");
+
+        for (String rawId : memberIdList) {
+            Long memberId = Long.valueOf(rawId.trim());
+            HealthStatus hs = loadHealthStatus(memberId);
+            DietPreferences dp = loadDietPreferences(memberId);
+            if (hs == null && dp == null) continue; // 无问卷数据 → 不打扰(保持「跳过问卷直接生成」回归)
+
+            List<String> allergies = parseNameList(dp != null ? dp.getAllergies() : null);
+
+            // 1) 睡眠信息缺失且目标涉及睡眠
+            if (goalSleep && hs != null && questions.size() < 3) {
+                Map<String, Object> ls = parseJsonMap(hs.getLifestyle());
+                Object sleepDuration = ls != null ? ls.get("sleepDuration") : null;
+                Object bedtime = ls != null ? ls.get("bedtime") : null;
+                boolean sleepMissing = isEmpty(sleepDuration) && isEmpty(bedtime);
+                boolean sleepShort = "不足6小时".equals(sleepDuration) || "6-7小时".equals(sleepDuration);
+                if (sleepMissing || sleepShort) {
+                    Map<String, Object> q = new LinkedHashMap<>();
+                    q.put("questionId", "followup_sleep_" + memberId);
+                    q.put("question", "关于睡眠:通常几点入睡、每晚大约睡几小时?夜间是否有醒来/入睡困难?(用于校准睡眠建议)");
+                    questions.add(q);
+                }
+            }
+            // 2) 肠道/排便信息缺失且目标涉及肠道
+            if (goalGut && hs != null && questions.size() < 3) {
+                Map<String, Object> ls = parseJsonMap(hs.getLifestyle());
+                Object bowelFrequency = ls != null ? ls.get("bowelFrequency") : null;
+                Object bowelForm = ls != null ? ls.get("bowelForm") : null;
+                if (isEmpty(bowelFrequency) && isEmpty(bowelForm)) {
+                    Map<String, Object> q = new LinkedHashMap<>();
+                    q.put("questionId", "followup_bowel_" + memberId);
+                    q.put("question", "关于肠道:通常几天排便一次、大便形态大致如何(干硬/成型/稀便)?是否有腹胀等不适?");
+                    questions.add(q);
+                }
+            }
+            // 3) 过敏信息缺失且目标涉及过敏/皮肤(仅问卷未填过敏时补充)
+            if (goalAllergy && allergies.isEmpty() && questions.size() < 3 && dp != null) {
+                boolean noPref = Boolean.TRUE.equals(dp.getNoPreference());
+                if (!noPref) {
+                    Map<String, Object> q = new LinkedHashMap<>();
+                    q.put("questionId", "followup_allergy_" + memberId);
+                    q.put("question", "关于过敏:是否有已知食物/药物过敏,或出现过皮肤湿疹、鼻炎等反应?具体是什么引发的?");
+                    questions.add(q);
+                }
+            }
+            // 4) 儿童在服药物 → 剂量需医生确认(RL03 儿童剂量医生定)
+            if (hs != null && questions.size() < 3 && isChild(hs, memberId)) {
+                List<String> meds = parseNameList(hs.getMedications());
+                if (!meds.isEmpty()) {
+                    Map<String, Object> q = new LinkedHashMap<>();
+                    q.put("questionId", "followup_child_med_" + memberId);
+                    q.put("question", "关于用药:当前在服药物(" + String.join("、", meds) + ")是否由医生开具?具体剂量与用法?(儿童用量须遵医嘱,用于规避与营养方案冲突)");
+                    questions.add(q);
+                }
+            }
+        }
+        return questions;
+    }
+
+    /**
+     * R3 带补充答案生成方案(HL-03):答案并入目标上下文后复用既有 4 参生成链路。
+     */
+    @Override
+    public Object generatePlanResult(Long familyId, String memberIds, String dimensions, String goal,
+                                    Map<String, Object> followUpAnswers) {
+        String enhancedGoal = goal;
+        if (followUpAnswers != null && !followUpAnswers.isEmpty()) {
+            StringBuilder sb = new StringBuilder(goal == null ? "" : goal);
+            boolean headerAppended = false;
+            for (Map.Entry<String, Object> e : followUpAnswers.entrySet()) {
+                // QA 修复(HL-03):全部跳过时前端用 __dismissed__ 占位标记保证携带,
+                // 此处跳过标记键,不把占位拼入 goal
+                if ("__dismissed__".equals(e.getKey())) continue;
+                String v = e.getValue() == null ? "" : String.valueOf(e.getValue());
+                if (v.trim().isEmpty()) continue;
+                if (!headerAppended) {
+                    sb.append("\n\n【补充信息】");
+                    headerAppended = true;
+                }
+                sb.append("\n").append(demapQuestionId(e.getKey())).append(":").append(v.trim());
+            }
+            enhancedGoal = sb.toString();
+        }
+        return generatePlanResult(familyId, memberIds, dimensions, enhancedGoal);
+    }
+
+    // ==================== R2/R3 私有工具(HL-03) ====================
+
+    private List<String> splitMemberIds(String memberIds) {
+        List<String> out = new ArrayList<>();
+        if (memberIds == null || memberIds.trim().isEmpty()) return out;
+        for (String s : memberIds.split(",")) {
+            if (s != null && !s.trim().isEmpty()) out.add(s.trim());
+        }
+        return out;
+    }
+
+    /** 读取成员健康档案(memberId 维度,口径与 HealthStatusServiceImpl.getByMemberIdOnly 一致) */
+    private HealthStatus loadHealthStatus(Long memberId) {
+        HealthStatus s = null;
+        if (memberId != null) {
+            List<HealthStatus> list = healthStatusMapper.selectList(
+                    new LambdaQueryWrapper<HealthStatus>()
+                            .eq(HealthStatus::getMemberId, memberId)
+                            .orderByDesc(HealthStatus::getId)
+                            .last("LIMIT 1"));
+            if (!list.isEmpty()) s = list.get(0);
+        }
+        return s;
+    }
+
+    private DietPreferences loadDietPreferences(Long memberId) {
+        if (memberId == null) return null;
+        return dietPreferencesMapper.selectOne(
+                new LambdaQueryWrapper<DietPreferences>()
+                        .eq(DietPreferences::getFamilyMemberId, memberId)
+                        .last("LIMIT 1"));
+    }
+
+    /** 从健康档案归纳「要解决的问题」(RL05:只陈述数据事实 + 就医建议方向,不宣称治疗) */
+    private void collectAttributionFromHealth(HealthStatus hs, List<String> problems,
+                                              List<String> riskNotes, List<String> dataGaps) {
+        if (hs == null) {
+            dataGaps.add("未填写身体数据(血压/血糖/血脂、疾病史、用药、生活方式)");
+            return;
+        }
+        // 血压
+        String bp = hs.getBloodPressure();
+        if (bp != null && !bp.trim().isEmpty()) {
+            String[] parts = bp.split("/");
+            try {
+                int sys = Integer.parseInt(parts[0].trim());
+                int dia = parts.length > 1 ? Integer.parseInt(parts[1].trim()) : 0;
+                if (sys >= 140 || dia >= 90) {
+                    problems.add("血压偏高(" + bp + "):需要关注血压管理,建议在医生指导下监测与干预");
+                } else if (sys < 90 || (dia > 0 && dia < 60)) {
+                    problems.add("血压偏低(" + bp + "):需要关注低血压风险,避免剧烈变化");
+                }
+            } catch (NumberFormatException ignored) {
+                dataGaps.add("血压数据格式异常(" + bp + "),请核对");
+            }
+        }
+        // 血糖
+        if (hs.getBloodGlucose() != null && hs.getBloodGlucose().doubleValue() > 0) {
+            double g = hs.getBloodGlucose().doubleValue();
+            if (g >= 7.0) {
+                problems.add("空腹血糖偏高(" + g + " mmol/L):需要关注血糖管理,建议就医复测并按医嘱处理");
+            } else if (g > 6.1) {
+                problems.add("空腹血糖处于偏高区间(" + g + " mmol/L):建议复查并调整饮食结构");
+            }
+        }
+        // 血脂
+        List<Map<String, Object>> lipids = parseJsonArray(hs.getBloodLipids());
+        for (Map<String, Object> lp : lipids) {
+            String name = String.valueOf(lp.getOrDefault("name", "血脂"));
+            Object value = lp.get("value");
+            if (value != null && !String.valueOf(value).trim().isEmpty()) {
+                try {
+                    double v = Double.parseDouble(String.valueOf(value));
+                    // QA 修复(HL-03):按血脂项目细分阈值——总胆固醇/TC 5.2、LDL/低密度 3.4、
+                    // 甘油三酯及其他 1.7;HDL 高好低坏不判高(>2.2 仅软提示偏高区间,不进 problems)
+                    String upper = name.toUpperCase();
+                    if (upper.contains("HDL") || name.contains("高密度")) {
+                        if (v > 2.2) {
+                            riskNotes.add(name + "数值处于偏高区间(" + v + "):高密度脂蛋白偏高通常无需干预,个别情况建议就医复查确认");
+                        }
+                        continue;
+                    }
+                    double threshold;
+                    if (name.contains("总胆固醇") || upper.contains("TC")) {
+                        threshold = 5.2;
+                    } else if (upper.contains("LDL") || name.contains("低密度")) {
+                        threshold = 3.4;
+                    } else {
+                        threshold = 1.7;
+                    }
+                    if (v > threshold) {
+                        problems.add(name + "偏高(" + v + "):需要关注血脂水平,建议结合饮食调整并就医复查");
+                    }
+                } catch (NumberFormatException ignored) {
+                    dataGaps.add(name + "数据异常,请核对");
+                }
+            }
+        }
+        // 疾病史
+        List<String> diseases = parseNameList(hs.getDiseaseHistory());
+        if (!diseases.isEmpty()) {
+            problems.add("已记录疾病史:" + String.join("、", diseases) + "(方案需与此兼容,建议遵医嘱管理)");
+        }
+        // 用药
+        List<String> meds = parseNameList(hs.getMedications());
+        if (!meds.isEmpty()) {
+            riskNotes.add("在服药物:" + String.join("、", meds) + "(营养/饮食方案须规避相互作用,用药调整一律遵医嘱)");
+        }
+        // 生活方式
+        Map<String, Object> ls = parseJsonMap(hs.getLifestyle());
+        if (ls == null || ls.isEmpty()) {
+            dataGaps.add("未填写生活方式(睡眠/排便/情绪/抗生素史/过敏表现)");
+        } else {
+            Object sleepDuration = ls.get("sleepDuration");
+            if (isEmpty(sleepDuration)) dataGaps.add("未填写睡眠时长");
+            else if ("不足6小时".equals(sleepDuration) || "6-7小时".equals(sleepDuration)) {
+                problems.add("睡眠时长偏短(" + sleepDuration + "):睡眠不足会影响身体恢复与免疫力");
+            }
+            Object bowel = ls.get("bowelFrequency");
+            Object bowelForm = ls.get("bowelForm");
+            boolean bowelKnown = !isEmpty(bowel) || !isEmpty(bowelForm);
+            if (!bowelKnown) dataGaps.add("未填写排便信息");
+            Object antibiotic = ls.get("antibioticHistory");
+            if (antibiotic != null && String.valueOf(antibiotic).contains("是")) {
+                riskNotes.add("近 2 年曾使用抗生素:提示肠道菌群可能受影响,建议关注菌群重建(食品/生活方式层面)");
+            }
+            Object allergies = ls.get("allergySymptoms");
+            if (allergies instanceof List && !((List<?>) allergies).isEmpty()) {
+                problems.add("有过敏表现:" + ((List<?>) allergies).stream().map(String::valueOf).reduce((a, b) -> a + "、" + b).orElse(""));
+            }
+            Object mood = ls.get("mood");
+            if (mood != null && (String.valueOf(mood).contains("差") || String.valueOf(mood).contains("焦虑")
+                    || String.valueOf(mood).contains("低落") || String.valueOf(mood).contains("烦躁"))) {
+                riskNotes.add("情绪状态需关注(" + mood + "):建议睡眠与压力管理,必要时寻求专业支持");
+            }
+        }
+    }
+
+    /** 从饮食偏好归纳「要解决的问题」 */
+    private void collectAttributionFromDiet(DietPreferences dp, List<String> problems, List<String> dataGaps) {
+        if (dp == null) {
+            dataGaps.add("未填写饮食习惯");
+            return;
+        }
+        Map<String, Object> ds = parseJsonMap(dp.getDietStructure());
+        if (ds != null) {
+            Object veg = ds.get("vegetableFruit");
+            if (veg != null && (String.valueOf(veg).contains("很少") || String.valueOf(veg).contains("不吃")
+                    || String.valueOf(veg).contains("≤1份") || String.valueOf(veg).contains("<1份"))) {
+                problems.add("蔬菜水果摄入偏少(" + veg + "):膳食纤维与维生素可能不足,建议逐步增加种类与频率");
+            }
+            Object sugary = ds.get("sugaryDrinks");
+            if (sugary != null && (String.valueOf(sugary).contains("每天") || String.valueOf(sugary).contains("经常"))) {
+                problems.add("含糖饮料偏多(" + sugary + "):与血糖/体重/肠道菌群相关,建议减量并用白水/无糖替代");
+            }
+        }
+        List<String> allergies = parseNameList(dp.getAllergies());
+        List<String> avoid = parseNameList(dp.getAbsoluteAvoid());
+        if (!allergies.isEmpty()) {
+            problems.add("食物过敏/不耐受:" + String.join("、", allergies) + "(饮食方案须规避)");
+        } else if (avoid.isEmpty() && !Boolean.TRUE.equals(dp.getNoPreference())) {
+            dataGaps.add("未确认食物过敏/忌口情况");
+        }
+    }
+
+    private boolean isChild(HealthStatus hs, Long memberId) {
+        FamilyMember fm = memberId != null ? familyMemberMapper.selectById(memberId) : null;
+        if (fm != null && fm.getBirthday() != null) {
+            return computeAge(fm.getBirthday()) < 18;
+        }
+        return false;
+    }
+
+    private int computeAge(java.util.Date birthday) {
+        if (birthday == null) return -1;
+        java.util.Calendar now = java.util.Calendar.getInstance();
+        java.util.Calendar birth = java.util.Calendar.getInstance();
+        birth.setTime(birthday);
+        int age = now.get(java.util.Calendar.YEAR) - birth.get(java.util.Calendar.YEAR);
+        if (now.get(java.util.Calendar.DAY_OF_YEAR) < birth.get(java.util.Calendar.DAY_OF_YEAR)) {
+            age--;
+        }
+        return age;
+    }
+
+    private Map<String, Object> parseJsonMap(String json) {
+        if (json == null || json.trim().isEmpty()) return null;
+        try {
+            JsonNode node = objectMapper.readTree(json);
+            if (node == null || !node.isObject()) return null;
+            Map<String, Object> out = new LinkedHashMap<>();
+            java.util.Iterator<Map.Entry<String, JsonNode>> it = node.fields();
+            while (it.hasNext()) {
+                Map.Entry<String, JsonNode> e = it.next();
+                if (e.getValue().isTextual()) out.put(e.getKey(), e.getValue().asText());
+                else if (e.getValue().isArray()) {
+                    List<String> arr = new ArrayList<>();
+                    for (JsonNode n : e.getValue()) arr.add(n.asText());
+                    out.put(e.getKey(), arr);
+                } else out.put(e.getKey(), e.getValue().asText());
+            }
+            return out;
+        } catch (Exception e) {
+            log.warn("解析 JSON Map 失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private List<Map<String, Object>> parseJsonArray(String json) {
+        List<Map<String, Object>> out = new ArrayList<>();
+        if (json == null || json.trim().isEmpty()) return out;
+        try {
+            JsonNode node = objectMapper.readTree(json);
+            if (node != null && node.isArray()) {
+                for (JsonNode n : node) {
+                    Map<String, Object> row = new LinkedHashMap<>();
+                    if (n.isObject()) {
+                        java.util.Iterator<Map.Entry<String, JsonNode>> it = n.fields();
+                        while (it.hasNext()) {
+                            Map.Entry<String, JsonNode> e = it.next();
+                            row.put(e.getKey(), e.getValue().isTextual() ? e.getValue().asText() : e.getValue());
+                        }
+                    } else {
+                        row.put("name", n.asText());
+                    }
+                    out.add(row);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析 JSON Array 失败: {}", e.getMessage());
+        }
+        return out;
+    }
+
+    /** 解析 JSON 字符串数组 [{name,note}] 或纯字符串数组为名称列表 */
+    private List<String> parseNameList(String json) {
+        List<String> out = new ArrayList<>();
+        if (json == null || json.trim().isEmpty()) return out;
+        try {
+            JsonNode node = objectMapper.readTree(json);
+            if (node != null && node.isArray()) {
+                for (JsonNode n : node) {
+                    if (n.isTextual()) {
+                        if (!n.asText().trim().isEmpty()) out.add(n.asText().trim());
+                    } else if (n.isObject() && n.has("name")) {
+                        String name = n.get("name").asText("");
+                        if (!name.trim().isEmpty()) out.add(name.trim());
+                    }
+                }
+            }
+        } catch (Exception e) {
+            // 非 JSON:按逗号/顿号分隔尝试
+            for (String s : json.split("[,,、]")) {
+                if (s != null && !s.trim().isEmpty()) out.add(s.trim());
+            }
+        }
+        return out;
+    }
+
+    private boolean isEmpty(Object v) {
+        return v == null || String.valueOf(v).trim().isEmpty();
+    }
+
+    private boolean containsAny(String text, String... keys) {
+        if (text == null) return false;
+        for (String k : keys) {
+            if (text.contains(k)) return true;
+        }
+        return false;
+    }
+
+    /** followup_xxx_<memberId> → 可读问题主题 */
+    private String demapQuestionId(String questionId) {
+        if (questionId == null) return "补充说明";
+        if (questionId.startsWith("followup_sleep")) return "睡眠情况";
+        if (questionId.startsWith("followup_bowel")) return "肠道/排便情况";
+        if (questionId.startsWith("followup_allergy")) return "过敏情况";
+        if (questionId.startsWith("followup_child_med")) return "用药情况";
+        return "补充说明";
+    }
+
     /**
      * 渲染多人方案 planContent:先共性,再各成员个性。
      * LangGraph 返回结构:{common: {overview, sections}, individual: {memberId: {overview, sections}}}

+ 366 - 9
cfc-frontend/pages/health/health-plan-summary.vue

@@ -107,13 +107,98 @@
       <button class="btn-sign-entry" @click="goSignature">{{ signatureDataUrl ? '重新签名' : '去签名' }}</button>
 
       <view class="btn-pair">
-        <button class="btn-confirm full-btn" :disabled="!signatureDataUrl || loading" @click="confirmAndGenerate">{{ loading ? '生成中...' : '确认签名并生成方案' }}</button>
+        <button class="btn-confirm full-btn" :disabled="!signatureDataUrl || loading" @click="confirmAndGenerate">{{ loading ? '处理中...' : '确认签名,分析要解决的问题' }}</button>
         <button class="btn-skip" @click="step = 2">返回修改问卷</button>
       </view>
     </view>
 
+    <!-- Step 4a: 归因方案(R2/HL-03:先讲清要解决的问题,确认后才生成解决方案) -->
+    <view class="section" v-if="step === 4 && (attributionStep || attributionLoading) && !viewMode">
+      <view class="section-title">🔍 要解决的问题(归因分析)</view>
+      <view class="hint-text">基于您填写的健康问卷与饮食档案,以下为当前需要重点解决的问题。确认无误后,AI 将围绕这些问题生成个性化解决方案。</view>
+
+      <view v-if="attributionLoading" class="loading-state">
+        <text class="loading-icon">⏳</text>
+        <text class="loading-text">正在分析要解决的问题...</text>
+      </view>
+      <block v-else>
+        <view class="attribution-summary" v-if="attributionData && attributionData.summary">
+          <text class="attribution-summary-text">{{ attributionData.summary }}</text>
+        </view>
+
+        <view class="attribution-member" v-for="(m, mi) in attributionMembers" :key="mi">
+          <view class="attribution-member-head">
+            <text class="attribution-member-name">{{ m.name }}</text>
+            <text class="attribution-member-age" v-if="m.age"> · {{ m.age }}岁</text>
+          </view>
+          <view v-if="m.problems && m.problems.length > 0" class="attr-block attr-problems">
+            <text class="attr-block-title">需关注的问题</text>
+            <view class="attr-item" v-for="(p, pi) in m.problems" :key="pi">
+              <text class="attr-item-dot">•</text>
+              <text class="attr-item-text">{{ p }}</text>
+            </view>
+          </view>
+          <view v-if="m.riskNotes && m.riskNotes.length > 0" class="attr-block attr-risks">
+            <text class="attr-block-title">风险提示</text>
+            <view class="attr-item" v-for="(r, ri) in m.riskNotes" :key="ri">
+              <text class="attr-item-dot">•</text>
+              <text class="attr-item-text">{{ r }}</text>
+            </view>
+          </view>
+          <view v-if="m.dataGaps && m.dataGaps.length > 0" class="attr-block attr-gaps">
+            <text class="attr-block-title">信息待补充</text>
+            <view class="attr-item" v-for="(g, gi) in m.dataGaps" :key="gi">
+              <text class="attr-item-dot">•</text>
+              <text class="attr-item-text">{{ g }}</text>
+            </view>
+          </view>
+          <view v-if="(!m.problems || m.problems.length === 0) && (!m.riskNotes || m.riskNotes.length === 0) && (!m.dataGaps || m.dataGaps.length === 0)" class="attr-empty">
+            未发现明显异常项
+          </view>
+        </view>
+
+        <view class="attr-disclaimer">归因分析仅基于您填写的问卷信息进行客观陈述,不构成诊疗建议;涉及血压/血糖/血脂异常、在服药物或儿童用量等情况,请咨询专业医生。</view>
+
+        <view class="btn-pair">
+          <button class="btn-confirm full-btn" :disabled="loading" @click="confirmAttribution">{{ loading ? '生成中...' : '归因确认,生成解决方案' }}</button>
+        </view>
+        <view class="back-link" @click="backToQuestionnaire">← 返回修改问卷</view>
+      </block>
+    </view>
+
+    <!-- Step 4b: 对话式补充(R3/HL-03:生成方案前的不确定点追问) -->
+    <view class="section" v-if="step === 4 && followUpActive && !attributionStep && !viewMode">
+      <view class="section-title">💬 补充几个问题</view>
+      <view class="hint-text">为了让方案更贴合实际情况,请补充回答以下问题(可跳过任意一题)</view>
+
+      <view class="followup-progress">
+        <text class="followup-progress-text">第 {{ followUpIdx + 1 }} / {{ followUpQuestions.length }} 题</text>
+      </view>
+
+      <!-- 已答历史(对话形式) -->
+      <view v-if="followUpHistory.length > 0" class="followup-history">
+        <view class="followup-hist-item" v-for="(hi, hiIdx) in followUpHistory" :key="hiIdx">
+          <text class="followup-q">问:{{ hi.question }}</text>
+          <text class="followup-a" v-if="hi.answer">答:{{ hi.answer }}</text>
+          <text class="followup-a followup-a-skip" v-else>已跳过</text>
+        </view>
+      </view>
+
+      <view class="followup-current">
+        <text class="followup-current-q">{{ currentFollowUpQuestion.question }}</text>
+        <textarea class="followup-input" v-model="currentFollowUpAnswer" placeholder="请输入您的回答(选填)" :maxlength="200" auto-height></textarea>
+      </view>
+
+      <view class="btn-pair">
+        <button v-if="followUpIdx > 0" class="btn-skip" :disabled="loading" @click="prevFollowUp">上一题</button>
+        <button class="btn-skip" :disabled="loading" @click="skipFollowUp">跳过此题</button>
+        <button class="btn-confirm" :disabled="loading" @click="nextFollowUp">{{ followUpIdx + 1 >= followUpQuestions.length ? '提交并生成方案' : '下一题' }}</button>
+      </view>
+      <view class="back-link" @click="backToQuestionnaire">← 返回修改问卷</view>
+    </view>
+
     <!-- Step 4: 生成方案 -->
-    <view class="section" v-if="step === 4 && !viewMode">
+    <view class="section" v-if="step === 4 && !attributionStep && !followUpActive && !attributionLoading && !viewMode">
       <view class="section-title">📊 方案生成</view>
 
       <!-- 加载中 -->
@@ -247,7 +332,7 @@
 </template>
 
 <script>
-import { generateHealthPlan, regenerateSection, adjustPlanSection, confirmPlan, saveHealthPlan, getHealthPlanDetail } from '../../utils/api'
+import { generateHealthPlan, regenerateSection, adjustPlanSection, confirmPlan, saveHealthPlan, getHealthPlanDetail, getAttributionPlans } from '../../utils/api'
 import { getHealthStatus, saveHealthStatus, getDietPreferences, saveDietPreferences, getMyMembership, saveHealthSignature } from '../../utils/api'
 import MemberChipPicker from '@/pages/health/components/MemberChipPicker.vue'
 import ComprehensiveSurvey from '@/pages/health/components/ComprehensiveSurvey.vue'
@@ -303,7 +388,17 @@ export default {
       // Step 3 签名确认
       signatureDataUrl: '',
       // 确认页:是否展开非关键项
-      showFullSummary: false
+      showFullSummary: false,
+      // R2 归因方案(HL-03):签名确认后先展示要解决的问题,用户确认后才生成解决方案
+      attributionStep: false,
+      attributionLoading: false,
+      attributionData: null,
+      // R3 对话补充(HL-03):方案生成前询问问卷未覆盖的不确定点
+      followUpActive: false,
+      followUpQuestions: [],
+      followUpAnswerMap: {},
+      followUpAnswers: null,
+      followUpIdx: 0
     }
   },
   computed: {
@@ -334,10 +429,42 @@ export default {
     currentSectionHint: function() {
       return this.sectionHints[this.editIndex] || ''
     },
-    // 对话式调整发送按钮可用性:有调整意图且不在请求中
+// 对话式调整发送按钮可用性:有调整意图且不在请求中
     canSendDialogue: function() {
       return !this.loading && this.dialogueIntent && String(this.dialogueIntent).trim() !== ''
     },
+    // R2 归因方案:后端归因结果按成员列表展开
+    attributionMembers: function() {
+      var d = this.attributionData
+      return (d && d.members instanceof Array) ? d.members : []
+    },
+    // R3 对话补充:当前题
+    currentFollowUpQuestion: function() {
+      return this.followUpQuestions[this.followUpIdx] || {}
+    },
+    currentFollowUpAnswer: {
+      get: function() {
+        var q = this.followUpQuestions[this.followUpIdx]
+        return q ? (this.followUpAnswerMap[q.questionId] || '') : ''
+      },
+      set: function(val) {
+        var q = this.followUpQuestions[this.followUpIdx]
+        if (q) this.$set(this.followUpAnswerMap, q.questionId, val || '')
+      }
+    },
+    // R3 对话补充:已答历史(对话形式;空 answer 视为跳过)
+    followUpHistory: function() {
+      var self = this
+      var out = []
+      for (var i = 0; i < self.followUpIdx; i++) {
+        var q = self.followUpQuestions[i]
+        if (q) out.push({
+          question: q.question,
+          answer: self.followUpAnswerMap[q.questionId]
+        })
+      }
+      return out
+    },
 selectedMembers: function() {
       var self = this
       var out = []
@@ -928,7 +1055,7 @@ selectedMembers: function() {
         }
       })
     },
-    // 确认签名并生成方案:先把签名逐成员落库,再生成
+    // 确认签名并生成方案:先把签名逐成员落库,再进入归因分析(R2)
     confirmAndGenerate: function() {
       var self = this
       if (!self.signatureDataUrl) {
@@ -937,6 +1064,10 @@ selectedMembers: function() {
       }
       if (self.loading) return
       self.loading = true
+      // 新一轮确认:清掉可能的归因/追问残留状态
+      self.attributionStep = false
+      self.followUpActive = false
+      self.followUpAnswers = null
       // 为每个有问卷数据的成员落库签名(MEDIUMTEXT 只更新签名列)
       var sigTasks = []
       self.selectedMemberIds.forEach(function(mid) {
@@ -947,7 +1078,7 @@ selectedMembers: function() {
       if (sigTasks.length === 0) {
         self.loading = false
         self.step = 4
-        self.generatePlan()
+        self.loadAttribution()
         return
       }
       Promise.all(sigTasks).then(function() {
@@ -955,12 +1086,12 @@ selectedMembers: function() {
         // 本地回显签署时间(服务端以 health_status.signed_at 为准)
         self.stampLocalSignedAt()
         self.step = 4
-        self.generatePlan()
+        self.loadAttribution()
       }).catch(function() {
         // 签名落库失败不阻塞生成
         self.loading = false
         self.step = 4
-        self.generatePlan()
+        self.loadAttribution()
       })
     },
     // 签名成功后本地回显签署时间(格式 yyyy-MM-dd HH:mm:ss)
@@ -976,6 +1107,81 @@ selectedMembers: function() {
       })
     },
 
+    // R2 归因方案(HL-03):签名确认后展示要解决的问题,确认后才生成解决方案
+    loadAttribution: function() {
+      var self = this
+      if (self.attributionLoading) return
+      self.attributionLoading = true
+      self.attributionData = null
+      self.followUpActive = false
+      self.followUpAnswers = null
+      getAttributionPlans({
+        memberIds: self.selectedMemberIds.join(','),
+        goal: self.userGoal.trim()
+      }).then(function(res) {
+        self.attributionLoading = false
+        if (res && res.code === 200 && res.data) {
+          self.attributionData = res.data
+          self.attributionStep = true
+        } else {
+          // 归因获取失败不阻塞:直接进入方案生成(保持回归)
+          self.attributionStep = false
+          self.generatePlan()
+        }
+      }).catch(function() {
+        self.attributionLoading = false
+        self.attributionStep = false
+        self.generatePlan()
+      })
+    },
+
+    // 用户确认归因 → 进入解决方案生成(R2)
+    confirmAttribution: function() {
+      var self = this
+      if (self.loading) return
+      self.attributionStep = false
+      self.generatePlan()
+    },
+    // 归因/追问页返回修改问卷(签名保留,重新确认后重新归因)
+    backToQuestionnaire: function() {
+      this.step = 2
+    },
+    // R3 对话补充:上一题 / 下一题(末题=提交) / 跳过
+    prevFollowUp: function() {
+      if (this.followUpIdx > 0) this.followUpIdx--
+    },
+    nextFollowUp: function() {
+      var self = this
+      if (self.followUpIdx < self.followUpQuestions.length - 1) {
+        self.followUpIdx++
+      } else {
+        self.submitFollowUpAnswers()
+      }
+    },
+    skipFollowUp: function() {
+      var self = this
+      var q = self.followUpQuestions[self.followUpIdx]
+      if (q) self.$set(self.followUpAnswerMap, q.questionId, '')
+      self.nextFollowUp()
+    },
+    // 收集完毕:组装 answers 按 questionId 回传,带答案重新生成方案
+    submitFollowUpAnswers: function() {
+      var self = this
+      var map = {}
+      self.followUpQuestions.forEach(function(q) {
+        var v = self.followUpAnswerMap[q.questionId]
+        if (v && String(v).trim()) map[q.questionId] = String(v).trim()
+      })
+      // QA 修复(HL-03):全部跳过/全部留空时 map 为空 → 置带标记的非空对象,
+      // 保证 generatePlan 携带 followUpAnswers → 后端 hasAnswers=true → 不再触发 detect 死循环
+      if (Object.keys(map).length === 0) {
+        map = { __dismissed__: true }
+      }
+      self.followUpAnswers = map
+      self.followUpActive = false
+      self.generatePlan()
+    },
+
     // ===== 方案生成与编辑 (保持原有逻辑) =====
     generatePlan: function() {
       var self = this
@@ -986,10 +1192,27 @@ selectedMembers: function() {
         dimensions: 'body',
         goal: self.userGoal.trim()
       }
+      // R3(HL-03):对话补充答案回传后携带 followUpAnswers 重新生成
+      if (self.followUpAnswers && Object.keys(self.followUpAnswers).length > 0) {
+        params.followUpAnswers = self.followUpAnswers
+      }
       generateHealthPlan(params).then(function(res) {
         self.loading = false
         if (res && res.code === 200 && res.data) {
           var data = res.data
+          // R3:后端检测到需补充信息 → 进入对话式提问(无填充的步骤不触发,保持回归)
+          if (data && data.needFollowUp && data.followUpQuestions && data.followUpQuestions.length > 0) {
+            self.followUpQuestions = data.followUpQuestions
+            self.followUpAnswerMap = {}
+            self.followUpQuestions.forEach(function(q) {
+              self.$set(self.followUpAnswerMap, q.questionId, '')
+            })
+            self.followUpIdx = 0
+            self.followUpActive = true
+            return
+          }
+          self.followUpActive = false
+          self.followUpAnswers = null
           // 多人方案:后端返回 {planContent, common, individual} 结构化对象
           if (data && typeof data === 'object' && data.common && data.individual) {
             // 用 common 填充三大板块;再把每位成员的 individual 内容追加到同名板块后
@@ -2136,4 +2359,138 @@ selectedMembers: function() {
   border: 2rpx solid $brand-primary;
   border-radius: 24rpx;
 }
+
+/* ===== R2 归因方案(HL-03) ===== */
+.attribution-summary {
+  background: $uni-primary-pale;
+  border-radius: 16rpx;
+  padding: 20rpx 24rpx;
+  border-left: 4rpx solid $brand-primary;
+  margin-bottom: 20rpx;
+}
+.attribution-summary-text {
+  font-size: calc(27rpx * var(--font-scale, 1));
+  color: $facade-text-deep;
+  line-height: 1.7;
+}
+.attribution-member {
+  border: 2rpx solid $facade-hairline;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  margin-bottom: 16rpx;
+}
+.attribution-member-head {
+  display: flex;
+  align-items: baseline;
+  margin-bottom: 12rpx;
+}
+.attribution-member-name {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $facade-text-strong;
+}
+.attribution-member-age {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  margin-left: 8rpx;
+}
+.attr-block {
+  margin-bottom: 12rpx;
+}
+.attr-block:last-child { margin-bottom: 0; }
+.attr-block-title {
+  display: block;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  font-weight: 500;
+  margin-bottom: 6rpx;
+}
+.attr-problems .attr-block-title { color: $status-danger; }
+.attr-risks .attr-block-title { color: $status-warning; }
+.attr-gaps .attr-block-title { color: $facade-text-muted; }
+.attr-item {
+  display: flex;
+  align-items: flex-start;
+  margin-bottom: 6rpx;
+}
+.attr-item:last-child { margin-bottom: 0; }
+.attr-item-dot {
+  flex-shrink: 0;
+  margin-right: 12rpx;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  line-height: 1.6;
+}
+.attr-problems .attr-item-dot { color: $status-danger; }
+.attr-item-text {
+  flex: 1;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+  line-height: 1.6;
+  word-break: break-all;
+}
+.attr-empty {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $mat-green;
+  padding: 8rpx 0;
+}
+.attr-disclaimer {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  line-height: 1.6;
+  background: $fortune-1-tint;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  margin-top: 4rpx;
+}
+
+/* ===== R3 对话式补充(HL-03) ===== */
+.followup-progress {
+  margin-bottom: 16rpx;
+}
+.followup-progress-text {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $brand-primary;
+  font-weight: 500;
+}
+.followup-history {
+  margin-bottom: 16rpx;
+}
+.followup-hist-item {
+  background: $app-bg;
+  border-radius: 16rpx;
+  padding: 16rpx 20rpx;
+  margin-bottom: 12rpx;
+}
+.followup-q {
+  display: block;
+  font-size: calc(25rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  margin-bottom: 8rpx;
+  line-height: 1.5;
+}
+.followup-a {
+  display: block;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+  line-height: 1.6;
+  word-break: break-all;
+}
+.followup-a-skip { color: $facade-text-muted; }
+.followup-current-q {
+  display: block;
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: 500;
+  color: $facade-text-strong;
+  line-height: 1.6;
+  margin-bottom: 12rpx;
+}
+.followup-input {
+  width: 100%;
+  min-height: 140rpx;
+  box-sizing: border-box;
+  background: $app-bg;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+}
 </style>

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

@@ -2613,6 +2613,7 @@ export const getDeficiencySummary = (memberId) => request('/api/health/nutrition
 export const getNutritionTrend = (memberId) => request('/api/health/nutrition/trend', 'POST', { memberId })
 export const getHealthAnalysis = (memberId) => request('/api/health/analysis', 'POST', { memberId })
 export const getNutritionMappings = (data) => request('/api/health/nutrition/mappings', 'POST', data || {})
+export const getAttributionPlans = (data) => request('/api/health/plan/attribution-plans', 'POST', data)
 
 // ===== 先天画像 =====
 export const getInnatePortrait = (data) => request('/api/mind/innate/portrait', 'POST', data)

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
docs/superpowers/PROJECT-OVERVIEW.md


+ 25 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -3618,3 +3618,28 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 | SC-X-18 | 推送跨端送达 | 公众号模板消息 + 微信待办 webhook 已投产;小程序订阅消息(requestSubscribeMessage/subscribeMessage)前后端 0 命中,`WechatNotificationChannel` 预留禁用;cfc-langgraph 无推送 | 缺口 X-G-3,需产品侧确认 |
 > 本批仅登记缺口不实现,转产品/运维侧确认后由后续批次落地。
 *文档更新:2026-11-07(EVD-15 跨角色域剩余 8 SC 契约核对增补「跨角色域剩余」段落 4.65:双角色数据隔离越权 / 文章发布闭环 / 消息通知闭环 / 解绑重分配 / 注销数据导出 / 活动名额并发 / 多端登录互斥 / 推送跨端送达 + 命名回填 2 处 + 契约缺口 3 项。完整核验表见 `docs/superpowers/specs/2026-11-07-cross-role-contract.md`)*
+
+### 4.66 健康方案制定流程重构(HL-03:归因方案 + 对话补充追问,`/api/health/plan/*`)
+> 需求规格:`docs/superpowers/specs/2026-11-07-health-plan-flow-rework-change.md` §三 R2/R3、§五 HL-03。
+> 流程:签名/问卷确认 → `attribution-plans` 展示「要解决的问题」(用户确认)→ `generate` 生成方案;`generate` 命中不确定点返回 `needFollowUp`,前端逐题追问后携带 `followUpAnswers` 重调 `generate` 完成生成。
+> 红线:归因文案仅陈述数据事实 + 建议就医方向(RL05),不宣称治疗;儿童用量一律「遵医嘱」。
+
+#### 4.66.1 归因方案(`POST /api/health/plan/attribution-plans`)
+- **认证**:JWT 登录(`JwtInterceptor` 拦截 `/api/**`,不在公开路径);付费墙同 `/generate`(家庭会员或健康启航计划任一有效)。
+- **请求**:`{ "memberIds": "1,2", "goal": "改善睡眠,提升免疫力" }`
+- **响应**:`Result<{ goal, summary, members: Array<{ memberId, name, age, problems[], riskNotes[], dataGaps[] }> }>`
+  - `problems`:需关注的问题(血压/血糖/血脂偏高、疾病史、睡眠不足、蔬果不足、含糖饮料、过敏等)
+  - `riskNotes`:风险提示(在服药物、抗生素史、情绪状态)
+  - `dataGaps`:信息待补充(未填写问卷/生活方式/排便等)
+- **实现**:`HealthPlanServiceImpl.generateAttributionPlans`(规则驱动,读 `health_status` + `diet_preferences`,不依赖 AI / 归因报告管线;未改动既有 `AttributionPlanService`)。
+
+#### 4.66.2 方案生成扩展(`POST /api/health/plan/generate`)
+- **请求**(扩展):`{ "memberIds", "dimensions", "goal", "followUpAnswers": { questionId: answer }? }`
+- **行为分支**:
+  1. 未携带 `followUpAnswers` → 先 `detectFollowUpQuestions` 检测;**命中**返回 `Result<{ needFollowUp: true, followUpQuestions: [{ questionId, question }] }>`(不生成方案);未命中走原生成链路(行为不变,保持回归)。
+  2. 携带 `followUpAnswers` → `generatePlanResult(..., followUpAnswers)`:答案并入目标上下文(`【补充信息】`)后复用既有 4 参生成链路。
+- **questionId 规范**:`followup_sleep_<memberId>` / `followup_bowel_<memberId>` / `followup_allergy_<memberId>` / `followup_child_med_<memberId>`。
+- **触发条件**:仅成员有问卷数据且目标相关、关键信息缺失时(无问卷数据 = 跳过问卷路径不触发,回归安全)。
+- **实现**:5 参重载 `generatePlanResult`(4 参签名未变,`PlanLifecycleParentContractTest` 不受影响)。
+
+*文档更新:2026-11-07(HL-03 方案流程重构 R2 归因方案 + R3 对话补充追问登记段落 4.66,含前端 api.js `getAttributionPlans` 封装与 `health-plan-summary.vue` 归因/追问交互)*

Некоторые файлы не были показаны из-за большого количества измененных файлов