From f71340f485a671081c796c5857bfec38cf2d153a Mon Sep 17 00:00:00 2001 From: "qinyong@9artedu.com" Date: Mon, 22 Jun 2026 09:49:55 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../artedu/generation/controller/GenerationController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index cc65382..b23c15c 100644 --- 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 @@ -136,8 +136,8 @@ public class GenerationController { List messages; boolean isGroupChat = roomid != null && !roomid.isEmpty(); - // 查最近 7 天,避免今天没聊天就找不到记录 - long startTime = get7DaysAgoStartTime(); + // 查最近 今 天,避免今天没聊天就找不到记录 + long startTime = getTodayStartTime(); long endTime = getTodayEndTime(); if (isGroupChat) { From 569539d1f0c4a5da0c06427e8709b735c73b1376 Mon Sep 17 00:00:00 2001 From: "qinyong@9artedu.com" Date: Tue, 23 Jun 2026 17:35:04 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=BA=93=E7=9A=84=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/common/pom.xml | 4 + .../com/artedu/common/util/HttpUtils.java | 139 ++++++++++++++++ .../controller/GenerationController.java | 153 +++++++++++++++--- .../generation/service/PromptEngine.java | 27 +++- .../src/main/resources/application.yml | 3 + backend/pom.xml | 5 + 6 files changed, 308 insertions(+), 23 deletions(-) create mode 100644 backend/common/src/main/java/com/artedu/common/util/HttpUtils.java diff --git a/backend/common/pom.xml b/backend/common/pom.xml index 6e19b5e..105eccc 100644 --- a/backend/common/pom.xml +++ b/backend/common/pom.xml @@ -49,5 +49,9 @@ com.fasterxml.jackson.core jackson-databind + + com.squareup.okhttp3 + okhttp + diff --git a/backend/common/src/main/java/com/artedu/common/util/HttpUtils.java b/backend/common/src/main/java/com/artedu/common/util/HttpUtils.java new file mode 100644 index 0000000..12dae29 --- /dev/null +++ b/backend/common/src/main/java/com/artedu/common/util/HttpUtils.java @@ -0,0 +1,139 @@ +package com.artedu.common.util; + +import lombok.extern.slf4j.Slf4j; +import okhttp3.*; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@Slf4j +public class HttpUtils { + + private static final OkHttpClient OK_HTTP_CLIENT = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + + private static final MediaType JSON_MEDIA_TYPE = MediaType.get("application/json; charset=utf-8"); + private static final MediaType FORM_MEDIA_TYPE = MediaType.get("application/x-www-form-urlencoded; charset=utf-8"); + + public static String get(String url) throws IOException { + return get(url, null); + } + + public static String get(String url, Map headers) throws IOException { + Request.Builder requestBuilder = new Request.Builder().url(url).get(); + + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + requestBuilder.addHeader(entry.getKey(), entry.getValue()); + } + } + + Request request = requestBuilder.build(); + return executeRequest(request); + } + + public static String post(String url, String jsonBody) throws IOException { + return post(url, jsonBody, null); + } + + public static String post(String url, String jsonBody, Map headers) throws IOException { + RequestBody body = RequestBody.create(jsonBody, JSON_MEDIA_TYPE); + Request.Builder requestBuilder = new Request.Builder().url(url).post(body); + + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + requestBuilder.addHeader(entry.getKey(), entry.getValue()); + } + } + + Request request = requestBuilder.build(); + return executeRequest(request); + } + + public static String postForm(String url, Map formParams) throws IOException { + return postForm(url, formParams, null); + } + + public static String postForm(String url, Map formParams, Map headers) throws IOException { + FormBody.Builder formBodyBuilder = new FormBody.Builder(); + if (formParams != null) { + for (Map.Entry entry : formParams.entrySet()) { + String value = entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; + formBodyBuilder.add(entry.getKey(), value); + } + } + + RequestBody body = formBodyBuilder.build(); + Request.Builder requestBuilder = new Request.Builder().url(url).post(body); + + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + requestBuilder.addHeader(entry.getKey(), entry.getValue()); + } + } + + Request request = requestBuilder.build(); + return executeRequest(request); + } + + public static String put(String url, String jsonBody) throws IOException { + return put(url, jsonBody, null); + } + + public static String put(String url, String jsonBody, Map headers) throws IOException { + RequestBody body = RequestBody.create(jsonBody, JSON_MEDIA_TYPE); + Request.Builder requestBuilder = new Request.Builder().url(url).put(body); + + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + requestBuilder.addHeader(entry.getKey(), entry.getValue()); + } + } + + Request request = requestBuilder.build(); + return executeRequest(request); + } + + public static String delete(String url) throws IOException { + return delete(url, null); + } + + public static String delete(String url, Map headers) throws IOException { + Request.Builder requestBuilder = new Request.Builder().url(url).delete(); + + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + requestBuilder.addHeader(entry.getKey(), entry.getValue()); + } + } + + Request request = requestBuilder.build(); + return executeRequest(request); + } + + private static String executeRequest(Request request) throws IOException { + log.debug("HTTP request: {} {}", request.method(), request.url()); + + try (Response response = OK_HTTP_CLIENT.newCall(request).execute()) { + if (!response.isSuccessful()) { + String errorBody = response.body() != null ? response.body().string() : ""; + log.error("HTTP request failed: {} {} - {} - {}", + request.method(), request.url(), response.code(), errorBody); + throw new IOException("HTTP request failed: " + response.code() + " " + errorBody); + } + + ResponseBody body = response.body(); + String result = body != null ? body.string() : ""; + log.debug("HTTP response: {} {} - {}", request.method(), request.url(), result); + return result; + } + } + + public static OkHttpClient getClient() { + return OK_HTTP_CLIENT; + } +} \ No newline at end of file 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 index b23c15c..a165000 100644 --- 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 @@ -1,12 +1,15 @@ package com.artedu.generation.controller; import com.artedu.common.result.Result; +import com.artedu.common.util.HttpUtils; import com.artedu.generation.client.LLMClient; +import com.artedu.generation.config.QaConfig; 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 com.baomidou.mybatisplus.core.toolkit.StringUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; @@ -16,12 +19,16 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import javax.validation.constraints.NotBlank; import java.io.IOException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.stream.Collectors; @Slf4j @Validated @@ -41,6 +48,9 @@ public class GenerationController { @Autowired private ArchiveMessageMapper archiveMessageMapper; + @Autowired + private QaConfig qaConfig; + private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); private static final List> STYLES = List.of( @@ -115,16 +125,54 @@ public class GenerationController { @RequestParam(value = "roomid", required = false) String roomid, @RequestParam(value = "staffName", required = false) String staffName) { - String chatContext = buildChatContext(fromUser, toUser, roomid); + List messages = buildChatContext(fromUser, toUser, roomid); + // 遍历消息:只取前三条是客户的消息,组装 查询知识库 + String external_str = ""; //原始问题 + // 历史消息上下文 + List top15 = (messages == null || messages.isEmpty()) + ? new ArrayList<>() + : messages.stream().limit(15).collect(Collectors.toList()); + StringBuilder sb = new StringBuilder(); + boolean is_external = true; + int count = 0; + for (ArchiveMessage msg : messages) { + sb.append(msg.getCreatedAt()).append(" ").append(msg.getFromRole()).append(" ").append(msg.getContent()).append("\n"); + if ("EXTERNAL".equals(messages.get(0).getFromRole())){ //第一条消息是客户的消息 则继续 + if(count<3){ + if ("EXTERNAL".equals(msg.getFromRole()) && is_external){ //判断判断前三条是否只包含客户消息 + external_str += msg.getContent()+","; + } + if (!"EXTERNAL".equals(msg.getFromRole())){ //判断前三条是否包含非客户的消息,如果包含,则不需要后续的消息 + is_external = false; + } + } + } + count++; + } - String[] promptPair = promptEngine.buildUtterancePrompt(chatContext, staffName); - String systemPrompt = promptPair[0]; - String userPrompt = promptPair[1]; - - String generated = qianwenClient.generate(systemPrompt, userPrompt, 800); + String answerNew = ""; + //如果最后几条都是客户消息返回不为空,则直接返回结果,否则走原逻辑 + if (!StringUtils.isBlank(external_str)){ //不为空 发起知识库的查询 + String answer = getAnswer(top15, external_str); + if(!StringUtils.isBlank(answer)){ + answer = answer.replaceAll("\"", "").replace("[","").replace("]",""); + if(!StringUtils.isBlank(answer)){ + String promptNew = promptEngine.buildAnswerPrompt(answer); + answerNew = qianwenClient.generate(promptNew, 800); + } + } + } + //没有命中知识库,则根据当天聊天内容生成 + if(StringUtils.isBlank(answerNew)){ + String chatContext = sb.toString(); + String[] promptPair = promptEngine.buildUtterancePrompt(chatContext, staffName); + String systemPrompt = promptPair[0]; + String userPrompt = promptPair[1]; + answerNew = qianwenClient.generate(systemPrompt, userPrompt, 800); + } Map result = new HashMap<>(); - result.put("content", generated); + result.put("content", answerNew); result.put("source", "LLM"); result.put("model", qianwenClient.getCurrentModel() != null ? qianwenClient.getCurrentModel().getModelName() : "default"); @@ -132,14 +180,47 @@ public class GenerationController { return Result.success(result); } - private String buildChatContext(String fromUser, String toUser, String roomid) { + private String getAnswer(List messages, String external_str){ + List msgInfo = messages.stream() + .map(msg -> String.join(" ", + String.valueOf(msg.getCreatedAt()), + String.valueOf(msg.getFromRole()), + String.valueOf(msg.getContent()) + ) + "\n").collect(Collectors.toList()) ; + Map params = new HashMap<>(); + params.put("external_str", external_str); + params.put("messages_top15", msgInfo); + try { + String jsonBody = com.artedu.common.util.JsonUtils.toJson(params); + String response = HttpUtils.post(qaConfig.getApiUrl(), jsonBody); + log.info("知识库答案:{}",response); + return response; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private List buildChatContext(String fromUser, String toUser, String roomid) { List messages; boolean isGroupChat = roomid != null && !roomid.isEmpty(); // 查最近 今 天,避免今天没聊天就找不到记录 long startTime = getTodayStartTime(); long endTime = getTodayEndTime(); - +//// 指定日期:2026-05-22 todo 测试需要 +// LocalDate targetDate = LocalDate.of(2026, 6, 18); +// +//// 当天 00:00:00 毫秒时间戳 +// long startTime = targetDate.atStartOfDay() +// .atZone(ZoneId.systemDefault()) +// .toInstant() +// .toEpochMilli(); +// +//// 当天 23:59:59 毫秒时间戳 +// long endTime = LocalDateTime.of(targetDate, java.time.LocalTime.of(23, 59, 59)) +// .atZone(ZoneId.systemDefault()) +// .toInstant() +// .toEpochMilli(); if (isGroupChat) { messages = archiveMessageMapper.selectGroupChatMessages(roomid, startTime, endTime); } else { @@ -159,14 +240,10 @@ public class GenerationController { } if (messages == null || messages.isEmpty()) { - return null; + return new ArrayList<>(); } - 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(); + return messages; } private long getTodayStartTime() { @@ -201,7 +278,39 @@ public class GenerationController { startEvent.put("styles", STYLES); emitter.send(SseEmitter.event().name("start").data(startEvent)); - String chatContext = buildChatContext(fromUser, toUser, roomid); + List messages = buildChatContext(fromUser, toUser, roomid); + // 遍历消息:只取前三条是客户的消息,组装 查询知识库 + String external_str = ""; //原始问题 + // 历史消息上下文 + List top15 = (messages == null || messages.isEmpty()) + ? new ArrayList<>() + : messages.stream().limit(15).collect(Collectors.toList()); + StringBuilder sb = new StringBuilder(); + boolean is_external = true; + for (ArchiveMessage msg : messages) { + sb.append(msg.getCreatedAt()).append(" ").append(msg.getFromRole()).append(" ").append(msg.getContent()).append("\n"); + if ("EXTERNAL".equals(messages.get(0).getFromRole())){ //第一条消息是客户的消息 则继续 + if ("EXTERNAL".equals(msg.getFromRole()) && is_external){ //判断只要消息不是内部人员,则后续不考虑 + is_external = false; + external_str += msg.getContent()+","; + } + } + } + + String generated = ""; + //如果最后几条都是客户消息返回不为空,则直接返回结果,否则走原逻辑 + if (!StringUtils.isBlank(external_str)){ //不为空 发起知识库的查询 + String answer = getAnswer(top15, external_str); + if(!StringUtils.isBlank(answer)){ + answer = answer.replaceAll("\"", "").replace("[","").replace("]",""); + if(!StringUtils.isBlank(answer)){ + String promptNew = promptEngine.buildAnswerPrompt(answer); + generated = qianwenClient.generate(promptNew, 800); + log.info("原问题:{},改写后的问题:{},命中知识库结果:{}", external_str, answer, generated); + } + } + } + String chatContext = sb.toString(); if (chatContext == null || chatContext.isEmpty()) { Map err = new HashMap<>(); err.put("type", "error"); @@ -210,13 +319,15 @@ public class GenerationController { emitter.complete(); return; } + if(StringUtils.isBlank(generated)){ + String systemPrompt = promptEngine.buildSystemPrompt(chatContext, staffName); + String userPrompt = promptEngine.buildMultiStyleUserPrompt(staffName); - String systemPrompt = promptEngine.buildSystemPrompt(chatContext, staffName); - String userPrompt = promptEngine.buildMultiStyleUserPrompt(staffName); + long startTime = System.currentTimeMillis(); + generated = qianwenClient.generate(systemPrompt, userPrompt, 1200); + log.info("多风格话术生成完成, elapsed={}ms", System.currentTimeMillis() - startTime); + } - long startTime = System.currentTimeMillis(); - String generated = qianwenClient.generate(systemPrompt, userPrompt, 1200); - log.info("多风格话术生成完成, elapsed={}ms", System.currentTimeMillis() - startTime); List utterances = parseMultiStyle(generated); if (utterances.size() < 3) { 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 index 03dd6fd..28adb3d 100644 --- 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 @@ -31,8 +31,8 @@ public class PromptEngine { sb.append("每条话术固定三要素,缺一不可:完整话术文本、预期效果、INTERNAL 语气;\n"); sb.append("单条话术文本≤100 字,整体风格专业、自然、有温度。\n"); sb.append("## 单条话术固定格式(严格遵守,不改动结构)\n"); - sb.append("1、李先生您好,这个产品标准版 9800/年。不过不同企业的需求差别很大,方便说一下您这边大概的使用场景和人数吗?我可以帮您推荐最合适的版本,避免买多了浪费。\n"); - sb.append("2、价格方面我先给您一个参考——标准版 9800/年。上周有个和您同行做电商的客户,用了我们系统后客服效率提升了 40%。要不我发一份他们的使用案例给您看看?\n"); + sb.append("1、您好,第九联盟(点晴科技)提供线上课程11800元/8-10个月和线下课程33800元/6个月全日制,大学生可线上+寒暑假线下结合(按线下收费)。支持包括3年回放和答疑、重读机会,以及简历辅导、模拟面试、测试指导、校招群内推等服务。\n"); + sb.append("2、您好,有什么可以帮助你吗\n"); return sb.toString(); } @@ -164,4 +164,27 @@ public class PromptEngine { public String buildSummaryPrompt(String context) { return "请对以下对话进行简要总结(50字以内),提炼学员核心需求和当前阶段:\n\n" + context; } + /** + * 构建话术改写提示词 + */ + public String buildAnswerPrompt(String answer) { + StringBuilder sb = new StringBuilder(); + + sb.append("你是一位经验丰富的课程顾问,擅长用亲切、自然、像朋友聊天的方式跟学生/家长沟通。\n\n\n"); + + sb.append("【任务】把\"原内容信息\"改写成温暖、有说服力、让人愿意继续聊下去的回复。\n\n"); + sb.append("【执行流程】\n"); + sb.append("1. 首先检查是否有\"原内容信息\":\n"); + sb.append("- 如果没有提供,或内容为空,或内容仅为系统提示本身 → 必须直接输出空,不输出任何其他文字。\n"); + sb.append("- 如果提供了 → 执行步骤2\n"); + sb.append("2. 润色原内容,保持字数相近,只输出润色结果。\n"); + sb.append("【约束】\n"); + sb.append("- 严禁在原内容为空时编造、引导、解释。\n"); + sb.append("- 输出字数必须与原信息字数相近。\n"); + sb.append("- 只输出润色结果,禁止输出\"好的\"\"请提供\"等额外文字。\n"); + sb.append("【原内容】\n"); + sb.append(answer); + + return sb.toString(); + } } diff --git a/backend/generation-service/src/main/resources/application.yml b/backend/generation-service/src/main/resources/application.yml index 222f584..5dedd41 100644 --- a/backend/generation-service/src/main/resources/application.yml +++ b/backend/generation-service/src/main/resources/application.yml @@ -35,6 +35,9 @@ llm: api-key: sk-39ceb8d746014b349109e76f893acb18 model: qwen-plus +qa: + api-url: ${QA_API_URL:http://8.159.132.53:8000/api/qa/query} + mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl diff --git a/backend/pom.xml b/backend/pom.xml index 70b7de4..c6e2cac 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -91,6 +91,11 @@ 2.2.224 test + + com.squareup.okhttp3 + okhttp + 4.12.0 + From 3435428b710b7d3ebe50a13cb0267330b3a397a4 Mon Sep 17 00:00:00 2001 From: "qinyong@9artedu.com" Date: Wed, 24 Jun 2026 10:58:25 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/artedu/generation/config/QaConfig.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 backend/generation-service/src/main/java/com/artedu/generation/config/QaConfig.java diff --git a/backend/generation-service/src/main/java/com/artedu/generation/config/QaConfig.java b/backend/generation-service/src/main/java/com/artedu/generation/config/QaConfig.java new file mode 100644 index 0000000..e95e174 --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/config/QaConfig.java @@ -0,0 +1,13 @@ +package com.artedu.generation.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "qa") +public class QaConfig { + + private String apiUrl = "http://localhost:8000/api/qa/query"; +} \ No newline at end of file