Browse Source

feat(health-plan): HL-03/R2+R3 归因方案先行(签名后先出归因/确认后生成) + 生成不确定点对话补充(followUpQuestions逐题询问回传二次生成)

Agnes Agent 5 days ago
parent
commit
87ffb4e3ea

+ 28 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java

@@ -29,7 +29,7 @@ public class HealthPlanController {
     @Resource
     private com.etotem.cfc.service.MembershipService membershipService;
 
-    @Operation(summary = "生成健康方案(文本)")
+    @Operation(summary = "生成健康方案(文本,支持 R3 不确定点对话补充回答二次生成)")
     @PostMapping("/generate")
     public Result<Object> generate(@RequestBody Map<String, Object> params,
                                    @RequestAttribute("userId") Long userId,
@@ -50,7 +50,33 @@ 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:用户对不确定点(followUpQuestions)的对话补充回答,二次生成时透传
+        @SuppressWarnings("unchecked")
+        Map<String, Object> followUpAnswers = params.get("followUpAnswers") instanceof Map
+                ? (Map<String, Object>) params.get("followUpAnswers") : null;
+        Object result = healthPlanService.generatePlanResultWithFollowUp(
+                familyId, memberIds, dimensions, goal, followUpAnswers);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "归因方案生成(R2:先讲清要解决的问题,确认后才进入解决方案生成)")
+    @PostMapping("/attribution")
+    public Result<Map<String, Object>> generateAttribution(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        // 付费墙拦截:与 /generate 一致,防止未付费用户绕过生成流程直接取归因
+        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()) {
+            return Result.error("memberIds不能为空");
+        }
+        Map<String, Object> result = healthPlanService.generateAttributionSummary(familyId, memberIds, goal);
         return Result.success(result);
     }
 

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

@@ -23,6 +23,42 @@ public interface HealthPlanService {
      *   前端可据此保存 planJson(common/individual 结构),确认时按成员分别生成任务
      */
     Object generatePlanResult(Long familyId, String memberIds, String dimensions, String goal);
+
+    /**
+     * HL-03/R3:生成健康方案并返回结构化结果(含不确定点 followUpQuestions)。
+     * 与 {@link #generatePlanResult} 的区别:支持用户对话补充回答后二次生成,并在首次生成时
+     * 返回「不确定点」列表供前端对话式补充提问。
+     *
+     * <p>返回结构:
+     * <ul>
+     *   <li>单人生成:{@code {planContent: String, followUpQuestions: List<Map>}}</li>
+     *   <li>多人生成:{@code {planContent: String, common: Map, individual: Map<memberId, Map>, followUpQuestions: List<Map>}}</li>
+     *   <li>AI 服务不可用(降级样本):{@code {planContent: String, followUpQuestions: []}}</li>
+     * </ul>
+     * {@code followUpQuestions} 首次生成(followUpAnswers 为空)时由 LangGraph 按数据缺失检测;
+     * 带 followUpAnswers 二次生成时为空(用户已回答)。
+     *
+     * @param followUpAnswers 用户对话补充回答({@code {questionId: 答案}}),可空
+     */
+    Object generatePlanResultWithFollowUp(Long familyId, String memberIds, String dimensions,
+                                          String goal, java.util.Map<String, Object> followUpAnswers);
+
+    /**
+     * HL-03/R2:归因方案生成(复用 {@code AttributionAnalysisService} 归因逻辑)。
+     * 解决方案生成之前先调用:讲清要解决的问题(归因结论/成员归因/教练提问)。
+     *
+     * <p>有菌群报告或 DAN 测评数据时走真实归因分析({@code attributionAnalysisService.analyze});
+     * 无检测数据时降级为五维健康自检规则归因。返回结构:
+     * <ul>
+     *   <li>{@code mode}: "analysis"(真实归因)| "fallback"(健康自检降级)</li>
+     *   <li>{@code attributionId}: 真实归因时归因报告ID</li>
+     *   <li>{@code conclusion}/{@code summary}: 归因结论与一句话收尾</li>
+     *   <li>{@code members}: [{memberId, name, roleLabel, surface, essence, weakDimensions}]</li>
+     *   <li>{@code coachQuestions}: 教练提问序列</li>
+     *   <li>{@code dataSources}: 数据来源摘要</li>
+     * </ul>
+     */
+    Map<String, Object> generateAttributionSummary(Long familyId, String memberIds, String goal);
     String regenerateSection(Long familyId, String section, String feedback, String planJsonStr);
 
     /**

+ 372 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -2,19 +2,26 @@ package com.etotem.cfc.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.AttributionInput;
+import com.etotem.cfc.dto.AttributionReportVO;
 import com.etotem.cfc.dto.PlanApproveResultDTO;
+import com.etotem.cfc.entity.DanAssessmentResult;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FiveDimensionSelfCheck;
 import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 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.HealthReportMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AttributionAnalysisService;
 import com.etotem.cfc.service.HealthPlanService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
@@ -61,6 +68,15 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     @Resource
     private FiveDimensionSelfCheckMapper selfCheckMapper;
 
+    @Resource
+    private HealthReportMapper healthReportMapper;
+
+    @Resource
+    private DanAssessmentResultMapper danAssessmentResultMapper;
+
+    @Resource
+    private AttributionAnalysisService attributionAnalysisService;
+
     @Resource
     private com.etotem.cfc.service.AiGateway aiGateway;
 
@@ -276,8 +292,25 @@ public class HealthPlanServiceImpl implements HealthPlanService {
 
     @Override
     public Object generatePlanResult(Long familyId, String memberIds, String dimensions, String goal) {
+        Object result = generatePlanResultWithFollowUp(familyId, memberIds, dimensions, goal, null);
+        // 向后兼容:单人生成返回 String(planContent);多人返回 Map(结构同上,多 followUpQuestions 键)
+        if (result instanceof Map) {
+            Map<?, ?> m = (Map<?, ?>) result;
+            if (m.containsKey("planContent") && !m.containsKey("common")) {
+                return m.get("planContent");
+            }
+        }
+        return result;
+    }
+
+    @Override
+    public Object generatePlanResultWithFollowUp(Long familyId, String memberIds, String dimensions,
+                                                String goal, java.util.Map<String, Object> followUpAnswers) {
         if (langgraphBaseUrl == null || langgraphBaseUrl.trim().isEmpty()) {
-            return buildSamplePlan(memberIds, dimensions, goal);
+            Map<String, Object> fallback = new HashMap<>();
+            fallback.put("planContent", buildSamplePlan(memberIds, dimensions, goal));
+            fallback.put("followUpQuestions", new ArrayList<>());
+            return fallback;
         }
         try {
             RestTemplate restTemplate = new RestTemplate();
@@ -286,21 +319,29 @@ public class HealthPlanServiceImpl implements HealthPlanService {
             body.put("member_ids", memberIds);
             body.put("dimensions", dimensions);
             body.put("goal", goal);
+            // R3:用户对话补充回答(首次为 null,LangGraph 据此返回不确定点 followUpQuestions)
+            if (followUpAnswers != null && !followUpAnswers.isEmpty()) {
+                body.put("follow_up_answers", followUpAnswers);
+            } else {
+                body.put("follow_up_answers", new java.util.LinkedHashMap<String, Object>());
+            }
             String url = langgraphBaseUrl + "/api/v1/health/plan/generate";
             ResponseEntity<String> response = restTemplate.postForEntity(url, body, String.class);
             if (response.getBody() != null && response.getStatusCode().is2xxSuccessful()) {
                 JsonNode root = objectMapper.readTree(response.getBody());
                 if (root.has("success") && root.get("success").asBoolean()) {
                     JsonNode data = root.get("data");
+                    List<Map<String, Object>> followUpQuestions = extractFollowUpQuestions(data);
                     // 多人方案:common(共性)+ individual(各成员个性)
                     if (data != null && data.has("common")) {
                         Map<String, Object> multi = new HashMap<>();
                         multi.put("planContent", buildMultiMemberPlanContent(data));
                         if (data.has("common")) multi.put("common", data.get("common"));
                         if (data.has("individual")) multi.put("individual", data.get("individual"));
+                        multi.put("followUpQuestions", followUpQuestions);
                         return multi;
                     }
-                    // 单人生成:保持原 flat 结构
+                    // 单人生成:{planContent, followUpQuestions} 结构化返回(前端可先收集不确定点再回传)
                     if (data != null && data.has("overview")) {
                         StringBuilder sb = new StringBuilder();
                         sb.append("## 方案概述\n\n").append(data.get("overview").asText()).append("\n\n");
@@ -310,16 +351,342 @@ public class HealthPlanServiceImpl implements HealthPlanService {
                                 sb.append(section.get("content").asText()).append("\n\n");
                             }
                         }
-                        return sb.toString();
+                        Map<String, Object> single = new HashMap<>();
+                        single.put("planContent", sb.toString());
+                        single.put("followUpQuestions", followUpQuestions);
+                        return single;
                     }
                 }
                 return root.has("data") && root.get("data").has("raw")
-                    ? root.get("data").get("raw").asText() : response.getBody();
+                    ? wrapPlanResult(root.get("data").get("raw").asText()) : wrapPlanResult(response.getBody());
             }
         } catch (Exception e) {
             log.warn("调用LangGraph生成方案失败: {}", e.getMessage());
         }
-        return buildSamplePlan(memberIds, dimensions, goal);
+        return wrapPlanResult(buildSamplePlan(memberIds, dimensions, goal));
+    }
+
+    /** 抽取 LangGraph 返回的不确定点(follow_up_questions)为 List<Map>;缺失返回空列表 */
+    private List<Map<String, Object>> extractFollowUpQuestions(JsonNode data) {
+        List<Map<String, Object>> out = new ArrayList<>();
+        if (data == null || !data.has("follow_up_questions") || !data.get("follow_up_questions").isArray()) {
+            return out;
+        }
+        for (JsonNode q : data.get("follow_up_questions")) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            if (q.has("id")) item.put("id", q.get("id").asText());
+            item.put("question", q.has("question") ? q.get("question").asText() : "");
+            if (q.has("hint")) item.put("hint", q.get("hint").asText());
+            out.add(item);
+        }
+        return out;
+    }
+
+    /** 将任意生成结果包装为统一的 {planContent, followUpQuestions:[]} 结构(降级/原始兜底分支) */
+    private Map<String, Object> wrapPlanResult(String planContent) {
+        Map<String, Object> out = new HashMap<>();
+        out.put("planContent", planContent != null ? planContent : "");
+        out.put("followUpQuestions", new ArrayList<>());
+        return out;
+    }
+
+    // ===== HL-03/R2 归因方案(复用 AttributionAnalysisService 归因逻辑)=====
+
+    @Override
+    public Map<String, Object> generateAttributionSummary(Long familyId, String memberIds, String goal) {
+        List<Long> memberIdList = parseIds(memberIds);
+        Map<String, Object> result = new LinkedHashMap<>();
+
+        // 1) 收集每个成员的检测数据(菌群报告 / DAN 结果,各取最新)
+        List<Long> gutReportIds = findGutReportIds(familyId, memberIdList);
+        List<Long> danResultIds = findDanResultIds(familyId, memberIdList);
+
+        // 2) 有检测数据 → 真实归因分析(复用 AttributionAnalysisService.analyze,绕开关卡直调)
+        if (!gutReportIds.isEmpty() || !danResultIds.isEmpty()) {
+            try {
+                AttributionInput input = new AttributionInput();
+                input.setFamilyId(familyId);
+                input.setMemberIds(memberIdList);
+                input.setGutReportIds(gutReportIds);
+                input.setDanResultIds(danResultIds);
+                AttributionReportVO vo = attributionAnalysisService.analyze(input);
+                result.put("mode", "analysis");
+                result.put("attributionId", vo.getAttributionId());
+                result.putAll(extractAttributionSummary(vo.getReportJson()));
+                if (vo.getWarnings() != null) result.put("warnings", vo.getWarnings());
+                if (goal != null && !goal.trim().isEmpty()) result.put("goal", goal);
+                return result;
+            } catch (Exception e) {
+                log.warn("真实归因分析失败,降级健康自检规则归因: {}", e.getMessage());
+            }
+        }
+
+        // 3) 无检测数据 / 归因失败 → 五维健康自检规则降级
+        result.put("mode", "fallback");
+        result.putAll(buildFallbackAttribution(familyId, memberIdList, goal));
+        return result;
+    }
+
+    /** 按成员取最新菌群报告ID(health_reports.report_type='gut_flora',subjectId=成员ID) */
+    private List<Long> findGutReportIds(Long familyId, List<Long> memberIdList) {
+        List<Long> out = new ArrayList<>();
+        if (memberIdList == null || memberIdList.isEmpty()) {
+            return out;
+        }
+        try {
+            QueryWrapper<HealthReport> qw = new QueryWrapper<>();
+            qw.in("subject_id", memberIdList)
+                    .eq("report_type", "gut_flora")
+                    .eq("status", "active")
+                    .orderByDesc("report_date").orderByDesc("id");
+            List<HealthReport> reports = healthReportMapper.selectList(qw);
+            // 每个成员只取最新一条(orderByDesc 后首个出现者)
+            java.util.Set<Long> seen = new java.util.LinkedHashSet<>();
+            for (HealthReport r : reports) {
+                if (r.getSubjectId() != null && seen.add(r.getSubjectId())) {
+                    out.add(r.getId());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("查询菌群报告失败: {}", e.getMessage());
+        }
+        return out;
+    }
+
+    /** 按成员取最新 DAN 结果ID(dan_assessment_results.family_member_id=成员ID,completed 状态) */
+    private List<Long> findDanResultIds(Long familyId, List<Long> memberIdList) {
+        List<Long> out = new ArrayList<>();
+        if (memberIdList == null || memberIdList.isEmpty()) {
+            return out;
+        }
+        try {
+            QueryWrapper<DanAssessmentResult> qw = new QueryWrapper<>();
+            qw.in("family_member_id", memberIdList)
+                    .eq("status", "completed")
+                    .orderByDesc("id");
+            List<DanAssessmentResult> dans = danAssessmentResultMapper.selectList(qw);
+            java.util.Set<Long> seen = new java.util.LinkedHashSet<>();
+            for (DanAssessmentResult d : dans) {
+                if (d.getFamilyMemberId() != null && seen.add(d.getFamilyMemberId())) {
+                    out.add(d.getId());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("查询 DAN 测评结果失败: {}", e.getMessage());
+        }
+        return out;
+    }
+
+    /** 从十节归因报告 JSON 提取前端「归因方案」摘要 */
+    private Map<String, Object> extractAttributionSummary(String reportJson) {
+        Map<String, Object> out = new LinkedHashMap<>();
+        if (reportJson == null || reportJson.trim().isEmpty()) {
+            return out;
+        }
+        try {
+            JsonNode root = objectMapper.readTree(reportJson);
+            JsonNode sections = root.path("sections");
+            JsonNode conclusion = sections.path("conclusion");
+            out.put("conclusion", conclusion.path("headline").asText(""));
+            if (conclusion.path("mainDimensions").isArray()) {
+                out.put("mainDimensions", jsonArrayToList(conclusion.path("mainDimensions")));
+            }
+            out.put("summary", sections.path("closing").path("line").asText(""));
+
+            // 逐人归因(surface/essence/短板维度)
+            if (sections.path("perMember").isArray()) {
+                List<Map<String, Object>> members = new ArrayList<>();
+                for (JsonNode m : sections.path("perMember")) {
+                    Map<String, Object> row = new LinkedHashMap<>();
+                    row.put("memberId", m.path("memberId").asLong());
+                    row.put("name", m.path("name").asText(""));
+                    row.put("roleLabel", m.path("roleLabel").asText(""));
+                    row.put("surface", m.path("surface").asText(""));
+                    row.put("essence", m.path("essence").asText(""));
+                    row.put("weakDimensions", jsonArrayToList(m.path("weakDimensions")));
+                    members.add(row);
+                }
+                out.put("members", members);
+            }
+
+            // 教练提问序列
+            if (sections.path("coachQuestions").isArray()) {
+                out.put("coachQuestions", jsonArrayToList(sections.path("coachQuestions")));
+            }
+
+            // 12 周可检验目标(前 8 条)
+            if (sections.path("goals").isArray()) {
+                List<Map<String, Object>> goals = new ArrayList<>();
+                int cnt = 0;
+                for (JsonNode g : sections.path("goals")) {
+                    if (cnt++ >= 8) break;
+                    Map<String, Object> row = new LinkedHashMap<>();
+                    row.put("indicator", g.path("indicator").asText(""));
+                    row.put("now", g.path("now").asText(""));
+                    row.put("target", g.path("target").asText(""));
+                    row.put("owner", g.path("owner").asText(""));
+                    goals.add(row);
+                }
+                out.put("goals", goals);
+            }
+
+            // 数据来源摘要(按 normSource 归类)
+            List<String> dataSources = new ArrayList<>();
+            int gut = 0;
+            int dan = 0;
+            for (JsonNode o : sections.path("overview")) {
+                String src = o.path("normSource").asText("");
+                if (src.contains("cognitive_norms") || src.contains("health_norm_reference") || src.contains("dan")) {
+                    dan++;
+                } else if (src.contains("populationLevel") || src.contains("attribution_norm_values")
+                        || src.contains("gut") || src.contains("flora")) {
+                    gut++;
+                }
+            }
+            if (gut > 0) dataSources.add("菌群读数 " + gut + " 项");
+            if (dan > 0) dataSources.add("DAN 读数 " + dan + " 项");
+            if (dataSources.isEmpty()) dataSources.add("归因分析(检测数据)");
+            out.put("dataSources", dataSources);
+
+            // 命中红线(仅提示,不阻断)
+            if (sections.path("redlines").isArray()) {
+                List<String> hits = new ArrayList<>();
+                for (JsonNode r : sections.path("redlines")) {
+                    if (r.path("hit").asBoolean(false)) {
+                        hits.add(r.path("code").asText(""));
+                    }
+                }
+                if (!hits.isEmpty()) out.put("redlineHits", hits);
+            }
+        } catch (Exception e) {
+            log.warn("解析归因报告摘要失败: {}", e.getMessage());
+        }
+        return out;
+    }
+
+    /** 降级:无检测数据时基于五维健康自检的低分维度做规则归因 */
+    private Map<String, Object> buildFallbackAttribution(Long familyId, List<Long> memberIdList, String goal) {
+        Map<String, Object> out = new LinkedHashMap<>();
+        out.put("conclusion", "健康自检维度提示(暂无检测报告,按五维自检低分维度规则归因)");
+        out.put("summary", "当前未关联肠道菌群/儿童成人测评数据,基于五维家庭自检的低分维度做初步归因;补充检测报告后可更精准定位问题。");
+        out.put("dataSources", java.util.Collections.singletonList("五维家庭自检"));
+
+        List<Map<String, Object>> members = new ArrayList<>();
+        if (memberIdList != null) {
+            for (Long mid : memberIdList) {
+                Map<String, Object> row = new LinkedHashMap<>();
+                row.put("memberId", mid);
+                String name = memberName(mid);
+                row.put("name", name);
+                row.put("roleLabel", name);
+                FiveDimensionSelfCheck check = latestSelfCheck(mid);
+                List<String> weak = new ArrayList<>();
+                Map<String, Object> dims = new LinkedHashMap<>();
+                if (check != null && check.getScoresJson() != null && !check.getScoresJson().trim().isEmpty()) {
+                    dims.putAll(parseScoreMap(check.getScoresJson()));
+                    for (Map.Entry<String, Object> e : dims.entrySet()) {
+                        Object v = e.getValue();
+                        if (v instanceof Number && !(v instanceof java.math.BigDecimal)
+                                && ((Number) v).doubleValue() <= 5.0d) {
+                            weak.add(e.getKey());
+                        } else if (v instanceof java.math.BigDecimal
+                                && ((java.math.BigDecimal) v).doubleValue() <= 5.0d) {
+                            weak.add(e.getKey());
+                        }
+                    }
+                }
+                row.put("weakDimensions", weak);
+                row.put("dimScores", dims);
+                row.put("surface", weak.isEmpty() ? "暂无明显低分维度" : "低分维度:" + String.join("/", weak));
+                row.put("essence", "需结合健康自检与生活习惯进一步确认(无检测数据)");
+                members.add(row);
+            }
+        }
+        out.put("members", members);
+        if (goal != null && !goal.trim().isEmpty()) {
+            out.put("goal", goal);
+        }
+        return out;
+    }
+
+    /** 成员昵称(family_members.nickname),取不到时回退 "成员{id}" */
+    private String memberName(Long memberId) {
+        if (memberId == null) {
+            return "成员";
+        }
+        try {
+            FamilyMember fm = familyMemberMapper.selectById(memberId);
+            if (fm != null && fm.getNickname() != null && !fm.getNickname().trim().isEmpty()) {
+                return fm.getNickname();
+            }
+        } catch (Exception ignored) {
+            // 成员不存在时回退
+        }
+        return "成员" + memberId;
+    }
+
+    /** 成员最近一次五维自检(无则 null) */
+    private FiveDimensionSelfCheck latestSelfCheck(Long memberId) {
+        if (memberId == null) {
+            return null;
+        }
+        try {
+            QueryWrapper<FiveDimensionSelfCheck> qw = new QueryWrapper<>();
+            qw.eq("family_member_id", memberId).orderByDesc("created_at").last("LIMIT 1");
+            return selfCheckMapper.selectOne(qw);
+        } catch (Exception e) {
+            log.warn("读取五维自检失败 memberId={}: {}", memberId, e.getMessage());
+            return null;
+        }
+    }
+
+    /** 解析 {@code {"body":6,"wisdom":7,...}} 维度分数 JSON */
+    private Map<String, Object> parseScoreMap(String json) {
+        Map<String, Object> out = new LinkedHashMap<>();
+        if (json == null || json.trim().isEmpty()) {
+            return out;
+        }
+        try {
+            JsonNode node = objectMapper.readTree(json);
+            if (node != null && node.isObject()) {
+                java.util.Iterator<Map.Entry<String, JsonNode>> it = node.fields();
+                while (it.hasNext()) {
+                    Map.Entry<String, JsonNode> e = it.next();
+                    out.put(e.getKey(), e.getValue().isNumber() ? e.getValue().numberValue() : e.getValue().asText());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析五维自检分数失败: {}", e.getMessage());
+        }
+        return out;
+    }
+
+    /** 逗号分隔 ID → List<Long> */
+    private List<Long> parseIds(String csv) {
+        List<Long> out = new ArrayList<>();
+        if (csv == null || csv.trim().isEmpty()) {
+            return out;
+        }
+        for (String s : csv.split(",")) {
+            try {
+                if (!s.trim().isEmpty()) {
+                    out.add(Long.valueOf(s.trim()));
+                }
+            } catch (NumberFormatException ignored) {
+                // 跳过非法 ID
+            }
+        }
+        return out;
+    }
+
+    private List<String> jsonArrayToList(JsonNode arr) {
+        List<String> out = new ArrayList<>();
+        if (arr != null && arr.isArray()) {
+            for (JsonNode n : arr) {
+                out.add(n.asText());
+            }
+        }
+        return out;
     }
 
     /**

+ 74 - 3
cfc-backend/src/test/java/com/etotem/cfc/unit/PlanLifecycleParentContractTest.java

@@ -67,11 +67,14 @@ class PlanLifecycleParentContractTest extends AssessmentPlanContractTestBase {
     // ==================== SC-P-40 生成 AI 方案 ====================
 
     @Test
-    @DisplayName("SC-P-40 正常:会员生成方案 → 200")
+    @DisplayName("SC-P-40 正常:会员生成方案 → 200(R3 结构化返回含 followUpQuestions)")
     void generate_正常_会员() throws Exception {
         when(membershipSvc.getMemberLevel(1L)).thenReturn("BASIC");
-        when(planSvc.generatePlanResult(7L, "10", "wisdom,body", "提升专注力"))
-                .thenReturn("## 方案概述\n...");
+        Map<String, Object> planResult = new HashMap<>();
+        planResult.put("planContent", "## 方案概述\n...");
+        planResult.put("followUpQuestions", Collections.emptyList());
+        when(planSvc.generatePlanResultWithFollowUp(7L, "10", "wisdom,body", "提升专注力", null))
+                .thenReturn(planResult);
         Map<String, Object> params = new HashMap<>();
         params.put("memberIds", "10");
         params.put("dimensions", "wisdom,body");
@@ -82,6 +85,29 @@ class PlanLifecycleParentContractTest extends AssessmentPlanContractTestBase {
         assertNotNull(r.getData());
     }
 
+    @Test
+    @DisplayName("HL-03/R3 正常:带 followUpAnswers 二次生成 → 透传补充回答")
+    void generate_带followUpAnswers() throws Exception {
+        when(membershipSvc.getMemberLevel(1L)).thenReturn("BASIC");
+        Map<String, Object> answers = new HashMap<>();
+        answers.put("gut_flora", "做过,双歧杆菌偏低");
+        Map<String, Object> planResult = new HashMap<>();
+        planResult.put("planContent", "## 方案概述\n含补充回答的方案");
+        planResult.put("followUpQuestions", Collections.emptyList());
+        when(planSvc.generatePlanResultWithFollowUp(7L, "10", "wisdom,body", "提升专注力", answers))
+                .thenReturn(planResult);
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberIds", "10");
+        params.put("dimensions", "wisdom,body");
+        params.put("goal", "提升专注力");
+        params.put("followUpAnswers", answers);
+
+        Result<Object> r = controller.generate(params, 1L, 7L);
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        verify(planSvc).generatePlanResultWithFollowUp(7L, "10", "wisdom,body", "提升专注力", answers);
+    }
+
     @Test
     @DisplayName("SC-P-40 权限:非会员且无健康启航计划 → 4001 付费墙")
     void generate_权限_付费墙() throws Exception {
@@ -110,6 +136,51 @@ class PlanLifecycleParentContractTest extends AssessmentPlanContractTestBase {
         assertTrue(r.getMessage().contains("dimensions和goal不能为空"));
     }
 
+    // ==================== HL-03/R2 归因方案 ====================
+
+    @Test
+    @DisplayName("HL-03/R2 正常:归因方案生成 → 200(复用归因逻辑)")
+    void attribution_正常() throws Exception {
+        when(membershipSvc.getMemberLevel(1L)).thenReturn("BASIC");
+        Map<String, Object> attribution = new HashMap<>();
+        attribution.put("mode", "analysis");
+        attribution.put("conclusion", "肠道菌群失衡为主因");
+        when(planSvc.generateAttributionSummary(7L, "10", "改善免疫力")).thenReturn(attribution);
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberIds", "10");
+        params.put("goal", "改善免疫力");
+        Result<Map<String, Object>> r = controller.generateAttribution(params, 1L, 7L);
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData());
+        assertEquals("肠道菌群失衡为主因", r.getData().get("conclusion"));
+    }
+
+    @Test
+    @DisplayName("HL-03/R2 权限:非会员且无健康启航计划 → 4001 付费墙")
+    void attribution_权限_付费墙() throws Exception {
+        when(membershipSvc.getMemberLevel(1L)).thenReturn("FREE");
+        Map<String, Object> check = new HashMap<>();
+        check.put("isActive", false);
+        when(purchaseSvc.checkPurchase(1L)).thenReturn(check);
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberIds", "10");
+        params.put("goal", "改善免疫力");
+        Result<Map<String, Object>> r = controller.generateAttribution(params, 1L, 7L);
+        assertEquals(4001, r.getCode());
+        assertTrue(r.getMessage().contains("会员"));
+    }
+
+    @Test
+    @DisplayName("HL-03/R2 异常:memberIds 为空 → 业务提示")
+    void attribution_异常_参数缺失() throws Exception {
+        when(membershipSvc.getMemberLevel(1L)).thenReturn("BASIC");
+        Map<String, Object> params = new HashMap<>();
+        params.put("goal", "改善免疫力");
+        Result<Map<String, Object>> r = controller.generateAttribution(params, 1L, 7L);
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("memberIds不能为空"));
+    }
+
     // ==================== SC-P-41 分板块重生成 ====================
 
     @Test

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

@@ -112,8 +112,8 @@
       </view>
     </view>
 
-    <!-- Step 4: 生成方案 -->
-    <view class="section" v-if="step === 4 && !viewMode">
+    <!-- Step 4: 生成方案(R2 归因展示期间不渲染,先看归因面板) -->
+    <view class="section" v-if="step === 4 && !viewMode && !attributionShowing">
       <view class="section-title">📊 方案生成</view>
 
       <!-- 加载中 -->
@@ -123,8 +123,8 @@
         <text class="loading-hint">预计需要 10-30 秒,请耐心等待</text>
       </view>
 
-      <!-- 方案预览 -->
-      <view v-else>
+      <!-- 方案预览(R3 追问浮层打开时隐藏,避免露出「生成失败」占位) -->
+      <view v-else-if="!followUpVisible">
         <view class="overview-card" v-if="planData.overview">
           <text class="overview-text">{{ planData.overview }}</text>
         </view>
@@ -152,6 +152,96 @@
       </view>
     </view>
 
+    <!-- 归因方案(R2):签名确认后先讲清要解决的问题,用户确认后才进入解决方案生成 -->
+    <view class="section attr-section" v-if="step === 4 && attributionShowing && !viewMode">
+      <view v-if="attributionLoading" class="attr-loading">
+        <text class="loading-icon">⏳</text>
+        <text class="loading-text">AI 正在分析问题归因...</text>
+        <text class="loading-hint">预计需要 10-20 秒</text>
+      </view>
+      <view v-else>
+        <view class="attr-head">
+          <view class="attr-head-left">
+            <text class="attr-title">🔍 问题归因分析</text>
+            <text class="attr-sub">生成方案前,先明确需要解决的问题</text>
+          </view>
+          <text v-if="attributionData.mode" class="attr-mode">{{ attributionData.mode === 'analysis' ? '基于检测数据' : '基于健康自检' }}</text>
+        </view>
+        <view v-if="attributionData.conclusion" class="attr-conclusion">
+          <text class="attr-conclusion-label">核心结论</text>
+          <text class="attr-conclusion-text">{{ attributionData.conclusion }}</text>
+        </view>
+        <view v-if="attributionData.summary" class="attr-summary">
+          <text class="attr-summary-text">{{ attributionData.summary }}</text>
+        </view>
+        <!-- 逐成员归因(表象 → 本质) -->
+        <view v-if="attributionData.members && attributionData.members.length > 0" class="attr-block">
+          <text class="attr-block-title">成员归因</text>
+          <view v-for="m in attributionData.members" :key="m.memberId" class="attr-member">
+            <view class="attr-member-head">
+              <text class="attr-member-name">{{ m.name || '成员' }}</text>
+              <text v-if="m.roleLabel" class="attr-member-role">{{ m.roleLabel }}</text>
+            </view>
+            <view class="attr-row">
+              <text class="attr-cell-label">表象</text>
+              <text class="attr-cell-value">{{ m.surface || '—' }}</text>
+            </view>
+            <view class="attr-row">
+              <text class="attr-cell-label">本质</text>
+              <text class="attr-cell-value">{{ m.essence || '—' }}</text>
+            </view>
+            <view v-if="m.weakDimensions && m.weakDimensions.length > 0" class="attr-weak">
+              <view class="attr-cell-label">薄弱维度</view>
+              <view class="attr-weak-chips">
+                <text v-for="(wd, wdi) in m.weakDimensions" :key="wdi" class="attr-weak-chip">{{ wd }}</text>
+              </view>
+            </view>
+          </view>
+        </view>
+        <!-- 教练问题 -->
+        <view v-if="attributionData.coachQuestions && attributionData.coachQuestions.length > 0" class="attr-block">
+          <text class="attr-block-title">需要进一步思考的问题</text>
+          <view v-for="(q, qi) in attributionData.coachQuestions" :key="qi" class="attr-question">
+            <text class="attr-question-text">{{ q.question || q }}</text>
+          </view>
+        </view>
+        <!-- 数据来源 -->
+        <view v-if="attributionData.dataSources && attributionData.dataSources.length > 0" class="attr-block">
+          <text class="attr-block-title">数据来源</text>
+          <view class="attr-source-chips">
+            <text v-for="(s, si) in attributionData.dataSources" :key="si" class="attr-source-chip">{{ s.name || s }}</text>
+          </view>
+        </view>
+        <view v-if="attributionData.warnings && attributionData.warnings.length > 0" class="attr-warning">
+          <text v-for="(w, wi) in attributionData.warnings" :key="wi" class="attr-warning-text">⚠️ {{ w }}</text>
+        </view>
+        <view class="btn-pair">
+          <button class="btn-confirm" :disabled="loading" @click="confirmAttribution">已了解,生成解决方案</button>
+          <button class="btn-skip" @click="skipAttribution">跳过归因</button>
+        </view>
+        <view class="back-link" @click="skipAttribution">不看了,直接生成</view>
+      </view>
+    </view>
+
+    <!-- 不确定点对话补充(R3):AI 返回 followUpQuestions 时逐题询问,回答后回传二次生成 -->
+    <view v-if="followUpVisible && !viewMode" class="followup-overlay">
+      <view class="followup-card">
+        <text class="followup-title">💬 补充几个问题</text>
+        <text class="followup-desc">为让方案更精准,请补充以下信息(可跳过)</text>
+        <view class="followup-q" v-if="currentFollowUpQuestion">
+          <text class="followup-q-index">{{ followUpIndex + 1 }}/{{ followUpQuestions.length }}</text>
+          <text class="followup-q-text">{{ currentFollowUpQuestion.question }}</text>
+          <text v-if="currentFollowUpQuestion.hint" class="followup-q-hint">{{ currentFollowUpQuestion.hint }}</text>
+        </view>
+        <textarea class="followup-input" v-model="followUpAnswerInput" placeholder="请输入您的回答(可选)..." auto-height />
+        <view class="followup-actions">
+          <button class="btn-skip followup-skip" @click="skipFollowUpQuestion">跳过此题</button>
+          <button class="btn-confirm followup-next" :disabled="!followUpAnswerInput" @click="submitFollowUpAnswer">下一题</button>
+        </view>
+        <view class="followup-cancel-link" @click="cancelFollowUp">暂不补充,直接生成</view>
+      </view>
+    </view>
+
     <!-- Step 3 编辑模式:各 section 编辑 -->
     <view class="section" v-if="editing && !viewMode">
       <view class="section-title-row">
@@ -247,7 +337,7 @@
 </template>
 
 <script>
-import { generateHealthPlan, regenerateSection, adjustPlanSection, confirmPlan, saveHealthPlan, getHealthPlanDetail } from '../../utils/api'
+import { generateHealthPlan, generatePlanAttribution, regenerateSection, adjustPlanSection, confirmPlan, saveHealthPlan, getHealthPlanDetail } 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 +393,19 @@ export default {
       // Step 3 签名确认
       signatureDataUrl: '',
       // 确认页:是否展开非关键项
-      showFullSummary: false
+      showFullSummary: false,
+      // HL-03/R2:归因方案(签名后先出归因,确认后才生成解决方案)
+      attributionData: {},
+      attributionShowing: false,
+      attributionLoading: false,
+      // HL-03/R3:不确定点对话补充回答(AI 返回 followUpQuestions 时逐题询问,回答后回传二次生成)
+      followUpQuestions: [],
+      followUpIndex: 0,
+      followUpAnswersMap: {},
+      followUpAnswerInput: '',
+      followUpVisible: false,
+      pendingFollowUpAnswers: null,
+      followUpHandled: false
     }
   },
   computed: {
@@ -338,6 +440,10 @@ export default {
     canSendDialogue: function() {
       return !this.loading && this.dialogueIntent && String(this.dialogueIntent).trim() !== ''
     },
+    // HL-03/R3:当前正被追问的不确定点(逐题询问)
+    currentFollowUpQuestion: function() {
+      return this.followUpQuestions[this.followUpIndex] || null
+    },
 selectedMembers: function() {
       var self = this
       var out = []
@@ -947,7 +1053,9 @@ selectedMembers: function() {
       if (sigTasks.length === 0) {
         self.loading = false
         self.step = 4
-        self.generatePlan()
+        self.attributionShowing = true
+        self.followUpHandled = false
+        self.generateAttribution()
         return
       }
       Promise.all(sigTasks).then(function() {
@@ -955,12 +1063,16 @@ selectedMembers: function() {
         // 本地回显签署时间(服务端以 health_status.signed_at 为准)
         self.stampLocalSignedAt()
         self.step = 4
-        self.generatePlan()
+        self.attributionShowing = true
+        self.followUpHandled = false
+        self.generateAttribution()
       }).catch(function() {
         // 签名落库失败不阻塞生成
         self.loading = false
         self.step = 4
-        self.generatePlan()
+        self.attributionShowing = true
+        self.followUpHandled = false
+        self.generateAttribution()
       })
     },
     // 签名成功后本地回显签署时间(格式 yyyy-MM-dd HH:mm:ss)
@@ -976,20 +1088,69 @@ selectedMembers: function() {
       })
     },
 
+    // ===== 归因方案(HL-03/R2):签名后先出归因,确认后才生成解决方案 =====
+    generateAttribution: function() {
+      var self = this
+      self.attributionLoading = true
+      var params = {
+        memberIds: self.selectedMemberIds.join(','),
+        goal: self.userGoal.trim()
+      }
+      generatePlanAttribution(params).then(function(res) {
+        self.attributionLoading = false
+        if (res && res.code === 200 && res.data) {
+          self.attributionData = res.data
+        } else {
+          self.attributionData = {}
+          uni.showToast({ title: res && res.message ? res.message : '归因分析获取失败', icon: 'none' })
+        }
+      }).catch(function(err) {
+        self.attributionLoading = false
+        self.attributionData = {}
+        if (err && err.code === 4001) {
+          self.showPaywallSheet()
+        } else {
+          uni.showToast({ title: '请求失败,请检查网络', icon: 'none' })
+        }
+      })
+    },
+    // 已了解归因 → 进入解决方案生成
+    confirmAttribution: function() {
+      this.attributionShowing = false
+      this.generatePlan()
+    },
+    // 跳过归因 → 直接进入解决方案生成
+    skipAttribution: function() {
+      this.attributionShowing = false
+      this.generatePlan()
+    },
+
     // ===== 方案生成与编辑 (保持原有逻辑) =====
     generatePlan: function() {
       var self = this
       if (!self.canGenerate) return
       self.loading = true
+      var followUpAnswers = self.pendingFollowUpAnswers
+      // 单次消费:补充回答只随本次透传,避免后续「重新生成」误带上次的回答
+      self.pendingFollowUpAnswers = null
       var params = {
         memberIds: self.selectedMemberIds.join(','),
         dimensions: 'body',
         goal: self.userGoal.trim()
       }
+      if (followUpAnswers) {
+        params.followUpAnswers = followUpAnswers
+      }
       generateHealthPlan(params).then(function(res) {
         self.loading = false
         if (res && res.code === 200 && res.data) {
           var data = res.data
+          // R3:首次生成若返回不确定点 → 先对话补充,不渲染半成品方案
+          var fq = (data && (data.followUpQuestions || data.follow_up_questions)) || []
+          if (fq.length > 0 && !self.followUpHandled && !followUpAnswers) {
+            self.startFollowUpDialog(fq)
+            return
+          }
           // 多人方案:后端返回 {planContent, common, individual} 结构化对象
           if (data && typeof data === 'object' && data.common && data.individual) {
             // 用 common 填充三大板块;再把每位成员的 individual 内容追加到同名板块后
@@ -1024,6 +1185,11 @@ selectedMembers: function() {
             self.planData = { overview: data }
             self.multiMemberPlanJson = null
             self.step = 4
+          } else if (data.planContent) {
+            // R3 单人生成结构化返回 {planContent, followUpQuestions}
+            self.planData = { overview: data.planContent }
+            self.multiMemberPlanJson = null
+            self.step = 4
           } else if (data.overview) {
             self.planData = data
             self.multiMemberPlanJson = null
@@ -1056,6 +1222,54 @@ selectedMembers: function() {
         }
       })
     },
+
+    // ===== 不确定点对话补充(HL-03/R3):逐题询问,回答后回传二次生成 =====
+    startFollowUpDialog: function(fq) {
+      // 阻断循环:一旦进入过追问流程,后续生成(带回答)不再重复弹窗
+      this.followUpHandled = true
+      this.followUpQuestions = fq || []
+      this.followUpIndex = 0
+      this.followUpAnswersMap = {}
+      this.followUpAnswerInput = ''
+      this.followUpVisible = true
+    },
+    submitFollowUpAnswer: function() {
+      var self = this
+      var q = self.currentFollowUpQuestion
+      var answer = (self.followUpAnswerInput || '').trim()
+      if (!q || !answer) return
+      if (q.id) self.followUpAnswersMap[String(q.id)] = answer
+      self.followUpAnswerInput = ''
+      if (self.followUpIndex < self.followUpQuestions.length - 1) {
+        self.followUpIndex++
+      } else {
+        self.finishFollowUp()
+      }
+    },
+    skipFollowUpQuestion: function() {
+      var self = this
+      var q = self.currentFollowUpQuestion
+      // 跳过记空值:仍视为「已回应」,避免二次生成时再次追问同一题
+      if (q && q.id) self.followUpAnswersMap[String(q.id)] = ''
+      self.followUpAnswerInput = ''
+      if (self.followUpIndex < self.followUpQuestions.length - 1) {
+        self.followUpIndex++
+      } else {
+        self.finishFollowUp()
+      }
+    },
+    finishFollowUp: function() {
+      this.followUpVisible = false
+      // 单次消费:本次补充回答仅随下一次生成透传
+      this.pendingFollowUpAnswers = this.followUpAnswersMap
+      this.generatePlan()
+    },
+    cancelFollowUp: function() {
+      this.followUpVisible = false
+      this.pendingFollowUpAnswers = null
+      this.generatePlan()
+    },
+
     checkQuestionnaireGate: function() {
       var self = this
       getMyMembership().then(function(res) {
@@ -2136,4 +2350,262 @@ selectedMembers: function() {
   border: 2rpx solid $brand-primary;
   border-radius: 24rpx;
 }
+
+/* ===== 归因方案(HL-03/R2) ===== */
+.attr-section { margin-top: 0; }
+.attr-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 80rpx 20rpx;
+}
+.attr-head {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  margin-bottom: 20rpx;
+}
+.attr-head-left {
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  margin-right: 16rpx;
+}
+.attr-title {
+  font-size: calc(32rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $facade-text-strong;
+}
+.attr-sub {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  margin-top: 6rpx;
+}
+.attr-mode {
+  flex-shrink: 0;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $brand-primary;
+  background: $uni-primary-pale;
+  padding: 6rpx 16rpx;
+  border-radius: 24rpx;
+}
+.attr-conclusion {
+  background: $uni-primary-pale;
+  border-left: 4rpx solid $brand-primary;
+  border-radius: 16rpx;
+  padding: 20rpx 24rpx;
+  margin-bottom: 16rpx;
+}
+.attr-conclusion-label {
+  display: block;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $brand-primary;
+  font-weight: bold;
+  margin-bottom: 6rpx;
+}
+.attr-conclusion-text {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  color: $facade-text-deep;
+  line-height: 1.7;
+}
+.attr-summary {
+  background: $facade-fill-ghost;
+  border-radius: 16rpx;
+  padding: 20rpx 24rpx;
+  margin-bottom: 20rpx;
+}
+.attr-summary-text {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+  line-height: 1.7;
+}
+.attr-block {
+  margin-bottom: 20rpx;
+}
+.attr-block-title {
+  display: block;
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $facade-text-strong;
+  margin-bottom: 12rpx;
+}
+.attr-member {
+  background: $app-bg;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  margin-bottom: 16rpx;
+  border: 2rpx solid $facade-hairline-2;
+}
+.attr-member-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12rpx;
+}
+.attr-member-name {
+  font-size: calc(28rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $brand-primary;
+}
+.attr-member-role {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  background: $facade-fill-slate;
+  padding: 4rpx 16rpx;
+  border-radius: 24rpx;
+}
+.attr-row {
+  display: flex;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  padding: 8rpx 0;
+  border-bottom: 1rpx solid $facade-line;
+}
+.attr-row:last-child { border-bottom: none; }
+.attr-cell-label {
+  flex: 0 0 120rpx;
+  color: $facade-text-muted;
+}
+.attr-cell-value {
+  flex: 1;
+  color: $facade-text-strong;
+  word-break: break-all;
+}
+.attr-weak {
+  margin-top: 10rpx;
+}
+.attr-weak-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+  margin-top: 8rpx;
+}
+.attr-weak-chip {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $status-warning-strong;
+  background: $fortune-1-tint;
+  padding: 6rpx 18rpx;
+  border-radius: 24rpx;
+}
+.attr-question {
+  background: $app-bg;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  margin-bottom: 12rpx;
+}
+.attr-question-text {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-deep;
+  line-height: 1.6;
+}
+.attr-source-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+.attr-source-chip {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $status-info;
+  background: $app-bg;
+  border: 2rpx solid $status-info;
+  padding: 8rpx 18rpx;
+  border-radius: 24rpx;
+}
+.attr-warning {
+  background: rgba(255, 87, 34, 0.08);
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  margin-bottom: 16rpx;
+}
+.attr-warning-text {
+  display: block;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $status-warning-strong;
+  line-height: 1.6;
+}
+
+/* ===== 不确定点对话补充(HL-03/R3) ===== */
+.followup-overlay {
+  position: fixed;
+  top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(0,0,0,0.55);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 200;
+}
+.followup-card {
+  background: $app-surface;
+  border-radius: 24rpx;
+  padding: 40rpx 32rpx;
+  width: 620rpx;
+  max-height: 80vh;
+  overflow-y: auto;
+  box-sizing: border-box;
+}
+.followup-title {
+  font-size: calc(32rpx * var(--font-scale, 1));
+  font-weight: bold;
+  color: $facade-text-strong;
+  margin-bottom: 10rpx;
+}
+.followup-desc {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  margin-bottom: 20rpx;
+}
+.followup-q {
+  background: $uni-primary-pale;
+  border-radius: 16rpx;
+  padding: 20rpx 24rpx;
+  margin-bottom: 16rpx;
+}
+.followup-q-index {
+  display: block;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $brand-primary;
+  margin-bottom: 8rpx;
+}
+.followup-q-text {
+  display: block;
+  font-size: calc(28rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+  line-height: 1.7;
+  margin-bottom: 8rpx;
+}
+.followup-q-hint {
+  display: block;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  line-height: 1.6;
+}
+.followup-input {
+  width: 100%;
+  min-height: 160rpx;
+  max-height: 320rpx;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+  background: $app-bg;
+  border: 2rpx solid $brand-primary;
+  border-radius: 16rpx;
+  padding: 20rpx 24rpx;
+  line-height: 1.7;
+  box-sizing: border-box;
+}
+.followup-actions {
+  display: flex;
+  gap: 16rpx;
+  margin-top: 24rpx;
+}
+.followup-actions .followup-skip,
+.followup-actions .followup-next {
+  flex: 1;
+  margin-top: 0;
+}
+.followup-cancel-link {
+  text-align: center;
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  margin-top: 20rpx;
+}
+.followup-cancel-link:active { opacity: 0.7; }
 </style>

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

@@ -2629,6 +2629,8 @@ export const getDimensionHistory = (data) => request('/api/dimension/history', '
 
 // ===== 健康方案汇总 =====
 export const generateHealthPlan = (data) => request('/api/health/plan/generate', 'POST', data)
+// HL-03/R2 归因方案(签名后、解决方案生成前:讲清要解决的问题)
+export const generatePlanAttribution = (data) => request('/api/health/plan/attribution', 'POST', data)
 export const regenerateSection = (data) => request('/api/health/plan/regenerate-section', 'POST', data)
 export const adjustPlanSection = (data) => request('/api/health/plan/adjust', 'POST', data)
 export const confirmPlan = (data) => request('/api/health/plan/confirm', 'POST', data)

+ 67 - 6
cfc-langgraph/app/api/adapter.py

@@ -553,6 +553,8 @@ class HealthPlanRequest(BaseModel):
     dimensions: Optional[str] = None
     goal: str = ""
     family_id: Optional[int] = Field(default=None, validation_alias=AliasChoices("family_id", "familyId"))
+    # HL-03/R3:用户对不确定点的对话补充回答(首次为空 dict,二次生成携带答案)
+    follow_up_answers: Optional[dict] = Field(default=None, validation_alias=AliasChoices("follow_up_answers", "followUpAnswers"))
 
 
 class HealthPlanRegenerateRequest(BaseModel):
@@ -891,9 +893,11 @@ async def _collect_plan_data(java: JavaClient, retriever: RagRetriever, member_i
 
     # 2. 健康指标
     all_indicators = []
-    abnormal_list = []
     for member in members_info:
         reports = await java.get_member_reports(int(member["id"]))
+        # HL-03/R3:记录报告类型,供不确定点检测(如缺少菌群报告)
+        member["report_types"] = [str(r.get("reportType", "")) for r in reports] if reports else []
+        member["has_gut_flora"] = any(t == "gut_flora" for t in member["report_types"])
         if not reports:
             continue
         latest = max(reports, key=lambda r: r.get("reportDate", ""))
@@ -929,6 +933,51 @@ async def _collect_plan_data(java: JavaClient, retriever: RagRetriever, member_i
     return members_info, all_indicators, kb_results
 
 
+def _detect_follow_up_questions(members_info: list, follow_up_answers: Optional[dict]) -> list:
+    """HL-03/R3:检测生成方案时的不确定点(问卷未涉及但需了解的特殊问题)。
+
+    首次生成(follow_up_answers 为空)时按数据缺失检测;用户已回答(follow_up_answers 非空)时
+    返回空列表,避免重复追问。每个不确定点形如 {id, question, hint}。
+    """
+    if follow_up_answers:
+        # 已回答 → 二次生成不再追问
+        return []
+    questions: list = []
+    if not members_info:
+        return questions
+
+    # 缺少肠道菌群检测数据
+    has_gut = any(m.get("has_gut_flora") for m in members_info)
+    if not has_gut:
+        questions.append({
+            "id": "gut_flora",
+            "question": "是否做过肠道菌群检测?如果做过,报告中明显的异常是什么?(如某菌属偏低/偏高、菌群多样性不足)",
+            "hint": "问卷外补充:用于精准定位肠道菌群维度",
+        })
+
+    # 缺少健康自检(health_status)信息
+    has_health_status = any(
+        isinstance(m.get("health_status"), dict) and m.get("health_status") for m in members_info
+    )
+    if not has_health_status:
+        questions.append({
+            "id": "health_status",
+            "question": "最近一次体检/健康评估中,有哪些指标明显异常?(如血脂、血糖、维生素缺乏等)",
+            "hint": "问卷外补充:帮助定制饮食与营养建议",
+        })
+    return questions
+
+
+def _format_follow_up_answers(follow_up_answers: Optional[dict]) -> str:
+    """将用户对话补充回答格式化为 prompt 片段(空则返回空串)。"""
+    if not follow_up_answers:
+        return ""
+    lines = ["", "## 用户补充回答(对话式特殊问题)"]
+    for key, val in follow_up_answers.items():
+        lines.append(f"- {key}: {val}")
+    return "\n".join(lines)
+
+
 # 从 LLM 原始输出中提取 tasks 的正则兜底:在 content 文本里找形如
 # "1. 动作(动词/名词)..." 的行。以可识别的动作词开头视为潜在任务。
 _TASK_FALLBACK_RE = re.compile(r"^\s*(?:\d+[\.、)]|\-\s*)\s*"
@@ -1153,10 +1202,12 @@ async def _generate_individual_plan(llm, member: dict, member_indicators: list,
         return {}
 
 
-async def _synthesize_multi_member_plan(llm, members_info: list, individual_plans: dict, goal: str, dimensions: str) -> dict:
+async def _synthesize_multi_member_plan(llm, members_info: list, individual_plans: dict, goal: str, dimensions: str,
+                                        follow_up_answers: Optional[dict] = None) -> dict:
     """将 N 份单人方案交给 LLM 做交集合成,返回 MultiMemberPlanResponse.model_dump() 结构。
 
     LLM 失败或输出不合法时,退化为纯程序拼接:common 各部分置空,individual 直接透传各成员方案。
+    follow_up_answers(HL-03/R3):用户对话补充回答,非空时并入合成 prompt 供 LLM 参考。
     """
     blocks = []
     for m in members_info:
@@ -1188,6 +1239,8 @@ async def _synthesize_multi_member_plan(llm, members_info: list, individual_plan
 
     prompt = MULTI_MEMBER_SYNTHESIS_PROMPT + "\n\n## 各成员独立方案(请按 memberId 归并,memberId 必须与下列标题中的 id 完全一致)\n\n"
     prompt += "\n\n---\n\n".join(blocks)
+    # HL-03/R3:用户补充回答并入合成 prompt(首次为空串)
+    prompt += _format_follow_up_answers(follow_up_answers)
     try:
         response = await llm.ainvoke([SystemMessage(content=prompt)])
         answer = response.content
@@ -1245,6 +1298,9 @@ async def health_plan_generate(req: HealthPlanRequest):
         except Exception:
             member["profile"] = {}
 
+    # HL-03/R3:检测不确定点(首次生成,二次生成已回答返回空)
+    follow_up_questions = _detect_follow_up_questions(members_info, req.follow_up_answers)
+
     # === 多人方案:先每人单独生成,再 LLM 交集合成(共性/个性) ===
     if len(members_info) > 1:
         individual_plans = {}
@@ -1252,7 +1308,9 @@ async def health_plan_generate(req: HealthPlanRequest):
             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 "")
+        result = await _synthesize_multi_member_plan(llm, members_info, individual_plans, goal, req.dimensions or "",
+                                                     follow_up_answers=req.follow_up_answers)
+        result["follow_up_questions"] = follow_up_questions
         return {"success": True, "data": result, "parse_error": None}
 
     # === 单人生成:保持原有 flat 结构(向后兼容) ===
@@ -1300,15 +1358,18 @@ async def health_plan_generate(req: HealthPlanRequest):
             parts.append(f"---\n{title}\n{content}")
 
     full_prompt = "\n".join(parts)
+    # HL-03/R3:用户补充回答并入单人生成 prompt(首次为空串)
+    full_prompt += _format_follow_up_answers(req.follow_up_answers)
 
     messages = [SystemMessage(content=full_prompt)]
     try:
         response = await llm.ainvoke(messages)
         answer = response.content
         result = _parse_plan_response(answer)
-        if result["success"]:
-            return {"success": True, "data": result["data"], "parse_error": result["error"]}
-        return {"success": True, "data": result["data"], "parse_error": result["error"]}
+        data = result["data"]
+        # HL-03/R3:不确定点随 data 返回(首次生成),前端对话式补充后带 followUpAnswers 二次生成
+        data["follow_up_questions"] = follow_up_questions
+        return {"success": True, "data": data, "parse_error": result["error"]}
     except Exception as e:
         logger.error("方案生成失败: %s", e)
         return {"success": False, "error": str(e)}

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

@@ -35,6 +35,8 @@ class HealthPlanResponse(BaseModel):
     sections: List[HealthPlanSection] = []
     abnormal_indicators: List[dict] = []
     knowledge_sources: List[dict] = []
+    # HL-03/R3:生成时的不确定点(对话式补充提问),无则空列表
+    follow_up_questions: List[dict] = []
 
 
 class PlanPart(BaseModel):
@@ -43,6 +45,8 @@ class PlanPart(BaseModel):
     sections: List[HealthPlanSection] = []
     abnormal_indicators: List[dict] = []
     knowledge_sources: List[dict] = []
+    # HL-03/R3:生成时的不确定点(对话式补充提问),无则空列表
+    follow_up_questions: List[dict] = []
 
 
 class MultiMemberPlanResponse(BaseModel):
@@ -53,6 +57,8 @@ class MultiMemberPlanResponse(BaseModel):
     """
     common: PlanPart = PlanPart()
     individual: Dict[str, PlanPart] = {}
+    # HL-03/R3:多人方案整体的不确定点(对话式补充提问),无则空列表
+    follow_up_questions: List[dict] = []
 
     def build_rendered_content(self, member_names: Dict[str, str]) -> str:
         """把 common+individual 组合渲染为 Markdown 文本(供 planContent 使用)。

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

@@ -1897,6 +1897,66 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 
 *文档更新:2026-11-08(HL-04 R4 方案对话式调整,见 `specs/2026-11-07-health-plan-flow-rework-change.md`)*
 
+### 4.49.2 健康方案归因(R2)+ 生成不确定点补充(R3)(`POST /api/health/plan/attribution`、`/generate`)
+
+健康方案生成流程改造(HL-03,见 `specs/2026-11-07-health-plan-flow-rework-change.md`):签名确认后**先出「归因方案」讲清要解决的问题**(R2),用户确认后才生成解决方案;生成接口返回**不确定点 `followUpQuestions`**(R3),前端以对话方式逐题补充回答后回传二次生成。
+
+**① `POST /api/health/plan/attribution`** — 归因方案(R2)
+
+| 端点 | 权限 | 请求 | 返回 |
+|---|---|---|---|
+| `POST /api/health/plan/attribution` | 登录用户(JWT);付费墙同 `/generate`(免费用户且未购健康启航计划 → `4001`) | `{memberIds, goal}` | `Result<Map>` 归因摘要 |
+
+- **入参**:`memberIds`(必填,逗号分隔成员 ID)、`goal`(可选)。
+- **归因逻辑**(`HealthPlanServiceImpl.generateAttributionSummary`):有菌群报告(`health_reports.report_type='gut_flora', status='active'` 按成员取最新)或 DAN 结果(`dan_assessment_results.status='completed'` 按成员取最新)→ 复用 `AttributionAnalysisService.analyze`(Service 层直调,不受 `app.attribution.enabled` 开关拦截)返回 `mode='analysis'`;否则降级五维健康自检规则归因返回 `mode='fallback'`。
+- **返回结构**:
+
+```json
+{
+  "code": 200,
+  "data": {
+    "mode": "analysis" | "fallback",
+    "attributionId": 123,             // analysis 模式
+    "conclusion": "核心结论",
+    "summary": "总体归因摘要",
+    "members": [
+      { "memberId": 1, "name": "小李", "roleLabel": "孩子", "surface": "表象", "essence": "本质", "weakDimensions": ["肠道", "免疫"] }
+    ],
+    "coachQuestions": ["需要进一步思考的问题"],
+    "dataSources": ["菌群读数 5 项", "DAN 读数 3 项"],
+    "warnings": ["..."],               // analysis 模式可选
+    "goal": "提升专注力"                // 传入 goal 时回显
+  }
+}
+```
+
+**② `POST /api/health/plan/generate`** — 生成方案,新增 `followUpAnswers` 可选参数(R3)
+
+| 端点 | 权限 | 请求 | 返回 |
+|---|---|---|---|
+| `POST /api/health/plan/generate` | 登录用户(JWT);付费墙同上 | `{memberIds, dimensions, goal, followUpAnswers?}` | `Result<Object>` |
+
+- **新增参数 `followUpAnswers`**(可选 Map):首次生成不传;若响应返回 `followUpQuestions`,前端逐题询问用户后,把 `{questionId: 回答}` 作为 `followUpAnswers` 回传二次生成。非空时 LangGraph `_detect_follow_up_questions` 返回 `[]`(避免循环追问),回答内容并入 prompt(单人 `full_prompt` + 多人 `_synthesize_multi_member_plan`)。
+- **返回结构**(生成成功含 `followUpQuestions` 键):
+
+```json
+{
+  "code": 200,
+  "data": {
+    "planContent": "方案正文...",
+    "common": { "overview": "...", "sections": [...] },   // 多人模式
+    "individual": { "memberId": { "sections": [...] } },  // 多人模式
+    "followUpQuestions": [
+      { "id": "gut_flora", "question": "是否做过肠道菌群检测?...", "hint": "问卷外补充:..." }
+    ]
+  }
+}
+```
+
+- **不确定点检测**(LangGraph `_detect_follow_up_questions`):`follow_up_answers` 非空 → `[]`;否则按 `has_gut_flora`(缺 → `gut_flora` 题)+ `health_status`(缺 → `health_status` 题)检测,最多 2 题。单人返回 `{planContent, followUpQuestions}`;多人返回 `{planContent, common, individual, followUpQuestions}`;LangGraph 不可用降级 `{planContent, followUpQuestions: []}`。
+
+*文档更新:2026-11-08(HL-03 R2+R3 归因方案 + 不确定点对话补充,见 `specs/2026-11-07-health-plan-flow-rework-change.md`)*
+
 ### 4.50 活动海报模板(`/api/admin/poster-template/*` + `/api/poster-template/*`)
 
 Web 管理端定义活动海报模板(迁移381 `activity_poster_templates` 表),小程序端活动详情页「生成海报」拉取可用模板渲染画布。管理端接口校验 `@RequestAttribute("role")`,仅 `admin`/`operator` 可访问;`templateKey` 限定 `gradient`(渐变)/`minimal`(深色)/`card`(白底卡片),`name` 全局唯一。