chore: add .gitignore for all backend modules and update generation service

1. 为所有后端子模块添加.gitignore文件,忽略target编译目录
2. 重构LLM客户端接口,新增支持关闭推理过程的生成方法
3. 为各模型客户端实现新的重载generate方法
4. 重构PromptEngine,替换原有话术生成逻辑为对话历史分析模式
5. 新增对话历史存档实体类和Mapper,实现单聊/群聊历史查询接口
6. 简化GenerationController,移除SSE流式生成相关代码
This commit is contained in:
jiao 2026-06-10 17:50:34 +08:00
parent efed845b6f
commit 5e33db1ad3
28 changed files with 208 additions and 226 deletions

2
backend/analytics-service/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
target/
target

2
backend/archive-service/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
target/
target

2
backend/auth-service/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
target/
target

2
backend/common/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
target/
target

View File

@ -0,0 +1,2 @@
target/
target

2
backend/gateway/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
target/
target

2
backend/generation-service/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
target/
target

View File

@ -32,6 +32,11 @@ public class BailianClient implements LLMClient {
@Override
public String generate(String system, String prompt, Integer maxTokens) {
return generate(system, prompt, maxTokens, true);
}
@Override
public String generate(String system, String prompt, Integer maxTokens, boolean thinkingDisabled) {
if (model == null || model.getApiKey() == null || model.getApiKey().isEmpty()) {
log.error("百炼模型配置不完整");
return null;
@ -65,6 +70,13 @@ public class BailianClient implements LLMClient {
if (maxTokens != null) {
parameters.put("max_tokens", maxTokens);
}
if (thinkingDisabled) {
Map<String, Object> thinking = new HashMap<>();
thinking.put("type", "disabled");
parameters.put("thinking", thinking);
}
requestBody.put("parameters", parameters);
try {

View File

@ -33,6 +33,11 @@ public class DoubaoClient implements LLMClient {
@Override
public String generate(String system, String prompt, Integer maxTokens) {
return generate(system, prompt, maxTokens, true);
}
@Override
public String generate(String system, String prompt, Integer maxTokens, boolean thinkingDisabled) {
if (model == null || model.getApiKey() == null || model.getApiKey().isEmpty()) {
log.error("豆包模型配置不完整");
return null;
@ -59,12 +64,18 @@ public class DoubaoClient implements LLMClient {
if (maxTokens != null) {
requestBody.put("max_tokens", maxTokens);
}
// 从 configJson 读取 temperature,默认 0.7
Double temperature = parseTemperature();
if (temperature != null) {
requestBody.put("temperature", temperature);
}
if (thinkingDisabled) {
Map<String, Object> thinking = new HashMap<>();
thinking.put("type", "disabled");
requestBody.put("thinking", thinking);
}
try {
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
Map<String, Object> response = restTemplate.postForObject(url, entity, Map.class);

View File

@ -32,6 +32,11 @@ public class HunyuanClient implements LLMClient {
@Override
public String generate(String system, String prompt, Integer maxTokens) {
return generate(system, prompt, maxTokens, true);
}
@Override
public String generate(String system, String prompt, Integer maxTokens, boolean thinkingDisabled) {
if (model == null || model.getApiKey() == null || model.getApiKey().isEmpty()) {
log.error("混元模型配置不完整");
return null;
@ -59,6 +64,12 @@ public class HunyuanClient implements LLMClient {
requestBody.put("MaxTokens", maxTokens);
}
if (thinkingDisabled) {
Map<String, Object> thinking = new HashMap<>();
thinking.put("type", "disabled");
requestBody.put("thinking", thinking);
}
try {
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
Map<String, Object> response = restTemplate.postForObject(url, entity, Map.class);

View File

@ -26,6 +26,17 @@ public interface LLMClient {
*/
String generate(String system, String prompt, Integer maxTokens);
/**
* 文本生成(支持system消息和关闭推理过程)
*
* @param system 系统提示词
* @param prompt 用户提示词
* @param maxTokens 最大token数
* @param thinkingDisabled 是否关闭推理过程
* @return 生成的文本
*/
String generate(String system, String prompt, Integer maxTokens, boolean thinkingDisabled);
/**
* 获取当前使用的模型配置
*/

View File

@ -2,24 +2,20 @@ package com.artedu.generation.controller;
import com.artedu.common.result.Result;
import com.artedu.generation.client.LLMClient;
import com.artedu.generation.entity.ArchiveMessage;
import com.artedu.generation.mapper.ArchiveMessageMapper;
import com.artedu.generation.service.LlmModelService;
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.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.validation.constraints.NotBlank;
import java.io.IOException;
import java.util.ArrayList;
import javax.validation.constraints.NotBlank;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Slf4j
@Validated
@ -36,31 +32,21 @@ public class GenerationController {
@Autowired
private LlmModelService llmModelService;
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
private static final List<Map<String, String>> STYLES = List.of(
Map.of("id", "friendly", "name", "亲和风格", "desc", "温暖亲切,像朋友聊天"),
Map.of("id", "professional", "name", "专业风格", "desc", "数据驱动,专业权威"),
Map.of("id", "urgent", "name", "促单风格", "desc", "制造紧迫感,促成决策")
);
@Autowired
private ArchiveMessageMapper archiveMessageMapper;
@PostMapping("/utterance")
public Result<Map<String, Object>> generateUtterance(
@NotBlank(message = "message不能为空") @RequestParam("message") String message,
@RequestParam(value = "context", required = false) String context,
@RequestParam(value = "customerProfile", required = false) String customerProfile,
@RequestParam(value = "conversationContext", required = false) String conversationContext,
@RequestParam(value = "referenceUtterances", required = false) String referenceUtterances,
@NotBlank(message = "intent不能为空") @RequestParam("intent") String intent,
@NotBlank(message = "stage不能为空") @RequestParam("stage") String stage,
@RequestParam(value = "stageLabel", required = false) String stageLabel,
@RequestParam(value = "intentLabel", required = false) String intentLabel) {
@RequestParam(value = "fromUser", required = false) String fromUser,
@RequestParam(value = "toUser", required = false) String toUser,
@RequestParam(value = "roomid", required = false) String roomid) {
String[] promptPair = promptEngine.buildUtterancePrompt(
message, context, customerProfile, conversationContext,
referenceUtterances, intent, stage, stageLabel, intentLabel);
String chatContext = buildChatContext(fromUser, toUser, roomid);
String[] promptPair = promptEngine.buildUtterancePrompt(chatContext);
String systemPrompt = promptPair[0];
String userPrompt = promptPair[1];
String generated = qianwenClient.generate(systemPrompt, userPrompt, 800);
Map<String, Object> result = new HashMap<>();
@ -72,6 +58,43 @@ public class GenerationController {
return Result.success(result);
}
private String buildChatContext(String fromUser, String toUser, String roomid) {
List<ArchiveMessage> messages;
boolean isGroupChat = roomid != null && !roomid.isEmpty();
long startTime = getTodayStartTime();
long endTime = getTodayEndTime();
if (isGroupChat) {
messages = archiveMessageMapper.selectGroupChatMessages(roomid, startTime, endTime);
} else {
if (fromUser == null || fromUser.isEmpty() || toUser == null || toUser.isEmpty()) {
log.warn("单聊模式需要fromUser和toUser参数");
return null;
}
toUser = "[\""+toUser+"\"]";
messages = archiveMessageMapper.selectSingleChatMessages(fromUser, toUser, startTime, endTime);
}
if (messages == null || messages.isEmpty()) {
return null;
}
StringBuilder sb = new StringBuilder();
for (ArchiveMessage msg : messages) {
sb.append(msg.getCreatedAt()).append(" ").append(msg.getFromRole()).append(msg.getContent()).append("\n");
}
return sb.toString().trim();
}
private long getTodayStartTime() {
return java.time.LocalDate.now().atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli();
}
private long getTodayEndTime() {
return java.time.LocalDate.now().plusDays(1).atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() - 1;
}
@PostMapping("/summary")
public Result<Map<String, Object>> generateSummary(
@NotBlank(message = "context不能为空") @RequestParam("context") String context) {
@ -85,131 +108,6 @@ public class GenerationController {
return Result.success(result);
}
/**
* 流式生成多风格话术(SSE)
* 页面打开即自动推荐 3 条不同风格话术
*/
@GetMapping(value = "/utterance-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamUtterance(
@NotBlank(message = "message不能为空") @RequestParam("message") String message,
@RequestParam(value = "context", required = false) String context,
@RequestParam(value = "customerProfile", required = false) String customerProfile,
@RequestParam(value = "conversationContext", required = false) String conversationContext,
@RequestParam(value = "referenceUtterances", required = false) String referenceUtterances,
@NotBlank(message = "intent不能为空") @RequestParam("intent") String intent,
@NotBlank(message = "stage不能为空") @RequestParam("stage") String stage,
@RequestParam(value = "stageLabel", required = false) String stageLabel,
@RequestParam(value = "intentLabel", required = false) String intentLabel) {
SseEmitter emitter = new SseEmitter(120000L); // 2分钟超时
sseExecutor.execute(() -> {
try {
// 1. 发送开始事件,告知前端3种风格
Map<String, Object> startEvent = new HashMap<>();
startEvent.put("type", "start");
startEvent.put("styles", STYLES);
emitter.send(SseEmitter.event().name("start").data(startEvent));
// 2. 构建 system + user prompt
String systemPrompt = promptEngine.buildSystemPrompt(stageLabel, intentLabel);
String userPrompt = promptEngine.buildMultiStyleUserPrompt(
message, context, customerProfile, conversationContext,
referenceUtterances, intent, stage, stageLabel, intentLabel);
// 3. 调用 LLM 生成(非流式,后台一次完成)
long startTime = System.currentTimeMillis();
String generated = qianwenClient.generate(systemPrompt, userPrompt, 1200);
log.info("多风格话术生成完成, elapsed={}ms", System.currentTimeMillis() - startTime);
// 4. 解析出3条话术
List<String> utterances = parseMultiStyle(generated);
if (utterances.size() < 3) {
// 如果解析失败,用回退方案:把整个内容当一条
while (utterances.size() < 3) {
utterances.add(generated != null ? generated : "生成失败,请稍后重试");
}
}
// 5. 逐条逐字流式推送给前端(伪流式,打字机效果)
for (int i = 0; i < Math.min(3, utterances.size()); i++) {
String text = utterances.get(i);
String styleId = STYLES.get(i).get("id");
// 先发送该条话术的起始事件
Map<String, Object> itemStart = new HashMap<>();
itemStart.put("type", "item-start");
itemStart.put("styleId", styleId);
itemStart.put("index", i);
emitter.send(SseEmitter.event().name("item-start").data(itemStart));
// 逐字推送(每20-40ms推送一个字,模拟打字速度)
for (int j = 0; j < text.length(); j++) {
String ch = text.substring(j, j + 1);
Map<String, Object> chunk = new HashMap<>();
chunk.put("type", "chunk");
chunk.put("styleId", styleId);
chunk.put("text", ch);
chunk.put("index", i);
emitter.send(SseEmitter.event().name("chunk").data(chunk));
Thread.sleep(25); // 25ms/字,约 40字/秒
}
// 该条话术完成
Map<String, Object> complete = new HashMap<>();
complete.put("type", "complete");
complete.put("styleId", styleId);
complete.put("fullText", text);
complete.put("index", i);
emitter.send(SseEmitter.event().name("complete").data(complete));
}
// 6. 全部完成
Map<String, Object> done = new HashMap<>();
done.put("type", "done");
done.put("model", qianwenClient.getCurrentModel() != null
? qianwenClient.getCurrentModel().getModelName() : "default");
emitter.send(SseEmitter.event().name("done").data(done));
emitter.complete();
} catch (Exception e) {
log.error("流式生成失败", e);
try {
Map<String, Object> error = new HashMap<>();
error.put("type", "error");
error.put("message", e.getMessage());
emitter.send(SseEmitter.event().name("error").data(error));
} catch (IOException ignored) {}
emitter.completeWithError(e);
}
});
return emitter;
}
/**
* 解析多风格话术,按 "|||" 分隔
*/
private List<String> parseMultiStyle(String text) {
List<String> result = new ArrayList<>();
if (text == null || text.isEmpty()) return result;
// 去掉常见的AI输出前缀
String cleaned = text.replaceAll("^(风格一[::]?|风格二[::]?|风格三[::]?|亲和风格[::]?|专业风格[::]?|促单风格[::]?)", "").trim();
// 按 ||| 分隔
String[] parts = cleaned.split("\\|\\|\\|");
for (String part : parts) {
String trimmed = part.trim();
if (!trimmed.isEmpty() && trimmed.length() > 5) {
// 去掉可能的风格标签前缀
trimmed = trimmed.replaceAll("^(风格[一二三四五][::]?|【.*?】[::]?)", "").trim();
result.add(trimmed);
}
}
return result;
}
/**
* 通用文本生成接口(供其他服务调用)
*/

View File

@ -0,0 +1,32 @@
package com.artedu.generation.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("archive_messages")
public class ArchiveMessage {
@TableId(type = IdType.AUTO)
private Long id;
private String msgid;
private Long seq;
private String corpId;
private String action;
private String fromUser;
private String fromRole;
private String toUser;
private String tolist;
private String roomid;
private String msgtype;
private Long msgtime;
private String content;
private String mediaData;
private Integer decryptStatus;
private String sessionId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,39 @@
package com.artedu.generation.mapper;
import com.artedu.generation.entity.ArchiveMessage;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ArchiveMessageMapper extends BaseMapper<ArchiveMessage> {
@Select("<script>" +
"(SELECT msgtime,created_at, from_role, content FROM archive_messages " +
"WHERE from_user = #{fromUser} AND to_user = #{toUser} " +
"AND msgtime >= #{startTime} AND msgtime &lt; #{endTime} AND msgtype='text' " +
"AND content IS NOT NULL AND content != '' limit 100) " +
"UNION ALL " +
"(SELECT msgtime,created_at, from_role, content FROM archive_messages " +
"WHERE from_user = #{toUser} AND to_user = #{fromUser} " +
"AND msgtime >= #{startTime} AND msgtime &lt; #{endTime} AND msgtype='text' " +
"AND content IS NOT NULL AND content != '' limit 100)" +
"ORDER BY msgtime DESC" +
"</script>")
List<ArchiveMessage> selectSingleChatMessages(@Param("fromUser") String fromUser,
@Param("toUser") String toUser,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
@Select("SELECT created_at, from_role, content FROM archive_messages " +
"WHERE roomid = #{roomid} " +
"AND msgtime >= #{startTime} AND msgtime < #{endTime} AND msgtype='text' " +
"AND content IS NOT NULL AND content != '' " +
"ORDER BY msgtime DESC")
List<ArchiveMessage> selectGroupChatMessages(@Param("roomid") String roomid,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
}

View File

@ -10,20 +10,24 @@ public class PromptEngine {
/**
* 构建生成话术的系统提示词(角色设定)
*/
public String buildSystemPrompt(String stageLabel, String intentLabel) {
public String buildSystemPrompt(String chatHistoryContext) {
StringBuilder sb = new StringBuilder();
sb.append("你是「小九」,第九联盟(CG美术培训)的资深课程顾问,从业8年。");
sb.append("第九联盟专注CG数字艺术教育10年,累计培养8000+学员,合作企业包括腾讯、网易、米哈游等500+家。");
sb.append("6大校区:上海、西安、厦门、武汉、青岛、合肥。");
sb.append("\n\n回复原则:\n");
sb.append("1. 语气像微信聊天一样自然亲切,不要机械生硬、不要堆砌数据\n");
sb.append("2. 结合学员具体情况给针对性建议,不要泛泛而谈\n");
sb.append("3. 适当用学员成功案例或真实数据增强说服力,点到为止\n");
sb.append("4. 长度控制在150-250字,简洁有力\n");
sb.append("5. 如知道学员真实姓名则称呼姓名,不知道真实姓名时绝对不要编造名字,可以直接用自然的方式开头\n");
if (stageLabel != null && !stageLabel.isEmpty()) {
sb.append("6. 当前处于「").append(stageLabel).append("」阶段,回复要匹配该阶段的沟通目标\n");
}
sb.append("资深销售教练,仅输出符合规范的 3 条推荐回复话术,全程禁止输出任何对话分析、客户判断、前置解读、总结说明文字,任何分析类文字一律不写,正文只保留 3 条话术条目。\n");
sb.append("## 对话历史\n");
sb.append(chatHistoryContext);
sb.append("\n");
sb.append("## 说明\n");
sb.append("其中INTERNAL表示销售,EXTERNAL表示客户\n");
sb.append(" ## 内置分析逻辑(仅 AI 内部运算,绝对不能打印 / 展示出来)\n");
sb.append("内部自动完成 5 项分析,仅用于生成话术,不对外输出:1. 客户意图;2. 情绪状态;3. 购买阶段;4. 关键信号;5. 未解决问题\n");
sb.append("## 硬性输出铁律(违规判定:出现任何分析文字直接不合格)\n");
sb.append("禁止输出:客户情况总结、对话解读、阶段判断、需求分析、前置铺垫、过渡介绍、标题说明、总述段落;\n");
sb.append("全文只能存在 3 条话术条目,除此以外无任何文字,不能加开头、结尾、备注类语句;\n");
sb.append("每条话术固定三要素,缺一不可:完整话术文本、预期效果、INTERNAL 语气;\n");
sb.append("单条话术文本≤100 字,整体风格专业、自然、有温度。\n");
sb.append("## 单条话术固定格式(严格遵守,不改动结构)\n");
sb.append("1、李先生您好,这个产品标准版 9800/年。不过不同企业的需求差别很大,方便说一下您这边大概的使用场景和人数吗?我可以帮您推荐最合适的版本,避免买多了浪费。\n");
sb.append("2、价格方面我先给您一个参考——标准版 9800/年。上周有个和您同行做电商的客户,用了我们系统后客服效率提升了 40%。要不我发一份他们的使用案例给您看看?\n");
return sb.toString();
}
@ -76,14 +80,11 @@ public class PromptEngine {
* 构建完整话术 Prompt(system + user 分离)
* 返回 String[]{systemPrompt, userPrompt}
*/
public String[] buildUtterancePrompt(String message, String context,
String customerProfile, String conversationContext,
String referenceUtterances,
String intent, String stage,
String stageLabel, String intentLabel) {
String system = buildSystemPrompt(stageLabel, intentLabel);
String user = buildUserPrompt(message, context, customerProfile, conversationContext,
referenceUtterances, intent, stage, stageLabel, intentLabel);
public String[] buildUtterancePrompt(String chatHistoryContext) {
String system = buildSystemPrompt(chatHistoryContext);
// String user = buildUserPrompt(message, context, customerProfile, conversationContext,
// referenceUtterances, intent, stage, stageLabel, intentLabel);
String user = "";
return new String[]{system, user};
}
@ -128,59 +129,6 @@ public class PromptEngine {
return sb.toString();
}
/**
* 构建多风格话术的用户提示词
* 一次生成 3 条不同风格的话术,用特定分隔符
*/
public String buildMultiStyleUserPrompt(String message, String context,
String customerProfile, String conversationContext,
String referenceUtterances,
String intent, String stage,
String stageLabel, String intentLabel) {
StringBuilder sb = new StringBuilder();
if (customerProfile != null && !customerProfile.isEmpty()) {
sb.append("【学员画像】\n").append(customerProfile).append("\n\n");
}
String history = null;
if (conversationContext != null && !conversationContext.isEmpty()) {
history = conversationContext;
} else if (context != null && !context.isEmpty()) {
history = context;
}
if (history != null && !history.isEmpty()) {
sb.append("【最近对话】\n").append(history).append("\n\n");
}
String stageDisplay = (stageLabel != null && !stageLabel.isEmpty()) ? stageLabel : stage;
String intentDisplay = (intentLabel != null && !intentLabel.isEmpty()) ? intentLabel : intent;
sb.append("【当前阶段】").append(stageDisplay).append("\n");
sb.append("【学员意图】").append(intentDisplay).append("\n\n");
if (referenceUtterances != null && !referenceUtterances.isEmpty()) {
sb.append("【参考话术风格】\n").append(referenceUtterances).append("\n\n");
}
sb.append("学员最新消息:\"").append(message).append("\"\n\n");
sb.append("请为这条学员消息生成3种不同风格的回复话术,每种风格完全不同,让学员有更多选择:\n\n");
sb.append("风格一【亲和风格】:语气温暖、亲切、像朋友聊天,拉近距离,适合建立好感\n");
sb.append("风格二【专业风格】:用数据、案例、专业分析增强说服力,适合建立信任\n");
sb.append("风格三【促单风格】:制造紧迫感,强调限时优惠或名额有限,适合促成决策\n\n");
sb.append("要求:\n");
sb.append("- 每条话术150-250字\n");
sb.append("- 直接输出话术内容,不要加任何前缀或解释\n");
sb.append("- 每条话术独立成行,用「|||」分隔\n\n");
sb.append("输出格式示例:\n");
sb.append("亲和风格话术内容...\n");
sb.append("|||\n");
sb.append("专业风格话术内容...\n");
sb.append("|||\n");
sb.append("促单风格话术内容...");
return sb.toString();
}
public String buildSummaryPrompt(String context) {
return "请对以下对话进行简要总结(50字以内),提炼学员核心需求和当前阶段:\n\n" + context;
}

2
backend/intent-service/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
target/
target

2
backend/kb-admin-service/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
target/
target

View File

@ -0,0 +1,2 @@
target/
target