diff --git a/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java b/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java new file mode 100644 index 0000000..33fe8b9 --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java @@ -0,0 +1,54 @@ +package com.artedu.generation.controller; + +import com.artedu.common.result.Result; +import com.artedu.generation.service.PromptEngine; +import com.artedu.generation.service.QianwenClient; +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/generation") +public class GenerationController { + + @Autowired + private QianwenClient qianwenClient; + + @Autowired + private PromptEngine promptEngine; + + @PostMapping("/utterance") + public Result> generateUtterance( + @RequestParam("message") String message, + @RequestParam(value = "context", required = false) String context, + @RequestParam("intent") String intent, + @RequestParam("stage") String stage) { + + String prompt = promptEngine.buildUtterancePrompt(message, context, intent, stage); + String generated = qianwenClient.generate(prompt, 500); + + Map result = new HashMap<>(); + result.put("content", generated); + result.put("source", "LLM"); + result.put("model", "qwen-turbo"); + + return Result.success(result); + } + + @PostMapping("/summary") + public Result> generateSummary( + @RequestParam("context") String context) { + + String prompt = promptEngine.buildSummaryPrompt(context); + String generated = qianwenClient.generate(prompt, 100); + + Map result = new HashMap<>(); + result.put("summary", generated); + + return Result.success(result); + } +} diff --git a/backend/generation-service/src/main/java/com/artedu/generation/service/PromptEngine.java b/backend/generation-service/src/main/java/com/artedu/generation/service/PromptEngine.java new file mode 100644 index 0000000..13d3065 --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/service/PromptEngine.java @@ -0,0 +1,28 @@ +package com.artedu.generation.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class PromptEngine { + + public String buildUtterancePrompt(String message, String context, String intent, String stage) { + StringBuilder sb = new StringBuilder(); + sb.append("你是第九联盟(CG美术培训机构)的课程顾问助手。请根据学员的咨询消息,生成一段专业、友好、有说服力的回复话术。\n\n"); + sb.append("【机构背景】\n"); + sb.append("- 第九联盟专注CG数字艺术教育10年\n"); + sb.append("- 累计培养8000+学员,与腾讯、网易、米哈游等500+企业有就业合作\n"); + sb.append("- 6大校区:上海、西安、厦门、武汉、青岛、合肥\n\n"); + sb.append("【当前对话阶段】").append(stage).append("\n"); + sb.append("【学员意图】").append(intent).append("\n"); + sb.append("【对话上下文】\n").append(context != null ? context : "无").append("\n\n"); + sb.append("【学员最新消息】\n").append(message).append("\n\n"); + sb.append("请直接输出回复话术,不要包含任何解释或前缀。话术要自然、专业、符合顾问身份。"); + return sb.toString(); + } + + public String buildSummaryPrompt(String context) { + return "请对以下对话进行简要总结(50字以内),提炼学员核心需求和当前阶段:\n\n" + context; + } +} diff --git a/backend/generation-service/src/main/java/com/artedu/generation/service/QianwenClient.java b/backend/generation-service/src/main/java/com/artedu/generation/service/QianwenClient.java new file mode 100644 index 0000000..f273dbb --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/service/QianwenClient.java @@ -0,0 +1,87 @@ +package com.artedu.generation.service; + +import com.artedu.common.util.JsonUtils; +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.HashMap; +import java.util.Map; + +@Slf4j +@Service +public class QianwenClient { + + @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 String generate(String prompt, Integer maxTokens) { + long startTime = System.currentTimeMillis(); + + try { + 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"); + if (maxTokens != null) { + parameters.put("max_tokens", maxTokens); + } + 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); + log.info("LLM生成完成, elapsed={}ms, tokens={}", + System.currentTimeMillis() - startTime, + extractTokens(result)); + return text; + } + + log.error("LLM生成失败: status={}, body={}", response.getStatusCode(), response.getBody()); + return null; + + } catch (Exception e) { + log.error("LLM生成异常: {}", e.getMessage(), e); + return null; + } + } + + 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 Integer extractTokens(Map result) { + if (result == null) return 0; + Map usage = (Map) result.get("usage"); + if (usage != null) { + Object total = usage.get("total_tokens"); + return total != null ? ((Number) total).intValue() : 0; + } + return 0; + } +} diff --git a/backend/recommendation-service/src/main/java/com/artedu/recommend/controller/RecommendController.java b/backend/recommendation-service/src/main/java/com/artedu/recommend/controller/RecommendController.java new file mode 100644 index 0000000..fcfb7b2 --- /dev/null +++ b/backend/recommendation-service/src/main/java/com/artedu/recommend/controller/RecommendController.java @@ -0,0 +1,119 @@ +package com.artedu.recommend.controller; + +import com.artedu.common.result.Result; +import com.artedu.common.util.JsonUtils; +import com.artedu.recommend.entity.Recommendation; +import com.artedu.recommend.entity.Utterance; +import com.artedu.recommend.mapper.RecommendationMapper; +import com.artedu.recommend.service.RankingService; +import com.artedu.recommend.service.RecallService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@RestController +@RequestMapping("/api/v1/recommend") +public class RecommendController { + + @Autowired + private RecallService recallService; + + @Autowired + private RankingService rankingService; + + @Autowired + private RecommendationMapper recommendationMapper; + + @PostMapping("/get") + public Result>> recommend( + @RequestParam("sessionId") String sessionId, + @RequestParam("customerId") String customerId, + @RequestParam("staffId") String staffId, + @RequestParam("corpId") String corpId, + @RequestParam("intentCode") String intentCode, + @RequestParam("stage") String stage, + @RequestParam(value = "studentType", defaultValue = "通用") String studentType, + @RequestParam(value = "message", required = false) String message) { + + long startTime = System.currentTimeMillis(); + + // 三层召回 + List ruleResults = recallService.ruleRecall(intentCode, stage, studentType, corpId); + List vectorResults = message != null ? recallService.vectorRecall(message, corpId) : List.of(); + List hotResults = recallService.hotRecall(corpId); + + // 合并去重 + Map candidateMap = new LinkedHashMap<>(); + for (Utterance u : ruleResults) { + candidateMap.put(u.getUtteranceId(), u); + } + for (Utterance u : vectorResults) { + candidateMap.putIfAbsent(u.getUtteranceId(), u); + } + for (Utterance u : hotResults) { + candidateMap.putIfAbsent(u.getUtteranceId(), u); + } + + List candidates = new ArrayList<>(candidateMap.values()); + + // 排序 + List ranked = rankingService.rank(candidates); + + // 记录推荐日志 + Recommendation rec = new Recommendation(); + rec.setRecommendationId(UUID.randomUUID().toString()); + rec.setSessionId(sessionId); + rec.setCustomerId(customerId); + rec.setStaffId(staffId); + rec.setCorpId(corpId); + rec.setPrimaryIntent(intentCode); + rec.setCurrentStage(stage); + rec.setStudentMessage(message); + rec.setRecommendedCount(ranked.size()); + rec.setGenerationMode(candidates.size() >= 3 ? "KB_ONLY" : "KB_LLM_MIX"); + rec.setElapsedMs((int) (System.currentTimeMillis() - startTime)); + rec.setCreatedAt(java.time.LocalDateTime.now()); + recommendationMapper.insert(rec); + + // 构建响应 + List> result = ranked.stream().map(u -> { + Map map = new HashMap<>(); + map.put("utteranceId", u.getUtteranceId()); + map.put("title", u.getTitle()); + map.put("content", u.getContent()); + map.put("successRate", u.getSuccessRate()); + map.put("usedCount", u.getUsedCount()); + map.put("priority", u.getPriority()); + return map; + }).collect(Collectors.toList()); + + return Result.success(result); + } + + @PostMapping("/feedback") + public Result feedback( + @RequestParam("recommendationId") String recommendationId, + @RequestParam("action") String action, + @RequestParam(value = "feedback", required = false) String feedback, + @RequestParam(value = "reason", required = false) String reason) { + + Recommendation rec = recommendationMapper.selectOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(Recommendation::getRecommendationId, recommendationId) + ); + + if (rec != null) { + rec.setStaffAction(action); + rec.setFeedback(feedback); + rec.setFeedbackReason(reason); + rec.setActionTime(java.time.LocalDateTime.now()); + recommendationMapper.updateById(rec); + } + + return Result.success("反馈已记录"); + } +} diff --git a/backend/recommendation-service/src/main/java/com/artedu/recommend/entity/Recommendation.java b/backend/recommendation-service/src/main/java/com/artedu/recommend/entity/Recommendation.java new file mode 100644 index 0000000..b878404 --- /dev/null +++ b/backend/recommendation-service/src/main/java/com/artedu/recommend/entity/Recommendation.java @@ -0,0 +1,37 @@ +package com.artedu.recommend.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("recommendations") +public class Recommendation { + + @TableId(type = IdType.AUTO) + private Long id; + + private String recommendationId; + private String sessionId; + private String turnId; + private String customerId; + private String staffId; + private String corpId; + private String primaryIntent; + private String secondaryIntents; + private String studentType; + private String currentStage; + private String studentMessage; + private Integer recommendedCount; + private String recallSources; + private String generationMode; + private Integer elapsedMs; + private String staffAction; + private String feedback; + private String feedbackReason; + private LocalDateTime createdAt; + private LocalDateTime actionTime; +} diff --git a/backend/recommendation-service/src/main/java/com/artedu/recommend/entity/Utterance.java b/backend/recommendation-service/src/main/java/com/artedu/recommend/entity/Utterance.java new file mode 100644 index 0000000..bebd1f0 --- /dev/null +++ b/backend/recommendation-service/src/main/java/com/artedu/recommend/entity/Utterance.java @@ -0,0 +1,47 @@ +package com.artedu.recommend.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("utterances") +public class Utterance { + + @TableId(type = IdType.AUTO) + private Long id; + + private String utteranceId; + private String corpId; + private String title; + private String content; + private String contentText; + private Integer version; + private String status; + private Integer priority; + private String stageTags; + private String intentTags; + private String profileTags; + private String courseTypeTags; + private String emotionTags; + private String topicTags; + private Integer usedCount; + private Integer selectedCount; + private Integer positiveFeedback; + private Integer negativeFeedback; + private Integer conversionCount; + private Double successRate; + private Integer avgResponseTimeMs; + private Integer hasVector; + private LocalDateTime vectorUpdatedAt; + private String triggerKeywords; + private String variables; + private String createdBy; + private String reviewedBy; + private String source; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/recommendation-service/src/main/java/com/artedu/recommend/mapper/RecommendationMapper.java b/backend/recommendation-service/src/main/java/com/artedu/recommend/mapper/RecommendationMapper.java new file mode 100644 index 0000000..dfc8c38 --- /dev/null +++ b/backend/recommendation-service/src/main/java/com/artedu/recommend/mapper/RecommendationMapper.java @@ -0,0 +1,9 @@ +package com.artedu.recommend.mapper; + +import com.artedu.recommend.entity.Recommendation; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface RecommendationMapper extends BaseMapper { +} diff --git a/backend/recommendation-service/src/main/java/com/artedu/recommend/mapper/UtteranceMapper.java b/backend/recommendation-service/src/main/java/com/artedu/recommend/mapper/UtteranceMapper.java new file mode 100644 index 0000000..937b3d5 --- /dev/null +++ b/backend/recommendation-service/src/main/java/com/artedu/recommend/mapper/UtteranceMapper.java @@ -0,0 +1,9 @@ +package com.artedu.recommend.mapper; + +import com.artedu.recommend.entity.Utterance; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UtteranceMapper extends BaseMapper { +} diff --git a/backend/recommendation-service/src/main/java/com/artedu/recommend/service/RankingService.java b/backend/recommendation-service/src/main/java/com/artedu/recommend/service/RankingService.java new file mode 100644 index 0000000..6454017 --- /dev/null +++ b/backend/recommendation-service/src/main/java/com/artedu/recommend/service/RankingService.java @@ -0,0 +1,137 @@ +package com.artedu.recommend.service; + +import com.artedu.recommend.entity.Utterance; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class RankingService { + + private static final double WEIGHT_SUCCESS_RATE = 0.35; + private static final double WEIGHT_USED_COUNT = 0.20; + private static final double WEIGHT_PRIORITY = 0.25; + private static final double WEIGHT_FRESHNESS = 0.20; + private static final double MMR_LAMBDA = 0.5; + + public List rank(List candidates) { + if (candidates == null || candidates.isEmpty()) { + return List.of(); + } + + // 计算基础分数 + List scoredList = candidates.stream() + .map(this::scoreUtterance) + .sorted(Comparator.comparingDouble(ScoredUtterance::getScore).reversed()) + .collect(Collectors.toList()); + + // MMR多样性重排 + return mmrReRank(scoredList); + } + + private ScoredUtterance scoreUtterance(Utterance u) { + double successRate = u.getSuccessRate() != null ? u.getSuccessRate() : 0.0; + double usedCount = u.getUsedCount() != null ? Math.log1p(u.getUsedCount()) / Math.log(1000) : 0.0; + double priority = u.getPriority() != null ? (11 - u.getPriority()) / 10.0 : 0.5; + double freshness = calculateFreshness(u); + + double totalScore = successRate * WEIGHT_SUCCESS_RATE + + usedCount * WEIGHT_USED_COUNT + + priority * WEIGHT_PRIORITY + + freshness * WEIGHT_FRESHNESS; + + return new ScoredUtterance(u, totalScore); + } + + private double calculateFreshness(Utterance u) { + if (u.getUpdatedAt() == null) { + return 0.5; + } + long daysAgo = java.time.Duration.between(u.getUpdatedAt(), java.time.LocalDateTime.now()).toDays(); + return Math.max(0, 1.0 - daysAgo / 30.0); + } + + private List mmrReRank(List scoredList) { + List result = new ArrayList<>(); + Set selectedIds = new HashSet<>(); + + while (result.size() < 5 && !scoredList.isEmpty()) { + ScoredUtterance best = null; + double bestMmrScore = -1; + + for (ScoredUtterance candidate : scoredList) { + if (selectedIds.contains(candidate.getUtterance().getUtteranceId())) { + continue; + } + + double maxSim = 0; + for (Utterance selected : result) { + double sim = similarity(candidate.getUtterance(), selected); + if (sim > maxSim) { + maxSim = sim; + } + } + + double mmrScore = MMR_LAMBDA * candidate.getScore() + - (1 - MMR_LAMBDA) * maxSim; + + if (mmrScore > bestMmrScore) { + bestMmrScore = mmrScore; + best = candidate; + } + } + + if (best == null) { + break; + } + + result.add(best.getUtterance()); + selectedIds.add(best.getUtterance().getUtteranceId()); + } + + return result; + } + + private double similarity(Utterance u1, Utterance u2) { + // 简化实现:基于标签重叠度计算相似度 + Set tags1 = new HashSet<>(); + Set tags2 = new HashSet<>(); + + if (u1.getIntentTags() != null) { + tags1.addAll(JsonUtils.fromJsonList(u1.getIntentTags(), String.class)); + } + if (u2.getIntentTags() != null) { + tags2.addAll(JsonUtils.fromJsonList(u2.getIntentTags(), String.class)); + } + + if (tags1.isEmpty() || tags2.isEmpty()) { + return 0.0; + } + + Set intersection = new HashSet<>(tags1); + intersection.retainAll(tags2); + + return (double) intersection.size() / Math.max(tags1.size(), tags2.size()); + } + + private static class ScoredUtterance { + private final Utterance utterance; + private final double score; + + ScoredUtterance(Utterance utterance, double score) { + this.utterance = utterance; + this.score = score; + } + + Utterance getUtterance() { + return utterance; + } + + double getScore() { + return score; + } + } +} diff --git a/backend/recommendation-service/src/main/java/com/artedu/recommend/service/RecallService.java b/backend/recommendation-service/src/main/java/com/artedu/recommend/service/RecallService.java new file mode 100644 index 0000000..8eca7e7 --- /dev/null +++ b/backend/recommendation-service/src/main/java/com/artedu/recommend/service/RecallService.java @@ -0,0 +1,120 @@ +package com.artedu.recommend.service; + +import com.artedu.common.util.JsonUtils; +import com.artedu.recommend.entity.Utterance; +import com.artedu.recommend.mapper.UtteranceMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class RecallService { + + @Autowired + private UtteranceMapper utteranceMapper; + + @Autowired + private RedisTemplate redisTemplate; + + private static final String VECTOR_KEY_PREFIX = "utterance:vector:"; + private static final String HOT_KEY = "utterance:hot"; + + public List ruleRecall(String intentCode, String stage, String studentType, String corpId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(Utterance::getCorpId, corpId) + .eq(Utterance::getStatus, "ACTIVE"); + + List all = utteranceMapper.selectList(wrapper); + + return all.stream().filter(u -> { + List intentTags = JsonUtils.fromJsonList(u.getIntentTags(), String.class); + List stageTags = JsonUtils.fromJsonList(u.getStageTags(), String.class); + List profileTags = JsonUtils.fromJsonList(u.getProfileTags(), String.class); + + boolean intentMatch = intentTags != null && intentTags.contains(intentCode); + boolean stageMatch = stageTags != null && stageTags.contains(stage); + boolean profileMatch = profileTags == null || profileTags.isEmpty() || + profileTags.contains("通用") || profileTags.contains(studentType); + + return intentMatch && stageMatch && profileMatch; + }).sorted(Comparator.comparingInt(Utterance::getPriority)) + .limit(20) + .collect(Collectors.toList()); + } + + public List vectorRecall(String messageEmbedding, String corpId) { + // 简化实现:通过Redis存储的向量进行余弦相似度计算 + // 实际项目中应使用向量数据库或更高效的相似度计算 + Set keys = redisTemplate.keys(VECTOR_KEY_PREFIX + "*"); + if (keys == null || keys.isEmpty()) { + return List.of(); + } + + Map similarityMap = new HashMap<>(); + for (String key : keys) { + String vectorStr = (String) redisTemplate.opsForValue().get(key); + if (vectorStr != null) { + double sim = cosineSimilarity(messageEmbedding, vectorStr); + if (sim > 0.7) { + similarityMap.put(key.replace(VECTOR_KEY_PREFIX, ""), sim); + } + } + } + + List utteranceIds = similarityMap.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(10) + .map(Map.Entry::getKey) + .collect(Collectors.toList()); + + if (utteranceIds.isEmpty()) { + return List.of(); + } + + return utteranceMapper.selectList( + new LambdaQueryWrapper() + .eq(Utterance::getCorpId, corpId) + .in(Utterance::getUtteranceId, utteranceIds) + .eq(Utterance::getStatus, "ACTIVE") + ); + } + + public List hotRecall(String corpId) { + Set> hotSet = redisTemplate.opsForZSet() + .reverseRangeWithScores(HOT_KEY + ":" + corpId, 0, 9); + + if (hotSet == null || hotSet.isEmpty()) { + return utteranceMapper.selectList( + new LambdaQueryWrapper() + .eq(Utterance::getCorpId, corpId) + .eq(Utterance::getStatus, "ACTIVE") + .orderByDesc(Utterance::getSuccessRate) + .last("LIMIT 10") + ); + } + + List utteranceIds = hotSet.stream() + .map(t -> (String) t.getValue()) + .collect(Collectors.toList()); + + return utteranceMapper.selectList( + new LambdaQueryWrapper() + .eq(Utterance::getCorpId, corpId) + .in(Utterance::getUtteranceId, utteranceIds) + .eq(Utterance::getStatus, "ACTIVE") + ); + } + + private double cosineSimilarity(String v1, String v2) { + // 简化实现:实际应解析向量并计算余弦相似度 + // 这里使用随机值作为占位 + return 0.5 + Math.random() * 0.5; + } +}