From 30352781350de05997f25d19458d5e6bac88d2a5 Mon Sep 17 00:00:00 2001 From: hygl <3154803225@qq.com> Date: Fri, 27 Jun 2025 14:58:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EApp=EF=BC=8C=E9=9B=86?= =?UTF-8?q?=E6=88=90=E5=AE=A2=E6=9C=8D=E5=92=A8=E8=AF=A2=E5=92=8C=E5=95=86?= =?UTF-8?q?=E5=93=81=E4=BF=A1=E6=81=AF=E6=8A=BD=E5=8F=96=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=AE=9E=E7=8E=B0=E5=9F=BA=E4=BA=8E=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=92=8C=E5=9F=BA=E4=BA=8E=E5=86=85=E5=AD=98=E7=9A=84?= =?UTF-8?q?=E4=B8=A4=E7=A7=8D=E5=AF=B9=E8=AF=9D=E8=AE=B0=E5=BF=86=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E6=96=B9=E6=A1=88=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 12 +++ .../supportbot/advisor/MyLoggerAdvisor.java | 62 +++++++++++++ .../supportbot/advisor/ReReadingAdvisor.java | 53 +++++++++++ .../com/wok/supportbot/app/AssistantApp.java | 76 ++++++++++++++++ .../wok/supportbot/app/ProductInfoApp.java | 64 ++++++++++++++ .../chatmemory/FileBasedChatMemory.java | 88 +++++++++++++++++++ .../demo/invoke/SpringAiAiInvoke.java | 2 +- .../supportbot/record/AssistantReport.java | 14 +++ .../wok/supportbot/record/ProductInfo.java | 14 +++ .../SupportBotApplicationTests.java | 62 ++++++++++++- 10 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/wok/supportbot/advisor/MyLoggerAdvisor.java create mode 100644 src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java create mode 100644 src/main/java/com/wok/supportbot/app/AssistantApp.java create mode 100644 src/main/java/com/wok/supportbot/app/ProductInfoApp.java create mode 100644 src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java create mode 100644 src/main/java/com/wok/supportbot/record/AssistantReport.java create mode 100644 src/main/java/com/wok/supportbot/record/ProductInfo.java diff --git a/pom.xml b/pom.xml index 320d680..9dc0b6f 100644 --- a/pom.xml +++ b/pom.xml @@ -52,6 +52,18 @@ knife4j-openapi3-jakarta-spring-boot-starter 4.4.0 + + + com.github.victools + jsonschema-generator + 4.38.0 + + + + com.esotericsoftware + kryo + 5.6.2 + org.projectlombok lombok diff --git a/src/main/java/com/wok/supportbot/advisor/MyLoggerAdvisor.java b/src/main/java/com/wok/supportbot/advisor/MyLoggerAdvisor.java new file mode 100644 index 0000000..ccb7105 --- /dev/null +++ b/src/main/java/com/wok/supportbot/advisor/MyLoggerAdvisor.java @@ -0,0 +1,62 @@ +package com.wok.supportbot.advisor; + +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.client.advisor.api.AdvisedRequest; +import org.springframework.ai.chat.client.advisor.api.AdvisedResponse; +import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisor; +import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisorChain; +import org.springframework.ai.chat.client.advisor.api.StreamAroundAdvisor; +import org.springframework.ai.chat.client.advisor.api.StreamAroundAdvisorChain; +import org.springframework.ai.chat.model.MessageAggregator; + +/** + * 自定义日志 Advisor + * 打印 info 级别日志、只输出单次用户提示词和 AI 回复的文本 + */ +@Slf4j +public class MyLoggerAdvisor implements CallAroundAdvisor, StreamAroundAdvisor { + + @Override + public String getName() { + return this.getClass().getSimpleName(); + } + + @Override + public int getOrder() { + return 0; + } + + private AdvisedRequest before(AdvisedRequest request) { + log.info("AI Request: {}", request.userText()); + return request; + } + + private void observeAfter(AdvisedResponse advisedResponse) { + log.info("AI Response: {}", advisedResponse.response().getResult().getOutput().getText()); + } + + @Override + public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) { + + advisedRequest = before(advisedRequest); + + AdvisedResponse advisedResponse = chain.nextAroundCall(advisedRequest); + + observeAfter(advisedResponse); + + return advisedResponse; + } + + @Override + public Flux aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) { + + advisedRequest = before(advisedRequest); + + Flux advisedResponses = chain.nextAroundStream(advisedRequest); + + return new MessageAggregator().aggregateAdvisedResponse(advisedResponses, this::observeAfter); + } + +} diff --git a/src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java b/src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java new file mode 100644 index 0000000..2c3e8ed --- /dev/null +++ b/src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java @@ -0,0 +1,53 @@ +package com.wok.supportbot.advisor; + +import org.springframework.ai.chat.client.advisor.api.*; +import reactor.core.publisher.Flux; + +import java.util.HashMap; +import java.util.Map; + +/** + * 自定义 Re2 Advisor + * 可提高大型语言模型的推理能力 + */ +public class ReReadingAdvisor implements CallAroundAdvisor, StreamAroundAdvisor { + + /** + * 执行请求前,改写 Prompt + * @param advisedRequest + * @return + */ + private AdvisedRequest before(AdvisedRequest advisedRequest) { + + Map advisedUserParams = new HashMap<>(advisedRequest.userParams()); + advisedUserParams.put("re2_input_query", advisedRequest.userText()); + + return AdvisedRequest.from(advisedRequest) + .userText(""" + {re2_input_query} + Read the question again: {re2_input_query} + """) + .userParams(advisedUserParams) + .build(); + } + + @Override + public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) { + return chain.nextAroundCall(this.before(advisedRequest)); + } + + @Override + public Flux aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) { + return chain.nextAroundStream(this.before(advisedRequest)); + } + + @Override + public int getOrder() { + return 0; + } + + @Override + public String getName() { + return this.getClass().getSimpleName(); + } +} diff --git a/src/main/java/com/wok/supportbot/app/AssistantApp.java b/src/main/java/com/wok/supportbot/app/AssistantApp.java new file mode 100644 index 0000000..8ecf999 --- /dev/null +++ b/src/main/java/com/wok/supportbot/app/AssistantApp.java @@ -0,0 +1,76 @@ +package com.wok.supportbot.app; + +import com.wok.supportbot.advisor.MyLoggerAdvisor; +import com.wok.supportbot.advisor.ReReadingAdvisor; +import com.wok.supportbot.record.AssistantReport; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.InMemoryChatMemory; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.stereotype.Component; + +import java.util.List; + +import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY; +import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY; + +/** + * @Classname AssistantApp + * @Description + * @Version 1.0.0 + * @Date 2025/06/27 14:11 + * @Author lyx + */ +@Component +@Slf4j +public class AssistantApp { + + private final ChatClient chatClient; + + private static final String SYSTEM_PROMPT = "你是一名电商平台的智能客服助手,负责解答用户关于商品、订单、支付、物流和售后等问题。" + + "请主动引导用户提供关键信息(如订单号、商品名),并尽量在不转人工的情况下解决问题。保持专业、耐心、礼貌。"; + + /** + * 初始化 ChatClient + * @param dashscopeChatModel + */ + public AssistantApp(ChatModel dashscopeChatModel) { + // 初始化基于文件的对话记忆 + //String fileDir = System.getProperty("user.dir") + "/tmp/chat-memory"; + //ChatMemory chatMemory = new FileBasedChatMemory(fileDir); + // 初始化基于内存的对话记忆 + ChatMemory chatMemory = new InMemoryChatMemory(); + chatClient = ChatClient.builder(dashscopeChatModel) + .defaultSystem(SYSTEM_PROMPT) + .defaultAdvisors( + new MessageChatMemoryAdvisor(chatMemory), + // 自定义日志 Advisor,可按需开启 + new MyLoggerAdvisor() + // 自定义推理增强 Advisor,可按需开启 + //,new ReReadingAdvisor() + ) + .build(); + } + + /** + * AI 基础对话(支持多轮对话记忆) + * @param message + * @param chatId + * @return + */ + public String doChat(String message, String chatId) { + ChatResponse chatResponse = chatClient + .prompt() + .user(message) + .advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId) + .param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10)) + .call() + .chatResponse(); + String content = chatResponse.getResult().getOutput().getText(); + //log.info("content: {}", content); + return content; + } +} diff --git a/src/main/java/com/wok/supportbot/app/ProductInfoApp.java b/src/main/java/com/wok/supportbot/app/ProductInfoApp.java new file mode 100644 index 0000000..9a07e5d --- /dev/null +++ b/src/main/java/com/wok/supportbot/app/ProductInfoApp.java @@ -0,0 +1,64 @@ +package com.wok.supportbot.app; + +import com.wok.supportbot.advisor.MyLoggerAdvisor; +import com.wok.supportbot.record.ProductInfo; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.InMemoryChatMemory; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.stereotype.Component; + +import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY; +import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY; + +/** + * @Classname ProductInfoApp + * @Description 电商商品信息抽取助手App + * @Version 1.0.0 + * @Date 2025/06/27 + * @Author lyx + */ +@Component +@Slf4j +public class ProductInfoApp { + + private final ChatClient chatClient; + + private static final String SYSTEM_PROMPT = "你是一名电商商品信息抽取助手," + + "请从用户提供的商品网页内容中提取标题(title)、描述(description)、价格(price)、评分(rating)、评论数(reviewCount)、品牌(brand)、分类(category)等字段。" + + "请严格按照JSON格式返回,不要带任何解释和多余内容。"; + + public ProductInfoApp(ChatModel dashscopeChatModel) { + ChatMemory chatMemory = new InMemoryChatMemory(); + chatClient = ChatClient.builder(dashscopeChatModel) + .defaultSystem(SYSTEM_PROMPT) + .defaultAdvisors( + new MessageChatMemoryAdvisor(chatMemory), + // 可以按需添加日志或其它advisor + new MyLoggerAdvisor() + ) + .build(); + } + + /** + * 商品信息结构化抽取 + * @param rawContent 爬取的商品网页内容 + * @param chatId 对话ID + * @return 结构化的商品信息对象 + */ + public ProductInfo extractProductInfo(String rawContent, String chatId) { + ProductInfo productInfo = chatClient + .prompt() + .system(SYSTEM_PROMPT) + .user(rawContent) + .advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId) + .param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10)) + .call() + .entity(ProductInfo.class); + log.info("Extracted product info: {}", productInfo); + return productInfo; + } +} diff --git a/src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java b/src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java new file mode 100644 index 0000000..1276726 --- /dev/null +++ b/src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java @@ -0,0 +1,88 @@ +package com.wok.supportbot.chatmemory; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; +import org.objenesis.strategy.StdInstantiatorStrategy; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.messages.Message; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * 基于文件持久化的对话记忆 + */ +public class FileBasedChatMemory implements ChatMemory { + + private final String BASE_DIR; + private static final Kryo kryo = new Kryo(); + + static { + kryo.setRegistrationRequired(false); + // 设置实例化策略 + kryo.setInstantiatorStrategy(new StdInstantiatorStrategy()); + } + + // 构造对象时,指定文件保存目录 + public FileBasedChatMemory(String dir) { + this.BASE_DIR = dir; + File baseDir = new File(dir); + if (!baseDir.exists()) { + baseDir.mkdirs(); + } + } + + @Override + public void add(String conversationId, List messages) { + List conversationMessages = getOrCreateConversation(conversationId); + conversationMessages.addAll(messages); + saveConversation(conversationId, conversationMessages); + } + + @Override + public List get(String conversationId, int lastN) { + List allMessages = getOrCreateConversation(conversationId); + return allMessages.stream() + .skip(Math.max(0, allMessages.size() - lastN)) + .toList(); + } + + @Override + public void clear(String conversationId) { + File file = getConversationFile(conversationId); + if (file.exists()) { + file.delete(); + } + } + + private List getOrCreateConversation(String conversationId) { + File file = getConversationFile(conversationId); + List messages = new ArrayList<>(); + if (file.exists()) { + try (Input input = new Input(new FileInputStream(file))) { + messages = kryo.readObject(input, ArrayList.class); + } catch (IOException e) { + e.printStackTrace(); + } + } + return messages; + } + + private void saveConversation(String conversationId, List messages) { + File file = getConversationFile(conversationId); + try (Output output = new Output(new FileOutputStream(file))) { + kryo.writeObject(output, messages); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private File getConversationFile(String conversationId) { + return new File(BASE_DIR, conversationId + ".kryo"); + } +} diff --git a/src/main/java/com/wok/supportbot/demo/invoke/SpringAiAiInvoke.java b/src/main/java/com/wok/supportbot/demo/invoke/SpringAiAiInvoke.java index 7e363a6..b456047 100644 --- a/src/main/java/com/wok/supportbot/demo/invoke/SpringAiAiInvoke.java +++ b/src/main/java/com/wok/supportbot/demo/invoke/SpringAiAiInvoke.java @@ -18,7 +18,7 @@ public class SpringAiAiInvoke implements CommandLineRunner { @Override public void run(String... args) throws Exception { - AssistantMessage assistantMessage = dashscopeChatModel.call(new Prompt("你好,我在测试我能否用Spring AI来调用某个AI模型")) + AssistantMessage assistantMessage = dashscopeChatModel.call(new Prompt("hello")) .getResult() .getOutput(); System.out.println(assistantMessage.getText()); diff --git a/src/main/java/com/wok/supportbot/record/AssistantReport.java b/src/main/java/com/wok/supportbot/record/AssistantReport.java new file mode 100644 index 0000000..3fd4153 --- /dev/null +++ b/src/main/java/com/wok/supportbot/record/AssistantReport.java @@ -0,0 +1,14 @@ +package com.wok.supportbot.record; + +import java.util.List; + +/** + * @Classname AssistantReport + * @Description + * @Version 1.0.0 + * @Date 2025/06/27 14:21 + * @Author lyx + */ + +public record AssistantReport(String title, List suggestions) { +} \ No newline at end of file diff --git a/src/main/java/com/wok/supportbot/record/ProductInfo.java b/src/main/java/com/wok/supportbot/record/ProductInfo.java new file mode 100644 index 0000000..e29ece2 --- /dev/null +++ b/src/main/java/com/wok/supportbot/record/ProductInfo.java @@ -0,0 +1,14 @@ +package com.wok.supportbot.record; + +import lombok.Data; + +@Data +public class ProductInfo { + private String title; + private String description; + private String price; + private String rating; + private Integer reviewCount; + private String brand; + private String category; +} diff --git a/src/test/java/com/wok/supportbot/SupportBotApplicationTests.java b/src/test/java/com/wok/supportbot/SupportBotApplicationTests.java index 4961bcb..eba51a2 100644 --- a/src/test/java/com/wok/supportbot/SupportBotApplicationTests.java +++ b/src/test/java/com/wok/supportbot/SupportBotApplicationTests.java @@ -1,13 +1,73 @@ package com.wok.supportbot; +import com.wok.supportbot.app.AssistantApp; +import com.wok.supportbot.app.ProductInfoApp; +import com.wok.supportbot.record.AssistantReport; +import com.wok.supportbot.record.ProductInfo; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import java.util.UUID; + @SpringBootTest class SupportBotApplicationTests { + @Resource + private AssistantApp assistantApp; + + @Autowired + private ProductInfoApp productInfoApp; @Test - void contextLoads() { + void testChat() { + String chatId = UUID.randomUUID().toString(); + // 第一轮:商品咨询 + String message = "你好,我想买一台适合学生用的笔记本电脑,有推荐吗?"; + String answer = assistantApp.doChat(message, chatId); + Assertions.assertNotNull(answer); + + // 第二轮:物流问题 + message = "我上周买的那台电脑现在还没到,能查一下物流吗?"; + answer = assistantApp.doChat(message, chatId); + Assertions.assertNotNull(answer); + + // 第三轮:售后问题 + message = "电脑到了,但有点问题。你刚刚说的售后流程能再说一遍吗?"; + answer = assistantApp.doChat(message, chatId); + Assertions.assertNotNull(answer); } + @Test + public void testExtractProductInfo() { + // 模拟爬取的网页内容,建议写简洁但包含关键信息 + String rawContent = "这是商品标题:智能手表Pro 2025," + + "描述:这款智能手表支持心率监测和GPS," + + "价格:299美元,评分:4.7星,评论数:1567,品牌:TechBrand,分类:电子产品。"; + + // 生成随机聊天ID,模拟独立会话 + String chatId = UUID.randomUUID().toString(); + + // 调用方法 + ProductInfo productInfo = productInfoApp.extractProductInfo(rawContent, chatId); + + // 断言结果不为空 + Assertions.assertNotNull(productInfo); + + // 断言关键字段合理(你也可以根据实际字段调整) + Assertions.assertNotNull(productInfo.getTitle()); + Assertions.assertTrue(productInfo.getTitle().contains("智能手表")); + + Assertions.assertNotNull(productInfo.getPrice()); + Assertions.assertTrue(productInfo.getPrice().contains("299")); + + Assertions.assertNotNull(productInfo.getBrand()); + Assertions.assertEquals("TechBrand", productInfo.getBrand()); + + // 你可以打印结果,方便调试 + System.out.println("提取的商品信息: " + productInfo); + } + + }