10 changed files with 445 additions and 2 deletions
-
12pom.xml
-
62src/main/java/com/wok/supportbot/advisor/MyLoggerAdvisor.java
-
53src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java
-
76src/main/java/com/wok/supportbot/app/AssistantApp.java
-
64src/main/java/com/wok/supportbot/app/ProductInfoApp.java
-
88src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java
-
2src/main/java/com/wok/supportbot/demo/invoke/SpringAiAiInvoke.java
-
14src/main/java/com/wok/supportbot/record/AssistantReport.java
-
14src/main/java/com/wok/supportbot/record/ProductInfo.java
-
62src/test/java/com/wok/supportbot/SupportBotApplicationTests.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<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) { |
||||
|
|
||||
|
advisedRequest = before(advisedRequest); |
||||
|
|
||||
|
Flux<AdvisedResponse> advisedResponses = chain.nextAroundStream(advisedRequest); |
||||
|
|
||||
|
return new MessageAggregator().aggregateAdvisedResponse(advisedResponses, this::observeAfter); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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<String, Object> 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<AdvisedResponse> 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(); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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<Message> messages) { |
||||
|
List<Message> conversationMessages = getOrCreateConversation(conversationId); |
||||
|
conversationMessages.addAll(messages); |
||||
|
saveConversation(conversationId, conversationMessages); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public List<Message> get(String conversationId, int lastN) { |
||||
|
List<Message> 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<Message> getOrCreateConversation(String conversationId) { |
||||
|
File file = getConversationFile(conversationId); |
||||
|
List<Message> 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<Message> 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"); |
||||
|
} |
||||
|
} |
||||
@ -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<String> suggestions) { |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -1,13 +1,73 @@ |
|||||
package com.wok.supportbot; |
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.junit.jupiter.api.Test; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
import org.springframework.boot.test.context.SpringBootTest; |
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
|
||||
|
import java.util.UUID; |
||||
|
|
||||
@SpringBootTest |
@SpringBootTest |
||||
class SupportBotApplicationTests { |
class SupportBotApplicationTests { |
||||
|
@Resource |
||||
|
private AssistantApp assistantApp; |
||||
|
|
||||
|
@Autowired |
||||
|
private ProductInfoApp productInfoApp; |
||||
|
|
||||
@Test |
@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); |
||||
|
} |
||||
|
|
||||
|
|
||||
} |
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue