Преглед изворни кода

增加与ai大模型对话

wwh пре 3 недеља
родитељ
комит
d04ab96cf6

+ 9 - 0
huimv-employment/fe-api/src/main/java/com/huimv/employment/config/AsyncConfiguration.java

@@ -0,0 +1,9 @@
1
+package com.huimv.employment.config;
2
+
3
+import org.springframework.context.annotation.Configuration;
4
+import org.springframework.scheduling.annotation.EnableAsync;
5
+
6
+@Configuration
7
+@EnableAsync
8
+public class AsyncConfiguration {
9
+}

+ 16 - 0
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbIntentClassificationClient.java

@@ -0,0 +1,16 @@
1
+package com.huimv.employment.integration.kb;
2
+
3
+/**
4
+ * 调用智能体意图分类接口(v2 §6.4.5,BE → Agent)。
5
+ */
6
+public interface KbIntentClassificationClient {
7
+
8
+    /**
9
+     * 对用户消息进行意图分类。
10
+     *
11
+     * @param userMessage 用户首条消息
12
+     * @param agentId     智能体标识,可为 null
13
+     * @return 分类结果 summary;上游不可用时返回 {@code null} 以触发本地兜底
14
+     */
15
+    String classify(String userMessage, String agentId);
16
+}

+ 100 - 0
huimv-employment/fe-integration/src/main/java/com/huimv/employment/integration/kb/KbIntentClassificationClientImpl.java

@@ -0,0 +1,100 @@
1
+package com.huimv.employment.integration.kb;
2
+
3
+import com.fasterxml.jackson.databind.JsonNode;
4
+import com.fasterxml.jackson.databind.ObjectMapper;
5
+import org.slf4j.Logger;
6
+import org.slf4j.LoggerFactory;
7
+import org.springframework.beans.factory.annotation.Qualifier;
8
+import org.springframework.http.HttpEntity;
9
+import org.springframework.http.HttpHeaders;
10
+import org.springframework.http.HttpMethod;
11
+import org.springframework.http.MediaType;
12
+import org.springframework.http.ResponseEntity;
13
+import org.springframework.stereotype.Service;
14
+import org.springframework.util.StringUtils;
15
+import org.springframework.web.client.RestClientException;
16
+import org.springframework.web.client.RestTemplate;
17
+
18
+import java.util.LinkedHashMap;
19
+import java.util.Map;
20
+
21
+/**
22
+ * 转发至智能体 {@code POST /api/mcp/conversation/classify-intent}。
23
+ */
24
+@Service
25
+public class KbIntentClassificationClientImpl implements KbIntentClassificationClient {
26
+
27
+    private static final Logger log = LoggerFactory.getLogger(KbIntentClassificationClientImpl.class);
28
+
29
+    private static final String HEADER_AGENT_ID = "X-Agent-Id";
30
+    private static final String PATH_CLASSIFY = "/api/mcp/conversation/classify-intent";
31
+
32
+    private final KbApiProperties properties;
33
+    private final RestTemplate restTemplate;
34
+    private final ObjectMapper objectMapper;
35
+
36
+    public KbIntentClassificationClientImpl(KbApiProperties properties,
37
+                                              @Qualifier("kbRestTemplate") RestTemplate restTemplate,
38
+                                              ObjectMapper objectMapper) {
39
+        this.properties = properties;
40
+        this.restTemplate = restTemplate;
41
+        this.objectMapper = objectMapper;
42
+    }
43
+
44
+    @Override
45
+    public String classify(String userMessage, String agentId) {
46
+        if (!properties.isEnabled() || !StringUtils.hasText(userMessage)) {
47
+            return null;
48
+        }
49
+        try {
50
+            Map<String, Object> body = new LinkedHashMap<>();
51
+            body.put("user_message", userMessage.trim());
52
+            HttpHeaders headers = new HttpHeaders();
53
+            headers.setContentType(MediaType.APPLICATION_JSON);
54
+            properties.applyAuthHeader(headers);
55
+            headers.add(HEADER_AGENT_ID, resolveAgentId(agentId));
56
+            ResponseEntity<String> response = restTemplate.exchange(
57
+                    properties.baseUrl() + PATH_CLASSIFY,
58
+                    HttpMethod.POST,
59
+                    new HttpEntity<>(body, headers),
60
+                    String.class);
61
+            return parseSummary(response.getBody());
62
+        } catch (RestClientException ex) {
63
+            log.debug("上游意图分类不可用,将使用本地兜底:{}", ex.getMessage());
64
+            return null;
65
+        } catch (Exception ex) {
66
+            log.warn("解析上游意图分类响应失败:{}", ex.getMessage());
67
+            return null;
68
+        }
69
+    }
70
+
71
+    private String parseSummary(String raw) throws Exception {
72
+        if (!StringUtils.hasText(raw)) {
73
+            return null;
74
+        }
75
+        JsonNode root = objectMapper.readTree(raw.trim());
76
+        JsonNode data = root.path("data");
77
+        if (!data.isMissingNode() && data.has("summary")) {
78
+            return textValue(data.get("summary"));
79
+        }
80
+        if (root.has("summary")) {
81
+            return textValue(root.get("summary"));
82
+        }
83
+        return null;
84
+    }
85
+
86
+    private static String textValue(JsonNode node) {
87
+        if (node == null || node.isNull()) {
88
+            return null;
89
+        }
90
+        String text = node.asText(null);
91
+        return StringUtils.hasText(text) ? text.trim() : null;
92
+    }
93
+
94
+    private String resolveAgentId(String agentId) {
95
+        if (StringUtils.hasText(agentId)) {
96
+            return agentId.trim();
97
+        }
98
+        return properties.getDefaultAgentId();
99
+    }
100
+}

+ 9 - 1
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationChatService.java

@@ -19,15 +19,18 @@ public class ConversationChatService {
19 19
 
20 20
     private final ConversationService conversationService;
21 21
     private final ConversationMessageService conversationMessageService;
22
+    private final ConversationIntentService conversationIntentService;
22 23
     private final KbOpenAiProxyService kbOpenAiProxyService;
23 24
     private final ObjectMapper objectMapper;
24 25
 
25 26
     public ConversationChatService(ConversationService conversationService,
26 27
                                    ConversationMessageService conversationMessageService,
28
+                                   ConversationIntentService conversationIntentService,
27 29
                                    KbOpenAiProxyService kbOpenAiProxyService,
28 30
                                    ObjectMapper objectMapper) {
29 31
         this.conversationService = conversationService;
30 32
         this.conversationMessageService = conversationMessageService;
33
+        this.conversationIntentService = conversationIntentService;
31 34
         this.kbOpenAiProxyService = kbOpenAiProxyService;
32 35
         this.objectMapper = objectMapper;
33 36
     }
@@ -41,7 +44,12 @@ public class ConversationChatService {
41 44
         String userText = request.getMessage().trim();
42 45
         byte[] upstreamBody = buildUpstreamBody(conversation, userId, userText);
43 46
 
44
-        conversationMessageService.saveUserMessage(conversation.getId(), userText);
47
+        boolean firstUserMessage = conversationMessageService.isFirstUserMessage(conversation.getId());
48
+        conversationMessageService.saveUserMessage(conversation.getId(), userText, !firstUserMessage);
49
+        if (firstUserMessage) {
50
+            conversationIntentService.classifyAndUpdateTitleAsync(
51
+                    conversation.getConversationNo(), userText, agentId);
52
+        }
45 53
         String assistantText = kbOpenAiProxyService.streamConsoleChatCollecting(
46 54
                 upstreamBody, agentId, output, objectMapper);
47 55
         conversationMessageService.saveAssistantMessage(conversation.getId(), assistantText);

+ 63 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationIntentClassifier.java

@@ -0,0 +1,63 @@
1
+package com.huimv.employment.service.conversation;
2
+
3
+import org.springframework.stereotype.Component;
4
+import org.springframework.util.StringUtils;
5
+
6
+/**
7
+ * 本地意图分类兜底(上游智能体不可用时使用)。
8
+ */
9
+@Component
10
+public class ConversationIntentClassifier {
11
+
12
+    private static final String TYPE_EMPLOYMENT = "employment";
13
+    private static final String TYPE_KNOWLEDGE = "knowledge";
14
+    private static final String TYPE_ORDER = "order";
15
+    private static final String TYPE_COST = "cost";
16
+    private static final String TYPE_ATTENDANCE = "attendance";
17
+    private static final String TYPE_EMERGENCY = "emergency";
18
+
19
+    public String classify(String userMessage) {
20
+        if (!StringUtils.hasText(userMessage)) {
21
+            return TYPE_EMPLOYMENT;
22
+        }
23
+        String text = userMessage.trim();
24
+
25
+        if (containsAny(text, "紧急", "事故", "工伤", "投诉")) {
26
+            return TYPE_EMERGENCY;
27
+        }
28
+        if (containsAny(text, "考勤", "打卡", "签到", "到岗")) {
29
+            return TYPE_ATTENDANCE;
30
+        }
31
+        if (containsAny(text, "订单", "进度", "流程", "到哪", "状态")) {
32
+            return TYPE_ORDER;
33
+        }
34
+        if (containsAny(text, "成本", "测算", "工资", "发薪", "发工资", "多少钱", "费用", "税")) {
35
+            return TYPE_COST;
36
+        }
37
+        if (containsAny(text, "政策", "合规", "怎么", "什么是", "如何", "规定")) {
38
+            return TYPE_KNOWLEDGE;
39
+        }
40
+        if (containsAny(text, "招", "招聘", "搬运", "临时工", "用工", "小时工", "仓库", "人数")) {
41
+            return TYPE_EMPLOYMENT;
42
+        }
43
+        return summarizeAsOther(text);
44
+    }
45
+
46
+    private static boolean containsAny(String text, String... keywords) {
47
+        for (String keyword : keywords) {
48
+            if (text.contains(keyword)) {
49
+                return true;
50
+            }
51
+        }
52
+        return false;
53
+    }
54
+
55
+    /** other 类型:截取 4-12 字作为自定义摘要 */
56
+    private static String summarizeAsOther(String text) {
57
+        String normalized = text.replaceAll("\\s+", "");
58
+        if (normalized.length() <= 12) {
59
+            return normalized;
60
+        }
61
+        return normalized.substring(0, 12);
62
+    }
63
+}

+ 123 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationIntentService.java

@@ -0,0 +1,123 @@
1
+package com.huimv.employment.service.conversation;
2
+
3
+import com.huimv.employment.dao.entity.FeConversation;
4
+import com.huimv.employment.dao.mapper.FeConversationMapper;
5
+import com.huimv.employment.integration.kb.KbIntentClassificationClient;
6
+import org.slf4j.Logger;
7
+import org.slf4j.LoggerFactory;
8
+import org.springframework.scheduling.annotation.Async;
9
+import org.springframework.stereotype.Service;
10
+import org.springframework.transaction.annotation.Transactional;
11
+import org.springframework.util.StringUtils;
12
+
13
+import java.time.LocalDateTime;
14
+import java.util.Arrays;
15
+import java.util.Collections;
16
+import java.util.HashSet;
17
+import java.util.Set;
18
+
19
+/**
20
+ * 会话意图分类与标题更新(v2 §2.3)。
21
+ * <p>分类能力在智能体侧;本服务仅作为客户端调用智能体 {@code classify-intent},并落库更新会话标题。</p>
22
+ */
23
+@Service
24
+public class ConversationIntentService {
25
+
26
+    private static final Logger log = LoggerFactory.getLogger(ConversationIntentService.class);
27
+
28
+    private static final Set<String> STANDARD_INTENTS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
29
+            "employment", "knowledge", "order", "cost", "attendance", "emergency"
30
+    )));
31
+
32
+    private final ConversationService conversationService;
33
+    private final FeConversationMapper feConversationMapper;
34
+    private final KbIntentClassificationClient kbIntentClassificationClient;
35
+    private final ConversationIntentClassifier conversationIntentClassifier;
36
+
37
+    public ConversationIntentService(ConversationService conversationService,
38
+                                       FeConversationMapper feConversationMapper,
39
+                                       KbIntentClassificationClient kbIntentClassificationClient,
40
+                                       ConversationIntentClassifier conversationIntentClassifier) {
41
+        this.conversationService = conversationService;
42
+        this.feConversationMapper = feConversationMapper;
43
+        this.kbIntentClassificationClient = kbIntentClassificationClient;
44
+        this.conversationIntentClassifier = conversationIntentClassifier;
45
+    }
46
+
47
+    /**
48
+     * 首条消息后异步分类并更新标题,不阻塞主对话流。
49
+     */
50
+    @Async
51
+    public void classifyAndUpdateTitleAsync(String sessionId, String userMessage, String agentId) {
52
+        try {
53
+            classifyAndUpdateTitle(sessionId, userMessage, agentId);
54
+        } catch (Exception ex) {
55
+            log.warn("异步意图分类失败 sessionId={}: {}", sessionId, ex.getMessage());
56
+        }
57
+    }
58
+
59
+    @Transactional(rollbackFor = Exception.class)
60
+    void classifyAndUpdateTitle(String sessionId, String userMessage, String agentId) {
61
+        FeConversation conversation = conversationService.requireBySessionId(sessionId);
62
+        String summary = resolveSummary(userMessage, agentId);
63
+        applyClassification(conversation, summary, userMessage);
64
+    }
65
+
66
+    private String resolveSummary(String userMessage, String agentId) {
67
+        String upstream = kbIntentClassificationClient.classify(userMessage, agentId);
68
+        if (StringUtils.hasText(upstream)) {
69
+            return upstream.trim();
70
+        }
71
+        return conversationIntentClassifier.classify(userMessage);
72
+    }
73
+
74
+    private void applyClassification(FeConversation conversation, String summary, String userMessage) {
75
+        String chatType = resolveChatType(summary);
76
+        String title = resolveTitle(summary, userMessage);
77
+
78
+        FeConversation update = new FeConversation();
79
+        update.setId(conversation.getId());
80
+        update.setChatType(chatType);
81
+        update.setTitle(title);
82
+        update.setUpdateTime(LocalDateTime.now());
83
+        feConversationMapper.updateById(update);
84
+        log.debug("会话 {} 意图分类完成 summary={} title={}", conversation.getConversationNo(), summary, title);
85
+    }
86
+
87
+    private String resolveChatType(String summary) {
88
+        if (STANDARD_INTENTS.contains(summary)) {
89
+            return summary;
90
+        }
91
+        return "employment";
92
+    }
93
+
94
+    private String resolveTitle(String summary, String userMessage) {
95
+        if (!STANDARD_INTENTS.contains(summary)) {
96
+            return truncateTitle(summary);
97
+        }
98
+        switch (summary) {
99
+            case "employment":
100
+                return truncateTitle(StringUtils.hasText(userMessage) ? userMessage : "用工需求咨询");
101
+            case "knowledge":
102
+                return "知识咨询";
103
+            case "order":
104
+                return "订单管理";
105
+            case "cost":
106
+                return truncateTitle(StringUtils.hasText(userMessage) ? userMessage : "成本测算");
107
+            case "attendance":
108
+                return "现场考勤";
109
+            case "emergency":
110
+                return "紧急事件";
111
+            default:
112
+                return truncateTitle(userMessage);
113
+        }
114
+    }
115
+
116
+    private static String truncateTitle(String text) {
117
+        if (!StringUtils.hasText(text)) {
118
+            return null;
119
+        }
120
+        String trimmed = text.trim();
121
+        return trimmed.length() <= 200 ? trimmed : trimmed.substring(0, 200);
122
+    }
123
+}

+ 20 - 1
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/conversation/ConversationMessageService.java

@@ -63,11 +63,30 @@ public class ConversationMessageService {
63 63
 
64 64
     @Transactional(rollbackFor = Exception.class)
65 65
     public void saveUserMessage(Long conversationId, String userText) {
66
+        saveUserMessage(conversationId, userText, true);
67
+    }
68
+
69
+    /**
70
+     * @param refreshTitle 为 false 时仅刷新 last_message_at,标题由意图分类异步写入
71
+     */
72
+    @Transactional(rollbackFor = Exception.class)
73
+    public void saveUserMessage(Long conversationId, String userText, boolean refreshTitle) {
66 74
         if (conversationId == null || !StringUtils.hasText(userText)) {
67 75
             return;
68 76
         }
69 77
         insertMessage(conversationId, ROLE_USER, userText.trim());
70
-        conversationService.refreshAfterMessage(conversationId, userText);
78
+        conversationService.refreshAfterMessage(conversationId, refreshTitle ? userText : null);
79
+    }
80
+
81
+    /** 当前会话是否尚无 user 消息(用于首条消息意图分类) */
82
+    public boolean isFirstUserMessage(Long conversationId) {
83
+        if (conversationId == null) {
84
+            return false;
85
+        }
86
+        Long count = feConversationMessageMapper.selectCount(new LambdaQueryWrapper<FeConversationMessage>()
87
+                .eq(FeConversationMessage::getConversationId, conversationId)
88
+                .eq(FeConversationMessage::getRole, ROLE_USER));
89
+        return count == null || count == 0L;
71 90
     }
72 91
 
73 92
     @Transactional(rollbackFor = Exception.class)