feat(03): Phase 3 - 对话服务 + 意图服务
- conversation-service: 会话管理、Redis上下文窗口(10轮滑动窗口)、轮次解析、消息事件监听 - intent-service: 40意图分类、规则匹配+LLM混合识别、学员画像构建(5维度)、槽位填充 - LLMIntentClient: 通义千问API封装、Prompt构建、结果解析 - 提供意图识别API、学员画像查询API
This commit is contained in:
parent
48c1dd8452
commit
8941f6f613
@ -0,0 +1,50 @@
|
||||
package com.artedu.conversation.controller;
|
||||
|
||||
import com.artedu.common.result.Result;
|
||||
import com.artedu.conversation.entity.Conversation;
|
||||
import com.artedu.conversation.service.ConversationManager;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/conversations")
|
||||
public class ConversationController {
|
||||
|
||||
@Autowired
|
||||
private ConversationManager conversationManager;
|
||||
|
||||
@GetMapping("/{sessionId}/context")
|
||||
public Result<Map<String, Object>> getContext(@PathVariable("sessionId") String sessionId) {
|
||||
String contextStr = conversationManager.getContextString(sessionId);
|
||||
var messages = conversationManager.getContextMessages(sessionId);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("sessionId", sessionId);
|
||||
result.put("context", contextStr);
|
||||
result.put("messageCount", messages.size());
|
||||
result.put("messages", messages);
|
||||
|
||||
return Result.success(result);
|
||||
}
|
||||
|
||||
@GetMapping("/{sessionId}")
|
||||
public Result<Conversation> getConversation(@PathVariable("sessionId") String sessionId) {
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@PutMapping("/{sessionId}/status")
|
||||
public Result<String> updateStatus(
|
||||
@PathVariable("sessionId") String sessionId,
|
||||
@RequestParam("stage") String stage,
|
||||
@RequestParam(value = "confidence", required = false) Double confidence,
|
||||
@RequestParam(value = "summary", required = false) String summary) {
|
||||
|
||||
conversationManager.updateConversationStatus(sessionId, stage, confidence, summary);
|
||||
return Result.success("状态更新成功");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package com.artedu.conversation.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 会话实体
|
||||
*/
|
||||
@Data
|
||||
@TableName("conversations")
|
||||
public class Conversation {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private String sessionId;
|
||||
private String corpId;
|
||||
private String customerId;
|
||||
private String customerName;
|
||||
private String staffId;
|
||||
private String staffName;
|
||||
private String roomId;
|
||||
private String status;
|
||||
private String currentStage;
|
||||
private Double stageConfidence;
|
||||
private Integer roundCount;
|
||||
private Integer intentCount;
|
||||
private String lastMsgId;
|
||||
private LocalDateTime lastMsgTime;
|
||||
private String lastMsgContent;
|
||||
private LocalDateTime startTime;
|
||||
private LocalDateTime endTime;
|
||||
private Integer idleDuration;
|
||||
private String contextSummary;
|
||||
private String keyEvents;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package com.artedu.conversation.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 对话轮次实体
|
||||
*/
|
||||
@Data
|
||||
@TableName("conversation_turns")
|
||||
public class ConversationTurn {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private String turnId;
|
||||
private String sessionId;
|
||||
private Integer turnNumber;
|
||||
private String studentMsgId;
|
||||
private String studentContent;
|
||||
private String seatMsgId;
|
||||
private String seatContent;
|
||||
private String turnType;
|
||||
private String primaryIntent;
|
||||
private Double intentConfidence;
|
||||
private String studentEmotion;
|
||||
private String keyTopics;
|
||||
private String slotChanges;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.artedu.conversation.mapper;
|
||||
|
||||
import com.artedu.conversation.entity.Conversation;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 会话数据访问层
|
||||
*/
|
||||
@Mapper
|
||||
public interface ConversationMapper extends BaseMapper<Conversation> {
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.artedu.conversation.mapper;
|
||||
|
||||
import com.artedu.conversation.entity.ConversationTurn;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 对话轮次数据访问层
|
||||
*/
|
||||
@Mapper
|
||||
public interface ConversationTurnMapper extends BaseMapper<ConversationTurn> {
|
||||
}
|
||||
@ -0,0 +1,286 @@
|
||||
package com.artedu.conversation.service;
|
||||
|
||||
import com.artedu.common.util.JsonUtils;
|
||||
import com.artedu.conversation.entity.Conversation;
|
||||
import com.artedu.conversation.entity.ConversationTurn;
|
||||
import com.artedu.conversation.mapper.ConversationMapper;
|
||||
import com.artedu.conversation.mapper.ConversationTurnMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ConversationManager {
|
||||
|
||||
@Autowired
|
||||
private ConversationMapper conversationMapper;
|
||||
|
||||
@Autowired
|
||||
private ConversationTurnMapper conversationTurnMapper;
|
||||
|
||||
@Autowired
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private static final String CONV_CONTEXT_KEY = "conv:context:";
|
||||
private static final int MAX_CONTEXT_ROUNDS = 10;
|
||||
private static final int CONTEXT_EXPIRE_MINUTES = 30;
|
||||
|
||||
public Conversation getOrCreateSession(String sessionId, String corpId,
|
||||
String customerId, String staffId) {
|
||||
Conversation conv = conversationMapper.selectOne(
|
||||
new LambdaQueryWrapper<Conversation>()
|
||||
.eq(Conversation::getSessionId, sessionId)
|
||||
);
|
||||
|
||||
if (conv == null) {
|
||||
conv = new Conversation();
|
||||
conv.setSessionId(sessionId);
|
||||
conv.setCorpId(corpId);
|
||||
conv.setCustomerId(customerId);
|
||||
conv.setStaffId(staffId);
|
||||
conv.setStatus("ACTIVE");
|
||||
conv.setCurrentStage("开场白");
|
||||
conv.setStageConfidence(0.0);
|
||||
conv.setRoundCount(0);
|
||||
conv.setStartTime(LocalDateTime.now());
|
||||
conversationMapper.insert(conv);
|
||||
log.info("创建新会话: sessionId={}", sessionId);
|
||||
}
|
||||
|
||||
return conv;
|
||||
}
|
||||
|
||||
public void updateConversationStatus(String sessionId, String stage,
|
||||
Double confidence, String summary) {
|
||||
Conversation conv = conversationMapper.selectOne(
|
||||
new LambdaQueryWrapper<Conversation>()
|
||||
.eq(Conversation::getSessionId, sessionId)
|
||||
);
|
||||
if (conv != null) {
|
||||
conv.setCurrentStage(stage);
|
||||
if (confidence != null) {
|
||||
conv.setStageConfidence(confidence);
|
||||
}
|
||||
if (summary != null) {
|
||||
conv.setContextSummary(summary);
|
||||
}
|
||||
conv.setUpdatedAt(LocalDateTime.now());
|
||||
conversationMapper.updateById(conv);
|
||||
}
|
||||
}
|
||||
|
||||
public void addMessageToContext(String sessionId, String role, String content, Long msgTime) {
|
||||
String key = CONV_CONTEXT_KEY + sessionId;
|
||||
ContextMessage message = new ContextMessage(role, content, msgTime);
|
||||
String messageJson = JsonUtils.toJson(message);
|
||||
redisTemplate.opsForList().rightPush(key, messageJson);
|
||||
Long size = redisTemplate.opsForList().size(key);
|
||||
if (size != null && size > MAX_CONTEXT_ROUNDS * 2) {
|
||||
redisTemplate.opsForList().trim(key, -MAX_CONTEXT_ROUNDS * 2, -1);
|
||||
}
|
||||
redisTemplate.expire(key, CONTEXT_EXPIRE_MINUTES, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
public List<ContextMessage> getContextMessages(String sessionId) {
|
||||
String key = CONV_CONTEXT_KEY + sessionId;
|
||||
List<Object> jsonList = redisTemplate.opsForList().range(key, 0, -1);
|
||||
if (jsonList == null) {
|
||||
return List.of();
|
||||
}
|
||||
return jsonList.stream()
|
||||
.map(obj -> JsonUtils.fromJson((String) obj, ContextMessage.class))
|
||||
.filter(msg -> msg != null)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public String getContextString(String sessionId) {
|
||||
List<ContextMessage> messages = getContextMessages(sessionId);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (ContextMessage msg : messages) {
|
||||
String roleName = "student".equals(msg.getRole()) ? "学员" : "顾问";
|
||||
sb.append(roleName).append(": ").append(msg.getContent()).append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public void clearContext(String sessionId) {
|
||||
String key = CONV_CONTEXT_KEY + sessionId;
|
||||
redisTemplate.delete(key);
|
||||
}
|
||||
|
||||
public ConversationTurn createTurn(String sessionId, int turnNumber,
|
||||
String studentMsgId, String studentContent,
|
||||
String turnType) {
|
||||
ConversationTurn turn = new ConversationTurn();
|
||||
turn.setTurnId(sessionId + "_" + turnNumber);
|
||||
turn.setSessionId(sessionId);
|
||||
turn.setTurnNumber(turnNumber);
|
||||
turn.setStudentMsgId(studentMsgId);
|
||||
turn.setStudentContent(studentContent);
|
||||
turn.setTurnType(turnType != null ? turnType : "QUERY");
|
||||
turn.setCreatedAt(LocalDateTime.now());
|
||||
conversationTurnMapper.insert(turn);
|
||||
|
||||
Conversation conv = conversationMapper.selectOne(
|
||||
new LambdaQueryWrapper<Conversation>()
|
||||
.eq(Conversation::getSessionId, sessionId)
|
||||
);
|
||||
if (conv != null) {
|
||||
conv.setRoundCount(turnNumber);
|
||||
conv.setUpdatedAt(LocalDateTime.now());
|
||||
conversationMapper.updateById(conv);
|
||||
}
|
||||
|
||||
return turn;
|
||||
}
|
||||
|
||||
public void updateSeatReply(String turnId, String seatMsgId, String seatContent) {
|
||||
ConversationTurn turn = conversationTurnMapper.selectOne(
|
||||
new LambdaQueryWrapper<ConversationTurn>()
|
||||
.eq(ConversationTurn::getTurnId, turnId)
|
||||
);
|
||||
if (turn != null) {
|
||||
turn.setSeatMsgId(seatMsgId);
|
||||
turn.setSeatContent(seatContent);
|
||||
conversationTurnMapper.updateById(turn);
|
||||
}
|
||||
}
|
||||
|
||||
@RabbitListener(queues = "archive.new_message.queue")
|
||||
public void handleNewMessage(String messageJson) {
|
||||
try {
|
||||
ArchiveMessageEvent event = JsonUtils.fromJson(messageJson, ArchiveMessageEvent.class);
|
||||
if (event == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("收到新消息事件: sessionId={}, msgType={}",
|
||||
event.getSessionId(), event.getMsgtype());
|
||||
|
||||
Conversation conv = getOrCreateSession(
|
||||
event.getSessionId(),
|
||||
event.getCorpId(),
|
||||
event.getFromUser(),
|
||||
event.getToUser()
|
||||
);
|
||||
|
||||
String role = "EXTERNAL".equals(event.getFromRole()) ? "student" : "seat";
|
||||
addMessageToContext(event.getSessionId(), role, event.getContent(), event.getMsgtime());
|
||||
|
||||
conv.setLastMsgId(event.getMsgid());
|
||||
conv.setLastMsgTime(LocalDateTime.now());
|
||||
conv.setLastMsgContent(truncateContent(event.getContent(), 200));
|
||||
conv.setIdleDuration(0);
|
||||
conversationMapper.updateById(conv);
|
||||
|
||||
if ("student".equals(role)) {
|
||||
String turnType = detectTurnType(event.getContent());
|
||||
createTurn(event.getSessionId(), conv.getRoundCount() + 1,
|
||||
event.getMsgid(), event.getContent(), turnType);
|
||||
} else {
|
||||
updateLastTurn(conv.getSessionId(), conv.getRoundCount(),
|
||||
event.getMsgid(), event.getContent());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("处理新消息事件失败: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private String detectTurnType(String content) {
|
||||
if (content == null || content.isEmpty()) {
|
||||
return "QUERY";
|
||||
}
|
||||
String lower = content.toLowerCase();
|
||||
if (lower.contains("贵") || lower.contains("考虑") || lower.contains("担心")) {
|
||||
return "OBJECTION";
|
||||
}
|
||||
if (lower.contains("报名") || lower.contains("决定") || lower.contains("确定")) {
|
||||
return "INTEREST";
|
||||
}
|
||||
if (lower.contains("?") || lower.contains("?") || lower.contains("吗")) {
|
||||
return "QUERY";
|
||||
}
|
||||
return "QUERY";
|
||||
}
|
||||
|
||||
private void updateLastTurn(String sessionId, int roundNumber, String seatMsgId, String content) {
|
||||
ConversationTurn turn = conversationTurnMapper.selectOne(
|
||||
new LambdaQueryWrapper<ConversationTurn>()
|
||||
.eq(ConversationTurn::getSessionId, sessionId)
|
||||
.eq(ConversationTurn::getTurnNumber, roundNumber)
|
||||
);
|
||||
if (turn != null) {
|
||||
turn.setSeatMsgId(seatMsgId);
|
||||
turn.setSeatContent(content);
|
||||
conversationTurnMapper.updateById(turn);
|
||||
}
|
||||
}
|
||||
|
||||
private String truncateContent(String content, int maxLength) {
|
||||
if (content == null) {
|
||||
return "";
|
||||
}
|
||||
return content.length() > maxLength ? content.substring(0, maxLength) + "..." : content;
|
||||
}
|
||||
|
||||
public static class ArchiveMessageEvent {
|
||||
private String msgid;
|
||||
private String corpId;
|
||||
private String sessionId;
|
||||
private String fromUser;
|
||||
private String fromRole;
|
||||
private String toUser;
|
||||
private String msgtype;
|
||||
private String content;
|
||||
private Long msgtime;
|
||||
|
||||
public String getMsgid() { return msgid; }
|
||||
public void setMsgid(String msgid) { this.msgid = msgid; }
|
||||
public String getCorpId() { return corpId; }
|
||||
public void setCorpId(String corpId) { this.corpId = corpId; }
|
||||
public String getSessionId() { return sessionId; }
|
||||
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
|
||||
public String getFromUser() { return fromUser; }
|
||||
public void setFromUser(String fromUser) { this.fromUser = fromUser; }
|
||||
public String getFromRole() { return fromRole; }
|
||||
public void setFromRole(String fromRole) { this.fromRole = fromRole; }
|
||||
public String getToUser() { return toUser; }
|
||||
public void setToUser(String toUser) { this.toUser = toUser; }
|
||||
public String getMsgtype() { return msgtype; }
|
||||
public void setMsgtype(String msgtype) { this.msgtype = msgtype; }
|
||||
public String getContent() { return content; }
|
||||
public void setContent(String content) { this.content = content; }
|
||||
public Long getMsgtime() { return msgtime; }
|
||||
public void setMsgtime(Long msgtime) { this.msgtime = msgtime; }
|
||||
}
|
||||
|
||||
public static class ContextMessage {
|
||||
private String role;
|
||||
private String content;
|
||||
private Long msgTime;
|
||||
|
||||
public ContextMessage() {}
|
||||
|
||||
public ContextMessage(String role, String content, Long msgTime) {
|
||||
this.role = role;
|
||||
this.content = content;
|
||||
this.msgTime = msgTime;
|
||||
}
|
||||
|
||||
public String getRole() { return role; }
|
||||
public void setRole(String role) { this.role = role; }
|
||||
public String getContent() { return content; }
|
||||
public void setContent(String content) { this.content = content; }
|
||||
public Long getMsgTime() { return msgTime; }
|
||||
public void setMsgTime(Long msgTime) { this.msgTime = msgTime; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
package com.artedu.intent.controller;
|
||||
|
||||
import com.artedu.common.result.Result;
|
||||
import com.artedu.intent.dto.IntentResult;
|
||||
import com.artedu.intent.entity.CustomerProfile;
|
||||
import com.artedu.intent.entity.IntentCategory;
|
||||
import com.artedu.intent.mapper.CustomerProfileMapper;
|
||||
import com.artedu.intent.mapper.IntentCategoryMapper;
|
||||
import com.artedu.intent.service.IntentRecognitionService;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/intent")
|
||||
public class IntentController {
|
||||
|
||||
@Autowired
|
||||
private IntentRecognitionService intentRecognitionService;
|
||||
|
||||
@Autowired
|
||||
private IntentCategoryMapper intentCategoryMapper;
|
||||
|
||||
@Autowired
|
||||
private CustomerProfileMapper customerProfileMapper;
|
||||
|
||||
@PostMapping("/recognize")
|
||||
public Result<IntentResult> recognize(
|
||||
@RequestParam("message") String message,
|
||||
@RequestParam("customerId") String customerId,
|
||||
@RequestParam("corpId") String corpId,
|
||||
@RequestParam("staffId") String staffId,
|
||||
@RequestParam(value = "contextSummary", required = false) String contextSummary) {
|
||||
|
||||
IntentResult result = intentRecognitionService.recognize(message, contextSummary, customerId, corpId, staffId);
|
||||
return Result.success(result);
|
||||
}
|
||||
|
||||
@GetMapping("/categories")
|
||||
public Result<List<IntentCategory>> listCategories() {
|
||||
List<IntentCategory> list = intentCategoryMapper.selectList(
|
||||
new LambdaQueryWrapper<IntentCategory>()
|
||||
.eq(IntentCategory::getStatus, 1)
|
||||
);
|
||||
return Result.success(list);
|
||||
}
|
||||
|
||||
@GetMapping("/profile/{customerId}")
|
||||
public Result<CustomerProfile> getProfile(
|
||||
@PathVariable("customerId") String customerId,
|
||||
@RequestParam("corpId") String corpId) {
|
||||
|
||||
CustomerProfile profile = customerProfileMapper.selectOne(
|
||||
new LambdaQueryWrapper<CustomerProfile>()
|
||||
.eq(CustomerProfile::getCustomerId, customerId)
|
||||
.eq(CustomerProfile::getCorpId, corpId)
|
||||
);
|
||||
return Result.success(profile);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.artedu.intent.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class IntentResult {
|
||||
|
||||
private IntentInfo primaryIntent;
|
||||
private List<IntentInfo> secondaryIntents;
|
||||
private Double confidence;
|
||||
private String sentiment;
|
||||
private Double sentimentConfidence;
|
||||
|
||||
@Data
|
||||
public static class IntentInfo {
|
||||
private String code;
|
||||
private String name;
|
||||
private String domain;
|
||||
private Double confidence;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package com.artedu.intent.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("customer_profiles")
|
||||
public class CustomerProfile {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private String customerId;
|
||||
private String corpId;
|
||||
private String studentType;
|
||||
private Double studentTypeConfidence;
|
||||
private String skillLevel;
|
||||
private Double skillLevelConfidence;
|
||||
private String intentLevel;
|
||||
private Double intentScore;
|
||||
private String concernFocus;
|
||||
private Double concernFocusConfidence;
|
||||
private String decisionStage;
|
||||
private Double decisionStageConfidence;
|
||||
private String interestedCourses;
|
||||
private String budgetHint;
|
||||
private String preferredCity;
|
||||
private Integer age;
|
||||
private String education;
|
||||
private String currentOccupation;
|
||||
private String slotData;
|
||||
private Integer conversationCount;
|
||||
private LocalDateTime lastConversationTime;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package com.artedu.intent.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("intent_categories")
|
||||
public class IntentCategory {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private String code;
|
||||
private String name;
|
||||
private String domain;
|
||||
private String description;
|
||||
private String priority;
|
||||
private String triggerKeywords;
|
||||
private String sampleExpressions;
|
||||
private Integer status;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package com.artedu.intent.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("intent_recognitions")
|
||||
public class IntentRecognition {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private String sessionId;
|
||||
private String turnId;
|
||||
private String msgId;
|
||||
private String customerId;
|
||||
private String staffId;
|
||||
private String messageContent;
|
||||
private String primaryIntentCode;
|
||||
private Double primaryIntentConfidence;
|
||||
private String secondaryIntents;
|
||||
private String sentiment;
|
||||
private Double sentimentConfidence;
|
||||
private String recognitionMethod;
|
||||
private String llmModel;
|
||||
private Integer promptTokens;
|
||||
private Integer completionTokens;
|
||||
private Integer elapsedMs;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.artedu.intent.mapper;
|
||||
|
||||
import com.artedu.intent.entity.CustomerProfile;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface CustomerProfileMapper extends BaseMapper<CustomerProfile> {
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.artedu.intent.mapper;
|
||||
|
||||
import com.artedu.intent.entity.IntentCategory;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface IntentCategoryMapper extends BaseMapper<IntentCategory> {
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.artedu.intent.mapper;
|
||||
|
||||
import com.artedu.intent.entity.IntentRecognition;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface IntentRecognitionMapper extends BaseMapper<IntentRecognition> {
|
||||
}
|
||||
@ -0,0 +1,291 @@
|
||||
package com.artedu.intent.service;
|
||||
|
||||
import com.artedu.common.config.RabbitConfig;
|
||||
import com.artedu.common.util.JsonUtils;
|
||||
import com.artedu.intent.dto.IntentResult;
|
||||
import com.artedu.intent.entity.CustomerProfile;
|
||||
import com.artedu.intent.entity.IntentCategory;
|
||||
import com.artedu.intent.entity.IntentRecognition;
|
||||
import com.artedu.intent.mapper.CustomerProfileMapper;
|
||||
import com.artedu.intent.mapper.IntentCategoryMapper;
|
||||
import com.artedu.intent.mapper.IntentRecognitionMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class IntentRecognitionService {
|
||||
|
||||
@Autowired
|
||||
private IntentCategoryMapper intentCategoryMapper;
|
||||
|
||||
@Autowired
|
||||
private IntentRecognitionMapper intentRecognitionMapper;
|
||||
|
||||
@Autowired
|
||||
private CustomerProfileMapper customerProfileMapper;
|
||||
|
||||
@Autowired
|
||||
private LLMIntentClient llmIntentClient;
|
||||
|
||||
@Autowired
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
@Autowired
|
||||
private RabbitTemplate rabbitTemplate;
|
||||
|
||||
private static final String INTENT_CACHE_KEY = "intent:";
|
||||
|
||||
public IntentResult recognize(String message, String contextSummary,
|
||||
String customerId, String corpId, String staffId) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
String cacheKey = INTENT_CACHE_KEY + customerId + ":" + message.hashCode();
|
||||
IntentResult cached = (IntentResult) redisTemplate.opsForValue().get(cacheKey);
|
||||
if (cached != null) {
|
||||
log.debug("意图识别命中缓存: customerId={}", customerId);
|
||||
return cached;
|
||||
}
|
||||
|
||||
IntentResult ruleResult = ruleMatch(message);
|
||||
if (ruleResult != null && ruleResult.getConfidence() > 0.85) {
|
||||
log.debug("意图识别规则匹配命中: intent={}, confidence={}",
|
||||
ruleResult.getPrimaryIntent().getCode(), ruleResult.getConfidence());
|
||||
|
||||
saveRecognition(message, contextSummary, customerId, corpId, staffId, ruleResult, "RULE", startTime);
|
||||
updateProfile(customerId, corpId, ruleResult);
|
||||
redisTemplate.opsForValue().set(cacheKey, ruleResult, 5, TimeUnit.MINUTES);
|
||||
|
||||
return ruleResult;
|
||||
}
|
||||
|
||||
IntentResult llmResult = llmIntentClient.recognize(message, contextSummary, getAllCategories());
|
||||
|
||||
if (ruleResult != null && llmResult != null) {
|
||||
if (ruleResult.getPrimaryIntent().getCode()
|
||||
.equals(llmResult.getPrimaryIntent().getCode())) {
|
||||
llmResult.setConfidence(Math.min(1.0, llmResult.getConfidence() + 0.05));
|
||||
}
|
||||
}
|
||||
|
||||
saveRecognition(message, contextSummary, customerId, corpId, staffId, llmResult, "HYBRID", startTime);
|
||||
updateProfile(customerId, corpId, llmResult);
|
||||
redisTemplate.opsForValue().set(cacheKey, llmResult, 5, TimeUnit.MINUTES);
|
||||
sendRecommendEvent(customerId, corpId, staffId, llmResult, contextSummary);
|
||||
|
||||
log.info("意图识别完成: intent={}, confidence={}, elapsed={}ms",
|
||||
llmResult.getPrimaryIntent().getCode(),
|
||||
llmResult.getConfidence(),
|
||||
System.currentTimeMillis() - startTime);
|
||||
|
||||
return llmResult;
|
||||
}
|
||||
|
||||
private IntentResult ruleMatch(String message) {
|
||||
if (message == null || message.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String lowerMsg = message.toLowerCase();
|
||||
|
||||
List<IntentCategory> categories = intentCategoryMapper.selectList(
|
||||
new LambdaQueryWrapper<IntentCategory>()
|
||||
.eq(IntentCategory::getStatus, 1)
|
||||
);
|
||||
|
||||
IntentCategory bestMatch = null;
|
||||
double bestScore = 0;
|
||||
|
||||
for (IntentCategory category : categories) {
|
||||
List<String> keywords = JsonUtils.fromJsonList(
|
||||
category.getTriggerKeywords(), String.class);
|
||||
if (keywords == null || keywords.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int matchCount = 0;
|
||||
for (String keyword : keywords) {
|
||||
if (lowerMsg.contains(keyword.toLowerCase())) {
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchCount > 0) {
|
||||
double score = (double) matchCount / keywords.size();
|
||||
if ("P1".equals(category.getPriority())) {
|
||||
score *= 1.1;
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMatch = category;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch != null && bestScore > 0.3) {
|
||||
IntentResult result = new IntentResult();
|
||||
IntentResult.IntentInfo intentInfo = new IntentResult.IntentInfo();
|
||||
intentInfo.setCode(bestMatch.getCode());
|
||||
intentInfo.setName(bestMatch.getName());
|
||||
intentInfo.setDomain(bestMatch.getDomain());
|
||||
intentInfo.setConfidence(Math.min(1.0, bestScore));
|
||||
result.setPrimaryIntent(intentInfo);
|
||||
result.setConfidence(bestScore);
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void saveRecognition(String message, String contextSummary, String customerId,
|
||||
String corpId, String staffId, IntentResult result,
|
||||
String method, long startTime) {
|
||||
try {
|
||||
IntentRecognition record = new IntentRecognition();
|
||||
record.setSessionId(contextSummary);
|
||||
record.setMsgId(UUID.randomUUID().toString());
|
||||
record.setCustomerId(customerId);
|
||||
record.setStaffId(staffId);
|
||||
record.setMessageContent(message.length() > 200 ? message.substring(0, 200) : message);
|
||||
record.setPrimaryIntentCode(result.getPrimaryIntent().getCode());
|
||||
record.setPrimaryIntentConfidence(result.getPrimaryIntent().getConfidence());
|
||||
record.setSecondaryIntents(JsonUtils.toJson(result.getSecondaryIntents()));
|
||||
record.setSentiment(result.getSentiment());
|
||||
record.setRecognitionMethod(method);
|
||||
record.setElapsedMs((int) (System.currentTimeMillis() - startTime));
|
||||
record.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
intentRecognitionMapper.insert(record);
|
||||
} catch (Exception e) {
|
||||
log.error("保存意图识别记录失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void updateProfile(String customerId, String corpId, IntentResult result) {
|
||||
try {
|
||||
CustomerProfile profile = customerProfileMapper.selectOne(
|
||||
new LambdaQueryWrapper<CustomerProfile>()
|
||||
.eq(CustomerProfile::getCustomerId, customerId)
|
||||
.eq(CustomerProfile::getCorpId, corpId)
|
||||
);
|
||||
|
||||
if (profile == null) {
|
||||
profile = new CustomerProfile();
|
||||
profile.setCustomerId(customerId);
|
||||
profile.setCorpId(corpId);
|
||||
profile.setIntentLevel("低");
|
||||
profile.setIntentScore(0.0);
|
||||
profile.setDecisionStage("信息了解");
|
||||
profile.setConversationCount(1);
|
||||
}
|
||||
|
||||
String intentCode = result.getPrimaryIntent().getCode();
|
||||
double score = result.getPrimaryIntent().getConfidence();
|
||||
|
||||
updateIntentScore(profile, intentCode, score);
|
||||
updateConcernFocus(profile, intentCode);
|
||||
updateDecisionStage(profile, intentCode);
|
||||
|
||||
profile.setLastConversationTime(LocalDateTime.now());
|
||||
profile.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
if (profile.getId() == null) {
|
||||
customerProfileMapper.insert(profile);
|
||||
} else {
|
||||
customerProfileMapper.updateById(profile);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("更新学员画像失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void updateIntentScore(CustomerProfile profile, String intentCode, double score) {
|
||||
double currentScore = profile.getIntentScore() != null ? profile.getIntentScore() : 0.0;
|
||||
if (intentCode.startsWith("INT-PRICE-") || intentCode.startsWith("INT-JOB-")) {
|
||||
profile.setIntentScore(Math.min(100.0, currentScore + score * 10));
|
||||
} else if (intentCode.startsWith("INT-COURSE-")) {
|
||||
profile.setIntentScore(Math.min(100.0, currentScore + score * 5));
|
||||
}
|
||||
|
||||
if (profile.getIntentScore() >= 70) {
|
||||
profile.setIntentLevel("高");
|
||||
} else if (profile.getIntentScore() >= 40) {
|
||||
profile.setIntentLevel("中");
|
||||
}
|
||||
}
|
||||
|
||||
private void updateConcernFocus(CustomerProfile profile, String intentCode) {
|
||||
if (intentCode.startsWith("INT-PRICE-")) {
|
||||
profile.setConcernFocus("价格敏感型");
|
||||
} else if (intentCode.startsWith("INT-JOB-")) {
|
||||
profile.setConcernFocus("就业导向型");
|
||||
} else if (intentCode.startsWith("INT-TEACH-")) {
|
||||
profile.setConcernFocus("师资看重型");
|
||||
}
|
||||
}
|
||||
|
||||
private void updateDecisionStage(CustomerProfile profile, String intentCode) {
|
||||
if (intentCode.startsWith("INT-PRICE-03") || intentCode.startsWith("INT-PRICE-04")) {
|
||||
profile.setDecisionStage("购买决策");
|
||||
} else if (intentCode.startsWith("INT-COURSE-04") || intentCode.startsWith("INT-COURSE-02")) {
|
||||
profile.setDecisionStage("方案比较");
|
||||
}
|
||||
}
|
||||
|
||||
private void sendRecommendEvent(String customerId, String corpId, String staffId,
|
||||
IntentResult result, String contextSummary) {
|
||||
try {
|
||||
Map<String, Object> event = Map.of(
|
||||
"customerId", customerId,
|
||||
"corpId", corpId,
|
||||
"staffId", staffId,
|
||||
"primaryIntent", result.getPrimaryIntent().getCode(),
|
||||
"sessionId", contextSummary
|
||||
);
|
||||
rabbitTemplate.convertAndSend(
|
||||
RabbitConfig.CONVERSATION_EXCHANGE,
|
||||
RabbitConfig.ROUTING_KEY_RECOMMEND,
|
||||
JsonUtils.toJson(event)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.error("发送推荐事件失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private List<IntentCategory> getAllCategories() {
|
||||
return intentCategoryMapper.selectList(
|
||||
new LambdaQueryWrapper<IntentCategory>()
|
||||
.eq(IntentCategory::getStatus, 1)
|
||||
);
|
||||
}
|
||||
|
||||
@RabbitListener(queues = "conversation.intent_analyze.queue")
|
||||
public void handleIntentAnalyze(String messageJson) {
|
||||
try {
|
||||
Map<String, Object> event = JsonUtils.fromJsonMap(messageJson);
|
||||
if (event == null) {
|
||||
return;
|
||||
}
|
||||
String customerId = (String) event.get("customerId");
|
||||
String corpId = (String) event.get("corpId");
|
||||
String staffId = (String) event.get("staffId");
|
||||
String message = (String) event.get("message");
|
||||
String contextSummary = (String) event.get("contextSummary");
|
||||
|
||||
recognize(message, contextSummary, customerId, corpId, staffId);
|
||||
} catch (Exception e) {
|
||||
log.error("处理意图分析事件失败: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
package com.artedu.intent.service;
|
||||
|
||||
import com.artedu.common.util.JsonUtils;
|
||||
import com.artedu.intent.dto.IntentResult;
|
||||
import com.artedu.intent.entity.IntentCategory;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class LLMIntentClient {
|
||||
|
||||
@Value("${llm.qianwen.api-key:}")
|
||||
private String apiKey;
|
||||
|
||||
@Value("${llm.qianwen.model:qwen-turbo}")
|
||||
private String model;
|
||||
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
private static final String API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation";
|
||||
|
||||
public IntentResult recognize(String message, String contextSummary, List<IntentCategory> categories) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
try {
|
||||
String prompt = buildPrompt(message, contextSummary, categories);
|
||||
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("model", model);
|
||||
|
||||
Map<String, Object> input = new HashMap<>();
|
||||
input.put("prompt", prompt);
|
||||
requestBody.put("input", input);
|
||||
|
||||
Map<String, Object> parameters = new HashMap<>();
|
||||
parameters.put("result_format", "text");
|
||||
requestBody.put("parameters", parameters);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Authorization", "Bearer " + apiKey);
|
||||
|
||||
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(API_URL, request, String.class);
|
||||
|
||||
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
|
||||
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||||
String text = extractText(result);
|
||||
return parseIntentResult(text, categories);
|
||||
}
|
||||
|
||||
log.error("LLM意图识别失败: status={}", response.getStatusCode());
|
||||
return createDefaultResult();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("LLM意图识别异常: {}", e.getMessage(), e);
|
||||
return createDefaultResult();
|
||||
}
|
||||
}
|
||||
|
||||
private String buildPrompt(String message, String contextSummary, List<IntentCategory> categories) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("你是一个专业的教育培训机构客服意图识别助手。请根据学员的咨询消息,判断其意图。\n\n");
|
||||
sb.append("可选意图列表:\n");
|
||||
for (IntentCategory cat : categories) {
|
||||
sb.append("- ").append(cat.getCode()).append(": ").append(cat.getName())
|
||||
.append(" (").append(cat.getDomain()).append(")\n");
|
||||
}
|
||||
sb.append("\n对话上下文:\n").append(contextSummary != null ? contextSummary : "无");
|
||||
sb.append("\n学员最新消息:").append(message);
|
||||
sb.append("\n\n请输出JSON格式结果,包含:primaryIntentCode, confidence(0-1), sentiment(POSITIVE/NEUTRAL/NEGATIVE)");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String extractText(Map<String, Object> result) {
|
||||
if (result == null) return "";
|
||||
Map<String, Object> output = (Map<String, Object>) result.get("output");
|
||||
if (output != null) {
|
||||
return (String) output.get("text");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private IntentResult parseIntentResult(String text, List<IntentCategory> categories) {
|
||||
IntentResult result = new IntentResult();
|
||||
try {
|
||||
String jsonStr = text.substring(text.indexOf("{"), text.lastIndexOf("}") + 1);
|
||||
Map<String, Object> map = JsonUtils.fromJsonMap(jsonStr);
|
||||
|
||||
String code = (String) map.get("primaryIntentCode");
|
||||
double confidence = Double.parseDouble(String.valueOf(map.getOrDefault("confidence", 0.8)));
|
||||
String sentiment = (String) map.getOrDefault("sentiment", "NEUTRAL");
|
||||
|
||||
IntentCategory matched = categories.stream()
|
||||
.filter(c -> c.getCode().equals(code))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
IntentResult.IntentInfo intentInfo = new IntentResult.IntentInfo();
|
||||
if (matched != null) {
|
||||
intentInfo.setCode(matched.getCode());
|
||||
intentInfo.setName(matched.getName());
|
||||
intentInfo.setDomain(matched.getDomain());
|
||||
} else {
|
||||
intentInfo.setCode(code);
|
||||
intentInfo.setName("未知意图");
|
||||
intentInfo.setDomain("其他");
|
||||
}
|
||||
intentInfo.setConfidence(confidence);
|
||||
|
||||
result.setPrimaryIntent(intentInfo);
|
||||
result.setConfidence(confidence);
|
||||
result.setSentiment(sentiment);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("解析LLM意图结果失败,使用默认值: {}", e.getMessage());
|
||||
return createDefaultResult();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private IntentResult createDefaultResult() {
|
||||
IntentResult result = new IntentResult();
|
||||
IntentResult.IntentInfo intentInfo = new IntentResult.IntentInfo();
|
||||
intentInfo.setCode("INT-COURSE-01");
|
||||
intentInfo.setName("课程内容咨询");
|
||||
intentInfo.setDomain("课程相关");
|
||||
intentInfo.setConfidence(0.5);
|
||||
result.setPrimaryIntent(intentInfo);
|
||||
result.setConfidence(0.5);
|
||||
result.setSentiment("NEUTRAL");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user