Forráskód Böngészése

feat(indicator): IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析

iwt 2 napja
szülő
commit
6f1e0dfef6

+ 270 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java

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

+ 93 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorMappingServiceTest.java

@@ -0,0 +1,93 @@
+package com.etotem.cfc.service;
+
+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 org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+class IndicatorMappingServiceTest {
+
+    private IndicatorMappingService service;
+    private IndicatorMappingRuleMapper ruleMapper;
+    private IndicatorDefinitionMapper definitionMapper;
+    private AiGateway aiGateway;
+
+    @BeforeEach
+    void setUp() {
+        service = new IndicatorMappingService();
+        ruleMapper = Mockito.mock(IndicatorMappingRuleMapper.class);
+        definitionMapper = Mockito.mock(IndicatorDefinitionMapper.class);
+        aiGateway = Mockito.mock(AiGateway.class);
+        ReflectionTestUtils.setField(service, "ruleMapper", ruleMapper);
+        ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
+        ReflectionTestUtils.setField(service, "aiGateway", aiGateway);
+    }
+
+    @Test
+    void resolveExactHitReturnsDefinitionId() {
+        IndicatorMappingRule rule = new IndicatorMappingRule();
+        rule.setDefinitionId(7L);
+        when(ruleMapper.selectOne(any())).thenReturn(rule);
+        Long defId = service.resolve("gut_flora", "双歧杆菌属");
+        assertEquals(7L, defId);
+        verify(ruleMapper).selectOne(any());
+    }
+
+    @Test
+    void resolveMissLazilyRegistersDefinitionAndRule() {
+        when(ruleMapper.selectOne(any())).thenReturn(null);
+        IndicatorDefinition created = new IndicatorDefinition();
+        created.setId(99L);
+        when(definitionMapper.insert(any(IndicatorDefinition.class))).thenAnswer(inv -> {
+            IndicatorDefinition d = inv.getArgument(0);
+            d.setId(99L);
+            return 1;
+        });
+        Long defId = service.resolve("gut_flora", "新出现的菌X");
+        assertEquals(99L, defId);
+        // 惰性注册:定义 + auto_created 规则
+        verify(definitionMapper).insert(any(IndicatorDefinition.class));
+        verify(ruleMapper).insert(any(IndicatorMappingRule.class));
+    }
+
+    @Test
+    void resolveFuzzyHitPicksBestCandidate() {
+        // exact 未命中 → fuzzy 候选命中(别名/近似名)
+        when(ruleMapper.selectOne(any())).thenReturn(null);
+        IndicatorMappingRule candidate = new IndicatorMappingRule();
+        candidate.setDefinitionId(3L);
+        candidate.setRawName("双歧杆菌");  // 与 "双歧杆菌属" 高相似
+        when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(candidate));
+
+        Long defId = service.resolve("gut_flora", "双歧杆菌属");
+        assertEquals(3L, defId);
+    }
+
+    @Test
+    void resolveFuzzyMissFallsBackToAiSuggestion() {
+        when(ruleMapper.selectOne(any())).thenReturn(null);
+        when(ruleMapper.selectList(any())).thenReturn(Collections.emptyList());
+        // AI 建议命中
+        when(aiGateway.classifyIndicator(eq("gut_flora"), eq("神秘指标"), anyList()))
+                .thenReturn("{\"code\":\"indicator.x.y\",\"name\":\"神秘指标\"}");
+        IndicatorDefinition suggested = new IndicatorDefinition();
+        suggested.setId(41L);
+        when(definitionMapper.selectOne(any())).thenReturn(suggested);
+
+        Long defId = service.resolve("gut_flora", "神秘指标");
+        assertEquals(41L, defId);
+        // AI 建议登记为 pending(status=0)规则
+        verify(ruleMapper).insert(argThat(r -> r.getMatchType().equals("ai_fallback") && r.getStatus() == 0));
+    }
+}