diff --git a/backend/conversation-service/src/main/java/com/artedu/conversation/controller/ConversationController.java b/backend/conversation-service/src/main/java/com/artedu/conversation/controller/ConversationController.java new file mode 100644 index 0000000..2c3aa9e --- /dev/null +++ b/backend/conversation-service/src/main/java/com/artedu/conversation/controller/ConversationController.java @@ -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> getContext(@PathVariable("sessionId") String sessionId) { + String contextStr = conversationManager.getContextString(sessionId); + var messages = conversationManager.getContextMessages(sessionId); + + Map 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 getConversation(@PathVariable("sessionId") String sessionId) { + return Result.success(); + } + + @PutMapping("/{sessionId}/status") + public Result 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("状态更新成功"); + } +} diff --git a/backend/conversation-service/src/main/java/com/artedu/conversation/entity/Conversation.java b/backend/conversation-service/src/main/java/com/artedu/conversation/entity/Conversation.java new file mode 100644 index 0000000..5cb4324 --- /dev/null +++ b/backend/conversation-service/src/main/java/com/artedu/conversation/entity/Conversation.java @@ -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; +} diff --git a/backend/conversation-service/src/main/java/com/artedu/conversation/entity/ConversationTurn.java b/backend/conversation-service/src/main/java/com/artedu/conversation/entity/ConversationTurn.java new file mode 100644 index 0000000..63c6a51 --- /dev/null +++ b/backend/conversation-service/src/main/java/com/artedu/conversation/entity/ConversationTurn.java @@ -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; +} diff --git a/backend/conversation-service/src/main/java/com/artedu/conversation/mapper/ConversationMapper.java b/backend/conversation-service/src/main/java/com/artedu/conversation/mapper/ConversationMapper.java new file mode 100644 index 0000000..3ebd9c7 --- /dev/null +++ b/backend/conversation-service/src/main/java/com/artedu/conversation/mapper/ConversationMapper.java @@ -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 { +} diff --git a/backend/conversation-service/src/main/java/com/artedu/conversation/mapper/ConversationTurnMapper.java b/backend/conversation-service/src/main/java/com/artedu/conversation/mapper/ConversationTurnMapper.java new file mode 100644 index 0000000..5d7d339 --- /dev/null +++ b/backend/conversation-service/src/main/java/com/artedu/conversation/mapper/ConversationTurnMapper.java @@ -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 { +} diff --git a/backend/conversation-service/src/main/java/com/artedu/conversation/service/ConversationManager.java b/backend/conversation-service/src/main/java/com/artedu/conversation/service/ConversationManager.java new file mode 100644 index 0000000..5cff9b5 --- /dev/null +++ b/backend/conversation-service/src/main/java/com/artedu/conversation/service/ConversationManager.java @@ -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 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() + .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() + .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 getContextMessages(String sessionId) { + String key = CONV_CONTEXT_KEY + sessionId; + List 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 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() + .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() + .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() + .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; } + } +} diff --git a/backend/intent-service/src/main/java/com/artedu/intent/controller/IntentController.java b/backend/intent-service/src/main/java/com/artedu/intent/controller/IntentController.java new file mode 100644 index 0000000..2087063 --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/controller/IntentController.java @@ -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 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> listCategories() { + List list = intentCategoryMapper.selectList( + new LambdaQueryWrapper() + .eq(IntentCategory::getStatus, 1) + ); + return Result.success(list); + } + + @GetMapping("/profile/{customerId}") + public Result getProfile( + @PathVariable("customerId") String customerId, + @RequestParam("corpId") String corpId) { + + CustomerProfile profile = customerProfileMapper.selectOne( + new LambdaQueryWrapper() + .eq(CustomerProfile::getCustomerId, customerId) + .eq(CustomerProfile::getCorpId, corpId) + ); + return Result.success(profile); + } +} diff --git a/backend/intent-service/src/main/java/com/artedu/intent/dto/IntentResult.java b/backend/intent-service/src/main/java/com/artedu/intent/dto/IntentResult.java new file mode 100644 index 0000000..5f26105 --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/dto/IntentResult.java @@ -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 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; + } +} diff --git a/backend/intent-service/src/main/java/com/artedu/intent/entity/CustomerProfile.java b/backend/intent-service/src/main/java/com/artedu/intent/entity/CustomerProfile.java new file mode 100644 index 0000000..b4f25af --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/entity/CustomerProfile.java @@ -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; +} diff --git a/backend/intent-service/src/main/java/com/artedu/intent/entity/IntentCategory.java b/backend/intent-service/src/main/java/com/artedu/intent/entity/IntentCategory.java new file mode 100644 index 0000000..84fa7bd --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/entity/IntentCategory.java @@ -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; +} diff --git a/backend/intent-service/src/main/java/com/artedu/intent/entity/IntentRecognition.java b/backend/intent-service/src/main/java/com/artedu/intent/entity/IntentRecognition.java new file mode 100644 index 0000000..e1acfb2 --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/entity/IntentRecognition.java @@ -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; +} diff --git a/backend/intent-service/src/main/java/com/artedu/intent/mapper/CustomerProfileMapper.java b/backend/intent-service/src/main/java/com/artedu/intent/mapper/CustomerProfileMapper.java new file mode 100644 index 0000000..57a901f --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/mapper/CustomerProfileMapper.java @@ -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 { +} diff --git a/backend/intent-service/src/main/java/com/artedu/intent/mapper/IntentCategoryMapper.java b/backend/intent-service/src/main/java/com/artedu/intent/mapper/IntentCategoryMapper.java new file mode 100644 index 0000000..e7032e1 --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/mapper/IntentCategoryMapper.java @@ -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 { +} diff --git a/backend/intent-service/src/main/java/com/artedu/intent/mapper/IntentRecognitionMapper.java b/backend/intent-service/src/main/java/com/artedu/intent/mapper/IntentRecognitionMapper.java new file mode 100644 index 0000000..4dc5832 --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/mapper/IntentRecognitionMapper.java @@ -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 { +} diff --git a/backend/intent-service/src/main/java/com/artedu/intent/service/IntentRecognitionService.java b/backend/intent-service/src/main/java/com/artedu/intent/service/IntentRecognitionService.java new file mode 100644 index 0000000..f076e8b --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/service/IntentRecognitionService.java @@ -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 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 categories = intentCategoryMapper.selectList( + new LambdaQueryWrapper() + .eq(IntentCategory::getStatus, 1) + ); + + IntentCategory bestMatch = null; + double bestScore = 0; + + for (IntentCategory category : categories) { + List 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() + .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 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 getAllCategories() { + return intentCategoryMapper.selectList( + new LambdaQueryWrapper() + .eq(IntentCategory::getStatus, 1) + ); + } + + @RabbitListener(queues = "conversation.intent_analyze.queue") + public void handleIntentAnalyze(String messageJson) { + try { + Map 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); + } + } +} diff --git a/backend/intent-service/src/main/java/com/artedu/intent/service/LLMIntentClient.java b/backend/intent-service/src/main/java/com/artedu/intent/service/LLMIntentClient.java new file mode 100644 index 0000000..5941e0a --- /dev/null +++ b/backend/intent-service/src/main/java/com/artedu/intent/service/LLMIntentClient.java @@ -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 categories) { + long startTime = System.currentTimeMillis(); + + try { + String prompt = buildPrompt(message, contextSummary, categories); + + Map requestBody = new HashMap<>(); + requestBody.put("model", model); + + Map input = new HashMap<>(); + input.put("prompt", prompt); + requestBody.put("input", input); + + Map 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> request = new HttpEntity<>(requestBody, headers); + ResponseEntity response = restTemplate.postForEntity(API_URL, request, String.class); + + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { + Map 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 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 result) { + if (result == null) return ""; + Map output = (Map) result.get("output"); + if (output != null) { + return (String) output.get("text"); + } + return ""; + } + + private IntentResult parseIntentResult(String text, List categories) { + IntentResult result = new IntentResult(); + try { + String jsonStr = text.substring(text.indexOf("{"), text.lastIndexOf("}") + 1); + Map 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; + } +}