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

chore: auto bump version and changelog [skip ci]

iwt 2 недель назад
Родитель
Сommit
21ad41ea62

+ 14 - 11
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -191,20 +191,23 @@ public class AIChatController {
             inputs.put("portrait_prompt", chatPortrait);
         }
 
-        // 注入自检上下文(P1-2)
+        // 注入自检上下文(P1-2):自动注入最近一次自检摘要(维度分 + 低分项),
+        // 使 /chat/send 无需前端额外传参即可针对自检结果连续对话;
+        // 指定 selfCheckId 时优先用该记录(服务内校验归属,无效静默跳过)
+        Long selfCheckId = null;
         if (selfCheckIdStr != null && !selfCheckIdStr.trim().isEmpty()) {
             try {
-                Long selfCheckId = Long.valueOf(selfCheckIdStr);
-                com.etotem.cfc.entity.FiveDimensionSelfCheck selfCheck = selfCheckMapper.selectById(selfCheckId);
-                if (selfCheck != null) {
-                    java.util.Map<String, Object> sc = new java.util.LinkedHashMap<>();
-                    sc.put("totalScore", selfCheck.getTotalScore());
-                    sc.put("scores", selfCheck.getScoresJson());
-                    sc.put("createdAt", selfCheck.getCreatedAt() != null ? selfCheck.getCreatedAt().toString() : "");
-                    inputs.put("self_check_result", objectMapper.writeValueAsString(sc));
-                }
+                selfCheckId = Long.valueOf(selfCheckIdStr);
+            } catch (Exception e) {
+                log.warn("selfCheckId 解析失败: {}", e.getMessage());
+            }
+        }
+        java.util.Map<String, Object> selfCheckSummary = selfCheckAnalysisService.buildLatestSummary(userId, selfCheckId);
+        if (selfCheckSummary != null) {
+            try {
+                inputs.put("self_check_result", objectMapper.writeValueAsString(selfCheckSummary));
             } catch (Exception e) {
-                log.warn("注入自检上下文失败: {}", e.getMessage());
+                log.warn("序列化自检上下文失败: {}", e.getMessage());
             }
         }
 

+ 58 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -230,6 +230,64 @@ public class AiGateway {
         }
     }
 
+    /**
+     * 分析型对话(调用 LangGraph chat_graph),失败时返回 null
+     *
+     * 与 {@link #chat(String, Long, String, Map)} 的差异:
+     * <ol>
+     *   <li>不消耗会员 AI_CHAT 对话配额——分析型调用是功能内嵌(如自检趋势解读),
+     *       与用户主动对话额度解耦,避免 FREE 用户被硬门禁挡掉、BASIC 用户被动扣次;</li>
+     *   <li>不落本地会话镜像——不是用户主动发起的对话,不污染会话列表。</li>
+     * </ol>
+     * 熔断/超时/PII 脱敏/Fallback 行为与 chat() 完全一致。
+     *
+     * @param query Java 侧组装好的 prompt
+     * @param inputs 上下文(仅 String/Number/Boolean 会透传给 chat_graph)
+     * @return 含 "answer" 字段的 Map;失败返回 null
+     */
+    public Map<String, Object> chatForAnalysis(String query, Long userId, String conversationId,
+                                                 Map<String, Object> inputs) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            // PII脱敏
+            query = piisService.sanitizeText(query);
+            Map<String, Object> sanitizedInputs = piisService.sanitizeContext(inputs, userId);
+
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("query", query != null ? query : "");
+            body.put("user_id", userId);
+            body.put("conversation_id", conversationId != null ? conversationId : "");
+
+            if (sanitizedInputs != null && !sanitizedInputs.isEmpty()) {
+                ObjectNode ctx = body.putObject("context");
+                sanitizedInputs.forEach((key, value) -> {
+                    if (value instanceof String) ctx.put(key, (String) value);
+                    else if (value instanceof Number) ctx.put(key, ((Number) value).doubleValue());
+                    else if (value instanceof Boolean) ctx.put(key, (Boolean) value);
+                });
+            }
+
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/chat";
+
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                Map<String, Object> result = new LinkedHashMap<>();
+                result.put("answer", root.has("answer") ? root.get("answer").asText() : "");
+                result.put("conversationId", root.has("conversation_id") ? root.get("conversation_id").asText() : "");
+                consecutiveFailures.set(0);
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway chatForAnalysis 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
     public Map<String, Object> generateQuestionnaire(Map<String, Object> inputs) {
         if (!enabled || isCircuitOpen()) return null;
 

+ 5 - 7
cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java

@@ -60,6 +60,9 @@ public class FiveDimensionSelfCheckService {
     @Resource
     private SelfCheckAnalysisService selfCheckAnalysisService;
 
+    @Resource
+    private SelfCheckTrendService selfCheckTrendService;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     /** 维度中文名与五行 */
@@ -612,15 +615,10 @@ public class FiveDimensionSelfCheckService {
     }
 
     /**
-     * 获取自检趋势分析结果(含历史列表)
+     * 获取自检趋势分析结果(含历史列表 + 各维度 delta + AI 解读)
      */
     public Map<String, Object> getTrendAnalysis(Long userId) {
-        Map<String, Object> trend = selfCheckAnalysisService.generateTrend(userId);
-        Map<String, Object> resp = new HashMap<>();
-        resp.put("history", selfCheckAnalysisService.getRecentHistory(userId, 3));
-        resp.put("aiInsight", trend.getOrDefault("aiInsight", ""));
-        resp.put("trendSummary", trend.getOrDefault("trendSummary", ""));
-        return resp;
+        return selfCheckTrendService.getTrendData(userId);
     }
 
     // === 内部方法 ===

+ 124 - 24
cfc-backend/src/main/java/com/etotem/cfc/service/SelfCheckAnalysisService.java

@@ -23,8 +23,128 @@ public class SelfCheckAnalysisService {
     @Resource
     private FiveDimensionSelfCheckMapper selfCheckMapper;
 
+    @Resource
+    private WuxingSourcingService wuxingSourcingService;
+
+    @Resource
+    private SelfCheckTrendService selfCheckTrendService;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
+    /** 低分阈值(≤6 视为低分项) */
+    private static final int LOW_SCORE_THRESHOLD = 6;
+
+    /** 维度展示顺序:身·智·富·行·心 */
+    private static final List<String> DIMENSION_ORDER =
+            Collections.unmodifiableList(Arrays.asList("body", "wisdom", "wealth", "action", "mind"));
+
+    /**
+     * 组装最近一次自检摘要(P1-2 Chat 上下文注入)
+     *
+     * <p>输出结构对齐 LangGraph chat_graph 的 self_check_result 解析约定:
+     * {@code totalScore} + {@code dimensions:[{name,score}]} + {@code aiInsight}(低分项摘要)。
+     * 这样 /chat/send 无需前端额外传参即可基于自检结果连续对话。</p>
+     *
+     * @param userId  当前登录用户(用于归属校验)
+     * @param checkId 指定自检记录 ID,可为空(为空时取该用户最近一次)
+     * @return 摘要 Map;无记录 / 记录不属于当前用户 / 异常时返回 null(调用方静默跳过)
+     */
+    public Map<String, Object> buildLatestSummary(Long userId, Long checkId) {
+        try {
+            FiveDimensionSelfCheck record;
+            if (checkId != null) {
+                record = selfCheckMapper.selectById(checkId);
+                if (record == null) {
+                    log.info("自检上下文注入跳过:checkId={} 不存在", checkId);
+                    return null;
+                }
+                if (userId == null || !userId.equals(record.getUserId())) {
+                    log.info("自检上下文注入跳过:checkId={} 不属于当前用户", checkId);
+                    return null;
+                }
+            } else {
+                if (userId == null) {
+                    return null;
+                }
+                record = selfCheckMapper.selectOne(
+                        new LambdaQueryWrapper<FiveDimensionSelfCheck>()
+                                .eq(FiveDimensionSelfCheck::getUserId, userId)
+                                .orderByDesc(FiveDimensionSelfCheck::getCreatedAt)
+                                .last("LIMIT 1")
+                );
+            }
+            if (record == null) {
+                return null;
+            }
+
+            Map<String, Integer> scores = record.getScoresJson() != null
+                    ? objectMapper.readValue(record.getScoresJson(), new TypeReference<Map<String, Integer>>() {})
+                    : new LinkedHashMap<>();
+
+            List<Map<String, Object>> dimensions = new ArrayList<>();
+            List<Map<String, Object>> lowItems = new ArrayList<>();
+            Map<String, Object> lowest = null;
+            for (String code : DIMENSION_ORDER) {
+                Integer score = scores.get(code);
+                if (score == null) {
+                    continue;
+                }
+                Map<String, Object> d = new LinkedHashMap<>();
+                d.put("dimension", code);
+                d.put("name", wuxingSourcingService.dimensionName(code));
+                d.put("score", score);
+                String level = wuxingSourcingService.levelOf(score);
+                d.put("level", level);
+                d.put("levelName", wuxingSourcingService.levelName(level));
+                dimensions.add(d);
+                if (score <= LOW_SCORE_THRESHOLD) {
+                    lowItems.add(d);
+                }
+                if (lowest == null || score < toInt(lowest.get("score"))) {
+                    lowest = d;
+                }
+            }
+
+            Map<String, Object> summary = new LinkedHashMap<>();
+            summary.put("id", record.getId());
+            summary.put("totalScore", record.getTotalScore());
+            summary.put("createdAt", record.getCreatedAt() != null ? record.getCreatedAt().toString() : "");
+            summary.put("dimensions", dimensions);
+            summary.put("lowItems", lowItems);
+            summary.put("lowestDimension", lowest);
+            if (lowest != null) {
+                StringBuilder insight = new StringBuilder("最近一次自检低分维度(≤")
+                        .append(LOW_SCORE_THRESHOLD).append("分):");
+                if (lowItems.isEmpty()) {
+                    insight.append("无(各维度均健康)");
+                } else {
+                    List<String> parts = new ArrayList<>();
+                    for (Map<String, Object> low : lowItems) {
+                        parts.add(low.get("name") + " " + low.get("score") + "分");
+                    }
+                    insight.append(String.join("、", parts));
+                }
+                insight.append(";最低分维度:").append(lowest.get("name"))
+                        .append("(").append(toInt(lowest.get("score"))).append("分)");
+                summary.put("aiInsight", insight.toString());
+            }
+            return summary;
+        } catch (Exception e) {
+            log.warn("组装自检摘要失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private int toInt(Object v) {
+        if (v == null) return 0;
+        if (v instanceof Number) return ((Number) v).intValue();
+        try {
+            return Integer.parseInt(v.toString());
+        } catch (Exception e) {
+            return 0;
+        }
+    }
+
     /**
      * 生成自检建议(AI 优先,失败返回 adviceJson=null + fallbackUsed=true)
      */
@@ -68,32 +188,12 @@ public class SelfCheckAnalysisService {
     }
 
     /**
-     * 生成趋势分析(AI 优先,失败返回空 insight)
+     * 生成趋势分析(兼容入口,趋势逻辑已收敛到 {@link SelfCheckTrendService})
+     *
+     * @return 含 history / recordCount / trendAvailable / trendDetails / trendSummary / aiInsight
      */
     public Map<String, Object> generateTrend(Long userId) {
-        try {
-            List<Map<String, Object>> history = getRecentHistory(userId, 3);
-            Map<String, Object> inputs = new LinkedHashMap<>();
-            inputs.put("history", history);
-            inputs.put("userId", userId);
-            Map<String, Object> result = aiGateway.generateSelfCheckTrend(inputs);
-            if (result == null) {
-                Map<String, Object> empty = new LinkedHashMap<>();
-                empty.put("aiInsight", "");
-                empty.put("trendSummary", "");
-                return empty;
-            }
-            Map<String, Object> trendResult = new LinkedHashMap<>();
-            trendResult.put("aiInsight", result.getOrDefault("aiInsight", ""));
-            trendResult.put("trendSummary", result.getOrDefault("trendSummary", ""));
-            return trendResult;
-        } catch (Exception e) {
-            log.warn("趋势分析异常: {}", e.getMessage());
-            Map<String, Object> empty = new LinkedHashMap<>();
-            empty.put("aiInsight", "");
-            empty.put("trendSummary", "");
-            return empty;
-        }
+        return selfCheckTrendService.getTrendData(userId);
     }
 
     /** 获取最近 N 次自检历史(供 AI 和前端使用) */

+ 387 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/SelfCheckTrendService.java

@@ -0,0 +1,387 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FiveDimensionSelfCheck;
+import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+/**
+ * 五维自检历史趋势聚合 + AI 趋势解读(P1-1)
+ *
+ * 职责:
+ * <ol>
+ *   <li>聚合该成员历次自检得分(five_dimension_self_checks 表,按维度);</li>
+ *   <li>Java 侧确定性计算各维度 delta / 方向(上升 / 下降 / 波动 / 持平),
+ *       保证 AI 不可用时前端仍能展示趋势;</li>
+ *   <li>组装 prompt 上下文 → AiGateway → LangGraph chat_graph 生成趋势解读;</li>
+ *   <li>AI 不可用时分三级降级(chat_graph → 专用 trend graph → 确定性文案)。</li>
+ * </ol>
+ * 遵循 AiGateway 熔断 / 超时 / Fallback 规范,零 LangGraph 改动。
+ */
+@Service
+public class SelfCheckTrendService {
+
+    private static final Logger log = LoggerFactory.getLogger(SelfCheckTrendService.class);
+
+    /** 参与趋势聚合的最大自检次数 */
+    static final int HISTORY_LIMIT = 3;
+    /** 至少 N 次记录才出趋势解读 */
+    static final int TREND_MIN_RECORDS = 2;
+    /** 低分阈值(≤6 视为低分项) */
+    static final int LOW_SCORE_THRESHOLD = 6;
+    /** AI 解读最大长度(超出在句末截断) */
+    static final int MAX_INSIGHT_LENGTH = 600;
+
+    /** 维度展示顺序:身·智·富·行·心 */
+    private static final List<String> DIMENSION_ORDER =
+            Collections.unmodifiableList(Arrays.asList("body", "wisdom", "wealth", "action", "mind"));
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+    private final SimpleDateFormat dateTimeFmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+    private final SimpleDateFormat dateFmt = new SimpleDateFormat("yyyy-MM-dd");
+
+    @Resource
+    private AiGateway aiGateway;
+
+    @Resource
+    private FiveDimensionSelfCheckMapper selfCheckMapper;
+
+    @Resource
+    private WuxingSourcingService wuxingSourcingService;
+
+    @Resource
+    private TaskParseService taskParseService;
+
+    /**
+     * 获取自检趋势数据(含历史、各维度 delta、AI 解读),供 /api/family/self-check/trend-analysis 使用。
+     */
+    public Map<String, Object> getTrendData(Long userId) {
+        List<Map<String, Object>> history = getHistoryAscending(userId, HISTORY_LIMIT);
+        List<Map<String, Object>> trendDetails = computeTrendDetails(history);
+        boolean available = history.size() >= TREND_MIN_RECORDS;
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("history", history);
+        result.put("recordCount", history.size());
+        result.put("trendAvailable", available);
+        result.put("trendDetails", trendDetails);
+        result.put("trendSummary", buildDeltaSummary(trendDetails));
+        result.put("aiInsight", available ? generateAiInsight(userId, history, trendDetails) : "");
+        return result;
+    }
+
+    // ===== 历史聚合 =====
+
+    /** 最近 N 次自检(升序:最早在前),每条含维度明细(name/element/color/score/level) */
+    List<Map<String, Object>> getHistoryAscending(Long userId, int limit) {
+        if (userId == null) {
+            return Collections.emptyList();
+        }
+        try {
+            List<FiveDimensionSelfCheck> records = selfCheckMapper.selectList(
+                    new LambdaQueryWrapper<FiveDimensionSelfCheck>()
+                            .eq(FiveDimensionSelfCheck::getUserId, userId)
+                            .orderByDesc(FiveDimensionSelfCheck::getCreatedAt)
+                            .last("LIMIT " + limit)
+            );
+            List<Map<String, Object>> history = new ArrayList<>();
+            if (records != null) {
+                for (FiveDimensionSelfCheck r : records) {
+                    Map<String, Object> item = new LinkedHashMap<>();
+                    item.put("id", r.getId());
+                    item.put("createdAt", r.getCreatedAt() != null ? dateTimeFmt.format(r.getCreatedAt()) : "");
+                    item.put("date", r.getCreatedAt() != null ? dateFmt.format(r.getCreatedAt()) : "");
+                    item.put("totalScore", r.getTotalScore());
+                    item.put("dimensions", parseDimensions(r.getScoresJson()));
+                    history.add(item);
+                }
+            }
+            // 升序(最早在前),便于前端画折线
+            Collections.reverse(history);
+            return history;
+        } catch (Exception e) {
+            log.warn("聚合自检历史失败: {}", e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    private List<Map<String, Object>> parseDimensions(String scoresJson) {
+        List<Map<String, Object>> dims = new ArrayList<>();
+        if (scoresJson == null || scoresJson.isEmpty()) {
+            return dims;
+        }
+        try {
+            Map<String, Integer> scores = objectMapper.readValue(scoresJson,
+                    new TypeReference<Map<String, Integer>>() {});
+            for (Map.Entry<String, Integer> entry : scores.entrySet()) {
+                String code = entry.getKey();
+                int score = entry.getValue() == null ? 0 : entry.getValue();
+                Map<String, Object> d = new LinkedHashMap<>();
+                d.put("dimension", code);
+                d.put("name", wuxingSourcingService.dimensionName(code));
+                d.put("element", wuxingSourcingService.elementOf(code));
+                d.put("color", wuxingSourcingService.colorOf(code));
+                d.put("score", score);
+                String level = wuxingSourcingService.levelOf(score);
+                d.put("level", level);
+                d.put("levelName", wuxingSourcingService.levelName(level));
+                dims.add(d);
+            }
+        } catch (Exception e) {
+            log.warn("解析自检得分 JSON 失败: {}", e.getMessage());
+        }
+        return dims;
+    }
+
+    // ===== 确定性趋势计算 =====
+
+    /** 各维度变化(相对首次):firstScore / latestScore / delta / direction */
+    List<Map<String, Object>> computeTrendDetails(List<Map<String, Object>> history) {
+        List<Map<String, Object>> details = new ArrayList<>();
+        if (history.size() < TREND_MIN_RECORDS) {
+            return details;
+        }
+        for (String code : DIMENSION_ORDER) {
+            List<Integer> values = new ArrayList<>();
+            for (Map<String, Object> h : history) {
+                for (Object dimObj : (List<?>) h.getOrDefault("dimensions", Collections.emptyList())) {
+                    Map<String, Object> dim = (Map<String, Object>) dimObj;
+                    if (code.equals(dim.get("dimension"))) {
+                        values.add(toInt(dim.get("score")));
+                    }
+                }
+            }
+            if (values.size() < TREND_MIN_RECORDS) {
+                continue;
+            }
+            int first = values.get(0);
+            int latest = values.get(values.size() - 1);
+            int delta = latest - first;
+            Map<String, Object> detail = new LinkedHashMap<>();
+            detail.put("dimension", code);
+            detail.put("name", wuxingSourcingService.dimensionName(code));
+            detail.put("color", wuxingSourcingService.colorOf(code));
+            detail.put("firstScore", first);
+            detail.put("latestScore", latest);
+            detail.put("delta", delta);
+            detail.put("direction", directionOf(values));
+            details.add(detail);
+        }
+        return details;
+    }
+
+    /** 方向判定:出现先升后降或先降后升视为"波动",否则按首尾差判上升/下降/持平 */
+    private String directionOf(List<Integer> values) {
+        boolean up = false;
+        boolean down = false;
+        for (int i = 1; i < values.size(); i++) {
+            if (values.get(i) > values.get(i - 1)) up = true;
+            if (values.get(i) < values.get(i - 1)) down = true;
+        }
+        if (up && down) return "波动";
+        if (up) return "上升";
+        if (down) return "下降";
+        return "持平";
+    }
+
+    /** 形如"身-2 智+1 富0 行-1 心+2"的 delta 简写 */
+    String buildDeltaSummary(List<Map<String, Object>> details) {
+        if (details.isEmpty()) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        for (Map<String, Object> d : details) {
+            if (sb.length() > 0) {
+                sb.append(" ");
+            }
+            int delta = toInt(d.get("delta"));
+            sb.append(d.get("name")).append(delta > 0 ? "+" + delta : String.valueOf(delta));
+        }
+        return sb.toString();
+    }
+
+    // ===== AI 趋势解读(Java 侧组装 prompt 上下文) =====
+
+    /**
+     * 生成 AI 趋势解读,三级降级:
+     * 1. AiGateway.chatForAnalysis → LangGraph chat_graph(Java 组装 prompt + 上下文)
+     * 2. AiGateway.generateSelfCheckTrend → 专用 trend graph
+     * 3. 确定性文案(基于 delta 计算,AI 全程不可用时兜底)
+     */
+    private String generateAiInsight(Long userId, List<Map<String, Object>> history,
+                                     List<Map<String, Object>> details) {
+        try {
+            String prompt = buildTrendPrompt(history, details);
+            Map<String, Object> inputs = new LinkedHashMap<>();
+            inputs.put("userId", userId);
+            Map<String, Object> snapshot = latestSnapshot(history);
+            if (snapshot != null) {
+                inputs.put("self_check_result", objectMapper.writeValueAsString(snapshot));
+            }
+            Map<String, Object> resp = aiGateway.chatForAnalysis(prompt, userId, "self-check-trend-" + userId, inputs);
+            String insight = resp != null ? cleanInsight((String) resp.get("answer")) : "";
+            if (!insight.isEmpty()) {
+                return insight;
+            }
+
+            // 二级兜底:LangGraph 专用趋势 graph
+            Map<String, Object> trendInputs = new LinkedHashMap<>();
+            trendInputs.put("history", history);
+            trendInputs.put("userId", userId);
+            Map<String, Object> trend = aiGateway.generateSelfCheckTrend(trendInputs);
+            if (trend != null) {
+                String fallback = cleanInsight((String) trend.get("aiInsight"));
+                if (!fallback.isEmpty()) {
+                    return fallback;
+                }
+            }
+        } catch (Exception e) {
+            log.warn("自检趋势 AI 解读失败: {}", e.getMessage());
+        }
+        return buildFallbackInsight(history, details);
+    }
+
+    private String buildTrendPrompt(List<Map<String, Object>> history, List<Map<String, Object>> details) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("用户最近").append(history.size()).append("次五维自检记录(身·智·富·行·心,每维0-9分,总分45分):\n");
+        int idx = 1;
+        for (Map<String, Object> h : history) {
+            sb.append(idx).append(". ").append(h.get("date")).append(" 总分")
+              .append(h.get("totalScore")).append("/45");
+            for (Object dimObj : (List<?>) h.getOrDefault("dimensions", Collections.emptyList())) {
+                Map<String, Object> dim = (Map<String, Object>) dimObj;
+                sb.append(",").append(dim.get("name")).append(dim.get("score")).append("分");
+            }
+            sb.append("\n");
+            idx++;
+        }
+        sb.append("各维度变化(相对首次):").append(buildDeltaSummary(details)).append("\n");
+        sb.append("请用2-3句话解读这段趋势:指出上升/下降/波动最明显的维度和可能的原因(不要编造未提供的信息),并给出1-2条可执行建议。");
+        sb.append("要求口语化、温暖、简洁;不要输出[TASK]标记;不要使用markdown标题或列表符号。");
+        return sb.toString();
+    }
+
+    /** 最近一次快照(chat_graph 的 self_check_result 上下文:总分 + 维度分 + 低分项) */
+    private Map<String, Object> latestSnapshot(List<Map<String, Object>> history) {
+        if (history.isEmpty()) {
+            return null;
+        }
+        Map<String, Object> last = history.get(history.size() - 1);
+        List<Map<String, Object>> dims = (List<Map<String, Object>>) last.getOrDefault("dimensions", Collections.emptyList());
+        Map<String, Object> snapshot = new LinkedHashMap<>();
+        snapshot.put("totalScore", last.get("totalScore"));
+        snapshot.put("createdAt", last.get("createdAt"));
+        snapshot.put("dimensions", dims);
+
+        List<String> lowLines = new ArrayList<>();
+        Map<String, Object> lowest = null;
+        for (Map<String, Object> d : dims) {
+            int score = toInt(d.get("score"));
+            if (score <= LOW_SCORE_THRESHOLD) {
+                lowLines.add(d.get("name") + " " + score + "分");
+            }
+            if (lowest == null || score < toInt(lowest.get("score"))) {
+                lowest = d;
+            }
+        }
+        if (lowest != null) {
+            String lowText = lowLines.isEmpty()
+                    ? lowest.get("name") + " " + toInt(lowest.get("score")) + "分"
+                    : String.join("、", lowLines);
+            snapshot.put("aiInsight", "最近一次自检低分维度(≤" + LOW_SCORE_THRESHOLD + "分):" + lowText);
+        }
+        return snapshot;
+    }
+
+    /** 清洗 AI 输出:去 [TASK] 标记 / 代码块 / markdown 符号,超长句末截断 */
+    String cleanInsight(String raw) {
+        if (raw == null || raw.trim().isEmpty()) {
+            return "";
+        }
+        String text = raw;
+        try {
+            text = taskParseService.stripTaskMarkers(raw);
+        } catch (Exception e) {
+            log.debug("清洗趋势解读时 stripTaskMarkers 失败,保留原文: {}", e.getMessage());
+        }
+        text = text.replace("```json", "").replace("```", "")
+                   .replaceAll("(?m)^#{1,6}\\s*", "")
+                   .replace("*", "")
+                   .trim();
+        if (text.length() > MAX_INSIGHT_LENGTH) {
+            int cut = text.substring(0, MAX_INSIGHT_LENGTH).lastIndexOf("。");
+            text = cut > 0 ? text.substring(0, cut + 1) : text.substring(0, MAX_INSIGHT_LENGTH);
+        }
+        return text;
+    }
+
+    /** 确定性降级文案:总分变化 + 变化最大维度 + 当前最低分维度建议 */
+    private String buildFallbackInsight(List<Map<String, Object>> history, List<Map<String, Object>> details) {
+        if (history.size() < TREND_MIN_RECORDS) {
+            return "";
+        }
+        Map<String, Object> first = history.get(0);
+        Map<String, Object> last = history.get(history.size() - 1);
+        int firstTotal = toInt(first.get("totalScore"));
+        int lastTotal = toInt(last.get("totalScore"));
+        int totalDelta = lastTotal - firstTotal;
+
+        StringBuilder sb = new StringBuilder();
+        sb.append("近").append(history.size()).append("次自检,总分从").append(firstTotal)
+          .append("变为").append(lastTotal);
+        if (totalDelta > 0) {
+            sb.append("(+").append(totalDelta).append(")");
+        } else if (totalDelta < 0) {
+            sb.append("(").append(totalDelta).append(")");
+        }
+        sb.append("。");
+
+        Map<String, Object> maxDim = null;
+        for (Map<String, Object> d : details) {
+            if (maxDim == null || Math.abs(toInt(d.get("delta"))) > Math.abs(toInt(maxDim.get("delta")))) {
+                maxDim = d;
+            }
+        }
+        if (maxDim != null) {
+            sb.append(maxDim.get("name")).append("维度").append(maxDim.get("direction"))
+              .append("最明显(").append(toInt(maxDim.get("delta"))).append("分)。");
+        }
+
+        Map<String, Object> lowest = lowestDimension(last);
+        if (lowest != null) {
+            sb.append("当前最低分维度是").append(lowest.get("name")).append("(")
+              .append(toInt(lowest.get("score"))).append("分),建议下次自检前先从一个小行动开始。");
+        }
+        return sb.toString();
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> lowestDimension(Map<String, Object> item) {
+        List<Map<String, Object>> dims = (List<Map<String, Object>>) item.getOrDefault("dimensions", Collections.emptyList());
+        Map<String, Object> lowest = null;
+        for (Map<String, Object> d : dims) {
+            if (lowest == null || toInt(d.get("score")) < toInt(lowest.get("score"))) {
+                lowest = d;
+            }
+        }
+        return lowest;
+    }
+
+    private int toInt(Object v) {
+        if (v == null) return 0;
+        if (v instanceof Number) return ((Number) v).intValue();
+        try {
+            return Integer.parseInt(v.toString());
+        } catch (Exception e) {
+            return 0;
+        }
+    }
+}

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

@@ -59,4 +59,16 @@ public class WuxingSourcingService {
         String[] meta = DIMENSION_META.get(dimension);
         return meta == null ? dimension : meta[0];
     }
+
+    /** 维度五行(土/金/水/木/火) */
+    public String elementOf(String dimension) {
+        String[] meta = DIMENSION_META.get(dimension);
+        return meta == null ? "" : meta[1];
+    }
+
+    /** 维度主题色 */
+    public String colorOf(String dimension) {
+        String[] meta = DIMENSION_META.get(dimension);
+        return meta == null ? "" : meta[2];
+    }
 }

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-68ae2312f3aef8e7850d7bd6dee1d25419506b88
+5f00aadac1029482485e7d76c160a69b104b39d4

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1786",
+  "version": "1.0.1787",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1786",
+      "version": "1.0.1787",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1787",
+  "version": "1.0.1788",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 6 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,12 @@
 
 ---
 
+## v1.0.1788 (2026-10-25)
+
+### 新功能
+- F-3b2 发放接入 F14-F18 改投统一账本
+
+
 ## v1.0.1787 (2026-10-24)
 
 ### 新功能

+ 7 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1787
+> 当前版本: v1.0.1788
 
 ## 历史版本
 
@@ -8,6 +8,12 @@
 
 ---
 
+## v1.0.1788 (2026-10-25)
+
+### 新功能
+- F-3b2 发放接入 F14-F18 改投统一账本
+
+
 ## v1.0.1787 (2026-10-24)
 
 ### 新功能