|
|
@@ -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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|