feat(04): Phase 4 - 推荐服务 + LLM生成服务

- recommendation-service: 三层召回(规则/向量/热门)、多因子排序、MMR多样性重排、推荐记录
- generation-service: 通义千问API封装、Prompt引擎、话术动态生成、对话摘要生成
- 提供推荐获取API、反馈记录API、话术生成API、摘要生成API
This commit is contained in:
jiao 2026-05-12 18:07:08 +08:00
parent 8941f6f613
commit 4583635800
10 changed files with 647 additions and 0 deletions

View File

@ -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<Map<String, Object>> 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<String, Object> result = new HashMap<>();
result.put("content", generated);
result.put("source", "LLM");
result.put("model", "qwen-turbo");
return Result.success(result);
}
@PostMapping("/summary")
public Result<Map<String, Object>> generateSummary(
@RequestParam("context") String context) {
String prompt = promptEngine.buildSummaryPrompt(context);
String generated = qianwenClient.generate(prompt, 100);
Map<String, Object> result = new HashMap<>();
result.put("summary", generated);
return Result.success(result);
}
}

View File

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

View File

@ -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<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");
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<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);
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<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 Integer extractTokens(Map<String, Object> result) {
if (result == null) return 0;
Map<String, Object> usage = (Map<String, Object>) result.get("usage");
if (usage != null) {
Object total = usage.get("total_tokens");
return total != null ? ((Number) total).intValue() : 0;
}
return 0;
}
}

View File

@ -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<List<Map<String, Object>>> 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<Utterance> ruleResults = recallService.ruleRecall(intentCode, stage, studentType, corpId);
List<Utterance> vectorResults = message != null ? recallService.vectorRecall(message, corpId) : List.of();
List<Utterance> hotResults = recallService.hotRecall(corpId);
// 合并去重
Map<String, Utterance> 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<Utterance> candidates = new ArrayList<>(candidateMap.values());
// 排序
List<Utterance> 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<Map<String, Object>> result = ranked.stream().map(u -> {
Map<String, Object> 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<String> 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<Recommendation>()
.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("反馈已记录");
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Utterance> rank(List<Utterance> candidates) {
if (candidates == null || candidates.isEmpty()) {
return List.of();
}
// 计算基础分数
List<ScoredUtterance> 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<Utterance> mmrReRank(List<ScoredUtterance> scoredList) {
List<Utterance> result = new ArrayList<>();
Set<String> 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<String> tags1 = new HashSet<>();
Set<String> 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<String> 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;
}
}
}

View File

@ -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<String, Object> redisTemplate;
private static final String VECTOR_KEY_PREFIX = "utterance:vector:";
private static final String HOT_KEY = "utterance:hot";
public List<Utterance> ruleRecall(String intentCode, String stage, String studentType, String corpId) {
LambdaQueryWrapper<Utterance> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Utterance::getCorpId, corpId)
.eq(Utterance::getStatus, "ACTIVE");
List<Utterance> all = utteranceMapper.selectList(wrapper);
return all.stream().filter(u -> {
List<String> intentTags = JsonUtils.fromJsonList(u.getIntentTags(), String.class);
List<String> stageTags = JsonUtils.fromJsonList(u.getStageTags(), String.class);
List<String> 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<Utterance> vectorRecall(String messageEmbedding, String corpId) {
// 简化实现:通过Redis存储的向量进行余弦相似度计算
// 实际项目中应使用向量数据库或更高效的相似度计算
Set<String> keys = redisTemplate.keys(VECTOR_KEY_PREFIX + "*");
if (keys == null || keys.isEmpty()) {
return List.of();
}
Map<String, Double> 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<String> utteranceIds = similarityMap.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(10)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
if (utteranceIds.isEmpty()) {
return List.of();
}
return utteranceMapper.selectList(
new LambdaQueryWrapper<Utterance>()
.eq(Utterance::getCorpId, corpId)
.in(Utterance::getUtteranceId, utteranceIds)
.eq(Utterance::getStatus, "ACTIVE")
);
}
public List<Utterance> hotRecall(String corpId) {
Set<ZSetOperations.TypedTuple<Object>> hotSet = redisTemplate.opsForZSet()
.reverseRangeWithScores(HOT_KEY + ":" + corpId, 0, 9);
if (hotSet == null || hotSet.isEmpty()) {
return utteranceMapper.selectList(
new LambdaQueryWrapper<Utterance>()
.eq(Utterance::getCorpId, corpId)
.eq(Utterance::getStatus, "ACTIVE")
.orderByDesc(Utterance::getSuccessRate)
.last("LIMIT 10")
);
}
List<String> utteranceIds = hotSet.stream()
.map(t -> (String) t.getValue())
.collect(Collectors.toList());
return utteranceMapper.selectList(
new LambdaQueryWrapper<Utterance>()
.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;
}
}