|
|
@@ -0,0 +1,270 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.IndicatorDefinition;
|
|
|
+import com.etotem.cfc.entity.IndicatorMappingRule;
|
|
|
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
|
|
|
+import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+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.math.BigDecimal;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 指标归一化映射:raw_name → definition_id。
|
|
|
+ * 解析顺序:exact 命中映射规则 → fuzzy(CJK 二元组相似度)→ AI 兜底(登记 pending)→ 惰性注册新定义。
|
|
|
+ * 单一写者模式的「身份解析」环节;规则表是权威,管理端可 review 修正。
|
|
|
+ */
|
|
|
+@Service
|
|
|
+public class IndicatorMappingService {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(IndicatorMappingService.class);
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorMappingRuleMapper ruleMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorDefinitionMapper definitionMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private AiGateway aiGateway;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ObjectMapper objectMapper = new ObjectMapper();
|
|
|
+
|
|
|
+ /** fuzzy 相似度阈值(CJK 二元组 Dice 系数),低于则视为未命中 */
|
|
|
+ private static final double FUZZY_THRESHOLD = 0.5;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 解析原始指标名为 definition_id。
|
|
|
+ *
|
|
|
+ * @param sourceType 报告类型(gut_flora 等)
|
|
|
+ * @param rawName 报告原始名称(指标名/菌名/食材名,已 trim)
|
|
|
+ */
|
|
|
+ public Long resolve(String sourceType, String rawName) {
|
|
|
+ if (sourceType == null || rawName == null || rawName.trim().isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String name = rawName.trim();
|
|
|
+
|
|
|
+ // 1) exact 命中规则表
|
|
|
+ IndicatorMappingRule rule = ruleMapper.selectOne(new LambdaQueryWrapper<IndicatorMappingRule>()
|
|
|
+ .eq(IndicatorMappingRule::getSourceType, sourceType)
|
|
|
+ .eq(IndicatorMappingRule::getRawName, name)
|
|
|
+ .orderByDesc(IndicatorMappingRule::getPriority)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ if (rule != null && rule.getDefinitionId() != null) {
|
|
|
+ return rule.getDefinitionId();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2) fuzzy:与同来源全部启用规则比较 CJK 二元组相似度
|
|
|
+ List<IndicatorMappingRule> candidates = ruleMapper.selectList(new LambdaQueryWrapper<IndicatorMappingRule>()
|
|
|
+ .eq(IndicatorMappingRule::getSourceType, sourceType)
|
|
|
+ .eq(IndicatorMappingRule::getStatus, 1));
|
|
|
+ IndicatorMappingRule best = null;
|
|
|
+ double bestScore = 0;
|
|
|
+ for (IndicatorMappingRule c : candidates) {
|
|
|
+ double s = bigramSimilarity(name, c.getRawName());
|
|
|
+ if (s > bestScore) {
|
|
|
+ bestScore = s;
|
|
|
+ best = c;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (best != null && bestScore >= FUZZY_THRESHOLD) {
|
|
|
+ return best.getDefinitionId();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3) AI 兜底:只建议,登记 pending(status=0)规则待管理端确认
|
|
|
+ String aiJson = aiGateway != null
|
|
|
+ ? aiGateway.classifyIndicator(sourceType, name, Collections.emptyList())
|
|
|
+ : null;
|
|
|
+ if (aiJson != null) {
|
|
|
+ try {
|
|
|
+ JsonNode suggestion = objectMapper.readTree(aiJson);
|
|
|
+ if (suggestion != null && suggestion.hasNonNull("code")) {
|
|
|
+ IndicatorDefinition def = definitionMapper.selectOne(new LambdaQueryWrapper<IndicatorDefinition>()
|
|
|
+ .eq(IndicatorDefinition::getCode, suggestion.get("code").asText())
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ if (def != null) {
|
|
|
+ registerRule(sourceType, name, def.getId(), "ai_fallback", 0);
|
|
|
+ log.info("AI 兜底建议:sourceType={}, raw={} → defId={}(pending 待确认)",
|
|
|
+ sourceType, name, def.getId());
|
|
|
+ return def.getId();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("AI 归类建议解析失败 raw={}: {}", name, e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4) 惰性注册:新 definition + auto_created 规则
|
|
|
+ return lazyRegister(sourceType, name);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 惰性注册新定义(code 规则:indicator.<cat>.<name> / bacteria.<name> / food.<name>) */
|
|
|
+ private Long lazyRegister(String sourceType, String rawName) {
|
|
|
+ DefinitionCode code = buildCode(sourceType, rawName);
|
|
|
+ IndicatorDefinition def = new IndicatorDefinition();
|
|
|
+ def.setDomain("physical");
|
|
|
+ def.setCategory(code.category);
|
|
|
+ def.setCode(code.code);
|
|
|
+ def.setName(rawName);
|
|
|
+ def.setDataType("number");
|
|
|
+ def.setSourceKind(code.sourceKind);
|
|
|
+ def.setReportTypeScope(sourceType);
|
|
|
+ def.setValueType(inferValueType(sourceType));
|
|
|
+ def.setGoodDirection("neutral");
|
|
|
+ def.setSortOrder(0);
|
|
|
+ def.setStatus(1);
|
|
|
+ def.setCreatedAt(new Date());
|
|
|
+ def.setUpdatedAt(new Date());
|
|
|
+ definitionMapper.insert(def);
|
|
|
+ registerRule(sourceType, rawName, def.getId(), "auto_created", 1);
|
|
|
+ log.info("惰性注册指标定义 sourceType={}, raw={} → defId={}", sourceType, rawName, def.getId());
|
|
|
+ return def.getId();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void registerRule(String sourceType, String rawName, Long definitionId, String matchType, int status) {
|
|
|
+ IndicatorMappingRule rule = new IndicatorMappingRule();
|
|
|
+ rule.setSourceType(sourceType);
|
|
|
+ rule.setRawName(rawName);
|
|
|
+ rule.setDefinitionId(definitionId);
|
|
|
+ rule.setMatchType(matchType);
|
|
|
+ rule.setPriority(0);
|
|
|
+ rule.setStatus(status);
|
|
|
+ rule.setCreatedAt(new Date());
|
|
|
+ rule.setUpdatedAt(new Date());
|
|
|
+ try {
|
|
|
+ ruleMapper.insert(rule);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("登记映射规则冲突(已存在?),忽略 sourceType={}, raw={}: {}", sourceType, rawName, e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 内部工具 ----------
|
|
|
+
|
|
|
+ private static class DefinitionCode {
|
|
|
+ String code;
|
|
|
+ String category;
|
|
|
+ String sourceKind;
|
|
|
+ }
|
|
|
+
|
|
|
+ private DefinitionCode buildCode(String sourceType, String rawName) {
|
|
|
+ DefinitionCode dc = new DefinitionCode();
|
|
|
+ if ("food_recommend".equals(sourceType)) {
|
|
|
+ dc.sourceKind = "food_index";
|
|
|
+ dc.category = "食材";
|
|
|
+ dc.code = "food." + rawName;
|
|
|
+ } else if (isBacteriaContext(sourceType)) {
|
|
|
+ dc.sourceKind = "bacteria";
|
|
|
+ dc.category = "菌属";
|
|
|
+ dc.code = "bacteria." + rawName;
|
|
|
+ } else {
|
|
|
+ dc.sourceKind = "report_indicator";
|
|
|
+ dc.category = "报告指标";
|
|
|
+ dc.code = "indicator." + rawName;
|
|
|
+ }
|
|
|
+ // code 有唯一约束,过长/含特殊字符时安全截断
|
|
|
+ if (dc.code.length() > 190) {
|
|
|
+ dc.code = dc.code.substring(0, 190);
|
|
|
+ }
|
|
|
+ return dc;
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isBacteriaContext(String sourceType) {
|
|
|
+ return sourceType != null && sourceType.startsWith("gut_flora");
|
|
|
+ }
|
|
|
+
|
|
|
+ private String inferValueType(String sourceType) {
|
|
|
+ return isBacteriaContext(sourceType) ? "numeric" : "numeric";
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * CJK 二元组 Dice 相似度:中文按字符二元组,ASCII 按词/字符二元组混合。
|
|
|
+ * 0~1;1 = 完全相同(但 exact 已先行命中)。
|
|
|
+ */
|
|
|
+ double bigramSimilarity(String a, String b) {
|
|
|
+ if (a == null || b == null) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ if (a.equals(b)) {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ List<String> gramsA = bigrams(a);
|
|
|
+ List<String> gramsB = bigrams(b);
|
|
|
+ if (gramsA.isEmpty() || gramsB.isEmpty()) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ int common = 0;
|
|
|
+ for (String g : gramsA) {
|
|
|
+ if (gramsB.contains(g)) {
|
|
|
+ common++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return (2.0 * common) / (gramsA.size() + gramsB.size());
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<String> bigrams(String s) {
|
|
|
+ if (s == null || s.length() < 2) {
|
|
|
+ List<String> single = new ArrayList<>();
|
|
|
+ if (s != null && !s.isEmpty()) {
|
|
|
+ single.add(s);
|
|
|
+ }
|
|
|
+ return single;
|
|
|
+ }
|
|
|
+ List<String> result = new ArrayList<>();
|
|
|
+ for (int i = 0; i < s.length() - 1; i++) {
|
|
|
+ result.add(s.substring(i, i + 2));
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 数值型解析:去掉 %/~/>/< 与空格后尝试 BigDecimal */
|
|
|
+ public BigDecimal parseNumericValue(String raw) {
|
|
|
+ if (raw == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String cleaned = raw.replaceAll("[%<>~\\s]", "").trim();
|
|
|
+ if (cleaned.isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return new BigDecimal(cleaned);
|
|
|
+ } catch (Exception e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 批量种子导入(管理端调用):seed 列表 [{rawName, definitionId?, category?, unit?}] */
|
|
|
+ public int importSeeds(String sourceType, List<Map<String, Object>> seeds) {
|
|
|
+ int count = 0;
|
|
|
+ for (Map<String, Object> seed : seeds) {
|
|
|
+ String rawName = seed.get("rawName") != null ? seed.get("rawName").toString() : null;
|
|
|
+ if (rawName == null || rawName.trim().isEmpty()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Object defIdObj = seed.get("definitionId");
|
|
|
+ Long defId;
|
|
|
+ if (defIdObj instanceof Number) {
|
|
|
+ defId = ((Number) defIdObj).longValue();
|
|
|
+ } else if (defIdObj != null) {
|
|
|
+ defId = Long.valueOf(defIdObj.toString());
|
|
|
+ } else {
|
|
|
+ defId = lazyRegister(sourceType, rawName);
|
|
|
+ }
|
|
|
+ registerRule(sourceType, rawName.trim(), defId, "exact", 1);
|
|
|
+ count++;
|
|
|
+ }
|
|
|
+ log.info("种子导入完成 sourceType={}, seeds={}", sourceType, count);
|
|
|
+ return count;
|
|
|
+ }
|
|
|
+}
|