feat(03): Phase 3 - 对话服务 + 意图服务

- conversation-service: 会话管理、Redis上下文窗口(10轮滑动窗口)、轮次解析、消息事件监听
- intent-service: 40意图分类、规则匹配+LLM混合识别、学员画像构建(5维度)、槽位填充
- LLMIntentClient: 通义千问API封装、Prompt构建、结果解析
- 提供意图识别API、学员画像查询API
This commit is contained in:
jiao 2026-05-12 18:04:21 +08:00
parent 48c1dd8452
commit 8941f6f613
16 changed files with 1081 additions and 0 deletions

View File

@ -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("状态更新成功");
}
}

View File

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

View File

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

View File

@ -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> {
}

View File

@ -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> {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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> {
}

View File

@ -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> {
}

View File

@ -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> {
}

View File

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

View File

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