|
@@ -0,0 +1,726 @@
|
|
|
|
|
+package com.etotem.cfc.service.impl;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
|
|
+import com.etotem.cfc.dto.AttributionInput;
|
|
|
|
|
+import com.etotem.cfc.dto.AttributionReportVO;
|
|
|
|
|
+import com.etotem.cfc.entity.AttributionReport;
|
|
|
|
|
+import com.etotem.cfc.entity.AttributionNormValue;
|
|
|
|
|
+import com.etotem.cfc.entity.CognitiveNorm;
|
|
|
|
|
+import com.etotem.cfc.entity.DanAssessmentResult;
|
|
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
|
|
+import com.etotem.cfc.entity.HealthGutFlora;
|
|
|
|
|
+import com.etotem.cfc.entity.HealthNormReference;
|
|
|
|
|
+import com.etotem.cfc.entity.HealthReport;
|
|
|
|
|
+import com.etotem.cfc.entity.SurveyRecord;
|
|
|
|
|
+import com.etotem.cfc.mapper.AttributionNormValueMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.AttributionReportMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.CognitiveNormMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.DanAssessmentResultMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.HealthGutFloraMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.HealthNormReferenceMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.HealthReportMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.SurveyRecordMapper;
|
|
|
|
|
+import com.etotem.cfc.service.AttributionAnalysisService;
|
|
|
|
|
+import com.etotem.cfc.service.attribution.AttributionNarrativeService;
|
|
|
|
|
+import com.etotem.cfc.service.attribution.AttributionReportBuilder;
|
|
|
|
|
+import com.etotem.cfc.service.attribution.FalsificationEngine;
|
|
|
|
|
+import com.etotem.cfc.service.attribution.FiveDimAttributionEngine;
|
|
|
|
|
+import com.etotem.cfc.service.attribution.IndicatorInterpretEngine;
|
|
|
|
|
+import com.etotem.cfc.service.attribution.MainCauseChainEngine;
|
|
|
|
|
+import com.etotem.cfc.service.attribution.NormCompareEngine;
|
|
|
|
|
+import com.etotem.cfc.service.attribution.model.CauseLink;
|
|
|
|
|
+import com.etotem.cfc.service.attribution.model.CompareResult;
|
|
|
|
|
+import com.etotem.cfc.service.attribution.model.FalsifiedItem;
|
|
|
|
|
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
|
|
|
|
|
+import com.etotem.cfc.service.attribution.model.MemberAttribution;
|
|
|
|
|
+import com.etotem.cfc.service.attribution.model.MemberProfile;
|
|
|
|
|
+import com.etotem.cfc.service.attribution.model.NormEntry;
|
|
|
|
|
+import com.etotem.cfc.util.AttributionRoleUtil;
|
|
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
|
+import com.fasterxml.jackson.databind.node.ArrayNode;
|
|
|
|
|
+import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
|
|
|
+import org.slf4j.Logger;
|
|
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+
|
|
|
|
|
+import javax.annotation.Resource;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.Date;
|
|
|
|
|
+import java.util.Iterator;
|
|
|
|
|
+import java.util.LinkedHashMap;
|
|
|
|
|
+import java.util.LinkedHashSet;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.Set;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * {@link AttributionAnalysisService} 默认实现(B1)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>Pipeline:常模比对 → 指标解读 → 五维归因 → 主因链 → 被证伪 → AI 叙述(规则兜底)→ 红线硬注入 → 落库。
|
|
|
|
|
+ * 全程确定性规则优先;AI 只负责叙述且失败不阻断。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @author 家庭归因阶段二(ATT-S2-B1)
|
|
|
|
|
+ */
|
|
|
|
|
+@Service
|
|
|
|
|
+public class AttributionAnalysisServiceImpl implements AttributionAnalysisService {
|
|
|
|
|
+
|
|
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(AttributionAnalysisServiceImpl.class);
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ObjectMapper objectMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private HealthGutFloraMapper healthGutFloraMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private HealthReportMapper healthReportMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private DanAssessmentResultMapper danAssessmentResultMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private AttributionNormValueMapper attributionNormValueMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private CognitiveNormMapper cognitiveNormMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private HealthNormReferenceMapper healthNormReferenceMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private SurveyRecordMapper surveyRecordMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private AttributionReportMapper attributionReportMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private NormCompareEngine normCompareEngine;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private IndicatorInterpretEngine indicatorInterpretEngine;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private FiveDimAttributionEngine fiveDimAttributionEngine;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private MainCauseChainEngine mainCauseChainEngine;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private FalsificationEngine falsificationEngine;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private AttributionNarrativeService attributionNarrativeService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private AttributionReportBuilder attributionReportBuilder;
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 主入口 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public AttributionReportVO analyze(AttributionInput input) {
|
|
|
|
|
+ if (input == null) {
|
|
|
|
|
+ throw new IllegalArgumentException("归因入参不能为空");
|
|
|
|
|
+ }
|
|
|
|
|
+ List<MemberProfile> members = resolveMembers(input);
|
|
|
|
|
+ List<IndicatorReading> raw = loadRawReadings(input, members);
|
|
|
|
|
+ List<NormEntry> norms = loadNorms();
|
|
|
|
|
+ Map<String, Object> flags = loadFlags(input);
|
|
|
|
|
+
|
|
|
|
|
+ CompareResult compare = normCompareEngine.compare(members, raw, norms, flags);
|
|
|
|
|
+ List<MemberAttribution> attributions = indicatorInterpretEngine.interpret(compare.getReadings(), members);
|
|
|
|
|
+ FiveDimAttributionEngine.FiveDimResult five = fiveDimAttributionEngine.attribute(attributions, members);
|
|
|
|
|
+ List<CauseLink> links = mainCauseChainEngine.chain(five.attributions, five.familyMap);
|
|
|
|
|
+ List<FalsifiedItem> falsified = falsificationEngine.falsify(five.attributions, compare.getReadings(), flags);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> narrative = narrate(five, links, falsified, compare, input);
|
|
|
|
|
+
|
|
|
|
|
+ List<String> redlineHits = attributionReportBuilder.computeRedlineHits(
|
|
|
|
|
+ five, compare.getIntermediateState(), members);
|
|
|
|
|
+ String reportJson = attributionReportBuilder.buildReportJson(
|
|
|
|
|
+ five, links, falsified, compare, narrative, redlineHits, input);
|
|
|
|
|
+
|
|
|
|
|
+ AttributionReport report = new AttributionReport();
|
|
|
|
|
+ report.setFamilyId(input.getFamilyId());
|
|
|
|
|
+ report.setMemberIds(joinIds(input.getMemberIds()));
|
|
|
|
|
+ report.setPlanId(input.getPlanId());
|
|
|
|
|
+ report.setStatus(AttributionReport.STATUS_ANALYZING);
|
|
|
|
|
+ report.setReportJson(reportJson);
|
|
|
|
|
+ report.setRedlineHits(toJsonArray(redlineHits));
|
|
|
|
|
+ report.setIntermediateState(toJsonMap(compare.getIntermediateState()));
|
|
|
|
|
+ report.setCreatedAt(new Date());
|
|
|
|
|
+ report.setUpdatedAt(new Date());
|
|
|
|
|
+ attributionReportMapper.insert(report);
|
|
|
|
|
+ // ANALYZING → READY(状态机 §4.2)
|
|
|
|
|
+ report.setStatus(AttributionReport.STATUS_READY);
|
|
|
|
|
+ report.setUpdatedAt(new Date());
|
|
|
|
|
+ attributionReportMapper.updateById(report);
|
|
|
|
|
+
|
|
|
|
|
+ AttributionReportVO vo = toVO(report);
|
|
|
|
|
+ vo.setWarnings(compare.getWarnings());
|
|
|
|
|
+ return vo;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public AttributionReportVO getReport(Long attributionId) {
|
|
|
|
|
+ if (attributionId == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ AttributionReport report = attributionReportMapper.selectById(attributionId);
|
|
|
|
|
+ if (report == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ return toVO(report);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public AttributionReportVO recompute(Long attributionId, Map<String, Object> fieldDeltas) {
|
|
|
|
|
+ AttributionReport report = attributionReportMapper.selectById(attributionId);
|
|
|
|
|
+ if (report == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ Map<String, Object> state = parseMap(report.getIntermediateState());
|
|
|
|
|
+ if (fieldDeltas != null) {
|
|
|
|
|
+ state.putAll(fieldDeltas);
|
|
|
|
|
+ }
|
|
|
|
|
+ report.setIntermediateState(toJsonMap(state));
|
|
|
|
|
+ report.setStatus(AttributionReport.STATUS_ADJUSTED);
|
|
|
|
|
+ report.setUpdatedAt(new Date());
|
|
|
|
|
+ // 回填后同步报告 JSON 内的中间态与状态标记
|
|
|
|
|
+ try {
|
|
|
|
|
+ JsonNode node = objectMapper.readTree(report.getReportJson());
|
|
|
|
|
+ if (node instanceof ObjectNode) {
|
|
|
|
|
+ ObjectNode root = (ObjectNode) node;
|
|
|
|
|
+ root.set("intermediateState", objectMapper.valueToTree(state));
|
|
|
|
|
+ root.put("status", AttributionReport.STATUS_ADJUSTED);
|
|
|
|
|
+ root.put("updatedAt", report.getUpdatedAt().toInstant().toString());
|
|
|
|
|
+ report.setReportJson(objectMapper.writeValueAsString(root));
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("recompute: 报告 JSON 同步失败 attributionId={}: {}", attributionId, e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ attributionReportMapper.updateById(report);
|
|
|
|
|
+ return toVO(report);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 成员解析 ====================
|
|
|
|
|
+
|
|
|
|
|
+ private List<MemberProfile> resolveMembers(AttributionInput input) {
|
|
|
|
|
+ List<MemberProfile> members = new ArrayList<>();
|
|
|
|
|
+ List<Long> ids = input.getMemberIds();
|
|
|
|
|
+ if (ids == null || ids.isEmpty()) {
|
|
|
|
|
+ return members;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (Long id : ids) {
|
|
|
|
|
+ if (id == null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ FamilyMember fm = null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ fm = familyMemberMapper.selectById(id);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("读取家庭成员失败 memberId={}: {}", id, e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ MemberProfile p = new MemberProfile();
|
|
|
|
|
+ p.setMemberId(id);
|
|
|
|
|
+ if (fm != null) {
|
|
|
|
|
+ p.setName(fm.getNickname());
|
|
|
|
|
+ p.setGender(fm.getGender());
|
|
|
|
|
+ int age = fm.getAge();
|
|
|
|
|
+ p.setAge(age);
|
|
|
|
|
+ p.setAgeBand(AttributionRoleUtil.ageBand(age));
|
|
|
|
|
+ p.setGeneration(fm.getGeneration());
|
|
|
|
|
+ p.setSpouse(Boolean.TRUE.equals(fm.getIsSpouse()));
|
|
|
|
|
+ p.setMinor(age < 18);
|
|
|
|
|
+ p.setFemaleTeen("female".equalsIgnoreCase(fm.getGender()) && age >= 13 && age <= 18);
|
|
|
|
|
+ String role = AttributionRoleUtil.roleOf(fm);
|
|
|
|
|
+ p.setRole(role);
|
|
|
|
|
+ p.setRoleLabel(AttributionRoleUtil.roleLabel(role));
|
|
|
|
|
+ } else {
|
|
|
|
|
+ p.setName("成员" + id);
|
|
|
|
|
+ p.setRole("unknown");
|
|
|
|
|
+ p.setRoleLabel("成员");
|
|
|
|
|
+ }
|
|
|
|
|
+ members.add(p);
|
|
|
|
|
+ }
|
|
|
|
|
+ return members;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 原始读数加载 ====================
|
|
|
|
|
+
|
|
|
|
|
+ private List<IndicatorReading> loadRawReadings(AttributionInput input, List<MemberProfile> members) {
|
|
|
|
|
+ List<IndicatorReading> readings = new ArrayList<>();
|
|
|
|
|
+ Map<Long, MemberProfile> byId = new LinkedHashMap<>();
|
|
|
|
|
+ for (MemberProfile m : members) {
|
|
|
|
|
+ byId.put(m.getMemberId(), m);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 菌群读数(health_gut_flora ← health_reports.subject_id = 成员ID)
|
|
|
|
|
+ List<Long> gutIds = input.getGutReportIds();
|
|
|
|
|
+ if (gutIds != null && !gutIds.isEmpty()) {
|
|
|
|
|
+ Map<Long, Long> reportToMember = new LinkedHashMap<>();
|
|
|
|
|
+ try {
|
|
|
|
|
+ List<HealthReport> reports = healthReportMapper.selectBatchIds(gutIds);
|
|
|
|
|
+ for (HealthReport r : reports) {
|
|
|
|
|
+ if (r.getSubjectId() != null) {
|
|
|
|
|
+ reportToMember.put(r.getId(), r.getSubjectId());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("读取菌群报告失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ List<HealthGutFlora> flora = healthGutFloraMapper.selectList(
|
|
|
|
|
+ new LambdaQueryWrapper<HealthGutFlora>().in(HealthGutFlora::getReportId, gutIds));
|
|
|
|
|
+ for (HealthGutFlora f : flora) {
|
|
|
|
|
+ IndicatorReading r = new IndicatorReading();
|
|
|
|
|
+ r.setSource("gut_flora");
|
|
|
|
|
+ r.setCode(f.getBacteriaName());
|
|
|
|
|
+ r.setDisplayName(f.getBacteriaName());
|
|
|
|
|
+ r.setValue(f.getBacteriaValue());
|
|
|
|
|
+ r.setNumericValue(parseDouble(f.getBacteriaValue()));
|
|
|
|
|
+ r.setPopulationLevel(f.getPopulationLevel());
|
|
|
|
|
+ r.setUnit(f.getBacteriaValue() != null && f.getBacteriaValue().contains("%") ? "%" : null);
|
|
|
|
|
+ Long memberId = reportToMember.get(f.getReportId());
|
|
|
|
|
+ r.setMemberId(memberId);
|
|
|
|
|
+ MemberProfile p = memberId == null ? null : byId.get(memberId);
|
|
|
|
|
+ r.setMemberName(p != null ? p.getName() : null);
|
|
|
|
|
+ readings.add(r);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("读取菌群明细失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // DAN 读数(dan_assessment_results.family_member_id = 成员ID)
|
|
|
|
|
+ List<Long> danIds = input.getDanResultIds();
|
|
|
|
|
+ if (danIds != null && !danIds.isEmpty()) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ List<DanAssessmentResult> dans = danAssessmentResultMapper.selectBatchIds(danIds);
|
|
|
|
|
+ for (DanAssessmentResult d : dans) {
|
|
|
|
|
+ readings.addAll(toDanReadings(d, byId));
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("读取 DAN 结果失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return readings;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<IndicatorReading> toDanReadings(DanAssessmentResult d, Map<Long, MemberProfile> byId) {
|
|
|
|
|
+ List<IndicatorReading> out = new ArrayList<>();
|
|
|
|
|
+ MemberProfile p = d.getFamilyMemberId() == null ? null : byId.get(d.getFamilyMemberId());
|
|
|
|
|
+ String name = p != null ? p.getName() : d.getFamilyMemberName();
|
|
|
|
|
+
|
|
|
|
|
+ addDan(out, d, byId, name, "emotionScore", "情绪商数", d.getEmotionScore());
|
|
|
|
|
+ addDan(out, d, byId, name, "resilienceScore", "心理韧性", d.getResilienceScore());
|
|
|
|
|
+ addDan(out, d, byId, name, "stressCopingScore", "压力应对", d.getStressCopingScore());
|
|
|
|
|
+ addDan(out, d, byId, name, "selfAwarenessScore", "自我认知", d.getSelfAwarenessScore());
|
|
|
|
|
+ addDan(out, d, byId, name, "attentionScore", "注意力", d.getAttentionScore());
|
|
|
|
|
+ addDan(out, d, byId, name, "focusScore", "专注力", d.getFocusScore());
|
|
|
|
|
+ addDan(out, d, byId, name, "memoryScore", "记忆力", d.getMemoryScore());
|
|
|
|
|
+ addDan(out, d, byId, name, "logicScore", "逻辑思维", d.getLogicScore());
|
|
|
|
|
+ addDan(out, d, byId, name, "perceptionScore", "感知", d.getPerceptionScore());
|
|
|
|
|
+ addDan(out, d, byId, name, "spatialScore", "空间思维", d.getSpatialScore());
|
|
|
|
|
+ addDan(out, d, byId, name, "processingSpeedScore", "加工速度", d.getProcessingSpeedScore());
|
|
|
|
|
+ addDan(out, d, byId, name, "languageScore", "语言表达", d.getLanguageScore());
|
|
|
|
|
+ addDan(out, d, byId, name, "bigFiveOverallScore", "大五综合", d.getBigFiveOverallScore());
|
|
|
|
|
+ addDan(out, d, byId, name, "neuroticismScore", "神经质", d.getNeuroticismScore());
|
|
|
|
|
+ addDan(out, d, byId, name, "extraversionScore", "外向性", d.getExtraversionScore());
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void addDan(List<IndicatorReading> out, DanAssessmentResult d, Map<Long, MemberProfile> byId,
|
|
|
|
|
+ String name, String code, String display, Integer score) {
|
|
|
|
|
+ if (score == null) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ IndicatorReading r = new IndicatorReading();
|
|
|
|
|
+ r.setSource("dan");
|
|
|
|
|
+ r.setCode(code);
|
|
|
|
|
+ r.setDisplayName(display);
|
|
|
|
|
+ r.setValue(String.valueOf(score));
|
|
|
|
|
+ r.setNumericValue(score.doubleValue());
|
|
|
|
|
+ r.setUnit("分");
|
|
|
|
|
+ r.setMemberId(d.getFamilyMemberId());
|
|
|
|
|
+ r.setMemberName(name);
|
|
|
|
|
+ out.add(r);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 常模加载 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /** DAN 常模优先复用 cognitive_norms / health_norm_reference;菌群走 attribution_norm_values */
|
|
|
|
|
+ private List<NormEntry> loadNorms() {
|
|
|
|
|
+ List<NormEntry> norms = new ArrayList<>();
|
|
|
|
|
+ // 菌群常模
|
|
|
|
|
+ try {
|
|
|
|
|
+ List<AttributionNormValue> values = attributionNormValueMapper.selectList(
|
|
|
|
|
+ new LambdaQueryWrapper<AttributionNormValue>().eq(AttributionNormValue::getNormType, "gut_flora"));
|
|
|
|
|
+ for (AttributionNormValue v : values) {
|
|
|
|
|
+ NormEntry n = new NormEntry();
|
|
|
|
|
+ n.setNormType("gut_flora");
|
|
|
|
|
+ n.setCode(v.getCode());
|
|
|
|
|
+ n.setDisplayName(v.getDisplayName());
|
|
|
|
|
+ n.setAgeBand(v.getMemberAgeBand());
|
|
|
|
|
+ n.setGender(v.getGender());
|
|
|
|
|
+ n.setP5(toDouble(v.getP5()));
|
|
|
|
|
+ n.setP25(toDouble(v.getP25()));
|
|
|
|
|
+ n.setP50(toDouble(v.getP50()));
|
|
|
|
|
+ n.setP75(toDouble(v.getP75()));
|
|
|
|
|
+ n.setP95(toDouble(v.getP95()));
|
|
|
|
|
+ n.setUnit(v.getUnit());
|
|
|
|
|
+ n.setSource("attribution_norm_values");
|
|
|
|
|
+ norms.add(n);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("读取 attribution_norm_values 失败(表可能未建立): {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ // DAN 认知常模(cognitive_norms)
|
|
|
|
|
+ try {
|
|
|
|
|
+ List<CognitiveNorm> cognitive = cognitiveNormMapper.selectList(null);
|
|
|
|
|
+ for (CognitiveNorm c : cognitive) {
|
|
|
|
|
+ NormEntry n = new NormEntry();
|
|
|
|
|
+ n.setNormType("dan");
|
|
|
|
|
+ n.setCode(c.getDimensionKey());
|
|
|
|
|
+ n.setDisplayName(c.getDimensionKey());
|
|
|
|
|
+ n.setAgeBand(null);
|
|
|
|
|
+ n.setGender(c.getGender());
|
|
|
|
|
+ n.setP5(toDouble(c.getPercentile5()));
|
|
|
|
|
+ n.setP25(toDouble(c.getPercentile25()));
|
|
|
|
|
+ n.setP50(toDouble(c.getPercentile50()));
|
|
|
|
|
+ n.setP75(toDouble(c.getPercentile75()));
|
|
|
|
|
+ n.setP95(toDouble(c.getPercentile95()));
|
|
|
|
|
+ n.setUnit("分");
|
|
|
|
|
+ n.setSource("cognitive_norms");
|
|
|
|
|
+ norms.add(n);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("读取 cognitive_norms 失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ // DAN 情绪/大五等维度常模(health_norm_reference)
|
|
|
|
|
+ try {
|
|
|
|
|
+ List<HealthNormReference> refs = healthNormReferenceMapper.selectList(null);
|
|
|
|
|
+ for (HealthNormReference h : refs) {
|
|
|
|
|
+ NormEntry n = new NormEntry();
|
|
|
|
|
+ n.setNormType("dan");
|
|
|
|
|
+ n.setCode(h.getDimension());
|
|
|
|
|
+ n.setDisplayName(h.getDimension());
|
|
|
|
|
+ n.setAgeBand(null);
|
|
|
|
|
+ n.setGender(h.getGender());
|
|
|
|
|
+ n.setP5(toDouble(h.getPercentile5()));
|
|
|
|
|
+ n.setP25(toDouble(h.getPercentile25()));
|
|
|
|
|
+ n.setP50(toDouble(h.getPercentile50()));
|
|
|
|
|
+ n.setP75(toDouble(h.getPercentile75()));
|
|
|
|
|
+ n.setP95(toDouble(h.getPercentile95()));
|
|
|
|
|
+ n.setUnit("分");
|
|
|
|
|
+ n.setSource("health_norm_reference");
|
|
|
|
|
+ norms.add(n);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("读取 health_norm_reference 失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ return norms;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 标志位(确认问卷 / mock) ====================
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, Object> loadFlags(AttributionInput input) {
|
|
|
|
|
+ Map<String, Object> flags = new LinkedHashMap<>();
|
|
|
|
|
+ // hasDan:提供了 DAN 结果ID 即视为家中有 DAN 测评数据(B1 中间态键)
|
|
|
|
|
+ if (input.getDanResultIds() != null && !input.getDanResultIds().isEmpty()) {
|
|
|
|
|
+ flags.put("hasDan", true);
|
|
|
|
|
+ }
|
|
|
|
|
+ // 确认问卷答案 → 风险标记(Q11/Q12/Q13)
|
|
|
|
|
+ if (input.getConfirmSurveyRecordId() != null) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ SurveyRecord record = surveyRecordMapper.selectById(input.getConfirmSurveyRecordId());
|
|
|
|
|
+ if (record != null && record.getAnswersJson() != null) {
|
|
|
|
|
+ flags.putAll(parseSurveyFlags(record.getAnswersJson()));
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("读取确认问卷记录失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // mock/测试补充标志(E12)
|
|
|
|
|
+ if (input.getMockFlags() != null) {
|
|
|
|
|
+ flags.putAll(input.getMockFlags());
|
|
|
|
|
+ }
|
|
|
|
|
+ return flags;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 解析确认问卷答案为风险标记 */
|
|
|
|
|
+ Map<String, Object> parseSurveyFlags(String answersJson) {
|
|
|
|
|
+ Map<String, Object> flags = new LinkedHashMap<>();
|
|
|
|
|
+ try {
|
|
|
|
|
+ JsonNode root = objectMapper.readTree(answersJson);
|
|
|
|
|
+ if (root == null || !root.isObject()) {
|
|
|
|
|
+ return flags;
|
|
|
|
|
+ }
|
|
|
|
|
+ JsonNode answers = root.has("answers") && root.get("answers").isObject() ? root.get("answers") : root;
|
|
|
|
|
+ flags.put("riskMoodLow", hit(answers, "mood3m", "明显低落", "低落"));
|
|
|
|
|
+ flags.put("riskInsomnia", hit(answers, "sleep3m", "持续失眠", "频繁失眠"));
|
|
|
|
|
+ flags.put("riskSchoolRefusal", hit(answers, "schoolRefusal", "频繁"));
|
|
|
|
|
+ flags.put("riskSelfHarm", hit(answers, "selfHarmIdea", "有"));
|
|
|
|
|
+ flags.put("riskBullying", hit(answers, "bullied", "频繁"));
|
|
|
|
|
+ flags.put("riskEating", hit(answers, "dietingConcern", "正在节食", "明显在意"));
|
|
|
|
|
+ flags.put("riskPeerInvalid", hit(answers, "peerRelation", "无效", "待确认"));
|
|
|
|
|
+ // 已下发并作答的条件题 → 反推结论标志(Q4 只在 ironLow 时下发;Q5 只在 hasDiabetes 时下发)
|
|
|
|
|
+ flags.put("ironLow", Boolean.TRUE.equals(flags.get("ironLow"))
|
|
|
|
|
+ || answers.has("ironMealWithMilkTea"));
|
|
|
|
|
+ flags.put("hasDiabetes", Boolean.TRUE.equals(flags.get("hasDiabetes"))
|
|
|
|
|
+ || answers.has("diabetesDuration"));
|
|
|
|
|
+ // B2 submit 并入的 riskHits(Q11/Q12/Q13 命中标记)→ 风险标记
|
|
|
|
|
+ JsonNode riskHits = root.get("_attributionRiskHits");
|
|
|
|
|
+ if (riskHits != null && riskHits.isArray()) {
|
|
|
|
|
+ for (JsonNode rh : riskHits) {
|
|
|
|
|
+ String code = rh.path("code").asText("");
|
|
|
|
|
+ String value = rh.path("value").asText("");
|
|
|
|
|
+ applyRiskHit(flags, code, value);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("解析确认问卷答案失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ return flags;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static boolean hit(JsonNode answers, String code, String... values) {
|
|
|
|
|
+ JsonNode node = answers.get(code);
|
|
|
|
|
+ if (node == null || node.isNull()) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ String text = node.asText();
|
|
|
|
|
+ for (String v : values) {
|
|
|
|
|
+ if (text.contains(v)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** riskHits 命中 → 风险标记(E4 弱提示:仅标记,不阻断) */
|
|
|
|
|
+ private static void applyRiskHit(Map<String, Object> flags, String code, String value) {
|
|
|
|
|
+ if (code == null) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ String v = value == null ? "" : value;
|
|
|
|
|
+ if ("selfHarmIdea".equals(code)) {
|
|
|
|
|
+ flags.put("riskSelfHarm", v.contains("有"));
|
|
|
|
|
+ } else if ("schoolRefusal".equals(code)) {
|
|
|
|
|
+ flags.put("riskSchoolRefusal", v.contains("频繁"));
|
|
|
|
|
+ } else if ("sleep3m".equals(code)) {
|
|
|
|
|
+ flags.put("riskInsomnia", v.contains("持续失眠") || v.contains("频繁失眠"));
|
|
|
|
|
+ } else if ("mood3m".equals(code)) {
|
|
|
|
|
+ flags.put("riskMoodLow", v.contains("低落"));
|
|
|
|
|
+ } else if ("bullied".equals(code)) {
|
|
|
|
|
+ flags.put("riskBullying", v.contains("频繁"));
|
|
|
|
|
+ } else if ("dietingConcern".equals(code) || "bodyWeightConcern".equals(code)) {
|
|
|
|
|
+ flags.put("riskEating", v.contains("节食") || v.contains("明显在意"));
|
|
|
|
|
+ } else if ("peerRelation".equals(code)) {
|
|
|
|
|
+ flags.put("riskPeerInvalid", true);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 叙述 ====================
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, Object> narrate(FiveDimAttributionEngine.FiveDimResult five, List<CauseLink> links,
|
|
|
|
|
+ List<FalsifiedItem> falsified, CompareResult compare, AttributionInput input) {
|
|
|
|
|
+ Map<String, Object> context = new LinkedHashMap<>();
|
|
|
|
|
+ List<Map<String, Object>> attrView = new ArrayList<>();
|
|
|
|
|
+ for (MemberAttribution a : five.attributions) {
|
|
|
|
|
+ Map<String, Object> m = new LinkedHashMap<>();
|
|
|
|
|
+ m.put("memberId", a.getMemberId());
|
|
|
|
|
+ m.put("name", a.getName());
|
|
|
|
|
+ m.put("roleLabel", a.getRoleLabel());
|
|
|
|
|
+ m.put("surface", a.getSurface());
|
|
|
|
|
+ m.put("essence", a.getEssence());
|
|
|
|
|
+ m.put("fiveDim", a.getFiveDim());
|
|
|
|
|
+ m.put("weakDimensions", a.getWeakDimensions());
|
|
|
|
|
+ m.put("abnormalCount", a.getAbnormalCount());
|
|
|
|
|
+ attrView.add(m);
|
|
|
|
|
+ }
|
|
|
|
|
+ context.put("attributions", attrView);
|
|
|
|
|
+ context.put("familyMap", five.familyMap);
|
|
|
|
|
+ context.put("mainCauseChain", links);
|
|
|
|
|
+ context.put("falsified", falsified);
|
|
|
|
|
+ context.put("intermediateState", compare.getIntermediateState());
|
|
|
|
|
+ try {
|
|
|
|
|
+ return attributionNarrativeService.narrate(context, null);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("归因叙述失败,走规则兜底: {}", e.getMessage());
|
|
|
|
|
+ Map<String, Object> fallback = new LinkedHashMap<>();
|
|
|
|
|
+ fallback.put("conclusionHeadline", "本轮归因以规则结论为准");
|
|
|
|
|
+ fallback.put("closingLine", "先共享基座后精准,12 周可复盘检验。");
|
|
|
|
|
+ fallback.put("coachQuestions", new ArrayList<String>());
|
|
|
|
|
+ fallback.put("narratedBy", "rule");
|
|
|
|
|
+ return fallback;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 工具 ====================
|
|
|
|
|
+
|
|
|
|
|
+ AttributionReportVO toVO(AttributionReport report) {
|
|
|
|
|
+ AttributionReportVO vo = new AttributionReportVO();
|
|
|
|
|
+ vo.setAttributionId(report.getId());
|
|
|
|
|
+ vo.setFamilyId(report.getFamilyId());
|
|
|
|
|
+ vo.setPlanId(report.getPlanId());
|
|
|
|
|
+ vo.setStatus(report.getStatus());
|
|
|
|
|
+ vo.setReportJson(report.getReportJson());
|
|
|
|
|
+ vo.setMemberIds(parseIds(report.getMemberIds()));
|
|
|
|
|
+ vo.setRedlineHits(parseStringList(report.getRedlineHits()));
|
|
|
|
|
+ vo.setIntermediateState(parseMap(report.getIntermediateState()));
|
|
|
|
|
+ return vo;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String toJsonArray(List<String> list) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return objectMapper.writeValueAsString(list == null ? new ArrayList<String>() : list);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ return "[]";
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String toJsonMap(Map<String, Object> map) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return objectMapper.writeValueAsString(map == null ? new LinkedHashMap<String, Object>() : map);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ return "{}";
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> parseMap(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()) {
|
|
|
|
|
+ Iterator<Map.Entry<String, JsonNode>> it = node.fields();
|
|
|
|
|
+ while (it.hasNext()) {
|
|
|
|
|
+ Map.Entry<String, JsonNode> e = it.next();
|
|
|
|
|
+ out.put(e.getKey(), toJava(e.getValue()));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("解析 JSON 对象失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ List<String> parseStringList(String json) {
|
|
|
|
|
+ List<String> out = new ArrayList<>();
|
|
|
|
|
+ if (json == null || json.trim().isEmpty()) {
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ JsonNode node = objectMapper.readTree(json);
|
|
|
|
|
+ if (node != null && node.isArray()) {
|
|
|
|
|
+ for (JsonNode n : node) {
|
|
|
|
|
+ out.add(n.asText());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("解析 JSON 数组失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Object toJava(JsonNode node) {
|
|
|
|
|
+ if (node == null || node.isNull()) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (node.isBoolean()) {
|
|
|
|
|
+ return node.booleanValue();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (node.isNumber()) {
|
|
|
|
|
+ return node.numberValue();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (node.isTextual()) {
|
|
|
|
|
+ return node.textValue();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (node.isArray()) {
|
|
|
|
|
+ List<Object> list = new ArrayList<>();
|
|
|
|
|
+ for (JsonNode item : node) {
|
|
|
|
|
+ list.add(toJava(item));
|
|
|
|
|
+ }
|
|
|
|
|
+ return list;
|
|
|
|
|
+ }
|
|
|
|
|
+ return node.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 String joinIds(List<Long> ids) {
|
|
|
|
|
+ if (ids == null || ids.isEmpty()) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
|
|
+ for (Long id : ids) {
|
|
|
|
|
+ if (id == null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (sb.length() > 0) {
|
|
|
|
|
+ sb.append(',');
|
|
|
|
|
+ }
|
|
|
|
|
+ sb.append(id);
|
|
|
|
|
+ }
|
|
|
|
|
+ return sb.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static Double parseDouble(String s) {
|
|
|
|
|
+ if (s == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return Double.valueOf(s.replace("%", "").replace("%", "").trim());
|
|
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static Double toDouble(java.math.BigDecimal d) {
|
|
|
|
|
+ return d == null ? null : d.doubleValue();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static Double toDouble(Integer i) {
|
|
|
|
|
+ return i == null ? null : i.doubleValue();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static boolean truthy(Object v) {
|
|
|
|
|
+ if (v == null) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (v instanceof Boolean) {
|
|
|
|
|
+ return (Boolean) v;
|
|
|
|
|
+ }
|
|
|
|
|
+ return Boolean.parseBoolean(String.valueOf(v));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static String str(Object v, String fallback) {
|
|
|
|
|
+ if (v == null) {
|
|
|
|
|
+ return fallback;
|
|
|
|
|
+ }
|
|
|
|
|
+ String s = String.valueOf(v);
|
|
|
|
|
+ return s.trim().isEmpty() ? fallback : s;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|