Spring AI 应用 - 智能记者

参考实现: https://github.com/mshumer/ai-journalist

上面是通过 Claude 配合 SERP 搜索 API,使用 Python 语言实现的,本文通过 GitHub Copilot 辅助改为了基于 Spring AI 的 Java 版本,由于我个人没有开通 Claude,所以使用的 OpenAI。

AIJournalist 实现

基本定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 记者类,用于撰写和编辑文章
*/
public class AIJournalist {
private ChatClient chatClient; // 聊天客户端
private RestTemplate restTemplate = new RestTemplate(); // 用于发送HTTP请求的模板
private HttpHeaders serpApiHeader; // SERP API的请求头

/**
* 构造函数,初始化聊天客户端和SERP API的请求头
*
* @param chatClient 聊天客户端
* @param serpApiKey SERP API的密钥
*/
public AIJournalist(ChatClient chatClient, String serpApiKey) {
this.chatClient = chatClient;
this.serpApiHeader = new HttpHeaders();

serpApiHeader.setContentType(MediaType.APPLICATION_JSON);
serpApiHeader.set("X-API-KEY", serpApiKey);
}

为了方便外部替换 ChatClient 实现,作为参数传递进去使用,搜索使用的 SERP,可以免费申请初始的额度用于搜索。

下面是 main 方法调用:

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
// HTTPS
System.setProperty("https.proxyHost", "localhost");
System.setProperty("https.proxyPort", "7890");
// 创建聊天客户端
var openAiApi = new OpenAiApi("替换token");
var chatClient = new OpenAiChatClient(openAiApi, OpenAiChatOptions.builder()
.withModel("gpt-4-turbo").withTemperature(0.4F).build());
// 启动
AIJournalist journalist = new AIJournalist(chatClient, "替换token");
journalist.start();
}

这里选择的 gpt-4-turbo,在实现中会通过搜索引擎获取大量上下文,因此需要支持更大上下文的模型,gpt-4-turbo 支持的 token 上限为 128,000,如果遇到超出上下文的情况,还可以考虑尝试 gpt-4-32k 来支持 32,768 tokens。

下面看串起整个调用的 start() 方法。

1
2
3
4
5
6
7
8
9
10
/**
* 开始撰写和编辑文章的过程
*/
public void start() {
// User input
Scanner scanner = new Scanner(System.in);
System.out.print("输入要写的主题:");
String topic = scanner.nextLine();
System.out.print("初稿完成后,是否要进行自动编辑?这可能会提高性能,但有点不可靠。回答“是”或“否”:");
String doEdit = scanner.nextLine();

首先通过控制台输入主题,以及是否要进行自动编辑。比如输入如下信息:

1
2
输入要写的主题:How to use Obsidian?
初稿完成后,是否要进行自动编辑?这可能会提高性能,但有点不可靠。回答“是”或“否”:是

输入主题后,通过 getSearchTerms 方法根据主题生成搜索词:

1
2
3
4
5
// Generate search terms
List<String> searchTerms = getSearchTerms(topic);
System.out.println("\n------------------------------");
System.out.println("\n搜索词 '" + topic + "':");
System.out.println(String.join(", ", searchTerms));

getSearchTerms 方法会调用 AI 根据提示词生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 根据给定的主题生成搜索词
*
* @param topic 主题
* @return 搜索词列表
*/
public List<String> getSearchTerms(String topic) {
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是一位世界级的记者。生成一个包含5个搜索词的列表,用于研究和撰写关于该主题的文章。"));
messages.add(new UserMessage("主题: " + topic + "\n\n请提供一个与'" + topic + "'相关的5个搜索词的列表,用于研究和撰写文章。以逗号分隔的Java可解析列表形式回复。"));
String responseText = call(messages);
return Arrays.asList(responseText.replace("[", "")
.replace("]", "").replace("\"", "").split(","));
}

根据前面输入的主题,这里响应结果如下:

1
2
搜索词 'How to use Obsidian?':
Obsidian app tutorial, Obsidian note-taking features, Obsidian plugins, Obsidian sync capabilities, Obsidian vs Notion comparison

接下要搜调用搜索 API 分别搜索这几个搜索词

1
2
3
4
5
6
7
// Perform searches and select relevant URLs
List<String> relevantUrls = new ArrayList<>();
for (String term : searchTerms) {
List<Map<String, Object>> searchResults = getSearchResults(term);
List<String> urls = selectRelevantUrls(searchResults);
relevantUrls.addAll(urls);
}

通过 getSearchResults 方法搜索(因为AI会用我们的语言编写文章,所以搜索英文资料会比中文效果更好):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 根据给定的搜索词获取搜索结果
*
* @param searchTerm 搜索词
* @return 搜索结果
*/
@SuppressWarnings({"unchecked", "rawtypes"})
public List<Map<String, Object>> getSearchResults(String searchTerm) {
// Create request body
String body = "{\"q\":\"" + searchTerm + "\",\"hl\":\"en\",\"num\":10}";
// Create entity
HttpEntity<String> entity = new HttpEntity<>(body, serpApiHeader);
// Execute request
ResponseEntity<Map> response = restTemplate.exchange(
"https://google.serper.dev/search",
HttpMethod.POST,
entity,
Map.class);
return (List<Map<String, Object>>) response.getBody().get("organic");
}

然后通过方法 selectRelevantUrls 使用 AI 筛选搜索结果中的 URL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 从给定的搜索结果中选择相关的URL
*
* @param searchResults 搜索结果
* @return 相关的URL列表
*/
public List<String> selectRelevantUrls(List<Map<String, Object>> searchResults) {
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是一位记者助手。从给定的搜索结果中,选择出看起来最相关和信息丰富的 URL,用于撰写关于该主题的文章。"));
StringBuilder searchResultsText = new StringBuilder();
for (int i = 0; i < searchResults.size(); i++) {
searchResultsText.append(i + 1).append(". ").append(searchResults.get(i).get("link")).append("\n");
}
messages.add(new UserMessage("搜索结果:\n" + searchResultsText + "\n\n请选择看起来最相关和信息丰富的 URL 的编号,用于撰写关于该主题的文章。以逗号分隔的 Java 可解析列表形式回复(如 [1,2,4])。"));
String responseText = call(messages);
String[] numbers = responseText.replace("[", "")
.replace("]", "").replace("\"", "").split(",");
List<String> relevantUrls = new ArrayList<>();
for (String num : numbers) {
int index = Integer.parseInt(num.trim()) - 1;
relevantUrls.add((String) searchResults.get(index).get("link"));
}

return relevantUrls;
}

循环多个搜索关键字,拿到所有可以参考的 URL 链接,然后通过下面代码输出:

1
2
3
4
5
String urls = IntStream.range(0, relevantUrls.size())
.mapToObj(i -> (i + 1) + ". " + relevantUrls.get(i))
.collect(Collectors.joining("\n"));
System.out.println("\n------------------------------");
System.out.println("要阅读的相关 URL:\n" + urls);

当前主题输出的示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
要阅读的相关 URL:
1. https://obsidian.rocks/getting-started-with-obsidian-a-beginners-guide/
2. https://bobbypowers.net/beginners-guide-to-obsidian/
3. https://thetotalliving.medium.com/the-ultimate-guide-to-obsidian-8de0a5ea5c20
4. https://obsidian.md/
5. https://www.makeuseof.com/what-is-obsidian-note-taking/
6. https://www.cloudwards.net/obsidian-review/
7. https://obsidian.md/plugins
8. https://github.com/obsidianmd/obsidian-releases
9. https://obsidianninja.com/best-obsidian-plugins/
10. https://www.dsebastien.net/2022-10-19-the-must-have-obsidian-plugins/
11. https://help.obsidian.md/Obsidian+Sync/Introduction+to+Obsidian+Sync
12. https://help.obsidian.md/Getting+started/Sync+your+notes+across+devices
13. https://help.obsidian.md/Obsidian+Sync/Sync+limitations
14. https://www.nuclino.com/solutions/obsidian-vs-notion
15. https://plaky.com/blog/obsidian-vs-notion/
16. https://clickup.com/blog/obsidian-vs-notion/
17. https://www.techrepublic.com/article/obsidian-vs-notion/
18. https://www.androidauthority.com/obsidian-vs-notion-3319050/

接下来获取 URL 的内容:

1
2
3
4
5
6
7
8
9
10
11
12
// Get article text from relevant URLs
List<String> articleTexts = new ArrayList<>();
for (String url : relevantUrls) {
try {
String text = getArticleText(url);
if (text.length() > 75) {
articleTexts.add(text);
}
} catch (Exception e) {
e.printStackTrace();
}
}

getArticleText 方法使用 Jsoup 获取并解析 html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 从给定的URL获取文章文本
*
* @param url 文章的URL
* @return 文章的文本
*/
public String getArticleText(String url) {
try {
Document doc = Jsoup.connect(url).get();
return doc.body().text();
} catch (Exception e) {
System.out.println("解析URL" + url + " 错误: " + e.getMessage());
return "";
}
}

控制输出参考文章:

1
2
System.out.println("\n------------------------------");
System.out.println("参考文章:" + articleTexts);

示例如下(太长,截取部分)

1
2
3
4
5
6
7
8
9
10
参考文章:[Obsidian Rocks Exploring knowledge management with Ob
- [[Interests MOC]]
- [[Work MOC]]
- [[Home MOC]]
The text above may confuse you. What’s with the funky square br
2. Item two
3. Item three To create an unordered list, simply use asterisks
* Item two
* Item three Blockquotes: To create a blockquote, simply type >
> --Abraham Lincoln Note: Obsidian also has callouts, which are

开始根据提供的上下文写作:

1
2
3
4
5
6
System.out.println("\n\n正在写文章...");
// Write the article
String article = writeArticle(topic, articleTexts);
System.out.println("\n------------------------------");
System.out.println("\n生成的文章:");
System.out.println(article);

通过提示词模板调用AI编写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 根据给定的主题和参考文章文本撰写文章
*
* @param topic 主题
* @param articleTexts 参考文章文本
* @return 撰写的文章
*/
public String writeArticle(String topic, List<String> articleTexts) {
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是一位世界级的记者。根据以下的参考文章和主题,撰写一篇关于该主题的文章。"));
StringBuilder articlesText = new StringBuilder();
for (int i = 0; i < articleTexts.size(); i++) {
String article = articleTexts.get(i);
articlesText.append(i + 1).append(". ").append(article).append("\n");
}
messages.add(new UserMessage("参考文章:\n" + articlesText + "\n\n主题: " + topic + "\n\n请撰写一篇关于该主题的文章。"));
return call(messages);
}

输出的内容参考次条:掌握Obsidian:从入门到精通的全面指南

判断是否需要对写好的文章进行编辑:

1
2
3
4
5
6
7
if (doEdit.toLowerCase().contains("是")) {
// Edit the article
String editedArticle = editArticle(article);
System.out.println("\n------------------------------");
System.out.println("\n编辑文章:");
System.out.println(editedArticle);
}

编辑方法 editArticle 如下:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 编辑文章以提高其质量
*
* @param article 要编辑的文章
* @return 编辑后的文章
*/
public String editArticle(String article) {
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是一位世界级的编辑。根据以下的文章,进行编辑以提高其质量。"));
messages.add(new UserMessage("请编辑以下文章以提高其质量:\n" + article));
return call(messages);
}

前面提示词是记者,这里是世界级编辑。

编辑后的内容看:全面掌握Obsidian:从新手到专家的实用指南

至此完成了文章的编写,编写的内容不一定很好,个人感觉搜索引擎搜索的结果以及从网页提取的方式对整个结果有很大的影响,如果上下文提供的好,效果应该能改善。

在本文中,Spring AI 只是起到了一个 Chat 的作用,Chat 提供了搜索词、筛选URL,以记者身份编写内容,以编辑身份修改内容。除此之外还用到了搜索 API,使用Jsoup解析HTML来提供上下文。一个复杂的 AI 就是以不同提示词、用法、身份进行多轮交互来产生最终的结果,没有特别复杂的东西。

本文代码较长,完整内容放在了 gist,地址如下:

https://gist.github.com/abel533/300e642cb4e2548830981ce824036586

点击阅读原文即可跳转。


Spring AI 应用 - 智能记者
https://blog.mybatis.io/post/c12a7e15.html
作者
Liuzh
发布于
2024年6月26日
许可协议