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);
+ }
+
+
}